Skip to main content

nereids_fitting/
parameters.rs

1//! Fit parameter types, bounds, and constraints.
2//!
3//! Parameters for the forward model that can be fitted or held fixed.
4//! Supports non-negativity constraints and sum-to-one constraints for
5//! isotopes of the same element.
6
7use std::borrow::Cow;
8
9/// A single fit parameter with value, bounds, and fixed/free flag.
10///
11/// The `name` field uses `Cow<'static, str>` so that static strings
12/// (e.g. `"U-238"`, `"temperature_k"`) avoid heap allocation entirely,
13/// while dynamic strings from `format!()` still work via the `Owned` variant.
14/// When a `ParameterSet` template is cloned per-pixel, `Cow::Borrowed` names
15/// are copied as a pointer+length (16 bytes, no heap) instead of allocating
16/// a new `String` on the heap.
17#[derive(Debug, Clone)]
18pub struct FitParameter {
19    /// Parameter name (for reporting).
20    pub name: Cow<'static, str>,
21    /// Current value.
22    pub value: f64,
23    /// Lower bound (f64::NEG_INFINITY if unbounded).
24    pub lower: f64,
25    /// Upper bound (f64::INFINITY if unbounded).
26    pub upper: f64,
27    /// If true, parameter is held fixed during fitting.
28    pub fixed: bool,
29}
30
31impl FitParameter {
32    /// Create a new free parameter with non-negativity constraint.
33    pub fn non_negative(name: impl Into<Cow<'static, str>>, value: f64) -> Self {
34        Self {
35            name: name.into(),
36            value,
37            lower: 0.0,
38            upper: f64::INFINITY,
39            fixed: false,
40        }
41    }
42
43    /// Create a new free parameter with no bounds.
44    pub fn unbounded(name: impl Into<Cow<'static, str>>, value: f64) -> Self {
45        Self {
46            name: name.into(),
47            value,
48            lower: f64::NEG_INFINITY,
49            upper: f64::INFINITY,
50            fixed: false,
51        }
52    }
53
54    /// Create a fixed parameter.
55    pub fn fixed(name: impl Into<Cow<'static, str>>, value: f64) -> Self {
56        Self {
57            name: name.into(),
58            value,
59            lower: f64::NEG_INFINITY,
60            upper: f64::INFINITY,
61            fixed: true,
62        }
63    }
64
65    /// Clamp value to bounds.
66    pub fn clamp(&mut self) {
67        self.value = self.value.clamp(self.lower, self.upper);
68    }
69}
70
71/// Collection of fit parameters for a forward model fit.
72#[derive(Debug, Clone)]
73pub struct ParameterSet {
74    /// All parameters (fixed + free).
75    pub params: Vec<FitParameter>,
76}
77
78impl ParameterSet {
79    pub fn new(params: Vec<FitParameter>) -> Self {
80        Self { params }
81    }
82
83    /// Number of free (non-fixed) parameters.
84    pub fn n_free(&self) -> usize {
85        self.params.iter().filter(|p| !p.fixed).count()
86    }
87
88    /// Get the values of all free parameters as a vector.
89    pub fn free_values(&self) -> Vec<f64> {
90        self.params
91            .iter()
92            .filter(|p| !p.fixed)
93            .map(|p| p.value)
94            .collect()
95    }
96
97    /// Set the values of free parameters from a vector.
98    ///
99    /// # Panics
100    /// Panics if `values.len() != self.n_free()`.
101    pub fn set_free_values(&mut self, values: &[f64]) {
102        let n_free = self.n_free();
103        assert_eq!(
104            values.len(),
105            n_free,
106            "set_free_values: values length ({}) must match number of free parameters ({})",
107            values.len(),
108            n_free,
109        );
110        let mut j = 0;
111        for p in &mut self.params {
112            if !p.fixed {
113                p.value = values[j];
114                p.clamp();
115                j += 1;
116            }
117        }
118    }
119
120    /// Get the value of all parameters (fixed + free) as a vector.
121    pub fn all_values(&self) -> Vec<f64> {
122        self.params.iter().map(|p| p.value).collect()
123    }
124
125    /// Write all parameter values into the provided buffer, resizing if needed.
126    ///
127    /// This is the buffer-reuse counterpart of [`all_values`](Self::all_values).
128    /// Callers that fit many pixels in a loop can allocate one `Vec<f64>` and
129    /// reuse it across iterations to avoid per-pixel allocation churn.
130    pub fn all_values_into(&self, buf: &mut Vec<f64>) {
131        buf.clear();
132        buf.extend(self.params.iter().map(|p| p.value));
133    }
134
135    /// Write free parameter values into the provided buffer, resizing if needed.
136    ///
137    /// This is the buffer-reuse counterpart of [`free_values`](Self::free_values).
138    /// Callers that fit many pixels in a loop can allocate one `Vec<f64>` and
139    /// reuse it across iterations to avoid per-pixel allocation churn.
140    pub fn free_values_into(&self, buf: &mut Vec<f64>) {
141        buf.clear();
142        buf.extend(self.params.iter().filter(|p| !p.fixed).map(|p| p.value));
143    }
144
145    /// Indices (into `params`) of free parameters.
146    pub fn free_indices(&self) -> Vec<usize> {
147        self.params
148            .iter()
149            .enumerate()
150            .filter(|(_, p)| !p.fixed)
151            .map(|(i, _)| i)
152            .collect()
153    }
154
155    /// Write free parameter indices into the provided buffer, resizing if needed.
156    ///
157    /// This is the buffer-reuse counterpart of [`free_indices`](Self::free_indices).
158    /// Callers that compute the Jacobian many times in a loop can allocate one
159    /// `Vec<usize>` and reuse it across iterations to avoid per-call allocation.
160    pub fn free_indices_into(&self, buf: &mut Vec<usize>) {
161        buf.clear();
162        buf.extend(
163            self.params
164                .iter()
165                .enumerate()
166                .filter(|(_, p)| !p.fixed)
167                .map(|(i, _)| i),
168        );
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn test_parameter_set_free_values() {
178        let params = ParameterSet::new(vec![
179            FitParameter::non_negative("a", 1.0),
180            FitParameter::fixed("b", 2.0),
181            FitParameter::non_negative("c", 3.0),
182        ]);
183
184        assert_eq!(params.n_free(), 2);
185        assert_eq!(params.free_values(), vec![1.0, 3.0]);
186        assert_eq!(params.all_values(), vec![1.0, 2.0, 3.0]);
187    }
188
189    #[test]
190    #[should_panic(expected = "set_free_values: values length")]
191    fn test_set_free_values_length_mismatch() {
192        let mut params = ParameterSet::new(vec![
193            FitParameter::non_negative("a", 1.0),
194            FitParameter::non_negative("b", 2.0),
195        ]);
196        // 2 free params but only 1 value — should panic
197        params.set_free_values(&[1.0]);
198    }
199
200    #[test]
201    fn test_set_free_values_with_clamping() {
202        let mut params = ParameterSet::new(vec![
203            FitParameter::non_negative("a", 1.0),
204            FitParameter::fixed("b", 2.0),
205            FitParameter::non_negative("c", 3.0),
206        ]);
207
208        // Set a to -0.5 (should clamp to 0.0) and c to 5.0
209        params.set_free_values(&[-0.5, 5.0]);
210        assert_eq!(params.params[0].value, 0.0); // clamped
211        assert_eq!(params.params[1].value, 2.0); // fixed, unchanged
212        assert_eq!(params.params[2].value, 5.0);
213    }
214}