Skip to content

Commit 776e747

Browse files
authored
Implement ElectricMultipoleParameters (#49)
Close #38. To do: - [x] Implement new class `ElectricMultipoleParameters`. - [x] Add new tests and make existing tests more symmetric.
1 parent 35ed73a commit 776e747

File tree

4 files changed

+122
-9
lines changed

4 files changed

+122
-9
lines changed

src/pals/kinds/Quadrupole.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from typing import Literal, Optional
22

3+
from pydantic import model_validator
4+
35
from .mixin import ThickElement
46
from ..parameters import MagneticMultipoleParameters, ElectricMultipoleParameters
57

@@ -11,5 +13,14 @@ class Quadrupole(ThickElement):
1113
kind: Literal["Quadrupole"] = "Quadrupole"
1214

1315
# Quadrupole-specific parameters
16+
MagneticMultipoleP: Optional[MagneticMultipoleParameters] = None
1417
ElectricMultipoleP: Optional[ElectricMultipoleParameters] = None
15-
MagneticMultipoleP: MagneticMultipoleParameters
18+
19+
@model_validator(mode="after")
20+
def validate_at_least_one_multipole(self) -> "Quadrupole":
21+
"""Ensure at least one multipole parameter is specified."""
22+
if self.MagneticMultipoleP is None and self.ElectricMultipoleP is None:
23+
raise ValueError(
24+
"At least one of 'MagneticMultipoleP' or 'ElectricMultipoleP' must be specified"
25+
)
26+
return self
Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,63 @@
1-
from pydantic import BaseModel, ConfigDict
1+
from pydantic import BaseModel, ConfigDict, model_validator
2+
from typing import Any
3+
4+
# Valid parameter prefixes, their expected format and description
5+
_PARAMETER_PREFIXES = {
6+
"tilt": ("tiltN", "Tilt"),
7+
"En": ("EnN", "Normal component"),
8+
"Es": ("EsN", "Skew component"),
9+
}
10+
11+
12+
def _validate_order(
13+
key_num: str, parameter_name: str, prefix: str, expected_format: str
14+
) -> None:
15+
"""Validate that the order number is a non-negative integer without leading zeros."""
16+
error_msg = (
17+
f"Invalid {parameter_name}: '{prefix}{key_num}'. "
18+
f"Parameter must be of the form '{expected_format}', where 'N' is a non-negative integer without leading zeros."
19+
)
20+
if not key_num.isdigit() or (key_num.startswith("0") and key_num != "0"):
21+
raise ValueError(error_msg)
222

323

424
class ElectricMultipoleParameters(BaseModel):
5-
"""Electric multipole parameters"""
25+
"""Electric multipole parameters
26+
27+
Valid parameter formats:
28+
- tiltN: Tilt of Nth order multipole
29+
- EnN: Normal component of Nth order multipole
30+
- EsN: Skew component of Nth order multipole
31+
- *NL: Length-integrated versions of components (e.g., En3L, EsNL)
32+
33+
Where N is a positive integer without leading zeros (except "0" itself).
34+
"""
635

7-
# Allow arbitrary fields (TODO: remove this)
836
model_config = ConfigDict(extra="allow")
937

10-
# TODO: add ElectricMultipoleParameters in a follow-up RP
11-
# https://pals-project.readthedocs.io/en/latest/element-parameters.html#electricmultipolep-electric-multipole-parameters
38+
@model_validator(mode="before")
39+
@classmethod
40+
def validate(cls, values: dict[str, Any]) -> dict[str, Any]:
41+
"""Validate all parameter names match the expected multipole format."""
42+
for key in values:
43+
# Check if key ends with 'L' for length-integrated values
44+
is_length_integrated = key.endswith("L")
45+
base_key = key[:-1] if is_length_integrated else key
46+
47+
# No length-integrated values allowed for tilt parameter
48+
if is_length_integrated and base_key.startswith("tilt"):
49+
raise ValueError(f"Invalid electric multipole parameter: '{key}'. ")
50+
51+
# Find matching prefix
52+
for prefix, (expected_format, description) in _PARAMETER_PREFIXES.items():
53+
if base_key.startswith(prefix):
54+
key_num = base_key[len(prefix) :]
55+
_validate_order(key_num, description, prefix, expected_format)
56+
break
57+
else:
58+
raise ValueError(
59+
f"Invalid electric multipole parameter: '{key}'. "
60+
f"Parameters must be of the form 'tiltN', 'EnN', or 'EsN' "
61+
f"(with optional 'L' suffix for length-integrated), where 'N' is a non-negative integer."
62+
)
63+
return values

tests/test_elements.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ def test_Quadrupole():
5555
# Create one drift element with custom name and length
5656
element_name = "quadrupole_element"
5757
element_length = 1.0
58+
# Magnetic multipole parameters
5859
element_magnetic_multipole_Bn1 = 1.1
5960
element_magnetic_multipole_Bn2 = 1.2
6061
element_magnetic_multipole_Bs1 = 2.1
@@ -69,10 +70,26 @@ def test_Quadrupole():
6970
Bs2=element_magnetic_multipole_Bs2,
7071
tilt2=element_magnetic_multipole_tilt2,
7172
)
73+
# Electric multipole parameters
74+
element_electric_multipole_En1 = 1.1
75+
element_electric_multipole_En2 = 1.2
76+
element_electric_multipole_Es1 = 2.1
77+
element_electric_multipole_Es2 = 2.2
78+
element_electric_multipole_tilt1 = 3.1
79+
element_electric_multipole_tilt2 = 3.2
80+
element_electric_multipole = pals.ElectricMultipoleParameters(
81+
En1=element_electric_multipole_En1,
82+
Es1=element_electric_multipole_Es1,
83+
tilt1=element_electric_multipole_tilt1,
84+
En2=element_electric_multipole_En2,
85+
Es2=element_electric_multipole_Es2,
86+
tilt2=element_electric_multipole_tilt2,
87+
)
7288
element = pals.Quadrupole(
7389
name=element_name,
7490
length=element_length,
7591
MagneticMultipoleP=element_magnetic_multipole,
92+
ElectricMultipoleP=element_electric_multipole,
7693
)
7794
assert element.name == element_name
7895
assert element.length == element_length
@@ -82,6 +99,12 @@ def test_Quadrupole():
8299
assert element.MagneticMultipoleP.Bn2 == element_magnetic_multipole_Bn2
83100
assert element.MagneticMultipoleP.Bs2 == element_magnetic_multipole_Bs2
84101
assert element.MagneticMultipoleP.tilt2 == element_magnetic_multipole_tilt2
102+
assert element.ElectricMultipoleP.En1 == element_electric_multipole_En1
103+
assert element.ElectricMultipoleP.Es1 == element_electric_multipole_Es1
104+
assert element.ElectricMultipoleP.tilt1 == element_electric_multipole_tilt1
105+
assert element.ElectricMultipoleP.En2 == element_electric_multipole_En2
106+
assert element.ElectricMultipoleP.Es2 == element_electric_multipole_Es2
107+
assert element.ElectricMultipoleP.tilt2 == element_electric_multipole_tilt2
85108
# Serialize the BeamLine object to YAML
86109
yaml_data = yaml.dump(element.model_dump(), default_flow_style=False)
87110
print(f"\n{yaml_data}")
@@ -117,12 +140,14 @@ def test_Sextupole():
117140
name="sext1",
118141
length=0.5,
119142
MagneticMultipoleP=pals.MagneticMultipoleParameters(Bn2=1.0),
143+
ElectricMultipoleP=pals.ElectricMultipoleParameters(En2=1.0),
120144
ApertureP=pals.ApertureParameters(x_limits=[-0.1, 0.1]),
121145
)
122146
assert element.name == "sext1"
123147
assert element.length == 0.5
124148
assert element.kind == "Sextupole"
125149
assert element.MagneticMultipoleP.Bn2 == 1.0
150+
assert element.ElectricMultipoleP.En2 == 1.0
126151
assert element.ApertureP.x_limits == [-0.1, 0.1]
127152

128153

@@ -131,12 +156,14 @@ def test_Octupole():
131156
element = pals.Octupole(
132157
name="oct1",
133158
length=0.3,
159+
MagneticMultipoleP=pals.MagneticMultipoleParameters(Bn3=0.5),
134160
ElectricMultipoleP=pals.ElectricMultipoleParameters(En3=0.5),
135161
MetaP=pals.MetaParameters(alias="octupole_test"),
136162
)
137163
assert element.name == "oct1"
138164
assert element.length == 0.3
139165
assert element.kind == "Octupole"
166+
assert element.MagneticMultipoleP.Bn3 == 0.5
140167
assert element.ElectricMultipoleP.En3 == 0.5
141168
assert element.MetaP.alias == "octupole_test"
142169

@@ -147,12 +174,16 @@ def test_Multipole():
147174
name="mult1",
148175
length=0.4,
149176
MagneticMultipoleP=pals.MagneticMultipoleParameters(Bn1=2.0, Bn2=1.5),
177+
ElectricMultipoleP=pals.ElectricMultipoleParameters(En1=2.0, En2=1.5),
150178
BodyShiftP=pals.BodyShiftParameters(x_offset=0.01),
151179
)
152180
assert element.name == "mult1"
153181
assert element.length == 0.4
154182
assert element.kind == "Multipole"
155183
assert element.MagneticMultipoleP.Bn1 == 2.0
184+
assert element.MagneticMultipoleP.Bn2 == 1.5
185+
assert element.ElectricMultipoleP.En1 == 2.0
186+
assert element.ElectricMultipoleP.En2 == 1.5
156187
assert element.BodyShiftP.x_offset == 0.01
157188

158189

tests/test_parameters.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
BeamBeamParameters,
77
BendParameters,
88
BodyShiftParameters,
9+
ElectricMultipoleParameters,
910
FloorShiftParameters,
1011
ForkParameters,
1112
MagneticMultipoleParameters,
@@ -38,9 +39,27 @@ def test_ParameterClasses():
3839
meta = MetaParameters(alias="test", description="test element")
3940
assert meta.alias == "test"
4041

41-
# Test ElectricMultipoleParameters (TODO)
42-
# emp = ElectricMultipoleParameters(En1=1.0, Es1=0.5)
43-
# assert emp.En1 == 1.0
42+
# Test ElectricMultipoleParameters
43+
emp = ElectricMultipoleParameters(tilt1=1.2, En1=1.0, Es1=0.5)
44+
assert emp.tilt1 == 1.2
45+
assert emp.En1 == 1.0
46+
assert emp.Es1 == 0.5
47+
48+
emp2 = ElectricMultipoleParameters(En1L=1.0, Es1L=0.5)
49+
assert emp2.En1L == 1.0
50+
assert emp2.Es1L == 0.5
51+
52+
# catch typos
53+
with pytest.raises(ValidationError):
54+
_ = ElectricMultipoleParameters(Em1=1.0, Es1=0.5)
55+
with pytest.raises(ValidationError):
56+
_ = ElectricMultipoleParameters(En1=1.0, Ev1=0.5)
57+
with pytest.raises(ValidationError):
58+
_ = ElectricMultipoleParameters(En01=1.0, Es01=0.5)
59+
with pytest.raises(ValidationError):
60+
_ = ElectricMultipoleParameters(En1v=1.0, Es1l=0.5)
61+
with pytest.raises(ValidationError):
62+
_ = ElectricMultipoleParameters(tilt1L=1.2)
4463

4564
# Test MagneticMultipoleParameters
4665
mmp = MagneticMultipoleParameters(tilt1=1.2, Bn1=1.0, Bs1=0.5)

0 commit comments

Comments
 (0)