From 67f504bfe17d3d3ae973cca63318b7defa4a2c41 Mon Sep 17 00:00:00 2001 From: Chris McComb Date: Sun, 25 Oct 2026 15:12:14 -0400 Subject: [PATCH] Document JSON simulation validation pilot and lock input contract --- docs/evidence/json-case-pydantic-pilot.md | 108 ++++++++++++++++++++++ tests/test_case_simulation_boundary.py | 90 ++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 docs/evidence/json-case-pydantic-pilot.md create mode 100644 tests/test_case_simulation_boundary.py diff --git a/docs/evidence/json-case-pydantic-pilot.md b/docs/evidence/json-case-pydantic-pilot.md new file mode 100644 index 00000000..edf674e7 --- /dev/null +++ b/docs/evidence/json-case-pydantic-pilot.md @@ -0,0 +1,108 @@ +# JSON `simulation` validation pilot (#634) + +**Decision:** Keep the existing shared `_section` and `_number` validators. Do not +add Pydantic as a runtime dependency for this boundary. + +## Bounded prototype + +The current `simulation` boundary is nine nonblank lines in +`wecsim/dynamics/caseDynamics.py` (the `_section` call and five `_number` +calls). The two helpers are already used by other case sections, so replacing +this section cannot remove them. An executable local prototype using Pydantic +2.14.1 required 34 nonblank implementation lines for the model and adapter, +before changing the `run_case` call site. That is at least 25 additional lines +for this section +and would add Pydantic plus its transitive runtime packages. + +| Measure | Current parser | Pydantic `simulation` prototype | +| --- | ---: | ---: | +| Section validation lines | 9 | 34 plus call-site wiring | +| New runtime packages | 0 | 5 in the pilot environment | +| Shared `_section` / `_number` still needed | Yes | Yes | + +The prototype used `extra="forbid"`, `strict=True`, `allow_inf_nan=False`, and +`validate_default=True`, then delegated scalar conversion to `_number` to +preserve numeric strings, boolean rejection, finite ranges, and existing +`ValueError` messages. It called `_section` first to preserve the sorted +missing/unknown-key diagnostic. It kept the optional solver, radiation, and +added-mass settings untyped: these have layout-specific validation and the +`radiation_memory` default varies by layout. The resulting JSON Schema had +nine named fields, but a faithful schema for the accepted numeric-string inputs +would need further customization. Modeling the four physics settings more +strictly would change the input contract unless their layout rules moved too. + +The original mapping must remain available to the layout checks. They use +`set(sim)` and key presence; for example, an explicit `ramp_time=100` on a +no-wave free decay is rejected even though omitted `ramp_time` is accepted. +A model dump with defaults would erase that distinction. A future model would +need to retain the mapping or use `model_dump(exclude_unset=True)`. + +In a local comparison, the prototype produced identical values, +explicit-key sets, exception types, and messages to the current parser for 13 +cases: minimal input, numeric +strings, explicit defaults, booleans, null, nonnumeric input, negative and +nonfinite values, unknown and missing keys, a non-object section, and multiple +invalid fields. The permanent tests in +`tests/test_case_simulation_boundary.py` capture the accepted/rejected contract +and explicit-key behavior without requiring Pydantic. + +The rejected prototype was: + +```python +from typing import Any +from pydantic import BaseModel, ConfigDict, ValidationError, ValidationInfo, field_validator +from wecsim.dynamics.case_inputs import _number, _section + +_REQUIRED = {"dt", "end_time"} +_ALLOWED = _REQUIRED | { + "ramp_time", "rho", "g", "radiation_memory", "radiation_method", + "added_mass_scheme", "solver", +} + +class SimulationInput(BaseModel): + model_config = ConfigDict( + extra="forbid", strict=True, allow_inf_nan=False, validate_default=True, + ) + dt: float + end_time: float + ramp_time: float = 100 + rho: float = 1000 + g: float = 9.81 + radiation_memory: Any = None + radiation_method: Any = None + added_mass_scheme: Any = None + solver: Any = None + + @field_validator("dt", "end_time", "ramp_time", "rho", "g", mode="before") + @classmethod + def _numeric(cls, value: Any, info: ValidationInfo) -> float: + name = info.field_name + return _number(value, f"simulation.{name}", + positive=name in {"dt", "rho", "g"}, + nonnegative=name in {"end_time", "ramp_time"}) + +def validate_simulation(value: Any) -> SimulationInput: + value = _section(value, "simulation", _REQUIRED, _ALLOWED) + try: + return SimulationInput.model_validate(value) + except ValidationError as exc: + raise exc.errors()[0]["ctx"]["error"] from exc +``` + +## Verification + +On the current Python parser, the case-boundary, CLI/provenance, +builder-recovery, Python API, Sphere fixture parity, and RM3 two-column +fixture parity suites passed without Pydantic installed: **138 tests in +8.38 s**. The command was: + +```text +python -m pytest -q tests/test_case_simulation_boundary.py \ + tests/test_case_physical_boundaries.py \ + tests/test_unsupported_case_boundaries.py tests/test_cli_bundled_case.py \ + tests/test_builder_recovery.py tests/test_public_builder_recovery.py \ + tests/test_python_api.py tests/test_case_dynamics.py \ + tests/test_sphere_portable_parity.py tests/test_rm3_two_column_motion.py +``` + +No Python runtime parser or WEC builder code changed in this pilot. diff --git a/tests/test_case_simulation_boundary.py b/tests/test_case_simulation_boundary.py new file mode 100644 index 00000000..10c03178 --- /dev/null +++ b/tests/test_case_simulation_boundary.py @@ -0,0 +1,90 @@ +"""Keep the JSON simulation contract explicit for future validator changes.""" + +from copy import deepcopy +from pathlib import Path + +import pytest + +from wecsim.dynamics.caseDynamics import run_case + + +HYDRO = str(Path(__file__).resolve().parents[1] / "examples/data/sphere.h5") + + +def _regular_case(): + return { + "simulation": {"dt": .1, "end_time": 0}, + "wave": {"type": "regular", "height": 0, "period": 8}, + "bodies": [{"hydro_file": HYDRO, + "coordinate_map": [[0], [0], [1], [0], [0], [0]]}], + "constraint": {"kind": "linear_subspace"}, + } + + +def test_simulation_accepts_numeric_strings_without_mutating_case(): + case = _regular_case() + case["simulation"] = { + "dt": "0.1", "end_time": "0", "ramp_time": "0", + "rho": "1000", "g": "9.81", + } + original = deepcopy(case) + + response = run_case(case) + + assert response.time.tolist() == [0] + assert case == original + + +@pytest.mark.parametrize("update, message", [ + ({"dt": True}, "simulation.dt must be numeric"), + ({"end_time": False}, "simulation.end_time must be numeric"), + ({"dt": "bad"}, "simulation.dt must be numeric"), + ({"dt": "0"}, "simulation.dt is outside its supported range"), + ({"end_time": "-1"}, "simulation.end_time is outside its supported range"), + ({"ramp_time": "-1"}, "simulation.ramp_time is outside its supported range"), + ({"rho": False}, "simulation.rho must be numeric"), + ({"rho": "NaN"}, "simulation.rho is outside its supported range"), + ({"g": "Infinity"}, "simulation.g is outside its supported range"), + ({"unknown": 1}, "simulation: missing [], unsupported ['unknown']"), + ({"dt": True, "end_time": "bad"}, "simulation.dt must be numeric"), +]) +def test_simulation_rejects_invalid_values_and_unknown_keys(update, message): + case = _regular_case() + case["simulation"].update(update) + with pytest.raises(ValueError) as exc: + run_case(case) + assert str(exc.value) == message + + +def test_simulation_requires_object_and_required_keys(): + case = _regular_case() + case["simulation"] = [] + with pytest.raises(ValueError) as exc: + run_case(case) + assert str(exc.value) == "simulation must be an object" + + case["simulation"] = {"dt": .1} + with pytest.raises(ValueError) as exc: + run_case(case) + assert str(exc.value) == "simulation: missing ['end_time'], unsupported []" + + case["simulation"] = {"unused": 1} + with pytest.raises(ValueError) as exc: + run_case(case) + assert str(exc.value) == ( + "simulation: missing ['dt', 'end_time'], unsupported ['unused']" + ) + + +def test_explicit_ramp_time_remains_distinct_from_omitted_default(): + case = { + "simulation": {"dt": .1, "end_time": 0}, + "wave": {"type": "none"}, + "bodies": [{"hydro_file": HYDRO}], + "constraint": {"kind": "heave"}, + } + assert run_case(case).time.tolist() == [0] + + case["simulation"]["ramp_time"] = 100 + with pytest.raises(ValueError, match="ramp_time is inapplicable to no-wave free decay"): + run_case(case)