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
si/py.typed ADDED
@@ -0,0 +1 @@
1
+ # Marker for PEP 561 inline type information.
simulation/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Simulation support modules for the nmag Python 3 rewrite."""
simulation/clock.py ADDED
@@ -0,0 +1,237 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from math import isfinite
5
+ from typing import Any
6
+
7
+ from tabulate import tabulate
8
+
9
+ from si.physical import SI
10
+
11
+ _zero_seconds_cache: SI | None = None
12
+
13
+
14
+ def _zero_seconds() -> SI:
15
+ global _zero_seconds_cache
16
+ if _zero_seconds_cache is None:
17
+ _zero_seconds_cache = SI(0.0, "s")
18
+ return SI(_zero_seconds_cache._quantity)
19
+
20
+
21
+ def fmt_time(t: SI, fmt_ps: str = ".2f", fmt_ns: str = ".2f") -> str:
22
+ """Formats an SI time object into picoseconds or nanoseconds."""
23
+ t_ps = float(t / SI(1e-12, "s"))
24
+
25
+ ps_str = f"{t_ps:{fmt_ps}}"
26
+ ns_str = f"{(t_ps / 1000.0):{fmt_ns}}"
27
+
28
+ return f"{ps_str} ps" if t_ps < 100.0 else f"{ns_str} ns"
29
+
30
+
31
+ @dataclass
32
+ class SimulationClock:
33
+ """
34
+ This object specifies all the parameters which define the current time
35
+ in the simulation, such as the simulation time, step number, ...
36
+
37
+ Attributes:
38
+ id: Unique identifier for data saved. Incremented on save.
39
+ stage: Stage number. Increments when the external field changes.
40
+ step: Total number of steps performed (always increases).
41
+ stage_step: Step number from the beginning of the current stage.
42
+ zero_stage_step: The value of 'step' at the beginning of the stage.
43
+ time: Total simulation time (always increases).
44
+ stage_time: The simulation time from the beginning of the stage.
45
+ zero_stage_time: The value of 'time' at the beginning of the stage.
46
+ real_time: The real world time used for advancing time.
47
+ last_step_dt_si: Last time step's length in SI units.
48
+ convergence: Flag indicating if convergence is reached.
49
+ stage_end: Flag indicating the end of a stage.
50
+ exit_hysteresis: Flag to signal exit from hysteresis loop.
51
+ """
52
+
53
+ id: int = -1
54
+ stage: int = 1
55
+ step: int = 0
56
+ time: SI = field(default_factory=_zero_seconds)
57
+ stage_step: int = 0
58
+ stage_time: SI = field(default_factory=_zero_seconds)
59
+ real_time: SI = field(default_factory=_zero_seconds)
60
+ stage_end: bool = False
61
+ convergence: bool = False
62
+ exit_hysteresis: bool = False
63
+ zero_stage_time: SI = field(default_factory=_zero_seconds)
64
+ zero_stage_step: int = 0
65
+ time_reached_su: float = 0.0
66
+ time_reached_si: SI = field(default_factory=_zero_seconds)
67
+ last_step_dt_su: float = 0.0
68
+ last_step_dt_si: SI = field(default_factory=_zero_seconds)
69
+
70
+ def __getitem__(self, key: str, /) -> Any:
71
+ if key not in self.__dataclass_fields__:
72
+ raise KeyError(key)
73
+ return getattr(self, key)
74
+
75
+ def get(self, key: str, default: Any = None, /) -> Any:
76
+ try:
77
+ return self[key]
78
+ except KeyError:
79
+ return default
80
+
81
+ def copy(self) -> dict[str, Any]:
82
+ return asdict(self)
83
+
84
+ def checkpoint_state(self) -> dict[str, int | float | bool]:
85
+ """Return a JSON-safe representation of the simulation clock."""
86
+ return {
87
+ "id": self.id,
88
+ "stage": self.stage,
89
+ "step": self.step,
90
+ "stage_step": self.stage_step,
91
+ "zero_stage_step": self.zero_stage_step,
92
+ "time_seconds": self.time.in_units_of(SI(1.0, "s")),
93
+ "stage_time_seconds": self.stage_time.in_units_of(SI(1.0, "s")),
94
+ "real_time_seconds": self.real_time.in_units_of(SI(1.0, "s")),
95
+ "zero_stage_time_seconds": self.zero_stage_time.in_units_of(SI(1.0, "s")),
96
+ "time_reached_seconds": self.time_reached_si.in_units_of(SI(1.0, "s")),
97
+ "last_step_seconds": self.last_step_dt_si.in_units_of(SI(1.0, "s")),
98
+ "stage_end": self.stage_end,
99
+ "convergence": self.convergence,
100
+ "exit_hysteresis": self.exit_hysteresis,
101
+ }
102
+
103
+ @classmethod
104
+ def from_checkpoint_state(cls, state: dict[str, object]) -> SimulationClock:
105
+ """Build a validated clock from :meth:`checkpoint_state` data."""
106
+ integer_names = ("id", "stage", "step", "stage_step", "zero_stage_step")
107
+ seconds_names = (
108
+ "time_seconds",
109
+ "stage_time_seconds",
110
+ "real_time_seconds",
111
+ "zero_stage_time_seconds",
112
+ "time_reached_seconds",
113
+ "last_step_seconds",
114
+ )
115
+ boolean_names = ("stage_end", "convergence", "exit_hysteresis")
116
+ required = {*integer_names, *seconds_names, *boolean_names}
117
+ missing = sorted(required.difference(state))
118
+ if missing:
119
+ raise ValueError(f"Checkpoint clock state is missing {', '.join(missing)}.")
120
+
121
+ integers: dict[str, int] = {}
122
+ for name in integer_names:
123
+ value = state[name]
124
+ if isinstance(value, bool) or not isinstance(value, int):
125
+ raise ValueError(f"Checkpoint clock {name} must be an integer.")
126
+ integers[name] = value
127
+ if integers["stage"] < 1 or any(
128
+ integers[name] < 0 for name in ("step", "stage_step", "zero_stage_step")
129
+ ):
130
+ raise ValueError("Checkpoint clock counters must be non-negative and stage at least one.")
131
+
132
+ seconds: dict[str, float] = {}
133
+ for name in seconds_names:
134
+ value = state[name]
135
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
136
+ raise ValueError(f"Checkpoint clock {name} must be a finite number.")
137
+ converted = float(value)
138
+ if not isfinite(converted) or converted < 0.0:
139
+ raise ValueError(f"Checkpoint clock {name} must be a non-negative finite number.")
140
+ seconds[name] = converted
141
+
142
+ booleans: dict[str, bool] = {}
143
+ for name in boolean_names:
144
+ value = state[name]
145
+ if not isinstance(value, bool):
146
+ raise ValueError(f"Checkpoint clock {name} must be a boolean.")
147
+ booleans[name] = value
148
+
149
+ return cls(
150
+ id=integers["id"],
151
+ stage=integers["stage"],
152
+ step=integers["step"],
153
+ stage_step=integers["stage_step"],
154
+ zero_stage_step=integers["zero_stage_step"],
155
+ time=SI(seconds["time_seconds"], "s"),
156
+ stage_time=SI(seconds["stage_time_seconds"], "s"),
157
+ real_time=SI(seconds["real_time_seconds"], "s"),
158
+ zero_stage_time=SI(seconds["zero_stage_time_seconds"], "s"),
159
+ time_reached_su=seconds["time_reached_seconds"],
160
+ time_reached_si=SI(seconds["time_reached_seconds"], "s"),
161
+ last_step_dt_su=seconds["last_step_seconds"],
162
+ last_step_dt_si=SI(seconds["last_step_seconds"], "s"),
163
+ stage_end=booleans["stage_end"],
164
+ convergence=booleans["convergence"],
165
+ exit_hysteresis=booleans["exit_hysteresis"],
166
+ )
167
+
168
+ # __init__ and __repr__ are GONE (auto-generated)
169
+
170
+ def inc_stage(self, stage: int | None = None) -> None:
171
+ """Advance the clock to the next stage."""
172
+ if stage is None:
173
+ self.stage += 1
174
+ else:
175
+ self.stage = stage
176
+ self.stage_step = 0
177
+ self.stage_time = SI(0.0, "s")
178
+ self.convergence = False
179
+ self.zero_stage_step = self.step
180
+ self.zero_stage_time = self.time
181
+
182
+ def record_advance(
183
+ self,
184
+ *,
185
+ stage_time_seconds: float,
186
+ accepted_steps: int,
187
+ last_step_seconds: float,
188
+ wall_seconds: float,
189
+ ) -> None:
190
+ """Atomically record an accepted group of integration steps."""
191
+ previous_stage_seconds = self.stage_time.in_units_of(SI(1.0, "s"))
192
+ delta_seconds = stage_time_seconds - previous_stage_seconds
193
+ if delta_seconds < 0.0:
194
+ raise ValueError("stage time cannot move backwards.")
195
+ if accepted_steps < 0:
196
+ raise ValueError("accepted_steps cannot be negative.")
197
+
198
+ self.step += accepted_steps
199
+ self.stage_step += accepted_steps
200
+ self.time += SI(delta_seconds, "s")
201
+ self.stage_time = SI(stage_time_seconds, "s")
202
+ self.time_reached_su = stage_time_seconds
203
+ self.time_reached_si = SI(stage_time_seconds, "s")
204
+ self.last_step_dt_su = last_step_seconds
205
+ self.last_step_dt_si = SI(last_step_seconds, "s")
206
+ self.real_time += SI(max(0.0, wall_seconds), "s")
207
+
208
+ # This method was updated to use tabulate, the format of the data printed out might
209
+ # look slightly different, but there is a lot less manual formatting code here now.
210
+ def __str__(self) -> str:
211
+ ft = fmt_time
212
+
213
+ rows = [
214
+ [
215
+ f"ID={self.id}",
216
+ f"Step={self.step}",
217
+ f"Time={ft(self.time)}",
218
+ f"Last step size={ft(self.last_step_dt_si)}",
219
+ ],
220
+ [
221
+ "",
222
+ f"Stage={self.stage}",
223
+ f"Stage-step={self.stage_step}",
224
+ f"Stage-time={ft(self.stage_time)}",
225
+ ],
226
+ [
227
+ "",
228
+ f"Convergence={self.convergence}",
229
+ f"Stage-end={self.stage_end}",
230
+ f"Exit hysteresis={self.exit_hysteresis}",
231
+ ],
232
+ ]
233
+
234
+ table = tabulate(rows, tablefmt="pipe")
235
+
236
+ sep_line = "=" * (len(table.splitlines()[0]))
237
+ return f"{sep_line}\n{table}\n{sep_line}"
@@ -0,0 +1,273 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import logging
5
+ import time
6
+ from collections.abc import Generator
7
+ from contextlib import contextmanager
8
+ from pathlib import Path
9
+ from typing import Any, cast
10
+
11
+ from si.physical import SI
12
+ from simulation.data_writer_collection import (
13
+ DataWriterCollectionMixin,
14
+ SimulationSource,
15
+ _SubfieldRequest,
16
+ )
17
+ from simulation.quantity import Quantity, known_quantities
18
+
19
+ log = logging.getLogger("nmag")
20
+
21
+
22
+ class DataWriter(DataWriterCollectionMixin):
23
+ def __init__(self, ndt_filename: Path, h5_filename: Path, *, append: bool = False) -> None:
24
+ """
25
+ :param ndt_filename: Path to the tabular output (.ndt).
26
+ :param h5_filename: Path to the spatial output (.h5).
27
+ """
28
+ self.ndt_filename = ndt_filename
29
+ self.h5_filename = h5_filename
30
+ self.quantities = known_quantities
31
+
32
+ self.quantities_by_name: dict[str, Quantity] = {q.name: q for q in known_quantities}
33
+
34
+ self._header_written: bool = False
35
+ self._column_names: list[str] | None = None
36
+ self._column_index_by_name: dict[str, int] | None = None
37
+ self._column_units: dict[str, str] | None = None
38
+ self._subfield_requests: list[_SubfieldRequest] = []
39
+ self._writes_maxangle: bool = False
40
+ self._last_saved_step: int = -1
41
+ self.last_save_timings_seconds: dict[str, float] = {}
42
+ self._active_save_timings: dict[str, float] | None = None
43
+ self._append_schema_pending = False
44
+ if append and self.ndt_filename.exists():
45
+ self._recover_ndt_schema()
46
+ self._append_schema_pending = True
47
+
48
+ def _recover_ndt_schema(self) -> None:
49
+ """Recover the schema and last step from an NDT file opened for append."""
50
+
51
+ with open(self.ndt_filename, newline="", encoding="utf-8") as stream:
52
+ rows = [row for row in csv.reader(stream, delimiter="\t") if row]
53
+ if len(rows) < 2 or not rows[0][0].startswith("# Simulation:"):
54
+ raise ValueError(f"Cannot append to malformed NDT output: {self.ndt_filename}.")
55
+ column_names = rows[1]
56
+ if len(column_names) != len(set(column_names)):
57
+ raise ValueError(f"Cannot append to NDT output with duplicate columns: {self.ndt_filename}.")
58
+ self._column_names = column_names
59
+ self._column_index_by_name = {name: index for index, name in enumerate(column_names)}
60
+ self._column_units = {}
61
+ self._header_written = True
62
+ step_index = self._column_index_by_name.get("step")
63
+ if step_index is not None:
64
+ for row in reversed(rows[2:]):
65
+ if len(row) != len(column_names):
66
+ continue
67
+ try:
68
+ self._last_saved_step = int(float(row[step_index]))
69
+ except ValueError:
70
+ continue
71
+ break
72
+
73
+ def save(
74
+ self,
75
+ source: SimulationSource,
76
+ fields: str | list[str] | None = None,
77
+ avoid_same_step: bool = False,
78
+ ) -> None:
79
+
80
+ current_step = source.step
81
+ if avoid_same_step and current_step == self._last_saved_step:
82
+ return
83
+
84
+ timings: dict[str, float] = {}
85
+ self._active_save_timings = timings
86
+ total_started = time.perf_counter()
87
+ try:
88
+ with self._record_timing("write_ndt_row"):
89
+ self._write_ndt_row(source)
90
+
91
+ if fields is not None:
92
+ with self._record_timing("save_spatial_fields"):
93
+ self._trigger_field_save(source, fields)
94
+ finally:
95
+ timings["total"] = time.perf_counter() - total_started
96
+ self.last_save_timings_seconds = dict(sorted(timings.items()))
97
+ self._active_save_timings = None
98
+
99
+ self._last_saved_step = current_step
100
+
101
+ @contextmanager
102
+ def _record_timing(self, name: str) -> Generator[None]:
103
+ started = time.perf_counter()
104
+ try:
105
+ yield
106
+ finally:
107
+ timings = self._active_save_timings
108
+ if timings is not None:
109
+ timings[name] = timings.get(name, 0.0) + time.perf_counter() - started
110
+
111
+ def _trigger_field_save(self, source: SimulationSource, fields: str | list[str]) -> None:
112
+ with self._record_timing("spatial_detail:field_selection"):
113
+ field_names_to_save: list[str] = []
114
+
115
+ if fields == "all":
116
+ field_names_to_save = source.get_all_field_names()
117
+ elif isinstance(fields, list):
118
+ field_names_to_save = fields
119
+ else:
120
+ raise ValueError(f"Invalid fields argument: {fields}")
121
+
122
+ if field_names_to_save:
123
+ with self._record_timing("spatial_detail:write_dispatch"):
124
+ source.save_spatial_fields(
125
+ filename=str(self.h5_filename), fieldnames=field_names_to_save
126
+ )
127
+ self._record_source_spatial_save_timings(source)
128
+
129
+ def _write_ndt_row(self, source: SimulationSource) -> None:
130
+ quantities: list[Quantity] = []
131
+ with self._record_timing("gather_ndt_columns"):
132
+ if self._append_schema_pending:
133
+ with self._record_timing("gather_ndt_append_schema_columns"):
134
+ columns, _quantities = self._gather_data(source)
135
+ elif self._header_written:
136
+ with self._record_timing("gather_ndt_existing_schema_columns"):
137
+ columns = self._gather_existing_schema_data(source)
138
+ quantities: list[Quantity] = []
139
+ else:
140
+ with self._record_timing("gather_ndt_initial_schema_columns"):
141
+ columns, quantities = self._gather_data(source)
142
+
143
+ if not self._header_written:
144
+ with self._record_timing("build_ndt_header_schema"):
145
+ col_names: list[str] = []
146
+ col_units: dict[str, str] = {}
147
+
148
+ for (name, _), qty in zip(columns, quantities, strict=True):
149
+ col_names.append(name)
150
+ if qty.units:
151
+ col_units[name] = qty.units.dens_str()
152
+ else:
153
+ col_units[name] = "-"
154
+
155
+ column_index_by_name = {name: index for index, name in enumerate(col_names)}
156
+ with self._record_timing("format_ndt_values"):
157
+ row_values: list[Any] = [self._ndt_cell_value(value) for _, value in columns]
158
+
159
+ with open(self.ndt_filename, "w", newline="", encoding="utf-8") as f:
160
+ writer = csv.writer(f, delimiter="\t")
161
+ with self._record_timing("write_ndt_header"):
162
+ f.write(f"# Simulation: {source.name}\n")
163
+ writer.writerow(col_names)
164
+
165
+ with self._record_timing("write_ndt_values"):
166
+ writer.writerow(row_values)
167
+
168
+ self._column_names = col_names
169
+ self._column_index_by_name = column_index_by_name
170
+ self._column_units = col_units
171
+ self._header_written = True
172
+ return
173
+
174
+ if self._column_names is None:
175
+ log.error("Column names not initialized.")
176
+ return
177
+
178
+ with self._record_timing("validate_ndt_schema"):
179
+ actual_names = [name for name, _ in columns]
180
+ if actual_names != self._column_names:
181
+ raise ValueError(
182
+ "Cannot append output with a different NDT schema. "
183
+ f"Expected {self._column_names!r}, got {actual_names!r}."
184
+ )
185
+ self._append_schema_pending = False
186
+
187
+ with self._record_timing("format_ndt_values"):
188
+ row_values = self._existing_schema_row_values(columns)
189
+
190
+ with self._record_timing("write_ndt_values"):
191
+ with open(self.ndt_filename, "a", newline="", encoding="utf-8") as f:
192
+ writer = csv.writer(f, delimiter="\t")
193
+ writer.writerow(row_values)
194
+
195
+ def _existing_schema_row_values(
196
+ self,
197
+ columns: list[tuple[str, Any]],
198
+ ) -> list[Any]:
199
+ if self._column_names is None:
200
+ return []
201
+ if self._column_index_by_name is None:
202
+ self._column_index_by_name = {
203
+ name: index for index, name in enumerate(self._column_names)
204
+ }
205
+
206
+ row_values: list[Any] = [None] * len(self._column_names)
207
+ for name, value in columns:
208
+ index = self._column_index_by_name.get(name)
209
+ if index is not None:
210
+ row_values[index] = self._ndt_cell_value(value)
211
+ return row_values
212
+
213
+ @staticmethod
214
+ def _ndt_cell_value(value: Any) -> Any:
215
+ if isinstance(value, SI):
216
+ return value.magnitude
217
+ return value
218
+
219
+ def _record_source_average_timings(
220
+ self,
221
+ source: SimulationSource,
222
+ prefix: str,
223
+ ) -> None:
224
+ timings = self._active_save_timings
225
+ if timings is None:
226
+ return
227
+ source_timings = getattr(
228
+ source,
229
+ "last_subfield_average_timings_seconds",
230
+ None,
231
+ )
232
+ if not isinstance(source_timings, dict):
233
+ return
234
+ for name, seconds in cast(dict[object, object], source_timings).items():
235
+ if isinstance(seconds, (int, float)):
236
+ timing_name = f"average_detail:{prefix}:{name!s}"
237
+ timings[timing_name] = timings.get(timing_name, 0.0) + float(seconds)
238
+
239
+ def _record_source_spatial_save_timings(self, source: SimulationSource) -> None:
240
+ timings = self._active_save_timings
241
+ if timings is None:
242
+ return
243
+ source_timings = getattr(
244
+ source,
245
+ "last_spatial_save_timings_seconds",
246
+ None,
247
+ )
248
+ if not isinstance(source_timings, dict):
249
+ return
250
+ for name, seconds in cast(dict[object, object], source_timings).items():
251
+ if isinstance(seconds, (int, float)):
252
+ timing_name = f"spatial_detail:{name!s}"
253
+ timings[timing_name] = timings.get(timing_name, 0.0) + float(seconds)
254
+
255
+ def _record_source_maxangle_timings(
256
+ self,
257
+ source: SimulationSource,
258
+ prefix: str,
259
+ ) -> None:
260
+ timings = self._active_save_timings
261
+ if timings is None:
262
+ return
263
+ source_timings = getattr(
264
+ source,
265
+ "last_maxangle_timings_seconds",
266
+ None,
267
+ )
268
+ if not isinstance(source_timings, dict):
269
+ return
270
+ for name, seconds in cast(dict[object, object], source_timings).items():
271
+ if isinstance(seconds, (int, float)):
272
+ timing_name = f"average_detail:{prefix}:{name!s}"
273
+ timings[timing_name] = timings.get(timing_name, 0.0) + float(seconds)