1use nereids_core::constants::PIVOT_FLOOR;
4use nereids_physics::counts_response::DetectorBinResponseMatrix;
5
6use crate::error::FittingError;
7use crate::lm::{FitModel, FlatMatrix};
8
9pub struct ExactTwoArmRatioModel {
35 inner: Box<dyn FitModel>,
36 response: DetectorBinResponseMatrix,
37 scaled_fluence_weights: Vec<f64>,
38 open_expectation: Vec<f64>,
39 n_true_energies: usize,
40 n_detector_bins: usize,
41}
42
43impl ExactTwoArmRatioModel {
44 pub fn new(
46 inner: Box<dyn FitModel>,
47 response: DetectorBinResponseMatrix,
48 incident_fluence_weights: &[f64],
49 ) -> Result<Self, FittingError> {
50 if incident_fluence_weights.len() != response.n_true_energies() {
51 return Err(FittingError::LengthMismatch {
52 expected: response.n_true_energies(),
53 actual: incident_fluence_weights.len(),
54 field: "incident_fluence_weights",
55 });
56 }
57 for (index, &fluence) in incident_fluence_weights.iter().enumerate() {
58 if !fluence.is_finite() || fluence < 0.0 {
59 return Err(FittingError::InvalidConfig(format!(
60 "incident_fluence_weights[{index}] must be finite and >= 0, got {fluence}"
61 )));
62 }
63 }
64 let fluence_scale = incident_fluence_weights
65 .iter()
66 .copied()
67 .fold(0.0_f64, f64::max);
68 if fluence_scale <= 0.0 {
69 return Err(FittingError::InvalidConfig(
70 "incident_fluence_weights must contain at least one positive value".into(),
71 ));
72 }
73
74 let n_true_energies = response.n_true_energies();
75 let n_detector_bins = response.n_detector_bins();
76 let scaled_fluence_weights: Vec<f64> = incident_fluence_weights
77 .iter()
78 .map(|fluence| fluence / fluence_scale)
79 .collect();
80 let mut open_expectation = vec![0.0; n_detector_bins];
81 let mut open_compensation = vec![0.0; n_detector_bins];
82 for (true_index, &fluence) in scaled_fluence_weights.iter().enumerate() {
83 for (detector_bin, probability) in response.row_entries(true_index) {
84 let value = fluence * probability;
85 compensated_add(
86 &mut open_expectation[detector_bin],
87 &mut open_compensation[detector_bin],
88 value,
89 );
90 }
91 }
92 for detector_bin in 0..n_detector_bins {
93 open_expectation[detector_bin] += open_compensation[detector_bin];
94 }
95
96 Ok(Self {
97 inner,
98 response,
99 scaled_fluence_weights,
100 open_expectation,
101 n_true_energies,
102 n_detector_bins,
103 })
104 }
105
106 pub fn open_expectation(&self) -> &[f64] {
108 &self.open_expectation
109 }
110
111 fn map_true_energy_values(&self, values: &[f64]) -> Result<Vec<f64>, FittingError> {
112 if values.len() != self.n_true_energies {
113 return Err(FittingError::LengthMismatch {
114 expected: self.n_true_energies,
115 actual: values.len(),
116 field: "true_energy_model",
117 });
118 }
119 let mut sample = vec![0.0; self.n_detector_bins];
120 let mut compensation = vec![0.0; self.n_detector_bins];
121 for (true_index, &value) in values.iter().enumerate() {
122 if !value.is_finite() {
123 return Err(FittingError::EvaluationFailed(format!(
124 "true-energy model output[{true_index}] is not finite: {value}"
125 )));
126 }
127 let fluence = self.scaled_fluence_weights[true_index];
128 for (detector_bin, probability) in self.response.row_entries(true_index) {
129 compensated_add(
130 &mut sample[detector_bin],
131 &mut compensation[detector_bin],
132 value * fluence * probability,
133 );
134 }
135 }
136
137 Ok(sample
138 .into_iter()
139 .zip(compensation)
140 .zip(&self.open_expectation)
141 .map(|((sum, correction), &open)| {
142 if open > PIVOT_FLOOR {
143 (sum + correction) / open
144 } else {
145 1.0
146 }
147 })
148 .collect())
149 }
150}
151
152impl FitModel for ExactTwoArmRatioModel {
153 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
154 let true_transmission = self.inner.evaluate(params)?;
155 self.map_true_energy_values(&true_transmission)
156 }
157
158 fn analytical_jacobian(
159 &self,
160 params: &[f64],
161 free_param_indices: &[usize],
162 _y_current: &[f64],
163 ) -> Option<FlatMatrix> {
164 let true_transmission = self.inner.evaluate(params).ok()?;
165 if true_transmission.len() != self.n_true_energies {
166 return None;
167 }
168 let inner_jacobian =
169 self.inner
170 .analytical_jacobian(params, free_param_indices, &true_transmission)?;
171 if inner_jacobian.nrows != self.n_true_energies
172 || inner_jacobian.ncols != free_param_indices.len()
173 {
174 return None;
175 }
176
177 let mut output = FlatMatrix::zeros(self.n_detector_bins, free_param_indices.len());
178 for true_index in 0..self.n_true_energies {
179 let fluence = self.scaled_fluence_weights[true_index];
180 for (detector_bin, probability) in self.response.row_entries(true_index) {
181 let open = self.open_expectation[detector_bin];
182 if open <= PIVOT_FLOOR {
183 continue;
184 }
185 let weight = fluence * probability / open;
186 for column in 0..free_param_indices.len() {
187 *output.get_mut(detector_bin, column) +=
188 weight * inner_jacobian.get(true_index, column);
189 }
190 }
191 }
192 Some(output)
193 }
194}
195
196#[inline]
197fn compensated_add(sum: &mut f64, compensation: &mut f64, value: f64) {
198 let next = *sum + value;
199 if sum.abs() >= value.abs() {
200 *compensation += (*sum - next) + value;
201 } else {
202 *compensation += (value - next) + *sum;
203 }
204 *sum = next;
205}
206
207#[cfg(test)]
208mod tests {
209 use std::sync::Arc;
210
211 use nereids_physics::resolution::{ResolutionFunction, TOF_FACTOR, TabulatedResolution};
212
213 use super::*;
214
215 struct LinearTrueEnergyModel;
216
217 impl FitModel for LinearTrueEnergyModel {
218 fn evaluate(&self, params: &[f64]) -> Result<Vec<f64>, FittingError> {
219 Ok(vec![params[0], 1.0 - 0.5 * params[0]])
220 }
221
222 fn analytical_jacobian(
223 &self,
224 _params: &[f64],
225 free_param_indices: &[usize],
226 _y_current: &[f64],
227 ) -> Option<FlatMatrix> {
228 if free_param_indices != [0] {
229 return None;
230 }
231 Some(FlatMatrix {
232 data: vec![1.0, -0.5],
233 nrows: 2,
234 ncols: 1,
235 })
236 }
237 }
238
239 fn triangle_response() -> ResolutionFunction {
240 ResolutionFunction::Tabulated(Arc::new(
241 TabulatedResolution::from_kernels(
242 vec![25.0],
243 vec![(vec![-1.0, 0.0, 1.0], vec![0.0, 1.0, 0.0])],
244 25.0,
245 )
246 .expect("valid triangle response"),
247 ))
248 }
249
250 #[test]
251 fn maps_values_and_analytical_jacobian_through_separate_arms() {
252 let arrival_0 = TOF_FACTOR * 25.0 / 25.0_f64.sqrt();
253 let arrival_1 = arrival_0 + 1.0;
254 let energy_1 = (TOF_FACTOR * 25.0 / arrival_1).powi(2);
255 let response = DetectorBinResponseMatrix::new(
256 &[25.0, energy_1],
257 &[arrival_0 - 1.0, arrival_0, arrival_0 + 1.0, arrival_0 + 2.0],
258 0.0,
259 &triangle_response(),
260 )
261 .expect("valid exact response");
262 let model =
263 ExactTwoArmRatioModel::new(Box::new(LinearTrueEnergyModel), response, &[100.0, 200.0])
264 .expect("valid exact ratio model");
265
266 let values = model.evaluate(&[0.2]).expect("model evaluation");
267 let expected = [0.2, 2.0 / 3.0, 0.9];
268 for (got, want) in values.iter().zip(expected) {
269 assert!((got - want).abs() < 2.0e-12, "{got} != {want}");
270 }
271
272 let jacobian = model
273 .analytical_jacobian(&[0.2], &[0], &values)
274 .expect("analytical Jacobian");
275 let expected_jacobian = [1.0, 0.0, -0.5];
276 for (row, want) in expected_jacobian.into_iter().enumerate() {
277 assert!((jacobian.get(row, 0) - want).abs() < 2.0e-12);
278 }
279 }
280}