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 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
@@ -0,0 +1,5 @@
1
+ """Exporters sub-package."""
2
+
3
+ from radiapy.exporters.html_exporter import export_dashboard
4
+
5
+ __all__ = ["export_dashboard"]