shape-plotters 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,3 @@
1
+ from shape_plotters.cli import main
2
+
3
+ main()
shape_plotters/cli.py ADDED
@@ -0,0 +1,19 @@
1
+ """Command-line entry point: shape-plotters beta|fos (or python -m shape_plotters)."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ from collections.abc import Sequence
6
+
7
+ from shape_plotters.core.engine import ShapePlotterApp
8
+ from shape_plotters.renders.beta import BetaRender
9
+ from shape_plotters.renders.fos import FoSRender
10
+
11
+ RENDERS = {"beta": BetaRender, "fos": FoSRender}
12
+
13
+
14
+ def main(argv: Sequence[str] | None = None) -> None:
15
+ parser = argparse.ArgumentParser(
16
+ prog="shape-plotters", description="Interactive nuclear-shape plotter")
17
+ parser.add_argument("render", choices=sorted(RENDERS))
18
+ args = parser.parse_args(argv)
19
+ ShapePlotterApp(RENDERS[args.render]()).run()
File without changes
@@ -0,0 +1,91 @@
1
+ """Optional WMMM energy adapter — the only module allowed to import wmmm.
2
+
3
+ The energy model is strictly local: wmmm is never a dependency of this
4
+ package (not even an optional extra) and is installed out-of-band as a local
5
+ editable install. This module carries the calling convention only — no model
6
+ code, data, or paths. The engine consults available() to decide whether the
7
+ Energy button exists; compute() returns EnergyResult, with failures in
8
+ EnergyResult.error rather than exceptions.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import importlib.util
13
+ from dataclasses import dataclass
14
+ from typing import Sequence
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class EnergyResult:
19
+ """One WMMM point result (energies in MeV); zero-filled when error is set."""
20
+ is_valid: bool
21
+ mass_excess: float
22
+ total_energy: float
23
+ macro_energy: float
24
+ micro_energy: float
25
+ surface_energy: float
26
+ coulomb_energy: float
27
+ proton_pairing_gap: float
28
+ neutron_pairing_gap: float
29
+ proton_k: int
30
+ neutron_k: int
31
+ corrected_beta10: float # cross-check channel (vs the render scalar), not displayed
32
+ error: str | None = None
33
+
34
+
35
+ # Model handles are configuration-immutable: one per (param_type,
36
+ # com_correction) for the app lifetime (~20 ms per compute thereafter).
37
+ _models: dict[tuple[str, bool], object] = {}
38
+
39
+
40
+ def available() -> bool:
41
+ """True when the wmmm package is importable; performs no import."""
42
+ return importlib.util.find_spec("wmmm") is not None
43
+
44
+
45
+ def _error(message: str) -> EnergyResult:
46
+ return EnergyResult(False, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
47
+ 0, 0, 0.0, error=message)
48
+
49
+
50
+ def _get_model(param_type: str, com_correction: bool):
51
+ key = (param_type, com_correction)
52
+ model = _models.get(key)
53
+ if model is None:
54
+ import wmmm # deferred: loads the Fortran library on first use only
55
+ model = wmmm.Model(param_type)
56
+ if not com_correction:
57
+ model.set("beta_10_com_shift", 0.0)
58
+ _models[key] = model
59
+ return model
60
+
61
+
62
+ def compute(param_type: str, z: int, n: int, shape: Sequence[float],
63
+ com_correction: bool = True) -> EnergyResult:
64
+ """One WMMM point computation; never raises.
65
+
66
+ Parameters
67
+ ----------
68
+ param_type : str
69
+ "legendre" (20 shape params) or "fos" (7).
70
+ z, n : int
71
+ Proton and neutron numbers.
72
+ shape : Sequence[float]
73
+ Shape parameters in WMMM's convention (raw slider values).
74
+ com_correction : bool
75
+ WMMM's beta_10_com_shift flag. False evaluates beta10 as given
76
+ (legendre only — inert on the fos path).
77
+ """
78
+ try:
79
+ model = _get_model(param_type, com_correction)
80
+ r = model.compute(int(z), int(n), list(shape))
81
+ except Exception as exc: # any wmmm failure becomes a stats-box message
82
+ return _error(f"{type(exc).__name__}: {exc}")
83
+ return EnergyResult(
84
+ is_valid=bool(r.is_valid), mass_excess=r.mass_excess,
85
+ total_energy=r.total_energy, macro_energy=r.macro_energy,
86
+ micro_energy=r.micro_energy, surface_energy=r.surface_energy,
87
+ coulomb_energy=r.coulomb_energy,
88
+ proton_pairing_gap=r.proton_pairing_gap,
89
+ neutron_pairing_gap=r.neutron_pairing_gap,
90
+ proton_k=int(r.proton_k), neutron_k=int(r.neutron_k),
91
+ corrected_beta10=r.corrected_beta10)
@@ -0,0 +1,451 @@
1
+ """ShapePlotterApp: parameterization-independent interactive figure.
2
+
3
+ Owns display units (fm/R0 toggle; ShapeResult is R0 units), layout, widgets,
4
+ invalid-shape greying, stats, and save. Everything shape-specific comes from
5
+ the render (contract in shape_plotters/core/result.py).
6
+
7
+ Artists are created once at build; update() only mutates data, text, and
8
+ visibility. v0.1 cleared and replotted every axes per slider event — legend
9
+ and text layout made that the frame-time bottleneck (see
10
+ docs/superpowers/specs/2026-07-03-engine-v0.2-performance-design.md).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import numpy as np
15
+ import matplotlib.pyplot as plt
16
+ from matplotlib.widgets import Button, CheckButtons
17
+
18
+ from shape_plotters.core import energy, fragments, quadrature
19
+ from shape_plotters.core.result import ShapeResult
20
+ from shape_plotters.core.widgets import IntTextBox, SliderRow
21
+
22
+ # Display convention (both old plotters used 1.16); distinct from WMMM physics constants.
23
+ R0_FM = 1.16
24
+
25
+ VALID_COLOR = "tab:blue"
26
+ DERIV_COLOR = "tab:red"
27
+ NECK_COLOR = "tab:green"
28
+ RTHETA_COLOR = "tab:orange" # R(θ) star-convex representation (FoS shifted shapes)
29
+ INVALID_COLOR = "0.55"
30
+ UNITS_LABEL = "fm units"
31
+
32
+ # WMMM scission bands: neck radius 1.2-1.5 fm (comparable because display
33
+ # R0 = 1.16 fm equals WMMM's geometric R0; see FORD.md / v0.2 handoff).
34
+ SCISSION_BAND_FM = (1.2, 1.5)
35
+
36
+
37
+ class ShapePlotterApp:
38
+ def __init__(self, render) -> None:
39
+ self.render = render
40
+ self.fm_units = True
41
+ self.toggle_state = {t.key: t.default for t in render.toggles}
42
+ self.last_result: ShapeResult | None = None
43
+ self._last_ok: bool | None = None
44
+ self._last_overlay_ok: bool | None = None
45
+ self._stats_base = ""
46
+ self._energy_lines: list[str] = []
47
+ self._build_figure()
48
+ self._build_artists()
49
+ self._build_widgets()
50
+ self.update()
51
+
52
+ # ---------- construction ----------
53
+
54
+ def _build_figure(self) -> None:
55
+ self.fig = plt.figure(figsize=(15, 9))
56
+ try:
57
+ self.fig.canvas.manager.set_window_title(f"shape-plotters — {self.render.name}")
58
+ except AttributeError:
59
+ pass # headless backends may lack a window manager
60
+ ncols = 3 if self.render.has_extra_panel else 2
61
+ gs = self.fig.add_gridspec(
62
+ 1, ncols + 1, left=0.05, right=0.98, top=0.96, bottom=0.52,
63
+ wspace=0.30, width_ratios=[1.0] * ncols + [0.55])
64
+ self.ax_radius = self.fig.add_subplot(gs[0])
65
+ self.ax_shape = self.fig.add_subplot(gs[1])
66
+ self.ax_extra = self.fig.add_subplot(gs[2]) if self.render.has_extra_panel else None
67
+ self.ax_stats = self.fig.add_subplot(gs[ncols])
68
+ self.ax_stats.axis("off")
69
+
70
+ def _build_artists(self) -> None:
71
+ # fm_units defaults to True; the unit toggle re-texts labels in place.
72
+ unit = "fm"
73
+ ax = self.ax_radius
74
+ (self.r_line,) = ax.plot([], [], color=VALID_COLOR, lw=2, label=f"R(θ) [{unit}]")
75
+ (self.dr_line,) = ax.plot([], [], color=DERIV_COLOR, lw=2, ls=":",
76
+ label="dR/dθ (lib)")
77
+ ax.set_xlim(0.0, np.pi) # also disables x-autoscale on this axes
78
+ ax.set_xlabel("θ [rad]")
79
+ self.radius_legend = ax.legend(loc="upper center", fontsize=12)
80
+ ax.grid(alpha=0.3)
81
+
82
+ ax = self.ax_shape
83
+ (self.shape_upper,) = ax.plot([], [], color=VALID_COLOR, lw=2, label="shape")
84
+ (self.shape_lower,) = ax.plot([], [], color=VALID_COLOR, lw=2)
85
+ (self.neck_line,) = ax.plot([], [], color=NECK_COLOR, ls="--", lw=1.5)
86
+ # z_cm marker: red point on the z axis (COM of an axially symmetric shape).
87
+ (self.zcm_point,) = ax.plot([], [], marker="o", ms=6, ls="",
88
+ color="tab:red", zorder=5, label="z_cm")
89
+ # Orange dashed overlay. Two sources share these artists (a render only
90
+ # ever drives one): the FoS R(θ) star-convex representation drawn where
91
+ # it actually sits, or a render-supplied outline (beta's COM-corrected
92
+ # shape). Labels come from the render so each names its own overlay.
93
+ ov_label = getattr(self.render, "overlay_label", "R(θ) star-convex")
94
+ ov_zcm_label = getattr(self.render, "overlay_zcm_label", "z_cm (R(θ))")
95
+ (self.rtheta_upper,) = ax.plot([], [], color=RTHETA_COLOR, lw=1.2, ls="--",
96
+ alpha=0.9, visible=False, label=ov_label)
97
+ (self.rtheta_lower,) = ax.plot([], [], color=RTHETA_COLOR, lw=1.2, ls="--",
98
+ alpha=0.9, visible=False)
99
+ (self.rtheta_zcm,) = ax.plot([], [], marker="o", ms=6, ls="",
100
+ color=RTHETA_COLOR, zorder=5, visible=False,
101
+ label=ov_zcm_label)
102
+ self.scission_bands = [
103
+ ax.axhspan(SCISSION_BAND_FM[0], SCISSION_BAND_FM[1],
104
+ color=NECK_COLOR, alpha=0.15),
105
+ ax.axhspan(-SCISSION_BAND_FM[1], -SCISSION_BAND_FM[0],
106
+ color=NECK_COLOR, alpha=0.15),
107
+ ]
108
+ ax.set_aspect("equal", adjustable="datalim")
109
+ ax.set_xlabel(f"z [{unit}]")
110
+ ax.set_ylabel(f"ρ [{unit}]")
111
+ ax.grid(alpha=0.3)
112
+ # Distinguishes the true shape from the R(θ) overlay; built once and only
113
+ # shown while the overlay is active (update() toggles visibility).
114
+ self.shape_legend = ax.legend(loc="upper right", fontsize=12)
115
+ self.shape_legend.set_visible(False)
116
+
117
+ self.extra_legend = None
118
+ self.zcm_extra = None
119
+ if self.ax_extra is not None:
120
+ ax = self.ax_extra
121
+ (self.extra_rho,) = ax.plot([], [], color=VALID_COLOR, lw=2,
122
+ label=f"ρ(z) [{unit}]")
123
+ (self.extra_drho,) = ax.plot([], [], color=DERIV_COLOR, lw=1.5, ls=":",
124
+ label="dρ/dz (lib)")
125
+ (self.zcm_extra,) = ax.plot([], [], marker="o", ms=6, ls="",
126
+ color="tab:red", zorder=5)
127
+ ax.set_xlabel(f"z [{unit}]")
128
+ self.extra_legend = ax.legend(loc="upper center", fontsize=12)
129
+ ax.grid(alpha=0.3)
130
+
131
+ self.stats_text = self.ax_stats.text(
132
+ 0.0, 1.0, "", va="top", family="monospace", fontsize=12,
133
+ transform=self.ax_stats.transAxes)
134
+
135
+ def _build_widgets(self) -> None:
136
+ self.rows: dict[str, SliderRow] = {}
137
+ y = 0.06
138
+ for spec in self.render.slider_specs:
139
+ row = SliderRow(self.fig, y, spec.label, spec.vmin, spec.vmax,
140
+ spec.vinit, spec.step, spec.markers)
141
+ row.slider.on_changed(self.update)
142
+ self.rows[spec.key] = row
143
+ y += 0.031
144
+ self.z_box = IntTextBox(self.fig, (0.88, 0.40, 0.06, 0.030), "Z ", 92, self._on_zn)
145
+ self.n_box = IntTextBox(self.fig, (0.88, 0.36, 0.06, 0.030), "N ", 144, self._on_zn)
146
+ self.btn_reset = Button(self.fig.add_axes((0.86, 0.31, 0.10, 0.030)), "Reset")
147
+ self.btn_reset.on_clicked(self._reset)
148
+ self.btn_save = Button(self.fig.add_axes((0.86, 0.27, 0.10, 0.030)), "Save")
149
+ self.btn_save.on_clicked(self._save)
150
+ labels = [UNITS_LABEL] + [t.label for t in self.render.toggles]
151
+ actives = [self.fm_units] + [t.default for t in self.render.toggles]
152
+ n = len(labels)
153
+ self.checks = CheckButtons(
154
+ self.fig.add_axes((0.86, 0.26 - 0.04 * n, 0.11, 0.04 * n)), labels, actives)
155
+ self.checks.on_clicked(self._on_check)
156
+ self.btn_energy = None
157
+ if energy.available():
158
+ # Right column, below the toggle block; absent (layout untouched)
159
+ # when the local WMMM install is missing.
160
+ self.btn_energy = Button(
161
+ self.fig.add_axes((0.86, 0.26 - 0.04 * n - 0.045, 0.10, 0.030)),
162
+ "Energy")
163
+ self.btn_energy.on_clicked(self._on_energy)
164
+ self._suppress_widget_draws()
165
+
166
+ def _suppress_widget_draws(self) -> None:
167
+ # update() issues the single authoritative draw_idle(); without this,
168
+ # Slider.set_val adds a second full-figure draw per event. IntTextBox
169
+ # is excluded: TextBox needs its own draws for typing echo.
170
+ widgets: list = [self.btn_reset, self.btn_save, self.checks]
171
+ if self.btn_energy is not None:
172
+ widgets.append(self.btn_energy)
173
+ for row in self.rows.values():
174
+ widgets += [row.slider, row.btn_dec, row.btn_inc]
175
+ for w in widgets:
176
+ if hasattr(w, "drawon"): # guard against future mpl API changes
177
+ w.drawon = False
178
+
179
+ # ---------- callbacks ----------
180
+
181
+ def _on_check(self, label: str) -> None:
182
+ if label == UNITS_LABEL:
183
+ self.fm_units = not self.fm_units
184
+ self._relabel_units()
185
+ else:
186
+ for t in self.render.toggles:
187
+ if t.label == label:
188
+ self.toggle_state[t.key] = not self.toggle_state[t.key]
189
+ self.update()
190
+
191
+ def _relabel_units(self) -> None:
192
+ # Rare event: re-text legends/labels in place instead of rebuilding them.
193
+ unit = "fm" if self.fm_units else "R0"
194
+ self.radius_legend.get_texts()[0].set_text(f"R(θ) [{unit}]")
195
+ self.ax_shape.set_xlabel(f"z [{unit}]")
196
+ self.ax_shape.set_ylabel(f"ρ [{unit}]")
197
+ if self.ax_extra is not None:
198
+ self.ax_extra.set_xlabel(f"z [{unit}]")
199
+ self.extra_legend.get_texts()[0].set_text(f"ρ(z) [{unit}]")
200
+ self._reposition_scission()
201
+
202
+ def _on_zn(self) -> None:
203
+ # Z/N changes the fm<->R0 scale, so fm-fixed band edges move in R0 mode.
204
+ self._reposition_scission()
205
+ self.update()
206
+
207
+ def _fm_to_display(self, v_fm: float) -> float:
208
+ if self.fm_units:
209
+ return v_fm
210
+ return v_fm / (R0_FM * float(self.z_box.value + self.n_box.value) ** (1.0 / 3.0))
211
+
212
+ def _reposition_scission(self) -> None:
213
+ lo, hi = (self._fm_to_display(v) for v in SCISSION_BAND_FM)
214
+ upper, lower = self.scission_bands
215
+ self._set_band(upper, lo, hi)
216
+ self._set_band(lower, -hi, -lo)
217
+
218
+ @staticmethod
219
+ def _set_band(band, ylo: float, yhi: float) -> None:
220
+ if hasattr(band, "set_height"): # Rectangle (mpl >= 3.8, incl. 3.11)
221
+ band.set_y(ylo)
222
+ band.set_height(yhi - ylo)
223
+ else: # Polygon fallback for older mpl
224
+ band.set_xy([[0.0, ylo], [0.0, yhi], [1.0, yhi], [1.0, ylo]])
225
+
226
+ def _reset(self, _event=None) -> None:
227
+ # Each set_val fires update(); fine at 8 sliders.
228
+ for spec in self.render.slider_specs:
229
+ self.rows[spec.key].slider.set_val(spec.vinit)
230
+
231
+ def _save(self, _event=None) -> None:
232
+ params = {k: row.slider.val for k, row in self.rows.items()}
233
+ fname = self.render.filename(self.z_box.value, self.n_box.value, params)
234
+ self.fig.savefig(fname, dpi=300, bbox_inches="tight")
235
+ print(f"Saved {fname}")
236
+
237
+ def _refresh_stats(self) -> None:
238
+ self.stats_text.set_text("\n".join([self._stats_base, *self._energy_lines]))
239
+
240
+ def _on_energy(self, _event=None) -> None:
241
+ result = self.last_result
242
+ if result is None or not result.ok:
243
+ self._energy_lines = ["", "WMMM: shape invalid (not computed)"]
244
+ else:
245
+ params = {k: row.slider.val for k, row in self.rows.items()}
246
+ requests = self.render.energy_requests(params, result)
247
+ lines: list[str] = []
248
+ for req in requests:
249
+ res = energy.compute(
250
+ req.param_type, self.z_box.value, self.n_box.value,
251
+ req.shape, com_correction=req.com_correction)
252
+ lines += self._energy_block(
253
+ req.label if len(requests) > 1 else None, res)
254
+ self._energy_lines = lines
255
+ self._refresh_stats()
256
+ self.fig.canvas.draw_idle()
257
+
258
+ @staticmethod
259
+ def _energy_block(label: str | None, res: energy.EnergyResult) -> list[str]:
260
+ # Energies are MeV — deliberately outside the fm/R0 unit toggle.
261
+ head = "WMMM" if label is None else f"WMMM ({label})"
262
+ if res.error is not None:
263
+ return ["", f"{head}: error", f" {res.error}"]
264
+ if not res.is_valid:
265
+ return ["", f"{head}: invalid shape"]
266
+ return ["", f"{head} [MeV]:",
267
+ f" E_total = {res.total_energy:.4f}",
268
+ f" E_macro = {res.macro_energy:.4f}",
269
+ f" E_micro = {res.micro_energy:.4f}",
270
+ f" mass_ex = {res.mass_excess:.4f}",
271
+ f" E_surf = {res.surface_energy:.4f}",
272
+ f" E_coul = {res.coulomb_energy:.4f}",
273
+ f" gap_p = {res.proton_pairing_gap:.4f} k_p = {res.proton_k}",
274
+ f" gap_n = {res.neutron_pairing_gap:.4f} k_n = {res.neutron_k}"]
275
+
276
+ # ---------- drawing ----------
277
+
278
+ def _scale(self) -> float:
279
+ if not self.fm_units:
280
+ return 1.0
281
+ return R0_FM * float(self.z_box.value + self.n_box.value) ** (1.0 / 3.0)
282
+
283
+ def update(self, _val=None) -> None:
284
+ params = {k: row.slider.val for k, row in self.rows.items()}
285
+ result = self.render.compute(params, self.toggle_state)
286
+ self.last_result = result
287
+ scale = self._scale()
288
+ unit = "fm" if self.fm_units else "R0"
289
+
290
+ r = result.radius * scale
291
+ self.r_line.set_data(result.theta, r)
292
+ self.dr_line.set_data(result.theta, result.dr_dtheta * scale)
293
+
294
+ z, rho = result.z * scale, result.rho * scale
295
+ rn, rs = result.r_north * scale, result.r_south * scale
296
+ # Beta's R(θ) parametric outline is open at the GL poles → append the
297
+ # analytic poles; the FoS ρ(z) profile already ends at ρ=0 → draw as-is.
298
+ if rho.size > 1 and max(abs(float(rho[0])), abs(float(rho[-1]))) > 1e-9:
299
+ first, last = (-rs, rn) if z[0] < z[-1] else (rn, -rs)
300
+ z_c = np.concatenate(([first], z, [last]))
301
+ rho_c = np.concatenate(([0.0], rho, [0.0]))
302
+ else:
303
+ z_c, rho_c = z, rho
304
+ self.shape_upper.set_data(z_c, rho_c)
305
+ self.shape_lower.set_data(z_c, -rho_c)
306
+ if result.neck is not None:
307
+ zn, rn_neck = result.neck.z * scale, result.neck.rho * scale
308
+ self.neck_line.set_data([zn, zn], [-rn_neck, rn_neck])
309
+ self.neck_line.set_visible(True)
310
+ else:
311
+ self.neck_line.set_visible(False)
312
+
313
+ if self.ax_extra is not None:
314
+ self.extra_rho.set_data(z, rho)
315
+ if result.drho_dz is not None:
316
+ # drho/dz is a unit-free slope: both lengths scale identically.
317
+ self.extra_drho.set_data(z, result.drho_dz)
318
+ self.extra_drho.set_visible(True)
319
+ else:
320
+ self.extra_drho.set_visible(False)
321
+
322
+ self._apply_validity(result.ok)
323
+ title = "" if result.ok else f"{result.status_name}: {result.message}"
324
+ if self.ax_shape.get_title() != title:
325
+ self.ax_shape.set_title(title, color="tab:red", fontsize=12)
326
+
327
+ v = quadrature.volume(result.theta, result.radius) * scale**3
328
+ s = quadrature.surface_area(result.theta, result.radius, result.dr_dtheta) * scale**2
329
+ zc = result.z_cm * scale # true-shape COM (display frame)
330
+ self.zcm_point.set_data([zc], [0.0])
331
+ self.zcm_point.set_visible(result.ok)
332
+ if self.zcm_extra is not None:
333
+ self.zcm_extra.set_data([zc], [0.0])
334
+ self.zcm_extra.set_visible(result.ok)
335
+
336
+ # Orange overlay on the cross-section. A render may supply the outline
337
+ # directly (beta's COM-corrected shape, pre-closed at the poles); else
338
+ # the engine infers the FoS R(θ) star-convex representation and draws it
339
+ # where it actually sits, shown only when its COM differs from the true
340
+ # shape's (FoS with a3/a5/a7 ≠ 0). Beta's R(θ) IS the true shape, so that
341
+ # inference never fires for beta.
342
+ if result.overlay_z is not None:
343
+ show_rtheta = bool(result.ok)
344
+ if show_rtheta:
345
+ self.rtheta_upper.set_data(result.overlay_z * scale, result.overlay_rho * scale)
346
+ self.rtheta_lower.set_data(result.overlay_z * scale, -result.overlay_rho * scale)
347
+ self.rtheta_zcm.set_data([result.overlay_z_cm * scale], [0.0])
348
+ else:
349
+ zc_r = quadrature.z_cm(result.theta, result.radius) * scale
350
+ show_rtheta = bool(result.ok and abs(zc_r - zc) > 1e-6 * (1.0 + abs(zc)))
351
+ if show_rtheta:
352
+ zr = result.radius * np.cos(result.theta) * scale
353
+ rr = result.radius * np.sin(result.theta) * scale
354
+ first, last = (-rs, rn) if zr[0] < zr[-1] else (rn, -rs)
355
+ zr = np.concatenate(([first], zr, [last]))
356
+ rr = np.concatenate(([0.0], rr, [0.0]))
357
+ self.rtheta_upper.set_data(zr, rr)
358
+ self.rtheta_lower.set_data(zr, -rr)
359
+ self.rtheta_zcm.set_data([zc_r], [0.0])
360
+ self._apply_overlay_validity(result.overlay_ok)
361
+ for art in (self.rtheta_upper, self.rtheta_lower):
362
+ art.set_visible(show_rtheta)
363
+ # No COM marker for an invalid corrected shape — it isn't centered anywhere.
364
+ self.rtheta_zcm.set_visible(show_rtheta and result.overlay_ok)
365
+ self.shape_legend.set_visible(show_rtheta)
366
+
367
+ # Fragment split at the neck (R0 units; the fm scale cancels in a ratio,
368
+ # so only the mass split f*(Z+N) needs the display Z/N).
369
+ frag = None
370
+ if result.neck is not None:
371
+ v_lo, v_hi = fragments.fragment_volumes(
372
+ result.z, result.rho, result.neck.z,
373
+ -result.r_south, result.r_north)
374
+ total = v_lo + v_hi
375
+ if total > 0.0 and v_lo > 0.0 and v_hi > 0.0:
376
+ a_mass = self.z_box.value + self.n_box.value
377
+ fracs = sorted((v_lo / total, v_hi / total), reverse=True)
378
+ frag = (fracs[0], fracs[1],
379
+ fracs[0] * a_mass, fracs[1] * a_mass, a_mass)
380
+
381
+ self._stats_base = self._stats_block(result, scale, unit, v, s, zc, frag)
382
+ self._energy_lines = [] # any shape/Z/N/unit change invalidates energies
383
+ self._refresh_stats()
384
+
385
+ # visible_only: the hidden neck line keeps stale data by design.
386
+ for ax in (self.ax_radius, self.ax_shape, self.ax_extra):
387
+ if ax is not None:
388
+ ax.relim(visible_only=True)
389
+ ax.autoscale_view()
390
+ self.fig.canvas.draw_idle()
391
+
392
+ def _apply_validity(self, ok: bool) -> None:
393
+ # Color/alpha churn only on the valid<->invalid flip, not per frame.
394
+ if ok == self._last_ok:
395
+ return
396
+ self._last_ok = ok
397
+ color = VALID_COLOR if ok else INVALID_COLOR
398
+ deriv = DERIV_COLOR if ok else INVALID_COLOR
399
+ alpha = 1.0 if ok else 0.45
400
+ for line in (self.r_line, self.shape_upper, self.shape_lower):
401
+ line.set_color(color)
402
+ line.set_alpha(alpha)
403
+ self.dr_line.set_color(deriv)
404
+ self.dr_line.set_alpha(alpha)
405
+ if self.ax_extra is not None:
406
+ self.extra_rho.set_color(color)
407
+ self.extra_rho.set_alpha(alpha)
408
+ self.extra_drho.set_color(deriv)
409
+ self.extra_drho.set_alpha(alpha)
410
+
411
+ def _apply_overlay_validity(self, ok: bool) -> None:
412
+ # The COM-corrected overlay greys when its own shape is invalid (a
413
+ # centering beta10 exists but self-intersects). Flip-cached like the
414
+ # main-shape greying to avoid per-frame color churn.
415
+ if ok == self._last_overlay_ok:
416
+ return
417
+ self._last_overlay_ok = ok
418
+ color = RTHETA_COLOR if ok else INVALID_COLOR
419
+ alpha = 1.0 if ok else 0.45
420
+ for line in (self.rtheta_upper, self.rtheta_lower):
421
+ line.set_color(color)
422
+ line.set_alpha(alpha)
423
+
424
+ def _stats_block(self, result: ShapeResult, scale: float, unit: str,
425
+ v: float, s: float, zc: float,
426
+ frag: tuple[float, float, float, float, int] | None = None) -> str:
427
+ lines = [f"[{self.render.name}] units: {unit}"]
428
+ if not result.ok:
429
+ lines += [f"INVALID: {result.status_name} ({result.status})", ""]
430
+ for key, val in result.scalars.items():
431
+ if key in result.length_keys:
432
+ lines.append(f"{key} = {val * scale:.4f} {unit}")
433
+ else:
434
+ lines.append(f"{key} = {val:.4f}")
435
+ if result.neck is not None:
436
+ lines += [f"neck ({result.neck.source}):",
437
+ f" z = {result.neck.z * scale:.4f} {unit}",
438
+ f" ρ = {result.neck.rho * scale:.4f} {unit}",
439
+ f" depth = {result.neck.depth:.3f}"]
440
+ lines += ["", f"volume = {v:.4f} {unit}³ (GL)",
441
+ f"surface = {s:.4f} {unit}² (GL)",
442
+ f"z_cm = {zc:.4f} {unit} (GL)"]
443
+ if frag is not None:
444
+ f_hi, f_lo, a_hi, a_lo, a_tot = frag
445
+ lines += ["", "fragments (@ z_neck):",
446
+ f" vol frac = {f_hi:.2f} : {f_lo:.2f}",
447
+ f" mass A = {a_hi:.0f} : {a_lo:.0f} (A={a_tot})"]
448
+ return "\n".join(lines)
449
+
450
+ def run(self) -> None:
451
+ plt.show()
@@ -0,0 +1,63 @@
1
+ """Cylindrical fragment volumes about the neck plane.
2
+
3
+ The neck (shape_plotters/core/neck.py, or a library-native neck) splits a necked shape
4
+ into two pre-fragments. Their volumes are the disk integrals V = pi * ∫ rho(z)^2
5
+ dz on each side of z_neck — the cylindrical form, distinct from the whole-body
6
+ spherical integral in shape_plotters/core/quadrature.py, which integrates the entire body
7
+ and cannot be split at a z-plane. Works for any rho(z) profile: FoS's lib-native
8
+ one and beta's parametric z = R cos θ, rho = R sin θ alike.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import numpy as np
13
+
14
+ from shape_plotters.core.result import Array
15
+
16
+
17
+ def fragment_volumes(z: Array, rho: Array, z_neck: float,
18
+ z_lo: float, z_hi: float) -> tuple[float, float]:
19
+ """Cylindrical disk volumes below/above the neck plane.
20
+
21
+ Parameters
22
+ ----------
23
+ z, rho : Array
24
+ The rho(z) profile in a common length unit; order need not be sorted
25
+ (beta's parametric z descends, FoS's ascends — both are handled).
26
+ z_neck : float
27
+ Axial split plane (same unit as z).
28
+ z_lo, z_hi : float
29
+ Pole positions (z_lo = -r_south, z_hi = +r_north) where the caps close
30
+ at rho = 0. The profile endpoints rarely reach the exact poles (GL is an
31
+ open rule for beta), so the caps are appended to include the end volume.
32
+
33
+ Returns
34
+ -------
35
+ (V_below, V_above) : tuple[float, float]
36
+ Disk volumes pi * ∫ rho(z)^2 dz below and above z_neck, in the cube of
37
+ the length unit. (0.0, 0.0) for a degenerate profile (total <= 0).
38
+ """
39
+ # Cap the profile at the poles (rho = 0) and sort ascending in z so the
40
+ # trapezoid is well defined regardless of the render's native ordering.
41
+ zc = np.concatenate(([z_lo], np.asarray(z, dtype=float), [z_hi]))
42
+ rc = np.concatenate(([0.0], np.asarray(rho, dtype=float), [0.0]))
43
+ order = np.argsort(zc)
44
+ zc, rc = zc[order], rc[order]
45
+
46
+ z_neck = float(np.clip(z_neck, zc[0], zc[-1]))
47
+ rho_neck = float(np.interp(z_neck, zc, rc))
48
+
49
+ # Split at z_neck; the interpolated neck point closes both sides at the
50
+ # shared plane so neither fragment loses the wedge next to the cut.
51
+ below, above = zc < z_neck, zc > z_neck
52
+ z_b = np.concatenate((zc[below], [z_neck]))
53
+ r_b = np.concatenate((rc[below], [rho_neck]))
54
+ z_a = np.concatenate(([z_neck], zc[above]))
55
+ r_a = np.concatenate(([rho_neck], rc[above]))
56
+
57
+ v_below = float(np.trapezoid(np.pi * r_b**2, z_b))
58
+ v_above = float(np.trapezoid(np.pi * r_a**2, z_a))
59
+ if not (np.isfinite(v_below) and np.isfinite(v_above)):
60
+ return 0.0, 0.0
61
+ if v_below + v_above <= 0.0:
62
+ return 0.0, 0.0
63
+ return v_below, v_above
@@ -0,0 +1,41 @@
1
+ """Display-only neck heuristic for profiles without a lib-native neck.
2
+
3
+ Two highest interior local maxima of a 1-D profile, lowest minimum between
4
+ them. No fixed split point and no depth threshold — depth thresholds are WMMM
5
+ physics policy, not plotter geometry (spec 2026-07-03, section 3).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+ from scipy.signal import find_peaks
11
+
12
+ from .result import Array
13
+
14
+
15
+ def find_neck_indices(rho: Array) -> tuple[int, int, int] | None:
16
+ """Locate a neck in a 1-D profile.
17
+
18
+ Returns
19
+ -------
20
+ (i_neck, i_lobe_a, i_lobe_b) with i_lobe_a < i_neck < i_lobe_b,
21
+ or None when the profile has fewer than two interior maxima or the two
22
+ highest maxima are adjacent.
23
+ """
24
+ peaks, _ = find_peaks(rho)
25
+ if len(peaks) < 2:
26
+ return None
27
+ top_two = peaks[np.argsort(rho[peaks])[-2:]]
28
+ i_a, i_b = int(top_two.min()), int(top_two.max())
29
+ if i_b - i_a < 2:
30
+ return None
31
+ interior = np.arange(i_a + 1, i_b)
32
+ i_neck = int(interior[np.argmin(rho[interior])])
33
+ return i_neck, i_a, i_b
34
+
35
+
36
+ def neck_depth(rho: Array, i_neck: int, i_a: int, i_b: int) -> float:
37
+ """1 - rho_neck / (lower of the two lobe maxima); 0 for degenerate input."""
38
+ lower_max = min(rho[i_a], rho[i_b])
39
+ if lower_max <= 0.0:
40
+ return 0.0
41
+ return float(1.0 - rho[i_neck] / lower_max)