From bc317d176c26d37635bb532cd5ca23407568d03a Mon Sep 17 00:00:00 2001
From: Ivan Morozov <ivan.morozov@elettra.eu>
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 <ivan.morozov@elettra.eu>
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 <ivan.morozov@elettra.eu>
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)
