Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/python/picongpu/picmi/diagnostics/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .png import Png
from .timestepspec import TimeStepSpec
from .checkpoint import Checkpoint
from .rangespec import RangeSpec

__all__ = [
"Auto",
Expand All @@ -23,4 +24,5 @@
"Png",
"TimeStepSpec",
"Checkpoint",
"RangeSpec",
]
127 changes: 127 additions & 0 deletions lib/python/picongpu/picmi/diagnostics/rangespec.py
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why 20 here?

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)
2 changes: 2 additions & 0 deletions lib/python/picongpu/pypicongpu/output/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .png import Png
from .timestepspec import TimeStepSpec
from .checkpoint import Checkpoint
from .rangespec import RangeSpec

__all__ = [
"Auto",
Expand All @@ -14,4 +15,5 @@
"Png",
"TimeStepSpec",
"Checkpoint",
"RangeSpec",
]
56 changes: 56 additions & 0 deletions lib/python/picongpu/pypicongpu/output/rangespec.py
Original file line number Diff line number Diff line change
@@ -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))}
35 changes: 35 additions & 0 deletions share/picongpu/pypicongpu/schema/output/rangespec.RangeSpec.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
3 changes: 2 additions & 1 deletion test/python/picongpu/quick/picmi/diagnostics/__init__.py
Original file line number Diff line number Diff line change
@@ -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
61 changes: 61 additions & 0 deletions test/python/picongpu/quick/picmi/diagnostics/rangespec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""
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)
rs.check()

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):
with self.assertWarnsRegex(UserWarning, warning):
rs.check()


if __name__ == "__main__":
unittest.main()
1 change: 1 addition & 0 deletions test/python/picongpu/quick/pypicongpu/output/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
66 changes: 66 additions & 0 deletions test/python/picongpu/quick/pypicongpu/output/rangespec.py
Original file line number Diff line number Diff line change
@@ -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()