From 1336cd8b0d7a1355b57033eafbd9a9ff2b08b1fa Mon Sep 17 00:00:00 2001 From: mafshari64 Date: Thu, 25 Sep 2025 15:22:05 +0200 Subject: [PATCH 1/2] adding RangeSpec to picongpu --- .../picongpu/picmi/diagnostics/__init__.py | 2 + .../picongpu/picmi/diagnostics/rangespec.py | 127 ++++++++++++++++++ .../picongpu/pypicongpu/output/__init__.py | 2 + .../picongpu/pypicongpu/output/rangespec.py | 56 ++++++++ .../schema/output/rangespec.RangeSpec.json | 35 +++++ .../quick/picmi/diagnostics/__init__.py | 3 +- .../quick/picmi/diagnostics/rangespec.py | 81 +++++++++++ .../quick/pypicongpu/output/__init__.py | 1 + .../quick/pypicongpu/output/rangespec.py | 66 +++++++++ 9 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 lib/python/picongpu/picmi/diagnostics/rangespec.py create mode 100644 lib/python/picongpu/pypicongpu/output/rangespec.py create mode 100644 share/picongpu/pypicongpu/schema/output/rangespec.RangeSpec.json create mode 100644 test/python/picongpu/quick/picmi/diagnostics/rangespec.py create mode 100644 test/python/picongpu/quick/pypicongpu/output/rangespec.py 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..89ba5d3b09 --- /dev/null +++ b/lib/python/picongpu/picmi/diagnostics/rangespec.py @@ -0,0 +1,127 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from picongpu.picmi.diagnostics.util import diagnostic_converts_to +from ...pypicongpu.output.rangespec import RangeSpec as PyPIConGPURangeSpec +import warnings +import typeguard + + +@diagnostic_converts_to(PyPIConGPURangeSpec) +@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): + """ + 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 + dummy_sim_box = tuple(20 for _ in range(len(self.ranges))) # Match number of dimensions + processed_ranges = [ + self._interpret_negatives(self._interpret_nones(s, dim_size), dim_size) + for s, dim_size in zip(self.ranges, dummy_sim_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) 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..a5eed74219 --- /dev/null +++ b/lib/python/picongpu/pypicongpu/output/rangespec.py @@ -0,0 +1,56 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from typing import List +from ..rendering.renderedobject import RenderedObject +from ..util import build_typesafe_property + +import typeguard + + +class _RangeSpecMeta(type): + def __getitem__(cls, args): + if not isinstance(args, tuple): + args = (args,) + return cls(*args) + + +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, metaclass=_RangeSpecMeta): + ranges = build_typesafe_property(List[slice]) + + def __init__(self, *args): + 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 _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..1a8a0f1011 --- /dev/null +++ b/test/python/picongpu/quick/picmi/diagnostics/rangespec.py @@ -0,0 +1,81 @@ +""" +This file is part of PIConGPU. +Copyright 2025 PIConGPU contributors +Authors: Masoud Afshari +License: GPLv3+ +""" + +from picongpu.picmi.diagnostics import RangeSpec +from picongpu.pypicongpu.output.rangespec import RangeSpec as PyPIConGPURangeSpec +import unittest + +TESTCASES_VALID = [ + (RangeSpec[:], [slice(None, None, None)], (20,), [slice(0, 19, None)], [{"begin": 0, "end": 19}]), + (RangeSpec[0:10], [slice(0, 10, None)], (20,), [slice(0, 10, None)], [{"begin": 0, "end": 10}]), + (RangeSpec[10:5], [slice(10, 5, None)], (20,), [slice(10, 10, None)], [{"begin": 10, "end": 10}]), + ( + RangeSpec[0:10, 5:15], + [slice(0, 10, None), slice(5, 15, None)], + (20, 30), + [slice(0, 10, None), slice(5, 15, None)], + [{"begin": 0, "end": 10}, {"begin": 5, "end": 15}], + ), + ( + RangeSpec[0:10, 5:15, 2:8], + [slice(0, 10, None), slice(5, 15, None), slice(2, 8, None)], + (20, 30, 40), + [slice(0, 10, None), slice(5, 15, None), slice(2, 8, None)], + [{"begin": 0, "end": 10}, {"begin": 5, "end": 15}, {"begin": 2, "end": 8}], + ), +] + +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"), +] + +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, serialization, and clipping.""" + for rs, ranges, sim_box, pypicongpu_ranges, serialized in TESTCASES_VALID: + with self.subTest(rs=rs, sim_box=sim_box): + self.assertEqual(rs.ranges, ranges) + rs.check() + pypicongpu_rs = rs.get_as_pypicongpu(sim_box) + self.assertIsInstance(pypicongpu_rs, PyPIConGPURangeSpec) + self.assertEqual(pypicongpu_rs.ranges, pypicongpu_ranges) + self.assertEqual(pypicongpu_rs.get_rendering_context()["ranges"], serialized) + + def test_rangespec_invalid(self): + """Test invalid RangeSpec inputs and simulation box.""" + for args, error in TESTCASES_INVALID: + with self.subTest(args=args, error=error): + with self.assertRaisesRegex((ValueError, TypeError), error): + RangeSpec(*args) + rs = RangeSpec[0:10, 5:15] + with self.assertRaisesRegex(ValueError, "Number of range specifications"): + rs.get_as_pypicongpu((20,)) + with self.assertRaisesRegex(ValueError, "Dimension size must be positive"): + rs.get_as_pypicongpu((20, 0)) + + def test_rangespec_warning(self): + """Test warnings for empty ranges.""" + for rs, warning in TESTCASES_WARNING: + with self.subTest(rs=rs, warning=warning): + with self.assertWarnsRegex(UserWarning, warning): + rs.check() + + +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..dd889c8e8d --- /dev/null +++ b/test/python/picongpu/quick/pypicongpu/output/rangespec.py @@ -0,0 +1,66 @@ +""" +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 + + +class TestRangeSpec(unittest.TestCase): + def test_instantiation_and_types(self): + """Test instantiation, type safety, and valid serialization.""" + # Valid configurations + rs = PyPIConGPURangeSpec[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[0:10, 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[:, :, :] + 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(TypeError): + PyPIConGPURangeSpec[invalid] + + invalid_endpoints = [slice(0.0, 10), slice(0, "b")] + for invalid in invalid_endpoints: + with self.subTest(invalid=invalid): + with self.assertRaises(TypeError): + PyPIConGPURangeSpec[invalid] + + def test_rendering_and_validation(self): + """Test serialization output and validation errors.""" + # Valid serialization + rs = PyPIConGPURangeSpec[0:10, 5:15, 2:8] + context = rs.get_rendering_context() + self.assertEqual(context["ranges"], [{"begin": 0, "end": 10}, {"begin": 5, "end": 15}, {"begin": 2, "end": 8}]) + + # Validation errors + with self.assertRaisesRegex(ValueError, "RangeSpec must have at most 3 ranges"): + PyPIConGPURangeSpec[0:10, 0:10, 0:10, 0:10] + + with self.assertRaisesRegex(ValueError, "RangeSpec must have at least one range"): + PyPIConGPURangeSpec() + + with self.assertRaisesRegex(ValueError, "Step must be None"): + PyPIConGPURangeSpec[0:10:2] + + with self.assertRaises(TypeError): + PyPIConGPURangeSpec[slice(0, 10.0)] + + +if __name__ == "__main__": + unittest.main() From 42cd4a968479f56eb456381bb24e0ccd224724ab Mon Sep 17 00:00:00 2001 From: mafshari64 Date: Thu, 25 Sep 2025 18:13:13 +0200 Subject: [PATCH 2/2] fix picmi RangeSpec CI test (ci: no-compile) --- .../quick/picmi/diagnostics/rangespec.py | 44 +++++-------------- 1 file changed, 12 insertions(+), 32 deletions(-) diff --git a/test/python/picongpu/quick/picmi/diagnostics/rangespec.py b/test/python/picongpu/quick/picmi/diagnostics/rangespec.py index 1a8a0f1011..ee76b9be72 100644 --- a/test/python/picongpu/quick/picmi/diagnostics/rangespec.py +++ b/test/python/picongpu/quick/picmi/diagnostics/rangespec.py @@ -6,27 +6,15 @@ """ from picongpu.picmi.diagnostics import RangeSpec -from picongpu.pypicongpu.output.rangespec import RangeSpec as PyPIConGPURangeSpec import unittest TESTCASES_VALID = [ - (RangeSpec[:], [slice(None, None, None)], (20,), [slice(0, 19, None)], [{"begin": 0, "end": 19}]), - (RangeSpec[0:10], [slice(0, 10, None)], (20,), [slice(0, 10, None)], [{"begin": 0, "end": 10}]), - (RangeSpec[10:5], [slice(10, 5, None)], (20,), [slice(10, 10, None)], [{"begin": 10, "end": 10}]), - ( - RangeSpec[0:10, 5:15], - [slice(0, 10, None), slice(5, 15, None)], - (20, 30), - [slice(0, 10, None), slice(5, 15, None)], - [{"begin": 0, "end": 10}, {"begin": 5, "end": 15}], - ), - ( - RangeSpec[0:10, 5:15, 2:8], - [slice(0, 10, None), slice(5, 15, None), slice(2, 8, None)], - (20, 30, 40), - [slice(0, 10, None), slice(5, 15, None), slice(2, 8, None)], - [{"begin": 0, "end": 10}, {"begin": 5, "end": 15}, {"begin": 2, "end": 8}], - ), + (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 = [ @@ -37,6 +25,7 @@ ), ((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 = [ @@ -47,30 +36,21 @@ class PICMI_TestRangeSpec(unittest.TestCase): def test_rangespec(self): - """Test RangeSpec instantiation, serialization, and clipping.""" - for rs, ranges, sim_box, pypicongpu_ranges, serialized in TESTCASES_VALID: - with self.subTest(rs=rs, sim_box=sim_box): + """Test RangeSpec instantiation with valid inputs.""" + for rs, ranges in TESTCASES_VALID: + with self.subTest(rs=rs): self.assertEqual(rs.ranges, ranges) rs.check() - pypicongpu_rs = rs.get_as_pypicongpu(sim_box) - self.assertIsInstance(pypicongpu_rs, PyPIConGPURangeSpec) - self.assertEqual(pypicongpu_rs.ranges, pypicongpu_ranges) - self.assertEqual(pypicongpu_rs.get_rendering_context()["ranges"], serialized) def test_rangespec_invalid(self): - """Test invalid RangeSpec inputs and simulation box.""" + """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) - rs = RangeSpec[0:10, 5:15] - with self.assertRaisesRegex(ValueError, "Number of range specifications"): - rs.get_as_pypicongpu((20,)) - with self.assertRaisesRegex(ValueError, "Dimension size must be positive"): - rs.get_as_pypicongpu((20, 0)) def test_rangespec_warning(self): - """Test warnings for empty ranges.""" + """Test warnings for empty or invalid ranges.""" for rs, warning in TESTCASES_WARNING: with self.subTest(rs=rs, warning=warning): with self.assertWarnsRegex(UserWarning, warning):