Compare commits

..

31 Commits

Author SHA1 Message Date
f49b2a5f8a Merge pull request #19 from pim-n/feature-fluence-rate
- Added mass attenuation interpolator
- PG fluence rate function that can be called at any position in the landscape
- Rewrote landscape module to pseudo-builder pattern (LandscapeBuilder & LandscapeConstructor)
- Added entry point (CLI)
- Verified installation process and updated instructions accordingly
- Updated tests
- various bugfixes
- various minor improvements
2026-02-13 16:18:52 +01:00
508cad474b Add scipy to requirements (due to interpolation of attenuation coeffs) 2026-02-13 16:13:55 +01:00
beb411d456 update tests to reflect new Landscape design 2026-02-13 16:07:42 +01:00
0db1fe0626 manually remove detector imports (not implemented yet) 2026-02-13 16:01:07 +01:00
845f006cd3 Update pyproject.toml and README.md to reflect new conditions. 2026-02-13 15:02:53 +01:00
ac8c38592d Add CLI entry point with --test flag for building test landscape using LandscapeDirector 2026-02-13 14:58:11 +01:00
a4fb4a7c57 Add LandscapeDirector with a test case to build landscape using LandscapeBuilder 2026-02-13 14:57:37 +01:00
e8bf687563 add default box height to ensure non-zero z dimension in the landscape 2026-02-13 14:56:27 +01:00
49a0dcd301 Improve isotopes and sources 2026-02-13 14:55:42 +01:00
26f96b06fe code file paths/names in config folder for more centralized definition of filenames 2026-02-13 14:50:02 +01:00
55258d7727 rename pg_rad logging->logger to avoid import conflict with default logging library. 2026-02-13 14:46:51 +01:00
82331f3bbd add default value for object position (0,0,0) 2026-02-12 16:48:16 +01:00
abc1195c91 Rewrite preset isotope 2026-02-12 14:45:24 +01:00
a95cca26d9 Move landscape construction to LandscapeBuilder object 2026-02-12 14:43:41 +01:00
8274b5e371 add size of path as attribute to Path 2026-02-12 14:41:05 +01:00
4f72fe8ff4 Add OutOfBoundsError 2026-02-12 14:02:13 +01:00
9b0c77d254 Merge branch 'feature-fluence-rate' of github.com:pim-n/pg-rad into feature-fluence-rate 2026-02-12 13:33:36 +01:00
225287c46a Merge pull request #27 from pim-n/dev
pull latest dev
2026-02-12 13:32:59 +01:00
6ceffb4361 Move plotting functionality out of Landscape to LandscapeSlicePlotter 2026-02-12 09:28:37 +01:00
3f7395ed70 add fluence function to landscape and update to new position system 2026-02-10 13:53:57 +01:00
0971c2bab9 fix typo in unit conversion 2026-02-10 11:28:38 +01:00
9c1b97d912 Update sources and objects to new position system 2026-02-10 11:24:40 +01:00
a1acf95004 update sources tests to accomodate new pos attribute 2026-02-09 15:36:23 +01:00
016ea6b783 add tests for fluence rate and attenuation interpolation 2026-02-09 15:35:22 +01:00
2b85a07aa0 add attenuation table 2026-02-09 15:34:05 +01:00
d6d9fa6f92 update init for isotopes module 2026-02-09 15:32:49 +01:00
521e5a556e Add isotope presets file 2026-02-09 15:32:19 +01:00
0c81b4df89 Add mu_mass_air to isotope automatically calculated from energy and fix error msg 2026-02-09 15:31:53 +01:00
2c436c52ad generate init for physics module 2026-02-09 15:30:27 +01:00
d4b67be775 Add primary photon fluence at distance R from point source 2026-02-09 15:29:46 +01:00
c2ddc5bfe2 Add attenuation interpolator 2026-02-09 15:29:30 +01:00
30 changed files with 544 additions and 152 deletions

View File

@ -31,6 +31,14 @@ With the virtual environment activated, run:
pip install -e .[dev] pip install -e .[dev]
``` ```
## Running example landscape
The example landscape can be generated using the command-line interface. Still in the virtual environment, run
```
pgrad --test --loglevel DEBUG
```
## Tests ## Tests
Tests can be run with `pytest` from the root directory of the repository. With the virtual environment activated, run: Tests can be run with `pytest` from the root directory of the repository. With the virtual environment activated, run:

View File

@ -7,6 +7,7 @@ where = ["src"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
"pg_rad.data" = ["*.csv"] "pg_rad.data" = ["*.csv"]
"pg_rad.configs" = ["*.yml"]
[project] [project]
name = "pg-rad" name = "pg-rad"
@ -21,14 +22,19 @@ dependencies = [
"matplotlib>=3.9.2", "matplotlib>=3.9.2",
"numpy>=2", "numpy>=2",
"pandas>=2.3.1", "pandas>=2.3.1",
"pyyaml>=6.0.2" "pyyaml>=6.0.2",
"scipy>=1.15.0"
] ]
license = "MIT" license = "MIT"
license-files = ["LICEN[CS]E*"] license-files = ["LICEN[CS]E*"]
[project.scripts]
pgrad = "pg_rad.main:main"
[project.urls] [project.urls]
Homepage = "https://github.com/pim-n/pg-rad" Homepage = "https://github.com/pim-n/pg-rad"
Issues = "https://github.com/pim-n/pg-rad/issues" Issues = "https://github.com/pim-n/pg-rad/issues"
[project.optional-dependencies] [project.optional-dependencies]
dev = ["pytest", "mkinit", "notebook", "mkdocs-material", "mkdocstrings-python", "mkdocs-jupyter"] dev = ["pytest", "mkinit", "notebook", "mkdocs-material", "mkdocstrings-python", "mkdocs-jupyter"]

View File

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

View File

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

View File

@ -0,0 +1,39 @@
energy_mev,mu
1.00000E-03,3.606E+03
1.50000E-03,1.191E+03
2.00000E-03,5.279E+02
3.00000E-03,1.625E+02
3.20290E-03,1.340E+02
3.20290E-03,1.485E+02
4.00000E-03,7.788E+01
5.00000E-03,4.027E+01
6.00000E-03,2.341E+01
8.00000E-03,9.921E+00
1.00000E-02,5.120E+00
1.50000E-02,1.614E+00
2.00000E-02,7.779E-01
3.00000E-02,3.538E-01
4.00000E-02,2.485E-01
5.00000E-02,2.080E-01
6.00000E-02,1.875E-01
8.00000E-02,1.662E-01
1.00000E-01,1.541E-01
1.50000E-01,1.356E-01
2.00000E-01,1.233E-01
3.00000E-01,1.067E-01
4.00000E-01,9.549E-02
5.00000E-01,8.712E-02
6.00000E-01,8.055E-02
8.00000E-01,7.074E-02
1.00000E+00,6.358E-02
1.25000E+00,5.687E-02
1.50000E+00,5.175E-02
2.00000E+00,4.447E-02
3.00000E+00,3.581E-02
4.00000E+00,3.079E-02
5.00000E+00,2.751E-02
6.00000E+00,2.522E-02
8.00000E+00,2.225E-02
1.00000E+01,2.045E-02
1.50000E+01,1.810E-02
2.00000E+01,1.705E-02
1 energy_mev mu
2 1.00000E-03 3.606E+03
3 1.50000E-03 1.191E+03
4 2.00000E-03 5.279E+02
5 3.00000E-03 1.625E+02
6 3.20290E-03 1.340E+02
7 3.20290E-03 1.485E+02
8 4.00000E-03 7.788E+01
9 5.00000E-03 4.027E+01
10 6.00000E-03 2.341E+01
11 8.00000E-03 9.921E+00
12 1.00000E-02 5.120E+00
13 1.50000E-02 1.614E+00
14 2.00000E-02 7.779E-01
15 3.00000E-02 3.538E-01
16 4.00000E-02 2.485E-01
17 5.00000E-02 2.080E-01
18 6.00000E-02 1.875E-01
19 8.00000E-02 1.662E-01
20 1.00000E-01 1.541E-01
21 1.50000E-01 1.356E-01
22 2.00000E-01 1.233E-01
23 3.00000E-01 1.067E-01
24 4.00000E-01 9.549E-02
25 5.00000E-01 8.712E-02
26 6.00000E-01 8.055E-02
27 8.00000E-01 7.074E-02
28 1.00000E+00 6.358E-02
29 1.25000E+00 5.687E-02
30 1.50000E+00 5.175E-02
31 2.00000E+00 4.447E-02
32 3.00000E+00 3.581E-02
33 4.00000E+00 3.079E-02
34 5.00000E+00 2.751E-02
35 6.00000E+00 2.522E-02
36 8.00000E+00 2.225E-02
37 1.00000E+01 2.045E-02
38 1.50000E+01 1.810E-02
39 2.00000E+01 1.705E-02

View File

@ -4,7 +4,7 @@ __ignore__ = ["logger"]
from pg_rad.exceptions import exceptions from pg_rad.exceptions import exceptions
from pg_rad.exceptions.exceptions import (ConvergenceError, DataLoadError, from pg_rad.exceptions.exceptions import (ConvergenceError, DataLoadError,
InvalidCSVError,) InvalidCSVError, OutOfBoundsError,)
__all__ = ['ConvergenceError', 'DataLoadError', 'InvalidCSVError', __all__ = ['ConvergenceError', 'DataLoadError', 'InvalidCSVError',
'exceptions'] 'OutOfBoundsError', 'exceptions']

View File

@ -8,3 +8,7 @@ class DataLoadError(Exception):
class InvalidCSVError(DataLoadError): class InvalidCSVError(DataLoadError):
"""Raised when a file is not a valid CSV.""" """Raised when a file is not a valid CSV."""
class OutOfBoundsError(Exception):
"""Raised when an object is attempted to be placed out of bounds."""

View File

@ -2,7 +2,9 @@
__ignore__ = ["logger"] __ignore__ = ["logger"]
from pg_rad.isotopes import isotope from pg_rad.isotopes import isotope
from pg_rad.isotopes import presets
from pg_rad.isotopes.isotope import (Isotope,) from pg_rad.isotopes.isotope import (Isotope,)
from pg_rad.isotopes.presets import (CS137,)
__all__ = ['Isotope', 'isotope'] __all__ = ['CS137', 'Isotope', 'isotope', 'presets']

View File

@ -1,3 +1,6 @@
from pg_rad.physics import get_mass_attenuation_coeff
class Isotope: class Isotope:
"""Represents the essential information of an isotope. """Represents the essential information of an isotope.
@ -16,8 +19,9 @@ class Isotope:
raise ValueError("primary_gamma must be a positive energy (keV).") raise ValueError("primary_gamma must be a positive energy (keV).")
if not (0 <= b <= 1): if not (0 <= b <= 1):
raise ValueError("branching_ratio_pg must be a ratio (0 <= b <= 1)") raise ValueError("branching_ratio_pg must be a ratio b in [0,1]")
self.name = name self.name = name
self.E = E self.E = E
self.b = b self.b = b
self.mu_mass_air = get_mass_attenuation_coeff(E / 1000)

View File

@ -0,0 +1,10 @@
from .isotope import Isotope
class CS137(Isotope):
def __init__(self):
super().__init__(
name="Cs-137",
E=661.66,
b=0.851
)

View File

@ -1,8 +1,11 @@
# do not expose internal logger when running mkinit # do not expose internal logger when running mkinit
__ignore__ = ["logger"] __ignore__ = ["logger"]
from pg_rad.landscape import director
from pg_rad.landscape import landscape from pg_rad.landscape import landscape
from pg_rad.landscape.landscape import (Landscape, create_landscape_from_path,) from pg_rad.landscape.director import (LandscapeDirector,)
from pg_rad.landscape.landscape import (Landscape, LandscapeBuilder,)
__all__ = ['Landscape', 'create_landscape_from_path', 'landscape'] __all__ = ['Landscape', 'LandscapeBuilder', 'LandscapeDirector', 'director',
'landscape']

View File

@ -0,0 +1,23 @@
from importlib.resources import files
import logging
from pg_rad.configs.filepaths import TEST_EXP_DATA
from pg_rad.isotopes import CS137
from pg_rad.landscape.landscape import LandscapeBuilder
from pg_rad.objects import PointSource
class LandscapeDirector:
def __init__(self):
self.logger = logging.getLogger(__name__)
self.logger.debug("LandscapeDirector initialized.")
def build_test_landscape(self):
fp = files('pg_rad.data').joinpath(TEST_EXP_DATA)
source = PointSource(activity=100E9, isotope=CS137(), pos=(0, 0, 0))
lb = LandscapeBuilder("Test landscape")
lb.set_air_density(1.243)
lb.set_path_from_experimental_data(fp, z=0)
lb.set_point_sources(source)
landscape = lb.build()
return landscape

View File

@ -1,133 +1,161 @@
import logging import logging
from typing import Self
from matplotlib import pyplot as plt from pg_rad.dataloader import load_data
from matplotlib.patches import Circle from pg_rad.exceptions import OutOfBoundsError
import numpy as np
from pg_rad.path import Path
from pg_rad.objects import PointSource from pg_rad.objects import PointSource
from pg_rad.path import Path, path_from_RT90
from pg_rad.physics.fluence import phi_single_source
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Landscape: class Landscape:
"""A generic Landscape that can contain a Path and sources. """
A generic Landscape that can contain a Path and sources.
Args:
air_density (float, optional): Air density, kg/m^3. Defaults to 1.243.
size (int | tuple[int, int, int], optional): Size of the world.
Defaults to 500.
scale (str, optional): The scale of the size argument passed.
Defaults to 'meters'.
""" """
def __init__( def __init__(
self, self,
air_density: float = 1.243, name: str,
size: int | tuple[int, int, int] = 500, path: Path,
scale: str = 'meters' point_sources: list[PointSource] = [],
size: tuple[int, int, int] = [500, 500, 50],
air_density: float = 1.243
): ):
if isinstance(size, int): """Initialize a landscape.
self.world = np.zeros((size, size, size))
elif isinstance(size, tuple) and len(size) == 3:
self.world = np.zeros(size)
else:
raise TypeError("size must be integer or a tuple of 3 integers.")
self.air_density = air_density
self.scale = scale
self.path: Path = None
self.sources: list[PointSource] = []
logger.debug("Landscape initialized.")
def plot(self, z: float | int = 0):
"""Plot a slice of the world at a height `z`.
Args: Args:
z (int, optional): Height of slice. Defaults to 0. path (Path): A Path object.
point_sources (list[PointSource], optional): List of point sources.
air_density (float, optional): Air density in kg/m^3.
Defaults to 1.243.
size (tuple[int, int, int], optional): (x,y,z) dimensions of world
in meters. Defaults to [500, 500, 50].
Returns: Raises:
fig, ax: Matplotlib figure objects. TypeError: _description_
""" """
x_lim, y_lim, _ = self.world.shape
fig, ax = plt.subplots() self.name = name
ax.set_xlim(right=x_lim) self.path = path
ax.set_ylim(top=y_lim) self.point_sources = point_sources
ax.set_xlabel(f"X [{self.scale}]") self.size = size
ax.set_ylabel(f"Y [{self.scale}]") self.air_density = air_density
if self.path is not None: logger.debug(f"Landscape created: {self.name}")
ax.plot(self.path.x_list, self.path.y_list, 'bo-')
for s in self.sources: def calculate_fluence_at(self, pos: tuple):
if np.isclose(s.z, z): total_phi = 0.
dot = Circle( for source in self.point_sources:
(s.x, s.y), r = source.distance_to(pos)
radius=5, phi_source = phi_single_source(
color=s.color, r=r,
zorder=5 activity=source.activity,
branching_ratio=source.isotope.b,
mu_mass_air=source.isotope.mu_mass_air,
air_density=self.air_density
)
total_phi += phi_source
return total_phi
def calculate_fluence_along_path(self):
pass
class LandscapeBuilder:
def __init__(self, name: str = "Unnamed landscape"):
self.name = name
self._path = None
self._point_sources = []
self._size = None
self._air_density = None
logger.debug(f"LandscapeBuilder initialized: {self.name}")
def set_air_density(self, air_density) -> Self:
"""Set the air density of the world."""
self._air_density = air_density
return self
def set_landscape_size(self, size: tuple[int, int, int]) -> Self:
"""Set the size of the landscape in meters (x,y,z)."""
if self._path and any(p > s for p, s in zip(self._path.size, size)):
raise OutOfBoundsError(
"Cannot set landscape size smaller than the path."
) )
ax.text( self._size = size
s.x + 0.06, logger.debug("Size of the landscape has been updated.")
s.y + 0.06,
s.name, return self
color=s.color,
fontsize=10, def set_path_from_experimental_data(
ha="left", self,
va="bottom", filename: str,
zorder=6 z: int,
east_col: str = "East",
north_col: str = "North"
) -> Self:
df = load_data(filename)
self._path = path_from_RT90(
df=df,
east_col=east_col,
north_col=north_col,
z=z
) )
ax.add_patch(dot) # The size of the landscape will be updated if
# 1) _size is not set, or
# 2) _size is too small to contain the path.
needs_resize = (
not self._size
or any(p > s for p, s in zip(self._path.size, self._size))
)
return fig, ax if needs_resize:
if not self._size:
logger.debug("Because no Landscape size was set, "
"it will now set to path dimensions.")
else:
logger.warning(
"Path exceeds current landscape size. "
"Landscape size will be expanded to accommodate path."
)
def add_sources(self, *sources: PointSource): self.set_landscape_size(self._path.size)
return self
def set_point_sources(self, *sources):
"""Add one or more point sources to the world. """Add one or more point sources to the world.
Args: Args:
*sources (pg_rad.sources.PointSource): One or more sources, *sources (pg_rad.sources.PointSource): One or more sources,
passed as Source1, Source2, ... passed as Source1, Source2, ...
Raises: Raises:
ValueError: If the source is outside the boundaries of the OutOfBoundsError: If any source is outside the boundaries of the
landscape. landscape.
""" """
max_x, max_y, max_z = self.world.shape[:3]
if any( if any(
not (0 <= source.x < max_x and any(p < 0 or p >= s for p, s in zip(source.pos, self._size))
0 <= source.y < max_y and
0 <= source.z < max_z)
for source in sources for source in sources
): ):
raise ValueError("One or more sources are outside the landscape!") raise OutOfBoundsError(
"One or more sources attempted to "
"be placed outside the landscape."
)
self.sources.extend(sources) self._point_sources = sources
def set_path(self, path: Path): def build(self):
""" landscape = Landscape(
Set the path in the landscape. name=self.name,
""" path=self._path,
self.path = path point_sources=self._point_sources,
size=self._size,
air_density=self._air_density
)
logger.info(f"Landscape built successfully: {landscape.name}")
def create_landscape_from_path(path: Path, max_z: float | int = 50):
"""Generate a landscape from a path, using its dimensions to determine
the size of the landscape.
Args:
path (Path): A Path object describing the trajectory.
max_z (int, optional): Height of the world. Defaults to 50 meters.
Returns:
landscape (pg_rad.landscape.Landscape): A landscape with dimensions
based on the provided Path.
"""
max_x = np.ceil(max(path.x_list))
max_y = np.ceil(max(path.y_list))
landscape = Landscape(size=(max_x, max_y, max_z))
landscape.path = path
return landscape return landscape

View File

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

View File

@ -1,8 +1,10 @@
import logging import logging.config
import pathlib from importlib.resources import files
import yaml import yaml
from pg_rad.configs.filepaths import LOGGING_CONFIG
def setup_logger(log_level: str = "WARNING"): def setup_logger(log_level: str = "WARNING"):
levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
@ -10,8 +12,7 @@ def setup_logger(log_level: str = "WARNING"):
if log_level not in levels: if log_level not in levels:
raise ValueError(f"Log level must be one of {levels}.") raise ValueError(f"Log level must be one of {levels}.")
base_dir = pathlib.Path(__file__).resolve().parent config_file = files('pg_rad.configs').joinpath(LOGGING_CONFIG)
config_file = base_dir / "configs" / "logging.yml"
with open(config_file) as f: with open(config_file) as f:
config = yaml.safe_load(f) config = yaml.safe_load(f)

View File

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

33
src/pg_rad/main.py Normal file
View File

@ -0,0 +1,33 @@
import argparse
from pg_rad.logger import setup_logger
from pg_rad.landscape import LandscapeDirector
def main():
parser = argparse.ArgumentParser(
prog="pg-rad",
description="Primary Gamma RADiation landscape tool"
)
parser.add_argument(
"--test",
action="store_true",
help="Load and run the test landscape"
)
parser.add_argument(
"--loglevel",
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
)
args = parser.parse_args()
setup_logger(args.loglevel)
if args.test:
landscape = LandscapeDirector().build_test_landscape()
print(landscape.name)
if __name__ == "__main__":
main()

View File

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

View File

@ -1,36 +1,42 @@
import math
from typing import Self from typing import Self
import numpy as np
class BaseObject: class BaseObject:
def __init__( def __init__(
self, self,
x: float, pos: tuple[float, float, float] = (0, 0, 0),
y: float,
z: float,
name: str = "Unnamed object", name: str = "Unnamed object",
color: str = 'grey'): color: str = 'grey'):
""" """
A generic object. A generic object.
Args: Args:
x (float): X coordinate. pos (tuple[float, float, float]): Position vector (x,y,z).
y (float): Y coordinate.
z (float): Z coordinate.
name (str, optional): Name for the object. name (str, optional): Name for the object.
Defaults to "Unnamed object". Defaults to "Unnamed object".
color (str, optional): Matplotlib compatible color string. color (str, optional): Matplotlib compatible color string.
Defaults to "red". Defaults to "red".
""" """
self.x = x if len(pos) != 3:
self.y = y raise ValueError("Position must be tuple of length 3 (x,y,z).")
self.z = z self.pos = pos
self.name = name self.name = name
self.color = color self.color = color
def distance_to(self, other: Self) -> float: def distance_to(self, other: Self | tuple) -> float:
return math.dist( if isinstance(other, tuple) and len(other) == 3:
(self.x, self.y, self.z), r = np.linalg.norm(
(other.x, other.y, other.z), np.subtract(self.pos, other)
) )
else:
try:
r = np.linalg.norm(
np.subtract(self.pos, other.pos)
)
except AttributeError as e:
raise e("other must be an object in the world \
or a position tuple (x,y,z).")
return r

View File

@ -11,26 +11,23 @@ class PointSource(BaseObject):
def __init__( def __init__(
self, self,
x: float,
y: float,
z: float,
activity: int, activity: int,
isotope: Isotope, isotope: Isotope,
pos: tuple[float, float, float] = (0, 0, 0),
name: str | None = None, name: str | None = None,
color: str = "red"): color: str = 'red'
):
"""A point source. """A point source.
Args: Args:
x (float): X coordinate.
y (float): Y coordinate.
z (float): Z coordinate.
activity (int): Activity A in MBq. activity (int): Activity A in MBq.
isotope (Isotope): The isotope. isotope (Isotope): The isotope.
name (str | None, optional): Can give the source a unique name. pos (tuple[float, float, float], optional):
Defaults to None, making the name sequential. Position of the PointSource.
(Source-1, Source-2, etc.). name (str, optional): Can give the source a unique name.
color (str, optional): Matplotlib compatible color string. If not provided, point sources are sequentially
Defaults to "red". named: Source1, Source2, ...
color (str, optional): Matplotlib compatible color string
""" """
self.id = PointSource._id_counter self.id = PointSource._id_counter
@ -40,11 +37,10 @@ class PointSource(BaseObject):
if name is None: if name is None:
name = f"Source {self.id}" name = f"Source {self.id}"
super().__init__(x, y, z, name, color) super().__init__(pos, name, color)
self.activity = activity self.activity = activity
self.isotope = isotope self.isotope = isotope
self.color = color
logger.debug(f"Source created: {self.name}") logger.debug(f"Source created: {self.name}")

View File

@ -42,14 +42,18 @@ class Path:
def __init__( def __init__(
self, self,
coord_list: Sequence[tuple[float, float]], coord_list: Sequence[tuple[float, float]],
z: float = 0 z: float = 0.,
z_box: float = 50.
): ):
"""Construct a path of sequences based on a list of coordinates. """Construct a path of sequences based on a list of coordinates.
Args: Args:
coord_list (Sequence[tuple[float, float]]): List of x,y coord_list (Sequence[tuple[float, float]]): List of x,y
coordinates. coordinates.
z (float, optional): Height of the path. Defaults to 0. z (float, optional): position of the path in z-direction in meters.
Defaults to 0 meters.
z_box (float, optional): How much empty space to set
above the path in meters. Defaults to 50 meters.
""" """
if len(coord_list) < 2: if len(coord_list) < 2:
@ -70,6 +74,11 @@ class Path:
] ]
self.z = z self.z = z
self.size = (
np.ceil(max(self.x_list)),
np.ceil(max(self.y_list)),
z + z_box
)
logger.debug("Path created.") logger.debug("Path created.")

View File

@ -0,0 +1,10 @@
# 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 (phi_single_source,)
__all__ = ['attenuation', 'fluence', 'get_mass_attenuation_coeff',
'phi_single_source']

View File

@ -0,0 +1,17 @@
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

@ -0,0 +1,37 @@
import numpy as np
def phi_single_source(
r: float,
activity: float | int,
branching_ratio: float,
mu_mass_air: float,
air_density: float,
) -> float:
"""Compute the contribution of a single point source to the
primary photon fluence rate phi at position (x,y,z).
Args:
r (float): [m] Distance to the point source.
activity (float | int): [Bq] Activity of the point source.
branching_ratio (float): Branching ratio for the photon energy E_gamma.
mu_mass_air (float): [cm^2/g] Mass attenuation coefficient for air.
air_density (float): [kg/m^3] Air density.
Returns:
phi (float): [s^-1 m^-2] Primary photon fluence rate at distance r from
a point source.
"""
# Linear photon attenuation coefficient in m^-1.
mu_mass_air *= 0.1
mu_air = mu_mass_air * air_density
phi = (
activity
* branching_ratio
* np.exp(-mu_air * r)
/ (4 * np.pi * r**2)
)
return phi

View File

@ -0,0 +1,7 @@
# 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

@ -0,0 +1,75 @@
import logging
from matplotlib import pyplot as plt
from matplotlib.patches import Circle
from pg_rad.landscape import Landscape
logger = logging.getLogger(__name__)
class LandscapeSlicePlotter:
def plot(self, landscape: Landscape, z: int = 0):
"""Plot a top-down slice of the landscape at a height z.
Args:
landscape (Landscape): the landscape to plot
z (int, optional): Height at which to plot slice. Defaults to 0.
""" """
"""
self.z = z
fig, ax = plt.subplots()
self._draw_base(ax, landscape)
self._draw_path(ax, landscape)
self._draw_point_sources(ax, landscape)
ax.set_aspect("equal")
plt.show()
def _draw_base(self, ax, landscape):
width, height = landscape.size[:2]
ax.set_xlim(right=width)
ax.set_ylim(top=height)
ax.set_xlabel("X [m]")
ax.set_ylabel("Y [m]")
ax.set_title(f"Landscape (top-down, z = {self.z})")
def _draw_path(self, ax, landscape):
if landscape.path.z < self.z:
ax.plot(landscape.path.x_list, landscape.path.y_list, 'bo-')
else:
logger.warning(
"Path is above the slice height z."
"It will not show on the plot."
)
def _draw_point_sources(self, ax, landscape):
for s in landscape.point_sources:
if s.z <= self.z:
dot = Circle(
(s.x, s.y),
radius=5,
color=s.color,
zorder=5
)
ax.text(
s.x + 0.06,
s.y + 0.06,
s.name,
color=s.color,
fontsize=10,
ha="left",
va="bottom",
zorder=6
)
ax.add_patch(dot)
else:
logger.warning(
f"Source {s.name} is above slice height z."
"It will not show on the plot."
)

View File

@ -0,0 +1,29 @@
import pytest
from pg_rad.physics import get_mass_attenuation_coeff
@pytest.mark.parametrize("energy,mu", [
(1.00000E-03, 3.606E+03),
(1.00000E-02, 5.120E+00),
(1.00000E-01, 1.541E-01),
(1.00000E+00, 6.358E-02),
(1.00000E+01, 2.045E-02)
])
def test_exact_attenuation_retrieval(energy, mu):
"""
Test if retrieval of values that are exactly in the table is correct.
"""
func_mu = get_mass_attenuation_coeff(energy)
assert pytest.approx(func_mu, rel=1E-6) == mu
@pytest.mark.parametrize("energy,mu", [
(0.662, 0.0778)
])
def test_attenuation_interpolation(energy, mu):
"""
Test retreival for Cs-137 mass attenuation coefficient.
"""
interp_mu = get_mass_attenuation_coeff(energy)
assert pytest.approx(interp_mu, rel=1E-2) == mu

View File

@ -0,0 +1,34 @@
from math import dist, exp, pi
import pytest
from pg_rad.landscape import LandscapeDirector
@pytest.fixture
def phi_ref():
A = 100 # MBq
b = 0.851
mu_mass_air = 0.0778 # cm^2/g
air_density = 1.243 # kg/m^3
r = dist((0, 0, 0), (10, 10, 0)) # m
A *= 1E9 # Convert to Bq
mu_mass_air *= 0.1 # Convert to m^2/kg
mu_air = mu_mass_air * air_density # [m^2/kg] x [kg/m^3] = [m^-1]
# [s^-1] x exp([m^-1] x [m]) / [m^-2] = [s^-1 m^-2]
phi = A * b * exp(-mu_air * r) / (4 * pi * r**2)
return phi
@pytest.fixture
def test_landscape():
landscape = LandscapeDirector().build_test_landscape()
return landscape
def test_single_source_fluence(phi_ref, test_landscape):
phi = test_landscape.calculate_fluence_at((10, 10, 0))
assert pytest.approx(phi, rel=1E-3) == phi_ref

View File

@ -1,27 +1,36 @@
import numpy as np import numpy as np
import pytest import pytest
from pg_rad.sources import PointSource from pg_rad.objects import PointSource
from pg_rad.isotopes import CS137
@pytest.fixture @pytest.fixture
def test_sources(): def test_sources():
iso = CS137()
pos_a = np.random.rand(3) pos_a = np.random.rand(3)
pos_b = np.random.rand(3) pos_b = np.random.rand(3)
a = PointSource(*tuple(pos_a), strength = None) a = PointSource(pos=pos_a, activity=None, isotope=iso)
b = PointSource(*tuple(pos_b), strength = None) b = PointSource(pos=pos_b, activity=None, isotope=iso)
return pos_a, pos_b, a, b return pos_a, pos_b, a, b
def test_if_distances_equal(test_sources): def test_if_distances_equal(test_sources):
"""Verify whether from PointSource A to PointSource B is the same as B to A.""" """
Verify whether distance from PointSource A to B is the same as B to A.
"""
_, _, a, b = test_sources _, _, a, b = test_sources
assert a.distance_to(b) == b.distance_to(a) assert a.distance_to(b) == b.distance_to(a)
def test_distance_calculation(test_sources): def test_distance_calculation(test_sources):
"""Verify whether distance between two PointSources is calculated correctly.""" """
Verify whether distance between PointSources is calculated correctly.
"""
pos_a, pos_b, a, b = test_sources pos_a, pos_b, a, b = test_sources
@ -32,4 +41,4 @@ def test_distance_calculation(test_sources):
assert np.isclose( assert np.isclose(
a.distance_to(b), a.distance_to(b),
np.sqrt(dx**2 + dy**2 + dz**2), np.sqrt(dx**2 + dy**2 + dz**2),
rtol = 1e-12) rtol=1e-12)