diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..dc0f6ec
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,14 @@
+# Normalise line endings to LF in the working tree on every platform.
+#
+# Several drift gates byte-compare a committed, generated artifact against a fresh render
+# (e.g. tests/integration/test_schema_html.py reads docs/reference/schema/index.html and compares
+# it to schema_html.render(), which emits LF). With core.autocrlf=true on Windows, a checkout
+# would rewrite those files with CRLF and break the compare — even though CI (LF) stays green.
+# Pinning eol=lf keeps the working copy byte-identical to what the generators produce.
+* text=auto eol=lf
+
+# Belt-and-braces for the byte-reproducible generated artifacts the drift gates check.
+*.html text eol=lf
+*.xsd text eol=lf
+*.xml text eol=lf
+*.py text eol=lf
diff --git a/docs/concepts/pipeline-data-flow.md b/docs/concepts/pipeline-data-flow.md
index 07b7ca0..4f6be07 100644
--- a/docs/concepts/pipeline-data-flow.md
+++ b/docs/concepts/pipeline-data-flow.md
@@ -6,19 +6,26 @@
```mermaid
flowchart TD
- Input["Calculation input
(examples/calculation_input.json)"]
- Build["build.py
(acoustic seams compute &
populate the schema object)"]
+ Input["Calculation input XML
(examples/calculation_input.xml)"]
+ Build["build.py
(parse + validate input, acoustic seams
compute & populate the schema object)"]
Objects["Schema data object
(Platform, generated from XSD)"]
XML["Emitted Platform XML"]
Golden["Golden file"]
Reference["Known-good reference"]
+ Input -. "input gate" .-> Input
Input --> Build --> Objects --> XML
Objects -. "tests assert here" .-> Golden
XML -. "structural gate" .-> XML
XML -. "migration safety" .-> Reference
```
+The input is held to its own contract too: `examples/calculation_input.xml` is a compact set of
+**parameters** validated against `schema/calculation_input.xsd` (and parsed into a typed
+`CalculationInput` model, generated from that schema exactly as `Platform` is generated from the
+output schema). The acoustic seams *expand* those parameters into the many bands and sectors of
+the output — the input and output schemas are deliberately different shapes.
+
The acoustic seams compute the values and the builder populates **one schema data object**
directly — there is no intermediate domain hierarchy built only to be converted (ADR 0010).
That object, before serialisation, is the **typed testable boundary**: tests assert on it
diff --git a/docs/concepts/typed-vs-dicts.md b/docs/concepts/typed-vs-dicts.md
index 1cff96f..f409cd8 100644
--- a/docs/concepts/typed-vs-dicts.md
+++ b/docs/concepts/typed-vs-dicts.md
@@ -14,7 +14,7 @@ typed, and documented by the contract, so values never have to live in a loosely
```python
from acoustic_dataset import build
-input_path = "examples/calculation_input.json"
+input_path = "examples/calculation_input.xml"
# Build the schema's data object directly from the input:
platform = build.build_platform_from_file(input_path)
@@ -91,13 +91,15 @@ schema object, and it also enforces the schema's numeric **ranges** at that poin
does not meet the schema is rejected before serialisation:
```python
-from acoustic_dataset import acoustics, build
+from decimal import Decimal
+
+from acoustic_dataset import build
-data = acoustics.load_input("examples/calculation_input.json")
+data = build.load_input("examples/calculation_input.xml")
# Decibels are bounded to [-200, 300]. Force an impossible
# source level into the input, then build the schema object:
-data["sensors"]["active"]["sourceLevelDb"] = 9999.0
+data.sensors.active_sonar.active_source_level_db.value = Decimal("9999")
build.build_platform(data)
# -> MappingError: rejected as it is built,
# not left for a later stage
diff --git a/docs/how-to/change-the-schema.md b/docs/how-to/change-the-schema.md
index 2bc47a5..26dd002 100644
--- a/docs/how-to/change-the-schema.md
+++ b/docs/how-to/change-the-schema.md
@@ -5,21 +5,28 @@
The schema is the single source of truth, so changing it is a *configuration change, not a
redesign*: models, validation, bindings and the schema docs all regenerate from it.
+There are **two** schemas, regenerated together by `make generate`: `schema/acoustic_dataset.xsd`
+(the output dataset) and `schema/calculation_input.xsd` (the calculation parameters). They are
+different shapes — the builder *expands* the input parameters into the output dataset — so an
+output change usually means touching the output schema, the builder, and possibly the input
+schema if a new parameter is needed.
+
## Steps
-1. **Edit the schema.**
- Change `schema/acoustic_dataset.xsd` (the contract). When you add or rename a type/element,
- put its definition prose in `xs:annotation/xs:documentation` so it rides through to the
- generated model docstrings and the schema reference.
+1. **Edit the schema(s).**
+ Change `schema/acoustic_dataset.xsd` and/or `schema/calculation_input.xsd` (the contracts).
+ When you add or rename a type/element, put its definition prose in
+ `xs:annotation/xs:documentation` so it rides through to the generated model docstrings and the
+ schema reference.
2. **Regenerate the models.**
```bash
make generate
```
- Runs `xsdata` over the schema and rewrites `src/acoustic_dataset/models/`. **Do not
- hand-edit** the result — it's a generated artifact
- (ADR 0008). Generation is pinned to the 3.9
- toolchain so the output is byte-reproducible for the drift gate.
+ Runs `xsdata` over both schemas and rewrites `src/acoustic_dataset/models/` (output) and
+ `src/acoustic_dataset/input_models/` (input). **Do not hand-edit** the result — it's a
+ generated artifact (ADR 0008). Generation is pinned to the 3.9 toolchain so the output is
+ byte-reproducible for the drift gate.
3. **Regenerate the schema reference.**
```bash
@@ -33,8 +40,8 @@ redesign*: models, validation, bindings and the schema docs all regenerate from
for any added/renamed/retyped fields. Generation code does *not* change.
5. **Update the example and golden file.**
- Adjust `examples/calculation_input.json`, then refresh the golden file if the new output is
- intended:
+ Adjust `examples/calculation_input.xml` (it must stay valid against
+ `schema/calculation_input.xsd`), then refresh the golden file if the new output is intended:
```bash
make pipeline # writes build/acoustic_dataset.xml
cp build/acoustic_dataset.xml tests/golden/acoustic_dataset.xml
@@ -63,6 +70,7 @@ output that is schema-valid but differs from what a consumer depends on
## What you should *not* touch
-- Generated models (`src/acoustic_dataset/models/`) — regenerate instead.
+- Generated models (`src/acoustic_dataset/models/`, `src/acoustic_dataset/input_models/`) —
+ regenerate instead.
- Generated HTML schema reference under `reference/schema/` — regenerate instead.
- The generation code — it's schema-agnostic by design.
diff --git a/docs/onboarding.md b/docs/onboarding.md
index b79fbba..0faaf0b 100644
--- a/docs/onboarding.md
+++ b/docs/onboarding.md
@@ -25,10 +25,11 @@ before the same `make verify` / `make pipeline`. Both paths reach the *same* gre
| You're looking for… | It's here |
|---|---|
| The contract (the XSD) | `schema/acoustic_dataset.xsd` ([how to change it](how-to/change-the-schema.md)) |
+| The input contract (calculation parameters) | `schema/calculation_input.xsd` |
| The scientific seams (named, testable calc functions) | `src/acoustic_dataset/acoustics/` |
| The **one** place the schema object is built | `src/acoustic_dataset/build.py` |
-| Generated models (never hand-edited — regenerate) | `src/acoustic_dataset/models/` |
-| Example calculation input | `examples/calculation_input.json` |
+| Generated models (never hand-edited — regenerate) | `src/acoustic_dataset/models/` (output), `src/acoustic_dataset/input_models/` (input) |
+| Example calculation input | `examples/calculation_input.xml` |
| Tests (unit / integration / golden) | `tests/` |
| The plan & design artifacts | `specs/001-codespace-xml-scaffold/` (`spec.md`, `plan.md`, `tasks.md`) |
| The generated HTML schema reference | [reference/schema](reference/schema/index.html) (run `make gen-schema-docs`) |
diff --git a/examples/calculation_input.json b/examples/calculation_input.json
deleted file mode 100644
index 7a68145..0000000
--- a/examples/calculation_input.json
+++ /dev/null
@@ -1,52 +0,0 @@
-{
- "schemaVersion": "0.2.0",
- "name": "Reference Platform A",
- "generatedUtc": "2026-06-14T00:00:00Z",
- "characteristics": {
- "draftMetres": 7.5,
- "lengthMetres": 95.0,
- "weightTonnes": 2400.0,
- "yearIntroduced": 1998
- },
- "radiatedNoise": {
- "baseFrequencyHz": 50.0,
- "bandRatio": 2.0,
- "bandCount": 10,
- "bearingStepDeg": 30.0,
- "baseLevelDb": 140.0,
- "rolloffDbPerOctave": 5.0,
- "directivity": {
- "peakBearingDeg": 180.0,
- "amplitudeDb": 6.0
- }
- },
- "sensors": {
- "active": {
- "name": "AS-900 Echo",
- "manufacturer": "DeepBlue Sonics",
- "operatingFrequencyHz": 6000.0,
- "sourceLevelDb": 215.0,
- "beamwidthDeg": 15.0,
- "pulseLengthSeconds": 0.5,
- "detectionThresholdDb": 12.0
- },
- "passive": [
- {
- "name": "PA-110 Flank Array",
- "manufacturer": "DeepBlue Sonics",
- "operatingFrequencyHz": 1500.0,
- "arrayGainDb": 18.0,
- "detectionThresholdDb": 10.0,
- "bearingAccuracyDeg": 1.5
- },
- {
- "name": "PA-220 Towed Array",
- "manufacturer": "Marine Acoustics Ltd",
- "operatingFrequencyHz": 300.0,
- "arrayGainDb": 22.0,
- "detectionThresholdDb": 8.0,
- "bearingAccuracyDeg": 2.0
- }
- ]
- }
-}
diff --git a/examples/calculation_input.xml b/examples/calculation_input.xml
new file mode 100644
index 0000000..7930881
--- /dev/null
+++ b/examples/calculation_input.xml
@@ -0,0 +1,56 @@
+
+
+
+ 0.2.0
+ Reference Platform A
+ 2026-06-14T00:00:00Z
+
+ 7.5
+ 95.0
+ 2400.0
+ 1998
+
+
+ 50.0
+ 2.0
+ 10
+ 30.0
+ 140.0
+ 5.0
+
+ 180.0
+ 6.0
+
+
+
+
+ AS-900 Echo
+ DeepBlue Sonics
+ 6000.0
+ 215.0
+ 15.0
+ 0.5
+ 12.0
+
+
+ PA-110 Flank Array
+ DeepBlue Sonics
+ 1500.0
+ 18.0
+ 10.0
+ 1.5
+
+
+ PA-220 Towed Array
+ Marine Acoustics Ltd
+ 300.0
+ 22.0
+ 8.0
+ 2.0
+
+
+
diff --git a/pyproject.toml b/pyproject.toml
index 6d92344..c16ab43 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -52,6 +52,7 @@ select = ["E", "F", "I", "UP", "B"]
[tool.ruff.lint.per-file-ignores]
# Generated bindings are artifacts, never hand-edited — do not lint them.
"src/acoustic_dataset/models/*" = ["ALL"]
+"src/acoustic_dataset/input_models/*" = ["ALL"]
[tool.mypy]
python_version = "3.9"
@@ -59,8 +60,8 @@ python_version = "3.9"
# so a bare `mypy` in `make verify` works.
files = ["src/acoustic_dataset"]
ignore_missing_imports = true
-# Generated models are excluded from type-checking (they are an artifact).
-exclude = "src/acoustic_dataset/models/"
+# Generated bindings (output + input) are excluded from type-checking (they are artifacts).
+exclude = "src/acoustic_dataset/(models|input_models)/"
[tool.pytest.ini_options]
testpaths = ["tests"]
diff --git a/schema/calculation_input.xsd b/schema/calculation_input.xsd
new file mode 100644
index 0000000..6978353
--- /dev/null
+++ b/schema/calculation_input.xsd
@@ -0,0 +1,341 @@
+
+
+
+
+
+
+ Calculation-input schema. A document supplies the parameters for one platform: its physical
+ characteristics, the parameters of its directional radiated-noise model, and the sonar fit
+ (one active sonar and the passive sonars). The named value types carry XSD facets (ranges)
+ so an out-of-range parameter is rejected at the input gate, before any computation runs.
+
+
+
+
+
+
+
+ A level expressed in decibels (dB), bounded to a sane sonar range.
+
+
+
+
+
+
+
+
+
+ A spectral gradient in decibels shed per octave (doubling of frequency); non-negative.
+
+
+
+
+
+
+
+
+ A non-negative distance in metres.
+
+
+
+
+
+
+
+
+ A non-negative mass/displacement in tonnes (1000 kg).
+
+
+
+
+
+
+
+
+ A non-negative duration in seconds.
+
+
+
+
+
+
+
+
+ A non-negative frequency in hertz (Hz).
+
+
+
+
+
+
+
+
+ A multiplicative ratio strictly greater than zero (e.g. the geometric step between band centres; 2 = octave spacing).
+
+
+
+
+
+
+
+
+ An angle in degrees, in the closed interval [0, 360] (e.g. a beamwidth).
+
+
+
+
+
+
+
+
+
+ A bearing sampling step in degrees, in the half-open interval (0, 360]: must divide the circle into one or more sectors.
+
+
+
+
+
+
+
+
+
+ A compass bearing in degrees, in the half-open interval [0, 360): 0 is dead
+ ahead, increasing clockwise.
+
+
+
+
+
+
+
+
+
+ A Gregorian calendar year, constrained to a sane modern range [1900, 2100].
+
+
+
+
+
+
+
+
+
+
+ Version of the input schema this document targets.
+
+
+ Human-readable name of the platform the parameters describe.
+
+
+ UTC timestamp identifying when these input parameters were produced.
+
+
+
+ Draft (depth of the lowest point below the waterline), in metres.
+
+
+ Overall length of the platform, in metres.
+
+
+ Displacement (weight) of the platform, in tonnes.
+
+
+ Calendar year the platform class entered service.
+
+
+
+ Centre frequency of the first (lowest) radiated-noise band, in hertz.
+
+
+ Geometric step between successive band centre frequencies (2 = octave spacing).
+
+
+ How many frequency bands to synthesise (1-based ladder length).
+
+
+ Angular step at which each band is sampled all round the platform, in degrees (30 = twelve sectors).
+
+
+ Radiated noise level at the base frequency, before roll-off and directivity, in decibels.
+
+
+ High-frequency spectral roll-off applied per octave above the base frequency, in decibels.
+
+
+
+ Bearing at which the directivity lobe peaks, in degrees [0, 360).
+
+
+ Peak-to-mean amplitude of the directivity lobe, in decibels.
+
+
+
+ Model name of the active sonar.
+
+
+ Manufacturer of the active sonar.
+
+
+ Nominal operating (centre) frequency of the active sonar, in hertz.
+
+
+ Transmit source level of the active sonar, in decibels.
+
+
+ Transmit/receive beamwidth of the active sonar, in degrees.
+
+
+ Transmit pulse length of the active sonar, in seconds.
+
+
+ Signal-to-noise ratio the active sonar needs to detect an echo, in decibels. Consumed to derive the maximum echo range; not emitted verbatim in the output.
+
+
+
+ Model name of the passive sonar.
+
+
+ Manufacturer of the passive sonar.
+
+
+ Nominal operating (centre) frequency of the passive sonar, in hertz.
+
+
+ Array gain of the passive sonar's receiving array, in decibels.
+
+
+ Signal-to-noise ratio the passive sonar needs for detection, in decibels.
+
+
+ 1-sigma bearing accuracy of the passive sonar, in degrees.
+
+
+
+
+
+ The physical characteristics of the platform (passed through to the output unchanged).
+
+
+
+
+
+
+
+
+
+
+
+ The directional lobe applied to the radiated-noise field: a single peak bearing and its amplitude.
+
+
+
+
+
+
+
+
+
+ Parameters of the directional radiated-noise model, expanded by the acoustics seams into the output's bands and sectors.
+
+
+
+
+
+
+
+
+
+ The directivity lobe applied across all bearings.
+
+
+
+
+
+
+ Parameters of the platform's active sonar. The detection threshold is consumed to derive the output's maximum echo range.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Parameters of one passive sonar carried by the platform.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The sonar fit to be described: one active sonar and one or more passive sonars.
+
+
+
+ The platform's single active sonar.
+
+
+ The platform's passive sonars.
+
+
+
+
+
+
+
+
+ Root element: the calculation parameters for one platform — its physical characteristics, its radiated-noise model parameters, and its sonar fit. The pipeline expands these into a validated Platform dataset.
+
+
+
+
+
+
+ The platform's physical characteristics.
+
+
+ The platform's radiated-noise model parameters.
+
+
+ The platform's sonar fit.
+
+
+
+
+
+
diff --git a/specs/001-codespace-xml-scaffold/contracts/cli-commands.md b/specs/001-codespace-xml-scaffold/contracts/cli-commands.md
index a1e2111..6469559 100644
--- a/specs/001-codespace-xml-scaffold/contracts/cli-commands.md
+++ b/specs/001-codespace-xml-scaffold/contracts/cli-commands.md
@@ -18,14 +18,16 @@ tests and CI depend on; command names and exit-code semantics must remain stable
## CLI subcommands (`python -m acoustic_dataset.cli ...`)
### `generate`
-- **Input**: `--schema schema/acoustic_dataset.xsd` (default), `--out src/acoustic_dataset/models`.
+- **Input**: no args regenerates every `schema/*.xsd` (the output `acoustic_dataset.xsd` →
+ `src/acoustic_dataset/models/` and the input `calculation_input.xsd` →
+ `src/acoustic_dataset/input_models/`); `--schema [--out ]` regenerates just one.
- **Behaviour**: Invoke xsdata generation; write generated dataclasses with a "do not edit" header.
- **Contract**: Idempotent — re-running on an unchanged schema produces byte-identical output (enables
the CI drift check `git diff --exit-code`). Malformed/missing schema → exit non-zero with an
actionable message; never writes partial output.
### `pipeline`
-- **Input**: `--input examples/calculation_input.json` (default), `--out build/acoustic_dataset.xml`.
+- **Input**: `--input examples/calculation_input.xml` (default), `--out build/acoustic_dataset.xml`.
- **Behaviour**: acoustics seams → `CalculationResult` → single mapping → populated objects →
serialise → validate (xmlschema) → round-trip check. Writes artifact only if both structural gates pass.
- **Contract**: Exit 0 ⇔ artifact written AND schema-valid AND round-trip-equal. A band/type mismatch
diff --git a/src/acoustic_dataset/acoustics/__init__.py b/src/acoustic_dataset/acoustics/__init__.py
index 38c3667..3781ac8 100644
--- a/src/acoustic_dataset/acoustics/__init__.py
+++ b/src/acoustic_dataset/acoustics/__init__.py
@@ -19,9 +19,7 @@
from __future__ import annotations
-import json
import math
-from pathlib import Path
# ----- individual seam functions (each independently unit-tested) -----
@@ -75,11 +73,3 @@ def bearings(step_deg: float) -> list[float]:
"""
count = int(round(360.0 / step_deg))
return [round(step_deg * k, 6) for k in range(count)]
-
-
-# ----- input loading -----
-
-
-def load_input(path: Path) -> dict:
- """Read and parse a calculation-input JSON file."""
- return json.loads(Path(path).read_text(encoding="utf-8"))
diff --git a/src/acoustic_dataset/build.py b/src/acoustic_dataset/build.py
index f73ed1f..81ee15d 100644
--- a/src/acoustic_dataset/build.py
+++ b/src/acoustic_dataset/build.py
@@ -23,10 +23,12 @@
from decimal import ROUND_HALF_EVEN, Decimal
from pathlib import Path
+from typing import Any
-from xsdata.models.datatype import XmlDateTime
+from xsdata.formats.dataclass.parsers import XmlParser
-from acoustic_dataset import acoustics
+from acoustic_dataset import acoustics, validate
+from acoustic_dataset.input_models import calculation_input as cin
from acoustic_dataset.models.acoustic_dataset import (
ActiveManufacturer,
ActiveName,
@@ -63,6 +65,9 @@
YearIntroduced,
)
+_REPO_ROOT = Path(__file__).resolve().parents[2]
+DEFAULT_INPUT_SCHEMA = _REPO_ROOT / "schema" / "calculation_input.xsd"
+
# Declared schema bands (kept in step with schema/acoustic_dataset.xsd). These mirror the
# XSD facets so a violation is caught at build time with a clear, location-aware message.
_DECIBELS_RANGE: tuple[Decimal, Decimal] = (Decimal("-200"), Decimal("300"))
@@ -112,27 +117,50 @@ def _require_int_in_range(value: int, low: int, high: int, *, where: str, field:
return value
-def _build_characteristics(spec: dict) -> Characteristics:
+# --- reading the typed input ---------------------------------------------------------------
+# Each input element is a generated wrapper carrying its scalar on ``.value``. The fields are
+# typed ``Optional`` (xsdata's default) but the input schema marks them required, and
+# :func:`load_input` runs the XSD gate before parsing, so by here every value is present. The
+# accessors take ``Any`` so we read across that generated-binding boundary without fighting the
+# Optional types.
+
+
+def _f(node: Any) -> float:
+ """The wrapper's scalar value as a ``float`` (for the acoustic seams)."""
+ return float(node.value)
+
+
+def _s(node: Any) -> str:
+ """The wrapper's scalar value as a ``str``."""
+ return str(node.value)
+
+
+def _i(node: Any) -> int:
+ """The wrapper's scalar value as an ``int``."""
+ return int(node.value)
+
+
+def _build_characteristics(spec: Any) -> Characteristics: # spec: cin.Characteristics
where = "characteristics"
return Characteristics(
draft=Draft(
_require_min(
- _dec(float(spec["draftMetres"])), _NON_NEGATIVE, where=where, field="Draft"
+ _dec(_f(spec.draft_metres)), _NON_NEGATIVE, where=where, field="Draft"
)
),
length=Length(
_require_min(
- _dec(float(spec["lengthMetres"])), _NON_NEGATIVE, where=where, field="Length"
+ _dec(_f(spec.length_metres)), _NON_NEGATIVE, where=where, field="Length"
)
),
weight=Weight(
_require_min(
- _dec(float(spec["weightTonnes"])), _NON_NEGATIVE, where=where, field="Weight"
+ _dec(_f(spec.weight_tonnes)), _NON_NEGATIVE, where=where, field="Weight"
)
),
year_introduced=YearIntroduced(
_require_int_in_range(
- int(spec["yearIntroduced"]), *_YEAR_RANGE, where=where, field="YearIntroduced"
+ _i(spec.year_introduced), *_YEAR_RANGE, where=where, field="YearIntroduced"
)
),
)
@@ -150,16 +178,16 @@ def _build_sector(band_index: int, bearing_deg: float, level_db: float) -> Secto
)
-def _build_bands(spec: dict) -> list[Band]:
+def _build_bands(spec: Any) -> list[Band]: # spec: cin.RadiatedNoise
"""Synthesise the directional radiated-noise bands and build them into schema objects."""
- base_hz = float(spec["baseFrequencyHz"])
- ratio = float(spec["bandRatio"])
- band_count = int(spec["bandCount"])
- base_level = float(spec["baseLevelDb"])
- rolloff = float(spec["rolloffDbPerOctave"])
- peak_bearing = float(spec["directivity"]["peakBearingDeg"])
- amplitude = float(spec["directivity"]["amplitudeDb"])
- sampled_bearings = acoustics.bearings(float(spec["bearingStepDeg"]))
+ base_hz = _f(spec.base_frequency_hz)
+ ratio = _f(spec.band_ratio)
+ band_count = _i(spec.band_count)
+ base_level = _f(spec.base_level_db)
+ rolloff = _f(spec.rolloff_db_per_octave)
+ peak_bearing = _f(spec.directivity.peak_bearing_deg)
+ amplitude = _f(spec.directivity.amplitude_db)
+ sampled_bearings = acoustics.bearings(_f(spec.bearing_step_deg))
bands: list[Band] = []
for index in range(1, band_count + 1):
@@ -191,16 +219,16 @@ def _build_bands(spec: dict) -> list[Band]:
return bands
-def _build_active(spec: dict) -> ActiveSonar:
+def _build_active(spec: Any) -> ActiveSonar: # spec: cin.ActiveSonar
where = "active sonar"
- source_level = float(spec["sourceLevelDb"])
- max_range = acoustics.active_max_range_m(source_level, float(spec["detectionThresholdDb"]))
+ source_level = _f(spec.active_source_level_db)
+ max_range = acoustics.active_max_range_m(source_level, _f(spec.active_detection_threshold_db))
return ActiveSonar(
- active_name=ActiveName(str(spec["name"])),
- active_manufacturer=ActiveManufacturer(str(spec["manufacturer"])),
+ active_name=ActiveName(_s(spec.active_name)),
+ active_manufacturer=ActiveManufacturer(_s(spec.active_manufacturer)),
active_operating_frequency=ActiveOperatingFrequency(
_require_min(
- _dec(float(spec["operatingFrequencyHz"])),
+ _dec(_f(spec.active_operating_frequency_hz)),
_NON_NEGATIVE,
where=where,
field="OperatingFrequency",
@@ -213,12 +241,12 @@ def _build_active(spec: dict) -> ActiveSonar:
),
beamwidth=Beamwidth(
_require_in_range(
- _dec(float(spec["beamwidthDeg"])), *_DEGREES_RANGE, where=where, field="Beamwidth"
+ _dec(_f(spec.active_beamwidth_deg)), *_DEGREES_RANGE, where=where, field="Beamwidth"
)
),
pulse_length=PulseLength(
_require_min(
- _dec(float(spec["pulseLengthSeconds"])),
+ _dec(_f(spec.active_pulse_length_seconds)),
_NON_NEGATIVE,
where=where,
field="PulseLength",
@@ -230,14 +258,14 @@ def _build_active(spec: dict) -> ActiveSonar:
)
-def _build_passive(ordinal: int, spec: dict) -> PassiveSonar:
+def _build_passive(ordinal: int, spec: Any) -> PassiveSonar: # spec: cin.PassiveSonar
where = f"passive sonar {ordinal}"
return PassiveSonar(
- passive_name=PassiveName(str(spec["name"])),
- passive_manufacturer=PassiveManufacturer(str(spec["manufacturer"])),
+ passive_name=PassiveName(_s(spec.passive_name)),
+ passive_manufacturer=PassiveManufacturer(_s(spec.passive_manufacturer)),
passive_operating_frequency=PassiveOperatingFrequency(
_require_min(
- _dec(float(spec["operatingFrequencyHz"])),
+ _dec(_f(spec.passive_operating_frequency_hz)),
_NON_NEGATIVE,
where=where,
field="OperatingFrequency",
@@ -245,12 +273,13 @@ def _build_passive(ordinal: int, spec: dict) -> PassiveSonar:
),
array_gain=ArrayGain(
_require_in_range(
- _dec(float(spec["arrayGainDb"])), *_DECIBELS_RANGE, where=where, field="ArrayGain"
+ _dec(_f(spec.passive_array_gain_db)), *_DECIBELS_RANGE, where=where,
+ field="ArrayGain"
)
),
detection_threshold=DetectionThreshold(
_require_in_range(
- _dec(float(spec["detectionThresholdDb"])),
+ _dec(_f(spec.passive_detection_threshold_db)),
*_DECIBELS_RANGE,
where=where,
field="DetectionThreshold",
@@ -258,7 +287,7 @@ def _build_passive(ordinal: int, spec: dict) -> PassiveSonar:
),
bearing_accuracy=BearingAccuracy(
_require_in_range(
- _dec(float(spec["bearingAccuracyDeg"])),
+ _dec(_f(spec.passive_bearing_accuracy_deg)),
*_DEGREES_RANGE,
where=where,
field="BearingAccuracy",
@@ -267,26 +296,44 @@ def _build_passive(ordinal: int, spec: dict) -> PassiveSonar:
)
-def build_platform(data: dict) -> Platform:
- """Build a populated, schema-conformant ``Platform`` from a parsed input document.
+def load_input(path: Path, *, schema: Path = DEFAULT_INPUT_SCHEMA) -> cin.CalculationInput:
+ """Read, validate and parse a calculation-input XML file into the typed input model.
+
+ The input is held to its own contract (``schema/calculation_input.xsd``): the XSD gate runs
+ *before* parsing, so a malformed or out-of-range parameter is rejected up front (mirroring the
+ structural gate on the output). On success the document is parsed into a
+ :class:`~acoustic_dataset.input_models.calculation_input.CalculationInput`.
+ """
+ path = Path(path)
+ errors = validate.schema_errors(path, schema)
+ if errors:
+ raise MappingError(
+ f"input {path} is not valid against {schema.name}: " + "; ".join(errors)
+ )
+ return XmlParser().parse(str(path), cin.CalculationInput)
+
+
+def build_platform(data: cin.CalculationInput) -> Platform:
+ """Build a populated, schema-conformant ``Platform`` from the typed calculation input.
Computes the dataset's values and constructs the generated model objects directly,
converting to the schema's types, quantising, and range-checking on the way (raising
:class:`MappingError` on any value that does not meet the schema).
"""
- bands = _build_bands(data["radiatedNoise"])
+ src: Any = data # generated wrappers carry Optional fields; load_input's gate guarantees them.
+ bands = _build_bands(src.radiated_noise)
if not bands:
raise MappingError("calculation produced no bands; nothing to build")
return Platform(
- schema_version=SchemaVersion(str(data.get("schemaVersion", "0.2.0"))),
- platform_name=PlatformName(str(data["name"])),
- generated_utc=GeneratedUtc(XmlDateTime.from_string(str(data["generatedUtc"]))),
- characteristics=_build_characteristics(data["characteristics"]),
+ schema_version=SchemaVersion(_s(src.schema_version)),
+ platform_name=PlatformName(_s(src.name)),
+ generated_utc=GeneratedUtc(src.generated_utc.value),
+ characteristics=_build_characteristics(src.characteristics),
radiated_noise=RadiatedNoise(band=bands),
sensors=Sensors(
- active_sonar=_build_active(data["sensors"]["active"]),
+ active_sonar=_build_active(src.sensors.active_sonar),
passive_sonar=[
- _build_passive(i, p) for i, p in enumerate(data["sensors"]["passive"], start=1)
+ _build_passive(i, p) for i, p in enumerate(src.sensors.passive_sonar, start=1)
],
),
)
@@ -294,4 +341,4 @@ def build_platform(data: dict) -> Platform:
def build_platform_from_file(path: Path) -> Platform:
"""Convenience: load an input file and build the schema's ``Platform`` from it."""
- return build_platform(acoustics.load_input(path))
+ return build_platform(load_input(path))
diff --git a/src/acoustic_dataset/cli.py b/src/acoustic_dataset/cli.py
index 9b5e339..88d73f2 100644
--- a/src/acoustic_dataset/cli.py
+++ b/src/acoustic_dataset/cli.py
@@ -18,18 +18,21 @@
_REPO_ROOT = _PKG_DIR.parent.parent
DEFAULT_SCHEMA = _REPO_ROOT / "schema" / "acoustic_dataset.xsd"
-DEFAULT_INPUT = _REPO_ROOT / "examples" / "calculation_input.json"
+DEFAULT_INPUT_SCHEMA = _REPO_ROOT / "schema" / "calculation_input.xsd"
+DEFAULT_INPUT = _REPO_ROOT / "examples" / "calculation_input.xml"
DEFAULT_OUT = _REPO_ROOT / "build" / "acoustic_dataset.xml"
def cmd_generate(args: argparse.Namespace) -> int:
from acoustic_dataset import generate
- return generate.main(
- ["--schema", str(args.schema), "--out", str(args.out)]
- if args.out
- else ["--schema", str(args.schema)]
- )
+ # No --schema -> regenerate every schema/*.xsd; --schema X -> just that one.
+ argv: list[str] = []
+ if args.schema is not None:
+ argv += ["--schema", str(args.schema)]
+ if args.out is not None:
+ argv += ["--out", str(args.out)]
+ return generate.main(argv)
def cmd_pipeline(args: argparse.Namespace) -> int:
@@ -136,8 +139,9 @@ def build_parser() -> argparse.ArgumentParser:
)
sub = parser.add_subparsers(dest="command", required=True)
- p_gen = sub.add_parser("generate", help="Regenerate typed models from the XSD (xsdata).")
- p_gen.add_argument("--schema", type=Path, default=DEFAULT_SCHEMA)
+ p_gen = sub.add_parser("generate", help="Regenerate typed models from schema/*.xsd (xsdata).")
+ p_gen.add_argument("--schema", type=Path, default=None,
+ help="Regenerate a single XSD (default: all schema/*.xsd).")
p_gen.add_argument("--out", type=Path, default=None)
p_gen.set_defaults(func=cmd_generate)
diff --git a/src/acoustic_dataset/generate.py b/src/acoustic_dataset/generate.py
index db7bd65..916471f 100644
--- a/src/acoustic_dataset/generate.py
+++ b/src/acoustic_dataset/generate.py
@@ -27,23 +27,59 @@
import shutil
import subprocess
import sys
+from dataclasses import dataclass
from pathlib import Path
# src/acoustic_dataset/generate.py -> parents: [acoustic_dataset, src, ]
_PKG_DIR = Path(__file__).resolve().parent
_SRC_DIR = _PKG_DIR.parent
_REPO_ROOT = _SRC_DIR.parent
+_SCHEMA_DIR = _REPO_ROOT / "schema"
-DEFAULT_SCHEMA = _REPO_ROOT / "schema" / "acoustic_dataset.xsd"
-DEFAULT_OUT = _PKG_DIR / "models"
-_TARGET_PACKAGE = "acoustic_dataset.models"
-_HEADER = (
- "# DO NOT EDIT BY HAND.\n"
- "# Generated from schema/acoustic_dataset.xsd by `make generate` (xsdata).\n"
- "# Regenerate after any schema change; CI fails on drift. See docs/decisions/0008.\n"
+@dataclass(frozen=True)
+class SchemaSpec:
+ """One XSD and where its generated binding lands.
+
+ Each schema generates into its *own* package: the input and output schemas share element
+ names (Characteristics, Sensors, RadiatedNoise, ...), so a shared package's ``__init__``
+ re-exports would collide. Separate packages keep both bindings unambiguous.
+ """
+
+ schema: Path
+ out: Path
+ package: str # dotted target package xsdata writes into
+ module: str # module to import-check (xsdata names it after the schema file stem)
+
+
+# The output dataset schema and the calculation-input schema. ``make generate`` regenerates both.
+SCHEMAS = (
+ SchemaSpec(
+ schema=_SCHEMA_DIR / "acoustic_dataset.xsd",
+ out=_PKG_DIR / "models",
+ package="acoustic_dataset.models",
+ module="acoustic_dataset",
+ ),
+ SchemaSpec(
+ schema=_SCHEMA_DIR / "calculation_input.xsd",
+ out=_PKG_DIR / "input_models",
+ package="acoustic_dataset.input_models",
+ module="calculation_input",
+ ),
)
+# Kept for back-compat with callers that target the output schema directly.
+DEFAULT_SCHEMA = SCHEMAS[0].schema
+DEFAULT_OUT = SCHEMAS[0].out
+
+
+def _header(schema_name: str) -> str:
+ return (
+ "# DO NOT EDIT BY HAND.\n"
+ f"# Generated from schema/{schema_name} by `make generate` (xsdata).\n"
+ "# Regenerate after any schema change; CI fails on drift. See docs/decisions/0008.\n"
+ )
+
# Defence-in-depth: strip kw_only if a future xsdata bump emits it despite --no-kw-only.
_KW_ONLY_PATTERNS = (
"@dataclass(kw_only=True)", # the common case we emit
@@ -66,20 +102,21 @@ def _make_39_compatible(text: str) -> str:
return text
-def _postprocess(out_dir: Path) -> None:
+def _postprocess(out_dir: Path, schema_name: str) -> None:
+ header = _header(schema_name)
for py in sorted(out_dir.glob("*.py")):
original = py.read_text(encoding="utf-8")
body = _make_39_compatible(original)
- if not body.startswith(_HEADER):
- body = _HEADER + body
+ if not body.startswith(header):
+ body = header + body
py.write_text(body, encoding="utf-8")
-def _import_check() -> None:
+def _import_check(package: str, module: str) -> None:
"""Import the freshly generated package so broken output fails loudly."""
importlib.invalidate_caches()
- mod_name = f"{_TARGET_PACKAGE}.acoustic_dataset"
- sys.modules.pop(_TARGET_PACKAGE, None)
+ mod_name = f"{package}.{module}"
+ sys.modules.pop(package, None)
sys.modules.pop(mod_name, None)
try:
importlib.import_module(mod_name)
@@ -90,8 +127,14 @@ def _import_check() -> None:
) from exc
-def generate(schema: Path = DEFAULT_SCHEMA, out: Path = DEFAULT_OUT) -> Path:
- """Regenerate typed models from ``schema`` into ``out``.
+def generate(
+ schema: Path = DEFAULT_SCHEMA,
+ out: Path = DEFAULT_OUT,
+ *,
+ package: str = SCHEMAS[0].package,
+ module: str = SCHEMAS[0].module,
+) -> Path:
+ """Regenerate typed models from ``schema`` into ``out`` (package ``package``).
Returns the output directory. Raises :class:`GenerationError` on any failure,
never leaving partial output (the target directory is rebuilt atomically-ish:
@@ -107,10 +150,10 @@ def generate(schema: Path = DEFAULT_SCHEMA, out: Path = DEFAULT_OUT) -> Path:
shutil.rmtree(out)
# xsdata maps the dotted package onto the filesystem relative to its cwd, so we
- # run it from the src/ root to land output at src/acoustic_dataset/models/.
+ # run it from the src/ root to land output at src//.
cmd = [
sys.executable, "-m", "xsdata", "generate", str(schema),
- "--package", _TARGET_PACKAGE,
+ "--package", package,
"--structure-style", "filenames",
"--no-include-header", # deterministic output (no version/timestamp) for the drift gate
# Force 3.9-compatible output regardless of the generating interpreter, so the committed
@@ -130,25 +173,40 @@ def generate(schema: Path = DEFAULT_SCHEMA, out: Path = DEFAULT_OUT) -> Path:
f" stderr: {proc.stderr.strip()}"
)
- _postprocess(out)
- _import_check()
+ _postprocess(out, schema.name)
+ _import_check(package, module)
return out
+def generate_all(specs: tuple[SchemaSpec, ...] = SCHEMAS) -> list[Path]:
+ """Regenerate every project schema, each into its own package."""
+ return [
+ generate(s.schema, s.out, package=s.package, module=s.module) for s in specs
+ ]
+
+
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="acoustic generate", description=__doc__)
- parser.add_argument("--schema", type=Path, default=DEFAULT_SCHEMA,
- help="Path to the XSD (default: schema/acoustic_dataset.xsd)")
- parser.add_argument("--out", type=Path, default=DEFAULT_OUT,
- help="Output package dir (default: src/acoustic_dataset/models)")
+ parser.add_argument("--schema", type=Path, default=None,
+ help="Path to a single XSD (default: regenerate all schema/*.xsd).")
+ parser.add_argument("--out", type=Path, default=None,
+ help="Output package dir for a single --schema run.")
args = parser.parse_args(argv)
try:
- out = generate(args.schema, args.out)
+ if args.schema is None:
+ outs = generate_all()
+ else:
+ # Single-schema run: infer the package from --out (default: the output schema's).
+ out = args.out or DEFAULT_OUT
+ package = ".".join(["acoustic_dataset", *out.resolve().relative_to(_PKG_DIR).parts]) \
+ if out.resolve().is_relative_to(_PKG_DIR) else SCHEMAS[0].package
+ outs = [generate(args.schema, out, package=package, module=args.schema.stem)]
except GenerationError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
- rel = out.relative_to(_REPO_ROOT) if out.is_relative_to(_REPO_ROOT) else out
- print(f"Generated models in {rel}")
+ for out in outs:
+ rel = out.relative_to(_REPO_ROOT) if out.is_relative_to(_REPO_ROOT) else out
+ print(f"Generated models in {rel}")
return 0
diff --git a/src/acoustic_dataset/input_models/__init__.py b/src/acoustic_dataset/input_models/__init__.py
new file mode 100644
index 0000000..6ba82c6
--- /dev/null
+++ b/src/acoustic_dataset/input_models/__init__.py
@@ -0,0 +1,78 @@
+# DO NOT EDIT BY HAND.
+# Generated from schema/calculation_input.xsd by `make generate` (xsdata).
+# Regenerate after any schema change; CI fails on drift. See docs/decisions/0008.
+from acoustic_dataset.input_models.calculation_input import (
+ ActiveBeamwidthDeg,
+ ActiveDetectionThresholdDb,
+ ActiveManufacturer,
+ ActiveName,
+ ActiveOperatingFrequencyHz,
+ ActivePulseLengthSeconds,
+ ActiveSonar,
+ ActiveSourceLevelDb,
+ AmplitudeDb,
+ BandCount,
+ BandRatio,
+ BaseFrequencyHz,
+ BaseLevelDb,
+ BearingStepDeg,
+ CalculationInput,
+ Characteristics,
+ Directivity,
+ DraftMetres,
+ GeneratedUtc,
+ LengthMetres,
+ Name,
+ PassiveArrayGainDb,
+ PassiveBearingAccuracyDeg,
+ PassiveDetectionThresholdDb,
+ PassiveManufacturer,
+ PassiveName,
+ PassiveOperatingFrequencyHz,
+ PassiveSonar,
+ PeakBearingDeg,
+ RadiatedNoise,
+ RolloffDbPerOctave,
+ SchemaVersion,
+ Sensors,
+ WeightTonnes,
+ YearIntroduced,
+)
+
+__all__ = [
+ "ActiveBeamwidthDeg",
+ "ActiveDetectionThresholdDb",
+ "ActiveManufacturer",
+ "ActiveName",
+ "ActiveOperatingFrequencyHz",
+ "ActivePulseLengthSeconds",
+ "ActiveSonar",
+ "ActiveSourceLevelDb",
+ "AmplitudeDb",
+ "BandCount",
+ "BandRatio",
+ "BaseFrequencyHz",
+ "BaseLevelDb",
+ "BearingStepDeg",
+ "CalculationInput",
+ "Characteristics",
+ "Directivity",
+ "DraftMetres",
+ "GeneratedUtc",
+ "LengthMetres",
+ "Name",
+ "PassiveArrayGainDb",
+ "PassiveBearingAccuracyDeg",
+ "PassiveDetectionThresholdDb",
+ "PassiveManufacturer",
+ "PassiveName",
+ "PassiveOperatingFrequencyHz",
+ "PassiveSonar",
+ "PeakBearingDeg",
+ "RadiatedNoise",
+ "RolloffDbPerOctave",
+ "SchemaVersion",
+ "Sensors",
+ "WeightTonnes",
+ "YearIntroduced",
+]
diff --git a/src/acoustic_dataset/input_models/calculation_input.py b/src/acoustic_dataset/input_models/calculation_input.py
new file mode 100644
index 0000000..137c1df
--- /dev/null
+++ b/src/acoustic_dataset/input_models/calculation_input.py
@@ -0,0 +1,791 @@
+# DO NOT EDIT BY HAND.
+# Generated from schema/calculation_input.xsd by `make generate` (xsdata).
+# Regenerate after any schema change; CI fails on drift. See docs/decisions/0008.
+from dataclasses import dataclass, field
+from decimal import Decimal
+from typing import Optional
+
+from xsdata.models.datatype import XmlDateTime
+
+
+@dataclass
+class ActiveBeamwidthDeg:
+ """
+ Transmit/receive beamwidth of the active sonar, in degrees.
+ """
+
+ value: Optional[Decimal] = field(
+ default=None,
+ metadata={
+ "required": True,
+ "min_inclusive": Decimal("0"),
+ "max_inclusive": Decimal("360"),
+ },
+ )
+
+
+@dataclass
+class ActiveDetectionThresholdDb:
+ """Signal-to-noise ratio the active sonar needs to detect an echo, in decibels.
+
+ Consumed to derive the maximum echo range; not emitted verbatim in
+ the output.
+ """
+
+ value: Optional[Decimal] = field(
+ default=None,
+ metadata={
+ "required": True,
+ "min_inclusive": Decimal("-200"),
+ "max_inclusive": Decimal("300"),
+ },
+ )
+
+
+@dataclass
+class ActiveManufacturer:
+ """
+ Manufacturer of the active sonar.
+ """
+
+ value: str = field(
+ default="",
+ metadata={
+ "required": True,
+ },
+ )
+
+
+@dataclass
+class ActiveName:
+ """
+ Model name of the active sonar.
+ """
+
+ value: str = field(
+ default="",
+ metadata={
+ "required": True,
+ },
+ )
+
+
+@dataclass
+class ActiveOperatingFrequencyHz:
+ """
+ Nominal operating (centre) frequency of the active sonar, in hertz.
+ """
+
+ value: Optional[Decimal] = field(
+ default=None,
+ metadata={
+ "required": True,
+ "min_inclusive": Decimal("0"),
+ },
+ )
+
+
+@dataclass
+class ActivePulseLengthSeconds:
+ """
+ Transmit pulse length of the active sonar, in seconds.
+ """
+
+ value: Optional[Decimal] = field(
+ default=None,
+ metadata={
+ "required": True,
+ "min_inclusive": Decimal("0"),
+ },
+ )
+
+
+@dataclass
+class ActiveSourceLevelDb:
+ """
+ Transmit source level of the active sonar, in decibels.
+ """
+
+ value: Optional[Decimal] = field(
+ default=None,
+ metadata={
+ "required": True,
+ "min_inclusive": Decimal("-200"),
+ "max_inclusive": Decimal("300"),
+ },
+ )
+
+
+@dataclass
+class AmplitudeDb:
+ """
+ Peak-to-mean amplitude of the directivity lobe, in decibels.
+ """
+
+ value: Optional[Decimal] = field(
+ default=None,
+ metadata={
+ "required": True,
+ "min_inclusive": Decimal("-200"),
+ "max_inclusive": Decimal("300"),
+ },
+ )
+
+
+@dataclass
+class BandCount:
+ """
+ How many frequency bands to synthesise (1-based ladder length).
+ """
+
+ value: Optional[int] = field(
+ default=None,
+ metadata={
+ "required": True,
+ },
+ )
+
+
+@dataclass
+class BandRatio:
+ """
+ Geometric step between successive band centre frequencies (2 = octave spacing).
+ """
+
+ value: Optional[Decimal] = field(
+ default=None,
+ metadata={
+ "required": True,
+ "min_exclusive": Decimal("0"),
+ },
+ )
+
+
+@dataclass
+class BaseFrequencyHz:
+ """
+ Centre frequency of the first (lowest) radiated-noise band, in hertz.
+ """
+
+ value: Optional[Decimal] = field(
+ default=None,
+ metadata={
+ "required": True,
+ "min_inclusive": Decimal("0"),
+ },
+ )
+
+
+@dataclass
+class BaseLevelDb:
+ """
+ Radiated noise level at the base frequency, before roll-off and directivity, in
+ decibels.
+ """
+
+ value: Optional[Decimal] = field(
+ default=None,
+ metadata={
+ "required": True,
+ "min_inclusive": Decimal("-200"),
+ "max_inclusive": Decimal("300"),
+ },
+ )
+
+
+@dataclass
+class BearingStepDeg:
+ """
+ Angular step at which each band is sampled all round the platform, in degrees
+ (30 = twelve sectors).
+ """
+
+ value: Optional[Decimal] = field(
+ default=None,
+ metadata={
+ "required": True,
+ "min_exclusive": Decimal("0"),
+ "max_inclusive": Decimal("360"),
+ },
+ )
+
+
+@dataclass
+class DraftMetres:
+ """
+ Draft (depth of the lowest point below the waterline), in metres.
+ """
+
+ value: Optional[Decimal] = field(
+ default=None,
+ metadata={
+ "required": True,
+ "min_inclusive": Decimal("0"),
+ },
+ )
+
+
+@dataclass
+class GeneratedUtc:
+ """
+ UTC timestamp identifying when these input parameters were produced.
+ """
+
+ value: Optional[XmlDateTime] = field(
+ default=None,
+ metadata={
+ "required": True,
+ },
+ )
+
+
+@dataclass
+class LengthMetres:
+ """
+ Overall length of the platform, in metres.
+ """
+
+ value: Optional[Decimal] = field(
+ default=None,
+ metadata={
+ "required": True,
+ "min_inclusive": Decimal("0"),
+ },
+ )
+
+
+@dataclass
+class Name:
+ """
+ Human-readable name of the platform the parameters describe.
+ """
+
+ value: str = field(
+ default="",
+ metadata={
+ "required": True,
+ },
+ )
+
+
+@dataclass
+class PassiveArrayGainDb:
+ """
+ Array gain of the passive sonar's receiving array, in decibels.
+ """
+
+ value: Optional[Decimal] = field(
+ default=None,
+ metadata={
+ "required": True,
+ "min_inclusive": Decimal("-200"),
+ "max_inclusive": Decimal("300"),
+ },
+ )
+
+
+@dataclass
+class PassiveBearingAccuracyDeg:
+ """
+ 1-sigma bearing accuracy of the passive sonar, in degrees.
+ """
+
+ value: Optional[Decimal] = field(
+ default=None,
+ metadata={
+ "required": True,
+ "min_inclusive": Decimal("0"),
+ "max_inclusive": Decimal("360"),
+ },
+ )
+
+
+@dataclass
+class PassiveDetectionThresholdDb:
+ """
+ Signal-to-noise ratio the passive sonar needs for detection, in decibels.
+ """
+
+ value: Optional[Decimal] = field(
+ default=None,
+ metadata={
+ "required": True,
+ "min_inclusive": Decimal("-200"),
+ "max_inclusive": Decimal("300"),
+ },
+ )
+
+
+@dataclass
+class PassiveManufacturer:
+ """
+ Manufacturer of the passive sonar.
+ """
+
+ value: str = field(
+ default="",
+ metadata={
+ "required": True,
+ },
+ )
+
+
+@dataclass
+class PassiveName:
+ """
+ Model name of the passive sonar.
+ """
+
+ value: str = field(
+ default="",
+ metadata={
+ "required": True,
+ },
+ )
+
+
+@dataclass
+class PassiveOperatingFrequencyHz:
+ """
+ Nominal operating (centre) frequency of the passive sonar, in hertz.
+ """
+
+ value: Optional[Decimal] = field(
+ default=None,
+ metadata={
+ "required": True,
+ "min_inclusive": Decimal("0"),
+ },
+ )
+
+
+@dataclass
+class PeakBearingDeg:
+ """
+ Bearing at which the directivity lobe peaks, in degrees [0, 360).
+ """
+
+ value: Optional[Decimal] = field(
+ default=None,
+ metadata={
+ "required": True,
+ "min_inclusive": Decimal("0"),
+ "max_exclusive": Decimal("360"),
+ },
+ )
+
+
+@dataclass
+class RolloffDbPerOctave:
+ """
+ High-frequency spectral roll-off applied per octave above the base frequency,
+ in decibels.
+ """
+
+ value: Optional[Decimal] = field(
+ default=None,
+ metadata={
+ "required": True,
+ "min_inclusive": Decimal("0"),
+ },
+ )
+
+
+@dataclass
+class SchemaVersion:
+ """
+ Version of the input schema this document targets.
+ """
+
+ value: str = field(
+ default="",
+ metadata={
+ "required": True,
+ },
+ )
+
+
+@dataclass
+class WeightTonnes:
+ """
+ Displacement (weight) of the platform, in tonnes.
+ """
+
+ value: Optional[Decimal] = field(
+ default=None,
+ metadata={
+ "required": True,
+ "min_inclusive": Decimal("0"),
+ },
+ )
+
+
+@dataclass
+class YearIntroduced:
+ """
+ Calendar year the platform class entered service.
+ """
+
+ value: Optional[int] = field(
+ default=None,
+ metadata={
+ "required": True,
+ "min_inclusive": 1900,
+ "max_inclusive": 2100,
+ },
+ )
+
+
+@dataclass
+class ActiveSonar:
+ """Parameters of the platform's active sonar.
+
+ The detection threshold is consumed to derive the output's maximum
+ echo range.
+ """
+
+ active_name: Optional[ActiveName] = field(
+ default=None,
+ metadata={
+ "name": "ActiveName",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ active_manufacturer: Optional[ActiveManufacturer] = field(
+ default=None,
+ metadata={
+ "name": "ActiveManufacturer",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ active_operating_frequency_hz: Optional[ActiveOperatingFrequencyHz] = (
+ field(
+ default=None,
+ metadata={
+ "name": "ActiveOperatingFrequencyHz",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ )
+ active_source_level_db: Optional[ActiveSourceLevelDb] = field(
+ default=None,
+ metadata={
+ "name": "ActiveSourceLevelDb",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ active_beamwidth_deg: Optional[ActiveBeamwidthDeg] = field(
+ default=None,
+ metadata={
+ "name": "ActiveBeamwidthDeg",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ active_pulse_length_seconds: Optional[ActivePulseLengthSeconds] = field(
+ default=None,
+ metadata={
+ "name": "ActivePulseLengthSeconds",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ active_detection_threshold_db: Optional[ActiveDetectionThresholdDb] = (
+ field(
+ default=None,
+ metadata={
+ "name": "ActiveDetectionThresholdDb",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ )
+
+
+@dataclass
+class Characteristics:
+ """
+ The physical characteristics of the platform (passed through to the output
+ unchanged).
+ """
+
+ draft_metres: Optional[DraftMetres] = field(
+ default=None,
+ metadata={
+ "name": "DraftMetres",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ length_metres: Optional[LengthMetres] = field(
+ default=None,
+ metadata={
+ "name": "LengthMetres",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ weight_tonnes: Optional[WeightTonnes] = field(
+ default=None,
+ metadata={
+ "name": "WeightTonnes",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ year_introduced: Optional[YearIntroduced] = field(
+ default=None,
+ metadata={
+ "name": "YearIntroduced",
+ "type": "Element",
+ "required": True,
+ },
+ )
+
+
+@dataclass
+class Directivity:
+ """The directional lobe applied to the radiated-noise field: a single peak bearing and its amplitude."""
+
+ peak_bearing_deg: Optional[PeakBearingDeg] = field(
+ default=None,
+ metadata={
+ "name": "PeakBearingDeg",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ amplitude_db: Optional[AmplitudeDb] = field(
+ default=None,
+ metadata={
+ "name": "AmplitudeDb",
+ "type": "Element",
+ "required": True,
+ },
+ )
+
+
+@dataclass
+class PassiveSonar:
+ """
+ Parameters of one passive sonar carried by the platform.
+ """
+
+ passive_name: Optional[PassiveName] = field(
+ default=None,
+ metadata={
+ "name": "PassiveName",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ passive_manufacturer: Optional[PassiveManufacturer] = field(
+ default=None,
+ metadata={
+ "name": "PassiveManufacturer",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ passive_operating_frequency_hz: Optional[PassiveOperatingFrequencyHz] = (
+ field(
+ default=None,
+ metadata={
+ "name": "PassiveOperatingFrequencyHz",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ )
+ passive_array_gain_db: Optional[PassiveArrayGainDb] = field(
+ default=None,
+ metadata={
+ "name": "PassiveArrayGainDb",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ passive_detection_threshold_db: Optional[PassiveDetectionThresholdDb] = (
+ field(
+ default=None,
+ metadata={
+ "name": "PassiveDetectionThresholdDb",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ )
+ passive_bearing_accuracy_deg: Optional[PassiveBearingAccuracyDeg] = field(
+ default=None,
+ metadata={
+ "name": "PassiveBearingAccuracyDeg",
+ "type": "Element",
+ "required": True,
+ },
+ )
+
+
+@dataclass
+class RadiatedNoise:
+ """
+ Parameters of the directional radiated-noise model, expanded by the acoustics
+ seams into the output's bands and sectors.
+
+ :ivar base_frequency_hz:
+ :ivar band_ratio:
+ :ivar band_count:
+ :ivar bearing_step_deg:
+ :ivar base_level_db:
+ :ivar rolloff_db_per_octave:
+ :ivar directivity: The directivity lobe applied across all bearings.
+ """
+
+ base_frequency_hz: Optional[BaseFrequencyHz] = field(
+ default=None,
+ metadata={
+ "name": "BaseFrequencyHz",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ band_ratio: Optional[BandRatio] = field(
+ default=None,
+ metadata={
+ "name": "BandRatio",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ band_count: Optional[BandCount] = field(
+ default=None,
+ metadata={
+ "name": "BandCount",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ bearing_step_deg: Optional[BearingStepDeg] = field(
+ default=None,
+ metadata={
+ "name": "BearingStepDeg",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ base_level_db: Optional[BaseLevelDb] = field(
+ default=None,
+ metadata={
+ "name": "BaseLevelDb",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ rolloff_db_per_octave: Optional[RolloffDbPerOctave] = field(
+ default=None,
+ metadata={
+ "name": "RolloffDbPerOctave",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ directivity: Optional[Directivity] = field(
+ default=None,
+ metadata={
+ "name": "Directivity",
+ "type": "Element",
+ "required": True,
+ },
+ )
+
+
+@dataclass
+class Sensors:
+ """The sonar fit to be described: one active sonar and one or more passive sonars.
+
+ :ivar active_sonar: The platform's single active sonar.
+ :ivar passive_sonar: The platform's passive sonars.
+ """
+
+ active_sonar: Optional[ActiveSonar] = field(
+ default=None,
+ metadata={
+ "name": "ActiveSonar",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ passive_sonar: list[PassiveSonar] = field(
+ default_factory=list,
+ metadata={
+ "name": "PassiveSonar",
+ "type": "Element",
+ "min_occurs": 1,
+ },
+ )
+
+
+@dataclass
+class CalculationInput:
+ """Root element: the calculation parameters for one platform — its physical characteristics, its radiated-noise model parameters, and its sonar fit. The pipeline expands these into a validated Platform dataset.
+
+ :ivar schema_version:
+ :ivar name:
+ :ivar generated_utc:
+ :ivar characteristics: The platform's physical characteristics.
+ :ivar radiated_noise: The platform's radiated-noise model
+ parameters.
+ :ivar sensors: The platform's sonar fit.
+ """
+
+ schema_version: Optional[SchemaVersion] = field(
+ default=None,
+ metadata={
+ "name": "SchemaVersion",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ name: Optional[Name] = field(
+ default=None,
+ metadata={
+ "name": "Name",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ generated_utc: Optional[GeneratedUtc] = field(
+ default=None,
+ metadata={
+ "name": "GeneratedUtc",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ characteristics: Optional[Characteristics] = field(
+ default=None,
+ metadata={
+ "name": "Characteristics",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ radiated_noise: Optional[RadiatedNoise] = field(
+ default=None,
+ metadata={
+ "name": "RadiatedNoise",
+ "type": "Element",
+ "required": True,
+ },
+ )
+ sensors: Optional[Sensors] = field(
+ default=None,
+ metadata={
+ "name": "Sensors",
+ "type": "Element",
+ "required": True,
+ },
+ )
diff --git a/tests/conftest.py b/tests/conftest.py
index bbd7b24..2165d54 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -21,7 +21,7 @@ def schema_path() -> Path:
@pytest.fixture(scope="session")
def input_path() -> Path:
- return _REPO_ROOT / "examples" / "calculation_input.json"
+ return _REPO_ROOT / "examples" / "calculation_input.xml"
@pytest.fixture(scope="session")
diff --git a/tests/integration/test_gates.py b/tests/integration/test_gates.py
index a1b17f8..80f8d9b 100644
--- a/tests/integration/test_gates.py
+++ b/tests/integration/test_gates.py
@@ -6,9 +6,11 @@
from __future__ import annotations
+from decimal import Decimal
+
import pytest
-from acoustic_dataset import acoustics, build, serialize, validate
+from acoustic_dataset import build, serialize, validate
def test_golden_passes_both_structural_gates(golden_path, schema_path):
@@ -39,8 +41,8 @@ def test_schema_invalid_document_is_caught(input_path, schema_path):
def test_build_rejects_out_of_band_value_before_serialisation(input_path):
- data = acoustics.load_input(input_path)
+ data = build.load_input(input_path)
# Force an impossible source level past the schema's decibel band: the guard must fire.
- data["sensors"]["active"]["sourceLevelDb"] = 9999.0
+ data.sensors.active_sonar.active_source_level_db.value = Decimal("9999")
with pytest.raises(build.MappingError, match="SourceLevel"):
build.build_platform(data)
diff --git a/tests/unit/test_typed_vs_dict.py b/tests/unit/test_typed_vs_dict.py
index 25f5c4f..48fcd8a 100644
--- a/tests/unit/test_typed_vs_dict.py
+++ b/tests/unit/test_typed_vs_dict.py
@@ -10,7 +10,7 @@
import pytest
-from acoustic_dataset import acoustics, build
+from acoustic_dataset import build
from acoustic_dataset.models.acoustic_dataset import Sector
@@ -45,8 +45,8 @@ def test_a_stored_field_has_the_schema_declared_type(input_path):
def test_out_of_range_value_is_rejected_as_it_is_stored(input_path):
- data = acoustics.load_input(input_path)
+ data = build.load_input(input_path)
# The schema bounds Decibels to [-200, 300]; a dict would carry 9999 straight through.
- data["sensors"]["active"]["sourceLevelDb"] = 9999.0
+ data.sensors.active_sonar.active_source_level_db.value = Decimal("9999")
with pytest.raises(build.MappingError, match="SourceLevel"):
build.build_platform(data)