#!/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 = [ '', f"{html.escape(title)}", 'Fixed-reference n=1 physical-radius profiles, absolute mean errors, angular scatter, and sampling coverage.', '', '', f'{html.escape(title)}', 'Physical spheres; ξ = πr/R. Reference R and central scales are prescribed, never fitted.', ] 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'{html.escape(title)}') self.parts.append(f'') for value, label in ticks: y = ymap(value) self.parts.append(f'') self.parts.append(f'{label}') for i in range(5): value = xlim[0] + i * (xlim[1] - xlim[0]) / 4.0 x = xmap(value) self.parts.append(f'') self.parts.append(f'{value:.3g}') self.parts.append(f'') self.parts.append(f'{html.escape(xlabel)}') self.parts.append(f'{html.escape(ylabel)}') 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'') else: coordinates = " ".join(f"{x:.3f},{y:.3f}" for x, y in segment) self.parts.append(f'') if item.get("label"): x = left + (legend_index % 2) * width / 2 y = top - 35 + (legend_index // 2) * 17 self.parts.append(f'') self.parts.append(f'{html.escape(item["label"])}') 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'{html.escape(note)}') self.parts.append('Missing/nonfinite samples are not connected; zero errors are omitted on logarithmic axes. Material means are conditional.') self.parts.append("") 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(["![Fixed-reference physical profiles, errors, angular scatter, and coverage](polytrope_profiles.svg)", "", "## 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()