Back to home page

sPhenix code displayed by LXR

 
 

    


File indexing completed on 2026-08-30 08:14:26

0001 #include "GlobalFieldFitter.h"
0002 
0003 #include "helpers.h"
0004 #include "parameters.h"
0005 
0006 #include <algorithm>
0007 #include <cmath>
0008 #include <utility>
0009 #include <array>
0010 #include <vector>
0011 
0012 bool nearly_same_control_r(double lhs, double rhs)
0013 {
0014   return std::abs(lhs - rhs) < 1e-3;
0015 }
0016 
0017 bool smaller_neighbor_distance(const std::pair<double, double> &lhs, const std::pair<double, double> &rhs)
0018 {
0019   return lhs.first < rhs.first;
0020 }
0021 
0022 // GlobalFieldFitter represents DeltaR and R*DeltaPhi as two independent scalar
0023 // fields on the same periodic (phi, R) control grid. Observations contribute
0024 // bilinearly to nearby control points, while second-difference penalties keep
0025 // the fitted surfaces smooth between measured stripes.
0026 GlobalFieldFitter::GlobalFieldFitter(const std::vector<std::array<double, 6>> &observations, const std::vector<double> &control_r_positions) : m_observations(observations), m_requestedControlRPositions(control_r_positions) {}
0027 
0028 // Build and solve the two regularized weighted-least-squares systems.
0029 //
0030 // The coefficient layout may be sparse when active-control interpolation is
0031 // enabled. In that case only controls with sufficient data support participate
0032 // in the linear solve; build_dense_evaluation_grid() reconstructs values at
0033 // inactive controls afterward for fast, continuous evaluation.
0034 bool GlobalFieldFitter::fit()
0035 {
0036   // A fitter can be reused, so remove every product of a previous fit first.
0037   m_isValid = false;
0038   m_coefficients_delta_r.clear();
0039   m_coefficients_r_delta_phi.clear();
0040   m_evaluation_coefficients_delta_r.clear();
0041   m_evaluation_coefficients_r_delta_phi.clear();
0042 
0043   if (m_observations.empty())
0044   {
0045     return false;
0046   }
0047 
0048   initialize_grid();
0049   if (m_nControlPhi < 2 || m_nControlR < 2)
0050   {
0051     return false;
0052   }
0053 
0054   compute_control_support();
0055   std::vector<int> full_to_fit_index;
0056   select_active_controls(full_to_fit_index);
0057 
0058   // full_to_fit_index maps the rectangular control grid into the compact
0059   // coefficient vector used by the solve. A value of -1 marks an inactive
0060   // control that will later be filled by interpolation.
0061   int nFit = 0;
0062   for (int fit_index : full_to_fit_index)
0063   {
0064     if (fit_index >= 0)
0065     {
0066       nFit = std::max(nFit, fit_index + 1);
0067     }
0068   }
0069   if (nFit < global_field_min_active_controls)
0070   {
0071     return false;
0072   }
0073 
0074   std::vector<std::vector<double>> normal_delta_r(nFit, std::vector<double>(nFit, 0.0));
0075   std::vector<std::vector<double>> normal_r_delta_phi(nFit, std::vector<double>(nFit, 0.0));
0076   std::vector<double> rhs_delta_r(nFit, 0.0);
0077   std::vector<double> rhs_r_delta_phi(nFit, 0.0);
0078 
0079   // Accumulate A^T W A and A^T W y directly. Each observation has at most four
0080   // bilinear basis weights, so no explicit design matrix is needed.
0081   for (const auto &observation : m_observations)
0082   {
0083     std::vector<int> indices;
0084     std::vector<double> weights;
0085     accumulate_bilinear_weights(observation[observation_phi], observation[observation_r], indices, weights);
0086     if (indices.empty())
0087     {
0088       continue;
0089     }
0090 
0091     const double sigma_delta_r = std::max(observation[observation_sigma_delta_r], 1e-6);
0092     const double sigma_r_delta_phi = std::max(observation[observation_sigma_r_delta_phi], 1e-6);
0093     const double inv_var_delta_r = 1.0 / (sigma_delta_r * sigma_delta_r);
0094     const double inv_var_r_delta_phi = 1.0 / (sigma_r_delta_phi * sigma_r_delta_phi);
0095 
0096     // If one or more of the four surrounding controls are inactive, renormalize
0097     // the remaining basis weights so this observation retains unit influence.
0098     double active_weight_sum = 0.0;
0099     for (size_t a = 0; a < indices.size(); a++)
0100     {
0101       if (full_to_fit_index[indices[a]] >= 0)
0102       {
0103         active_weight_sum += weights[a];
0104       }
0105     }
0106     if (active_weight_sum <= 1e-6)
0107     {
0108       continue;
0109     }
0110 
0111     for (size_t a = 0; a < indices.size(); a++)
0112     {
0113       const int ia = full_to_fit_index[indices[a]];
0114       if (ia < 0)
0115       {
0116         continue;
0117       }
0118 
0119       const double wa = weights[a] / active_weight_sum;
0120       rhs_delta_r[ia] += wa * inv_var_delta_r * observation[observation_delta_r];
0121       rhs_r_delta_phi[ia] += wa * inv_var_r_delta_phi * observation[observation_r_delta_phi];
0122 
0123       for (size_t b = 0; b < indices.size(); b++)
0124       {
0125         const int ib = full_to_fit_index[indices[b]];
0126         if (ib < 0)
0127         {
0128           continue;
0129         }
0130 
0131         const double wb = weights[b] / active_weight_sum;
0132         normal_delta_r[ia][ib] += wa * inv_var_delta_r * wb;
0133         normal_r_delta_phi[ia][ib] += wa * inv_var_r_delta_phi * wb;
0134       }
0135     }
0136   }
0137 
0138   add_smoothness_penalty(normal_delta_r, full_to_fit_index);
0139   add_smoothness_penalty(normal_r_delta_phi, full_to_fit_index);
0140 
0141   // The nugget makes weakly constrained systems nonsingular and limits the
0142   // numerical condition number without imposing a meaningful field shape.
0143   for (int i = 0; i < nFit; i++)
0144   {
0145     normal_delta_r[i][i] += global_field_kernel_nugget;
0146     normal_r_delta_phi[i][i] += global_field_kernel_nugget;
0147   }
0148 
0149   if (!solve_linear_system(normal_delta_r, rhs_delta_r, m_coefficients_delta_r))
0150   {
0151     return false;
0152   }
0153   if (!solve_linear_system(normal_r_delta_phi, rhs_r_delta_phi, m_coefficients_r_delta_phi))
0154   {
0155     return false;
0156   }
0157 
0158   build_dense_evaluation_grid();
0159 
0160   // Evaluation routines intentionally return zero until the complete fit has
0161   // succeeded, so mark the object valid only at the end.
0162   m_isValid = true;
0163   return true;
0164 }
0165 
0166 bool GlobalFieldFitter::is_valid() const
0167 {
0168   return m_isValid;
0169 }
0170 
0171 double GlobalFieldFitter::evaluate_delta_r(double phi, double r) const
0172 {
0173   // Prefer the reconstructed dense grid. The compact coefficients are retained
0174   // as a fallback for configurations that do not require reconstruction.
0175   if (!m_evaluation_coefficients_delta_r.empty())
0176   {
0177     return evaluate_component(m_evaluation_coefficients_delta_r, phi, r);
0178   }
0179   return evaluate_component(m_coefficients_delta_r, phi, r);
0180 }
0181 
0182 double GlobalFieldFitter::evaluate_r_delta_phi(double phi, double r) const
0183 {
0184   if (!m_evaluation_coefficients_r_delta_phi.empty())
0185   {
0186     return evaluate_component(m_evaluation_coefficients_r_delta_phi, phi, r);
0187   }
0188   return evaluate_component(m_coefficients_r_delta_phi, phi, r);
0189 }
0190 
0191 double GlobalFieldFitter::evaluate_delta_phi(double phi, double r) const
0192 {
0193   // R*DeltaPhi is fitted because it has distance units and behaves better
0194   // numerically. Convert back to angular displacement only at evaluation time.
0195   if (std::abs(r) < 1e-6)
0196   {
0197     return 0.0;
0198   }
0199   return evaluate_r_delta_phi(phi, r) / r;
0200 }
0201 
0202 double GlobalFieldFitter::predictive_sigma_delta_r(double /*phi*/, double /*r*/) const
0203 {
0204   // The current uncertainty model is global rather than position-dependent.
0205   // The arguments remain in the API so a local model can be introduced later.
0206   return estimate_global_residual_sigma(true);
0207 }
0208 
0209 double GlobalFieldFitter::predictive_sigma_r_delta_phi(double /*phi*/, double /*r*/) const
0210 {
0211   return estimate_global_residual_sigma(false);
0212 }
0213 
0214 std::vector<std::array<double, 7>> GlobalFieldFitter::control_points() const
0215 {
0216   // Export the full rectangular grid, including interpolated inactive controls,
0217   // together with the support metadata used by diagnostic plots.
0218   std::vector<std::array<double, 7>> points;
0219   points.reserve(m_nControlPhi * m_nControlR);
0220   for (int rIndex = 0; rIndex < m_nControlR; ++rIndex)
0221   {
0222     const double r = control_r(rIndex);
0223     for (int phiIndex = 0; phiIndex < m_nControlPhi; ++phiIndex)
0224     {
0225       const int index = control_index(phiIndex, rIndex);
0226       const double phi = control_phi(phiIndex);
0227       std::array<double, 7> point{};
0228       point[control_point_phi] = phi;
0229       point[control_point_r] = r;
0230       point[control_point_delta_r] = evaluate_delta_r(phi, r);
0231       point[control_point_r_delta_phi] = evaluate_r_delta_phi(phi, r);
0232       point[control_point_delta_phi] = std::abs(r) > 1e-6 ? point[control_point_r_delta_phi] / r : 0.0;
0233       if (index >= 0 && index < static_cast<int>(m_controlSupport.size()))
0234       {
0235         point[control_point_support] = m_controlSupport[index];
0236       }
0237       if (index >= 0 && index < static_cast<int>(m_activeControl.size()))
0238       {
0239         point[control_point_active] = m_activeControl[index];
0240       }
0241       points.push_back(point);
0242     }
0243   }
0244   return points;
0245 }
0246 
0247 void GlobalFieldFitter::initialize_grid()
0248 {
0249   // Phi controls are uniformly spaced and periodic. R controls may instead be
0250   // supplied by the measured radial geometry, which avoids forcing a uniform
0251   // grid across irregular radial bands.
0252   const double phi_spacing = std::max(0.05, global_field_control_phi_spacing_rad);
0253   m_nControlPhi = std::max(3, static_cast<int>(std::round((2.0 * M_PI) / phi_spacing)));
0254   m_phiMin = 0.0;
0255   m_phiMax = 2.0 * M_PI;
0256   m_phiStep = (m_phiMax - m_phiMin) / static_cast<double>(m_nControlPhi);
0257 
0258   m_rMin = fit_r_min_cm;
0259   m_rMax = fit_r_max_cm;
0260   if (m_rMax <= m_rMin)
0261   {
0262     m_rMax = m_rMin + 1.0;
0263   }
0264 
0265   m_controlRPositions.clear();
0266   m_controlRPositions.reserve(m_requestedControlRPositions.size());
0267   for (double r : m_requestedControlRPositions)
0268   {
0269     if (r < m_rMin || r > m_rMax)
0270     {
0271       continue;
0272     }
0273     m_controlRPositions.push_back(r);
0274   }
0275   std::sort(m_controlRPositions.begin(), m_controlRPositions.end());
0276   // Nearly identical requested radii would create zero-width interpolation
0277   // cells, so collapse them before deciding whether the custom grid is usable.
0278   m_controlRPositions.erase(std::unique(m_controlRPositions.begin(), m_controlRPositions.end(), nearly_same_control_r), m_controlRPositions.end());
0279 
0280   if (m_controlRPositions.size() < 2)
0281   {
0282     // Fall back to a uniform radial grid when no usable geometry was supplied.
0283     m_nControlR = std::max(2, global_field_control_r_bins);
0284     m_rStep = (m_rMax - m_rMin) / static_cast<double>(m_nControlR - 1);
0285     m_controlRPositions.resize(m_nControlR);
0286     for (int i = 0; i < m_nControlR; i++)
0287     {
0288       m_controlRPositions[i] = m_rMin + static_cast<double>(i) * m_rStep;
0289     }
0290   }
0291   else
0292   {
0293     m_nControlR = static_cast<int>(m_controlRPositions.size());
0294     m_rStep = 0.0;
0295   }
0296 }
0297 
0298 int GlobalFieldFitter::control_index(int phi_index, int r_index) const
0299 {
0300   // Phi wraps across the detector seam; R is bounded by the fitted radial
0301   // interval. Keeping this policy in one helper prevents seam inconsistencies.
0302   int wrapped_phi = phi_index % m_nControlPhi;
0303   if (wrapped_phi < 0)
0304   {
0305     wrapped_phi += m_nControlPhi;
0306   }
0307   const int clamped_r = std::clamp(r_index, 0, m_nControlR - 1);
0308   return clamped_r * m_nControlPhi + wrapped_phi;
0309 }
0310 
0311 void GlobalFieldFitter::accumulate_bilinear_weights(double phi, double r, std::vector<int> &indices, std::vector<double> &weights) const
0312 {
0313   indices.clear();
0314   weights.clear();
0315 
0316   // Normalize phi before finding its two neighboring periodic controls.
0317   const double phi_width = m_phiMax - m_phiMin;
0318   while (phi < m_phiMin)
0319   {
0320     phi += phi_width;
0321   }
0322   while (phi >= m_phiMax)
0323   {
0324     phi -= phi_width;
0325   }
0326 
0327   const double phi_u = (phi - m_phiMin) / m_phiStep;
0328   const int phi0 = static_cast<int>(std::floor(phi_u));
0329   const int phi1 = phi0 + 1;
0330   const double phi_t = phi_u - std::floor(phi_u);
0331 
0332   // Locate the enclosing radial interval. This works for both uniform and
0333   // geometry-provided radial control positions.
0334   const double r_clamped = std::clamp(r, control_r(0), control_r(m_nControlR - 1));
0335   auto upper = std::upper_bound(m_controlRPositions.begin(), m_controlRPositions.end(), r_clamped);
0336   int r1 = static_cast<int>(upper - m_controlRPositions.begin());
0337   if (r1 <= 0)
0338   {
0339     r1 = 1;
0340   }
0341   if (r1 >= m_nControlR)
0342   {
0343     r1 = m_nControlR - 1;
0344   }
0345   const int r0 = r1 - 1;
0346   const double r_span = std::max(control_r(r1) - control_r(r0), 1e-6);
0347   const double r_t = std::clamp((r_clamped - control_r(r0)) / r_span, 0.0, 1.0);
0348 
0349   // Tensor products of the one-dimensional linear weights give the four basis
0350   // coefficients used by fitting and field evaluation.
0351   const double w00 = (1.0 - phi_t) * (1.0 - r_t);
0352   const double w10 = phi_t * (1.0 - r_t);
0353   const double w01 = (1.0 - phi_t) * r_t;
0354   const double w11 = phi_t * r_t;
0355 
0356   indices = {control_index(phi0, r0), control_index(phi1, r0), control_index(phi0, r1), control_index(phi1, r1)};
0357   weights = {w00, w10, w01, w11};
0358 }
0359 
0360 void GlobalFieldFitter::add_smoothness_penalty(std::vector<std::vector<double>> &normal_matrix, const std::vector<int> &full_to_fit_index) const
0361 {
0362   const double lambda = global_field_regularization_lambda;
0363 
0364   // Penalize the periodic phi second difference c[p-1]-2c[p]+c[p+1].
0365   // Adding lambda*D^T*D to the normal matrix discourages curvature without
0366   // forcing the field toward zero.
0367   for (int r = 0; r < m_nControlR; r++)
0368   {
0369     for (int p = 0; p < m_nControlPhi; p++)
0370     {
0371       const int im1 = control_index(p - 1, r);
0372       const int i0 = control_index(p, r);
0373       const int ip1 = control_index(p + 1, r);
0374       const double phi_coeffs[3] = {1.0, -2.0, 1.0};
0375       const int phi_fit_indices[3] = {full_to_fit_index[im1], full_to_fit_index[i0], full_to_fit_index[ip1]};
0376       if (phi_fit_indices[0] < 0 || phi_fit_indices[1] < 0 || phi_fit_indices[2] < 0)
0377       {
0378         continue;
0379       }
0380 
0381       for (int a = 0; a < 3; a++)
0382       {
0383         for (int b = 0; b < 3; b++)
0384         {
0385           normal_matrix[phi_fit_indices[a]][phi_fit_indices[b]] += lambda * phi_coeffs[a] * phi_coeffs[b];
0386         }
0387       }
0388     }
0389   }
0390 
0391   // Apply the analogous second-difference penalty along R. Radial endpoints
0392   // have no two-sided stencil and are constrained by data and neighboring rows.
0393   for (int r = 1; r < m_nControlR - 1; r++)
0394   {
0395     for (int p = 0; p < m_nControlPhi; p++)
0396     {
0397       const int jm1 = control_index(p, r - 1);
0398       const int j0 = control_index(p, r);
0399       const int jp1 = control_index(p, r + 1);
0400       const double r_coeffs[3] = {1.0, -2.0, 1.0};
0401       const int r_fit_indices[3] = {full_to_fit_index[jm1], full_to_fit_index[j0], full_to_fit_index[jp1]};
0402       if (r_fit_indices[0] < 0 || r_fit_indices[1] < 0 || r_fit_indices[2] < 0)
0403       {
0404         continue;
0405       }
0406 
0407       for (int a = 0; a < 3; a++)
0408       {
0409         for (int b = 0; b < 3; b++)
0410         {
0411           normal_matrix[r_fit_indices[a]][r_fit_indices[b]] += lambda * r_coeffs[a] * r_coeffs[b];
0412         }
0413       }
0414     }
0415   }
0416 }
0417 
0418 bool GlobalFieldFitter::solve_linear_system(std::vector<std::vector<double>> matrix, std::vector<double> rhs, std::vector<double> &solution) const
0419 {
0420   // Solve the dense augmented system with Gauss-Jordan elimination and partial
0421   // pivoting. The matrices are modest control-grid systems, so avoiding an
0422   // additional linear-algebra dependency is reasonable here.
0423   const size_t n = matrix.size();
0424   solution.assign(n, 0.0);
0425   if (rhs.size() != n)
0426   {
0427     return false;
0428   }
0429 
0430   for (size_t i = 0; i < n; i++)
0431   {
0432     matrix[i].push_back(rhs[i]);
0433   }
0434 
0435   for (size_t pivot_col = 0; pivot_col < n; pivot_col++)
0436   {
0437     // Choose the largest available pivot in this column for numerical stability.
0438     size_t pivot_row = pivot_col;
0439     double pivot_abs = std::abs(matrix[pivot_row][pivot_col]);
0440 
0441     for (size_t row = pivot_col + 1; row < n; row++)
0442     {
0443       const double candidate_abs = std::abs(matrix[row][pivot_col]);
0444       if (candidate_abs > pivot_abs)
0445       {
0446         pivot_abs = candidate_abs;
0447         pivot_row = row;
0448       }
0449     }
0450 
0451     if (pivot_abs < 1e-12)
0452     {
0453       return false;
0454     }
0455 
0456     if (pivot_row != pivot_col)
0457     {
0458       std::swap(matrix[pivot_row], matrix[pivot_col]);
0459     }
0460 
0461     // Normalize the pivot row, then eliminate this column from every other row.
0462     const double pivot = matrix[pivot_col][pivot_col];
0463     for (size_t col = pivot_col; col <= n; col++)
0464     {
0465       matrix[pivot_col][col] /= pivot;
0466     }
0467 
0468     for (size_t row = 0; row < n; row++)
0469     {
0470       if (row == pivot_col)
0471       {
0472         continue;
0473       }
0474 
0475       const double factor = matrix[row][pivot_col];
0476       for (size_t col = pivot_col; col <= n; col++)
0477       {
0478         matrix[row][col] -= factor * matrix[pivot_col][col];
0479       }
0480     }
0481   }
0482 
0483   for (size_t row = 0; row < n; row++)
0484   {
0485     solution[row] = matrix[row][n];
0486   }
0487 
0488   return true;
0489 }
0490 
0491 double GlobalFieldFitter::evaluate_component(const std::vector<double> &coefficients, double phi, double r) const
0492 {
0493   if (!m_isValid || coefficients.empty())
0494   {
0495     return 0.0;
0496   }
0497 
0498   if (static_cast<int>(coefficients.size()) != m_nControlPhi * m_nControlR)
0499   {
0500     // A compact vector contains active controls only and therefore cannot be
0501     // indexed as a rectangular grid. Evaluate it with neighbor interpolation.
0502     return interpolate_active_value(coefficients, phi, r);
0503   }
0504 
0505   std::vector<int> indices;
0506   std::vector<double> weights;
0507   accumulate_bilinear_weights(phi, r, indices, weights);
0508 
0509   double value = 0.0;
0510   for (size_t i = 0; i < indices.size(); i++)
0511   {
0512     value += weights[i] * coefficients[indices[i]];
0513   }
0514 
0515   return value;
0516 }
0517 
0518 void GlobalFieldFitter::compute_control_support()
0519 {
0520   // Support is the sum of bilinear basis weights contributed by observations.
0521   // It is more informative than a hard count near control-cell boundaries.
0522   const int nCtrl = m_nControlPhi * m_nControlR;
0523   m_controlSupport.assign(nCtrl, 0.0);
0524 
0525   for (const auto &observation : m_observations)
0526   {
0527     std::vector<int> indices;
0528     std::vector<double> weights;
0529     accumulate_bilinear_weights(observation[observation_phi], observation[observation_r], indices, weights);
0530     for (size_t i = 0; i < indices.size(); i++)
0531     {
0532       m_controlSupport[indices[i]] += weights[i];
0533     }
0534   }
0535 }
0536 
0537 void GlobalFieldFitter::select_active_controls(std::vector<int> &full_to_fit_index)
0538 {
0539   const int nCtrl = m_nControlPhi * m_nControlR;
0540   full_to_fit_index.assign(nCtrl, -1);
0541   m_activeControl.assign(nCtrl, false);
0542 
0543   if (!use_active_control_interpolation)
0544   {
0545     // Dense mode fits every control directly, regardless of local support.
0546     for (int i = 0; i < nCtrl; i++)
0547     {
0548       m_activeControl[i] = true;
0549       full_to_fit_index[i] = i;
0550     }
0551     return;
0552   }
0553 
0554   // Sparse mode solves only controls with enough direct observation support.
0555   int nActive = 0;
0556   for (int i = 0; i < nCtrl; i++)
0557   {
0558     if (m_controlSupport[i] < global_field_min_control_support)
0559     {
0560       continue;
0561     }
0562 
0563     m_activeControl[i] = true;
0564     full_to_fit_index[i] = nActive++;
0565   }
0566 
0567   if (nActive >= global_field_min_active_controls)
0568   {
0569     return;
0570   }
0571 
0572   // A sparse solve with too few controls is not meaningful. Fall back to the
0573   // dense grid and let regularization plus the nugget stabilize the solution.
0574   for (int i = 0; i < nCtrl; i++)
0575   {
0576     m_activeControl[i] = true;
0577     full_to_fit_index[i] = i;
0578   }
0579 }
0580 
0581 void GlobalFieldFitter::build_dense_evaluation_grid()
0582 {
0583   // Convert compact fitted coefficients into a full grid so subsequent map
0584   // generation uses inexpensive bilinear interpolation everywhere.
0585   const int nCtrl = m_nControlPhi * m_nControlR;
0586   m_evaluation_coefficients_delta_r.assign(nCtrl, 0.0);
0587   m_evaluation_coefficients_r_delta_phi.assign(nCtrl, 0.0);
0588 
0589   if (!use_active_control_interpolation || static_cast<int>(m_coefficients_delta_r.size()) == nCtrl || static_cast<int>(m_coefficients_r_delta_phi.size()) == nCtrl)
0590   {
0591     m_evaluation_coefficients_delta_r = m_coefficients_delta_r;
0592     m_evaluation_coefficients_r_delta_phi = m_coefficients_r_delta_phi;
0593     return;
0594   }
0595 
0596   // Copy fitted active-control values back into their rectangular locations.
0597   int coeff_index = 0;
0598   // Fill unsupported controls from nearby active controls. This extrapolation
0599   // affects evaluation only; inactive values never enter the original solve.
0600   for (int ir = 0; ir < m_nControlR; ir++)
0601   {
0602     for (int iphi = 0; iphi < m_nControlPhi; iphi++)
0603     {
0604       const int full_index = control_index(iphi, ir);
0605       if (full_index >= static_cast<int>(m_activeControl.size()) || !m_activeControl[full_index])
0606       {
0607         continue;
0608       }
0609       if (coeff_index >= static_cast<int>(m_coefficients_delta_r.size()) || coeff_index >= static_cast<int>(m_coefficients_r_delta_phi.size()))
0610       {
0611         break;
0612       }
0613 
0614       m_evaluation_coefficients_delta_r[full_index] = m_coefficients_delta_r[coeff_index];
0615       m_evaluation_coefficients_r_delta_phi[full_index] = m_coefficients_r_delta_phi[coeff_index];
0616       coeff_index++;
0617     }
0618   }
0619 
0620   for (int ir = 0; ir < m_nControlR; ir++)
0621   {
0622     const double r = control_r(ir);
0623     for (int iphi = 0; iphi < m_nControlPhi; iphi++)
0624     {
0625       const int full_index = control_index(iphi, ir);
0626       if (full_index < static_cast<int>(m_activeControl.size()) && m_activeControl[full_index])
0627       {
0628         continue;
0629       }
0630 
0631       const double phi = control_phi(iphi);
0632       m_evaluation_coefficients_delta_r[full_index] = interpolate_active_value(m_coefficients_delta_r, phi, r);
0633       m_evaluation_coefficients_r_delta_phi[full_index] = interpolate_active_value(m_coefficients_r_delta_phi, phi, r);
0634     }
0635   }
0636 }
0637 
0638 double GlobalFieldFitter::control_phi(int phi_index) const
0639 {
0640   int wrapped_phi = phi_index % m_nControlPhi;
0641   if (wrapped_phi < 0)
0642   {
0643     wrapped_phi += m_nControlPhi;
0644   }
0645   return m_phiMin + static_cast<double>(wrapped_phi) * m_phiStep;
0646 }
0647 
0648 double GlobalFieldFitter::control_r(int r_index) const
0649 {
0650   const int clamped_r = std::clamp(r_index, 0, m_nControlR - 1);
0651   if (clamped_r < static_cast<int>(m_controlRPositions.size()))
0652   {
0653     return m_controlRPositions[clamped_r];
0654   }
0655   return m_rMin;
0656 }
0657 
0658 double GlobalFieldFitter::interpolate_active_value(const std::vector<double> &coefficients, double phi, double r) const
0659 {
0660   if (coefficients.empty())
0661   {
0662     return 0.0;
0663   }
0664 
0665   // Retain only the nearest active controls in a scaled detector metric.
0666   // Separate R and R*phi scales encode the intended interpolation anisotropy.
0667   std::vector<std::pair<double, double>> neighbors;
0668   neighbors.reserve(std::max(1, global_field_active_interpolation_neighbors));
0669   int coeff_index = 0;
0670   const int max_neighbors = std::max(1, global_field_active_interpolation_neighbors);
0671   const double r_scale = std::max(1e-6, global_field_active_interpolation_r_scale_cm);
0672   const double rphi_scale = std::max(1e-6, global_field_active_interpolation_rphi_scale_cm);
0673 
0674   for (int ir = 0; ir < m_nControlR; ir++)
0675   {
0676     const double controlR = control_r(ir);
0677     for (int iphi = 0; iphi < m_nControlPhi; iphi++)
0678     {
0679       const int full_index = control_index(iphi, ir);
0680       if (full_index >= static_cast<int>(m_activeControl.size()) || !m_activeControl[full_index])
0681       {
0682         continue;
0683       }
0684       if (coeff_index >= static_cast<int>(coefficients.size()))
0685       {
0686         break;
0687       }
0688 
0689       // Convert angular separation to an arc length at the mean radius.
0690       const double dphi = wrap_delta_phi(phi - control_phi(iphi));
0691       const double dr = r - controlR;
0692       const double rmean = 0.5 * (r + controlR);
0693       const double radial_distance = dr / r_scale;
0694       const double phi_distance = (rmean * dphi) / rphi_scale;
0695       const double dist2 = radial_distance * radial_distance + phi_distance * phi_distance;
0696       if (dist2 <= 1e-12)
0697       {
0698         return coefficients[coeff_index];
0699       }
0700 
0701       if (static_cast<int>(neighbors.size()) < max_neighbors)
0702       {
0703         neighbors.emplace_back(dist2, coefficients[coeff_index]);
0704       }
0705       else
0706       {
0707         auto farthest = std::max_element(neighbors.begin(), neighbors.end(), smaller_neighbor_distance);
0708         if (farthest != neighbors.end() && dist2 < farthest->first)
0709         {
0710           *farthest = std::make_pair(dist2, coefficients[coeff_index]);
0711         }
0712       }
0713       coeff_index++;
0714     }
0715   }
0716 
0717   if (neighbors.empty())
0718   {
0719     return 0.0;
0720   }
0721 
0722   // Inverse-distance weighting supplies a smooth value without introducing a
0723   // second global fit for unsupported controls.
0724   const double power = std::max(0.1, global_field_active_interpolation_power);
0725   double weighted_sum = 0.0;
0726   double weight_sum = 0.0;
0727   for (const auto &neighbor : neighbors)
0728   {
0729     const double dist2 = neighbor.first;
0730     const double value = neighbor.second;
0731     const double dist = std::max(std::sqrt(dist2), 1e-6);
0732     const double weight = 1.0 / std::pow(dist, power);
0733     weighted_sum += weight * value;
0734     weight_sum += weight;
0735   }
0736 
0737   if (weight_sum <= 0.0)
0738   {
0739     return 0.0;
0740   }
0741   return weighted_sum / weight_sum;
0742 }
0743 
0744 double GlobalFieldFitter::estimate_global_residual_sigma(bool fit_delta_r) const
0745 {
0746   if (!m_isValid || m_observations.empty())
0747   {
0748     return 999.0;
0749   }
0750 
0751   // Use MAD rather than RMS so a small number of incorrect stripe assignments
0752   // does not dominate the reported predictive uncertainty.
0753   std::vector<double> residuals;
0754   residuals.reserve(m_observations.size());
0755   for (const auto &observation : m_observations)
0756   {
0757     const double model = fit_delta_r ? evaluate_delta_r(observation[observation_phi], observation[observation_r]) : evaluate_r_delta_phi(observation[observation_phi], observation[observation_r]);
0758     const double value = fit_delta_r ? observation[observation_delta_r] : observation[observation_r_delta_phi];
0759     residuals.push_back(value - model);
0760   }
0761 
0762   return robust_mad_sigma(residuals, fit_delta_r ? fallback_sigma_prior_delta_r_cm : fallback_sigma_prior_r_delta_phi_cm);
0763 }