drbx 2.0.0.dev0__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 (106) hide show
  1. drbx/__init__.py +20 -0
  2. drbx/__main__.py +5 -0
  3. drbx/cli.py +586 -0
  4. drbx/config/__init__.py +20 -0
  5. drbx/config/boutinp.py +556 -0
  6. drbx/config/model.py +34 -0
  7. drbx/config/normalization.py +66 -0
  8. drbx/data/atomic_rates/__init__.py +1 -0
  9. drbx/data/atomic_rates/iz_AMJUEL_H.x_2.1.5.json +213 -0
  10. drbx/data/atomic_rates/iz_AMJUEL_H.x_2.3.9a.json +213 -0
  11. drbx/data/atomic_rates/rec_AMJUEL_H.x_2.1.8.json +213 -0
  12. drbx/data/atomic_rates/rec_AMJUEL_H.x_2.3.13a.json +213 -0
  13. drbx/geometry/__init__.py +207 -0
  14. drbx/geometry/embedding.py +56 -0
  15. drbx/geometry/essos_import.py +1385 -0
  16. drbx/geometry/fci_geometry.py +4622 -0
  17. drbx/geometry/fci_maps.py +85 -0
  18. drbx/geometry/island_divertor.py +291 -0
  19. drbx/geometry/metric_tensor.py +99 -0
  20. drbx/geometry/open_slab.py +150 -0
  21. drbx/geometry/rotating_ellipse.py +253 -0
  22. drbx/geometry/shifted_torus.py +225 -0
  23. drbx/geometry/stellarator.py +287 -0
  24. drbx/geometry/vmec_extender_import.py +499 -0
  25. drbx/geometry/vmec_jax_import.py +306 -0
  26. drbx/linear/__init__.py +37 -0
  27. drbx/linear/dispersion.py +138 -0
  28. drbx/linear/eigen.py +91 -0
  29. drbx/native/__init__.py +224 -0
  30. drbx/native/array_backend.py +64 -0
  31. drbx/native/deck_runner.py +779 -0
  32. drbx/native/electromagnetic.py +250 -0
  33. drbx/native/expression.py +173 -0
  34. drbx/native/fci.py +295 -0
  35. drbx/native/fci_2_field_rhs.py +182 -0
  36. drbx/native/fci_4_field_rhs.py +1267 -0
  37. drbx/native/fci_boundaries.py +2494 -0
  38. drbx/native/fci_differentiable_case.py +304 -0
  39. drbx/native/fci_drb_EB_rhs.py +1243 -0
  40. drbx/native/fci_drb_rhs.py +190 -0
  41. drbx/native/fci_halo.py +1575 -0
  42. drbx/native/fci_helpers.py +350 -0
  43. drbx/native/fci_model.py +294 -0
  44. drbx/native/fci_neutral.py +139 -0
  45. drbx/native/fci_operators.py +4081 -0
  46. drbx/native/fci_sharding.py +597 -0
  47. drbx/native/fci_sheath_recycling.py +206 -0
  48. drbx/native/fci_time_integrator.py +96 -0
  49. drbx/native/fci_vorticity.py +198 -0
  50. drbx/native/fluid_1d.py +330 -0
  51. drbx/native/hasegawa_wakatani.py +196 -0
  52. drbx/native/limiters.py +57 -0
  53. drbx/native/mesh.py +238 -0
  54. drbx/native/metrics.py +234 -0
  55. drbx/native/neutrals/__init__.py +58 -0
  56. drbx/native/neutrals/atomic_rates.py +134 -0
  57. drbx/native/neutrals/detachment_sol_model.py +221 -0
  58. drbx/native/neutrals/reactions.py +164 -0
  59. drbx/native/neutrals/recycling_sol_model.py +197 -0
  60. drbx/native/sol_flux_tube.py +133 -0
  61. drbx/native/stellarator_turbulence.py +343 -0
  62. drbx/native/transport.py +134 -0
  63. drbx/native/units.py +32 -0
  64. drbx/native/vorticity.py +252 -0
  65. drbx/runtime/__init__.py +53 -0
  66. drbx/runtime/artifacts.py +161 -0
  67. drbx/runtime/memory.py +144 -0
  68. drbx/runtime/output.py +374 -0
  69. drbx/runtime/paths.py +9 -0
  70. drbx/runtime/performance.py +161 -0
  71. drbx/runtime/run_config.py +184 -0
  72. drbx/runtime/scheduler.py +99 -0
  73. drbx/runtime/state.py +40 -0
  74. drbx/validation/__init__.py +424 -0
  75. drbx/validation/autodiff_diffusion.py +329 -0
  76. drbx/validation/autodiff_diffusion_uncertainty.py +235 -0
  77. drbx/validation/diverted_tokamak_movie.py +558 -0
  78. drbx/validation/essos_fieldline_import_campaign.py +181 -0
  79. drbx/validation/essos_imported_artifact_audit.py +535 -0
  80. drbx/validation/essos_imported_drb_movie_campaign.py +2826 -0
  81. drbx/validation/essos_imported_fci_campaign.py +5241 -0
  82. drbx/validation/essos_imported_pytree_campaign.py +406 -0
  83. drbx/validation/essos_vmec_closed_field_campaign.py +314 -0
  84. drbx/validation/essos_vmec_closed_field_transient_campaign.py +629 -0
  85. drbx/validation/essos_vmec_fieldline_surface_campaign.py +620 -0
  86. drbx/validation/fluid_1d_mms_convergence.py +250 -0
  87. drbx/validation/geometry_lineouts.py +136 -0
  88. drbx/validation/geometry_slices.py +178 -0
  89. drbx/validation/publication_plotting.py +91 -0
  90. drbx/validation/stellarator_drb_pytree_campaign.py +621 -0
  91. drbx/validation/stellarator_fci_geometry_campaign.py +200 -0
  92. drbx/validation/stellarator_fci_operator_campaign.py +304 -0
  93. drbx/validation/stellarator_fci_suite_campaign.py +264 -0
  94. drbx/validation/stellarator_metric_mms_campaign.py +289 -0
  95. drbx/validation/stellarator_neutral_physics_campaign.py +255 -0
  96. drbx/validation/stellarator_sheath_recycling_campaign.py +331 -0
  97. drbx/validation/stellarator_sol_showcase.py +628 -0
  98. drbx/validation/stellarator_vorticity_campaign.py +304 -0
  99. drbx/validation/vmec_extender_edge_field_campaign.py +260 -0
  100. drbx/validation/vmec_extender_sol_smoke_campaign.py +365 -0
  101. drbx-2.0.0.dev0.dist-info/METADATA +380 -0
  102. drbx-2.0.0.dev0.dist-info/RECORD +106 -0
  103. drbx-2.0.0.dev0.dist-info/WHEEL +5 -0
  104. drbx-2.0.0.dev0.dist-info/entry_points.txt +2 -0
  105. drbx-2.0.0.dev0.dist-info/licenses/LICENSE +21 -0
  106. drbx-2.0.0.dev0.dist-info/top_level.txt +1 -0
drbx/config/boutinp.py ADDED
@@ -0,0 +1,556 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import math
5
+ import operator
6
+ import re
7
+ from collections import OrderedDict
8
+ from dataclasses import dataclass, replace
9
+ from pathlib import Path
10
+ from typing import Any, Iterable, Iterator, Mapping
11
+
12
+ try:
13
+ import tomllib
14
+ except ModuleNotFoundError: # pragma: no cover - exercised on Python < 3.11
15
+ import tomli as tomllib
16
+
17
+ ROOT_SECTION = "__root__"
18
+
19
+ _MISSING = object()
20
+ _SAFE_BINOPS = {
21
+ ast.Add: operator.add,
22
+ ast.Sub: operator.sub,
23
+ ast.Mult: operator.mul,
24
+ ast.Div: operator.truediv,
25
+ ast.Pow: operator.pow,
26
+ ast.Mod: operator.mod,
27
+ }
28
+ _SAFE_UNARYOPS = {
29
+ ast.UAdd: operator.pos,
30
+ ast.USub: operator.neg,
31
+ }
32
+ _SAFE_FUNCTIONS = {
33
+ "abs": abs,
34
+ "cos": math.cos,
35
+ "exp": math.exp,
36
+ "log": math.log,
37
+ "max": max,
38
+ "min": min,
39
+ "sin": math.sin,
40
+ "sqrt": math.sqrt,
41
+ "tan": math.tan,
42
+ }
43
+ _SAFE_CONSTANTS = {
44
+ "e": math.e,
45
+ "pi": math.pi,
46
+ }
47
+ _REFERENCE_PATTERN = re.compile(r"\b[A-Za-z_][A-Za-z0-9_]*(?::[A-Za-z_][A-Za-z0-9_]*)+\b")
48
+ _INTEGER_PATTERN = re.compile(r"[+-]?\d+")
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class OptionValue:
53
+ raw: str
54
+ parsed: bool | int | float | str | tuple[str, ...]
55
+ kind: str
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class OptionEntry:
60
+ key: str
61
+ value: OptionValue
62
+ line: int
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class OptionSection:
67
+ name: str
68
+ entries: "OrderedDict[str, OptionEntry]"
69
+
70
+ def __contains__(self, key: str) -> bool:
71
+ return key in self.entries
72
+
73
+ def __getitem__(self, key: str) -> OptionEntry:
74
+ return self.entries[key]
75
+
76
+ def get(self, key: str, default: Any = None) -> OptionEntry | Any:
77
+ return self.entries.get(key, default)
78
+
79
+ def keys(self) -> Iterable[str]:
80
+ return self.entries.keys()
81
+
82
+ def items(self) -> Iterable[tuple[str, OptionEntry]]:
83
+ return self.entries.items()
84
+
85
+ def values(self) -> Iterable[OptionEntry]:
86
+ return self.entries.values()
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class BoutConfig:
91
+ sections: "OrderedDict[str, OptionSection]"
92
+
93
+ def section_names(self, *, include_root: bool = False) -> tuple[str, ...]:
94
+ names = tuple(self.sections)
95
+ if include_root:
96
+ return names
97
+ return tuple(name for name in names if name != ROOT_SECTION)
98
+
99
+ def has_section(self, name: str) -> bool:
100
+ return name in self.sections
101
+
102
+ def section(self, name: str = ROOT_SECTION) -> OptionSection:
103
+ if name not in self.sections:
104
+ raise KeyError(f"Unknown section {name!r}")
105
+ return self.sections[name]
106
+
107
+ def has_option(self, section: str, key: str) -> bool:
108
+ return self.has_section(section) and key in self.sections[section]
109
+
110
+ def entry(self, section: str, key: str) -> OptionEntry:
111
+ return self.section(section)[key]
112
+
113
+ def get(self, section: str, key: str, default: Any = _MISSING) -> OptionValue | Any:
114
+ if self.has_option(section, key):
115
+ return self.entry(section, key).value
116
+ if default is not _MISSING:
117
+ return default
118
+ raise KeyError(f"Missing option {section}:{key}")
119
+
120
+ def raw(self, section: str, key: str) -> str:
121
+ return self.entry(section, key).value.raw
122
+
123
+ def parsed(self, section: str, key: str) -> bool | int | float | str | tuple[str, ...]:
124
+ return self.entry(section, key).value.parsed
125
+
126
+
127
+ def apply_bout_overrides(config: BoutConfig, overrides: Iterable[str]) -> BoutConfig:
128
+ sections: "OrderedDict[str, OrderedDict[str, OptionEntry]]" = OrderedDict(
129
+ (name, OrderedDict(section.entries)) for name, section in config.sections.items()
130
+ )
131
+ for index, override in enumerate(overrides, start=1):
132
+ if "=" not in override:
133
+ raise ValueError(f"Override must be an assignment: {override!r}")
134
+ raw_key, raw_value = override.split("=", 1)
135
+ key = raw_key.strip()
136
+ value = raw_value.strip()
137
+ if ":" in key:
138
+ section_name, option_name = key.split(":", 1)
139
+ else:
140
+ section_name, option_name = ROOT_SECTION, key
141
+ sections.setdefault(section_name, OrderedDict())
142
+ existing = sections[section_name].get(option_name)
143
+ line = existing.line if existing is not None else -index
144
+ sections[section_name][option_name] = OptionEntry(
145
+ key=option_name,
146
+ value=_parse_value(value),
147
+ line=line,
148
+ )
149
+ return BoutConfig(
150
+ OrderedDict(
151
+ (name, replace(config.sections.get(name, OptionSection(name=name, entries=OrderedDict())), entries=entries))
152
+ for name, entries in sections.items()
153
+ )
154
+ )
155
+
156
+
157
+ def load_bout_input(path: str | Path) -> BoutConfig:
158
+ source = Path(path)
159
+ text = source.read_text(encoding="utf-8")
160
+ if source.suffix.lower() == ".toml":
161
+ return parse_toml_input(text)
162
+ return parse_bout_input(text)
163
+
164
+
165
+ def rewrite_input_precision(template_text: str, precision: str) -> str:
166
+ """Return ``template_text`` with its ``[runtime]`` precision entry replaced.
167
+
168
+ Only the existing ``precision = ...`` line is rewritten; every other line of
169
+ the deck is preserved verbatim. Raises ``ValueError`` when the template has
170
+ no precision entry to rewrite.
171
+ """
172
+
173
+ lines = template_text.splitlines()
174
+ for index, line in enumerate(lines):
175
+ if line.strip().startswith("precision ="):
176
+ lines[index] = f'precision = "{precision}"'
177
+ return "\n".join(lines) + "\n"
178
+ raise ValueError("Template TOML is missing a [runtime] precision entry")
179
+
180
+
181
+ def parse_toml_input(text: str) -> BoutConfig:
182
+ parsed = tomllib.loads(text)
183
+ raw_sections: "OrderedDict[str, OrderedDict[str, OptionEntry]]" = OrderedDict()
184
+ raw_sections[ROOT_SECTION] = OrderedDict()
185
+ next_line = -1
186
+
187
+ def add_entry(section_name: str, key: str, value: Any) -> None:
188
+ nonlocal next_line
189
+ raw_sections.setdefault(section_name, OrderedDict())
190
+ raw_sections[section_name][key] = OptionEntry(
191
+ key=key,
192
+ value=_parse_value(_serialize_toml_value(value)),
193
+ line=next_line,
194
+ )
195
+ next_line -= 1
196
+
197
+ def visit_table(prefix: tuple[str, ...], table: Mapping[str, Any]) -> None:
198
+ for key, value in table.items():
199
+ path = (*prefix, key)
200
+ if isinstance(value, Mapping):
201
+ if _looks_like_scalar_wrapper(value):
202
+ add_entry(_toml_section_name(prefix), key, value)
203
+ continue
204
+ visit_table(path, value)
205
+ continue
206
+ add_entry(_toml_section_name(prefix), key, value)
207
+
208
+ for key, value in parsed.items():
209
+ if isinstance(value, Mapping):
210
+ visit_table((key,), value)
211
+ continue
212
+ add_entry(ROOT_SECTION, key, value)
213
+
214
+ sections: "OrderedDict[str, OptionSection]" = OrderedDict(
215
+ (name, OptionSection(name=name, entries=entries)) for name, entries in raw_sections.items()
216
+ )
217
+ return BoutConfig(sections=sections)
218
+
219
+
220
+ def parse_bout_input(text: str) -> BoutConfig:
221
+ raw_sections: "OrderedDict[str, OrderedDict[str, OptionEntry]]" = OrderedDict()
222
+ raw_sections[ROOT_SECTION] = OrderedDict()
223
+ current_section = ROOT_SECTION
224
+ pending_key: str | None = None
225
+ pending_line = 0
226
+ pending_chunks: list[str] = []
227
+ pending_balance = 0
228
+
229
+ def commit(section_name: str, key: str, raw_value: str, line: int) -> None:
230
+ raw_sections.setdefault(section_name, OrderedDict())
231
+ raw_sections[section_name][key] = OptionEntry(
232
+ key=key,
233
+ value=_parse_value(raw_value),
234
+ line=line,
235
+ )
236
+
237
+ for line_number, physical_line in enumerate(text.splitlines(), start=1):
238
+ logical_line = _strip_inline_comment(physical_line).strip()
239
+ if not logical_line:
240
+ continue
241
+
242
+ if pending_key is not None:
243
+ pending_chunks.append(logical_line)
244
+ pending_balance += _structural_balance(logical_line)
245
+ if pending_balance <= 0:
246
+ commit(current_section, pending_key, " ".join(pending_chunks), pending_line)
247
+ pending_key = None
248
+ pending_chunks = []
249
+ pending_balance = 0
250
+ continue
251
+
252
+ if logical_line.startswith("[") and logical_line.endswith("]"):
253
+ current_section = logical_line[1:-1].strip()
254
+ raw_sections.setdefault(current_section, OrderedDict())
255
+ continue
256
+
257
+ if "=" not in logical_line:
258
+ raise ValueError(f"Line {line_number} is not a section header or assignment: {physical_line!r}")
259
+
260
+ key, raw_value = logical_line.split("=", 1)
261
+ key = key.strip()
262
+ raw_value = raw_value.strip()
263
+ balance = _structural_balance(raw_value)
264
+ if balance > 0:
265
+ pending_key = key
266
+ pending_line = line_number
267
+ pending_chunks = [raw_value]
268
+ pending_balance = balance
269
+ continue
270
+
271
+ commit(current_section, key, raw_value, line_number)
272
+
273
+ if pending_key is not None:
274
+ raise ValueError(f"Unclosed multiline value for {current_section}:{pending_key}")
275
+
276
+ sections: "OrderedDict[str, OptionSection]" = OrderedDict(
277
+ (name, OptionSection(name=name, entries=entries)) for name, entries in raw_sections.items()
278
+ )
279
+ return BoutConfig(sections=sections)
280
+
281
+
282
+ class NumericResolver:
283
+ def __init__(self, config: BoutConfig, external_values: Mapping[str, float] | None = None):
284
+ self.config = config
285
+ self.external_values = dict(external_values or {})
286
+ self._cache: dict[tuple[str, str], float] = {}
287
+
288
+ def resolve(self, section: str, key: str) -> float:
289
+ return self._resolve_option(section, key, seen=set())
290
+
291
+ def evaluate(self, expression: str, *, current_section: str = ROOT_SECTION) -> float:
292
+ return self._evaluate_expression(expression, current_section=current_section, seen=set())
293
+
294
+ def _resolve_option(self, section: str, key: str, seen: set[tuple[str, str]]) -> float:
295
+ cache_key = (section, key)
296
+ if cache_key in self._cache:
297
+ return self._cache[cache_key]
298
+ if cache_key in seen:
299
+ cycle = " -> ".join(f"{sec}:{name}" for sec, name in (*seen, cache_key))
300
+ raise ValueError(f"Cyclic numeric reference detected: {cycle}")
301
+ seen.add(cache_key)
302
+ value = self.config.get(section, key)
303
+
304
+ if isinstance(value.parsed, bool):
305
+ result = float(value.parsed)
306
+ elif isinstance(value.parsed, int | float):
307
+ result = float(value.parsed)
308
+ elif isinstance(value.parsed, tuple):
309
+ raise TypeError(f"Option {section}:{key} is a list, not a scalar numeric expression")
310
+ else:
311
+ result = self._evaluate_expression(value.raw, current_section=section, seen=seen)
312
+
313
+ self._cache[cache_key] = result
314
+ seen.remove(cache_key)
315
+ return result
316
+
317
+ def _evaluate_expression(self, expression: str, *, current_section: str, seen: set[tuple[str, str]]) -> float:
318
+ sanitized = expression.replace("π", "pi").replace("^", "**")
319
+ sanitized = re.sub(r"(?<=\d)pi\b", "*pi", sanitized)
320
+ references: dict[str, str] = {}
321
+
322
+ def replace_reference(match: re.Match[str]) -> str:
323
+ token = f"__ref_{len(references)}"
324
+ references[token] = match.group(0)
325
+ return token
326
+
327
+ sanitized = _REFERENCE_PATTERN.sub(replace_reference, sanitized)
328
+ tree = ast.parse(sanitized, mode="eval")
329
+ return float(self._eval_node(tree.body, current_section=current_section, references=references, seen=seen))
330
+
331
+ def _eval_node(
332
+ self,
333
+ node: ast.AST,
334
+ *,
335
+ current_section: str,
336
+ references: Mapping[str, str],
337
+ seen: set[tuple[str, str]],
338
+ ) -> float:
339
+ if isinstance(node, ast.Constant):
340
+ if isinstance(node.value, int | float):
341
+ return float(node.value)
342
+ raise TypeError(f"Unsupported constant value: {node.value!r}")
343
+ if isinstance(node, ast.Name):
344
+ return self._resolve_name(node.id, current_section=current_section, references=references, seen=seen)
345
+ if isinstance(node, ast.BinOp) and type(node.op) in _SAFE_BINOPS:
346
+ left = self._eval_node(node.left, current_section=current_section, references=references, seen=seen)
347
+ right = self._eval_node(node.right, current_section=current_section, references=references, seen=seen)
348
+ return _SAFE_BINOPS[type(node.op)](left, right)
349
+ if isinstance(node, ast.UnaryOp) and type(node.op) in _SAFE_UNARYOPS:
350
+ operand = self._eval_node(node.operand, current_section=current_section, references=references, seen=seen)
351
+ return _SAFE_UNARYOPS[type(node.op)](operand)
352
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
353
+ function_name = node.func.id
354
+ if function_name not in _SAFE_FUNCTIONS:
355
+ raise TypeError(f"Unsupported numeric function {function_name!r}")
356
+ args = [
357
+ self._eval_node(argument, current_section=current_section, references=references, seen=seen)
358
+ for argument in node.args
359
+ ]
360
+ return float(_SAFE_FUNCTIONS[function_name](*args))
361
+ raise TypeError(f"Unsupported numeric expression node: {ast.dump(node, include_attributes=False)}")
362
+
363
+ def _resolve_name(
364
+ self,
365
+ name: str,
366
+ *,
367
+ current_section: str,
368
+ references: Mapping[str, str],
369
+ seen: set[tuple[str, str]],
370
+ ) -> float:
371
+ if name in references:
372
+ reference = references[name]
373
+ section_name, key = reference.rsplit(":", 1)
374
+ return self._resolve_option(section_name, key, seen)
375
+ if name in self.external_values:
376
+ return float(self.external_values[name])
377
+ if name in _SAFE_CONSTANTS:
378
+ return _SAFE_CONSTANTS[name]
379
+ if current_section != ROOT_SECTION and self.config.has_option(current_section, name):
380
+ return self._resolve_option(current_section, name, seen)
381
+ if self.config.has_option(ROOT_SECTION, name):
382
+ return self._resolve_option(ROOT_SECTION, name, seen)
383
+ raise KeyError(f"Unknown numeric symbol {name!r} while evaluating section {current_section!r}")
384
+
385
+
386
+ def _parse_value(raw: str) -> OptionValue:
387
+ value = raw.strip()
388
+ if not value:
389
+ return OptionValue(raw="", parsed="", kind="empty")
390
+
391
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
392
+ return OptionValue(raw=value, parsed=value[1:-1], kind="string")
393
+
394
+ lowercase = value.lower()
395
+ if lowercase == "true":
396
+ return OptionValue(raw=value, parsed=True, kind="bool")
397
+ if lowercase == "false":
398
+ return OptionValue(raw=value, parsed=False, kind="bool")
399
+
400
+ items = _parse_sequence(value)
401
+ if items is not None:
402
+ return OptionValue(raw=value, parsed=items, kind="list")
403
+
404
+ if _INTEGER_PATTERN.fullmatch(value):
405
+ return OptionValue(raw=value, parsed=int(value), kind="int")
406
+
407
+ try:
408
+ parsed_float = float(value)
409
+ except ValueError:
410
+ return OptionValue(raw=value, parsed=value, kind="expression")
411
+ return OptionValue(raw=value, parsed=parsed_float, kind="float")
412
+
413
+
414
+ def _looks_like_scalar_wrapper(value: Mapping[str, Any]) -> bool:
415
+ return any(key in value for key in ("expr", "raw", "ref", "value", "string"))
416
+
417
+
418
+ def _toml_section_name(path: tuple[str, ...]) -> str:
419
+ if not path:
420
+ return ROOT_SECTION
421
+ head, *tail = path
422
+ if head in {"root", "time"}:
423
+ return ROOT_SECTION
424
+ if head in {"species", "fields"} and tail:
425
+ return tail[0]
426
+ if head == "runtime":
427
+ return "runtime" if not tail else ":".join((head, *tail))
428
+ return ":".join(path)
429
+
430
+
431
+ def _serialize_toml_value(value: Any) -> str:
432
+ if isinstance(value, Mapping):
433
+ if "expr" in value:
434
+ return str(value["expr"])
435
+ if "raw" in value:
436
+ return str(value["raw"])
437
+ if "ref" in value:
438
+ return str(value["ref"])
439
+ if "value" in value:
440
+ return _serialize_toml_value(value["value"])
441
+ if "string" in value:
442
+ return _quote_toml_string(str(value["string"]))
443
+ raise TypeError(f"Unsupported TOML scalar wrapper keys: {tuple(value)}")
444
+ if isinstance(value, bool):
445
+ return "true" if value else "false"
446
+ if isinstance(value, int):
447
+ return str(value)
448
+ if isinstance(value, float):
449
+ return format(value, ".16g")
450
+ if isinstance(value, str):
451
+ return _quote_toml_string(value)
452
+ if isinstance(value, tuple | list):
453
+ items = ", ".join(_serialize_toml_sequence_item(item) for item in value)
454
+ if len(value) == 1:
455
+ items = f"{items},"
456
+ return f"({items})"
457
+ raise TypeError(f"Unsupported TOML input value type: {type(value)!r}")
458
+
459
+
460
+ def _serialize_toml_sequence_item(value: Any) -> str:
461
+ if isinstance(value, str):
462
+ return value
463
+ if isinstance(value, bool):
464
+ return "true" if value else "false"
465
+ if isinstance(value, int):
466
+ return str(value)
467
+ if isinstance(value, float):
468
+ return format(value, ".16g")
469
+ if isinstance(value, Mapping):
470
+ return _serialize_toml_value(value)
471
+ raise TypeError(f"Unsupported TOML sequence item type: {type(value)!r}")
472
+
473
+
474
+ def _quote_toml_string(value: str) -> str:
475
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
476
+ return f'"{escaped}"'
477
+
478
+
479
+ def _parse_sequence(value: str) -> tuple[str, ...] | None:
480
+ candidate = value
481
+ if value.startswith("(") and value.endswith(")"):
482
+ candidate = value[1:-1].strip()
483
+ parts = tuple(part.strip() for part in _split_top_level_commas(candidate))
484
+ if len(parts) <= 1:
485
+ return None
486
+ return tuple(part for part in parts if part)
487
+
488
+
489
+ def _split_top_level_commas(value: str) -> list[str]:
490
+ parts: list[str] = []
491
+ current: list[str] = []
492
+ paren_depth = 0
493
+ bracket_depth = 0
494
+ brace_depth = 0
495
+ in_single = False
496
+ in_double = False
497
+
498
+ for index, char in enumerate(value):
499
+ previous = value[index - 1] if index else ""
500
+ if char == "'" and not in_double and previous != "\\":
501
+ in_single = not in_single
502
+ elif char == '"' and not in_single and previous != "\\":
503
+ in_double = not in_double
504
+ elif not in_single and not in_double:
505
+ if char == "(":
506
+ paren_depth += 1
507
+ elif char == ")":
508
+ paren_depth -= 1
509
+ elif char == "[":
510
+ bracket_depth += 1
511
+ elif char == "]":
512
+ bracket_depth -= 1
513
+ elif char == "{":
514
+ brace_depth += 1
515
+ elif char == "}":
516
+ brace_depth -= 1
517
+ elif char == "," and paren_depth == 0 and bracket_depth == 0 and brace_depth == 0:
518
+ parts.append("".join(current).strip())
519
+ current = []
520
+ continue
521
+ current.append(char)
522
+
523
+ parts.append("".join(current).strip())
524
+ return parts
525
+
526
+
527
+ def _strip_inline_comment(line: str) -> str:
528
+ in_single = False
529
+ in_double = False
530
+ for index, char in enumerate(line):
531
+ previous = line[index - 1] if index else ""
532
+ if char == "'" and not in_double and previous != "\\":
533
+ in_single = not in_single
534
+ elif char == '"' and not in_single and previous != "\\":
535
+ in_double = not in_double
536
+ elif char == "#" and not in_single and not in_double:
537
+ return line[:index]
538
+ return line
539
+
540
+
541
+ def _structural_balance(value: str) -> int:
542
+ balance = 0
543
+ in_single = False
544
+ in_double = False
545
+ for index, char in enumerate(value):
546
+ previous = value[index - 1] if index else ""
547
+ if char == "'" and not in_double and previous != "\\":
548
+ in_single = not in_single
549
+ elif char == '"' and not in_single and previous != "\\":
550
+ in_double = not in_double
551
+ elif not in_single and not in_double:
552
+ if char in "([{":
553
+ balance += 1
554
+ elif char in ")]}":
555
+ balance -= 1
556
+ return balance
drbx/config/model.py ADDED
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from .boutinp import BoutConfig
4
+
5
+ PUBLIC_MODEL_SECTION = "model"
6
+
7
+
8
+ def locate_model_section(config: BoutConfig) -> str:
9
+ if config.has_section(PUBLIC_MODEL_SECTION):
10
+ return PUBLIC_MODEL_SECTION
11
+
12
+ component_sections = tuple(
13
+ name for name in config.section_names() if config.has_option(name, "components")
14
+ )
15
+ if len(component_sections) == 1:
16
+ return component_sections[0]
17
+
18
+ normalized_sections = tuple(
19
+ name
20
+ for name in component_sections
21
+ if all(config.has_option(name, key) for key in ("Nnorm", "Tnorm", "Bnorm"))
22
+ )
23
+ if len(normalized_sections) == 1:
24
+ return normalized_sections[0]
25
+
26
+ raise KeyError("Could not determine the model section. Define [model] or provide a single component section.")
27
+
28
+
29
+ def has_model_section(config: BoutConfig) -> bool:
30
+ try:
31
+ locate_model_section(config)
32
+ except KeyError:
33
+ return False
34
+ return True
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Mapping
5
+
6
+ from .boutinp import BoutConfig, NumericResolver
7
+ from .model import locate_model_section
8
+
9
+ ELEMENTARY_CHARGE = 1.602176634e-19
10
+ PROTON_MASS = 1.672621898e-27
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class MetricPolicy:
15
+ normalise_metric: bool
16
+ recalculate_metric: bool
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ModelNormalization:
21
+ Nnorm: float
22
+ Tnorm: float
23
+ Bnorm: float
24
+ Cs0: float
25
+ Omega_ci: float
26
+ rho_s0: float
27
+ units: Mapping[str, float]
28
+ metric_policy: MetricPolicy
29
+
30
+ @classmethod
31
+ def from_config(
32
+ cls,
33
+ config: BoutConfig,
34
+ *,
35
+ external_values: Mapping[str, float] | None = None,
36
+ ) -> "ModelNormalization":
37
+ resolver = NumericResolver(config, external_values=external_values)
38
+ model_section = locate_model_section(config)
39
+ Nnorm = resolver.resolve(model_section, "Nnorm")
40
+ Tnorm = resolver.resolve(model_section, "Tnorm")
41
+ Bnorm = resolver.resolve(model_section, "Bnorm")
42
+ Cs0 = (ELEMENTARY_CHARGE * Tnorm / PROTON_MASS) ** 0.5
43
+ Omega_ci = ELEMENTARY_CHARGE * Bnorm / PROTON_MASS
44
+ rho_s0 = Cs0 / Omega_ci
45
+ normalise_metric = bool(config.parsed(model_section, "normalise_metric")) if config.has_option(model_section, "normalise_metric") else False
46
+ recalculate_metric = bool(config.parsed(model_section, "recalculate_metric")) if config.has_option(model_section, "recalculate_metric") else False
47
+ units = {
48
+ "inv_meters_cubed": Nnorm,
49
+ "eV": Tnorm,
50
+ "Tesla": Bnorm,
51
+ "seconds": 1.0 / Omega_ci,
52
+ "meters": rho_s0,
53
+ }
54
+ return cls(
55
+ Nnorm=Nnorm,
56
+ Tnorm=Tnorm,
57
+ Bnorm=Bnorm,
58
+ Cs0=Cs0,
59
+ Omega_ci=Omega_ci,
60
+ rho_s0=rho_s0,
61
+ units=units,
62
+ metric_policy=MetricPolicy(
63
+ normalise_metric=normalise_metric,
64
+ recalculate_metric=recalculate_metric,
65
+ ),
66
+ )
@@ -0,0 +1 @@
1
+ """Atomic rate coefficients bundled with the native open-field models."""