Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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()