Skip to main content

phasesmith_workflows/
tof_multibank.rs

1//! Atomic multi-bank fixed-cell TOF Le Bail extraction.
2//!
3//! Every bank owns its grid, instrument, observations, background, phase
4//! scales, and extracted intensities. Stable phase/reflection identities,
5//! Miller indices, and d-spacings form the shared fixed-cell contract.
6
7use std::collections::BTreeSet;
8use std::error::Error;
9use std::fmt::{Display, Formatter};
10
11use phasesmith_model::RecordId;
12
13use crate::tof_lebail::{
14    flatten_intensities, initialize_intensities, install_intensities, maximum_background_change,
15    normal_tof_stop, redistribute, refine_background, state_input,
16};
17use crate::{
18    DiagnosticValue, RefinementEventKind, RefinementLimits, RefinementRuntime, ResidualEvaluation,
19    ResidualOptions, RuntimeError, TerminationReason, TofChebyshevBackground, TofLeBailCalculation,
20    TofLeBailError, TofLeBailInput, TofLeBailOptions, TofLeBailPhase, TofReflectionIntensity,
21    calculate_tof_lebail_pattern, evaluate_tof_residuals,
22};
23
24/// One detector bank in an atomic multi-bank extraction.
25#[derive(Clone, Debug, PartialEq)]
26pub struct TofLeBailBank {
27    /// Stable bank identity used by application and persistence layers.
28    pub bank_id: RecordId,
29    /// Bank-local observations, instrument, phase scales/intensities, and background.
30    pub input: TofLeBailInput,
31}
32
33/// Two or more TOF banks sharing fixed phase/reflection geometry.
34#[derive(Clone, Debug, PartialEq)]
35pub struct TofMultiBankInput {
36    /// Banks in deterministic caller-owned order.
37    pub banks: Vec<TofLeBailBank>,
38}
39
40impl TofMultiBankInput {
41    /// Validate bank identities, local inputs, and shared fixed-cell topology.
42    ///
43    /// # Errors
44    ///
45    /// Returns [`TofMultiBankError`] for fewer than two banks, duplicate IDs,
46    /// invalid local state, or inconsistent phase/reflection geometry.
47    pub fn validate(&self) -> Result<(), TofMultiBankError> {
48        if self.banks.len() < 2 {
49            return Err(TofMultiBankError::TooFewBanks);
50        }
51        let mut bank_ids = BTreeSet::new();
52        for bank in &self.banks {
53            bank.input.validate()?;
54            if !bank_ids.insert(bank.bank_id.clone()) {
55                return Err(TofMultiBankError::DuplicateBankId {
56                    bank_id: bank.bank_id.clone(),
57                });
58            }
59        }
60        let shared = &self.banks[0].input.phases;
61        for bank in self.banks.iter().skip(1) {
62            validate_shared_topology(shared, &bank.input.phases, &bank.bank_id)?;
63        }
64        Ok(())
65    }
66}
67
68/// Aggregate residual metrics over every included sample in all banks.
69#[derive(Clone, Copy, Debug, PartialEq)]
70pub struct TofMultiBankMetrics {
71    /// Included observations summed over all banks.
72    pub included_samples: usize,
73    /// Sum of absolute residuals divided by summed absolute observations.
74    pub rp: f64,
75    /// Square root of joint chi-square divided by weighted observed square sum.
76    pub rwp: f64,
77    /// Sum of selected squared weighted residuals over every bank.
78    pub chi_square: f64,
79    /// Joint chi-square divided by included samples minus fitted local parameters.
80    pub reduced_chi_square: f64,
81}
82
83/// One bank's accepted display state.
84#[derive(Clone, Debug, PartialEq)]
85pub struct TofMultiBankResultBank {
86    /// Stable bank identity.
87    pub bank_id: RecordId,
88    /// Final calculation and fused derivative product.
89    pub calculation: TofLeBailCalculation,
90    /// Final bank-local residual arrays and metrics.
91    pub metrics: ResidualEvaluation,
92    /// Final bank-local phase scales and extracted intensities.
93    pub phases: Vec<TofLeBailPhase>,
94    /// Final bank-local refinable background.
95    pub background: Option<TofChebyshevBackground>,
96    /// Flattened stable reflection intensities for this bank.
97    pub intensities: Vec<TofReflectionIntensity>,
98}
99
100/// One bank's last accepted state inside a joint checkpoint.
101#[derive(Clone, Debug, PartialEq)]
102pub struct TofMultiBankCheckpointBank {
103    /// Stable bank identity.
104    pub bank_id: RecordId,
105    /// Accepted local phase/intensity state.
106    pub phases: Vec<TofLeBailPhase>,
107    /// Accepted local background state.
108    pub background: Option<TofChebyshevBackground>,
109}
110
111/// One atomically accepted multi-bank redistribution cycle.
112#[derive(Clone, Debug, PartialEq)]
113pub struct TofMultiBankIterationRecord {
114    /// One-based accepted cycle index.
115    pub iteration: usize,
116    /// Bank-local residual records in input order.
117    pub bank_metrics: Vec<ResidualEvaluation>,
118    /// Aggregate metrics over every bank.
119    pub metrics: TofMultiBankMetrics,
120    /// Largest relative intensity change over every bank/reflection.
121    pub maximum_relative_intensity_change: f64,
122    /// Largest absolute background-coefficient change over every bank.
123    pub maximum_absolute_background_change: f64,
124}
125
126/// Complete immutable continuation state for atomic multi-bank extraction.
127#[derive(Clone, Debug, PartialEq)]
128pub struct TofMultiBankCheckpoint {
129    /// Number of atomically accepted cycles.
130    pub completed_iterations: usize,
131    /// Accepted bank-local states in immutable request order.
132    pub banks: Vec<TofMultiBankCheckpointBank>,
133    /// Complete deterministic joint history.
134    pub history: Vec<TofMultiBankIterationRecord>,
135}
136
137impl TofMultiBankCheckpoint {
138    /// Revalidate this continuation against the exact multi-bank contract.
139    ///
140    /// # Errors
141    ///
142    /// Returns [`TofMultiBankError::InvalidCheckpoint`] for stale identities,
143    /// topology, background contracts, or non-contiguous history.
144    pub fn validate_for(
145        &self,
146        input: &TofMultiBankInput,
147        options: &TofLeBailOptions,
148    ) -> Result<(), TofMultiBankError> {
149        input.validate()?;
150        options.validate()?;
151        if self.completed_iterations != self.history.len()
152            || self.completed_iterations > options.cycles
153            || self.banks.len() != input.banks.len()
154        {
155            return Err(TofMultiBankError::InvalidCheckpoint(
156                "multi-bank checkpoint counts differ from the request",
157            ));
158        }
159        for (saved, original) in self.banks.iter().zip(&input.banks) {
160            if saved.bank_id != original.bank_id {
161                return Err(TofMultiBankError::InvalidCheckpoint(
162                    "multi-bank checkpoint bank order or identity changed",
163                ));
164            }
165            validate_checkpoint_bank(saved, original)?;
166        }
167        if self.history.iter().enumerate().any(|(index, record)| {
168            record.iteration != index + 1 || record.bank_metrics.len() != input.banks.len()
169        }) {
170            return Err(TofMultiBankError::InvalidCheckpoint(
171                "multi-bank checkpoint history is not contiguous and bank-aligned",
172            ));
173        }
174        Ok(())
175    }
176}
177
178/// Complete result from one atomic multi-bank fixed-cell extraction.
179#[derive(Clone, Debug, PartialEq)]
180pub struct TofMultiBankResult {
181    /// Final bank-local states and calculations in request order.
182    pub banks: Vec<TofMultiBankResultBank>,
183    /// Final aggregate residual metrics.
184    pub metrics: TofMultiBankMetrics,
185    /// Complete deterministic accepted history.
186    pub history: Vec<TofMultiBankIterationRecord>,
187    /// Stable bounded-runtime termination category.
188    pub termination_reason: TerminationReason,
189    /// Complete last accepted atomic state.
190    pub checkpoint: TofMultiBankCheckpoint,
191}
192
193/// Run atomic fixed-cell TOF Le Bail extraction over two or more banks.
194///
195/// # Errors
196///
197/// Returns [`TofMultiBankError`] for an invalid shared/local contract or a
198/// numerical/runtime failure.
199pub fn refine_tof_multibank(
200    input: &TofMultiBankInput,
201    options: &TofLeBailOptions,
202) -> Result<TofMultiBankResult, TofMultiBankError> {
203    input.validate()?;
204    options.validate()?;
205    let max_evaluations = options
206        .cycles
207        .checked_mul(3)
208        .ok_or(TofLeBailError::AllocationOverflow)?;
209    let limits = RefinementLimits::new(options.cycles, max_evaluations, None, 1)?;
210    let mut runtime = RefinementRuntime::new(limits, None)?;
211    refine_tof_multibank_with_runtime(input, options, None, &mut runtime)
212}
213
214/// Run atomic multi-bank extraction with host runtime and optional continuation.
215///
216/// A model evaluation covers all banks. Candidate state is accepted only after
217/// every bank completes the cycle, so cancellation or failure cannot expose a
218/// partially advanced bank set.
219///
220/// # Errors
221///
222/// Returns [`TofMultiBankError`] for contract, calculation, residual, or
223/// non-normal runtime failures.
224#[allow(clippy::too_many_lines)]
225pub fn refine_tof_multibank_with_runtime(
226    input: &TofMultiBankInput,
227    options: &TofLeBailOptions,
228    checkpoint: Option<&TofMultiBankCheckpoint>,
229    runtime: &mut RefinementRuntime<TofMultiBankCheckpoint>,
230) -> Result<TofMultiBankResult, TofMultiBankError> {
231    input.validate()?;
232    options.validate()?;
233    let restored = restore_state(input, options, checkpoint)?;
234    let mut states = restored.states;
235    let mut history = restored.history;
236    if let Some(checkpoint) = checkpoint {
237        runtime.resume_accepted(checkpoint.completed_iterations)?;
238    }
239    runtime.emit(
240        RefinementEventKind::Start,
241        "tof_multibank",
242        "multi-bank TOF Le Bail extraction started",
243        vec![(
244            "bank_count".to_owned(),
245            DiagnosticValue::Unsigned(input.banks.len() as u64),
246        )],
247    )?;
248    let mut accepted_calculations = None;
249    let mut termination = TerminationReason::MaxIterations;
250    for iteration in restored.first_iteration..=options.cycles {
251        if let Err(error) = runtime.begin_iteration(iteration) {
252            termination = normal_tof_stop(error)?;
253            break;
254        }
255        let candidate = match prepare_multibank_cycle(input, &states, options, 0, runtime)? {
256            MultiBankCycleOutcome::Candidate(candidate) => candidate,
257            MultiBankCycleOutcome::Stopped(reason) => {
258                termination = reason;
259                break;
260            }
261        };
262        let MultiBankCycleCandidate {
263            states: candidate_states,
264            calculations,
265            bank_metrics,
266            metrics,
267            maximum_relative_intensity_change,
268            maximum_absolute_background_change,
269        } = candidate;
270        states = candidate_states;
271        history.push(TofMultiBankIterationRecord {
272            iteration,
273            bank_metrics: bank_metrics.clone(),
274            metrics,
275            maximum_relative_intensity_change,
276            maximum_absolute_background_change,
277        });
278        accepted_calculations = Some(calculations);
279        let accepted_checkpoint = checkpoint_from_states(input, &states, &history);
280        runtime.accept_step(Some(&accepted_checkpoint))?;
281        runtime.emit(
282            RefinementEventKind::Iteration,
283            "tof_multibank_iteration",
284            "multi-bank TOF Le Bail cycle accepted",
285            vec![
286                ("rwp".to_owned(), DiagnosticValue::Float(metrics.rwp)),
287                (
288                    "maximum_relative_intensity_change".to_owned(),
289                    DiagnosticValue::Float(maximum_relative_intensity_change),
290                ),
291                (
292                    "maximum_absolute_background_change".to_owned(),
293                    DiagnosticValue::Float(maximum_absolute_background_change),
294                ),
295            ],
296        )?;
297    }
298    let calculations = match accepted_calculations {
299        Some(calculations) => calculations,
300        None => calculate_states(input, &states, options)?,
301    };
302    let bank_metrics = evaluate_bank_metrics(input, &states, &calculations, options)?;
303    let parameter_count = fitted_parameter_count(&states)?;
304    let metrics = aggregate_metrics(input, &calculations, options, parameter_count)?;
305    let checkpoint = checkpoint_from_states(input, &states, &history);
306    checkpoint.validate_for(input, options)?;
307    let banks = input
308        .banks
309        .iter()
310        .zip(states)
311        .zip(calculations)
312        .zip(bank_metrics)
313        .map(
314            |(((bank, state), calculation), metrics)| TofMultiBankResultBank {
315                bank_id: bank.bank_id.clone(),
316                calculation,
317                metrics,
318                intensities: reflection_intensities(&state.phases),
319                phases: state.phases,
320                background: state.background,
321            },
322        )
323        .collect();
324    runtime.emit(
325        RefinementEventKind::Termination,
326        "tof_multibank",
327        "multi-bank TOF Le Bail extraction terminated",
328        vec![(
329            "termination_reason".to_owned(),
330            DiagnosticValue::String(termination.as_str().to_owned()),
331        )],
332    )?;
333    Ok(TofMultiBankResult {
334        banks,
335        metrics,
336        history,
337        termination_reason: termination,
338        checkpoint,
339    })
340}
341
342#[derive(Clone)]
343pub(crate) struct AcceptedBankState {
344    pub(crate) phases: Vec<TofLeBailPhase>,
345    pub(crate) background: Option<TofChebyshevBackground>,
346}
347
348pub(crate) struct MultiBankCycleCandidate {
349    pub(crate) states: Vec<AcceptedBankState>,
350    pub(crate) calculations: Vec<TofLeBailCalculation>,
351    pub(crate) bank_metrics: Vec<ResidualEvaluation>,
352    pub(crate) metrics: TofMultiBankMetrics,
353    pub(crate) maximum_relative_intensity_change: f64,
354    pub(crate) maximum_absolute_background_change: f64,
355}
356
357pub(crate) enum MultiBankCycleOutcome {
358    Candidate(MultiBankCycleCandidate),
359    Stopped(TerminationReason),
360}
361
362pub(crate) fn prepare_multibank_cycle<C>(
363    input: &TofMultiBankInput,
364    states: &[AcceptedBankState],
365    options: &TofLeBailOptions,
366    shared_parameter_count: usize,
367    runtime: &mut RefinementRuntime<C>,
368) -> Result<MultiBankCycleOutcome, TofMultiBankError> {
369    if let Err(error) = runtime.begin_evaluation() {
370        return Ok(MultiBankCycleOutcome::Stopped(normal_tof_stop(error)?));
371    }
372    let current_calculations = calculate_states(input, states, options)?;
373    let mut candidate_states = Vec::with_capacity(states.len());
374    let mut maximum_relative_intensity_change = 0.0_f64;
375    for ((bank, state), calculation) in input.banks.iter().zip(states).zip(&current_calculations) {
376        let current = flatten_intensities(&state.phases);
377        let updated = redistribute(&bank.input.pattern, calculation, &current, options)?;
378        maximum_relative_intensity_change = maximum_relative_intensity_change.max(
379            updated
380                .iter()
381                .zip(&current)
382                .map(|(updated, current)| {
383                    (updated - current).abs() / current.abs().max(options.initial_intensity_floor)
384                })
385                .fold(0.0_f64, f64::max),
386        );
387        candidate_states.push(AcceptedBankState {
388            phases: install_intensities(&state.phases, &updated)?,
389            background: state.background.clone(),
390        });
391    }
392    if let Err(error) = runtime.begin_evaluation() {
393        return Ok(MultiBankCycleOutcome::Stopped(normal_tof_stop(error)?));
394    }
395    let intensity_calculations = calculate_states(input, &candidate_states, options)?;
396    let mut maximum_absolute_background_change = 0.0_f64;
397    for (((bank, previous), candidate), calculation) in input
398        .banks
399        .iter()
400        .zip(states)
401        .zip(&mut candidate_states)
402        .zip(&intensity_calculations)
403    {
404        let background = refine_background(
405            &bank.input.pattern,
406            &calculation.profile_y,
407            candidate.background.as_ref(),
408            options,
409        )?;
410        maximum_absolute_background_change = maximum_absolute_background_change.max(
411            maximum_background_change(previous.background.as_ref(), background.as_ref()),
412        );
413        candidate.background = background;
414    }
415    if let Err(error) = runtime.begin_evaluation() {
416        return Ok(MultiBankCycleOutcome::Stopped(normal_tof_stop(error)?));
417    }
418    let calculations = calculate_states(input, &candidate_states, options)?;
419    let bank_metrics = evaluate_bank_metrics(input, &candidate_states, &calculations, options)?;
420    let parameter_count = fitted_parameter_count(&candidate_states)?
421        .checked_add(shared_parameter_count)
422        .ok_or(TofLeBailError::AllocationOverflow)?;
423    let metrics = aggregate_metrics(input, &calculations, options, parameter_count)?;
424    Ok(MultiBankCycleOutcome::Candidate(MultiBankCycleCandidate {
425        states: candidate_states,
426        calculations,
427        bank_metrics,
428        metrics,
429        maximum_relative_intensity_change,
430        maximum_absolute_background_change,
431    }))
432}
433
434struct RestoredState {
435    states: Vec<AcceptedBankState>,
436    history: Vec<TofMultiBankIterationRecord>,
437    first_iteration: usize,
438}
439
440fn restore_state(
441    input: &TofMultiBankInput,
442    options: &TofLeBailOptions,
443    checkpoint: Option<&TofMultiBankCheckpoint>,
444) -> Result<RestoredState, TofMultiBankError> {
445    let Some(checkpoint) = checkpoint else {
446        let states = input
447            .banks
448            .iter()
449            .map(|bank| {
450                Ok(AcceptedBankState {
451                    phases: initialize_intensities(&bank.input, options)?,
452                    background: bank.input.background.clone(),
453                })
454            })
455            .collect::<Result<Vec<_>, TofMultiBankError>>()?;
456        return Ok(RestoredState {
457            states,
458            history: Vec::new(),
459            first_iteration: 1,
460        });
461    };
462    checkpoint.validate_for(input, options)?;
463    Ok(RestoredState {
464        states: checkpoint
465            .banks
466            .iter()
467            .map(|bank| AcceptedBankState {
468                phases: bank.phases.clone(),
469                background: bank.background.clone(),
470            })
471            .collect(),
472        history: checkpoint.history.clone(),
473        first_iteration: checkpoint
474            .completed_iterations
475            .checked_add(1)
476            .ok_or(TofLeBailError::AllocationOverflow)?,
477    })
478}
479
480pub(crate) fn calculate_states(
481    input: &TofMultiBankInput,
482    states: &[AcceptedBankState],
483    options: &TofLeBailOptions,
484) -> Result<Vec<TofLeBailCalculation>, TofMultiBankError> {
485    input
486        .banks
487        .iter()
488        .zip(states)
489        .map(|(bank, state)| {
490            let live = state_input(&bank.input, state.phases.clone(), state.background.clone())?;
491            Ok(calculate_tof_lebail_pattern(&live, options)?)
492        })
493        .collect()
494}
495
496pub(crate) fn evaluate_bank_metrics(
497    input: &TofMultiBankInput,
498    states: &[AcceptedBankState],
499    calculations: &[TofLeBailCalculation],
500    options: &TofLeBailOptions,
501) -> Result<Vec<ResidualEvaluation>, TofMultiBankError> {
502    input
503        .banks
504        .iter()
505        .zip(states)
506        .zip(calculations)
507        .map(|((bank, state), calculation)| {
508            Ok(evaluate_tof_residuals(
509                &bank.input.pattern,
510                &calculation.y,
511                ResidualOptions {
512                    use_uncertainty: options.use_uncertainty,
513                    parameter_count: flatten_intensities(&state.phases).len()
514                        + state
515                            .background
516                            .as_ref()
517                            .map_or(0, |background| background.coefficients().len()),
518                },
519            )
520            .map_err(TofLeBailError::from)?)
521        })
522        .collect()
523}
524
525pub(crate) fn fitted_parameter_count(
526    states: &[AcceptedBankState],
527) -> Result<usize, TofMultiBankError> {
528    states.iter().try_fold(0_usize, |total, state| {
529        total
530            .checked_add(flatten_intensities(&state.phases).len())
531            .and_then(|value| {
532                value.checked_add(
533                    state
534                        .background
535                        .as_ref()
536                        .map_or(0, |background| background.coefficients().len()),
537                )
538            })
539            .ok_or_else(|| TofLeBailError::AllocationOverflow.into())
540    })
541}
542
543pub(crate) fn aggregate_metrics(
544    input: &TofMultiBankInput,
545    calculations: &[TofLeBailCalculation],
546    options: &TofLeBailOptions,
547    parameter_count: usize,
548) -> Result<TofMultiBankMetrics, TofMultiBankError> {
549    let mut included_samples = 0_usize;
550    let mut absolute_residual_sum = 0.0;
551    let mut absolute_observed_sum = 0.0;
552    let mut weighted_observed_square_sum = 0.0;
553    let mut chi_square = 0.0;
554    for (bank, calculation) in input.banks.iter().zip(calculations) {
555        let pattern = &bank.input.pattern;
556        let observed = pattern
557            .observed_y
558            .as_ref()
559            .ok_or(TofLeBailError::MissingObservations)?;
560        let uncertainty = options
561            .use_uncertainty
562            .then_some(pattern.uncertainty.as_deref())
563            .flatten();
564        for sample in 0..pattern.sample_count() {
565            if pattern.mask.as_ref().is_some_and(|mask| !mask[sample]) {
566                continue;
567            }
568            included_samples = included_samples
569                .checked_add(1)
570                .ok_or(TofLeBailError::AllocationOverflow)?;
571            let residual = calculation.y[sample] - observed[sample];
572            absolute_residual_sum += residual.abs();
573            absolute_observed_sum += observed[sample].abs();
574            let (weighted_residual, weighted_observed) = uncertainty.map_or_else(
575                || (residual, observed[sample]),
576                |sigma| (residual / sigma[sample], observed[sample] / sigma[sample]),
577            );
578            chi_square += weighted_residual * weighted_residual;
579            weighted_observed_square_sum += weighted_observed * weighted_observed;
580        }
581    }
582    let degrees_of_freedom = included_samples.checked_sub(parameter_count);
583    Ok(TofMultiBankMetrics {
584        included_samples,
585        rp: if absolute_observed_sum == 0.0 {
586            f64::INFINITY
587        } else {
588            absolute_residual_sum / absolute_observed_sum
589        },
590        rwp: if weighted_observed_square_sum == 0.0 {
591            f64::INFINITY
592        } else {
593            (chi_square / weighted_observed_square_sum).sqrt()
594        },
595        chi_square,
596        reduced_chi_square: match degrees_of_freedom {
597            Some(degrees) if degrees > 0 => chi_square / count_as_f64(degrees),
598            _ => f64::INFINITY,
599        },
600    })
601}
602
603fn checkpoint_from_states(
604    input: &TofMultiBankInput,
605    states: &[AcceptedBankState],
606    history: &[TofMultiBankIterationRecord],
607) -> TofMultiBankCheckpoint {
608    TofMultiBankCheckpoint {
609        completed_iterations: history.len(),
610        banks: input
611            .banks
612            .iter()
613            .zip(states)
614            .map(|(bank, state)| TofMultiBankCheckpointBank {
615                bank_id: bank.bank_id.clone(),
616                phases: state.phases.clone(),
617                background: state.background.clone(),
618            })
619            .collect(),
620        history: history.to_vec(),
621    }
622}
623
624fn validate_shared_topology(
625    shared: &[TofLeBailPhase],
626    local: &[TofLeBailPhase],
627    bank_id: &RecordId,
628) -> Result<(), TofMultiBankError> {
629    if shared.len() != local.len()
630        || shared.iter().zip(local).any(|(shared, local)| {
631            shared.phase_id() != local.phase_id()
632                || shared.name() != local.name()
633                || shared.reflection_ids() != local.reflection_ids()
634                || shared.hkl() != local.hkl()
635                || shared.d_spacing_angstrom() != local.d_spacing_angstrom()
636        })
637    {
638        return Err(TofMultiBankError::SharedTopologyMismatch {
639            bank_id: bank_id.clone(),
640        });
641    }
642    Ok(())
643}
644
645fn validate_checkpoint_bank(
646    saved: &TofMultiBankCheckpointBank,
647    original: &TofLeBailBank,
648) -> Result<(), TofMultiBankError> {
649    validate_shared_topology(&original.input.phases, &saved.phases, &saved.bank_id)?;
650    if saved
651        .phases
652        .iter()
653        .zip(&original.input.phases)
654        .any(|(saved, original)| saved.scale().to_bits() != original.scale().to_bits())
655    {
656        return Err(TofMultiBankError::InvalidCheckpoint(
657            "multi-bank checkpoint phase scale changed",
658        ));
659    }
660    match (&saved.background, &original.input.background) {
661        (None, None) => Ok(()),
662        (Some(saved), Some(original))
663            if saved.background_id() == original.background_id()
664                && saved
665                    .domain_us()
666                    .iter()
667                    .zip(original.domain_us())
668                    .all(|(saved, original)| saved.to_bits() == original.to_bits())
669                && saved.coefficients().len() == original.coefficients().len() =>
670        {
671            Ok(())
672        }
673        _ => Err(TofMultiBankError::InvalidCheckpoint(
674            "multi-bank checkpoint background contract changed",
675        )),
676    }
677}
678
679pub(crate) fn reflection_intensities(phases: &[TofLeBailPhase]) -> Vec<TofReflectionIntensity> {
680    phases
681        .iter()
682        .flat_map(|phase| {
683            phase
684                .reflection_ids()
685                .iter()
686                .zip(phase.integrated_intensity())
687                .map(
688                    |(reflection_id, integrated_intensity)| TofReflectionIntensity {
689                        phase_id: phase.phase_id().as_str().to_owned(),
690                        reflection_id: reflection_id.clone(),
691                        integrated_intensity: *integrated_intensity,
692                    },
693                )
694        })
695        .collect()
696}
697
698#[allow(clippy::cast_precision_loss)]
699fn count_as_f64(value: usize) -> f64 {
700    value as f64
701}
702
703/// Invalid multi-bank TOF request, shared contract, or numerical state.
704#[derive(Debug)]
705pub enum TofMultiBankError {
706    /// At least two banks are required.
707    TooFewBanks,
708    /// A stable bank identity is repeated.
709    DuplicateBankId {
710        /// Repeated bank identity.
711        bank_id: RecordId,
712    },
713    /// One bank disagrees with shared phase/reflection geometry.
714    SharedTopologyMismatch {
715        /// Inconsistent bank identity.
716        bank_id: RecordId,
717    },
718    /// One bank's single-pattern workflow failed.
719    LeBail(TofLeBailError),
720    /// A continuation state is stale or malformed.
721    InvalidCheckpoint(&'static str),
722    /// Runtime control or checkpoint delivery failed.
723    Runtime(RuntimeError),
724}
725
726impl Display for TofMultiBankError {
727    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
728        match self {
729            Self::TooFewBanks => formatter.write_str("multi-bank TOF requires at least two banks"),
730            Self::DuplicateBankId { bank_id } => write!(formatter, "duplicate TOF bank {bank_id}"),
731            Self::SharedTopologyMismatch { bank_id } => write!(
732                formatter,
733                "TOF bank {bank_id} differs from shared phase/reflection geometry"
734            ),
735            Self::LeBail(error) => Display::fmt(error, formatter),
736            Self::InvalidCheckpoint(message) => formatter.write_str(message),
737            Self::Runtime(error) => Display::fmt(error, formatter),
738        }
739    }
740}
741
742impl Error for TofMultiBankError {
743    fn source(&self) -> Option<&(dyn Error + 'static)> {
744        match self {
745            Self::LeBail(error) => Some(error),
746            Self::Runtime(error) => Some(error),
747            _ => None,
748        }
749    }
750}
751
752impl From<TofLeBailError> for TofMultiBankError {
753    fn from(value: TofLeBailError) -> Self {
754        Self::LeBail(value)
755    }
756}
757
758impl From<RuntimeError> for TofMultiBankError {
759    fn from(value: RuntimeError) -> Self {
760        Self::Runtime(value)
761    }
762}