171 lines
9.1 KiB
Python
171 lines
9.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Plot physical-plane FEM cuts without joining material across the vacuum."""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
import matplotlib
|
|
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
|
|
|
|
|
|
@dataclass
|
|
class Slice:
|
|
directory: Path
|
|
metadata: dict[str, str]
|
|
vertices: np.ndarray
|
|
triangles: np.ndarray
|
|
surface: np.ndarray
|
|
reference_surface: np.ndarray
|
|
|
|
|
|
def table(path: Path, names: list[str]) -> np.ndarray:
|
|
with path.open(newline="") as stream:
|
|
reader = csv.DictReader(stream)
|
|
if reader.fieldnames is None or any(name not in reader.fieldnames for name in names):
|
|
raise ValueError(f"Missing required columns in {path}")
|
|
rows = [[float(row[name]) for name in names] for row in reader]
|
|
values = np.asarray(rows, dtype=float).reshape((-1, len(names)))
|
|
if not np.isfinite(values).all():
|
|
raise ValueError(f"Nonfinite coordinates or fields in {path}")
|
|
return values
|
|
|
|
|
|
def load_slice(directory: Path) -> Slice:
|
|
with (directory / "metadata.csv").open(newline="") as stream:
|
|
metadata = {row["metric"]: row["value"] for row in csv.DictReader(stream)}
|
|
axes = {"xz": (0, 2, 1), "xy": (0, 1, 2), "yz": (1, 2, 0)}
|
|
plane = metadata["plane"]
|
|
if plane not in axes or tuple(int(metadata[name]) for name in ("horizontal_axis", "vertical_axis", "normal_axis")) != axes[plane]:
|
|
raise ValueError("Slice orientation metadata is inconsistent")
|
|
if float(metadata["deformation_scale"]) != 1:
|
|
raise ValueError("This plotter requires actual deformation with scale exactly one")
|
|
tolerance, offset = float(metadata["root_tolerance"]), float(metadata["offset"])
|
|
if not np.isfinite([tolerance, offset]).all() or tolerance <= 0:
|
|
raise ValueError("Invalid physical-plane tolerance or offset")
|
|
v = table(directory / "vertices.csv", ["vertex", "element", "x", "y", "z", "rho", "h", "plane_error"])
|
|
t = table(directory / "triangles.csv", ["triangle", "element", "v0", "v1", "v2"])
|
|
if len(v) != int(metadata["vertex_count"]) or len(t) != int(metadata["triangle_count"]):
|
|
raise ValueError("Slice row counts disagree with metadata")
|
|
if not np.array_equal(v[:, 0], np.arange(len(v))) or not np.array_equal(t[:, 0], np.arange(len(t))):
|
|
raise ValueError("Slice indices must be contiguous")
|
|
connectivity = t[:, 2:].astype(int)
|
|
if not np.array_equal(connectivity, t[:, 2:]) or (connectivity.size and (connectivity.min() < 0 or connectivity.max() >= len(v))):
|
|
raise ValueError("Invalid supplied triangle connectivity")
|
|
if len(t) and not np.all(v[connectivity, 1] == t[:, 1, None]):
|
|
raise ValueError("A triangle joins different material elements")
|
|
normal = axes[plane][2]
|
|
if len(v) and (np.max(np.abs(v[:, 2 + normal] - offset)) > tolerance * 1.01 or np.max(v[:, 7]) > tolerance * 1.01):
|
|
raise ValueError("Field samples do not lie on the requested physical plane")
|
|
surfaces = []
|
|
for filename, count in (("surface.csv", "surface_segments"), ("reference_surface.csv", "reference_surface_segments")):
|
|
s = table(directory / filename, ["x0", "y0", "z0", "x1", "y1", "z1", "maximum_plane_error"])
|
|
if len(s) != int(metadata[count]):
|
|
raise ValueError("Surface row count disagrees with metadata")
|
|
if len(s) and (np.max(np.abs(s[:, [normal, 3 + normal]] - offset)) > tolerance * 1.01 or np.max(s[:, 6]) > tolerance * 1.01):
|
|
raise ValueError("An outline does not intersect the requested physical plane")
|
|
surfaces.append(s)
|
|
if int(metadata["surface_coplanar_triangles"]) or int(metadata["reference_surface_coplanar_triangles"]):
|
|
raise ValueError("A surface patch is coplanar; this is not a resolved curve intersection")
|
|
if len(t) and not len(surfaces[0]):
|
|
raise ValueError("A nonempty material slice needs its deformed boundary")
|
|
return Slice(directory, metadata, v, connectivity, *surfaces)
|
|
|
|
|
|
def color_scale(values: np.ndarray):
|
|
"""Keep the sign visible; neither abs, logarithms nor clipping changes data."""
|
|
lower, upper = float(values.min()), float(values.max())
|
|
if lower < 0 < upper:
|
|
return TwoSlopeNorm(vmin=lower, vcenter=0, vmax=upper), "coolwarm", "color scale centered at zero"
|
|
if lower == upper:
|
|
delta = max(abs(lower) * .01, 1e-12)
|
|
return Normalize(lower - delta, upper + delta), "viridis", "linear color scale"
|
|
return Normalize(lower, upper), "viridis", "linear color scale"
|
|
|
|
|
|
def render(data: Slice, *, reference_outline: bool = False, title: str | None = None):
|
|
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:
|
|
values = vertices[:, column]
|
|
norm, cmap, note = color_scale(values)
|
|
artist = ax.tripcolor(connectivity, values, shading="gouraud", 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)]
|
|
bar.set_ticks(np.unique(np.r_[norm.vmin, ticks, norm.vmax]))
|
|
ax.set_title(f"{name}\nsampled min {values.min():.5g}; max {values.max():.5g}\n{note}", fontsize=10)
|
|
else:
|
|
ax.set_title(name)
|
|
ax.text(.5, .5, "No material intersection resolved", ha="center", va="center", transform=ax.transAxes)
|
|
for surface, color, linestyle, label in (
|
|
(data.surface, "#111111", "solid", "Deformed material boundary"),
|
|
(data.reference_surface if reference_outline else np.empty((0, 7)), "#656565", "dashed", "Undeformed boundary"),
|
|
):
|
|
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"])])
|
|
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)
|
|
ax.set_xlim(lower[0] - padding, upper[0] + padding)
|
|
ax.set_ylim(lower[1] - padding, upper[1] + padding)
|
|
ax.set_aspect("equal", adjustable="box")
|
|
ax.set_xlabel(f"{labels[u]} (model length units)")
|
|
ax.set_ylabel(f"{labels[v]} (model length units)")
|
|
ax.set_facecolor("#f3f3f3")
|
|
if len(data.surface) or (reference_outline and len(data.reference_surface)):
|
|
ax.legend(loc="upper right", fontsize=7, framealpha=.9)
|
|
description = title or data.directory.parent.name.replace("_", " ")
|
|
status = "Equilibrium not assessed by this image"
|
|
run_metadata = data.directory.parent.parent / "metadata.txt"
|
|
if data.directory.parent.name == "final_slices" and run_metadata.exists():
|
|
entries = dict(line.split("=", 1) for line in run_metadata.read_text().splitlines() if "=" in line)
|
|
if entries.get("converged") == "0":
|
|
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)
|
|
return figure
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
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("--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)
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
for path in paths:
|
|
figure.savefig(path, dpi=200)
|
|
print(path)
|
|
plt.close(figure)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|