Skip to content

Commit c924be1

Browse files
authored
Add PlaceholderName (#60)
1 parent 7ff3bb6 commit c924be1

6 files changed

Lines changed: 198 additions & 7 deletions

File tree

src/pals/kinds/PlaceholderName.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from pydantic import BaseModel, Field, model_serializer
2+
from typing import Annotated
3+
4+
from .mixin import BaseElement
5+
6+
7+
class PlaceholderName(BaseModel):
8+
"""Represents a reference to a named element.
9+
10+
This placeholder is replaced with a physically distinct
11+
element during beamline expansion.
12+
13+
This class behaves like a string (via __str__ and __eq__) but stores
14+
a true reference to the actual element object once it's resolved.
15+
16+
The element field holds a reference (not a copy) to the actual element.
17+
18+
Attributes:
19+
name: The name of the referenced element
20+
element: A reference to the resolved element object (None until resolved)
21+
22+
Example:
23+
>>> ref = PlaceholderName(name="drift1")
24+
>>> ref.name
25+
'drift1'
26+
>>> str(ref)
27+
'drift1'
28+
>>> ref == "drift1"
29+
True
30+
>>> ref.element # None until resolved
31+
>>> drift = pals.Drift(name="drift1", length=1.0)
32+
>>> ref.element = drift
33+
>>> ref.is_resolved()
34+
True
35+
>>> ref.element is drift # True - it's a reference, not a copy
36+
True
37+
"""
38+
39+
name: str = Field(..., description="The name of the referenced element")
40+
element: Annotated[
41+
"BaseElement | None",
42+
Field(default=None, description="Reference to the resolved element object"),
43+
] = None
44+
45+
@model_serializer(mode="plain")
46+
def _serialize_as_name(self) -> str:
47+
"""Serialize this reference as just its name.
48+
49+
This makes `model_dump()` return a string (the element name), so nested
50+
serialization (e.g. inside BeamLine.line) produces plain strings too.
51+
"""
52+
return self.name
53+
54+
def __init__(self, name: str | None = None, /, **data):
55+
"""Initialize with either positional name or keyword arguments."""
56+
if name is not None:
57+
super().__init__(name=name, **data)
58+
else:
59+
super().__init__(**data)
60+
61+
def __str__(self) -> str:
62+
"""Return the element name as string."""
63+
return self.name
64+
65+
def __eq__(self, other: object) -> bool:
66+
"""Enable string comparison."""
67+
if isinstance(other, str):
68+
return self.name == other
69+
if isinstance(other, PlaceholderName):
70+
return self.name == other.name and self.element is other.element
71+
return False
72+
73+
def __hash__(self) -> int:
74+
"""Make hashable like a string."""
75+
return hash(self.name)
76+
77+
def is_resolved(self) -> bool:
78+
"""Check if this reference has been resolved to an actual element."""
79+
return self.element is not None
80+
81+
def __repr__(self) -> str:
82+
"""Return a representation of the PlaceholderName."""
83+
resolved = "resolved" if self.is_resolved() else "unresolved"
84+
return f"PlaceholderName('{self.name}', {resolved})"

src/pals/kinds/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from .NullEle import NullEle # noqa: F401
2727
from .Octupole import Octupole # noqa: F401
2828
from .Patch import Patch # noqa: F401
29+
from .PlaceholderName import PlaceholderName # noqa: F401
2930
from .Quadrupole import Quadrupole # noqa: F401
3031
from .RBend import RBend # noqa: F401
3132
from .RFCavity import RFCavity # noqa: F401

src/pals/kinds/all_elements.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@
44
avoiding duplication between BeamLine.line and UnionEle.elements.
55
"""
66

7-
from typing import Annotated, Union
7+
from typing import Union
88

9-
from pydantic import Field
109

1110
from .ACKicker import ACKicker
1211
from .BeamBeam import BeamBeam
@@ -15,6 +14,7 @@
1514
from .CrabCavity import CrabCavity
1615
from .Drift import Drift
1716
from .EGun import EGun
17+
from .PlaceholderName import PlaceholderName
1818
from .Feedback import Feedback
1919
from .Fiducial import Fiducial
2020
from .FloorShift import FloorShift
@@ -83,6 +83,13 @@ def get_all_element_types(extra_types: tuple = None):
8383

8484

8585
def get_all_elements_as_annotation(extra_types: tuple = None):
86-
"""Return the Union type of all allowed elements with their name as the discriminator field."""
87-
types = get_all_element_types(extra_types)
88-
return Annotated[Union[types], Field(discriminator="kind")]
86+
"""Return the Union type of all allowed elements with their kind as the discriminator field.
87+
88+
Note: PlaceholderName is included to support string references to named elements.
89+
Since PlaceholderName doesn't have a 'kind' field, we cannot use discriminator.
90+
Pydantic will still properly validate the union by trying each type in order in
91+
our unpack_element_list_structure method.
92+
"""
93+
types = get_all_element_types(extra_types) + (PlaceholderName,)
94+
# We can't use discriminator with PlaceholderName in the union since it has no 'kind' field
95+
return Union[types]

src/pals/kinds/mixin/all_element_mixin.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
from . import BaseElement
8+
from ..PlaceholderName import PlaceholderName
89

910

1011
def unpack_element_list_structure(
@@ -44,7 +45,14 @@ def unpack_element_list_structure(
4445
for item in data[field_name]:
4546
# An element can be a string that refers to another element
4647
if isinstance(item, str):
47-
raise RuntimeError("Reference/alias elements not yet implemented")
48+
# Wrap the string in a Placeholder name object
49+
new_list.append(PlaceholderName(item))
50+
continue
51+
# An element can be a PlaceholderName instance directly
52+
elif isinstance(item, PlaceholderName):
53+
# Keep the PlaceholderName as-is
54+
new_list.append(item)
55+
continue
4856
# An element can be a dict
4957
elif isinstance(item, dict):
5058
if not (len(item) == 1):
@@ -69,7 +77,7 @@ def unpack_element_list_structure(
6977
continue
7078

7179
raise TypeError(
72-
f"Value must be a reference string or a dict, but we got {item!r}"
80+
f"Value must be a reference string, PlaceholderName, or a dict, but we got {item!r}"
7381
)
7482

7583
data[field_name] = new_list

src/pals/schema_version.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from typing import Optional
2+
3+
# PALS schema version - null for now, will be set when version scheme is finalized
4+
PALS_SCHEMA_VERSION: Optional[str] = None

tests/test_elements.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,3 +537,90 @@ def test_Lattice():
537537
assert lattice.branches[1].name == "line2"
538538
assert lattice.branches[0].line == [element1, element2, element3]
539539
assert lattice.branches[1].line == [element3]
540+
541+
542+
def test_BeamLine_with_string_references():
543+
"""Test BeamLine with string references to elements defined elsewhere"""
544+
import yaml
545+
546+
# Test YAML deserialization with string references
547+
yaml_data = """
548+
fodo_cell:
549+
kind: BeamLine
550+
line:
551+
- drift1
552+
- quad1
553+
- drift2:
554+
kind: Drift
555+
length: 0.5
556+
"""
557+
558+
data = yaml.safe_load(yaml_data)
559+
beamline = pals.BeamLine(**data)
560+
561+
assert beamline.name == "fodo_cell"
562+
assert len(beamline.line) == 3
563+
564+
# First element should be a PlaceholderName that behaves like the string "drift1"
565+
assert isinstance(beamline.line[0], pals.PlaceholderName)
566+
assert beamline.line[0] == "drift1"
567+
assert beamline.line[0].name == "drift1"
568+
assert beamline.line[0].element is None # Not yet resolved
569+
assert not beamline.line[0].is_resolved()
570+
571+
# Second element should be a PlaceholderName that behaves like the string "quad1"
572+
assert isinstance(beamline.line[1], pals.PlaceholderName)
573+
assert beamline.line[1] == "quad1"
574+
assert beamline.line[1].name == "quad1"
575+
assert beamline.line[1].element is None # Not yet resolved
576+
assert not beamline.line[1].is_resolved()
577+
578+
# Third element should be a Drift object
579+
assert isinstance(beamline.line[2], pals.Drift)
580+
assert beamline.line[2].name == "drift2"
581+
assert beamline.line[2].length == 0.5
582+
583+
# Test that we can resolve the reference later
584+
drift_element = pals.Drift(name="drift1", length=1.0)
585+
beamline.line[0].element = drift_element
586+
assert beamline.line[0].is_resolved()
587+
assert beamline.line[0].element.name == "drift1"
588+
assert beamline.line[0].element.length == 1.0
589+
590+
591+
def test_PlaceholderName_direct():
592+
"""Test PlaceholderName creation and behavior directly"""
593+
# Test creation with positional argument
594+
ref1 = pals.PlaceholderName("test_element")
595+
assert ref1.name == "test_element"
596+
assert str(ref1) == "test_element"
597+
assert ref1 == "test_element"
598+
assert not ref1.is_resolved()
599+
600+
# Test creation with keyword argument
601+
ref2 = pals.PlaceholderName(name="another_element")
602+
assert ref2.name == "another_element"
603+
assert str(ref2) == "another_element"
604+
assert ref2 == "another_element"
605+
606+
# Test hash (for use in sets/dicts)
607+
ref_set = {ref1, ref2}
608+
assert len(ref_set) == 2
609+
assert ref1 in ref_set
610+
611+
# Test resolution
612+
drift = pals.Drift(name="test_element", length=2.5)
613+
ref1.element = drift
614+
assert ref1.is_resolved()
615+
assert ref1.element.length == 2.5
616+
617+
# Test repr
618+
assert "test_element" in repr(ref1)
619+
assert "resolved" in repr(ref1)
620+
assert "unresolved" in repr(ref2)
621+
622+
# Test that element is a reference, not a copy
623+
assert ref1.element is drift # Same object identity
624+
# Modify the original and verify the reference sees the change
625+
drift.length = 3.0
626+
assert ref1.element.length == 3.0 # Change is visible through reference

0 commit comments

Comments
 (0)