From 1c8cc41e3c45d5868bf4c1350a2ace8c6e95e097 Mon Sep 17 00:00:00 2001 From: Pim Nelissen Date: Tue, 3 Mar 2026 21:01:51 +0100 Subject: [PATCH] Improve error handling. Add alignment feature for point sources --- src/pg_rad/exceptions/exceptions.py | 4 ++ src/pg_rad/inputparser/parser.py | 59 ++++++++++++++++++--------- src/pg_rad/inputparser/specs.py | 12 +++--- src/pg_rad/landscape/builder.py | 62 ++++++++++++++++++++++++++++- src/pg_rad/landscape/director.py | 2 +- 5 files changed, 113 insertions(+), 26 deletions(-) diff --git a/src/pg_rad/exceptions/exceptions.py b/src/pg_rad/exceptions/exceptions.py index 0b5a474..aac816d 100644 --- a/src/pg_rad/exceptions/exceptions.py +++ b/src/pg_rad/exceptions/exceptions.py @@ -31,3 +31,7 @@ class DimensionError(ValueError): class InvalidIsotopeError(ValueError): """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.""" diff --git a/src/pg_rad/inputparser/parser.py b/src/pg_rad/inputparser/parser.py index d95c603..5b5a111 100644 --- a/src/pg_rad/inputparser/parser.py +++ b/src/pg_rad/inputparser/parser.py @@ -4,7 +4,11 @@ from typing import Any, Dict, List, Union import yaml -from pg_rad.exceptions.exceptions import MissingConfigKeyError, DimensionError +from pg_rad.exceptions.exceptions import ( + MissingConfigKeyError, + DimensionError, + InvalidConfigValueError +) from pg_rad.configs import defaults from .specs import ( @@ -14,7 +18,7 @@ from .specs import ( PathSpec, ProceduralPathSpec, CSVPathSpec, - SourceSpec, + PointSourceSpec, AbsolutePointSourceSpec, RelativePointSourceSpec, DetectorSpec, @@ -98,19 +102,23 @@ class ConfigParser: def _parse_options(self) -> SimulationOptionsSpec: options = self.config.get("options", {}) - allowed = {"air_density", "seed"} + allowed = {"air_density_kg_per_m3", "seed"} self._warn_unknown_keys( section="options", provided=set(options.keys()), 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") if not isinstance(air_density, float) or air_density <= 0: raise ValueError( - "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 ( seed is not None or @@ -230,9 +238,9 @@ class ConfigParser: def _is_turn(segment_type: str) -> bool: 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", {}) - specs: List[SourceSpec] = [] + specs: List[PointSourceSpec] = [] for name, params in source_dict.items(): @@ -245,7 +253,7 @@ class ConfigParser: isotope = params.get("isotope") if not isinstance(activity, int | float) or activity <= 0: - raise ValueError( + raise InvalidConfigValueError( f"sources.{name}.activity_MBq must be positive value " "in MegaBequerels." ) @@ -270,6 +278,16 @@ class ConfigParser: ) 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( RelativePointSourceSpec( name=name, @@ -278,12 +296,13 @@ class ConfigParser: along_path=float(position["along_path"]), dist_from_path=float(position["dist_from_path"]), side=position["side"], - z=position.get("z", defaults.DEFAULT_SOURCE_HEIGHT) + z=position.get("z", defaults.DEFAULT_SOURCE_HEIGHT), + alignment=alignment ) ) else: - raise ValueError( + raise InvalidConfigValueError( f"Invalid position format for source '{name}'." ) @@ -295,7 +314,7 @@ class ConfigParser: missing = required - det_dict.keys() if missing: - raise MissingConfigKeyError(missing) + raise MissingConfigKeyError("detector", missing) name = det_dict.get("name") is_isotropic = det_dict.get("is_isotropic") @@ -303,19 +322,23 @@ class ConfigParser: default_detectors = defaults.DETECTOR_EFFICIENCIES - if eff is None and name in default_detectors.keys(): + if name in default_detectors.keys() and not eff: eff = default_detectors[name] - elif eff is not None: + elif eff: pass else: raise ValueError( - f"The detector {name} is not in the library, and no " - "efficiency was defined. Either specify detector efficiency " - "or choose one from the following list: " - f"{default_detectors.keys()}" + f"The detector {name} not found in library. Either " + f"specify {name}.efficiency or " + "choose a detector from the following list: " + f"{default_detectors.keys()}." ) - return DetectorSpec(name=name, eff=eff, is_isotropic=is_isotropic) + return DetectorSpec( + name=name, + efficiency=eff, + is_isotropic=is_isotropic + ) def _warn_unknown_keys(self, section: str, provided: set, allowed: set): unknown = provided - allowed diff --git a/src/pg_rad/inputparser/specs.py b/src/pg_rad/inputparser/specs.py index 7bb3631..6f74f14 100644 --- a/src/pg_rad/inputparser/specs.py +++ b/src/pg_rad/inputparser/specs.py @@ -1,5 +1,6 @@ from abc import ABC from dataclasses import dataclass +from typing import Literal @dataclass @@ -42,31 +43,32 @@ class CSVPathSpec(PathSpec): @dataclass -class SourceSpec(ABC): +class PointSourceSpec(ABC): activity_MBq: float isotope: str name: str @dataclass -class AbsolutePointSourceSpec(SourceSpec): +class AbsolutePointSourceSpec(PointSourceSpec): x: float y: float z: float @dataclass -class RelativePointSourceSpec(SourceSpec): +class RelativePointSourceSpec(PointSourceSpec): along_path: float dist_from_path: float side: str z: float + alignment: Literal["best", "worst"] | None @dataclass class DetectorSpec: name: str - eff: float | None + efficiency: float is_isotropic: bool @@ -76,5 +78,5 @@ class SimulationSpec: runtime: RuntimeSpec options: SimulationOptionsSpec path: PathSpec - point_sources: list[SourceSpec] + point_sources: list[PointSourceSpec] detector: DetectorSpec diff --git a/src/pg_rad/landscape/builder.py b/src/pg_rad/landscape/builder.py index 7fca241..2ab78ef 100644 --- a/src/pg_rad/landscape/builder.py +++ b/src/pg_rad/landscape/builder.py @@ -1,5 +1,7 @@ import logging -from typing import Self +from typing import Literal, Self + +import numpy as np from .landscape import Landscape from pg_rad.dataloader.dataloader import load_data @@ -113,11 +115,25 @@ class LandscapeBuilder: pos = (s.x, s.y, s.z) elif isinstance(s, RelativePointSourceSpec): 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( x_list=path.x_list, y_list=path.y_list, path_z=path.z, - along_path=s.along_path, + along_path=along_path, side=s.side, dist_from_path=s.dist_from_path) if any( @@ -158,6 +174,48 @@ class LandscapeBuilder: max_size = max(self._path.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): landscape = Landscape( name=self.name, diff --git a/src/pg_rad/landscape/director.py b/src/pg_rad/landscape/director.py index cd163b4..f8d7e68 100644 --- a/src/pg_rad/landscape/director.py +++ b/src/pg_rad/landscape/director.py @@ -22,7 +22,7 @@ class LandscapeDirector: def build_test_landscape(): fp = files('pg_rad.data').joinpath(TEST_EXP_DATA) source = PointSource( - activity_MBq=100E9, + activity_MBq=100E6, isotope="CS137", position=(0, 0, 0) )