mirror of
https://github.com/pim-n/pg-rad
synced 2026-03-23 21:58:12 +01:00
Compare commits
55 Commits
main
...
845f006cd3
| Author | SHA1 | Date | |
|---|---|---|---|
| 845f006cd3 | |||
| ac8c38592d | |||
| a4fb4a7c57 | |||
| e8bf687563 | |||
| 49a0dcd301 | |||
| 26f96b06fe | |||
| 55258d7727 | |||
| 82331f3bbd | |||
| abc1195c91 | |||
| a95cca26d9 | |||
| 8274b5e371 | |||
| 4f72fe8ff4 | |||
| 9b0c77d254 | |||
| 225287c46a | |||
| 6ceffb4361 | |||
| 08299724e1 | |||
| 3aff764075 | |||
| 05a71c31a8 | |||
| f5cc5218e6 | |||
| 3f7395ed70 | |||
| d9e3f2a209 | |||
| 0971c2bab9 | |||
| 9c1b97d912 | |||
| a1acf95004 | |||
| 016ea6b783 | |||
| 2b85a07aa0 | |||
| d6d9fa6f92 | |||
| 521e5a556e | |||
| 0c81b4df89 | |||
| 2c436c52ad | |||
| d4b67be775 | |||
| c2ddc5bfe2 | |||
| 08a056a32d | |||
| f37db46037 | |||
| c23ea40ec6 | |||
| dcc3be1c22 | |||
| 52b2eaaeb5 | |||
| ead96eb723 | |||
| f5a126b927 | |||
| c1b827c871 | |||
| 85f80ace97 | |||
| a4e965c9d6 | |||
| 15b7e7e65e | |||
| db6f859a60 | |||
| 14e49e63aa | |||
| 2551f854d6 | |||
| caec70b39b | |||
| 21ea25a3d8 | |||
| d7c670d344 | |||
| 85f306a469 | |||
| c459b732bb | |||
| 4632fe35c9 | |||
| 7cfbbb8792 | |||
| 7290e241a2 | |||
| 38318ad822 |
2
.github/workflows/ci-docs.yml
vendored
2
.github/workflows/ci-docs.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
|||||||
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
|
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: 3.x
|
python-version: 3.12.9
|
||||||
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,3 +1,6 @@
|
|||||||
|
# Custom
|
||||||
|
dev-tools/
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[codz]
|
*.py[codz]
|
||||||
|
|||||||
28
README.md
28
README.md
@ -23,13 +23,35 @@ With Python verion `>=3.12.4` and `<3.13`, create a virtual environment and inst
|
|||||||
```
|
```
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
(venv) pip install -e .[dev]
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
Tests can be run with `pytest` from the root directory of the repository.
|
Tests can be run with `pytest` from the root directory of the repository. With the virtual environment activated, run:
|
||||||
|
|
||||||
```
|
```
|
||||||
(venv) pytest
|
pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Local viewing of documentation
|
||||||
|
|
||||||
|
PG-RAD uses [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/) for generating documentation. It can be locally viewed by (in the venv) running:
|
||||||
|
```
|
||||||
|
mkdocs serve
|
||||||
|
```
|
||||||
|
|
||||||
|
where you can add the `--livereload` flag to automatically update the documentation as you write to the Markdown files.
|
||||||
|
|||||||
253
demo/demo.ipynb
253
demo/demo.ipynb
File diff suppressed because one or more lines are too long
18
docs/javascripts/mathjax.js
Normal file
18
docs/javascripts/mathjax.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
window.MathJax = {
|
||||||
|
tex: {
|
||||||
|
inlineMath: [['$', '$'], ["\\(", "\\)"]],
|
||||||
|
displayMath: [['$$', '$$'], ["\\[", "\\]"]],
|
||||||
|
processEscapes: true,
|
||||||
|
processEnvironments: true
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
processHtmlClass: "arithmatex"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document$.subscribe(() => {
|
||||||
|
MathJax.startup.output.clearCache()
|
||||||
|
MathJax.typesetClear()
|
||||||
|
MathJax.texReset()
|
||||||
|
MathJax.typesetPromise()
|
||||||
|
})
|
||||||
272
docs/pg-rad-in-python.ipynb
Normal file
272
docs/pg-rad-in-python.ipynb
Normal file
File diff suppressed because one or more lines are too long
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
title: Using PG-RAD as a module
|
|
||||||
---
|
|
||||||
|
|
||||||
Consult the API documentation in the side bar.
|
|
||||||
@ -28,8 +28,16 @@ markdown_extensions:
|
|||||||
- pymdownx.inlinehilite
|
- pymdownx.inlinehilite
|
||||||
- pymdownx.snippets
|
- pymdownx.snippets
|
||||||
- pymdownx.superfences
|
- pymdownx.superfences
|
||||||
|
- pymdownx.arithmatex:
|
||||||
|
generic: true
|
||||||
|
|
||||||
|
extra_javascript:
|
||||||
|
- javascripts/mathjax.js
|
||||||
|
- https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js
|
||||||
|
|
||||||
plugins:
|
plugins:
|
||||||
|
- mkdocs-jupyter:
|
||||||
|
execute: false
|
||||||
- mkdocstrings:
|
- mkdocstrings:
|
||||||
enabled: !ENV [ENABLE_MKDOCSTRINGS, true]
|
enabled: !ENV [ENABLE_MKDOCSTRINGS, true]
|
||||||
default_handler: python
|
default_handler: python
|
||||||
|
|||||||
@ -5,9 +5,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
"pg_rad.data" = ["*.csv"]
|
||||||
|
"pg_rad.configs" = ["*.yml"]
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pg-rad"
|
name = "pg-rad"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
authors = [
|
authors = [
|
||||||
{ name="Pim Nelissen", email="pi0274ne-s@student.lu.se" },
|
{ name="Pim Nelissen", email="pi0274ne-s@student.lu.se" },
|
||||||
]
|
]
|
||||||
@ -18,15 +22,17 @@ dependencies = [
|
|||||||
"matplotlib>=3.9.2",
|
"matplotlib>=3.9.2",
|
||||||
"numpy>=2",
|
"numpy>=2",
|
||||||
"pandas>=2.3.1",
|
"pandas>=2.3.1",
|
||||||
"piecewise_regression==1.5.0",
|
|
||||||
"pyyaml>=6.0.2"
|
"pyyaml>=6.0.2"
|
||||||
]
|
]
|
||||||
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", "notebook", "mkdocs-material", "mkdocstrings-python"]
|
dev = ["pytest", "mkinit", "notebook", "mkdocs-material", "mkdocstrings-python", "mkdocs-jupyter"]
|
||||||
3
src/pg_rad/configs/filepaths.py
Normal file
3
src/pg_rad/configs/filepaths.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
ATTENUATION_TABLE = 'attenuation_table.csv'
|
||||||
|
TEST_EXP_DATA = 'test_path_coords.csv'
|
||||||
|
LOGGING_CONFIG = 'logging.yml'
|
||||||
1
src/pg_rad/data/__init__.py
Normal file
1
src/pg_rad/data/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
__all__ = []
|
||||||
39
src/pg_rad/data/attenuation_table.csv
Normal file
39
src/pg_rad/data/attenuation_table.csv
Normal 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
|
||||||
|
8
src/pg_rad/dataloader/__init__.py
Normal file
8
src/pg_rad/dataloader/__init__.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# 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']
|
||||||
@ -1,12 +1,14 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
from pg_rad.logger import setup_logger
|
|
||||||
from pg_rad.exceptions import DataLoadError, InvalidCSVError
|
from pg_rad.exceptions import DataLoadError, InvalidCSVError
|
||||||
|
|
||||||
logger = setup_logger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def load_data(filename: str) -> pd.DataFrame:
|
def load_data(filename: str) -> pd.DataFrame:
|
||||||
logger.debug(f"Attempting to load data from {filename}")
|
logger.debug(f"Attempting to load file: {filename}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
df = pd.read_csv(filename, delimiter=',')
|
df = pd.read_csv(filename, delimiter=',')
|
||||||
@ -23,4 +25,5 @@ def load_data(filename: str) -> pd.DataFrame:
|
|||||||
logger.exception(f"Unexpected error while loading {filename}")
|
logger.exception(f"Unexpected error while loading {filename}")
|
||||||
raise DataLoadError("Unexpected error while loading data") from e
|
raise DataLoadError("Unexpected error while loading data") from e
|
||||||
|
|
||||||
|
logger.debug(f"File loaded: {filename}")
|
||||||
return df
|
return df
|
||||||
10
src/pg_rad/exceptions/__init__.py
Normal file
10
src/pg_rad/exceptions/__init__.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# 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']
|
||||||
@ -1,8 +1,14 @@
|
|||||||
class ConvergenceError(Exception):
|
class ConvergenceError(Exception):
|
||||||
"""Raised when an algorithm fails to converge."""
|
"""Raised when an algorithm fails to converge."""
|
||||||
|
|
||||||
|
|
||||||
class DataLoadError(Exception):
|
class DataLoadError(Exception):
|
||||||
"""Base class for data loading errors."""
|
"""Base class for data loading errors."""
|
||||||
|
|
||||||
|
|
||||||
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."""
|
||||||
10
src/pg_rad/isotopes/__init__.py
Normal file
10
src/pg_rad/isotopes/__init__.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# do not expose internal logger when running mkinit
|
||||||
|
__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__ = ['CS137', 'Isotope', 'isotope', 'presets']
|
||||||
@ -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)
|
||||||
10
src/pg_rad/isotopes/presets.py
Normal file
10
src/pg_rad/isotopes/presets.py
Normal 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
|
||||||
|
)
|
||||||
@ -1,126 +0,0 @@
|
|||||||
from matplotlib import pyplot as plt
|
|
||||||
from matplotlib.patches import Circle
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from pg_rad.path import Path
|
|
||||||
from pg_rad.sources import PointSource
|
|
||||||
|
|
||||||
class Landscape:
|
|
||||||
"""A generic Landscape that can contain a Path and sources.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
air_density (float, optional): Air density in 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__(
|
|
||||||
self,
|
|
||||||
air_density: float = 1.243,
|
|
||||||
size: int | tuple[int, int, int] = 500,
|
|
||||||
scale = 'meters',
|
|
||||||
):
|
|
||||||
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 an integer or a tuple of 3 integers.")
|
|
||||||
|
|
||||||
self.air_density = air_density
|
|
||||||
self.scale = scale
|
|
||||||
|
|
||||||
self.path: Path = None
|
|
||||||
self.sources: list[PointSource] = []
|
|
||||||
|
|
||||||
def plot(self, z = 0):
|
|
||||||
"""Plot a slice of the world at a height `z`.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
z (int, optional): Height of slice. Defaults to 0.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
fig, ax: Matplotlib figure objects.
|
|
||||||
"""
|
|
||||||
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}]")
|
|
||||||
|
|
||||||
if not self.path == 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
|
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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)
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
self.path = path
|
|
||||||
|
|
||||||
def create_landscape_from_path(path: Path, max_z = 500):
|
|
||||||
"""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 500 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
|
|
||||||
11
src/pg_rad/landscape/__init__.py
Normal file
11
src/pg_rad/landscape/__init__.py
Normal file
@ -0,0 +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', 'LandscapeDirector', 'director',
|
||||||
|
'landscape']
|
||||||
23
src/pg_rad/landscape/director.py
Normal file
23
src/pg_rad/landscape/director.py
Normal 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=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
|
||||||
161
src/pg_rad/landscape/landscape.py
Normal file
161
src/pg_rad/landscape/landscape.py
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Self
|
||||||
|
|
||||||
|
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__)
|
||||||
|
|
||||||
|
|
||||||
|
class Landscape:
|
||||||
|
"""
|
||||||
|
A generic Landscape that can contain a Path and sources.
|
||||||
|
"""
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
path: Path,
|
||||||
|
point_sources: list[PointSource] = [],
|
||||||
|
size: tuple[int, int, int] = [500, 500, 50],
|
||||||
|
air_density: float = 1.243
|
||||||
|
):
|
||||||
|
"""Initialize a 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].
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError: _description_
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.name = name
|
||||||
|
self.path = path
|
||||||
|
self.point_sources = point_sources
|
||||||
|
self.size = size
|
||||||
|
self.air_density = air_density
|
||||||
|
|
||||||
|
logger.debug(f"Landscape created: {self.name}")
|
||||||
|
|
||||||
|
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):
|
||||||
|
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."
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
z=z
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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.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."
|
||||||
|
)
|
||||||
|
|
||||||
|
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):
|
||||||
|
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
|
||||||
@ -1,17 +0,0 @@
|
|||||||
import logging
|
|
||||||
import logging.config
|
|
||||||
import pathlib
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
def setup_logger(name):
|
|
||||||
logger = logging.getLogger(name)
|
|
||||||
|
|
||||||
base_dir = pathlib.Path(__file__).resolve().parent
|
|
||||||
config_file = base_dir / "configs" / "logging.yml"
|
|
||||||
|
|
||||||
with open(config_file) as f:
|
|
||||||
config = yaml.safe_load(f)
|
|
||||||
|
|
||||||
logging.config.dictConfig(config)
|
|
||||||
return logger
|
|
||||||
5
src/pg_rad/logger/__init__.py
Normal file
5
src/pg_rad/logger/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from pg_rad.logger import logger
|
||||||
|
|
||||||
|
from pg_rad.logger.logger import (setup_logger,)
|
||||||
|
|
||||||
|
__all__ = ['logger', 'setup_logger']
|
||||||
22
src/pg_rad/logger/logger.py
Normal file
22
src/pg_rad/logger/logger.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
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"]
|
||||||
|
|
||||||
|
if log_level not in levels:
|
||||||
|
raise ValueError(f"Log level must be one of {levels}.")
|
||||||
|
|
||||||
|
config_file = files('pg_rad.configs').joinpath(LOGGING_CONFIG)
|
||||||
|
|
||||||
|
with open(config_file) as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
|
|
||||||
|
config["loggers"]["root"]["level"] = log_level
|
||||||
|
|
||||||
|
logging.config.dictConfig(config)
|
||||||
33
src/pg_rad/main.py
Normal file
33
src/pg_rad/main.py
Normal 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()
|
||||||
@ -1,33 +0,0 @@
|
|||||||
import math
|
|
||||||
from typing import Self
|
|
||||||
|
|
||||||
class Object:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
x: float,
|
|
||||||
y: float,
|
|
||||||
z: float,
|
|
||||||
name: str = "Unnamed object",
|
|
||||||
color: str = 'grey'):
|
|
||||||
"""
|
|
||||||
A generic object.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
x (float): X coordinate.
|
|
||||||
y (float): Y coordinate.
|
|
||||||
z (float): Z coordinate.
|
|
||||||
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
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
13
src/pg_rad/objects/__init__.py
Normal file
13
src/pg_rad/objects/__init__.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# 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',
|
||||||
|
'sources']
|
||||||
42
src/pg_rad/objects/objects.py
Normal file
42
src/pg_rad/objects/objects.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
from typing import Self
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
class BaseObject:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
pos: tuple[float, float, float] = (0, 0, 0),
|
||||||
|
name: str = "Unnamed object",
|
||||||
|
color: str = 'grey'):
|
||||||
|
"""
|
||||||
|
A generic object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
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".
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 | 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
|
||||||
53
src/pg_rad/objects/sources.py
Normal file
53
src/pg_rad/objects/sources.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from .objects import BaseObject
|
||||||
|
from pg_rad.isotopes import Isotope
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PointSource(BaseObject):
|
||||||
|
_id_counter = 1
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
activity: int,
|
||||||
|
isotope: Isotope,
|
||||||
|
pos: tuple[float, float, float] = (0, 0, 0),
|
||||||
|
name: str | None = None,
|
||||||
|
color: str = 'red'
|
||||||
|
):
|
||||||
|
"""A point source.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
activity (int): Activity A in MBq.
|
||||||
|
isotope (Isotope): The isotope.
|
||||||
|
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
|
||||||
|
PointSource._id_counter += 1
|
||||||
|
|
||||||
|
# default name derived from ID if not provided
|
||||||
|
if name is None:
|
||||||
|
name = f"Source {self.id}"
|
||||||
|
|
||||||
|
super().__init__(pos, name, color)
|
||||||
|
|
||||||
|
self.activity = activity
|
||||||
|
self.isotope = isotope
|
||||||
|
|
||||||
|
logger.debug(f"Source created: {self.name}")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
repr_str = (f"PointSource(name={self.name}, "
|
||||||
|
+ f"pos={(self.x, self.y, self.z)}, "
|
||||||
|
+ f"A={self.activity} MBq), "
|
||||||
|
+ f"isotope={self.isotope.name}.")
|
||||||
|
|
||||||
|
return repr_str
|
||||||
@ -1,187 +0,0 @@
|
|||||||
from collections.abc import Sequence
|
|
||||||
import math
|
|
||||||
|
|
||||||
from matplotlib import pyplot as plt
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
import piecewise_regression
|
|
||||||
|
|
||||||
from pg_rad.exceptions import ConvergenceError
|
|
||||||
from pg_rad.logger import setup_logger
|
|
||||||
|
|
||||||
logger = setup_logger(__name__)
|
|
||||||
|
|
||||||
class PathSegment:
|
|
||||||
def __init__(self, a: tuple[float, float], b: tuple[float, float]):
|
|
||||||
"""A straight Segment of a Path, from (x_a, y_a) to (x_b, y_b).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
a (tuple[float, float]): The starting point (x_a, y_a).
|
|
||||||
b (tuple[float, float]): The final point (x_b, y_b).
|
|
||||||
"""
|
|
||||||
self.a = a
|
|
||||||
self.b = b
|
|
||||||
|
|
||||||
def get_length(self) -> float:
|
|
||||||
return math.dist(self.a, self.b)
|
|
||||||
|
|
||||||
length = property(get_length)
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return str(f"({self.a}, {self.b})")
|
|
||||||
|
|
||||||
def __getitem__(self, index) -> float:
|
|
||||||
if index == 0:
|
|
||||||
return self.a
|
|
||||||
elif index == 1:
|
|
||||||
return self.b
|
|
||||||
else:
|
|
||||||
raise IndexError
|
|
||||||
|
|
||||||
class Path:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
coord_list: Sequence[tuple[float, float]],
|
|
||||||
z: float = 0,
|
|
||||||
path_simplify = False
|
|
||||||
):
|
|
||||||
"""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.
|
|
||||||
path_simplify (bool, optional): Whether to pg_rad.path.simplify_path(). Defaults to False.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if len(coord_list) < 2:
|
|
||||||
raise ValueError("Must provide at least two coordinates as a list of tuples, e.g. [(x1, y1), (x2, y2)]")
|
|
||||||
|
|
||||||
x, y = tuple(zip(*coord_list))
|
|
||||||
|
|
||||||
if path_simplify:
|
|
||||||
try:
|
|
||||||
x, y = simplify_path(list(x), list(y))
|
|
||||||
except ConvergenceError:
|
|
||||||
logger.warning("Continuing without simplifying path.")
|
|
||||||
|
|
||||||
self.x_list = list(x)
|
|
||||||
self.y_list = list(y)
|
|
||||||
|
|
||||||
coord_list = list(zip(x, y))
|
|
||||||
|
|
||||||
self.segments = [PathSegment(i, ip1) for i, ip1 in zip(coord_list, coord_list[1:])]
|
|
||||||
|
|
||||||
self.z = z
|
|
||||||
|
|
||||||
def get_length(self) -> float:
|
|
||||||
return sum([s.length for s in self.segments])
|
|
||||||
|
|
||||||
length = property(get_length)
|
|
||||||
|
|
||||||
def __getitem__(self, index) -> PathSegment:
|
|
||||||
return self.segments[index]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return str([str(s) for s in self.segments])
|
|
||||||
|
|
||||||
def plot(self, **kwargs):
|
|
||||||
"""
|
|
||||||
Plot the path using matplotlib.
|
|
||||||
"""
|
|
||||||
plt.plot(self.x_list, self.y_list, **kwargs)
|
|
||||||
|
|
||||||
def simplify_path(
|
|
||||||
x: Sequence[float],
|
|
||||||
y: Sequence[float],
|
|
||||||
keep_endpoints_equal: bool = False,
|
|
||||||
n_breakpoints: int = 3
|
|
||||||
):
|
|
||||||
"""From full resolution x and y arrays, return a piecewise linearly approximated/simplified pair of x and y arrays.
|
|
||||||
|
|
||||||
This function uses the `piecewise_regression` package. From a full set of
|
|
||||||
coordinate pairs, the function fits linear sections, automatically finding
|
|
||||||
the number of breakpoints and their positions.
|
|
||||||
|
|
||||||
On why the default value of n_breakpoints is 3, from the `piecewise_regression`
|
|
||||||
docs:
|
|
||||||
"If you do not have (or do not want to use) initial guesses for the number
|
|
||||||
of breakpoints, you can set it to n_breakpoints=3, and the algorithm will
|
|
||||||
randomly generate start_values. With a 50% chance, the bootstrap restarting
|
|
||||||
algorithm will either use the best currently converged breakpoints or
|
|
||||||
randomly generate new start_values, escaping the local optima in two ways in
|
|
||||||
order to find better global optima."
|
|
||||||
|
|
||||||
Args:
|
|
||||||
x (Sequence[float]): Full list of x coordinates.
|
|
||||||
y (Sequence[float]): Full list of y coordinates.
|
|
||||||
keep_endpoints_equal (bool, optional): Whether or not to force start
|
|
||||||
and end to be exactly equal to the original. This will worsen the linear
|
|
||||||
approximation at the beginning and end of path. Defaults to False.
|
|
||||||
n_breakpoints (int, optional): Number of breakpoints. Defaults to 3.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
x (list[float]): Reduced list of x coordinates.
|
|
||||||
y (list[float]): Reduced list of y coordinates.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ConvergenceError: If the fitting algorithm failed to simplify the path.
|
|
||||||
|
|
||||||
Reference:
|
|
||||||
Pilgrim, C., (2021). piecewise-regression (aka segmented regression) in Python. Journal of Open Source Software, 6(68), 3859, https://doi.org/10.21105/joss.03859.
|
|
||||||
"""
|
|
||||||
|
|
||||||
logger.debug(f"Attempting piecewise regression on path.")
|
|
||||||
|
|
||||||
pw_fit = piecewise_regression.Fit(x, y, n_breakpoints=n_breakpoints)
|
|
||||||
pw_res = pw_fit.get_results()
|
|
||||||
|
|
||||||
if pw_res == None:
|
|
||||||
logger.error("Piecewise regression failed to converge.")
|
|
||||||
raise ConvergenceError("Piecewise regression failed to converge.")
|
|
||||||
|
|
||||||
est = pw_res['estimates']
|
|
||||||
|
|
||||||
# extract and sort breakpoints
|
|
||||||
breakpoints_x = sorted(
|
|
||||||
v['estimate'] for k, v in est.items() if k.startswith('breakpoint')
|
|
||||||
)
|
|
||||||
|
|
||||||
x_points = [x[0]] + breakpoints_x + [x[-1]]
|
|
||||||
|
|
||||||
y_points = pw_fit.predict(x_points)
|
|
||||||
|
|
||||||
if keep_endpoints_equal:
|
|
||||||
logger.debug("Forcing endpoint equality.")
|
|
||||||
y_points[0] = y[0]
|
|
||||||
y_points[-1] = y[-1]
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Piecewise regression reduced path from {len(x)-1} to {len(x_points)-1} segments."
|
|
||||||
)
|
|
||||||
|
|
||||||
return x_points, y_points
|
|
||||||
|
|
||||||
def path_from_RT90(
|
|
||||||
df: pd.DataFrame,
|
|
||||||
east_col: str = "East",
|
|
||||||
north_col: str = "North",
|
|
||||||
**kwargs
|
|
||||||
) -> Path:
|
|
||||||
"""Construct a path from East and North formatted coordinates (RT90) in a Pandas DataFrame.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
df (pandas.DataFrame): DataFrame containing at least the two columns noted in the cols argument.
|
|
||||||
east_col (str): The column name for the East coordinates.
|
|
||||||
north_col (str): The column name for the North coordinates.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path: A Path object built from the aquisition coordinates in the DataFrame.
|
|
||||||
"""
|
|
||||||
|
|
||||||
east_arr = np.array(df[east_col]) - min(df[east_col])
|
|
||||||
north_arr = np.array(df[north_col]) - min(df[north_col])
|
|
||||||
|
|
||||||
coord_pairs = list(zip(east_arr, north_arr))
|
|
||||||
|
|
||||||
path = Path(coord_pairs, **kwargs)
|
|
||||||
return path
|
|
||||||
8
src/pg_rad/path/__init__.py
Normal file
8
src/pg_rad/path/__init__.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# 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']
|
||||||
130
src/pg_rad/path/path.py
Normal file
130
src/pg_rad/path/path.py
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
from collections.abc import Sequence
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
|
||||||
|
from matplotlib import pyplot as plt
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PathSegment:
|
||||||
|
def __init__(self, a: tuple[float, float], b: tuple[float, float]):
|
||||||
|
"""A straight Segment of a Path, from (x_a, y_a) to (x_b, y_b).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
a (tuple[float, float]): The starting point (x_a, y_a).
|
||||||
|
b (tuple[float, float]): The final point (x_b, y_b).
|
||||||
|
"""
|
||||||
|
self.a = a
|
||||||
|
self.b = b
|
||||||
|
|
||||||
|
def get_length(self) -> float:
|
||||||
|
return math.dist(self.a, self.b)
|
||||||
|
|
||||||
|
length = property(get_length)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return str(f"({self.a}, {self.b})")
|
||||||
|
|
||||||
|
def __getitem__(self, index) -> float:
|
||||||
|
if index == 0:
|
||||||
|
return self.a
|
||||||
|
elif index == 1:
|
||||||
|
return self.b
|
||||||
|
else:
|
||||||
|
raise IndexError
|
||||||
|
|
||||||
|
|
||||||
|
class Path:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coord_list: Sequence[tuple[float, float]],
|
||||||
|
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): 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:
|
||||||
|
raise ValueError("Must provide at least two coordinates as a \
|
||||||
|
of tuples, e.g. [(x1, y1), (x2, y2)]")
|
||||||
|
|
||||||
|
x, y = tuple(zip(*coord_list))
|
||||||
|
|
||||||
|
self.x_list = list(x)
|
||||||
|
self.y_list = list(y)
|
||||||
|
|
||||||
|
coord_list = list(zip(x, y))
|
||||||
|
|
||||||
|
self.segments = [
|
||||||
|
PathSegment(i, ip1)
|
||||||
|
for i, ip1 in
|
||||||
|
zip(coord_list, coord_list[1:])
|
||||||
|
]
|
||||||
|
|
||||||
|
self.z = z
|
||||||
|
self.size = (
|
||||||
|
np.ceil(max(self.x_list)),
|
||||||
|
np.ceil(max(self.y_list)),
|
||||||
|
z + z_box
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug("Path created.")
|
||||||
|
|
||||||
|
def get_length(self) -> float:
|
||||||
|
return sum([s.length for s in self.segments])
|
||||||
|
|
||||||
|
length = property(get_length)
|
||||||
|
|
||||||
|
def __getitem__(self, index) -> PathSegment:
|
||||||
|
return self.segments[index]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return str([str(s) for s in self.segments])
|
||||||
|
|
||||||
|
def plot(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Plot the path using matplotlib.
|
||||||
|
"""
|
||||||
|
plt.plot(self.x_list, self.y_list, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def path_from_RT90(
|
||||||
|
df: pd.DataFrame,
|
||||||
|
east_col: str = "East",
|
||||||
|
north_col: str = "North",
|
||||||
|
**kwargs
|
||||||
|
) -> Path:
|
||||||
|
"""Construct a path from East and North formatted coordinates (RT90)
|
||||||
|
in a Pandas DataFrame.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
df (pandas.DataFrame): DataFrame containing at least the two columns
|
||||||
|
noted in the cols argument.
|
||||||
|
east_col (str): The column name for the East coordinates.
|
||||||
|
north_col (str): The column name for the North coordinates.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path: A Path object built from the aquisition coordinates in the
|
||||||
|
DataFrame.
|
||||||
|
"""
|
||||||
|
|
||||||
|
east_arr = np.array(df[east_col]) - min(df[east_col])
|
||||||
|
north_arr = np.array(df[north_col]) - min(df[north_col])
|
||||||
|
|
||||||
|
coord_pairs = list(zip(east_arr, north_arr))
|
||||||
|
|
||||||
|
path = Path(coord_pairs, **kwargs)
|
||||||
|
logger.debug("Loaded path from provided RT90 coordinates.")
|
||||||
|
return path
|
||||||
10
src/pg_rad/physics/__init__.py
Normal file
10
src/pg_rad/physics/__init__.py
Normal 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']
|
||||||
17
src/pg_rad/physics/attenuation.py
Normal file
17
src/pg_rad/physics/attenuation.py
Normal 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)
|
||||||
37
src/pg_rad/physics/fluence.py
Normal file
37
src/pg_rad/physics/fluence.py
Normal 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
|
||||||
7
src/pg_rad/plotting/__init__.py
Normal file
7
src/pg_rad/plotting/__init__.py
Normal 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']
|
||||||
75
src/pg_rad/plotting/landscape_plotter.py
Normal file
75
src/pg_rad/plotting/landscape_plotter.py
Normal 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."
|
||||||
|
)
|
||||||
@ -1,43 +0,0 @@
|
|||||||
from pg_rad.objects import Object
|
|
||||||
from pg_rad.isotope import Isotope
|
|
||||||
|
|
||||||
class PointSource(Object):
|
|
||||||
_id_counter = 1
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
x: float,
|
|
||||||
y: float,
|
|
||||||
z: float,
|
|
||||||
activity: int,
|
|
||||||
isotope: Isotope,
|
|
||||||
name: str | None = None,
|
|
||||||
color: str = "red"):
|
|
||||||
"""A point source.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
x (float): X coordinate.
|
|
||||||
y (float): Y coordinate.
|
|
||||||
z (float): Z coordinate.
|
|
||||||
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".
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.id = PointSource._id_counter
|
|
||||||
PointSource._id_counter += 1
|
|
||||||
|
|
||||||
# default name derived from ID if not provided
|
|
||||||
if name is None:
|
|
||||||
name = f"Source {self.id}"
|
|
||||||
|
|
||||||
super().__init__(x, y, z, name, color)
|
|
||||||
|
|
||||||
self.activity = activity
|
|
||||||
self.isotope = isotope
|
|
||||||
self.color = color
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"PointSource(name={self.name}, pos={(self.x, self.y, self.z)}, isotope={self.isotope.name}, A={self.activity} MBq)"
|
|
||||||
30
tests/test_attenuation_functions.py
Normal file
30
tests/test_attenuation_functions.py
Normal file
@ -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
|
||||||
41
tests/test_fluence_rate.py
Normal file
41
tests/test_fluence_rate.py
Normal file
@ -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
|
||||||
@ -1,47 +0,0 @@
|
|||||||
import pathlib
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from pg_rad.dataloader import load_data
|
|
||||||
from pg_rad.path import Path, path_from_RT90
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def test_df():
|
|
||||||
csv_path = pathlib.Path(__file__).parent / "data/coordinates.csv"
|
|
||||||
return load_data(csv_path)
|
|
||||||
|
|
||||||
def test_piecewise_regression(test_df):
|
|
||||||
"""_Verify whether the intermediate points deviate less than 0.1 SD._"""
|
|
||||||
|
|
||||||
p_full = path_from_RT90(
|
|
||||||
test_df,
|
|
||||||
east_col="East",
|
|
||||||
north_col="North",
|
|
||||||
simplify_path=False
|
|
||||||
)
|
|
||||||
|
|
||||||
p_simpl = path_from_RT90(
|
|
||||||
test_df,
|
|
||||||
east_col="East",
|
|
||||||
north_col="North",
|
|
||||||
simplify_path=True
|
|
||||||
)
|
|
||||||
|
|
||||||
x_f = np.array(p_full.x_list)
|
|
||||||
y_f = np.array(p_full.y_list)
|
|
||||||
|
|
||||||
x_s = np.array(p_simpl.x_list)
|
|
||||||
y_s = np.array(p_simpl.y_list)
|
|
||||||
|
|
||||||
sd = np.std(y_f)
|
|
||||||
|
|
||||||
for xb, yb in zip(x_s[1:-1], y_s[1:-1]):
|
|
||||||
# find nearest original x index
|
|
||||||
idx = np.argmin(np.abs(x_f - xb))
|
|
||||||
deviation = abs(yb - y_f[idx])
|
|
||||||
|
|
||||||
assert deviation < 0.1 * sd, (
|
|
||||||
f"Breakpoint deviation too large: {deviation:.4f} "
|
|
||||||
f"(threshold {0.1 * sd:.4f}) at x={xb:.2f}"
|
|
||||||
)
|
|
||||||
@ -1,28 +1,36 @@
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pg_rad.objects import Source
|
from pg_rad.objects import PointSource
|
||||||
|
from pg_rad.isotopes import Isotope
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_sources():
|
def test_sources():
|
||||||
|
iso = Isotope("test", E=662, b=0)
|
||||||
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 = Source(*tuple(pos_a), strength = None)
|
a = PointSource(pos_a, activity=None, isotope=iso)
|
||||||
b = Source(*tuple(pos_b), strength = None)
|
b = PointSource(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 object A to object 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 static objects (e.g. sources)
|
"""
|
||||||
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
|
||||||
|
|
||||||
Reference in New Issue
Block a user