Skip to main content

phasesmith_workflows/
tof_structural_multibank.rs

1//! Guarded multi-bank structural neutron TOF calculation and objective products.
2
3use std::collections::BTreeSet;
4use std::error::Error;
5use std::fmt::{Display, Formatter};
6
7use phasesmith_core::{
8    TOF_GLOBAL_PARAMETER_COUNT, TofBankGeometry, TofError, TofInstrument, TofInstrumentParameter,
9};
10use phasesmith_crystallography::{IntegratedIntensityCorrectionModel, P1ParameterLayout};
11use phasesmith_engine::{
12    BuiltInScatteringModel, StructuralTofError, StructuralTofInputView, StructuralTofResult,
13    calculate_structural_tof_pattern_jvp_with_context,
14    calculate_structural_tof_pattern_vjp_with_context,
15    calculate_structural_tof_pattern_with_context,
16};
17use phasesmith_execution::ExecutionPolicy;
18use phasesmith_model::{DomainError, RecordId, TofPatternRecord};
19
20use crate::{
21    LatticeBounds, ParameterBounds, ParameterError, ParameterKey, ParameterSet, ParameterSpec,
22    ResidualError, ResidualEvaluation, ResidualOptions, RietveldError, RietveldParameterError,
23    RietveldPhase, RietveldStructuralLayout, RietveldStructuralSelection, TofChebyshevBackground,
24    TofInstrumentParameterBound, TofLeBailError, evaluate_tof_residuals,
25};
26
27/// One observed bank and every bank-local structural-TOF quantity.
28#[derive(Clone, Debug, PartialEq)]
29pub struct StructuralTofBank {
30    /// Stable bank identity used to namespace all local parameters.
31    pub bank_id: RecordId,
32    /// Microsecond observations, uncertainties, mask, and fixed background.
33    pub pattern: TofPatternRecord,
34    /// Bank-local TOF calibration and profile coefficients.
35    pub instrument: TofInstrument,
36    /// Fixed bank scattering angle.
37    pub geometry: TofBankGeometry,
38    /// Explicit neutral or fixed-angle neutron TOF correction.
39    pub correction_model: IntegratedIntensityCorrectionModel,
40    /// Bank-local structural intensity scale.
41    pub scale: f64,
42    /// Closed physical scale bounds.
43    pub scale_bounds: ParameterBounds,
44    /// Whether the bank scale participates in objective parameter products.
45    pub refine_scale: bool,
46    /// Optional additive refinable background on top of the fixed pattern background.
47    pub background: Option<TofChebyshevBackground>,
48    /// Whether every coefficient of `background` participates in products.
49    pub refine_background: bool,
50    /// Selected bank-local instrument coefficients and their physical bounds.
51    pub instrument_bounds: Vec<TofInstrumentParameterBound>,
52}
53
54/// Shared structural phase plus one or more explicit TOF banks.
55#[derive(Clone, Debug, PartialEq)]
56pub struct StructuralTofMultiBankInput {
57    /// One fixed-topology neutron structural phase; its scale/correction are neutral placeholders.
58    pub phase: RietveldPhase,
59    /// Shared structural parameter families, with `phase_scale` required false.
60    pub structural_selection: RietveldStructuralSelection,
61    /// Required setting-aware bounds when lattice refinement is selected.
62    pub lattice_bounds: Option<LatticeBounds>,
63    /// Ordered bank-local observations and models.
64    pub banks: Vec<StructuralTofBank>,
65    /// Inclusive finite profile support radius in total-FWHM units.
66    pub support_fwhm: f64,
67    /// Exponential quadrature truncation exponent.
68    pub tail_log: f64,
69    /// Use supplied one-sigma uncertainties in the summed objective.
70    pub use_uncertainty: bool,
71    /// Bounded execution policy shared by all bank calculations.
72    pub execution: ExecutionPolicy,
73}
74
75impl StructuralTofMultiBankInput {
76    /// Validate the complete shared/local structural TOF contract.
77    ///
78    /// # Errors
79    ///
80    /// Returns [`StructuralTofMultiBankError`] for invalid physical state,
81    /// identity, parameter selection, or observation arrays.
82    pub fn validate(&self) -> Result<(), StructuralTofMultiBankError> {
83        self.phase.validate()?;
84        let definition = self.phase.definition();
85        if definition.scattering_model != BuiltInScatteringModel::NeutronNuclear
86            || !definition.scattering_real_offset.is_empty()
87            || !definition.scattering_imag_offset.is_empty()
88        {
89            return Err(StructuralTofMultiBankError::InvalidPhaseContract(
90                "structural TOF requires built-in neutron scattering without X-ray offsets",
91            ));
92        }
93        if definition.correction_model != IntegratedIntensityCorrectionModel::Neutral
94            || definition.scale.to_bits() != 1.0_f64.to_bits()
95        {
96            return Err(StructuralTofMultiBankError::InvalidPhaseContract(
97                "the shared phase must use neutral correction and unit placeholder scale",
98            ));
99        }
100        if self.phase.sample_physics().is_some()
101            || self.phase.reflection_domain().is_some()
102            || self.phase.contributions()
103                != &phasesmith_core::OwnedCwContributions::neutral(definition.hkl.len())
104        {
105            return Err(StructuralTofMultiBankError::InvalidPhaseContract(
106                "CW sample physics and dynamic topology are not part of structural TOF",
107            ));
108        }
109        if self.structural_selection.phase_scale {
110            return Err(StructuralTofMultiBankError::InvalidPhaseContract(
111                "structural TOF phase scale is bank-local and cannot be shared",
112            ));
113        }
114        if !self.support_fwhm.is_finite()
115            || self.support_fwhm <= 0.0
116            || !self.tail_log.is_finite()
117            || self.tail_log <= 0.0
118        {
119            return Err(StructuralTofMultiBankError::InvalidSupport);
120        }
121        if self.banks.is_empty() {
122            return Err(StructuralTofMultiBankError::TooFewBanks);
123        }
124        if self
125            .banks
126            .iter()
127            .map(|bank| &bank.bank_id)
128            .collect::<BTreeSet<_>>()
129            .len()
130            != self.banks.len()
131        {
132            return Err(StructuralTofMultiBankError::DuplicateBankId);
133        }
134        for bank in &self.banks {
135            validate_bank(bank)?;
136        }
137        RietveldStructuralLayout::new(
138            std::slice::from_ref(&self.phase),
139            self.structural_selection,
140            std::slice::from_ref(&self.lattice_bounds),
141        )?;
142        Ok(())
143    }
144}
145
146#[derive(Clone, Debug, PartialEq)]
147struct BankParameterMapping {
148    bank_id: RecordId,
149    pattern: TofPatternRecord,
150    geometry: TofBankGeometry,
151    correction_model: IntegratedIntensityCorrectionModel,
152    scale_bounds: ParameterBounds,
153    scale: Option<usize>,
154    instrument_bounds: Vec<TofInstrumentParameterBound>,
155    instrument: Vec<(TofInstrumentParameter, usize)>,
156    background_contract: Option<(RecordId, [f64; 2], usize)>,
157    background: Vec<usize>,
158}
159
160/// Stable shared/local physical parameter packing for structural TOF banks.
161#[derive(Clone, Debug, PartialEq)]
162pub struct StructuralTofMultiBankLayout {
163    parameters: ParameterSet,
164    structural: RietveldStructuralLayout,
165    structural_count: usize,
166    structural_selection: RietveldStructuralSelection,
167    lattice_bounds: Option<LatticeBounds>,
168    reflection_ids: Vec<String>,
169    support_fwhm: f64,
170    tail_log: f64,
171    use_uncertainty: bool,
172    execution: ExecutionPolicy,
173    banks: Vec<BankParameterMapping>,
174}
175
176impl StructuralTofMultiBankLayout {
177    /// Build the symmetry-aware shared and namespaced bank-local parameter set.
178    ///
179    /// # Errors
180    ///
181    /// Returns [`StructuralTofMultiBankError`] for an invalid request or scalar.
182    pub fn new(input: &StructuralTofMultiBankInput) -> Result<Self, StructuralTofMultiBankError> {
183        input.validate()?;
184        let structural = RietveldStructuralLayout::new(
185            std::slice::from_ref(&input.phase),
186            input.structural_selection,
187            std::slice::from_ref(&input.lattice_bounds),
188        )?;
189        let mut specs = structural.parameters().specs().to_vec();
190        let structural_count = specs.len();
191        let mut banks = Vec::with_capacity(input.banks.len());
192        for bank in &input.banks {
193            let owner = bank.bank_id.as_str();
194            let scale = if bank.refine_scale {
195                let index = specs.len();
196                specs.push(ParameterSpec::new(
197                    ParameterKey::new("tof_scale", owner, "scale")?,
198                    bank.scale,
199                    "relative",
200                    bank.scale_bounds,
201                    bank.scale.abs().max(1.0),
202                    true,
203                )?);
204                Some(index)
205            } else {
206                None
207            };
208            let instrument_values = bank.instrument.values();
209            let mut instrument = Vec::with_capacity(bank.instrument_bounds.len());
210            for bound in &bank.instrument_bounds {
211                let index = specs.len();
212                let value = instrument_values[bound.parameter.index()];
213                let half_span = 0.5 * (bound.upper - bound.lower);
214                specs.push(ParameterSpec::new(
215                    ParameterKey::new("tof_instrument", owner, bound.parameter.name())?,
216                    value,
217                    instrument_unit(bound.parameter),
218                    ParameterBounds::new(bound.lower, bound.upper)?,
219                    value.abs().max(half_span).max(f64::EPSILON.sqrt()),
220                    true,
221                )?);
222                instrument.push((bound.parameter, index));
223            }
224            let mut background = Vec::new();
225            if bank.refine_background {
226                let model = bank.background.as_ref().ok_or(
227                    StructuralTofMultiBankError::InvalidBankContract(
228                        "refine_background requires a background model",
229                    ),
230                )?;
231                for (order, value) in model.coefficients().iter().copied().enumerate() {
232                    let index = specs.len();
233                    specs.push(ParameterSpec::new(
234                        ParameterKey::new("tof_background", owner, format!("coefficient_{order}"))?,
235                        value,
236                        "intensity",
237                        ParameterBounds::default(),
238                        value.abs().max(1.0),
239                        true,
240                    )?);
241                    background.push(index);
242                }
243            }
244            banks.push(BankParameterMapping {
245                bank_id: bank.bank_id.clone(),
246                pattern: bank.pattern.clone(),
247                geometry: bank.geometry,
248                correction_model: bank.correction_model,
249                scale_bounds: bank.scale_bounds,
250                scale,
251                instrument_bounds: bank.instrument_bounds.clone(),
252                instrument,
253                background_contract: bank.background.as_ref().map(|model| {
254                    (
255                        model.background_id().clone(),
256                        model.domain_us(),
257                        model.coefficients().len(),
258                    )
259                }),
260                background,
261            });
262        }
263        Ok(Self {
264            parameters: ParameterSet::new(specs)?,
265            structural,
266            structural_count,
267            structural_selection: input.structural_selection,
268            lattice_bounds: input.lattice_bounds.clone(),
269            reflection_ids: input.phase.reflection_ids().to_vec(),
270            support_fwhm: input.support_fwhm,
271            tail_log: input.tail_log,
272            use_uncertainty: input.use_uncertainty,
273            execution: input.execution.clone(),
274            banks,
275        })
276    }
277
278    /// Borrow stable physical parameters in solver order.
279    #[must_use]
280    pub const fn parameters(&self) -> &ParameterSet {
281        &self.parameters
282    }
283
284    /// Install one absolute physical state into cloned shared/local records.
285    ///
286    /// # Errors
287    ///
288    /// Returns [`StructuralTofMultiBankError`] for a stale contract, wrong
289    /// value count, violated bound, or invalid resulting physical model.
290    pub fn apply_values(
291        &self,
292        input: &StructuralTofMultiBankInput,
293        values: &[f64],
294    ) -> Result<StructuralTofMultiBankInput, StructuralTofMultiBankError> {
295        let current = self
296            .parameters
297            .specs()
298            .iter()
299            .map(ParameterSpec::value)
300            .collect::<Vec<_>>();
301        self.apply_value_change(input, &current, values)
302    }
303
304    /// Install a change between two absolute solver states.
305    ///
306    /// Symmetry-constrained site coordinates are local tangent coordinates,
307    /// so their difference is applied to the current structure. Every other
308    /// selected value is installed absolutely.
309    ///
310    /// # Errors
311    ///
312    /// Returns [`StructuralTofMultiBankError`] for a stale contract, wrong
313    /// value count, violated bound, or invalid resulting physical model.
314    pub fn apply_value_change(
315        &self,
316        input: &StructuralTofMultiBankInput,
317        current_values: &[f64],
318        values: &[f64],
319    ) -> Result<StructuralTofMultiBankInput, StructuralTofMultiBankError> {
320        self.validate_contract(input)?;
321        if current_values.len() != self.parameters.specs().len()
322            || values.len() != self.parameters.specs().len()
323            || current_values.iter().any(|value| !value.is_finite())
324        {
325            return Err(StructuralTofMultiBankError::ParameterLengthMismatch);
326        }
327        for (spec, value) in self.parameters.specs().iter().zip(values) {
328            if !value.is_finite() || !spec.bounds().contains(*value) {
329                return Err(StructuralTofMultiBankError::Parameter(
330                    ParameterError::ValueOutsideBounds {
331                        key: spec.key().clone(),
332                        value: *value,
333                    },
334                ));
335            }
336        }
337        let phase = self
338            .structural
339            .apply_value_change(
340                std::slice::from_ref(&input.phase),
341                &current_values[..self.structural_count],
342                &values[..self.structural_count],
343            )?
344            .into_iter()
345            .next()
346            .ok_or(StructuralTofMultiBankError::InternalInvariant)?;
347        let mut result = input.clone();
348        result.phase = phase;
349        for ((bank, mapping), original) in
350            result.banks.iter_mut().zip(&self.banks).zip(&input.banks)
351        {
352            if let Some(index) = mapping.scale {
353                bank.scale = values[index];
354            }
355            let mut instrument_values = original.instrument.values();
356            for &(parameter, index) in &mapping.instrument {
357                instrument_values[parameter.index()] = values[index];
358            }
359            bank.instrument = TofInstrument::from_values(instrument_values)?;
360            if !mapping.background.is_empty() {
361                let coefficients = mapping
362                    .background
363                    .iter()
364                    .map(|index| values[*index])
365                    .collect();
366                bank.background = Some(
367                    original
368                        .background
369                        .as_ref()
370                        .ok_or(StructuralTofMultiBankError::InternalInvariant)?
371                        .with_coefficients(coefficients)?,
372                );
373            }
374        }
375        result.validate()?;
376        Ok(result)
377    }
378
379    fn validate_contract(
380        &self,
381        input: &StructuralTofMultiBankInput,
382    ) -> Result<(), StructuralTofMultiBankError> {
383        input.validate()?;
384        self.structural
385            .validate_phases(std::slice::from_ref(&input.phase))?;
386        if input.structural_selection != self.structural_selection
387            || input.lattice_bounds != self.lattice_bounds
388            || input.phase.reflection_ids() != self.reflection_ids
389            || input.support_fwhm.to_bits() != self.support_fwhm.to_bits()
390            || input.tail_log.to_bits() != self.tail_log.to_bits()
391            || input.use_uncertainty != self.use_uncertainty
392            || input.execution != self.execution
393            || input.banks.len() != self.banks.len()
394        {
395            return Err(StructuralTofMultiBankError::BankContractMismatch);
396        }
397        for (bank, mapping) in input.banks.iter().zip(&self.banks) {
398            let background_contract = bank.background.as_ref().map(|model| {
399                (
400                    model.background_id().clone(),
401                    model.domain_us(),
402                    model.coefficients().len(),
403                )
404            });
405            if bank.bank_id != mapping.bank_id
406                || bank.pattern != mapping.pattern
407                || bank.geometry != mapping.geometry
408                || bank.correction_model != mapping.correction_model
409                || bank.scale_bounds != mapping.scale_bounds
410                || bank.refine_scale != mapping.scale.is_some()
411                || bank.instrument_bounds != mapping.instrument_bounds
412                || bank.refine_background == mapping.background.is_empty()
413                || background_contract != mapping.background_contract
414            {
415                return Err(StructuralTofMultiBankError::BankContractMismatch);
416            }
417        }
418        Ok(())
419    }
420
421    fn native_tangent(
422        &self,
423        bank_index: usize,
424        direction: &[f64],
425        native_count: usize,
426    ) -> Result<Vec<f64>, StructuralTofMultiBankError> {
427        if direction.len() != self.parameters.specs().len() {
428            return Err(StructuralTofMultiBankError::ParameterLengthMismatch);
429        }
430        let mut tangent = self
431            .structural
432            .native_tangents(&direction[..self.structural_count])?
433            .into_iter()
434            .next()
435            .ok_or(StructuralTofMultiBankError::InternalInvariant)?;
436        if tangent.len() != native_count {
437            return Err(StructuralTofMultiBankError::InternalInvariant);
438        }
439        if let Some(index) = self.banks[bank_index].scale {
440            tangent[native_count - 1] = direction[index];
441        }
442        Ok(tangent)
443    }
444
445    fn scatter_native_gradient(
446        &self,
447        bank_index: usize,
448        native: &[f64],
449        output: &mut [f64],
450    ) -> Result<(), StructuralTofMultiBankError> {
451        let shared = self.structural.project_native_gradients(&[native])?;
452        for (target, value) in output[..self.structural_count].iter_mut().zip(shared) {
453            *target += value;
454        }
455        if let Some(index) = self.banks[bank_index].scale {
456            output[index] += native
457                .last()
458                .copied()
459                .ok_or(StructuralTofMultiBankError::InternalInvariant)?;
460        }
461        Ok(())
462    }
463}
464
465/// One bank's display-ready structural TOF calculation.
466#[derive(Clone, Debug, PartialEq)]
467pub struct StructuralTofBankCalculation {
468    /// Stable bank identity.
469    pub bank_id: RecordId,
470    /// Complete calculated values including fixed and refinable background.
471    pub y: Vec<f64>,
472    /// Structural finite-support profile contribution.
473    pub profile_y: Vec<f64>,
474    /// Fixed plus refinable background contribution.
475    pub background_y: Vec<f64>,
476    /// Structural reflection and fused profile intermediates.
477    pub structural: StructuralTofResult,
478    /// Per-bank residual metrics under the objective weighting contract.
479    pub metrics: ResidualEvaluation,
480}
481
482/// Ordered multi-bank structural TOF calculation.
483#[derive(Clone, Debug, PartialEq)]
484pub struct StructuralTofMultiBankCalculation {
485    /// Bank calculations in request order.
486    pub banks: Vec<StructuralTofBankCalculation>,
487    /// Half the summed weighted squared residual over all banks.
488    pub objective: f64,
489}
490
491/// One bank's profile and joint-parameter directional derivative.
492#[derive(Clone, Debug, PartialEq)]
493pub struct StructuralTofMultiBankProduct {
494    /// Stable bank identity.
495    pub bank_id: RecordId,
496    /// Complete calculated values at the prepared state.
497    pub y: Vec<f64>,
498    /// Directional derivative of calculated values.
499    pub derivative: Vec<f64>,
500}
501
502/// Complete objective values and gradient in joint physical order.
503#[derive(Clone, Debug, PartialEq)]
504pub struct StructuralTofMultiBankGradient {
505    /// Accepted-state calculations and residual metrics.
506    pub calculation: StructuralTofMultiBankCalculation,
507    /// Gradient of half the summed weighted residual square.
508    pub gradient: Vec<f64>,
509}
510
511/// Prepared matrix-free structural TOF sum over all banks.
512pub struct PreparedStructuralTofMultiBankObjective {
513    input: StructuralTofMultiBankInput,
514    layout: StructuralTofMultiBankLayout,
515    background_bases: Vec<Option<crate::TofChebyshevBasis>>,
516}
517
518impl PreparedStructuralTofMultiBankObjective {
519    /// Prepare stable parameter mappings and bank-local background bases.
520    ///
521    /// # Errors
522    ///
523    /// Returns [`StructuralTofMultiBankError`] for invalid request state.
524    pub fn new(input: StructuralTofMultiBankInput) -> Result<Self, StructuralTofMultiBankError> {
525        let layout = StructuralTofMultiBankLayout::new(&input)?;
526        let background_bases = input
527            .banks
528            .iter()
529            .map(|bank| {
530                bank.background
531                    .as_ref()
532                    .map(|model| model.basis(&bank.pattern.tof_us))
533                    .transpose()
534            })
535            .collect::<Result<Vec<_>, _>>()?;
536        Ok(Self {
537            input,
538            layout,
539            background_bases,
540        })
541    }
542
543    /// Borrow the complete prepared request.
544    #[must_use]
545    pub const fn input(&self) -> &StructuralTofMultiBankInput {
546        &self.input
547    }
548
549    /// Borrow the stable shared/local parameter layout.
550    #[must_use]
551    pub const fn layout(&self) -> &StructuralTofMultiBankLayout {
552        &self.layout
553    }
554
555    /// Calculate all banks and the summed objective.
556    ///
557    /// # Errors
558    ///
559    /// Returns [`StructuralTofMultiBankError`] for a numerical or residual failure.
560    pub fn calculate(
561        &self,
562    ) -> Result<StructuralTofMultiBankCalculation, StructuralTofMultiBankError> {
563        let mut banks = Vec::with_capacity(self.input.banks.len());
564        let mut objective = 0.0;
565        for (index, bank) in self.input.banks.iter().enumerate() {
566            let structural = calculate_bank(&self.input, bank)?;
567            let profile_y = structural.accumulation.y.clone();
568            let background_y = background_values(bank, self.background_bases[index].as_ref())?;
569            let y = profile_y
570                .iter()
571                .zip(&background_y)
572                .map(|(profile, background)| profile + background)
573                .collect::<Vec<_>>();
574            let metrics = evaluate_tof_residuals(
575                &bank.pattern,
576                &y,
577                ResidualOptions {
578                    use_uncertainty: self.input.use_uncertainty,
579                    parameter_count: self.layout.parameters.specs().len(),
580                },
581            )?;
582            objective += 0.5 * metrics.chi_square;
583            banks.push(StructuralTofBankCalculation {
584                bank_id: bank.bank_id.clone(),
585                y,
586                profile_y,
587                background_y,
588                structural,
589                metrics,
590            });
591        }
592        Ok(StructuralTofMultiBankCalculation { banks, objective })
593    }
594
595    /// Apply every bank Jacobian to one joint physical direction.
596    ///
597    /// # Errors
598    ///
599    /// Returns [`StructuralTofMultiBankError`] for an invalid direction or product.
600    pub fn jvp(
601        &self,
602        direction: &[f64],
603    ) -> Result<Vec<StructuralTofMultiBankProduct>, StructuralTofMultiBankError> {
604        let native_count = P1ParameterLayout {
605            site_count: self.input.phase.definition().fractional_xyz.len(),
606        }
607        .parameter_count();
608        let mut products = Vec::with_capacity(self.input.banks.len());
609        for (bank_index, bank) in self.input.banks.iter().enumerate() {
610            let tangent = self
611                .layout
612                .native_tangent(bank_index, direction, native_count)?;
613            let species = species(self.input.phase.definition());
614            let view = bank_view(&self.input, bank, &species);
615            let forward = calculate_structural_tof_pattern_jvp_with_context(
616                self.input.phase.definition().cell,
617                &self.input.phase.definition().space_group,
618                &view,
619                &tangent,
620                self.input.execution.context(),
621            )?;
622            let mut derivative = forward.d_y;
623            add_instrument_jvp(
624                &mut derivative,
625                &forward.result,
626                &self.layout.banks[bank_index],
627                direction,
628            )?;
629            add_background_jvp(
630                &mut derivative,
631                self.background_bases[bank_index].as_ref(),
632                &self.layout.banks[bank_index],
633                direction,
634            )?;
635            let background = background_values(bank, self.background_bases[bank_index].as_ref())?;
636            let y = forward
637                .result
638                .accumulation
639                .y
640                .iter()
641                .zip(background)
642                .map(|(profile, background)| profile + background)
643                .collect();
644            products.push(StructuralTofMultiBankProduct {
645                bank_id: bank.bank_id.clone(),
646                y,
647                derivative,
648            });
649        }
650        Ok(products)
651    }
652
653    /// Apply the transpose of all bank Jacobians and sum shared rows.
654    ///
655    /// # Errors
656    ///
657    /// Returns [`StructuralTofMultiBankError`] for bank/sample shape or product failures.
658    pub fn vjp(&self, weights: &[Vec<f64>]) -> Result<Vec<f64>, StructuralTofMultiBankError> {
659        if weights.len() != self.input.banks.len() {
660            return Err(StructuralTofMultiBankError::BankWeightCountMismatch);
661        }
662        let mut result = vec![0.0; self.layout.parameters.specs().len()];
663        for (bank_index, (bank, weights)) in self.input.banks.iter().zip(weights).enumerate() {
664            let species = species(self.input.phase.definition());
665            let view = bank_view(&self.input, bank, &species);
666            let reverse = calculate_structural_tof_pattern_vjp_with_context(
667                self.input.phase.definition().cell,
668                &self.input.phase.definition().space_group,
669                &view,
670                weights,
671                self.input.execution.context(),
672            )?;
673            self.layout
674                .scatter_native_gradient(bank_index, &reverse.gradient, &mut result)?;
675            add_instrument_vjp(
676                &mut result,
677                &reverse.result,
678                &self.layout.banks[bank_index],
679                weights,
680            )?;
681            add_background_vjp(
682                &mut result,
683                self.background_bases[bank_index].as_ref(),
684                &self.layout.banks[bank_index],
685                weights,
686            )?;
687        }
688        Ok(result)
689    }
690
691    /// Evaluate the accepted objective and its physical gradient.
692    ///
693    /// # Errors
694    ///
695    /// Returns [`StructuralTofMultiBankError`] for residual or reverse-product state.
696    pub fn gradient(&self) -> Result<StructuralTofMultiBankGradient, StructuralTofMultiBankError> {
697        let calculation = self.calculate()?;
698        let weights = self
699            .input
700            .banks
701            .iter()
702            .zip(&calculation.banks)
703            .map(|(bank, calculation)| {
704                objective_weights(bank, &calculation.metrics, self.input.use_uncertainty)
705            })
706            .collect::<Vec<_>>();
707        let gradient = self.vjp(&weights)?;
708        Ok(StructuralTofMultiBankGradient {
709            calculation,
710            gradient,
711        })
712    }
713
714    /// Apply `J^T W J + damping I` in joint physical coordinates.
715    ///
716    /// # Errors
717    ///
718    /// Returns [`StructuralTofMultiBankError`] for invalid damping or products.
719    pub fn normal_product(
720        &self,
721        direction: &[f64],
722        damping: f64,
723    ) -> Result<Vec<f64>, StructuralTofMultiBankError> {
724        if !damping.is_finite() || damping < 0.0 {
725            return Err(StructuralTofMultiBankError::InvalidDamping);
726        }
727        let products = self.jvp(direction)?;
728        let weights = self
729            .input
730            .banks
731            .iter()
732            .zip(products)
733            .map(|(bank, product)| {
734                weighted_direction(bank, product.derivative, self.input.use_uncertainty)
735            })
736            .collect::<Vec<_>>();
737        let mut result = self.vjp(&weights)?;
738        for (value, direction) in result.iter_mut().zip(direction) {
739            *value += damping * direction;
740        }
741        Ok(result)
742    }
743}
744
745fn validate_bank(bank: &StructuralTofBank) -> Result<(), StructuralTofMultiBankError> {
746    bank.pattern.validate()?;
747    if bank.pattern.observed_y.is_none() {
748        return Err(StructuralTofMultiBankError::MissingObservations);
749    }
750    bank.instrument.validate()?;
751    bank.geometry.validate()?;
752    if !bank.scale.is_finite() || bank.scale < 0.0 || !bank.scale_bounds.contains(bank.scale) {
753        return Err(StructuralTofMultiBankError::InvalidBankContract(
754            "bank scale must be finite, non-negative, and inside its bounds",
755        ));
756    }
757    match bank.correction_model {
758        IntegratedIntensityCorrectionModel::Neutral => {}
759        IntegratedIntensityCorrectionModel::TimeOfFlightNeutronLorentz { two_theta_deg }
760            if two_theta_deg.to_bits() == bank.geometry.two_theta_deg.to_bits() => {}
761        _ => {
762            return Err(StructuralTofMultiBankError::InvalidBankContract(
763                "bank correction must be neutral or match the bank angle exactly",
764            ));
765        }
766    }
767    let mut selected = BTreeSet::new();
768    let values = bank.instrument.values();
769    for bound in &bank.instrument_bounds {
770        if !bound.lower.is_finite()
771            || !bound.upper.is_finite()
772            || bound.lower >= bound.upper
773            || !selected.insert(bound.parameter)
774            || !(bound.lower..=bound.upper).contains(&values[bound.parameter.index()])
775        {
776            return Err(StructuralTofMultiBankError::InvalidBankContract(
777                "instrument selections require unique finite bounds containing the current value",
778            ));
779        }
780    }
781    if let Some(background) = &bank.background {
782        background.validate()?;
783        background.basis(&bank.pattern.tof_us)?;
784    } else if bank.refine_background {
785        return Err(StructuralTofMultiBankError::InvalidBankContract(
786            "refine_background requires a background model",
787        ));
788    }
789    Ok(())
790}
791
792fn species(definition: &phasesmith_engine::StructuralPhaseDefinition) -> Vec<&str> {
793    definition
794        .scattering_species
795        .iter()
796        .map(String::as_str)
797        .collect()
798}
799
800fn bank_view<'a>(
801    input: &'a StructuralTofMultiBankInput,
802    bank: &'a StructuralTofBank,
803    species: &'a [&'a str],
804) -> StructuralTofInputView<'a> {
805    let definition = input.phase.definition();
806    StructuralTofInputView {
807        tof_us: &bank.pattern.tof_us,
808        hkl: &definition.hkl,
809        multiplicity: &definition.multiplicity,
810        fractional_xyz: &definition.fractional_xyz,
811        occupancy: &definition.occupancy,
812        u_iso_angstrom2: &definition.u_iso_angstrom2,
813        anisotropic_mask: &definition.anisotropic_mask,
814        u_aniso_cif_angstrom2: &definition.u_aniso_cif_angstrom2,
815        scattering_species: species,
816        scale: bank.scale,
817        coordinate_tolerance: definition.coordinate_tolerance,
818        correction_model: bank.correction_model,
819        bank_geometry: bank.geometry,
820        instrument: bank.instrument,
821        support_fwhm: input.support_fwhm,
822        tail_log: input.tail_log,
823    }
824}
825
826fn calculate_bank(
827    input: &StructuralTofMultiBankInput,
828    bank: &StructuralTofBank,
829) -> Result<StructuralTofResult, StructuralTofMultiBankError> {
830    let species = species(input.phase.definition());
831    Ok(calculate_structural_tof_pattern_with_context(
832        input.phase.definition().cell,
833        &input.phase.definition().space_group,
834        &bank_view(input, bank, &species),
835        input.execution.context(),
836    )?)
837}
838
839fn background_values(
840    bank: &StructuralTofBank,
841    basis: Option<&crate::TofChebyshevBasis>,
842) -> Result<Vec<f64>, StructuralTofMultiBankError> {
843    let mut values = if let (Some(model), Some(basis)) = (&bank.background, basis) {
844        model.calculate_from_basis(basis)?
845    } else {
846        vec![0.0; bank.pattern.sample_count()]
847    };
848    for (value, fixed) in values.iter_mut().zip(&bank.pattern.background_y) {
849        *value += fixed;
850    }
851    Ok(values)
852}
853
854fn add_instrument_jvp(
855    derivative: &mut [f64],
856    result: &StructuralTofResult,
857    mapping: &BankParameterMapping,
858    direction: &[f64],
859) -> Result<(), StructuralTofMultiBankError> {
860    let global = result
861        .accumulation
862        .derivatives
863        .global
864        .as_ref()
865        .ok_or(StructuralTofMultiBankError::InternalInvariant)?;
866    if global.parameter_count != TOF_GLOBAL_PARAMETER_COUNT
867        || global.values.len() != TOF_GLOBAL_PARAMETER_COUNT * derivative.len()
868    {
869        return Err(StructuralTofMultiBankError::InternalInvariant);
870    }
871    for &(parameter, index) in &mapping.instrument {
872        let row = &global.values
873            [parameter.index() * derivative.len()..(parameter.index() + 1) * derivative.len()];
874        for (target, value) in derivative.iter_mut().zip(row) {
875            *target += direction[index] * value;
876        }
877    }
878    Ok(())
879}
880
881fn add_instrument_vjp(
882    output: &mut [f64],
883    result: &StructuralTofResult,
884    mapping: &BankParameterMapping,
885    weights: &[f64],
886) -> Result<(), StructuralTofMultiBankError> {
887    let global = result
888        .accumulation
889        .derivatives
890        .global
891        .as_ref()
892        .ok_or(StructuralTofMultiBankError::InternalInvariant)?;
893    if global.values.len() != TOF_GLOBAL_PARAMETER_COUNT * weights.len() {
894        return Err(StructuralTofMultiBankError::InternalInvariant);
895    }
896    for &(parameter, index) in &mapping.instrument {
897        let row = &global.values
898            [parameter.index() * weights.len()..(parameter.index() + 1) * weights.len()];
899        output[index] += row.iter().zip(weights).map(|(a, b)| a * b).sum::<f64>();
900    }
901    Ok(())
902}
903
904fn add_background_jvp(
905    derivative: &mut [f64],
906    basis: Option<&crate::TofChebyshevBasis>,
907    mapping: &BankParameterMapping,
908    direction: &[f64],
909) -> Result<(), StructuralTofMultiBankError> {
910    if mapping.background.is_empty() {
911        return Ok(());
912    }
913    let basis = basis.ok_or(StructuralTofMultiBankError::InternalInvariant)?;
914    if basis.rows != derivative.len() || basis.columns != mapping.background.len() {
915        return Err(StructuralTofMultiBankError::InternalInvariant);
916    }
917    for (sample, row) in basis.values.chunks_exact(basis.columns).enumerate() {
918        derivative[sample] += row
919            .iter()
920            .zip(&mapping.background)
921            .map(|(value, index)| value * direction[*index])
922            .sum::<f64>();
923    }
924    Ok(())
925}
926
927fn add_background_vjp(
928    output: &mut [f64],
929    basis: Option<&crate::TofChebyshevBasis>,
930    mapping: &BankParameterMapping,
931    weights: &[f64],
932) -> Result<(), StructuralTofMultiBankError> {
933    if mapping.background.is_empty() {
934        return Ok(());
935    }
936    let basis = basis.ok_or(StructuralTofMultiBankError::InternalInvariant)?;
937    if basis.rows != weights.len() || basis.columns != mapping.background.len() {
938        return Err(StructuralTofMultiBankError::InternalInvariant);
939    }
940    for (sample, row) in basis.values.chunks_exact(basis.columns).enumerate() {
941        for (value, index) in row.iter().zip(&mapping.background) {
942            output[*index] += weights[sample] * value;
943        }
944    }
945    Ok(())
946}
947
948fn objective_weights(
949    bank: &StructuralTofBank,
950    metrics: &ResidualEvaluation,
951    use_uncertainty: bool,
952) -> Vec<f64> {
953    (0..bank.pattern.sample_count())
954        .map(|sample| {
955            if !metrics.included[sample] {
956                0.0
957            } else if use_uncertainty {
958                let sigma = bank.pattern.uncertainty.as_ref().map_or(1.0, |v| v[sample]);
959                metrics.residual[sample] / (sigma * sigma)
960            } else {
961                metrics.residual[sample]
962            }
963        })
964        .collect()
965}
966
967fn weighted_direction(
968    bank: &StructuralTofBank,
969    direction: Vec<f64>,
970    use_uncertainty: bool,
971) -> Vec<f64> {
972    direction
973        .into_iter()
974        .enumerate()
975        .map(|(sample, value)| {
976            if bank.pattern.mask.as_ref().is_some_and(|mask| !mask[sample]) {
977                0.0
978            } else if use_uncertainty {
979                let sigma = bank.pattern.uncertainty.as_ref().map_or(1.0, |v| v[sample]);
980                value / (sigma * sigma)
981            } else {
982                value
983            }
984        })
985        .collect()
986}
987
988const fn instrument_unit(parameter: TofInstrumentParameter) -> &'static str {
989    match parameter {
990        TofInstrumentParameter::Zero | TofInstrumentParameter::Z => "microsecond",
991        TofInstrumentParameter::Difc | TofInstrumentParameter::X => "microsecond/angstrom",
992        TofInstrumentParameter::Difa | TofInstrumentParameter::Y => "microsecond/angstrom^2",
993        TofInstrumentParameter::Difb => "microsecond*angstrom",
994        TofInstrumentParameter::Alpha => "microsecond^-1*angstrom",
995        TofInstrumentParameter::Beta0 => "microsecond^-1",
996        TofInstrumentParameter::Beta1 => "angstrom^4/microsecond",
997        TofInstrumentParameter::Betaq => "angstrom^2/microsecond",
998        TofInstrumentParameter::Sigma0 => "microsecond^2",
999        TofInstrumentParameter::Sigma1 => "microsecond^2/angstrom^2",
1000        TofInstrumentParameter::Sigma2 => "microsecond^2/angstrom^4",
1001        TofInstrumentParameter::Sigmaq => "microsecond^2/angstrom",
1002    }
1003}
1004
1005/// Invalid structural multi-bank TOF request or objective product.
1006#[derive(Debug)]
1007pub enum StructuralTofMultiBankError {
1008    /// At least one bank is required for a structural TOF objective.
1009    TooFewBanks,
1010    /// Stable bank IDs must be unique.
1011    DuplicateBankId,
1012    /// Observed intensities are required in every bank.
1013    MissingObservations,
1014    /// Shared structural phase uses an unsupported TOF contract.
1015    InvalidPhaseContract(&'static str),
1016    /// A bank-local model or selection is invalid.
1017    InvalidBankContract(&'static str),
1018    /// Support or tail controls are nonphysical.
1019    InvalidSupport,
1020    /// Prepared bank identities or selected families changed.
1021    BankContractMismatch,
1022    /// Physical value or direction length differs from the layout.
1023    ParameterLengthMismatch,
1024    /// Reverse products do not contain one vector per bank.
1025    BankWeightCountMismatch,
1026    /// Damping must be finite and non-negative.
1027    InvalidDamping,
1028    /// A supposedly validated internal shape was inconsistent.
1029    InternalInvariant,
1030    /// TOF pattern domain validation failed.
1031    Pattern(DomainError),
1032    /// Shared phase validation failed.
1033    Rietveld(RietveldError),
1034    /// Symmetry-aware structural parameter mapping failed.
1035    StructuralParameters(RietveldParameterError),
1036    /// Stable scalar parameter validation failed.
1037    Parameter(ParameterError),
1038    /// Background evaluation failed.
1039    Background(TofLeBailError),
1040    /// Structural TOF engine evaluation failed.
1041    Structural(StructuralTofError),
1042    /// TOF instrument or bank geometry failed.
1043    Tof(TofError),
1044    /// Residual evaluation failed.
1045    Residual(ResidualError),
1046}
1047
1048impl Display for StructuralTofMultiBankError {
1049    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
1050        match self {
1051            Self::TooFewBanks => formatter.write_str("structural TOF requires at least one bank"),
1052            Self::DuplicateBankId => formatter.write_str("structural TOF bank IDs must be unique"),
1053            Self::MissingObservations => {
1054                formatter.write_str("every structural TOF bank requires observations")
1055            }
1056            Self::InvalidPhaseContract(reason) | Self::InvalidBankContract(reason) => {
1057                formatter.write_str(reason)
1058            }
1059            Self::InvalidSupport => {
1060                formatter.write_str("structural TOF support controls must be finite and positive")
1061            }
1062            Self::BankContractMismatch => formatter
1063                .write_str("structural TOF bank contract changed under the prepared layout"),
1064            Self::ParameterLengthMismatch => {
1065                formatter.write_str("structural TOF parameter value/direction length is wrong")
1066            }
1067            Self::BankWeightCountMismatch => formatter
1068                .write_str("structural TOF reverse products require one weight vector per bank"),
1069            Self::InvalidDamping => {
1070                formatter.write_str("structural TOF damping must be finite and non-negative")
1071            }
1072            Self::InternalInvariant => {
1073                formatter.write_str("structural TOF internal shape invariant failed")
1074            }
1075            Self::Pattern(error) => Display::fmt(error, formatter),
1076            Self::Rietveld(error) => Display::fmt(error, formatter),
1077            Self::StructuralParameters(error) => Display::fmt(error, formatter),
1078            Self::Parameter(error) => Display::fmt(error, formatter),
1079            Self::Background(error) => Display::fmt(error, formatter),
1080            Self::Structural(error) => Display::fmt(error, formatter),
1081            Self::Tof(error) => Display::fmt(error, formatter),
1082            Self::Residual(error) => Display::fmt(error, formatter),
1083        }
1084    }
1085}
1086
1087impl Error for StructuralTofMultiBankError {}
1088
1089impl From<DomainError> for StructuralTofMultiBankError {
1090    fn from(value: DomainError) -> Self {
1091        Self::Pattern(value)
1092    }
1093}
1094impl From<RietveldError> for StructuralTofMultiBankError {
1095    fn from(value: RietveldError) -> Self {
1096        Self::Rietveld(value)
1097    }
1098}
1099impl From<RietveldParameterError> for StructuralTofMultiBankError {
1100    fn from(value: RietveldParameterError) -> Self {
1101        Self::StructuralParameters(value)
1102    }
1103}
1104impl From<ParameterError> for StructuralTofMultiBankError {
1105    fn from(value: ParameterError) -> Self {
1106        Self::Parameter(value)
1107    }
1108}
1109impl From<TofLeBailError> for StructuralTofMultiBankError {
1110    fn from(value: TofLeBailError) -> Self {
1111        Self::Background(value)
1112    }
1113}
1114impl From<StructuralTofError> for StructuralTofMultiBankError {
1115    fn from(value: StructuralTofError) -> Self {
1116        Self::Structural(value)
1117    }
1118}
1119impl From<TofError> for StructuralTofMultiBankError {
1120    fn from(value: TofError) -> Self {
1121        Self::Tof(value)
1122    }
1123}
1124impl From<ResidualError> for StructuralTofMultiBankError {
1125    fn from(value: ResidualError) -> Self {
1126        Self::Residual(value)
1127    }
1128}