1use 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#[derive(Clone, Debug, PartialEq)]
26pub struct TofLeBailBank {
27 pub bank_id: RecordId,
29 pub input: TofLeBailInput,
31}
32
33#[derive(Clone, Debug, PartialEq)]
35pub struct TofMultiBankInput {
36 pub banks: Vec<TofLeBailBank>,
38}
39
40impl TofMultiBankInput {
41 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#[derive(Clone, Copy, Debug, PartialEq)]
70pub struct TofMultiBankMetrics {
71 pub included_samples: usize,
73 pub rp: f64,
75 pub rwp: f64,
77 pub chi_square: f64,
79 pub reduced_chi_square: f64,
81}
82
83#[derive(Clone, Debug, PartialEq)]
85pub struct TofMultiBankResultBank {
86 pub bank_id: RecordId,
88 pub calculation: TofLeBailCalculation,
90 pub metrics: ResidualEvaluation,
92 pub phases: Vec<TofLeBailPhase>,
94 pub background: Option<TofChebyshevBackground>,
96 pub intensities: Vec<TofReflectionIntensity>,
98}
99
100#[derive(Clone, Debug, PartialEq)]
102pub struct TofMultiBankCheckpointBank {
103 pub bank_id: RecordId,
105 pub phases: Vec<TofLeBailPhase>,
107 pub background: Option<TofChebyshevBackground>,
109}
110
111#[derive(Clone, Debug, PartialEq)]
113pub struct TofMultiBankIterationRecord {
114 pub iteration: usize,
116 pub bank_metrics: Vec<ResidualEvaluation>,
118 pub metrics: TofMultiBankMetrics,
120 pub maximum_relative_intensity_change: f64,
122 pub maximum_absolute_background_change: f64,
124}
125
126#[derive(Clone, Debug, PartialEq)]
128pub struct TofMultiBankCheckpoint {
129 pub completed_iterations: usize,
131 pub banks: Vec<TofMultiBankCheckpointBank>,
133 pub history: Vec<TofMultiBankIterationRecord>,
135}
136
137impl TofMultiBankCheckpoint {
138 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#[derive(Clone, Debug, PartialEq)]
180pub struct TofMultiBankResult {
181 pub banks: Vec<TofMultiBankResultBank>,
183 pub metrics: TofMultiBankMetrics,
185 pub history: Vec<TofMultiBankIterationRecord>,
187 pub termination_reason: TerminationReason,
189 pub checkpoint: TofMultiBankCheckpoint,
191}
192
193pub 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#[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(¤t_calculations) {
376 let current = flatten_intensities(&state.phases);
377 let updated = redistribute(&bank.input.pattern, calculation, ¤t, options)?;
378 maximum_relative_intensity_change = maximum_relative_intensity_change.max(
379 updated
380 .iter()
381 .zip(¤t)
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#[derive(Debug)]
705pub enum TofMultiBankError {
706 TooFewBanks,
708 DuplicateBankId {
710 bank_id: RecordId,
712 },
713 SharedTopologyMismatch {
715 bank_id: RecordId,
717 },
718 LeBail(TofLeBailError),
720 InvalidCheckpoint(&'static str),
722 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}