Compare physical slice refinement with shared scalar rendering
This commit is contained in:
@@ -56,6 +56,16 @@ different range. All-positive or all-negative data use a linear color scale.
|
||||
No density or enthalpy values are clipped, replaced by an EOS evaluation, or
|
||||
logged. Extrema in panel titles are sampled extrema.
|
||||
|
||||
Field values are linearly interpolated within each supplied triangle at the
|
||||
centers of a 512-by-512 scalar grid, then mapped to colors. This avoids Gouraud
|
||||
shading's interpolation of RGBA colors, which can change an unchanged affine
|
||||
field's appearance under triangle refinement. `--pixels N` changes this display
|
||||
resolution (32–2048). Uncovered pixels remain masked; a pixel covered by more
|
||||
than one approximate triangle takes the first supplied triangle, with no
|
||||
cross-element averaging. No global triangulation or global point-location
|
||||
topology is inferred. Pixels can miss subpixel features and straddle a boundary;
|
||||
the separately drawn physical outline remains the geometric reference.
|
||||
|
||||
Final-state plots use the surrounding run's convergence flag when available;
|
||||
replayed or initial states are labeled as not assessed by the image. Give a
|
||||
descriptive title, including the physical parameters and any known failure,
|
||||
@@ -115,6 +125,36 @@ neighboring curved hexahedra and do not replace this export refinement check.
|
||||
Globally consistent face triangulation would remove the diagonal mismatch;
|
||||
it is not implemented in this bounded diagnostic.
|
||||
|
||||
The comparison helper enforces a common plane/orientation/offset, increasing
|
||||
subdivision counts, shared physical axes and shared per-field color scales:
|
||||
|
||||
```sh
|
||||
python experiments/compare_model_slices.py \
|
||||
/absolute/path/to/coarse/final_slices/xz \
|
||||
/absolute/path/to/fine/final_slices/xz \
|
||||
--source-state /absolute/path/to/original/accepted_state.txt \
|
||||
--reference-outline --title 'Same rotating trial; unconverged' \
|
||||
--output /absolute/path/outside/source/slice_refinement
|
||||
```
|
||||
|
||||
It writes a side-by-side PNG/PDF and JSON containing triangle-area sums, signed
|
||||
piecewise-linear 2D density/enthalpy integrals, surface bounds, segment-length
|
||||
sums, exported geometric diagnostics and their absolute/relative changes. Area
|
||||
and field integrals include any overlapping triangles; segment-length sums
|
||||
include any duplicate segments. These are measures of the displayed slice,
|
||||
not exact curved-FEM integrals, 3D mass or virial diagnostics. Near-zero signed
|
||||
integrals make relative changes sensitive, so also inspect absolute changes.
|
||||
The two panels use the same scalar raster grid and bounds; its pixel resolution
|
||||
and overlap ownership are recorded. Quantitative integrals are computed from
|
||||
the supplied triangles, independently of the display raster.
|
||||
|
||||
`--source-state` is the caller's declaration of the common saved state and its
|
||||
SHA256 is recorded. The caller must verify that both exports used that state,
|
||||
mesh, field orders and physical specifications. The current export CSVs do not
|
||||
bind themselves to a snapshot, and this helper does not establish provenance
|
||||
from matching plane metadata. Output artifacts must be outside the source
|
||||
checkout and existing files are not overwritten.
|
||||
|
||||
## Checks
|
||||
|
||||
Every export runs pure geometric controls: unit-cube section area, integration
|
||||
@@ -126,6 +166,7 @@ a coincident internal face. The same controls can run without FEM:
|
||||
c++ -std=c++20 -O0 experiments/model_slice_geometry_tests.cpp -o /tmp/model_slice_checks
|
||||
/tmp/model_slice_checks
|
||||
python -m unittest discover -s experiments -p test_plot_model_slices.py -v
|
||||
python -m unittest discover -s experiments -p test_compare_model_slices.py -v
|
||||
```
|
||||
|
||||
The Python controls cover signed independent fields, supplied connectivity for
|
||||
@@ -134,3 +175,10 @@ cross-element triangle, an off-plane field/outline, and shape exaggeration.
|
||||
They are geometry and plotting controls, not a substitute for a coupled-model
|
||||
validation run. The exporter checks that its density, enthalpy and displacement
|
||||
source buffers remain bitwise unchanged.
|
||||
|
||||
The comparison controls use an affine signed density and independent enthalpy
|
||||
on a unit square at two subdivisions. They check exact area/integrals/perimeter,
|
||||
explicit counting of duplicated triangles, shared axes/color normalizations,
|
||||
and rejection of a mismatched offset or reversed refinement. They do not run a
|
||||
FEM model. An additional affine control verifies that coarse and fine scalar
|
||||
rasters agree, and the disconnected-material control verifies masked vacuum.
|
||||
|
||||
162
experiments/compare_model_slices.py
Normal file
162
experiments/compare_model_slices.py
Normal file
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare two visualization resolutions of the same physical FEM state."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from plot_model_slices import Slice, color_scale, load_slice, plt, render
|
||||
|
||||
|
||||
def slice_metrics(data: Slice) -> dict:
|
||||
"""Integrate the displayed triangles, not the underlying curved FE slice."""
|
||||
u, v = int(data.metadata["horizontal_axis"]), int(data.metadata["vertical_axis"])
|
||||
points = data.vertices[data.triangles]
|
||||
x, y = points[:, :, 2 + u], points[:, :, 2 + v]
|
||||
area = .5 * np.abs((x[:, 1] - x[:, 0]) * (y[:, 2] - y[:, 0]) -
|
||||
(x[:, 2] - x[:, 0]) * (y[:, 1] - y[:, 0]))
|
||||
metrics = {
|
||||
"triangle_count": len(data.triangles),
|
||||
"triangle_area_sum": float(np.sum(area)),
|
||||
"piecewise_linear_density_integral": float(np.sum(area * points[:, :, 5].mean(axis=1))),
|
||||
"piecewise_linear_enthalpy_integral": float(np.sum(area * points[:, :, 6].mean(axis=1))),
|
||||
}
|
||||
metrics["exported_geometric_diagnostics"] = {
|
||||
key: float(data.metadata[key]) if key in data.metadata else None
|
||||
for key in ("maximum_plane_error", "maximum_sampled_chord_error", "surface_maximum_plane_error",
|
||||
"reference_surface_maximum_plane_error", "surface_maximum_sampled_chord_error",
|
||||
"reference_surface_maximum_sampled_chord_error")
|
||||
}
|
||||
for name, segments in (("surface", data.surface), ("reference_surface", data.reference_surface)):
|
||||
if len(segments):
|
||||
a, b = segments[:, [u, v]], segments[:, [3 + u, 3 + v]]
|
||||
endpoints = np.concatenate((a, b))
|
||||
values = {"segment_count": len(segments),
|
||||
"segment_length_sum": float(np.linalg.norm(b - a, axis=1).sum()),
|
||||
"u_min": float(endpoints[:, 0].min()), "u_max": float(endpoints[:, 0].max()),
|
||||
"v_min": float(endpoints[:, 1].min()), "v_max": float(endpoints[:, 1].max())}
|
||||
else:
|
||||
values = {"segment_count": 0, "segment_length_sum": 0., "u_min": None, "u_max": None, "v_min": None, "v_max": None}
|
||||
metrics[name] = values
|
||||
return metrics
|
||||
|
||||
|
||||
def differences(coarse: dict, fine: dict) -> dict:
|
||||
result = {}
|
||||
for key in coarse:
|
||||
a, b = coarse[key], fine[key]
|
||||
if isinstance(a, dict):
|
||||
result[key] = differences(a, b)
|
||||
elif a is None or b is None:
|
||||
result[key] = {"coarse": a, "fine": b, "signed_change": None, "absolute_change": None,
|
||||
"relative_change": None, "relative_absolute_change": None}
|
||||
else:
|
||||
delta = b - a
|
||||
relative = delta / abs(a) if a != 0 else (0. if delta == 0 else None)
|
||||
result[key] = {"coarse": a, "fine": b, "signed_change": delta, "absolute_change": abs(delta),
|
||||
"relative_change": relative,
|
||||
"relative_absolute_change": abs(relative) if relative is not None else None}
|
||||
return result
|
||||
|
||||
|
||||
def validate_pair(coarse: Slice, fine: Slice) -> None:
|
||||
if coarse.directory.resolve() == fine.directory.resolve():
|
||||
raise ValueError("Coarse and fine exports must be distinct directories")
|
||||
for key in ("plane", "horizontal_axis", "vertical_axis", "normal_axis"):
|
||||
if coarse.metadata[key] != fine.metadata[key]:
|
||||
raise ValueError("The paired exports have different physical planes")
|
||||
if float(coarse.metadata["offset"]) != float(fine.metadata["offset"]):
|
||||
raise ValueError("The paired exports have different physical plane offsets")
|
||||
if int(coarse.metadata["subdivisions"]) >= int(fine.metadata["subdivisions"]):
|
||||
raise ValueError("Fine subdivisions must exceed coarse subdivisions")
|
||||
|
||||
|
||||
def comparison(coarse: Slice, fine: Slice, source_state: Path) -> dict:
|
||||
validate_pair(coarse, fine)
|
||||
if not source_state.is_file():
|
||||
raise ValueError("--source-state must identify the caller-verified common saved state file")
|
||||
digest = hashlib.sha256()
|
||||
with source_state.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return {
|
||||
"coarse_directory": str(coarse.directory.resolve()), "fine_directory": str(fine.directory.resolve()),
|
||||
"plane": coarse.metadata["plane"], "offset": float(coarse.metadata["offset"]),
|
||||
"coarse_subdivisions": int(coarse.metadata["subdivisions"]), "fine_subdivisions": int(fine.metadata["subdivisions"]),
|
||||
"state_provenance": {
|
||||
"caller_asserted_common_source": str(source_state.resolve()), "source_sha256": digest.hexdigest(),
|
||||
"export_binding_verified": False,
|
||||
"limitation": "Caller must verify that both exports used this same state, mesh, field orders and physical specifications. The CSVs do not bind themselves cryptographically to the source state.",
|
||||
},
|
||||
"changes": differences(slice_metrics(coarse), slice_metrics(fine)),
|
||||
"scope": [
|
||||
"Triangle area and field integrals sum supplied triangles, including any overlaps; they are not union-area measures.",
|
||||
"Field integrals are signed integrals of piecewise-linear vertex data in 2D; they are not 3D mass, pressure, virial or exact curved-FEM integrals.",
|
||||
"Surface segment-length sums approximate perimeter and include any duplicated/overlapping segments.",
|
||||
"Relative change is (fine-coarse)/abs(coarse); it is null for a zero coarse value and a nonzero fine value.",
|
||||
"Near-zero coarse signed integrals can have large relative changes; inspect absolute changes as well.",
|
||||
"Shared physical axes and separate shared density/enthalpy color normalizations preserve actual deformation and signed field values.",
|
||||
"Agreement under one refinement does not certify global geometric conformity, positivity or equilibrium.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def render_comparison(coarse: Slice, fine: Slice, *, reference_outline=False, title=None, pixels=512):
|
||||
validate_pair(coarse, fine)
|
||||
bounds = (min(float(d.metadata["u_min"]) for d in (coarse, fine)),
|
||||
max(float(d.metadata["u_max"]) for d in (coarse, fine)),
|
||||
min(float(d.metadata["v_min"]) for d in (coarse, fine)),
|
||||
max(float(d.metadata["v_max"]) for d in (coarse, fine)))
|
||||
normalizations = [color_scale(np.concatenate((coarse.vertices[:, column], fine.vertices[:, column])))
|
||||
for column in (5, 6)] if len(coarse.vertices) + len(fine.vertices) else None
|
||||
figure, axes = plt.subplots(2, 2, figsize=(13.4, 10.6), layout="constrained")
|
||||
for column, (label, data) in enumerate((("Coarse", coarse), ("Fine", fine))):
|
||||
render(data, reference_outline=reference_outline,
|
||||
title=f"{label}: {data.metadata['subdivisions']} subdivisions",
|
||||
normalizations=normalizations, physical_bounds=bounds,
|
||||
figure=figure, axes=axes[:, column], heading=False, pixels=pixels)
|
||||
figure.suptitle(f"{title or 'Same-state slice refinement'} — physical {coarse.metadata['plane']} plane, offset {float(coarse.metadata['offset']):.6g}\n"
|
||||
f"Common axes/color scales; {pixels}×{pixels} scalar pixels; actual deformation; equilibrium not assessed", fontsize=11)
|
||||
return figure
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("coarse", type=Path, help="Coarse plane directory, e.g. first/final_slices/xz")
|
||||
parser.add_argument("fine", type=Path, help="Fine plane directory for the same saved state")
|
||||
parser.add_argument("--source-state", required=True, type=Path,
|
||||
help="Common saved state file; caller asserts both exports used this state and matching discretization")
|
||||
parser.add_argument("--output", required=True, type=Path, help="New JSON/PNG/PDF prefix outside the source checkout")
|
||||
parser.add_argument("--reference-outline", action="store_true")
|
||||
parser.add_argument("--pixels", type=int, default=512, help="Common scalar sample grid per axis, 32–2048")
|
||||
parser.add_argument("--title")
|
||||
args = parser.parse_args()
|
||||
source_checkout = Path(__file__).resolve().parents[1]
|
||||
if args.output.resolve().is_relative_to(source_checkout):
|
||||
raise ValueError("Comparison artifacts must be written outside the source checkout")
|
||||
paths = [args.output.with_suffix(suffix) for suffix in (".json", ".png", ".pdf")]
|
||||
if any(path.exists() for path in paths):
|
||||
raise FileExistsError("Refusing to overwrite an existing comparison artifact")
|
||||
coarse, fine = load_slice(args.coarse), load_slice(args.fine)
|
||||
report = comparison(coarse, fine, args.source_state)
|
||||
figure = render_comparison(coarse, fine, reference_outline=args.reference_outline, title=args.title, pixels=args.pixels)
|
||||
report["rasterization"] = {"pixels_per_axis": args.pixels, "sample_location": "pixel centers",
|
||||
"scalar_interpolation": "barycentric within each supplied triangle before color mapping",
|
||||
"overlap_owner": "first supplied triangle; no trace averaging", "vacuum": "masked",
|
||||
"limitation": "Subpixel features may be missed; this raster is not an exact curved-FEM evaluation."}
|
||||
report["figures"] = [str(path.resolve()) for path in paths[1:]]
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
paths[0].write_text(json.dumps(report, indent=2, allow_nan=False) + "\n")
|
||||
for path in paths[1:]:
|
||||
figure.savefig(path, dpi=200)
|
||||
plt.close(figure)
|
||||
for path in paths:
|
||||
print(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -12,7 +12,6 @@ matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.collections import LineCollection
|
||||
from matplotlib.colors import Normalize, TwoSlopeNorm
|
||||
from matplotlib.tri import Triangulation
|
||||
import numpy as np
|
||||
|
||||
|
||||
@@ -90,21 +89,69 @@ def color_scale(values: np.ndarray):
|
||||
return Normalize(lower, upper), "viridis", "linear color scale"
|
||||
|
||||
|
||||
def render(data: Slice, *, reference_outline: bool = False, title: str | None = None):
|
||||
def rasterize_fields(data: Slice, bounds, pixels=512):
|
||||
"""Evaluate each triangle's linear scalar interpolant at pixel centers.
|
||||
|
||||
No global point-location topology is assumed: curved-cell chords can have
|
||||
gaps or overlaps. In an overlap the first supplied triangle owns the pixel.
|
||||
This deterministic display rule does not turn overlaps into a union-area
|
||||
diagnostic and never averages independent element traces.
|
||||
"""
|
||||
if not 32 <= pixels <= 2048:
|
||||
raise ValueError("Raster resolution must be between 32 and 2048 pixels per axis")
|
||||
u0, u1, v0, v1 = bounds
|
||||
if not np.isfinite(bounds).all() or u1 <= u0 or v1 <= v0:
|
||||
raise ValueError("Invalid scalar raster bounds")
|
||||
du, dv = (u1 - u0) / pixels, (v1 - v0) / pixels
|
||||
us = u0 + (np.arange(pixels) + .5) * du
|
||||
vs = v0 + (np.arange(pixels) + .5) * dv
|
||||
values = np.zeros((2, pixels, pixels))
|
||||
covered = np.zeros((pixels, pixels), dtype=bool)
|
||||
u, v = int(data.metadata["horizontal_axis"]), int(data.metadata["vertical_axis"])
|
||||
for indices in data.triangles:
|
||||
triangle = data.vertices[indices]
|
||||
x, y = triangle[:, 2 + u], triangle[:, 2 + v]
|
||||
left = max(0, int(np.ceil((x.min() - u0) / du - .5 - 1e-12)))
|
||||
right = min(pixels - 1, int(np.floor((x.max() - u0) / du - .5 + 1e-12)))
|
||||
bottom = max(0, int(np.ceil((y.min() - v0) / dv - .5 - 1e-12)))
|
||||
top = min(pixels - 1, int(np.floor((y.max() - v0) / dv - .5 + 1e-12)))
|
||||
if left > right or bottom > top:
|
||||
continue
|
||||
dx1, dx2, dy1, dy2 = x[1] - x[0], x[2] - x[0], y[1] - y[0], y[2] - y[0]
|
||||
determinant = dx1 * dy2 - dy1 * dx2
|
||||
if determinant == 0:
|
||||
continue
|
||||
dx, dy = us[None, left:right + 1] - x[0], vs[bottom:top + 1, None] - y[0]
|
||||
a = (dx * dy2 - dy * dx2) / determinant
|
||||
b = (dy * dx1 - dx * dy1) / determinant
|
||||
ownership = covered[bottom:top + 1, left:right + 1]
|
||||
inside = (a >= -2e-13) & (b >= -2e-13) & (a + b <= 1 + 2e-13) & ~ownership
|
||||
for field, column in enumerate((5, 6)):
|
||||
scalar = triangle[0, column] + a * (triangle[1, column] - triangle[0, column]) + b * (triangle[2, column] - triangle[0, column])
|
||||
values[field, bottom:top + 1, left:right + 1][inside] = scalar[inside]
|
||||
ownership[inside] = True
|
||||
return np.ma.array(values, mask=np.broadcast_to(~covered, values.shape))
|
||||
|
||||
|
||||
def render(data: Slice, *, reference_outline: bool = False, title: str | None = None,
|
||||
normalizations=None, physical_bounds=None, figure=None, axes=None, heading=True, pixels=512):
|
||||
m, vertices = data.metadata, data.vertices
|
||||
u, v = int(m["horizontal_axis"]), int(m["vertical_axis"])
|
||||
labels = ("x", "y", "z")
|
||||
figure, axes = plt.subplots(1, 2, figsize=(12.4, 5.6), layout="constrained")
|
||||
connectivity = None
|
||||
if len(data.triangles):
|
||||
# Providing connectivity is essential: Delaunay triangulation of this
|
||||
# point cloud would bridge holes and average across DG interfaces.
|
||||
connectivity = Triangulation(vertices[:, 2 + u], vertices[:, 2 + v], triangles=data.triangles)
|
||||
for ax, column, name in zip(axes, (5, 6), ("Independent density", "Specific enthalpy")):
|
||||
if connectivity is not None:
|
||||
if figure is None:
|
||||
figure, axes = plt.subplots(1, 2, figsize=(12.4, 5.6), layout="constrained")
|
||||
elif axes is None or len(axes) != 2:
|
||||
raise ValueError("An existing figure requires two field axes")
|
||||
bounds = physical_bounds if physical_bounds is not None else (float(m["u_min"]), float(m["u_max"]), float(m["v_min"]), float(m["v_max"]))
|
||||
raster = rasterize_fields(data, bounds, pixels) if len(data.triangles) else None
|
||||
for field, (ax, column, name) in enumerate(zip(axes, (5, 6), ("Independent density", "Specific enthalpy"))):
|
||||
if raster is not None:
|
||||
values = vertices[:, column]
|
||||
norm, cmap, note = color_scale(values)
|
||||
artist = ax.tripcolor(connectivity, values, shading="gouraud", norm=norm, cmap=cmap, rasterized=True)
|
||||
norm, cmap, note = color_scale(values) if normalizations is None else normalizations[field]
|
||||
# Colormapping follows scalar interpolation. Gouraud shading would
|
||||
# interpolate RGBA instead, changing an affine field's appearance
|
||||
# under triangle refinement even with identical scalar norms.
|
||||
artist = ax.imshow(raster[field], extent=bounds, origin="lower", interpolation="none", norm=norm, cmap=cmap, rasterized=True)
|
||||
bar = figure.colorbar(artist, ax=ax, shrink=.82, label="model field units")
|
||||
ticks = bar.get_ticks()
|
||||
ticks = ticks[(ticks >= norm.vmin) & (ticks <= norm.vmax)]
|
||||
@@ -120,8 +167,8 @@ def render(data: Slice, *, reference_outline: bool = False, title: str | None =
|
||||
if len(surface):
|
||||
segments = np.stack((surface[:, [u, v]], surface[:, [3 + u, 3 + v]]), axis=1)
|
||||
ax.add_collection(LineCollection(segments, colors=color, linewidths=1.05, linestyles=linestyle, label=label))
|
||||
lower = np.array([float(m["u_min"]), float(m["v_min"])])
|
||||
upper = np.array([float(m["u_max"]), float(m["v_max"])])
|
||||
lower = np.array([bounds[0], bounds[2]])
|
||||
upper = np.array([bounds[1], bounds[3]])
|
||||
if not np.isfinite(np.r_[lower, upper]).all() or np.any(upper <= lower):
|
||||
raise ValueError("Invalid slice display bounds")
|
||||
padding = .04 * max(upper - lower)
|
||||
@@ -142,8 +189,12 @@ def render(data: Slice, *, reference_outline: bool = False, title: str | None =
|
||||
status = "UNCONVERGED state"
|
||||
elif entries.get("converged") == "1":
|
||||
status = "Solver reported convergence; physical validation is separate"
|
||||
figure.suptitle(f"{description} — physical {m['plane']} plane, {labels[int(m['normal_axis'])]}={float(m['offset']):.6g}\n"
|
||||
f"{status}; actual deformation; subdivisions {m['subdivisions']}", fontsize=11)
|
||||
if heading:
|
||||
figure.suptitle(f"{description} — physical {m['plane']} plane, {labels[int(m['normal_axis'])]}={float(m['offset']):.6g}\n"
|
||||
f"{status}; actual deformation; subdivisions {m['subdivisions']}; {pixels}×{pixels} scalar pixels", fontsize=11)
|
||||
else:
|
||||
for ax in axes:
|
||||
ax.set_title(f"{description}\n{ax.get_title()}", fontsize=10)
|
||||
return figure
|
||||
|
||||
|
||||
@@ -152,13 +203,14 @@ def main() -> None:
|
||||
parser.add_argument("directory", type=Path, help="One plane directory, e.g. final_slices/xz")
|
||||
parser.add_argument("--output", type=Path, help="Output prefix; defaults to DIRECTORY/model_slice")
|
||||
parser.add_argument("--reference-outline", action="store_true")
|
||||
parser.add_argument("--pixels", type=int, default=512, help="Scalar sample grid per axis, 32–2048 (default 512)")
|
||||
parser.add_argument("--title")
|
||||
args = parser.parse_args()
|
||||
output = args.output or args.directory / "model_slice"
|
||||
paths = [output.with_suffix(suffix) for suffix in (".png", ".pdf")]
|
||||
if any(path.exists() for path in paths):
|
||||
raise FileExistsError("Refusing to overwrite an existing slice figure")
|
||||
figure = render(load_slice(args.directory), reference_outline=args.reference_outline, title=args.title)
|
||||
figure = render(load_slice(args.directory), reference_outline=args.reference_outline, title=args.title, pixels=args.pixels)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
for path in paths:
|
||||
figure.savefig(path, dpi=200)
|
||||
|
||||
124
experiments/test_compare_model_slices.py
Normal file
124
experiments/test_compare_model_slices.py
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Affine same-state controls for physical-slice refinement summaries."""
|
||||
import csv
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
|
||||
import compare_model_slices as compare
|
||||
from plot_model_slices import load_slice, plt, rasterize_fields
|
||||
|
||||
|
||||
def write_csv(path, header, rows):
|
||||
with path.open("w", newline="") as stream:
|
||||
writer = csv.writer(stream)
|
||||
writer.writerow(header)
|
||||
writer.writerows(rows)
|
||||
|
||||
|
||||
def square(directory, subdivisions, offset=.2):
|
||||
directory.mkdir()
|
||||
vertices, triangles = [], []
|
||||
n = subdivisions
|
||||
for j in range(n):
|
||||
for i in range(n):
|
||||
corners = [(i / n, j / n), ((i + 1) / n, j / n), (i / n, (j + 1) / n), ((i + 1) / n, (j + 1) / n)]
|
||||
for indices in ((0, 1, 3), (0, 3, 2)):
|
||||
start = len(vertices)
|
||||
triangles.append([len(triangles), 0, start, start + 1, start + 2])
|
||||
for corner in indices:
|
||||
x, z = corners[corner]
|
||||
vertices.append([len(vertices), 0, x, offset, z, x - .5, 1 + x + 2 * z, 0])
|
||||
write_csv(directory / "vertices.csv", ["vertex", "element", "x", "y", "z", "rho", "h", "plane_error"], vertices)
|
||||
write_csv(directory / "triangles.csv", ["triangle", "element", "v0", "v1", "v2"], triangles)
|
||||
segments = []
|
||||
for i in range(n):
|
||||
a, b = i / n, (i + 1) / n
|
||||
for x0, z0, x1, z1 in ((a, 0, b, 0), (1, a, 1, b), (b, 1, a, 1), (0, b, 0, a)):
|
||||
segments.append([x0, offset, z0, x1, offset, z1, 0])
|
||||
for name in ("surface.csv", "reference_surface.csv"):
|
||||
write_csv(directory / name, ["x0", "y0", "z0", "x1", "y1", "z1", "maximum_plane_error"], segments)
|
||||
metadata = dict(plane="xz", horizontal_axis=0, vertical_axis=2, normal_axis=1, offset=offset,
|
||||
root_tolerance=1e-11, deformation_scale=1, vertex_count=len(vertices), triangle_count=len(triangles),
|
||||
surface_segments=len(segments), reference_surface_segments=len(segments), surface_coplanar_triangles=0,
|
||||
reference_surface_coplanar_triangles=0, subdivisions=n, u_min=0, u_max=1, v_min=0, v_max=1)
|
||||
write_csv(directory / "metadata.csv", ["metric", "value"], metadata.items())
|
||||
|
||||
|
||||
class ComparisonTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temporary.name)
|
||||
square(self.root / "coarse", 2)
|
||||
square(self.root / "fine", 4)
|
||||
self.coarse = load_slice(self.root / "coarse")
|
||||
self.fine = load_slice(self.root / "fine")
|
||||
self.source = self.root / "synthetic_state.txt"
|
||||
self.source.write_text("rho=x-0.5; h=1+x+2z; unit square; y=0.2\n")
|
||||
|
||||
def tearDown(self):
|
||||
plt.close("all")
|
||||
self.temporary.cleanup()
|
||||
|
||||
def test_affine_integrals_and_perimeter_do_not_change_under_refinement(self):
|
||||
report = compare.comparison(self.coarse, self.fine, self.source)
|
||||
for key, expected in (("triangle_area_sum", 1.), ("piecewise_linear_density_integral", 0.),
|
||||
("piecewise_linear_enthalpy_integral", 2.5)):
|
||||
self.assertAlmostEqual(report["changes"][key]["coarse"], expected, places=14)
|
||||
self.assertAlmostEqual(report["changes"][key]["fine"], expected, places=14)
|
||||
self.assertLess(report["changes"][key]["absolute_change"], 1e-14)
|
||||
self.assertEqual(report["changes"]["surface"]["segment_length_sum"]["fine"], 4.)
|
||||
self.assertEqual(report["changes"]["surface"]["u_min"]["fine"], 0.)
|
||||
self.assertEqual(report["changes"]["surface"]["v_max"]["fine"], 1.)
|
||||
self.assertFalse(report["state_provenance"]["export_binding_verified"])
|
||||
self.assertEqual(len(report["state_provenance"]["source_sha256"]), 64)
|
||||
|
||||
def test_overlap_is_counted_not_hidden_as_union_area(self):
|
||||
self.fine.triangles = np.concatenate((self.fine.triangles, self.fine.triangles))
|
||||
metrics = compare.slice_metrics(self.fine)
|
||||
self.assertEqual(metrics["triangle_area_sum"], 2.)
|
||||
self.assertEqual(metrics["piecewise_linear_enthalpy_integral"], 5.)
|
||||
|
||||
def test_shared_axes_and_signed_color_ranges(self):
|
||||
figure = compare.render_comparison(self.coarse, self.fine, reference_outline=True)
|
||||
axes = figure.axes[:4]
|
||||
for ax in axes:
|
||||
self.assertEqual(ax.get_xlim(), axes[0].get_xlim())
|
||||
self.assertEqual(ax.get_ylim(), axes[0].get_ylim())
|
||||
self.assertEqual(ax.get_aspect(), 1.)
|
||||
self.assertIs(axes[0].images[0].norm, axes[1].images[0].norm)
|
||||
self.assertIs(axes[2].images[0].norm, axes[3].images[0].norm)
|
||||
self.assertEqual(axes[0].images[0].norm.vmin, -.5)
|
||||
self.assertEqual(axes[0].images[0].norm.vmax, .5)
|
||||
for extension in ("png", "pdf"):
|
||||
path = self.root / ("comparison." + extension)
|
||||
figure.savefig(path)
|
||||
self.assertGreater(path.stat().st_size, 1000)
|
||||
|
||||
def test_affine_scalar_raster_is_refinement_invariant(self):
|
||||
a = rasterize_fields(self.coarse, (0, 1, 0, 1), 65)
|
||||
b = rasterize_fields(self.fine, (0, 1, 0, 1), 65)
|
||||
self.assertFalse(a.mask.any())
|
||||
self.assertFalse(b.mask.any())
|
||||
np.testing.assert_allclose(a.data, b.data, rtol=0, atol=1e-14)
|
||||
|
||||
def test_different_plane_offset_is_rejected(self):
|
||||
square(self.root / "wrong_offset", 4, offset=.3)
|
||||
with self.assertRaisesRegex(ValueError, "offsets"):
|
||||
compare.comparison(self.coarse, load_slice(self.root / "wrong_offset"), self.source)
|
||||
|
||||
def test_reversed_refinement_is_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError, "exceed"):
|
||||
compare.comparison(self.fine, self.coarse, self.source)
|
||||
|
||||
def test_relative_zero_denominator_is_explicit(self):
|
||||
result = compare.differences({"zero": 0., "negative": -2.}, {"zero": 1., "negative": -3.})
|
||||
self.assertIsNone(result["zero"]["relative_change"])
|
||||
self.assertEqual(result["negative"]["relative_change"], -.5)
|
||||
self.assertEqual(result["negative"]["relative_absolute_change"], .5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -64,6 +64,8 @@ class ModelSlicePlotTests(unittest.TestCase):
|
||||
for triangle in data.triangles:
|
||||
horizontal = data.vertices[triangle, 2]
|
||||
self.assertTrue(np.all(horizontal < 0) or np.all(horizontal > 0))
|
||||
raster = slices.rasterize_fields(data, (-2, 2, 0, 1), 64)
|
||||
self.assertTrue(raster.mask[:, :, 24:40].all())
|
||||
figure = slices.render(data, reference_outline=True, title="Analytic geometry/schema control")
|
||||
self.assertEqual(figure.axes[0].get_aspect(), 1.)
|
||||
self.assertEqual(figure.axes[1].get_aspect(), 1.)
|
||||
@@ -77,6 +79,20 @@ class ModelSlicePlotTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "different material elements"):
|
||||
slices.load_slice(self.directory)
|
||||
|
||||
def test_raster_overlap_uses_first_triangle_without_trace_averaging(self):
|
||||
data = slices.load_slice(self.directory)
|
||||
original = slices.rasterize_fields(data, (-2, 2, 0, 1), 64)
|
||||
extra = data.vertices[:3].copy()
|
||||
extra[:, 0] += len(data.vertices)
|
||||
extra[:, 1] = 2
|
||||
extra[:, 5] = 100
|
||||
extra[:, 6] = -100
|
||||
data.vertices = np.concatenate((data.vertices, extra))
|
||||
data.triangles = np.concatenate((data.triangles, [[6, 7, 8]]))
|
||||
overlapped = slices.rasterize_fields(data, (-2, 2, 0, 1), 64)
|
||||
np.testing.assert_array_equal(original.mask, overlapped.mask)
|
||||
np.testing.assert_array_equal(original.data, overlapped.data)
|
||||
|
||||
def test_wrong_physical_plane_is_rejected(self):
|
||||
path = self.directory / "vertices.csv"
|
||||
text = path.read_text().replace("0,0,-2,0.2,0,", "0,0,-2,0.25,0,")
|
||||
|
||||
Reference in New Issue
Block a user