-
Notifications
You must be signed in to change notification settings - Fork 225
adding RangeSpec to picongpu #5485
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mafshari64
wants to merge
2
commits into
ComputationalRadiationPhysics:dev
Choose a base branch
from
mafshari64:rangespec
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
35
share/picongpu/pypicongpu/schema/output/rangespec.RangeSpec.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why
20
here?