Skip to content

Commit

Permalink
Merge pull request #17 from pheuer/coverage_improvements
Browse files Browse the repository at this point in the history
Coverage improvements
  • Loading branch information
pheuer authored Feb 19, 2025
2 parents bbd52fa + 9b3ce2e commit 7fa9678
Show file tree
Hide file tree
Showing 12 changed files with 285 additions and 43 deletions.
6 changes: 3 additions & 3 deletions src/cr39py/core/ci.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from matplotlib import use as matplotlib_use


def in_ci():
def in_ci(): # pragma: no cover
"""
GitLab CI sets the variable CI == 'true' on all pipelines,
so the presence and value of this environment variable can be used
Expand All @@ -15,15 +15,15 @@ def in_ci():
return "CI" in os.environ and os.environ["CI"] == "true"


def in_unit_test():
def in_unit_test(): # pragma: no cover
"""
Pytest sets this environment variable
"""
# https://stackoverflow.com/questions/25188119/test-if-code-is-executed-from-within-a-py-test-session
return "PYTEST_CURRENT_TEST" in os.environ


class SilentPlotting:
class SilentPlotting: # pragma: no cover
"""
Context manager that allows matplotlib to create plots silently
to ensure plotting functions run without actually having all the plots
Expand Down
13 changes: 10 additions & 3 deletions src/cr39py/etch/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import numpy as np


def goal_diameter(fluence, desired_F2=0.05, max_goal=10):
def goal_diameter(fluence, desired_F2=0.025, max_goal=10):
"""Calculates the ideal track diameter in um to achieve a given overlap parameter F2.
Parameters
Expand All @@ -13,8 +13,8 @@ def goal_diameter(fluence, desired_F2=0.05, max_goal=10):
desired_F2 : float, optional
The desired track overlap parameter F2. The default
value is 0.05, meaning that ~5% of tracks will suffer overlap with
a neighbor.
value is 0.025, meaning that ~2.5% of tracks will suffer overlap with
a neighbor. The model breaks down for F2~>0.3.
max_goal : float, optional
A maximum at which the goal track diameter will be clipped, in um.
Expand All @@ -24,6 +24,13 @@ def goal_diameter(fluence, desired_F2=0.05, max_goal=10):
-------
goal_diameter: float
The goal track diameter in um.
Raises
------
ValueError
If desired_F2 > 0.3, in which case the model breaks down.
"""

def chi(F2):
Expand Down
31 changes: 28 additions & 3 deletions src/cr39py/scan/base_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,10 @@ def __init__(
parent_scan: "Scan" = None,
) -> None:

if ind is None:
if ind is None: # pragma: no cover
raise ValueError("ind argument is required")

if unit is None:
if unit is None: # pragma: no cover
raise ValueError("unit argument is required")

# These parameters are intended to not be mutable
Expand Down Expand Up @@ -249,6 +249,8 @@ def __init__(self) -> None:
# Etch time, u.Quantity
self._etch_time = None

self._filepath = None

self.metadata = {}

@property
Expand Down Expand Up @@ -278,6 +280,16 @@ def etch_time(self) -> u.Quantity:
"""
return self._etch_time

@property
def filepath(self) -> Path:
"""
Path to the file from which the scan was loaded.
If the scan was not loaded from a file, e.g. if it was
created directly from a track array, this will be ``None``.
"""
return self._filepath

@property
def axes(self) -> dict[Axis]:
"""
Expand Down Expand Up @@ -350,7 +362,10 @@ def from_cpsa(cls, path: Path, etch_time: float | None = None):

tracks, metadata = read_cpsa(path)

return cls.from_tracks(tracks, etch_time, metadata=metadata)
obj = cls.from_tracks(tracks, etch_time, metadata=metadata)
obj._filepath = path

return obj

# **********************************
# Framesize setup
Expand Down Expand Up @@ -944,6 +959,16 @@ def save_histogram(self, path: Path, *args, **kwargs) -> None:
elif ext.lower() == ".csv":
np.savetxt(path, arr.m, delimiter=",")

elif ext.lower() == ".png":
fig, ax = plt.subplots()
ax.set_aspect("equal")
if self.filepath is not None:
ax.set_title(self.filepath.stem, fontsize=9)
ax.pcolormesh(hax.m, vax.m, arr.m.T)
ax.set_xlabel("X")
ax.set_ylabel("Y")
fig.savefig(path, dpi=200)

else:
raise ValueError(f"Unsupported file extension: {ext}")

Expand Down
1 change: 1 addition & 0 deletions src/cr39py/scan/cpsa.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ def extract_etch_time(path: Path) -> float:
returns None.
"""
path = Path(path)

# Cast the filename as lowercase, so "H" and "h" will both match
filename = str(path.name).lower()
Expand Down
13 changes: 8 additions & 5 deletions src/cr39py/scan/cut.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,28 +234,31 @@ def test(self, trackdata):
"""
Returns a boolean array representing which tracks fall within this cut.
Note that the mask returned by this function is tracks that are inside
the cut, e.g. tracks that should be excluded by the cut.
Parameters
----------
trackdata : np.ndarray (ntracks, 6)
Track data array.
Returns
-------
keep : np.ndarray, bool (ntracks,)
in_cut : np.ndarray, bool (ntracks,)
A boolean array indicating whether or not each track in the
trackdata array fits within the cut.
"""
ntracks, _ = trackdata.shape
keep = np.ones(ntracks).astype("bool")
in_cut = np.ones(ntracks).astype("bool")

for key in self.bounds.keys():
if self.bounds[key] is not None:
i = self.indices[key]
if "min" in key:
keep *= np.greater(trackdata[:, i], getattr(self, key))
in_cut *= np.greater(trackdata[:, i], getattr(self, key))
else:
keep *= np.less(trackdata[:, i], getattr(self, key))
in_cut *= np.less(trackdata[:, i], getattr(self, key))

# Return a 1 for every track that is in the cut
return keep.astype(bool)
return in_cut.astype(bool)
48 changes: 28 additions & 20 deletions src/cr39py/scan/subset.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@
from cr39py.core.types import TrackData
from cr39py.scan.cut import Cut

# TODO: Eliminate the domain? Why not just have that be another cut in the cut list...


class Subset(ExportableClassMixin):
"""
Expand All @@ -28,6 +26,9 @@ class Subset(ExportableClassMixin):
The subset is defined by a domain, a list
of cuts, and a number of diameter slices (Dslices).
The domain is a cut that defines the area in parameter space that the subset. Unlike regular cuts,
the domain is inclusive, and the ``apply_cuts`` method will not invert the domain when it inverts other cuts.
Parameters
----------
Expand Down Expand Up @@ -134,10 +135,7 @@ def set_domain(self, *args, **kwargs) -> None:
domain.
"""

if len(args) < 1 or args[0] is None:
self.domain = Cut()
elif len(args) == 1:
if len(args) == 1 and isinstance(args[0], Cut):
c = args[0]
else:
c = Cut(**kwargs)
Expand Down Expand Up @@ -248,14 +246,19 @@ def remove_cut(self, i: int) -> None:
i : int
Index of the cut to remove
Raises
------
ValueError
If the index is out of bounds.
"""

if i > len(self.cuts) - 1:
print(
raise ValueError(
f"Cannot remove the {i} cut, there are only " f"{len(self.cuts)} cuts."
)
else:
self.cuts.pop(i)
self.cuts.pop(i)

def replace_cut(self, i: int, cut: Cut):
"""Replace the ith cut in the Subset list with a new cut
Expand All @@ -266,13 +269,19 @@ def replace_cut(self, i: int, cut: Cut):
Index of the Cut to replace.
cut : `~cr39py.cut.Cut`
New cut to insert.
Raises
------
ValueError
If the index is out of bounds.
"""
if i > len(self.cuts) - 1:
print(
raise ValueError(
f"Cannot replace the {i} cut, there are only " f"{len(self.cuts)} cuts."
)
else:
self.cuts[i] = cut
self.cuts[i] = cut

def apply_cuts(
self, tracks: TrackData, use_cuts: list[int] | None = None, invert: bool = False
Expand All @@ -292,8 +301,8 @@ def apply_cuts(
invert : bool (optional)
If True, return the inverse of the cuts selected, i.e. the
tracks that would otherwise be excluded. The default is
False.
tracks that would otherwise be excluded. The domain will not be inerted.
The default is False.
Returns
-------
Expand Down Expand Up @@ -331,16 +340,15 @@ def apply_cuts(
# in the excluded region
include *= np.logical_not(x)

# Regardless of anything else, only show tracks that are within
# the domain
if self.domain is not None:
include *= self.domain.test(tracks)
domain_include = self.domain.test(tracks)

# Select only these tracks
# Select only these these tracks
# If inverting, do not invert the domain tracks
if invert:
selected_tracks = tracks[~include, :]
selected_tracks = tracks[(~include) * domain_include, :]
else:
selected_tracks = tracks[include, :]
selected_tracks = tracks[include * domain_include, :]

# Calculate the bin edges for each dslice
# !! note that the tracks are already sorted into order by diameter
Expand Down
Empty file added tests/etch/__init__.py
Empty file.
27 changes: 27 additions & 0 deletions tests/etch/test_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""_
This file contains tests for the etch/tools.py file.
"""

import numpy as np
import pytest

from cr39py.etch.tools import goal_diameter

cases = [
(1e5, 0.05, 20, 4.06),
# Extremely low fluence and high F2 to test max goal diameter
(1e2, 0.2, 5, 5),
]


@pytest.mark.parametrize("fluence, desired_F2, max_goal, expected", cases)
def test_goal_diameter(fluence, desired_F2, max_goal, expected):
assert np.isclose(goal_diameter(fluence, desired_F2, max_goal), expected, rtol=0.03)


def test_goal_diameter_raises():
"""
Raise an exception if F2 > 0.3
"""
with pytest.raises(ValueError):
goal_diameter(1e5, 0.4, 20)
44 changes: 36 additions & 8 deletions tests/scan/test_scan.py → tests/scan/test_base_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,38 @@ def test_subset(cr39scan):
with pytest.raises(ValueError):
cr39scan.remove_subset(0)

cr39scan.remove_subset(2)
# Cannot select subset outside range
with pytest.raises(ValueError):
cr39scan.remove_subset(1000)

# Select the last tubset
cr39scan.select_subset(-1)

# Remove the first subset
cr39scan.remove_subset(0)


def test_manipulate_cuts(cr39scan):

cr39scan.set_domain(xmin=0)
cr39scan.add_cut(cmin=30)
cr39scan.add_cut(Cut(dmin=10))

cr39scan.set_ndslices(2)
cr39scan.select_dslice(0)

cr39scan.remove_cut(1)
cr39scan.replace_cut(0, Cut(cmin=20))


@pytest.mark.parametrize("statistic", ["mean", "median"])
def test_track_energy(cr39scan, statistic):
cr39scan.track_energy("D", statistic)


@pytest.mark.parametrize("attribute", ["chi", "F2", "track_density"])
@pytest.mark.parametrize(
"attribute", ["chi", "F2", "track_density", "etch_time", "ntracks"]
)
def test_access_attributes(cr39scan, attribute):
assert hasattr(cr39scan, attribute)
getattr(cr39scan, attribute)
Expand All @@ -84,21 +107,19 @@ def test_histogram(cr39scan):
cr39scan.histogram()


def test_plot(cr39scan):
@pytest.mark.parametrize("fcn_name", ["cutplot", "plot", "focus_plot"])
def test_plot_functions(fcn_name, cr39scan):
with SilentPlotting():
cr39scan.cutplot()
getattr(cr39scan, fcn_name)()


@pytest.mark.parametrize("ext", [".csv", ".h5"])
@pytest.mark.parametrize("ext", [".csv", ".h5", ".png"])
def test_save_histogram(cr39scan, tmp_path, ext):

# Save the histogram
path = tmp_path / Path("test_histogram" + ext)
cr39scan.save_histogram(path)

# Get the histogram for reference
_, _, hist = cr39scan.histogram()

# Read the data from the histogram
if ext == ".h5":
with h5py.File(path, "r") as f:
Expand All @@ -107,5 +128,12 @@ def test_save_histogram(cr39scan, tmp_path, ext):
elif ext == ".csv":
data = np.loadtxt(path, delimiter=",")

elif ext == ".png":
# Skip the check on the data in this case
return

# Get the histogram for reference
_, _, hist = cr39scan.histogram()

# Test that the data matches expectations
assert np.allclose(data, hist, rtol=0.05)
Loading

0 comments on commit 7fa9678

Please sign in to comment.