1use fusion_types::error::{FusionError, FusionResult};
10use fusion_types::state::RadialProfiles;
11
12const MIN_T_KEV: f64 = 0.05;
13const MIN_NE: f64 = 1e-4;
14
15#[derive(Debug, Clone, Copy)]
16pub struct PedestalConfig {
17 pub beta_p_ped: f64,
18 pub rho_s: f64,
19 pub r_major: f64,
20 pub alpha_crit: f64,
21 pub tau_elm: f64,
22}
23
24impl Default for PedestalConfig {
25 fn default() -> Self {
26 Self {
27 beta_p_ped: 0.35,
28 rho_s: 2.0e-3,
29 r_major: 6.2,
30 alpha_crit: 2.5,
31 tau_elm: 1.0e-3,
32 }
33 }
34}
35
36#[derive(Debug, Clone)]
37pub struct PedestalModel {
38 pub config: PedestalConfig,
39 last_gradient: f64,
40 elm_cooldown_s: f64,
41}
42
43impl PedestalModel {
44 pub fn new(config: PedestalConfig) -> FusionResult<Self> {
45 if !config.beta_p_ped.is_finite() || config.beta_p_ped < 0.0 {
46 return Err(FusionError::ConfigError(
47 "pedestal beta_p_ped must be finite and >= 0".to_string(),
48 ));
49 }
50 if !config.rho_s.is_finite() || config.rho_s <= 0.0 {
51 return Err(FusionError::ConfigError(
52 "pedestal rho_s must be finite and > 0".to_string(),
53 ));
54 }
55 if !config.r_major.is_finite() || config.r_major <= 0.0 {
56 return Err(FusionError::ConfigError(
57 "pedestal r_major must be finite and > 0".to_string(),
58 ));
59 }
60 if !config.alpha_crit.is_finite() || config.alpha_crit <= 0.0 {
61 return Err(FusionError::ConfigError(
62 "pedestal alpha_crit must be finite and > 0".to_string(),
63 ));
64 }
65 if !config.tau_elm.is_finite() || config.tau_elm <= 0.0 {
66 return Err(FusionError::ConfigError(
67 "pedestal tau_elm must be finite and > 0".to_string(),
68 ));
69 }
70 Ok(Self {
71 config,
72 last_gradient: 0.0,
73 elm_cooldown_s: 0.0,
74 })
75 }
76
77 pub fn pedestal_width(&self) -> f64 {
79 let beta = self.config.beta_p_ped;
80 let rho_s = self.config.rho_s;
81 let r_major = self.config.r_major;
82 (beta.sqrt() * (rho_s / r_major)).clamp(0.03, 0.12)
83 }
84
85 fn effective_alpha_crit(&self) -> f64 {
87 let shear_proxy = 1.0 / self.pedestal_width().max(1e-4);
88 self.config.alpha_crit.max(2.5 * shear_proxy)
89 }
90
91 pub fn is_elm_triggered(&self, pressure_gradient: f64) -> bool {
92 self.elm_cooldown_s <= 0.0 && pressure_gradient.abs() > self.effective_alpha_crit()
93 }
94
95 pub fn advance(&mut self, dt_s: f64) {
97 self.elm_cooldown_s = (self.elm_cooldown_s - dt_s.max(0.0)).max(0.0);
98 }
99
100 pub fn last_gradient(&self) -> f64 {
101 self.last_gradient
102 }
103
104 pub fn apply_elm_crash(&mut self, profiles: &mut RadialProfiles) {
106 let width = self.pedestal_width();
107 let rho_start = (1.0 - width).clamp(0.75, 0.995);
108 let tau = self.config.tau_elm;
109 let burst_fraction = (1.0 - (-1.0e-3 / tau).exp()).clamp(0.05, 0.95);
110
111 for i in 0..profiles.rho.len() {
112 let rho = profiles.rho[i];
113 if rho >= rho_start {
114 let edge_w = ((rho - rho_start) / width).clamp(0.0, 1.0);
115 let temperature_drop = (0.2 + 0.6 * edge_w * burst_fraction).clamp(0.0, 0.95);
116 let density_drop = 0.5 * temperature_drop;
117
118 profiles.te[i] = (profiles.te[i] * (1.0 - temperature_drop)).max(MIN_T_KEV);
119 profiles.ti[i] = (profiles.ti[i] * (1.0 - temperature_drop)).max(MIN_T_KEV);
120 profiles.ne[i] = (profiles.ne[i] * (1.0 - density_drop)).max(MIN_NE);
121 }
122 }
123
124 self.elm_cooldown_s = 3.0 * tau;
125 }
126
127 pub fn record_gradient(&mut self, pressure_gradient: f64) {
128 self.last_gradient = pressure_gradient;
129 }
130}
131
132impl Default for PedestalModel {
133 fn default() -> Self {
134 Self::new(PedestalConfig::default()).expect("default pedestal config must be valid")
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use ndarray::Array1;
142
143 fn sample_profiles() -> RadialProfiles {
144 let n = 50;
145 let rho = Array1::linspace(0.0, 1.0, n);
146 let te = Array1::from_shape_fn(n, |i| 8.0 - 6.5 * rho[i]);
147 let ti = te.clone();
148 let ne = Array1::from_shape_fn(n, |i| 9.0 - 5.0 * rho[i]);
149 let n_impurity = Array1::zeros(n);
150 RadialProfiles {
151 rho,
152 te,
153 ti,
154 ne,
155 n_impurity,
156 }
157 }
158
159 #[test]
160 fn test_pedestal_width_eped_scaling() {
161 let model = PedestalModel::new(PedestalConfig {
162 beta_p_ped: 0.5,
163 rho_s: 3.0e-3,
164 r_major: 6.0,
165 alpha_crit: 2.5,
166 tau_elm: 1.0e-3,
167 })
168 .expect("valid pedestal config");
169 let width = model.pedestal_width();
170 assert!(width > 0.0);
171 assert!(width <= 0.12);
172 }
173
174 #[test]
175 fn test_elm_trigger_threshold() {
176 let model = PedestalModel::default();
177 assert!(!model.is_elm_triggered(1.0));
178 assert!(model.is_elm_triggered(5_000.0));
179 }
180
181 #[test]
182 fn test_apply_elm_crash_reduces_edge_profiles() {
183 let mut model = PedestalModel::default();
184 let mut profiles = sample_profiles();
185
186 let i_edge = profiles.rho.len() - 2;
187 let te_before = profiles.te[i_edge];
188 let ne_before = profiles.ne[i_edge];
189
190 model.apply_elm_crash(&mut profiles);
191
192 assert!(profiles.te[i_edge] < te_before);
193 assert!(profiles.ne[i_edge] < ne_before);
194 }
195
196 #[test]
197 fn test_elm_cooldown_blocks_retrigger_until_advanced() {
198 let mut model = PedestalModel::default();
199 let mut profiles = sample_profiles();
200
201 assert!(model.is_elm_triggered(5_000.0));
202 model.apply_elm_crash(&mut profiles);
203 assert!(
204 !model.is_elm_triggered(5_000.0),
205 "ELM cooldown should block immediate retriggering"
206 );
207
208 model.advance(10.0);
209 assert!(
210 model.is_elm_triggered(5_000.0),
211 "ELM trigger should recover after cooldown elapses"
212 );
213 }
214
215 #[test]
216 fn test_elm_crash_respects_minimum_profile_floors() {
217 let mut model = PedestalModel::default();
218 let n = 16;
219 let mut profiles = RadialProfiles {
220 rho: Array1::linspace(0.0, 1.0, n),
221 te: Array1::from_elem(n, 0.06),
222 ti: Array1::from_elem(n, 0.06),
223 ne: Array1::from_elem(n, 2.0e-4),
224 n_impurity: Array1::zeros(n),
225 };
226
227 model.apply_elm_crash(&mut profiles);
228 assert!(profiles.te.iter().all(|v| *v >= MIN_T_KEV));
229 assert!(profiles.ti.iter().all(|v| *v >= MIN_T_KEV));
230 assert!(profiles.ne.iter().all(|v| *v >= MIN_NE));
231 }
232
233 #[test]
234 fn test_pedestal_constructor_rejects_invalid_config() {
235 let bad = [
236 PedestalConfig {
237 beta_p_ped: -1.0,
238 ..Default::default()
239 },
240 PedestalConfig {
241 rho_s: 0.0,
242 ..Default::default()
243 },
244 PedestalConfig {
245 r_major: 0.0,
246 ..Default::default()
247 },
248 PedestalConfig {
249 alpha_crit: 0.0,
250 ..Default::default()
251 },
252 PedestalConfig {
253 tau_elm: 0.0,
254 ..Default::default()
255 },
256 ];
257 for cfg in bad {
258 let err = PedestalModel::new(cfg).expect_err("invalid config must error");
259 match err {
260 FusionError::ConfigError(msg) => {
261 assert!(msg.contains("pedestal"));
262 }
263 other => panic!("Unexpected error: {other:?}"),
264 }
265 }
266 }
267}