Compare commits

46 Commits

Author SHA1 Message Date
f1658ac6e0 remove mkinit requirement 2026-05-17 08:30:44 +02:00
10868dae6d Merge pull request #72 from pim-n/release-0.1.0
prepare for release 0.1.0
2026-05-17 07:36:38 +02:00
80f1266183 exclude exp. coordinates due to data mismatch. warn users 2026-05-17 07:32:04 +02:00
e70b939c81 suppress matplotlib and PIL debug logging 2026-05-17 07:31:42 +02:00
3a23414810 fix interpolation issue at -170 to -180 degree 2026-05-17 07:31:14 +02:00
e39574eb8b Merge pull request #70 from pim-n/feature-unc-plotting
Feature unc plotting
2026-05-12 08:53:00 +02:00
fcbde3547c remove print line 2026-05-12 08:50:42 +02:00
adceafbec7 update NaIR efficiencies 2026-05-12 08:48:42 +02:00
a0bca78d5d add uncertainty bands in plot for sqrt(N) counting statistics 2026-05-06 00:38:17 +02:00
bc538ff5dd hotfix: fix incorrect integration scheme, move to time integration as Bukartas 2021 doccomp. 2026-05-06 00:37:26 +02:00
2a15ae12f6 Merge branch 'dev' of github.com:pim-n/pg-rad into dev 2026-05-03 17:46:50 +02:00
d48f42e8af update metadata 2026-05-03 17:46:10 +02:00
3afd217366 Merge pull request #69 from pim-n/feature-new-detector
Feature new detector
2026-05-03 17:40:27 +02:00
54ae0c4c0d fix #61 2026-05-03 17:38:09 +02:00
40b09971ff pass global seed onto fluence and background 2026-05-03 17:29:53 +02:00
372082f19b add bounds_check optional flag (default false) 2026-05-03 15:58:38 +02:00
2dd6ab6beb add angular dependence NaIR 2026-04-23 16:05:20 +02:00
ec4bf6cbe6 add NaIR detector (isotropic) 2026-04-23 16:02:29 +02:00
b63fa68f21 hotfix: dataframe export fail due to inconsistent dimensions 2026-04-21 12:19:25 +02:00
be6ef0e084 Merge pull request #68 from pim-n/feature-opt-bkg
optional background and fix cps bug
2026-04-21 11:46:40 +02:00
936e283705 bugfix: first cps is 0 2026-04-21 11:43:11 +02:00
591e2fb41a add bkg count option in input 2026-04-21 10:53:53 +02:00
8017159c5b patch export to add also detector details 2026-04-16 13:40:47 +02:00
eadf14fd49 Merge pull request #66 from pim-n/exporting-data
Exporting data
2026-04-15 09:31:32 +02:00
73f630bd47 fix if logic so warning not raised incorrectly 2026-04-15 09:02:03 +02:00
086b4b4b55 fix csv filename output 2026-04-15 08:58:56 +02:00
f02daa35dd add parameter JSON export 2026-04-15 08:44:18 +02:00
09609b4429 add landscape size as simulation output 2026-04-15 08:43:11 +02:00
0a0073ccce hotfix for export filename 2026-04-14 15:08:49 +02:00
237b301061 update docs. update example configs to latest spec. 2026-04-02 09:11:52 +02:00
3d4cea337b remove print statement 2026-04-02 08:34:58 +02:00
bd4f9af6ba add warning when user does not specify any outputs 2026-04-02 08:34:43 +02:00
06422b208b remove requirements.txt 2026-04-02 08:28:56 +02:00
2bd70634dc recursively include all datafiles in pg_rad.data module 2026-03-31 13:30:02 +02:00
f75a80f792 Merge pull request #60 from pim-n/feature-background-activity
background CPS for 3x3 inch NaI detector based on FWHM lookup method
export functionality (to CSV)
2026-03-31 11:16:20 +02:00
07741f1392 include landscape edges 2026-03-31 11:10:15 +02:00
3ec2a7601c PEP8 2026-03-31 10:59:26 +02:00
99028c916f PEP8 2026-03-31 10:51:16 +02:00
5bcf1778ea update plotting. add export functionality. update main to work with new plotting and saving/export functionality. 2026-03-31 10:48:54 +02:00
7ed12989f4 add waypoints (XY/East North) to CountRateOutput 2026-03-31 10:47:51 +02:00
591d81b8a3 rename test to example landscape. Fixes #51 2026-03-30 08:52:08 +02:00
78d877c9bc PEP8 2026-03-30 08:49:18 +02:00
60edbd1976 fix typos / PEP8 2026-03-30 08:48:37 +02:00
db12d573b2 improve out of bounds handling of source placement 2026-03-30 08:48:15 +02:00
c635c7f594 Add background functionality 2026-03-30 08:24:07 +02:00
0a60bb09e9 improve plotting functionality 2026-03-30 08:23:02 +02:00
22 changed files with 612 additions and 151 deletions

26
CITATION.cff Normal file
View File

@ -0,0 +1,26 @@
# This CITATION.cff file was generated with cffinit.
# Visit https://bit.ly/cffinit to generate yours today!
cff-version: 1.2.0
title: PG-RAD - Primary Gamma RADiation Simulator
message: >-
If you use this software, please cite it using the
metadata from this file.
type: software
authors:
- given-names: Pim
family-names: Nelissen
email: pi0274ne-s@student.lu.se
affiliation: Lund University
repository-code: 'https://github.com/pim-n/pg-rad'
abstract: >-
Primary Gamma RADiation (PG-RAD) is a deterministic
landscape simulator to simulate vehicle-based gamma source
localization scenarios.
keywords:
- gamma spectrometry
- mobile gamma spectrometry
- emergency preparedness
- environmental radiation
license: MIT

View File

@ -2,10 +2,7 @@
To get started quickly, you may copy and modify one of the example configs found [here](quickstart.md#example-configs).
The config file must be a [YAML](https://yaml.org/) file. YAML is a serialization language that works with key-value pairs, but in a syntax more readable than some other alternatives. In YAML, the indentation matters. I
The remainder of this chapter will explain the different required and optionals keys, what they represent, and allowed values.
The config file must be a [YAML](https://yaml.org/) file. YAML is a serialization language that works with key-value pairs, but in a syntax more readable than some other alternatives. The remainder of this chapter will explain the different required and optionals keys, what they represent, and allowed values.
## Required keys
@ -124,11 +121,11 @@ Like with the lengths, if a turn segment has no angle specified, a random one (w
Letting PG-RAD randomly assign lengths and angles can cause (expected) issues. That is because of physics restrictions. If the combination of length, angle (radius) and velocity of the vehicle is such that the centrifugal force makes it impossible to take this turn, PG-RAD will raise an error. To fix it, you can 1) reduce the speed; 2) define a smaller angle for the turn; or 3) assign more length to the turn segment.
!!! info
For more information about how procedural roads are generated, including the random sampling of lengths and angles, see X
For more information about how procedural roads are generated, including the random sampling of lengths and angles, see [this](explainers/prefab_roads.ipynb) explainer.
### Sources
Currently, the only type of source supported is a point source. Point sources can be added under the `sources` key, where the **subkey is the name** of the source:
Currently, the only type of source supported is an isotropic point source. However, an arbitrary number of point sources can be added to the landscape. Point sources can be added under the `sources` key, where the **subkey is the name** of the source:
```yaml
sources:
@ -190,21 +187,10 @@ Note that side is relative to the direction of travel. The path will by default
### Detector
The final required key is the `detector`. Currently, only isotropic detectors are supported. Nonetheless, you must specify it with `name`, `is_isotropic` and `efficiency`:
The final required key is the `detector`. Currently, custom detectors are not yet supported and you must choose from a list of existing detectors:
```yaml
detector:
name: test
is_isotropic: True
efficiency: 0.02
```
Note there are some existing detectors available, where efficiency is not required and will be looked up by PG-RAD itself:
```yaml
detector:
name: NaIR
is_isotropic: True
detector: LU_HPGe_90
```
## Optional keys

View File

@ -0,0 +1,121 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "a8d303ad",
"metadata": {},
"source": [
"# Gamma detectors along a path"
]
},
{
"cell_type": "markdown",
"id": "08dda386",
"metadata": {},
"source": [
"## Fluence rate at $\\vec{r}$\n",
"\n",
"Let $\\vec{r}_{p} = (x_{p},y_{p},z_{p})$ denote the location of a point source $p$. Let $\\vec{r}_{i} = (x_{i},y_{i},z_{i})$ denote an arbitrary point in space. The primary photon fluence rate at $\\vec{r}$ is then given by\n",
"\n",
"$$\n",
"\\dot{\\phi}(r) = \\frac{A n_\\gamma \\exp(-\\mu_{air} r)}{4\\pi r^2}\n",
"$$\n",
"\n",
"where $r = ||\\vec{r}_p - \\vec{r}_i ||$. The units are $\\dot{\\phi} \\sim \\frac{\\text{photons}}{s \\cdot m^2}$\n",
"\n",
"## Count rate\n",
"\n",
"Gamma detectors are not perfectly efficient and efficiency is dependent on both photon energy $E_\\gamma$ and incident angle $\\theta$ [1].\n",
"\n",
"- the field efficiency $\\varepsilon_D (E_\\gamma) \\in [0, 1]$, in units of area $\\text{m}^2$,\n",
"- the relative angular efficiency $\\varepsilon_\\theta (E_\\gamma, \\theta) \\in [0, 1]$, dimensionless.\n",
"\n",
"The total efficiency of the detector is then defined as\n",
"\n",
"$$\n",
"\\varepsilon(E_\\gamma, \\theta) = \\varepsilon_D (E_\\gamma) \\varepsilon_\\theta (E_\\gamma, \\theta) \\; .\n",
"$$\n",
"\n",
"Where $\\varepsilon(E_\\gamma, \\theta) \\sim \\text{m}^2$.\n",
"\n",
"If the detector $D$ is positioned at $\\vec{r}_i$, the **count rate** becomes\n",
"\n",
"$$\n",
"\\dot{N}(r, E_\\gamma, \\theta) = \\varepsilon(E_\\gamma, \\theta) \\phi(r)\n",
"$$\n",
"\n",
"where $\\dot{N} \\sim \\frac{\\text{counts}}{s}$.\n",
"\n",
"## Acquisiton time \n",
"\n",
"The acquisition time window $t_{w}$ is the time during which counts are accumulated in the detector until readout into the digital system. A typical $t_{w}$ in mobile gamma spectrometry is 1 to 10 seconds [2]. \n",
"\n",
"## Integration of counts\n",
"\n",
"Suppose an acquisition time of $t_{w}$ seconds and a fixed velocity $v$ in meters per seconds. Let $R(u)$ describe a road of $L$ meters long in the xy-plane, described as a function of arc length $u$ in meters (distance traveled along the road), where $u \\in [0, L]$. The euclidian norm between the point $R(u)$ and point source $\\vec{r}_p$ is then\n",
"\n",
"$$\n",
"r(u) = || \\vec{r}_p - R(u) ||\n",
"$$\n",
"\n",
"Assuming a fixed velocity $v$, the distance traveled during one acquisition window $t_{w}$ is $\\Delta_s \\equiv vt_{w}$ meters. The path is divided into $K = L/\\Delta s$ segments, where the $k$-th segment represents the interval\n",
"\n",
"$$\n",
"u \\in [(k-1) \\Delta_s, k\\Delta_s] \\; , \\; k = 1, 2, \\dots, K\n",
"$$\n",
"\n",
"The total count rate acquired during segment $k$-th is then\n",
"\n",
"$$\n",
"N_{w}(k) = \\frac{1}{v} \\int_{(k-1)\\Delta_s}^{k\\Delta_s} \\underbrace{\\dot{N}(r(u), E_\\gamma, \\theta(u))}_{\\text{CPS}} du\n",
"$$\n",
"\n",
"## Numerical approximation\n",
"\n",
"Let us divide each segment into $N$ equally spaced points with step size $\\Delta u = \\Delta s / N$. Applying the trapezoidal rule then gives\n",
"\n",
"$$\n",
"N_w(k) \\approx \\frac{\\Delta u}{v}\n",
"\\left[\n",
"\\frac{\\dot{N}_0 + \\dot{N}_N}{2}\n",
"+ \\sum_{n=1}^{N-1} \\dot{N}_n\n",
"\\right],\n",
"$$\n",
"\n",
"where\n",
"\n",
"$$\n",
"\\dot{N}_n = \\dot{N}\\big(r(u_n), E_\\gamma, \\theta(u_n)\\big), \\quad\n",
"u_n = (k-1)\\Delta s + n \\Delta u.\n",
"$$\n",
"\n",
"## References\n",
"\n",
"[1] A. Bukartas, Assessment of mobile radiometry data in radiological emergencies using Bayesian statistical methods, thesis/doccomp, Lund University, 2021. Accessed: Jan. 19, 2026. [Online]. Available: http://lup.lub.lu.se/record/4c298e71-3278-42a7-818a-6f17a5121d56\n",
"\n",
"[2] R. Finck, A. Bukartas, M. Jönsson, and C. Rääf, Maximum detection distances for gamma emitting point sources in mobile gamma spectrometry, Applied Radiation and Isotopes, vol. 184, p. 110195, Jun. 2022, doi: 10.1016/j.apradiso.2022.110195.\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": ".venv",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.9"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@ -22,13 +22,18 @@ Primary Gamma RADiation landscape tool
If you get something like `pgrad: command not found`, please consult the [installation guide](installation.md).
You can run a quick test scenario as follows:
You can run a quick test by running the example landscape as follows:
```
pgrad --test
pgrad --example
```
This should produce a plot of a scenario containing a single point source and a path.
This should produce an output like
```
INFO: Landscape built successfully: Example landscape
WARNING: No output produced. Use --save flag to save outputs and/or --showplots to display interactive plots.
```
## Running PG-RAD
@ -38,11 +43,11 @@ In order to use the CLI for your own simulations, you need to provide a *config
pgrad --config path/to/my_config.yml
```
where `path/to/my_config.yml` points to your config file.
where `path/to/my_config.yml` points to your config file. To check the results live, add the `--showplots` flag. If you want to save the results directly, then add the `--save` flag (you can use them at the same time as well).
## Example configs
The easiest way is to take one of these example configs, and adjust them as needed. Alternatively, there is a detailed guide on how to write your own config file [here](config-spec.md).
The easiest way to get started is to take one of these example configs, and adjust them as needed. Alternatively, there is a detailed guide on how to write your own config file [here](config-spec.md).
=== "Example 1"
@ -61,15 +66,14 @@ The easiest way is to take one of these example configs, and adjust them as need
sources:
source1:
activity_MBq: 1000
isotope: CS137
isotope: Cs137
gamma_energy_keV: 662
position:
along_path: 100
dist_from_path: 50
side: left
detector:
name: dummy
is_isotropic: True
detector: dummy
```
=== "Example 2"
@ -89,21 +93,21 @@ The easiest way is to take one of these example configs, and adjust them as need
sources:
source1:
activity_MBq: 1000
isotope: CS137
isotope: Cs137
gamma_energy_keV: 662
position: [104.3, 32.5, 0]
source2:
activity_MBq: 100
isotope: CS137
isotope: Cs137
gamma_energy_keV: 662
position: [0, 0, 0]
detector:
name: dummy
is_isotropic: True
detector: dummy
```
=== "Example 3"
This is an example of a procedural path with random apportionment of total length and random angles being assigned to turns. The parameter `alpha` is optional, and is related to randomness. A higher value leads to more uniform apportionment of lengths and a lower value to more random apportionment. More information about `alpha` can be found [here](pg-rad-config-spec.md).
This is an example of a procedural path with random apportionment of total length and random angles being assigned to turns. The parameter `alpha` is optional, and is related to randomness. A higher value leads to more uniform apportionment of lengths and a lower value to more random apportionment. More information about `alpha` can be found [here](explainers/prefab_roads.ipynb).
``` yaml
name: Example 3
@ -121,12 +125,11 @@ The easiest way is to take one of these example configs, and adjust them as need
sources:
source1:
activity_MBq: 1000
isotope: CS137
isotope: Cs137
gamma_energy_keV: 662
position: [0, 0, 0]
detector:
name: dummy
is_isotropic: True
detector: dummy
```
=== "Example 4"
@ -148,12 +151,11 @@ The easiest way is to take one of these example configs, and adjust them as need
sources:
source1:
activity_MBq: 1000
isotope: CS137
isotope: Cs137
gamma_energy_keV: 662
position: [0, 0, 0]
detector:
name: dummy
is_isotropic: True
detector: dummy
```
=== "Example 5"
@ -178,10 +180,9 @@ The easiest way is to take one of these example configs, and adjust them as need
sources:
source1:
activity_MBq: 1000
isotope: CS137
isotope: Cs137
gamma_energy_keV: 662
position: [0, 0, 0]
detector:
name: dummy
is_isotropic: True
detector: dummy
```

View File

@ -6,16 +6,16 @@ build-backend = "setuptools.build_meta"
where = ["src"]
[tool.setuptools.package-data]
"pg_rad.data" = ["*.csv"]
"pg_rad.data" = ["**/*.csv"]
"pg_rad.configs" = ["*.yml"]
[project]
name = "pg-rad"
version = "0.2.1"
name = "pgrad"
version = "0.1.0"
authors = [
{ name="Pim Nelissen", email="pi0274ne-s@student.lu.se" },
]
description = "Primary Gamma RADiation Landscape"
description = "PG-RAD - Primary Gamma RADiation Simulator"
readme = "README.md"
requires-python = ">=3.12.4,<3.13"
dependencies = [
@ -37,4 +37,4 @@ Issues = "https://github.com/pim-n/pg-rad/issues"
[project.optional-dependencies]
dev = ["pytest", "mkinit", "notebook", "mkdocs-material", "mkdocstrings-python", "mkdocs-jupyter", "flake8"]
dev = ["pytest", "notebook", "mkdocs-material", "mkdocstrings-python", "mkdocs-jupyter", "flake8"]

View File

@ -1,6 +0,0 @@
matplotlib>=3.9.2
notebook>=7.2.1
numpy>=2
pandas>=2.3.1
piecewise_regression==1.5.0
pyyaml>=6.0.2

View File

@ -9,11 +9,23 @@ from pg_rad.detector.detector import Detector
def generate_background(
cps_array: np.ndarray,
detector: Detector,
energy_keV: float,
lam_inp: int | None = None,
seed: int | None = None
) -> np.ndarray:
"""
Generate synthetic background cps for a given detector and energy.
"""
pass
if not lam_inp:
ROI_lo, ROI_hi = get_roi_from_fwhm(detector, energy_keV)
lam = get_cps_from_roi(detector, ROI_lo, ROI_hi)
else:
lam = lam_inp
rng = np.random.default_rng(seed=seed)
return rng.poisson(lam=lam, size=cps_array.shape)
def fwhm(A: float, B: float, C: float, E: float) -> float:
@ -39,8 +51,13 @@ def get_cps_from_roi(
roi_hi: float
) -> float:
try:
csv = files('pg_rad.data.backgrounds').joinpath(detector.name+'.csv')
data = read_csv(csv)
except FileNotFoundError:
raise NotImplementedError(
f"Detector {detector.name} does not have backgrounds implemented."
)
# get indices of nearest bins
idx_min = (data["Energy"] - roi_lo).abs().idxmin()

View File

@ -18,6 +18,7 @@ angle,662,1173,1332
160,1.113,1.096,1.099
170,1.091,1.076,1.083
180,1.076,1.066,1.078
-180,1.076,1.066,1.078
-170,1.102,1.091,1.093
-160,1.122,1.100,1.102
-150,1.128,1.105,1.093

1 angle 662 1173 1332
18 160 1.113 1.096 1.099
19 170 1.091 1.076 1.083
20 180 1.076 1.066 1.078
21 -180 1.076 1.066 1.078
22 -170 1.102 1.091 1.093
23 -160 1.122 1.100 1.102
24 -150 1.128 1.105 1.093

View File

@ -0,0 +1,38 @@
angle,662
0,0.027
10,0.162
20,0.346
30,0.517
40,0.662
50,0.794
60,0.882
70,0.947
80,0.995
90,1.000
100,0.970
110,0.895
120,0.778
130,0.649
140,0.546
150,0.477
160,0.387
170,0.267
180,0.205
-180,0.205
-170,0.266
-160,0.385
-150,0.527
-140,0.671
-130,0.764
-120,0.838
-110,0.763
-100,0.838
-90,0.903
-80,0.904
-70,0.898
-60,0.862
-50,0.717
-40,0.337
-30,0.154
-20,0.253
-10,0.125
1 angle 662
2 0 0.027
3 10 0.162
4 20 0.346
5 30 0.517
6 40 0.662
7 50 0.794
8 60 0.882
9 70 0.947
10 80 0.995
11 90 1.000
12 100 0.970
13 110 0.895
14 120 0.778
15 130 0.649
16 140 0.546
17 150 0.477
18 160 0.387
19 170 0.267
20 180 0.205
21 -180 0.205
22 -170 0.266
23 -160 0.385
24 -150 0.527
25 -140 0.671
26 -130 0.764
27 -120 0.838
28 -110 0.763
29 -100 0.838
30 -90 0.903
31 -80 0.904
32 -70 0.898
33 -60 0.862
34 -50 0.717
35 -40 0.337
36 -30 0.154
37 -20 0.253
38 -10 0.125

View File

@ -0,0 +1,3 @@
Energy,Data,cps
0,0,0
10000,0,0
1 Energy Data cps
2 0 0 0
3 10000 0 0

View File

@ -2,3 +2,4 @@ name,type,is_isotropic
dummy,NaI,true
LU_NaI_3inch,NaI,true
LU_HPGe_90,HPGe,false
LU_NaIR,NaI,false
1 name type is_isotropic
2 dummy NaI true
3 LU_NaI_3inch NaI true
4 LU_HPGe_90 HPGe false
5 LU_NaIR NaI false

View File

@ -0,0 +1,5 @@
energy_keV,field_efficiency_m2
0,0
661.657,0.0261
1173.228,0.0203
1332.492,0.0166
1 energy_keV field_efficiency_m2
2 0 0
3 661.657 0.0261
4 1173.228 0.0203
5 1332.492 0.0166

View File

@ -104,7 +104,7 @@ class ConfigParser:
def _parse_options(self) -> SimulationOptionsSpec:
options = self.config.get("options", {})
allowed = {"air_density_kg_per_m3", "seed"}
allowed = {"air_density_kg_per_m3", "seed", "bkg_cps"}
self._warn_unknown_keys(
section="options",
provided=set(options.keys()),
@ -116,23 +116,33 @@ class ConfigParser:
defaults.DEFAULT_AIR_DENSITY
)
seed = options.get("seed")
bkg_cps = options.get("bkg_cps")
if not isinstance(air_density, float) or air_density <= 0:
raise InvalidConfigValueError(
"options.air_density_kg_per_m3 must be a positive float "
"in kg/m^3."
)
if (
seed is not None or
seed is not None and
(isinstance(seed, int) and seed <= 0)
):
raise InvalidConfigValueError(
"Seed must be a positive integer value."
)
if bkg_cps is not None and (
not isinstance(bkg_cps, int) or bkg_cps < 0
):
raise InvalidConfigValueError(
"Background CPS must be an integer >= 0."
)
return SimulationOptionsSpec(
air_density=air_density,
seed=seed,
bkg_cps=bkg_cps
)
def _parse_path(self) -> PathSpec:

View File

@ -18,6 +18,7 @@ class RuntimeSpec:
class SimulationOptionsSpec:
air_density: float
seed: int | None = None
bkg_cps: int | None = None
@dataclass

View File

@ -107,7 +107,8 @@ class LandscapeBuilder:
def set_point_sources(
self,
*sources: AbsolutePointSourceSpec | RelativePointSourceSpec
*sources: AbsolutePointSourceSpec | RelativePointSourceSpec,
bounds_check: bool = False
):
"""Add one or more point sources to the world.
@ -145,8 +146,12 @@ class LandscapeBuilder:
along_path=along_path,
side=s.side,
dist_from_path=s.dist_from_path)
if any(
p < 0 or p >= s for p, s in zip(pos, self._size)
# we dont support -x values, but negative y values are possible as
# the path is centered in the y direction.
if bounds_check and not (
(0 <= pos[0] <= self._size[0]) and
(-0.5 * self._size[1] <= pos[1] <= 0.5 * self._size[1])
):
raise OutOfBoundsError(
"One or more sources attempted to "

View File

@ -2,6 +2,7 @@ import argparse
import logging
import sys
from numpy.random import SeedSequence
from pandas.errors import ParserError
from pg_rad.exceptions.exceptions import (
@ -17,6 +18,7 @@ from pg_rad.inputparser.parser import ConfigParser
from pg_rad.landscape.director import LandscapeDirector
from pg_rad.plotting.result_plotter import ResultPlotter
from pg_rad.simulator.engine import SimulationEngine
from pg_rad.utils.export import generate_folder_name, save_results
def main():
@ -29,9 +31,9 @@ def main():
help="Build from a config file."
)
parser.add_argument(
"--test",
"--example",
action="store_true",
help="Load and run the test landscape."
help="Load and run an example landscape."
)
parser.add_argument(
"--loglevel",
@ -39,18 +41,23 @@ def main():
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
)
parser.add_argument(
"--saveplot",
"--showplots",
action="store_true",
help="Save the plot or not."
help="Show the plots immediately."
)
parser.add_argument(
"--save",
action="store_true",
help="Save the outputs"
)
args = parser.parse_args()
setup_logger(args.loglevel)
logger = logging.getLogger(__name__)
if args.test:
test_yaml = """
name: Test landscape
if args.example:
input_config = """
name: Example landscape
speed: 8.33
acquisition_time: 1
@ -66,27 +73,27 @@ def main():
sources:
test_source:
activity_MBq: 100
position: [250, 100, 0]
position: [250, 30, 0]
isotope: Cs137
gamma_energy_keV: 661
detector: LU_NaI_3inch
options:
seed: 1234
"""
cp = ConfigParser(test_yaml).parse()
landscape = LandscapeDirector.build_from_config(cp)
output = SimulationEngine(
landscape=landscape,
runtime_spec=cp.runtime,
sim_spec=cp.options,
).simulate()
plotter = ResultPlotter(landscape, output)
plotter.plot()
elif args.config:
input_config = args.config
else:
logger.warning(
"No input provided. Try --example or --config path/to/config.yml. "
)
sys.exit(1)
try:
cp = ConfigParser(args.config).parse()
cp = ConfigParser(input_config).parse()
if cp.options.seed is None:
entr = SeedSequence().entropy
cp.options.seed = int(str(entr)[:6])
landscape = LandscapeDirector.build_from_config(cp)
output = SimulationEngine(
landscape=landscape,
@ -95,7 +102,18 @@ def main():
).simulate()
plotter = ResultPlotter(landscape, output)
if args.save:
folder_name = generate_folder_name(output)
save_results(output, folder_name)
plotter.save(folder_name)
if args.showplots:
plotter.plot()
if not (args.save or args.showplots):
logger.warning(
"No output produced. Use --save flag to save outputs and/or "
"--showplots to display interactive plots."
)
except (
MissingConfigKeyError,
KeyError
@ -111,7 +129,8 @@ def main():
OutOfBoundsError,
DimensionError,
InvalidIsotopeError,
InvalidConfigValueError
InvalidConfigValueError,
NotImplementedError
) as e:
logger.critical(e)
logger.critical(

View File

@ -2,6 +2,8 @@ from typing import Tuple, TYPE_CHECKING
import numpy as np
from pg_rad.background.background import generate_background
if TYPE_CHECKING:
from pg_rad.landscape.landscape import Landscape
from pg_rad.detector.detector import Detector
@ -106,7 +108,10 @@ def calculate_counts_along_path(
landscape: "Landscape",
detector: "Detector",
velocity: float,
t_acq: float,
points_per_segment: int = 10,
bkg_cps_input: int | None = None,
seed: int | None = None
) -> Tuple[np.ndarray, np.ndarray]:
"""Compute the counts recorded in each acquisition period in the landscape.
@ -114,6 +119,7 @@ def calculate_counts_along_path(
landscape (Landscape): _description_
detector (Detector): _description_
points_per_segment (int, optional): _description_. Defaults to 100.
bkg_cps_input (int | None, optional): Optional background CPS.
Returns:
Tuple[np.ndarray, np.ndarray]: Array of acquisition points and
@ -146,12 +152,41 @@ def calculate_counts_along_path(
landscape, full_positions, detector
)
# reshape so each segment is on a row
cps_per_seg = cps.reshape(num_segments, points_per_segment)
if bkg_cps_input is None:
bkg = generate_background(
cps, detector, landscape.point_sources[0].isotope.E,
seed=seed
)
elif bkg_cps_input == 0:
bkg = bkg_cps_input
else:
bkg = generate_background(
cps, detector, landscape.point_sources[0].isotope.E,
lam_inp=bkg_cps_input,
seed=seed
)
du = s[1] - s[0]
integrated_counts = np.trapezoid(cps_per_seg, dx=du, axis=1) / velocity
int_counts_result = np.zeros(num_points)
int_counts_result[1:] = integrated_counts
cps_with_bg = cps + bkg
return original_distances, s, cps, int_counts_result
# Integrate along time dimension. see e.g. Bukartas (2021 doccomp.)
t = s / abs(velocity)
# acquisition bins
t_bins = np.arange(0, t[-1] + t_acq, t_acq)
int_counts = np.zeros(len(t_bins) - 1)
acq_points = np.zeros(len(t_bins) - 1)
for i in range(len(t_bins) - 1):
mask = (t >= t_bins[i]) & (t < t_bins[i + 1])
int_counts[i] = np.trapezoid(cps_with_bg[mask], t[mask])
acq_points[i] = np.mean(s[mask])
return (
acq_points[:-1],
s[:-1],
cps_with_bg[:-1],
int_counts[:-1],
np.mean(bkg)
)

View File

@ -4,11 +4,10 @@ from matplotlib import pyplot as plt
from matplotlib.axes import Axes
from matplotlib.patches import Circle
from numpy import median
from pg_rad.landscape.landscape import Landscape
logger = logging.getLogger(__name__)
plt.set_loglevel(level='warning')
class LandscapeSlicePlotter:
@ -58,12 +57,7 @@ class LandscapeSlicePlotter:
ax.set_xlim(right=max(width, .5*height))
# if the road is very flat, we center it vertically (looks better)
if median(landscape.path.y_list) == 0:
h = max(height, .5*width)
ax.set_ylim(bottom=-h//2,
top=h//2)
else:
ax.set_ylim(top=max(height, .5*width))
ax.set_ylim(bottom=-.5*width, top=.5*width)
ax.set_xlabel("X [m]")
ax.set_ylabel("Y [m]")

View File

@ -10,6 +10,9 @@ from pg_rad.simulator.outputs import SimulationOutput
from pg_rad.landscape.landscape import Landscape
plt.set_loglevel(level='warning')
class ResultPlotter:
def __init__(self, landscape: Landscape, output: SimulationOutput):
self.landscape = landscape
@ -23,6 +26,15 @@ class ResultPlotter:
plt.show()
def save(self, path: str, landscape_z: float = 0) -> None:
fig_1 = self._plot_main(landscape_z)
fig_2 = self._plot_detector()
fig_3 = self._plot_metadata()
fig_1.savefig(path+"/main.jpg")
fig_2.savefig(path+"/detector.jpg")
fig_3.savefig(path+"/metadata.jpg")
def _plot_main(self, landscape_z):
fig = plt.figure(figsize=(12, 8))
fig.suptitle(self.landscape.name)
@ -37,10 +49,11 @@ class ResultPlotter:
self._draw_cps(ax_cps)
ax_counts = fig.add_subplot(gs[0, 1])
self._draw_count_rate(ax_counts)
self._draw_counts(ax_counts)
ax_landscape = fig.add_subplot(gs[1, :])
self._plot_landscape(ax_landscape, landscape_z)
return fig
def _plot_detector(self):
det = self.landscape.detector
@ -61,6 +74,7 @@ class ResultPlotter:
]
self._draw_angular_efficiency_polar(ax_polar, det, energies[0])
return fig
def _plot_metadata(self):
fig, axs = plt.subplots(2, 1, figsize=(10, 6))
@ -68,6 +82,7 @@ class ResultPlotter:
self._draw_table(axs[0])
self._draw_source_table(axs[1])
return fig
def _plot_landscape(self, ax, z):
lp = LandscapeSlicePlotter()
@ -77,17 +92,35 @@ class ResultPlotter:
def _draw_cps(self, ax):
x = self.count_rate_res.sub_points
y = self.count_rate_res.cps
ax.plot(x, y, color='b')
ax.plot(x, y, color='b', label=f'max(CPS) = {y.max():.2f}')
ax.legend(handlelength=0, handletextpad=0, fancybox=True)
ax.set_title('Counts per second (CPS)')
ax.set_xlabel('Arc length s [m]')
ax.set_ylabel('CPS [s$^{-1}$]')
def _draw_count_rate(self, ax):
x = self.count_rate_res.acquisition_points
y = self.count_rate_res.integrated_counts
ax.plot(x, y, color='r', linestyle='--', alpha=0.2)
def _draw_counts(self, ax):
x = self.count_rate_res.distance[1:]
y = self.count_rate_res.integrated_counts[1:]
yerr = np.sqrt(y)
peak_idx = y.argmax()
ax.fill_between(
x,
y - yerr,
y + yerr,
color='red',
alpha=0.15,
)
ax.plot(
x, y,
color='r', linestyle='--', alpha=0.2,
label=rf'max(counts)={y[peak_idx]:.2f} $\pm$ {yerr[peak_idx]:.2f}'
)
ax.legend(handlelength=0, handletextpad=0, fancybox=True)
ax.scatter(x, y, color='r', marker='x')
ax.set_title('Integrated counts')
ax.set_title('Counts')
ax.set_xlabel('Arc length s [m]')
ax.set_ylabel('N')
@ -99,6 +132,8 @@ class ResultPlotter:
["Air density (kg/m^3)", round(self.landscape.air_density, 3)],
["Total path length (m)", round(self.landscape.path.length, 3)],
["Readout points", len(self.count_rate_res.integrated_counts)],
["Seed", self.count_rate_res.seed],
["Mean background cps", round(self.count_rate_res.mean_bkg_cps, 3)]
]
ax.table(
@ -124,7 +159,7 @@ class ResultPlotter:
# list field efficiencies for each primary gamma in the landscape
effs = {e: det.get_efficiency(e) for e in source_energies}
formatted_effs = ", ".join(
f"{value:.3f} @ {key:.1f} keV"
f"{value:.5f} @ {key:.1f} keV"
for key, value in effs.items()
)
ax.set_axis_off()
@ -201,6 +236,5 @@ class ResultPlotter:
theta_rad = np.radians(theta_deg)
print(theta_rad)
ax.plot(theta_rad, eff)
ax.set_title(f"Rel. angular efficiency @ {energy_keV:.1f} keV")

View File

@ -3,6 +3,7 @@ from typing import List
from pg_rad.landscape.landscape import Landscape
from pg_rad.simulator.outputs import (
CountRateOutput,
DetectorOutput,
SimulationOutput,
SourceOutput
)
@ -31,21 +32,38 @@ class SimulationEngine:
count_rate_results = self._calculate_count_rate_along_path()
source_results = self._calculate_point_source_distance_to_path()
detector_results = self._generate_detector_output()
return SimulationOutput(
name=self.landscape.name,
size=self.landscape.size,
count_rate=count_rate_results,
detector=detector_results,
sources=source_results
)
def _calculate_count_rate_along_path(self) -> CountRateOutput:
acq_points, sub_points, cps, int_counts = calculate_counts_along_path(
acq_points, sub_points, cps, int_counts, mean_bkg_counts = (
calculate_counts_along_path(
self.landscape,
self.detector,
velocity=self.runtime_spec.speed
velocity=self.runtime_spec.speed,
bkg_cps_input=self.sim_spec.bkg_cps,
t_acq=self.runtime_spec.acquisition_time,
seed=self.sim_spec.seed
)
)
return CountRateOutput(acq_points, sub_points, cps, int_counts)
return CountRateOutput(
self.landscape.path.x_list,
self.landscape.path.y_list,
acq_points,
sub_points,
cps,
int_counts,
mean_bkg_counts,
self.sim_spec.seed
)
def _calculate_point_source_distance_to_path(self) -> List[SourceOutput]:
@ -69,3 +87,13 @@ class SimulationEngine:
)
return source_output
def _generate_detector_output(self) -> DetectorOutput:
return DetectorOutput(
name=self.detector.name,
type=self.detector.type,
is_isotropic=self.detector.is_isotropic,
field_eff=self.detector.get_efficiency(
self.landscape.point_sources[0].isotope.E
)
)

View File

@ -5,10 +5,14 @@ from dataclasses import dataclass
@dataclass
class CountRateOutput:
acquisition_points: List[float]
x: List[float]
y: List[float]
distance: List[float]
sub_points: List[float]
cps: List[float]
integrated_counts: List[float]
mean_bkg_cps: List[float]
seed: int
@dataclass
@ -21,8 +25,18 @@ class SourceOutput:
dist_from_path: float
@dataclass
class DetectorOutput:
name: str
type: str
is_isotropic: bool
field_eff: float
@dataclass
class SimulationOutput:
name: str
size: tuple
detector: DetectorOutput
count_rate: CountRateOutput
sources: List[SourceOutput]

128
src/pg_rad/utils/export.py Normal file
View File

@ -0,0 +1,128 @@
from dataclasses import asdict
from datetime import datetime as dt
import json
import os
import logging
import re
from numpy import array, full_like, ndarray, bool_
from pandas import DataFrame
from pg_rad.simulator.outputs import SimulationOutput
logger = logging.getLogger(__name__)
logging.getLogger("PIL").setLevel(logging.WARNING)
class NumpyEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, ndarray):
return obj.tolist()
elif isinstance(obj, bool_):
return bool(obj)
return super().default(obj)
def generate_folder_name(sim: SimulationOutput) -> str:
formatted_sim_name = re.sub(r"\s+", '_', sim.name.lower())
folder_name = (
formatted_sim_name +
'_result_' +
dt.today().strftime('%Y%m%d_%H%M')
)
return folder_name
def save_results(sim: SimulationOutput, folder_name: str) -> None:
"""Parse all simulation output and save to a folder."""
if not os.path.exists(folder_name):
os.makedirs(folder_name)
else:
logger.warning("Folder already exists. Overwrite?")
ans = input("[type 'n' to cancel overwrite] ")
if ans.lower() == 'n':
return
logger.debug(
f"Integrated counts: {list(sim.count_rate.integrated_counts)}"
)
logger.debug(
f"Distances: {list(sim.count_rate.distance)}"
)
df = generate_df(sim)
csv_name = generate_csv_name(sim)
df.to_csv(f"{folder_name}/{csv_name}.csv", index=False)
param_dict = generate_sim_param_dict(sim)
with open(f"{folder_name}/parameters.json", 'w') as f:
json.dump(param_dict, f, cls=NumpyEncoder)
logger.info(f"Simulation output saved to {folder_name}!")
def generate_sim_param_dict(sim: SimulationOutput) -> dict:
"""Parse simulation parameters and hyperparameters to dictionary."""
d = asdict(sim)
d.pop('count_rate')
return d
def generate_df(sim: SimulationOutput) -> DataFrame:
"""Parse simulation output to CSV format and the name of CSV."""
br_array = full_like(
sim.count_rate.integrated_counts,
sim.count_rate.mean_bkg_cps
)
east_coords = sim.count_rate.x[1:]
north_coords = sim.count_rate.y[1:]
if len(east_coords) != sim.count_rate.integrated_counts.shape:
east_coords = None
north_coords = None
logger.warning(
"PG-RAD currently does not support interpolation"
" of experimental paths for export. Only ROI_P, ROI_BR and Dist"
" will be saved."
)
result_df = DataFrame(
{
"East": east_coords,
"North": north_coords,
"ROI_P": sim.count_rate.integrated_counts,
"ROI_BR": br_array,
"Dist": sim.count_rate.distance
}
)
return result_df
def generate_csv_name(sim: SimulationOutput) -> str:
"""Generate CSV name according to Alex' specification"""
num_src = len(sim.sources)
src_ids = [str(i+1) for i in range(num_src)]
bkg_cps = round(sim.count_rate.mean_bkg_cps)
source_param_strings = [
[
str(round(s.activity))+"MBq",
str(round(s.dist_from_path))+"m",
str(round(s.position[0]))+'_'+str(round(s.position[1]))
]
for s in sim.sources
]
if num_src == 1:
src_str = "_".join(source_param_strings[0])
else:
src_str_array = array(
[list(item) for item in zip(*source_param_strings)]
)
src_str = "_".join(src_str_array.flat)
src_ids_str = "_".join(src_ids)
csv_name = f"{src_ids_str}_src_{bkg_cps}_cps_bkg_{src_str}"
return csv_name