From 5789833908d0e5c871675aabc671339f8b986a9a Mon Sep 17 00:00:00 2001 From: Ian Mayo Date: Fri, 19 Jun 2026 13:00:44 +0100 Subject: [PATCH] feat(input): XML calculation-input schema + extracted dataclasses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the input side symmetric with the output: the calculation input is now an XML document held to its own contract, parsed into typed dataclasses generated from that schema — instead of an unvalidated JSON dict. - schema/calculation_input.xsd: small XSD in the same salami-slice idiom, modelling the parameter vocabulary (incl. input-only fields like the active sonar's detection threshold that the output derives from but never emits). - input_models/: dataclasses generated from it via xsdata. Separate package because input/output schemas share element names (Characteristics, Sensors). - generate.py: regenerate both schemas (make generate does both; --schema does one); import-check each. - build.load_input(): runs the input XSD gate, then parses into a typed CalculationInput; the builders read typed attributes. acoustics.load_input removed (keeps acoustics pure). - examples/calculation_input.xml replaces the .json (same values -> output is byte-identical to the golden file). - .gitattributes: pin LF so the byte-compared schema-docs drift gate passes on Windows checkouts (core.autocrlf=true), not just on LF-based CI. - cli, pyproject (ruff/mypy excludes), tests, docs and the CLI contract updated. Co-Authored-By: Claude Opus 4.8 --- .gitattributes | 14 + docs/concepts/pipeline-data-flow.md | 11 +- docs/concepts/typed-vs-dicts.md | 10 +- docs/how-to/change-the-schema.md | 30 +- docs/onboarding.md | 5 +- examples/calculation_input.json | 52 -- examples/calculation_input.xml | 56 ++ pyproject.toml | 5 +- schema/calculation_input.xsd | 341 ++++++++ .../contracts/cli-commands.md | 6 +- src/acoustic_dataset/acoustics/__init__.py | 10 - src/acoustic_dataset/build.py | 129 ++- src/acoustic_dataset/cli.py | 20 +- src/acoustic_dataset/generate.py | 110 ++- src/acoustic_dataset/input_models/__init__.py | 78 ++ .../input_models/calculation_input.py | 791 ++++++++++++++++++ tests/conftest.py | 2 +- tests/integration/test_gates.py | 8 +- tests/unit/test_typed_vs_dict.py | 6 +- 19 files changed, 1517 insertions(+), 167 deletions(-) create mode 100644 .gitattributes delete mode 100644 examples/calculation_input.json create mode 100644 examples/calculation_input.xml create mode 100644 schema/calculation_input.xsd create mode 100644 src/acoustic_dataset/input_models/__init__.py create mode 100644 src/acoustic_dataset/input_models/calculation_input.py 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)