nmag-python-3 0.0.2__py3-none-any.whl

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 (147) hide show
  1. anisotropy/__init__.py +28 -0
  2. anisotropy/anisotropy.py +13 -0
  3. anisotropy/evaluation.py +70 -0
  4. anisotropy/model.py +200 -0
  5. anisotropy/predefined.py +202 -0
  6. anisotropy/py.typed +1 -0
  7. anisotropy/values.py +84 -0
  8. mag_material/__init__.py +3 -0
  9. mag_material/mag_material.py +231 -0
  10. mag_material/parameters.py +150 -0
  11. mag_material/py.typed +1 -0
  12. nmag/__init__.py +36 -0
  13. nmag/backends.py +493 -0
  14. nmag/checkpoint.py +327 -0
  15. nmag/config.py +174 -0
  16. nmag/demag/__init__.py +39 -0
  17. nmag/demag/bem_operator.py +149 -0
  18. nmag/demag/geometry.py +134 -0
  19. nmag/demag/lindholm.py +133 -0
  20. nmag/demag/lindholm_fast.py +463 -0
  21. nmag/demag/linear.py +489 -0
  22. nmag/dynamics/__init__.py +255 -0
  23. nmag/output.py +27 -0
  24. nmag/parallel.py +45 -0
  25. nmag/py.typed +1 -0
  26. nmag/resources.py +38 -0
  27. nmag/simulation/__init__.py +519 -0
  28. nmag/simulation/anisotropy/__init__.py +5 -0
  29. nmag/simulation/anisotropy/fields.py +56 -0
  30. nmag/simulation/anisotropy/materials.py +138 -0
  31. nmag/simulation/demag/__init__.py +1 -0
  32. nmag/simulation/demag/bem/__init__.py +11 -0
  33. nmag/simulation/demag/bem/diagnostics.py +73 -0
  34. nmag/simulation/demag/bem/dirichlet.py +85 -0
  35. nmag/simulation/demag/bem/hierarchical.py +74 -0
  36. nmag/simulation/demag/bem/operator.py +292 -0
  37. nmag/simulation/demag/fem/__init__.py +11 -0
  38. nmag/simulation/demag/fem/assembly.py +114 -0
  39. nmag/simulation/demag/fem/charges.py +38 -0
  40. nmag/simulation/demag/fem/geometry.py +261 -0
  41. nmag/simulation/demag/fields/__init__.py +11 -0
  42. nmag/simulation/demag/fields/auxiliary.py +186 -0
  43. nmag/simulation/demag/fields/probe.py +77 -0
  44. nmag/simulation/demag/fields/recovery.py +207 -0
  45. nmag/simulation/demag/solver.py +17 -0
  46. nmag/simulation/dynamics/__init__.py +92 -0
  47. nmag/simulation/dynamics/advance.py +199 -0
  48. nmag/simulation/dynamics/integrator.py +263 -0
  49. nmag/simulation/exchange/__init__.py +13 -0
  50. nmag/simulation/exchange/coefficients.py +185 -0
  51. nmag/simulation/exchange/fields.py +106 -0
  52. nmag/simulation/exchange/llg_rhs.py +218 -0
  53. nmag/simulation/fields/__init__.py +19 -0
  54. nmag/simulation/fields/arrays.py +111 -0
  55. nmag/simulation/fields/availability.py +188 -0
  56. nmag/simulation/fields/averages.py +293 -0
  57. nmag/simulation/fields/derived.py +164 -0
  58. nmag/simulation/fields/maxangle.py +163 -0
  59. nmag/simulation/fields/probes.py +106 -0
  60. nmag/simulation/implicit_dynamics.py +221 -0
  61. nmag/simulation/mesh/__init__.py +13 -0
  62. nmag/simulation/mesh/geometry.py +153 -0
  63. nmag/simulation/mesh/materials.py +299 -0
  64. nmag/simulation/mesh/probe.py +234 -0
  65. nmag/simulation/restart.py +103 -0
  66. nmag/simulation/support.py +224 -0
  67. nmag_python_3-0.0.2.dist-info/METADATA +157 -0
  68. nmag_python_3-0.0.2.dist-info/RECORD +147 -0
  69. nmag_python_3-0.0.2.dist-info/WHEEL +5 -0
  70. nmag_python_3-0.0.2.dist-info/licenses/LICENSE +339 -0
  71. nmag_python_3-0.0.2.dist-info/top_level.txt +8 -0
  72. nmesh/__init__.py +130 -0
  73. nmesh/backend.py +286 -0
  74. nmesh/geometry/__init__.py +52 -0
  75. nmesh/geometry/boolean_operations.py +157 -0
  76. nmesh/geometry/primitives.py +453 -0
  77. nmesh/geometry/transform.py +126 -0
  78. nmesh/io/__init__.py +50 -0
  79. nmesh/io/ascii.py +132 -0
  80. nmesh/io/legacy_nmesh_hdf5.py +318 -0
  81. nmesh/io/meshio_support.py +170 -0
  82. nmesh/mesh_generation.py +182 -0
  83. nmesh/mesh_io.py +227 -0
  84. nmesh/mesh_model.py +147 -0
  85. nmesh/mesh_utilities.py +79 -0
  86. nmesh/mesher/__init__.py +21 -0
  87. nmesh/mesher/driver.py +146 -0
  88. nmesh/mesher/meshing_defaults.py +252 -0
  89. nmesh/mesher/meshing_parameters.py +185 -0
  90. nmesh/mesher/parity.py +21 -0
  91. nmesh/mesher/parity_canonical.py +142 -0
  92. nmesh/mesher/parity_comparison.py +191 -0
  93. nmesh/mesher/parity_metrics.py +114 -0
  94. nmesh/mesher/periodic.py +97 -0
  95. nmesh/mesher/relaxation/__init__.py +14 -0
  96. nmesh/mesher/relaxation/_constants.py +20 -0
  97. nmesh/mesher/relaxation/_types.py +15 -0
  98. nmesh/mesher/relaxation/density.py +170 -0
  99. nmesh/mesher/relaxation/engine/__init__.py +18 -0
  100. nmesh/mesher/relaxation/engine/state.py +155 -0
  101. nmesh/mesher/relaxation/engine/steps.py +248 -0
  102. nmesh/mesher/relaxation/engine/topology.py +230 -0
  103. nmesh/mesher/relaxation/forces/__init__.py +96 -0
  104. nmesh/mesher/relaxation/forces/jit.py +102 -0
  105. nmesh/mesher/relaxation/forces/neighbors.py +186 -0
  106. nmesh/mesher/relaxation/forces/simplex.py +302 -0
  107. nmesh/mesher/relaxation/forces/summary.py +207 -0
  108. nmesh/mesher/relaxation/forces/types.py +92 -0
  109. nmesh/mesher/relaxation/geometry/__init__.py +6 -0
  110. nmesh/mesher/relaxation/geometry/builder.py +154 -0
  111. nmesh/mesher/relaxation/geometry/model.py +194 -0
  112. nmesh/mesher/relaxation/seeding/__init__.py +74 -0
  113. nmesh/mesher/relaxation/seeding/periodic.py +88 -0
  114. nmesh/mesher/relaxation/seeding/points.py +88 -0
  115. nmesh/mesher/relaxation/seeding/sampling.py +142 -0
  116. nmesh/mesher/relaxation/topology/__init__.py +297 -0
  117. nmesh/mesher/relaxation/topology/finalize.py +78 -0
  118. nmesh/mesher/relaxation/topology/recovery.py +310 -0
  119. nmesh/mesher/sectioned_config.py +70 -0
  120. nmesh/nmesh.py +99 -0
  121. nmesh/py.typed +1 -0
  122. nmesh/utils/__init__.py +33 -0
  123. nmesh/utils/array_list_utils.py +128 -0
  124. nmesh/utils/constants.py +22 -0
  125. nmesh/utils/timing_memory_utils.py +51 -0
  126. nmesh/utils/types.py +13 -0
  127. si/constants.py +49 -0
  128. si/physical.py +722 -0
  129. si/py.typed +1 -0
  130. simulation/__init__.py +1 -0
  131. simulation/clock.py +237 -0
  132. simulation/data_writer.py +273 -0
  133. simulation/data_writer_collection.py +267 -0
  134. simulation/hysteresis.py +74 -0
  135. simulation/hysteresis_runner.py +286 -0
  136. simulation/hysteresis_schedule.py +180 -0
  137. simulation/inference/__init__.py +3 -0
  138. simulation/inference/inference.py +95 -0
  139. simulation/py.typed +1 -0
  140. simulation/quantity.py +88 -0
  141. simulation/simulation_core.py +458 -0
  142. throttler/__init__.py +3 -0
  143. throttler/py.typed +1 -0
  144. throttler/throttler.py +55 -0
  145. when/__init__.py +3 -0
  146. when/py.typed +1 -0
  147. when/when.py +416 -0
nmag/checkpoint.py ADDED
@@ -0,0 +1,327 @@
1
+ """Native HDF5 checkpoints for the supported Python 3 simulation state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any, cast
11
+
12
+ import h5py
13
+ import numpy as np
14
+
15
+ from anisotropy import anisotropy_signature_values
16
+ from si.physical import SI
17
+ from simulation.clock import SimulationClock
18
+
19
+ from .dynamics import ConvergenceTracker, IntegratorConfig
20
+
21
+ CHECKPOINT_FORMAT = "nmag-python-3-checkpoint"
22
+ CHECKPOINT_VERSION = 1
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class CheckpointContents:
27
+ magnetisation: np.ndarray
28
+ pinning: np.ndarray
29
+ external_field: np.ndarray
30
+ current_density: np.ndarray | None
31
+ metadata: dict[str, object]
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class RestartRuntimeState:
36
+ clock: SimulationClock
37
+ integrator_config: IntegratorConfig
38
+ stopping_dm_dt: float
39
+ maximum_time_seconds: float
40
+ maximum_dm_dt: float | None
41
+ convergence: ConvergenceTracker
42
+
43
+
44
+ def _array_fingerprint(digest: Any, label: str, values: object, dtype: Any) -> None:
45
+ array = np.ascontiguousarray(np.asarray(values, dtype=dtype))
46
+ digest.update(label.encode("ascii"))
47
+ digest.update(str(array.shape).encode("ascii"))
48
+ digest.update(str(array.dtype).encode("ascii"))
49
+ digest.update(array.tobytes())
50
+
51
+
52
+ def mesh_fingerprint(simulation: Any) -> str:
53
+ """Return a stable hash of the loaded physical mesh and its topology."""
54
+ mesh = simulation._require_mesh()
55
+ simplices = np.asarray(mesh.simplices, dtype=np.int64)
56
+ regions_raw = mesh.regions
57
+ regions = np.asarray(
58
+ [1] * len(simplices) if regions_raw is None else regions_raw,
59
+ dtype=np.int64,
60
+ )
61
+ digest = hashlib.sha256()
62
+ _array_fingerprint(digest, "points", simulation._mesh_points(), np.dtype(np.float64))
63
+ _array_fingerprint(digest, "simplices", simplices, np.dtype(np.int64))
64
+ _array_fingerprint(digest, "regions", regions, np.dtype(np.int64))
65
+ return digest.hexdigest()
66
+
67
+
68
+ def material_fingerprint(simulation: Any) -> str:
69
+ """Hash mesh-region material values that influence supported dynamics.
70
+
71
+ Saturation magnetisation is represented per simplex here rather than by
72
+ volume-averaged nodal recovery. The latter can differ by harmless last-bit
73
+ geometry-kernel roundoff after a run, which must not make an otherwise
74
+ compatible checkpoint unloadable.
75
+ """
76
+ coefficients = simulation._nodal_material_coefficients()
77
+ mesh = simulation._require_mesh()
78
+ regions = np.asarray(mesh.regions or [1] * len(mesh.simplices), dtype=np.int64)
79
+ digest = hashlib.sha256()
80
+ for name, values in (
81
+ ("simplex_ms", simulation._simplex_material_ms_values(regions)),
82
+ ("volume_charge_scale", simulation._simplex_volume_charge_scales(regions)),
83
+ ("exchange_prefactor", coefficients.exchange_prefactor),
84
+ ("precession", coefficients.precession),
85
+ ("damping", coefficients.damping),
86
+ ("normalisation", coefficients.normalisation),
87
+ ("stt_adiabatic", coefficients.stt_adiabatic),
88
+ ("stt_nonadiabatic", coefficients.stt_nonadiabatic),
89
+ ):
90
+ _array_fingerprint(digest, name, values, np.dtype(np.float64))
91
+ for region in np.unique(regions):
92
+ material = simulation._simplex_material(int(region))
93
+ signature = anisotropy_signature_values(
94
+ material.anisotropy,
95
+ material.anisotropy_order,
96
+ )
97
+ _array_fingerprint(
98
+ digest,
99
+ f"anisotropy:{int(region)}",
100
+ signature,
101
+ np.dtype(np.float64),
102
+ )
103
+ return digest.hexdigest()
104
+
105
+
106
+ def _finite_array(name: str, values: object, shape: tuple[int, ...]) -> np.ndarray:
107
+ array = np.ascontiguousarray(np.asarray(values, dtype=np.float64))
108
+ if array.shape != shape:
109
+ raise ValueError(f"Checkpoint {name} must have shape {shape}, got {array.shape}.")
110
+ if not np.all(np.isfinite(array)):
111
+ raise ValueError(f"Checkpoint {name} values must be finite.")
112
+ return array
113
+
114
+
115
+ def _finite_number(name: str, value: object, *, positive: bool = False) -> float:
116
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
117
+ raise ValueError(f"Checkpoint {name} must be a finite number.")
118
+ number = float(value)
119
+ if not np.isfinite(number) or (positive and number <= 0.0):
120
+ qualifier = "positive finite" if positive else "finite"
121
+ raise ValueError(f"Checkpoint {name} must be a {qualifier} number.")
122
+ return number
123
+
124
+
125
+ def _snapshot(simulation: Any) -> CheckpointContents:
126
+ point_count = len(simulation._mesh_points())
127
+ if "m" not in simulation._fields:
128
+ raise RuntimeError("Magnetisation must be set before saving a restart checkpoint.")
129
+
130
+ magnetisation = _finite_array("magnetisation", simulation._fields["m"], (point_count, 3))
131
+ pinning = _finite_array(
132
+ "pinning",
133
+ simulation._fields.get("pin", np.ones(point_count, dtype=float)),
134
+ (point_count,),
135
+ )
136
+ external_field = _finite_array(
137
+ "external field",
138
+ simulation._fields.get("H_ext", np.zeros(3, dtype=float)),
139
+ (3,),
140
+ )
141
+ current_density = None
142
+ if "current_density" in simulation._fields:
143
+ current_density = _finite_array(
144
+ "current density",
145
+ simulation._fields["current_density"],
146
+ (point_count, 3),
147
+ )
148
+
149
+ config = simulation.integrator_config
150
+ config.validate()
151
+ max_dm_dt = simulation.max_dm_dt
152
+ if max_dm_dt is not None:
153
+ max_dm_dt = _finite_number("maximum dm/dt", max_dm_dt)
154
+ dynamics_state: dict[str, object] = {
155
+ "relative_tolerance": config.relative_tolerance,
156
+ "absolute_tolerance": config.absolute_tolerance,
157
+ "maximum_step_seconds": config.maximum_step_seconds,
158
+ "exact_tstop": config.exact_tstop,
159
+ "stopping_dm_dt": _finite_number(
160
+ "stopping dm/dt", simulation.stopping_dm_dt, positive=True
161
+ ),
162
+ "maximum_time_seconds": simulation.max_time_reached.in_units_of(SI(1.0, "s")),
163
+ "maximum_dm_dt": max_dm_dt,
164
+ "convergence": simulation.convergence.checkpoint_state(),
165
+ }
166
+ _finite_number("maximum time", dynamics_state["maximum_time_seconds"], positive=True)
167
+ metadata: dict[str, object] = {
168
+ "mesh_fingerprint": mesh_fingerprint(simulation),
169
+ "material_fingerprint": material_fingerprint(simulation),
170
+ "do_demag": bool(simulation.do_demag),
171
+ "clock": simulation.clock.checkpoint_state(),
172
+ "dynamics": dynamics_state,
173
+ }
174
+ return CheckpointContents(magnetisation, pinning, external_field, current_density, metadata)
175
+
176
+
177
+ def save_checkpoint(simulation: Any, destination: Path) -> Path:
178
+ """Atomically persist a complete native checkpoint and return its path."""
179
+ contents = _snapshot(simulation)
180
+ destination = destination.expanduser()
181
+ temporary = destination.with_name(f".{destination.name}.tmp")
182
+ try:
183
+ with h5py.File(str(temporary), "w") as h5:
184
+ h5.attrs["format"] = CHECKPOINT_FORMAT
185
+ h5.attrs["version"] = CHECKPOINT_VERSION
186
+ h5.attrs["metadata"] = json.dumps(contents.metadata, allow_nan=False, sort_keys=True)
187
+ state = h5.create_group("state")
188
+ state.create_dataset("m", data=contents.magnetisation, dtype=np.float64)
189
+ state.create_dataset("pin", data=contents.pinning, dtype=np.float64)
190
+ state.create_dataset("H_ext", data=contents.external_field, dtype=np.float64)
191
+ if contents.current_density is not None:
192
+ state.create_dataset(
193
+ "current_density",
194
+ data=contents.current_density,
195
+ dtype=np.float64,
196
+ )
197
+ h5.flush()
198
+ with temporary.open("rb") as checkpoint_file:
199
+ os.fsync(checkpoint_file.fileno())
200
+ os.replace(temporary, destination)
201
+ except Exception:
202
+ temporary.unlink(missing_ok=True)
203
+ raise
204
+ return destination
205
+
206
+
207
+ def _attribute_text(value: object, name: str) -> str:
208
+ if isinstance(value, bytes):
209
+ return value.decode("utf-8")
210
+ if isinstance(value, str):
211
+ return value
212
+ raise ValueError(f"Checkpoint {name} attribute must be text.")
213
+
214
+
215
+ def read_checkpoint(filename: Path, point_count: int) -> CheckpointContents:
216
+ """Read and fully validate native checkpoint data without mutating a simulation."""
217
+ try:
218
+ with h5py.File(str(filename), "r") as h5:
219
+ if _attribute_text(h5.attrs.get("format"), "format") != CHECKPOINT_FORMAT:
220
+ raise ValueError("File is not an nmag-python-3 native checkpoint.")
221
+ version = cast(object, h5.attrs.get("version"))
222
+ if isinstance(version, bool) or not isinstance(version, (int, np.integer)):
223
+ raise ValueError("Checkpoint version must be an integer.")
224
+ if str(cast(object, version)) != str(CHECKPOINT_VERSION):
225
+ raise ValueError(
226
+ f"Unsupported checkpoint version {version}; expected {CHECKPOINT_VERSION}."
227
+ )
228
+ try:
229
+ decoded_metadata = json.loads(_attribute_text(h5.attrs.get("metadata"), "metadata"))
230
+ except json.JSONDecodeError as exc:
231
+ raise ValueError("Checkpoint metadata is not valid JSON.") from exc
232
+ if not isinstance(decoded_metadata, dict):
233
+ raise ValueError("Checkpoint metadata must be an object.")
234
+ metadata = cast(dict[str, object], decoded_metadata)
235
+ try:
236
+ state = h5["state"]
237
+ if not isinstance(state, h5py.Group):
238
+ raise ValueError("Checkpoint state must be an HDF5 group.")
239
+ magnetisation = _finite_array("magnetisation", state["m"], (point_count, 3))
240
+ pinning = _finite_array("pinning", state["pin"], (point_count,))
241
+ external_field = _finite_array("external field", state["H_ext"], (3,))
242
+ current_density = (
243
+ _finite_array("current density", state["current_density"], (point_count, 3))
244
+ if "current_density" in state
245
+ else None
246
+ )
247
+ except KeyError as exc:
248
+ raise ValueError(
249
+ f"Checkpoint is missing required state dataset {exc.args[0]!r}."
250
+ ) from exc
251
+ except OSError as exc:
252
+ raise ValueError(f"Unable to read checkpoint {filename}: {exc}") from exc
253
+ return CheckpointContents(magnetisation, pinning, external_field, current_density, metadata)
254
+
255
+
256
+ def validate_mesh(simulation: Any, contents: CheckpointContents) -> None:
257
+ saved = contents.metadata.get("mesh_fingerprint")
258
+ if not isinstance(saved, str):
259
+ raise ValueError("Checkpoint metadata is missing a mesh fingerprint.")
260
+ if saved != mesh_fingerprint(simulation):
261
+ raise ValueError("Checkpoint mesh does not match the loaded simulation mesh.")
262
+
263
+
264
+ def runtime_state(simulation: Any, contents: CheckpointContents) -> RestartRuntimeState:
265
+ """Validate full-restart compatibility and parse all non-array runtime state."""
266
+ validate_mesh(simulation, contents)
267
+ saved_material = contents.metadata.get("material_fingerprint")
268
+ if not isinstance(saved_material, str):
269
+ raise ValueError("Checkpoint metadata is missing a material fingerprint.")
270
+ if saved_material != material_fingerprint(simulation):
271
+ raise ValueError("Checkpoint materials do not match the loaded simulation.")
272
+ saved_demag = contents.metadata.get("do_demag")
273
+ if not isinstance(saved_demag, bool) or saved_demag != simulation.do_demag:
274
+ raise ValueError("Checkpoint demagnetization mode does not match the loaded simulation.")
275
+
276
+ clock_state = contents.metadata.get("clock")
277
+ dynamics = contents.metadata.get("dynamics")
278
+ if not isinstance(clock_state, dict) or not isinstance(dynamics, dict):
279
+ raise ValueError("Checkpoint metadata is missing clock or dynamics state.")
280
+ parsed_clock_state = cast(dict[str, object], clock_state)
281
+ parsed_dynamics = cast(dict[str, object], dynamics)
282
+ clock = SimulationClock.from_checkpoint_state(parsed_clock_state)
283
+ try:
284
+ exact_tstop = parsed_dynamics["exact_tstop"]
285
+ if not isinstance(exact_tstop, bool):
286
+ raise ValueError("Checkpoint exact_tstop must be a boolean.")
287
+ config = IntegratorConfig(
288
+ relative_tolerance=_finite_number(
289
+ "relative tolerance", parsed_dynamics["relative_tolerance"], positive=True
290
+ ),
291
+ absolute_tolerance=_finite_number(
292
+ "absolute tolerance", parsed_dynamics["absolute_tolerance"], positive=True
293
+ ),
294
+ maximum_step_seconds=_finite_number(
295
+ "maximum step", parsed_dynamics["maximum_step_seconds"], positive=True
296
+ ),
297
+ exact_tstop=exact_tstop,
298
+ )
299
+ config.validate()
300
+ stopping_dm_dt = _finite_number(
301
+ "stopping dm/dt", parsed_dynamics["stopping_dm_dt"], positive=True
302
+ )
303
+ maximum_time = _finite_number(
304
+ "maximum time", parsed_dynamics["maximum_time_seconds"], positive=True
305
+ )
306
+ maximum_dm_dt_raw = parsed_dynamics["maximum_dm_dt"]
307
+ maximum_dm_dt = (
308
+ None
309
+ if maximum_dm_dt_raw is None
310
+ else _finite_number("maximum dm/dt", maximum_dm_dt_raw)
311
+ )
312
+ convergence_state = parsed_dynamics["convergence"]
313
+ except KeyError as exc:
314
+ raise ValueError(f"Checkpoint dynamics state is missing {exc.args[0]!r}.") from exc
315
+ if not isinstance(convergence_state, dict):
316
+ raise ValueError("Checkpoint convergence state must be an object.")
317
+ convergence = ConvergenceTracker.from_checkpoint_state(
318
+ cast(dict[str, object], convergence_state)
319
+ )
320
+ return RestartRuntimeState(
321
+ clock,
322
+ config,
323
+ stopping_dm_dt,
324
+ maximum_time,
325
+ maximum_dm_dt,
326
+ convergence,
327
+ )
nmag/config.py ADDED
@@ -0,0 +1,174 @@
1
+ """Typed runtime configuration for supported Nmag Python workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import os
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass, field
9
+ from enum import Enum
10
+ from numbers import Real
11
+ from pathlib import Path
12
+ from types import MappingProxyType
13
+ from typing import Literal, cast
14
+
15
+ AcceleratorMode = Literal["auto", "off", "rust"]
16
+ OutputPolicy = Literal["error", "replace", "append"]
17
+ IntegratorBackend = Literal["scipy", "diffsol"]
18
+ DemagBemStorage = Literal["auto", "dense", "hierarchical", "matrix-free"]
19
+
20
+ ACCELERATOR_ENV = "NMAG_ACCELERATOR"
21
+ DEMAG_BEM_STORAGE_ENV = "NMAG_DEMAG_BEM_STORAGE_BACKEND"
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class HierarchicalBemConfig:
26
+ """Control accuracy and resources for compressed Lindholm BEM.
27
+
28
+ Attributes:
29
+ relative_tolerance: Requested relative compression/certification error.
30
+ admissibility_eta: Geometric separation threshold for low-rank blocks.
31
+ leaf_size: Maximum cluster leaf size.
32
+ max_rank: Maximum accepted low-rank block rank.
33
+ validation_vectors: Random vectors used to certify completed action.
34
+ validation_rows: Exact rows sampled during certification.
35
+ memory_fraction: Fraction of currently available memory allowed for the
36
+ completed hierarchy.
37
+ """
38
+
39
+ relative_tolerance: float = 1.0e-6
40
+ admissibility_eta: float = 2.0
41
+ leaf_size: int = 32
42
+ max_rank: int = 128
43
+ validation_vectors: int = 4
44
+ validation_rows: int = 64
45
+ memory_fraction: float = 0.20
46
+
47
+ def __post_init__(self) -> None:
48
+ for name in ("relative_tolerance", "admissibility_eta", "memory_fraction"):
49
+ raw_value = getattr(self, name)
50
+ if type(raw_value) is bool or not isinstance(raw_value, Real):
51
+ raise TypeError(f"{name} must be a real number.")
52
+ value = float(raw_value)
53
+ if not math.isfinite(value) or value <= 0.0:
54
+ raise ValueError(f"{name} must be finite and positive.")
55
+ object.__setattr__(self, name, value)
56
+ if self.memory_fraction > 1.0:
57
+ raise ValueError("memory_fraction must not exceed 1.0.")
58
+ for name in ("leaf_size", "max_rank", "validation_vectors", "validation_rows"):
59
+ value = getattr(self, name)
60
+ if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
61
+ raise ValueError(f"{name} must be a positive integer.")
62
+
63
+
64
+ class RustKernel(str, Enum):
65
+ """Rust-accelerated calculation families accepted as override keys."""
66
+
67
+ LINDHOLM_BEM = "lindholm_bem"
68
+ PROBE_GEOMETRY = "probe_geometry"
69
+ FEM_GEOMETRY = "fem_geometry"
70
+ BOUNDARY_FACES = "boundary_faces"
71
+ FEM_ASSEMBLY = "fem_assembly"
72
+ NODAL_RECOVERY = "nodal_recovery"
73
+ CELL_AVERAGE = "cell_average"
74
+ LLG = "llg"
75
+ MAXANGLE = "maxangle"
76
+
77
+
78
+ def _validate_mode(value: object, *, field_name: str) -> AcceleratorMode:
79
+ if not isinstance(value, str):
80
+ raise TypeError(f"{field_name} must be a string mode.")
81
+ if value not in {"auto", "off", "rust"}:
82
+ raise ValueError(f"{field_name} must be one of 'auto', 'off', or 'rust', got {value!r}.")
83
+ return cast(AcceleratorMode, value)
84
+
85
+
86
+ def _empty_overrides() -> Mapping[RustKernel, AcceleratorMode]:
87
+ return {}
88
+
89
+
90
+ @dataclass(frozen=True, slots=True)
91
+ class NmagConfig:
92
+ """Define immutable output, acceleration, storage, and integrator policy.
93
+
94
+ Attributes:
95
+ default_name: Simulation name used when ``Simulation(name=...)`` is
96
+ omitted.
97
+ output_directory: Directory for NDT, HDF5, and default checkpoint files.
98
+ The directory must exist before output is written.
99
+ output_policy: ``"error"` protects old output, ``"replace"`` starts it
100
+ again, and ``"append"`` validates and extends the NDT schema.
101
+ accelerator: Global ``"auto"``, ``"off"``, or strict ``"rust"`` mode.
102
+ accelerator_overrides: Per-:class:`RustKernel` mode overrides.
103
+ integrator_backend: ``"scipy"`` or experimental ``"diffsol"``.
104
+ demag_bem_storage: ``"auto"``, ``"dense"``, ``"hierarchical"``, or
105
+ ``"matrix-free"`` boundary-operator storage.
106
+ hierarchical_bem: Accuracy and resource settings for hierarchy builds.
107
+ """
108
+
109
+ default_name: str = "nmag_simulation"
110
+ output_directory: Path = Path(".")
111
+ output_policy: OutputPolicy = "error"
112
+ accelerator: AcceleratorMode = "auto"
113
+ accelerator_overrides: Mapping[RustKernel, AcceleratorMode] = field(
114
+ default_factory=_empty_overrides
115
+ )
116
+ integrator_backend: IntegratorBackend = "scipy"
117
+ demag_bem_storage: DemagBemStorage = "auto"
118
+ hierarchical_bem: HierarchicalBemConfig = field(default_factory=HierarchicalBemConfig)
119
+
120
+ def __post_init__(self) -> None:
121
+ if not self.default_name:
122
+ raise ValueError("default_name must not be empty.")
123
+ if self.output_policy not in {"error", "replace", "append"}:
124
+ raise ValueError("output_policy must be 'error', 'replace', or 'append'.")
125
+ if self.integrator_backend not in {"scipy", "diffsol"}:
126
+ raise ValueError("integrator_backend must be 'scipy' or 'diffsol'.")
127
+ if self.demag_bem_storage not in {"auto", "dense", "hierarchical", "matrix-free"}:
128
+ raise ValueError(
129
+ "demag_bem_storage must be 'auto', 'dense', 'hierarchical', or 'matrix-free'."
130
+ )
131
+ if not isinstance(cast(object, self.hierarchical_bem), HierarchicalBemConfig):
132
+ raise TypeError("hierarchical_bem must be a HierarchicalBemConfig instance.")
133
+ object.__setattr__(self, "output_directory", Path(self.output_directory).expanduser())
134
+ object.__setattr__(
135
+ self,
136
+ "accelerator",
137
+ _validate_mode(self.accelerator, field_name="accelerator"),
138
+ )
139
+
140
+ validated_overrides: dict[RustKernel, AcceleratorMode] = {}
141
+ raw_overrides = cast(Mapping[object, object], self.accelerator_overrides)
142
+ for raw_kernel, raw_mode in raw_overrides.items():
143
+ if not isinstance(raw_kernel, RustKernel):
144
+ raise TypeError("accelerator_overrides keys must be RustKernel values.")
145
+ validated_overrides[raw_kernel] = _validate_mode(
146
+ raw_mode,
147
+ field_name=f"accelerator_overrides[{raw_kernel.value!r}]",
148
+ )
149
+ object.__setattr__(
150
+ self,
151
+ "accelerator_overrides",
152
+ MappingProxyType(validated_overrides),
153
+ )
154
+
155
+ @classmethod
156
+ def from_environment(cls) -> NmagConfig:
157
+ """Build default configuration from supported process-level selectors.
158
+
159
+ Returns:
160
+ Configuration using ``NMAG_ACCELERATOR`` and
161
+ ``NMAG_DEMAG_BEM_STORAGE_BACKEND`` when set.
162
+ """
163
+
164
+ accelerator = os.environ.get(ACCELERATOR_ENV, "auto").strip().lower()
165
+ bem_storage = os.environ.get(DEMAG_BEM_STORAGE_ENV, "auto").strip().lower()
166
+ return cls(
167
+ accelerator=_validate_mode(accelerator, field_name=ACCELERATOR_ENV),
168
+ demag_bem_storage=cast(DemagBemStorage, bem_storage),
169
+ )
170
+
171
+ def accelerator_mode_for(self, kernel: RustKernel) -> AcceleratorMode:
172
+ """Return a kernel override, falling back to the global accelerator mode."""
173
+
174
+ return self.accelerator_overrides.get(kernel, self.accelerator)
nmag/demag/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from . import geometry as _geometry
6
+ from . import lindholm as _lindholm
7
+ from . import lindholm_fast as _fast
8
+ from . import linear as _linear
9
+
10
+ _HELPER_MODULES = (_geometry, _lindholm, _fast, _linear)
11
+
12
+ # Preserve the established patch points used by downstream tests and tooling.
13
+ np = _linear.np
14
+ _scipy_linalg = _linear._scipy_linalg
15
+
16
+
17
+ def _solve_gauge_fixed(*args: Any, **kwargs: Any) -> Any:
18
+ """Forward to the solver while preserving facade-level monkeypatches."""
19
+
20
+ _linear._scipy_linalg = _scipy_linalg
21
+ return _linear._solve_gauge_fixed(*args, **kwargs)
22
+
23
+
24
+ def _solve_linear_system(*args: Any, **kwargs: Any) -> Any:
25
+ """Forward to the solver while preserving facade-level monkeypatches."""
26
+
27
+ _linear._scipy_linalg = _scipy_linalg
28
+ return _linear._solve_linear_system(*args, **kwargs)
29
+
30
+
31
+ def __getattr__(name: str) -> Any:
32
+ """Resolve compatibility exports from the focused demag modules."""
33
+
34
+ for module in _HELPER_MODULES:
35
+ try:
36
+ return getattr(module, name)
37
+ except AttributeError:
38
+ continue
39
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+
8
+ from .lindholm_fast import _apply_lindholm_bem_matrix_free_fast
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class BemOperatorStats:
13
+ """Describe construction and storage of the active BEM operator.
14
+
15
+ Attributes:
16
+ requested_backend: Storage mode requested by configuration.
17
+ effective_backend: Storage implementation actually constructed.
18
+ fallback_reason: Explanation when construction changed modes.
19
+ boundary_nodes: Number of boundary degrees of freedom.
20
+ boundary_faces: Number of oriented surface triangles.
21
+ setup_seconds: Operator construction time.
22
+ storage_bytes: Bytes retained by the effective operator.
23
+ dense_equivalent_bytes: Bytes required by an equivalent dense matrix.
24
+ dense_blocks: Exact blocks in a hierarchy.
25
+ low_rank_blocks: Compressed blocks in a hierarchy.
26
+ maximum_rank: Largest compressed-block rank.
27
+ mean_rank: Mean compressed-block rank.
28
+ sampled_relative_error: Certification error measured during setup.
29
+ """
30
+
31
+ requested_backend: str
32
+ effective_backend: str
33
+ fallback_reason: str | None
34
+ boundary_nodes: int
35
+ boundary_faces: int
36
+ setup_seconds: float
37
+ storage_bytes: int
38
+ dense_equivalent_bytes: int
39
+ dense_blocks: int = 0
40
+ low_rank_blocks: int = 0
41
+ maximum_rank: int = 0
42
+ mean_rank: float = 0.0
43
+ sampled_relative_error: float = 0.0
44
+
45
+ @property
46
+ def compression_ratio(self) -> float:
47
+ if self.dense_equivalent_bytes == 0:
48
+ return 0.0
49
+ return self.storage_bytes / self.dense_equivalent_bytes
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class MatrixFreeLindholmBemOperator:
54
+ """Exact Lindholm BEM action without storing the dense boundary matrix."""
55
+
56
+ points: np.ndarray
57
+ simplices: np.ndarray
58
+ boundary_faces: np.ndarray
59
+ face_points: np.ndarray
60
+ zeta_vectors: np.ndarray
61
+ eta_vectors: np.ndarray
62
+ edge_lengths: np.ndarray
63
+ corner_cosines: np.ndarray
64
+ denominator_factors: np.ndarray
65
+ boundary_nodes: np.ndarray
66
+ local_index_by_point: np.ndarray
67
+ solid_angles: np.ndarray
68
+ rust_accelerator: Any | None = None
69
+
70
+ @property
71
+ def shape(self) -> tuple[int, int]:
72
+ size = len(self.boundary_nodes)
73
+ return size, size
74
+
75
+ def __matmul__(self, values: np.ndarray) -> np.ndarray:
76
+ vector = np.asarray(values, dtype=np.float64)
77
+ expected = (len(self.boundary_nodes),)
78
+ if vector.shape != expected:
79
+ raise ValueError(f"BEM input must have shape {expected}, got {vector.shape}.")
80
+ if self.rust_accelerator is not None:
81
+ return np.asarray(
82
+ self.rust_accelerator.apply_lindholm_bem_matrix_free(
83
+ self.points,
84
+ self.simplices,
85
+ self.boundary_faces,
86
+ self.boundary_nodes,
87
+ self.local_index_by_point,
88
+ vector,
89
+ ),
90
+ dtype=float,
91
+ )
92
+ return np.asarray(
93
+ _apply_lindholm_bem_matrix_free_fast(
94
+ self.points,
95
+ self.boundary_faces,
96
+ self.face_points,
97
+ self.zeta_vectors,
98
+ self.eta_vectors,
99
+ self.edge_lengths,
100
+ self.corner_cosines,
101
+ self.denominator_factors,
102
+ self.boundary_nodes,
103
+ self.local_index_by_point,
104
+ self.solid_angles,
105
+ vector,
106
+ ),
107
+ dtype=float,
108
+ )
109
+
110
+ @property
111
+ def storage_bytes(self) -> int:
112
+ arrays = (
113
+ self.points,
114
+ self.simplices,
115
+ self.boundary_faces,
116
+ self.face_points,
117
+ self.zeta_vectors,
118
+ self.eta_vectors,
119
+ self.edge_lengths,
120
+ self.corner_cosines,
121
+ self.denominator_factors,
122
+ self.boundary_nodes,
123
+ self.local_index_by_point,
124
+ self.solid_angles,
125
+ )
126
+ return sum(int(array.nbytes) for array in arrays)
127
+
128
+
129
+ @dataclass(frozen=True, slots=True)
130
+ class HierarchicalLindholmBemOperator:
131
+ """Certified compressed Lindholm operator owned by the Rust extension."""
132
+
133
+ rust_operator: Any
134
+
135
+ @property
136
+ def shape(self) -> tuple[int, int]:
137
+ size = int(self.rust_operator.size)
138
+ return size, size
139
+
140
+ def __matmul__(self, values: np.ndarray) -> np.ndarray:
141
+ vector = np.asarray(values, dtype=np.float64)
142
+ expected = (self.shape[1],)
143
+ if vector.shape != expected:
144
+ raise ValueError(f"BEM input must have shape {expected}, got {vector.shape}.")
145
+ return np.asarray(self.rust_operator.matvec(vector), dtype=np.float64)
146
+
147
+ @property
148
+ def storage_bytes(self) -> int:
149
+ return int(self.rust_operator.storage_bytes)