radiapy 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.
- radiapy/__init__.py +20 -0
- radiapy/__main__.py +82 -0
- radiapy/cli.py +70 -0
- radiapy/core.py +263 -0
- radiapy/exporters/__init__.py +5 -0
- radiapy/exporters/html_exporter.py +320 -0
- radiapy/parsers/__init__.py +14 -0
- radiapy/parsers/base_parser.py +127 -0
- radiapy/parsers/csv_parser.py +114 -0
- radiapy/parsers/geant4_parser.py +121 -0
- radiapy/physics/__init__.py +16 -0
- radiapy/physics/attenuation.py +134 -0
- radiapy/physics/inverse_square.py +97 -0
- radiapy/physics/nist_data.py +128 -0
- radiapy/visualizers/__init__.py +17 -0
- radiapy/visualizers/attenuation_viz.py +243 -0
- radiapy/visualizers/comparison_viz.py +242 -0
- radiapy/visualizers/distance_viz.py +172 -0
- radiapy/visualizers/particle_viz.py +226 -0
- radiapy-0.1.0.dist-info/METADATA +285 -0
- radiapy-0.1.0.dist-info/RECORD +25 -0
- radiapy-0.1.0.dist-info/WHEEL +5 -0
- radiapy-0.1.0.dist-info/entry_points.txt +2 -0
- radiapy-0.1.0.dist-info/licenses/LICENSE +21 -0
- radiapy-0.1.0.dist-info/top_level.txt +1 -0
radiapy/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Radiapy — Interactive visualization of radiation experiments.
|
|
3
|
+
|
|
4
|
+
Quick start::
|
|
5
|
+
|
|
6
|
+
from radiapy import RadViz
|
|
7
|
+
|
|
8
|
+
rv = RadViz("dataset_geiger.csv")
|
|
9
|
+
rv.generate_dashboard("dashboard.html")
|
|
10
|
+
|
|
11
|
+
Or visualize a single plot::
|
|
12
|
+
|
|
13
|
+
fig = rv.plot_attenuation()
|
|
14
|
+
fig.show()
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from radiapy.core import RadViz
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
__all__ = ["RadViz"]
|
radiapy/__main__.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Entry point for ``python -m radiapy CSV_FILE [options]``.
|
|
2
|
+
|
|
3
|
+
Usage
|
|
4
|
+
-----
|
|
5
|
+
python -m radiapy demo/dataset_geiger.csv
|
|
6
|
+
python -m radiapy demo/dataset_geiger.csv --output my_report.html
|
|
7
|
+
python -m radiapy demo/dataset_geiger.csv --report-only
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
18
|
+
p = argparse.ArgumentParser(
|
|
19
|
+
prog="python -m radiapy",
|
|
20
|
+
description="Radiapy — interactive radiation experiment visualizer",
|
|
21
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
22
|
+
epilog="""
|
|
23
|
+
examples:
|
|
24
|
+
python -m radiapy demo/dataset_geiger.csv
|
|
25
|
+
python -m radiapy demo/dataset_geiger.csv --output report.html
|
|
26
|
+
python -m radiapy demo/dataset_geiger.csv --report-only
|
|
27
|
+
""",
|
|
28
|
+
)
|
|
29
|
+
p.add_argument("csv_file", metavar="CSV_FILE", help="Geiger-counter CSV dataset")
|
|
30
|
+
p.add_argument(
|
|
31
|
+
"--output", "-o",
|
|
32
|
+
default="dashboard.html",
|
|
33
|
+
metavar="FILE",
|
|
34
|
+
help="Output HTML dashboard path (default: dashboard.html)",
|
|
35
|
+
)
|
|
36
|
+
p.add_argument(
|
|
37
|
+
"--report-only",
|
|
38
|
+
action="store_true",
|
|
39
|
+
help="Print the physics report to stdout without generating HTML",
|
|
40
|
+
)
|
|
41
|
+
return p.parse_args(argv)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main(argv: list[str] | None = None) -> int:
|
|
45
|
+
args = _parse_args(argv)
|
|
46
|
+
csv_path = Path(args.csv_file)
|
|
47
|
+
|
|
48
|
+
if not csv_path.exists():
|
|
49
|
+
print(f"ERROR: file not found — {csv_path}", file=sys.stderr)
|
|
50
|
+
return 1
|
|
51
|
+
|
|
52
|
+
from radiapy.core import RadViz
|
|
53
|
+
|
|
54
|
+
print("=" * 60)
|
|
55
|
+
print(" Radiapy v0.1.0 — Radiation Experiment Visualizer")
|
|
56
|
+
print("=" * 60)
|
|
57
|
+
|
|
58
|
+
rv = RadViz(csv_path)
|
|
59
|
+
|
|
60
|
+
print(f"\nDataset loaded: {csv_path}")
|
|
61
|
+
print(f" Isotopes : {', '.join(rv.dataset.isotopes)}")
|
|
62
|
+
print(f" Distances : {rv.dataset.distances} cm")
|
|
63
|
+
print(f" Materials : {', '.join(rv.dataset.materials)}")
|
|
64
|
+
print(f" Total rows : {len(rv.dataset.raw)}")
|
|
65
|
+
print()
|
|
66
|
+
|
|
67
|
+
print(rv.physics_report())
|
|
68
|
+
|
|
69
|
+
if args.report_only:
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
out = rv.generate_dashboard(args.output)
|
|
73
|
+
size_kb = out.stat().st_size // 1024
|
|
74
|
+
print(f"\nDashboard saved : {out.resolve()}")
|
|
75
|
+
print(f"File size : {size_kb} KB")
|
|
76
|
+
print(f"\nOpen in browser :")
|
|
77
|
+
print(f" open {out}")
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
sys.exit(main())
|
radiapy/cli.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Command-line interface for Radiapy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from radiapy.core import RadViz
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
@click.version_option(package_name="radiapy")
|
|
15
|
+
def main() -> None:
|
|
16
|
+
"""Radiapy — radiation experiment visualizer."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@main.command()
|
|
20
|
+
@click.argument("input", metavar="CSV_FILE", type=click.Path(exists=True, dir_okay=False))
|
|
21
|
+
@click.option(
|
|
22
|
+
"--output", "-o",
|
|
23
|
+
default="dashboard.html",
|
|
24
|
+
show_default=True,
|
|
25
|
+
help="Path for the output HTML dashboard.",
|
|
26
|
+
)
|
|
27
|
+
def dashboard(input: str, output: str) -> None:
|
|
28
|
+
"""Generate an interactive HTML dashboard from CSV_FILE."""
|
|
29
|
+
rv = RadViz(input)
|
|
30
|
+
path = rv.generate_dashboard(output)
|
|
31
|
+
click.echo(f"\nOpen in browser: {path.resolve()}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@main.command()
|
|
35
|
+
@click.argument("input", metavar="CSV_FILE", type=click.Path(exists=True, dir_okay=False))
|
|
36
|
+
def report(input: str) -> None:
|
|
37
|
+
"""Print a physics report (fits, μ values, discrepancies) to stdout."""
|
|
38
|
+
rv = RadViz(input)
|
|
39
|
+
click.echo(rv.physics_report())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@main.command()
|
|
43
|
+
@click.option("--isotope", default="Am241", show_default=True, help="Isotope key.")
|
|
44
|
+
@click.option("--material", default="Pb", show_default=True, help="Absorber material.")
|
|
45
|
+
@click.option(
|
|
46
|
+
"--output", "-o",
|
|
47
|
+
default="particles.html",
|
|
48
|
+
show_default=True,
|
|
49
|
+
help="Output HTML file.",
|
|
50
|
+
)
|
|
51
|
+
def simulate(isotope: str, material: str, output: str) -> None:
|
|
52
|
+
"""Export the animated particle simulation (no CSV required)."""
|
|
53
|
+
import plotly.io as pio
|
|
54
|
+
from radiapy.visualizers.particle_viz import plot_particles
|
|
55
|
+
|
|
56
|
+
click.echo(f"Simulating {isotope} through {material} …")
|
|
57
|
+
fig = plot_particles(isotope=isotope, material=material)
|
|
58
|
+
out = Path(output)
|
|
59
|
+
pio.write_html(fig, out, include_plotlyjs=True, full_html=True)
|
|
60
|
+
click.echo(f"Saved: {out.resolve()}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Backward-compatible alias: `radviz --input FILE --output FILE`
|
|
64
|
+
@click.command(name="radviz", hidden=True)
|
|
65
|
+
@click.option("--input", "-i", required=True, type=click.Path(exists=True), help="Input CSV file.")
|
|
66
|
+
@click.option("--output", "-o", default="dashboard.html", help="Output HTML path.")
|
|
67
|
+
def radviz_legacy(input: str, output: str) -> None:
|
|
68
|
+
"""Legacy single-command interface: radviz --input FILE --output FILE."""
|
|
69
|
+
rv = RadViz(input)
|
|
70
|
+
rv.generate_dashboard(output)
|
radiapy/core.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Core RadViz class: one-stop interface for parsing, physics, and visualization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import plotly.graph_objects as go
|
|
10
|
+
|
|
11
|
+
from radiapy.parsers.base_parser import ParsedDataset
|
|
12
|
+
from radiapy.parsers.csv_parser import CSVParser
|
|
13
|
+
from radiapy.physics import (
|
|
14
|
+
fit_inverse_square,
|
|
15
|
+
fit_attenuation,
|
|
16
|
+
transmission_from_ratio_stats,
|
|
17
|
+
)
|
|
18
|
+
from radiapy.physics.nist_data import (
|
|
19
|
+
MU_LINEAR,
|
|
20
|
+
FILTER_THICKNESS_CM,
|
|
21
|
+
expected_transmission,
|
|
22
|
+
ISOTOPE_DESCRIPTION,
|
|
23
|
+
)
|
|
24
|
+
from radiapy.visualizers.attenuation_viz import plot_attenuation, plot_attenuation_spectrum
|
|
25
|
+
from radiapy.visualizers.distance_viz import plot_inverse_square, plot_inverse_square_normalized
|
|
26
|
+
from radiapy.visualizers.comparison_viz import plot_comparison, plot_heatmap, plot_mu_comparison
|
|
27
|
+
from radiapy.visualizers.particle_viz import plot_particles
|
|
28
|
+
from radiapy.exporters.html_exporter import export_dashboard
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RadViz:
|
|
32
|
+
"""Main Radiapy interface.
|
|
33
|
+
|
|
34
|
+
Load an experimental CSV, compute physics fits, and generate interactive
|
|
35
|
+
Plotly visualizations or a standalone HTML dashboard.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
csv_path : str or Path
|
|
40
|
+
Path to the Geiger-counter experiment CSV file.
|
|
41
|
+
schema : dict, optional
|
|
42
|
+
Column mapping for non-standard CSV files. See ``CSVParser.DEFAULT_SCHEMA``.
|
|
43
|
+
|
|
44
|
+
Examples
|
|
45
|
+
--------
|
|
46
|
+
>>> from radiapy import RadViz
|
|
47
|
+
>>> rv = RadViz("radviz/demo/dataset_geiger.csv")
|
|
48
|
+
>>> rv.generate_dashboard("dashboard.html")
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, csv_path: str | Path, schema: dict | None = None) -> None:
|
|
52
|
+
self.csv_path = Path(csv_path)
|
|
53
|
+
self.dataset: ParsedDataset = CSVParser(self.csv_path, schema).parse()
|
|
54
|
+
self._fits: dict[str, Any] = {}
|
|
55
|
+
self._compute_fits()
|
|
56
|
+
|
|
57
|
+
# ------------------------------------------------------------------ #
|
|
58
|
+
# Physics
|
|
59
|
+
# ------------------------------------------------------------------ #
|
|
60
|
+
|
|
61
|
+
def _compute_fits(self) -> None:
|
|
62
|
+
"""Run all physics fits and cache results."""
|
|
63
|
+
ds = self.dataset
|
|
64
|
+
|
|
65
|
+
# Inverse-square fits per isotope
|
|
66
|
+
for iso in ds.isotopes:
|
|
67
|
+
gs_list = sorted(
|
|
68
|
+
[gs for gs in ds.group_stats if gs.isotope == iso],
|
|
69
|
+
key=lambda g: g.distance_cm,
|
|
70
|
+
)
|
|
71
|
+
if len(gs_list) < 2:
|
|
72
|
+
continue
|
|
73
|
+
distances = np.array([g.distance_cm for g in gs_list])
|
|
74
|
+
counts = np.array([g.counts_free_mean for g in gs_list])
|
|
75
|
+
sems = np.array([g.counts_free_sem for g in gs_list])
|
|
76
|
+
self._fits[f"isq_{iso}"] = fit_inverse_square(distances, counts, sems, isotope=iso)
|
|
77
|
+
|
|
78
|
+
# Attenuation fits: we back-calculate μ per (isotope, material) from T_mean
|
|
79
|
+
for iso in ds.isotopes:
|
|
80
|
+
for mat in ds.materials:
|
|
81
|
+
x = FILTER_THICKNESS_CM.get(mat)
|
|
82
|
+
mu_nist = MU_LINEAR.get(iso, {}).get(mat)
|
|
83
|
+
if x is None or mu_nist is None:
|
|
84
|
+
continue
|
|
85
|
+
# Use all distances: collect (x_eff, counts_filtered, counts_free)
|
|
86
|
+
thicknesses, counts_filt, counts_free, sems = [], [], [], []
|
|
87
|
+
for gs in ds.group_stats:
|
|
88
|
+
if gs.isotope != iso:
|
|
89
|
+
continue
|
|
90
|
+
T = gs.transmission.get(mat)
|
|
91
|
+
T_sem = gs.transmission_sem.get(mat, 0.0)
|
|
92
|
+
I0 = gs.counts_free_mean
|
|
93
|
+
if T is not None and I0 > 0:
|
|
94
|
+
thicknesses.append(x)
|
|
95
|
+
counts_filt.append(T * I0)
|
|
96
|
+
counts_free.append(I0)
|
|
97
|
+
sems.append(T_sem * I0 if T_sem else None)
|
|
98
|
+
|
|
99
|
+
if len(thicknesses) >= 1:
|
|
100
|
+
self._fits[f"att_{iso}_{mat}"] = fit_attenuation(
|
|
101
|
+
np.array(thicknesses, dtype=float),
|
|
102
|
+
np.array(counts_filt, dtype=float),
|
|
103
|
+
I0_estimate=float(np.mean(counts_free)),
|
|
104
|
+
mu_nist=mu_nist,
|
|
105
|
+
material=mat,
|
|
106
|
+
isotope=iso,
|
|
107
|
+
sigma=None,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def physics_report(self) -> str:
|
|
111
|
+
"""Return a human-readable text summary of all physics fits."""
|
|
112
|
+
lines = ["=" * 70, "Radiapy Physics Report", "=" * 70, ""]
|
|
113
|
+
|
|
114
|
+
lines.append("--- Inverse-Square Law Fits ---")
|
|
115
|
+
for iso in self.dataset.isotopes:
|
|
116
|
+
key = f"isq_{iso}"
|
|
117
|
+
if key in self._fits:
|
|
118
|
+
lines.append(f" {self._fits[key].summary()}")
|
|
119
|
+
lines.append("")
|
|
120
|
+
|
|
121
|
+
lines.append("--- Attenuation Fits vs NIST ---")
|
|
122
|
+
for iso in self.dataset.isotopes:
|
|
123
|
+
for mat in self.dataset.materials:
|
|
124
|
+
key = f"att_{iso}_{mat}"
|
|
125
|
+
if key in self._fits:
|
|
126
|
+
lines.append(f" {self._fits[key].summary()}")
|
|
127
|
+
lines.append("")
|
|
128
|
+
|
|
129
|
+
lines.append("--- Expected Transmissions (NIST Beer-Lambert) ---")
|
|
130
|
+
for iso in self.dataset.isotopes:
|
|
131
|
+
for mat in self.dataset.materials:
|
|
132
|
+
t = expected_transmission(iso, mat)
|
|
133
|
+
mu = MU_LINEAR.get(iso, {}).get(mat, float("nan"))
|
|
134
|
+
x = FILTER_THICKNESS_CM.get(mat, float("nan"))
|
|
135
|
+
lines.append(
|
|
136
|
+
f" {iso}/{mat}: T_NIST = {t:.4f} (μ = {mu:.3f} cm⁻¹, x = {x} cm)"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return "\n".join(lines)
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------------------------------ #
|
|
142
|
+
# Individual visualizations
|
|
143
|
+
# ------------------------------------------------------------------ #
|
|
144
|
+
|
|
145
|
+
def plot_attenuation(
|
|
146
|
+
self,
|
|
147
|
+
isotope: str | None = None,
|
|
148
|
+
distance_cm: float | None = None,
|
|
149
|
+
show_nist: bool = True,
|
|
150
|
+
) -> go.Figure:
|
|
151
|
+
"""Grouped bar chart of experimental transmission per material.
|
|
152
|
+
|
|
153
|
+
Parameters
|
|
154
|
+
----------
|
|
155
|
+
isotope : Restrict to one isotope. None → all (side-by-side).
|
|
156
|
+
distance_cm : Restrict to one distance. None → average over all.
|
|
157
|
+
show_nist : Draw Beer-Lambert reference lines.
|
|
158
|
+
"""
|
|
159
|
+
return plot_attenuation(self.dataset, isotope=isotope, distance_cm=distance_cm, show_nist=show_nist)
|
|
160
|
+
|
|
161
|
+
def plot_attenuation_spectrum(self, materials: list[str] | None = None) -> go.Figure:
|
|
162
|
+
"""NIST μ/ρ vs energy for Al, Cu, Pb (shows Pb K-edge)."""
|
|
163
|
+
return plot_attenuation_spectrum(materials)
|
|
164
|
+
|
|
165
|
+
def plot_inverse_square(self, isotope: str | None = None) -> go.Figure:
|
|
166
|
+
"""Counts vs distance scatter with 1/r² fit overlay."""
|
|
167
|
+
return plot_inverse_square(self.dataset, isotope=isotope)
|
|
168
|
+
|
|
169
|
+
def plot_inverse_square_normalized(self) -> go.Figure:
|
|
170
|
+
"""Counts × r² vs distance — flat → confirms inverse-square law."""
|
|
171
|
+
return plot_inverse_square_normalized(self.dataset)
|
|
172
|
+
|
|
173
|
+
def plot_heatmap(self) -> go.Figure:
|
|
174
|
+
"""2-D heatmap of transmission: rows = material, columns = (isotope, distance)."""
|
|
175
|
+
return plot_heatmap(self.dataset)
|
|
176
|
+
|
|
177
|
+
def plot_mu_comparison(self) -> go.Figure:
|
|
178
|
+
"""Bar chart of experimental vs NIST μ with discrepancy %."""
|
|
179
|
+
return plot_mu_comparison(self.dataset)
|
|
180
|
+
|
|
181
|
+
def plot_comparison(self, distance_cm: float | None = None) -> go.Figure:
|
|
182
|
+
"""Cd109 vs Am241 overlay for each material."""
|
|
183
|
+
return plot_comparison(self.dataset, distance_cm=distance_cm)
|
|
184
|
+
|
|
185
|
+
def plot_particles(
|
|
186
|
+
self,
|
|
187
|
+
isotope: str = "Am241",
|
|
188
|
+
material: str = "Pb",
|
|
189
|
+
thickness_levels: list[float] | None = None,
|
|
190
|
+
) -> go.Figure:
|
|
191
|
+
"""Animated photon simulation with absorber-thickness slider.
|
|
192
|
+
|
|
193
|
+
Parameters
|
|
194
|
+
----------
|
|
195
|
+
isotope : Source isotope.
|
|
196
|
+
material : Absorber material.
|
|
197
|
+
thickness_levels : List of thicknesses (cm) for the slider.
|
|
198
|
+
"""
|
|
199
|
+
return plot_particles(isotope=isotope, material=material, thickness_levels=thickness_levels)
|
|
200
|
+
|
|
201
|
+
# ------------------------------------------------------------------ #
|
|
202
|
+
# Dashboard
|
|
203
|
+
# ------------------------------------------------------------------ #
|
|
204
|
+
|
|
205
|
+
def generate_dashboard(self, output_path: str | Path = "dashboard.html") -> Path:
|
|
206
|
+
"""Build and export a full interactive HTML dashboard.
|
|
207
|
+
|
|
208
|
+
Parameters
|
|
209
|
+
----------
|
|
210
|
+
output_path : Destination HTML file path.
|
|
211
|
+
|
|
212
|
+
Returns
|
|
213
|
+
-------
|
|
214
|
+
Path to the generated file.
|
|
215
|
+
"""
|
|
216
|
+
output_path = Path(output_path)
|
|
217
|
+
print(f"Building Radiapy dashboard → {output_path} …")
|
|
218
|
+
|
|
219
|
+
figures: dict = {}
|
|
220
|
+
|
|
221
|
+
print(" [1/8] Attenuation bar charts …")
|
|
222
|
+
figures["attenuation"] = self.plot_attenuation()
|
|
223
|
+
|
|
224
|
+
print(" [2/8] NIST μ/ρ spectrum …")
|
|
225
|
+
figures["spectrum"] = self.plot_attenuation_spectrum()
|
|
226
|
+
|
|
227
|
+
print(" [3/8] Inverse-square law …")
|
|
228
|
+
figures["distance"] = self.plot_inverse_square()
|
|
229
|
+
|
|
230
|
+
print(" [4/8] 1/r² diagnostic …")
|
|
231
|
+
figures["distance_norm"] = self.plot_inverse_square_normalized()
|
|
232
|
+
|
|
233
|
+
print(" [5/8] Transmission heatmap …")
|
|
234
|
+
figures["heatmap"] = self.plot_heatmap()
|
|
235
|
+
|
|
236
|
+
print(" [6/8] μ comparison …")
|
|
237
|
+
figures["mu_compare"] = self.plot_mu_comparison()
|
|
238
|
+
|
|
239
|
+
print(" [7/8] Multi-isotope comparison …")
|
|
240
|
+
figures["comparison"] = self.plot_comparison()
|
|
241
|
+
|
|
242
|
+
print(" [8/8] Particle animation …")
|
|
243
|
+
figures["particles"] = self.plot_particles()
|
|
244
|
+
|
|
245
|
+
# Summary stats for header cards
|
|
246
|
+
n_rows = len(self.dataset.raw)
|
|
247
|
+
n_isotopes = len(self.dataset.isotopes)
|
|
248
|
+
n_distances = len(self.dataset.distances)
|
|
249
|
+
n_materials = len(self.dataset.materials)
|
|
250
|
+
n_replicates = n_rows // (n_isotopes * n_distances) if n_isotopes * n_distances else 0
|
|
251
|
+
|
|
252
|
+
dataset_summary = {
|
|
253
|
+
"isotopes": (n_isotopes, "Isotopes"),
|
|
254
|
+
"distances": (n_distances, "Distances"),
|
|
255
|
+
"materials": (n_materials, "Materials"),
|
|
256
|
+
"replicates": (n_replicates, "Replicates / condition"),
|
|
257
|
+
"rows": (n_rows, "Total measurements"),
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
path = export_dashboard(figures, output_path, dataset_summary=dataset_summary)
|
|
261
|
+
size_kb = path.stat().st_size // 1024
|
|
262
|
+
print(f"\nDashboard generated: {path} ({size_kb} KB)")
|
|
263
|
+
return path
|