spinoct 0.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. spinoct-0.10.0/LICENSE +21 -0
  2. spinoct-0.10.0/PKG-INFO +68 -0
  3. spinoct-0.10.0/README.md +43 -0
  4. spinoct-0.10.0/pyproject.toml +51 -0
  5. spinoct-0.10.0/setup.cfg +4 -0
  6. spinoct-0.10.0/src/spinoct/__init__.py +24 -0
  7. spinoct-0.10.0/src/spinoct/adjoint.py +251 -0
  8. spinoct-0.10.0/src/spinoct/amortized.py +218 -0
  9. spinoct-0.10.0/src/spinoct/analytic/__init__.py +24 -0
  10. spinoct-0.10.0/src/spinoct/analytic/elliptic.py +155 -0
  11. spinoct-0.10.0/src/spinoct/analytic/sot.py +154 -0
  12. spinoct-0.10.0/src/spinoct/analytic/uniaxial.py +521 -0
  13. spinoct-0.10.0/src/spinoct/control/__init__.py +32 -0
  14. spinoct-0.10.0/src/spinoct/control/baselines.py +188 -0
  15. spinoct-0.10.0/src/spinoct/control/constrained.py +258 -0
  16. spinoct-0.10.0/src/spinoct/control/hybrid.py +250 -0
  17. spinoct-0.10.0/src/spinoct/dynamics/__init__.py +8 -0
  18. spinoct-0.10.0/src/spinoct/dynamics/llg.py +201 -0
  19. spinoct-0.10.0/src/spinoct/dynamics/system.py +205 -0
  20. spinoct-0.10.0/src/spinoct/lattice/__init__.py +36 -0
  21. spinoct-0.10.0/src/spinoct/lattice/chain.py +111 -0
  22. spinoct-0.10.0/src/spinoct/lattice/mep.py +229 -0
  23. spinoct-0.10.0/src/spinoct/lattice/ocp.py +511 -0
  24. spinoct-0.10.0/src/spinoct/lattice/reversal.py +179 -0
  25. spinoct-0.10.0/src/spinoct/metrics/__init__.py +129 -0
  26. spinoct-0.10.0/src/spinoct/numeric/__init__.py +12 -0
  27. spinoct-0.10.0/src/spinoct/numeric/image_ocp.py +382 -0
  28. spinoct-0.10.0/src/spinoct/pareto.py +133 -0
  29. spinoct-0.10.0/src/spinoct/thermal/__init__.py +37 -0
  30. spinoct-0.10.0/src/spinoct/thermal/stabilize.py +201 -0
  31. spinoct-0.10.0/src/spinoct/thermal/stochastic.py +200 -0
  32. spinoct-0.10.0/src/spinoct/units/__init__.py +306 -0
  33. spinoct-0.10.0/src/spinoct.egg-info/PKG-INFO +68 -0
  34. spinoct-0.10.0/src/spinoct.egg-info/SOURCES.txt +50 -0
  35. spinoct-0.10.0/src/spinoct.egg-info/dependency_links.txt +1 -0
  36. spinoct-0.10.0/src/spinoct.egg-info/requires.txt +9 -0
  37. spinoct-0.10.0/src/spinoct.egg-info/top_level.txt +1 -0
  38. spinoct-0.10.0/tests/test_adjoint.py +67 -0
  39. spinoct-0.10.0/tests/test_amortized.py +64 -0
  40. spinoct-0.10.0/tests/test_baselines_and_sot.py +202 -0
  41. spinoct-0.10.0/tests/test_constrained.py +59 -0
  42. spinoct-0.10.0/tests/test_docs_and_sources.py +47 -0
  43. spinoct-0.10.0/tests/test_elliptic.py +111 -0
  44. spinoct-0.10.0/tests/test_hybrid.py +75 -0
  45. spinoct-0.10.0/tests/test_image_ocp.py +167 -0
  46. spinoct-0.10.0/tests/test_lattice.py +101 -0
  47. spinoct-0.10.0/tests/test_lattice_mep.py +84 -0
  48. spinoct-0.10.0/tests/test_lattice_ocp.py +165 -0
  49. spinoct-0.10.0/tests/test_pareto.py +46 -0
  50. spinoct-0.10.0/tests/test_thermal.py +144 -0
  51. spinoct-0.10.0/tests/test_uniaxial_analytic.py +387 -0
  52. spinoct-0.10.0/tests/test_units.py +98 -0
spinoct-0.10.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Felipe Santibanez-Leal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: spinoct
3
+ Version: 0.10.0
4
+ Summary: Optimal control paths and energy-efficient switching pulses for classical spin dynamics (Landau-Lifshitz-Gilbert)
5
+ Author-email: Felipe Santibanez-Leal <fsantibanez@gmail.com>
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/fsantibanezleal/CAOS_SpinOCT
8
+ Keywords: optimal-control,magnetization-switching,landau-lifshitz-gilbert,spintronics,spin-dynamics,micromagnetics,pulse-shaping,energy-efficient-memory
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Physics
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: numpy>=1.24
18
+ Requires-Dist: scipy>=1.10
19
+ Provides-Extra: torch
20
+ Requires-Dist: torch>=2.2; extra == "torch"
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=8; extra == "dev"
23
+ Requires-Dist: ruff>=0.4; extra == "dev"
24
+ Dynamic: license-file
25
+
26
+ # spinoct
27
+
28
+ Optimal control paths and energy-efficient switching pulses for classical spin dynamics
29
+ (Landau-Lifshitz-Gilbert).
30
+
31
+ `spinoct` computes the control (an applied magnetic field, an electric current, or both) that
32
+ drives a magnetic moment from one state to another in a given time for the least dissipated energy.
33
+ It is the reusable engine behind [Espira](https://github.com/fsantibanezleal/CAOS_RES_Espira), and
34
+ it is deliberately independent of any material database, so it works on any spin Hamiltonian.
35
+
36
+ ## Why this exists
37
+
38
+ The optimal control of magnetization switching has a small, rigorous literature (Kwiatkowski,
39
+ Badarneh, Berkov and Bessarab, Phys. Rev. Lett. 126, 177206 (2021), and the papers that follow it),
40
+ but no open implementation. The analytic solutions live only as equations in journal articles, and
41
+ the group's own code is not public. `spinoct` is that implementation, with the closed-form results
42
+ built in as positive controls that every numerical solver is validated against before it is trusted.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install spinoct # core: numpy + scipy
48
+ pip install "spinoct[torch]" # add the batched GPU solvers
49
+ ```
50
+
51
+ ## The dimensional contract
52
+
53
+ Read `spinoct.units` first. The same physical quantity is written four different ways across this
54
+ literature, and the central quantity of the package, the switching cost `Phi = int |b|^2 dt`, is in
55
+ tesla-squared-seconds, not joules. It becomes an energy only through an explicit circuit model. The
56
+ package refuses to hide that assumption: `spinoct.units.CircuitModel` is a required, described
57
+ object, never a buried constant.
58
+
59
+ ## Status
60
+
61
+ Pre-1.0, under active development. The analytic uniaxial optimal control path, its closed-form pulse
62
+ and cost, and the negative-parameter Jacobi elliptic machinery it needs are complete and validated.
63
+ The numerical image-based solver, the GRAPE and CRAB constrained solvers, and the batched GPU lane
64
+ are in progress.
65
+
66
+ ## License
67
+
68
+ MIT. See `LICENSE`.
@@ -0,0 +1,43 @@
1
+ # spinoct
2
+
3
+ Optimal control paths and energy-efficient switching pulses for classical spin dynamics
4
+ (Landau-Lifshitz-Gilbert).
5
+
6
+ `spinoct` computes the control (an applied magnetic field, an electric current, or both) that
7
+ drives a magnetic moment from one state to another in a given time for the least dissipated energy.
8
+ It is the reusable engine behind [Espira](https://github.com/fsantibanezleal/CAOS_RES_Espira), and
9
+ it is deliberately independent of any material database, so it works on any spin Hamiltonian.
10
+
11
+ ## Why this exists
12
+
13
+ The optimal control of magnetization switching has a small, rigorous literature (Kwiatkowski,
14
+ Badarneh, Berkov and Bessarab, Phys. Rev. Lett. 126, 177206 (2021), and the papers that follow it),
15
+ but no open implementation. The analytic solutions live only as equations in journal articles, and
16
+ the group's own code is not public. `spinoct` is that implementation, with the closed-form results
17
+ built in as positive controls that every numerical solver is validated against before it is trusted.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install spinoct # core: numpy + scipy
23
+ pip install "spinoct[torch]" # add the batched GPU solvers
24
+ ```
25
+
26
+ ## The dimensional contract
27
+
28
+ Read `spinoct.units` first. The same physical quantity is written four different ways across this
29
+ literature, and the central quantity of the package, the switching cost `Phi = int |b|^2 dt`, is in
30
+ tesla-squared-seconds, not joules. It becomes an energy only through an explicit circuit model. The
31
+ package refuses to hide that assumption: `spinoct.units.CircuitModel` is a required, described
32
+ object, never a buried constant.
33
+
34
+ ## Status
35
+
36
+ Pre-1.0, under active development. The analytic uniaxial optimal control path, its closed-form pulse
37
+ and cost, and the negative-parameter Jacobi elliptic machinery it needs are complete and validated.
38
+ The numerical image-based solver, the GRAPE and CRAB constrained solvers, and the batched GPU lane
39
+ are in progress.
40
+
41
+ ## License
42
+
43
+ MIT. See `LICENSE`.
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "spinoct"
7
+ version = "0.10.0"
8
+ description = "Optimal control paths and energy-efficient switching pulses for classical spin dynamics (Landau-Lifshitz-Gilbert)"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Felipe Santibanez-Leal", email = "fsantibanez@gmail.com" }]
12
+ requires-python = ">=3.10"
13
+ dependencies = ["numpy>=1.24", "scipy>=1.10"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Science/Research",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Scientific/Engineering :: Physics",
20
+ ]
21
+ keywords = [
22
+ "optimal-control",
23
+ "magnetization-switching",
24
+ "landau-lifshitz-gilbert",
25
+ "spintronics",
26
+ "spin-dynamics",
27
+ "micromagnetics",
28
+ "pulse-shaping",
29
+ "energy-efficient-memory",
30
+ ]
31
+
32
+ [project.urls]
33
+ Repository = "https://github.com/fsantibanezleal/CAOS_SpinOCT"
34
+
35
+ [project.optional-dependencies]
36
+ torch = ["torch>=2.2"]
37
+ dev = ["pytest>=8", "ruff>=0.4"]
38
+
39
+ [tool.setuptools.packages.find]
40
+ where = ["src"]
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = ["tests"]
44
+ addopts = "-q"
45
+
46
+ [tool.ruff]
47
+ line-length = 110
48
+ target-version = "py310"
49
+
50
+ [tool.ruff.lint]
51
+ select = ["E", "F", "W", "I", "UP", "B"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,24 @@
1
+ """spinoct: optimal control paths and energy-efficient switching pulses for classical spin dynamics.
2
+
3
+ The package solves one problem: given a magnetic system, an initial and a final state, and a
4
+ switching time, find the control (an applied magnetic field, an electric current, or both) that
5
+ drives the transition for the least dissipated energy.
6
+
7
+ It is the engine behind Espira (https://github.com/fsantibanezleal/CAOS_RES_Espira) and is
8
+ deliberately independent of any material database, so it can be used on any spin Hamiltonian.
9
+
10
+ Start here
11
+ ----------
12
+ - :mod:`spinoct.units` is the dimensional contract. Read it before anything else; the same symbol
13
+ means four different things across this literature.
14
+ - :mod:`spinoct.dynamics` carries the system definition and the Landau-Lifshitz-Gilbert equation.
15
+ - :mod:`spinoct.analytic` carries the closed-form optimal control paths, which are the positive
16
+ controls every numerical result is checked against.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ __version__ = "0.10.0"
22
+ __display_version__ = "0.10.000"
23
+
24
+ __all__ = ["__display_version__", "__version__"]
@@ -0,0 +1,251 @@
1
+ """Exact gradient-based pulse optimization by the discrete adjoint method (R10).
2
+
3
+ The finite-difference gradient of the constrained solvers costs one forward integration per control
4
+ parameter. The adjoint method computes the exact gradient with respect to every control parameter in a
5
+ single backward pass, at the cost of one extra integration, independent of the number of parameters.
6
+ For a pulse with many slices this is the difference between a solver that scales and one that does not,
7
+ and it is the classical route to gradient-based optimal control.
8
+
9
+ We differentiate through a norm-projected forward Euler integration of the Landau-Lifshitz-Gilbert
10
+ equation analytically (a hand-derived reverse-mode pass), so the gradient is exact for the discretized
11
+ system and needs no automatic-differentiation dependency; the core stays pure numpy. The same reverse
12
+ pass runs elementwise, so it batches and ports to a GPU tensor library unchanged, which is the
13
+ ``[torch]`` extra.
14
+
15
+ Forward step, with control field ``b_k`` piecewise constant on ``N`` slices:
16
+
17
+ r_k = s_k + dt * f(s_k, b_k), s_{k+1} = r_k / |r_k|,
18
+
19
+ with ``f`` the LLG right-hand side. The objective is the switching cost plus a reversal-fidelity
20
+ penalty:
21
+
22
+ C = sum_k |b_k|^2 dt + lambda (1 + s_{N,z}) / 2.
23
+
24
+ The adjoint recursion propagates the cost sensitivity ``g_k = dC/ds_k`` backward and reads off
25
+ ``dC/db_k`` at each step. All Jacobians are analytic because the LLG right-hand side is a sum of cross
26
+ products, which are linear in each argument.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass
32
+
33
+ import numpy as np
34
+
35
+ from .dynamics.system import MacrospinSystem
36
+
37
+ __all__ = ["AdjointResult", "adjoint_gradient", "optimize_pulse_adjoint"]
38
+
39
+
40
+ def _cross_matrix(v: np.ndarray) -> np.ndarray:
41
+ """The skew matrix ``[v]_x`` such that ``[v]_x u = v x u``."""
42
+ return np.array(
43
+ [[0.0, -v[2], v[1]], [v[2], 0.0, -v[0]], [-v[1], v[0], 0.0]]
44
+ )
45
+
46
+
47
+ def _f(s: np.ndarray, b_applied: np.ndarray, system: MacrospinSystem) -> np.ndarray:
48
+ """The LLG right-hand side for one moment."""
49
+ b_total = system.internal_field(s) + b_applied
50
+ alpha, gamma = system.alpha, system.gamma
51
+ precession = np.cross(s, b_total)
52
+ damping = np.cross(s, precession)
53
+ return (-gamma * precession - alpha * gamma * damping) / (1.0 + alpha**2)
54
+
55
+
56
+ def _f_jacobians(
57
+ s: np.ndarray, b_applied: np.ndarray, system: MacrospinSystem
58
+ ) -> tuple[np.ndarray, np.ndarray]:
59
+ """The Jacobians ``df/ds`` and ``df/db`` at a point, each a 3x3 matrix.
60
+
61
+ The internal field is linear in s (a diagonal anisotropy matrix), so
62
+ ``b_total = A s + b_applied`` with ``A = diag(0, 0, 2K/mu)`` for the uniaxial easy axis. The LLG
63
+ right-hand side is a sum of cross products, each bilinear, so the Jacobians are assembled from
64
+ skew matrices.
65
+ """
66
+ alpha, gamma = system.alpha, system.gamma
67
+ scale = 1.0 / (1.0 + alpha**2)
68
+ anisotropy = np.diag([0.0, 0.0, 2.0 * system.anisotropy_j / system.mu])
69
+ b_total = anisotropy @ s + b_applied
70
+
71
+ sx = _cross_matrix(s)
72
+ btx = _cross_matrix(b_total)
73
+ # d/ds of (s x b_total) = [s]_x (A) - [b_total]_x, since b_total depends on s through A.
74
+ dprec_ds = sx @ anisotropy - btx
75
+ prec = np.cross(s, b_total)
76
+ px = _cross_matrix(prec)
77
+ # damping = s x prec; d/ds = [s]_x dprec_ds - [prec]_x
78
+ ddamp_ds = sx @ dprec_ds - px
79
+ df_ds = scale * (-gamma * dprec_ds - alpha * gamma * ddamp_ds)
80
+
81
+ # d/db_applied: b_total depends on b_applied as identity.
82
+ dprec_db = -btx * 0.0 + sx # d(s x b_total)/db_applied = [s]_x
83
+ ddamp_db = sx @ dprec_db
84
+ df_db = scale * (-gamma * dprec_db - alpha * gamma * ddamp_db)
85
+ return df_ds, df_db
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class AdjointResult:
90
+ """The outcome of an adjoint-gradient pulse optimization.
91
+
92
+ Attributes:
93
+ times: the slice times, s, shape ``(N,)``.
94
+ field: the optimized field per slice, shape ``(N, 3)``, T.
95
+ cost: the switching cost, T^2 s.
96
+ final_sz: the final z-component.
97
+ switched: whether the moment reversed.
98
+ objective: the final penalized objective value.
99
+ iterations: the number of gradient steps taken.
100
+ """
101
+
102
+ times: np.ndarray
103
+ field: np.ndarray
104
+ cost: float
105
+ final_sz: float
106
+ switched: bool
107
+ objective: float
108
+ iterations: int
109
+
110
+
111
+ def _forward(
112
+ field: np.ndarray, times: np.ndarray, system: MacrospinSystem
113
+ ) -> tuple[np.ndarray, np.ndarray]:
114
+ """Forward integration; returns the trajectory and the pre-normalization vectors."""
115
+ n = times.size
116
+ s = np.array([0.0, 0.0, 1.0])
117
+ trajectory = np.empty((n, 3))
118
+ pre_norm = np.empty((n, 3))
119
+ trajectory[0] = s
120
+ pre_norm[0] = s
121
+ for k in range(n - 1):
122
+ dt = times[k + 1] - times[k]
123
+ r = s + dt * _f(s, field[k], system)
124
+ pre_norm[k + 1] = r
125
+ s = r / np.linalg.norm(r)
126
+ trajectory[k + 1] = s
127
+ return trajectory, pre_norm
128
+
129
+
130
+ def adjoint_gradient(
131
+ field: np.ndarray,
132
+ times: np.ndarray,
133
+ system: MacrospinSystem,
134
+ fidelity_weight: float,
135
+ ) -> tuple[float, float, np.ndarray]:
136
+ """The objective and its exact gradient with respect to the per-slice field.
137
+
138
+ Args:
139
+ field: the field per slice, shape ``(N, 3)``, T.
140
+ times: the slice times, s, shape ``(N,)``.
141
+ system: the macrospin.
142
+ fidelity_weight: the penalty weight on ``(1 + s_z(T)) / 2``.
143
+
144
+ Returns:
145
+ ``(objective, final_sz, gradient)`` with ``gradient`` of shape ``(N, 3)``.
146
+ """
147
+ n = times.size
148
+ trajectory, pre_norm = _forward(field, times, system)
149
+ final_sz = float(trajectory[-1, 2])
150
+
151
+ steps = np.diff(times)
152
+ cost = float(np.sum(np.sum(field[:-1] ** 2, axis=1) * steps))
153
+ objective = cost + fidelity_weight * 0.5 * (1.0 + final_sz)
154
+
155
+ grad = np.zeros_like(field)
156
+ # Terminal adjoint: dC/ds_N from the fidelity term.
157
+ g = np.array([0.0, 0.0, 0.5 * fidelity_weight])
158
+
159
+ for k in range(n - 2, -1, -1):
160
+ dt = times[k + 1] - times[k]
161
+ # Through the normalization s_{k+1} = r / |r|.
162
+ r = pre_norm[k + 1]
163
+ norm = np.linalg.norm(r)
164
+ s_next = r / norm
165
+ d_normalize = (np.eye(3) - np.outer(s_next, s_next)) / norm # ds_{k+1}/dr
166
+ g_r = d_normalize.T @ g # dC/dr_k
167
+
168
+ df_ds, df_db = _f_jacobians(trajectory[k], field[k], system)
169
+ # r = s_k + dt f(s_k, b_k): dr/ds_k = I + dt df_ds; dr/db_k = dt df_db.
170
+ # Direct cost term in b_k (this slice contributes |b_k|^2 dt).
171
+ grad[k] += 2.0 * field[k] * dt + dt * (df_db.T @ g_r)
172
+ # Propagate to s_k.
173
+ g = (np.eye(3) + dt * df_ds).T @ g_r
174
+
175
+ return objective, final_sz, grad
176
+
177
+
178
+ def optimize_pulse_adjoint(
179
+ system: MacrospinSystem,
180
+ switching_time: float,
181
+ n_slices: int = 60,
182
+ fidelity_weight: float | None = None,
183
+ learning_rate: float | None = None,
184
+ max_iterations: int = 400,
185
+ seed: int = 0,
186
+ ) -> AdjointResult:
187
+ """Optimize a transverse field pulse by adjoint-gradient descent.
188
+
189
+ Args:
190
+ system: the macrospin (uniaxial).
191
+ switching_time: ``T`` in s.
192
+ n_slices: the number of piecewise-constant field slices.
193
+ fidelity_weight: the reversal penalty weight; a scaled default is used if omitted.
194
+ learning_rate: the gradient-descent step; a scaled default is used if omitted.
195
+ max_iterations: the descent iteration cap.
196
+ seed: the initial-guess seed.
197
+
198
+ Returns:
199
+ The :class:`AdjointResult`.
200
+ """
201
+ from scipy.optimize import minimize
202
+
203
+ from .analytic.uniaxial import cost_free_macrospin
204
+
205
+ del learning_rate # the exact gradient is fed to L-BFGS, which sets its own step
206
+ times = np.linspace(0.0, switching_time, n_slices)
207
+ free = cost_free_macrospin(switching_time, system.alpha, system.gamma)
208
+ if fidelity_weight is None:
209
+ fidelity_weight = 50.0 * free
210
+
211
+ # Non-dimensionalize so L-BFGS works in O(1) variables: the field in units of the anisotropy
212
+ # field, the objective in units of the free-macrospin cost. Otherwise the objective and gradient
213
+ # are order 1e-12 and the optimizer's default tolerances are met before it takes a single step.
214
+ field_scale = system.anisotropy_field
215
+ objective_scale = max(free, 1e-300)
216
+
217
+ rng = np.random.default_rng(seed)
218
+ # Only the two transverse components per slice are optimized; the drive stays perpendicular.
219
+ initial = rng.normal(scale=0.3, size=(n_slices, 2)).reshape(-1)
220
+
221
+ def unpack(vector: np.ndarray) -> np.ndarray:
222
+ field = np.zeros((n_slices, 3))
223
+ field[:, :2] = vector.reshape(n_slices, 2) * field_scale
224
+ return field
225
+
226
+ def objective_and_grad(vector: np.ndarray) -> tuple[float, np.ndarray]:
227
+ field = unpack(vector)
228
+ objective, _final_sz, grad = adjoint_gradient(field, times, system, fidelity_weight)
229
+ scaled_grad = grad[:, :2].reshape(-1) * field_scale / objective_scale
230
+ return objective / objective_scale, scaled_grad
231
+
232
+ result = minimize(
233
+ objective_and_grad,
234
+ initial,
235
+ method="L-BFGS-B",
236
+ jac=True,
237
+ options={"maxiter": max_iterations, "ftol": 1e-12, "gtol": 1e-9},
238
+ )
239
+
240
+ field = unpack(result.x)
241
+ objective, final_sz, _ = adjoint_gradient(field, times, system, fidelity_weight)
242
+ cost = float(np.sum(np.sum(field[:-1] ** 2, axis=1) * np.diff(times)))
243
+ return AdjointResult(
244
+ times=times,
245
+ field=field,
246
+ cost=cost,
247
+ final_sz=final_sz,
248
+ switched=final_sz < 0.0,
249
+ objective=objective,
250
+ iterations=int(result.nit),
251
+ )
@@ -0,0 +1,218 @@
1
+ """An amortized learned policy for optimal switching pulses (R15).
2
+
3
+ Every solver in this package re-optimizes from scratch for each new set of material parameters. An
4
+ amortized policy instead learns the map from parameters to the optimal pulse once, and then emits a
5
+ near-optimal pulse for any new parameters instantly, with no optimization at inference. This is useful
6
+ when a pulse must be chosen online, and it is a clean testbed for a learned controller because the
7
+ uniaxial optimum is known in closed form, so the policy's claim is checkable rather than merely
8
+ plausible.
9
+
10
+ The output representation matters, and getting it wrong is instructive. Regressing a learned model onto
11
+ the raw pulse amplitude profile fails: switching is a threshold phenomenon, and a profile fit in a
12
+ least-squares sense is often a few percent too weak to complete the reversal, so the emitted pulse does
13
+ not switch even though it looks close. The fix is to amortize the map onto the pulse's single
14
+ physically-meaningful shape parameter, the elliptic parameter ``p`` that the closed-form solution is
15
+ built from. Any ``p`` yields a genuine optimal-control pulse that reverses the moment; a slightly wrong
16
+ ``p`` yields the optimal pulse for a slightly different switching time, which still switches and is only
17
+ slightly suboptimal. So the shape-parameter policy is both reliable and near-optimal, while the
18
+ profile policy is neither. That contrast is the finding.
19
+
20
+ The policy is a small multilayer perceptron in numpy, so the package keeps no autodiff dependency. It
21
+ maps the two dimensionless parameters that determine the optimal uniaxial pulse, the damping ``alpha``
22
+ and the log switching time ``ln(T/tau0)``, to ``ln(p)``, and is trained by regression against the
23
+ analytic shape parameter on a grid. It is then validated the only honest way: the emitted pulse is fed
24
+ to the equation of motion and its cost and outcome are compared to the analytic optimum on parameter
25
+ combinations the policy never saw in training.
26
+
27
+ The pre-declared acceptance criterion (from the project's research dossier): a learned controller that
28
+ cannot reach the analytic optimum on the uniaxial case, where the optimum is known, has no business
29
+ being trusted on the harder cases where it is not. This module is that gate, and the shape-parameter
30
+ policy passes it.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from dataclasses import dataclass
36
+
37
+ import numpy as np
38
+
39
+ from .analytic.uniaxial import UniaxialOptimalControl, solve_shape_parameter
40
+ from .dynamics.llg import integrate_llg_tabulated, switching_cost
41
+ from .dynamics.system import MacrospinSystem
42
+
43
+ __all__ = ["AmortizedPolicy", "PolicyEvaluation", "evaluate_policy", "train_amortized_policy"]
44
+
45
+ #: The number of amplitude samples the emitted pulse is reconstructed on for integration.
46
+ _N_SAMPLES = 200
47
+
48
+
49
+ def _features(system: MacrospinSystem, switching_time: float) -> np.ndarray:
50
+ """The two dimensionless inputs the policy sees: ``[alpha, ln(T/tau0)]``."""
51
+ return np.array([system.alpha, np.log(switching_time / system.tau0)])
52
+
53
+
54
+ @dataclass
55
+ class AmortizedPolicy:
56
+ """A trained policy that emits the optimal-pulse shape parameter for given parameters.
57
+
58
+ Attributes:
59
+ w1, b1, w2, b2: the two-layer network weights and biases.
60
+ input_mean, input_std: the input normalization.
61
+ target_mean, target_std: the ``ln(p)`` target normalization.
62
+ """
63
+
64
+ w1: np.ndarray
65
+ b1: np.ndarray
66
+ w2: np.ndarray
67
+ b2: np.ndarray
68
+ input_mean: np.ndarray
69
+ input_std: np.ndarray
70
+ target_mean: float
71
+ target_std: float
72
+
73
+ def _forward(self, features: np.ndarray) -> float:
74
+ x = (features - self.input_mean) / self.input_std
75
+ h = np.tanh(x @ self.w1 + self.b1)
76
+ y = float(h @ self.w2 + self.b2)
77
+ return y * self.target_std + self.target_mean
78
+
79
+ def shape_parameter(self, system: MacrospinSystem, switching_time: float) -> float:
80
+ """The predicted shape parameter ``p`` for a system and switching time."""
81
+ return float(np.exp(self._forward(_features(system, switching_time))))
82
+
83
+ def pulse(self, system: MacrospinSystem, switching_time: float) -> UniaxialOptimalControl:
84
+ """The optimal-control pulse the policy emits, built from the predicted shape parameter."""
85
+ return UniaxialOptimalControl(
86
+ system=system, switching_time=switching_time, p=self.shape_parameter(system, switching_time)
87
+ )
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class PolicyEvaluation:
92
+ """The result of applying the policy's pulse to the equation of motion.
93
+
94
+ Attributes:
95
+ cost: the switching cost of the emitted pulse, T^2 s.
96
+ analytic_cost: the closed-form optimal cost for the same system and switching time, T^2 s.
97
+ cost_ratio: ``cost / analytic_cost``, at least 1 for a valid pulse up to discretization.
98
+ predicted_p: the shape parameter the policy emitted.
99
+ true_p: the exact shape parameter for these parameters.
100
+ final_sz: the final z-component under the emitted pulse.
101
+ switched: whether the emitted pulse reversed the moment.
102
+ """
103
+
104
+ cost: float
105
+ analytic_cost: float
106
+ cost_ratio: float
107
+ predicted_p: float
108
+ true_p: float
109
+ final_sz: float
110
+ switched: bool
111
+
112
+
113
+ def evaluate_policy(
114
+ policy: AmortizedPolicy, system: MacrospinSystem, switching_time: float
115
+ ) -> PolicyEvaluation:
116
+ """Apply the policy's emitted pulse to the equation of motion and score it against the optimum.
117
+
118
+ Args:
119
+ policy: the trained policy.
120
+ system: the macrospin (uniaxial).
121
+ switching_time: ``T`` in s.
122
+
123
+ Returns:
124
+ The :class:`PolicyEvaluation`.
125
+ """
126
+ emitted = policy.pulse(system, switching_time)
127
+ optimal = UniaxialOptimalControl.for_switching_time(system, switching_time)
128
+ grid = np.linspace(0.0, switching_time, _N_SAMPLES)
129
+ field_table = emitted.field_vector(grid)
130
+ trajectory = integrate_llg_tabulated(np.array([0.0, 0.0, 1.0]), field_table, grid, system)
131
+ final_sz = float(trajectory[-1, 2])
132
+ cost = switching_cost(grid, field_table)
133
+ analytic_cost = optimal.cost()
134
+ return PolicyEvaluation(
135
+ cost=cost,
136
+ analytic_cost=analytic_cost,
137
+ cost_ratio=cost / analytic_cost if analytic_cost > 0 else float("inf"),
138
+ predicted_p=emitted.p,
139
+ true_p=optimal.p,
140
+ final_sz=final_sz,
141
+ switched=final_sz < 0.0,
142
+ )
143
+
144
+
145
+ def train_amortized_policy(
146
+ mu: float,
147
+ anisotropy_j: float,
148
+ alphas: np.ndarray,
149
+ switching_times_tau0: np.ndarray,
150
+ hidden: int = 24,
151
+ epochs: int = 6000,
152
+ learning_rate: float = 0.05,
153
+ seed: int = 0,
154
+ ) -> AmortizedPolicy:
155
+ """Train the amortized policy by regression onto the analytic shape parameter on a grid.
156
+
157
+ Args:
158
+ mu: magnetic moment, J/T (fixed across the training set).
159
+ anisotropy_j: anisotropy energy, J (fixed).
160
+ alphas: the damping values to train on.
161
+ switching_times_tau0: the switching times to train on, in units of tau0.
162
+ hidden: the hidden-layer width.
163
+ epochs: the number of full-batch gradient steps.
164
+ learning_rate: the step size.
165
+ seed: the weight-initialization seed.
166
+
167
+ Returns:
168
+ The trained :class:`AmortizedPolicy`.
169
+ """
170
+ features = []
171
+ targets = []
172
+ for alpha in alphas:
173
+ system = MacrospinSystem(mu=mu, anisotropy_j=anisotropy_j, alpha=float(alpha))
174
+ for t_tau0 in switching_times_tau0:
175
+ switching_time = system.switching_time_from_tau0(float(t_tau0))
176
+ features.append(_features(system, switching_time))
177
+ targets.append(np.log(solve_shape_parameter(switching_time, system)))
178
+ features = np.array(features)
179
+ targets = np.array(targets)
180
+
181
+ input_mean, input_std = features.mean(0), features.std(0) + 1e-12
182
+ target_mean, target_std = float(targets.mean()), float(targets.std() + 1e-12)
183
+ x = (features - input_mean) / input_std
184
+ y = (targets - target_mean) / target_std
185
+
186
+ rng = np.random.default_rng(seed)
187
+ n_in = x.shape[1]
188
+ w1 = rng.normal(scale=1.0 / np.sqrt(n_in), size=(n_in, hidden))
189
+ b1 = np.zeros(hidden)
190
+ w2 = rng.normal(scale=1.0 / np.sqrt(hidden), size=hidden)
191
+ b2 = 0.0
192
+
193
+ n = x.shape[0]
194
+ for _ in range(epochs):
195
+ h_pre = x @ w1 + b1
196
+ h = np.tanh(h_pre)
197
+ pred = h @ w2 + b2
198
+ error = pred - y
199
+ grad_w2 = h.T @ error / n
200
+ grad_b2 = float(error.mean())
201
+ grad_h = np.outer(error, w2) * (1.0 - h**2)
202
+ grad_w1 = x.T @ grad_h / n
203
+ grad_b1 = grad_h.mean(0)
204
+ w1 -= learning_rate * grad_w1
205
+ b1 -= learning_rate * grad_b1
206
+ w2 -= learning_rate * grad_w2
207
+ b2 -= learning_rate * grad_b2
208
+
209
+ return AmortizedPolicy(
210
+ w1=w1,
211
+ b1=b1,
212
+ w2=w2,
213
+ b2=b2,
214
+ input_mean=input_mean,
215
+ input_std=input_std,
216
+ target_mean=target_mean,
217
+ target_std=target_std,
218
+ )