Compare commits

28 Commits

Author SHA1 Message Date
08299724e1 Merge pull request #20 from pim-n/remove-path-simplify
Remove path simplify
2026-02-10 14:15:10 +01:00
3aff764075 update __init__.py to remove simplify_path 2026-02-10 14:11:12 +01:00
05a71c31a8 remove piecewise_regression from requirements 2026-02-10 14:08:57 +01:00
f5cc5218e6 remove simplify_path functionality 2026-02-10 14:08:40 +01:00
d9e3f2a209 remove simplify path tests 2026-02-10 13:49:09 +01:00
08a056a32d fix Object -> BaseOject reference 2026-02-05 14:37:37 +01:00
f37db46037 Merge pull request #16 from pim-n/fix-pep8
Improve PEP8 adherance
2026-02-05 14:21:31 +01:00
c23ea40ec6 Improve PEP8 adherance using flake8 linter 2026-02-05 14:19:49 +01:00
dcc3be1c22 Merge pull request #14 from pim-n/fix-object-name
rename Object to BaseObject. Addresses #8
2026-02-02 11:38:04 +01:00
52b2eaaeb5 rename Object to BaseObject 2026-02-02 11:33:20 +01:00
ead96eb723 Merge pull request #13 from pim-n/fix-imports-and-refactor
Fix imports and refactor
2026-01-31 10:14:39 +01:00
f5a126b927 bump version 2026-01-31 10:10:14 +01:00
c1b827c871 ignore dev tools folder 2026-01-31 10:06:12 +01:00
85f80ace97 resolve circular imports 2026-01-31 10:01:54 +01:00
a4e965c9d6 Add init files for modularization, generated using mkinit (now a dependency on dev) 2026-01-31 09:44:18 +01:00
15b7e7e65e refactor code into modules 2026-01-31 09:42:21 +01:00
db6f859a60 improving logging setup 2026-01-28 15:54:34 +01:00
14e49e63aa fix to make mathjax work in notebook page 2026-01-28 15:07:58 +01:00
2551f854d6 Merge branch 'dev' of github.com:pim-n/pg-rad into dev 2026-01-28 13:50:35 +01:00
caec70b39b move test_objects.py to test_sources.py and update to reflect refactor 2026-01-28 13:50:16 +01:00
21ea25a3d8 fix typo in README.md 2026-01-28 13:40:30 +01:00
d7c670d344 Update README.md 2026-01-28 13:39:38 +01:00
85f306a469 Merge branch 'dev' of github.com:pim-n/pg-rad into dev 2026-01-28 13:33:12 +01:00
c459b732bb update demo notebook 2026-01-28 13:32:25 +01:00
4632fe35c9 add mathjax javascript code 2026-01-28 13:24:23 +01:00
7cfbbb8792 move demo within docs 2026-01-28 13:22:42 +01:00
7290e241a2 Update ci-docs.yml 2026-01-28 12:34:53 +01:00
38318ad822 Merge pull request #2 from pim-n/main
to dev
2026-01-28 12:33:32 +01:00
27 changed files with 597 additions and 559 deletions

View File

@ -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
View File

@ -1,3 +1,6 @@
# Custom
dev-tools/
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[codz] *.py[codz]

View File

@ -23,13 +23,27 @@ 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]
``` ```
## 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.

File diff suppressed because one or more lines are too long

View 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

File diff suppressed because one or more lines are too long

View File

@ -1,5 +0,0 @@
---
title: Using PG-RAD as a module
---
Consult the API documentation in the side bar.

View File

@ -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

View File

@ -5,9 +5,12 @@ 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"]
[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,7 +21,6 @@ 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"
@ -29,4 +31,4 @@ 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"]

View 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']

View File

@ -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
return df logger.debug(f"File loaded: {filename}")
return df

View 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,)
__all__ = ['ConvergenceError', 'DataLoadError', 'InvalidCSVError',
'exceptions']

View File

@ -1,8 +1,10 @@
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."""

View File

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

View File

@ -5,7 +5,7 @@ class Isotope:
name (str): Full name (e.g. Caesium-137). name (str): Full name (e.g. Caesium-137).
E (float): Energy of the primary gamma in keV. E (float): Energy of the primary gamma in keV.
b (float): Branching ratio for the gamma at energy E. b (float): Branching ratio for the gamma at energy E.
""" """
def __init__( def __init__(
self, self,
name: str, name: str,
@ -20,4 +20,4 @@ class Isotope:
self.name = name self.name = name
self.E = E self.E = E
self.b = b self.b = b

View File

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

View File

@ -1,38 +1,46 @@
import logging
from matplotlib import pyplot as plt from matplotlib import pyplot as plt
from matplotlib.patches import Circle from matplotlib.patches import Circle
import numpy as np import numpy as np
from pg_rad.path import Path from pg_rad.path import Path
from pg_rad.sources import PointSource from pg_rad.objects import PointSource
logger = logging.getLogger(__name__)
class Landscape: class Landscape:
"""A generic Landscape that can contain a Path and sources. """A generic Landscape that can contain a Path and sources.
Args: Args:
air_density (float, optional): Air density in kg / m^3. Defaults to 1.243. 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. size (int | tuple[int, int, int], optional): Size of the world.
scale (str, optional): The scale of the size argument passed. Defaults to 'meters'. Defaults to 500.
""" scale (str, optional): The scale of the size argument passed.
Defaults to 'meters'.
"""
def __init__( def __init__(
self, self,
air_density: float = 1.243, air_density: float = 1.243,
size: int | tuple[int, int, int] = 500, size: int | tuple[int, int, int] = 500,
scale = 'meters', scale: str = 'meters'
): ):
if isinstance(size, int): if isinstance(size, int):
self.world = np.zeros((size, size, size)) self.world = np.zeros((size, size, size))
elif isinstance(size, tuple) and len(size) == 3: elif isinstance(size, tuple) and len(size) == 3:
self.world = np.zeros(size) self.world = np.zeros(size)
else: else:
raise TypeError("size must be an integer or a tuple of 3 integers.") raise TypeError("size must be integer or a tuple of 3 integers.")
self.air_density = air_density self.air_density = air_density
self.scale = scale self.scale = scale
self.path: Path = None self.path: Path = None
self.sources: list[PointSource] = [] self.sources: list[PointSource] = []
logger.debug("Landscape initialized.")
def plot(self, z = 0):
def plot(self, z: float | int = 0):
"""Plot a slice of the world at a height `z`. """Plot a slice of the world at a height `z`.
Args: Args:
@ -40,7 +48,7 @@ class Landscape:
Returns: Returns:
fig, ax: Matplotlib figure objects. fig, ax: Matplotlib figure objects.
""" """
x_lim, y_lim, _ = self.world.shape x_lim, y_lim, _ = self.world.shape
fig, ax = plt.subplots() fig, ax = plt.subplots()
@ -49,9 +57,9 @@ class Landscape:
ax.set_xlabel(f"X [{self.scale}]") ax.set_xlabel(f"X [{self.scale}]")
ax.set_ylabel(f"Y [{self.scale}]") ax.set_ylabel(f"Y [{self.scale}]")
if not self.path == None: if self.path is not None:
ax.plot(self.path.x_list, self.path.y_list, 'bo-') ax.plot(self.path.x_list, self.path.y_list, 'bo-')
for s in self.sources: for s in self.sources:
if np.isclose(s.z, z): if np.isclose(s.z, z):
dot = Circle( dot = Circle(
@ -73,19 +81,19 @@ class Landscape:
) )
ax.add_patch(dot) ax.add_patch(dot)
return fig, ax return fig, ax
def add_sources(self, *sources: PointSource): def add_sources(self, *sources: PointSource):
"""Add one or more point sources to the world. """Add one or more point sources to the world.
Args: Args:
*sources (pg_rad.sources.PointSource): One or more sources, passed as *sources (pg_rad.sources.PointSource): One or more sources,
Source1, Source2, ... passed as Source1, Source2, ...
Raises: Raises:
ValueError: If the source is outside the boundaries of the landscape. ValueError: If the source is outside the boundaries of the
""" landscape.
"""
max_x, max_y, max_z = self.world.shape[:3] max_x, max_y, max_z = self.world.shape[:3]
if any( if any(
@ -103,24 +111,23 @@ class Landscape:
Set the path in the landscape. Set the path in the landscape.
""" """
self.path = path self.path = path
def create_landscape_from_path(path: Path, max_z = 500):
def create_landscape_from_path(path: Path, max_z: float | int = 50):
"""Generate a landscape from a path, using its dimensions to determine """Generate a landscape from a path, using its dimensions to determine
the size of the landscape. the size of the landscape.
Args: Args:
path (Path): A Path object describing the trajectory. path (Path): A Path object describing the trajectory.
max_z (int, optional): Height of the world. Defaults to 500 meters. max_z (int, optional): Height of the world. Defaults to 50 meters.
Returns: Returns:
landscape (pg_rad.landscape.Landscape): A landscape with dimensions based on the provided Path. landscape (pg_rad.landscape.Landscape): A landscape with dimensions
""" based on the provided Path.
"""
max_x = np.ceil(max(path.x_list)) max_x = np.ceil(max(path.x_list))
max_y = np.ceil(max(path.y_list)) max_y = np.ceil(max(path.y_list))
landscape = Landscape( landscape = Landscape(size=(max_x, max_y, max_z))
size = (max_x, max_y, max_z)
)
landscape.path = path landscape.path = path
return landscape return landscape

View File

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

View File

@ -1,17 +1,21 @@
import logging import logging
import logging.config
import pathlib import pathlib
import yaml import yaml
def setup_logger(name):
logger = logging.getLogger(name) 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}.")
base_dir = pathlib.Path(__file__).resolve().parent base_dir = pathlib.Path(__file__).resolve().parent
config_file = base_dir / "configs" / "logging.yml" config_file = base_dir / "configs" / "logging.yml"
with open(config_file) as f: with open(config_file) as f:
config = yaml.safe_load(f) config = yaml.safe_load(f)
config["loggers"]["root"]["level"] = log_level
logging.config.dictConfig(config) logging.config.dictConfig(config)
return logger

View 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']

View File

@ -1,7 +1,8 @@
import math import math
from typing import Self from typing import Self
class Object:
class BaseObject:
def __init__( def __init__(
self, self,
x: float, x: float,
@ -16,18 +17,20 @@ class Object:
x (float): X coordinate. x (float): X coordinate.
y (float): Y coordinate. y (float): Y coordinate.
z (float): Z coordinate. z (float): Z coordinate.
name (str, optional): Name for the object. Defaults to "Unnamed object". name (str, optional): Name for the object.
color (str, optional): Matplotlib compatible color string. Defaults to "red". Defaults to "Unnamed object".
color (str, optional): Matplotlib compatible color string.
Defaults to "red".
""" """
self.x = x self.x = x
self.y = y self.y = y
self.z = z self.z = z
self.name = name self.name = name
self.color = color self.color = color
def distance_to(self, other: Self) -> float: def distance_to(self, other: Self) -> float:
return math.dist( return math.dist(
(self.x, self.y, self.z), (self.x, self.y, self.z),
(other.x, other.y, other.z), (other.x, other.y, other.z),
) )

View File

@ -1,8 +1,14 @@
from pg_rad.objects import Object import logging
from pg_rad.isotope import Isotope
class PointSource(Object): from .objects import BaseObject
from pg_rad.isotopes import Isotope
logger = logging.getLogger(__name__)
class PointSource(BaseObject):
_id_counter = 1 _id_counter = 1
def __init__( def __init__(
self, self,
x: float, x: float,
@ -23,9 +29,10 @@ class PointSource(Object):
name (str | None, optional): Can give the source a unique name. name (str | None, optional): Can give the source a unique name.
Defaults to None, making the name sequential. Defaults to None, making the name sequential.
(Source-1, Source-2, etc.). (Source-1, Source-2, etc.).
color (str, optional): Matplotlib compatible color string. Defaults to "red". color (str, optional): Matplotlib compatible color string.
Defaults to "red".
""" """
self.id = PointSource._id_counter self.id = PointSource._id_counter
PointSource._id_counter += 1 PointSource._id_counter += 1
@ -39,5 +46,12 @@ class PointSource(Object):
self.isotope = isotope self.isotope = isotope
self.color = color self.color = color
logger.debug(f"Source created: {self.name}")
def __repr__(self): def __repr__(self):
return f"PointSource(name={self.name}, pos={(self.x, self.y, self.z)}, isotope={self.isotope.name}, A={self.activity} MBq)" 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

View File

@ -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

View 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']

121
src/pg_rad/path/path.py Normal file
View File

@ -0,0 +1,121 @@
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
):
"""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.
"""
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
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

View File

@ -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}"
)

View File

@ -1,28 +1,27 @@
import numpy as np import numpy as np
import pytest import pytest
from pg_rad.objects import Source from pg_rad.sources import PointSource
@pytest.fixture @pytest.fixture
def test_sources(): def test_sources():
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(*tuple(pos_a), strength = None)
b = Source(*tuple(pos_b), strength = None) b = PointSource(*tuple(pos_b), strength = None)
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 from PointSource A to PointSource 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) """Verify whether distance between two PointSources is calculated correctly."""
is calculated correctly._"""
pos_a, pos_b, a, b = test_sources pos_a, pos_b, a, b = test_sources