Separate uncertain density observations from plotted trends

This commit is contained in:
2026-09-19 12:06:37 -04:00
parent cd71f374f0
commit 903615f10f

View File

@@ -193,6 +193,19 @@ def write_csv(path, table, fields=None):
writer.writerows(table)
def plot_observation(row, field, negative=False, log=True):
"""Keep unresolved sign measurements out of resolved convergence trends."""
value = row[field]
if value is None:
return None, "undefined"
finite(value, "plotted " + field, log)
if log and value == 0:
return None, "observed zero"
if negative and row["negative_classification"] == "uncertain_witnesses_only":
return None, f"uncertain-only observation {value:.6g}"
return value, None
def plot_figure(frozen, coupled, output):
os.environ.setdefault("MPLCONFIGDIR", str(output / "matplotlib_cache"))
import matplotlib
@@ -205,6 +218,8 @@ def plot_figure(frozen, coupled, output):
row_count = bool(frozen) + 2 * bool(coupled)
require(row_count > 0, "No audited rows to plot")
fig, axes = plt.subplots(row_count, 3, figsize=(13.8, 3.9 * row_count + .7), squeeze=False)
model_keys = sorted({(row["n"], row["J"]) for row in [*frozen, *coupled]})
colors = {key: plt.get_cmap("tab10")(index % 10) for index, key in enumerate(model_keys)}
def groups(table, frozen_case):
result = defaultdict(list)
@@ -215,32 +230,32 @@ def plot_figure(frozen, coupled, output):
def panel(ax, grouped, field, title, ylabel, negative=False, log=True, second=None, legend=False):
annotations, positive = [], False
for index, group in enumerate(grouped):
color = plt.get_cmap("tab10")(index % 10)
for group in grouped:
color = colors[(group[0]["n"], group[0]["J"])]
label = group[0]["case"]
x = [r["density_degree"] for r in group]
series = [(field, "-")] + ([(second, "--")] if second else [])
for metric, style in series:
values = []
for row in group:
value = row[metric]
if value is None or (log and value == 0):
annotations.append(f"{label} Q{row['density_degree']}: {'undefined' if value is None else 'observed zero'}"
value, note = plot_observation(row, metric, negative, log)
if note is not None:
annotations.append(f"{label} Q{row['density_degree']}: {note}"
+ (" (force)" if metric == second else ""))
# NaN breaks the line, so a tiny uncertain-only number
# cannot look like resolved orders-of-magnitude gain.
values.append(math.nan)
continue
finite(value, "plotted " + metric, log)
positive = True
values.append(value)
not_converged = row.get("converged") is False
uncertain = negative and row["negative_classification"] == "uncertain_witnesses_only"
ax.plot(row["density_degree"], value, marker="x" if not_converged else "o", markersize=5,
color=color, markerfacecolor="none" if uncertain else color, linestyle="none")
color=color, markerfacecolor=color, linestyle="none")
ax.plot(x, values, style, color=color, linewidth=1.15, label=label if metric == field else None)
if log and positive:
ax.set_yscale("log")
if not positive:
ax.text(.5, .5, "No positive finite observations" if log else "No finite observations",
ax.text(.5, .5, "No positive values in trend" if log else "No finite values in trend",
ha="center", va="center", transform=ax.transAxes)
ax.set_xticks([2, 3, 4], ["Q2", "Q3", "Q4"])
ax.set_xlim(1.85, 4.15)
@@ -273,7 +288,7 @@ def plot_figure(frozen, coupled, output):
panel(axes[row, 1], grouped, "newton_iteration_seconds", "Newton iteration cost", "Seconds (excludes observer and setup)", log=False)
panel(axes[row, 2], grouped, "krylov_iterations", "Total reported Krylov work", "Iterations", log=False)
fig.suptitle("Density approximation order — audited observations", fontsize=14, y=.985)
fig.text(.025, .018, "Open markers: uncertain-only negative witnesses; ×: nonlinear solve did not converge. Zero/undefined values are listed below panels, not placed on a log floor.\n"
fig.text(.025, .018, "Colors identify (n, J) consistently; ×: nonlinear solve did not converge. Uncertain-only negatives, zeros and undefined values are listed below panels, never placed on a log floor.\n"
"Negative regions use root-sliced quadrature; EOS norms use unsliced q30. P(h) virials do not certify density-EOS validity. See CSV/JSON for all outcomes and timing scope.", fontsize=8)
fig.subplots_adjust(left=.075, right=.985, top=.92 if row_count == 1 else .95, bottom=.15 if row_count == 1 else .085,
wspace=.35, hspace=.72)
@@ -283,6 +298,18 @@ def plot_figure(frozen, coupled, output):
class Tests(unittest.TestCase):
def test_uncertain_plot_observation_breaks_trend(self):
row = {"negative_mass": 1e-30, "negative_classification": "uncertain_witnesses_only"}
plotted, note = plot_observation(row, "negative_mass", negative=True)
self.assertIsNone(plotted)
self.assertIn("1e-30", note)
self.assertEqual(row["negative_mass"], 1e-30)
self.assertEqual(plot_observation(row, "negative_mass", negative=False), (1e-30, None))
row["negative_classification"] = "resolved_witnesses_present"
self.assertEqual(plot_observation(row, "negative_mass", negative=True), (1e-30, None))
row["negative_mass"] = 0.
self.assertEqual(plot_observation(row, "negative_mass", negative=True), (None, "observed zero"))
def test_zero_and_undefined_are_not_floored(self):
fine = {k: 0. for k in ("negative_mass", "negative_volume", "negative_mass_fraction", "negative_volume_fraction",
"negative_mass_uncertainty_scale", "resolved_negative_intervals", "uncertain_negative_intervals", "uncertain_negative_mass")}
@@ -394,6 +421,7 @@ def main():
"Pressure-force and residual-metric rejection counts retain the recorded stage distinction; residual-metric counts include any nonfinite metric failures as well as insufficient decrease.",
"Ratios to Q2 compare recorded endpoints and do not imply equal convergence or error tolerances were attained.",
"Undefined values are null/blank; zero is kept exactly. No logarithmic plot floor is inserted.",
"Uncertain-only negative observations retain exact table values and appear as separate plot annotations; resolved trends do not connect through them. Colors are stable by (n,J).",
"P(h) virial milestones do not establish pointwise density validity; density-based virials are retained separately."],
"frozen_projection": frozen, "coupled_outcomes": coupled, "relative_to_Q2": relative,
"artifacts_sha256": {path.name: digest(path) for path in args.output.iterdir() if path.is_file()}}