diff --git a/lib/python/picongpu/picmi/diagnostics/__init__.py b/lib/python/picongpu/picmi/diagnostics/__init__.py index 9fc207e6af..c228b34886 100644 --- a/lib/python/picongpu/picmi/diagnostics/__init__.py +++ b/lib/python/picongpu/picmi/diagnostics/__init__.py @@ -13,6 +13,7 @@ from .png import Png from .timestepspec import TimeStepSpec from .checkpoint import Checkpoint +from .rangespec import RangeSpec __all__ = [ "Auto", @@ -23,4 +24,5 @@ "Png", "TimeStepSpec", "Checkpoint", + "RangeSpec", ] diff --git a/lib/python/picongpu/picmi/diagnostics/rangespec.py b/lib/python/picongpu/picmi/diagnostics/rangespec.py new file mode 100644 index 0000000000..b867cc71ef --- /dev/null +++ b/lib/python/picongpu/picmi/diagnostics/rangespec.py @@ -0,0 +1,147 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from ...pypicongpu.output.rangespec import RangeSpec as PyPIConGPURangeSpec +import warnings +import typeguard + + +@typeguard.typechecked +class _RangeSpecMeta(type): + """ + Custom metaclass providing the [] operator for RangeSpec. + """ + + def __getitem__(cls, args): + if not isinstance(args, tuple): + args = (args,) + return cls(*args) + + +class RangeSpec(metaclass=_RangeSpecMeta): + """ + A class to specify a contiguous range of cells for simulation output in 1D, 2D, or 3D. + + This class stores a list of slices representing inclusive cell ranges for each dimension. + Slices must have step=None (contiguous ranges) and integer or None endpoints. Use the [] + operator for concise syntax, e.g., RangeSpec[0:10, 5:15]. For example: + - 1D: RangeSpec[0:10] specifies cells 0 to 10 (x). + - 3D: RangeSpec[0:10, 5:15, 2:8] specifies cells 0 to 10 (x), 5 to 15 (y), 2 to 8 (z). + + The default RangeSpec[:] includes all cells in the simulation box for 1D. + + Ranges where begin > end (e.g., RangeSpec[10:5]) result in an empty range after processing, + disabling output for that dimension. + """ + + def __init__(self, *args): + """ + Initialize a RangeSpec with a list of slices. + :param args: 1 to 3 slice objects, e.g., slice(0, 10), slice(5, 15). + """ + if not args: + raise ValueError("RangeSpec must have at least one range") + if len(args) > 3: + raise ValueError(f"RangeSpec must have at most 3 ranges, got {len(args)}") + if not all(isinstance(s, slice) for s in args): + raise TypeError("All elements must be slice objects") + for i, s in enumerate(args): + if s.step is not None: + raise ValueError(f"Step must be None in dimension {i + 1}, got {s.step}") + if s.start is not None and not isinstance(s.start, int): + raise TypeError(f"Begin in dimension {i + 1} must be int or None, got {type(s.start)}") + if s.stop is not None and not isinstance(s.stop, int): + raise TypeError(f"End in dimension {i + 1} must be int or None, got {type(s.stop)}") + self.ranges = list(args) + + def __len__(self): + """ + Return the number of dimensions specified in the range. + """ + return len(self.ranges) + + def check(self, simulation_box: tuple[int, ...]): + """ + Validate the RangeSpec and warn if any range is empty or has begin > end. + """ + # Check for begin > end in raw slices + for i, s in enumerate(self.ranges): + start = s.start if s.start is not None else 0 + stop = s.stop if s.stop is not None else 0 + if start > stop: + warnings.warn( + f"RangeSpec has begin > end in dimension {i + 1}, resulting in an empty range after processing" + ) + + # Check for empty ranges after processing + processed_ranges = [ + self._interpret_negatives(self._interpret_nones(s, dim_size), dim_size) + for s, dim_size in zip(self.ranges, simulation_box) + ] + + for i, s in enumerate(processed_ranges): + if s.start >= s.stop: + warnings.warn(f"RangeSpec has an empty range in dimension {i + 1}, disabling output for this dimension") + + def _interpret_nones(self, spec: slice, dim_size: int) -> slice: + """ + :param spec: Input slice. + :param dim_size: Size of the simulation box in the dimension. + :return: Slice with non-negative bounds, clipped to [0, dim_size-1], empty if begin > end. + Replace None in slice bounds with simulation box limits (0 for begin, dim_size-1 for end). + """ + return slice( + 0 if spec.start is None else spec.start, + dim_size - 1 if spec.stop is None else spec.stop, + None, + ) + + def _interpret_negatives(self, spec: slice, dim_size: int) -> slice: + """ + Convert negative indices to positive, clipping to simulation box. + """ + if dim_size <= 0: + raise ValueError(f"Dimension size must be positive. Got {dim_size}") + + begin = spec.start if spec.start is not None else 0 + end = spec.stop if spec.stop is not None else dim_size - 1 + + # Convert negative indices + begin = dim_size + begin if begin < 0 else begin + end = dim_size + end if end < 0 else end + + # Clip to simulation box + begin = max(0, min(begin, dim_size - 1)) + end = max(0, min(end, dim_size - 1)) + + # Ensure empty range if begin > end + if begin > end: + end = begin + + return slice(begin, end, None) + + def get_as_pypicongpu(self, simulation_box: tuple[int, ...], **kwargs) -> PyPIConGPURangeSpec: + """ + Convert to a PyPIConGPURangeSpec object, applying simulation box clipping. + + :param simulation_box: tuple of dimension sizes (1 to 3 dimensions). + :return: PyPIConGPURangeSpec object with clipped, non-negative ranges. + :raises ValueError: If the number of ranges does not match the simulation box dimensions. + """ + if len(self.ranges) != len(simulation_box): + raise ValueError( + f"Number of range specifications ({len(self.ranges)}) must match " + f"simulation box dimensions ({len(simulation_box)})" + ) + + # Process each dimension + processed_ranges = [ + self._interpret_negatives(self._interpret_nones(s, dim_size), dim_size) + for s, dim_size in zip(self.ranges, simulation_box) + ] + + return PyPIConGPURangeSpec(processed_ranges) diff --git a/lib/python/picongpu/pypicongpu/output/__init__.py b/lib/python/picongpu/pypicongpu/output/__init__.py index 9636a64119..2da1d2270b 100644 --- a/lib/python/picongpu/pypicongpu/output/__init__.py +++ b/lib/python/picongpu/pypicongpu/output/__init__.py @@ -5,6 +5,7 @@ from .png import Png from .timestepspec import TimeStepSpec from .checkpoint import Checkpoint +from .rangespec import RangeSpec __all__ = [ "Auto", @@ -14,4 +15,5 @@ "Png", "TimeStepSpec", "Checkpoint", + "RangeSpec", ] diff --git a/lib/python/picongpu/pypicongpu/output/rangespec.py b/lib/python/picongpu/pypicongpu/output/rangespec.py new file mode 100644 index 0000000000..ccf6ffc434 --- /dev/null +++ b/lib/python/picongpu/pypicongpu/output/rangespec.py @@ -0,0 +1,35 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from ..rendering.renderedobject import RenderedObject +from ..util import build_typesafe_property + +import typeguard + + +def _serialize(spec: slice) -> dict: + if not isinstance(spec, slice): + raise ValueError(f"Expected a slice for range, got {type(spec)}") + if spec.start is not None and not isinstance(spec.start, int): + raise ValueError(f"Begin must be int or None, got {type(spec.start)}") + if spec.stop is not None and not isinstance(spec.stop, int): + raise ValueError(f"End must be int or None, got {type(spec.stop)}") + return { + "begin": spec.start if spec.start is not None else 0, + "end": spec.stop if spec.stop is not None else -1, + } + + +@typeguard.typechecked +class RangeSpec(RenderedObject): + ranges = build_typesafe_property(list[slice]) + + def __init__(self, ranges: list[slice]): + self.ranges = ranges + + def _get_serialized(self) -> dict: + return {"ranges": list(map(_serialize, self.ranges))} diff --git a/share/picongpu/pypicongpu/schema/output/rangespec.RangeSpec.json b/share/picongpu/pypicongpu/schema/output/rangespec.RangeSpec.json new file mode 100644 index 0000000000..8a0a3a44cb --- /dev/null +++ b/share/picongpu/pypicongpu/schema/output/rangespec.RangeSpec.json @@ -0,0 +1,35 @@ +{ + "$id": "https://registry.hzdr.de/crp/picongpu/schema/picongpu.pypicongpu.output.rangespec.RangeSpec", + "type": "object", + "description": "Range specification for PIConGPU simulation output in 1D, 2D, or 3D", + "unevaluatedProperties": false, + "required": [ + "ranges" + ], + "properties": { + "ranges": { + "type": "array", + "description": "List of ranges for each dimension (1 to 3)", + "minItems": 1, + "maxItems": 3, + "items": { + "type": "object", + "properties": { + "begin": { + "type": "integer", + "description": "Start index of the range (inclusive)" + }, + "end": { + "type": "integer", + "description": "End index of the range (inclusive)" + } + }, + "required": [ + "begin", + "end" + ], + "unevaluatedProperties": false + } + } + } +} diff --git a/test/python/picongpu/quick/picmi/diagnostics/__init__.py b/test/python/picongpu/quick/picmi/diagnostics/__init__.py index 2d4b6a07c1..af3dac9139 100644 --- a/test/python/picongpu/quick/picmi/diagnostics/__init__.py +++ b/test/python/picongpu/quick/picmi/diagnostics/__init__.py @@ -1,9 +1,10 @@ """ This file is part of PIConGPU. Copyright 2025 PIConGPU contributors -Authors: Julian Lenz +Authors: Julian Lenz, Masoud Afshari License: GPLv3+ """ # flake8: noqa from .timestepspec import * # pyflakes.ignore +from .rangespec import * # pyflakes.ignore diff --git a/test/python/picongpu/quick/picmi/diagnostics/rangespec.py b/test/python/picongpu/quick/picmi/diagnostics/rangespec.py new file mode 100644 index 0000000000..1d5260635e --- /dev/null +++ b/test/python/picongpu/quick/picmi/diagnostics/rangespec.py @@ -0,0 +1,65 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from picongpu.picmi.diagnostics import RangeSpec +import unittest + +TESTCASES_VALID = [ + (RangeSpec[:], [slice(None, None, None)]), + (RangeSpec[0:10], [slice(0, 10, None)]), + (RangeSpec[10:5], [slice(10, 5, None)]), + (RangeSpec[-5:15], [slice(-5, 15, None)]), + (RangeSpec[0:10, 5:15], [slice(0, 10, None), slice(5, 15, None)]), + (RangeSpec[0:10, 5:15, 2:8], [slice(0, 10, None), slice(5, 15, None), slice(2, 8, None)]), +] + +TESTCASES_INVALID = [ + ((), "RangeSpec must have at least one range"), + ( + (slice(0, 10, None), slice(5, 15, None), slice(2, 8, None), slice(1, 2, None)), + "RangeSpec must have at most 3 ranges", + ), + ((slice(0, 10, 2),), "Step must be None in dimension 1"), + ((slice("0", 10, None),), "Begin in dimension 1 must be int or None"), + ((slice(0, "10", None),), "End in dimension 1 must be int or None"), +] + +TESTCASES_WARNING = [ + (RangeSpec[10:5], "RangeSpec has begin > end in dimension 1, resulting in an empty range"), + (RangeSpec[-5:10], "RangeSpec has an empty range in dimension 1, disabling output"), +] + + +class PICMI_TestRangeSpec(unittest.TestCase): + def test_rangespec(self): + """Test RangeSpec instantiation with valid inputs.""" + for rs, ranges in TESTCASES_VALID: + with self.subTest(rs=rs): + self.assertEqual(rs.ranges, ranges) + # Pass simulation_box based on number of dimensions + simulation_box = tuple([128] * len(rs.ranges)) + rs.check(simulation_box) + + def test_rangespec_invalid(self): + """Test invalid RangeSpec inputs.""" + for args, error in TESTCASES_INVALID: + with self.subTest(args=args, error=error): + with self.assertRaisesRegex((ValueError, TypeError), error): + RangeSpec(*args) + + def test_rangespec_warning(self): + """Test warnings for empty or invalid ranges.""" + for rs, warning in TESTCASES_WARNING: + with self.subTest(rs=rs, warning=warning): + # Pass simulation_box for 1D + simulation_box = (128,) + with self.assertWarnsRegex(UserWarning, warning): + rs.check(simulation_box) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/python/picongpu/quick/pypicongpu/output/__init__.py b/test/python/picongpu/quick/pypicongpu/output/__init__.py index 350673738c..7d87bef581 100644 --- a/test/python/picongpu/quick/pypicongpu/output/__init__.py +++ b/test/python/picongpu/quick/pypicongpu/output/__init__.py @@ -2,3 +2,4 @@ from .auto import * # pyflakes.ignore from .phase_space import * # pyflakes.ignore from .timestepspec import * # pyflakes.ignore +from .rangespec import * # pyflakes.ignore diff --git a/test/python/picongpu/quick/pypicongpu/output/rangespec.py b/test/python/picongpu/quick/pypicongpu/output/rangespec.py new file mode 100644 index 0000000000..0c3aedc315 --- /dev/null +++ b/test/python/picongpu/quick/pypicongpu/output/rangespec.py @@ -0,0 +1,59 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from picongpu.pypicongpu.output.rangespec import RangeSpec as PyPIConGPURangeSpec +import unittest +import typeguard + + +class TestRangeSpec(unittest.TestCase): + def test_instantiation_and_types(self): + """Test instantiation, type safety, and valid serialization.""" + # Valid configurations + rs = PyPIConGPURangeSpec([slice(0, 10)]) + self.assertEqual(rs.ranges, [slice(0, 10, None)]) + context = rs.get_rendering_context() + self.assertEqual(context["ranges"], [{"begin": 0, "end": 10}]) + + rs = PyPIConGPURangeSpec([slice(0, 10), slice(5, 15)]) + self.assertEqual(rs.ranges, [slice(0, 10, None), slice(5, 15, None)]) + context = rs.get_rendering_context() + self.assertEqual(context["ranges"], [{"begin": 0, "end": 10}, {"begin": 5, "end": 15}]) + + rs = PyPIConGPURangeSpec([slice(None, None), slice(None, None), slice(None, None)]) + self.assertEqual(rs.ranges, [slice(None, None, None), slice(None, None, None), slice(None, None, None)]) + context = rs.get_rendering_context() + self.assertEqual(context["ranges"], [{"begin": 0, "end": -1}, {"begin": 0, "end": -1}, {"begin": 0, "end": -1}]) + + # Type safety + invalid_inputs = ["string", 1] + for invalid in invalid_inputs: + with self.subTest(invalid=invalid): + with self.assertRaises(typeguard.TypeCheckError): + PyPIConGPURangeSpec([invalid]) + + invalid_endpoints = [slice(0.0, 10), slice(0, "b")] + for invalid in invalid_endpoints: + with self.subTest(invalid=invalid): + with self.assertRaises(ValueError): + rs = PyPIConGPURangeSpec([invalid]) + rs.get_rendering_context() + + def test_rendering_and_validation(self): + """Test serialization output and validation errors.""" + # Valid serialization + rs = PyPIConGPURangeSpec([slice(0, 10), slice(5, 15), slice(2, 8)]) + context = rs.get_rendering_context() + self.assertEqual(context["ranges"], [{"begin": 0, "end": 10}, {"begin": 5, "end": 15}, {"begin": 2, "end": 8}]) + + with self.assertRaises(ValueError): + rs = PyPIConGPURangeSpec([slice(0, 10.0)]) + rs.get_rendering_context() + + +if __name__ == "__main__": + unittest.main()