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.
- spinoct-0.10.0/LICENSE +21 -0
- spinoct-0.10.0/PKG-INFO +68 -0
- spinoct-0.10.0/README.md +43 -0
- spinoct-0.10.0/pyproject.toml +51 -0
- spinoct-0.10.0/setup.cfg +4 -0
- spinoct-0.10.0/src/spinoct/__init__.py +24 -0
- spinoct-0.10.0/src/spinoct/adjoint.py +251 -0
- spinoct-0.10.0/src/spinoct/amortized.py +218 -0
- spinoct-0.10.0/src/spinoct/analytic/__init__.py +24 -0
- spinoct-0.10.0/src/spinoct/analytic/elliptic.py +155 -0
- spinoct-0.10.0/src/spinoct/analytic/sot.py +154 -0
- spinoct-0.10.0/src/spinoct/analytic/uniaxial.py +521 -0
- spinoct-0.10.0/src/spinoct/control/__init__.py +32 -0
- spinoct-0.10.0/src/spinoct/control/baselines.py +188 -0
- spinoct-0.10.0/src/spinoct/control/constrained.py +258 -0
- spinoct-0.10.0/src/spinoct/control/hybrid.py +250 -0
- spinoct-0.10.0/src/spinoct/dynamics/__init__.py +8 -0
- spinoct-0.10.0/src/spinoct/dynamics/llg.py +201 -0
- spinoct-0.10.0/src/spinoct/dynamics/system.py +205 -0
- spinoct-0.10.0/src/spinoct/lattice/__init__.py +36 -0
- spinoct-0.10.0/src/spinoct/lattice/chain.py +111 -0
- spinoct-0.10.0/src/spinoct/lattice/mep.py +229 -0
- spinoct-0.10.0/src/spinoct/lattice/ocp.py +511 -0
- spinoct-0.10.0/src/spinoct/lattice/reversal.py +179 -0
- spinoct-0.10.0/src/spinoct/metrics/__init__.py +129 -0
- spinoct-0.10.0/src/spinoct/numeric/__init__.py +12 -0
- spinoct-0.10.0/src/spinoct/numeric/image_ocp.py +382 -0
- spinoct-0.10.0/src/spinoct/pareto.py +133 -0
- spinoct-0.10.0/src/spinoct/thermal/__init__.py +37 -0
- spinoct-0.10.0/src/spinoct/thermal/stabilize.py +201 -0
- spinoct-0.10.0/src/spinoct/thermal/stochastic.py +200 -0
- spinoct-0.10.0/src/spinoct/units/__init__.py +306 -0
- spinoct-0.10.0/src/spinoct.egg-info/PKG-INFO +68 -0
- spinoct-0.10.0/src/spinoct.egg-info/SOURCES.txt +50 -0
- spinoct-0.10.0/src/spinoct.egg-info/dependency_links.txt +1 -0
- spinoct-0.10.0/src/spinoct.egg-info/requires.txt +9 -0
- spinoct-0.10.0/src/spinoct.egg-info/top_level.txt +1 -0
- spinoct-0.10.0/tests/test_adjoint.py +67 -0
- spinoct-0.10.0/tests/test_amortized.py +64 -0
- spinoct-0.10.0/tests/test_baselines_and_sot.py +202 -0
- spinoct-0.10.0/tests/test_constrained.py +59 -0
- spinoct-0.10.0/tests/test_docs_and_sources.py +47 -0
- spinoct-0.10.0/tests/test_elliptic.py +111 -0
- spinoct-0.10.0/tests/test_hybrid.py +75 -0
- spinoct-0.10.0/tests/test_image_ocp.py +167 -0
- spinoct-0.10.0/tests/test_lattice.py +101 -0
- spinoct-0.10.0/tests/test_lattice_mep.py +84 -0
- spinoct-0.10.0/tests/test_lattice_ocp.py +165 -0
- spinoct-0.10.0/tests/test_pareto.py +46 -0
- spinoct-0.10.0/tests/test_thermal.py +144 -0
- spinoct-0.10.0/tests/test_uniaxial_analytic.py +387 -0
- 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.
|
spinoct-0.10.0/PKG-INFO
ADDED
|
@@ -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`.
|
spinoct-0.10.0/README.md
ADDED
|
@@ -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"]
|
spinoct-0.10.0/setup.cfg
ADDED
|
@@ -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
|
+
)
|