277 lines
14 KiB
Python
277 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Plot completed, artifact-passed coupled-convergence audit summaries.
|
|
|
|
Example (run rendering serially with numerical campaigns):
|
|
python plot_coupled_convergence.py analysis.json --output NEW_FIGURE_DIRECTORY
|
|
Use --pair n3 or --pair n2 to request exactly one complete pair. Default: both.
|
|
The script reads summary JSON only; it does not rerun the underlying FEM/artifact
|
|
audit. Matplotlib is imported only when figures are actually rendered.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from dataclasses import dataclass
|
|
import hashlib
|
|
import json
|
|
import math
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
|
|
PAIRS = {
|
|
"n3": ("n3_J0_control", "n3_J0_tight"),
|
|
"n2": ("n2_restart_raw", "n2_restart_gravity_path"),
|
|
}
|
|
LABELS = {
|
|
"n3_J0_control": "Control linear tolerance",
|
|
"n3_J0_tight": "Tighter linear tolerance",
|
|
"n2_restart_raw": "Raw trial path",
|
|
"n2_restart_gravity_path": "Gravity-completed trial path",
|
|
}
|
|
|
|
|
|
def require(condition, message):
|
|
if not condition:
|
|
raise ValueError(message)
|
|
|
|
|
|
def finite(value, name):
|
|
number = float(value)
|
|
require(math.isfinite(number), f"Nonfinite {name}")
|
|
return number
|
|
|
|
|
|
def near(a, b, name):
|
|
require(math.isclose(a, b, rel_tol=3e-11, abs_tol=0.), f"Inconsistent {name}: {a} versus {b}")
|
|
|
|
|
|
def digest_bytes(data):
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
@dataclass
|
|
class Curve:
|
|
identifier: str
|
|
campaign: str
|
|
audit_path: str
|
|
producer_commit: str
|
|
mesh_sha256: str
|
|
metadata: dict
|
|
input_state_hashes: dict
|
|
time: list[float]
|
|
residual: list[float]
|
|
accepted: list[bool]
|
|
threshold: float
|
|
outcome: str
|
|
failure: str | None
|
|
|
|
|
|
def curve(case, campaign, audit_path):
|
|
name = case["id"]
|
|
require(case.get("complete") is True and case.get("artifact_passed") is True,
|
|
f"Case {name} is incomplete or did not pass the artifact audit")
|
|
require(case.get("kind") == "newton" and case.get("outcome") in ("converged", "not_converged"),
|
|
f"Case {name} has no completed Newton trajectory")
|
|
initial = finite(case["initial_residual"], "initial residual")
|
|
require(initial > 0, "Log plots require strictly positive residuals; exact zeros are not silently floored")
|
|
atol, rtol = finite(case["atol"], "atol"), finite(case["rtol"], "rtol")
|
|
require(atol >= 0 and rtol >= 0, "Nonlinear tolerances must be nonnegative")
|
|
threshold = finite(case["actual_threshold"], "actual stopping threshold")
|
|
near(threshold, max(atol, rtol * initial), "actual stopping threshold")
|
|
require(threshold > 0, "A zero stopping threshold cannot be drawn on an unmodified log scale")
|
|
history = case["history"]
|
|
require(isinstance(history, list) and len(history) == int(case["attempted_updates"]), "Missing Newton history rows")
|
|
times, residuals, accepted, durations = [0.], [initial], [], []
|
|
for index, row in enumerate(history):
|
|
require(int(row["iteration"]) == index, "Missing, repeated or unordered Newton iteration")
|
|
require(str(row["accepted"]) in ("0", "1"), "Invalid acceptance flag")
|
|
seconds = finite(row["seconds"], "Newton duration")
|
|
residual = finite(row["F"], "Newton residual")
|
|
require(seconds >= 0 and residual > 0, "Negative time or nonpositive log residual")
|
|
is_accepted = str(row["accepted"]) == "1"
|
|
if not is_accepted:
|
|
near(residual, residuals[-1], "restored residual after failed Newton attempt")
|
|
durations.append(seconds)
|
|
times.append(math.fsum(durations))
|
|
residuals.append(residual)
|
|
accepted.append(is_accepted)
|
|
near(times[-1], finite(case["newton_seconds"], "reported Newton time"), "summed Newton time")
|
|
near(residuals[-1], finite(case["final_residual"], "final residual"), "final residual")
|
|
require(sum(accepted) == int(case["accepted_updates"]), "Accepted-update count differs from history")
|
|
require(case["outcome"] != "converged" or residuals[-1] <= threshold,
|
|
"Convergence outcome exceeds the actual stopping threshold")
|
|
return Curve(name, campaign["path"], audit_path, campaign["producer_commit"], campaign["mesh_sha256"],
|
|
case["metadata"], case.get("run", {}).get("input_state_hashes", {}),
|
|
times, residuals, accepted, threshold, case["outcome"], case.get("failure"))
|
|
|
|
|
|
def load_pairs(paths, selected="both"):
|
|
require(selected in ("both", "n3", "n2"), "Unknown requested curve pair")
|
|
requested = list(PAIRS) if selected == "both" else [selected]
|
|
wanted = {name for pair in requested for name in PAIRS[pair]}
|
|
curves, sources = {}, []
|
|
for path in paths:
|
|
path = Path(path).resolve()
|
|
raw = path.read_bytes()
|
|
audit = json.loads(raw)
|
|
require(audit.get("complete") is True and audit.get("artifact_passed") is True,
|
|
f"Only completed, artifact-passed audit summaries may be plotted: {path}")
|
|
sources.append({"path": str(path), "sha256": digest_bytes(raw),
|
|
"auditor_sha256": audit["auditor_sha256"],
|
|
"benchmark_checker_sha256": audit.get("benchmark_checker_sha256"),
|
|
"audit_scope": audit.get("scope"), "audit_limitations": audit.get("limitations", [])})
|
|
for campaign in audit["campaigns"]:
|
|
require(campaign.get("complete") is True and campaign.get("artifact_passed") is True,
|
|
"An audit contains an incomplete or failed campaign")
|
|
for case in campaign["cases"]:
|
|
if case.get("id") in wanted:
|
|
require(case["id"] not in curves, f"Ambiguous duplicate case {case['id']} across audits")
|
|
curves[case["id"]] = curve(case, campaign, str(path))
|
|
require(set(curves) == wanted, f"Missing requested curves: {sorted(wanted - set(curves))}")
|
|
pairs = {}
|
|
for name in requested:
|
|
left, right = (curves[key] for key in PAIRS[name])
|
|
require(left.mesh_sha256 == right.mesh_sha256, "A paired comparison uses different mesh hashes")
|
|
for key in ("n", "J", "K", "rho_c", "surface_prescription", "surface_degree", "surface_maximum_order",
|
|
"density_order", "enthalpy_order", "gravity_potential_order", "gravity_flux_order", "displacement_order"):
|
|
require(left.metadata.get(key) == right.metadata.get(key), f"Paired physical/discrete setting differs: {key}")
|
|
require(float(left.metadata["n"]) == (3. if name == "n3" else 2.) and float(left.metadata["J"]) == 0.,
|
|
"A named control pair has unexpected physical parameters")
|
|
if name == "n2":
|
|
require(bool(left.input_state_hashes) and left.input_state_hashes == right.input_state_hashes,
|
|
"Restart pair does not declare the same hash-bound input state files")
|
|
near(left.residual[0], right.residual[0], "shared restart initial norm")
|
|
pairs[name] = (left, right)
|
|
return pairs, sources
|
|
|
|
|
|
def residual_reduction(candidate):
|
|
return [100. * (1. - value / candidate.residual[0]) for value in candidate.residual]
|
|
|
|
|
|
def render_pair(name, curves):
|
|
import matplotlib
|
|
matplotlib.use("Agg")
|
|
import matplotlib.pyplot as plt
|
|
|
|
local = None
|
|
if name == "n2":
|
|
figure, (axis, local) = plt.subplots(2, 1, sharex=True, figsize=(8.5, 8.3),
|
|
gridspec_kw={"height_ratios": [1.35, 1.]}, layout="constrained")
|
|
else:
|
|
figure, axis = plt.subplots(figsize=(8.5, 5.7), layout="constrained")
|
|
colors = ("#1769a8", "#c35b16")
|
|
markers = ("o", "s")
|
|
for candidate, color, marker in zip(curves, colors, markers):
|
|
label = LABELS[candidate.identifier]
|
|
tolerance = candidate.metadata.get("linear_tol")
|
|
if tolerance is not None:
|
|
label += f" (linear rtol {float(tolerance):g})"
|
|
label += "; " + ("converged" if candidate.outcome == "converged" else "not converged")
|
|
axis.plot(candidate.time, candidate.residual, color=color, marker=marker, markersize=4,
|
|
linewidth=1.6, label=label)
|
|
failed = [i + 1 for i, accepted in enumerate(candidate.accepted) if not accepted]
|
|
if failed:
|
|
axis.scatter([candidate.time[i] for i in failed], [candidate.residual[i] for i in failed],
|
|
marker="x", s=65, linewidths=1.6, color=color, zorder=4)
|
|
if local is not None:
|
|
reduction = residual_reduction(candidate)
|
|
local.plot(candidate.time, reduction, color=color, marker=marker, markersize=4, linewidth=1.6)
|
|
if failed:
|
|
local.scatter([candidate.time[i] for i in failed], [reduction[i] for i in failed],
|
|
marker="x", s=65, linewidths=1.6, color=color, zorder=4)
|
|
if curves[0].threshold == curves[1].threshold:
|
|
axis.axhline(curves[0].threshold, color="#555555", linestyle=":", linewidth=1.4,
|
|
label=f"Actual stopping threshold (both): {curves[0].threshold:.4g}")
|
|
else:
|
|
for candidate, color in zip(curves, colors):
|
|
axis.axhline(candidate.threshold, color=color, linestyle=":", linewidth=1.3,
|
|
label=f"{LABELS[candidate.identifier]} threshold: {candidate.threshold:.4g}")
|
|
axis.set_yscale("log")
|
|
axis.set_xlim(left=0., right=max(1., max(c.time[-1] for c in curves) * 1.04))
|
|
if local is None:
|
|
axis.set_xlabel("Cumulative reported Newton time (s)")
|
|
else:
|
|
# The full panel retains the stopping threshold. A separate zero-based
|
|
# local quantity reveals percent-level progress without compressing or
|
|
# hiding the many orders of magnitude still required for convergence.
|
|
all_reductions = [value for candidate in curves for value in residual_reduction(candidate)]
|
|
low, high = min(0., min(all_reductions)), max(0., max(all_reductions))
|
|
padding = .08 * max(high - low, .1)
|
|
local.set_ylim(low - padding if low < 0 else 0., high + padding)
|
|
local.set_ylabel("Residual reduction from\nshared restart (%)")
|
|
local.set_xlabel("Cumulative reported Newton time (s)")
|
|
local.set_title("Local progress from the same trajectories", fontsize=10)
|
|
local.grid(True, alpha=.22)
|
|
local.text(.02, .04, "Local reduction scale; actual stopping thresholds remain in the full-scale panel above.",
|
|
fontsize=8, transform=local.transAxes,
|
|
bbox={"facecolor": "white", "alpha": .8, "edgecolor": "none"})
|
|
axis.set_ylabel("Normalized coupled residual")
|
|
axis.grid(True, which="major", alpha=.22)
|
|
axis.legend(fontsize=8, loc="best")
|
|
title = "n = 3, J = 0: linear-solve tolerance control" if name == "n3" else "n = 2, J = 0: common-snapshot trial-path control"
|
|
axis.set_title(title, pad=12)
|
|
figure.supxlabel("Includes the initial norm at t = 0 and every completed Newton attempt; x marks a failed attempt.\n"
|
|
"Time excludes setup/observers. Residual convergence does not establish unbordered equilibrium or virial balance.",
|
|
fontsize=8)
|
|
return figure, matplotlib.__version__
|
|
|
|
|
|
def sidecar(name, curves, sources):
|
|
report = {
|
|
"pair": name, "source_audits": sources,
|
|
"plotter_sha256": digest_bytes(Path(__file__).read_bytes()),
|
|
"curves": [{"id": c.identifier, "campaign": c.campaign, "audit_path": c.audit_path,
|
|
"producer_commit": c.producer_commit, "mesh_sha256": c.mesh_sha256,
|
|
"metadata": c.metadata, "input_state_hashes": c.input_state_hashes,
|
|
"cumulative_newton_seconds": c.time, "normalized_residual": c.residual,
|
|
"accepted_updates_after_t0": c.accepted, "actual_stopping_threshold": c.threshold,
|
|
"outcome": c.outcome, "failure": c.failure} for c in curves],
|
|
"scope": ["Consumes the completed artifact-passed audit JSON; underlying file/FEM audits are not rerun.",
|
|
"Initial residual and stopping threshold are taken from the solver metric audited in that summary.",
|
|
"Cumulative time sums completed history rows; setup, observers and any pre-solver initialization are excluded.",
|
|
"Failed attempts retain their restored residual and elapsed time; no missing or incomplete case is drawn.",
|
|
"Zero residuals or thresholds are refused rather than silently floored on the logarithmic axis.",
|
|
"The n=3 norm may include the central-density border; neither residual decline nor its stopping threshold proves physical equilibrium or virial balance."],
|
|
}
|
|
if name == "n2":
|
|
report["local_progress_panel"] = {
|
|
"quantity": "100*(1-F/F0), in percent", "shared_initial_residual": curves[0].residual[0],
|
|
"zero_included": True, "actual_stopping_threshold_display": "full-scale logarithmic panel above",
|
|
"curves": [{"id": c.identifier, "residual_reduction_percent": residual_reduction(c)} for c in curves],
|
|
}
|
|
return report
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("audits", nargs="+", type=Path)
|
|
parser.add_argument("--output", required=True, type=Path, help="New output directory outside the source checkout")
|
|
parser.add_argument("--pair", choices=("both", "n3", "n2"), default="both")
|
|
args = parser.parse_args()
|
|
require(not args.output.exists(), "Refusing to overwrite an existing figure directory")
|
|
require(not args.output.resolve().is_relative_to(Path(__file__).resolve().parents[1]),
|
|
"Write plot artifacts outside the source checkout")
|
|
pairs, sources = load_pairs(args.audits, args.pair)
|
|
# Preflight every requested curve before importing Matplotlib or creating
|
|
# output, so missing/incomplete controls cannot silently produce half a set.
|
|
args.output.mkdir(parents=True)
|
|
for name, curves in pairs.items():
|
|
figure, version = render_pair(name, curves)
|
|
report = sidecar(name, curves, sources)
|
|
report["matplotlib_version"], report["python_version"] = version, sys.version
|
|
report["figures"] = []
|
|
for suffix in ("png", "pdf"):
|
|
path = args.output / f"{name}_paired_convergence.{suffix}"
|
|
figure.savefig(path, dpi=200)
|
|
report["figures"].append({"path": str(path.resolve()), "sha256": digest_bytes(path.read_bytes())})
|
|
from matplotlib import pyplot as plt
|
|
plt.close(figure)
|
|
path = args.output / f"{name}_paired_convergence.json"
|
|
path.write_text(json.dumps(report, indent=2, allow_nan=False) + "\n")
|
|
print(path)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|