From c2ddc5bfe241879a4b66a5824823ccd05b28dd34 Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Mon, 9 Feb 2026 15:29:30 +0100 Subject: [PATCH 01/28] Add attenuation interpolator --- src/pg_rad/physics/attenuation.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/pg_rad/physics/attenuation.py diff --git a/src/pg_rad/physics/attenuation.py b/src/pg_rad/physics/attenuation.py new file mode 100644 index 0000000..bdc70e3 --- /dev/null +++ b/src/pg_rad/physics/attenuation.py @@ -0,0 +1,15 @@ +from importlib.resources import files + +from pandas import read_csv +from scipy.interpolate import interp1d + + +def get_mass_attenuation_coeff( + *args + ) -> float: + csv = files('pg_rad.data').joinpath('attenuation_table.csv') + data = read_csv(csv) + x = data["energy_mev"].to_numpy() + y = data["mu"].to_numpy() + f = interp1d(x, y) + return f(*args) From d4b67be775cc57fc2feac41b06f633388bd4adfd Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Mon, 9 Feb 2026 15:29:46 +0100 Subject: [PATCH 02/28] Add primary photon fluence at distance R from point source --- src/pg_rad/physics/fluence.py | 37 +++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/pg_rad/physics/fluence.py diff --git a/src/pg_rad/physics/fluence.py b/src/pg_rad/physics/fluence.py new file mode 100644 index 0000000..8d327ed --- /dev/null +++ b/src/pg_rad/physics/fluence.py @@ -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 = 0.1 * mu_mass_air * air_density + + phi = ( + activity + * branching_ratio + * np.exp(-mu_air * r) + / (4 * np.pi * r**2) + ) + + return phi From 2c436c52ad3d910fa9adaa63cc661335f1a2c1bf Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Mon, 9 Feb 2026 15:30:27 +0100 Subject: [PATCH 03/28] generate init for physics module --- src/pg_rad/physics/__init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/pg_rad/physics/__init__.py diff --git a/src/pg_rad/physics/__init__.py b/src/pg_rad/physics/__init__.py new file mode 100644 index 0000000..fe9b62d --- /dev/null +++ b/src/pg_rad/physics/__init__.py @@ -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'] From 0c81b4df899a28ab6f0e2fe6264c819d647ca83c Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Mon, 9 Feb 2026 15:31:53 +0100 Subject: [PATCH 04/28] Add mu_mass_air to isotope automatically calculated from energy and fix error msg --- src/pg_rad/isotopes/isotope.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pg_rad/isotopes/isotope.py b/src/pg_rad/isotopes/isotope.py index ea758b8..78ac54e 100644 --- a/src/pg_rad/isotopes/isotope.py +++ b/src/pg_rad/isotopes/isotope.py @@ -1,3 +1,6 @@ +from pg_rad.physics import get_mass_attenuation_coeff + + class Isotope: """Represents the essential information of an isotope. @@ -11,13 +14,14 @@ class Isotope: name: str, E: float, b: float - ): + ): if E <= 0: raise ValueError("primary_gamma must be a positive energy (keV).") 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.E = E self.b = b + self.mu_mass_air = get_mass_attenuation_coeff(E / 1000) From 521e5a556e19b190f3babe2c790b1bee455858d6 Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Mon, 9 Feb 2026 15:32:19 +0100 Subject: [PATCH 05/28] Add isotope presets file --- src/pg_rad/isotopes/presets.py | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/pg_rad/isotopes/presets.py diff --git a/src/pg_rad/isotopes/presets.py b/src/pg_rad/isotopes/presets.py new file mode 100644 index 0000000..5b8224b --- /dev/null +++ b/src/pg_rad/isotopes/presets.py @@ -0,0 +1,4 @@ +from .isotope import Isotope + + +CS137 = Isotope("Cs-137", E=661.66, b=0.851) From d6d9fa6f92ff6d08bfb5ec1992f32bf001f21fcf Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Mon, 9 Feb 2026 15:32:49 +0100 Subject: [PATCH 06/28] update init for isotopes module --- src/pg_rad/isotopes/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pg_rad/isotopes/__init__.py b/src/pg_rad/isotopes/__init__.py index 0c7976d..6f000c0 100644 --- a/src/pg_rad/isotopes/__init__.py +++ b/src/pg_rad/isotopes/__init__.py @@ -2,7 +2,9 @@ __ignore__ = ["logger"] from pg_rad.isotopes import isotope +from pg_rad.isotopes import presets from pg_rad.isotopes.isotope import (Isotope,) +from pg_rad.isotopes.presets import (CS137,) -__all__ = ['Isotope', 'isotope'] +__all__ = ['CS137', 'Isotope', 'isotope', 'presets'] From 2b85a07aa019e0df9c9eefad8dce9c8b579aa1d5 Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Mon, 9 Feb 2026 15:34:05 +0100 Subject: [PATCH 07/28] add attenuation table --- src/pg_rad/data/__init__.py | 1 + src/pg_rad/data/attenuation_table.csv | 39 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 src/pg_rad/data/__init__.py create mode 100644 src/pg_rad/data/attenuation_table.csv diff --git a/src/pg_rad/data/__init__.py b/src/pg_rad/data/__init__.py new file mode 100644 index 0000000..a9a2c5b --- /dev/null +++ b/src/pg_rad/data/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/src/pg_rad/data/attenuation_table.csv b/src/pg_rad/data/attenuation_table.csv new file mode 100644 index 0000000..0c8f024 --- /dev/null +++ b/src/pg_rad/data/attenuation_table.csv @@ -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 \ No newline at end of file From 016ea6b783dd66deb6d33b0e3f96674645323274 Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Mon, 9 Feb 2026 15:35:22 +0100 Subject: [PATCH 08/28] add tests for fluence rate and attenuation interpolation --- tests/test_attenuation_functions.py | 30 +++++++++++++++++++++ tests/test_fluence_rate.py | 41 +++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 tests/test_attenuation_functions.py create mode 100644 tests/test_fluence_rate.py diff --git a/tests/test_attenuation_functions.py b/tests/test_attenuation_functions.py new file mode 100644 index 0000000..ad9d5e4 --- /dev/null +++ b/tests/test_attenuation_functions.py @@ -0,0 +1,30 @@ +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), + (1.25, 0.06) +]) +def test_attenuation_interpolation(energy, mu): + """ + Test Cs-137 and Co-60 mass attenuation coefficients. + """ + interp_mu = get_mass_attenuation_coeff(energy) + assert pytest.approx(interp_mu, rel=1E-2) == mu diff --git a/tests/test_fluence_rate.py b/tests/test_fluence_rate.py new file mode 100644 index 0000000..710b2fb --- /dev/null +++ b/tests/test_fluence_rate.py @@ -0,0 +1,41 @@ +from math import dist, exp, pi + +import pytest + +from pg_rad.isotopes import CS137 +from pg_rad.landscape import Landscape +from pg_rad.objects import PointSource + + +@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 = Landscape() + source = PointSource( + pos=(0, 0, 0), + activity=100E9, + isotope=CS137) + landscape.add_sources(source) + 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 From a1acf95004e96749c8eb5ac328e1db676261d07c Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Mon, 9 Feb 2026 15:36:23 +0100 Subject: [PATCH 09/28] update sources tests to accomodate new pos attribute --- tests/test_sources.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/test_sources.py b/tests/test_sources.py index 156276a..1ce84ee 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -1,27 +1,36 @@ import numpy as np import pytest -from pg_rad.sources import PointSource +from pg_rad.objects import PointSource +from pg_rad.isotopes import Isotope + @pytest.fixture def test_sources(): + iso = Isotope("test", E=662, b=0) pos_a = np.random.rand(3) pos_b = np.random.rand(3) - a = PointSource(*tuple(pos_a), strength = None) - b = PointSource(*tuple(pos_b), strength = None) + a = PointSource(pos_a, activity=None, isotope=iso) + b = PointSource(pos_b, activity=None, isotope=iso) return pos_a, pos_b, a, b + 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 assert a.distance_to(b) == b.distance_to(a) + 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 @@ -32,4 +41,4 @@ def test_distance_calculation(test_sources): assert np.isclose( a.distance_to(b), np.sqrt(dx**2 + dy**2 + dz**2), - rtol = 1e-12) \ No newline at end of file + rtol=1e-12) From 9c1b97d912c0ce6365a0e7650857d893ef705fa0 Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Tue, 10 Feb 2026 11:24:40 +0100 Subject: [PATCH 10/28] Update sources and objects to new position system --- src/pg_rad/objects/objects.py | 36 ++++++++++++++++++++--------------- src/pg_rad/objects/sources.py | 11 +++-------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/src/pg_rad/objects/objects.py b/src/pg_rad/objects/objects.py index 16f03b9..a610d9e 100644 --- a/src/pg_rad/objects/objects.py +++ b/src/pg_rad/objects/objects.py @@ -1,36 +1,42 @@ -import math from typing import Self +import numpy as np + class BaseObject: def __init__( self, - x: float, - y: float, - z: float, + pos: tuple[float, float, float], name: str = "Unnamed object", color: str = 'grey'): """ A generic object. Args: - x (float): X coordinate. - y (float): Y coordinate. - z (float): Z coordinate. + pos (tuple[float, float, float]): Position vector (x,y,z). name (str, optional): Name for the object. Defaults to "Unnamed object". color (str, optional): Matplotlib compatible color string. Defaults to "red". """ - self.x = x - self.y = y - self.z = z + if len(pos) != 3: + raise ValueError("Position must be tuple of length 3 (x,y,z).") + self.pos = pos self.name = name self.color = color - def distance_to(self, other: Self) -> float: - return math.dist( - (self.x, self.y, self.z), - (other.x, other.y, other.z), - ) + def distance_to(self, other: Self | tuple) -> float: + if isinstance(other, tuple) and len(other) == 3: + r = np.linalg.norm( + 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 diff --git a/src/pg_rad/objects/sources.py b/src/pg_rad/objects/sources.py index 1bc3a2d..ee47509 100644 --- a/src/pg_rad/objects/sources.py +++ b/src/pg_rad/objects/sources.py @@ -11,9 +11,7 @@ class PointSource(BaseObject): def __init__( self, - x: float, - y: float, - z: float, + pos: tuple, activity: int, isotope: Isotope, name: str | None = None, @@ -21,9 +19,7 @@ class PointSource(BaseObject): """A point source. Args: - x (float): X coordinate. - y (float): Y coordinate. - z (float): Z coordinate. + pos (tuple): a position vector of length 3 (x,y,z). activity (int): Activity A in MBq. isotope (Isotope): The isotope. name (str | None, optional): Can give the source a unique name. @@ -40,11 +36,10 @@ class PointSource(BaseObject): if name is None: name = f"Source {self.id}" - super().__init__(x, y, z, name, color) + super().__init__(pos, name, color) self.activity = activity self.isotope = isotope - self.color = color logger.debug(f"Source created: {self.name}") From 0971c2bab94e996179a62d4c2dac4a7f129c28f9 Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Tue, 10 Feb 2026 11:28:38 +0100 Subject: [PATCH 11/28] fix typo in unit conversion --- src/pg_rad/physics/fluence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pg_rad/physics/fluence.py b/src/pg_rad/physics/fluence.py index 8d327ed..8bae5f3 100644 --- a/src/pg_rad/physics/fluence.py +++ b/src/pg_rad/physics/fluence.py @@ -25,7 +25,7 @@ def phi_single_source( # 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 = ( activity From 3f7395ed70d9847504fad1e2c533d28dd8f692d7 Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Tue, 10 Feb 2026 13:53:57 +0100 Subject: [PATCH 12/28] add fluence function to landscape and update to new position system --- src/pg_rad/landscape/landscape.py | 33 ++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/src/pg_rad/landscape/landscape.py b/src/pg_rad/landscape/landscape.py index 1883694..815a90d 100644 --- a/src/pg_rad/landscape/landscape.py +++ b/src/pg_rad/landscape/landscape.py @@ -3,9 +3,11 @@ import logging from matplotlib import pyplot as plt from matplotlib.patches import Circle import numpy as np +from numpy.typing import ArrayLike from pg_rad.path import Path from pg_rad.objects import PointSource +from pg_rad.physics.fluence import phi_single_source logger = logging.getLogger(__name__) @@ -35,7 +37,6 @@ class Landscape: self.air_density = air_density self.scale = scale - self.path: Path = None self.sources: list[PointSource] = [] logger.debug("Landscape initialized.") @@ -94,12 +95,10 @@ class Landscape: ValueError: If the source is outside the boundaries of the landscape. """ - max_x, max_y, max_z = self.world.shape[:3] - - if any( - not (0 <= source.x < max_x and - 0 <= source.y < max_y and - 0 <= source.z < max_z) + if not any( + (0 <= source.pos[0] <= self.world.shape[0] or + 0 <= source.pos[1] <= self.world.shape[1] or + 0 <= source.pos[2] <= self.world.shape[2]) for source in sources ): raise ValueError("One or more sources are outside the landscape!") @@ -110,8 +109,28 @@ class Landscape: """ Set the path in the landscape. """ + if not isinstance(path, Path): + raise TypeError("path must be of type Path.") self.path = path + def calculate_fluence_at(self, pos: tuple): + total_phi = 0. + for source in self.sources: + r = source.distance_to(pos) + phi_source = phi_single_source( + r=r, + 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): + if self.path is None: + raise ValueError("Path is not set!") + def create_landscape_from_path(path: Path, max_z: float | int = 50): """Generate a landscape from a path, using its dimensions to determine From 6ceffb436126248c0d12e55c1a38c9b445dcc9bd Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Thu, 12 Feb 2026 09:28:37 +0100 Subject: [PATCH 13/28] Move plotting functionality out of Landscape to LandscapeSlicePlotter --- src/pg_rad/landscape/landscape.py | 84 +++++++----------------- src/pg_rad/plotting/__init__.py | 7 ++ src/pg_rad/plotting/landscape_plotter.py | 75 +++++++++++++++++++++ 3 files changed, 105 insertions(+), 61 deletions(-) create mode 100644 src/pg_rad/plotting/__init__.py create mode 100644 src/pg_rad/plotting/landscape_plotter.py diff --git a/src/pg_rad/landscape/landscape.py b/src/pg_rad/landscape/landscape.py index 815a90d..6575087 100644 --- a/src/pg_rad/landscape/landscape.py +++ b/src/pg_rad/landscape/landscape.py @@ -13,77 +13,34 @@ logger = logging.getLogger(__name__) class Landscape: - """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'. + """ + A generic Landscape that can contain a Path and sources. """ def __init__( self, - air_density: float = 1.243, - size: int | tuple[int, int, int] = 500, - scale: str = 'meters' + path: Path, + point_sources: list[PointSource] = [], + size: tuple[int, int, int] = [500, 500, 50], + air_density: float = 1.243 ): - if isinstance(size, int): - 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`. + """Initialize a landscape. 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: - fig, ax: Matplotlib figure objects. + Raises: + TypeError: _description_ """ - x_lim, y_lim, _ = self.world.shape - fig, ax = plt.subplots() - ax.set_xlim(right=x_lim) - ax.set_ylim(top=y_lim) - ax.set_xlabel(f"X [{self.scale}]") - ax.set_ylabel(f"Y [{self.scale}]") + self.path = path + self.point_sources = point_sources + self.size = size + self.air_density = air_density - if self.path is not None: - ax.plot(self.path.x_list, self.path.y_list, 'bo-') - - for s in self.sources: - if np.isclose(s.z, 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) - - return fig, ax + logger.debug("Landscape initialized.") def add_sources(self, *sources: PointSource): """Add one or more point sources to the world. @@ -150,3 +107,8 @@ def create_landscape_from_path(path: Path, max_z: float | int = 50): landscape = Landscape(size=(max_x, max_y, max_z)) landscape.path = path return landscape + + +class LandscapeBuilder: + def __init__(self): + pass \ No newline at end of file diff --git a/src/pg_rad/plotting/__init__.py b/src/pg_rad/plotting/__init__.py new file mode 100644 index 0000000..271d2c9 --- /dev/null +++ b/src/pg_rad/plotting/__init__.py @@ -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'] diff --git a/src/pg_rad/plotting/landscape_plotter.py b/src/pg_rad/plotting/landscape_plotter.py new file mode 100644 index 0000000..9c5552b --- /dev/null +++ b/src/pg_rad/plotting/landscape_plotter.py @@ -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." + ) From 4f72fe8ff488989b220afcada2738a0f289aab33 Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Thu, 12 Feb 2026 14:02:13 +0100 Subject: [PATCH 14/28] Add OutOfBoundsError --- src/pg_rad/exceptions/__init__.py | 4 ++-- src/pg_rad/exceptions/exceptions.py | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pg_rad/exceptions/__init__.py b/src/pg_rad/exceptions/__init__.py index 426b7e7..99b24a1 100644 --- a/src/pg_rad/exceptions/__init__.py +++ b/src/pg_rad/exceptions/__init__.py @@ -4,7 +4,7 @@ __ignore__ = ["logger"] from pg_rad.exceptions import exceptions from pg_rad.exceptions.exceptions import (ConvergenceError, DataLoadError, - InvalidCSVError,) + InvalidCSVError, OutOfBoundsError,) __all__ = ['ConvergenceError', 'DataLoadError', 'InvalidCSVError', - 'exceptions'] + 'OutOfBoundsError', 'exceptions'] diff --git a/src/pg_rad/exceptions/exceptions.py b/src/pg_rad/exceptions/exceptions.py index 60a1b34..dc8c3eb 100644 --- a/src/pg_rad/exceptions/exceptions.py +++ b/src/pg_rad/exceptions/exceptions.py @@ -8,3 +8,7 @@ class DataLoadError(Exception): class InvalidCSVError(DataLoadError): """Raised when a file is not a valid CSV.""" + + +class OutOfBoundsError(Exception): + """Raised when an object is attempted to be placed out of bounds.""" From 8274b5e37191056d5a015ee14f9892c3f9875d40 Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Thu, 12 Feb 2026 14:41:05 +0100 Subject: [PATCH 15/28] add size of path as attribute to Path --- src/pg_rad/path/path.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/pg_rad/path/path.py b/src/pg_rad/path/path.py index e750ba2..dca2582 100644 --- a/src/pg_rad/path/path.py +++ b/src/pg_rad/path/path.py @@ -70,6 +70,11 @@ class Path: ] self.z = z + self.size = ( + np.ceil(max(self.x_list)), + np.ceil(max(self.y_list)), + z + ) logger.debug("Path created.") From a95cca26d928823fa80562ff05ffa2e701527a19 Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Thu, 12 Feb 2026 14:43:41 +0100 Subject: [PATCH 16/28] Move landscape construction to LandscapeBuilder object --- src/pg_rad/landscape/__init__.py | 4 +- src/pg_rad/landscape/landscape.py | 154 ++++++++++++++++++------------ 2 files changed, 97 insertions(+), 61 deletions(-) diff --git a/src/pg_rad/landscape/__init__.py b/src/pg_rad/landscape/__init__.py index 9a0c85c..5bfc4d7 100644 --- a/src/pg_rad/landscape/__init__.py +++ b/src/pg_rad/landscape/__init__.py @@ -3,6 +3,6 @@ __ignore__ = ["logger"] from pg_rad.landscape import landscape -from pg_rad.landscape.landscape import (Landscape, create_landscape_from_path,) +from pg_rad.landscape.landscape import (Landscape, LandscapeBuilder,) -__all__ = ['Landscape', 'create_landscape_from_path', 'landscape'] +__all__ = ['Landscape', 'LandscapeBuilder', 'landscape'] diff --git a/src/pg_rad/landscape/landscape.py b/src/pg_rad/landscape/landscape.py index 6575087..861f9f2 100644 --- a/src/pg_rad/landscape/landscape.py +++ b/src/pg_rad/landscape/landscape.py @@ -1,12 +1,10 @@ import logging +from typing import Self -from matplotlib import pyplot as plt -from matplotlib.patches import Circle -import numpy as np -from numpy.typing import ArrayLike - -from pg_rad.path import Path +from pg_rad.dataloader import load_data +from pg_rad.exceptions import OutOfBoundsError 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__) @@ -28,8 +26,10 @@ class Landscape: Args: 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]. + 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]. Raises: TypeError: _description_ @@ -42,34 +42,6 @@ class Landscape: logger.debug("Landscape initialized.") - def add_sources(self, *sources: PointSource): - """Add one or more point sources to the world. - - Args: - *sources (pg_rad.sources.PointSource): One or more sources, - passed as Source1, Source2, ... - Raises: - ValueError: If the source is outside the boundaries of the - landscape. - """ - if not any( - (0 <= source.pos[0] <= self.world.shape[0] or - 0 <= source.pos[1] <= self.world.shape[1] or - 0 <= source.pos[2] <= self.world.shape[2]) - for source in sources - ): - raise ValueError("One or more sources are outside the landscape!") - - self.sources.extend(sources) - - def set_path(self, path: Path): - """ - Set the path in the landscape. - """ - if not isinstance(path, Path): - raise TypeError("path must be of type Path.") - self.path = path - def calculate_fluence_at(self, pos: tuple): total_phi = 0. for source in self.sources: @@ -85,30 +57,94 @@ class Landscape: return total_phi def calculate_fluence_along_path(self): - if self.path is None: - raise ValueError("Path is not set!") - - -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 + pass class LandscapeBuilder: def __init__(self): - pass \ No newline at end of file + self._path = None + self._point_sources = [] + self._size = None + self._air_density = None + + 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." + ) + + self._size = size + logger.debug("Size of the landscape has been updated.") + + return self + + def set_path_from_experimental_data( + self, + filename: str, + 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 + ) + + # 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)) + ) + + if needs_resize: + if not self._size: + logger.info("Landscape size set to path dimensions.") + else: + logger.warning( + "Path exceeds current landscape size. " + "Expanding landscape to accommodate it." + ) + + self.set_landscape_size(self._path.size) + + return self + + def set_point_sources(self, *sources): + """Add one or more point sources to the world. + + Args: + *sources (pg_rad.sources.PointSource): One or more sources, + passed as Source1, Source2, ... + Raises: + OutOfBoundsError: If any source is outside the boundaries of the + landscape. + """ + + if any( + any(p < 0 or p >= s for p, s in zip(source.pos, self._size)) + for source in sources + ): + raise OutOfBoundsError( + "One or more sources attempted to " + "be placed outside the landscape." + ) + + self._point_sources = sources + + def build(self): + return Landscape( + path=self._path, + point_sources=self._point_sources, + size=self._size, + air_density=self._air_density + ) From abc1195c917bbcd437151704762383cc43d05b7c Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Thu, 12 Feb 2026 14:45:24 +0100 Subject: [PATCH 17/28] Rewrite preset isotope --- src/pg_rad/isotopes/presets.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/pg_rad/isotopes/presets.py b/src/pg_rad/isotopes/presets.py index 5b8224b..9d07a65 100644 --- a/src/pg_rad/isotopes/presets.py +++ b/src/pg_rad/isotopes/presets.py @@ -1,4 +1,10 @@ from .isotope import Isotope -CS137 = Isotope("Cs-137", E=661.66, b=0.851) +class CS137(Isotope): + def __init__(self): + super.__init__( + name="Cs-137", + E=661.66, + b=0.851 + ) From 82331f3bbd75e49c4c3ad79eb868226ddfc0ef84 Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Thu, 12 Feb 2026 16:48:16 +0100 Subject: [PATCH 18/28] add default value for object position (0,0,0) --- src/pg_rad/objects/objects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pg_rad/objects/objects.py b/src/pg_rad/objects/objects.py index a610d9e..d3ec285 100644 --- a/src/pg_rad/objects/objects.py +++ b/src/pg_rad/objects/objects.py @@ -6,7 +6,7 @@ import numpy as np class BaseObject: def __init__( self, - pos: tuple[float, float, float], + pos: tuple[float, float, float] = (0, 0, 0), name: str = "Unnamed object", color: str = 'grey'): """ From 55258d772771542e70fb250ff8e36976923a797f Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Fri, 13 Feb 2026 14:46:51 +0100 Subject: [PATCH 19/28] rename pg_rad logging->logger to avoid import conflict with default logging library. --- src/pg_rad/logger/__init__.py | 5 +++++ src/pg_rad/{logging => logger}/logger.py | 9 +++++---- src/pg_rad/logging/__init__.py | 5 ----- 3 files changed, 10 insertions(+), 9 deletions(-) create mode 100644 src/pg_rad/logger/__init__.py rename src/pg_rad/{logging => logger}/logger.py (67%) delete mode 100644 src/pg_rad/logging/__init__.py diff --git a/src/pg_rad/logger/__init__.py b/src/pg_rad/logger/__init__.py new file mode 100644 index 0000000..57b4e3f --- /dev/null +++ b/src/pg_rad/logger/__init__.py @@ -0,0 +1,5 @@ +from pg_rad.logger import logger + +from pg_rad.logger.logger import (setup_logger,) + +__all__ = ['logger', 'setup_logger'] diff --git a/src/pg_rad/logging/logger.py b/src/pg_rad/logger/logger.py similarity index 67% rename from src/pg_rad/logging/logger.py rename to src/pg_rad/logger/logger.py index a460e20..370f35f 100644 --- a/src/pg_rad/logging/logger.py +++ b/src/pg_rad/logger/logger.py @@ -1,8 +1,10 @@ -import logging -import pathlib +import logging.config +from importlib.resources import files import yaml +from pg_rad.configs.filepaths import LOGGING_CONFIG + def setup_logger(log_level: str = "WARNING"): levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] @@ -10,8 +12,7 @@ def setup_logger(log_level: str = "WARNING"): if log_level not in levels: raise ValueError(f"Log level must be one of {levels}.") - base_dir = pathlib.Path(__file__).resolve().parent - config_file = base_dir / "configs" / "logging.yml" + config_file = files('pg_rad.configs').joinpath(LOGGING_CONFIG) with open(config_file) as f: config = yaml.safe_load(f) diff --git a/src/pg_rad/logging/__init__.py b/src/pg_rad/logging/__init__.py deleted file mode 100644 index 666f5de..0000000 --- a/src/pg_rad/logging/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from pg_rad.logging import logger - -from pg_rad.logging.logger import (setup_logger,) - -__all__ = ['logger', 'setup_logger'] From 26f96b06fe3001777aee0e008dfb10cd28423e9b Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Fri, 13 Feb 2026 14:50:02 +0100 Subject: [PATCH 20/28] code file paths/names in config folder for more centralized definition of filenames --- src/pg_rad/configs/filepaths.py | 3 +++ .../coordinates.csv => src/pg_rad/data/test_path_coords.csv | 0 src/pg_rad/physics/attenuation.py | 4 +++- 3 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 src/pg_rad/configs/filepaths.py rename tests/data/coordinates.csv => src/pg_rad/data/test_path_coords.csv (100%) diff --git a/src/pg_rad/configs/filepaths.py b/src/pg_rad/configs/filepaths.py new file mode 100644 index 0000000..e8103af --- /dev/null +++ b/src/pg_rad/configs/filepaths.py @@ -0,0 +1,3 @@ +ATTENUATION_TABLE = 'attenuation_table.csv' +TEST_EXP_DATA = 'test_path_coords.csv' +LOGGING_CONFIG = 'logging.yml' diff --git a/tests/data/coordinates.csv b/src/pg_rad/data/test_path_coords.csv similarity index 100% rename from tests/data/coordinates.csv rename to src/pg_rad/data/test_path_coords.csv diff --git a/src/pg_rad/physics/attenuation.py b/src/pg_rad/physics/attenuation.py index bdc70e3..9af70e9 100644 --- a/src/pg_rad/physics/attenuation.py +++ b/src/pg_rad/physics/attenuation.py @@ -3,11 +3,13 @@ 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.csv') + csv = files('pg_rad.data').joinpath(ATTENUATION_TABLE) data = read_csv(csv) x = data["energy_mev"].to_numpy() y = data["mu"].to_numpy() From 49a0dcd3012267d69c83a8b038690ffa5bc76c1c Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Fri, 13 Feb 2026 14:55:42 +0100 Subject: [PATCH 21/28] Improve isotopes and sources --- src/pg_rad/isotopes/presets.py | 2 +- src/pg_rad/objects/sources.py | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/pg_rad/isotopes/presets.py b/src/pg_rad/isotopes/presets.py index 9d07a65..d8135d6 100644 --- a/src/pg_rad/isotopes/presets.py +++ b/src/pg_rad/isotopes/presets.py @@ -3,7 +3,7 @@ from .isotope import Isotope class CS137(Isotope): def __init__(self): - super.__init__( + super().__init__( name="Cs-137", E=661.66, b=0.851 diff --git a/src/pg_rad/objects/sources.py b/src/pg_rad/objects/sources.py index ee47509..9361f1f 100644 --- a/src/pg_rad/objects/sources.py +++ b/src/pg_rad/objects/sources.py @@ -11,22 +11,23 @@ class PointSource(BaseObject): def __init__( self, - pos: tuple, activity: int, isotope: Isotope, + pos: tuple[float, float, float] = (0, 0, 0), name: str | None = None, - color: str = "red"): + color: str = 'red' + ): """A point source. Args: - pos (tuple): a position vector of length 3 (x,y,z). activity (int): Activity A in MBq. isotope (Isotope): The isotope. - name (str | None, optional): Can give the source a unique name. - Defaults to None, making the name sequential. - (Source-1, Source-2, etc.). - color (str, optional): Matplotlib compatible color string. - Defaults to "red". + pos (tuple[float, float, float], optional): + Position of the PointSource. + name (str, optional): Can give the source a unique name. + If not provided, point sources are sequentially + named: Source1, Source2, ... + color (str, optional): Matplotlib compatible color string """ self.id = PointSource._id_counter From e8bf6875634a022fe74cdec3169bd3bfcc58daea Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Fri, 13 Feb 2026 14:56:27 +0100 Subject: [PATCH 22/28] add default box height to ensure non-zero z dimension in the landscape --- src/pg_rad/path/path.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/pg_rad/path/path.py b/src/pg_rad/path/path.py index dca2582..5c9ade3 100644 --- a/src/pg_rad/path/path.py +++ b/src/pg_rad/path/path.py @@ -42,14 +42,18 @@ class Path: def __init__( self, 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. Args: coord_list (Sequence[tuple[float, float]]): List of x,y 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: @@ -73,7 +77,7 @@ class Path: self.size = ( np.ceil(max(self.x_list)), np.ceil(max(self.y_list)), - z + z + z_box ) logger.debug("Path created.") From a4fb4a7c5768daa1e9621ade9307fb6ea61db169 Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Fri, 13 Feb 2026 14:57:37 +0100 Subject: [PATCH 23/28] Add LandscapeDirector with a test case to build landscape using LandscapeBuilder --- src/pg_rad/landscape/__init__.py | 5 ++++- src/pg_rad/landscape/director.py | 23 +++++++++++++++++++++++ src/pg_rad/landscape/landscape.py | 23 +++++++++++++++++------ 3 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 src/pg_rad/landscape/director.py diff --git a/src/pg_rad/landscape/__init__.py b/src/pg_rad/landscape/__init__.py index 5bfc4d7..9dda6f9 100644 --- a/src/pg_rad/landscape/__init__.py +++ b/src/pg_rad/landscape/__init__.py @@ -1,8 +1,11 @@ # do not expose internal logger when running mkinit __ignore__ = ["logger"] +from pg_rad.landscape import director from pg_rad.landscape import landscape +from pg_rad.landscape.director import (LandscapeDirector,) from pg_rad.landscape.landscape import (Landscape, LandscapeBuilder,) -__all__ = ['Landscape', 'LandscapeBuilder', 'landscape'] +__all__ = ['Landscape', 'LandscapeBuilder', 'LandscapeDirector', 'director', + 'landscape'] diff --git a/src/pg_rad/landscape/director.py b/src/pg_rad/landscape/director.py new file mode 100644 index 0000000..e1fd9f4 --- /dev/null +++ b/src/pg_rad/landscape/director.py @@ -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=100, 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 diff --git a/src/pg_rad/landscape/landscape.py b/src/pg_rad/landscape/landscape.py index 861f9f2..a0e58d5 100644 --- a/src/pg_rad/landscape/landscape.py +++ b/src/pg_rad/landscape/landscape.py @@ -16,6 +16,7 @@ class Landscape: """ def __init__( self, + name: str, path: Path, point_sources: list[PointSource] = [], size: tuple[int, int, int] = [500, 500, 50], @@ -35,12 +36,13 @@ class Landscape: TypeError: _description_ """ + self.name = name self.path = path self.point_sources = point_sources self.size = size self.air_density = air_density - logger.debug("Landscape initialized.") + logger.debug(f"Landscape created: {self.name}") def calculate_fluence_at(self, pos: tuple): total_phi = 0. @@ -61,12 +63,15 @@ class Landscape: class LandscapeBuilder: - def __init__(self): + 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 @@ -95,7 +100,8 @@ class LandscapeBuilder: self._path = path_from_RT90( df=df, east_col=east_col, - north_col=north_col + north_col=north_col, + z=z ) # The size of the landscape will be updated if @@ -108,11 +114,12 @@ class LandscapeBuilder: if needs_resize: if not self._size: - logger.info("Landscape size set to path dimensions.") + logger.debug("Because no Landscape size was set, " + "it will now set to path dimensions.") else: logger.warning( "Path exceeds current landscape size. " - "Expanding landscape to accommodate it." + "Landscape size will be expanded to accommodate path." ) self.set_landscape_size(self._path.size) @@ -142,9 +149,13 @@ class LandscapeBuilder: self._point_sources = sources def build(self): - return Landscape( + landscape = Landscape( + name=self.name, path=self._path, point_sources=self._point_sources, size=self._size, air_density=self._air_density ) + + logger.info(f"Landscape built successfully: {landscape.name}") + return landscape From ac8c38592da00562b353390637311870f9095760 Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Fri, 13 Feb 2026 14:58:11 +0100 Subject: [PATCH 24/28] Add CLI entry point with --test flag for building test landscape using LandscapeDirector --- src/pg_rad/main.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/pg_rad/main.py diff --git a/src/pg_rad/main.py b/src/pg_rad/main.py new file mode 100644 index 0000000..19f4161 --- /dev/null +++ b/src/pg_rad/main.py @@ -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() From 845f006cd387d2ddd48f85af9f9ab2e8958b2efb Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Fri, 13 Feb 2026 15:02:53 +0100 Subject: [PATCH 25/28] Update pyproject.toml and README.md to reflect new conditions. --- README.md | 8 ++++++++ pyproject.toml | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/README.md b/README.md index d3a79ea..3b78136 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,14 @@ With the virtual environment activated, run: 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 can be run with `pytest` from the root directory of the repository. With the virtual environment activated, run: diff --git a/pyproject.toml b/pyproject.toml index 0f82ac2..07d3d1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ where = ["src"] [tool.setuptools.package-data] "pg_rad.data" = ["*.csv"] +"pg_rad.configs" = ["*.yml"] [project] name = "pg-rad" @@ -26,6 +27,9 @@ dependencies = [ license = "MIT" license-files = ["LICEN[CS]E*"] +[project.scripts] +pgrad = "pg_rad.main:main" + [project.urls] Homepage = "https://github.com/pim-n/pg-rad" Issues = "https://github.com/pim-n/pg-rad/issues" From 0db1fe062638c48d737f7ee11ef5eaefebbae220 Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Fri, 13 Feb 2026 16:01:07 +0100 Subject: [PATCH 26/28] manually remove detector imports (not implemented yet) --- src/pg_rad/objects/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pg_rad/objects/__init__.py b/src/pg_rad/objects/__init__.py index 2170a46..92edc21 100644 --- a/src/pg_rad/objects/__init__.py +++ b/src/pg_rad/objects/__init__.py @@ -1,13 +1,11 @@ # do not expose internal logger when running mkinit __ignore__ = ["logger"] -from pg_rad.objects import detectors from pg_rad.objects import objects from pg_rad.objects import sources -from pg_rad.objects.detectors import (Detector,) from pg_rad.objects.objects import (BaseObject,) from pg_rad.objects.sources import (PointSource,) -__all__ = ['BaseObject', 'Detector', 'PointSource', 'detectors', 'objects', +__all__ = ['BaseObject', 'PointSource', 'objects', 'sources'] From beb411d456e5ee03843e775e9bb595af2620870e Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Fri, 13 Feb 2026 16:07:42 +0100 Subject: [PATCH 27/28] update tests to reflect new Landscape design --- src/pg_rad/landscape/director.py | 2 +- src/pg_rad/landscape/landscape.py | 2 +- tests/test_attenuation_functions.py | 5 ++--- tests/test_fluence_rate.py | 11 ++--------- tests/test_sources.py | 8 ++++---- 5 files changed, 10 insertions(+), 18 deletions(-) diff --git a/src/pg_rad/landscape/director.py b/src/pg_rad/landscape/director.py index e1fd9f4..edddd7f 100644 --- a/src/pg_rad/landscape/director.py +++ b/src/pg_rad/landscape/director.py @@ -14,7 +14,7 @@ class LandscapeDirector: def build_test_landscape(self): fp = files('pg_rad.data').joinpath(TEST_EXP_DATA) - source = PointSource(activity=100, isotope=CS137(), pos=(0, 0, 0)) + 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) diff --git a/src/pg_rad/landscape/landscape.py b/src/pg_rad/landscape/landscape.py index a0e58d5..805081e 100644 --- a/src/pg_rad/landscape/landscape.py +++ b/src/pg_rad/landscape/landscape.py @@ -46,7 +46,7 @@ class Landscape: def calculate_fluence_at(self, pos: tuple): total_phi = 0. - for source in self.sources: + for source in self.point_sources: r = source.distance_to(pos) phi_source = phi_single_source( r=r, diff --git a/tests/test_attenuation_functions.py b/tests/test_attenuation_functions.py index ad9d5e4..385ace5 100644 --- a/tests/test_attenuation_functions.py +++ b/tests/test_attenuation_functions.py @@ -19,12 +19,11 @@ def test_exact_attenuation_retrieval(energy, mu): @pytest.mark.parametrize("energy,mu", [ - (0.662, 0.0778), - (1.25, 0.06) + (0.662, 0.0778) ]) def test_attenuation_interpolation(energy, mu): """ - Test Cs-137 and Co-60 mass attenuation coefficients. + Test retreival for Cs-137 mass attenuation coefficient. """ interp_mu = get_mass_attenuation_coeff(energy) assert pytest.approx(interp_mu, rel=1E-2) == mu diff --git a/tests/test_fluence_rate.py b/tests/test_fluence_rate.py index 710b2fb..c2615f6 100644 --- a/tests/test_fluence_rate.py +++ b/tests/test_fluence_rate.py @@ -2,9 +2,7 @@ from math import dist, exp, pi import pytest -from pg_rad.isotopes import CS137 -from pg_rad.landscape import Landscape -from pg_rad.objects import PointSource +from pg_rad.landscape import LandscapeDirector @pytest.fixture @@ -27,12 +25,7 @@ def phi_ref(): @pytest.fixture def test_landscape(): - landscape = Landscape() - source = PointSource( - pos=(0, 0, 0), - activity=100E9, - isotope=CS137) - landscape.add_sources(source) + landscape = LandscapeDirector().build_test_landscape() return landscape diff --git a/tests/test_sources.py b/tests/test_sources.py index 1ce84ee..39d3063 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -2,17 +2,17 @@ import numpy as np import pytest from pg_rad.objects import PointSource -from pg_rad.isotopes import Isotope +from pg_rad.isotopes import CS137 @pytest.fixture def test_sources(): - iso = Isotope("test", E=662, b=0) + iso = CS137() pos_a = np.random.rand(3) pos_b = np.random.rand(3) - a = PointSource(pos_a, activity=None, isotope=iso) - b = PointSource(pos_b, activity=None, isotope=iso) + a = PointSource(pos=pos_a, activity=None, isotope=iso) + b = PointSource(pos=pos_b, activity=None, isotope=iso) return pos_a, pos_b, a, b From 508cad474b365c3d97d0b49763d36d9e8048c767 Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Fri, 13 Feb 2026 16:13:55 +0100 Subject: [PATCH 28/28] Add scipy to requirements (due to interpolation of attenuation coeffs) --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 07d3d1f..a5be416 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,8 @@ dependencies = [ "matplotlib>=3.9.2", "numpy>=2", "pandas>=2.3.1", - "pyyaml>=6.0.2" + "pyyaml>=6.0.2", + "scipy>=1.15.0" ] license = "MIT" license-files = ["LICEN[CS]E*"] @@ -35,4 +36,5 @@ Homepage = "https://github.com/pim-n/pg-rad" Issues = "https://github.com/pim-n/pg-rad/issues" [project.optional-dependencies] + dev = ["pytest", "mkinit", "notebook", "mkdocs-material", "mkdocstrings-python", "mkdocs-jupyter"] \ No newline at end of file