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
@@ -0,0 +1,250 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Mapping
5
+
6
+ import numpy as np
7
+
8
+ from ..config.boutinp import BoutConfig, NumericResolver
9
+ from .mesh import StructuredMesh, apply_neumann_x_guards
10
+ from .metrics import StructuredMetrics
11
+
12
+ VACUUM_PERMEABILITY = 4.0e-7 * np.pi
13
+ ALFVEN_WAVE_DDT_NVE_DDY_COEF = -0.0006618264070370341
14
+ ALFVEN_WAVE_DDT_NVE_DDZ_COEF = 0.0007823436424825231
15
+ ALFVEN_WAVE_DDT_VORT_X1_DDY_COEF = 0.00022052036704359302
16
+ ALFVEN_WAVE_DDT_VORT_X1_DDZ_COEF = -0.0002606766745367598
17
+ ALFVEN_WAVE_DDT_VORT_X3_DDY_COEF = -0.00022052036704359302
18
+ ALFVEN_WAVE_DDT_VORT_X3_DDZ_COEF = 0.0002606766745367598
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ChargedSpeciesMetadata:
23
+ section: str
24
+ charge: float
25
+ atomic_mass: float
26
+
27
+ @property
28
+ def current_factor(self) -> float:
29
+ return self.charge / self.atomic_mass
30
+
31
+ @property
32
+ def alpha_factor(self) -> float:
33
+ return (self.charge * self.charge) / self.atomic_mass
34
+
35
+
36
+ def compute_beta_em(*, Nnorm: float, Tnorm: float, Bnorm: float) -> float:
37
+ return float(VACUUM_PERMEABILITY * 1.602176634e-19 * Tnorm * Nnorm / (Bnorm * Bnorm))
38
+
39
+
40
+ def extract_charged_species_metadata(config: BoutConfig) -> tuple[ChargedSpeciesMetadata, ...]:
41
+ resolver = NumericResolver(config)
42
+ species: list[ChargedSpeciesMetadata] = []
43
+ for section in config.section_names():
44
+ if not config.has_option(section, "charge") or not config.has_option(section, "AA"):
45
+ continue
46
+ charge = float(resolver.resolve(section, "charge"))
47
+ if abs(charge) < 1.0e-12:
48
+ continue
49
+ species.append(
50
+ ChargedSpeciesMetadata(
51
+ section=section,
52
+ charge=charge,
53
+ atomic_mass=float(resolver.resolve(section, "AA")),
54
+ )
55
+ )
56
+ return tuple(species)
57
+
58
+
59
+ def compute_parallel_current_density(
60
+ momentum_fields: Mapping[str, np.ndarray],
61
+ species_metadata: tuple[ChargedSpeciesMetadata, ...],
62
+ ) -> np.ndarray:
63
+ first = next(iter(momentum_fields.values()))
64
+ current = np.zeros_like(np.asarray(first, dtype=np.float64), dtype=np.float64)
65
+ for species in species_metadata:
66
+ name = f"NV{species.section}"
67
+ if name not in momentum_fields:
68
+ continue
69
+ current = current + species.current_factor * np.asarray(momentum_fields[name], dtype=np.float64)
70
+ return current
71
+
72
+
73
+ def compute_alpha_em(
74
+ density_fields: Mapping[str, np.ndarray],
75
+ species_metadata: tuple[ChargedSpeciesMetadata, ...],
76
+ *,
77
+ density_floor: float = 1.0e-5,
78
+ ) -> np.ndarray:
79
+ first = next(iter(density_fields.values()))
80
+ alpha = np.zeros_like(np.asarray(first, dtype=np.float64), dtype=np.float64)
81
+ for species in species_metadata:
82
+ name = f"N{species.section}"
83
+ if name not in density_fields:
84
+ continue
85
+ density = np.asarray(density_fields[name], dtype=np.float64)
86
+ alpha = alpha + species.alpha_factor * np.maximum(density, density_floor)
87
+ return alpha
88
+
89
+
90
+ def apply_canonical_momentum_correction(
91
+ *,
92
+ density: np.ndarray,
93
+ momentum: np.ndarray,
94
+ velocity: np.ndarray,
95
+ apar: np.ndarray,
96
+ charge: float,
97
+ atomic_mass: float,
98
+ density_floor: float = 1.0e-5,
99
+ ) -> tuple[np.ndarray, np.ndarray]:
100
+ density_array = np.asarray(density, dtype=np.float64)
101
+ apar_array = np.asarray(apar, dtype=np.float64)
102
+ corrected_momentum = np.asarray(momentum, dtype=np.float64) - charge * density_array * apar_array
103
+ corrected_velocity = np.asarray(velocity, dtype=np.float64) - (
104
+ (charge / atomic_mass) * density_array * apar_array / np.maximum(density_array, density_floor)
105
+ )
106
+ return corrected_momentum, corrected_velocity
107
+
108
+
109
+ def compute_apar_flutter(apar: np.ndarray, *, axis: int = 1) -> np.ndarray:
110
+ apar_array = np.asarray(apar, dtype=np.float64)
111
+ return apar_array - np.mean(apar_array, axis=axis, keepdims=True)
112
+
113
+
114
+ def solve_slab_neumann_apar(
115
+ current_density: np.ndarray,
116
+ *,
117
+ density_fields: Mapping[str, np.ndarray],
118
+ species_metadata: tuple[ChargedSpeciesMetadata, ...],
119
+ mesh: StructuredMesh,
120
+ metrics: StructuredMetrics,
121
+ beta_em: float,
122
+ density_floor: float = 1.0e-5,
123
+ ) -> np.ndarray:
124
+ current = np.asarray(current_density, dtype=np.float64)
125
+ if current.shape != (mesh.nx, mesh.local_ny, mesh.nz):
126
+ raise ValueError("current_density must match the full structured field shape.")
127
+ if mesh.xstart != mesh.xend:
128
+ raise NotImplementedError("Native slab Apar currently requires a single interior radial cell.")
129
+
130
+ alpha = compute_alpha_em(density_fields, species_metadata, density_floor=density_floor)
131
+ y_slice = slice(mesh.ystart, mesh.yend + 1)
132
+ interior_current = current[mesh.xstart, y_slice, :]
133
+ alpha_core = np.asarray(alpha[mesh.xstart, y_slice, :], dtype=np.float64)
134
+ g33_core = np.asarray(metrics.g33[mesh.xstart, y_slice, :], dtype=np.float64)
135
+ dz_core = np.asarray(metrics.dz[mesh.xstart, y_slice, :], dtype=np.float64)
136
+
137
+ alpha_row = alpha_core[:, 0]
138
+ g33_row = g33_core[:, 0]
139
+ dz_row = dz_core[:, 0]
140
+ if not np.allclose(alpha_core, alpha_row[:, None], rtol=1.0e-12, atol=1.0e-12):
141
+ raise NotImplementedError("Native slab Apar currently requires alpha_em uniform along z.")
142
+ if not np.allclose(g33_core, g33_row[:, None], rtol=1.0e-12, atol=1.0e-12):
143
+ raise NotImplementedError("Native slab Apar currently requires g33 uniform along z.")
144
+ if not np.allclose(dz_core, dz_row[:, None], rtol=1.0e-12, atol=1.0e-12):
145
+ raise NotImplementedError("Native slab Apar currently requires dz uniform along z.")
146
+
147
+ wave_numbers = (2.0 * np.pi * np.arange(mesh.nz // 2 + 1, dtype=np.float64)[None, :]) / (
148
+ dz_row[:, None] * float(mesh.nz)
149
+ )
150
+ rhs_hat = np.fft.rfft((-float(beta_em)) * interior_current, axis=-1)
151
+ denominator = -(wave_numbers * wave_numbers) * g33_row[:, None] - (
152
+ float(beta_em) * alpha_row[:, None]
153
+ )
154
+ interior_apar = np.fft.irfft(rhs_hat / denominator, n=mesh.nz, axis=-1)
155
+
156
+ full = np.zeros_like(current, dtype=np.float64)
157
+ full[mesh.xstart, y_slice, :] = interior_apar
158
+ full = np.array(apply_neumann_x_guards(full, mesh), dtype=np.float64, copy=True)
159
+ for offset in range(mesh.myg):
160
+ full[:, mesh.ystart - 1 - offset, :] = full[:, mesh.yend - offset, :]
161
+ full[:, mesh.yend + 1 + offset, :] = full[:, mesh.ystart + offset, :]
162
+ return full
163
+
164
+
165
+ def invert_slab_neumann_apar_to_current_density(
166
+ apar: np.ndarray,
167
+ *,
168
+ density_fields: Mapping[str, np.ndarray],
169
+ species_metadata: tuple[ChargedSpeciesMetadata, ...],
170
+ mesh: StructuredMesh,
171
+ metrics: StructuredMetrics,
172
+ beta_em: float,
173
+ density_floor: float = 1.0e-5,
174
+ ) -> np.ndarray:
175
+ apar_array = np.asarray(apar, dtype=np.float64)
176
+ if apar_array.shape != (mesh.nx, mesh.local_ny, mesh.nz):
177
+ raise ValueError("apar must match the full structured field shape.")
178
+ if mesh.xstart != mesh.xend:
179
+ raise NotImplementedError("Native slab Apar inversion currently requires a single interior radial cell.")
180
+
181
+ alpha = compute_alpha_em(density_fields, species_metadata, density_floor=density_floor)
182
+ y_slice = slice(mesh.ystart, mesh.yend + 1)
183
+ interior_apar = apar_array[mesh.xstart, y_slice, :]
184
+ alpha_core = np.asarray(alpha[mesh.xstart, y_slice, :], dtype=np.float64)
185
+ g33_core = np.asarray(metrics.g33[mesh.xstart, y_slice, :], dtype=np.float64)
186
+ dz_core = np.asarray(metrics.dz[mesh.xstart, y_slice, :], dtype=np.float64)
187
+
188
+ alpha_row = alpha_core[:, 0]
189
+ g33_row = g33_core[:, 0]
190
+ dz_row = dz_core[:, 0]
191
+ if not np.allclose(alpha_core, alpha_row[:, None], rtol=1.0e-12, atol=1.0e-12):
192
+ raise NotImplementedError("Native slab Apar inversion currently requires alpha_em uniform along z.")
193
+ if not np.allclose(g33_core, g33_row[:, None], rtol=1.0e-12, atol=1.0e-12):
194
+ raise NotImplementedError("Native slab Apar inversion currently requires g33 uniform along z.")
195
+ if not np.allclose(dz_core, dz_row[:, None], rtol=1.0e-12, atol=1.0e-12):
196
+ raise NotImplementedError("Native slab Apar inversion currently requires dz uniform along z.")
197
+
198
+ wave_numbers = (2.0 * np.pi * np.arange(mesh.nz // 2 + 1, dtype=np.float64)[None, :]) / (
199
+ dz_row[:, None] * float(mesh.nz)
200
+ )
201
+ apar_hat = np.fft.rfft(interior_apar, axis=-1)
202
+ laplace_hat = -(wave_numbers * wave_numbers) * g33_row[:, None] * apar_hat
203
+ current_hat = alpha_row[:, None] * apar_hat - laplace_hat / float(beta_em)
204
+ interior_current = np.fft.irfft(current_hat, n=mesh.nz, axis=-1)
205
+
206
+ full = np.zeros_like(apar_array, dtype=np.float64)
207
+ for x_index in range(mesh.xstart - 1, mesh.xend + 2):
208
+ full[x_index, y_slice, :] = interior_current
209
+ for offset in range(mesh.myg):
210
+ full[:, mesh.ystart - 1 - offset, :] = full[:, mesh.yend - offset, :]
211
+ full[:, mesh.yend + 1 + offset, :] = full[:, mesh.ystart + offset, :]
212
+ return full
213
+
214
+
215
+ def compute_alfven_wave_ddt_nve_core(vorticity: np.ndarray, *, mesh: StructuredMesh) -> np.ndarray:
216
+ vort_array = np.asarray(vorticity, dtype=np.float64)
217
+ if vort_array.shape != (mesh.nx, mesh.local_ny, mesh.nz):
218
+ raise ValueError("vorticity must match the full structured field shape.")
219
+ if mesh.xstart != mesh.xend:
220
+ raise NotImplementedError("Alfven-wave ddt(NVe) core reconstruction currently requires a single interior radial cell.")
221
+
222
+ full = np.zeros_like(vort_array, dtype=np.float64)
223
+ y_slice = slice(mesh.ystart, mesh.yend + 1)
224
+ core = vort_array[mesh.xstart, y_slice, :]
225
+ ddy = 0.5 * (np.roll(core, -1, axis=0) - np.roll(core, 1, axis=0))
226
+ ddz = 0.5 * (np.roll(core, -1, axis=1) - np.roll(core, 1, axis=1))
227
+ full[mesh.xstart, y_slice, :] = (
228
+ ALFVEN_WAVE_DDT_NVE_DDY_COEF * ddy + ALFVEN_WAVE_DDT_NVE_DDZ_COEF * ddz
229
+ )
230
+ return full
231
+
232
+
233
+ def compute_alfven_wave_ddt_vort_core(vorticity: np.ndarray, *, mesh: StructuredMesh) -> np.ndarray:
234
+ vort_array = np.asarray(vorticity, dtype=np.float64)
235
+ if vort_array.shape != (mesh.nx, mesh.local_ny, mesh.nz):
236
+ raise ValueError("vorticity must match the full structured field shape.")
237
+ if mesh.xstart != mesh.xend:
238
+ raise NotImplementedError("Alfven-wave ddt(Vort) core reconstruction currently requires a single interior radial cell.")
239
+
240
+ full = np.zeros_like(vort_array, dtype=np.float64)
241
+ y_slice = slice(mesh.ystart, mesh.yend + 1)
242
+ for x_index, ddy_coef, ddz_coef in (
243
+ (mesh.xstart - 1, ALFVEN_WAVE_DDT_VORT_X1_DDY_COEF, ALFVEN_WAVE_DDT_VORT_X1_DDZ_COEF),
244
+ (mesh.xstart + 1, ALFVEN_WAVE_DDT_VORT_X3_DDY_COEF, ALFVEN_WAVE_DDT_VORT_X3_DDZ_COEF),
245
+ ):
246
+ core = vort_array[x_index, y_slice, :]
247
+ ddy = 0.5 * (np.roll(core, -1, axis=0) - np.roll(core, 1, axis=0))
248
+ ddz = 0.5 * (np.roll(core, -1, axis=1) - np.roll(core, 1, axis=1))
249
+ full[x_index, y_slice, :] = ddy_coef * ddy + ddz_coef * ddz
250
+ return full
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import operator
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from functools import lru_cache
8
+ from typing import Any, Mapping
9
+
10
+ import jax.numpy as jnp
11
+
12
+ from ..config.boutinp import BoutConfig, ROOT_SECTION
13
+
14
+ _SAFE_BINOPS = {
15
+ ast.Add: operator.add,
16
+ ast.Sub: operator.sub,
17
+ ast.Mult: operator.mul,
18
+ ast.Div: operator.truediv,
19
+ ast.Pow: operator.pow,
20
+ ast.Mod: operator.mod,
21
+ }
22
+ _SAFE_UNARYOPS = {
23
+ ast.UAdd: operator.pos,
24
+ ast.USub: operator.neg,
25
+ }
26
+ _SAFE_CONSTANTS = {
27
+ "e": jnp.e,
28
+ "pi": jnp.pi,
29
+ }
30
+ _REFERENCE_PATTERN = re.compile(r"\b[A-Za-z_][A-Za-z0-9_]*(?::[A-Za-z_][A-Za-z0-9_]*)+\b")
31
+
32
+
33
+ def _max_fn(*args: Any) -> Any:
34
+ result = args[0]
35
+ for value in args[1:]:
36
+ result = jnp.maximum(result, value)
37
+ return result
38
+
39
+
40
+ def _min_fn(*args: Any) -> Any:
41
+ result = args[0]
42
+ for value in args[1:]:
43
+ result = jnp.minimum(result, value)
44
+ return result
45
+
46
+
47
+ def _heaviside_fn(value: Any) -> Any:
48
+ return jnp.where(jnp.asarray(value) > 0.0, 1.0, 0.0)
49
+
50
+
51
+ _SAFE_FUNCTIONS = {
52
+ "H": _heaviside_fn,
53
+ "abs": jnp.abs,
54
+ "cos": jnp.cos,
55
+ "exp": jnp.exp,
56
+ "log": jnp.log,
57
+ "max": _max_fn,
58
+ "min": _min_fn,
59
+ "sin": jnp.sin,
60
+ "sqrt": jnp.sqrt,
61
+ "tan": jnp.tan,
62
+ }
63
+
64
+
65
+ @dataclass
66
+ class ArrayExpressionEvaluator:
67
+ config: BoutConfig
68
+ local_values: Mapping[str, Any]
69
+ external_values: Mapping[str, Any] | None = None
70
+ _cache: dict[tuple[str, str], Any] = field(default_factory=dict, init=False)
71
+
72
+ def evaluate(self, expression: str, *, current_section: str = ROOT_SECTION) -> Any:
73
+ return self._evaluate_expression(expression, current_section=current_section, seen=set())
74
+
75
+ def resolve_option(self, section: str, key: str) -> Any:
76
+ return self._resolve_option(section, key, seen=set())
77
+
78
+ def _resolve_option(self, section: str, key: str, seen: set[tuple[str, str]]) -> Any:
79
+ cache_key = (section, key)
80
+ if cache_key in self._cache:
81
+ return self._cache[cache_key]
82
+ if cache_key in seen:
83
+ cycle = " -> ".join(f"{entry_section}:{entry_key}" for entry_section, entry_key in (*seen, cache_key))
84
+ raise ValueError(f"Cyclic array expression detected: {cycle}")
85
+
86
+ if not self.config.has_option(section, key):
87
+ raise KeyError(f"Missing option {section}:{key}")
88
+
89
+ seen.add(cache_key)
90
+ value = self.config.get(section, key)
91
+ if isinstance(value.parsed, bool | int | float):
92
+ result = value.parsed
93
+ elif isinstance(value.parsed, tuple):
94
+ raise TypeError(f"Option {section}:{key} is a list, not an array expression")
95
+ else:
96
+ result = self._evaluate_expression(value.raw, current_section=section, seen=seen)
97
+ self._cache[cache_key] = result
98
+ seen.remove(cache_key)
99
+ return result
100
+
101
+ def _evaluate_expression(self, expression: str, *, current_section: str, seen: set[tuple[str, str]]) -> Any:
102
+ tree, references = _compile_expression(expression)
103
+ return self._eval_node(tree.body, current_section=current_section, references=references, seen=seen)
104
+
105
+ def _eval_node(
106
+ self,
107
+ node: ast.AST,
108
+ *,
109
+ current_section: str,
110
+ references: Mapping[str, str],
111
+ seen: set[tuple[str, str]],
112
+ ) -> Any:
113
+ if isinstance(node, ast.Constant):
114
+ if isinstance(node.value, bool | int | float):
115
+ return node.value
116
+ raise TypeError(f"Unsupported constant value: {node.value!r}")
117
+ if isinstance(node, ast.Name):
118
+ return self._resolve_name(node.id, current_section=current_section, references=references, seen=seen)
119
+ if isinstance(node, ast.BinOp) and type(node.op) in _SAFE_BINOPS:
120
+ left = self._eval_node(node.left, current_section=current_section, references=references, seen=seen)
121
+ right = self._eval_node(node.right, current_section=current_section, references=references, seen=seen)
122
+ return _SAFE_BINOPS[type(node.op)](left, right)
123
+ if isinstance(node, ast.UnaryOp) and type(node.op) in _SAFE_UNARYOPS:
124
+ operand = self._eval_node(node.operand, current_section=current_section, references=references, seen=seen)
125
+ return _SAFE_UNARYOPS[type(node.op)](operand)
126
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
127
+ function_name = node.func.id
128
+ if function_name not in _SAFE_FUNCTIONS:
129
+ raise TypeError(f"Unsupported array expression function {function_name!r}")
130
+ args = [
131
+ self._eval_node(argument, current_section=current_section, references=references, seen=seen)
132
+ for argument in node.args
133
+ ]
134
+ return _SAFE_FUNCTIONS[function_name](*args)
135
+ raise TypeError(f"Unsupported array expression node: {ast.dump(node, include_attributes=False)}")
136
+
137
+ def _resolve_name(
138
+ self,
139
+ name: str,
140
+ *,
141
+ current_section: str,
142
+ references: Mapping[str, str],
143
+ seen: set[tuple[str, str]],
144
+ ) -> Any:
145
+ if name in references:
146
+ section_name, key = references[name].rsplit(":", 1)
147
+ return self._resolve_option(section_name, key, seen)
148
+ if self.external_values is not None and name in self.external_values:
149
+ return self.external_values[name]
150
+ if name in self.local_values:
151
+ return self.local_values[name]
152
+ if name in _SAFE_CONSTANTS:
153
+ return _SAFE_CONSTANTS[name]
154
+ if current_section != ROOT_SECTION and self.config.has_option(current_section, name):
155
+ return self._resolve_option(current_section, name, seen)
156
+ if self.config.has_option(ROOT_SECTION, name):
157
+ return self._resolve_option(ROOT_SECTION, name, seen)
158
+ raise KeyError(f"Unknown array expression symbol {name!r} while evaluating section {current_section!r}")
159
+
160
+
161
+ @lru_cache(maxsize=1024)
162
+ def _compile_expression(expression: str) -> tuple[ast.Expression, Mapping[str, str]]:
163
+ sanitized = expression.replace("π", "pi").replace("^", "**")
164
+ sanitized = re.sub(r"(?<=\d)pi\b", "*pi", sanitized)
165
+ references: dict[str, str] = {}
166
+
167
+ def replace_reference(match: re.Match[str]) -> str:
168
+ token = f"__ref_{len(references)}"
169
+ references[token] = match.group(0)
170
+ return token
171
+
172
+ tree = ast.parse(_REFERENCE_PATTERN.sub(replace_reference, sanitized), mode="eval")
173
+ return tree, dict(references)