Integrate Refactor solver sources and dual MSVC CMake builds
This commit is contained in:
@@ -0,0 +1,856 @@
|
||||
"""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": "lumped-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"),
|
||||
("mass_formulation", "consistent-reference", "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(tmp_path, result_fixtures):
|
||||
report = COMPARE.compare("hexa_freq", REFERENCE / "hexa_freq.inp",
|
||||
result_fixtures["hexa_freq"], tmp_path)
|
||||
assert report["precheck"]["passed"], report["precheck"]
|
||||
assert report["verdict"] == "not-run" and not report["blocking_passed"]
|
||||
assert report["families"] == []
|
||||
|
||||
|
||||
def test_supplied_modal_values_are_compared_not_reported_missing(result_fixtures):
|
||||
report = compare_fixture(result_fixtures["hexa_freq"], "hexa_freq")
|
||||
assert report["precheck"]["passed"], report["precheck"]
|
||||
# The artificial eigenvalues 1..10 must fail against genuine source Values.
|
||||
assert report["verdict"] == "failed"
|
||||
assert len(report["blocking_failures"]) == 10
|
||||
assert report["precheck"]["reference_row_counts"]["Value"] == 10
|
||||
assert [f["mode"] for f in report["families"]] == list(range(1, 11))
|
||||
assert all(f["quantity"] == "eigenvalue" for f in report["families"])
|
||||
|
||||
|
||||
def test_modal_eigenvalue_matches_pass_with_ten_independent_families(tmp_path, result_fixtures):
|
||||
path = copy_result(tmp_path, result_fixtures, "hexa_freq")
|
||||
reference = COMPARE.read_modal_reference(REFERENCE / "hexa_freq.txt")
|
||||
with h5py.File(path, "r+") as file:
|
||||
eigen = file["steps/Step-1/eigen"]
|
||||
for name, field in (("modes", "eigenvalue"), ("verification/pairs", "eigenvalue"),
|
||||
("frequencies", "frequency")):
|
||||
rows = eigen[name][:]
|
||||
rows[field] = [math.sqrt(row["value"]) / (2 * math.pi) if field == "frequency"
|
||||
else row["value"] for row in reference]
|
||||
eigen[name][:] = rows
|
||||
first = compare_fixture(path, "hexa_freq")
|
||||
assert first["verdict"] == "passed", first
|
||||
assert first["blocking_passed"] and first["blocking_failures"] == []
|
||||
assert len({f["identity"] for f in first["families"]}) == 10
|
||||
assert all(f["relative_rms"] == 0 and f["row_count"] == 1 for f in first["families"])
|
||||
assert first == compare_fixture(path, "hexa_freq")
|
||||
|
||||
|
||||
def test_modal_text_reads_value_not_cycles_per_time(tmp_path):
|
||||
original = REFERENCE / "hexa_freq.txt"
|
||||
rows = COMPARE.read_modal_reference(original)
|
||||
assert rows[0] == {"step": "Step-1", "mode": 1, "quantity": "eigenvalue",
|
||||
"unit_dimensions": "1/time^2", "value": 496.28}
|
||||
assert rows[-1]["value"] == 449260.0
|
||||
reversed_path = tmp_path / "reversed.txt"
|
||||
reversed_path.write_text("\n".join(reversed(original.read_text().splitlines())), encoding="utf-8")
|
||||
assert COMPARE.read_modal_reference(reversed_path) == rows
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation,code", [
|
||||
("missing", "modal-mode-inventory"), ("duplicate", "duplicate-modal-mode"),
|
||||
("extra", "wrong-modal-mode"), ("unit", "wrong-modal-reference-format"),
|
||||
("quantity", "wrong-modal-reference-format"), ("nan", "nonfinite-modal-reference"),
|
||||
("negative", "invalid-modal-reference-value"), ("order", "wrong-modal-order"),
|
||||
])
|
||||
def test_modal_text_rejects_invalid_source(tmp_path, mutation, code):
|
||||
lines = [line for line in (REFERENCE / "hexa_freq.txt").read_text().splitlines() if line.strip()]
|
||||
if mutation == "missing":
|
||||
lines.pop()
|
||||
elif mutation == "duplicate":
|
||||
lines.append(lines[0])
|
||||
elif mutation == "extra":
|
||||
lines.append("Mode 11: Value = 500000 Freq = 112.54 (cycles/time)")
|
||||
elif mutation == "unit":
|
||||
lines[0] = lines[0].replace("cycles/time", "Hz")
|
||||
elif mutation == "quantity":
|
||||
lines[0] = lines[0].replace("Value", "Frequency")
|
||||
elif mutation == "nan":
|
||||
lines[0] = lines[0].replace("496.28", "NaN")
|
||||
elif mutation == "negative":
|
||||
lines[0] = lines[0].replace("3.5456", "-3.5456")
|
||||
else:
|
||||
lines[0] = lines[0].replace("496.28", "900000")
|
||||
path = tmp_path / "invalid.txt"
|
||||
path.write_text("\n".join(lines), encoding="utf-8")
|
||||
with pytest.raises(COMPARE.ComparisonError) as error:
|
||||
COMPARE.read_modal_reference(path)
|
||||
assert error.value.code == code
|
||||
|
||||
|
||||
def test_modified_modal_reference_is_precheck_failure(tmp_path, result_fixtures):
|
||||
(tmp_path / "hexa_freq.txt").write_bytes((REFERENCE / "hexa_freq.txt").read_bytes() + b"\n")
|
||||
report = COMPARE.compare("hexa_freq", REFERENCE / "hexa_freq.inp",
|
||||
result_fixtures["hexa_freq"], tmp_path)
|
||||
assert report["verdict"] == "failed"
|
||||
assert report["precheck"]["code"] == "reference-hash-mismatch"
|
||||
|
||||
|
||||
@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