1use super::{
2 check_energy_grid, check_in_range, check_nonnegative, checked_text, invalid, lerp_on_grid,
3 parse_error, read_text, ElectronDataError, Result, Subshell, CACHE_FORMAT_VERSION,
4};
5use serde::de::IgnoredAny;
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8use std::path::Path;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum SamplingAxis {
15 ElasticPolarAngle,
18 InelasticEnergyLoss,
21}
22
23impl SamplingAxis {
24 pub fn unit(self) -> &'static str {
26 match self {
27 SamplingAxis::ElasticPolarAngle => "rad",
28 SamplingAxis::InelasticEnergyLoss => "eV",
29 }
30 }
31}
32
33#[derive(Debug, Clone, PartialEq)]
37pub struct CrossSectionTableParts {
38 pub model: String,
40 pub material: String,
42 pub provenance: String,
44 pub axis: SamplingAxis,
46 pub energy_ev: Vec<f64>,
48 pub inverse_mfp_per_m: Vec<f64>,
50 pub probability: Vec<f64>,
52 pub quantiles: Vec<Vec<f64>>,
54}
55
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
93#[serde(try_from = "RawCacheIn", into = "RawCacheOut")]
94pub struct CrossSectionTable {
95 parts: CrossSectionTableParts,
96}
97
98#[derive(Debug, Deserialize)]
102struct RawCacheIn {
103 format_version: Option<u32>,
104 model: Option<String>,
105 material: Option<String>,
106 provenance: Option<String>,
107 axis: Option<SamplingAxis>,
108 ordinate_unit: Option<String>,
109 energy_ev: Option<Vec<f64>>,
110 inverse_mfp_per_m: Option<Vec<f64>>,
111 probability: Option<Vec<f64>>,
112 quantiles: Option<Vec<Vec<f64>>>,
113 #[serde(flatten)]
114 unknown: BTreeMap<String, IgnoredAny>,
115}
116
117#[derive(Debug, Clone, Serialize)]
118struct RawCacheOut {
119 format_version: u32,
120 model: String,
121 material: String,
122 provenance: String,
123 axis: SamplingAxis,
124 ordinate_unit: String,
125 energy_ev: Vec<f64>,
126 inverse_mfp_per_m: Vec<f64>,
127 probability: Vec<f64>,
128 quantiles: Vec<Vec<f64>>,
129}
130
131fn required<T>(field: &'static str, v: Option<T>) -> Result<T> {
132 v.ok_or_else(|| ElectronDataError::Parse(format!("missing field `{field}`")))
133}
134
135impl TryFrom<RawCacheIn> for CrossSectionTable {
136 type Error = ElectronDataError;
137 fn try_from(r: RawCacheIn) -> Result<Self> {
138 if r.format_version != Some(CACHE_FORMAT_VERSION) {
139 return Err(ElectronDataError::UnsupportedVersion {
140 found: r.format_version,
141 supported: CACHE_FORMAT_VERSION,
142 });
143 }
144 if let Some(k) = r.unknown.keys().next() {
145 return Err(ElectronDataError::Parse(format!(
146 "unknown field `{k}` in a version {CACHE_FORMAT_VERSION} cache"
147 )));
148 }
149 let provenance = checked_text("provenance", r.provenance.as_deref())?;
150 let model = checked_text("model", r.model.as_deref())?;
151 let material = checked_text("material", r.material.as_deref())?;
152 let axis = required("axis", r.axis)?;
153 let unit = checked_text("ordinate_unit", r.ordinate_unit.as_deref())?;
154 if unit != axis.unit() {
155 return Err(ElectronDataError::MalformedText {
156 field: "ordinate_unit",
157 reason: format!(
158 "{unit:?} does not match axis {axis:?} (unit {:?})",
159 axis.unit()
160 ),
161 });
162 }
163 Self::new(CrossSectionTableParts {
164 model,
165 material,
166 provenance,
167 axis,
168 energy_ev: required("energy_ev", r.energy_ev)?,
169 inverse_mfp_per_m: required("inverse_mfp_per_m", r.inverse_mfp_per_m)?,
170 probability: required("probability", r.probability)?,
171 quantiles: required("quantiles", r.quantiles)?,
172 })
173 }
174}
175
176impl From<CrossSectionTable> for RawCacheOut {
177 fn from(t: CrossSectionTable) -> Self {
178 let p = t.parts;
179 Self {
180 format_version: CACHE_FORMAT_VERSION,
181 ordinate_unit: p.axis.unit().to_string(),
182 model: p.model,
183 material: p.material,
184 provenance: p.provenance,
185 axis: p.axis,
186 energy_ev: p.energy_ev,
187 inverse_mfp_per_m: p.inverse_mfp_per_m,
188 probability: p.probability,
189 quantiles: p.quantiles,
190 }
191 }
192}
193
194impl CrossSectionTable {
195 pub fn new(parts: CrossSectionTableParts) -> Result<Self> {
197 let provenance = checked_text("provenance", Some(&parts.provenance))?;
198 let model = checked_text("model", Some(&parts.model))?;
199 let material = checked_text("material", Some(&parts.material))?;
200 let n = parts.energy_ev.len();
201 check_energy_grid("incident energy grid", &parts.energy_ev)?;
202 if parts.inverse_mfp_per_m.len() != n {
203 return Err(invalid(
204 "inverse mean free path",
205 format!(
206 "need one value per energy: {n} energies, {} values",
207 parts.inverse_mfp_per_m.len()
208 ),
209 ));
210 }
211 check_nonnegative("inverse mean free path", &parts.inverse_mfp_per_m)?;
212 let p = &parts.probability;
213 if p.len() < 2 {
214 return Err(invalid("probability axis", "needs at least 2 points"));
215 }
216 if p.iter().any(|x| !x.is_finite()) {
217 return Err(invalid("probability axis", "values must be finite"));
218 }
219 if p[0] != 0.0 || p[p.len() - 1] != 1.0 {
220 return Err(invalid(
221 "probability axis",
222 format!(
223 "must start at 0 and end at 1, got {} .. {}",
224 p[0],
225 p[p.len() - 1]
226 ),
227 ));
228 }
229 if let Some(w) = p.windows(2).find(|w| w[1] <= w[0]) {
230 return Err(invalid(
231 "probability axis",
232 format!("must be strictly increasing, got {} then {}", w[0], w[1]),
233 ));
234 }
235 if parts.quantiles.len() != n {
236 return Err(invalid(
237 "inverse-CDF rows",
238 format!(
239 "need one row per energy: {n} energies, {} rows",
240 parts.quantiles.len()
241 ),
242 ));
243 }
244 for (i, row) in parts.quantiles.iter().enumerate() {
245 let e = parts.energy_ev[i];
246 if parts.inverse_mfp_per_m[i] == 0.0 {
247 if !row.is_empty() {
248 return Err(invalid(
249 "inverse-CDF rows",
250 format!(
251 "row {i} ({e} eV) has zero inverse mean free path and must be empty"
252 ),
253 ));
254 }
255 continue;
256 }
257 if row.len() != p.len() {
258 return Err(invalid(
259 "inverse-CDF rows",
260 format!(
261 "row {i} ({e} eV) has {} entries; the probability axis has {}",
262 row.len(),
263 p.len()
264 ),
265 ));
266 }
267 let max = match parts.axis {
268 SamplingAxis::ElasticPolarAngle => std::f64::consts::PI,
269 SamplingAxis::InelasticEnergyLoss => e,
270 };
271 if let Some(x) = row
272 .iter()
273 .find(|x| !(x.is_finite() && **x >= 0.0 && **x <= max))
274 {
275 return Err(invalid(
276 "inverse-CDF rows",
277 format!(
278 "row {i} ({e} eV): {x} is outside [0, {max}] {}",
279 parts.axis.unit()
280 ),
281 ));
282 }
283 if let Some(w) = row.windows(2).find(|w| w[1] < w[0]) {
284 return Err(invalid(
285 "inverse-CDF rows",
286 format!(
287 "row {i} ({e} eV) must be non-decreasing, got {} then {}",
288 w[0], w[1]
289 ),
290 ));
291 }
292 }
293 Ok(Self {
294 parts: CrossSectionTableParts {
295 model,
296 material,
297 provenance,
298 ..parts
299 },
300 })
301 }
302
303 pub fn from_toml_str(text: &str) -> Result<Self> {
305 let raw: RawCacheIn = toml::from_str(text).map_err(parse_error)?;
309 Self::try_from(raw)
310 }
311
312 pub fn to_toml_string(&self) -> Result<String> {
314 toml::to_string(self).map_err(|e| ElectronDataError::Parse(e.to_string()))
315 }
316
317 pub fn from_toml_file(path: impl AsRef<Path>) -> Result<Self> {
319 Self::from_toml_str(&read_text(path.as_ref())?)
320 }
321
322 pub fn write_toml_file(&self, path: impl AsRef<Path>) -> Result<()> {
324 let path = path.as_ref();
325 std::fs::write(path, self.to_toml_string()?)
326 .map_err(|e| ElectronDataError::Io(format!("{}: {e}", path.display())))
327 }
328
329 pub fn format_version(&self) -> u32 {
331 CACHE_FORMAT_VERSION
332 }
333
334 pub fn model(&self) -> &str {
336 &self.parts.model
337 }
338
339 pub fn material(&self) -> &str {
341 &self.parts.material
342 }
343
344 pub fn provenance(&self) -> &str {
346 &self.parts.provenance
347 }
348
349 pub fn axis(&self) -> SamplingAxis {
351 self.parts.axis
352 }
353
354 pub fn energy_ev(&self) -> &[f64] {
356 &self.parts.energy_ev
357 }
358
359 pub fn inverse_mfp_per_m(&self) -> &[f64] {
361 &self.parts.inverse_mfp_per_m
362 }
363
364 pub fn probability(&self) -> &[f64] {
366 &self.parts.probability
367 }
368
369 pub fn quantiles(&self, i: usize) -> Option<&[f64]> {
372 self.parts
373 .quantiles
374 .get(i)
375 .filter(|r| !r.is_empty())
376 .map(Vec::as_slice)
377 }
378
379 pub fn inverse_cdf(&self, i: usize, u: f64) -> Result<f64> {
384 let n = self.parts.energy_ev.len();
385 if i >= n {
386 return Err(ElectronDataError::IndexOutOfRange { index: i, len: n });
387 }
388 check_in_range("cumulative probability", u, 0.0, 1.0)?;
389 let Some(row) = self.quantiles(i) else {
390 return Err(ElectronDataError::ZeroRate {
391 energy_ev: self.parts.energy_ev[i],
392 });
393 };
394 Ok(lerp_on_grid(&self.parts.probability, row, u))
395 }
396
397 pub fn parts(&self) -> &CrossSectionTableParts {
399 &self.parts
400 }
401}
402
403pub const SHELL_CHANNEL_FORMAT_VERSION: u32 = 1;
410
411#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
442#[serde(try_from = "RawShellChannelIn", into = "RawShellChannelOut")]
443pub struct ShellChannelTable {
444 z: u8,
445 subshell: Subshell,
446 binding_energy_ev: f64,
447 binding_provenance: String,
448 table: CrossSectionTable,
449}
450
451#[derive(Debug, Deserialize)]
452struct RawShellChannelIn {
453 format_version: Option<u32>,
454 z: Option<u8>,
455 subshell: Option<String>,
456 binding_energy_ev: Option<f64>,
457 binding_provenance: Option<String>,
458 table: Option<RawCacheIn>,
459 #[serde(flatten)]
460 unknown: BTreeMap<String, IgnoredAny>,
461}
462
463#[derive(Debug, Clone, Serialize)]
464struct RawShellChannelOut {
465 format_version: u32,
466 z: u8,
467 subshell: String,
468 binding_energy_ev: f64,
469 binding_provenance: String,
470 table: CrossSectionTable,
471}
472
473impl TryFrom<RawShellChannelIn> for ShellChannelTable {
474 type Error = ElectronDataError;
475 fn try_from(r: RawShellChannelIn) -> Result<Self> {
476 if r.format_version != Some(SHELL_CHANNEL_FORMAT_VERSION) {
477 return Err(ElectronDataError::UnsupportedVersion {
478 found: r.format_version,
479 supported: SHELL_CHANNEL_FORMAT_VERSION,
480 });
481 }
482 if let Some(k) = r.unknown.keys().next() {
483 return Err(ElectronDataError::Parse(format!(
484 "unknown field `{k}` in a version {SHELL_CHANNEL_FORMAT_VERSION} shell-channel table"
485 )));
486 }
487 let label = checked_text("subshell", r.subshell.as_deref())?;
488 let subshell =
489 Subshell::from_label(&label).ok_or_else(|| ElectronDataError::MalformedText {
490 field: "subshell",
491 reason: format!("{label:?} is not an ENDF subshell label"),
492 })?;
493 Self::new(
494 required("z", r.z)?,
495 subshell,
496 required("binding_energy_ev", r.binding_energy_ev)?,
497 r.binding_provenance.as_deref().unwrap_or_default(),
498 CrossSectionTable::try_from(required("table", r.table)?)?,
499 )
500 }
501}
502
503impl From<ShellChannelTable> for RawShellChannelOut {
504 fn from(t: ShellChannelTable) -> Self {
505 Self {
506 format_version: SHELL_CHANNEL_FORMAT_VERSION,
507 z: t.z,
508 subshell: t.subshell.label().to_string(),
509 binding_energy_ev: t.binding_energy_ev,
510 binding_provenance: t.binding_provenance,
511 table: t.table,
512 }
513 }
514}
515
516impl ShellChannelTable {
517 pub fn new(
521 z: u8,
522 subshell: Subshell,
523 binding_energy_ev: f64,
524 binding_provenance: &str,
525 table: CrossSectionTable,
526 ) -> Result<Self> {
527 let name = format!("Z = {z} {}", subshell.label());
528 if z == 0 {
529 return Err(invalid("inner-shell channel", "Z must be at least 1"));
530 }
531 if !(binding_energy_ev.is_finite() && binding_energy_ev > 0.0) {
532 return Err(invalid(
533 "inner-shell channel",
534 format!(
535 "{name}: binding energy must be finite and positive, got {binding_energy_ev} eV"
536 ),
537 ));
538 }
539 let binding_provenance = checked_text("provenance", Some(binding_provenance))?;
540 if table.axis() != SamplingAxis::InelasticEnergyLoss {
541 return Err(invalid(
542 "inner-shell channel",
543 format!(
544 "{name}: the table samples {:?}, not the energy loss",
545 table.axis()
546 ),
547 ));
548 }
549 for i in 0..table.energy_ev().len() {
550 if let Some(q) = table.quantiles(i) {
551 if let Some(w) = q.iter().find(|&&w| w < binding_energy_ev) {
552 return Err(invalid(
553 "inner-shell channel",
554 format!(
555 "{name}: row {i} ({} eV) has the loss {w} eV below the binding \
556 energy {binding_energy_ev} eV (a shell contributes only above \
557 its edge)",
558 table.energy_ev()[i]
559 ),
560 ));
561 }
562 }
563 }
564 Ok(Self {
565 z,
566 subshell,
567 binding_energy_ev,
568 binding_provenance,
569 table,
570 })
571 }
572
573 pub fn z(&self) -> u8 {
575 self.z
576 }
577
578 pub fn subshell(&self) -> Subshell {
580 self.subshell
581 }
582
583 pub fn binding_energy_ev(&self) -> f64 {
585 self.binding_energy_ev
586 }
587
588 pub fn binding_provenance(&self) -> &str {
590 &self.binding_provenance
591 }
592
593 pub fn table(&self) -> &CrossSectionTable {
595 &self.table
596 }
597
598 pub fn format_version(&self) -> u32 {
600 SHELL_CHANNEL_FORMAT_VERSION
601 }
602
603 pub fn from_toml_str(text: &str) -> Result<Self> {
605 let raw: RawShellChannelIn = toml::from_str(text).map_err(parse_error)?;
606 Self::try_from(raw)
607 }
608
609 pub fn to_toml_string(&self) -> Result<String> {
611 toml::to_string(self).map_err(|e| ElectronDataError::Parse(e.to_string()))
612 }
613
614 pub fn from_toml_file(path: impl AsRef<Path>) -> Result<Self> {
616 Self::from_toml_str(&read_text(path.as_ref())?)
617 }
618
619 pub fn write_toml_file(&self, path: impl AsRef<Path>) -> Result<()> {
621 let path = path.as_ref();
622 std::fs::write(path, self.to_toml_string()?)
623 .map_err(|e| ElectronDataError::Io(format!("{}: {e}", path.display())))
624 }
625}