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
|
@@ -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,,
|