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.
@@ -0,0 +1,23 @@
1
+ """Shared Gauss-Legendre node set — WMMM's dense grid (GL-2048 in x = cos θ).
2
+
3
+ Shape-independent, computed once at import, read-only: the Python mirror of
4
+ WMMM's program_run_time_constants_mod node-set metadata. Renders evaluate
5
+ R and dR/dθ at THETA; quadrature.py integrates with W (integrands with a
6
+ sin θ factor absorb it into dx = -sin θ dθ). GL is an open rule — no θ = 0/π
7
+ nodes — so pole values come from the libraries' analytic pole radii.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+
13
+ N_NODES = 2048
14
+
15
+ _x, _w = np.polynomial.legendre.leggauss(N_NODES)
16
+ X = _x[::-1].copy() # cos(theta), descending +1 -> -1: exact GL abscissas
17
+ W = _w[::-1].copy() # weights paired with THETA's ordering
18
+ THETA = np.arccos(X) # ascending, strictly inside (0, pi)
19
+ SIN_THETA = np.sqrt(1.0 - X * X)
20
+
21
+ for _arr in (X, W, THETA, SIN_THETA):
22
+ _arr.setflags(write=False)
23
+ del _x, _w, _arr
@@ -0,0 +1,41 @@
1
+ """GL dot-product shape integrals on the shared node set (shape_plotters/core/nodes.py).
2
+
3
+ Spectrally exact — the same scheme as WMMM's dense set: every integrand
4
+ carries a sin(theta) factor that the x = cos(theta) substitution absorbs.
5
+ Arrays must be sampled on nodes.THETA; theta is accepted for interface
6
+ stability and checked against the node set.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+
12
+ from shape_plotters.core import nodes
13
+ from shape_plotters.core.result import Array
14
+
15
+
16
+ def _require_node_set(theta: Array) -> None:
17
+ if theta.shape != nodes.THETA.shape:
18
+ raise ValueError(
19
+ f"expected the shared GL node set ({nodes.N_NODES} nodes), got {theta.shape}")
20
+
21
+
22
+ def volume(theta: Array, radius: Array) -> float:
23
+ """V = (2*pi/3) * sum w_i R_i^3 (star-convex body)."""
24
+ _require_node_set(theta)
25
+ return float((2.0 * np.pi / 3.0) * np.sum(nodes.W * radius**3))
26
+
27
+
28
+ def surface_area(theta: Array, radius: Array, dr_dtheta: Array) -> float:
29
+ """S = 2*pi * sum w_i R_i sqrt(R_i^2 + R'_i^2), with lib-exact R'."""
30
+ _require_node_set(theta)
31
+ return float(2.0 * np.pi * np.sum(
32
+ nodes.W * radius * np.sqrt(radius**2 + dr_dtheta**2)))
33
+
34
+
35
+ def z_cm(theta: Array, radius: Array) -> float:
36
+ """z_cm = (pi/2) * sum w_i R_i^4 x_i / V; 0 if V <= 0."""
37
+ _require_node_set(theta)
38
+ v = volume(theta, radius)
39
+ if v <= 0.0:
40
+ return 0.0
41
+ return float((np.pi / 2.0) * np.sum(nodes.W * radius**4 * nodes.X) / v)
@@ -0,0 +1,92 @@
1
+ """Render contract: what a parameterization render gives the engine.
2
+
3
+ Everything in ShapeResult is in R0 units (dimensionless). The engine owns
4
+ display units (fm toggle) and applies R0 = 1.16 * A^(1/3) only at draw time.
5
+
6
+ A render must expose:
7
+ name: str
8
+ slider_specs: list[SliderSpec]
9
+ toggles: list[ToggleSpec]
10
+ has_extra_panel: bool # True -> engine draws rho(z) + drho_dz panel
11
+ compute(params: dict[str, float], toggles: dict[str, bool]) -> ShapeResult
12
+ filename(z: int, n: int, params: dict[str, float]) -> str
13
+ energy_requests(params: dict[str, float], result: ShapeResult)
14
+ -> list[EnergyRequest] # WMMM computations for this shape(s)
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+
20
+ import numpy as np
21
+ import numpy.typing as npt
22
+
23
+ Array = npt.NDArray[np.float64]
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class SliderSpec:
28
+ key: str
29
+ label: str
30
+ vmin: float
31
+ vmax: float
32
+ vinit: float
33
+ step: float
34
+ markers: tuple[float, ...] = () # practical-limit lines (red dotted)
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ToggleSpec:
39
+ key: str
40
+ label: str
41
+ default: bool
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class EnergyRequest:
46
+ """One WMMM point computation the engine should run on an Energy click.
47
+
48
+ Renders own the physics semantics (how many shapes are on screen and in
49
+ which parameter convention); the engine just iterates requests. Carries
50
+ no wmmm dependency — shape_plotters/core/energy.py resolves it.
51
+ """
52
+ label: str # stats-block header; mirrors the plot legend
53
+ param_type: str # "legendre" | "fos"
54
+ shape: tuple[float, ...] # raw slider values in WMMM's convention
55
+ com_correction: bool # WMMM's beta_10_com_shift flag
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class NeckInfo:
60
+ z: float # R0 units
61
+ rho: float # R0 units
62
+ depth: float # 1 - rho_neck / (lower of the two lobe maxima)
63
+ source: str # "lib" (FoS) or "py heuristic" (beta)
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class ShapeResult:
68
+ status: int # 0 = valid; engine greys the plot otherwise
69
+ status_name: str # symbolic name from the package Status IntEnum
70
+ message: str
71
+ theta: Array # shared GL node set (shape_plotters/core/nodes.py)
72
+ radius: Array # R(theta), R0 units
73
+ z: Array # profile axis, R0 units
74
+ rho: Array # rho(z), R0 units
75
+ drho_dz: Array | None # lib-native (FoS); None for beta
76
+ neck: NeckInfo | None
77
+ scalars: dict[str, float] # lib-native, R0 units where dimensional
78
+ length_keys: frozenset[str] # which scalars scale with the fm toggle
79
+ dr_dtheta: Array # lib-exact analytic dR/dθ, scaled like radius
80
+ r_north: float # analytic R(0), R0 units, scaled like radius
81
+ r_south: float # analytic R(pi), R0 units, scaled like radius
82
+ z_cm: float # true-shape COM in the cross-section frame, R0 units
83
+ # Optional orange cross-section overlay (beta's COM-corrected shape).
84
+ # Pre-closed cartesian outline in R0 units; the engine draws it when set.
85
+ overlay_z: Array | None = None
86
+ overlay_rho: Array | None = None
87
+ overlay_z_cm: float = 0.0
88
+ overlay_ok: bool = True # False => corrected shape is invalid; grey the overlay
89
+
90
+ @property
91
+ def ok(self) -> bool:
92
+ return self.status == 0
@@ -0,0 +1,68 @@
1
+ """Parameterization-independent matplotlib widget helpers."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Callable
5
+
6
+ import matplotlib
7
+ from matplotlib.figure import Figure
8
+ from matplotlib.widgets import Button, Slider, TextBox
9
+
10
+
11
+ class SliderRow:
12
+ """Slider with -/+ nudge buttons and optional practical-limit markers.
13
+
14
+ Extracted from ShapePlotterFoSFitter's create_slider. The grey line marks
15
+ the reset value; red dotted lines mark practical limits.
16
+ """
17
+
18
+ def __init__(self, fig: Figure, y_pos: float, label: str, vmin: float,
19
+ vmax: float, vinit: float, step: float,
20
+ markers: tuple[float, ...] = ()) -> None:
21
+ ax_dec = fig.add_axes((0.20, y_pos, 0.016, 0.024))
22
+ ax_sl = fig.add_axes((0.25, y_pos, 0.45, 0.024))
23
+ ax_inc = fig.add_axes((0.73, y_pos, 0.016, 0.024))
24
+ self.slider = Slider(ax_sl, label, vmin, vmax, valinit=vinit, valstep=step)
25
+ self.btn_dec = Button(ax_dec, "-")
26
+ self.btn_inc = Button(ax_inc, "+")
27
+ self.slider.ax.vlines(vinit, 0, 1, color="k", alpha=0.3, linewidth=1)
28
+ if markers:
29
+ self.slider.ax.vlines(list(markers), 0, 1, color="r", linestyle=":",
30
+ alpha=0.7, linewidth=1.5)
31
+ self._step = step
32
+ self.btn_dec.on_clicked(lambda _event: self._nudge(-self._step))
33
+ self.btn_inc.on_clicked(lambda _event: self._nudge(+self._step))
34
+
35
+ def _nudge(self, delta: float) -> None:
36
+ new = self.slider.val + delta
37
+ if self.slider.valmin <= new <= self.slider.valmax:
38
+ self.slider.set_val(new)
39
+
40
+
41
+ class IntTextBox:
42
+ """TextBox accepting positive ints; reverts to the last good value otherwise."""
43
+
44
+ def __init__(self, fig: Figure, rect: tuple[float, float, float, float],
45
+ label: str, initial: int, on_change: Callable[[], None]) -> None:
46
+ self.box = TextBox(fig.add_axes(rect), label, initial=str(initial))
47
+ if matplotlib.__version__.startswith("3.11"):
48
+ # mpl 3.11.0 regression: TextBox._resize is wrapped by
49
+ # _call_with_reparented_event, which reads event.inaxes — absent on
50
+ # ResizeEvent — so every window resize logs an AttributeError.
51
+ # Replace the connection (last one made in TextBox.__init__),
52
+ # keeping the intended stop-typing-on-resize behavior.
53
+ fig.canvas.mpl_disconnect(self.box._cids[-1])
54
+ fig.canvas.mpl_connect("resize_event", lambda _e: self.box.stop_typing())
55
+ self.value = int(initial)
56
+ self._on_change = on_change
57
+ self.box.on_submit(self._submit)
58
+
59
+ def _submit(self, text: str) -> None:
60
+ try:
61
+ v = int(text)
62
+ if v <= 0:
63
+ raise ValueError(text)
64
+ except ValueError:
65
+ self.box.set_val(str(self.value)) # re-fires _submit with the old value
66
+ return
67
+ self.value = v
68
+ self._on_change()
File without changes
@@ -0,0 +1,166 @@
1
+ """Beta (Legendre) render over the beta_parameterization package.
2
+
3
+ GL-native: R(theta) and analytic dR/dtheta come from the library's cached
4
+ tier evaluated on the shared GL-2048 primary theta set (shape_plotters/core/nodes.py) —
5
+ in sync with the energy model's dense grid. One read-only cache serves every
6
+ call; each call resolves from its betas, so the cache may be shared across
7
+ threads. Volume conservation and the COM correction are per-call library
8
+ options: radii, dR/dtheta and the polar radii arrive pre-scaled, and
9
+ resolve_shape reports the applied volume_factor. The neck is
10
+ the Python display-only heuristic (shape_plotters/core/neck.py) — graduating it into the
11
+ library is recorded future work.
12
+
13
+ Two shapes are drawn. The default (blue) shape uses beta10 (= the beta1 slider,
14
+ the l=1 dipole term) as set. The COM-corrected (orange) overlay ignores that
15
+ slider value and uses corrected_beta10 — the dipole the library computes from
16
+ beta2..beta8 to place the center of mass at the origin —
17
+ shown only when corrected_beta10 differs from the slider beta1 by more than
18
+ the overlay threshold (the two shapes then genuinely differ).
19
+
20
+ Fast-math precondition: params must be finite. Sliders only emit finite
21
+ values, so no screening happens here.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import numpy as np
26
+ import beta_parameterization as bp
27
+
28
+ from shape_plotters.core import nodes, quadrature
29
+ from shape_plotters.core.neck import find_neck_indices, neck_depth
30
+ from shape_plotters.core.result import EnergyRequest, NeckInfo, ShapeResult, SliderSpec
31
+
32
+ N_BETAS = 8
33
+ # WMMM's legendre parameterization takes 8 betas, all slider-driven.
34
+ WMMM_N_LEGENDRE_PARAMS = 8
35
+ # The COM-corrected shape coincides with the slider shape when the corrected
36
+ # dipole equals the slider beta1 (beta10 is a shape parameter, not a
37
+ # translation knob); below this |corrected_beta10 - beta1| the orange overlay
38
+ # is suppressed (slider units).
39
+ OVERLAY_BETA10_THRESHOLD = 0.001
40
+
41
+
42
+ class BetaRender:
43
+ name = "beta"
44
+ has_extra_panel = False
45
+ # Legend labels for the engine's orange overlay artists (the COM-corrected
46
+ # shape reuses the same drawing channel as the FoS R(θ) overlay).
47
+ overlay_label = "COM corrected"
48
+ overlay_zcm_label = "z_cm (corrected)"
49
+ # Ranges from the old ShapePlotter: beta1 (-1.6, 1.6), beta2 (0, 4), rest (-2, 2).
50
+ slider_specs = [
51
+ SliderSpec("beta1", "β1", -1.6, 1.6, 0.0, 0.01),
52
+ SliderSpec("beta2", "β2", 0.0, 4.0, 0.0, 0.01),
53
+ ] + [SliderSpec(f"beta{i}", f"β{i}", -2.0, 2.0, 0.0, 0.01) for i in range(3, N_BETAS + 1)]
54
+ toggles = []
55
+
56
+ def __init__(self) -> None:
57
+ # The blue (raw) and orange (COM-corrected) shapes share the cache;
58
+ # they differ only in the per-call apply_com option.
59
+ self._cache = bp.Cache(N_BETAS, nodes.THETA)
60
+
61
+ def compute(self, params: dict[str, float], toggles: dict[str, bool]) -> ShapeResult:
62
+ betas = [params[f"beta{i}"] for i in range(1, N_BETAS + 1)]
63
+ # Default (blue) shape: the slider betas as-is, including the slider beta10.
64
+ resolved = self._cache.resolve_shape(betas, conserve_volume=True)
65
+ rd = (self._cache.radius_and_derivative(betas, conserve_volume=True)
66
+ if resolved.ok else None)
67
+ ok = rd is not None and rd.ok
68
+
69
+ vol_factor = 1.0
70
+ if ok:
71
+ # Radii, derivative and poles arrive pre-scaled by the library's
72
+ # volume factor (conserve_volume=True); no Python rescaling.
73
+ radii = rd.radii
74
+ dr_dtheta = rd.dr_dtheta
75
+ r_north = resolved.r_north
76
+ r_south = resolved.r_south
77
+ vol_factor = resolved.volume_factor
78
+ # The slider shape's COM sits on the z axis at z_cm (nonzero for
79
+ # asymmetric betas — the red marker shows the offset).
80
+ z_cm = quadrature.z_cm(nodes.THETA, radii)
81
+ z = radii * nodes.X
82
+ rho = radii * nodes.SIN_THETA
83
+ else:
84
+ radii = np.zeros(nodes.N_NODES)
85
+ dr_dtheta = np.zeros(nodes.N_NODES)
86
+ r_north = r_south = z_cm = 0.0
87
+ # Unchecked path: keeps R(theta) even where it goes negative, so
88
+ # the broken (self-crossing) outline still draws — the engine
89
+ # greys it — instead of the shape collapsing to a point. Unscaled
90
+ # by design: invalid shapes get no volume conservation.
91
+ grid = self._cache.radius_grid_unchecked(betas)
92
+ z = grid.radii * nodes.X
93
+ rho = grid.radii * nodes.SIN_THETA
94
+
95
+ neck = None
96
+ if ok:
97
+ hit = find_neck_indices(rho)
98
+ if hit is not None:
99
+ i_neck, i_a, i_b = hit
100
+ neck = NeckInfo(z=float(z[i_neck]), rho=float(rho[i_neck]),
101
+ depth=neck_depth(rho, i_neck, i_a, i_b),
102
+ source="py heuristic")
103
+
104
+ scalars: dict[str, float] = {"vol_factor": vol_factor}
105
+ # COM-corrected (orange) overlay: beta10 recomputed from beta2..beta8 to
106
+ # center the COM. Built only when it differs from the slider shape
107
+ # (|corrected_beta10 - beta1| > threshold); below that the two coincide.
108
+ overlay_z = overlay_rho = None
109
+ overlay_z_cm = 0.0
110
+ overlay_ok = True
111
+ corrected = self._cache.resolve_shape(betas, conserve_volume=True, apply_com=True)
112
+ if corrected.ok:
113
+ scalars["corrected_beta10"] = corrected.corrected_beta10
114
+ if ok and abs(corrected.corrected_beta10 - betas[0]) > OVERLAY_BETA10_THRESHOLD:
115
+ rd_c = self._cache.radius_and_derivative(
116
+ betas, conserve_volume=True, apply_com=True)
117
+ if rd_c.ok:
118
+ radii_c = rd_c.radii
119
+ z_c = radii_c * nodes.X
120
+ rho_c = radii_c * nodes.SIN_THETA
121
+ # Close at the analytic poles (the engine's convention);
122
+ # corrected.r_north/r_south are already volume-scaled.
123
+ rn_c, rs_c = corrected.r_north, corrected.r_south
124
+ first, last = (-rs_c, rn_c) if z_c[0] < z_c[-1] else (rn_c, -rs_c)
125
+ overlay_z = np.concatenate(([first], z_c, [last]))
126
+ overlay_rho = np.concatenate(([0.0], rho_c, [0.0]))
127
+ overlay_z_cm = quadrature.z_cm(nodes.THETA, radii_c)
128
+ elif ok and corrected.status == bp.Status.interior_negative:
129
+ # The COM-centering beta10 exists (poles fine, COM converged) but
130
+ # the corrected shape is interior-negative — the library catches
131
+ # this at resolve_shape, because the volume factor is computed
132
+ # after validation. Draw the near-miss greyed via the unchecked
133
+ # path so it stays visible instead of the overlay silently
134
+ # vanishing. apply_com is explicit: without it this is the
135
+ # uncorrected shape.
136
+ grid_c = self._cache.radius_grid_unchecked(betas, apply_com=True)
137
+ overlay_z = grid_c.radii * nodes.X
138
+ overlay_rho = grid_c.radii * nodes.SIN_THETA
139
+ overlay_ok = False
140
+
141
+ primary = rd if resolved.ok else resolved
142
+ return ShapeResult(
143
+ status=int(primary.status), status_name=primary.status.name,
144
+ message=primary.message,
145
+ theta=nodes.THETA, radius=radii, z=z, rho=rho, drho_dz=None,
146
+ neck=neck, scalars=scalars, length_keys=frozenset(),
147
+ dr_dtheta=dr_dtheta, r_north=r_north, r_south=r_south, z_cm=z_cm,
148
+ overlay_z=overlay_z, overlay_rho=overlay_rho, overlay_z_cm=overlay_z_cm,
149
+ overlay_ok=overlay_ok)
150
+
151
+ def filename(self, z: int, n: int, params: dict[str, float]) -> str:
152
+ betas = "_".join(f"{params[f'beta{i}']:.2f}" for i in range(1, N_BETAS + 1))
153
+ return f"{z}_{n}_{betas}.png"
154
+
155
+ def energy_requests(self, params: dict[str, float],
156
+ result: ShapeResult) -> list[EnergyRequest]:
157
+ """WMMM requests: the slider (blue) shape, plus the COM-corrected
158
+ (orange) shape when the overlay is on screen. WMMM recomputes beta10
159
+ itself under com_correction=True, so both carry the same betas."""
160
+ shape = tuple(params[f"beta{i}"] for i in range(1, N_BETAS + 1)) \
161
+ + (0.0,) * (WMMM_N_LEGENDRE_PARAMS - N_BETAS)
162
+ requests = [EnergyRequest("slider", "legendre", shape, com_correction=False)]
163
+ if result.overlay_z is not None:
164
+ requests.append(
165
+ EnergyRequest("COM corrected", "legendre", shape, com_correction=True))
166
+ return requests
@@ -0,0 +1,107 @@
1
+ """FoS render over the fos_parameterization package.
2
+
3
+ rho(z)-native (lib-exact drho_dz, lib-native neck); R(theta) and analytic
4
+ dR/dtheta come from the library's cached tier on the shared GL-2048 set
5
+ (shape_plotters/core/nodes.py) — in sync with the energy model's dense grid. The cache
6
+ evaluates R(theta) in the total-shift frame internally, so no z_shift
7
+ plumbing. Neck position/radius are lib values on the cache's u-grid; only
8
+ the displayed depth reuses the shared peak analysis on the display profile.
9
+ The cache is read-only and every call resolves from params, so it may be
10
+ shared across threads. A separated shape draws its fragments from the
11
+ unchecked profile; the checked statuses still decide validity.
12
+
13
+ Fast-math precondition: params must be finite. Sliders only emit finite
14
+ values, so no screening happens here.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import numpy as np
19
+ import fos_parameterization as fp
20
+
21
+ from shape_plotters.core import nodes
22
+ from shape_plotters.core.neck import find_neck_indices, neck_depth
23
+ from shape_plotters.core.result import EnergyRequest, NeckInfo, ShapeResult, SliderSpec, ToggleSpec
24
+
25
+ # rho(z) display-panel resolution (native COM-frame profile) — a display
26
+ # choice, not a calculation grid: the GL theta-nodes sample the star-convex
27
+ # frame and cannot replace it. Tier-1 call: display density is a consumer
28
+ # choice the cache's u-grid must not dictate.
29
+ N_PROFILE_POINTS = 721
30
+ N_RHO_GRID = 7201 # cache u-grid — WMMM's N_FOS_RHO_GRID_POINTS
31
+ PARAM_KEYS = ("c", "a3", "a4", "a5", "a6", "a7", "a8")
32
+
33
+
34
+ class FoSRender:
35
+ name = "fos"
36
+ has_extra_panel = True
37
+ # Ranges and practical-limit markers from ShapePlotterFoSFitter.
38
+ slider_specs = [
39
+ SliderSpec("c", "c", 0.5, 3.5, 1.0, 0.01, markers=(1.0, 3.0)),
40
+ SliderSpec("a3", "a3", -0.6, 0.6, 0.0, 0.01, markers=(0.0, 0.5)),
41
+ SliderSpec("a4", "a4", -0.75, 0.75, 0.0, 0.01, markers=(-0.2, 0.72)),
42
+ ] + [SliderSpec(f"a{i}", f"a{i}", -0.5, 0.5, 0.0, 0.01, markers=(-0.2, 0.2))
43
+ for i in range(5, 9)]
44
+ toggles: list[ToggleSpec] = []
45
+
46
+ def __init__(self) -> None:
47
+ # One read-only cache: max_params 7, WMMM-parity u-grid, GL-2048 thetas.
48
+ self._cache = fp.Cache(len(PARAM_KEYS), N_RHO_GRID, nodes.THETA)
49
+
50
+ def compute(self, params: dict[str, float], toggles: dict[str, bool]) -> ShapeResult:
51
+ arr = [params[k] for k in PARAM_KEYS]
52
+ shp = self._cache.shape(arr)
53
+ rd = self._cache.radius_and_derivative(arr) if shp.ok else None
54
+ prof = fp.rho_z_grid(arr, N_PROFILE_POINTS)
55
+ ok = shp.ok and rd is not None and rd.ok and prof.ok
56
+ # A separated shape fails the checked profile with rho_negative; draw
57
+ # its fragments from the unchecked profile (rho = 0 in the void). The
58
+ # checked results keep deciding ok, status and title.
59
+ drawn = (fp.rho_z_grid_unchecked(arr, N_PROFILE_POINTS)
60
+ if prof.status == fp.Status.rho_negative else prof)
61
+
62
+ if ok:
63
+ radii, dr_dtheta = rd.radii, rd.dr_dtheta
64
+ else:
65
+ radii = np.zeros(nodes.N_NODES)
66
+ dr_dtheta = np.zeros(nodes.N_NODES)
67
+
68
+ primary = shp if not shp.ok else (rd if not rd.ok else prof)
69
+
70
+ # Cylindrical path carries its own rho-negative gate (2.0.0 gating
71
+ # asymmetry): separated shapes fail inside neck(); non-star-convex
72
+ # and beak-marginal shapes still yield a lib neck.
73
+ neck_info = None
74
+ nk = self._cache.neck(arr)
75
+ if nk.ok and nk.found and nk.rho_neck > 0.0:
76
+ depth = 0.0
77
+ if drawn.ok:
78
+ hit = find_neck_indices(drawn.rho)
79
+ if hit is not None:
80
+ depth = neck_depth(drawn.rho, *hit)
81
+ neck_info = NeckInfo(z=nk.z_neck, rho=nk.rho_neck,
82
+ depth=depth, source="lib")
83
+ # The FoS shape is COM-centered by definition (rho_z_grid is the COM
84
+ # frame). The R(θ) representation carries the star-convexity shift, so its
85
+ # own COM is offset — the engine draws that overlay separately.
86
+ return ShapeResult(
87
+ status=int(primary.status), status_name=primary.status.name,
88
+ message=primary.message,
89
+ theta=nodes.THETA, radius=radii,
90
+ z=drawn.z, rho=drawn.rho, drho_dz=drawn.drho_dz,
91
+ neck=neck_info,
92
+ scalars={"z_shift": shp.z_shift, "a2": fp.a2(arr)},
93
+ length_keys=frozenset({"z_shift"}),
94
+ dr_dtheta=dr_dtheta, r_north=shp.r_north, r_south=shp.r_south, z_cm=0.0)
95
+
96
+ def filename(self, z: int, n: int, params: dict[str, float]) -> str:
97
+ return (f"fos_shape_Z{z}_N{n}_c{params['c']:.2f}"
98
+ + "".join(f"_a{i}{params[f'a{i}']:.2f}" for i in range(3, 9))
99
+ + ".png")
100
+
101
+ def energy_requests(self, params: dict[str, float],
102
+ result: ShapeResult) -> list[EnergyRequest]:
103
+ """One request — the FoS dashed overlay is a reframing of the same
104
+ shape, not a second shape; com_correction is inert on the FoS path."""
105
+ return [EnergyRequest("FoS", "fos",
106
+ tuple(params[k] for k in PARAM_KEYS),
107
+ com_correction=True)]
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: shape-plotters
3
+ Version: 0.1.0
4
+ Summary: Interactive matplotlib plotters for axially symmetric nuclear shapes (beta, FoS)
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/AleksanderAugustyn/shape-plotters
7
+ Project-URL: Repository, https://github.com/AleksanderAugustyn/shape-plotters
8
+ Project-URL: Changelog, https://github.com/AleksanderAugustyn/shape-plotters/blob/master/CHANGELOG.md
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: numpy>=2.0
13
+ Requires-Dist: scipy>=1.13
14
+ Requires-Dist: matplotlib>=3.8.4
15
+ Requires-Dist: beta-parameterization==4.0.1
16
+ Requires-Dist: fos-parameterization==3.0.0
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest>=7; extra == "test"
19
+ Dynamic: license-file
20
+
21
+ # shape-plotters
22
+
23
+ Interactive matplotlib plotters for axially symmetric nuclear shapes. Move a
24
+ slider and the shape, its derivative and its integral properties update. One
25
+ shared engine draws the figure; each shape parameterization is a render module.
26
+
27
+ | render | parameterization | sliders | library |
28
+ |---|---|---|---|
29
+ | `beta` | spherical-harmonic (Legendre) expansion | β1–β8 | [beta-parameterization](https://github.com/AleksanderAugustyn/beta-parameterization) |
30
+ | `fos` | Fourier-over-Spheroid | c, a3–a8 | [fos-parameterization](https://github.com/AleksanderAugustyn/fos-parameterization) |
31
+
32
+ The shapes are not re-implemented here. R(θ) and the analytic dR/dθ come from
33
+ the compiled libraries, evaluated on a 2048-node Gauss-Legendre grid in cos θ.
34
+ That is the dense grid of the WMMM macroscopic-microscopic energy model, so
35
+ the plot shows exactly the shape the model evaluates.
36
+
37
+ ## What the figure shows
38
+
39
+ - **R(θ) and dR/dθ**, and the **cross-section** ρ(z) with equal axes. The FoS
40
+ render adds a ρ(z) panel with the library's dρ/dz.
41
+ - **Stats:** volume, surface and centre of mass by Gauss-Legendre quadrature,
42
+ the neck position, radius and depth, and the fragment volume and mass split
43
+ at the neck.
44
+ - **Overlays** (orange, dashed): for beta, the centre-of-mass corrected shape
45
+ when its β1 differs from the slider value; for FoS, the R(θ) star-convex
46
+ representation when its centre of mass is shifted.
47
+ - **Scission bands:** the neck-radius range 1.2–1.5 fm, shaded.
48
+ - **Invalid shapes** stay on screen, greyed, with the library's status as the
49
+ panel title.
50
+
51
+ Controls: ± nudge buttons on every slider, an fm / R₀ unit toggle with Z and N
52
+ boxes (R₀ = 1.16 A^(1/3) fm), Reset, and Save (300 dpi PNG named after the
53
+ parameters, written to the current directory). Red dotted marks on the FoS
54
+ sliders are practical limits.
55
+
56
+ ## Requirements
57
+
58
+ - Linux x86-64. The parameterization libraries ship manylinux wheels only;
59
+ WSL2 with WSLg works.
60
+ - Python 3.10 or newer with a matplotlib GUI backend. Tk is the default
61
+ (`python3-tk` on Debian/Ubuntu).
62
+
63
+ ## Install
64
+
65
+ pip install shape-plotters
66
+
67
+ or `pipx install shape-plotters` to get the command without touching another
68
+ environment. The parameterization libraries come along as prebuilt wheels
69
+ (Fortran shared library and libgfortran bundled), pinned to the exact versions
70
+ each render is written against.
71
+
72
+ Under WSL, keep the virtual environment on the Linux filesystem, not under
73
+ `/mnt/c`. The Windows drive is a 9P mount; a venv there makes matplotlib
74
+ rendering sluggish (~5× slower).
75
+
76
+ ## Run
77
+
78
+ shape-plotters beta
79
+ shape-plotters fos
80
+
81
+ `python -m shape_plotters beta` does the same.
82
+
83
+ ## Energy button
84
+
85
+ An Energy button appears when a Python package named `wmmm` is importable. It
86
+ prints the WMMM energies of the shape on screen. WMMM is not public yet and is
87
+ not a dependency: without it the button is absent and nothing else changes.
88
+ This repository contains the calling convention only, no model code, data or
89
+ output.
90
+
91
+ ## Development
92
+
93
+ git clone https://github.com/AleksanderAugustyn/shape-plotters.git
94
+ cd shape-plotters
95
+ python3 -m venv ~/.venvs/shape-plotters
96
+ source ~/.venvs/shape-plotters/bin/activate
97
+ pip install -e ".[test]"
98
+ python -m pytest
99
+
100
+ The WMMM smoke tests skip when `wmmm` is not installed. The frame-time budgets
101
+ in `tests/test_perf.py` assume a venv on the Linux filesystem.
102
+
103
+ shape_plotters/cli.py entry point and render registry
104
+ shape_plotters/core/ engine, widgets, quadrature, neck and fragment helpers
105
+ shape_plotters/core/result.py the render contract (what a render gives the engine)
106
+ shape_plotters/renders/ one module per parameterization
107
+ tests/ pytest suite, including frame-time budgets
108
+
109
+ A new parameterization is a new module in `shape_plotters/renders/` that
110
+ implements the render contract, plus one registry entry in `cli.py` and a
111
+ pinned library in `pyproject.toml`. The engine needs no change.
112
+
113
+ Releases are cut by pushing a version tag such as `0.1.0`; the `wheels`
114
+ workflow builds, tests and publishes to PyPI.
115
+
116
+ ## License
117
+
118
+ MIT, see [LICENSE](https://github.com/AleksanderAugustyn/shape-plotters/blob/master/LICENSE).
119
+ Changes are recorded in
120
+ [CHANGELOG.md](https://github.com/AleksanderAugustyn/shape-plotters/blob/master/CHANGELOG.md).
@@ -0,0 +1,21 @@
1
+ shape_plotters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ shape_plotters/__main__.py,sha256=jEE_3YQRcSTsewHe2AA_6rp2nCYEf_zp-AK7KKRjIq0,44
3
+ shape_plotters/cli.py,sha256=msJyvJMn8z0r4OO25k2pgddbxfSC2u0TA8-wdIRrDE0,699
4
+ shape_plotters/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ shape_plotters/core/energy.py,sha256=x2rJPM5XQdUQDbLMAe8JirsicZd4blnQpMJI83qte-k,3328
6
+ shape_plotters/core/engine.py,sha256=rnhgebjKbcj1wPxLpZXCtPuwDyTPFH5GYwJoWUjuHb8,21481
7
+ shape_plotters/core/fragments.py,sha256=nTHZex17cvSJywHL1M2OMxRygNe6VgFPJFUdDaGLV_Y,2721
8
+ shape_plotters/core/neck.py,sha256=tQIHcFT55JFyoLQKmWekHGOfkqsqWHRGlVGoE_yT7RI,1356
9
+ shape_plotters/core/nodes.py,sha256=8kqgGqCN0foSgjA6W4ij_5z8MMHY0QdvDpYT5LvXKf8,927
10
+ shape_plotters/core/quadrature.py,sha256=_jKz1Y_GG-gxLwHAwPop0az99Q0rAwPSvZScUrBztG4,1473
11
+ shape_plotters/core/result.py,sha256=fRInQWY0h0fwCbOI2sUjO9L3N86AD8-e_moU-hkhsPg,3495
12
+ shape_plotters/core/widgets.py,sha256=DkZmJdq8vpmOpDUEcLHn-tAajz4_614b3lZsDJA76P4,2949
13
+ shape_plotters/renders/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ shape_plotters/renders/beta.py,sha256=sXwzF87DG8vbgrGI2MV-qRxxqCp9JgFLDip_m4lj4qA,8611
15
+ shape_plotters/renders/fos.py,sha256=xT4rfQy-u_xsjyb3C0d-dFQnT3nF9MOgIkeN7Ck6xak,5233
16
+ shape_plotters-0.1.0.dist-info/licenses/LICENSE,sha256=TOWvwhGul1VrJu1mvCr4ROOVhUyrLLeyMrHlz43kvbM,1076
17
+ shape_plotters-0.1.0.dist-info/METADATA,sha256=jswckpQ6D2Q6deO1B29ZFcStt-GTZKgONRRs5mTYXpM,5133
18
+ shape_plotters-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
19
+ shape_plotters-0.1.0.dist-info/entry_points.txt,sha256=c9-sRNkE0HDFY-Cmoz-bYQnZbZ2uEKypBzlqdbTygHU,59
20
+ shape_plotters-0.1.0.dist-info/top_level.txt,sha256=zNeTjJBofeWO2t8wqtCJhAm_sdGGTrLQokCA_sp1WCY,15
21
+ shape_plotters-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ shape-plotters = shape_plotters.cli:main