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.
- shape_plotters/__init__.py +0 -0
- shape_plotters/__main__.py +3 -0
- shape_plotters/cli.py +19 -0
- shape_plotters/core/__init__.py +0 -0
- shape_plotters/core/energy.py +91 -0
- shape_plotters/core/engine.py +451 -0
- shape_plotters/core/fragments.py +63 -0
- shape_plotters/core/neck.py +41 -0
- shape_plotters/core/nodes.py +23 -0
- shape_plotters/core/quadrature.py +41 -0
- shape_plotters/core/result.py +92 -0
- shape_plotters/core/widgets.py +68 -0
- shape_plotters/renders/__init__.py +0 -0
- shape_plotters/renders/beta.py +166 -0
- shape_plotters/renders/fos.py +107 -0
- shape_plotters-0.1.0.dist-info/METADATA +120 -0
- shape_plotters-0.1.0.dist-info/RECORD +21 -0
- shape_plotters-0.1.0.dist-info/WHEEL +5 -0
- shape_plotters-0.1.0.dist-info/entry_points.txt +2 -0
- shape_plotters-0.1.0.dist-info/licenses/LICENSE +21 -0
- shape_plotters-0.1.0.dist-info/top_level.txt +1 -0
|
File without changes
|
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)
|