feat(verification): compare Hex8 original result identities and quantities

This commit is contained in:
KOKO\Mimi
2026-10-10 02:02:36 +09:00
parent 1eb9cddb82
commit 317fe5caf6
4 changed files with 1663 additions and 2 deletions
@@ -32,8 +32,9 @@ Frequency는 original unstressed 상태의10 modes 요청이며 결과 Value는
## Quantity와 identity
Static U1/U2/U3 global, RF1/RF2/RF3의 constrained projection, integration-point Cauchy
S11/S22/S33/S12/S13/S23 각각 blocking family다. U/RF는442-row 전체 inventory를 먼저 확인하고,
Static은 U1/U2/U3의 U-global, RF1/RF2/RF3 constrained projection의 RF-global,
integration-point Cauchy S11/S22/S33/S12/S13/S23의 S-global을 각각 독립 blocking family로 비교한다.
U/RF는442-row 전체 inventory를 먼저 확인하고,
RF만 실제 constrained components로 투영한다. Free Abaqus RF와 FESA free residual을 비교하지 않는다.
Stress는300×8×6 전부 포함하며 elemental-nodal stress·절점 외삽·평균으로 변환하지 않는다.
Dynamic U는101 frame 각각442×3인 독립 blocking family다. Frequency Value는 mode별 singleton이며
@@ -0,0 +1,137 @@
# Hex8 original comparison evidence
This page records T8's comparison-only Python implementation and its observed
results. It does not replace the feature PRD, numerical controls or completion
checklist. The comparator reads official rounded Float64 quantities; it does not
recompute the internal high+low solver acceptance gates from those observables.
## Entry point and identities
`uv run --with h5py --with pytest python scripts/verification/compare_hex8.py`
requires `--case`, the actual scratch `--input`, official `--results`, and
`--report`. `--references` defaults to `reference/hexa`. The optional
`--execution-record` consumes a closed native CLI record; it never executes its
command. A supplied record must have native exit0, unchanged original/copy byte
identities, and the exact input and result hashes. Without that record the report
explicitly certifies file comparison only, not successful solver execution.
For example, after a real original run has naturally completed:
```powershell
uv run --with h5py --with pytest python scripts/verification/compare_hex8.py `
--case hexa_linearstatic --input <scratch>/hexa_linearstatic.inp `
--results <scratch>/hexa_linearstatic_results.h5 `
--execution-record <scratch>/native.json --report <evidence>/comparison.json
```
The report path may not overwrite an input, reference, execution record or result.
Authoritative repository originals remain protected even with alternate copied
references; existing symlink/junction and hardlink aliases are also checked.
Exit0 means the declared comparison passes; exit1 means a numerical family fails;
exit2 means an identity/schema/finite/precondition error or missing modal reference.
The tool is not a solver launcher, result generator, reference repair or release
readiness check.
The fixed source inventory is the [reference-model](reference-model.md) inventory:
442 nodes, 300 C3D8 elements and 13 frozen original files. Input and CSV raw SHA256
must match the documented Windows working-tree bytes. The HDF5 metadata must
identify the actual lexically normalized absolute scratch path and that file's FNV1a64 content
identity; a different path with identical bytes is not silently accepted.
Complete CSV identities are checked before any physical values are converted.
Complete required Solid model/frame/physical schemas, UTF-8 attributes and explicit
LE F64/U64/U8 widths are then checked. Result arrays join the source node or
source element/point labels through their internal ID maps; CSV row order is not
an identity. Physical values, including nonprojected RF rows and unused exported
CSV derived quantities, must be finite before tolerance evaluation.
Static reference increment10 maps to the completed load-factor1 frame, independent
of its HDF5 storage frame index. RF validates all442 source rows before projecting
the actual51 constrained scalar components. It never compares free RF with free
residual. Stress compares all300×8×6 unaveraged global Cauchy components. Fixed
IntPt1–8 conventions are checked without nearest-coordinate remapping. Point
coordinates are auxiliary: CSV coordinate errors use the approved
`16*float32-eps*sum(abs(Na*Xa_axis))` source-resolution budget; the scalar stress
policy is unchanged.
Dynamic comparisons require exactly101 accepted frames. Integer increment0–100
is matched first, source decimal time must equal n/10, and each stored time must
be binary64 `n*0.1`. Each frame's1326 U scalars form a separate blocking family.
There is no interpolation, nearest-time matching, scale sharing between frames,
rotation family or acceleration/energy reference claim.
## Common numerical policy
Every static case has independent U-global, constrained RF-global and S-global
blocking families. Every dynamic frame has its own U-global blocking family.
For each family, only reference values define S=max(abs(reference)) and B=.01S.
Rows with abs(reference)<=B require absolute error<=B; other rows require relative
error<=.05. All-row RMS/S must also be<=.01. Boundaries are inclusive, and S=0
requires every actual value to be exactly0. No absolute floor, denominator clamp,
row deletion or cancellation of signed errors is applied. Reports preserve each
scalar identity/value/error/branch/pass and family scale/band/max-absolute-error,
absolute/relative RMS, source SHA and policy.
The independent typed modal comparison behavior requires explicit source step,
mode1–10, eigenvalue or frequency quantity, and matching `1/time^2` or `1/time`
units. Each mode is a singleton using the same RMS1% policy. This is not an
assumption about the absent original Value file's eventual CSV layout. Actual
modal original comparison remains **NOT RUN** until a genuine reference is
provided and its semantics and hashes are confirmed.
## Current observations
The initial seven behavior assertions failed before implementation (pytest native
exit1). The first expanded suite passed63/63 with native exit0. Those tests include
original-sized independently declared HDF5 schema fixtures and reference values;
they are comparator behavior fixtures, not actual FESA source validation evidence.
The affected Hex8 plus legacy HHT comparator suite passed119/119 (native exit0,
62.51s) before independent review fixes. Those bounded fixes were reproduced by
safe behavior RED tests and the five targeted regressions then passed5/5. Final affected checks after all three independent findings were corrected passed
124/124 with native exit0 (81.41s), with raw stdout and JUnit retained under
`.harness/hex8-evidence/t8/final-affected/`. The preliminary whole `uv ... pytest -q` attempt and the separate exact
configured `uv run --with h5py --with pytest python -B -m pytest -v -rs`
(`docs/operations.md:87`, adding h5py) both stopped during collection with native
exit2 and five legacy operational import errors (`msvc_harness` and removed `scripts.hooks`). It is not a whole-suite PASS;
no tests were excluded and no retired orchestration was restored.
Actual official linear-static and nonlinear-static results passed source,
model, schema, point, finite and frame prechecks and failed all three numerical
families. Their closed CLI evidence records both have native exit0 and matching
original/copy/result hashes; their comparator commands have native exit1.
These are genuine comparison failures under the unchanged policy, not reader
precheck failures or solver native execution failures. Frequency official readback
passes its prechecks; its comparator exits2 with `not-run`, not PASS.
| Case / blocking quantity | Scalars | Failing rows | S | Maximum absolute error | RMS/S |
| --- | ---: | ---: | ---: | ---: | ---: |
| linear static U | 1326 | 79 | .00186131289 | .00007588175038730229 | .016739519723527385 |
| linear static RF | 51 | 34 | 513497.094 | 78630.61640144575 | .06818892528188494 |
| linear static S | 14400 | 7783 | 3430145 | 982855.3871926877 | .04270903417463437 |
| nonlinear static U | 1326 | 79 | .00186110672 | .00007586510202476195 | .016737790679783753 |
| nonlinear static RF | 51 | 34 | 513445.594 | 78623.61011241788 | .0681899286018377 |
| nonlinear static S | 14400 | 7782 | 3429817.75 | 982753.151651503 | .04270850513087158 |
Official result SHA256 values are
`3ef92c35ea83ddb4d11437ff87ef15eca7dac179d378eda19570abae579b09d0` (linear),
`0095ebf2d360a8bd8a71212af0dd500ed611a56540a173a39d9c8d88d8e05bbf` (nonlinear),
and `0ec4d2b09250bab17a8532228cd34a312518c04023076f971e929b387031c6b3` (frequency).
Scratch input paths are
`C:/git/FESADev/.harness/hex8-evidence/t8-original-cli/<case>/<case>.inp`.
Each exact command, closed native exit and full row report is retained under
`C:/git/FESADev/.dryforge/worktrees/hex8-t8-reference/.harness/hex8-evidence/t8/official-comparisons/<case>/`.
The command records identify the actual scratch input, original reference directory,
HDF5 and closed native CLI record consumed. Comparator implementation review and
remaining naturally completed dynamic originals are tracked separately.
| Actual original | Current comparison state |
| --- | --- |
| `hexa_linearstatic` | Full precheck PASS; U/RF/S numerical FAIL (comparator native1) |
| `hexa_nonlinearstatic` | Full precheck PASS; U/RF/S numerical FAIL (comparator native1) |
| `hexa_dynlinear` | NOT RUN by comparator; root-owned actual CLI run pending |
| `hexa_dynnonlinear` | NOT RUN by comparator; root-owned actual CLI run pending |
| `hexa_freq` | Official HDF5 precheck PASS; modal Value absent, actual comparison NOT RUN (native2) |
Full MSVC build/CTest, original CLI orchestration, independent numerical/physics
investigation of the reference conflict and whole-feature completion are root-owned
and are not inferred from this Python implementation or its fixture tests.
+749
View File
@@ -0,0 +1,749 @@
"""Compare original Hex8 CSVs with official Solid HDF5; never launch a solver.
This entry point is intentionally restricted to reference/hexa's fixed original
models. Identity/schema checks precede finite checks and the common tolerance.
Rounded HDF5 observables are compared; internal high+low solver gates are not
recomputed from them. Missing original modal Value data remain NOT RUN.
"""
import argparse
import csv
from decimal import Decimal, InvalidOperation
import hashlib
import json
import math
import os
from pathlib import Path
import re
from typing import Any
import h5py
import numpy as np
ROOT = Path(__file__).resolve().parents[2]
REFERENCE_DIRECTORY = ROOT / "reference/hexa"
STEP = "Step-1"
INSTANCE = "PART-1-1"
NODE_COUNT = 442
ELEMENT_COUNT = 300
POLICY = {"relative_tolerance": 0.05, "relative_rms_tolerance": 0.01,
"near_zero_ratio": 0.01, "zero_scale": "exact-zero"}
CASES = {
"hexa_linearstatic": "linear-static",
"hexa_nonlinearstatic": "nonlinear-static-full-newton",
"hexa_dynlinear": "hht-linear-dynamics",
"hexa_dynnonlinear": "hht-nonlinear-dynamics",
"hexa_freq": "linear-frequency",
}
# Working-tree bytes (Windows CRLF), not Git's normalized LF blobs.
SOURCE_SHA256 = {
"hexa_dynlinear.inp": "f8db81b8ea56b689ab0ad892e6a84b7c6a0162c9d3ff95328ea99722f3de078e",
"hexa_dynlinear_U.csv": "623c20489e8cc18d23fa1728563ce5a26059f321861514ef8569e3d204ef6cb5",
"hexa_dynnonlinear.inp": "1685f86f844e8020bf46e1747b949699ac9aff0d7570bd21ec95109399365ff9",
"hexa_dynnonlinear_U.csv": "f5832a24577f54838bb49dec86a0dc9668cf43e78953f718a9d3635e0f574226",
"hexa_freq.inp": "42cbcec2a8b738f8b6d2c1e13114b8840dccdde6fc3e6f8a43a3272e85d82347",
"hexa_linearstatic.inp": "67183909c5dc962a4d4b65ffb81f7bd65b72e89a5d015c8369a8e0226347a647",
"hexa_linearstatic_RF.csv": "3e04e71ec075ffef098cf4870d77f9c400557fccb375369da8bf67cf67c1e36b",
"hexa_linearstatic_S.csv": "8840a502b9d28a91e5685349301a56fed37d2ac62327b71304716b714db577e8",
"hexa_linearstatic_U.csv": "1ff49aea3ec601cfd796d6a2fb6a4f91be3fc3de9426cdf3744d981216d4bd5f",
"hexa_nonlinearstatic.inp": "7858254025b96c6bfe3fb6498605ecf9c473425c2bc3593f5fbdc6902fd7c6cb",
"hexa_nonlinearstatic_RF.csv": "3ba67a4fb75b2ce3edee13d35b2ea66ac7d52fc16e612c086ed1122a834fee7b",
"hexa_nonlinearstatic_S.csv": "a8866a730f0d65b1e3921d504d255b72158b4a2b9ee30fc691712d94982b07ba",
"hexa_nonlinearstatic_U.csv": "bd6c2fe2b2bb2ffb0bc2ea52f67513c3967a396b5707bbebc9533fde7be25ac6",
}
BASE_COLUMNS = ["ODB Name", "Step", "Frame", "Part Instance Name"]
TAIL_COLUMNS = ["X", "Y", "Z", "Section Name", "Material Name", "Section Point"]
COMPONENTS = {"U": ["U1", "U2", "U3"], "RF": ["RF1", "RF2", "RF3"],
"S": ["S11", "S22", "S33", "S12", "S13", "S23"]}
STRESS_DERIVED = ["Mises", "Max. Principal", "Max. Principal (Abs)",
"Mid. Principal", "Min. Principal", "Tresca", "Pressure", "Third Invariant"]
NATURAL_NODES = np.array([[-1, -1, -1], [1, -1, -1], [1, 1, -1], [-1, 1, -1],
[-1, -1, 1], [1, -1, 1], [1, 1, 1], [-1, 1, 1]])
NATURAL_POINTS = np.array([[-1, -1, -1], [1, -1, -1], [-1, 1, -1], [1, 1, -1],
[-1, -1, 1], [1, -1, 1], [-1, 1, 1], [1, 1, 1]]) / math.sqrt(3.0)
FRAME_PATTERN = re.compile(
r"Increment\s+(\d+):\s*Step Time\s*=\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)"
)
class ComparisonError(ValueError):
def __init__(self, code: str, message: str):
super().__init__(message)
self.code = code
def require(condition: bool, code: str, message: str):
if not condition:
raise ComparisonError(code, message)
def canonical(value: str) -> str:
"""Abaqus ASCII case folding only; distinct instances never coalesce."""
return value.translate(str.maketrans("abcdefghijklmnopqrstuvwxyz", "ABCDEFGHIJKLMNOPQRSTUVWXYZ"))
def label(value: str) -> int:
require(re.fullmatch(r"[0-9]+", value) is not None, "invalid-source-label", value)
number = int(value)
require(0 < number <= 2**63 - 1, "invalid-source-label", value)
return number
def lexical_absolute(path: Path) -> str:
"""Match C++ absolute.lexically_normal without resolving physical aliases."""
return Path(os.path.abspath(path)).as_posix()
def sha256(path: Path) -> str:
with path.open("rb") as stream:
return hashlib.file_digest(stream, "sha256").hexdigest()
def fnv1a64(raw: bytes) -> str:
value = 14695981039346656037
for byte in raw:
value = ((value ^ byte) * 1099511628211) & (2**64 - 1)
return f"fnv1a64:{value:016x}"
def input_inventory(path: Path, case: str) -> dict[str, Any]:
"""Read only the frozen single-part identity-instance original subset."""
require(case in CASES, "unknown-source-case", case)
require(sha256(path) == SOURCE_SHA256[f"{case}.inp"], "source-input-hash-mismatch", str(path))
nodes, elements, sets, boundaries = {}, {}, {}, []
keyword, options, in_assembly = "", {}, False
model = {"case": case, "nodes": nodes, "elements": elements, "node_sets": sets,
"section_line": None, "source_path": lexical_absolute(path),
"content_identity": fnv1a64(path.read_bytes())}
for line_number, raw in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), 1):
line = raw.strip()
if not line or line.startswith("**"):
continue
if line.startswith("*"):
tokens = [token.strip() for token in line[1:].split(",")]
keyword = canonical(tokens[0])
options = {canonical(token.split("=", 1)[0]): token.split("=", 1)[1].strip()
for token in tokens[1:] if "=" in token}
if keyword == "ASSEMBLY":
in_assembly = True
elif keyword == "END ASSEMBLY":
in_assembly = False
elif keyword == "INSTANCE":
require(canonical(options["NAME"]) == INSTANCE and len(tokens) == 3,
"unsupported-source-instance", line)
elif keyword == "SOLID SECTION":
model["section_line"] = line_number
model["section_elset"] = options["ELSET"]
model["material"] = options["MATERIAL"]
elif keyword == "STEP":
require(options["NAME"] == STEP, "wrong-source-step", line)
continue
tokens = [token.strip() for token in line.split(",") if token.strip()]
if keyword == "NODE":
node = label(tokens[0])
require(node not in nodes and len(tokens) == 4, "source-node-inventory", line)
nodes[node] = tuple(float(value) for value in tokens[1:])
elif keyword == "ELEMENT":
element = label(tokens[0])
require(element not in elements and len(tokens) == 9 and options["TYPE"] == "C3D8",
"source-element-inventory", line)
elements[element] = tuple(label(value) for value in tokens[1:])
elif keyword == "NSET" and in_assembly:
name = canonical(options["NSET"])
require(canonical(options["INSTANCE"]) == INSTANCE, "wrong-source-instance", line)
sets.setdefault(name, []).extend(label(value) for value in tokens)
elif keyword == "BOUNDARY":
first, last = int(tokens[1]), int(tokens[2])
require(len(tokens) == 3 or float(tokens[3]) == 0.0, "unsupported-source-boundary", line)
boundaries.append((canonical(tokens[0]), first, last))
elif keyword == "DENSITY":
model["density"] = float(tokens[0])
elif keyword == "ELASTIC":
model["elastic"] = tuple(float(value) for value in tokens)
require(set(nodes) == set(range(1, NODE_COUNT + 1))
and set(elements) == set(range(1, ELEMENT_COUNT + 1)),
"source-model-inventory", "Expected original 442 nodes and 300 elements.")
constrained = set()
for name, first, last in boundaries:
require(name in sets and 1 <= first <= last <= 6, "source-boundary-identity", name)
constrained.update((node, component) for node in sets[name]
for component in range(first, min(last, 3) + 1))
model["constrained"] = constrained
return model
def point_coordinates(model, element: int, point: int):
xi = NATURAL_POINTS[point - 1]
shape = np.prod(1.0 + NATURAL_NODES * xi, axis=1) / 8.0
coordinates = np.array([model["nodes"][node] for node in model["elements"][element]])
terms = shape[:, None] * coordinates
return np.sum(terms, axis=0), np.sum(np.abs(terms), axis=0)
def read_reference(path: Path, case: str, quantity: str, model):
"""Validate all row identities before converting any physical values."""
require(quantity in COMPONENTS, "unknown-quantity", quantity)
dynamic = case.startswith("hexa_dyn")
require(not dynamic or quantity == "U", "wrong-reference-quantity", quantity)
extra = (["Element Label", "IntPt"] if quantity == "S" else ["Node Label"])
suffix = ([f"S-{name}" for name in STRESS_DERIVED] if quantity == "S"
else [f"{quantity}-Magnitude"])
headers = BASE_COLUMNS + extra + TAIL_COLUMNS + suffix + [f"{quantity}-{c}" for c in COMPONENTS[quantity]]
records = {}
with path.open(newline="", encoding="utf-8-sig") as stream:
reader = csv.reader(stream)
fields = [field.strip() for field in next(reader, [])]
require(len(fields) == len(set(fields)), "duplicate-reference-header", str(path))
require(set(fields) == set(headers), "wrong-reference-columns", str(fields))
for row_number, values in enumerate(reader, 2):
require(len(values) == len(fields), "wrong-reference-row-width", str(row_number))
row = dict(zip(fields, [value.strip() for value in values]))
require(row["ODB Name"].replace("\\", "/").rsplit("/", 1)[-1] == f"{case}.odb",
"wrong-reference-case", str(row_number))
require(row["Step"] == STEP, "wrong-reference-step", str(row_number))
require(canonical(row["Part Instance Name"]) == INSTANCE,
"wrong-reference-instance", str(row_number))
match = FRAME_PATTERN.fullmatch(row["Frame"])
require(match is not None, "invalid-reference-frame", str(row_number))
increment = int(match[1])
time = Decimal(match[2])
require(time.is_finite() and increment in (range(101) if dynamic else (10,))
and time == (Decimal(increment) / 10 if dynamic else Decimal(1)),
"wrong-reference-frame-time", str(row_number))
entity = label(row["Element Label" if quantity == "S" else "Node Label"])
point = label(row["IntPt"]) if quantity == "S" else 0
require(entity in model["elements" if quantity == "S" else "nodes"],
"extra-reference-entity", str(row_number))
require(quantity != "S" or 1 <= point <= 8, "wrong-reference-point", str(row_number))
require(row["Section Point"] == ""
and canonical(row["Material Name"]) == ("MATERIAL-1" if quantity == "S" else "NA")
and canonical(row["Section Name"]) == ("ASSEMBLY_PART-1-1_SET-1" if quantity == "S" else "NA"),
"wrong-reference-section", str(row_number))
key = (increment, entity, point)
require(key not in records, "duplicate-reference-identity", str(key))
records[key] = row
entities = model["elements" if quantity == "S" else "nodes"]
expected = {(n, entity, p) for n in (range(101) if dynamic else (10,))
for entity in entities for p in (range(1, 9) if quantity == "S" else (0,))}
require(records.keys() == expected, "reference-inventory", "Missing reference identities.")
return records
def reference_values(records, quantity, model):
result = {}
for key in sorted(records):
row = records[key]
values = [float(row[f"{quantity}-{component}"]) for component in COMPONENTS[quantity]]
coordinates = np.array([float(row[axis]) for axis in ("X", "Y", "Z")])
# Every exported numeric column is checked, including unused derived values.
derived = STRESS_DERIVED if quantity == "S" else ["Magnitude"]
other = [float(row[f"{quantity}-{name}"]) for name in derived]
require(all(math.isfinite(value) for value in values + other) and np.isfinite(coordinates).all(),
"nonfinite-reference-value", str(key))
if quantity == "S":
expected, scale = point_coordinates(model, key[1], key[2])
else:
expected = np.array(model["nodes"][key[1]])
scale = np.abs(expected)
budget = 16.0 * np.finfo(np.float32).eps * scale
require((np.abs(coordinates - expected) <= budget).all(),
"reference-coordinate-convention", str(key))
result[key] = values
return result
def evaluate_family(identity, result, reference):
require(len(result) == len(reference) == len(identity["rows"]) and len(result) > 0,
"scalar-inventory", identity["quantity"])
require(all(math.isfinite(value) for value in (*result, *reference)),
"nonfinite-comparison-value", "Comparison values must be finite.")
scale = max(abs(value) for value in reference)
band = POLICY["near_zero_ratio"] * scale
rows, error_norm = [], 0.0
for key, actual, expected in zip(identity["rows"], result, reference):
error = abs(actual - expected)
require(math.isfinite(error), "nonfinite-comparison-value", str(key))
error_norm = math.hypot(error_norm, error)
require(math.isfinite(error_norm), "nonfinite-comparison-value", str(key))
relative = None
if scale == 0.0:
branch, threshold, passed = "zero-scale-exact", 0.0, error == 0.0
elif abs(expected) <= band:
branch, threshold, passed = "near-zero", band, error <= band
else:
branch, threshold = "relative", POLICY["relative_tolerance"] * abs(expected)
relative = error / abs(expected)
require(math.isfinite(relative), "nonfinite-comparison-value", str(key))
passed = relative <= POLICY["relative_tolerance"]
rows.append({**key, "fesa_value": actual, "reference_value": expected,
"absolute_error": error, "relative_error": relative, "branch": branch,
"threshold": threshold, "passed": passed})
rows_passed = all(row["passed"] for row in rows)
absolute_rms = error_norm / math.sqrt(len(rows))
relative_rms = absolute_rms / scale if scale else None
require(relative_rms is None or math.isfinite(relative_rms), "nonfinite-comparison-value", "RMS")
rms_passed = rows_passed if not scale else relative_rms <= POLICY["relative_rms_tolerance"]
return {**{k: v for k, v in identity.items() if k != "rows"}, "impact": "blocking",
"row_count": len(rows), "reference_scale": scale, "near_zero_band": band,
"maximum_absolute_error": max(row["absolute_error"] for row in rows),
"absolute_rms": absolute_rms, "relative_rms": relative_rms,
"policy": dict(POLICY), "rows": rows, "rows_passed": rows_passed,
"rms_passed": rms_passed, "passed": rows_passed and rms_passed,
"diagnostic_code": "zero-reference-scale-nonzero-error" if not scale and not rows_passed else None}
def compare_modal(reference, result, quantity: str, unit: str):
"""Typed modal rows, not an invented format for the absent original CSV.
Each row explicitly supplies step, mode, quantity, unit_dimensions and value.
Callers must obtain those semantics from a genuine supplied artifact first.
Eigenvalues and frequencies are never silently converted into one another.
"""
units = {"eigenvalue": "1/time^2", "frequency": "1/time"}
require(quantity in units and unit == units[quantity], "wrong-modal-unit", unit)
inventories = []
for rows in (reference, result):
mapping = {}
for row in rows:
require(row["quantity"] == quantity, "wrong-modal-quantity", str(row["mode"]))
require(row["unit_dimensions"] == unit, "wrong-modal-unit", str(row["mode"]))
require(row["step"] == STEP, "wrong-modal-step", str(row["mode"]))
mode = row["mode"]
require(type(mode) is int and mode in range(1, 11), "wrong-modal-mode", str(mode))
require(mode not in mapping, "duplicate-modal-mode", str(mode))
mapping[mode] = row["value"]
require(set(mapping) == set(range(1, 11)), "modal-mode-inventory", "Lowest ten modes required.")
inventories.append(mapping)
families = [evaluate_family(
{"case": "hexa_freq", "step": STEP, "mode": mode, "quantity": quantity,
"unit_dimensions": unit, "rows": [{"mode": mode, "component": quantity}]},
[inventories[1][mode]], [inventories[0][mode]]) for mode in range(1, 11)]
return {"families": families, "passed": all(family["passed"] for family in families)}
def text(value) -> str:
return value.decode("utf-8", errors="strict") if isinstance(value, bytes) else str(value)
def string_attribute(obj, name, expected=None):
require(name in obj.attrs, "missing-result-attribute", f"{obj.name}:{name}")
attr = obj.attrs.get_id(name)
string = h5py.check_string_dtype(attr.dtype)
require(attr.shape == () and string is not None and string.length is None
and string.encoding == "utf-8", "wrong-result-attribute-type", f"{obj.name}:{name}")
value = text(obj.attrs[name])
require(bool(value) and (expected is None or value == expected),
"wrong-result-attribute", f"{obj.name}:{name}={value}")
return value
def numeric_attribute(obj, name, dtype, expected):
require(name in obj.attrs, "missing-result-attribute", f"{obj.name}:{name}")
attr = obj.attrs.get_id(name)
require(attr.shape == () and attr.dtype == np.dtype(dtype),
"wrong-result-attribute-type", f"{obj.name}:{name}")
require(obj.attrs[name] == expected, "wrong-result-attribute", f"{obj.name}:{name}")
def dataset(file, path, shape):
require(path in file and isinstance(file[path], h5py.Dataset), "missing-result-dataset", path)
value = file[path]
require(value.shape == shape and value.chunks is None and value.compression is None,
"wrong-result-shape", path)
return value
def table(file, path, count, fields):
value = dataset(file, path, (count,))
require(value.dtype.names == tuple(name for name, _ in fields), "wrong-result-table-schema", path)
for name, dtype in fields:
actual = value.dtype[name]
if dtype == "string":
info = h5py.check_string_dtype(actual)
valid = info is not None and info.length is None and info.encoding == "utf-8"
else:
valid = actual == np.dtype(dtype)
require(valid, "wrong-result-table-schema", f"{path}:{name}")
return value[:]
def read_results(path: Path, model):
"""Read complete official schema/identities; defer physical finite checks."""
case = model["case"]
dynamic, nonlinear = case.startswith("hexa_dyn"), "nonlinear" in case
frequency = case == "hexa_freq"
arrays, frames, modal = [], {}, {}
with h5py.File(path, "r") as file:
require("metadata" in file and isinstance(file["metadata"], h5py.Group),
"missing-result-metadata", str(path))
metadata = file["metadata"]
numeric_attribute(metadata, "schema_version", "<u8", 1)
for name, expected in {
"feature_id": "hex8-structural-analysis", "analysis_procedure": CASES[case],
"element_formulation": "FESA-Hex8", "integration_rule": "gauss-2x2x2",
"mass_formulation": "consistent-reference", "source_step_name": STEP,
"unit_system_label": "user-consistent-unspecified",
"coordinate_convention": "global-cartesian; reference-strain; current-cauchy-stress",
"component_order": "node-major; UX,UY,UZ",
"source_input_identity": f"path={model['source_path']};content_identity={model['content_identity']}",
}.items():
string_attribute(metadata, name, expected)
string_attribute(metadata, "solver_version")
nodes = table(file, "model/nodes", NODE_COUNT, [
("internal_node_id", "<u8"), ("instance_name", "string"),
("source_label", "string"), ("coordinates", ("<f8", (3,)))])
node_map = {}
for i, row in enumerate(nodes):
node = label(text(row["source_label"]))
require(int(row["internal_node_id"]) == i and canonical(text(row["instance_name"])) == INSTANCE,
"wrong-result-node-identity", str(i))
require(node not in node_map and node in model["nodes"], "result-node-inventory", str(node))
node_map[node] = i
require(node_map.keys() == model["nodes"].keys(), "result-node-inventory", str(path))
elements = table(file, "model/elements", ELEMENT_COUNT, [
("internal_element_id", "<u8"), ("instance_name", "string"), ("source_label", "string"),
("source_element_type", "string"), ("internal_formulation", "string"),
("node_internal_ids", ("<u8", (8,))), ("solid_section_internal_id", "<u8"),
("material_internal_id", "<u8")])
element_map = {}
for i, row in enumerate(elements):
element = label(text(row["source_label"]))
require(int(row["internal_element_id"]) == i and canonical(text(row["instance_name"])) == INSTANCE,
"wrong-result-element-identity", str(i))
require(element not in element_map and element in model["elements"],
"result-element-inventory", str(element))
require(text(row["source_element_type"]) == "C3D8"
and text(row["internal_formulation"]) == "FESA-Hex8"
and tuple(row["node_internal_ids"]) == tuple(node_map[n] for n in model["elements"][element])
and row["solid_section_internal_id"] == 0 and row["material_internal_id"] == 0,
"wrong-result-element-connectivity", str(element))
element_map[element] = i
materials = table(file, "model/solid/materials", 1, [
("internal_material_id", "<u8"), ("name", "string"), ("youngs_modulus", "<f8"),
("poisson_ratio", "<f8"), ("density_present", "u1"), ("density", "<f8")])
material = materials[0]
require(material["internal_material_id"] == 0 and canonical(text(material["name"])) == "MATERIAL-1"
and material["density_present"] == 1, "wrong-result-material-identity", str(path))
sections = table(file, "model/solid/sections", 1, [
("internal_section_id", "<u8"), ("source_file", "string"), ("source_line", "<u8"),
("source_elset", "string"), ("material_internal_id", "<u8")])
section = sections[0]
require(section["internal_section_id"] == 0 and section["material_internal_id"] == 0
and text(section["source_file"]) == model["source_path"]
and section["source_line"] == model["section_line"]
and canonical(text(section["source_elset"])) == canonical(model["section_elset"]),
"wrong-result-section-identity", str(path))
points = table(file, "model/solid_integration_points", ELEMENT_COUNT * 8, [
("internal_element_id", "<u8"), ("point_id", "u1"),
("natural_coordinates", ("<f8", (3,))), ("reference_coordinates", ("<f8", (3,)))])
for i, row in enumerate(points):
require(row["internal_element_id"] == i // 8 and row["point_id"] == i % 8 + 1,
"wrong-result-point-identity", str(i))
mask = dataset(file, "model/nodal_constraint_mask", (NODE_COUNT, 3))
require(mask.dtype == np.dtype("u1"), "wrong-result-mask-type", str(path))
string_attribute(mask, "component_names", "UX,UY,UZ")
mask = mask[:]
expected_mask = np.array([[(node, c) in model["constrained"] for c in (1, 2, 3)]
for node in node_map], dtype="u1")
require(np.array_equal(mask, expected_mask), "wrong-result-constraint-identity", str(path))
require("steps" in file and set(file["steps"]) == {STEP}, "wrong-result-step", str(path))
step = file[f"steps/{STEP}"]
expected_step_groups = {"eigen"} if frequency else ({"frames", "accepted_frames"} if dynamic or nonlinear else {"frames"})
require(set(step) == expected_step_groups, "wrong-result-step-inventory", str(path))
if frequency:
modal = read_modal_tables(file, arrays, int((mask == 0).sum()))
else:
count = 101 if dynamic else (10 if nonlinear else 1)
accepted = None
if dynamic or nonlinear:
coordinate = "time" if dynamic else "load_factor"
flag = "is_initial" if dynamic else "is_factor_one"
accepted = table(file, f"steps/{STEP}/accepted_frames", count, [
("frame_index", "<u8"), ("increment_index", "<u8"), (coordinate, "<f8"),
("iteration_count", "<u8"), (flag, "u1")])
for frame, row in enumerate(accepted):
increment = frame if dynamic else frame + 1
value = increment * 0.1
expected_flag = int(frame == (0 if dynamic else count - 1))
require(row["frame_index"] == frame and row["increment_index"] == increment
and row[coordinate] == value and row[flag] == expected_flag
and (row["iteration_count"] == 0 if dynamic and frame == 0
else 1 <= row["iteration_count"] <= 100),
"wrong-result-frame-schedule", str(frame))
frame_root = f"steps/{STEP}/frames"
require(set(file[frame_root]) == {str(i) for i in range(count)},
"result-frame-inventory", str(path))
for frame in range(count):
increment = frame if dynamic else (frame + 1 if nonlinear else 10)
value = increment * 0.1 if dynamic or nonlinear else 1.0
frames[increment] = read_frame(file, frame, increment, value, dynamic, nonlinear, arrays)
# Identity and schema checks above deliberately finish before physical checks.
arrays.extend([("model/nodes/coordinates", nodes["coordinates"]),
("model/points/natural", points["natural_coordinates"]),
("model/points/reference", points["reference_coordinates"]),
("model/material", np.array([material[n] for n in ("youngs_modulus", "poisson_ratio", "density")]))])
evidence = {"source_input_identity": string_attribute(metadata, "source_input_identity"),
"source_node_count": NODE_COUNT, "source_element_count": ELEMENT_COUNT,
"accepted_frame_count": len(frames), "constrained_scalar_count": int(mask.sum()),
"result_node_map": node_map, "result_element_map": element_map}
return {"frames": frames, "modal": modal, "nodes": nodes, "points": points,
"material": material, "arrays": arrays, "mask": mask, "evidence": evidence}
def physical_dataset(file, path, shape, names, units, location, frame=None,
increment=None, coordinate=None, dynamic=False, extra=None):
value = dataset(file, path, shape)
require(value.dtype == np.dtype("<f8"), "wrong-result-value-type", path)
attributes = {"component_names": names, "unit_dimensions": units,
"coordinate_system": "global-cartesian", "location": location}
if frame is not None:
attributes["step_name"] = STEP
attributes.update(extra or {})
for name, expected in attributes.items():
string_attribute(value, name, expected)
if frame is not None:
numeric_attribute(value, "frame_index", "<u8", frame)
numeric_attribute(value, "increment_index", "<u8", increment)
numeric_attribute(value, "time" if dynamic else "load_factor", "<f8", coordinate)
return value[()]
def read_frame(file, frame, increment, coordinate, dynamic, nonlinear, arrays):
root = f"steps/{STEP}/frames/{frame}"
require(set(file[root]) == {"nodal", "solid", "global"}, "wrong-result-frame-inventory", root)
definitions = {
"nodal/displacement": ((NODE_COUNT, 3), "U1,U2,U3", "length", "nodal", {}),
"nodal/reaction": ((NODE_COUNT, 3), "RF1,RF2,RF3", "force", "nodal", {}),
"nodal/residual": ((NODE_COUNT, 3), "R1,R2,R3", "force", "nodal", {}),
"solid/stress": ((ELEMENT_COUNT, 8, 6), "S11,S22,S33,S12,S13,S23", "force/length^2",
"integration-point", {"stress_measure": "cauchy"}),
"solid/strain": ((ELEMENT_COUNT, 8, 6), "E11,E22,E33,E12,E13,E23", "dimensionless",
"integration-point", {"strain_measure": "green-lagrange" if nonlinear else "infinitesimal",
"shear_convention": "tensor"}),
"global/strain_energy": ((), "scalar", "force*length", "global",
{"definition": "sum(W*detJ0)" if nonlinear else "0.5*u^T*K0*u"}),
}
if dynamic:
definitions.update({
"nodal/velocity": ((NODE_COUNT, 3), "V1,V2,V3", "length/time", "nodal", {}),
"nodal/acceleration": ((NODE_COUNT, 3), "A1,A2,A3", "length/time^2", "nodal", {"acceleration_kind": "physical"}),
"nodal/algorithmic_acceleration": ((NODE_COUNT, 3), "A1,A2,A3", "length/time^2", "nodal", {"acceleration_kind": "algorithmic"}),
"global/kinetic_energy": ((), "scalar", "force*length", "global", {"definition": "0.5*v^T*M*v"}),
"global/external_work": ((), "scalar", "force*length", "global", {"definition": "sum(0.5*(F_old+F_new)*delta_u)"}),
"global/energy_balance_error": ((), "scalar", "force*length", "global", {"definition": "T+U-T0-U0-Wext"}),
"global/linear_momentum": ((3,), "P1,P2,P3", "mass*length/time", "global", {}),
"global/angular_momentum": ((3,), "L1,L2,L3", "mass*length^2/time", "global", {"origin": "global0"}),
})
for group in ("nodal", "solid", "global"):
expected = {key.split("/", 1)[1] for key in definitions if key.startswith(group + "/")}
require(set(file[f"{root}/{group}"]) == expected, "wrong-result-quantity-inventory", root + "/" + group)
values = {}
for quantity, (shape, names, units, location, extra) in definitions.items():
path = root + "/" + quantity
values[quantity] = physical_dataset(file, path, shape, names, units, location,
frame, increment, coordinate, dynamic, extra)
arrays.append((path, values[quantity]))
return values
def read_modal_tables(file, arrays, free_count):
root = f"steps/{STEP}/eigen"
require(set(file[root]) == {"modes", "frequencies", "vectors", "verification"},
"wrong-modal-result-inventory", root)
modes = table(file, root + "/modes", 10, [("mode_id", "<u8"), ("eigenvalue", "<f8"),
("classification", "u1"), ("cluster_id", "<u8")])
for name, component, units in (("modes", "eigenvalue", "1/time^2"),
("frequencies", "frequency", "1/time")):
obj = file[root + "/" + name]
for attr, expected in {"component_names": component, "unit_dimensions": units,
"coordinate_system": "global-cartesian", "location": "global"}.items():
string_attribute(obj, attr, expected)
frequencies = table(file, root + "/frequencies", sum(row["eigenvalue"] >= 0 for row in modes),
[("mode_id", "<u8"), ("frequency", "<f8")])
require(list(modes["mode_id"]) == list(range(1, 11)) and (modes["classification"] <= 1).all()
and modes["cluster_id"][0] == 1
and all(b in (a, a + 1) for a, b in zip(modes["cluster_id"], modes["cluster_id"][1:])),
"wrong-modal-mode", root)
require(list(frequencies["mode_id"]) == [row["mode_id"] for row in modes if row["eigenvalue"] >= 0],
"wrong-modal-frequency-inventory", root)
vectors = physical_dataset(file, root + "/vectors", (10, NODE_COUNT, 3),
"UX,UY,UZ", "mass-normalized", "nodal",
extra={"normalization": "phi^T*M*phi=1"})
verification = root + "/verification"
require(set(file[verification]) == {"pairs", "eta_gram", "max_eta_orth"},
"wrong-modal-verification-inventory", verification)
pair_object = file[verification + "/pairs"]
require(isinstance(pair_object, h5py.Dataset) and len(pair_object.shape) == 1,
"wrong-modal-pair-shape", verification + "/pairs")
pair_count = pair_object.shape[0]
require(10 <= pair_count <= free_count, "wrong-modal-pair-count", str(pair_count))
pairs = table(file, verification + "/pairs", pair_count, [
("sorted_rank", "<u8"), ("solver_column", "<u8"), ("eigenvalue", "<f8"),
("modal_mass", "<f8"), ("rayleigh_quotient", "<f8"), ("eta_r", "<f8"),
("eta_norm", "<f8"), ("eta_energy", "<f8")])
require(list(pairs["sorted_rank"]) == list(range(1, pair_count + 1))
and len(set(pairs["solver_column"])) == pair_count
and (pairs["solver_column"] < pair_count).all(), "wrong-modal-pair-identity", verification)
for name in ("eta_gram", "max_eta_orth"):
value = dataset(file, verification + "/" + name, ())
require(value.dtype == np.dtype("<f8"), "wrong-result-value-type", name)
arrays.append((verification + "/" + name, value[()]))
arrays.extend([(root + "/vectors", vectors), (root + "/lambda", modes["eigenvalue"]),
(root + "/frequency", frequencies["frequency"])])
arrays.extend([(verification + "/" + name, pairs[name]) for name in pairs.dtype.names[2:]])
return {"modes": modes, "frequencies": frequencies, "pairs": pairs}
def check_result_values(result, model):
for path, values in result["arrays"]:
require(np.isfinite(values).all(), "nonfinite-result-value", path)
for row in result["nodes"]:
node = label(text(row["source_label"]))
require(tuple(row["coordinates"]) == model["nodes"][node], "wrong-result-coordinate", str(node))
material = result["material"]
require(tuple(material[name] for name in ("youngs_modulus", "poisson_ratio")) == model["elastic"]
and material["density"] == model["density"], "wrong-result-material-value", model["case"])
for i, row in enumerate(result["points"]):
element = list(result["evidence"]["result_element_map"])[i // 8]
expected, scale = point_coordinates(model, element, i % 8 + 1)
require(np.array_equal(row["natural_coordinates"], NATURAL_POINTS[i % 8])
and (np.abs(row["reference_coordinates"] - expected) <= 16 * np.finfo(float).eps * scale).all(),
"result-coordinate-convention", str(i))
for increment, values in result["frames"].items():
require((values["nodal/reaction"][result["mask"] == 0] == 0.0).all(),
"nonzero-free-result-reaction", str(increment))
if model["case"].startswith("hexa_dyn") and increment == 0:
require((values["nodal/displacement"] == 0.0).all() and (values["nodal/velocity"] == 0.0).all(),
"nonzero-initial-result-state", str(increment))
if result["modal"]:
modes, frequencies, pairs = (result["modal"][key] for key in ("modes", "frequencies", "pairs"))
require((np.diff(modes["eigenvalue"]) >= 0).all() and (np.diff(pairs["eigenvalue"]) >= 0).all(),
"wrong-modal-order", model["case"])
for row in frequencies:
expected = math.sqrt(modes[row["mode_id"] - 1]["eigenvalue"]) / (2 * math.pi)
require(row["frequency"] == expected, "wrong-modal-frequency", str(row["mode_id"]))
def compare(case: str, input_path: Path, results_path: Path, reference_directory: Path = REFERENCE_DIRECTORY,
execution_record: Path | None = None):
report = {"schema": "hex8-reference-comparison-v1", "case": case, "policy": dict(POLICY),
"identities": {}, "precheck": {"passed": False}, "families": [],
"blocking_failures": [], "blocking_passed": False, "verdict": "failed"}
try:
require(case in CASES, "unknown-source-case", case)
quantities = ["U"] if case.startswith("hexa_dyn") else (["U", "RF", "S"] if case != "hexa_freq" else [])
paths = {"input": input_path, "results": results_path}
paths.update({quantity: reference_directory / f"{case}_{quantity}.csv" for quantity in quantities})
for name, path in paths.items():
report["identities"][name] = {"path": lexical_absolute(path), "sha256": sha256(path)}
model = input_inventory(input_path, case)
report["input_copy_matches_approved_bytes"] = True
report["approved_input_sha256"] = SOURCE_SHA256[f"{case}.inp"]
report["execution"] = {"verified": False, "scope": "file comparison only; execution record not supplied"}
if execution_record is not None:
execution = json.loads(execution_record.read_text(encoding="utf-8-sig"))
command = execution.get("command")
require(execution.get("case") == case and execution.get("native_exit") == 0
and type(execution.get("native_exit")) is int
and execution.get("original_sha256") == report["identities"]["input"]["sha256"]
and execution.get("scratch_sha256") == report["identities"]["input"]["sha256"]
and execution.get("result_sha256") == report["identities"]["results"]["sha256"]
and execution.get("source_unchanged") is True
and execution.get("scratch_unchanged") is True
and execution.get("result_exists") is True
and isinstance(command, list) and len(command) == 2
and all(isinstance(item, str) for item in command)
and Path(lexical_absolute(Path(command[1]))) == Path(lexical_absolute(input_path)),
"invalid-execution-evidence", str(execution_record))
report["execution"] = {"verified": True, "native_exit": 0, "command": command,
"record_path": lexical_absolute(execution_record),
"record_sha256": sha256(execution_record)}
records = {}
for quantity in quantities:
require(report["identities"][quantity]["sha256"] == SOURCE_SHA256[f"{case}_{quantity}.csv"],
"reference-hash-mismatch", str(paths[quantity]))
records[quantity] = read_reference(paths[quantity], case, quantity, model)
result = read_results(results_path, model)
references = {quantity: reference_values(rows, quantity, model) for quantity, rows in records.items()}
check_result_values(result, model)
report["precheck"] = {"passed": True, **result["evidence"],
"reference_row_counts": {q: len(rows) for q, rows in records.items()},
"coordinate_check": "auxiliary; fixed identity; 16*float32-eps uncancelled source-coordinate budget"}
# Internal maps are useful to validation but unnecessary in the public report.
del report["precheck"]["result_node_map"], report["precheck"]["result_element_map"]
if case == "hexa_freq":
report["verdict"] = "not-run"
report["reason"] = "Original modal Value reference is absent; no format/quantity/unit assumption made."
return report
families = []
for increment in sorted(result["frames"]):
if not case.startswith("hexa_dyn") and increment != 10:
continue
values = result["frames"][increment]
for quantity in quantities:
actual, expected, identities = [], [], []
for key in sorted(references[quantity]):
if key[0] != increment:
continue
_, entity, point = key
index = result["evidence"]["result_element_map" if quantity == "S" else "result_node_map"][entity]
row = values[{"U": "nodal/displacement", "RF": "nodal/reaction", "S": "solid/stress"}[quantity]][index]
if quantity == "S":
row = row[point - 1]
for c, component in enumerate(COMPONENTS[quantity]):
if quantity == "RF" and (entity, c + 1) not in model["constrained"]:
continue
identities.append({"instance": INSTANCE, "component": component,
"element_label" if quantity == "S" else "node_label": entity,
**({"integration_point": point} if quantity == "S" else {})})
actual.append(float(row[c]))
expected.append(references[quantity][key][c])
identity = {"identity": f"{case}:{STEP}:increment-{increment}:{quantity}-global",
"case": case, "step": STEP, "increment": increment, "quantity": quantity,
"unit_dimensions": {"U": "length", "RF": "force", "S": "force/length^2"}[quantity],
"coordinate_system": "global-cartesian", "rows": identities,
"reference_sha256": report["identities"][quantity]["sha256"]}
if case.startswith("hexa_dyn"):
identity.update({"hdf5_time": increment * 0.1,
"reference_decimal_time": str(Decimal(increment) / 10)})
families.append(evaluate_family(identity, actual, expected))
report["families"] = families
report["blocking_failures"] = [f["identity"] for f in families if not f["passed"]]
report["blocking_passed"] = not report["blocking_failures"]
report["verdict"] = "passed" if report["blocking_passed"] else "failed"
except (ComparisonError, OSError, ValueError, InvalidOperation, UnicodeError, KeyError, TypeError) as error:
report["precheck"] = {"passed": False, "code": getattr(error, "code", "invalid-comparison-input"),
"message": str(error)}
return report
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--case", choices=CASES, required=True)
parser.add_argument("--input", type=Path, required=True, help="Actual byte-identical scratch input read by the solver.")
parser.add_argument("--results", type=Path, required=True)
parser.add_argument("--references", type=Path, default=REFERENCE_DIRECTORY)
parser.add_argument("--execution-record", type=Path, help="Closed native CLI record with input/result hashes; never executed here.")
parser.add_argument("--report", type=Path, required=True)
args = parser.parse_args()
protected = {args.input.resolve(), args.results.resolve()}
if args.execution_record is not None:
protected.add(args.execution_record.resolve())
protected.update(path.resolve() for path in args.references.glob("*"))
protected.update(path.resolve() for path in REFERENCE_DIRECTORY.glob("*"))
report_path = args.report.resolve()
hardlink_alias = args.report.exists() and any(
path.exists() and args.report.samefile(path) for path in protected)
if report_path in protected or hardlink_alias:
parser.error("The report path must not overwrite an input, reference or result.")
report = compare(args.case, args.input, args.results, args.references, args.execution_record)
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps(report, sort_keys=True, indent=2, allow_nan=False) + "\n", encoding="utf-8")
print(json.dumps({"case": args.case, "verdict": report["verdict"], "precheck": report["precheck"],
"blocking_failure_count": len(report["blocking_failures"]), "report": str(args.report)}, allow_nan=False))
return 2 if not report["precheck"]["passed"] or report["verdict"] == "not-run" else (0 if report["blocking_passed"] else 1)
if __name__ == "__main__":
raise SystemExit(main())
+774
View File
@@ -0,0 +1,774 @@
"""Independent comparison contracts; generated result fixtures are not source evidence."""
import csv
import importlib.util
import json
import math
import os
from pathlib import Path
import shutil
import subprocess
import sys
import h5py
import numpy as np
import pytest
ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location(
"compare_hex8", ROOT / "scripts/verification/compare_hex8.py"
)
COMPARE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(COMPARE)
def family(actual, reference):
identities = [{"node_label": i + 1, "component": "U1"} for i in range(len(actual))]
return COMPARE.evaluate_family(
{"case": "hexa_linearstatic", "step": "Step-1", "increment": 10,
"quantity": "U", "unit_dimensions": "length", "rows": identities},
actual, reference,
)
def test_zero_reference_scale_requires_exact_zero():
report = family([0.0, 1e-300], [0.0, 0.0])
assert not report["passed"]
assert report["diagnostic_code"] == "zero-reference-scale-nonzero-error"
def test_row_pass_does_not_override_one_percent_rms():
report = family([1.02] * 10, [1.0] * 10)
assert report["rows_passed"]
assert not report["rms_passed"]
assert not report["passed"]
def test_near_zero_band_uses_reference_scale_only():
report = family([100.0, 1.0], [100.0, 0.0])
assert report["passed"]
assert report["rows"][1]["branch"] == "near-zero"
assert not family([100.0, 1.0001], [100.0, 0.0])["passed"]
def test_nonfinite_family_fails_before_tolerance():
with pytest.raises(COMPARE.ComparisonError, match="finite"):
family([float("nan")], [1.0])
def test_header_strip_collision_rejected_before_bad_numeric(tmp_path):
path = tmp_path / "collision.csv"
path.write_text("Node Label, Node Label\n1,NaN\n", encoding="utf-8")
with pytest.raises(COMPARE.ComparisonError) as error:
COMPARE.read_reference(path, "hexa_linearstatic", "U", {})
assert error.value.code == "duplicate-reference-header"
def test_modal_quantity_and_units_are_explicit():
rows = [{"step": "Step-1", "mode": i, "quantity": "eigenvalue",
"unit_dimensions": "1/time^2", "value": float(i)} for i in range(1, 11)]
with pytest.raises(COMPARE.ComparisonError) as error:
COMPARE.compare_modal(rows, rows, "frequency", "1/time")
assert error.value.code == "wrong-modal-quantity"
def test_modal_singletons_use_common_one_percent_rms():
reference = [{"step": "Step-1", "mode": i, "quantity": "eigenvalue",
"unit_dimensions": "1/time^2", "value": float(i)} for i in range(1, 11)]
actual = [dict(row, value=row["value"] * 1.02) for row in reference]
report = COMPARE.compare_modal(reference, actual, "eigenvalue", "1/time^2")
assert not report["passed"]
assert len(report["families"]) == 10
assert all(row["rows_passed"] and not row["rms_passed"] for row in report["families"])
# These schema declarations are independent of the comparator's declarations.
TEXT = h5py.string_dtype(encoding="utf-8")
REFERENCE = ROOT / "reference/hexa"
def write_strings(obj, attributes):
for name, value in attributes.items():
obj.attrs.create(name, value, dtype=TEXT)
def compound(group, name, schema, rows):
data = np.empty(len(rows), dtype=np.dtype(schema))
for i, row in enumerate(rows):
data[i] = row
return group.create_dataset(name, data=data)
@pytest.fixture(scope="module")
def source_models():
return {case: COMPARE.input_inventory(REFERENCE / f"{case}.inp", case)
for case in ("hexa_linearstatic", "hexa_nonlinearstatic", "hexa_dynlinear", "hexa_dynnonlinear", "hexa_freq")}
def source_rows(case, quantity):
with (REFERENCE / f"{case}_{quantity}.csv").open(newline="", encoding="utf-8") as stream:
reader = csv.reader(stream)
headers = [value.strip() for value in next(reader)]
return [dict(zip(headers, [value.strip() for value in row])) for row in reader]
def make_result(path, model):
"""Build an original-sized schema fixture, not a validated physical solution."""
case = model["case"]
dynamic = case in ("hexa_dynlinear", "hexa_dynnonlinear")
nonlinear = case in ("hexa_nonlinearstatic", "hexa_dynnonlinear")
frequency = case == "hexa_freq"
reference_values = {}
if not frequency:
for quantity in (["U"] if dynamic else ["U", "RF", "S"]):
table_rows = {}
for row in source_rows(case, quantity):
increment = int(row["Frame"].split(":")[0].split()[-1])
entity = int(row["Element Label" if quantity == "S" else "Node Label"])
point = int(row["IntPt"]) if quantity == "S" else 0
names = ("S11", "S22", "S33", "S12", "S13", "S23") if quantity == "S" else tuple(f"{quantity}{c}" for c in (1, 2, 3))
table_rows[increment, entity, point] = [float(row[f"{quantity}-{name}"]) for name in names]
reference_values[quantity] = table_rows
with h5py.File(path, "w") as file:
metadata = file.create_group("metadata")
metadata.attrs.create("schema_version", np.uint64(1), dtype="<u8")
write_strings(metadata, {
"feature_id": "hex8-structural-analysis", "analysis_procedure": {
"hexa_linearstatic": "linear-static", "hexa_nonlinearstatic": "nonlinear-static-full-newton",
"hexa_dynlinear": "hht-linear-dynamics", "hexa_dynnonlinear": "hht-nonlinear-dynamics",
"hexa_freq": "linear-frequency"}[case],
"element_formulation": "FESA-Hex8", "integration_rule": "gauss-2x2x2",
"mass_formulation": "consistent-reference", "source_step_name": "Step-1",
"source_input_identity": f"path={model['source_path']};content_identity={model['content_identity']}",
"unit_system_label": "user-consistent-unspecified",
"coordinate_convention": "global-cartesian; reference-strain; current-cauchy-stress",
"component_order": "node-major; UX,UY,UZ", "solver_version": "fixture-not-source-evidence",
})
group = file.create_group("model")
compound(group, "nodes", [("internal_node_id", "<u8"), ("instance_name", TEXT),
("source_label", TEXT), ("coordinates", "<f8", (3,))],
[(i, "Part-1-1", str(label), coords) for i, (label, coords) in enumerate(model["nodes"].items())])
compound(group, "elements", [("internal_element_id", "<u8"), ("instance_name", TEXT),
("source_label", TEXT), ("source_element_type", TEXT),
("internal_formulation", TEXT), ("node_internal_ids", "<u8", (8,)),
("solid_section_internal_id", "<u8"), ("material_internal_id", "<u8")],
[(i, "Part-1-1", str(label), "C3D8", "FESA-Hex8", tuple(n - 1 for n in nodes), 0, 0)
for i, (label, nodes) in enumerate(model["elements"].items())])
solids = group.create_group("solid")
compound(solids, "materials", [("internal_material_id", "<u8"), ("name", TEXT),
("youngs_modulus", "<f8"), ("poisson_ratio", "<f8"),
("density_present", "u1"), ("density", "<f8")],
[(0, "Material-1", 2.1e11, .3, 1, 7850.)])
compound(solids, "sections", [("internal_section_id", "<u8"), ("source_file", TEXT),
("source_line", "<u8"), ("source_elset", TEXT), ("material_internal_id", "<u8")],
[(0, model["source_path"], model["section_line"], "Set-1", 0)])
point_signs = ((-1, -1, -1), (1, -1, -1), (-1, 1, -1), (1, 1, -1),
(-1, -1, 1), (1, -1, 1), (-1, 1, 1), (1, 1, 1))
node_signs = ((-1, -1, -1), (1, -1, -1), (1, 1, -1), (-1, 1, -1),
(-1, -1, 1), (1, -1, 1), (1, 1, 1), (-1, 1, 1))
points = []
for i, (_, connectivity) in enumerate(model["elements"].items()):
coords = np.array([model["nodes"][n] for n in connectivity])
for p, signs in enumerate(point_signs, 1):
natural = np.array(signs) / math.sqrt(3)
shape = np.array([math.prod(1 + sign * x for sign, x in zip(node, natural)) / 8
for node in node_signs])
points.append((i, p, natural, np.sum(shape[:, None] * coords, axis=0)))
compound(group, "solid_integration_points", [("internal_element_id", "<u8"), ("point_id", "u1"),
("natural_coordinates", "<f8", (3,)),
("reference_coordinates", "<f8", (3,))], points)
mask = group.create_dataset("nodal_constraint_mask", data=np.array(
[[(node, c) in model["constrained"] for c in (1, 2, 3)] for node in model["nodes"]], dtype="u1"))
write_strings(mask, {"component_names": "UX,UY,UZ"})
step = file.create_group("steps/Step-1")
if frequency:
eigen = step.create_group("eigen")
modes = compound(eigen, "modes", [("mode_id", "<u8"), ("eigenvalue", "<f8"),
("classification", "u1"), ("cluster_id", "<u8")],
[(m, float(m), 1, m) for m in range(1, 11)])
frequencies = compound(eigen, "frequencies", [("mode_id", "<u8"), ("frequency", "<f8")],
[(m, math.sqrt(m) / (2 * math.pi)) for m in range(1, 11)])
for obj, name, units in ((modes, "eigenvalue", "1/time^2"), (frequencies, "frequency", "1/time")):
write_strings(obj, {"component_names": name, "unit_dimensions": units,
"location": "global", "coordinate_system": "global-cartesian"})
vectors = eigen.create_dataset("vectors", data=np.zeros((10, 442, 3), dtype="<f8"))
write_strings(vectors, {"component_names": "UX,UY,UZ", "unit_dimensions": "mass-normalized",
"location": "nodal", "coordinate_system": "global-cartesian",
"normalization": "phi^T*M*phi=1"})
verification = eigen.create_group("verification")
compound(verification, "pairs", [("sorted_rank", "<u8"), ("solver_column", "<u8"),
("eigenvalue", "<f8"), ("modal_mass", "<f8"),
("rayleigh_quotient", "<f8"), ("eta_r", "<f8"),
("eta_norm", "<f8"), ("eta_energy", "<f8")],
[(m, m - 1, float(m), 1., float(m), 0., 0., 0.) for m in range(1, 11)])
verification.create_dataset("eta_gram", data=0., dtype="<f8")
verification.create_dataset("max_eta_orth", data=0., dtype="<f8")
return
count = 101 if dynamic else (10 if nonlinear else 1)
if dynamic or nonlinear:
compound(step, "accepted_frames", [("frame_index", "<u8"), ("increment_index", "<u8"),
("time" if dynamic else "load_factor", "<f8"),
("iteration_count", "<u8"),
("is_initial" if dynamic else "is_factor_one", "u1")],
[(frame, frame if dynamic else frame + 1, (frame if dynamic else frame + 1) * .1,
int(not (dynamic and frame == 0)), int(frame == (0 if dynamic else 9))) for frame in range(count)])
for frame in range(count):
increment = frame if dynamic else (frame + 1 if nonlinear else 10)
coordinate = increment * .1 if dynamic or nonlinear else 1.
frame_group = step.create_group(f"frames/{frame}")
definitions = {
"nodal/displacement": ((442, 3), "U1,U2,U3", "length", {}),
"nodal/reaction": ((442, 3), "RF1,RF2,RF3", "force", {}),
"nodal/residual": ((442, 3), "R1,R2,R3", "force", {}),
"solid/stress": ((300, 8, 6), "S11,S22,S33,S12,S13,S23", "force/length^2", {"stress_measure": "cauchy"}),
"solid/strain": ((300, 8, 6), "E11,E22,E33,E12,E13,E23", "dimensionless",
{"strain_measure": "green-lagrange" if nonlinear else "infinitesimal", "shear_convention": "tensor"}),
"global/strain_energy": ((), "scalar", "force*length", {"definition": "sum(W*detJ0)" if nonlinear else "0.5*u^T*K0*u"}),
}
if dynamic:
definitions.update({
"nodal/velocity": ((442, 3), "V1,V2,V3", "length/time", {}),
"nodal/acceleration": ((442, 3), "A1,A2,A3", "length/time^2", {"acceleration_kind": "physical"}),
"nodal/algorithmic_acceleration": ((442, 3), "A1,A2,A3", "length/time^2", {"acceleration_kind": "algorithmic"}),
"global/kinetic_energy": ((), "scalar", "force*length", {"definition": "0.5*v^T*M*v"}),
"global/external_work": ((), "scalar", "force*length", {"definition": "sum(0.5*(F_old+F_new)*delta_u)"}),
"global/energy_balance_error": ((), "scalar", "force*length", {"definition": "T+U-T0-U0-Wext"}),
"global/linear_momentum": ((3,), "P1,P2,P3", "mass*length/time", {}),
"global/angular_momentum": ((3,), "L1,L2,L3", "mass*length^2/time", {"origin": "global0"}),
})
for key, (shape, names, units, extra) in definitions.items():
values = np.zeros(shape, dtype="<f8")
quantity = {"nodal/displacement": "U", "nodal/reaction": "RF", "solid/stress": "S"}.get(key)
if quantity in reference_values and increment in (range(101) if dynamic else (10,)):
for (n, entity, point), components in reference_values[quantity].items():
if n == increment:
values[(entity - 1, point - 1) if quantity == "S" else entity - 1] = components
obj = frame_group.create_dataset(key, data=values)
write_strings(obj, {"component_names": names, "unit_dimensions": units, "step_name": "Step-1",
"coordinate_system": "global-cartesian", "location": {
"nodal": "nodal", "solid": "integration-point", "global": "global"}[key.split('/')[0]], **extra})
obj.attrs.create("frame_index", np.uint64(frame), dtype="<u8")
obj.attrs.create("increment_index", np.uint64(increment), dtype="<u8")
obj.attrs.create("time" if dynamic else "load_factor", coordinate, dtype="<f8")
@pytest.fixture(scope="module")
def result_fixtures(tmp_path_factory, source_models):
directory = tmp_path_factory.mktemp("hex8-comparison-fixtures")
paths = {}
for case, model in source_models.items():
path = directory / f"{case}.h5"
make_result(path, model)
paths[case] = path
return paths
def copy_result(tmp_path, result_fixtures, case="hexa_linearstatic"):
path = tmp_path / "mutated.h5"
shutil.copyfile(result_fixtures[case], path)
return path
def compare_fixture(path, case="hexa_linearstatic", execution=None):
return COMPARE.compare(case, REFERENCE / f"{case}.inp", path, REFERENCE, execution)
def mutate_csv(tmp_path, case, quantity, mutation):
rows = source_rows(case, quantity)
headers = list(rows[0])
mutation(headers, rows)
path = tmp_path / "mutated.csv"
with path.open("w", newline="", encoding="utf-8") as stream:
writer = csv.DictWriter(stream, fieldnames=headers)
writer.writeheader()
writer.writerows(rows)
return path
@pytest.mark.parametrize("case", ["hexa_linearstatic", "hexa_nonlinearstatic", "hexa_dynlinear", "hexa_dynnonlinear"])
def test_all_original_sized_schema_fixtures_compare_by_identity(case, result_fixtures):
report = compare_fixture(result_fixtures[case], case)
assert report["precheck"]["passed"], report["precheck"]
assert report["verdict"] == "passed"
assert report["execution"]["verified"] is False
assert len(report["families"]) == (101 if case.startswith("hexa_dyn") else 3)
if not case.startswith("hexa_dyn"):
assert [family["row_count"] for family in report["families"]] == [1326, 51, 14400]
assert report["precheck"]["reference_row_counts"]["RF"] == 442
else:
assert [family["increment"] for family in report["families"]] == list(range(101))
assert all(family["row_count"] == 1326 for family in report["families"])
assert report["families"][0]["reference_scale"] == 0.
def test_static_final_factor_maps_to_increment10_not_storage_frame(result_fixtures):
report = compare_fixture(result_fixtures["hexa_nonlinearstatic"], "hexa_nonlinearstatic")
assert {family["increment"] for family in report["families"]} == {10}
assert all(family["passed"] for family in report["families"])
@pytest.mark.parametrize("quantity", ["U", "RF", "S"])
def test_csv_order_does_not_supply_identity(tmp_path, source_models, quantity):
path = mutate_csv(tmp_path, "hexa_linearstatic", quantity, lambda _, rows: rows.reverse())
rows = COMPARE.read_reference(path, "hexa_linearstatic", quantity, source_models["hexa_linearstatic"])
original = COMPARE.read_reference(REFERENCE / f"hexa_linearstatic_{quantity}.csv", "hexa_linearstatic", quantity, source_models["hexa_linearstatic"])
assert COMPARE.reference_values(rows, quantity, source_models["hexa_linearstatic"]) == COMPARE.reference_values(original, quantity, source_models["hexa_linearstatic"])
@pytest.mark.parametrize("field,value,code", [
("Node Label", "443", "extra-reference-entity"), ("Part Instance Name", "OTHER", "wrong-reference-instance"),
("Step", "Step-2", "wrong-reference-step"), ("ODB Name", "hexa_nonlinearstatic.odb", "wrong-reference-case"),
("Frame", "Increment 9: Step Time = 1.000", "wrong-reference-frame-time"),
("Frame", "Increment 10: Step Time = 0.999", "wrong-reference-frame-time"),
])
def test_reference_identity_errors_precede_nonfinite(tmp_path, source_models, field, value, code):
def mutation(_, rows):
rows[0]["U-U1"] = "NaN"
rows[-1][field] = value
path = mutate_csv(tmp_path, "hexa_linearstatic", "U", mutation)
with pytest.raises(COMPARE.ComparisonError) as error:
COMPARE.read_reference(path, "hexa_linearstatic", "U", source_models["hexa_linearstatic"])
assert error.value.code == code
def test_rf_entire_inventory_precedes_constraint_projection(tmp_path, source_models):
def mutation(_, rows):
assert int(rows[0]["Node Label"]) == 1 # A free node, absent from projected RF family.
rows.pop(0)
path = mutate_csv(tmp_path, "hexa_linearstatic", "RF", mutation)
with pytest.raises(COMPARE.ComparisonError) as error:
COMPARE.read_reference(path, "hexa_linearstatic", "RF", source_models["hexa_linearstatic"])
assert error.value.code == "reference-inventory"
def test_duplicate_reference_identity_precedes_bad_numeric(tmp_path, source_models):
def mutation(_, rows):
rows[0]["RF-RF1"] = "NaN"
rows[-1]["Node Label"] = rows[0]["Node Label"]
path = mutate_csv(tmp_path, "hexa_linearstatic", "RF", mutation)
with pytest.raises(COMPARE.ComparisonError) as error:
COMPARE.read_reference(path, "hexa_linearstatic", "RF", source_models["hexa_linearstatic"])
assert error.value.code == "duplicate-reference-identity"
@pytest.mark.parametrize("point", ["0", "9", "bad"])
def test_invalid_stress_point_is_never_rematched(tmp_path, source_models, point):
path = mutate_csv(tmp_path, "hexa_linearstatic", "S", lambda _, rows: rows[0].update(IntPt=point))
with pytest.raises(COMPARE.ComparisonError):
COMPARE.read_reference(path, "hexa_linearstatic", "S", source_models["hexa_linearstatic"])
def test_stress_point_order_coordinates_are_auxiliary_not_identity(tmp_path, source_models):
path = mutate_csv(tmp_path, "hexa_linearstatic", "S", lambda _, rows: rows[2].update(
X=rows[3]["X"], Y=rows[3]["Y"], Z=rows[3]["Z"]))
rows = COMPARE.read_reference(path, "hexa_linearstatic", "S", source_models["hexa_linearstatic"])
with pytest.raises(COMPARE.ComparisonError) as error:
COMPARE.reference_values(rows, "S", source_models["hexa_linearstatic"])
assert error.value.code == "reference-coordinate-convention"
@pytest.mark.parametrize("header_mutation", ["missing", "extra"])
def test_wrong_component_columns_rejected(tmp_path, source_models, header_mutation):
def mutation(headers, rows):
if header_mutation == "missing":
headers.remove("U-U3")
for row in rows:
del row["U-U3"]
else:
headers.append("U-UR1")
for row in rows:
row["U-UR1"] = "0"
path = mutate_csv(tmp_path, "hexa_linearstatic", "U", mutation)
with pytest.raises(COMPARE.ComparisonError) as error:
COMPARE.read_reference(path, "hexa_linearstatic", "U", source_models["hexa_linearstatic"])
assert error.value.code == "wrong-reference-columns"
def test_dynamic_decimal_time_requires_exact_integer_frame(tmp_path, source_models):
path = mutate_csv(tmp_path, "hexa_dynlinear", "U", lambda _, rows: rows[442].update(
Frame="Increment 1: Step Time = 0.1000000001"))
with pytest.raises(COMPARE.ComparisonError) as error:
COMPARE.read_reference(path, "hexa_dynlinear", "U", source_models["hexa_dynlinear"])
assert error.value.code == "wrong-reference-frame-time"
def test_nonfinite_free_reference_rf_is_not_discarded(tmp_path, source_models):
path = mutate_csv(tmp_path, "hexa_linearstatic", "RF", lambda _, rows: rows[0].update({"RF-RF1": "NaN"}))
rows = COMPARE.read_reference(path, "hexa_linearstatic", "RF", source_models["hexa_linearstatic"])
with pytest.raises(COMPARE.ComparisonError) as error:
COMPARE.reference_values(rows, "RF", source_models["hexa_linearstatic"])
assert error.value.code == "nonfinite-reference-value"
def test_inclusive_row_and_rms_boundaries_are_independent():
report = family([21.] + [20.] * 99, [20.] * 100)
assert report["passed"] and report["rows"][0]["relative_error"] == .05
assert not family([21.00001] + [20.] * 99, [20.] * 100)["rows_passed"]
assert family([101.], [100.])["passed"]
assert not family([101.00001], [100.])["rms_passed"]
assert family([100., 2.], [100., 1.])["rows"][1]["branch"] == "near-zero"
@pytest.mark.parametrize("actual,reference", [([1e308], [-1e308]), ([float('inf')], [1.])])
def test_overflow_cannot_create_passing_metric(actual, reference):
with pytest.raises(COMPARE.ComparisonError):
family(actual, reference)
def test_wrong_result_node_beats_nonfinite_displacement(tmp_path, result_fixtures):
path = copy_result(tmp_path, result_fixtures)
with h5py.File(path, "r+") as file:
nodes = file["model/nodes"][:]
nodes[-1]["source_label"] = b"1"
file["model/nodes"][:] = nodes
file["steps/Step-1/frames/0/nodal/displacement"][0, 0] = np.nan
report = compare_fixture(path)
assert report["precheck"]["code"] == "result-node-inventory"
assert report["families"] == []
@pytest.mark.parametrize("attribute,value,code", [
("source_input_identity", "path=wrong;content_identity=fnv1a64:0000000000000000", "wrong-result-attribute"),
("analysis_procedure", "hht-linear-dynamics", "wrong-result-attribute"),
("component_order", "UX,UY,UZ,URX,URY,URZ", "wrong-result-attribute"),
])
def test_metadata_identity_rejects_other_model(tmp_path, result_fixtures, attribute, value, code):
path = copy_result(tmp_path, result_fixtures)
with h5py.File(path, "r+") as file:
file["metadata"].attrs.modify(attribute, value)
assert compare_fixture(path)["precheck"]["code"] == code
@pytest.mark.parametrize("attribute,value", [("component_names", "U1,U3,U2"), ("unit_dimensions", "force"),
("location", "integration-point"), ("coordinate_system", "local")])
def test_result_quantity_semantics_must_match(tmp_path, result_fixtures, attribute, value):
path = copy_result(tmp_path, result_fixtures)
with h5py.File(path, "r+") as file:
file["steps/Step-1/frames/0/nodal/displacement"].attrs.modify(attribute, value)
assert compare_fixture(path)["precheck"]["code"] == "wrong-result-attribute"
@pytest.mark.parametrize("shape,dtype,code", [((442, 6), "<f8", "wrong-result-shape"),
((442, 3), "<f4", "wrong-result-value-type"),
((442, 3), ">f8", "wrong-result-value-type")])
def test_wrong_shape_or_dtype_is_not_sliced_into_supported_components(tmp_path, result_fixtures, shape, dtype, code):
path = copy_result(tmp_path, result_fixtures)
with h5py.File(path, "r+") as file:
key = "steps/Step-1/frames/0/nodal/displacement"
del file[key]
file.create_dataset(key, shape=shape, dtype=dtype)
assert compare_fixture(path)["precheck"]["code"] == code
def test_signed_ids_and_fixed_ascii_are_not_official_compound_schema(tmp_path, result_fixtures):
path = copy_result(tmp_path, result_fixtures)
with h5py.File(path, "r+") as file:
old = file["model/nodes"][:]
del file["model/nodes"]
compound(file["model"], "nodes", [("internal_node_id", "<i8"), ("instance_name", "S16"),
("source_label", "S16"), ("coordinates", "<f8", (3,))], list(old))
assert compare_fixture(path)["precheck"]["code"] == "wrong-result-table-schema"
@pytest.mark.parametrize("target", ["missing", "extra", "noncanonical"])
def test_dynamic_frame_inventory_is_exact(tmp_path, result_fixtures, target):
path = copy_result(tmp_path, result_fixtures, "hexa_dynlinear")
with h5py.File(path, "r+") as file:
root = file["steps/Step-1/frames"]
if target == "missing":
del root["100"]
elif target == "extra":
root.create_group("101")
else:
root.move("1", "01")
assert compare_fixture(path, "hexa_dynlinear")["precheck"]["code"] == "result-frame-inventory"
def test_dynamic_binary_schedule_not_nearest_time(tmp_path, result_fixtures):
path = copy_result(tmp_path, result_fixtures, "hexa_dynlinear")
with h5py.File(path, "r+") as file:
rows = file["steps/Step-1/accepted_frames"][:]
rows[3]["time"] = .3 # Required binary64 3*.1 differs from decimal-literal .3.
file["steps/Step-1/accepted_frames"][:] = rows
assert compare_fixture(path, "hexa_dynlinear")["precheck"]["code"] == "wrong-result-frame-schedule"
def test_result_point_id_mismatch_rejected_before_nan_stress(tmp_path, result_fixtures):
path = copy_result(tmp_path, result_fixtures)
with h5py.File(path, "r+") as file:
rows = file["model/solid_integration_points"][:]
rows[2]["point_id"] = 4
file["model/solid_integration_points"][:] = rows
file["steps/Step-1/frames/0/solid/stress"][0, 0, 0] = np.nan
assert compare_fixture(path)["precheck"]["code"] == "wrong-result-point-identity"
def test_result_free_reaction_is_not_free_residual(tmp_path, result_fixtures):
path = copy_result(tmp_path, result_fixtures)
with h5py.File(path, "r+") as file:
file["steps/Step-1/frames/0/nodal/residual"][0, 0] = 1234.
assert compare_fixture(path)["verdict"] == "passed"
with h5py.File(path, "r+") as file:
file["steps/Step-1/frames/0/nodal/reaction"][0, 0] = 1234.
assert compare_fixture(path)["precheck"]["code"] == "nonzero-free-result-reaction"
def test_nonfinite_uncompared_quantity_is_schema_failure(tmp_path, result_fixtures):
path = copy_result(tmp_path, result_fixtures)
with h5py.File(path, "r+") as file:
file["steps/Step-1/frames/0/global/strain_energy"][()] = np.inf
assert compare_fixture(path)["precheck"]["code"] == "nonfinite-result-value"
def test_source_copy_must_be_byte_identical(tmp_path, result_fixtures):
source = tmp_path / "hexa_linearstatic.inp"
source.write_bytes((REFERENCE / "hexa_linearstatic.inp").read_bytes().replace(b"\r\n", b"\n"))
report = COMPARE.compare("hexa_linearstatic", source, result_fixtures["hexa_linearstatic"])
assert report["precheck"]["code"] == "source-input-hash-mismatch"
def test_report_is_deterministic_and_keeps_family_boundaries(result_fixtures):
first = compare_fixture(result_fixtures["hexa_linearstatic"])
second = compare_fixture(result_fixtures["hexa_linearstatic"])
assert json.dumps(first, sort_keys=True, allow_nan=False) == json.dumps(second, sort_keys=True, allow_nan=False)
assert [row["quantity"] for row in first["families"]] == ["U", "RF", "S"]
assert len({row["reference_sha256"] for row in first["families"]}) == 3
def test_modal_original_absence_is_not_pass(result_fixtures):
report = compare_fixture(result_fixtures["hexa_freq"], "hexa_freq")
assert report["precheck"]["passed"], report["precheck"]
assert report["verdict"] == "not-run" and not report["blocking_passed"]
assert report["families"] == []
@pytest.mark.parametrize("mutation,code", [("duplicate", "duplicate-modal-mode"), ("missing", "modal-mode-inventory"),
("extra", "wrong-modal-mode"), ("unit", "wrong-modal-unit"),
("step", "wrong-modal-step")])
def test_modal_identity_before_nonfinite(mutation, code):
rows = [{"step": "Step-1", "mode": i, "quantity": "frequency", "unit_dimensions": "1/time",
"value": float(i)} for i in range(1, 11)]
actual = [dict(row) for row in rows]
actual[0]["value"] = np.nan
if mutation == "duplicate":
actual[-1]["mode"] = 1
elif mutation == "missing":
actual.pop()
elif mutation == "extra":
actual.append(dict(rows[-1], mode=11))
elif mutation == "unit":
actual[-1]["unit_dimensions"] = "Hz_assumed_SI"
else:
actual[-1]["step"] = "Step-2"
with pytest.raises(COMPARE.ComparisonError) as error:
COMPARE.compare_modal(rows, actual, "frequency", "1/time")
assert error.value.code == code
def test_closed_execution_record_hashes_are_checked(tmp_path, result_fixtures):
path = result_fixtures["hexa_linearstatic"]
record = tmp_path / "native.json"
data = {"case": "hexa_linearstatic", "native_exit": 0, "source_unchanged": True,
"scratch_unchanged": True, "result_exists": True, "command": ["fesa.exe", str(REFERENCE / "hexa_linearstatic.inp")],
"original_sha256": COMPARE.sha256(REFERENCE / "hexa_linearstatic.inp"),
"scratch_sha256": COMPARE.sha256(REFERENCE / "hexa_linearstatic.inp"),
"result_sha256": COMPARE.sha256(path)}
record.write_text(json.dumps(data), encoding="utf-8")
assert compare_fixture(path, execution=record)["execution"]["verified"]
data["native_exit"] = 1
record.write_text(json.dumps(data), encoding="utf-8")
assert compare_fixture(path, execution=record)["precheck"]["code"] == "invalid-execution-evidence"
data["native_exit"], data["result_sha256"] = 0, "0" * 64
record.write_text(json.dumps(data), encoding="utf-8")
assert compare_fixture(path, execution=record)["precheck"]["code"] == "invalid-execution-evidence"
def test_cli_protects_reference_and_has_deterministic_exit(tmp_path, result_fixtures):
original_hash = COMPARE.sha256(REFERENCE / "hexa_linearstatic_U.csv")
command = [sys.executable, str(ROOT / "scripts/verification/compare_hex8.py"),
"--case", "hexa_linearstatic", "--input", str(REFERENCE / "hexa_linearstatic.inp"),
"--results", str(result_fixtures["hexa_linearstatic"])]
denied = subprocess.run(command + ["--report", str(REFERENCE / "hexa_linearstatic_U.csv")], capture_output=True, text=True)
assert denied.returncode == 2
assert COMPARE.sha256(REFERENCE / "hexa_linearstatic_U.csv") == original_hash
report_path = tmp_path / "report.json"
completed = subprocess.run(command + ["--report", str(report_path)], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
assert json.loads(report_path.read_text())["verdict"] == "passed"
def test_result_projection_joins_source_identities_not_storage_order(tmp_path, result_fixtures):
path = copy_result(tmp_path, result_fixtures)
with h5py.File(path, "r+") as file:
nodes = file["model/nodes"][:][::-1]
nodes["internal_node_id"] = np.arange(442, dtype="<u8")
file["model/nodes"][:] = nodes
for name in ("displacement", "reaction", "residual"):
obj = file[f"steps/Step-1/frames/0/nodal/{name}"]
obj[:] = obj[:][::-1]
mask = file["model/nodal_constraint_mask"]
mask[:] = mask[:][::-1]
elements = file["model/elements"][:][::-1]
elements["internal_element_id"] = np.arange(300, dtype="<u8")
elements["node_internal_ids"] = 441 - elements["node_internal_ids"]
file["model/elements"][:] = elements
points = file["model/solid_integration_points"][:].reshape(300, 8)[::-1].reshape(2400)
points["internal_element_id"] = np.repeat(np.arange(300, dtype="<u8"), 8)
file["model/solid_integration_points"][:] = points
for name in ("stress", "strain"):
obj = file[f"steps/Step-1/frames/0/solid/{name}"]
obj[:] = obj[:][::-1]
report = compare_fixture(path)
assert report["verdict"] == "passed", report["precheck"]
@pytest.mark.parametrize("mutation,code", [
("point-natural", "result-coordinate-convention"), ("point-reference", "result-coordinate-convention"),
("connectivity", "wrong-result-element-connectivity"), ("mask", "wrong-result-constraint-identity"),
("node-coordinate", "wrong-result-coordinate"), ("material", "wrong-result-material-value"),
("section", "wrong-result-section-identity"),
])
def test_model_geometry_and_properties_match_frozen_input(tmp_path, result_fixtures, mutation, code):
path = copy_result(tmp_path, result_fixtures)
with h5py.File(path, "r+") as file:
if mutation.startswith("point-"):
obj = file["model/solid_integration_points"]
rows = obj[:]
field = "natural_coordinates" if mutation == "point-natural" else "reference_coordinates"
rows[0][field][0] += .001
obj[:] = rows
elif mutation == "connectivity":
obj = file["model/elements"]
rows = obj[:]
rows[0]["node_internal_ids"][[0, 1]] = rows[0]["node_internal_ids"][[1, 0]]
obj[:] = rows
elif mutation == "mask":
file["model/nodal_constraint_mask"][0, 0] = 1
elif mutation == "node-coordinate":
obj = file["model/nodes"]
rows = obj[:]
rows[0]["coordinates"][0] = .001
obj[:] = rows
elif mutation == "material":
obj = file["model/solid/materials"]
rows = obj[:]
rows[0]["youngs_modulus"] *= 2
obj[:] = rows
else:
obj = file["model/solid/sections"]
rows = obj[:]
rows[0]["source_line"] += 1
obj[:] = rows
assert compare_fixture(path)["precheck"]["code"] == code
@pytest.mark.parametrize("mutation,code", [
("metadata-width", "wrong-result-attribute-type"), ("attribute-ascii", "wrong-result-attribute-type"),
("missing-dataset", "wrong-result-quantity-inventory"), ("extra-component-dataset", "wrong-result-quantity-inventory"),
("compound-member-order", "wrong-result-table-schema"),
])
def test_required_types_and_complete_dataset_inventory(tmp_path, result_fixtures, mutation, code):
path = copy_result(tmp_path, result_fixtures)
with h5py.File(path, "r+") as file:
if mutation == "metadata-width":
obj = file["metadata"]
del obj.attrs["schema_version"]
obj.attrs.create("schema_version", 1, dtype="<u4")
elif mutation == "attribute-ascii":
obj = file["steps/Step-1/frames/0/nodal/displacement"]
del obj.attrs["unit_dimensions"]
obj.attrs.create("unit_dimensions", b"length", dtype="S6")
elif mutation == "missing-dataset":
del file["steps/Step-1/frames/0/nodal/reaction"]
elif mutation == "extra-component-dataset":
file.create_dataset("steps/Step-1/frames/0/nodal/rotation", data=np.zeros((442, 3)))
else:
obj = file["model/solid_integration_points"]
rows = obj[:]
del file[obj.name]
compound(file["model"], "solid_integration_points", [
("point_id", "u1"), ("internal_element_id", "<u8"),
("natural_coordinates", "<f8", (3,)), ("reference_coordinates", "<f8", (3,))],
[(row["point_id"], row["internal_element_id"], row["natural_coordinates"], row["reference_coordinates"]) for row in rows])
assert compare_fixture(path)["precheck"]["code"] == code
def test_reference_raw_hash_rejects_modified_original_copy(tmp_path, result_fixtures):
for quantity in ("U", "RF", "S"):
shutil.copyfile(REFERENCE / f"hexa_linearstatic_{quantity}.csv", tmp_path / f"hexa_linearstatic_{quantity}.csv")
path = tmp_path / "hexa_linearstatic_U.csv"
path.write_bytes(path.read_bytes() + b"\r\n")
report = COMPARE.compare("hexa_linearstatic", REFERENCE / "hexa_linearstatic.inp", result_fixtures["hexa_linearstatic"], tmp_path)
assert report["precheck"]["code"] == "reference-hash-mismatch"
def test_case_families_cannot_share_scale_or_cancel_sign_errors():
assert family([100.], [100.])["passed"]
assert not family([.001], [0.])["passed"]
report = family([21., 19.], [20., 20.])
assert report["rows_passed"]
assert report["absolute_rms"] == 1.
assert report["relative_rms"] == .05
assert not report["rms_passed"]
def test_alternate_reference_directory_cannot_make_original_report_writable(tmp_path, monkeypatch):
original = REFERENCE / "hexa_linearstatic_U.csv"
original_sha = COMPARE.sha256(original)
monkeypatch.setattr(sys, "argv", ["compare_hex8.py", "--case", "hexa_linearstatic",
"--input", str(REFERENCE / "hexa_linearstatic.inp"), "--results", str(tmp_path / "fixture.h5"),
"--references", str(tmp_path / "copied-references"), "--report", str(original)])
monkeypatch.setattr(COMPARE, "compare", lambda *args: {"precheck": {"passed": True},
"case": "hexa_linearstatic", "verdict": "passed", "blocking_failures": [], "blocking_passed": True})
# This tripwire proves the old branch without ever allowing an original write.
write_text = Path.write_text
def protected_write(path, *args, **kwargs):
if path.resolve() == original.resolve():
pytest.fail("Comparator attempted to write an authoritative original reference")
return write_text(path, *args, **kwargs)
monkeypatch.setattr(Path, "write_text", protected_write)
with pytest.raises(SystemExit) as error:
COMPARE.main()
assert error.value.code == 2
assert COMPARE.sha256(original) == original_sha
@pytest.mark.parametrize("shape", [(), (10, 1)])
def test_modal_pair_rank_errors_produce_failed_precheck(tmp_path, result_fixtures, shape):
path = copy_result(tmp_path, result_fixtures, "hexa_freq")
with h5py.File(path, "r+") as file:
key = "steps/Step-1/eigen/verification/pairs"
del file[key]
file.create_dataset(key, shape=shape, dtype="<f8")
report = compare_fixture(path, "hexa_freq")
assert not report["precheck"]["passed"]
assert report["precheck"]["code"] == "wrong-modal-pair-shape"
assert report["families"] == []
def test_hardlink_report_alias_cannot_overwrite_known_source(tmp_path, monkeypatch):
protected = tmp_path / "originals"
protected.mkdir()
original = protected / "reference.csv"
original.write_bytes(b"immutable source fixture\n")
report_alias = tmp_path / "report-alias.json"
os.link(original, report_alias)
original_sha = COMPARE.sha256(original)
monkeypatch.setattr(COMPARE, "REFERENCE_DIRECTORY", protected)
monkeypatch.setattr(sys, "argv", ["compare_hex8.py", "--case", "hexa_linearstatic",
"--input", str(tmp_path / "input.inp"), "--results", str(tmp_path / "fixture.h5"),
"--references", str(tmp_path / "other-references"), "--report", str(report_alias)])
monkeypatch.setattr(COMPARE, "compare", lambda *args: {"precheck": {"passed": True},
"case": "hexa_linearstatic", "verdict": "passed", "blocking_failures": [], "blocking_passed": True})
# Neither the fixture nor its alias is written during the failing old path.
monkeypatch.setattr(Path, "write_text", lambda *args, **kwargs: pytest.fail("Unsafe alias write attempted"))
with pytest.raises(SystemExit) as error:
COMPARE.main()
assert error.value.code == 2
assert COMPARE.sha256(original) == original_sha
def test_lexically_equivalent_input_path_preserves_writer_identity(result_fixtures):
source = REFERENCE / "../hexa/hexa_linearstatic.inp"
report = COMPARE.compare("hexa_linearstatic", source, result_fixtures["hexa_linearstatic"])
assert report["verdict"] == "passed", report["precheck"]