From bc317d176c26d37635bb532cd5ca23407568d03a Mon Sep 17 00:00:00 2001 From: Ivan Morozov Date: Wed, 1 Apr 2026 11:04:48 +0200 Subject: [PATCH 1/3] support shifted quadrupole modeled as an sbend --- .github/workflows/pySC_tests.yml | 29 +++++++++++ .github/workflows/pyaml_tests.yml | 40 +++++++++++++++ pySC/configuration/magnets_conf.py | 32 +++++++++--- pySC/core/magnet.py | 40 +++++++++++++++ pySC/core/magnetsettings.py | 8 ++- pySC/core/supports.py | 5 ++ tests/core/test_magnet.py | 81 +++++++++++++++++++++++++++++- 7 files changed, 227 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/pySC_tests.yml create mode 100644 .github/workflows/pyaml_tests.yml diff --git a/.github/workflows/pySC_tests.yml b/.github/workflows/pySC_tests.yml new file mode 100644 index 00000000..c84a407d --- /dev/null +++ b/.github/workflows/pySC_tests.yml @@ -0,0 +1,29 @@ +name: Tests + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + tests: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.12", "3.13", "3.14"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package with test dependencies + run: pip install ".[test]" + + - name: Run tests + run: pytest --cov diff --git a/.github/workflows/pyaml_tests.yml b/.github/workflows/pyaml_tests.yml new file mode 100644 index 00000000..fa825d66 --- /dev/null +++ b/.github/workflows/pyaml_tests.yml @@ -0,0 +1,40 @@ +name: pyAML tests + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + tests: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.12", "3.14"] + + steps: + - uses: actions/checkout@v4 + + - name: Clone second repository + uses: actions/checkout@v4 + with: + repository: python-accelerator-middle-layer/pyaml + path: pyaml + ref: main + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package with test dependencies + run: | + pip install "./pyaml[test]" + pip install ./pyaml/tests/dummy_cs/tango-pyaml + pip install ".[test]" + + - name: Run tests + working-directory: pyaml + run: pytest -k "tuning" diff --git a/pySC/configuration/magnets_conf.py b/pySC/configuration/magnets_conf.py index ea65c2b5..4ad00b5b 100644 --- a/pySC/configuration/magnets_conf.py +++ b/pySC/configuration/magnets_conf.py @@ -1,4 +1,5 @@ from typing import Any +import math from ..core.simulated_commissioning import SimulatedCommissioning from ..core.lattice import ATLattice from ..core.magnet import MAGNET_NAME_TYPE @@ -18,6 +19,20 @@ def generate_default_magnet_control(SC: SimulatedCommissioning, index: int, magn components_to_invert = dict.get(magnet_category_conf, 'invert', []).copy() # defaults to empty list if not declared # we need to copy because we remove elements later to check for undeclared components to invert + is_shifted = magnet_category_conf.get('shifted', False) + bending_angle = SC.lattice.get_bending_angle(index) + bending_length = SC.lattice.get_length(index) + magnet_length = bending_length + design_shift = 0.0 + if is_shifted: + element = SC.lattice.design[index] + component_value = getattr(element, 'PolynomB')[1] + if not SC.lattice.is_dipole(index): + raise ValueError(f"magnets/{magnet_category_name}: shifted=true requires an sbend with non-zero quad component at index {index} ({magnet_name}).") + radius = bending_length/bending_angle + magnet_length = 2*abs(radius)*abs(math.sin(bending_angle/2)) + design_shift = bending_angle/(component_value*bending_length) + if 'components' in magnet_category_conf: components = [] cal_errors = [] @@ -26,11 +41,16 @@ def generate_default_magnet_control(SC: SimulatedCommissioning, index: int, magn components.append(component) cal_errors.append(cal_error) - magnet_length = SC.lattice.get_length(index) magnet_settings.add_individually_powered_magnet( - sim_index=index, controlled_components=components, - magnet_name=magnet_name, magnet_length=magnet_length, - to_design=to_design) + sim_index=index, + controlled_components=components, + magnet_name=magnet_name, + magnet_length=magnet_length, + is_shifted=is_shifted, + bending_length=bending_length, + design_shift=design_shift, + to_design=to_design + ) for component, cal_error in zip(components, cal_errors): control_name = f'{magnet_name}/{component}' @@ -59,7 +79,7 @@ def generate_default_magnet_control(SC: SimulatedCommissioning, index: int, magn offset = 0 setpoint = SC.lattice.get_magnet_component(index, component_type=component_type, order=order) if component[-1] == 'L': - length = SC.lattice.get_length(index) + length = magnet_length setpoint = setpoint * length if component in components_to_invert: @@ -113,4 +133,4 @@ def configure_magnets(SC: SimulatedCommissioning): SC.magnet_settings.connect_links() SC.magnet_settings.sendall() SC.design_magnet_settings.connect_links() - SC.design_magnet_settings.sendall() \ No newline at end of file + SC.design_magnet_settings.sendall() diff --git a/pySC/core/magnet.py b/pySC/core/magnet.py index 47053c30..2ef3241a 100644 --- a/pySC/core/magnet.py +++ b/pySC/core/magnet.py @@ -1,5 +1,6 @@ from __future__ import annotations from typing import Literal, Optional, Union, Any +import math from pydantic import BaseModel, model_validator, PrivateAttr, PositiveInt, NonNegativeInt import logging from .control import Control, LinearConv @@ -30,6 +31,9 @@ class Magnet(BaseModel, extra="forbid"): offset_B: Optional[list[float]] = None to_design: bool = False length: Optional[float] = None + is_shifted: bool = False + bending_length: Optional[float] = None + design_shift: float = 0.0 _links: list[ControlMagnetLink] = PrivateAttr(default=[]) _parent = PrivateAttr(default=None) @@ -112,8 +116,44 @@ def update(self): f"Invalid component '{link.component}' for magnet '{self.name}'" ) + if self.is_shifted: + assert self.length is not None, f"ERROR: quadrupole length not specified for shifted magnet: {repr(self)}" + shift = self.design_shift + if not self.to_design: + dx, _ = self._parent._parent.support_system.get_total_offset(self.sim_index) + shift += dx + k1 = self.B[1] + bending_angle = 2*math.asin(shift*k1*self.length/2) + arc_length = bending_angle/(shift*k1) + element = self._parent._parent.lattice.design[self.sim_index] if self.to_design else self._parent._parent.lattice.ring[self.sim_index] + element.Length = arc_length + element.BendingAngle = bending_angle + element.EntranceAngle = element.ExitAngle = bending_angle/2 + self.bending_length = arc_length + for ii in range(self.max_order + 1): self._parent._parent.lattice.set_magnet_component( self.sim_index, self.A[ii], 'A', ii, use_design=self.to_design) self._parent._parent.lattice.set_magnet_component( self.sim_index, self.B[ii], 'B', ii, use_design=self.to_design) + + if self.is_shifted: + + if self.to_design: + dx = dy = dz = 0.0 + roll = yaw = pitch = 0.0 + else: + dx, dy = self._parent._parent.support_system.get_total_offset(self.sim_index) + dz = self._parent._parent.support_system.data['L0'][self.sim_index].dz + roll, pitch, yaw = self._parent._parent.support_system.get_total_rotation(self.sim_index) + + self._parent._parent.lattice.update_misalignment( + self.sim_index, + dx=dx, + dy=dy, + dz=dz, + roll=roll, + yaw=yaw, + pitch=pitch, + use_design=self.to_design + ) diff --git a/pySC/core/magnetsettings.py b/pySC/core/magnetsettings.py index 792b5e10..0be1c148 100644 --- a/pySC/core/magnetsettings.py +++ b/pySC/core/magnetsettings.py @@ -94,6 +94,9 @@ def add_individually_powered_magnet(self, controlled_components: list[str], magnet_name: Optional[str] = None, magnet_length: Optional[float] = None, + is_shifted: bool = False, + bending_length: Optional[float] = None, + design_shift: float = 0.0, to_design: bool = False) -> None: """ Add a magnet with individually powered components. @@ -112,7 +115,10 @@ def add_individually_powered_magnet(self, sim_index=sim_index, max_order=max_order, to_design=to_design, - length=magnet_length) + length=magnet_length, + is_shifted=is_shifted, + bending_length=bending_length, + design_shift=design_shift) magnet._parent = self # Set the parent to the current settings instance # check non-zero components that are not controlled and put them in offset_A/B. diff --git a/pySC/core/supports.py b/pySC/core/supports.py index cd68b7da..fc387373 100644 --- a/pySC/core/supports.py +++ b/pySC/core/supports.py @@ -329,6 +329,11 @@ def trigger_update(self, level: str, index): else: self._parent.lattice.update_misalignment(index=eo.index, dx=dx, dy=dy, dz=dz, roll=roll, yaw=yaw, pitch=pitch) + magnet_name = self._parent.magnet_settings.index_mapping.get(eo.index) + if magnet_name is not None: + magnet = self._parent.magnet_settings.magnets[magnet_name] + if magnet.is_shifted: + magnet.update() def update_all(self) -> None: for index in self.data['L0'].keys(): diff --git a/tests/core/test_magnet.py b/tests/core/test_magnet.py index 4ae2dba5..626a5e84 100644 --- a/tests/core/test_magnet.py +++ b/tests/core/test_magnet.py @@ -1,7 +1,7 @@ """Tests for pySC.core.magnet: Magnet, ControlMagnetLink.""" import pytest +import math from unittest.mock import MagicMock, PropertyMock - from pySC.core.magnet import Magnet, ControlMagnetLink from pySC.core.control import Control, LinearConv @@ -118,3 +118,82 @@ def test_magnet_update_no_length_raises(): with pytest.raises(AssertionError, match="magnet length not specified"): m.update() + + +def test_shifted_magnet_update(): + + # shifted quadrupole is modeled as sbend (bengind and wedge angles) + # sbend parameters (length and angles) are computed from design quadrupole strength and shift value + # shift and strength changes trigger update of sbend lengthe and angles + + design_arc_length = 0.25 + design_shift = 0.01 + initial_k1 = 5.0 + initial_bending_angle = design_shift*initial_k1*design_arc_length + radius = design_arc_length/initial_bending_angle + quadrupole_length = 2*radius *math.sin(initial_bending_angle/2) + + m, parent = _make_magnet_with_parent(max_order=1, length=quadrupole_length) + m.is_shifted = True + m.bending_length = design_arc_length + m.design_shift = design_shift + + element = MagicMock() + element.Length = design_arc_length + element.BendingAngle = 0.0 + element.EntranceAngle = 0.0 + element.ExitAngle = 0.0 + + support_system = MagicMock() + support_system.get_total_offset.return_value = (0.0, 0.0) + support_system.get_total_rotation.return_value = (0.0, 0.0, 0.0) + support_system.data = {'L0': {0: MagicMock(dz=0.0)}} + parent._parent.support_system = support_system + parent._parent.lattice.ring = {0: element} + parent._parent.lattice.design = {0: element} + + ctrl = Control(name="c1", setpoint=initial_k1*quadrupole_length) + parent.controls["c1"] = ctrl + link = ControlMagnetLink(link_name="lk1", magnet_name=0, control_name="c1", component="B", order=2, is_integrated=True) + m._links = [link] + + m.update() + + expected_k1 = initial_k1 + expected_angle = initial_bending_angle + expected_arc_length = design_arc_length + assert m.B[1] == pytest.approx(expected_k1) + assert element.Length == pytest.approx(expected_arc_length) + assert element.BendingAngle == pytest.approx(expected_angle) + assert element.EntranceAngle == pytest.approx(expected_angle/2) + assert element.ExitAngle == pytest.approx(expected_angle/2) + assert m.length == pytest.approx(quadrupole_length) + assert m.bending_length == pytest.approx(expected_arc_length) + + ctrl.setpoint = 5.2*quadrupole_length + m.update() + + expected_k1 = 5.2 + expected_angle = 2*math.asin(design_shift*expected_k1*quadrupole_length/2) + expected_arc_length = expected_angle/(design_shift*expected_k1) + assert m.B[1] == pytest.approx(expected_k1) + assert element.Length == pytest.approx(expected_arc_length) + assert element.BendingAngle == pytest.approx(expected_angle) + assert element.EntranceAngle == pytest.approx(expected_angle/2) + assert element.ExitAngle == pytest.approx(expected_angle/2) + assert m.length == pytest.approx(quadrupole_length) + assert m.bending_length == pytest.approx(expected_arc_length) + + support_system.get_total_offset.return_value = (0.001, 0.0) + m.update() + + total_shift = design_shift + 0.001 + expected_angle = 2*math.asin(total_shift*expected_k1*quadrupole_length/2) + expected_arc_length = expected_angle/(total_shift*expected_k1) + assert m.B[1] == pytest.approx(expected_k1) + assert element.Length == pytest.approx(expected_arc_length) + assert element.BendingAngle == pytest.approx(expected_angle) + assert element.EntranceAngle == pytest.approx(expected_angle/2) + assert element.ExitAngle == pytest.approx(expected_angle/2) + assert m.length == pytest.approx(quadrupole_length) + assert m.bending_length == pytest.approx(expected_arc_length) From d9e23162e4668e44e7385ab934b74de46ea335fc Mon Sep 17 00:00:00 2001 From: Ivan Morozov Date: Thu, 2 Apr 2026 12:44:08 +0200 Subject: [PATCH 2/3] 04_02_2026: use PolynomB to propagate changes in shifted quads --- pySC/configuration/magnets_conf.py | 3 ++ pySC/core/magnet.py | 10 +---- pySC/core/magnetsettings.py | 4 +- tests/core/test_magnet.py | 68 +++++++++++++++--------------- 4 files changed, 41 insertions(+), 44 deletions(-) diff --git a/pySC/configuration/magnets_conf.py b/pySC/configuration/magnets_conf.py index 4ad00b5b..7f6465c2 100644 --- a/pySC/configuration/magnets_conf.py +++ b/pySC/configuration/magnets_conf.py @@ -24,6 +24,7 @@ def generate_default_magnet_control(SC: SimulatedCommissioning, index: int, magn bending_length = SC.lattice.get_length(index) magnet_length = bending_length design_shift = 0.0 + design_k1 = 0.0 if is_shifted: element = SC.lattice.design[index] component_value = getattr(element, 'PolynomB')[1] @@ -32,6 +33,7 @@ def generate_default_magnet_control(SC: SimulatedCommissioning, index: int, magn radius = bending_length/bending_angle magnet_length = 2*abs(radius)*abs(math.sin(bending_angle/2)) design_shift = bending_angle/(component_value*bending_length) + design_k1 = component_value if 'components' in magnet_category_conf: components = [] @@ -49,6 +51,7 @@ def generate_default_magnet_control(SC: SimulatedCommissioning, index: int, magn is_shifted=is_shifted, bending_length=bending_length, design_shift=design_shift, + design_k1=design_k1, to_design=to_design ) diff --git a/pySC/core/magnet.py b/pySC/core/magnet.py index 2ef3241a..b9d5cd7d 100644 --- a/pySC/core/magnet.py +++ b/pySC/core/magnet.py @@ -1,6 +1,5 @@ from __future__ import annotations from typing import Literal, Optional, Union, Any -import math from pydantic import BaseModel, model_validator, PrivateAttr, PositiveInt, NonNegativeInt import logging from .control import Control, LinearConv @@ -34,6 +33,7 @@ class Magnet(BaseModel, extra="forbid"): is_shifted: bool = False bending_length: Optional[float] = None design_shift: float = 0.0 + design_k1: float = 0.0 _links: list[ControlMagnetLink] = PrivateAttr(default=[]) _parent = PrivateAttr(default=None) @@ -123,13 +123,7 @@ def update(self): dx, _ = self._parent._parent.support_system.get_total_offset(self.sim_index) shift += dx k1 = self.B[1] - bending_angle = 2*math.asin(shift*k1*self.length/2) - arc_length = bending_angle/(shift*k1) - element = self._parent._parent.lattice.design[self.sim_index] if self.to_design else self._parent._parent.lattice.ring[self.sim_index] - element.Length = arc_length - element.BendingAngle = bending_angle - element.EntranceAngle = element.ExitAngle = bending_angle/2 - self.bending_length = arc_length + self.B[0] += shift * k1 - self.design_shift * self.design_k1 for ii in range(self.max_order + 1): self._parent._parent.lattice.set_magnet_component( diff --git a/pySC/core/magnetsettings.py b/pySC/core/magnetsettings.py index 0be1c148..e2aeb3dd 100644 --- a/pySC/core/magnetsettings.py +++ b/pySC/core/magnetsettings.py @@ -97,6 +97,7 @@ def add_individually_powered_magnet(self, is_shifted: bool = False, bending_length: Optional[float] = None, design_shift: float = 0.0, + design_k1: float = 0.0, to_design: bool = False) -> None: """ Add a magnet with individually powered components. @@ -118,7 +119,8 @@ def add_individually_powered_magnet(self, length=magnet_length, is_shifted=is_shifted, bending_length=bending_length, - design_shift=design_shift) + design_shift=design_shift, + design_k1=design_k1) magnet._parent = self # Set the parent to the current settings instance # check non-zero components that are not controlled and put them in offset_A/B. diff --git a/tests/core/test_magnet.py b/tests/core/test_magnet.py index 626a5e84..d975b5d9 100644 --- a/tests/core/test_magnet.py +++ b/tests/core/test_magnet.py @@ -1,7 +1,6 @@ """Tests for pySC.core.magnet: Magnet, ControlMagnetLink.""" import pytest -import math -from unittest.mock import MagicMock, PropertyMock +from unittest.mock import MagicMock from pySC.core.magnet import Magnet, ControlMagnetLink from pySC.core.control import Control, LinearConv @@ -121,28 +120,26 @@ def test_magnet_update_no_length_raises(): def test_shifted_magnet_update(): - - # shifted quadrupole is modeled as sbend (bengind and wedge angles) - # sbend parameters (length and angles) are computed from design quadrupole strength and shift value - # shift and strength changes trigger update of sbend lengthe and angles + """Shifted quadrupole feeds down""" design_arc_length = 0.25 design_shift = 0.01 initial_k1 = 5.0 - initial_bending_angle = design_shift*initial_k1*design_arc_length - radius = design_arc_length/initial_bending_angle - quadrupole_length = 2*radius *math.sin(initial_bending_angle/2) + initial_bending_angle = design_shift * initial_k1 * design_arc_length + initial_b0 = 0.02 - m, parent = _make_magnet_with_parent(max_order=1, length=quadrupole_length) + m, parent = _make_magnet_with_parent(max_order=1, length=design_arc_length) m.is_shifted = True m.bending_length = design_arc_length m.design_shift = design_shift + m.design_k1 = initial_k1 + m.offset_B[0] = initial_b0 element = MagicMock() element.Length = design_arc_length - element.BendingAngle = 0.0 - element.EntranceAngle = 0.0 - element.ExitAngle = 0.0 + element.BendingAngle = initial_bending_angle + element.EntranceAngle = initial_bending_angle / 2 + element.ExitAngle = initial_bending_angle / 2 support_system = MagicMock() support_system.get_total_offset.return_value = (0.0, 0.0) @@ -152,7 +149,7 @@ def test_shifted_magnet_update(): parent._parent.lattice.ring = {0: element} parent._parent.lattice.design = {0: element} - ctrl = Control(name="c1", setpoint=initial_k1*quadrupole_length) + ctrl = Control(name="c1", setpoint=initial_k1 * design_arc_length) parent.controls["c1"] = ctrl link = ControlMagnetLink(link_name="lk1", magnet_name=0, control_name="c1", component="B", order=2, is_integrated=True) m._links = [link] @@ -160,40 +157,41 @@ def test_shifted_magnet_update(): m.update() expected_k1 = initial_k1 + expected_b0 = initial_b0 expected_angle = initial_bending_angle - expected_arc_length = design_arc_length assert m.B[1] == pytest.approx(expected_k1) - assert element.Length == pytest.approx(expected_arc_length) + assert m.B[0] == pytest.approx(expected_b0) + assert element.Length == pytest.approx(design_arc_length) assert element.BendingAngle == pytest.approx(expected_angle) assert element.EntranceAngle == pytest.approx(expected_angle/2) assert element.ExitAngle == pytest.approx(expected_angle/2) - assert m.length == pytest.approx(quadrupole_length) - assert m.bending_length == pytest.approx(expected_arc_length) + assert m.length == pytest.approx(design_arc_length) + assert m.bending_length == pytest.approx(design_arc_length) - ctrl.setpoint = 5.2*quadrupole_length + ctrl.setpoint = 5.2 * design_arc_length m.update() expected_k1 = 5.2 - expected_angle = 2*math.asin(design_shift*expected_k1*quadrupole_length/2) - expected_arc_length = expected_angle/(design_shift*expected_k1) + expected_b0 = initial_b0 + design_shift * (expected_k1 - initial_k1) assert m.B[1] == pytest.approx(expected_k1) - assert element.Length == pytest.approx(expected_arc_length) - assert element.BendingAngle == pytest.approx(expected_angle) - assert element.EntranceAngle == pytest.approx(expected_angle/2) - assert element.ExitAngle == pytest.approx(expected_angle/2) - assert m.length == pytest.approx(quadrupole_length) - assert m.bending_length == pytest.approx(expected_arc_length) + assert m.B[0] == pytest.approx(expected_b0) + assert element.Length == pytest.approx(design_arc_length) + assert element.BendingAngle == pytest.approx(initial_bending_angle) + assert element.EntranceAngle == pytest.approx(initial_bending_angle/2) + assert element.ExitAngle == pytest.approx(initial_bending_angle/2) + assert m.length == pytest.approx(design_arc_length) + assert m.bending_length == pytest.approx(design_arc_length) support_system.get_total_offset.return_value = (0.001, 0.0) m.update() total_shift = design_shift + 0.001 - expected_angle = 2*math.asin(total_shift*expected_k1*quadrupole_length/2) - expected_arc_length = expected_angle/(total_shift*expected_k1) + expected_b0 = initial_b0 + total_shift * expected_k1 - design_shift * initial_k1 assert m.B[1] == pytest.approx(expected_k1) - assert element.Length == pytest.approx(expected_arc_length) - assert element.BendingAngle == pytest.approx(expected_angle) - assert element.EntranceAngle == pytest.approx(expected_angle/2) - assert element.ExitAngle == pytest.approx(expected_angle/2) - assert m.length == pytest.approx(quadrupole_length) - assert m.bending_length == pytest.approx(expected_arc_length) + assert m.B[0] == pytest.approx(expected_b0) + assert element.Length == pytest.approx(design_arc_length) + assert element.BendingAngle == pytest.approx(initial_bending_angle) + assert element.EntranceAngle == pytest.approx(initial_bending_angle/2) + assert element.ExitAngle == pytest.approx(initial_bending_angle/2) + assert m.length == pytest.approx(design_arc_length) + assert m.bending_length == pytest.approx(design_arc_length) From cef52a670086b0e2628d595f83910ed784117b43 Mon Sep 17 00:00:00 2001 From: Ivan Morozov Date: Thu, 11 Jun 2026 13:21:44 +0200 Subject: [PATCH 3/3] updated shifted quadrupole implementation --- pySC/configuration/magnets_conf.py | 62 +++++++++++++------ pySC/core/magnet.py | 34 ----------- pySC/core/magnetsettings.py | 10 +-- pySC/core/supports.py | 5 -- tests/configuration/test_magnets_conf.py | 48 +++++++++++++++ tests/core/test_magnet.py | 78 ------------------------ 6 files changed, 91 insertions(+), 146 deletions(-) diff --git a/pySC/configuration/magnets_conf.py b/pySC/configuration/magnets_conf.py index 7f6465c2..8c1cb0cf 100644 --- a/pySC/configuration/magnets_conf.py +++ b/pySC/configuration/magnets_conf.py @@ -1,8 +1,8 @@ from typing import Any -import math from ..core.simulated_commissioning import SimulatedCommissioning from ..core.lattice import ATLattice -from ..core.magnet import MAGNET_NAME_TYPE +from ..core.control import LinearConv +from ..core.magnet import ControlMagnetLink, MAGNET_NAME_TYPE from .general import get_error, get_indices_and_names from .supports_conf import generate_element_misalignments @@ -20,20 +20,12 @@ def generate_default_magnet_control(SC: SimulatedCommissioning, index: int, magn # we need to copy because we remove elements later to check for undeclared components to invert is_shifted = magnet_category_conf.get('shifted', False) - bending_angle = SC.lattice.get_bending_angle(index) - bending_length = SC.lattice.get_length(index) - magnet_length = bending_length - design_shift = 0.0 - design_k1 = 0.0 - if is_shifted: - element = SC.lattice.design[index] - component_value = getattr(element, 'PolynomB')[1] - if not SC.lattice.is_dipole(index): - raise ValueError(f"magnets/{magnet_category_name}: shifted=true requires an sbend with non-zero quad component at index {index} ({magnet_name}).") - radius = bending_length/bending_angle - magnet_length = 2*abs(radius)*abs(math.sin(bending_angle/2)) - design_shift = bending_angle/(component_value*bending_length) - design_k1 = component_value + magnet_length = SC.lattice.get_length(index) + if is_shifted and not SC.lattice.is_dipole(index): + raise ValueError( + f"magnets/{magnet_category_name}: shifted=true expects a dipole " + f"at index {index} ({magnet_name})." + ) if 'components' in magnet_category_conf: components = [] @@ -48,10 +40,6 @@ def generate_default_magnet_control(SC: SimulatedCommissioning, index: int, magn controlled_components=components, magnet_name=magnet_name, magnet_length=magnet_length, - is_shifted=is_shifted, - bending_length=bending_length, - design_shift=design_shift, - design_k1=design_k1, to_design=to_design ) @@ -93,6 +81,40 @@ def generate_default_magnet_control(SC: SimulatedCommissioning, index: int, magn magnet_settings.links[link_name].error.factor = factor magnet_settings.links[link_name].error.offset = offset + if is_shifted: + quad_components = [component for component in components if component in ('B2', 'B2L')] + if len(quad_components) != 1: + raise ValueError(f"magnets/{magnet_category_name}: shifted=true requires B2 or B2L in control") + + component = quad_components[0] + control_name = f'{magnet_name}/{component}' + source_link = magnet_settings.links[f'{control_name}->{control_name}'] + k1 = SC.lattice.get_magnet_component(index, component_type='B', order=1) + if k1 == 0: + raise ValueError(f"magnets/{magnet_category_name}: shifted=true requires a non-zero quadrupole component at index {index} ({magnet_name}).") + + k0 = SC.lattice.get_magnet_component(index, component_type='B', order=0) + reference_k0 = 0.0 + if type(SC.lattice) is ATLattice: + reference_k0 = SC.lattice.get_bending_angle(index) / magnet_length + k0 += reference_k0 + design_shift = k0 / k1 + + scale = magnet_length if source_link.is_integrated else 1.0 + factor = design_shift * source_link.error.factor / scale + offset = design_shift * source_link.error.offset / scale - reference_k0 + target_name = f'{magnet_name}/B1' + magnet_settings.add_link(ControlMagnetLink( + link_name=f'{control_name}->{target_name}', + magnet_name=magnet_name, + control_name=control_name, + component='B', + order=1, + error=LinearConv(factor=factor, offset=offset), + is_integrated=False, + )) + magnet_settings.magnets[magnet_name].offset_B[0] = 0.0 + assert len(components_to_invert) == 0, f"Found undeclared components in components to invert: magnets/{magnet_category_name}/invert: {components_to_invert}." diff --git a/pySC/core/magnet.py b/pySC/core/magnet.py index b9d5cd7d..47053c30 100644 --- a/pySC/core/magnet.py +++ b/pySC/core/magnet.py @@ -30,10 +30,6 @@ class Magnet(BaseModel, extra="forbid"): offset_B: Optional[list[float]] = None to_design: bool = False length: Optional[float] = None - is_shifted: bool = False - bending_length: Optional[float] = None - design_shift: float = 0.0 - design_k1: float = 0.0 _links: list[ControlMagnetLink] = PrivateAttr(default=[]) _parent = PrivateAttr(default=None) @@ -116,38 +112,8 @@ def update(self): f"Invalid component '{link.component}' for magnet '{self.name}'" ) - if self.is_shifted: - assert self.length is not None, f"ERROR: quadrupole length not specified for shifted magnet: {repr(self)}" - shift = self.design_shift - if not self.to_design: - dx, _ = self._parent._parent.support_system.get_total_offset(self.sim_index) - shift += dx - k1 = self.B[1] - self.B[0] += shift * k1 - self.design_shift * self.design_k1 - for ii in range(self.max_order + 1): self._parent._parent.lattice.set_magnet_component( self.sim_index, self.A[ii], 'A', ii, use_design=self.to_design) self._parent._parent.lattice.set_magnet_component( self.sim_index, self.B[ii], 'B', ii, use_design=self.to_design) - - if self.is_shifted: - - if self.to_design: - dx = dy = dz = 0.0 - roll = yaw = pitch = 0.0 - else: - dx, dy = self._parent._parent.support_system.get_total_offset(self.sim_index) - dz = self._parent._parent.support_system.data['L0'][self.sim_index].dz - roll, pitch, yaw = self._parent._parent.support_system.get_total_rotation(self.sim_index) - - self._parent._parent.lattice.update_misalignment( - self.sim_index, - dx=dx, - dy=dy, - dz=dz, - roll=roll, - yaw=yaw, - pitch=pitch, - use_design=self.to_design - ) diff --git a/pySC/core/magnetsettings.py b/pySC/core/magnetsettings.py index e2aeb3dd..792b5e10 100644 --- a/pySC/core/magnetsettings.py +++ b/pySC/core/magnetsettings.py @@ -94,10 +94,6 @@ def add_individually_powered_magnet(self, controlled_components: list[str], magnet_name: Optional[str] = None, magnet_length: Optional[float] = None, - is_shifted: bool = False, - bending_length: Optional[float] = None, - design_shift: float = 0.0, - design_k1: float = 0.0, to_design: bool = False) -> None: """ Add a magnet with individually powered components. @@ -116,11 +112,7 @@ def add_individually_powered_magnet(self, sim_index=sim_index, max_order=max_order, to_design=to_design, - length=magnet_length, - is_shifted=is_shifted, - bending_length=bending_length, - design_shift=design_shift, - design_k1=design_k1) + length=magnet_length) magnet._parent = self # Set the parent to the current settings instance # check non-zero components that are not controlled and put them in offset_A/B. diff --git a/pySC/core/supports.py b/pySC/core/supports.py index fc387373..cd68b7da 100644 --- a/pySC/core/supports.py +++ b/pySC/core/supports.py @@ -329,11 +329,6 @@ def trigger_update(self, level: str, index): else: self._parent.lattice.update_misalignment(index=eo.index, dx=dx, dy=dy, dz=dz, roll=roll, yaw=yaw, pitch=pitch) - magnet_name = self._parent.magnet_settings.index_mapping.get(eo.index) - if magnet_name is not None: - magnet = self._parent.magnet_settings.magnets[magnet_name] - if magnet.is_shifted: - magnet.update() def update_all(self) -> None: for index in self.data['L0'].keys(): diff --git a/tests/configuration/test_magnets_conf.py b/tests/configuration/test_magnets_conf.py index 657b415b..ee2476c4 100644 --- a/tests/configuration/test_magnets_conf.py +++ b/tests/configuration/test_magnets_conf.py @@ -125,6 +125,54 @@ def test_configure_magnets_dipole_convention(hmba_lattice_file): assert link.error.offset == pytest.approx(expected_offset, rel=1e-6) +@pytest.mark.slow +@pytest.mark.parametrize("component", ["B2", "B2L"]) +def test_configure_shifted_quadrupole_feed_down(hmba_lattice_file, component): + lattice = ATLattice(lattice_file=hmba_lattice_file, naming="FamName") + dip_name = "DQ1B" + dip_index = 50 + initial_angle = lattice.get_bending_angle(dip_index) + initial_b0 = lattice.get_magnet_component(dip_index, "B", 0) + initial_k1 = lattice.get_magnet_component(dip_index, "B", 1) + length = lattice.get_length(dip_index) + design_shift = (initial_angle / length + initial_b0) / initial_k1 + + config = { + "error_table": {"quad_cal": "0"}, + "magnets": { + "shifted_quadrupole": { + "regex": f"^{dip_name}$", + "components": [{component: "quad_cal"}], + "shifted": True, + }, + }, + } + SC = SimulatedCommissioning(lattice=lattice, configuration=config, seed=42) + configure_magnets(SC) + + control_name = f"{dip_name}/{component}" + feed_down_link = SC.magnet_settings.links[f"{control_name}->{dip_name}/B1"] + assert feed_down_link.is_integrated is False + assert SC.lattice.get_bending_angle(dip_index) == pytest.approx(initial_angle) + assert SC.lattice.get_magnet_component( + dip_index, "B", 0, use_design=False + ) == pytest.approx(initial_b0) + + delta_k1 = 0.2 + setpoint = initial_k1 + delta_k1 + if component == "B2L": + setpoint *= length + SC.magnet_settings.set(control_name, setpoint) + + assert SC.lattice.get_bending_angle(dip_index) == pytest.approx(initial_angle) + assert SC.lattice.get_magnet_component( + dip_index, "B", 1, use_design=False + ) == pytest.approx(initial_k1 + delta_k1) + assert SC.lattice.get_magnet_component( + dip_index, "B", 0, use_design=False + ) == pytest.approx(initial_b0 + design_shift * delta_k1) + + @pytest.mark.slow def test_configure_magnets_limits(hmba_lattice_file): """Controls have limits when configured.""" diff --git a/tests/core/test_magnet.py b/tests/core/test_magnet.py index d975b5d9..69bf5ea4 100644 --- a/tests/core/test_magnet.py +++ b/tests/core/test_magnet.py @@ -117,81 +117,3 @@ def test_magnet_update_no_length_raises(): with pytest.raises(AssertionError, match="magnet length not specified"): m.update() - - -def test_shifted_magnet_update(): - """Shifted quadrupole feeds down""" - - design_arc_length = 0.25 - design_shift = 0.01 - initial_k1 = 5.0 - initial_bending_angle = design_shift * initial_k1 * design_arc_length - initial_b0 = 0.02 - - m, parent = _make_magnet_with_parent(max_order=1, length=design_arc_length) - m.is_shifted = True - m.bending_length = design_arc_length - m.design_shift = design_shift - m.design_k1 = initial_k1 - m.offset_B[0] = initial_b0 - - element = MagicMock() - element.Length = design_arc_length - element.BendingAngle = initial_bending_angle - element.EntranceAngle = initial_bending_angle / 2 - element.ExitAngle = initial_bending_angle / 2 - - support_system = MagicMock() - support_system.get_total_offset.return_value = (0.0, 0.0) - support_system.get_total_rotation.return_value = (0.0, 0.0, 0.0) - support_system.data = {'L0': {0: MagicMock(dz=0.0)}} - parent._parent.support_system = support_system - parent._parent.lattice.ring = {0: element} - parent._parent.lattice.design = {0: element} - - ctrl = Control(name="c1", setpoint=initial_k1 * design_arc_length) - parent.controls["c1"] = ctrl - link = ControlMagnetLink(link_name="lk1", magnet_name=0, control_name="c1", component="B", order=2, is_integrated=True) - m._links = [link] - - m.update() - - expected_k1 = initial_k1 - expected_b0 = initial_b0 - expected_angle = initial_bending_angle - assert m.B[1] == pytest.approx(expected_k1) - assert m.B[0] == pytest.approx(expected_b0) - assert element.Length == pytest.approx(design_arc_length) - assert element.BendingAngle == pytest.approx(expected_angle) - assert element.EntranceAngle == pytest.approx(expected_angle/2) - assert element.ExitAngle == pytest.approx(expected_angle/2) - assert m.length == pytest.approx(design_arc_length) - assert m.bending_length == pytest.approx(design_arc_length) - - ctrl.setpoint = 5.2 * design_arc_length - m.update() - - expected_k1 = 5.2 - expected_b0 = initial_b0 + design_shift * (expected_k1 - initial_k1) - assert m.B[1] == pytest.approx(expected_k1) - assert m.B[0] == pytest.approx(expected_b0) - assert element.Length == pytest.approx(design_arc_length) - assert element.BendingAngle == pytest.approx(initial_bending_angle) - assert element.EntranceAngle == pytest.approx(initial_bending_angle/2) - assert element.ExitAngle == pytest.approx(initial_bending_angle/2) - assert m.length == pytest.approx(design_arc_length) - assert m.bending_length == pytest.approx(design_arc_length) - - support_system.get_total_offset.return_value = (0.001, 0.0) - m.update() - - total_shift = design_shift + 0.001 - expected_b0 = initial_b0 + total_shift * expected_k1 - design_shift * initial_k1 - assert m.B[1] == pytest.approx(expected_k1) - assert m.B[0] == pytest.approx(expected_b0) - assert element.Length == pytest.approx(design_arc_length) - assert element.BendingAngle == pytest.approx(initial_bending_angle) - assert element.EntranceAngle == pytest.approx(initial_bending_angle/2) - assert element.ExitAngle == pytest.approx(initial_bending_angle/2) - assert m.length == pytest.approx(design_arc_length) - assert m.bending_length == pytest.approx(design_arc_length)