This commit uses global pre allocated work space to dramatically reduce memory usage and allocation time
288 lines
16 KiB
Python
288 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Create a static SVG and Markdown summary of polytrope verification CSVs.
|
|
|
|
Uses only the Python standard library. Does not rerun the solver or alter input
|
|
CSVs; writes polytrope_profiles.svg and polytrope_summary.md in the input directory.
|
|
"""
|
|
|
|
import argparse
|
|
import csv
|
|
import html
|
|
import math
|
|
from pathlib import Path
|
|
|
|
|
|
FIELDS = (
|
|
("density_material", "theta_density", "density", "#2563eb"),
|
|
("enthalpy_material", "theta_enthalpy", "enthalpy", "#15803d"),
|
|
("potential", "theta_potential", "potential", "#c2410c"),
|
|
("gravity_radial", None, "radial gravity gradient", "#7e22ce"),
|
|
)
|
|
|
|
|
|
def read_rows(path):
|
|
with path.open(newline="") as stream:
|
|
return list(csv.DictReader(stream))
|
|
|
|
|
|
def number(value):
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return math.nan
|
|
|
|
|
|
def read_metrics(directory):
|
|
return {row["metric"]: number(row["value"])
|
|
for row in read_rows(directory / "physical_metrics.csv")}
|
|
|
|
|
|
def metadata(directory):
|
|
path = directory / "metadata.txt"
|
|
if not path.exists():
|
|
return {}
|
|
return dict(line.split("=", 1) for line in path.read_text().splitlines() if "=" in line)
|
|
|
|
|
|
def finite_max(values):
|
|
return max((value for value in values if math.isfinite(value)), default=math.nan)
|
|
|
|
|
|
def format_number(value):
|
|
return f"{value:.5g}" if math.isfinite(value) else "not available"
|
|
|
|
|
|
def reference_scales(rows):
|
|
origin = next((row for row in rows if number(row.get("xi")) == 0.0), None)
|
|
if origin is None:
|
|
raise ValueError("radial_profiles.csv must contain its analytic origin reference")
|
|
density = number(origin["density_material_analytic"])
|
|
enthalpy = number(origin["enthalpy_material_analytic"])
|
|
nonzero = next(row for row in rows if number(row.get("xi")) > 0.0)
|
|
radius = math.pi * number(nonzero["radius"]) / number(nonzero["xi"])
|
|
if not all(math.isfinite(value) and value > 0.0 for value in (density, enthalpy, radius)):
|
|
raise ValueError("Non-finite/non-positive fixed analytic reference scales")
|
|
return {"density_material": density, "enthalpy_material": enthalpy,
|
|
"potential": enthalpy, "gravity_radial": enthalpy / radius,
|
|
"radius": radius}
|
|
|
|
|
|
def points(rows, column, scale=1.0, interior=False):
|
|
result = []
|
|
for row in rows:
|
|
xi = number(row.get("xi"))
|
|
if interior and xi > math.pi:
|
|
continue
|
|
result.append((xi, number(row.get(column)) / scale))
|
|
return result
|
|
|
|
|
|
def path_segments(data, xmap, ymap, logarithmic=False):
|
|
segments, current = [], []
|
|
for x, y in data:
|
|
if not math.isfinite(x) or not math.isfinite(y) or (logarithmic and y <= 0.0):
|
|
if current:
|
|
segments.append(current)
|
|
current = []
|
|
continue
|
|
current.append((xmap(x), ymap(y)))
|
|
if current:
|
|
segments.append(current)
|
|
return segments
|
|
|
|
|
|
class Figure:
|
|
def __init__(self, title):
|
|
self.parts = [
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="910" viewBox="0 0 1200 910" role="img">',
|
|
f"<title>{html.escape(title)}</title>",
|
|
'<desc>Fixed-reference n=1 physical-radius profiles, absolute mean errors, angular scatter, and sampling coverage.</desc>',
|
|
'<rect width="1200" height="910" fill="#ffffff"/>',
|
|
'<style>text{font-family:Arial,Helvetica,sans-serif;fill:#1f2937;font-size:12px}.title{font-size:22px;font-weight:bold}.panel{font-size:15px;font-weight:bold}.small{font-size:11px}</style>',
|
|
f'<text class="title" x="65" y="35">{html.escape(title)}</text>',
|
|
'<text x="65" y="58">Physical spheres; ξ = πr/R. Reference R and central scales are prescribed, never fitted.</text>',
|
|
]
|
|
|
|
def panel(self, ident, box, title, xlabel, ylabel, xlim, ylim, series, log=False):
|
|
left, top, width, height = box
|
|
xmap = lambda value: left + width * (value - xlim[0]) / (xlim[1] - xlim[0])
|
|
if log:
|
|
lower, upper = math.log10(ylim[0]), math.log10(ylim[1])
|
|
ymap = lambda value: top + height * (upper - math.log10(value)) / (upper - lower)
|
|
ticks = [(10.0 ** exponent, f"1e{exponent}")
|
|
for exponent in range(math.ceil(lower), math.floor(upper) + 1)]
|
|
if len(ticks) > 7:
|
|
ticks = ticks[::math.ceil(len(ticks) / 7)]
|
|
else:
|
|
ymap = lambda value: top + height * (ylim[1] - value) / (ylim[1] - ylim[0])
|
|
ticks = [(ylim[0] + i * (ylim[1] - ylim[0]) / 4.0,
|
|
f"{ylim[0] + i * (ylim[1] - ylim[0]) / 4.0:.2g}") for i in range(5)]
|
|
self.parts.append(f'<text class="panel" x="{left}" y="{top - 58}">{html.escape(title)}</text>')
|
|
self.parts.append(f'<defs><clipPath id="{ident}"><rect x="{left}" y="{top}" width="{width}" height="{height}"/></clipPath></defs>')
|
|
for value, label in ticks:
|
|
y = ymap(value)
|
|
self.parts.append(f'<line x1="{left}" x2="{left + width}" y1="{y:.3f}" y2="{y:.3f}" stroke="#e5e7eb"/>')
|
|
self.parts.append(f'<text x="{left - 9}" y="{y + 4:.3f}" text-anchor="end">{label}</text>')
|
|
for i in range(5):
|
|
value = xlim[0] + i * (xlim[1] - xlim[0]) / 4.0
|
|
x = xmap(value)
|
|
self.parts.append(f'<line x1="{x:.3f}" x2="{x:.3f}" y1="{top}" y2="{top + height}" stroke="#f1f5f9"/>')
|
|
self.parts.append(f'<text x="{x:.3f}" y="{top + height + 20}" text-anchor="middle">{value:.3g}</text>')
|
|
self.parts.append(f'<rect x="{left}" y="{top}" width="{width}" height="{height}" fill="none" stroke="#64748b"/>')
|
|
self.parts.append(f'<text x="{left + width / 2}" y="{top + height + 42}" text-anchor="middle">{html.escape(xlabel)}</text>')
|
|
self.parts.append(f'<text transform="translate({left - 57},{top + height / 2}) rotate(-90)" text-anchor="middle">{html.escape(ylabel)}</text>')
|
|
legend_index = 0
|
|
for item in series:
|
|
dash = ' stroke-dasharray="6 4"' if item.get("dash") else ""
|
|
for segment in path_segments(item["data"], xmap, ymap, log):
|
|
if len(segment) == 1:
|
|
x, y = segment[0]
|
|
self.parts.append(f'<circle clip-path="url(#{ident})" cx="{x:.3f}" cy="{y:.3f}" r="2" fill="{item["color"]}"/>')
|
|
else:
|
|
coordinates = " ".join(f"{x:.3f},{y:.3f}" for x, y in segment)
|
|
self.parts.append(f'<polyline clip-path="url(#{ident})" points="{coordinates}" fill="none" stroke="{item["color"]}" stroke-width="1.8"{dash}/>')
|
|
if item.get("label"):
|
|
x = left + (legend_index % 2) * width / 2
|
|
y = top - 35 + (legend_index // 2) * 17
|
|
self.parts.append(f'<line x1="{x}" x2="{x + 20}" y1="{y}" y2="{y}" stroke="{item["color"]}" stroke-width="2"{dash}/>')
|
|
self.parts.append(f'<text class="small" x="{x + 26}" y="{y + 4}">{html.escape(item["label"])}</text>')
|
|
legend_index += 1
|
|
|
|
def write(self, path, control):
|
|
note = "Solid: measured state. Dashed same-color: analytic-mesh control." if control else "Mean errors and scatter are separately scaled by fixed central/reference values."
|
|
self.parts.append(f'<text class="small" x="65" y="864">{html.escape(note)}</text>')
|
|
self.parts.append('<text class="small" x="65" y="884">Missing/nonfinite samples are not connected; zero errors are omitted on logarithmic axes. Material means are conditional.</text>')
|
|
self.parts.append("</svg>")
|
|
path.write_text("\n".join(self.parts) + "\n")
|
|
|
|
|
|
def logarithmic_limits(series):
|
|
values = [y for item in series for _, y in item["data"] if math.isfinite(y) and y > 0.0]
|
|
if not values:
|
|
return 1e-16, 1.0
|
|
lower = max(-300, math.floor(math.log10(min(values))))
|
|
upper = max(lower + 2, math.ceil(math.log10(max(values))))
|
|
return 10.0 ** lower, 10.0 ** upper
|
|
|
|
|
|
def make_figure(directory, rows, control_rows):
|
|
scales = reference_scales(rows)
|
|
control_scales = reference_scales(control_rows) if control_rows else {}
|
|
if control_rows:
|
|
for key in scales:
|
|
if not math.isclose(scales[key], control_scales[key], rel_tol=1e-12):
|
|
raise ValueError(f"Control and measured fixed-reference scales differ: {key}")
|
|
figure = Figure("n = 1 polytrope: physical profile verification")
|
|
analytic = [(math.pi * i / 300, math.sin(math.pi * i / 300) / (math.pi * i / 300) if i else 1.0)
|
|
for i in range(301)]
|
|
profile_series = [{"data": analytic, "color": "#111827", "label": "analytic sin(ξ)/ξ", "dash": True}]
|
|
for _, column, label, color in FIELDS[:3]:
|
|
profile_series.append({"data": points(rows, column, interior=True), "color": color, "label": label})
|
|
profile_values = [y for item in profile_series for _, y in item["data"] if math.isfinite(y)]
|
|
lo, hi = min(profile_values), max(profile_values)
|
|
padding = max(0.05, 0.05 * (hi - lo))
|
|
figure.panel("profiles", (85, 150, 470, 255), "Interior dimensionless profiles", "ξ = πr/R", "θ from each field",
|
|
(0.0, math.pi), (lo - padding, hi + padding), profile_series)
|
|
maximum_xi = finite_max(number(row["xi"]) for row in rows)
|
|
mean_series, scatter_series = [], []
|
|
for field, _, label, color in FIELDS:
|
|
mean_series.append({"data": [(x, abs(y)) for x, y in points(rows, field + "_mean_error_scaled")], "color": color, "label": label})
|
|
scatter_series.append({"data": points(rows, field + "_angular_rms", scales[field]), "color": color, "label": label})
|
|
if control_rows:
|
|
mean_series.append({"data": [(x, abs(y)) for x, y in points(control_rows, field + "_mean_error_scaled")], "color": color, "dash": True})
|
|
scatter_series.append({"data": points(control_rows, field + "_angular_rms", control_scales[field]), "color": color, "dash": True})
|
|
figure.panel("mean_errors", (690, 150, 440, 255), "Absolute spherical-mean error", "ξ = πr/R", "absolute error / fixed scale",
|
|
(0.0, maximum_xi), logarithmic_limits(mean_series), mean_series, log=True)
|
|
figure.panel("scatter", (85, 565, 470, 230), "Angular RMS about the spherical mean", "ξ = πr/R", "angular RMS / fixed scale",
|
|
(0.0, maximum_xi), logarithmic_limits(scatter_series), scatter_series, log=True)
|
|
coverage = [
|
|
{"data": points(rows, "located_weight_fraction"), "color": "#111827", "label": "point location"},
|
|
{"data": points(rows, "material_weight_fraction"), "color": "#64748b", "label": "stellar material"},
|
|
{"data": points(rows, "density_material_valid_weight_fraction"), "color": "#2563eb", "label": "finite density", "dash": True},
|
|
{"data": points(rows, "enthalpy_material_valid_weight_fraction"), "color": "#15803d", "label": "finite enthalpy", "dash": True},
|
|
]
|
|
figure.panel("coverage", (690, 565, 440, 230), "Sampling coverage (inspect before means)", "ξ = πr/R", "fraction of requested angular weight",
|
|
(0.0, maximum_xi), (-0.03, 1.03), coverage)
|
|
figure.write(directory / "polytrope_profiles.svg", bool(control_rows))
|
|
|
|
|
|
def make_report(directory, rows, metrics, checks, control_directory, control_metrics):
|
|
info = metadata(directory)
|
|
failed = [row for row in checks if row.get("passed", "").lower() not in ("1", "true")]
|
|
lines = ["# Polytrope physical verification", "", f"Source: `{directory.resolve()}`.", "",
|
|
f"Declared screening checks: **{len(checks) - len(failed)}/{len(checks)} passed**. "
|
|
"These budgets are not a mesh-convergence certificate.", ""]
|
|
if info:
|
|
lines.append(f"Mode: `{info.get('mode', 'unknown')}`. Solver convergence: `{info.get('solver_converged', 'not applicable/reported')}`.")
|
|
if "solver_failure" in info:
|
|
lines.extend(["", "Solver failure: " + info["solver_failure"]])
|
|
lines.append("")
|
|
lines.extend(["", "",
|
|
"## Main diagnostics", ""])
|
|
if control_directory:
|
|
lines.extend([f"Analytic-mesh control: `{control_directory.resolve()}`. Control errors are shown directly, not subtracted from numerical errors.", ""])
|
|
lines.append("| Metric | Measured |" + (" Analytic-mesh control |" if control_directory else ""))
|
|
lines.append("|---|---:|" + ("---:|" if control_directory else ""))
|
|
selected = ("mass", "mass_relative_error", "volume_radius_relative_error", "surface_radius_relative_rms_error",
|
|
"density_relative_l2_error", "enthalpy_relative_l2_error", "potential_relative_l2_error",
|
|
"gravity_gradient_relative_l2_error", "binding_energy", "pressure_integral", "virial_error",
|
|
"force_virial_error", "enthalpy_virial_error", "enthalpy_force_virial_error",
|
|
"gravity_energy_consistency", "eos_enthalpy_scaled_rms", "closure_projection_pressure_gap",
|
|
"closure_projection_pressure_gap_relative_defect",
|
|
"normalized_bordered_residual", "normalized_unbordered_residual", "normalized_central_border_action",
|
|
"invalid_stellar_corner_samples", "maximum_stellar_corner_element_condition",
|
|
"profile_missing_points", "profile_maximum_location_error", "profile_maximum_scaled_error",
|
|
"profile_maximum_angular_rms_scaled")
|
|
for key in selected:
|
|
if key not in metrics:
|
|
continue
|
|
row = f"| `{key}` | {format_number(metrics[key])} |"
|
|
if control_directory:
|
|
row += f" {format_number(control_metrics.get(key, math.nan))} |"
|
|
lines.append(row)
|
|
lines.extend(["", "## Radial-profile diagnostics", "",
|
|
"| Field | Max absolute mean error / fixed scale | Max angular RMS / fixed scale |", "|---|---:|---:|"])
|
|
scales = reference_scales(rows)
|
|
for field, _, label, _ in FIELDS:
|
|
mean_error = finite_max(abs(number(row.get(field + "_mean_error_scaled"))) for row in rows)
|
|
scatter = finite_max(number(row.get(field + "_angular_rms")) / scales[field] for row in rows)
|
|
lines.append(f"| {label} | {format_number(mean_error)} | {format_number(scatter)} |")
|
|
incomplete = [row for row in rows if number(row.get("located_weight_fraction")) < 1.0 - 1e-10]
|
|
partial_material = [row for row in rows if 1e-10 < number(row.get("material_weight_fraction")) < 1.0 - 1e-10]
|
|
lines.extend(["", f"Shells with incomplete point-location coverage: **{len(incomplete)}**. "
|
|
f"Shells crossing the numerical material boundary: **{len(partial_material)}**.", "",
|
|
"Density/enthalpy means are conditional on located stellar material and finite values. "
|
|
"Missing samples and undefined exterior material fields are never zero-filled. "
|
|
"Angular RMS exposes nonspherical variation that a spherical mean can hide. "
|
|
"Origin data are single traces; radial gravity is undefined there. "
|
|
"Gravity is outward-positive ∇Φ, not inward acceleration.", ""])
|
|
if failed:
|
|
lines.extend(["## Failed declared screens", "", "| Metric | Observed | Maximum allowed |", "|---|---:|---:|"])
|
|
for row in failed:
|
|
lines.append(f"| `{row['metric']}` | {format_number(number(row['observed']))} | {format_number(number(row['maximum_allowed']))} |")
|
|
lines.append("")
|
|
lines.append("All plotted normalizations use the prescribed analytic reference. No radius, central density, or potential offset is fitted.")
|
|
(directory / "polytrope_summary.md").write_text("\n".join(lines) + "\n")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("directory", type=Path)
|
|
parser.add_argument("--control-directory", type=Path)
|
|
args = parser.parse_args()
|
|
rows = read_rows(args.directory / "radial_profiles.csv")
|
|
if not rows:
|
|
parser.error("radial_profiles.csv contains no rows")
|
|
metrics = read_metrics(args.directory)
|
|
checks = read_rows(args.directory / "verification_checks.csv")
|
|
control_rows = read_rows(args.control_directory / "radial_profiles.csv") if args.control_directory else []
|
|
control_metrics = read_metrics(args.control_directory) if args.control_directory else {}
|
|
make_figure(args.directory, rows, control_rows)
|
|
make_report(args.directory, rows, metrics, checks, args.control_directory, control_metrics)
|
|
print(args.directory / "polytrope_profiles.svg")
|
|
print(args.directory / "polytrope_summary.md")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|