Compare commits

...

21 Commits

Author SHA1 Message Date
7061ea8559 Merge pull request #55 from pim-n/feature-detectors-roadgen
Feature detectors roadgen
2026-03-20 10:43:46 +01:00
a1a18c6a35 update tests and plotting 2026-03-20 09:26:10 +01:00
b1d781714b update fluence and simulation to work with detectors and correct count rates 2026-03-20 09:25:09 +01:00
a133b8b1c7 update landscape to work with new detector 2026-03-20 09:23:02 +01:00
890570e148 simplify detector object. Add efficiency libraries and lookup interpolators 2026-03-20 09:21:40 +01:00
1e81570cf4 clean up imports and remove hard-coded __init__ refs 2026-03-12 14:34:40 +01:00
1dc553d2e2 update isotope lookup to tabular format. Add primary gamma energy to results plotting 2026-03-12 14:32:21 +01:00
09e74051f0 improve naming of integrated count calculation function 2026-03-11 09:51:11 +01:00
7dec54fa1c update plotter to add direction of travel arrow and detector info 2026-03-10 20:45:35 +01:00
938b3a7afc update exceptions and main.py (including test case) 2026-03-10 20:44:45 +01:00
b82196e431 Add flip direction. Change mean to Trapezoidal rule for integration along path. Scale count rate properly with acquisition time 2026-03-10 20:44:18 +01:00
b882f20358 add basic colouring 2026-03-03 21:42:19 +01:00
7e2d6076fd update docs 2026-03-03 21:04:46 +01:00
cdd6d3a8b4 improve logging. update test case for detector. 2026-03-03 21:03:26 +01:00
1c8cc41e3c Improve error handling. Add alignment feature for point sources 2026-03-03 21:01:51 +01:00
7612f74bcb rename eff to efficiency 2026-03-03 20:59:15 +01:00
b69b7455f1 fix isotope typo 2026-03-03 20:58:34 +01:00
c98000dfd8 Add detector architecture + isotropic detectors 2026-03-03 09:48:20 +01:00
41a8ca95b3 remove print statement 2026-03-03 09:32:45 +01:00
bb781ed082 let segmented road generator take specific angles and lengths for segments 2026-03-02 13:00:14 +01:00
8e429fe636 fix interpolator to properly interpolate in 2D space 2026-03-02 12:58:12 +01:00
61 changed files with 1687 additions and 623 deletions

View File

@ -1,4 +0,0 @@
---
title: pg_rad.landscape.create_landscape_from_path
---
::: pg_rad.landscape.create_landscape_from_path

View File

@ -1,4 +0,0 @@
---
title: pg_rad.landscape.Landscape
---
::: pg_rad.landscape.Landscape

View File

@ -1,5 +0,0 @@
---
title: pg_rad.objects.Object
---
::: pg_rad.objects.Object

View File

@ -1,4 +0,0 @@
---
title: pg_rad.path.Path
---
::: pg_rad.path.Path

View File

@ -1,5 +0,0 @@
---
title: pg_rad.path.path_from_RT90
---
::: pg_rad.path.path_from_RT90

View File

@ -1,4 +0,0 @@
---
title: pg_rad.path.simplify_path
---
::: pg_rad.path.simplify_path

View File

@ -1,5 +0,0 @@
---
title: pg_rad.sources.PointSource
---
::: pg_rad.sources.PointSource

218
docs/config-spec.md Normal file
View File

@ -0,0 +1,218 @@
!!! note
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.
## Required keys
### Simulation options
The first step is to name the simulation, and define the speed of the vehicle (assumed constant) and acquisition time.
#### Landscape name
The name is a string, which may include spaces, numbers and special characters.
Examples:
```yaml
name: test_landscape
```
```yaml
name: Test Landscape 1
```
#### Acquisition time
The acquisition time of the detector in seconds.
Example:
```yaml
acquisition_time: 1
```
!!! note
All units in the config file must be specified in SI units, e.g. meters and seconds, unless the key contains a unit itself (e.g. `activity_MBq` means activity in MegaBequerels).
#### Vehicle speed
The speed of the vehicle in m/s. Currently, the vehicle speed must be assumed constant. An example could be
```yaml
speed: 13.89 # this is approximately 50 km/h
```
!!! note
The text after the `#` signifies a comment. PG-RAD will ignore this, but it can be helpful for yourself to write notes.
### Path
The `path` keyword is used to create a path for the detector to travel along. There are two ways to specify a path; from experimental data or by specifying a procedural path.
#### Path - Experimental data
Currently the only supported coordinate format is the RT90 (East, North) coordinate system. If you have experimental data in CSV format with columns for these coordinates, then you can load that path into PG-RAD as follows:
```yaml
path:
file: path/to/experimental_data.csv
east_col_name: East
north_col_name: North
```
#### Path - Procedural path
Alternatively, you can let PG-RAD generate a path for you. A procedural path can be specified with at least two subkeys: `length` and `segments`.
Currently supported segments are: `straight`, `turn_left` and `turn_right`, and are provided in a list under the `segments` subkey as follows:
```yaml
path:
segments:
- straight
- turn_left
- straight
```
The length must also be specified, using the `length` subkey. `length` can be specified in two ways: a list with the same length as the `segments` list
```yaml
path:
segments:
- straight
- turn_left
- straight
length:
- 500
- 250
- 500
```
which will assign that length (meters) to each segment. Alternatively, a single number can be passed:
```yaml
path:
segments:
- straight
- turn_left
- straight
length: 1250
```
Setting the length for the total path will cause PG-RAD to *randomly assign* portions of the total length to each segment.
Finally, there is also an option to specify the turn angle in degrees:
```yaml
path:
segments:
- straight
- turn_left: 90
- straight
length: 1250
```
Like with the lengths, if a turn segment has no angle specified, a random one (within pre-defined limits) will be taken.
!!! warning
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
### 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:
```yaml
sources:
my_source: ...
```
the source name should not contain spaces or special characters other than `_` or `-`. There are three required subkeys under `sources.my_source`, which are: `activity_MBq`, `isotope` and `position`.
#### Source activity
The source activity is in MegaBequerels and must be a strictly positive number:
```yaml
sources:
my_source:
activity_MBq: 100
```
#### Source isotope
The isotope for the point source. This must be a string, following the naming convention of the symbol followed by the number of nucleons, e.g. `Cs137`:
```yaml
sources:
my_source:
activity_MBq: 100
isotope: Cs137
```
!!! info
Currently the following isotopes are supported: `Cs137`
#### Source position
There are two ways to specify the source position. Either with absolute (x,y,z) coordinates
```yaml
sources:
my_source:
activity_MBq: 100
isotope: Cs137
position: [0, 0, 0]
```
or relative to the path, using the subkeys `along_path`, `dist_from_path` and `side`
```yaml
sources:
my_source:
activity_MBq: 100
isotope: Cs137
position:
along_path: 100
dist_from_path: 50
side: left
```
Note that side is relative to the direction of travel. The path will by default start at (x,y) = (0,0) and initial heading is parallel to the x-axis.
### 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`:
```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
```
## Optional keys
The following subkeys are optional and should be put under the `options` key.
```yaml
options:
air_density_kg_per_m3: 1.243
seed: 1234
```

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,118 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "1a063d05",
"metadata": {},
"source": [
"# Pseudo-ramdom procedural roads\n",
"\n",
"Suppose one wishes to describe a road between A and B in terms of segments\n",
"\n",
"$$\n",
"\\text{straight, turn left, straight, turn right, straight}\n",
"$$\n",
"\n",
"Let's see how we can create a random road of length $L$ from a pre-determined set of prefabs.\n",
"\n",
"#### Random apportionment of total length\n",
"\n",
"Suppose we want to build a road of length $L$ out of $K$ segments. The total number of waypoints $N$ depends on the step size $\\Delta s$:\n",
"\n",
"$$\n",
"N = \\frac{L}{\\Delta s}.\n",
"$$\n",
"\n",
"Let $\\left( p_1, p_2, \\dots, p_K \\right)$ represent the proportion of $N$ that each prefab will be assigned, where $\\sum p_i = 1$. One useful distribution here is the [Dirichlet distribution](https://en.wikipedia.org/wiki/Dirichlet_distribution), which is parametrized by a vector $\\mathbf{\\alpha} = \\left(\\alpha_1, \\alpha_2, \\dots, \\alpha_K \\right)$. The special case where all $\\alpha_i$, the scalar parameter $\\alpha$ is called a *concentration parameter*. Setting the same $\\alpha$ across the entire parameter space makes the distribution symmetric, meaning no prior assumptions are made regarding the proportion of $N$ that will be assigned to each segment. $\\alpha = 1$ leads to what is known as a flat Dirichlet distribution, whereas higher values lead to more dense and evenly distributed $\\left( p_1, p_2, \\dots, p_K \\right)$. On the other hand, keeping $\\alpha \\leq 1$ gives a sparser distribution which can lead to larger variance in apportioned number of waypoints to $\\left( p_1, p_2, \\dots, p_K \\right)$.\n",
"\n",
"#### Expectation value and variance of Dirichlet distribution\n",
"\n",
"Suppose we draw our samples for proportion of length from the Dirichlet distribution\n",
"\n",
"$$\n",
"(p_1, p_2, \\ldots, p_K) \\sim \\text{Dirichlet}(\\alpha, \\alpha, \\ldots, \\alpha)\n",
"$$\n",
"\n",
"with $\\alpha _{0}=\\sum _{i=1}^{K}\\alpha _{i}$, the mean and variance are then\n",
"\n",
"$$\n",
"\\operatorname {E} [p_{i}]={\\frac {\\alpha _{i}}{\\alpha _{0}}}, \\; \\operatorname {Var} [p_{i}]={\\frac {\\alpha _{i}(\\alpha _{0}-\\alpha _{i})}{\\alpha _{0}^{2}(\\alpha _{0}+1)}}.\n",
"$$\n",
"\n",
"If $\\alpha$ is a scalar, then $\\alpha _{0}= K \\alpha$ and the above simplifies to\n",
"\n",
"$$\n",
"\\operatorname {E} [p_{i}]={\\frac {\\alpha}{K \\alpha}}={\\frac {1}{K}}, \\; \\operatorname {Var} [p_{i}]={\\frac {\\alpha(K \\alpha -\\alpha)}{(K \\alpha)^{2}(K \\alpha +1)}}.\n",
"$$\n",
"\n",
"We see that $\\operatorname {Var} [p_{i}] \\propto \\frac{1}{\\alpha}$ meaning that the variance reduces with increasing $\\alpha$. We can simply scale the proportions\n",
"\n",
"$$\n",
"(N \\cdot p_1, N \\cdot p_2, \\ldots, N \\cdot p_K)\n",
"$$\n",
"\n",
"to get the randomly assigned number of waypoints for each prefab. We now have a distribution which can give randomly assigned lengths to a given list of prefabs, with a parameter to control the degree of randomness. With a large concentration parameter $\\alpha$, the distribution of lengths will be more uniform, with each prefab getting $N \\cdot \\operatorname {E} [p_{i}]={\\frac {N}{K}}$ waypoints assigned to it. Likewise, keeping $\\alpha$ low increases variance and allows for a more random assignment of proportions of waypoints to each prefab segment.\n",
"\n",
"#### Random angles\n",
"\n",
"Suppose a turn of a pre-defined arc length $l$ made of $N/K$ waypoints. If one wants to create a random angle, one has to keep in mind that the minimum radius $R_{min}$ depends on the speed of the vehicle and the weather conditions:\n",
"\n",
"$$\n",
"R_{\\text{min,vehicle}} = \\frac{v^2}{g\\mu},\n",
"$$\n",
"\n",
"where\n",
"- $v$ is the velocity of the vehicle in $\\text{m/s}$,\n",
"- $g$ is the gravitational acceleration (about $9.8$ $\\text{m/s}^{2}$), and\n",
"- $\\mu$ is the friction coefficient (about $0.7$ for dry asphalt).\n",
"\n",
"A regular turn (not a U-turn or roundabout) should also have an lower and upper limit on the angle, say, 30 degrees to 90 degrees for a conservative estimate. In terms of radii, it becomes\n",
"\n",
"$$\n",
"R_{\\text{min}} = \\max\\left(R_{\\text{min,vehicle}}, \\frac{l}{\\pi/2}\\right)\n",
"$$\n",
"\n",
"and\n",
"\n",
"$$\n",
"R_{\\text{max}} = \\frac{l}{\\pi/6}.\n",
"$$\n",
"\n",
"We then sample\n",
"\n",
"$$\n",
"R \\sim \\text{Uniform}\\left(R_{\\text{min}}, R_{\\text{max\\_angle}}\\right)\n",
"$$\n",
"\n",
"and obtain a random radius for a turn of arc length $l$ with limits to ensure the radius is large enough given the velocity of the vehicle. Finally, the curvature profile is related to the radius by\n",
"\n",
"$$\n",
"\\kappa = \\frac{1}{R}\n",
"$$\n",
"\n",
"which means that the curvature profile of a turn is simply a vector $\\mathbf{\\kappa} = (1/R, \\dots, 1/R)$ with a length of $N/K$ waypoints."
]
}
],
"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

@ -2,29 +2,17 @@
Primary Gamma RADiation Landscapes (PG-RAD) is a Python package for research in source localization. It can simulate mobile gamma spectrometry data acquired from vehicle-borne detectors along a predefined path (e.g. a road). Primary Gamma RADiation Landscapes (PG-RAD) is a Python package for research in source localization. It can simulate mobile gamma spectrometry data acquired from vehicle-borne detectors along a predefined path (e.g. a road).
## Requirements ## About
PG-RAD requires Python `3.12`. The guides below assume a unix-like system. This software has been developed as part of dissertation work for the degree of master of Computational Science and Physics at Lund University, Sweden. The work has been done at the department of Medical Radiation Physics (MSF), Faculty of Medicine. The radiological emergency preparedness research group of MSF is assigned by the Swedish Radiation Safety Authority (SSM) to aid in preparation for effective mitigation of radiological or nuclear disasters on Swedish soil.
## Installation (CLI) ## Value proposition
<!--pipx seems like a possible option to install python package in a contained environment on unix--> PG-RAD is a toolbox that allows for simulation of detector response for a wide variety of source localization scenarios. The strength of the software lies in its simple and minimal configuration and user input, while its flexibility allows for reconstruction of specific scenarios with relative ease. PG-RAD is also general enough that novel methods such as UAV-borne detectors can be simulated and evaluated.
Lorem ipsum User input takes the form of an input file (YAML), describing the path, detector and source(s), and optional parameters. The output of the program is visualizations of the world (the path and sources), as well as the detector count rate as a function of distance travelled along the path.
## Installation (Python module) Users can provide experimental / geographical coordinates representing real roads. Alternatively, users can let PG-RAD generate a procedural road, where the user can easily control what that road should look like. The user can specify a single point source, several point sources, as well as a field of radioactive material covering a large area.
If you are interested in using PG-RAD in another Python project, create a virtual environment first:
```
python3 -m venv .venv
```
Then install PG-RAD in it:
```
source .venv/bin/activate
(.venv) pip install git+https://github.com/pim-n/pg-rad
``` ```
See how to get started with PG-RAD with your own Python code [here](pg-rad-in-python). See how to get started with PG-RAD with your own Python code [here](pg-rad-in-python).

47
docs/installation.md Normal file
View File

@ -0,0 +1,47 @@
## Requirements
PG-RAD requires Python `>=3.12.4` and `<3.13`. It has been tested on `3.12.9`. The guides below assume a unix-like system. You can check the Python version you have installed as follows:
```
python --version
```
If you don't have the right version installed there are various ways to get a compatible version, such as [pyenv](https://github.com/pyenv/pyenv?tab=readme-ov-file#installation).
## Installation (CLI)
<!--pipx seems like a possible option to install python package in a contained environment on unix-->
Lorem ipsum
## Installation (Python module)
If you are interested in using PG-RAD in another Python project, create a virtual environment first:
```
python -m venv .venv
```
Then install PG-RAD in it:
```
source .venv/bin/activate
(.venv) pip install git+https://github.com/pim-n/pg-rad
```
See how to get started with PG-RAD with your own Python code [here](pg-rad-in-python).
## For developers
```
git clone https://github.com/pim-n/pg-rad
cd pg-rad
git checkout dev
```
or
```
git@github.com:pim-n/pg-rad.git
cd pg-rad
git checkout dev
```

View File

@ -1,4 +0,0 @@
---
title: Using PG-RAD in CLI
---
Lorem ipsum.

File diff suppressed because one or more lines are too long

187
docs/quickstart.md Normal file
View File

@ -0,0 +1,187 @@
## Installation
See the [installation guide](installation.md).
## Test your installation
First, see if PG-RAD is available on your system by typing
```
pgrad --help
```
You should get output along the lines of
```
usage: pg-rad [-h] ...
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:
```
pgrad --test
```
This should produce a plot of a scenario containing a single point source and a path.
## Running PG-RAD
In order to use the CLI for your own simulations, you need to provide a *config file*. To run with your config, run
```
pgrad --config path/to/my_config.yml
```
where `path/to/my_config.yml` points to your config file.
## 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).
=== "Example 1"
The position can be defined relative to the path. `along_path` means at what distance traveled along the path the source is found. If the path is 200 meters long and `along_path` is `100` then the source is halfway along the path. `dist_from_path` is the distance in meters from the path. `side` is the side of the path the source is located. This is relative to the direction the path is traveled.
``` yaml
name: Example 1
speed: 13.89
acquisition_time: 1
path:
file: path/to/exp_coords.csv
east_col_name: East
north_col_name: North
sources:
source1:
activity_MBq: 1000
isotope: CS137
position:
along_path: 100
dist_from_path: 50
side: left
detector:
name: dummy
is_isotropic: True
```
=== "Example 2"
The position can also just be defined with (x,y,z) coordinates.
``` yaml
name: Example 2
speed: 13.89
acquisition_time: 1
path:
file: path/to/exp_coords.csv
east_col_name: East
north_col_name: North
sources:
source1:
activity_MBq: 1000
isotope: CS137
position: [104.3, 32.5, 0]
source2:
activity_MBq: 100
isotope: CS137
position: [0, 0, 0]
detector:
name: dummy
is_isotropic: True
```
=== "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).
``` yaml
name: Example 3
speed: 8.33
acquisition_time: 1
path:
length: 1000
segments:
- straight
- turn_left
- straight
alpha: 100
sources:
source1:
activity_MBq: 1000
isotope: CS137
position: [0, 0, 0]
detector:
name: dummy
is_isotropic: True
```
=== "Example 4"
This is an example of a procedural path that is partially specified. Note that turn_left now is a key for the corresponding angle of 45 degrees. The length is still divided randomly
``` yaml
name: Example 4
speed: 8.33
acquisition_time: 1
path:
length: 1000
segments:
- straight
- turn_left: 45
- straight
sources:
source1:
activity_MBq: 1000
isotope: CS137
position: [0, 0, 0]
detector:
name: dummy
is_isotropic: True
```
=== "Example 5"
This is an example of a procedural path that is fully specified. See how length is now a list matching the length of the segments.
``` yaml
name: Example 5
speed: 8.33
acquisition_time: 1
path:
length:
- 400
- 200
- 400
segments:
- straight
- turn_left: 45
- straight
sources:
source1:
activity_MBq: 1000
isotope: CS137
position: [0, 0, 0]
detector:
name: dummy
is_isotropic: True
```

View File

@ -30,6 +30,11 @@ markdown_extensions:
- pymdownx.superfences - pymdownx.superfences
- pymdownx.arithmatex: - pymdownx.arithmatex:
generic: true generic: true
- admonition
- pymdownx.details
- pymdownx.tabbed:
alternate_style: true
combine_header_slug: true
extra_javascript: extra_javascript:
- javascripts/mathjax.js - javascripts/mathjax.js
@ -47,3 +52,12 @@ plugins:
options: options:
show_source: false show_source: false
show_root_heading: false show_root_heading: false
nav:
- Home: index.md
- Installation Guide: installation.md
- Quickstart Guide: quickstart.md
- 'Tutorial: Writing a Config File': config-spec.md
- Explainers:
- explainers/planar_curve.ipynb
- explainers/prefab_roads.ipynb

View File

@ -1 +0,0 @@
__all__ = []

View File

@ -16,3 +16,10 @@ DEFAULT_MAX_TURN_ANGLE = 90.
DEFAULT_FRICTION_COEFF = 0.7 # dry asphalt DEFAULT_FRICTION_COEFF = 0.7 # dry asphalt
DEFAULT_GRAVITATIONAL_ACC = 9.81 # m/s^2 DEFAULT_GRAVITATIONAL_ACC = 9.81 # m/s^2
DEFAULT_ALPHA = 100. DEFAULT_ALPHA = 100.
# --- Detector efficiencies ---
DETECTOR_EFFICIENCIES = {
"dummy": 1.0,
"NaIR": 0.0216,
"NaIF": 0.0254
}

View File

@ -1,3 +1,4 @@
ATTENUATION_TABLE = 'attenuation_table.csv' ATTENUATION_TABLE = 'attenuation_table.csv'
ISOTOPE_TABLE = 'isotopes.csv'
TEST_EXP_DATA = 'test_path_coords.csv' TEST_EXP_DATA = 'test_path_coords.csv'
LOGGING_CONFIG = 'logging.yml' LOGGING_CONFIG = 'logging.yml'

View File

@ -3,10 +3,13 @@ disable_existing_loggers: false
formatters: formatters:
simple: simple:
format: '%(asctime)s - %(levelname)s: %(message)s' format: '%(asctime)s - %(levelname)s: %(message)s'
colored:
'()': pg_rad.logger.logger.ColorFormatter
format: '%(asctime)s - %(levelname)s: %(message)s'
handlers: handlers:
stdout: stdout:
class: logging.StreamHandler class: logging.StreamHandler
formatter: simple formatter: colored
stream: ext://sys.stdout stream: ext://sys.stdout
loggers: loggers:
root: root:

View File

@ -1 +0,0 @@
__all__ = []

View File

@ -0,0 +1,37 @@
angle,662,1173,1332
0,0.015,0.030,0.033
10,0.011,0.021,0.024
20,0.086,0.127,0.146
30,0.294,0.356,0.397
40,0.661,0.700,0.734
50,1.054,1.057,1.057
60,1.154,1.140,1.137
70,1.186,1.152,1.138
80,1.151,1.114,1.097
90,1.000,1.000,1.000
100,1.020,1.040,1.047
110,1.074,1.093,1.103
120,1.113,1.092,1.102
130,1.139,1.122,1.113
140,1.146,1.152,1.140
150,1.113,1.118,1.104
160,1.113,1.096,1.099
170,1.091,1.076,1.083
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
-140,1.144,1.112,1.123
-130,1.140,1.117,1.095
-120,1.146,1.127,1.098
-110,1.068,1.068,1.045
-100,1.013,1.025,1.016
-90,1.004,1.018,1.021
-80,1.150,1.137,1.132
-70,1.184,1.167,1.164
-60,1.158,1.140,1.138
-50,1.090,1.068,1.064
-40,0.595,0.620,0.631
-30,0.332,0.430,0.430
-20,0.055,0.081,0.096
-10,0.009,0.018,0.019
1 angle 662 1173 1332
2 0 0.015 0.030 0.033
3 10 0.011 0.021 0.024
4 20 0.086 0.127 0.146
5 30 0.294 0.356 0.397
6 40 0.661 0.700 0.734
7 50 1.054 1.057 1.057
8 60 1.154 1.140 1.137
9 70 1.186 1.152 1.138
10 80 1.151 1.114 1.097
11 90 1.000 1.000 1.000
12 100 1.020 1.040 1.047
13 110 1.074 1.093 1.103
14 120 1.113 1.092 1.102
15 130 1.139 1.122 1.113
16 140 1.146 1.152 1.140
17 150 1.113 1.118 1.104
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 -170 1.102 1.091 1.093
22 -160 1.122 1.100 1.102
23 -150 1.128 1.105 1.093
24 -140 1.144 1.112 1.123
25 -130 1.140 1.117 1.095
26 -120 1.146 1.127 1.098
27 -110 1.068 1.068 1.045
28 -100 1.013 1.025 1.016
29 -90 1.004 1.018 1.021
30 -80 1.150 1.137 1.132
31 -70 1.184 1.167 1.164
32 -60 1.158 1.140 1.138
33 -50 1.090 1.068 1.064
34 -40 0.595 0.620 0.631
35 -30 0.332 0.430 0.430
36 -20 0.055 0.081 0.096
37 -10 0.009 0.018 0.019

View File

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

View File

@ -0,0 +1,17 @@
energy_keV,field_efficiency_m2,uncertainty
59.5,0.00140,0.00005
81.0,0.00310,0.00010
122.1,0.00420,0.00013
136.5,0.00428,0.00017
160.6,0.00426,0.00030
223.2,0.00418,0.00024
276.4,0.00383,0.00012
302.9,0.00370,0.00012
356.0,0.00338,0.00010
383.8,0.00323,0.00010
511.0,0.00276,0.00008
661.7,0.00241,0.00007
834.8,0.00214,0.00007
1173.2,0.00179,0.00005
1274.5,0.00168,0.00005
1332.5,0.00166,0.00005
1 energy_keV field_efficiency_m2 uncertainty
2 59.5 0.00140 0.00005
3 81.0 0.00310 0.00010
4 122.1 0.00420 0.00013
5 136.5 0.00428 0.00017
6 160.6 0.00426 0.00030
7 223.2 0.00418 0.00024
8 276.4 0.00383 0.00012
9 302.9 0.00370 0.00012
10 356.0 0.00338 0.00010
11 383.8 0.00323 0.00010
12 511.0 0.00276 0.00008
13 661.7 0.00241 0.00007
14 834.8 0.00214 0.00007
15 1173.2 0.00179 0.00005
16 1274.5 0.00168 0.00005
17 1332.5 0.00166 0.00005

View File

@ -0,0 +1,64 @@
energy_keV,field_efficiency_m2
10,5.50129E-09
11.22018454,2.88553E-07
12.58925412,4.81878E-06
14.12537545,3.55112E-05
15.84893192,0.000146367
17.7827941,0.000397029
19.95262315,0.000803336
22.38721139,0.00131657
25.11886432,0.001862377
28.18382931,0.002373449
31.6227766,0.002811046
33.164,0.00269554
33.164,0.002698792
35.48133892,0.002509993
39.81071706,0.002801304
44.66835922,0.003015877
50.11872336,0.003227431
56.23413252,0.00341077
63.09573445,0.003562051
70.79457844,0.00368852
79.43282347,0.003788875
89,0.003867423
100,0.003925025
112.2018454,0.003967222
125.8925412,0.003991551
141.2537545,0.004000729
158.4893192,0.003993145
177.827941,0.003969163
199.5262315,0.003925289
223.8721139,0.003856247
251.1886432,0.00375596
281.8382931,0.003619634
316.227766,0.003446087
354.8133892,0.003242691
398.1071706,0.003021761
446.6835922,0.002791816
501.1872336,0.002568349
562.3413252,0.002350052
630.9573445,0.002147662
707.9457844,0.001957893
794.3282347,0.001785694
891,0.001626634
1000,0.001482571
1122.018454,0.00135047
1258.925412,0.001231358
1412.537545,0.001116695
1584.893192,0.001011833
1778.27941,0.000917017
1995.262315,0.000828435
2238.721139,0.000746854
2511.886432,0.000672573
2818.382931,0.00060493
3162.27766,0.000544458
3548.133892,0.000488446
3981.071706,0.000438438
4466.835922,0.000392416
5011.872336,0.00035092
5623.413252,0.000313959
6309.573445,0.000279409
7079.457844,0.000247794
7943.282347,0.000218768
8913,0.000190209
10000,0.000164309
1 energy_keV field_efficiency_m2
2 10 5.50129E-09
3 11.22018454 2.88553E-07
4 12.58925412 4.81878E-06
5 14.12537545 3.55112E-05
6 15.84893192 0.000146367
7 17.7827941 0.000397029
8 19.95262315 0.000803336
9 22.38721139 0.00131657
10 25.11886432 0.001862377
11 28.18382931 0.002373449
12 31.6227766 0.002811046
13 33.164 0.00269554
14 33.164 0.002698792
15 35.48133892 0.002509993
16 39.81071706 0.002801304
17 44.66835922 0.003015877
18 50.11872336 0.003227431
19 56.23413252 0.00341077
20 63.09573445 0.003562051
21 70.79457844 0.00368852
22 79.43282347 0.003788875
23 89 0.003867423
24 100 0.003925025
25 112.2018454 0.003967222
26 125.8925412 0.003991551
27 141.2537545 0.004000729
28 158.4893192 0.003993145
29 177.827941 0.003969163
30 199.5262315 0.003925289
31 223.8721139 0.003856247
32 251.1886432 0.00375596
33 281.8382931 0.003619634
34 316.227766 0.003446087
35 354.8133892 0.003242691
36 398.1071706 0.003021761
37 446.6835922 0.002791816
38 501.1872336 0.002568349
39 562.3413252 0.002350052
40 630.9573445 0.002147662
41 707.9457844 0.001957893
42 794.3282347 0.001785694
43 891 0.001626634
44 1000 0.001482571
45 1122.018454 0.00135047
46 1258.925412 0.001231358
47 1412.537545 0.001116695
48 1584.893192 0.001011833
49 1778.27941 0.000917017
50 1995.262315 0.000828435
51 2238.721139 0.000746854
52 2511.886432 0.000672573
53 2818.382931 0.00060493
54 3162.27766 0.000544458
55 3548.133892 0.000488446
56 3981.071706 0.000438438
57 4466.835922 0.000392416
58 5011.872336 0.00035092
59 5623.413252 0.000313959
60 6309.573445 0.000279409
61 7079.457844 0.000247794
62 7943.282347 0.000218768
63 8913 0.000190209
64 10000 0.000164309

View File

@ -0,0 +1,3 @@
energy_keV,field_efficiency_m2
0,1.0
10000,1.0
1 energy_keV field_efficiency_m2
2 0 1.0
3 10000 1.0

View File

@ -0,0 +1,6 @@
isotope,gamma_energy_keV,branching_ratio
Cs134,604.7,0.976
Cs134,795.9,0.855
Cs137,661.657,0.851
Co60,1173.228,1.0
Co60,1332.5,1.0
1 isotope gamma_energy_keV branching_ratio
2 Cs134 604.7 0.976
3 Cs134 795.9 0.855
4 Cs137 661.657 0.851
5 Co60 1173.228 1.0
6 Co60 1332.5 1.0

View File

@ -1,8 +0,0 @@
# do not expose internal logger when running mkinit
__ignore__ = ["logger"]
from pg_rad.dataloader import dataloader
from pg_rad.dataloader.dataloader import (load_data,)
__all__ = ['dataloader', 'load_data']

View File

View File

@ -0,0 +1,43 @@
from importlib.resources import files
from pandas import read_csv
from pg_rad.utils.interpolators import (
get_field_efficiency, get_angular_efficiency
)
class Detector:
def __init__(
self,
name: str,
type: str,
is_isotropic: bool
):
self.name = name
self.type = type
self.is_isotropic = is_isotropic
def get_efficiency(self, energy_keV, angle=None):
f_eff = get_field_efficiency(self.name, energy_keV)
if self.is_isotropic or angle is None:
return f_eff
else:
f_eff = get_field_efficiency(self.name, energy_keV)
a_eff = get_angular_efficiency(self.name, energy_keV, *angle)
return f_eff * a_eff
def load_detector(name) -> Detector:
csv = files('pg_rad.data').joinpath('detectors.csv')
data = read_csv(csv)
dets = data['name'].values
if name in dets:
row = data[data['name'] == name].iloc[0]
return Detector(row['name'], row['type'], row['is_isotropic'])
else:
raise NotImplementedError(
f"Detector with name '{name}' not in detector library. Available:"
f"{', '.join(dets)}"
)

View File

@ -1,10 +0,0 @@
# do not expose internal logger when running mkinit
__ignore__ = ["logger"]
from pg_rad.exceptions import exceptions
from pg_rad.exceptions.exceptions import (ConvergenceError, DataLoadError,
InvalidCSVError, OutOfBoundsError,)
__all__ = ['ConvergenceError', 'DataLoadError', 'InvalidCSVError',
'OutOfBoundsError', 'exceptions']

View File

@ -10,6 +10,10 @@ class InvalidCSVError(DataLoadError):
"""Raised when a file is not a valid CSV.""" """Raised when a file is not a valid CSV."""
class InvalidYAMLError(DataLoadError):
"""Raised when a file is not a valid YAML."""
class OutOfBoundsError(Exception): class OutOfBoundsError(Exception):
"""Raised when an object is attempted to be placed out of bounds.""" """Raised when an object is attempted to be placed out of bounds."""
@ -18,7 +22,11 @@ class MissingConfigKeyError(KeyError):
"""Raised when a (nested) config key is missing in the config.""" """Raised when a (nested) config key is missing in the config."""
def __init__(self, key, subkey=None): def __init__(self, key, subkey=None):
if subkey: if subkey:
self.message = f"Missing key in {key}: {', '.join(list(subkey))}" if isinstance(subkey, str):
pass
elif isinstance(subkey, set):
subkey = ', '.join(list(subkey))
self.message = f"Missing key in {key}: {subkey}"
else: else:
self.message = f"Missing key: {key}" self.message = f"Missing key: {key}"
@ -31,3 +39,7 @@ class DimensionError(ValueError):
class InvalidIsotopeError(ValueError): class InvalidIsotopeError(ValueError):
"""Raised if attempting to load an isotope that is not valid.""" """Raised if attempting to load an isotope that is not valid."""
class InvalidConfigValueError(ValueError):
"""Raised if a config key has an incorrect type or value."""

View File

View File

@ -4,21 +4,27 @@ from typing import Any, Dict, List, Union
import yaml import yaml
from pg_rad.exceptions.exceptions import MissingConfigKeyError, DimensionError from pg_rad.exceptions.exceptions import (
MissingConfigKeyError,
DimensionError,
InvalidConfigValueError,
InvalidYAMLError
)
from pg_rad.configs import defaults from pg_rad.configs import defaults
from pg_rad.isotopes.isotope import get_isotope
from .specs import ( from .specs import (
MetadataSpec, MetadataSpec,
RuntimeSpec, RuntimeSpec,
SimulationOptionsSpec, SimulationOptionsSpec,
SegmentSpec,
PathSpec, PathSpec,
ProceduralPathSpec, ProceduralPathSpec,
CSVPathSpec, CSVPathSpec,
SourceSpec, PointSourceSpec,
AbsolutePointSourceSpec, AbsolutePointSourceSpec,
RelativePointSourceSpec, RelativePointSourceSpec,
SimulationSpec, DetectorSpec,
SimulationSpec
) )
@ -32,7 +38,8 @@ class ConfigParser:
"acquisition_time", "acquisition_time",
"path", "path",
"sources", "sources",
"options", "detector",
"options"
} }
def __init__(self, config_source: str): def __init__(self, config_source: str):
@ -40,7 +47,7 @@ class ConfigParser:
def parse(self) -> SimulationSpec: def parse(self) -> SimulationSpec:
self._warn_unknown_keys( self._warn_unknown_keys(
section="global", section="root",
provided=set(self.config.keys()), provided=set(self.config.keys()),
allowed=self._ALLOWED_ROOT_KEYS, allowed=self._ALLOWED_ROOT_KEYS,
) )
@ -50,6 +57,7 @@ class ConfigParser:
options = self._parse_options() options = self._parse_options()
path = self._parse_path() path = self._parse_path()
sources = self._parse_point_sources() sources = self._parse_point_sources()
detector = self._parse_detector()
return SimulationSpec( return SimulationSpec(
metadata=metadata, metadata=metadata,
@ -57,6 +65,7 @@ class ConfigParser:
options=options, options=options,
path=path, path=path,
point_sources=sources, point_sources=sources,
detector=detector
) )
def _load_yaml(self, config_source: str) -> Dict[str, Any]: def _load_yaml(self, config_source: str) -> Dict[str, Any]:
@ -67,7 +76,7 @@ class ConfigParser:
data = yaml.safe_load(config_source) data = yaml.safe_load(config_source)
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValueError( raise InvalidYAMLError(
"Provided path or string is not a valid YAML representation." "Provided path or string is not a valid YAML representation."
) )
@ -79,13 +88,13 @@ class ConfigParser:
name=self.config["name"] name=self.config["name"]
) )
except KeyError as e: except KeyError as e:
raise MissingConfigKeyError("global", {"name"}) from e raise MissingConfigKeyError("root", {"name"}) from e
def _parse_runtime(self) -> RuntimeSpec: def _parse_runtime(self) -> RuntimeSpec:
required = {"speed", "acquisition_time"} required = {"speed", "acquisition_time"}
missing = required - self.config.keys() missing = required - self.config.keys()
if missing: if missing:
raise MissingConfigKeyError("global", missing) raise MissingConfigKeyError("root", missing)
return RuntimeSpec( return RuntimeSpec(
speed=float(self.config["speed"]), speed=float(self.config["speed"]),
@ -95,25 +104,31 @@ class ConfigParser:
def _parse_options(self) -> SimulationOptionsSpec: def _parse_options(self) -> SimulationOptionsSpec:
options = self.config.get("options", {}) options = self.config.get("options", {})
allowed = {"air_density", "seed"} allowed = {"air_density_kg_per_m3", "seed"}
self._warn_unknown_keys( self._warn_unknown_keys(
section="options", section="options",
provided=set(options.keys()), provided=set(options.keys()),
allowed=allowed, allowed=allowed,
) )
air_density = options.get("air_density", defaults.DEFAULT_AIR_DENSITY) air_density = options.get(
"air_density_kg_per_m3",
defaults.DEFAULT_AIR_DENSITY
)
seed = options.get("seed") seed = options.get("seed")
if not isinstance(air_density, float) or air_density <= 0: if not isinstance(air_density, float) or air_density <= 0:
raise ValueError( raise InvalidConfigValueError(
"options.air_density must be a positive float in kg/m^3." "options.air_density_kg_per_m3 must be a positive float "
"in kg/m^3."
) )
if ( if (
seed is not None or seed is not None or
(isinstance(seed, int) and seed <= 0) (isinstance(seed, int) and seed <= 0)
): ):
raise ValueError("Seed must be a positive integer value.") raise InvalidConfigValueError(
"Seed must be a positive integer value."
)
return SimulationOptionsSpec( return SimulationOptionsSpec(
air_density=air_density, air_density=air_density,
@ -122,14 +137,26 @@ class ConfigParser:
def _parse_path(self) -> PathSpec: def _parse_path(self) -> PathSpec:
allowed_csv = {"file", "east_col_name", "north_col_name", "z"} allowed_csv = {"file", "east_col_name", "north_col_name", "z"}
allowed_proc = {"segments", "length", "z"} allowed_proc = {"segments", "length", "z", "alpha", "direction"}
path = self.config.get("path") path = self.config.get("path")
direction = path.get("direction", 'positive')
if direction == 'positive':
opposite_direction = False
elif direction == 'negative':
opposite_direction = True
else:
raise InvalidConfigValueError(
"Direction must be positive or negative."
)
if path is None: if path is None:
raise MissingConfigKeyError("global", {"path"}) raise MissingConfigKeyError("path")
if not isinstance(path, dict): if not isinstance(path, dict):
raise ValueError("Path must be a dictionary.") raise InvalidConfigValueError("Path must be a dictionary.")
if "file" in path: if "file" in path:
self._warn_unknown_keys( self._warn_unknown_keys(
@ -143,26 +170,31 @@ class ConfigParser:
east_col_name=path["east_col_name"], east_col_name=path["east_col_name"],
north_col_name=path["north_col_name"], north_col_name=path["north_col_name"],
z=path.get("z", 0), z=path.get("z", 0),
opposite_direction=opposite_direction
) )
if "segments" in path: elif "segments" in path:
self._warn_unknown_keys( self._warn_unknown_keys(
section="path (procedural)", section="path (procedural)",
provided=set(path.keys()), provided=set(path.keys()),
allowed=allowed_proc, allowed=allowed_proc,
) )
return self._parse_procedural_path(path) return self._parse_procedural_path(path, opposite_direction)
raise ValueError("Invalid path configuration.") else:
raise InvalidConfigValueError("Invalid path configuration.")
def _parse_procedural_path( def _parse_procedural_path(
self, self,
path: Dict[str, Any] path: Dict[str, Any],
opposite_direction: bool
) -> ProceduralPathSpec: ) -> ProceduralPathSpec:
raw_segments = path.get("segments") raw_segments = path.get("segments")
if not isinstance(raw_segments, List): if not isinstance(raw_segments, List):
raise ValueError("path.segments must be a list of segments.") raise InvalidConfigValueError(
"path.segments must be a list of segments."
)
raw_length = path.get("length") raw_length = path.get("length")
@ -172,13 +204,16 @@ class ConfigParser:
if isinstance(raw_length, int | float): if isinstance(raw_length, int | float):
raw_length = [float(raw_length)] raw_length = [float(raw_length)]
segments = self._process_segment_angles(raw_segments) segments, angles = self._process_segment_angles(raw_segments)
lengths = self._process_segment_lengths(raw_length, len(segments)) lengths = self._process_segment_lengths(raw_length, len(segments))
resolved_segments = self._combine_segments_lengths(segments, lengths)
return ProceduralPathSpec( return ProceduralPathSpec(
segments=resolved_segments, segments=segments,
angles=angles,
lengths=lengths,
z=path.get("z", defaults.DEFAULT_PATH_HEIGHT), z=path.get("z", defaults.DEFAULT_PATH_HEIGHT),
alpha=path.get("alpha", defaults.DEFAULT_ALPHA),
opposite_direction=opposite_direction
) )
def _process_segment_angles( def _process_segment_angles(
@ -186,23 +221,29 @@ class ConfigParser:
raw_segments: List[Union[str, dict]] raw_segments: List[Union[str, dict]]
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
normalized = [] segments, angles = [], []
for segment in raw_segments: for segment in raw_segments:
if isinstance(segment, str): if isinstance(segment, str):
normalized.append({"type": segment, "angle": None}) segments.append(segment)
angles.append(None)
elif isinstance(segment, dict): elif isinstance(segment, dict):
if len(segment) != 1: if len(segment) != 1:
raise ValueError("Invalid segment definition.") raise InvalidConfigValueError(
"Invalid segment definition."
)
seg_type, angle = list(segment.items())[0] seg_type, angle = list(segment.items())[0]
normalized.append({"type": seg_type, "angle": angle}) segments.append(seg_type)
angles.append(angle)
else: else:
raise ValueError("Invalid segment entry format.") raise InvalidConfigValueError(
"Invalid segment entry format."
)
return normalized return segments, angles
def _process_segment_lengths( def _process_segment_lengths(
self, self,
@ -219,52 +260,44 @@ class ConfigParser:
"number of elements equal to the number of segments." "number of elements equal to the number of segments."
) )
def _combine_segments_lengths(
self,
segments: List[Dict[str, Any]],
lengths: List[float],
) -> List[SegmentSpec]:
resolved = []
for seg, length in zip(segments, lengths):
angle = seg["angle"]
if angle is not None and not self._is_turn(seg["type"]):
raise ValueError(
f"A {seg['type']} segment does not support an angle."
)
resolved.append(
SegmentSpec(
type=seg["type"],
length=length,
angle=angle,
)
)
return resolved
@staticmethod @staticmethod
def _is_turn(segment_type: str) -> bool: def _is_turn(segment_type: str) -> bool:
return segment_type in {"turn_left", "turn_right"} return segment_type in {"turn_left", "turn_right"}
def _parse_point_sources(self) -> List[SourceSpec]: def _parse_point_sources(self) -> List[PointSourceSpec]:
source_dict = self.config.get("sources", {}) source_dict = self.config.get("sources")
specs: List[SourceSpec] = [] if source_dict is None:
raise MissingConfigKeyError("sources")
if not isinstance(source_dict, dict):
raise InvalidConfigValueError(
"sources must have subkeys representing point source names."
)
specs: List[PointSourceSpec] = []
for name, params in source_dict.items(): for name, params in source_dict.items():
required = {
"activity_MBq", "isotope", "position", "gamma_energy_keV"
}
if not isinstance(params, dict):
raise InvalidConfigValueError(
f"sources.{name} is not defined correctly."
f" Must have subkeys {required}"
)
required = {"activity_MBq", "isotope", "position"}
missing = required - params.keys() missing = required - params.keys()
if missing: if missing:
raise MissingConfigKeyError(name, missing) raise MissingConfigKeyError(name, missing)
activity = params.get("activity_MBq") activity = params.get("activity_MBq")
isotope = params.get("isotope") isotope_name = params.get("isotope")
gamma_energy_keV = params.get("gamma_energy_keV")
isotope = get_isotope(isotope_name, gamma_energy_keV)
if not isinstance(activity, int | float) or activity <= 0: if not isinstance(activity, int | float) or activity <= 0:
raise ValueError( raise InvalidConfigValueError(
f"sources.{name}.activity_MBq must be positive value " f"sources.{name}.activity_MBq must be positive value "
"in MegaBequerels." "in MegaBequerels."
) )
@ -289,6 +322,16 @@ class ConfigParser:
) )
elif isinstance(position, dict): elif isinstance(position, dict):
alignment = position.get("acquisition_alignment")
if alignment not in {'best', 'worst', None}:
raise InvalidConfigValueError(
f"sources.{name}.acquisition_alignment must be "
"'best' or 'worst', with 'best' aligning source "
f"{name} in the middle of the two nearest acquisition "
"points, and 'worst' aligning exactly perpendicular "
"to the nearest acquisition point."
)
specs.append( specs.append(
RelativePointSourceSpec( RelativePointSourceSpec(
name=name, name=name,
@ -297,17 +340,25 @@ class ConfigParser:
along_path=float(position["along_path"]), along_path=float(position["along_path"]),
dist_from_path=float(position["dist_from_path"]), dist_from_path=float(position["dist_from_path"]),
side=position["side"], side=position["side"],
z=position.get("z", defaults.DEFAULT_SOURCE_HEIGHT) z=position.get("z", defaults.DEFAULT_SOURCE_HEIGHT),
alignment=alignment
) )
) )
else: else:
raise ValueError( raise InvalidConfigValueError(
f"Invalid position format for source '{name}'." f"Invalid position format for source '{name}'."
) )
return specs return specs
def _parse_detector(self) -> DetectorSpec:
det_name = self.config.get("detector")
if not det_name:
raise MissingConfigKeyError("detector")
return DetectorSpec(name=det_name)
def _warn_unknown_keys(self, section: str, provided: set, allowed: set): def _warn_unknown_keys(self, section: str, provided: set, allowed: set):
unknown = provided - allowed unknown = provided - allowed
if unknown: if unknown:

View File

@ -1,5 +1,6 @@
from abc import ABC from abc import ABC
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal
@dataclass @dataclass
@ -15,26 +16,22 @@ class RuntimeSpec:
@dataclass @dataclass
class SimulationOptionsSpec: class SimulationOptionsSpec:
air_density: float = 1.243 air_density: float
seed: int | None = None seed: int | None = None
@dataclass
class SegmentSpec:
type: str
length: float
angle: float | None
@dataclass @dataclass
class PathSpec(ABC): class PathSpec(ABC):
pass z: int | float
opposite_direction: bool
@dataclass @dataclass
class ProceduralPathSpec(PathSpec): class ProceduralPathSpec(PathSpec):
segments: list[SegmentSpec] segments: list[str]
z: int | float angles: list[float]
lengths: list[int | None]
alpha: float
@dataclass @dataclass
@ -42,29 +39,34 @@ class CSVPathSpec(PathSpec):
file: str file: str
east_col_name: str east_col_name: str
north_col_name: str north_col_name: str
z: int | float
@dataclass @dataclass
class SourceSpec(ABC): class PointSourceSpec(ABC):
activity_MBq: float activity_MBq: float
isotope: str isotope: str
name: str name: str
@dataclass @dataclass
class AbsolutePointSourceSpec(SourceSpec): class AbsolutePointSourceSpec(PointSourceSpec):
x: float x: float
y: float y: float
z: float z: float
@dataclass @dataclass
class RelativePointSourceSpec(SourceSpec): class RelativePointSourceSpec(PointSourceSpec):
along_path: float along_path: float
dist_from_path: float dist_from_path: float
side: str side: str
z: float z: float
alignment: Literal["best", "worst"] | None
@dataclass
class DetectorSpec:
name: str
@dataclass @dataclass
@ -73,4 +75,5 @@ class SimulationSpec:
runtime: RuntimeSpec runtime: RuntimeSpec
options: SimulationOptionsSpec options: SimulationOptionsSpec
path: PathSpec path: PathSpec
point_sources: list[SourceSpec] point_sources: list[PointSourceSpec]
detector: DetectorSpec

View File

@ -1,9 +0,0 @@
# do not expose internal logger when running mkinit
__ignore__ = ["logger"]
from pg_rad.isotopes import isotope
from pg_rad.isotopes.isotope import (CS137, Isotope, get_isotope,
preset_isotopes,)
__all__ = ['CS137', 'Isotope', 'get_isotope', 'isotope', 'preset_isotopes']

View File

@ -1,7 +1,10 @@
from typing import Dict, Type from importlib.resources import files
from pandas import read_csv
from pg_rad.configs.filepaths import ISOTOPE_TABLE
from pg_rad.exceptions.exceptions import InvalidIsotopeError from pg_rad.exceptions.exceptions import InvalidIsotopeError
from pg_rad.physics.attenuation import get_mass_attenuation_coeff from pg_rad.utils.interpolators import get_mass_attenuation_coeff
class Isotope: class Isotope:
@ -30,22 +33,35 @@ class Isotope:
self.mu_mass_air = get_mass_attenuation_coeff(E / 1000) self.mu_mass_air = get_mass_attenuation_coeff(E / 1000)
class CS137(Isotope): def get_isotope(isotope: str, energy_gamma_keV: float) -> Isotope:
def __init__(self): """Lazy factory function to create isotope objects."""
super().__init__( path = files('pg_rad.data').joinpath(ISOTOPE_TABLE)
name="Cs-137", df = read_csv(path)
E=661.66,
b=0.851 isotope_df = df[df['isotope'] == isotope]
if isotope_df.empty:
raise InvalidIsotopeError(f"No data available for isotope {isotope}.")
tol = 0.01 * energy_gamma_keV
closest_energy = isotope_df[
(isotope_df['gamma_energy_keV'] >= energy_gamma_keV - tol) &
(isotope_df['gamma_energy_keV'] <= energy_gamma_keV + tol)
]
if closest_energy.empty:
available_gammas = ', '.join(
str(x)+' keV' for x in isotope_df['gamma_energy_keV'].to_list()
)
raise InvalidIsotopeError(
f"No gamma of {energy_gamma_keV}±{tol} keV "
f"found for isotope {isotope}. "
f"Available gammas are: {available_gammas}"
) )
matched_row = closest_energy.iloc[0]
preset_isotopes: Dict[str, Type[Isotope]] = { return Isotope(
"CS137": CS137 name=isotope,
} E=matched_row['gamma_energy_keV'],
b=matched_row['branching_ratio']
)
def get_isotope(isotope_str: str) -> Isotope:
"""Lazy factory function to create isotope objects."""
if isotope_str not in preset_isotopes:
raise InvalidIsotopeError(f"Unknown isotope: {isotope_str}")
return preset_isotopes[isotope_str]()

View File

@ -1,5 +1,7 @@
import logging import logging
from typing import Self from typing import Literal, Self
import numpy as np
from .landscape import Landscape from .landscape import Landscape
from pg_rad.dataloader.dataloader import load_data from pg_rad.dataloader.dataloader import load_data
@ -10,9 +12,10 @@ from pg_rad.inputparser.specs import (
SimulationSpec, SimulationSpec,
CSVPathSpec, CSVPathSpec,
AbsolutePointSourceSpec, AbsolutePointSourceSpec,
RelativePointSourceSpec RelativePointSourceSpec,
DetectorSpec
) )
from pg_rad.detector.detector import load_detector
from pg_rad.path.path import Path, path_from_RT90 from pg_rad.path.path import Path, path_from_RT90
from road_gen.generators.segmented_road_generator import SegmentedRoadGenerator from road_gen.generators.segmented_road_generator import SegmentedRoadGenerator
@ -28,6 +31,7 @@ class LandscapeBuilder:
self._point_sources = [] self._point_sources = []
self._size = None self._size = None
self._air_density = 1.243 self._air_density = 1.243
self._detector = None
logger.debug(f"LandscapeBuilder initialized: {self.name}") logger.debug(f"LandscapeBuilder initialized: {self.name}")
@ -56,26 +60,31 @@ class LandscapeBuilder:
self, self,
sim_spec: SimulationSpec sim_spec: SimulationSpec
): ):
path = sim_spec.path
segments = sim_spec.path.segments segments = path.segments
types = [s.type for s in segments] lengths = path.lengths
lengths = [s.length for s in segments] angles = path.angles
angles = [s.angle for s in segments] alpha = path.alpha
z = path.z
sg = SegmentedRoadGenerator( sg = SegmentedRoadGenerator(
length=lengths,
ds=sim_spec.runtime.speed * sim_spec.runtime.acquisition_time, ds=sim_spec.runtime.speed * sim_spec.runtime.acquisition_time,
velocity=sim_spec.runtime.speed, velocity=sim_spec.runtime.speed,
seed=sim_spec.options.seed seed=sim_spec.options.seed
) )
x, y = sg.generate( x, y = sg.generate(
segments=types, segments=segments,
lengths=lengths, lengths=lengths,
angles=angles angles=angles,
alpha=alpha
) )
self._path = Path(list(zip(x, y))) self._path = Path(
list(zip(x, y)),
z=z,
opposite_direction=path.opposite_direction
)
self._fit_landscape_to_path() self._fit_landscape_to_path()
return self return self
@ -88,7 +97,8 @@ class LandscapeBuilder:
df=df, df=df,
east_col=spec.east_col_name, east_col=spec.east_col_name,
north_col=spec.north_col_name, north_col=spec.north_col_name,
z=spec.z z=spec.z,
opposite_direction=spec.opposite_direction
) )
self._fit_landscape_to_path() self._fit_landscape_to_path()
@ -114,11 +124,25 @@ class LandscapeBuilder:
pos = (s.x, s.y, s.z) pos = (s.x, s.y, s.z)
elif isinstance(s, RelativePointSourceSpec): elif isinstance(s, RelativePointSourceSpec):
path = self.get_path() path = self.get_path()
if s.alignment:
along_path = self._align_relative_source(
s.along_path,
path,
s.alignment
)
logger.info(
f"Because source {s.name} was set to align with path "
f"({s.alignment} alignment), it was moved to be at "
f"{along_path} m along the path from {s.along_path} m."
)
else:
along_path = s.along_path
pos = rel_to_abs_source_position( pos = rel_to_abs_source_position(
x_list=path.x_list, x_list=path.x_list,
y_list=path.y_list, y_list=path.y_list,
path_z=path.z, path_z=path.z,
along_path=s.along_path, along_path=along_path,
side=s.side, side=s.side,
dist_from_path=s.dist_from_path) dist_from_path=s.dist_from_path)
if any( if any(
@ -136,6 +160,10 @@ class LandscapeBuilder:
name=s.name name=s.name
)) ))
def set_detector(self, spec: DetectorSpec) -> Self:
self._detector = load_detector(spec.name)
return self
def _fit_landscape_to_path(self) -> None: def _fit_landscape_to_path(self) -> None:
"""The size of the landscape will be updated if """The size of the landscape will be updated if
1) _size is not set, or 1) _size is not set, or
@ -159,11 +187,54 @@ class LandscapeBuilder:
max_size = max(self._path.size) max_size = max(self._path.size)
self.set_landscape_size((max_size, max_size)) self.set_landscape_size((max_size, max_size))
def _align_relative_source(
self,
along_path: float,
path: "Path",
mode: Literal["best", "worst"],
) -> tuple[float, float, float]:
"""Given the arc length at which the point source is placed,
align the source relative to the waypoints of the path. Here,
'best' means the point source is moved such that it is
perpendicular to the midpoint between two acuisition points.
'worst' means the point source is moved such that it is
perpendicular to the nearest acquisition point.
The distance to the path is not affected by this algorithm.
For more details on alignment, see
Fig. 4 and page 24 in Bukartas (2021).
Args:
along_path (float): Current arc length position of the source.
path (Path): The path to align to.
mode (Literal["best", "worst"]): Alignment mode.
Returns:
along_new (float): The updated arc length position.
"""
ds = np.hypot(
path.x_list[1] - path.x_list[0],
path.y_list[1] - path.y_list[0],
)
if mode == "worst":
along_new = round(along_path / ds) * ds
elif mode == "best":
along_new = (round(along_path / ds - 0.5) + 0.5) * ds
else:
raise ValueError(f"Unknown alignment mode: {mode}")
return along_new
def build(self): def build(self):
landscape = Landscape( landscape = Landscape(
name=self.name, name=self.name,
path=self._path, path=self._path,
point_sources=self._point_sources, point_sources=self._point_sources,
detector=self._detector,
size=self._size, size=self._size,
air_density=self._air_density air_density=self._air_density
) )

View File

@ -22,7 +22,7 @@ class LandscapeDirector:
def build_test_landscape(): def build_test_landscape():
fp = files('pg_rad.data').joinpath(TEST_EXP_DATA) fp = files('pg_rad.data').joinpath(TEST_EXP_DATA)
source = PointSource( source = PointSource(
activity_MBq=100E9, activity_MBq=100E6,
isotope="CS137", isotope="CS137",
position=(0, 0, 0) position=(0, 0, 0)
) )
@ -45,5 +45,6 @@ class LandscapeDirector:
sim_spec=config, sim_spec=config,
) )
lb.set_point_sources(*config.point_sources) lb.set_point_sources(*config.point_sources)
lb.set_detector(config.detector)
landscape = lb.build() landscape = lb.build()
return landscape return landscape

View File

@ -1,6 +1,7 @@
import logging import logging
from pg_rad.path.path import Path from pg_rad.path.path import Path
from pg_rad.detector.detector import Detector
from pg_rad.objects.sources import PointSource from pg_rad.objects.sources import PointSource
@ -17,6 +18,7 @@ class Landscape:
path: Path, path: Path,
air_density: float, air_density: float,
point_sources: list[PointSource], point_sources: list[PointSource],
detector: Detector,
size: tuple[int, int, int] size: tuple[int, int, int]
): ):
"""Initialize a landscape. """Initialize a landscape.
@ -26,6 +28,7 @@ class Landscape:
path (Path): The path of the detector. path (Path): The path of the detector.
air_density (float): Air density in kg/m^3. air_density (float): Air density in kg/m^3.
point_sources (list[PointSource]): List of point sources. point_sources (list[PointSource]): List of point sources.
detector (Detector): The detector object.
size (tuple[int, int, int]): Size of the world. size (tuple[int, int, int]): Size of the world.
""" """
@ -34,5 +37,6 @@ class Landscape:
self.point_sources = point_sources self.point_sources = point_sources
self.size = size self.size = size
self.air_density = air_density self.air_density = air_density
self.detector = detector
logger.debug(f"Landscape created: {self.name}") logger.debug(f"Landscape created: {self.name}")

View File

@ -1,5 +0,0 @@
from pg_rad.logger import logger
from pg_rad.logger.logger import (setup_logger,)
__all__ = ['logger', 'setup_logger']

View File

@ -20,3 +20,21 @@ def setup_logger(log_level: str = "WARNING"):
config["loggers"]["root"]["level"] = log_level config["loggers"]["root"]["level"] = log_level
logging.config.dictConfig(config) logging.config.dictConfig(config)
class ColorFormatter(logging.Formatter):
# ANSI escape codes
COLORS = {
logging.DEBUG: "\033[36m", # Cyan
logging.INFO: "\033[32m", # Green
logging.WARNING: "\033[33m", # Yellow
logging.ERROR: "\033[31m", # Red
logging.CRITICAL: "\033[41m", # Red background
}
RESET = "\033[0m"
def format(self, record):
color = self.COLORS.get(record.levelno, self.RESET)
record.levelname = f"{color}{record.levelname}{self.RESET}"
record.msg = f"{record.msg}"
return super().format(record)

View File

@ -3,13 +3,14 @@ import logging
import sys import sys
from pandas.errors import ParserError from pandas.errors import ParserError
from yaml import YAMLError
from pg_rad.exceptions.exceptions import ( from pg_rad.exceptions.exceptions import (
MissingConfigKeyError, MissingConfigKeyError,
OutOfBoundsError, OutOfBoundsError,
DimensionError, DimensionError,
InvalidIsotopeError InvalidConfigValueError,
InvalidIsotopeError,
InvalidYAMLError
) )
from pg_rad.logger.logger import setup_logger from pg_rad.logger.logger import setup_logger
from pg_rad.inputparser.parser import ConfigParser from pg_rad.inputparser.parser import ConfigParser
@ -54,24 +55,30 @@ def main():
acquisition_time: 1 acquisition_time: 1
path: path:
length: 1000 length:
- 500
- 500
segments: segments:
- straight - straight
- turn_left: 45
direction: negative
sources: sources:
test_source: test_source:
activity_MBq: 1000 activity_MBq: 100
position: [500, 100, 0] position: [250, 100, 0]
isotope: CS137 isotope: Cs137
gamma_energy_keV: 661
detector: LU_NaI_3inch
""" """
cp = ConfigParser(test_yaml).parse() cp = ConfigParser(test_yaml).parse()
landscape = LandscapeDirector.build_from_config(cp) landscape = LandscapeDirector.build_from_config(cp)
output = SimulationEngine( output = SimulationEngine(
landscape=landscape, landscape=landscape,
runtime_spec=cp.runtime, runtime_spec=cp.runtime,
sim_spec=cp.options sim_spec=cp.options,
).simulate() ).simulate()
plotter = ResultPlotter(landscape, output) plotter = ResultPlotter(landscape, output)
@ -81,7 +88,6 @@ def main():
try: try:
cp = ConfigParser(args.config).parse() cp = ConfigParser(args.config).parse()
landscape = LandscapeDirector.build_from_config(cp) landscape = LandscapeDirector.build_from_config(cp)
output = SimulationEngine( output = SimulationEngine(
landscape=landscape, landscape=landscape,
runtime_spec=cp.runtime, runtime_spec=cp.runtime,
@ -92,20 +98,20 @@ def main():
plotter.plot() plotter.plot()
except ( except (
MissingConfigKeyError, MissingConfigKeyError,
KeyError, KeyError
YAMLError, ) as e:
): logger.critical(e)
logger.critical( logger.critical(
"The provided config file is invalid. " "The config file is missing required keys or may be an "
"Check the log above. You can consult the documentation for " "invalid YAML file. Check the log above. Consult the "
"an explanation of how to define a config file." "documentation for examples of how to write a config file."
) )
sys.exit(1) sys.exit(1)
except ( except (
OutOfBoundsError, OutOfBoundsError,
DimensionError, DimensionError,
InvalidIsotopeError, InvalidIsotopeError,
ValueError InvalidConfigValueError
) as e: ) as e:
logger.critical(e) logger.critical(e)
logger.critical( logger.critical(
@ -116,8 +122,10 @@ def main():
except ( except (
FileNotFoundError, FileNotFoundError,
ParserError ParserError,
): InvalidYAMLError
) as e:
logger.critical(e)
sys.exit(1) sys.exit(1)

View File

@ -1,11 +0,0 @@
# do not expose internal logger when running mkinit
__ignore__ = ["logger"]
from pg_rad.objects import objects
from pg_rad.objects import sources
from pg_rad.objects.objects import (BaseObject,)
from pg_rad.objects.sources import (PointSource,)
__all__ = ['BaseObject', 'PointSource', 'objects',
'sources']

View File

@ -1,7 +1,7 @@
import logging import logging
from .objects import BaseObject from .objects import BaseObject
from pg_rad.isotopes.isotope import Isotope, get_isotope from pg_rad.isotopes.isotope import Isotope
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -12,7 +12,7 @@ class PointSource(BaseObject):
def __init__( def __init__(
self, self,
activity_MBq: int, activity_MBq: int,
isotope: str, isotope: Isotope,
position: tuple[float, float, float] = (0, 0, 0), position: tuple[float, float, float] = (0, 0, 0),
name: str | None = None, name: str | None = None,
color: str = 'red' color: str = 'red'
@ -40,7 +40,7 @@ class PointSource(BaseObject):
super().__init__(position, name, color) super().__init__(position, name, color)
self.activity = activity_MBq self.activity = activity_MBq
self.isotope: Isotope = get_isotope(isotope) self.isotope = isotope
logger.debug(f"Source created: {self.name}") logger.debug(f"Source created: {self.name}")

View File

@ -1,8 +0,0 @@
# do not expose internal logger when running mkinit
__ignore__ = ["logger"]
from pg_rad.path import path
from pg_rad.path.path import (Path, PathSegment, path_from_RT90,)
__all__ = ['Path', 'PathSegment', 'path', 'path_from_RT90']

View File

@ -42,6 +42,7 @@ class Path:
def __init__( def __init__(
self, self,
coord_list: Sequence[tuple[float, float]], coord_list: Sequence[tuple[float, float]],
opposite_direction: bool,
z: float = 0., z: float = 0.,
z_box: float = 50. z_box: float = 50.
): ):
@ -74,6 +75,8 @@ class Path:
] ]
self.z = z self.z = z
self.opposite_direction = opposite_direction
self.size = ( self.size = (
np.ceil(max(self.x_list)), np.ceil(max(self.x_list)),
np.ceil(max(self.y_list)), np.ceil(max(self.y_list)),

View File

@ -1,12 +0,0 @@
# do not expose internal logger when running mkinit
__ignore__ = ["logger"]
from pg_rad.physics import attenuation
from pg_rad.physics import fluence
from pg_rad.physics.attenuation import (get_mass_attenuation_coeff,)
from pg_rad.physics.fluence import (calculate_fluence_along_path,
calculate_fluence_at, phi,)
__all__ = ['attenuation', 'calculate_fluence_along_path',
'calculate_fluence_at', 'fluence', 'get_mass_attenuation_coeff',
'phi']

View File

@ -1,17 +0,0 @@
from importlib.resources import files
from pandas import read_csv
from scipy.interpolate import interp1d
from pg_rad.configs.filepaths import ATTENUATION_TABLE
def get_mass_attenuation_coeff(
*args
) -> float:
csv = files('pg_rad.data').joinpath(ATTENUATION_TABLE)
data = read_csv(csv)
x = data["energy_mev"].to_numpy()
y = data["mu"].to_numpy()
f = interp1d(x, y)
return f(*args)

View File

@ -4,6 +4,7 @@ import numpy as np
if TYPE_CHECKING: if TYPE_CHECKING:
from pg_rad.landscape.landscape import Landscape from pg_rad.landscape.landscape import Landscape
from pg_rad.detector.detector import Detector
def phi( def phi(
@ -12,6 +13,7 @@ def phi(
branching_ratio: float, branching_ratio: float,
mu_mass_air: float, mu_mass_air: float,
air_density: float, air_density: float,
eff: float
) -> float: ) -> float:
"""Compute the contribution of a single point source to the """Compute the contribution of a single point source to the
primary photon fluence rate phi at position (x,y,z). primary photon fluence rate phi at position (x,y,z).
@ -29,42 +31,70 @@ def phi(
""" """
# Linear photon attenuation coefficient in m^-1. # Linear photon attenuation coefficient in m^-1.
mu_mass_air *= 0.1 mu_air = 0.1 * mu_mass_air * air_density
mu_air = mu_mass_air * air_density
phi_r = ( phi_r = (
activity activity
* eff
* branching_ratio * branching_ratio
* np.exp(-mu_air * r) * np.exp(-mu_air * r)
/ (4 * np.pi * r**2) ) / (4 * np.pi * r**2)
)
return phi_r return phi_r
def calculate_fluence_at(landscape: "Landscape", pos: np.ndarray, scaling=1E6): def calculate_count_rate_per_second(
"""Compute fluence at an arbitrary position in the landscape. landscape: "Landscape",
pos: np.ndarray,
detector: "Detector",
scaling=1E6
):
"""Compute count rate in s^-1 m^-2 at a position in the landscape.
Args: Args:
landscape (Landscape): The landscape to compute. landscape (Landscape): The landscape to compute.
pos (np.ndarray): (N, 3) array of positions. pos (np.ndarray): (N, 3) array of positions.
detector (Detector):
Detector object, needed to compute correct efficiency.
Returns: Returns:
total_phi (np.ndarray): (N,) array of fluences. total_phi (np.ndarray): (N,) array of count rates per second.
""" """
pos = np.atleast_2d(pos) pos = np.atleast_2d(pos)
total_phi = np.zeros(pos.shape[0]) total_phi = np.zeros(pos.shape[0])
for source in landscape.point_sources: for source in landscape.point_sources:
r = np.linalg.norm(pos - np.array(source.pos), axis=1) # See Bukartas (2021) page 25 for incidence angle math
source_to_detector = pos - np.array(source.pos)
r = np.linalg.norm(source_to_detector, axis=1)
r = np.maximum(r, 1E-3) # enforce minimum distance of 1cm r = np.maximum(r, 1E-3) # enforce minimum distance of 1cm
if not detector.is_isotropic:
v = np.zeros_like(pos)
v[1:] = pos[1:] - pos[:-1]
v[0] = v[1] # handle first point
vx, vy = v[:, 0], v[:, 1]
r_vec = pos - np.array(source.pos)
rx, ry = r_vec[:, 0], r_vec[:, 1]
theta = np.arctan2(vy, vx) - np.arctan2(ry, rx)
# normalise to [-pi, pi] and convert to degrees
theta = (theta + np.pi) % (2 * np.pi) - np.pi
theta_deg = np.degrees(theta)
eff = detector.get_efficiency(source.isotope.E, theta_deg)
else:
eff = detector.get_efficiency(source.isotope.E)
phi_source = phi( phi_source = phi(
r=r, r=r,
activity=source.activity * scaling, activity=source.activity * scaling,
branching_ratio=source.isotope.b, branching_ratio=source.isotope.b,
mu_mass_air=source.isotope.mu_mass_air, mu_mass_air=source.isotope.mu_mass_air,
air_density=landscape.air_density air_density=landscape.air_density,
eff=eff
) )
total_phi += phi_source total_phi += phi_source
@ -72,28 +102,56 @@ def calculate_fluence_at(landscape: "Landscape", pos: np.ndarray, scaling=1E6):
return total_phi return total_phi
def calculate_fluence_along_path( def calculate_counts_along_path(
landscape: "Landscape", landscape: "Landscape",
points_per_segment: int = 10 detector: "Detector",
velocity: float,
points_per_segment: int = 10,
) -> Tuple[np.ndarray, np.ndarray]: ) -> Tuple[np.ndarray, np.ndarray]:
"""Compute the counts recorded in each acquisition period in the landscape.
Args:
landscape (Landscape): _description_
detector (Detector): _description_
points_per_segment (int, optional): _description_. Defaults to 100.
Returns:
Tuple[np.ndarray, np.ndarray]: Array of acquisition points and
integrated count rates.
"""
path = landscape.path path = landscape.path
num_points = len(path.x_list)
num_segments = len(path.segments) num_segments = len(path.segments)
xnew = np.linspace( segment_lengths = np.array([seg.length for seg in path.segments])
path.x_list[0], original_distances = np.zeros(num_points)
path.x_list[-1], original_distances[1:] = np.cumsum(segment_lengths)
num=num_segments*points_per_segment)
ynew = np.interp(xnew, path.x_list, path.y_list) # arc lengths at which to evaluate the path
total_subpoints = num_segments * points_per_segment
s = np.linspace(0, original_distances[-1], total_subpoints)
z = np.full(xnew.shape, path.z) # Interpolate x and y as functions of arc length
xnew = np.interp(s, original_distances, path.x_list)
ynew = np.interp(s, original_distances, path.y_list)
z = np.full_like(xnew, path.z)
full_positions = np.c_[xnew, ynew, z] full_positions = np.c_[xnew, ynew, z]
phi_result = calculate_fluence_at(landscape, full_positions)
dist_travelled = np.linspace( if path.opposite_direction:
full_positions[0, 0], full_positions = np.flip(full_positions, axis=0)
path.length,
len(phi_result) # [counts/s]
cps = calculate_count_rate_per_second(
landscape, full_positions, detector
) )
return dist_travelled, phi_result # reshape so each segment is on a row
cps_per_seg = cps.reshape(num_segments, points_per_segment)
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
return original_distances, s, cps, int_counts_result

View File

@ -1,7 +0,0 @@
# do not expose internal logger when running mkinit
__ignore__ = ["logger"]
from pg_rad.plotting import landscape_plotter
from pg_rad.plotting.landscape_plotter import (LandscapeSlicePlotter,)
__all__ = ['LandscapeSlicePlotter', 'landscape_plotter']

View File

@ -69,7 +69,7 @@ class LandscapeSlicePlotter:
ax.set_ylabel("Y [m]") ax.set_ylabel("Y [m]")
ax.set_title(f"Landscape (top-down, z = {self.z})") ax.set_title(f"Landscape (top-down, z = {self.z})")
def _draw_path(self, ax, landscape): def _draw_path(self, ax, landscape: Landscape):
if landscape.path.z <= self.z: if landscape.path.z <= self.z:
ax.plot( ax.plot(
landscape.path.x_list, landscape.path.x_list,
@ -79,6 +79,9 @@ class LandscapeSlicePlotter:
markersize=3, markersize=3,
linewidth=1 linewidth=1
) )
if len(landscape.path.x_list) >= 2:
ax = self._draw_path_direction_arrow(ax, landscape.path)
else: else:
logger.warning( logger.warning(
"Path is above the slice height z." "Path is above the slice height z."
@ -113,3 +116,35 @@ class LandscapeSlicePlotter:
f"Source {s.name} is above slice height z." f"Source {s.name} is above slice height z."
"It will not show on the plot." "It will not show on the plot."
) )
def _draw_path_direction_arrow(self, ax, path) -> Axes:
inset_ax = ax.inset_axes([0.8, 0.1, 0.15, 0.15])
x_start, y_start = path.x_list[0], path.y_list[0]
x_end, y_end = path.x_list[1], path.y_list[1]
dx = x_end - x_start
dy = y_end - y_start
if path.opposite_direction:
dx = -dx
dy = -dy
length = 10
dx_norm = dx / (dx**2 + dy**2)**0.5 * length
dy_norm = dy / (dx**2 + dy**2)**0.5 * length
inset_ax.arrow(
0, 0,
dx_norm, dy_norm,
head_width=5, head_length=5,
fc='red', ec='red',
zorder=4, linewidth=1
)
inset_ax.set_xlim(-2*length, 2*length)
inset_ax.set_ylim(-2*length, 2*length)
inset_ax.set_title("Direction", fontsize=8)
inset_ax.set_xticks([])
inset_ax.set_yticks([])
return ax

View File

@ -1,3 +1,7 @@
from importlib.resources import files
import numpy as np
import pandas as pd
from matplotlib import pyplot as plt from matplotlib import pyplot as plt
from matplotlib.gridspec import GridSpec from matplotlib.gridspec import GridSpec
@ -13,43 +17,79 @@ class ResultPlotter:
self.source_res = output.sources self.source_res = output.sources
def plot(self, landscape_z: float = 0): def plot(self, landscape_z: float = 0):
fig = plt.figure(figsize=(12, 10), constrained_layout=True) self._plot_main(landscape_z)
fig.suptitle(self.landscape.name) self._plot_detector()
gs = GridSpec( self._plot_metadata()
3,
2,
width_ratios=[0.5, 0.5],
height_ratios=[0.7, 0.15, 0.15],
hspace=0.2)
ax1 = fig.add_subplot(gs[0, 0])
self._draw_count_rate(ax1)
ax2 = fig.add_subplot(gs[0, 1])
self._plot_landscape(ax2, landscape_z)
ax3 = fig.add_subplot(gs[1, :])
self._draw_table(ax3)
ax4 = fig.add_subplot(gs[2, :])
self._draw_source_table(ax4)
plt.tight_layout()
plt.show() plt.show()
def _plot_main(self, landscape_z):
fig = plt.figure(figsize=(12, 8))
fig.suptitle(self.landscape.name)
fig.canvas.manager.set_window_title("Main results")
gs = GridSpec(
2, 2, figure=fig,
height_ratios=[1, 2], width_ratios=[1, 1],
hspace=0.3, wspace=0.3)
ax_cps = fig.add_subplot(gs[0, 0])
self._draw_cps(ax_cps)
ax_counts = fig.add_subplot(gs[0, 1])
self._draw_count_rate(ax_counts)
ax_landscape = fig.add_subplot(gs[1, :])
self._plot_landscape(ax_landscape, landscape_z)
def _plot_detector(self):
det = self.landscape.detector
fig = plt.figure(figsize=(10, 4))
fig.canvas.manager.set_window_title("Detector")
gs = GridSpec(1, 2, figure=fig, width_ratios=[0.5, 0.5])
ax_table = fig.add_subplot(gs[0, 0])
self._draw_detector_table(ax_table)
if not det.is_isotropic:
ax_polar = fig.add_subplot(gs[0, 1], projection='polar')
energies = [
source.primary_gamma for source in self.source_res
]
self._draw_angular_efficiency_polar(ax_polar, det, energies[0])
def _plot_metadata(self):
fig, axs = plt.subplots(2, 1, figsize=(10, 6))
fig.canvas.manager.set_window_title("Simulation Metadata")
self._draw_table(axs[0])
self._draw_source_table(axs[1])
def _plot_landscape(self, ax, z): def _plot_landscape(self, ax, z):
lp = LandscapeSlicePlotter() lp = LandscapeSlicePlotter()
ax = lp.plot(landscape=self.landscape, z=z, ax=ax, show=False) ax = lp.plot(landscape=self.landscape, z=z, ax=ax, show=False)
return ax return ax
def _draw_count_rate(self, ax): def _draw_cps(self, ax):
x = self.count_rate_res.arc_length x = self.count_rate_res.sub_points
y = self.count_rate_res.count_rate y = self.count_rate_res.cps
ax.plot(x, y, label='Count rate', color='r') ax.plot(x, y, color='b')
ax.set_title('Count rate') ax.set_title('Counts per second (CPS)')
ax.set_xlabel('Arc length s [m]') ax.set_xlabel('Arc length s [m]')
ax.set_ylabel('Counts') ax.set_ylabel('CPS [s$^{-1}$]')
ax.legend()
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)
ax.scatter(x, y, color='r', marker='x')
ax.set_title('Integrated counts')
ax.set_xlabel('Arc length s [m]')
ax.set_ylabel('N')
def _draw_table(self, ax): def _draw_table(self, ax):
ax.set_axis_off() ax.set_axis_off()
@ -57,7 +97,42 @@ class ResultPlotter:
cols = ('Parameter', 'Value') cols = ('Parameter', 'Value')
data = [ data = [
["Air density (kg/m^3)", round(self.landscape.air_density, 3)], ["Air density (kg/m^3)", round(self.landscape.air_density, 3)],
["Total path length (m)", round(self.landscape.path.length, 3)] ["Total path length (m)", round(self.landscape.path.length, 3)],
["Readout points", len(self.count_rate_res.integrated_counts)],
]
ax.table(
cellText=data,
colLabels=cols,
loc='center'
)
return ax
def _draw_detector_table(self, ax):
det = self.landscape.detector
if det.is_isotropic:
det_type = "Isotropic"
else:
det_type = "Angular"
source_energies = [
source.primary_gamma for source in self.source_res
]
# 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"
for key, value in effs.items()
)
ax.set_axis_off()
ax.set_title('Detector')
cols = ('Parameter', 'Value')
data = [
["Detector", f"{det.name} ({det_type})"],
["Field efficiency", formatted_effs],
] ]
ax.table( ax.table(
@ -84,7 +159,7 @@ class ResultPlotter:
data = [ data = [
[ [
s.name, s.name,
s.isotope, s.isotope+f" ({s.primary_gamma} keV)",
s.activity, s.activity,
"("+", ".join(f"{val:.2f}" for val in s.position)+")", "("+", ".join(f"{val:.2f}" for val in s.position)+")",
round(s.dist_from_path, 2) round(s.dist_from_path, 2)
@ -98,3 +173,34 @@ class ResultPlotter:
) )
return ax return ax
def _draw_angular_efficiency_polar(self, ax, detector, energy_keV):
# find the energies available for this detector.
csv = files('pg_rad.data.angular_efficiencies').joinpath(
detector.name + '.csv'
)
data = pd.read_csv(csv)
energy_cols = [col for col in data.columns if col != "angle"]
energies = np.array([float(col) for col in energy_cols])
# take energy column that is within 1% tolerance of energy_keV
rel_diff = np.abs(energies - energy_keV) / energies
match_idx = np.where(rel_diff <= 0.01)[0]
best_idx = match_idx[np.argmin(rel_diff[match_idx])]
col = energy_cols[best_idx]
theta_deg = data["angle"].to_numpy()
eff = data[col].to_numpy()
idx = np.argsort(theta_deg)
theta_deg = theta_deg[idx]
eff = eff[idx]
theta_deg = np.append(theta_deg, theta_deg[0])
eff = np.append(eff, eff[0])
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

@ -6,7 +6,8 @@ from pg_rad.simulator.outputs import (
SimulationOutput, SimulationOutput,
SourceOutput SourceOutput
) )
from pg_rad.physics.fluence import calculate_fluence_along_path
from pg_rad.physics.fluence import calculate_counts_along_path
from pg_rad.utils.projection import minimal_distance_to_path from pg_rad.utils.projection import minimal_distance_to_path
from pg_rad.inputparser.specs import RuntimeSpec, SimulationOptionsSpec from pg_rad.inputparser.specs import RuntimeSpec, SimulationOptionsSpec
@ -16,11 +17,12 @@ class SimulationEngine:
def __init__( def __init__(
self, self,
landscape: Landscape, landscape: Landscape,
runtime_spec=RuntimeSpec, runtime_spec: RuntimeSpec,
sim_spec=SimulationOptionsSpec sim_spec: SimulationOptionsSpec,
): ):
self.landscape = landscape self.landscape = landscape
self.detector = self.landscape.detector
self.runtime_spec = runtime_spec self.runtime_spec = runtime_spec
self.sim_spec = sim_spec self.sim_spec = sim_spec
@ -37,8 +39,13 @@ class SimulationEngine:
) )
def _calculate_count_rate_along_path(self) -> CountRateOutput: def _calculate_count_rate_along_path(self) -> CountRateOutput:
arc_length, phi = calculate_fluence_along_path(self.landscape) acq_points, sub_points, cps, int_counts = calculate_counts_along_path(
return CountRateOutput(arc_length, phi) self.landscape,
self.detector,
velocity=self.runtime_spec.speed
)
return CountRateOutput(acq_points, sub_points, cps, int_counts)
def _calculate_point_source_distance_to_path(self) -> List[SourceOutput]: def _calculate_point_source_distance_to_path(self) -> List[SourceOutput]:
@ -55,6 +62,7 @@ class SimulationEngine:
SourceOutput( SourceOutput(
s.name, s.name,
s.isotope.name, s.isotope.name,
s.isotope.E,
s.activity, s.activity,
s.pos, s.pos,
dist_to_path) dist_to_path)

View File

@ -5,14 +5,17 @@ from dataclasses import dataclass
@dataclass @dataclass
class CountRateOutput: class CountRateOutput:
arc_length: List[float] acquisition_points: List[float]
count_rate: List[float] sub_points: List[float]
cps: List[float]
integrated_counts: List[float]
@dataclass @dataclass
class SourceOutput: class SourceOutput:
name: str name: str
isotope: str isotope: str
primary_gamma: float
activity: float activity: float
position: Tuple[float, float, float] position: Tuple[float, float, float]
dist_from_path: float dist_from_path: float

View File

@ -0,0 +1,56 @@
from importlib.resources import files
import numpy as np
from pandas import read_csv
from scipy.interpolate import interp1d, CubicSpline
from pg_rad.configs.filepaths import ATTENUATION_TABLE
def get_mass_attenuation_coeff(*args) -> float:
csv = files('pg_rad.data').joinpath(ATTENUATION_TABLE)
data = read_csv(csv)
x = data["energy_mev"].to_numpy()
y = data["mu"].to_numpy()
f = interp1d(x, y)
return f(*args)
def get_field_efficiency(name: str, energy_keV: float) -> float:
csv = files('pg_rad.data.field_efficiencies').joinpath(name+'.csv')
data = read_csv(csv)
data = data.groupby("energy_keV", as_index=False).mean()
x = data["energy_keV"].to_numpy()
y = data["field_efficiency_m2"].to_numpy()
f = CubicSpline(x, y)
return f(energy_keV)
def get_angular_efficiency(name: str, energy_keV: float, *angle: float):
csv = files('pg_rad.data.angular_efficiencies').joinpath(name+'.csv')
data = read_csv(csv)
# check all energies at which angular eff. is available for this detector.
# this is done within 1% tolerance
energy_cols = [col for col in data.columns if col != "angle"]
energies = np.array([float(col) for col in energy_cols])
rel_diff = np.abs(energies - energy_keV) / energies
match_idx = np.where(rel_diff <= 0.01)[0]
if len(match_idx) == 0:
raise NotImplementedError(
f"No angular efficiency defined for {energy_keV} keV "
f"in detector '{name}'. Available: {energies}"
)
best_idx = match_idx[np.argmin(rel_diff[match_idx])]
selected_energy_col = energy_cols[best_idx]
x = data["angle"].to_numpy()
y = data[selected_energy_col].to_numpy()
idx = np.argsort(x)
x = x[idx]
y = y[idx]
f = interp1d(x, y)
return f(angle)

View File

@ -7,7 +7,6 @@ class BaseRoadGenerator:
"""A base generator object for generating a road of a specified length.""" """A base generator object for generating a road of a specified length."""
def __init__( def __init__(
self, self,
length: int | float,
ds: int | float, ds: int | float,
velocity: int | float, velocity: int | float,
mu: float = 0.7, mu: float = 0.7,
@ -17,7 +16,6 @@ class BaseRoadGenerator:
"""Initialize a BaseGenerator with a given or random seed. """Initialize a BaseGenerator with a given or random seed.
Args: Args:
length (int | float): The total length of the road in meters.
ds (int | float): The step size in meters. ds (int | float): The step size in meters.
velocity (int | float): Velocity in meters per second. velocity (int | float): Velocity in meters per second.
mu (float): Coefficient of friction. Defaults to 0.7 (dry asphalt). mu (float): Coefficient of friction. Defaults to 0.7 (dry asphalt).
@ -31,9 +29,6 @@ class BaseRoadGenerator:
if not isinstance(seed, int): if not isinstance(seed, int):
raise TypeError("seed must be an integer or None.") raise TypeError("seed must be an integer or None.")
if not isinstance(length, int | float):
raise TypeError("Length must be an integer or float in meters.")
if not isinstance(ds, int | float): if not isinstance(ds, int | float):
raise TypeError("Step size must be integer or float in meters.") raise TypeError("Step size must be integer or float in meters.")
@ -42,7 +37,6 @@ class BaseRoadGenerator:
"Velocity must be integer or float in meters per second." "Velocity must be integer or float in meters per second."
) )
self.length = length
self.ds = ds self.ds = ds
self.velocity = velocity self.velocity = velocity

View File

@ -16,7 +16,6 @@ logger = logging.getLogger(__name__)
class SegmentedRoadGenerator(BaseRoadGenerator): class SegmentedRoadGenerator(BaseRoadGenerator):
def __init__( def __init__(
self, self,
length: int | float | list[int | float],
ds: int | float, ds: int | float,
velocity: int | float, velocity: int | float,
mu: float = 0.7, mu: float = 0.7,
@ -26,7 +25,6 @@ class SegmentedRoadGenerator(BaseRoadGenerator):
"""Initialize a SegmentedRoadGenerator with given or random seed. """Initialize a SegmentedRoadGenerator with given or random seed.
Args: Args:
length (int | float): The total length of the road in meters.
ds (int | float): The step size in meters. ds (int | float): The step size in meters.
velocity (int | float): Velocity in meters per second. velocity (int | float): Velocity in meters per second.
mu (float): Coefficient of friction. Defaults to 0.7 (dry asphalt). mu (float): Coefficient of friction. Defaults to 0.7 (dry asphalt).
@ -34,18 +32,13 @@ class SegmentedRoadGenerator(BaseRoadGenerator):
seed (int | None, optional): Set a seed for the generator. seed (int | None, optional): Set a seed for the generator.
Defaults to random seed. Defaults to random seed.
""" """
super().__init__(ds, velocity, mu, g, seed)
if isinstance(length, list):
length = sum(
[seg_len for seg_len in length if seg_len is not None]
)
super().__init__(length, ds, velocity, mu, g, seed)
def generate( def generate(
self, self,
segments: list[str], segments: list[str],
lengths: list[int | float] | None = None, lengths: list[int | float],
angles: list[int | float] | None = None, angles: list[float | None],
alpha: float = defaults.DEFAULT_ALPHA, alpha: float = defaults.DEFAULT_ALPHA,
min_turn_angle: float = defaults.DEFAULT_MIN_TURN_ANGLE, min_turn_angle: float = defaults.DEFAULT_MIN_TURN_ANGLE,
max_turn_angle: float = defaults.DEFAULT_MAX_TURN_ANGLE max_turn_angle: float = defaults.DEFAULT_MAX_TURN_ANGLE
@ -54,6 +47,8 @@ class SegmentedRoadGenerator(BaseRoadGenerator):
Args: Args:
segments (list[str]): List of segments. segments (list[str]): List of segments.
lengths (list[int | float]): List of segment lengths.
angles (list[float | None]): List of angles.
alpha (float, optional): Dirichlet concentration parameter. alpha (float, optional): Dirichlet concentration parameter.
A higher value leads to more uniform apportionment of the A higher value leads to more uniform apportionment of the
length amongst the segments, while a lower value allows more length amongst the segments, while a lower value allows more
@ -88,26 +83,29 @@ class SegmentedRoadGenerator(BaseRoadGenerator):
self.segments = segments self.segments = segments
self.alpha = alpha self.alpha = alpha
num_points = np.ceil(self.length / self.ds).astype(int)
total_length = sum(lengths)
num_points = np.ceil(total_length / self.ds).astype(int)
# divide num_points into len(segments) randomly sized parts. # divide num_points into len(segments) randomly sized parts.
if isinstance(self.length, list): if len(lengths) == len(segments):
parts = self.length parts = np.array([seg_len / total_length for seg_len in lengths])
else: else:
parts = self._rng.dirichlet( parts = self._rng.dirichlet(
np.full(len(segments), alpha), np.full(len(segments), alpha),
size=1)[0] size=1)[0]
parts = parts * num_points
parts = np.round(parts).astype(int)
# correct round off so the sum of parts is still total length L. parts = parts * num_points
if sum(parts) != num_points: parts = np.round(parts).astype(int)
parts[0] += num_points - sum(parts)
# correct round off so the sum of parts is still total length L.
if sum(parts) != num_points:
parts[0] += num_points - sum(parts)
curvature = np.zeros(num_points) curvature = np.zeros(num_points)
current_index = 0 current_index = 0
for seg_name, seg_length in zip(segments, parts): for seg_name, seg_length, seg_angle in zip(segments, parts, angles):
seg_function = prefabs.PREFABS[seg_name] seg_function = prefabs.PREFABS[seg_name]
if seg_name == 'straight': if seg_name == 'straight':
@ -128,12 +126,15 @@ class SegmentedRoadGenerator(BaseRoadGenerator):
f"({R_min}, {R_max_angle})" f"({R_min}, {R_max_angle})"
) )
rand_radius = self._rng.uniform(R_min, R_max_angle) if seg_angle:
radius = seg_length / np.deg2rad(seg_angle)
else:
radius = self._rng.uniform(R_min, R_max_angle)
if seg_name.startswith("u_turn"): if seg_name.startswith("u_turn"):
curvature_s = seg_function(rand_radius) curvature_s = seg_function(radius)
else: else:
curvature_s = seg_function(seg_length, rand_radius) curvature_s = seg_function(seg_length, radius)
curvature[current_index:(current_index + seg_length)] = curvature_s curvature[current_index:(current_index + seg_length)] = curvature_s
current_index += seg_length current_index += seg_length

View File

@ -1,6 +1,6 @@
import pytest import pytest
from pg_rad.physics import get_mass_attenuation_coeff from pg_rad.utils.interpolators import get_mass_attenuation_coeff
@pytest.mark.parametrize("energy,mu", [ @pytest.mark.parametrize("energy,mu", [

View File

@ -3,21 +3,28 @@ import pytest
from pg_rad.inputparser.parser import ConfigParser from pg_rad.inputparser.parser import ConfigParser
from pg_rad.landscape.director import LandscapeDirector from pg_rad.landscape.director import LandscapeDirector
from pg_rad.physics import calculate_fluence_at from pg_rad.physics.fluence import phi
@pytest.fixture @pytest.fixture
def phi_ref(test_landscape): def isotropic_detector():
from pg_rad.detector.detector import load_detector
return load_detector('dummy')
@pytest.fixture
def phi_ref(test_landscape, isotropic_detector):
source = test_landscape.point_sources[0] source = test_landscape.point_sources[0]
r = np.linalg.norm(np.array([10, 10, 0]) - np.array(source.pos)) r = np.linalg.norm(np.array([0, 0, 0]) - np.array(source.pos))
A = source.activity * 1E6 A = source.activity * 1E6
b = source.isotope.b b = source.isotope.b
mu_air = source.isotope.mu_mass_air * test_landscape.air_density mu_air = source.isotope.mu_mass_air * test_landscape.air_density
mu_air *= 0.1 mu_air *= 0.1
return A * b * np.exp(-mu_air * r) / (4 * np.pi * r**2) eff = isotropic_detector.get_efficiency(source.isotope.E)
return A * eff * b * np.exp(-mu_air * r) / (4 * np.pi * r**2)
@pytest.fixture @pytest.fixture
@ -36,8 +43,11 @@ def test_landscape():
sources: sources:
test_source: test_source:
activity_MBq: 100 activity_MBq: 100
position: [0, 0, 0] position: [0, 100, 0]
isotope: CS137 isotope: Cs137
gamma_energy_keV: 661
detector: dummy
""" """
cp = ConfigParser(test_yaml).parse() cp = ConfigParser(test_yaml).parse()
@ -45,9 +55,16 @@ def test_landscape():
return landscape return landscape
def test_single_source_fluence(phi_ref, test_landscape): def test_single_source_fluence(phi_ref, test_landscape, isotropic_detector):
phi = calculate_fluence_at( s = test_landscape.point_sources[0]
test_landscape, r = np.linalg.norm(np.array([0, 0, 0]) - np.array(s.pos))
np.array([10, 10, 0]), phi_calc = phi(
r,
s.activity*1E6,
s.isotope.b,
s.isotope.mu_mass_air,
test_landscape.air_density,
isotropic_detector.get_efficiency(s.isotope.E)
) )
assert pytest.approx(phi[0], rel=1E-3) == phi_ref
assert pytest.approx(phi_calc, rel=1E-6) == phi_ref

View File

@ -1,7 +1,7 @@
import numpy as np import numpy as np
import pytest import pytest
from pg_rad.objects import PointSource from pg_rad.objects.sources import PointSource
@pytest.fixture @pytest.fixture