Files
SystemSimulationApp/tests/test_physical_state_v21.py
T
lujingze a8c733883c 存档求解器回归基线与当前改动
纳管 AMESim 对齐基线、发布锁、回归测试及当前物理门禁调整。

更新日志仅记录已完成成果,并注明当前 HEAD 尚待真实 production 复跑与远端 workflow 验证。
2026-08-18 15:20:42 +00:00

488 lines
21 KiB
Python

from __future__ import annotations
import copy
import hashlib
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from app.simulation.benchmark_regression import summarize_simulation_result
from app.simulation.physical_state_v21 import (
PHYSICAL_STATE_V21_ID,
PhysicalStateV21Error,
approve_candidate_golden,
assess_report_against_amesim,
build_amesim_reference,
build_candidate_golden,
compare_contract_to_amesim,
evaluate_physical_state_v21,
load_approved_golden,
project_physical_state_v21,
write_candidate_golden,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
AMESIM_ARCHIVE = REPO_ROOT / "AmesimModels" / "test_mql.ame"
CANDIDATE_REPORT = (
REPO_ROOT
/ "tests/baselines/simulation/test_mql_8/runs"
/ "2026-08-18-production-amesim-aligned-v1-candidate-0.2.json"
)
def _variable(key: str, quantity: str, unit: str, category: str) -> dict[str, str]:
return {"key": key, "quantity": quantity, "unit": unit, "category": category}
def _synthetic_result() -> dict[str, object]:
times = [0.0, 0.04, 0.2]
variables: list[dict[str, str]] = []
series: dict[str, list[float]] = {"time": times}
def add(
key: str,
values: list[float],
quantity: str,
unit: str,
category: str,
) -> None:
variables.append(_variable(key, quantity, unit, category))
series[key] = values
add("amesim_pnch012_8.p", [100_000.0, 200_000.0, 300_000.0], "pressure", "Pa", "thermodynamic")
add("amesim_pnl0001_20.p", [100_000.0, 210_000.0, 310_000.0], "pressure", "Pa", "thermodynamic")
add("amesim_pnl0001_20.port_1.m_flow", [-0.1, -0.2, -0.3], "mass_flow", "kg/s", "port")
add("amesim_pnch012_8.port_1.m_flow", [0.1, 0.2, 0.3], "mass_flow", "kg/s", "port")
add("amesim_pnvo001_5.port_2.m_flow", [-0.2, -0.3, -0.4], "mass_flow", "kg/s", "port")
add("amesim_pnvo001_5.port_3.m_flow", [0.2, 0.3, 0.4], "mass_flow", "kg/s", "port")
for port, values in enumerate(
([0.4, 0.5, 0.6], [-0.1, -0.2, -0.3], [-0.2, -0.2, -0.2], [-0.1, -0.1, -0.1]),
start=1,
):
add(f"amesim_p4node2_8.port_{port}.m_flow", list(values), "mass_flow", "kg/s", "port")
add("amesim_pnvo001_5.xv", [0.0, 1.0, 1.0], "dimensionless", "", "derived")
add("amesim_mecmas21_9.x", [0.0, 0.0, 0.0], "length", "m", "state")
add("amesim_mecmas21_9.v", [0.0, 0.0, 0.0], "velocity", "m/s", "state")
add("amesim_mecmas21_9.port_1.f", [1.0, 1.0, 1.0], "force", "N", "port")
add("amesim_mecmas21_9.port_2.f", [2.0, 2.0, 2.0], "force", "N", "port")
add("amesim_mecmas21_10.x", [0.0, 0.0, 0.0], "length", "m", "state")
add("amesim_mecmas21_10.v", [0.0, 0.0, 0.0], "velocity", "m/s", "state")
add("amesim_mecmas21_10.port_1.f", [-2.0, -2.0, -2.0], "force", "N", "port")
add("amesim_mecmas21_10.port_2.f", [-1.0, -1.0, -1.0], "force", "N", "port")
add("storage_a.m", [1.0, 2.0, 3.0], "mass", "kg", "state")
add("storage_b.m", [4.0, 3.0, 2.0], "mass", "kg", "state")
return {
"success": True,
"status": "completed",
"partial": False,
"simulatedUntil": 0.2,
"requestedStopTime": 0.2,
"variables": variables,
"series": series,
"final": {key: values[-1] for key, values in series.items() if key != "time"},
"diagnostics": {},
}
class PhysicalStateProjectionTests(unittest.TestCase):
def test_projects_all_categories_with_units_and_strict_balances(self) -> None:
contract = project_physical_state_v21(
_synthetic_result(), checkpoint_times=(0.0, 0.04, 0.2), sample_step=0.04
)
self.assertEqual(contract["id"], PHYSICAL_STATE_V21_ID)
self.assertEqual(
contract["projectionCategories"],
["pressure", "massFlow", "conservation", "discreteMode"],
)
self.assertEqual(len(contract["layout"]["projectionKeys"]), 11)
endpoint = contract["checkpoints"][-1]["values"]
self.assertEqual(endpoint["conservation.totalStoredGasMass"], 5.0)
self.assertAlmostEqual(endpoint["conservation.p4node2_8.massBalance"], 0.0)
self.assertAlmostEqual(endpoint["conservation.pnvo001_5.massBalance"], 0.0)
self.assertAlmostEqual(
endpoint["conservation.pnl0001_20_pnch012_8.connectionMassBalance"], 0.0
)
self.assertEqual(endpoint["discrete.pnvo001_5.openMode"], 1.0)
self.assertEqual(endpoint["discrete.mecmas21_9.endstopMode"], 1.0)
self.assertEqual(endpoint["discrete.mecmas21_10.endstopMode"], -1.0)
pressure = contract["layout"]["projections"][0]["amesim"]
self.assertEqual(pressure["offset"], 101_300.0)
flow = contract["layout"]["projections"][2]["amesim"]
self.assertEqual(flow["scale"], -1.0e-3)
def test_fails_closed_on_wrong_source_unit(self) -> None:
result = _synthetic_result()
next(
variable
for variable in result["variables"]
if variable["key"] == "amesim_pnch012_8.p"
)["unit"] = "bar"
with self.assertRaisesRegex(PhysicalStateV21Error, "Unexpected metadata"):
project_physical_state_v21(result, checkpoint_times=(0.2,), sample_step=0.2)
def test_benchmark_summary_attaches_v21_only_to_matching_result(self) -> None:
summary = summarize_simulation_result(
_synthetic_result(), checkpoint_times=(0.0, 0.2), sample_step=0.2
)
self.assertEqual(summary["physicalStateV21"]["id"], PHYSICAL_STATE_V21_ID)
class AmesimReferenceTests(unittest.TestCase):
def test_reference_records_archive_hash_units_signs_and_known_endpoint(self) -> None:
reference = build_amesim_reference(AMESIM_ARCHIVE, checkpoint_times=(0.2,))
values = reference["checkpoints"][0]["values"]
self.assertAlmostEqual(values["pressure.pnch012_8.absolute"], 3_625_053.2009413484)
self.assertAlmostEqual(
values["massFlow.pnl0001_20.port_1.intoComponent"],
-0.3728806429214546,
)
self.assertEqual(values["discrete.mecmas21_9.endstopMode"], 1.0)
self.assertEqual(values["discrete.mecmas21_10.endstopMode"], -1.0)
provenance = reference["provenance"]
self.assertEqual(provenance["archivePath"], "AmesimModels/test_mql.ame")
self.assertEqual(len(provenance["archiveSha256"]), 64)
self.assertEqual(
[member["name"] for member in provenance["members"]],
["test_mql_.var", "test_mql_.results"],
)
def test_current_candidate_endpoint_passes_explicit_amesim_alignment_profile(self) -> None:
assessment = assess_report_against_amesim(
CANDIDATE_REPORT, archive_path=AMESIM_ARCHIVE
)
comparison = assessment["comparison"]
self.assertEqual(assessment["coverage"], "legacyEndpointOnly")
self.assertTrue(comparison["passed"])
self.assertEqual(comparison["comparedValueCount"], 9)
self.assertEqual(comparison["metricCount"], 11)
self.assertEqual(comparison["availableBaselineValueCount"], 9)
self.assertEqual(comparison["unavailableBaselineValueCount"], 2)
self.assertEqual(len(comparison["metrics"]), 11)
self.assertTrue(
all(
{
"requestedTime",
"key",
"category",
"actual",
"amesimBaseline",
"absoluteError",
"relativeError",
"evaluated",
"passed",
}
<= set(metric)
for metric in comparison["metrics"]
)
)
self.assertEqual(
comparison["worstValue"]["key"],
"massFlow.pnl0001_20.port_1.intoComponent",
)
self.assertAlmostEqual(
comparison["worstValue"]["relativeError"],
0.0013934852135053246,
)
self.assertEqual(
comparison["toleranceProfile"]["byCategory"]["discreteMode"]["relative"],
0.0,
)
def test_signal_jump_mass_flows_record_errors_but_are_not_evaluated(self) -> None:
contract = project_physical_state_v21(
_synthetic_result(), checkpoint_times=(0.0, 0.04, 0.2), sample_step=0.04
)
reference_checkpoints = []
for checkpoint in contract["checkpoints"]:
values = dict(checkpoint["values"])
values["conservation.p4node2_8.massBalance"] = None
values[
"conservation.pnl0001_20_pnch012_8.connectionMassBalance"
] = None
if checkpoint["requestedTime"] == 0.04:
values["massFlow.pnl0001_20.port_1.intoComponent"] += 100.0
values["massFlow.pnvo001_5.port_2.intoComponent"] -= 100.0
reference_checkpoints.append(
{"requestedTime": checkpoint["requestedTime"], "values": values}
)
comparison = compare_contract_to_amesim(
contract, {"checkpoints": reference_checkpoints}
)
excluded = [
metric
for metric in comparison["metrics"]
if metric["requestedTime"] == 0.04 and metric["category"] == "massFlow"
]
self.assertTrue(comparison["passed"])
self.assertEqual(comparison["metricCount"], 33)
self.assertEqual(comparison["availableBaselineValueCount"], 27)
self.assertEqual(comparison["unavailableBaselineValueCount"], 6)
self.assertEqual(comparison["comparedValueCount"], 25)
self.assertEqual(comparison["excludedValueCount"], 2)
self.assertEqual(len(comparison["metrics"]), 33)
self.assertEqual(len(excluded), 2)
self.assertTrue(all(metric["evaluated"] is False for metric in excluded))
self.assertTrue(all(metric["passed"] is None for metric in excluded))
self.assertTrue(all(metric["absoluteError"] > 99.0 for metric in excluded))
self.assertTrue(
all("LeftRightLimit" in metric["exclusionReason"] for metric in excluded)
)
self.assertNotIn(comparison["worstValue"], excluded)
unavailable = [
metric
for metric in comparison["metrics"]
if metric["amesimBaseline"] is None
]
self.assertEqual(len(unavailable), 6)
self.assertTrue(all(metric["evaluated"] is False for metric in unavailable))
self.assertTrue(all(metric["absoluteError"] is None for metric in unavailable))
self.assertTrue(all(metric["relativeError"] is None for metric in unavailable))
def test_relative_error_uses_the_amesim_baseline_denominator(self) -> None:
contract = project_physical_state_v21(
_synthetic_result(), checkpoint_times=(0.2,), sample_step=0.2
)
values = dict(contract["checkpoints"][0]["values"])
key = "pressure.pnch012_8.absolute"
values[key] = 0.5 * values[key]
comparison = compare_contract_to_amesim(
contract,
{"checkpoints": [{"requestedTime": 0.2, "values": values}]},
)
metric = next(item for item in comparison["metrics"] if item["key"] == key)
expected = abs(metric["actual"] - metric["amesimBaseline"]) / abs(
metric["amesimBaseline"]
)
self.assertAlmostEqual(metric["relativeError"], expected)
self.assertAlmostEqual(metric["relativeErrorPercent"], 100.0 * expected)
self.assertTrue(metric["relativeErrorDefined"])
def test_zero_amesim_baseline_has_undefined_relative_error(self) -> None:
contract = project_physical_state_v21(
_synthetic_result(), checkpoint_times=(0.2,), sample_step=0.2
)
values = dict(contract["checkpoints"][0]["values"])
key = "massFlow.pnl0001_20.port_1.intoComponent"
values[key] = -0.0
comparison = compare_contract_to_amesim(
contract,
{"checkpoints": [{"requestedTime": 0.2, "values": values}]},
)
metric = next(item for item in comparison["metrics"] if item["key"] == key)
self.assertIsNone(metric["relativeError"])
self.assertIsNone(metric["relativeErrorPercent"])
self.assertFalse(metric["relativeErrorDefined"])
self.assertEqual(metric["relativeErrorReason"], "zeroAmesimBaseline")
self.assertEqual(metric["comparisonBasis"], "absoluteNearZero")
self.assertEqual(
json.loads(json.dumps(comparison, allow_nan=False))["baselineAuthority"],
"amesim",
)
class ReviewedGoldenProtocolTests(unittest.TestCase):
def _report_and_reference(self):
contract = project_physical_state_v21(
_synthetic_result(), checkpoint_times=(0.0, 0.04, 0.2), sample_step=0.04
)
report = {
"generatedAt": "2026-08-18T00:00:00+00:00",
"source": {"sha256": "a" * 64},
"cases": [{
"caseId": "0.2s",
"lane": "production",
"worker": {"summary": {"physicalStateV21": contract}},
}],
}
keys = contract["layout"]["projectionKeys"]
reference_checkpoints = []
for checkpoint in contract["checkpoints"]:
reference_checkpoints.append({
"requestedTime": checkpoint["requestedTime"],
"values": {key: checkpoint["values"][key] for key in keys},
})
reference = {
"schemaVersion": 1,
"provenance": {
"archivePath": "AmesimModels/synthetic.ame",
"archiveBytes": 2,
"archiveSha256": "b" * 64,
"members": [
{"name": "synthetic.var", "bytes": 1, "sha256": "c" * 64},
{
"name": "synthetic.results",
"bytes": 1,
"sha256": "d" * 64,
},
],
},
"storedMassDataPaths": ["mgas@synthetic"],
"checkpoints": reference_checkpoints,
}
return contract, report, reference
def test_candidate_requires_exact_hash_and_is_approved_to_a_new_file(self) -> None:
contract, report, reference = self._report_and_reference()
with tempfile.TemporaryDirectory(
dir=REPO_ROOT, prefix=".physical-state-v21-test-"
) as temporary_directory:
root = Path(temporary_directory)
report_path = root / "report.json"
report_path.write_text(json.dumps(report), encoding="utf-8")
candidate_path = root / "candidate.json"
approved_path = root / "approved.json"
with patch(
"app.simulation.physical_state_v21.build_amesim_reference",
return_value=reference,
):
write_candidate_golden(report_path, candidate_path)
candidate = json.loads(candidate_path.read_text(encoding="utf-8"))
local_report = candidate["provenance"]["localSourceReport"]
self.assertFalse(Path(local_report["path"]).is_absolute())
self.assertEqual(local_report["bytes"], report_path.stat().st_size)
candidate_sha = hashlib.sha256(candidate_path.read_bytes()).hexdigest()
with self.assertRaisesRegex(PhysicalStateV21Error, "SHA mismatch"):
approve_candidate_golden(
candidate_path,
approved_path,
expected_candidate_sha256="0" * 64,
reviewed_by="reviewer",
note="reviewed",
)
approve_candidate_golden(
candidate_path,
approved_path,
expected_candidate_sha256=candidate_sha,
reviewed_by="reviewer",
note="units, signs, and errors reviewed",
)
golden = load_approved_golden(approved_path)
self.assertFalse(Path(golden["approval"]["candidatePath"]).is_absolute())
golden["checkpoints"][-1]["values"][0] += 1.0e9
self.assertTrue(evaluate_physical_state_v21(contract, golden)["passed"])
changed = copy.deepcopy(contract)
changed["checkpoints"][-1]["values"]["pressure.pnch012_8.absolute"] += 1000.0
failure = evaluate_physical_state_v21(changed, golden)
self.assertFalse(failure["passed"])
self.assertIn("amesimReferenceToleranceMismatch", failure["issues"])
def test_approval_rejects_failed_or_tampered_alignment(self) -> None:
_contract, report, reference = self._report_and_reference()
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
report_path = root / "report.json"
report_path.write_text(json.dumps(report), encoding="utf-8")
original_path = root / "candidate.json"
with patch(
"app.simulation.physical_state_v21.build_amesim_reference",
return_value=reference,
):
write_candidate_golden(report_path, original_path)
original = json.loads(original_path.read_text(encoding="utf-8"))
failed_alignment = copy.deepcopy(original)
failed_alignment["amesimAlignmentAtGeneration"]["passed"] = False
failed_path = root / "failed-alignment.json"
failed_path.write_text(json.dumps(failed_alignment), encoding="utf-8")
with self.assertRaisesRegex(
PhysicalStateV21Error, "AMESim alignment at generation"
):
approve_candidate_golden(
failed_path,
root / "failed-approved.json",
expected_candidate_sha256=hashlib.sha256(
failed_path.read_bytes()
).hexdigest(),
reviewed_by="reviewer",
note="must not override a failed alignment",
)
failed_metric = copy.deepcopy(original)
evaluated_metric = next(
metric
for metric in failed_metric["amesimAlignmentAtGeneration"]["metrics"]
if metric["evaluated"]
)
evaluated_metric["passed"] = False
failed_metric_path = root / "failed-metric.json"
failed_metric_path.write_text(json.dumps(failed_metric), encoding="utf-8")
with self.assertRaisesRegex(PhysicalStateV21Error, "evaluated metric"):
approve_candidate_golden(
failed_metric_path,
root / "failed-metric-approved.json",
expected_candidate_sha256=hashlib.sha256(
failed_metric_path.read_bytes()
).hexdigest(),
reviewed_by="reviewer",
note="must not override a failed metric",
)
def test_approval_rejects_local_conservation_residual(self) -> None:
_contract, report, reference = self._report_and_reference()
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
report_path = root / "report.json"
report_path.write_text(json.dumps(report), encoding="utf-8")
original_path = root / "candidate.json"
with patch(
"app.simulation.physical_state_v21.build_amesim_reference",
return_value=reference,
):
write_candidate_golden(report_path, original_path)
candidate = json.loads(original_path.read_text(encoding="utf-8"))
key = "conservation.p4node2_8.massBalance"
value_index = candidate["layout"]["projectionKeys"].index(key)
candidate["checkpoints"][1]["values"][value_index] = 2.0e-9
tampered_path = root / "conservation-residual.json"
tampered_path.write_text(json.dumps(candidate), encoding="utf-8")
with self.assertRaisesRegex(
PhysicalStateV21Error, "local conservation residual"
):
approve_candidate_golden(
tampered_path,
root / "residual-approved.json",
expected_candidate_sha256=hashlib.sha256(
tampered_path.read_bytes()
).hexdigest(),
reviewed_by="reviewer",
note="must not override a local conservation failure",
)
def test_candidate_writer_refuses_overwrite_and_unapproved_file_is_rejected(self) -> None:
_contract, report, reference = self._report_and_reference()
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
report_path = root / "report.json"
report_path.write_text(json.dumps(report), encoding="utf-8")
output = root / "candidate.json"
with patch(
"app.simulation.physical_state_v21.build_amesim_reference",
return_value=reference,
):
write_candidate_golden(report_path, output)
with self.assertRaisesRegex(PhysicalStateV21Error, "overwrite"):
write_candidate_golden(report_path, output)
with self.assertRaisesRegex(PhysicalStateV21Error, "approval.status"):
load_approved_golden(output)
if __name__ == "__main__":
unittest.main()