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,221 @@
1
+ """Self-consistent detaching 1D SOL model (evolved temperature).
2
+
3
+ Extends the recycling SOL to an *evolved* plasma pressure so the target
4
+ temperature responds to the plasma conditions -- the ingredient a detachment
5
+ study needs. Along the field it evolves ion density, parallel momentum, plasma
6
+ pressure, and a diffusive neutral density, with:
7
+
8
+ - **Spitzer parallel conduction** ``kappa ~ T^{5/2}`` solved implicitly (a solvax
9
+ tridiagonal), so the stiff parabolic heat transport is unconditionally stable;
10
+ - **radiative / ionization energy loss** from the AMJUEL fits, applied as a
11
+ *self-limiting* semi-implicit sink (``P <- P / (1 + dt * loss_rate)``) so the
12
+ loss cannot drive the pressure negative and switches off as the plasma cools;
13
+ - a **Bohm sheath heat sink** at the target and an **upstream power source**; and
14
+ - the neutral **recycling**, parallel diffusion, and ionization/recombination /
15
+ charge-exchange coupling of the recycling model.
16
+
17
+ As the upstream density rises at fixed upstream power the target cools, crosses
18
+ into the recombining regime below ~1 eV, and the target ion flux rolls over --
19
+ the classic SD1D detachment signature. Everything is pure ``jax.numpy`` (with the
20
+ solvax tridiagonal solves) and differentiable. Fields are hermes-3 normalized;
21
+ time is normalized to the parallel transit time.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+ from typing import NamedTuple
28
+
29
+ import jax
30
+ import jax.numpy as jnp
31
+
32
+ from solvax import tridiagonal_solve
33
+
34
+ from .atomic_rates import (
35
+ charge_exchange_rate_coefficient,
36
+ energy_loss_coefficient,
37
+ rate_coefficient,
38
+ )
39
+ from .reactions import PlasmaNormalization
40
+
41
+ __all__ = [
42
+ "DetachmentSolParameters",
43
+ "DetachmentSolState",
44
+ "detachment_sol_step",
45
+ "detachment_sol_run",
46
+ "detachment_diagnostics",
47
+ ]
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class DetachmentSolParameters:
52
+ """Parameters of the self-consistent detaching SOL model."""
53
+
54
+ parallel_length: float = 30.0
55
+ upstream_density: float = 4.0
56
+ upstream_power: float = 6.0 # normalized upstream power source
57
+ power_width: float = 0.2 # parallel width of the power source
58
+ conduction_coefficient: float = 2.0 # Spitzer kappa0 (normalized)
59
+ sheath_transmission: float = 7.0 # gamma (Te + Ti)
60
+ neutral_diffusion: float = 8.0
61
+ recycling_fraction: float = 0.95
62
+ ion_mass: float = 2.0
63
+ density_floor: float = 1.0e-4
64
+ pressure_floor: float = 1.0e-5
65
+ temperature_floor: float = 1.0e-3
66
+ # A scrape-off-layer reference temperature (upstream ~ tens of eV); the
67
+ # detachment transition and target-flux rollover live in this regime.
68
+ normalization: PlasmaNormalization = PlasmaNormalization(Tnorm=50.0)
69
+
70
+
71
+ class DetachmentSolState(NamedTuple):
72
+ """Evolved fields: ion density, ion momentum, plasma pressure, neutral density."""
73
+
74
+ ion_density: jnp.ndarray
75
+ ion_momentum: jnp.ndarray
76
+ plasma_pressure: jnp.ndarray
77
+ neutral_density: jnp.ndarray
78
+
79
+
80
+ def _temperature(density, pressure, params):
81
+ return jnp.maximum(pressure / (2.0 * jnp.maximum(density, params.density_floor)), params.temperature_floor)
82
+
83
+
84
+ def _sound_speed(density, pressure, params):
85
+ return jnp.sqrt(jnp.maximum(pressure / (params.ion_mass * jnp.maximum(density, params.density_floor)), 0.0))
86
+
87
+
88
+ def _hyperbolic_rhs(density, momentum, pressure, params):
89
+ mass = params.ion_mass
90
+ dz = 1.0 / density.shape[0]
91
+ velocity = momentum / (mass * jnp.maximum(density, params.density_floor))
92
+ speed = jnp.abs(velocity) + _sound_speed(density, pressure, params)
93
+ face_speed = jnp.maximum(speed[:-1], speed[1:])
94
+ flux_n = 0.5 * (density[:-1] * velocity[:-1] + density[1:] * velocity[1:]) - 0.5 * face_speed * (density[1:] - density[:-1])
95
+ flux_m = 0.5 * (momentum[:-1] * velocity[:-1] + pressure[:-1] + momentum[1:] * velocity[1:] + pressure[1:]) - 0.5 * face_speed * (momentum[1:] - momentum[:-1])
96
+ flux_p = 0.5 * ((5.0 / 3.0) * pressure[:-1] * velocity[:-1] + (5.0 / 3.0) * pressure[1:] * velocity[1:]) - 0.5 * face_speed * (pressure[1:] - pressure[:-1])
97
+ target_velocity = jnp.maximum(velocity[-1], _sound_speed(density, pressure, params)[-1])
98
+ flux_n = jnp.concatenate([jnp.zeros(1), flux_n, (density[-1] * target_velocity)[None]])
99
+ flux_m = jnp.concatenate([pressure[:1], flux_m, (mass * density[-1] * target_velocity**2 + pressure[-1])[None]])
100
+ flux_p = jnp.concatenate([jnp.zeros(1), flux_p, ((5.0 / 3.0) * pressure[-1] * target_velocity)[None]])
101
+ pressure_gradient = jnp.gradient(pressure, dz)
102
+ d_density = -(flux_n[1:] - flux_n[:-1]) / dz
103
+ d_momentum = -(flux_m[1:] - flux_m[:-1]) / dz
104
+ d_pressure = -(flux_p[1:] - flux_p[:-1]) / dz + (2.0 / 3.0) * velocity * pressure_gradient
105
+ return d_density, d_momentum, d_pressure
106
+
107
+
108
+ def _implicit_diffusion(field, face_diffusivity, dt):
109
+ n = field.shape[0]
110
+ dz = 1.0 / n
111
+ coefficient = dt / dz**2
112
+ lower = jnp.zeros(n).at[1:].set(-coefficient * face_diffusivity)
113
+ upper = jnp.zeros(n).at[:-1].set(-coefficient * face_diffusivity)
114
+ diagonal = jnp.ones(n).at[1:-1].set(1.0 + coefficient * (face_diffusivity[:-1] + face_diffusivity[1:]))
115
+ diagonal = diagonal.at[0].set(1.0 + coefficient * face_diffusivity[0])
116
+ diagonal = diagonal.at[-1].set(1.0 + coefficient * face_diffusivity[-1])
117
+ return tridiagonal_solve(lower, diagonal, upper, field, method="thomas")
118
+
119
+
120
+ def detachment_sol_step(state, params, dt):
121
+ """Advance the self-consistent detaching SOL one operator-split step."""
122
+
123
+ norm = params.normalization
124
+ nz = state.ion_density.shape[0]
125
+ dz = 1.0 / nz
126
+ z = (jnp.arange(nz, dtype=jnp.float64) + 0.5) / nz
127
+ density_floor = params.density_floor
128
+ pressure_floor = params.pressure_floor
129
+ rate_scale = norm.Nnorm * (params.parallel_length / norm.sound_speed)
130
+ energy_scale = rate_scale / norm.Tnorm
131
+
132
+ density = jnp.maximum(state.ion_density, density_floor)
133
+ momentum = state.ion_momentum
134
+ pressure = jnp.maximum(state.plasma_pressure, pressure_floor)
135
+ neutral_density = state.neutral_density
136
+
137
+ # Hyperbolic transport + upstream power source.
138
+ d_density, d_momentum, d_pressure = _hyperbolic_rhs(density, momentum, pressure, params)
139
+ power_source = params.upstream_power * jnp.exp(-(z**2) / params.power_width**2)
140
+ density = jnp.maximum(density + dt * d_density, density_floor)
141
+ momentum = momentum + dt * d_momentum
142
+ pressure = jnp.maximum(pressure + dt * d_pressure + dt * (2.0 / 3.0) * power_source, pressure_floor)
143
+
144
+ # Atomic reactions: particle exchange (implicit) + momentum friction.
145
+ temperature = _temperature(density, pressure, params)
146
+ physical_temperature = temperature * norm.Tnorm
147
+ electron_density_m3 = jnp.maximum(density, density_floor) * norm.Nnorm
148
+ electron = jnp.maximum(density, density_floor)
149
+ ionization = rate_coefficient("d", "iz", physical_temperature, electron_density_m3) * rate_scale
150
+ recombination = rate_coefficient("d", "rec", physical_temperature, electron_density_m3) * rate_scale
151
+ ionization_frequency = ionization * electron
152
+ recombination_frequency = recombination * electron
153
+ total = density + neutral_density
154
+ density = jnp.maximum((density + dt * ionization_frequency * total) / (1.0 + dt * (ionization_frequency + recombination_frequency)), density_floor)
155
+ neutral_density = jnp.maximum(total - density, 0.0)
156
+ effective_temperature = jnp.clip(2.0 * temperature * norm.Tnorm / params.ion_mass, 0.01, 1.0e4)
157
+ charge_exchange_frequency = charge_exchange_rate_coefficient(effective_temperature) * electron * rate_scale
158
+ momentum = momentum / (1.0 + dt * (charge_exchange_frequency * neutral_density + recombination_frequency))
159
+
160
+ # Self-limiting radiative / ionization energy loss.
161
+ ionization_energy = energy_loss_coefficient("d", "iz", physical_temperature, electron_density_m3)
162
+ recombination_energy = energy_loss_coefficient("d", "rec", physical_temperature, electron_density_m3)
163
+ energy_loss = (neutral_density * electron * ionization_energy + electron * electron * recombination_energy) * energy_scale
164
+ loss_rate = (2.0 / 3.0) * jnp.maximum(energy_loss, 0.0) / jnp.maximum(pressure, pressure_floor)
165
+ pressure = pressure / (1.0 + dt * loss_rate)
166
+
167
+ # Bohm sheath heat sink at the target (semi-implicit).
168
+ sound_speed = _sound_speed(density, pressure, params)
169
+ sheath_rate = jnp.zeros(nz).at[-1].set(
170
+ (2.0 / 3.0) * params.sheath_transmission * density[-1] * sound_speed[-1] * _temperature(density, pressure, params)[-1] / dz / jnp.maximum(pressure[-1], pressure_floor)
171
+ )
172
+ pressure = pressure / (1.0 + dt * sheath_rate)
173
+
174
+ # Implicit Spitzer conduction.
175
+ temperature = _temperature(density, pressure, params)
176
+ conductivity = params.conduction_coefficient * temperature**2.5
177
+ diffusivity = conductivity / (3.0 * jnp.maximum(density, density_floor))
178
+ pressure = jnp.maximum(_implicit_diffusion(pressure, 0.5 * (diffusivity[:-1] + diffusivity[1:]), dt), pressure_floor)
179
+
180
+ density = density.at[0].set(params.upstream_density)
181
+
182
+ # Neutral parallel diffusion + target recycling.
183
+ neutral_diffusivity = jnp.full(nz - 1, params.neutral_diffusion * 0.04 / (params.ion_mass * 0.05))
184
+ sound_speed = _sound_speed(density, pressure, params)
185
+ recycling_source = jnp.zeros(nz).at[-1].set(params.recycling_fraction * density[-1] * sound_speed[-1] / dz)
186
+ neutral_density = jnp.maximum(_implicit_diffusion(neutral_density + dt * recycling_source, neutral_diffusivity, dt), 0.0)
187
+
188
+ return DetachmentSolState(density, momentum, pressure, neutral_density)
189
+
190
+
191
+ def detachment_sol_run(state, params, *, dt, steps):
192
+ """Advance ``steps`` operator-split steps with a jitted ``lax.scan``."""
193
+
194
+ @jax.jit
195
+ def _run(initial):
196
+ def body(carry, _):
197
+ return detachment_sol_step(carry, params, dt), None
198
+
199
+ final, _ = jax.lax.scan(body, initial, None, length=steps)
200
+ return final
201
+
202
+ return _run(state)
203
+
204
+
205
+ class DetachmentDiagnostics(NamedTuple):
206
+ target_ion_flux: jnp.ndarray
207
+ target_temperature_ev: jnp.ndarray
208
+ target_density: jnp.ndarray
209
+
210
+
211
+ def detachment_diagnostics(state, params) -> DetachmentDiagnostics:
212
+ """Target ion flux (``n c_s``), target temperature [eV], and target density."""
213
+
214
+ density = jnp.maximum(state.ion_density, params.density_floor)
215
+ temperature = _temperature(density, jnp.maximum(state.plasma_pressure, params.pressure_floor), params)
216
+ sound_speed = _sound_speed(density, jnp.maximum(state.plasma_pressure, params.pressure_floor), params)
217
+ return DetachmentDiagnostics(
218
+ target_ion_flux=density[-1] * sound_speed[-1],
219
+ target_temperature_ev=temperature[-1] * params.normalization.Tnorm,
220
+ target_density=density[-1],
221
+ )
@@ -0,0 +1,164 @@
1
+ """Plasma <-> neutral atomic reaction sources for the 1D SOL model.
2
+
3
+ Assembles the ionization / recombination / charge-exchange source channels that
4
+ couple a hydrogenic plasma fluid (ion density ``Ni``, parallel momentum, ion
5
+ temperature ``Ti``, electron temperature ``Te``) to a neutral fluid (``Nn``,
6
+ momentum, ``Tn``), following the hermes-3 hydrogen reaction closure. Particle,
7
+ momentum, and (thermal) energy transfers use the Galilean-invariant form: each
8
+ particle transfer carries the source species' momentum ``m V`` and thermal
9
+ energy ``1.5 T``, and charge exchange adds a frictional heating ``0.5 m R dV^2``
10
+ from the ion-atom velocity difference. The electron channel is the ionization
11
+ cost / recombination radiation from the AMJUEL energy-loss fits.
12
+
13
+ All fields are in hermes-3 normalized units (density / ``Nnorm``, temperature /
14
+ ``Tnorm`` [eV], velocity / ``Cs0``); the returned source rates are normalized to
15
+ ``Nnorm * Omega_ci`` (particle / momentum) and ``Nnorm * Tnorm * Omega_ci``
16
+ (energy). Everything is pure ``jax.numpy`` and differentiable.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+
23
+ import jax.numpy as jnp
24
+
25
+ from .atomic_rates import (
26
+ charge_exchange_rate_coefficient,
27
+ energy_loss_coefficient,
28
+ rate_coefficient,
29
+ )
30
+
31
+ __all__ = ["PlasmaNormalization", "HydrogenReactionSources", "compute_hydrogen_reaction_sources"]
32
+
33
+ _ELEMENTARY_CHARGE = 1.602176634e-19
34
+ _PROTON_MASS = 1.67262192369e-27
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class PlasmaNormalization:
39
+ """hermes-3 normalization constants (deck defaults: 1e19 m^-3, 100 eV, 1 T)."""
40
+
41
+ Nnorm: float = 1.0e19
42
+ Tnorm: float = 100.0
43
+ Bnorm: float = 1.0
44
+ ion_mass: float = 2.0 # deuterium, in proton masses
45
+
46
+ @property
47
+ def sound_speed(self) -> float:
48
+ return float((_ELEMENTARY_CHARGE * self.Tnorm / _PROTON_MASS) ** 0.5)
49
+
50
+ @property
51
+ def cyclotron_frequency(self) -> float:
52
+ return float(_ELEMENTARY_CHARGE * self.Bnorm / _PROTON_MASS)
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class HydrogenReactionSources:
57
+ """Normalized reaction source channels for the ion, neutral, and electron fluids."""
58
+
59
+ ion_density: jnp.ndarray
60
+ neutral_density: jnp.ndarray
61
+ ion_momentum: jnp.ndarray
62
+ neutral_momentum: jnp.ndarray
63
+ ion_energy: jnp.ndarray
64
+ neutral_energy: jnp.ndarray
65
+ electron_energy: jnp.ndarray
66
+ ionization_rate: jnp.ndarray
67
+ recombination_rate: jnp.ndarray
68
+ charge_exchange_rate: jnp.ndarray
69
+
70
+
71
+ def compute_hydrogen_reaction_sources(
72
+ ion_density: jnp.ndarray,
73
+ ion_velocity: jnp.ndarray,
74
+ ion_temperature: jnp.ndarray,
75
+ electron_temperature: jnp.ndarray,
76
+ neutral_density: jnp.ndarray,
77
+ neutral_velocity: jnp.ndarray,
78
+ neutral_temperature: jnp.ndarray,
79
+ *,
80
+ normalization: PlasmaNormalization = PlasmaNormalization(),
81
+ charge_exchange_multiplier: float = 1.0,
82
+ ) -> HydrogenReactionSources:
83
+ """Return the normalized ion/neutral/electron reaction source channels."""
84
+
85
+ mass = float(normalization.ion_mass)
86
+ nnorm = float(normalization.Nnorm)
87
+ tnorm = float(normalization.Tnorm)
88
+ omega = normalization.cyclotron_frequency
89
+ rate_scale = nnorm / omega
90
+ energy_scale = nnorm / (tnorm * omega)
91
+
92
+ # Physical inputs for the rate fits.
93
+ electron_temperature_ev = electron_temperature * tnorm
94
+ electron_density_m3 = ion_density * nnorm # quasineutral, Z = 1
95
+
96
+ ionization_sigmav = rate_coefficient("d", "iz", electron_temperature_ev, electron_density_m3)
97
+ recombination_sigmav = rate_coefficient("d", "rec", electron_temperature_ev, electron_density_m3)
98
+ ionization_energy = energy_loss_coefficient("d", "iz", electron_temperature_ev, electron_density_m3)
99
+ recombination_energy = energy_loss_coefficient("d", "rec", electron_temperature_ev, electron_density_m3)
100
+
101
+ effective_temperature = jnp.clip(
102
+ (neutral_temperature / mass + ion_temperature / mass) * tnorm, 0.01, 1.0e4
103
+ )
104
+ cx_sigmav = charge_exchange_rate_coefficient(effective_temperature)
105
+
106
+ # Normalized volumetric reaction rates.
107
+ ionization_rate = neutral_density * ion_density * ionization_sigmav * rate_scale
108
+ recombination_rate = ion_density * ion_density * recombination_sigmav * rate_scale
109
+ charge_exchange_rate = (
110
+ ion_density * neutral_density * cx_sigmav * rate_scale * float(charge_exchange_multiplier)
111
+ )
112
+ ionization_radiation = neutral_density * ion_density * ionization_energy * energy_scale
113
+ recombination_radiation = ion_density * ion_density * recombination_energy * energy_scale
114
+
115
+ # Momentum carried by each particle transfer (source species' m V).
116
+ ionization_momentum = ionization_rate * mass * neutral_velocity
117
+ recombination_momentum = recombination_rate * mass * ion_velocity
118
+ cx_neutral_momentum = charge_exchange_rate * mass * neutral_velocity
119
+ cx_ion_momentum = charge_exchange_rate * mass * ion_velocity
120
+
121
+ # Thermal energy carried by each particle transfer, plus CX frictional heating.
122
+ ionization_energy_transfer = 1.5 * ionization_rate * neutral_temperature
123
+ recombination_energy_transfer = 1.5 * recombination_rate * ion_temperature
124
+ cx_neutral_thermal = 1.5 * charge_exchange_rate * neutral_temperature
125
+ cx_ion_thermal = 1.5 * charge_exchange_rate * ion_temperature
126
+ velocity_delta = ion_velocity - neutral_velocity
127
+ cx_frictional_heat = 0.5 * mass * charge_exchange_rate * velocity_delta**2
128
+
129
+ ion_density_source = ionization_rate - recombination_rate
130
+ neutral_density_source = -ionization_rate + recombination_rate
131
+ ion_momentum_source = (
132
+ ionization_momentum - recombination_momentum + cx_neutral_momentum - cx_ion_momentum
133
+ )
134
+ neutral_momentum_source = (
135
+ -ionization_momentum + recombination_momentum - cx_neutral_momentum + cx_ion_momentum
136
+ )
137
+ ion_energy_source = (
138
+ ionization_energy_transfer
139
+ - recombination_energy_transfer
140
+ + cx_neutral_thermal
141
+ - cx_ion_thermal
142
+ + cx_frictional_heat
143
+ )
144
+ neutral_energy_source = (
145
+ -ionization_energy_transfer
146
+ + recombination_energy_transfer
147
+ - cx_neutral_thermal
148
+ + cx_ion_thermal
149
+ + cx_frictional_heat
150
+ )
151
+ electron_energy_source = -ionization_radiation - recombination_radiation
152
+
153
+ return HydrogenReactionSources(
154
+ ion_density=ion_density_source,
155
+ neutral_density=neutral_density_source,
156
+ ion_momentum=ion_momentum_source,
157
+ neutral_momentum=neutral_momentum_source,
158
+ ion_energy=ion_energy_source,
159
+ neutral_energy=neutral_energy_source,
160
+ electron_energy=electron_energy_source,
161
+ ionization_rate=ionization_rate,
162
+ recombination_rate=recombination_rate,
163
+ charge_exchange_rate=charge_exchange_rate,
164
+ )
@@ -0,0 +1,197 @@
1
+ """Coupled 1D scrape-off-layer recycling model: plasma + diffusive neutral.
2
+
3
+ A reduced SOL model that couples a hydrogenic plasma fluid to a recycled neutral
4
+ fluid through the packaged atomic reactions. Along the field coordinate ``z``
5
+ (stagnation midplane at ``z = 0``, target plate at ``z = L``) it evolves the ion
6
+ density and parallel momentum on a *prescribed* parallel temperature profile
7
+ (hot upstream, cold target -- the imposed-temperature closure common in reduced
8
+ SOL studies, which sidesteps the stiff self-consistent conduction/radiation
9
+ energy balance), together with a neutral density that is:
10
+
11
+ - **recycled** from the Bohm ion flux at the target (fraction ``R``),
12
+ - **transported** by parallel diffusion (solved implicitly with a tridiagonal
13
+ solve, so the parabolic term is unconditionally stable), and
14
+ - **ionized / recombined** back into the plasma via the AMJUEL rate coefficients
15
+ (an operator-split, per-cell implicit update that is stable against the stiff
16
+ ionization source).
17
+
18
+ The result is an attached recycling SOL: neutrals born at the target penetrate
19
+ upstream, ionize where the plasma is hot, and the plasma flows back to the target
20
+ at the Bohm speed. Self-consistent detachment (an *evolved* temperature with
21
+ conduction and radiative rollover) is a further extension.
22
+
23
+ Everything is pure ``jax.numpy`` (with the solvax tridiagonal solve) and therefore
24
+ ``jit``/``grad``/``vmap`` transparent. Fields are hermes-3 normalized
25
+ (density / ``Nnorm``, temperature / ``Tnorm``, velocity / the reference sound
26
+ speed); the reaction rates use physical temperature and density internally.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass
32
+ from typing import NamedTuple
33
+
34
+ import jax
35
+ import jax.numpy as jnp
36
+
37
+ from solvax import tridiagonal_solve
38
+
39
+ from .atomic_rates import charge_exchange_rate_coefficient, rate_coefficient
40
+ from .reactions import PlasmaNormalization
41
+
42
+ __all__ = [
43
+ "SolRecyclingParameters",
44
+ "SolRecyclingState",
45
+ "linear_target_temperature_profile",
46
+ "sol_recycling_step",
47
+ "sol_recycling_run",
48
+ "target_ion_flux",
49
+ ]
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class SolRecyclingParameters:
54
+ """Parameters of the reduced recycling SOL model."""
55
+
56
+ parallel_length: float = 30.0 # m, stagnation -> target
57
+ upstream_density: float = 1.0 # fixed upstream ion density (normalized)
58
+ recycling_fraction: float = 1.0 # target recycling fraction R
59
+ neutral_diffusion: float = 8.0 # neutral parallel diffusion enhancement
60
+ neutral_temperature: float = 0.04 # normalized (cold, ~2 eV) neutral temperature
61
+ ion_mass: float = 2.0 # deuterium
62
+ density_floor: float = 1.0e-4
63
+ normalization: PlasmaNormalization = PlasmaNormalization()
64
+
65
+
66
+ class SolRecyclingState(NamedTuple):
67
+ """Evolved fields: ion density, ion momentum (A n v), neutral density."""
68
+
69
+ ion_density: jnp.ndarray
70
+ ion_momentum: jnp.ndarray
71
+ neutral_density: jnp.ndarray
72
+
73
+
74
+ def linear_target_temperature_profile(nz: int, *, upstream_ev: float = 30.0, target_ev: float = 2.0,
75
+ normalization: PlasmaNormalization = PlasmaNormalization()) -> jnp.ndarray:
76
+ """Prescribed parallel temperature (normalized): quadratic hot->cold to the target."""
77
+
78
+ z = (jnp.arange(nz, dtype=jnp.float64) + 0.5) / nz
79
+ profile_ev = upstream_ev - (upstream_ev - target_ev) * z**2
80
+ return profile_ev / normalization.Tnorm
81
+
82
+
83
+ def _sound_speed(density, temperature, params):
84
+ return jnp.sqrt(jnp.maximum(2.0 * temperature / params.ion_mass, 0.0))
85
+
86
+
87
+ def _velocity(momentum, density, params):
88
+ return momentum / (params.ion_mass * jnp.maximum(density, params.density_floor))
89
+
90
+
91
+ def _hyperbolic_rhs(density, momentum, temperature, params):
92
+ mass = params.ion_mass
93
+ dz = 1.0 / density.shape[0]
94
+ velocity = _velocity(momentum, density, params)
95
+ pressure = 2.0 * density * temperature
96
+ speed = jnp.abs(velocity) + _sound_speed(density, temperature, params)
97
+ face_speed = jnp.maximum(speed[:-1], speed[1:])
98
+ flux_n = 0.5 * (density[:-1] * velocity[:-1] + density[1:] * velocity[1:]) - 0.5 * face_speed * (density[1:] - density[:-1])
99
+ flux_m = 0.5 * (momentum[:-1] * velocity[:-1] + pressure[:-1] + momentum[1:] * velocity[1:] + pressure[1:]) - 0.5 * face_speed * (momentum[1:] - momentum[:-1])
100
+ # noflow stagnation at z=0 (pressure force only); Bohm outflow at the target.
101
+ target_velocity = jnp.maximum(velocity[-1], _sound_speed(density, temperature, params)[-1])
102
+ flux_n = jnp.concatenate([jnp.zeros(1), flux_n, (density[-1] * target_velocity)[None]])
103
+ flux_m = jnp.concatenate([pressure[:1], flux_m, (mass * density[-1] * target_velocity**2 + pressure[-1])[None]])
104
+ d_density = -(flux_n[1:] - flux_n[:-1]) / dz
105
+ d_momentum = -(flux_m[1:] - flux_m[:-1]) / dz
106
+ return d_density, d_momentum
107
+
108
+
109
+ def _implicit_diffusion(field, face_diffusivity, source, dt):
110
+ """Backward-Euler parallel diffusion with no-flux ends (tridiagonal solve)."""
111
+
112
+ n = field.shape[0]
113
+ dz = 1.0 / n
114
+ coefficient = dt / dz**2
115
+ lower = jnp.zeros(n).at[1:].set(-coefficient * face_diffusivity)
116
+ upper = jnp.zeros(n).at[:-1].set(-coefficient * face_diffusivity)
117
+ diagonal = jnp.ones(n).at[1:-1].set(1.0 + coefficient * (face_diffusivity[:-1] + face_diffusivity[1:]))
118
+ diagonal = diagonal.at[0].set(1.0 + coefficient * face_diffusivity[0])
119
+ diagonal = diagonal.at[-1].set(1.0 + coefficient * face_diffusivity[-1])
120
+ return tridiagonal_solve(lower, diagonal, upper, field + dt * source, method="thomas")
121
+
122
+
123
+ def _reaction_update(density, momentum, neutral_density, temperature, params, dt):
124
+ """Operator-split implicit ionization/recombination particle exchange, plus the
125
+ charge-exchange + recombination momentum friction that drags the plasma flow
126
+ toward the (stationary) neutrals -- the drag that keeps the target sonic."""
127
+
128
+ norm = params.normalization
129
+ physical_temperature = temperature * norm.Tnorm
130
+ density_floor = params.density_floor
131
+ electron_density_m3 = jnp.maximum(density, density_floor) * norm.Nnorm
132
+ # Time is normalized to the parallel transit time L / c_s, so a physical
133
+ # volumetric rate n^2 <sigma v> maps to n^2 <sigma v> * Nnorm * (L / c_s).
134
+ rate_scale = norm.Nnorm * (params.parallel_length / norm.sound_speed)
135
+ ionization = rate_coefficient("d", "iz", physical_temperature, electron_density_m3) * rate_scale
136
+ recombination = rate_coefficient("d", "rec", physical_temperature, electron_density_m3) * rate_scale
137
+ effective_temperature = jnp.clip(2.0 * temperature * norm.Tnorm / params.ion_mass, 0.01, 1.0e4)
138
+ charge_exchange = charge_exchange_rate_coefficient(effective_temperature) * rate_scale
139
+ electron = jnp.maximum(density, density_floor)
140
+ ionization_frequency = ionization * electron
141
+ recombination_frequency = recombination * electron
142
+ total = density + neutral_density
143
+ new_density = (density + dt * ionization_frequency * total) / (1.0 + dt * (ionization_frequency + recombination_frequency))
144
+ # Momentum friction: charge exchange and recombination remove ion parallel
145
+ # momentum (neutrals are ~stationary); implicit drag for stability.
146
+ friction_frequency = charge_exchange * neutral_density + recombination_frequency
147
+ new_momentum = momentum / (1.0 + dt * friction_frequency)
148
+ return jnp.maximum(new_density, density_floor), new_momentum, jnp.maximum(total - new_density, 0.0)
149
+
150
+
151
+ def sol_recycling_step(state, temperature, params, dt):
152
+ """Advance the recycling SOL one operator-split step."""
153
+
154
+ density = jnp.maximum(state.ion_density, params.density_floor)
155
+ momentum = state.ion_momentum
156
+ neutral_density = state.neutral_density
157
+
158
+ d_density, d_momentum = _hyperbolic_rhs(density, momentum, temperature, params)
159
+ density = jnp.maximum(density + dt * d_density, params.density_floor)
160
+ momentum = momentum + dt * d_momentum
161
+
162
+ density, momentum, neutral_density = _reaction_update(density, momentum, neutral_density, temperature, params, dt)
163
+
164
+ diffusivity = params.neutral_diffusion * params.neutral_temperature / (params.ion_mass * 0.05)
165
+ face_diffusivity = jnp.full(density.shape[0] - 1, diffusivity)
166
+ sound_speed = _sound_speed(density, temperature, params)
167
+ dz = 1.0 / density.shape[0]
168
+ recycling_source = jnp.zeros(density.shape[0]).at[-1].set(
169
+ params.recycling_fraction * density[-1] * sound_speed[-1] / dz
170
+ )
171
+ neutral_density = jnp.maximum(_implicit_diffusion(neutral_density, face_diffusivity, recycling_source, dt), 0.0)
172
+
173
+ # Pin the upstream ion density (Dirichlet) at the end of the step.
174
+ density = density.at[0].set(params.upstream_density)
175
+
176
+ return SolRecyclingState(ion_density=density, ion_momentum=momentum, neutral_density=neutral_density)
177
+
178
+
179
+ def sol_recycling_run(state, temperature, params, *, dt, steps):
180
+ """Advance ``steps`` operator-split steps with a jitted ``lax.scan``."""
181
+
182
+ @jax.jit
183
+ def _run(initial):
184
+ def body(carry, _):
185
+ return sol_recycling_step(carry, temperature, params, dt), None
186
+
187
+ final, _ = jax.lax.scan(body, initial, None, length=steps)
188
+ return final
189
+
190
+ return _run(state)
191
+
192
+
193
+ def target_ion_flux(state, temperature, params) -> jnp.ndarray:
194
+ """Bohm ion particle flux to the target plate ``n c_s``."""
195
+
196
+ density = jnp.maximum(state.ion_density, params.density_floor)
197
+ return density[-1] * _sound_speed(density, temperature, params)[-1]