feat(verification): compare Hex8 original result identities and quantities
This commit is contained in:
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user