纳管 AMESim 对齐基线、发布锁、回归测试及当前物理门禁调整。 更新日志仅记录已完成成果,并注明当前 HEAD 尚待真实 production 复跑与远端 workflow 验证。
1497 lines
61 KiB
Python
1497 lines
61 KiB
Python
"""Physical-state-v2.1 projection and AMESim-baseline protocol for test_mql.
|
|
|
|
AMESim simulation values are the authoritative physical baseline. Every
|
|
runtime evaluation compares the current projected values directly with that
|
|
baseline and reports the relative error when the AMESim denominator is
|
|
non-zero. Frozen Python values are retained only as implementation and
|
|
determinism evidence; they are not a physical-correctness authority.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from collections.abc import Mapping, Sequence
|
|
from datetime import UTC, datetime
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
import tarfile
|
|
from typing import Any
|
|
|
|
|
|
PHYSICAL_STATE_V21_ID = "physical-state-v2.1"
|
|
PHYSICAL_STATE_V21_SCHEMA_VERSION = 1
|
|
PHYSICAL_STATE_V21_GOLDEN_SCHEMA_VERSION = 1
|
|
AMESIM_REFERENCE_PRESSURE_PA = 101_300.0
|
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
|
DEFAULT_AMESIM_ARCHIVE = REPOSITORY_ROOT / "AmesimModels" / "test_mql.ame"
|
|
|
|
_PNCH_PRESSURE = "amesim_pnch012_8.p"
|
|
_LINE_PRESSURE = "amesim_pnl0001_20.p"
|
|
_LINE_FLOW = "amesim_pnl0001_20.port_1.m_flow"
|
|
_ORIFICE_FLOW = "amesim_pnvo001_5.port_2.m_flow"
|
|
_ORIFICE_OPENING = "amesim_pnvo001_5.xv"
|
|
_NODE_FLOWS = tuple(
|
|
f"amesim_p4node2_8.port_{index}.m_flow" for index in range(1, 5)
|
|
)
|
|
_ORIFICE_BALANCE_FLOWS = (
|
|
"amesim_pnvo001_5.port_2.m_flow",
|
|
"amesim_pnvo001_5.port_3.m_flow",
|
|
)
|
|
_CONNECTION_BALANCE_FLOWS = (
|
|
"amesim_pnl0001_20.port_1.m_flow",
|
|
"amesim_pnch012_8.port_1.m_flow",
|
|
)
|
|
_MASS_9_MODE_SOURCES = (
|
|
"amesim_mecmas21_9.x",
|
|
"amesim_mecmas21_9.v",
|
|
"amesim_mecmas21_9.port_1.f",
|
|
"amesim_mecmas21_9.port_2.f",
|
|
)
|
|
_MASS_10_MODE_SOURCES = (
|
|
"amesim_mecmas21_10.x",
|
|
"amesim_mecmas21_10.v",
|
|
"amesim_mecmas21_10.port_1.f",
|
|
"amesim_mecmas21_10.port_2.f",
|
|
)
|
|
|
|
|
|
class PhysicalStateV21Error(ValueError):
|
|
"""Raised when a projection or golden is incomplete or unauditable."""
|
|
|
|
|
|
def _sha256(payload: bytes) -> str:
|
|
return hashlib.sha256(payload).hexdigest()
|
|
|
|
|
|
def _canonical_sha256(value: object) -> str:
|
|
return _sha256(
|
|
json.dumps(
|
|
value,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
)
|
|
|
|
|
|
def _valid_sha256(value: object) -> bool:
|
|
return (
|
|
isinstance(value, str)
|
|
and len(value) == 64
|
|
and all(character in "0123456789abcdef" for character in value)
|
|
)
|
|
|
|
|
|
def _portable_path(path: Path | str) -> str:
|
|
"""Keep repository provenance relocatable while preserving external paths."""
|
|
|
|
resolved = Path(path).resolve()
|
|
try:
|
|
return resolved.relative_to(REPOSITORY_ROOT).as_posix()
|
|
except ValueError:
|
|
return str(resolved)
|
|
|
|
|
|
def _finite(value: object, *, field: str) -> float:
|
|
if not isinstance(value, (int, float)) or not math.isfinite(float(value)):
|
|
raise PhysicalStateV21Error(f"{field} must be a finite number.")
|
|
return float(value)
|
|
|
|
|
|
def _metadata_and_series(
|
|
result: Mapping[str, object],
|
|
) -> tuple[dict[str, Mapping[str, object]], Mapping[str, object]]:
|
|
variables = result.get("variables")
|
|
series = result.get("series")
|
|
if not isinstance(variables, list) or not isinstance(series, Mapping):
|
|
raise PhysicalStateV21Error("Simulation result requires variables and series.")
|
|
metadata: dict[str, Mapping[str, object]] = {}
|
|
for index, variable in enumerate(variables):
|
|
if not isinstance(variable, Mapping) or not isinstance(variable.get("key"), str):
|
|
raise PhysicalStateV21Error(f"Variable {index} has no string key.")
|
|
key = str(variable["key"])
|
|
if key in metadata:
|
|
raise PhysicalStateV21Error(f"Duplicate result variable key: {key}.")
|
|
metadata[key] = variable
|
|
return metadata, series
|
|
|
|
|
|
def _require_source(
|
|
metadata: Mapping[str, Mapping[str, object]],
|
|
series: Mapping[str, object],
|
|
key: str,
|
|
quantity: str,
|
|
unit: str,
|
|
) -> None:
|
|
variable = metadata.get(key)
|
|
if variable is None:
|
|
raise PhysicalStateV21Error(f"Required v2.1 source is missing: {key}.")
|
|
if variable.get("quantity") != quantity or variable.get("unit") != unit:
|
|
raise PhysicalStateV21Error(
|
|
f"Unexpected metadata for {key}: quantity={variable.get('quantity')!r}, "
|
|
f"unit={variable.get('unit')!r}; expected {quantity!r}, {unit!r}."
|
|
)
|
|
values = series.get(key)
|
|
if not isinstance(values, Sequence) or isinstance(values, (str, bytes)):
|
|
raise PhysicalStateV21Error(f"Required v2.1 series is missing: {key}.")
|
|
|
|
|
|
def _projection(
|
|
key: str,
|
|
category: str,
|
|
unit: str,
|
|
sources: Sequence[str],
|
|
python_formula: str,
|
|
amesim: Mapping[str, object],
|
|
*,
|
|
relative: float,
|
|
absolute: float,
|
|
) -> dict[str, object]:
|
|
return {
|
|
"key": key,
|
|
"category": category,
|
|
"unit": unit,
|
|
"python": {"sourceKeys": list(sources), "formula": python_formula},
|
|
"amesim": dict(amesim),
|
|
"tolerance": {"relative": relative, "absolute": absolute},
|
|
}
|
|
|
|
|
|
def _projection_layout(
|
|
result: Mapping[str, object],
|
|
) -> tuple[dict[str, object], tuple[str, ...]]:
|
|
metadata, series = _metadata_and_series(result)
|
|
for key in (_PNCH_PRESSURE, _LINE_PRESSURE):
|
|
_require_source(metadata, series, key, "pressure", "Pa")
|
|
for key in set(
|
|
(
|
|
_LINE_FLOW,
|
|
_ORIFICE_FLOW,
|
|
*_NODE_FLOWS,
|
|
*_ORIFICE_BALANCE_FLOWS,
|
|
*_CONNECTION_BALANCE_FLOWS,
|
|
)
|
|
):
|
|
_require_source(metadata, series, key, "mass_flow", "kg/s")
|
|
_require_source(metadata, series, _ORIFICE_OPENING, "dimensionless", "")
|
|
for key in (_MASS_9_MODE_SOURCES[0], _MASS_10_MODE_SOURCES[0]):
|
|
_require_source(metadata, series, key, "length", "m")
|
|
for key in (_MASS_9_MODE_SOURCES[1], _MASS_10_MODE_SOURCES[1]):
|
|
_require_source(metadata, series, key, "velocity", "m/s")
|
|
for key in (*_MASS_9_MODE_SOURCES[2:], *_MASS_10_MODE_SOURCES[2:]):
|
|
_require_source(metadata, series, key, "force", "N")
|
|
|
|
stored_mass_keys = tuple(
|
|
sorted(
|
|
key
|
|
for key, variable in metadata.items()
|
|
if variable.get("category") == "state"
|
|
and variable.get("quantity") == "mass"
|
|
and variable.get("unit") == "kg"
|
|
)
|
|
)
|
|
if not stored_mass_keys:
|
|
raise PhysicalStateV21Error("No stored gas-mass states were found.")
|
|
for key in stored_mass_keys:
|
|
values = series.get(key)
|
|
if not isinstance(values, Sequence) or isinstance(values, (str, bytes)):
|
|
raise PhysicalStateV21Error(f"Stored mass series is missing: {key}.")
|
|
|
|
gauge_to_absolute = {
|
|
"sourceUnit": "Pa (gauge)",
|
|
"targetUnit": "Pa (absolute)",
|
|
"formula": "target = source + 101300",
|
|
"scale": 1.0,
|
|
"offset": AMESIM_REFERENCE_PRESSURE_PA,
|
|
}
|
|
g_s_to_generic = {
|
|
"sourceUnit": "g/s (AMESim component orientation)",
|
|
"targetUnit": "kg/s (positive into generic component)",
|
|
"formula": "target = -source * 1e-3",
|
|
"scale": -1.0e-3,
|
|
"offset": 0.0,
|
|
}
|
|
projections = [
|
|
_projection(
|
|
"pressure.pnch012_8.absolute", "pressure", "Pa", [_PNCH_PRESSURE],
|
|
"identity", {"dataPaths": ["press@pn_c1_8"], **gauge_to_absolute},
|
|
relative=2.0e-4, absolute=1.0e-3,
|
|
),
|
|
_projection(
|
|
"pressure.pnl0001_20.absolute", "pressure", "Pa", [_LINE_PRESSURE],
|
|
"identity", {"dataPaths": ["p2@pneumatic_69"], **gauge_to_absolute},
|
|
relative=2.0e-4, absolute=1.0e-3,
|
|
),
|
|
_projection(
|
|
"massFlow.pnl0001_20.port_1.intoComponent", "massFlow", "kg/s",
|
|
[_LINE_FLOW], "identity", {"dataPaths": ["dm1@pneumatic_69"], **g_s_to_generic},
|
|
relative=2.0e-4, absolute=1.0e-9,
|
|
),
|
|
_projection(
|
|
"massFlow.pnvo001_5.port_2.intoComponent", "massFlow", "kg/s",
|
|
[_ORIFICE_FLOW], "identity", {"dataPaths": ["dm2@pn_morifice_1"], **g_s_to_generic},
|
|
relative=2.0e-4, absolute=1.0e-9,
|
|
),
|
|
_projection(
|
|
"conservation.p4node2_8.massBalance", "conservation", "kg/s",
|
|
_NODE_FLOWS, "sum",
|
|
{"dataPaths": [], "note": "AMESim archive has no complete saved four-port tuple."},
|
|
relative=0.0, absolute=1.0e-9,
|
|
),
|
|
_projection(
|
|
"conservation.pnvo001_5.massBalance", "conservation", "kg/s",
|
|
_ORIFICE_BALANCE_FLOWS, "sum",
|
|
{
|
|
"dataPaths": ["dm2@pn_morifice_1", "dm3@pn_morifice_1"],
|
|
"sourceUnit": "g/s", "targetUnit": "kg/s",
|
|
"formula": "target = -(source1 + source2) * 1e-3",
|
|
},
|
|
relative=0.0, absolute=1.0e-9,
|
|
),
|
|
_projection(
|
|
"conservation.pnl0001_20_pnch012_8.connectionMassBalance",
|
|
"conservation", "kg/s", _CONNECTION_BALANCE_FLOWS, "sum",
|
|
{"dataPaths": [], "note": "PNCH012 port flow is not saved; local connector contract is authoritative."},
|
|
relative=0.0, absolute=1.0e-9,
|
|
),
|
|
_projection(
|
|
"conservation.totalStoredGasMass", "conservation", "kg",
|
|
stored_mass_keys, "sum",
|
|
{
|
|
"dataPathSelection": "units == 'g' and signal starts with 'mgas'",
|
|
"sourceUnit": "g", "targetUnit": "kg",
|
|
"formula": "target = sum(sources) * 1e-3",
|
|
},
|
|
relative=2.0e-4, absolute=1.0e-9,
|
|
),
|
|
_projection(
|
|
"discrete.pnvo001_5.openMode", "discreteMode", "1",
|
|
[_ORIFICE_OPENING], "1 if opening >= 0.5 else 0",
|
|
{
|
|
"dataPaths": ["xv@pn_morifice_1"], "sourceUnit": "1",
|
|
"targetUnit": "mode code {0,1}", "formula": "1 if source >= 0.5 else 0",
|
|
},
|
|
relative=0.0, absolute=0.0,
|
|
),
|
|
_projection(
|
|
"discrete.mecmas21_9.endstopMode", "discreteMode", "1",
|
|
_MASS_9_MODE_SOURCES,
|
|
"-1 lower, 0 free, +1 upper using boundary and net-force direction",
|
|
{
|
|
"dataPaths": ["x1@mass_friction_endstops_19", "v1@mass_friction_endstops_19"],
|
|
"lowerBound": -0.72, "upperBound": 0.0,
|
|
"note": "No saved AMESim endstop code; reference is boundary occupancy.",
|
|
},
|
|
relative=0.0, absolute=0.0,
|
|
),
|
|
_projection(
|
|
"discrete.mecmas21_10.endstopMode", "discreteMode", "1",
|
|
_MASS_10_MODE_SOURCES,
|
|
"-1 lower, 0 free, +1 upper using boundary and net-force direction",
|
|
{
|
|
"dataPaths": ["x1@mass_friction_endstops_18", "v1@mass_friction_endstops_18"],
|
|
"lowerBound": 0.0, "upperBound": 0.37,
|
|
"note": "No saved AMESim endstop code; reference is boundary occupancy.",
|
|
},
|
|
relative=0.0, absolute=0.0,
|
|
),
|
|
]
|
|
keys = [str(projection["key"]) for projection in projections]
|
|
hash_payload = {"projectionKeys": keys, "projections": projections}
|
|
return {
|
|
**hash_payload,
|
|
"projectionLayoutSha256": _canonical_sha256(hash_payload),
|
|
}, stored_mass_keys
|
|
|
|
|
|
def _value_at(series: Mapping[str, object], key: str, index: int) -> float:
|
|
values = series[key]
|
|
assert isinstance(values, Sequence)
|
|
if index >= len(values):
|
|
raise PhysicalStateV21Error(f"Series {key!r} is short at index {index}.")
|
|
return _finite(values[index], field=f"series.{key}[{index}]")
|
|
|
|
|
|
def _endstop_mode(
|
|
position: float,
|
|
velocity: float,
|
|
force_1: float,
|
|
force_2: float,
|
|
lower: float,
|
|
upper: float,
|
|
) -> float:
|
|
x_tol = 1.0e-12 * max(abs(position), abs(lower), abs(upper), 1.0)
|
|
v_tol = 1.0e-12 * max(abs(velocity), 1.0)
|
|
net_force = force_1 + force_2
|
|
if position <= lower + x_tol and velocity <= v_tol and net_force <= 0.0:
|
|
return -1.0
|
|
if position >= upper - x_tol and velocity >= -v_tol and net_force >= 0.0:
|
|
return 1.0
|
|
return 0.0
|
|
|
|
|
|
def _project_row(
|
|
series: Mapping[str, object],
|
|
index: int,
|
|
stored_mass_keys: Sequence[str],
|
|
) -> dict[str, float]:
|
|
mass_9 = [_value_at(series, key, index) for key in _MASS_9_MODE_SOURCES]
|
|
mass_10 = [_value_at(series, key, index) for key in _MASS_10_MODE_SOURCES]
|
|
return {
|
|
"pressure.pnch012_8.absolute": _value_at(series, _PNCH_PRESSURE, index),
|
|
"pressure.pnl0001_20.absolute": _value_at(series, _LINE_PRESSURE, index),
|
|
"massFlow.pnl0001_20.port_1.intoComponent": _value_at(series, _LINE_FLOW, index),
|
|
"massFlow.pnvo001_5.port_2.intoComponent": _value_at(series, _ORIFICE_FLOW, index),
|
|
"conservation.p4node2_8.massBalance": sum(_value_at(series, key, index) for key in _NODE_FLOWS),
|
|
"conservation.pnvo001_5.massBalance": sum(_value_at(series, key, index) for key in _ORIFICE_BALANCE_FLOWS),
|
|
"conservation.pnl0001_20_pnch012_8.connectionMassBalance": sum(
|
|
_value_at(series, key, index) for key in _CONNECTION_BALANCE_FLOWS
|
|
),
|
|
"conservation.totalStoredGasMass": sum(_value_at(series, key, index) for key in stored_mass_keys),
|
|
"discrete.pnvo001_5.openMode": float(_value_at(series, _ORIFICE_OPENING, index) >= 0.5),
|
|
"discrete.mecmas21_9.endstopMode": _endstop_mode(*mass_9, -0.72, 0.0),
|
|
"discrete.mecmas21_10.endstopMode": _endstop_mode(*mass_10, 0.0, 0.37),
|
|
}
|
|
|
|
|
|
def project_physical_state_v21(
|
|
result: Mapping[str, object],
|
|
*,
|
|
checkpoint_times: Sequence[float],
|
|
sample_step: float,
|
|
) -> dict[str, object]:
|
|
"""Project pressure, flow, conservation, and discrete-mode checkpoints."""
|
|
|
|
if not math.isfinite(float(sample_step)) or float(sample_step) <= 0.0:
|
|
raise PhysicalStateV21Error("sample_step must be finite and positive.")
|
|
layout, stored_mass_keys = _projection_layout(result)
|
|
_metadata, series = _metadata_and_series(result)
|
|
raw_times = series.get("time")
|
|
if not isinstance(raw_times, Sequence) or isinstance(raw_times, (str, bytes)):
|
|
raise PhysicalStateV21Error("Simulation result requires a time series.")
|
|
times = [_finite(value, field=f"series.time[{index}]") for index, value in enumerate(raw_times)]
|
|
if not times or any(first >= second for first, second in zip(times, times[1:])):
|
|
raise PhysicalStateV21Error("Time series must be non-empty and strictly increasing.")
|
|
keys = layout["projectionKeys"]
|
|
assert isinstance(keys, list)
|
|
tolerance = max(1.0e-12, 0.51 * float(sample_step))
|
|
checkpoints: list[dict[str, object]] = []
|
|
for raw_requested in checkpoint_times:
|
|
requested = _finite(raw_requested, field="checkpoint time")
|
|
index = min(range(len(times)), key=lambda candidate: abs(times[candidate] - requested))
|
|
actual = times[index]
|
|
if abs(actual - requested) > tolerance:
|
|
checkpoints.append({"requestedTime": requested, "available": False, "nearestTime": actual})
|
|
continue
|
|
values = _project_row(series, index, stored_mass_keys)
|
|
if list(values) != keys:
|
|
raise PhysicalStateV21Error("Internal v2.1 projection layout drifted.")
|
|
checkpoints.append(
|
|
{"requestedTime": requested, "actualTime": actual, "available": True, "values": values}
|
|
)
|
|
return {
|
|
"schemaVersion": PHYSICAL_STATE_V21_SCHEMA_VERSION,
|
|
"id": PHYSICAL_STATE_V21_ID,
|
|
"projectionCategories": ["pressure", "massFlow", "conservation", "discreteMode"],
|
|
"layout": layout,
|
|
"checkpoints": checkpoints,
|
|
"comparisonMode": "amesimAuthoritativeBaselineWithLocalInvariants",
|
|
}
|
|
|
|
|
|
def physical_state_v21_applicable(result: Mapping[str, object]) -> bool:
|
|
"""Return true only for results carrying the test_mql v2.1 signature."""
|
|
|
|
series = result.get("series")
|
|
return isinstance(series, Mapping) and _PNCH_PRESSURE in series
|
|
|
|
|
|
def _amesim_value(results: Any, data_path: str, time_s: float) -> float:
|
|
from app.simulation.reporting.test_mql_comparison import interpolate_series_value
|
|
|
|
if data_path not in results.series_by_data_path:
|
|
raise PhysicalStateV21Error(f"AMESim is missing Data_Path {data_path!r}.")
|
|
return interpolate_series_value(results.times, results.series(data_path), time_s)
|
|
|
|
|
|
def _amesim_boundary_mode(
|
|
results: Any,
|
|
alias: str,
|
|
time_s: float,
|
|
lower: float,
|
|
upper: float,
|
|
) -> float:
|
|
position = _amesim_value(results, f"x1@{alias}", time_s)
|
|
velocity = _amesim_value(results, f"v1@{alias}", time_s)
|
|
tolerance = 1.0e-10 * max(abs(position), abs(lower), abs(upper), 1.0)
|
|
if abs(velocity) <= 1.0e-10 and position <= lower + tolerance:
|
|
return -1.0
|
|
if abs(velocity) <= 1.0e-10 and position >= upper - tolerance:
|
|
return 1.0
|
|
return 0.0
|
|
|
|
|
|
def _archive_provenance(archive_path: Path) -> dict[str, object]:
|
|
archive_payload = archive_path.read_bytes()
|
|
with tarfile.open(archive_path) as archive:
|
|
names = set(archive.getnames())
|
|
preferred = ("test_mql_.var", "test_mql_.results")
|
|
if all(name in names for name in preferred):
|
|
members = preferred
|
|
else:
|
|
pairs = sorted(
|
|
(name, f"{name[:-4]}.results")
|
|
for name in names
|
|
if name.endswith(".var") and f"{name[:-4]}.results" in names
|
|
)
|
|
if len(pairs) != 1:
|
|
raise PhysicalStateV21Error("Could not resolve one AMESim result pair.")
|
|
members = pairs[0]
|
|
member_records = []
|
|
for name in members:
|
|
extracted = archive.extractfile(name)
|
|
if extracted is None:
|
|
raise PhysicalStateV21Error(f"AMESim member is missing: {name}.")
|
|
payload = extracted.read()
|
|
member_records.append(
|
|
{"name": name, "bytes": len(payload), "sha256": _sha256(payload)}
|
|
)
|
|
return {
|
|
"archivePath": _portable_path(archive_path),
|
|
"archiveBytes": len(archive_payload),
|
|
"archiveSha256": _sha256(archive_payload),
|
|
"members": member_records,
|
|
"referencePressurePa": AMESIM_REFERENCE_PRESSURE_PA,
|
|
"role": "external calibration reference; not an equality claim",
|
|
}
|
|
|
|
|
|
def build_amesim_reference(
|
|
archive_path: Path | str,
|
|
*,
|
|
checkpoint_times: Sequence[float],
|
|
) -> dict[str, object]:
|
|
"""Return transformed AMESim values with archive/member provenance."""
|
|
|
|
# Keep the 132-state legacy model catalog out of benchmark worker startup;
|
|
# it is needed only by this explicit AMESim-reference operation.
|
|
from app.simulation.reporting.amesim_results import load_test_mql_amesim_results
|
|
from app.simulation.reporting.test_mql_variables import (
|
|
build_test_mql_variable_catalog,
|
|
)
|
|
|
|
path = Path(archive_path).resolve()
|
|
times = tuple(_finite(value, field="AMESim checkpoint") for value in checkpoint_times)
|
|
results = load_test_mql_amesim_results(path, time_stop_s=max(times, default=0.0))
|
|
variable_catalog = build_test_mql_variable_catalog(results)
|
|
mass_paths = tuple(
|
|
variable.data_path
|
|
for variable in variable_catalog.variables
|
|
if variable.units == "g" and variable.signal_name.startswith("mgas")
|
|
)
|
|
if not mass_paths:
|
|
raise PhysicalStateV21Error("AMESim archive contains no stored gas mass.")
|
|
checkpoints: list[dict[str, object]] = []
|
|
for time_s in times:
|
|
dm2 = _amesim_value(results, "dm2@pn_morifice_1", time_s)
|
|
dm3 = _amesim_value(results, "dm3@pn_morifice_1", time_s)
|
|
values: dict[str, float | None] = {
|
|
"pressure.pnch012_8.absolute": _amesim_value(
|
|
results, "press@pn_c1_8", time_s
|
|
) + AMESIM_REFERENCE_PRESSURE_PA,
|
|
"pressure.pnl0001_20.absolute": _amesim_value(
|
|
results, "p2@pneumatic_69", time_s
|
|
) + AMESIM_REFERENCE_PRESSURE_PA,
|
|
"massFlow.pnl0001_20.port_1.intoComponent": -1.0e-3
|
|
* _amesim_value(results, "dm1@pneumatic_69", time_s),
|
|
"massFlow.pnvo001_5.port_2.intoComponent": -1.0e-3 * dm2,
|
|
"conservation.p4node2_8.massBalance": None,
|
|
"conservation.pnvo001_5.massBalance": -1.0e-3 * (dm2 + dm3),
|
|
"conservation.pnl0001_20_pnch012_8.connectionMassBalance": None,
|
|
"conservation.totalStoredGasMass": 1.0e-3
|
|
* sum(_amesim_value(results, data_path, time_s) for data_path in mass_paths),
|
|
"discrete.pnvo001_5.openMode": float(
|
|
_amesim_value(results, "xv@pn_morifice_1", time_s) >= 0.5
|
|
),
|
|
# Generic mass_9 is the 90,000 kg AMESim instance 10 (alias _19).
|
|
"discrete.mecmas21_9.endstopMode": _amesim_boundary_mode(
|
|
results, "mass_friction_endstops_19", time_s, -0.72, 0.0
|
|
),
|
|
# Generic mass_10 is the 170,000 kg AMESim instance 9 (alias _18).
|
|
"discrete.mecmas21_10.endstopMode": _amesim_boundary_mode(
|
|
results, "mass_friction_endstops_18", time_s, 0.0, 0.37
|
|
),
|
|
}
|
|
checkpoints.append({"requestedTime": time_s, "values": values})
|
|
return {
|
|
"schemaVersion": 1,
|
|
"provenance": _archive_provenance(path),
|
|
"storedMassDataPaths": list(mass_paths),
|
|
"checkpoints": checkpoints,
|
|
}
|
|
|
|
|
|
def _validate_contract(
|
|
contract: Mapping[str, object],
|
|
*,
|
|
require_available: bool = True,
|
|
) -> tuple[list[str], list[Mapping[str, object]]]:
|
|
if contract.get("schemaVersion") != PHYSICAL_STATE_V21_SCHEMA_VERSION:
|
|
raise PhysicalStateV21Error("Unsupported v2.1 contract schemaVersion.")
|
|
if contract.get("id") != PHYSICAL_STATE_V21_ID:
|
|
raise PhysicalStateV21Error("Unexpected v2.1 contract id.")
|
|
layout = contract.get("layout")
|
|
if not isinstance(layout, Mapping):
|
|
raise PhysicalStateV21Error("v2.1 layout must be an object.")
|
|
keys = layout.get("projectionKeys")
|
|
projections = layout.get("projections")
|
|
if (
|
|
not isinstance(keys, list)
|
|
or not keys
|
|
or not all(isinstance(key, str) and key for key in keys)
|
|
or len(set(keys)) != len(keys)
|
|
):
|
|
raise PhysicalStateV21Error("Projection keys must be unique strings.")
|
|
if not isinstance(projections, list) or len(projections) != len(keys):
|
|
raise PhysicalStateV21Error("Projection metadata layout is invalid.")
|
|
typed: list[Mapping[str, object]] = []
|
|
for key, projection in zip(keys, projections):
|
|
if not isinstance(projection, Mapping) or projection.get("key") != key:
|
|
raise PhysicalStateV21Error("Projection metadata order is invalid.")
|
|
tolerance = projection.get("tolerance")
|
|
if not isinstance(tolerance, Mapping):
|
|
raise PhysicalStateV21Error(f"Projection {key} has no tolerance.")
|
|
for field in ("relative", "absolute"):
|
|
value = _finite(tolerance.get(field), field=f"{key}.tolerance.{field}")
|
|
if value < 0.0:
|
|
raise PhysicalStateV21Error("Projection tolerance cannot be negative.")
|
|
typed.append(projection)
|
|
hash_payload = {"projectionKeys": keys, "projections": projections}
|
|
if layout.get("projectionLayoutSha256") != _canonical_sha256(hash_payload):
|
|
raise PhysicalStateV21Error("Projection layout hash mismatch.")
|
|
checkpoints = contract.get("checkpoints")
|
|
if not isinstance(checkpoints, list) or not checkpoints:
|
|
raise PhysicalStateV21Error("v2.1 contract requires checkpoints.")
|
|
for checkpoint in checkpoints:
|
|
if not isinstance(checkpoint, Mapping):
|
|
raise PhysicalStateV21Error("v2.1 checkpoint must be an object.")
|
|
if not checkpoint.get("available"):
|
|
if require_available:
|
|
raise PhysicalStateV21Error("Every golden checkpoint must be available.")
|
|
continue
|
|
_finite(checkpoint.get("requestedTime"), field="checkpoint.requestedTime")
|
|
_finite(checkpoint.get("actualTime"), field="checkpoint.actualTime")
|
|
values = checkpoint.get("values")
|
|
if not isinstance(values, Mapping) or list(values) != keys:
|
|
raise PhysicalStateV21Error("Checkpoint values do not match layout.")
|
|
for key in keys:
|
|
_finite(values[key], field=f"checkpoint.values.{key}")
|
|
return keys, typed
|
|
|
|
|
|
def _case_summary(
|
|
report: Mapping[str, object],
|
|
*,
|
|
case_id: str,
|
|
lane: str,
|
|
) -> Mapping[str, object]:
|
|
cases = report.get("cases")
|
|
if not isinstance(cases, list):
|
|
raise PhysicalStateV21Error("Regression report cases must be a list.")
|
|
matches = [
|
|
case
|
|
for case in cases
|
|
if isinstance(case, Mapping)
|
|
and case.get("caseId") == case_id
|
|
and case.get("lane") == lane
|
|
]
|
|
if len(matches) != 1:
|
|
raise PhysicalStateV21Error(
|
|
f"Expected one {case_id!r}/{lane!r} case, found {len(matches)}."
|
|
)
|
|
worker = matches[0].get("worker")
|
|
summary = worker.get("summary") if isinstance(worker, Mapping) else None
|
|
if not isinstance(summary, Mapping):
|
|
raise PhysicalStateV21Error("Regression case has no worker summary.")
|
|
return summary
|
|
|
|
|
|
def _case_contract(
|
|
report: Mapping[str, object],
|
|
*,
|
|
case_id: str,
|
|
lane: str,
|
|
) -> Mapping[str, object]:
|
|
summary = _case_summary(report, case_id=case_id, lane=lane)
|
|
contract = summary.get("physicalStateV21")
|
|
if not isinstance(contract, Mapping):
|
|
raise PhysicalStateV21Error(
|
|
"Report has no physicalStateV21 contract; regenerate it with v2.1 enabled."
|
|
)
|
|
return contract
|
|
|
|
|
|
def _final_result_from_summary(summary: Mapping[str, object]) -> dict[str, object]:
|
|
"""Adapt a pre-v2.1 report's final map for one honest endpoint assessment."""
|
|
|
|
final = summary.get("final")
|
|
simulated_until = summary.get("simulatedUntil")
|
|
if not isinstance(final, Mapping):
|
|
raise PhysicalStateV21Error("Legacy report summary has no final values.")
|
|
time_s = _finite(simulated_until, field="summary.simulatedUntil")
|
|
required = {
|
|
_PNCH_PRESSURE: ("pressure", "Pa", "thermodynamic"),
|
|
_LINE_PRESSURE: ("pressure", "Pa", "thermodynamic"),
|
|
_LINE_FLOW: ("mass_flow", "kg/s", "port"),
|
|
_ORIFICE_FLOW: ("mass_flow", "kg/s", "port"),
|
|
_ORIFICE_OPENING: ("dimensionless", "", "derived"),
|
|
**{key: ("mass_flow", "kg/s", "port") for key in _NODE_FLOWS},
|
|
**{key: ("mass_flow", "kg/s", "port") for key in _ORIFICE_BALANCE_FLOWS},
|
|
**{key: ("mass_flow", "kg/s", "port") for key in _CONNECTION_BALANCE_FLOWS},
|
|
_MASS_9_MODE_SOURCES[0]: ("length", "m", "state"),
|
|
_MASS_9_MODE_SOURCES[1]: ("velocity", "m/s", "state"),
|
|
_MASS_9_MODE_SOURCES[2]: ("force", "N", "port"),
|
|
_MASS_9_MODE_SOURCES[3]: ("force", "N", "port"),
|
|
_MASS_10_MODE_SOURCES[0]: ("length", "m", "state"),
|
|
_MASS_10_MODE_SOURCES[1]: ("velocity", "m/s", "state"),
|
|
_MASS_10_MODE_SOURCES[2]: ("force", "N", "port"),
|
|
_MASS_10_MODE_SOURCES[3]: ("force", "N", "port"),
|
|
}
|
|
mass_keys = sorted(
|
|
key
|
|
for key in final
|
|
if isinstance(key, str)
|
|
and (key.endswith(".m") or key.endswith(".m1") or key.endswith(".m2"))
|
|
)
|
|
variables = []
|
|
series: dict[str, list[float]] = {"time": [time_s]}
|
|
for key, (quantity, unit, category) in required.items():
|
|
if key not in final:
|
|
raise PhysicalStateV21Error(f"Legacy final map is missing {key}.")
|
|
variables.append({"key": key, "quantity": quantity, "unit": unit, "category": category})
|
|
series[key] = [_finite(final[key], field=f"summary.final.{key}")]
|
|
for key in mass_keys:
|
|
variables.append({"key": key, "quantity": "mass", "unit": "kg", "category": "state"})
|
|
series[key] = [_finite(final[key], field=f"summary.final.{key}")]
|
|
return {"variables": variables, "series": series}
|
|
|
|
|
|
def project_report_endpoint_v21(
|
|
report: Mapping[str, object],
|
|
*,
|
|
case_id: str = "0.2s",
|
|
lane: str = "production",
|
|
) -> dict[str, object]:
|
|
"""Project the endpoint of a report produced before v2.1 was connected."""
|
|
|
|
summary = _case_summary(report, case_id=case_id, lane=lane)
|
|
result = _final_result_from_summary(summary)
|
|
time_s = float(summary["simulatedUntil"])
|
|
return project_physical_state_v21(
|
|
result, checkpoint_times=(time_s,), sample_step=max(time_s, 1.0e-12)
|
|
)
|
|
|
|
|
|
def compare_contract_to_amesim(
|
|
contract: Mapping[str, object],
|
|
amesim_reference: Mapping[str, object],
|
|
) -> dict[str, object]:
|
|
"""Compare every projection with the authoritative AMESim baseline.
|
|
|
|
AMESim may not expose every internal conservation quantity. Those rows are
|
|
retained in ``metrics`` with an unavailable baseline instead of being
|
|
silently discarded. A zero AMESim baseline has no mathematically defined
|
|
relative error, so the report records ``None`` and relies on the declared
|
|
absolute tolerance for the pass/fail decision.
|
|
"""
|
|
|
|
keys, projections = _validate_contract(contract)
|
|
checkpoints = contract["checkpoints"]
|
|
references = amesim_reference.get("checkpoints")
|
|
if not isinstance(checkpoints, list) or not isinstance(references, list):
|
|
raise PhysicalStateV21Error("AMESim comparison checkpoints are missing.")
|
|
if len(checkpoints) != len(references):
|
|
raise PhysicalStateV21Error("AMESim checkpoint count differs from contract.")
|
|
# This is an external alignment profile, deliberately distinct from the
|
|
# tighter local non-regression tolerances embedded in the projection layout.
|
|
external_tolerance = {
|
|
"pressure": {"relative": 2.0e-3, "absolute": 1.0e-3},
|
|
"massFlow": {"relative": 2.0e-3, "absolute": 1.0e-9},
|
|
"conservation": {"relative": 2.0e-3, "absolute": 1.0e-9},
|
|
"discreteMode": {"relative": 0.0, "absolute": 0.0},
|
|
}
|
|
compared = 0
|
|
available = 0
|
|
unavailable = 0
|
|
excluded = 0
|
|
issues: list[str] = []
|
|
metrics: list[dict[str, object]] = []
|
|
worst: dict[str, object] | None = None
|
|
max_ratio = 0.0
|
|
worst_relative: dict[str, object] | None = None
|
|
max_relative_error: float | None = None
|
|
max_recorded_relative_error: float | None = None
|
|
for checkpoint, reference in zip(checkpoints, references):
|
|
assert isinstance(checkpoint, Mapping) and isinstance(reference, Mapping)
|
|
actual_values = checkpoint["values"]
|
|
reference_values = reference.get("values")
|
|
assert isinstance(actual_values, Mapping) and isinstance(reference_values, Mapping)
|
|
for key, projection in zip(keys, projections):
|
|
actual = float(actual_values[key])
|
|
requested_time = float(checkpoint["requestedTime"])
|
|
category = str(projection["category"])
|
|
expected = reference_values.get(key)
|
|
if expected is None:
|
|
unavailable += 1
|
|
metrics.append(
|
|
{
|
|
"requestedTime": requested_time,
|
|
"key": key,
|
|
"category": category,
|
|
"actual": actual,
|
|
"amesimBaseline": None,
|
|
"absoluteError": None,
|
|
"relativeError": None,
|
|
"relativeErrorPercent": None,
|
|
"relativeErrorDefined": False,
|
|
"relativeErrorReason": "amesimBaselineUnavailable",
|
|
"allowedError": None,
|
|
"toleranceRatio": None,
|
|
"unboundedToleranceRatio": False,
|
|
"comparisonBasis": "localInvariantOnly",
|
|
"evaluated": False,
|
|
"passed": None,
|
|
"exclusionReason": "amesimBaselineUnavailable",
|
|
}
|
|
)
|
|
continue
|
|
available += 1
|
|
expected_numeric = float(expected)
|
|
tolerance = external_tolerance[category]
|
|
allowed = tolerance["absolute"] + tolerance["relative"] * abs(expected_numeric)
|
|
error = abs(actual - expected_numeric)
|
|
ratio = error / allowed if allowed > 0.0 else (0.0 if error == 0.0 else math.inf)
|
|
relative_error = (
|
|
None if expected_numeric == 0.0 else error / abs(expected_numeric)
|
|
)
|
|
relative_error_percent = (
|
|
None if relative_error is None else 100.0 * relative_error
|
|
)
|
|
near_zero_threshold = (
|
|
tolerance["absolute"] / tolerance["relative"]
|
|
if tolerance["relative"] > 0.0
|
|
else 0.0
|
|
)
|
|
if category == "discreteMode":
|
|
comparison_basis = "exactMatch"
|
|
elif expected_numeric == 0.0 or abs(expected_numeric) <= near_zero_threshold:
|
|
comparison_basis = "absoluteNearZero"
|
|
else:
|
|
comparison_basis = "mixedAbsoluteRelativeTolerance"
|
|
excluded_at_signal_jump = category == "massFlow" and math.isclose(
|
|
requested_time, 0.04, rel_tol=0.0, abs_tol=1.0e-12
|
|
)
|
|
metric = {
|
|
"requestedTime": requested_time,
|
|
"key": key,
|
|
"category": category,
|
|
"actual": actual,
|
|
"amesimBaseline": expected_numeric,
|
|
"absoluteError": error,
|
|
"relativeError": relative_error,
|
|
"relativeErrorPercent": relative_error_percent,
|
|
"relativeErrorDefined": relative_error is not None,
|
|
"relativeErrorReason": (
|
|
None if relative_error is not None else "zeroAmesimBaseline"
|
|
),
|
|
"allowedError": allowed,
|
|
"toleranceRatio": ratio if math.isfinite(ratio) else None,
|
|
"unboundedToleranceRatio": not math.isfinite(ratio),
|
|
"nearZeroThreshold": near_zero_threshold,
|
|
"comparisonBasis": comparison_basis,
|
|
}
|
|
if relative_error is not None and (
|
|
max_recorded_relative_error is None
|
|
or relative_error > max_recorded_relative_error
|
|
):
|
|
max_recorded_relative_error = relative_error
|
|
if excluded_at_signal_jump:
|
|
excluded += 1
|
|
metric.update(
|
|
{
|
|
"evaluated": False,
|
|
"passed": None,
|
|
"exclusionReason": (
|
|
"signalDiscontinuityLeftRightLimitSemanticsNotComparableAt0.04s"
|
|
),
|
|
}
|
|
)
|
|
metrics.append(metric)
|
|
continue
|
|
metric.update({"evaluated": True, "passed": ratio <= 1.0})
|
|
metrics.append(metric)
|
|
compared += 1
|
|
if ratio > max_ratio:
|
|
max_ratio = ratio
|
|
worst = metric
|
|
if relative_error is not None and (
|
|
max_relative_error is None or relative_error > max_relative_error
|
|
):
|
|
max_relative_error = relative_error
|
|
worst_relative = metric
|
|
if ratio > 1.0:
|
|
issues.append("amesimReferenceToleranceMismatch")
|
|
deduplicated = list(dict.fromkeys(issues))
|
|
return {
|
|
"passed": not deduplicated,
|
|
"issues": deduplicated,
|
|
"baselineAuthority": "amesim",
|
|
"metricCount": len(metrics),
|
|
"availableBaselineValueCount": available,
|
|
"unavailableBaselineValueCount": unavailable,
|
|
"comparedValueCount": compared,
|
|
"excludedValueCount": excluded,
|
|
"maxToleranceRatio": (
|
|
max_ratio if compared and math.isfinite(max_ratio) else None
|
|
),
|
|
"maxToleranceRatioUnbounded": bool(compared and not math.isfinite(max_ratio)),
|
|
"worstValue": worst,
|
|
"maxRelativeError": max_relative_error,
|
|
"maxRelativeErrorPercent": (
|
|
None if max_relative_error is None else 100.0 * max_relative_error
|
|
),
|
|
"worstRelativeErrorValue": worst_relative,
|
|
"maxRecordedRelativeError": max_recorded_relative_error,
|
|
"maxRecordedRelativeErrorPercent": (
|
|
None
|
|
if max_recorded_relative_error is None
|
|
else 100.0 * max_recorded_relative_error
|
|
),
|
|
"metrics": metrics,
|
|
"toleranceProfile": {
|
|
"id": "amesim-alignment-reviewed-v1",
|
|
"byCategory": external_tolerance,
|
|
"basis": (
|
|
"0.2% relative envelope for AMESim pressure/flow/inventory alignment; "
|
|
"conservation retains a 1e-9 absolute floor and discrete modes are exact."
|
|
),
|
|
"exclusions": [
|
|
{
|
|
"requestedTime": 0.04,
|
|
"category": "massFlow",
|
|
"reason": (
|
|
"The Python and AMESim samples at the signal discontinuity may "
|
|
"represent different left/right limits. Raw errors remain recorded."
|
|
),
|
|
}
|
|
],
|
|
},
|
|
"policy": (
|
|
"AMESim simulation values are the authoritative physical baseline and "
|
|
"are compared on every run; unavailable AMESim quantities remain "
|
|
"separate local-invariant checks."
|
|
),
|
|
}
|
|
|
|
|
|
def build_candidate_golden(
|
|
report_path: Path | str,
|
|
*,
|
|
archive_path: Path | str = DEFAULT_AMESIM_ARCHIVE,
|
|
case_id: str = "0.2s",
|
|
lane: str = "production",
|
|
golden_id: str | None = None,
|
|
) -> dict[str, object]:
|
|
"""Build an unapproved candidate from a report containing full v2.1 data."""
|
|
|
|
source_path = Path(report_path).resolve()
|
|
payload = source_path.read_bytes()
|
|
try:
|
|
report = json.loads(payload)
|
|
except json.JSONDecodeError as exc:
|
|
raise PhysicalStateV21Error(f"Regression report is invalid JSON: {exc}") from exc
|
|
if not isinstance(report, Mapping):
|
|
raise PhysicalStateV21Error("Regression report must be an object.")
|
|
contract = _case_contract(report, case_id=case_id, lane=lane)
|
|
keys, _projections = _validate_contract(contract)
|
|
checkpoints = contract["checkpoints"]
|
|
assert isinstance(checkpoints, list)
|
|
times = [float(checkpoint["requestedTime"]) for checkpoint in checkpoints]
|
|
amesim = build_amesim_reference(archive_path, checkpoint_times=times)
|
|
references = amesim["checkpoints"]
|
|
assert isinstance(references, list)
|
|
source = report.get("source")
|
|
source_sha = source.get("sha256") if isinstance(source, Mapping) else None
|
|
if not isinstance(source_sha, str) or len(source_sha) != 64:
|
|
raise PhysicalStateV21Error("Report source SHA-256 is missing.")
|
|
frozen = []
|
|
for checkpoint, reference in zip(checkpoints, references):
|
|
assert isinstance(checkpoint, Mapping) and isinstance(reference, Mapping)
|
|
values = checkpoint["values"]
|
|
reference_values = reference["values"]
|
|
assert isinstance(values, Mapping) and isinstance(reference_values, Mapping)
|
|
frozen.append(
|
|
{
|
|
"requestedTime": checkpoint["requestedTime"],
|
|
"values": [values[key] for key in keys],
|
|
"amesimReferenceValues": [reference_values[key] for key in keys],
|
|
}
|
|
)
|
|
layout = contract["layout"]
|
|
assert isinstance(layout, Mapping)
|
|
alignment = compare_contract_to_amesim(contract, amesim)
|
|
return {
|
|
"schemaVersion": PHYSICAL_STATE_V21_GOLDEN_SCHEMA_VERSION,
|
|
"id": golden_id
|
|
or f"test_mql_8-{lane}-{case_id}-{PHYSICAL_STATE_V21_ID}-candidate",
|
|
"contractId": PHYSICAL_STATE_V21_ID,
|
|
"caseId": case_id,
|
|
"lane": lane,
|
|
"sourceXmlSha256": source_sha,
|
|
"approval": {
|
|
"status": "candidate",
|
|
"generatedAt": datetime.now(UTC).isoformat(),
|
|
"warning": "The correctness gate rejects this file until explicit approval.",
|
|
},
|
|
"provenance": {
|
|
"localSourceReport": {
|
|
"path": _portable_path(source_path),
|
|
"bytes": len(payload),
|
|
"sha256": _sha256(payload),
|
|
"generatedAt": report.get("generatedAt"),
|
|
},
|
|
"amesim": amesim["provenance"],
|
|
"amesimStoredMassDataPaths": amesim["storedMassDataPaths"],
|
|
"comparisonPolicy": (
|
|
"Transformed AMESim values are the authoritative physical baseline; "
|
|
"local Python values are retained only for deterministic and "
|
|
"implementation-regression diagnostics."
|
|
),
|
|
},
|
|
"layout": dict(layout),
|
|
"checkpoints": frozen,
|
|
"amesimAlignmentAtGeneration": alignment,
|
|
}
|
|
|
|
|
|
def _exclusive_json_write(path: Path | str, value: object) -> Path:
|
|
output = Path(path).resolve()
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
serialized = json.dumps(
|
|
value, ensure_ascii=False, indent=2, allow_nan=False
|
|
) + "\n"
|
|
try:
|
|
descriptor = os.open(output, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
|
|
except FileExistsError as exc:
|
|
raise PhysicalStateV21Error(f"Refusing to overwrite {output}.") from exc
|
|
try:
|
|
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
handle.write(serialized)
|
|
except BaseException:
|
|
output.unlink(missing_ok=True)
|
|
raise
|
|
return output
|
|
|
|
|
|
def write_candidate_golden(
|
|
report_path: Path | str,
|
|
output_path: Path | str,
|
|
**kwargs: object,
|
|
) -> Path:
|
|
return _exclusive_json_write(
|
|
output_path,
|
|
build_candidate_golden(report_path, **kwargs),
|
|
)
|
|
|
|
|
|
def _validate_golden(
|
|
golden: Mapping[str, object],
|
|
*,
|
|
allowed_status: str = "approved",
|
|
) -> tuple[list[str], list[Mapping[str, object]]]:
|
|
if golden.get("schemaVersion") != PHYSICAL_STATE_V21_GOLDEN_SCHEMA_VERSION:
|
|
raise PhysicalStateV21Error("Unsupported v2.1 golden schemaVersion.")
|
|
if golden.get("contractId") != PHYSICAL_STATE_V21_ID:
|
|
raise PhysicalStateV21Error("Golden contract id is invalid.")
|
|
for field in ("id", "caseId", "lane"):
|
|
if not isinstance(golden.get(field), str) or not golden[field]:
|
|
raise PhysicalStateV21Error(f"Golden {field} must be non-empty.")
|
|
if not _valid_sha256(golden.get("sourceXmlSha256")):
|
|
raise PhysicalStateV21Error("Golden source XML SHA-256 is invalid.")
|
|
approval = golden.get("approval")
|
|
if not isinstance(approval, Mapping) or approval.get("status") != allowed_status:
|
|
raise PhysicalStateV21Error(
|
|
f"Golden approval.status must be {allowed_status!r}."
|
|
)
|
|
provenance = golden.get("provenance")
|
|
amesim = provenance.get("amesim") if isinstance(provenance, Mapping) else None
|
|
local_report = (
|
|
provenance.get("localSourceReport")
|
|
if isinstance(provenance, Mapping)
|
|
else None
|
|
)
|
|
if (
|
|
not isinstance(local_report, Mapping)
|
|
or not isinstance(local_report.get("path"), str)
|
|
or not local_report["path"]
|
|
or not isinstance(local_report.get("bytes"), int)
|
|
or int(local_report["bytes"]) <= 0
|
|
or not _valid_sha256(local_report.get("sha256"))
|
|
):
|
|
raise PhysicalStateV21Error("Golden local report provenance is invalid.")
|
|
if (
|
|
not isinstance(amesim, Mapping)
|
|
or not isinstance(amesim.get("archivePath"), str)
|
|
or not amesim["archivePath"]
|
|
or not isinstance(amesim.get("archiveBytes"), int)
|
|
or int(amesim["archiveBytes"]) <= 0
|
|
or not _valid_sha256(amesim.get("archiveSha256"))
|
|
):
|
|
raise PhysicalStateV21Error("Golden AMESim archive provenance is invalid.")
|
|
members = amesim.get("members")
|
|
if not isinstance(members, list) or len(members) != 2:
|
|
raise PhysicalStateV21Error("Golden AMESim member provenance is incomplete.")
|
|
member_names = []
|
|
for member in members:
|
|
if (
|
|
not isinstance(member, Mapping)
|
|
or not isinstance(member.get("name"), str)
|
|
or not _valid_sha256(member.get("sha256"))
|
|
or not isinstance(member.get("bytes"), int)
|
|
or int(member["bytes"]) <= 0
|
|
):
|
|
raise PhysicalStateV21Error("Golden AMESim member provenance is invalid.")
|
|
member_names.append(str(member["name"]))
|
|
if not any(name.endswith(".var") for name in member_names) or not any(
|
|
name.endswith(".results") for name in member_names
|
|
):
|
|
raise PhysicalStateV21Error("Golden AMESim provenance lacks .var/.results members.")
|
|
layout = golden.get("layout")
|
|
keys = layout.get("projectionKeys") if isinstance(layout, Mapping) else None
|
|
checkpoints = golden.get("checkpoints")
|
|
if not isinstance(keys, list) or not isinstance(checkpoints, list) or not checkpoints:
|
|
raise PhysicalStateV21Error("Golden layout/checkpoints are incomplete.")
|
|
contract_checkpoints = []
|
|
for checkpoint in checkpoints:
|
|
if not isinstance(checkpoint, Mapping):
|
|
raise PhysicalStateV21Error("Golden checkpoint must be an object.")
|
|
values = checkpoint.get("values")
|
|
references = checkpoint.get("amesimReferenceValues")
|
|
if not isinstance(values, list) or len(values) != len(keys):
|
|
raise PhysicalStateV21Error("Golden values have the wrong layout.")
|
|
if not isinstance(references, list) or len(references) != len(keys):
|
|
raise PhysicalStateV21Error("AMESim references have the wrong layout.")
|
|
for index, value in enumerate(values):
|
|
_finite(value, field=f"golden.values[{index}]")
|
|
for index, value in enumerate(references):
|
|
if value is not None:
|
|
_finite(value, field=f"golden.amesimReferenceValues[{index}]")
|
|
contract_checkpoints.append(
|
|
{
|
|
"requestedTime": checkpoint.get("requestedTime"),
|
|
"actualTime": checkpoint.get("requestedTime"),
|
|
"available": True,
|
|
"values": dict(zip(keys, values)),
|
|
}
|
|
)
|
|
synthetic_contract = {
|
|
"schemaVersion": PHYSICAL_STATE_V21_SCHEMA_VERSION,
|
|
"id": PHYSICAL_STATE_V21_ID,
|
|
"layout": layout,
|
|
"checkpoints": contract_checkpoints,
|
|
}
|
|
return _validate_contract(synthetic_contract)
|
|
|
|
|
|
def _validate_candidate_approval(
|
|
candidate: Mapping[str, object],
|
|
keys: Sequence[str],
|
|
projections: Sequence[Mapping[str, object]],
|
|
) -> None:
|
|
"""Fail closed before promoting an externally aligned local candidate."""
|
|
|
|
alignment = candidate.get("amesimAlignmentAtGeneration")
|
|
if not isinstance(alignment, Mapping) or alignment.get("passed") is not True:
|
|
raise PhysicalStateV21Error(
|
|
"Candidate AMESim alignment at generation must have passed."
|
|
)
|
|
metrics = alignment.get("metrics")
|
|
if not isinstance(metrics, list):
|
|
raise PhysicalStateV21Error("Candidate AMESim alignment metrics are invalid.")
|
|
evaluated_count = 0
|
|
for index, metric in enumerate(metrics):
|
|
if not isinstance(metric, Mapping):
|
|
raise PhysicalStateV21Error(
|
|
f"Candidate AMESim alignment metric {index} is invalid."
|
|
)
|
|
evaluated = metric.get("evaluated")
|
|
if evaluated is True:
|
|
evaluated_count += 1
|
|
if metric.get("passed") is not True:
|
|
raise PhysicalStateV21Error(
|
|
f"Candidate AMESim evaluated metric {index} did not pass."
|
|
)
|
|
elif evaluated is False:
|
|
exclusion_reason = metric.get("exclusionReason")
|
|
if (
|
|
metric.get("passed") is not None
|
|
or not isinstance(exclusion_reason, str)
|
|
or not exclusion_reason.strip()
|
|
):
|
|
raise PhysicalStateV21Error(
|
|
f"Candidate AMESim excluded metric {index} is not auditable."
|
|
)
|
|
else:
|
|
raise PhysicalStateV21Error(
|
|
f"Candidate AMESim alignment metric {index} lacks evaluated status."
|
|
)
|
|
if evaluated_count <= 0 or alignment.get("comparedValueCount") != evaluated_count:
|
|
raise PhysicalStateV21Error(
|
|
"Candidate AMESim comparedValueCount does not match evaluated metrics."
|
|
)
|
|
|
|
residual_indices: list[tuple[int, str, float]] = []
|
|
for index, (key, projection) in enumerate(zip(keys, projections)):
|
|
if (
|
|
projection.get("category") != "conservation"
|
|
or projection.get("unit") != "kg/s"
|
|
):
|
|
continue
|
|
tolerance = projection.get("tolerance")
|
|
assert isinstance(tolerance, Mapping)
|
|
absolute = _finite(
|
|
tolerance.get("absolute"), field=f"{key}.tolerance.absolute"
|
|
)
|
|
residual_indices.append((index, key, absolute))
|
|
checkpoints = candidate.get("checkpoints")
|
|
assert isinstance(checkpoints, list)
|
|
for checkpoint_index, checkpoint in enumerate(checkpoints):
|
|
assert isinstance(checkpoint, Mapping)
|
|
values = checkpoint.get("values")
|
|
assert isinstance(values, list)
|
|
for value_index, key, absolute in residual_indices:
|
|
residual = abs(
|
|
_finite(
|
|
values[value_index],
|
|
field=f"golden.checkpoints[{checkpoint_index}].{key}",
|
|
)
|
|
)
|
|
if residual > absolute:
|
|
raise PhysicalStateV21Error(
|
|
f"Candidate local conservation residual {key} at checkpoint "
|
|
f"{checkpoint_index} is {residual}, exceeding {absolute}."
|
|
)
|
|
|
|
|
|
def approve_candidate_golden(
|
|
candidate_path: Path | str,
|
|
output_path: Path | str,
|
|
*,
|
|
expected_candidate_sha256: str,
|
|
reviewed_by: str,
|
|
note: str,
|
|
) -> Path:
|
|
"""Approve an exact candidate hash into a new file, never in place."""
|
|
|
|
path = Path(candidate_path).resolve()
|
|
payload = path.read_bytes()
|
|
actual_sha = _sha256(payload)
|
|
if actual_sha != expected_candidate_sha256:
|
|
raise PhysicalStateV21Error(
|
|
f"Candidate SHA mismatch: expected {expected_candidate_sha256}, got {actual_sha}."
|
|
)
|
|
candidate = json.loads(payload)
|
|
if not isinstance(candidate, dict):
|
|
raise PhysicalStateV21Error("Candidate must be an object.")
|
|
keys, projections = _validate_golden(candidate, allowed_status="candidate")
|
|
_validate_candidate_approval(candidate, keys, projections)
|
|
if not reviewed_by.strip() or not note.strip():
|
|
raise PhysicalStateV21Error("Approval requires reviewer and note.")
|
|
approved = dict(candidate)
|
|
approved["approval"] = {
|
|
"status": "approved",
|
|
"approvedAt": datetime.now(UTC).isoformat(),
|
|
"reviewedBy": reviewed_by.strip(),
|
|
"note": note.strip(),
|
|
"candidatePath": _portable_path(path),
|
|
"candidateSha256": actual_sha,
|
|
}
|
|
return _exclusive_json_write(output_path, approved)
|
|
|
|
|
|
def load_approved_golden(path: Path | str) -> dict[str, object]:
|
|
golden_path = Path(path).resolve()
|
|
payload = golden_path.read_bytes()
|
|
try:
|
|
golden = json.loads(payload)
|
|
except json.JSONDecodeError as exc:
|
|
raise PhysicalStateV21Error(f"Golden is invalid JSON: {exc}") from exc
|
|
if not isinstance(golden, dict):
|
|
raise PhysicalStateV21Error("Golden must be an object.")
|
|
_validate_golden(golden)
|
|
golden["_path"] = str(golden_path)
|
|
golden["_sha256"] = _sha256(payload)
|
|
return golden
|
|
|
|
|
|
def evaluate_physical_state_v21(
|
|
contract: Mapping[str, object],
|
|
golden: Mapping[str, object],
|
|
) -> dict[str, object]:
|
|
"""Evaluate a v2.1 contract against frozen AMESim reference values.
|
|
|
|
The approved artifact pins AMESim provenance, layout, checkpoint times, and
|
|
transformed AMESim values. Its frozen Python values are intentionally not
|
|
used as the physical baseline.
|
|
"""
|
|
|
|
keys, projections = _validate_contract(contract)
|
|
golden_keys, _ = _validate_golden(golden)
|
|
if keys != golden_keys:
|
|
return {
|
|
"passed": False,
|
|
"issues": ["physicalStateV21LayoutMismatch"],
|
|
"comparedValueCount": 0,
|
|
}
|
|
actual_checkpoints = contract["checkpoints"]
|
|
expected_checkpoints = golden["checkpoints"]
|
|
assert isinstance(actual_checkpoints, list) and isinstance(expected_checkpoints, list)
|
|
if len(actual_checkpoints) != len(expected_checkpoints):
|
|
return {
|
|
"passed": False,
|
|
"issues": ["physicalStateV21CheckpointCountMismatch"],
|
|
"comparedValueCount": 0,
|
|
}
|
|
issues: list[str] = []
|
|
reference_checkpoints: list[dict[str, object]] = []
|
|
invariant_metrics: list[dict[str, object]] = []
|
|
invariant_max_ratio = 0.0
|
|
invariant_worst: dict[str, object] | None = None
|
|
for actual_checkpoint, expected_checkpoint in zip(actual_checkpoints, expected_checkpoints):
|
|
assert isinstance(actual_checkpoint, Mapping) and isinstance(expected_checkpoint, Mapping)
|
|
requested = float(expected_checkpoint["requestedTime"])
|
|
if not math.isclose(
|
|
float(actual_checkpoint["requestedTime"]),
|
|
requested,
|
|
rel_tol=0.0,
|
|
abs_tol=1.0e-12,
|
|
) or not math.isclose(
|
|
float(actual_checkpoint["actualTime"]),
|
|
requested,
|
|
rel_tol=0.0,
|
|
abs_tol=1.0e-12,
|
|
):
|
|
issues.append("physicalStateV21CheckpointTimeMismatch")
|
|
actual_values = actual_checkpoint["values"]
|
|
amesim_values = expected_checkpoint["amesimReferenceValues"]
|
|
assert isinstance(actual_values, Mapping) and isinstance(amesim_values, list)
|
|
reference_values = dict(zip(keys, amesim_values))
|
|
reference_checkpoints.append(
|
|
{"requestedTime": requested, "values": reference_values}
|
|
)
|
|
for key, projection, amesim_baseline in zip(
|
|
keys, projections, amesim_values
|
|
):
|
|
if amesim_baseline is not None:
|
|
continue
|
|
actual = float(actual_values[key])
|
|
tolerance = projection["tolerance"]
|
|
assert isinstance(tolerance, Mapping)
|
|
allowed = float(tolerance["absolute"])
|
|
error = abs(actual)
|
|
ratio = error / allowed if allowed > 0.0 else (0.0 if error == 0.0 else math.inf)
|
|
metric = {
|
|
"requestedTime": requested,
|
|
"key": key,
|
|
"category": projection["category"],
|
|
"actual": actual,
|
|
"amesimBaseline": None,
|
|
"invariantTarget": 0.0,
|
|
"absoluteError": error,
|
|
"relativeError": None,
|
|
"relativeErrorPercent": None,
|
|
"relativeErrorDefined": False,
|
|
"relativeErrorReason": "amesimBaselineUnavailable",
|
|
"allowedError": allowed,
|
|
"toleranceRatio": ratio if math.isfinite(ratio) else None,
|
|
"unboundedToleranceRatio": not math.isfinite(ratio),
|
|
"comparisonBasis": "localInvariantAbsoluteTolerance",
|
|
"passed": ratio <= 1.0,
|
|
}
|
|
invariant_metrics.append(metric)
|
|
if ratio > invariant_max_ratio:
|
|
invariant_max_ratio = ratio
|
|
invariant_worst = metric
|
|
if ratio > 1.0:
|
|
issues.append("physicalStateV21LocalInvariantMismatch")
|
|
comparison = compare_contract_to_amesim(
|
|
contract,
|
|
{"schemaVersion": 1, "checkpoints": reference_checkpoints},
|
|
)
|
|
raw_comparison_issues = comparison.get("issues")
|
|
if isinstance(raw_comparison_issues, list):
|
|
issues.extend(
|
|
str(issue)
|
|
for issue in raw_comparison_issues
|
|
if isinstance(issue, str)
|
|
)
|
|
deduplicated = list(dict.fromkeys(issues))
|
|
provenance = golden.get("provenance")
|
|
amesim_provenance = (
|
|
provenance.get("amesim") if isinstance(provenance, Mapping) else None
|
|
)
|
|
result = dict(comparison)
|
|
result.update({
|
|
"passed": not deduplicated,
|
|
"issues": deduplicated,
|
|
"baselineAuthority": "amesim",
|
|
"baselineSource": "approvedArtifact.amesimReferenceValues",
|
|
"amesimProvenance": (
|
|
dict(amesim_provenance)
|
|
if isinstance(amesim_provenance, Mapping)
|
|
else None
|
|
),
|
|
"pythonSnapshotRole": "determinismDiagnosticOnly",
|
|
"localInvariants": {
|
|
"passed": not any(
|
|
metric["passed"] is False for metric in invariant_metrics
|
|
),
|
|
"metricCount": len(invariant_metrics),
|
|
"maxToleranceRatio": (
|
|
invariant_max_ratio
|
|
if invariant_metrics and math.isfinite(invariant_max_ratio)
|
|
else None
|
|
),
|
|
"maxToleranceRatioUnbounded": bool(
|
|
invariant_metrics and not math.isfinite(invariant_max_ratio)
|
|
),
|
|
"worstValue": invariant_worst,
|
|
"metrics": invariant_metrics,
|
|
},
|
|
"goldenPath": golden.get("_path"),
|
|
"goldenSha256": golden.get("_sha256"),
|
|
})
|
|
return result
|
|
|
|
|
|
def assess_report_against_amesim(
|
|
report_path: Path | str,
|
|
*,
|
|
archive_path: Path | str = DEFAULT_AMESIM_ARCHIVE,
|
|
case_id: str = "0.2s",
|
|
lane: str = "production",
|
|
) -> dict[str, object]:
|
|
"""Assess a full or pre-v2.1 report and expose every comparable error."""
|
|
|
|
path = Path(report_path).resolve()
|
|
payload = path.read_bytes()
|
|
report = json.loads(payload)
|
|
if not isinstance(report, Mapping):
|
|
raise PhysicalStateV21Error("Report must be an object.")
|
|
summary = _case_summary(report, case_id=case_id, lane=lane)
|
|
if isinstance(summary.get("physicalStateV21"), Mapping):
|
|
contract = summary["physicalStateV21"]
|
|
coverage = "fullCheckpoints"
|
|
else:
|
|
contract = project_report_endpoint_v21(report, case_id=case_id, lane=lane)
|
|
coverage = "legacyEndpointOnly"
|
|
assert isinstance(contract, Mapping)
|
|
checkpoints = contract["checkpoints"]
|
|
assert isinstance(checkpoints, list)
|
|
times = [float(checkpoint["requestedTime"]) for checkpoint in checkpoints]
|
|
reference = build_amesim_reference(archive_path, checkpoint_times=times)
|
|
comparison = compare_contract_to_amesim(contract, reference)
|
|
return {
|
|
"schemaVersion": 1,
|
|
"reportPath": str(path),
|
|
"reportSha256": _sha256(payload),
|
|
"caseId": case_id,
|
|
"lane": lane,
|
|
"coverage": coverage,
|
|
"amesimProvenance": reference["provenance"],
|
|
"comparison": comparison,
|
|
}
|
|
|
|
|
|
def _parse_arguments(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Physical-state-v2.1 local gate.")
|
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
assess = subparsers.add_parser("assess-amesim")
|
|
assess.add_argument("--report", type=Path, required=True)
|
|
assess.add_argument("--amesim-archive", type=Path, default=DEFAULT_AMESIM_ARCHIVE)
|
|
assess.add_argument("--case", default="0.2s")
|
|
assess.add_argument("--lane", default="production")
|
|
assess.add_argument("--output", type=Path)
|
|
|
|
candidate = subparsers.add_parser("candidate")
|
|
candidate.add_argument("--report", type=Path, required=True)
|
|
candidate.add_argument("--amesim-archive", type=Path, default=DEFAULT_AMESIM_ARCHIVE)
|
|
candidate.add_argument("--case", default="0.2s")
|
|
candidate.add_argument("--lane", default="production")
|
|
candidate.add_argument("--id")
|
|
candidate.add_argument("--output", type=Path, required=True)
|
|
|
|
approve = subparsers.add_parser("approve")
|
|
approve.add_argument("--candidate", type=Path, required=True)
|
|
approve.add_argument("--expected-candidate-sha256", required=True)
|
|
approve.add_argument("--reviewed-by", required=True)
|
|
approve.add_argument("--note", required=True)
|
|
approve.add_argument("--output", type=Path, required=True)
|
|
|
|
check = subparsers.add_parser("check")
|
|
check.add_argument("--report", type=Path, required=True)
|
|
check.add_argument("--golden", type=Path, required=True)
|
|
check.add_argument("--case", default="0.2s")
|
|
check.add_argument("--lane", default="production")
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
arguments = _parse_arguments(argv)
|
|
if arguments.command == "assess-amesim":
|
|
assessment = assess_report_against_amesim(
|
|
arguments.report,
|
|
archive_path=arguments.amesim_archive,
|
|
case_id=arguments.case,
|
|
lane=arguments.lane,
|
|
)
|
|
serialized = json.dumps(
|
|
assessment, ensure_ascii=False, indent=2, allow_nan=False
|
|
) + "\n"
|
|
if arguments.output is None:
|
|
print(serialized, end="")
|
|
else:
|
|
_exclusive_json_write(arguments.output, assessment)
|
|
print(f"Assessment written: {arguments.output.resolve()}")
|
|
return 0 if assessment["comparison"]["passed"] else 1
|
|
if arguments.command == "candidate":
|
|
output = write_candidate_golden(
|
|
arguments.report, arguments.output,
|
|
archive_path=arguments.amesim_archive,
|
|
case_id=arguments.case, lane=arguments.lane, golden_id=arguments.id,
|
|
)
|
|
print(f"Candidate written without approval: {output}")
|
|
print(f"sha256={_sha256(output.read_bytes())}")
|
|
return 0
|
|
if arguments.command == "approve":
|
|
output = approve_candidate_golden(
|
|
arguments.candidate, arguments.output,
|
|
expected_candidate_sha256=arguments.expected_candidate_sha256,
|
|
reviewed_by=arguments.reviewed_by, note=arguments.note,
|
|
)
|
|
print(f"Approved golden written: {output}")
|
|
print(f"sha256={_sha256(output.read_bytes())}")
|
|
return 0
|
|
report = json.loads(arguments.report.read_bytes())
|
|
contract = _case_contract(report, case_id=arguments.case, lane=arguments.lane)
|
|
evaluation = evaluate_physical_state_v21(contract, load_approved_golden(arguments.golden))
|
|
print(json.dumps(evaluation, ensure_ascii=False, indent=2, allow_nan=False))
|
|
return 0 if evaluation["passed"] else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|