Back to home page

sPhenix code displayed by LXR

 
 

    


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

0001 #include "StripeComparison.h"
0002 
0003 #include "GlobalFieldFitter.h"
0004 #include "helpers.h"
0005 #include "parameters.h"
0006 
0007 #include <TGraph.h>
0008 #include <TGraph2D.h>
0009 #include <TH1.h>
0010 #include <TH2.h>
0011 #include <TTree.h>
0012 
0013 #include <algorithm>
0014 #include <array>
0015 #include <cmath>
0016 #include <cstddef>
0017 #include <iostream>
0018 #include <limits>
0019 #include <string>
0020 #include <tuple>
0021 #include <utility>
0022 #include <array>
0023 #include <vector>
0024 
0025 // Complete measured-to-reference comparison workflow:
0026 //
0027 //   1. filter stripe candidates and build radial-row bookkeeping;
0028 //   2. run full-R locked branch probes for q = -1, 0, +1;
0029 //   3. select the viable branch with the smallest cleaned score;
0030 //   4. robustly refit and write matching, field, and stripe-residual
0031 //      diagnostics.
0032 //
0033 constexpr std::array<const char *, 2> kComparisonSideNames = {"negz", "posz"};
0034 
0035 bool nearly_same_control_position(double lhs, double rhs)
0036 {
0037   return std::abs(lhs - rhs) < 1e-3;
0038 }
0039 
0040 std::vector<std::array<double, 3>> remove_topology_region_edge_rows(const std::vector<std::array<double, 3>> &stripes, size_t &removed);
0041 
0042 StripeComparison::~StripeComparison()
0043 {
0044   clear();
0045 }
0046 
0047 bool StripeComparison::initialize(const std::vector<std::array<double, 3>> &measured, const std::vector<std::array<double, 3>> &reference, int side, const std::vector<double> &controlRPositions)
0048 {
0049   // Reset all state from the previous detector side and preserve raw inputs for
0050   // output diagnostics before any filtering or matching occurs.
0051   if (measured.empty() || reference.empty())
0052   {
0053     std::cout << "ComputeStripeComparisonMaps: Empty measured or reference stripes" << std::endl;
0054     return false;
0055   }
0056 
0057   clear();
0058   m_sideName = kComparisonSideNames.at(side);
0059   m_controlRPositions = controlRPositions;
0060   std::sort(m_controlRPositions.begin(), m_controlRPositions.end());
0061   m_controlRPositions.erase(std::unique(m_controlRPositions.begin(), m_controlRPositions.end(), nearly_same_control_position), m_controlRPositions.end());
0062   return true;
0063 }
0064 
0065 bool StripeComparison::filter_isolated_inputs(const std::vector<std::array<double, 3>> &measured, const std::vector<std::array<double, 3>> &reference)
0066 {
0067   // Suppress isolated noise candidates before nearest-neighbor construction.
0068   filter_isolated_stripes(measured, m_measuredFiltered);
0069   filter_isolated_stripes(reference, m_referenceFiltered);
0070   size_t removedMeasuredEdgeStripes = 0;
0071   size_t removedReferenceEdgeStripes = 0;
0072   m_measuredFiltered = remove_topology_region_edge_rows(m_measuredFiltered, removedMeasuredEdgeStripes);
0073   m_referenceFiltered = remove_topology_region_edge_rows(m_referenceFiltered, removedReferenceEdgeStripes);
0074   std::cout << "Topology edge-row filter for side " << m_sideName << ": removed measured=" << removedMeasuredEdgeStripes << " reference=" << removedReferenceEdgeStripes << std::endl;
0075   if (m_measuredFiltered.empty() || m_referenceFiltered.empty())
0076   {
0077     std::cout << "ComputeStripeComparisonMaps: Empty measured or reference "
0078                  "after isolation/topology edge filtering"
0079               << std::endl;
0080     return false;
0081   }
0082   return true;
0083 }
0084 
0085 // --------------------------------------------------------------------------
0086 // Flattened data slots used by the stripe assignment pipeline
0087 // --------------------------------------------------------------------------
0088 
0089 // A candidate match is std::array<double, 6>.
0090 // It stores measured index, reference index, displacement values, and a simple
0091 // geometric distance. Indices are stored as doubles only because the flattened
0092 // container is all-double; every use as a vector index casts back to size_t.
0093 inline constexpr int candidate_measured_idx = 0;
0094 inline constexpr int candidate_reference_idx = 1;
0095 inline constexpr int candidate_delta_r = 2;
0096 inline constexpr int candidate_delta_phi = 3;
0097 inline constexpr int candidate_r_delta_phi = 4;
0098 inline constexpr int candidate_distance = 5;
0099 
0100 
0101 // A region is std::array<size_t, 2> = {begin row, one-past-last row}.
0102 inline constexpr int region_begin = 0;
0103 inline constexpr int region_end = 1;
0104 
0105 // A branch hypothesis result is
0106 // std::tuple<candidates, score, median residual, unmatched fraction,
0107 //            cleaning fraction, rejected-by-cleaning count, iterations>.
0108 inline constexpr int hypothesis_candidates = 0;
0109 inline constexpr int hypothesis_score = 1;
0110 inline constexpr int hypothesis_median_residual = 2;
0111 inline constexpr int hypothesis_unmatched_fraction = 3;
0112 inline constexpr int hypothesis_cleaning_fraction = 4;
0113 inline constexpr int hypothesis_rejected_by_cleaning = 5;
0114 inline constexpr int hypothesis_iterations = 6;
0115 
0116 
0117 void sort_topology_row(std::pair<double, std::vector<size_t>> &row, const std::vector<std::array<double, 3>> &stripes)
0118 {
0119   // row.first is the row center R.
0120   // row.second is the list of stripe indices in this radial row.
0121   // Phi ordering supplies the topology needed for +/- one-stripe seed shifts.
0122   for (size_t i = 0; i < row.second.size(); i++)
0123   {
0124     for (size_t j = i + 1; j < row.second.size(); j++)
0125     {
0126       if (stripes[row.second[j]][stripe_phi] < stripes[row.second[i]][stripe_phi])
0127       {
0128         std::swap(row.second[i], row.second[j]);
0129       }
0130     }
0131   }
0132   if (row.second.empty())
0133   {
0134     return;
0135   }
0136   double sum = 0.0;
0137   for (size_t index : row.second)
0138   {
0139     sum += stripes[index][stripe_r];
0140   }
0141   row.first = sum / static_cast<double>(row.second.size());
0142 }
0143 
0144 std::vector<std::pair<double, std::vector<size_t>>> build_topology_rows(const std::vector<std::array<double, 3>> &stripes)
0145 {
0146   // Return rows as std::pair<center R, vector of stripe indices>.
0147   // Build radial bands from this stripe set alone. A new row begins when the
0148   // next radius lies beyond the tolerance from the running row center.
0149   std::vector<size_t> radialOrder(stripes.size());
0150   for (size_t i = 0; i < stripes.size(); ++i)
0151   {
0152     radialOrder[i] = i;
0153   }
0154   for (size_t i = 0; i < radialOrder.size(); i++)
0155   {
0156     for (size_t j = i + 1; j < radialOrder.size(); j++)
0157     {
0158       if (stripes[radialOrder[j]][stripe_r] < stripes[radialOrder[i]][stripe_r])
0159       {
0160         std::swap(radialOrder[i], radialOrder[j]);
0161       }
0162     }
0163   }
0164   std::vector<std::pair<double, std::vector<size_t>>> rows;
0165   for (size_t index : radialOrder)
0166   {
0167     if (rows.empty() || std::abs(stripes[index][stripe_r] - rows.back().first) > topology_row_tolerance_cm)
0168     {
0169       rows.push_back({stripes[index][stripe_r], {index}});
0170       continue;
0171     }
0172     auto &row = rows.back();
0173     row.second.push_back(index);
0174     row.first += (stripes[index][stripe_r] - row.first) / static_cast<double>(row.second.size());
0175   }
0176   for (auto &row : rows)
0177   {
0178     sort_topology_row(row, stripes);
0179   }
0180   std::vector<std::pair<double, std::vector<size_t>>> keptRows;
0181   for (const auto &row : rows)
0182   {
0183     if (static_cast<int>(row.second.size()) >= topology_min_stripes_per_row)
0184     {
0185       keptRows.push_back(row);
0186     }
0187   }
0188   rows.swap(keptRows);
0189   return rows;
0190 }
0191 
0192 std::vector<std::array<size_t, 2>> build_topology_regions(const std::vector<std::pair<double, std::vector<size_t>>> &rows)
0193 {
0194   // Split rows into coarse radial regions at the largest row-to-row gaps.
0195   // Each region is {begin row, one-past-last row}.
0196   if (rows.empty())
0197   {
0198     return {};
0199   }
0200 
0201   std::vector<size_t> regionStarts{0};
0202   if (rows.size() > 1 && topology_region_count > 1)
0203   {
0204     std::vector<size_t> gapOrder(rows.size() - 1);
0205     for (size_t i = 0; i < gapOrder.size(); ++i)
0206     {
0207       gapOrder[i] = i;
0208     }
0209     for (size_t i = 0; i < gapOrder.size(); i++)
0210     {
0211       for (size_t j = i + 1; j < gapOrder.size(); j++)
0212       {
0213         const double gapI = rows[gapOrder[i] + 1].first - rows[gapOrder[i]].first;
0214         const double gapJ = rows[gapOrder[j] + 1].first - rows[gapOrder[j]].first;
0215         if (gapJ > gapI)
0216         {
0217           std::swap(gapOrder[i], gapOrder[j]);
0218         }
0219       }
0220     }
0221 
0222     const size_t splitCount = std::min(gapOrder.size(), static_cast<size_t>(std::max(0, topology_region_count - 1)));
0223     for (size_t i = 0; i < splitCount; ++i)
0224     {
0225       regionStarts.push_back(gapOrder[i] + 1);
0226     }
0227     std::sort(regionStarts.begin(), regionStarts.end());
0228   }
0229   regionStarts.push_back(rows.size());
0230 
0231   std::vector<std::array<size_t, 2>> regions;
0232   regions.reserve(regionStarts.size() - 1);
0233   for (size_t region = 0; region + 1 < regionStarts.size(); ++region)
0234   {
0235     regions.push_back({regionStarts[region], regionStarts[region + 1]});
0236   }
0237   return regions;
0238 }
0239 
0240 std::vector<std::array<double, 3>> remove_topology_region_edge_rows(const std::vector<std::array<double, 3>> &stripes, size_t &removed)
0241 {
0242   // Remove configurable inner/outer rows from each topology region.
0243   // This avoids using poorly constrained region boundaries as matching seeds.
0244   removed = 0;
0245   const auto rows = build_topology_rows(stripes);
0246   if (rows.empty())
0247   {
0248     removed = stripes.size();
0249     return {};
0250   }
0251 
0252   const auto regions = build_topology_regions(rows);
0253   std::vector<bool> keep(stripes.size(), false);
0254   for (const auto &row : rows)
0255   {
0256     for (size_t index : row.second)
0257     {
0258       if (index < keep.size())
0259       {
0260         keep[index] = true;
0261       }
0262     }
0263   }
0264   for (const auto &region : regions)
0265   {
0266     const size_t begin = region[region_begin];
0267     const size_t end = region[region_end];
0268     const size_t regionRows = end - begin;
0269     const size_t innerRows = std::min(static_cast<size_t>(std::max(0, topology_excluded_inner_rows_per_region)), regionRows);
0270     const size_t outerRows = std::min(static_cast<size_t>(std::max(0, topology_excluded_outer_rows_per_region)), regionRows);
0271     for (size_t offset = 0; offset < innerRows; ++offset)
0272     {
0273       for (size_t index : rows[begin + offset].second)
0274       {
0275         keep[index] = false;
0276       }
0277     }
0278     for (size_t offset = 0; offset < outerRows; ++offset)
0279     {
0280       for (size_t index : rows[end - 1 - offset].second)
0281       {
0282         keep[index] = false;
0283       }
0284     }
0285   }
0286 
0287   std::vector<std::array<double, 3>> filtered;
0288   filtered.reserve(stripes.size());
0289   for (size_t i = 0; i < stripes.size(); ++i)
0290   {
0291     if (keep[i])
0292     {
0293       filtered.push_back(stripes[i]);
0294     }
0295     else
0296     {
0297       ++removed;
0298     }
0299   }
0300   return filtered;
0301 }
0302 
0303 std::array<double, 6> make_assignment_candidate(size_t measuredIndex, size_t referenceIndex, const std::vector<std::array<double, 3>> &measured, const std::vector<std::array<double, 3>> &reference)
0304 {
0305   // Fill one std::array<double, 6> candidate using candidate_* slots.
0306   // Use measured R for R*DeltaPhi in diagnostics and field observations. The
0307   // broad phi gate and Hungarian field-consistency term use DeltaPhi directly
0308   // so angular aliases are penalized uniformly in radius.
0309   std::array<double, 6> candidate{};
0310   candidate[candidate_measured_idx] = static_cast<double>(measuredIndex);
0311   candidate[candidate_reference_idx] = static_cast<double>(referenceIndex);
0312   candidate[candidate_delta_r] = measured[measuredIndex][stripe_r] - reference[referenceIndex][stripe_r];
0313   candidate[candidate_delta_phi] = wrap_delta_phi(measured[measuredIndex][stripe_phi] - reference[referenceIndex][stripe_phi]);
0314   candidate[candidate_r_delta_phi] = measured[measuredIndex][stripe_r] * candidate[candidate_delta_phi];
0315   candidate[candidate_distance] = std::sqrt(candidate[candidate_delta_r] * candidate[candidate_delta_r] + candidate[candidate_r_delta_phi] * candidate[candidate_r_delta_phi]);
0316   return candidate;
0317 }
0318 
0319 bool assignment_candidate_within_gates(const std::array<double, 6> &candidate)
0320 {
0321   // Fast broad cut before more expensive field-based scoring.
0322   // These are broad admissibility gates, not the final matching rule. The phi
0323   // gate is angular, not RDeltaPhi, so the same DeltaPhi mismatch is rejected
0324   // consistently at small and large radius.
0325   return std::abs(candidate[candidate_delta_r]) <= global_assignment_max_abs_delta_r_cm && std::abs(candidate[candidate_delta_phi]) <= global_assignment_max_abs_delta_phi_rad;
0326 }
0327 
0328 
0329 // --------------------------------------------------------------------------
0330 // Radial-row topology and branch seeds
0331 // --------------------------------------------------------------------------
0332 
0333 std::pair<std::vector<std::pair<double, std::vector<size_t>>>, std::vector<std::pair<double, std::vector<size_t>>>> build_topology_initialization(const std::vector<std::array<double, 3>> &measured, const std::vector<std::array<double, 3>> &reference)
0334 {
0335   // Return {measured rows, reference rows}.
0336   // Rows are discovered independently. Their integer labels are bookkeeping and
0337   // do not assert that measured row a corresponds to reference row a.
0338   return {build_topology_rows(measured), build_topology_rows(reference)};
0339 }
0340 
0341 std::vector<std::array<double, 6>> keep_unique_assignment_candidates(std::vector<std::array<double, 6>> candidates, size_t measuredCount, size_t referenceCount)
0342 {
0343   // Sort by candidate_distance using plain loops, then keep each measured and
0344   // reference index no more than once.
0345   for (size_t i = 0; i < candidates.size(); i++)
0346   {
0347     for (size_t j = i + 1; j < candidates.size(); j++)
0348     {
0349       if (candidates[j][candidate_distance] < candidates[i][candidate_distance])
0350       {
0351         std::swap(candidates[i], candidates[j]);
0352       }
0353     }
0354   }
0355   std::vector<bool> usedMeasured(measuredCount, false);
0356   std::vector<bool> usedReference(referenceCount, false);
0357   std::vector<std::array<double, 6>> unique;
0358   unique.reserve(candidates.size());
0359   for (const auto &candidate : candidates)
0360   {
0361     if (!usedMeasured[static_cast<size_t>(candidate[candidate_measured_idx])] && !usedReference[static_cast<size_t>(candidate[candidate_reference_idx])])
0362     {
0363       usedMeasured[static_cast<size_t>(candidate[candidate_measured_idx])] = true;
0364       usedReference[static_cast<size_t>(candidate[candidate_reference_idx])] = true;
0365       unique.push_back(candidate);
0366     }
0367   }
0368   return unique;
0369 }
0370 
0371 int nearest_reference_row_for_measured_row(const std::pair<std::vector<std::pair<double, std::vector<size_t>>>, std::vector<std::pair<double, std::vector<size_t>>>> &topology, size_t measuredRow)
0372 {
0373   // topology.first is measured rows and topology.second is reference rows.
0374   // Pick the reference row with the closest radial center before applying any
0375   // tested branch shift.
0376   if (measuredRow >= topology.first.size() || topology.second.empty())
0377   {
0378     return -1;
0379   }
0380 
0381   int nearestReferenceRow = 0;
0382   double nearestRadialDistance = std::abs(topology.first[measuredRow].first - topology.second.front().first);
0383   for (size_t referenceRow = 1; referenceRow < topology.second.size(); ++referenceRow)
0384   {
0385     const double radialDistance = std::abs(topology.first[measuredRow].first - topology.second[referenceRow].first);
0386     if (radialDistance < nearestRadialDistance)
0387     {
0388       nearestRadialDistance = radialDistance;
0389       nearestReferenceRow = static_cast<int>(referenceRow);
0390     }
0391   }
0392   return nearestReferenceRow;
0393 }
0394 
0395 int expected_reference_row_ordinal(size_t measuredOrdinal, size_t measuredCount, size_t referenceCount, int branchShift)
0396 {
0397   // Map a local measured-row ordinal into the same fractional position in the
0398   // reference region, then offset it by the tested branch. This handles regions
0399   // with different row counts without assuming one-to-one row numbering.
0400   if (referenceCount == 0)
0401   {
0402     return -1;
0403   }
0404 
0405   size_t referenceOrdinal = 0;
0406   if (measuredCount > 1 && referenceCount > 1)
0407   {
0408     const double rowFraction = static_cast<double>(measuredOrdinal) / static_cast<double>(measuredCount - 1);
0409     referenceOrdinal = std::min(static_cast<size_t>(std::lround(rowFraction * static_cast<double>(referenceCount - 1))), referenceCount - 1);
0410   }
0411   return static_cast<int>(referenceOrdinal) + branchShift;
0412 }
0413 
0414 std::vector<int> solve_monotone_region_row_map(const std::pair<std::vector<std::pair<double, std::vector<size_t>>>, std::vector<std::pair<double, std::vector<size_t>>>> &topology, const std::array<size_t, 2> &measuredRegion, const std::array<size_t, 2> &referenceRegion, int branchShift, double &medianDeltaR)
0415 {
0416   // Dynamic programming row matcher for one radial region. It allows measured
0417   // rows to be skipped, allows reference rows to be skipped at no measured cost,
0418   // and keeps the final row order monotone.
0419   const size_t measuredCount = measuredRegion[region_end] > measuredRegion[region_begin] ? measuredRegion[region_end] - measuredRegion[region_begin] : 0;
0420   const size_t referenceCount = referenceRegion[region_end] > referenceRegion[region_begin] ? referenceRegion[region_end] - referenceRegion[region_begin] : 0;
0421   medianDeltaR = 0.0;
0422   std::vector<int> mappedReferenceByMeasured(measuredCount, -1);
0423   if (measuredCount == 0 || referenceCount == 0)
0424   {
0425     return mappedReferenceByMeasured;
0426   }
0427 
0428   std::vector<int> expectedReference(measuredCount, -1);
0429   std::vector<double> deltaRSeeds;
0430   deltaRSeeds.reserve(measuredCount);
0431   for (size_t measuredOrdinal = 0; measuredOrdinal < measuredCount; ++measuredOrdinal)
0432   {
0433     // Build a rough DeltaR prior from the branch-shifted fractional row map.
0434     // The median keeps the later DP cost centered even if a few rows are bad.
0435     expectedReference[measuredOrdinal] = expected_reference_row_ordinal(measuredOrdinal, measuredCount, referenceCount, branchShift);
0436     if (expectedReference[measuredOrdinal] < 0 || expectedReference[measuredOrdinal] >= static_cast<int>(referenceCount))
0437     {
0438       continue;
0439     }
0440     const auto &measuredRow = topology.first[measuredRegion[region_begin] + measuredOrdinal];
0441     const auto &referenceRow = topology.second[referenceRegion[region_begin] + static_cast<size_t>(expectedReference[measuredOrdinal])];
0442     deltaRSeeds.push_back(measuredRow.first - referenceRow.first);
0443   }
0444   if (!deltaRSeeds.empty())
0445   {
0446     medianDeltaR = median_value(deltaRSeeds);
0447   }
0448 
0449   const double sigmaDeltaR = std::max(topology_row_mapping_sigma_delta_r_cm, 1e-6);
0450   const double indexWeight = std::max(0.0, topology_row_mapping_index_weight);
0451   const double measuredSkipCost = std::max(0.0, topology_row_mapping_measured_skip_cost);
0452   const double infinity = 1.0e100;
0453 
0454   std::vector<std::vector<double>> dp(measuredCount + 1, std::vector<double>(referenceCount + 1, infinity));
0455   std::vector<std::vector<char>> previous(measuredCount + 1, std::vector<char>(referenceCount + 1, 0));
0456   // dp[i][j] is the best cost after considering the first i measured rows and
0457   // first j reference rows. previous records whether the best step skipped a
0458   // reference row, skipped a measured row, or matched the two current rows.
0459   dp[0][0] = 0.0;
0460   for (size_t j = 1; j <= referenceCount; ++j)
0461   {
0462     dp[0][j] = 0.0;
0463     previous[0][j] = 'r';
0464   }
0465   for (size_t i = 1; i <= measuredCount; ++i)
0466   {
0467     dp[i][0] = dp[i - 1][0] + measuredSkipCost;
0468     previous[i][0] = 'm';
0469   }
0470 
0471   for (size_t i = 1; i <= measuredCount; ++i)
0472   {
0473     for (size_t j = 1; j <= referenceCount; ++j)
0474     {
0475       double best = dp[i][j - 1];
0476       char choice = 'r';
0477 
0478       const double skipMeasured = dp[i - 1][j] + measuredSkipCost;
0479       if (skipMeasured < best)
0480       {
0481         best = skipMeasured;
0482         choice = 'm';
0483       }
0484 
0485       const auto &measuredRow = topology.first[measuredRegion[region_begin] + i - 1];
0486       const auto &referenceRow = topology.second[referenceRegion[region_begin] + j - 1];
0487       // The match cost combines radial consistency with the expected branch
0488       // position. This prevents a smooth but wrong branch from drifting across
0489       // the region one row at a time.
0490       const double deltaRResidual = ((measuredRow.first - referenceRow.first) - medianDeltaR) / sigmaDeltaR;
0491       const double expectedResidual = static_cast<double>(static_cast<int>(j - 1) - expectedReference[i - 1]);
0492       const double rowPairCost = deltaRResidual * deltaRResidual + indexWeight * expectedResidual * expectedResidual;
0493       const double match = dp[i - 1][j - 1] + rowPairCost;
0494       if (match < best)
0495       {
0496         best = match;
0497         choice = 'x';
0498       }
0499 
0500       dp[i][j] = best;
0501       previous[i][j] = choice;
0502     }
0503   }
0504 
0505   size_t i = measuredCount;
0506   size_t j = referenceCount;
0507   while (i > 0 || j > 0)
0508   {
0509     // Backtrack the selected DP path into absolute reference-row indices.
0510     const char choice = previous[i][j];
0511     if (choice == 'x')
0512     {
0513       mappedReferenceByMeasured[i - 1] = static_cast<int>(referenceRegion[region_begin] + j - 1);
0514       --i;
0515       --j;
0516     }
0517     else if (choice == 'm')
0518     {
0519       --i;
0520     }
0521     else
0522     {
0523       --j;
0524     }
0525   }
0526 
0527   return mappedReferenceByMeasured;
0528 }
0529 
0530 std::vector<int> build_monotone_row_mapping(const std::pair<std::vector<std::pair<double, std::vector<size_t>>>, std::vector<std::pair<double, std::vector<size_t>>>> &topology, int branchShift)
0531 {
0532   // Run the monotone DP independently inside each large radial region. The
0533   // returned vector is indexed by measured row and stores a reference-row index
0534   // or -1 when that measured row has no allowed row in this branch.
0535   std::vector<int> mappedReferenceByMeasured(topology.first.size(), -1);
0536   const auto measuredRegions = build_topology_regions(topology.first);
0537   const auto referenceRegions = build_topology_regions(topology.second);
0538   const size_t regionCount = std::min(measuredRegions.size(), referenceRegions.size());
0539   for (size_t region = 0; region < regionCount; ++region)
0540   {
0541     double medianDeltaR = 0.0;
0542     const auto regionMap = solve_monotone_region_row_map(topology, measuredRegions[region], referenceRegions[region], branchShift, medianDeltaR);
0543     size_t mappedRows = 0;
0544     for (size_t localMeasuredRow = 0; localMeasuredRow < regionMap.size(); ++localMeasuredRow)
0545     {
0546       if (regionMap[localMeasuredRow] < 0)
0547       {
0548         continue;
0549       }
0550       mappedReferenceByMeasured[measuredRegions[region][region_begin] + localMeasuredRow] = regionMap[localMeasuredRow];
0551       ++mappedRows;
0552     }
0553     std::cout << "Row map branch=" << branchShift << " region=" << region << " measuredRows=" << regionMap.size() << " referenceRows=" << (referenceRegions[region][region_end] - referenceRegions[region][region_begin]) << " mappedRows=" << mappedRows << " unmappedRows=" << (regionMap.size() - mappedRows) << " medianDeltaR=" << medianDeltaR << std::endl;
0554   }
0555   return mappedReferenceByMeasured;
0556 }
0557 
0558 std::vector<int> build_nearest_row_mapping(const std::pair<std::vector<std::pair<double, std::vector<size_t>>>, std::vector<std::pair<double, std::vector<size_t>>>> &topology, int branchShift)
0559 {
0560   // Simpler row map: nearest-R reference row plus the tested branch shift.
0561   // This is useful when the rows are already well behaved and the DP is not
0562   // requested by parameters.
0563   std::vector<int> mappedReferenceByMeasured(topology.first.size(), -1);
0564   for (size_t measuredRow = 0; measuredRow < topology.first.size(); ++measuredRow)
0565   {
0566     const int nearestReferenceRow = nearest_reference_row_for_measured_row(topology, measuredRow);
0567     const int lockedReferenceRow = nearestReferenceRow + branchShift;
0568     if (lockedReferenceRow < 0 || lockedReferenceRow >= static_cast<int>(topology.second.size()))
0569     {
0570       continue;
0571     }
0572     mappedReferenceByMeasured[measuredRow] = lockedReferenceRow;
0573   }
0574   return mappedReferenceByMeasured;
0575 }
0576 
0577 void repair_edge_row_branch_mapping(const std::pair<std::vector<std::pair<double, std::vector<size_t>>>, std::vector<std::pair<double, std::vector<size_t>>>> &topology, int branchShift, std::vector<int> &mappedReferenceByMeasuredRow)
0578 {
0579   // Edge rows can be biased by missing neighbors. Use the non-edge row branch
0580   // majority inside each region, then snap disagreeing edge rows back onto that
0581   // majority branch when the repaired row remains in range.
0582   const auto measuredRegions = build_topology_regions(topology.first);
0583   const auto referenceRegions = build_topology_regions(topology.second);
0584   const size_t regionCount = std::min(measuredRegions.size(), referenceRegions.size());
0585   const size_t edgeRows = static_cast<size_t>(std::max(0, topology_edge_row_branch_repair_edge_rows));
0586   if (edgeRows == 0)
0587   {
0588     return;
0589   }
0590 
0591   for (size_t region = 0; region < regionCount; ++region)
0592   {
0593     const auto &measuredRegion = measuredRegions[region];
0594     const auto &referenceRegion = referenceRegions[region];
0595     const size_t measuredCount = measuredRegion[region_end] > measuredRegion[region_begin] ? measuredRegion[region_end] - measuredRegion[region_begin] : 0;
0596     const size_t referenceCount = referenceRegion[region_end] > referenceRegion[region_begin] ? referenceRegion[region_end] - referenceRegion[region_begin] : 0;
0597     if (measuredCount == 0 || referenceCount == 0)
0598     {
0599       continue;
0600     }
0601 
0602     std::vector<int> rowBranchLabels(measuredCount, 999999);
0603     for (size_t measuredOrdinal = 0; measuredOrdinal < measuredCount; ++measuredOrdinal)
0604     {
0605       // Convert absolute row choices into branch labels relative to the
0606       // unshifted fractional row expectation.
0607       const size_t measuredRow = measuredRegion[region_begin] + measuredOrdinal;
0608       if (measuredRow >= mappedReferenceByMeasuredRow.size() || mappedReferenceByMeasuredRow[measuredRow] < 0)
0609       {
0610         continue;
0611       }
0612       const int localReferenceRow = mappedReferenceByMeasuredRow[measuredRow] - static_cast<int>(referenceRegion[region_begin]);
0613       const int expectedReferenceRow = expected_reference_row_ordinal(measuredOrdinal, measuredCount, referenceCount, 0);
0614       if (localReferenceRow < 0 || localReferenceRow >= static_cast<int>(referenceCount) || expectedReferenceRow < 0 || expectedReferenceRow >= static_cast<int>(referenceCount))
0615       {
0616         continue;
0617       }
0618       rowBranchLabels[measuredOrdinal] = localReferenceRow - expectedReferenceRow;
0619     }
0620 
0621     std::vector<int> branchVotes;
0622     branchVotes.reserve(measuredCount);
0623     for (size_t measuredOrdinal = 0; measuredOrdinal < measuredCount; ++measuredOrdinal)
0624     {
0625       // Prefer interior rows when deciding the branch. If no interior row was
0626       // mapped, fall back to all mapped rows so the repair can still proceed.
0627       const bool edgeRow = measuredOrdinal < edgeRows || measuredOrdinal + edgeRows >= measuredCount;
0628       if (!edgeRow && rowBranchLabels[measuredOrdinal] != 999999)
0629       {
0630         branchVotes.push_back(rowBranchLabels[measuredOrdinal]);
0631       }
0632     }
0633     if (branchVotes.empty())
0634     {
0635       for (int label : rowBranchLabels)
0636       {
0637         if (label != 999999)
0638         {
0639           branchVotes.push_back(label);
0640         }
0641       }
0642     }
0643     if (branchVotes.empty())
0644     {
0645       continue;
0646     }
0647 
0648     int majorityBranch = branchVotes.front();
0649     int majorityCount = 0;
0650     for (int candidate : branchVotes)
0651     {
0652       int count = 0;
0653       for (int label : branchVotes)
0654       {
0655         if (label == candidate)
0656         {
0657           ++count;
0658         }
0659       }
0660       if (count > majorityCount || (count == majorityCount && std::abs(candidate - branchShift) < std::abs(majorityBranch - branchShift)))
0661       {
0662         majorityBranch = candidate;
0663         majorityCount = count;
0664       }
0665     }
0666 
0667     size_t repairedRows = 0;
0668     for (size_t measuredOrdinal = 0; measuredOrdinal < measuredCount; ++measuredOrdinal)
0669     {
0670       const bool edgeRow = measuredOrdinal < edgeRows || measuredOrdinal + edgeRows >= measuredCount;
0671       if (!edgeRow || rowBranchLabels[measuredOrdinal] == 999999 || rowBranchLabels[measuredOrdinal] == majorityBranch)
0672       {
0673         continue;
0674       }
0675       const int expectedReferenceRow = expected_reference_row_ordinal(measuredOrdinal, measuredCount, referenceCount, 0);
0676       const int repairedLocalReferenceRow = expectedReferenceRow + majorityBranch;
0677       if (repairedLocalReferenceRow < 0 || repairedLocalReferenceRow >= static_cast<int>(referenceCount))
0678       {
0679         continue;
0680       }
0681       const size_t measuredRow = measuredRegion[region_begin] + measuredOrdinal;
0682       mappedReferenceByMeasuredRow[measuredRow] = static_cast<int>(referenceRegion[region_begin]) + repairedLocalReferenceRow;
0683       ++repairedRows;
0684     }
0685 
0686     std::cout << "Row branch repair testedBranch=" << branchShift << " region=" << region << " orderBranchMajority=" << majorityBranch << " relativeToTested=" << (majorityBranch - branchShift) << " votes=" << branchVotes.size() << " repairedEdgeRows=" << repairedRows << std::endl;
0687   }
0688 }
0689 
0690 std::vector<std::array<double, 6>> build_ordered_row_branch_seed(const std::pair<std::vector<std::pair<double, std::vector<size_t>>>, std::vector<std::pair<double, std::vector<size_t>>>> &topology, const std::vector<std::array<double, 3>> &measured, const std::vector<std::array<double, 3>> &reference, const std::pair<std::vector<int>, std::vector<int>> &allowedCandidates, int rowShift, int phiShift)
0691 {
0692   // Build a seed that assumes a coherent radial branch rather than minimizing
0693   // local displacement. Within each selected row pair, stripes are paired by
0694   // cyclic phi order. This can initialize large-DeltaR branches that a local
0695   // nearest-neighbor seed would avoid.
0696   (void) rowShift;
0697   std::vector<std::array<double, 6>> seed;
0698   for (size_t measuredRow = 0; measuredRow < topology.first.size(); ++measuredRow)
0699   {
0700     const auto &measuredIndices = topology.first[measuredRow].second;
0701     int referenceRow = -1;
0702     for (size_t measuredIndex : measuredIndices)
0703     {
0704       if (measuredIndex < allowedCandidates.second.size() && allowedCandidates.second[measuredIndex] >= 0)
0705       {
0706         referenceRow = allowedCandidates.second[measuredIndex];
0707         break;
0708       }
0709     }
0710     if (referenceRow < 0 || referenceRow >= static_cast<int>(topology.second.size()))
0711     {
0712       continue;
0713     }
0714 
0715     const auto &referenceIndices = topology.second[static_cast<size_t>(referenceRow)].second;
0716     if (measuredIndices.empty() || referenceIndices.empty())
0717     {
0718       continue;
0719     }
0720 
0721     for (size_t measuredPosition = 0; measuredPosition < measuredIndices.size(); ++measuredPosition)
0722     {
0723       const size_t measuredIndex = measuredIndices[measuredPosition];
0724       size_t referencePosition = 0;
0725       if (measuredIndices.size() == 1 || referenceIndices.size() == 1)
0726       {
0727         referencePosition = 0;
0728       }
0729       else
0730       {
0731         // Pair by fractional phi-order position so rows with different stripe
0732         // counts still seed the same angular neighborhood.
0733         const double rowFraction = static_cast<double>(measuredPosition) / static_cast<double>(measuredIndices.size() - 1);
0734         referencePosition = static_cast<size_t>(std::lround(rowFraction * static_cast<double>(referenceIndices.size() - 1)));
0735         referencePosition = std::min(referencePosition, referenceIndices.size() - 1);
0736       }
0737 
0738       int shiftedReferencePosition = static_cast<int>(referencePosition) + phiShift;
0739       shiftedReferencePosition %= static_cast<int>(referenceIndices.size());
0740       if (shiftedReferencePosition < 0)
0741       {
0742         shiftedReferencePosition += static_cast<int>(referenceIndices.size());
0743       }
0744       const size_t referenceIndex = referenceIndices[static_cast<size_t>(shiftedReferencePosition)];
0745       if (!(measuredIndex < allowedCandidates.second.size() && referenceIndex < allowedCandidates.first.size() && allowedCandidates.second[measuredIndex] >= 0 && allowedCandidates.second[measuredIndex] == allowedCandidates.first[referenceIndex]))
0746       {
0747         continue;
0748       }
0749 
0750       const auto candidate = make_assignment_candidate(measuredIndex, referenceIndex, measured, reference);
0751       if (assignment_candidate_within_gates(candidate))
0752       {
0753         seed.push_back(candidate);
0754       }
0755     }
0756   }
0757   return keep_unique_assignment_candidates(std::move(seed), measured.size(), reference.size());
0758 }
0759 
0760 std::pair<std::vector<int>, std::vector<int>> build_full_r_branch_locked_candidate_mask(const std::pair<std::vector<std::pair<double, std::vector<size_t>>>, std::vector<std::pair<double, std::vector<size_t>>>> &topology, size_t measuredCount, size_t referenceCount, int branchShift, bool applyEdgeRepair)
0761 {
0762   // Branch probes compare coherent row-shift hypotheses over the full detector.
0763   // Each measured row is allowed to match
0764   // only the nearest-R reference row plus the tested branch shift.
0765   std::pair<std::vector<int>, std::vector<int>> allowed;
0766   allowed.first.assign(referenceCount, -1);
0767   allowed.second.assign(measuredCount, -1);
0768   for (size_t referenceRow = 0; referenceRow < topology.second.size(); ++referenceRow)
0769   {
0770     for (size_t referenceIndex : topology.second[referenceRow].second)
0771     {
0772       if (referenceIndex < referenceCount)
0773       {
0774         allowed.first[referenceIndex] = static_cast<int>(referenceRow);
0775       }
0776     }
0777   }
0778   std::vector<int> mappedReferenceByMeasuredRow;
0779   if (use_topology_row_mapping_dp)
0780   {
0781     mappedReferenceByMeasuredRow = build_monotone_row_mapping(topology, branchShift);
0782   }
0783   else
0784   {
0785     mappedReferenceByMeasuredRow = build_nearest_row_mapping(topology, branchShift);
0786     if (applyEdgeRepair && use_topology_edge_row_branch_repair)
0787     {
0788       repair_edge_row_branch_mapping(topology, branchShift, mappedReferenceByMeasuredRow);
0789     }
0790   }
0791 
0792   for (size_t measuredRow = 0; measuredRow < topology.first.size(); ++measuredRow)
0793   {
0794     const int lockedReferenceRow = measuredRow < mappedReferenceByMeasuredRow.size() ? mappedReferenceByMeasuredRow[measuredRow] : -1;
0795     if (lockedReferenceRow < 0 || lockedReferenceRow >= static_cast<int>(topology.second.size()))
0796     {
0797       continue;
0798     }
0799     for (size_t measuredIndex : topology.first[measuredRow].second)
0800     {
0801       if (measuredIndex >= measuredCount)
0802       {
0803         continue;
0804       }
0805       allowed.second[measuredIndex] = lockedReferenceRow;
0806     }
0807   }
0808   return allowed;
0809 }
0810 
0811 // --------------------------------------------------------------------------
0812 // Hungarian assignment and field-guided costs
0813 // --------------------------------------------------------------------------
0814 
0815 std::vector<int> solve_hungarian_assignment(const std::vector<std::vector<double>> &cost)
0816 {
0817   // Hungarian shortest-augmenting-path formulation for a square cost matrix.
0818   // The returned vector maps each matrix row to exactly one matrix column.
0819   const int n = static_cast<int>(cost.size());
0820   if (n == 0)
0821   {
0822     return {};
0823   }
0824 
0825   const double inf = std::numeric_limits<double>::infinity();
0826   std::vector<double> u(n + 1, 0.0);
0827   std::vector<double> v(n + 1, 0.0);
0828   std::vector<int> p(n + 1, 0);
0829   std::vector<int> way(n + 1, 0);
0830 
0831   // Add one matrix row at a time to the current optimal partial assignment.
0832   for (int i = 1; i <= n; i++)
0833   {
0834     p[0] = i;
0835     int j0 = 0;
0836     std::vector<double> minv(n + 1, inf);
0837     std::vector<char> used(n + 1, false);
0838 
0839     // Search an augmenting path in reduced-cost space.
0840     do
0841     {
0842       used[j0] = true;
0843       const int i0 = p[j0];
0844       double delta = inf;
0845       int j1 = 0;
0846 
0847       for (int j = 1; j <= n; j++)
0848       {
0849         if (used[j])
0850         {
0851           continue;
0852         }
0853 
0854         const double cur = cost[i0 - 1][j - 1] - u[i0] - v[j];
0855         if (cur < minv[j])
0856         {
0857           minv[j] = cur;
0858           way[j] = j0;
0859         }
0860         if (minv[j] < delta)
0861         {
0862           delta = minv[j];
0863           j1 = j;
0864         }
0865       }
0866 
0867       if (!std::isfinite(delta))
0868       {
0869         break;
0870       }
0871 
0872       // Update dual potentials and remaining reduced distances.
0873       for (int j = 0; j <= n; j++)
0874       {
0875         if (used[j])
0876         {
0877           u[p[j]] += delta;
0878           v[j] -= delta;
0879         }
0880         else
0881         {
0882           minv[j] -= delta;
0883         }
0884       }
0885       j0 = j1;
0886     } while (p[j0] != 0);
0887 
0888     // Reverse the discovered path to augment the assignment.
0889     do
0890     {
0891       const int j1 = way[j0];
0892       p[j0] = p[j1];
0893       j0 = j1;
0894     } while (j0 != 0);
0895   }
0896 
0897   std::vector<int> row_to_col(n, -1);
0898   for (int j = 1; j <= n; j++)
0899   {
0900     if (p[j] > 0)
0901     {
0902       row_to_col[p[j] - 1] = j - 1;
0903     }
0904   }
0905 
0906   return row_to_col;
0907 }
0908 
0909 double global_assignment_candidate_cost(const std::array<double, 3> &measured, const std::array<double, 3> &reference, const GlobalFieldFitter &field, std::array<double, 6> &candidate)
0910 {
0911   // A pair is inexpensive when both displacement components agree with the
0912   // current smooth field at the measured stripe position. Raw displacement is
0913   // used only for broad gates; it is not itself an assignment cost.
0914   // Use an angular residual in the field-consistency cost so a one-stripe phi
0915   // alias is penalized similarly at small and large radius. DeltaR remains a
0916   // physical cm residual because radial distortions can wander substantially
0917   // while still needing to agree with the smooth fitted field.
0918 
0919   candidate[candidate_delta_r] = measured[stripe_r] - reference[stripe_r];
0920   candidate[candidate_delta_phi] = wrap_delta_phi(measured[stripe_phi] - reference[stripe_phi]);
0921   candidate[candidate_r_delta_phi] = measured[stripe_r] * candidate[candidate_delta_phi];
0922   candidate[candidate_distance] = std::sqrt(candidate[candidate_delta_r] * candidate[candidate_delta_r] + candidate[candidate_r_delta_phi] * candidate[candidate_r_delta_phi]);
0923 
0924   if (std::abs(candidate[candidate_delta_r]) > global_assignment_max_abs_delta_r_cm)
0925   {
0926     return std::numeric_limits<double>::infinity();
0927   }
0928   if (std::abs(candidate[candidate_delta_phi]) > global_assignment_max_abs_delta_phi_rad)
0929   {
0930     return std::numeric_limits<double>::infinity();
0931   }
0932   if (!field.is_valid())
0933   {
0934     return std::numeric_limits<double>::infinity();
0935   }
0936   // Normalize radial residuals in cm and angular residuals in radians. The field
0937   // itself is still fitted in RDeltaPhi units, but evaluate_delta_phi converts
0938   // it to the angular displacement needed for radius-independent phi matching.
0939   const double fieldDeltaR = field.evaluate_delta_r(measured[stripe_phi], measured[stripe_r]);
0940   const double fieldDeltaPhi = field.evaluate_delta_phi(measured[stripe_phi], measured[stripe_r]);
0941   const double deltaRSigma = std::max(global_assignment_field_sigma_delta_r_cm, 1e-6);
0942   const double deltaPhiSigma = std::max(global_assignment_field_sigma_delta_phi_rad, 1e-6);
0943   const double deltaRResidual = (candidate[candidate_delta_r] - fieldDeltaR) / deltaRSigma;
0944   const double deltaPhiResidual = wrap_delta_phi(candidate[candidate_delta_phi] - fieldDeltaPhi) / deltaPhiSigma;
0945   return global_assignment_field_delta_r_weight * deltaRResidual * deltaRResidual + global_assignment_field_delta_phi_weight * deltaPhiResidual * deltaPhiResidual;
0946 }
0947 
0948 std::vector<std::array<double, 6>> solve_global_assignment_once(const std::vector<std::array<double, 3>> &measured, const std::vector<std::array<double, 3>> &reference, const GlobalFieldFitter &field, const std::pair<std::vector<int>, std::vector<int>> &allowedCandidates)
0949 {
0950   // With a branch-locked mask, candidate sets are disjoint by reference row.
0951   // Solve one smaller Hungarian problem per reference row instead of one dense
0952   // all-stripe problem. This is exactly equivalent because no assignment in one
0953   // row can use a reference stripe from another row.
0954   if (measured.empty() || reference.empty())
0955   {
0956     return {};
0957   }
0958 
0959   int rowCount = 0;
0960   for (int row : allowedCandidates.first)
0961   {
0962     rowCount = std::max(rowCount, row + 1);
0963   }
0964   std::vector<std::vector<size_t>> measuredByRow(static_cast<size_t>(rowCount));
0965   std::vector<std::vector<size_t>> referenceByRow(static_cast<size_t>(rowCount));
0966   for (size_t i = 0; i < allowedCandidates.second.size(); ++i)
0967   {
0968     const int row = allowedCandidates.second[i];
0969     if (row >= 0 && row < rowCount)
0970     {
0971       measuredByRow[static_cast<size_t>(row)].push_back(i);
0972     }
0973   }
0974   for (size_t j = 0; j < allowedCandidates.first.size(); ++j)
0975   {
0976     const int row = allowedCandidates.first[j];
0977     if (row >= 0 && row < rowCount)
0978     {
0979       referenceByRow[static_cast<size_t>(row)].push_back(j);
0980     }
0981   }
0982 
0983   const double largeCost = 1.0e9;
0984   std::vector<std::array<double, 6>> matches;
0985   matches.reserve(std::min(measured.size(), reference.size()));
0986   for (int row = 0; row < rowCount; ++row)
0987   {
0988     const auto &measuredIndices = measuredByRow[static_cast<size_t>(row)];
0989     const auto &referenceIndices = referenceByRow[static_cast<size_t>(row)];
0990     const size_t nMeasured = measuredIndices.size();
0991     const size_t nReference = referenceIndices.size();
0992     const size_t n = nMeasured + nReference;
0993     if (nMeasured == 0 || nReference == 0)
0994     {
0995       continue;
0996     }
0997 
0998     std::vector<std::vector<double>> cost(n, std::vector<double>(n, largeCost));
0999     for (size_t localMeasured = 0; localMeasured < nMeasured; ++localMeasured)
1000     {
1001       const size_t measuredIndex = measuredIndices[localMeasured];
1002       for (size_t localReference = 0; localReference < nReference; ++localReference)
1003       {
1004         const size_t referenceIndex = referenceIndices[localReference];
1005         std::array<double, 6> candidate{};
1006         candidate[candidate_measured_idx] = static_cast<double>(measuredIndex);
1007         candidate[candidate_reference_idx] = static_cast<double>(referenceIndex);
1008         const double candidateCost = global_assignment_candidate_cost(measured[measuredIndex], reference[referenceIndex], field, candidate);
1009         if (std::isfinite(candidateCost))
1010         {
1011           cost[localMeasured][localReference] = candidateCost;
1012         }
1013       }
1014       cost[localMeasured][nReference + localMeasured] = global_assignment_unmatched_cost;
1015     }
1016     for (size_t localReference = 0; localReference < nReference; ++localReference)
1017     {
1018       cost[nMeasured + localReference][localReference] = 0.0;
1019     }
1020     for (size_t i = nMeasured; i < n; ++i)
1021     {
1022       for (size_t j = nReference; j < n; ++j)
1023       {
1024         cost[i][j] = 0.0;
1025       }
1026     }
1027 
1028     const auto assignment = solve_hungarian_assignment(cost);
1029     for (size_t localMeasured = 0; localMeasured < nMeasured && localMeasured < assignment.size(); ++localMeasured)
1030     {
1031       const int localReference = assignment[localMeasured];
1032       if (localReference < 0 || localReference >= static_cast<int>(nReference))
1033       {
1034         continue;
1035       }
1036       const size_t measuredIndex = measuredIndices[localMeasured];
1037       const size_t referenceIndex = referenceIndices[static_cast<size_t>(localReference)];
1038       std::array<double, 6> candidate{};
1039       candidate[candidate_measured_idx] = static_cast<double>(measuredIndex);
1040       candidate[candidate_reference_idx] = static_cast<double>(referenceIndex);
1041       const double candidateCost = global_assignment_candidate_cost(measured[measuredIndex], reference[referenceIndex], field, candidate);
1042       if (std::isfinite(candidateCost) && candidateCost <= global_assignment_max_final_cost)
1043       {
1044         matches.push_back(candidate);
1045       }
1046     }
1047   }
1048   return matches;
1049 }
1050 
1051 // --------------------------------------------------------------------------
1052 // Field fitting, assignment iteration, and branch scoring
1053 // --------------------------------------------------------------------------
1054 
1055 std::vector<std::array<double, 6>> make_observations(const std::vector<std::array<double, 6>> &candidates, const std::vector<std::array<double, 3>> &measured, bool useFixedSigma)
1056 {
1057   // Convert candidate arrays into GlobalFieldFitter observation arrays. The
1058   // measured stripe supplies the (phi, R) position and the candidate supplies
1059   // the measured-reference displacement.
1060   std::vector<std::array<double, 6>> observations;
1061   observations.reserve(candidates.size());
1062   for (const auto &candidate : candidates)
1063   {
1064     std::array<double, 6> observation;
1065     observation[observation_phi] = measured[static_cast<size_t>(candidate[candidate_measured_idx])][stripe_phi];
1066     observation[observation_r] = measured[static_cast<size_t>(candidate[candidate_measured_idx])][stripe_r];
1067     observation[observation_delta_r] = candidate[candidate_delta_r];
1068     observation[observation_r_delta_phi] = candidate[candidate_r_delta_phi];
1069     if (useFixedSigma)
1070     {
1071       // During assignment iteration every candidate gets the same uncertainty
1072       // so branch comparisons are not influenced by sample-dependent scales.
1073       observation[observation_sigma_delta_r] = fallback_sigma_prior_delta_r_cm;
1074       observation[observation_sigma_r_delta_phi] = fallback_sigma_prior_r_delta_phi_cm;
1075     }
1076     observations.push_back(observation);
1077   }
1078   return observations;
1079 }
1080 
1081 GlobalFieldFitter *fit_assignment_field(const std::vector<std::array<double, 6>> &candidates, const std::vector<std::array<double, 3>> &measured, const std::vector<double> &controlRPositions)
1082 {
1083   // Iterative Hungarian guidance uses fixed observation uncertainties so every
1084   // hypothesis follows the same update rule before final cleaning.
1085   if (static_cast<int>(candidates.size()) < min_ml_seed_neighbors)
1086   {
1087     return nullptr;
1088   }
1089 
1090   auto observations = make_observations(candidates, measured, true);
1091   auto *field = new GlobalFieldFitter(observations, controlRPositions);
1092   if (!field->fit())
1093   {
1094     delete field;
1095     return nullptr;
1096   }
1097 
1098   return field;
1099 }
1100 
1101 GlobalFieldFitter *fit_robust_field(std::vector<std::array<double, 6>> &observations, const std::vector<double> &controlRPositions)
1102 {
1103   // Final hypothesis fields use component-wise robust global scales. This same
1104   // helper is used by hypothesis scoring and the selected output path.
1105   if (static_cast<int>(observations.size()) < min_ml_seed_neighbors)
1106   {
1107     return nullptr;
1108   }
1109   std::vector<double> deltaRValues;
1110   std::vector<double> rDeltaPhiValues;
1111   deltaRValues.reserve(observations.size());
1112   rDeltaPhiValues.reserve(observations.size());
1113   for (const auto &observation : observations)
1114   {
1115     deltaRValues.push_back(observation[observation_delta_r]);
1116     rDeltaPhiValues.push_back(observation[observation_r_delta_phi]);
1117   }
1118   // Clamp MAD estimates so a nearly constant or highly contaminated sample
1119   // cannot make the regularized solve arbitrarily stiff or weak.
1120   const double sigmaDeltaR = std::clamp(robust_mad_sigma(deltaRValues, fallback_sigma_prior_delta_r_cm), min_sigma_prior_delta_r_cm, max_sigma_prior_delta_r_cm);
1121   const double sigmaRDeltaPhi = std::clamp(robust_mad_sigma(rDeltaPhiValues, fallback_sigma_prior_r_delta_phi_cm), min_sigma_prior_r_delta_phi_cm, max_sigma_prior_r_delta_phi_cm);
1122   for (auto &observation : observations)
1123   {
1124     observation[observation_sigma_delta_r] = sigmaDeltaR;
1125     observation[observation_sigma_r_delta_phi] = sigmaRDeltaPhi;
1126   }
1127   auto *field = new GlobalFieldFitter(observations, controlRPositions);
1128   if (!field->fit())
1129   {
1130     delete field;
1131     return nullptr;
1132   }
1133   return field;
1134 }
1135 
1136 GlobalFieldFitter *fit_robust_assignment_field(const std::vector<std::array<double, 6>> &candidates, const std::vector<std::array<double, 3>> &measured, const std::vector<double> &controlRPositions)
1137 {
1138   // Convert internal candidate records into the observation representation used
1139   // by GlobalFieldFitter, then apply the shared robust final-fit policy.
1140   auto observations = make_observations(candidates, measured, false);
1141   return fit_robust_field(observations, controlRPositions);
1142 }
1143 
1144 double assignment_change_fraction(const std::vector<std::array<double, 6>> &previous, const std::vector<std::array<double, 6>> &current, size_t measuredCount)
1145 {
1146   // Compare the complete assignment state of every measured stripe. The -1
1147   // sentinel ensures match-to-unmatched transitions count toward convergence.
1148   if (measuredCount == 0)
1149   {
1150     return 0.0;
1151   }
1152   std::vector<int> previousReference(measuredCount, -1);
1153   std::vector<int> currentReference(measuredCount, -1);
1154   for (const auto &candidate : previous)
1155   {
1156     if (candidate[candidate_measured_idx] < measuredCount)
1157     {
1158       previousReference[static_cast<size_t>(candidate[candidate_measured_idx])] = static_cast<int>(candidate[candidate_reference_idx]);
1159     }
1160   }
1161   for (const auto &candidate : current)
1162   {
1163     if (candidate[candidate_measured_idx] < measuredCount)
1164     {
1165       currentReference[static_cast<size_t>(candidate[candidate_measured_idx])] = static_cast<int>(candidate[candidate_reference_idx]);
1166     }
1167   }
1168   size_t changed = 0;
1169   for (size_t measuredIndex = 0; measuredIndex < measuredCount; ++measuredIndex)
1170   {
1171     if (previousReference[measuredIndex] != currentReference[measuredIndex])
1172     {
1173       ++changed;
1174     }
1175   }
1176   return static_cast<double>(changed) / static_cast<double>(measuredCount);
1177 }
1178 
1179 std::vector<std::array<double, 6>> clean_assignment_candidates(const std::vector<std::array<double, 6>> &candidates, const std::vector<std::array<double, 3>> &measured, size_t &rejected)
1180 {
1181   // Require each match to agree with robust displacement medians from nearby
1182   // stripes in the same narrow radial band and broad phi neighborhood.
1183   rejected = 0;
1184   std::vector<bool> keep(candidates.size(), true);
1185   for (size_t a = 0; a < candidates.size(); ++a)
1186   {
1187     std::vector<double> neighborDeltaR;
1188     std::vector<double> neighborRDeltaPhi;
1189     for (size_t b = 0; b < candidates.size(); ++b)
1190     {
1191       if (a == b)
1192       {
1193         continue;
1194       }
1195       const auto &measuredA = measured[candidates[a][candidate_measured_idx]];
1196       const auto &measuredB = measured[candidates[b][candidate_measured_idx]];
1197       if (std::abs(measuredA[stripe_r] - measuredB[stripe_r]) < seed_clean_radial_window_cm && std::abs(wrap_delta_phi(measuredA[stripe_phi] - measuredB[stripe_phi])) < seed_clean_phi_window_rad)
1198       {
1199         neighborDeltaR.push_back(candidates[b][candidate_delta_r]);
1200         neighborRDeltaPhi.push_back(candidates[b][candidate_r_delta_phi]);
1201       }
1202     }
1203     // Sparse matches are removed rather than allowed to control extrapolation.
1204     if (static_cast<int>(neighborDeltaR.size()) < seed_clean_min_neighbors)
1205     {
1206       keep[a] = false;
1207       ++rejected;
1208       continue;
1209     }
1210     const double deltaRResidual = candidates[a][candidate_delta_r] - median_value(neighborDeltaR);
1211     const double rDeltaPhiResidual = candidates[a][candidate_r_delta_phi] - median_value(neighborRDeltaPhi);
1212     if (std::hypot(deltaRResidual, rDeltaPhiResidual) > seed_clean_max_local_residual_cm)
1213     {
1214       keep[a] = false;
1215       ++rejected;
1216     }
1217   }
1218 
1219   std::vector<std::array<double, 6>> cleaned;
1220   cleaned.reserve(candidates.size() - rejected);
1221   for (size_t i = 0; i < candidates.size(); ++i)
1222   {
1223     if (keep[i])
1224     {
1225       cleaned.push_back(candidates[i]);
1226     }
1227   }
1228   return cleaned;
1229 }
1230 
1231 std::tuple<std::vector<std::array<double, 6>>, double, double, double, double, size_t, int> run_branch_probe(const std::vector<std::array<double, 6>> &seed, const std::pair<std::vector<int>, std::vector<int>> &allowedCandidates, const std::vector<std::array<double, 3>> &measured, const std::vector<std::array<double, 3>> &reference, const std::vector<double> &controlRPositions)
1232 {
1233   // Follow one seed basin to a complete assignment:
1234   // seed -> fixed-sigma field -> Hungarian -> repeat until stable.
1235   std::tuple<std::vector<std::array<double, 6>>, double, double, double, double, size_t, int> result{std::vector<std::array<double, 6>>{}, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::infinity(), 1.0, 1.0, 0, 0};
1236   std::get<hypothesis_candidates>(result) = seed;
1237   if (std::get<hypothesis_candidates>(result).empty())
1238   {
1239     return result;
1240   }
1241 
1242   const int maximumIterations = std::max(1, global_assignment_max_iterations);
1243   for (int iteration = 0; iteration < maximumIterations; ++iteration)
1244   {
1245     GlobalFieldFitter *field = fit_assignment_field(std::get<hypothesis_candidates>(result), measured, controlRPositions);
1246     if (!field)
1247     {
1248       std::get<hypothesis_candidates>(result).clear();
1249       return result;
1250     }
1251     auto assigned = solve_global_assignment_once(measured, reference, *field, allowedCandidates);
1252     delete field;
1253     // Convergence includes matches that appear or disappear, not just changed
1254     // reference indices among the surviving matched subset.
1255     const double changedFraction = assignment_change_fraction(std::get<hypothesis_candidates>(result), assigned, measured.size());
1256     std::get<hypothesis_iterations>(result) = iteration + 1;
1257     if (assigned.empty())
1258     {
1259       std::get<hypothesis_candidates>(result).clear();
1260       return result;
1261     }
1262     std::get<hypothesis_candidates>(result) = std::move(assigned);
1263     if (changedFraction < global_assignment_convergence_fraction)
1264     {
1265       break;
1266     }
1267   }
1268 
1269   // Hypothesis comparison is performed only after applying the exact production
1270   // cleaner and the robust final-field fit.
1271   const size_t preCleaningCount = std::get<hypothesis_candidates>(result).size();
1272   std::get<hypothesis_candidates>(result) = clean_assignment_candidates(std::get<hypothesis_candidates>(result), measured, std::get<hypothesis_rejected_by_cleaning>(result));
1273   if (std::get<hypothesis_candidates>(result).empty())
1274   {
1275     return result;
1276   }
1277   GlobalFieldFitter *finalField = fit_robust_assignment_field(std::get<hypothesis_candidates>(result), measured, controlRPositions);
1278   if (!finalField)
1279   {
1280     std::get<hypothesis_candidates>(result).clear();
1281     return result;
1282   }
1283   // Score the cleaned field by robust residual, unmatched fraction, and the
1284   // fraction removed by cleaning. All hypotheses use identical weights.
1285   std::vector<double> residuals;
1286   residuals.reserve(std::get<hypothesis_candidates>(result).size());
1287   for (const auto &candidate : std::get<hypothesis_candidates>(result))
1288   {
1289     const auto &stripe = measured[static_cast<size_t>(candidate[candidate_measured_idx])];
1290     const double deltaRResidual = candidate[candidate_delta_r] - finalField->evaluate_delta_r(stripe[stripe_phi], stripe[stripe_r]);
1291     const double rDeltaPhiResidual = candidate[candidate_r_delta_phi] - finalField->evaluate_r_delta_phi(stripe[stripe_phi], stripe[stripe_r]);
1292     residuals.push_back(std::hypot(deltaRResidual, rDeltaPhiResidual));
1293   }
1294   std::get<hypothesis_median_residual>(result) = median_value(residuals);
1295   std::get<hypothesis_unmatched_fraction>(result) = 1.0 - static_cast<double>(std::get<hypothesis_candidates>(result).size()) / static_cast<double>(std::max<size_t>(1, measured.size()));
1296   std::get<hypothesis_cleaning_fraction>(result) = static_cast<double>(std::get<hypothesis_rejected_by_cleaning>(result)) / static_cast<double>(std::max<size_t>(1, preCleaningCount));
1297   std::get<hypothesis_score>(result) = matching_hypothesis_residual_weight * std::get<hypothesis_median_residual>(result) + matching_hypothesis_unmatched_weight * std::get<hypothesis_unmatched_fraction>(result) + matching_hypothesis_cleaning_weight * std::get<hypothesis_cleaning_fraction>(result);
1298   delete finalField;
1299   return result;
1300 }
1301 
1302 bool StripeComparison::build_global_pattern_matches()
1303 {
1304   // Main matching driver. This function stays deliberately linear:
1305   // build row bookkeeping, probe branch shifts, optionally repair edges, then
1306   // copy the selected flattened candidates into the persistent seed-match array.
1307   auto &measured = m_measuredFiltered;
1308   auto &reference = m_referenceFiltered;
1309   m_seedMatches.clear();
1310 
1311   const auto topology = build_topology_initialization(measured, reference);
1312   const int minShift = matching_branch_probe_min_shift;
1313   const int maxShift = matching_branch_probe_max_shift;
1314   const std::string rowMappingMode = use_topology_row_mapping_dp ? "monotone_dp" : (use_topology_edge_row_branch_repair ? "nearest_r_selected_edge_repair" : "nearest_r");
1315   std::cout << "Branch probe setup for side " << m_sideName << ": measuredRows=" << topology.first.size() << " referenceRows=" << topology.second.size() << " branchProbes=" << (maxShift - minShift + 1) << " rowMapping=" << rowMappingMode << std::endl;
1316 
1317   std::tuple<std::vector<std::array<double, 6>>, double, double, double, double, size_t, int> bestViableResult{std::vector<std::array<double, 6>>{}, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::infinity(), 1.0, 1.0, 0, 0};
1318   std::tuple<std::vector<std::array<double, 6>>, double, double, double, double, size_t, int> bestFallbackResult{std::vector<std::array<double, 6>>{}, std::numeric_limits<double>::infinity(), std::numeric_limits<double>::infinity(), 1.0, 1.0, 0, 0};
1319   std::string bestViableName;
1320   std::string bestFallbackName;
1321   int bestViableBranch = 0;
1322   int bestFallbackBranch = 0;
1323 
1324   // Probe each coherent radial branch. Each branch is independently iterated,
1325   // cleaned, robustly refit, and scored; the selected cleaned probe is the final
1326   // production matching.
1327   for (int branchShift = minShift; branchShift <= maxShift; ++branchShift)
1328   {
1329     const auto branchMask = build_full_r_branch_locked_candidate_mask(topology, measured.size(), reference.size(), branchShift, false);
1330     const auto branchSeed = build_ordered_row_branch_seed(topology, measured, reference, branchMask, branchShift, 0);
1331     const auto result = run_branch_probe(branchSeed, branchMask, measured, reference, m_controlRPositions);
1332     const std::string name = "branch_probe_row_" + std::to_string(branchShift);
1333     const double matchedFraction = static_cast<double>(std::get<hypothesis_candidates>(result).size()) / static_cast<double>(std::max<size_t>(1, measured.size()));
1334     const double cleaningSurvival = 1.0 - std::get<hypothesis_cleaning_fraction>(result);
1335     const bool viable = matchedFraction >= matching_branch_probe_min_matched_fraction && cleaningSurvival >= matching_branch_probe_min_cleaning_survival;
1336 
1337     std::cout << "Branch probe " << name << " for side " << m_sideName << ": seed=" << branchSeed.size() << " final=" << std::get<hypothesis_candidates>(result).size() << " iterations=" << std::get<hypothesis_iterations>(result) << " cleaningRejected=" << std::get<hypothesis_rejected_by_cleaning>(result) << " residualMedian=" << std::get<hypothesis_median_residual>(result) << " unmatchedFraction=" << std::get<hypothesis_unmatched_fraction>(result) << " cleaningFraction=" << std::get<hypothesis_cleaning_fraction>(result) << " totalScore=" << std::get<hypothesis_score>(result) << std::endl;
1338     std::cout << "Branch summary for side " << m_sideName << ": branch=" << branchShift << " bestScore=" << std::get<hypothesis_score>(result) << " bestResidual=" << std::get<hypothesis_median_residual>(result) << " matchedFraction=" << matchedFraction << " cleaningSurvival=" << cleaningSurvival << " viable=" << viable << std::endl;
1339 
1340     if (std::get<hypothesis_score>(result) < std::get<hypothesis_score>(bestFallbackResult))
1341     {
1342       bestFallbackResult = result;
1343       bestFallbackName = name;
1344       bestFallbackBranch = branchShift;
1345     }
1346     if (viable && std::get<hypothesis_score>(result) < std::get<hypothesis_score>(bestViableResult))
1347     {
1348       bestViableResult = result;
1349       bestViableName = name;
1350       bestViableBranch = branchShift;
1351     }
1352   }
1353 
1354   const bool hasViable = !std::get<hypothesis_candidates>(bestViableResult).empty();
1355   auto selectedResult = hasViable ? bestViableResult : bestFallbackResult;
1356   std::string selectedName = hasViable ? bestViableName : bestFallbackName;
1357   const int selectedBranchShift = hasViable ? bestViableBranch : bestFallbackBranch;
1358   std::pair<std::vector<int>, std::vector<int>> selectedMask = build_full_r_branch_locked_candidate_mask(topology, measured.size(), reference.size(), selectedBranchShift, false);
1359   if (std::get<hypothesis_candidates>(selectedResult).empty())
1360   {
1361     std::cout << "ComputeStripeComparisonMaps: Selected branch probe produced no usable assignment" << std::endl;
1362     return false;
1363   }
1364 
1365   if (!use_topology_row_mapping_dp && use_topology_edge_row_branch_repair)
1366   {
1367     // Edge repair is only tried after a branch has already won. That keeps the
1368     // broad branch search simple and uses repair only as a final local cleanup.
1369     const auto repairedMask = build_full_r_branch_locked_candidate_mask(topology, measured.size(), reference.size(), selectedBranchShift, true);
1370     const auto repairedSeed = build_ordered_row_branch_seed(topology, measured, reference, repairedMask, selectedBranchShift, 0);
1371     auto repairedResult = run_branch_probe(repairedSeed, repairedMask, measured, reference, m_controlRPositions);
1372     std::cout << "Selected branch edge repair for side " << m_sideName << ": branch=" << selectedBranchShift << " seed=" << repairedSeed.size() << " final=" << std::get<hypothesis_candidates>(repairedResult).size() << " iterations=" << std::get<hypothesis_iterations>(repairedResult) << " cleaningRejected=" << std::get<hypothesis_rejected_by_cleaning>(repairedResult) << " residualMedian=" << std::get<hypothesis_median_residual>(repairedResult) << " unmatchedFraction=" << std::get<hypothesis_unmatched_fraction>(repairedResult) << " cleaningFraction=" << std::get<hypothesis_cleaning_fraction>(repairedResult) << " totalScore=" << std::get<hypothesis_score>(repairedResult) << std::endl;
1373     if (!std::get<hypothesis_candidates>(repairedResult).empty() && std::get<hypothesis_score>(repairedResult) <= std::get<hypothesis_score>(selectedResult))
1374     {
1375       selectedResult = std::move(repairedResult);
1376       selectedMask = repairedMask;
1377       selectedName += "_edge_repaired";
1378     }
1379     else if (std::get<hypothesis_candidates>(repairedResult).empty())
1380     {
1381       std::cout << "Selected branch edge repair produced no usable assignment; keeping unrepaired selected branch" << std::endl;
1382     }
1383     else
1384     {
1385       std::cout << "Selected branch edge repair worsened score from " << std::get<hypothesis_score>(selectedResult) << " to " << std::get<hypothesis_score>(repairedResult) << "; keeping unrepaired selected branch" << std::endl;
1386     }
1387   }
1388 
1389   const auto &selectedCandidates = std::get<hypothesis_candidates>(selectedResult);
1390   const double selectedScore = std::get<hypothesis_score>(selectedResult);
1391   m_selectedBranchShift = selectedBranchShift;
1392   m_selectedReferenceRowByIndex = selectedMask.first;
1393   m_selectedAllowedReferenceRowByMeasured = selectedMask.second;
1394   std::vector<bool> measuredMatched(measured.size(), false);
1395   for (const auto &candidate : selectedCandidates)
1396   {
1397     // m_seedMatches is the stored match table used by the final field fit and
1398     // output trees. It repeats the stripe coordinates so later diagnostics do
1399     // not need to chase measured/reference indices for every value.
1400     measuredMatched[static_cast<size_t>(candidate[candidate_measured_idx])] = true;
1401     std::array<double, 9> seed{};
1402     seed[seed_measured_idx] = candidate[candidate_measured_idx];
1403     seed[seed_reference_idx] = candidate[candidate_reference_idx];
1404     seed[seed_phi] = measured[static_cast<size_t>(candidate[candidate_measured_idx])][stripe_phi];
1405     seed[seed_r] = measured[static_cast<size_t>(candidate[candidate_measured_idx])][stripe_r];
1406     seed[seed_reference_phi] = reference[static_cast<size_t>(candidate[candidate_reference_idx])][stripe_phi];
1407     seed[seed_reference_r] = reference[static_cast<size_t>(candidate[candidate_reference_idx])][stripe_r];
1408     seed[seed_delta_r] = seed[seed_r] - seed[seed_reference_r];
1409     seed[seed_delta_phi] = wrap_delta_phi(seed[seed_phi] - seed[seed_reference_phi]);
1410     seed[seed_r_delta_phi] = seed[seed_r] * seed[seed_delta_phi];
1411     m_seedMatches.push_back(seed);
1412   }
1413 
1414   const size_t unmatched = static_cast<size_t>(std::count(measuredMatched.begin(), measuredMatched.end(), false));
1415   std::cout << "Selected matching hypothesis " << selectedName << " for side " << m_sideName << ": score=" << selectedScore << " matches=" << m_seedMatches.size() << " unmatchedMeasured=" << unmatched << "/" << measured.size() << " selectedBranch=" << selectedBranchShift << std::endl;
1416   return true;
1417 }
1418 
1419 bool StripeComparison::build_global_field_estimates()
1420 {
1421   // Refit the selected matches with robust global scales. This is the field
1422   // written to the output file and used for final candidate diagnostics.
1423   m_globalObservations.clear();
1424   m_globalObservations.reserve(m_seedMatches.size());
1425   for (const auto &seed : m_seedMatches)
1426   {
1427     std::array<double, 6> observation;
1428     observation[observation_phi] = seed[seed_phi];
1429     observation[observation_r] = seed[seed_r];
1430     observation[observation_delta_r] = seed[seed_delta_r];
1431     observation[observation_r_delta_phi] = seed[seed_r_delta_phi];
1432     m_globalObservations.push_back(observation);
1433   }
1434   delete m_globalFieldFitter;
1435   m_globalFieldFitter = fit_robust_field(m_globalObservations, m_controlRPositions);
1436   std::cout << "Global field estimates: built " << m_globalObservations.size() << " observations" << std::endl;
1437   return m_globalFieldFitter && m_globalFieldFitter->is_valid();
1438 }
1439 
1440 void StripeComparison::fill_candidate_diagnostics(size_t diagnosticMeasuredIndex, size_t diagnosticReferenceIndex, double &referencePhi, double &referenceR, double &deltaR, double &deltaPhi, double &rDeltaPhi, double &cost, double &residualDeltaR, double &residualDeltaPhi, double &residualRDeltaPhi, int &withinGate)
1441 {
1442   // Fill one row of the candidate diagnostic tree for an arbitrary
1443   // measured/reference pair. The caller chooses which pair to inspect; this
1444   // routine computes raw displacement, gate status, field cost, and residuals.
1445   std::array<double, 6> candidate = make_assignment_candidate(diagnosticMeasuredIndex, diagnosticReferenceIndex, m_measuredFiltered, m_referenceFiltered);
1446   withinGate = assignment_candidate_within_gates(candidate) ? 1 : 0;
1447   cost = global_assignment_candidate_cost(m_measuredFiltered[diagnosticMeasuredIndex], m_referenceFiltered[diagnosticReferenceIndex], *m_globalFieldFitter, candidate);
1448   referencePhi = m_referenceFiltered[diagnosticReferenceIndex][stripe_phi];
1449   referenceR = m_referenceFiltered[diagnosticReferenceIndex][stripe_r];
1450   deltaR = candidate[candidate_delta_r];
1451   deltaPhi = candidate[candidate_delta_phi];
1452   rDeltaPhi = candidate[candidate_r_delta_phi];
1453   residualDeltaR = m_globalFieldFitter->evaluate_delta_r(m_measuredFiltered[diagnosticMeasuredIndex][stripe_phi], m_measuredFiltered[diagnosticMeasuredIndex][stripe_r]) - candidate[candidate_delta_r];
1454   residualDeltaPhi = wrap_delta_phi(m_globalFieldFitter->evaluate_delta_phi(m_measuredFiltered[diagnosticMeasuredIndex][stripe_phi], m_measuredFiltered[diagnosticMeasuredIndex][stripe_r]) - candidate[candidate_delta_phi]);
1455   residualRDeltaPhi = m_globalFieldFitter->evaluate_r_delta_phi(m_measuredFiltered[diagnosticMeasuredIndex][stripe_phi], m_measuredFiltered[diagnosticMeasuredIndex][stripe_r]) - candidate[candidate_r_delta_phi];
1456 }
1457 
1458 void StripeComparison::write_output_maps()
1459 {
1460   // All output is diagnostic ROOT content. The actual matching and field have
1461   // already been computed before this routine starts.
1462   if (!m_globalFieldFitter || !m_globalFieldFitter->is_valid())
1463   {
1464     return;
1465   }
1466 
1467   TGraph prematchMeasured;
1468   // Filtered input positions show exactly what survived isolation and row-edge
1469   // cuts before any assignment was attempted.
1470   prematchMeasured.SetName((std::string("gr_prematch_measured_") + m_sideName).c_str());
1471   prematchMeasured.SetTitle((std::string("Pre-match measured stripes after isolation and topology edge-row filtering - ") + m_sideName + ";#phi [rad];R [cm]").c_str());
1472   prematchMeasured.SetMarkerStyle(20);
1473   for (size_t i = 0; i < m_measuredFiltered.size(); i++)
1474   {
1475     prematchMeasured.SetPoint(static_cast<int>(i), m_measuredFiltered[i][stripe_phi], m_measuredFiltered[i][stripe_r]);
1476   }
1477   safe_write_object(&prematchMeasured);
1478 
1479   TGraph prematchReference;
1480   prematchReference.SetName((std::string("gr_prematch_reference_") + m_sideName).c_str());
1481   prematchReference.SetTitle((std::string("Pre-match reference stripes after isolation and topology edge-row filtering - ") + m_sideName + ";#phi [rad];R [cm]").c_str());
1482   prematchReference.SetMarkerStyle(24);
1483   for (size_t i = 0; i < m_referenceFiltered.size(); i++)
1484   {
1485     prematchReference.SetPoint(static_cast<int>(i), m_referenceFiltered[i][stripe_phi], m_referenceFiltered[i][stripe_r]);
1486   }
1487   safe_write_object(&prematchReference);
1488 
1489   TGraph matchedMeasured;
1490   // Matched measured/reference graphs are written separately so they can be
1491   // overlaid with different marker styles in ROOT.
1492   matchedMeasured.SetName((std::string("gr_hungarian_matched_measured_") + m_sideName).c_str());
1493   matchedMeasured.SetTitle((std::string("Hungarian matched measured stripes in original coordinates - ") + m_sideName + ";#phi [rad];R [cm]").c_str());
1494   matchedMeasured.SetMarkerStyle(20);
1495   for (size_t i = 0; i < m_seedMatches.size(); i++)
1496   {
1497     matchedMeasured.SetPoint(static_cast<int>(i), m_seedMatches[i][seed_phi], m_seedMatches[i][seed_r]);
1498   }
1499   safe_write_object(&matchedMeasured);
1500 
1501   TGraph matchedReference;
1502   matchedReference.SetName((std::string("gr_hungarian_matched_reference_") + m_sideName).c_str());
1503   matchedReference.SetTitle((std::string("Hungarian matched reference stripes - ") + m_sideName + ";#phi [rad];R [cm]").c_str());
1504   matchedReference.SetMarkerStyle(24);
1505   for (size_t i = 0; i < m_seedMatches.size(); i++)
1506   {
1507     matchedReference.SetPoint(static_cast<int>(i), m_seedMatches[i][seed_reference_phi], m_seedMatches[i][seed_reference_r]);
1508   }
1509   safe_write_object(&matchedReference);
1510 
1511   const auto referenceRows = build_topology_rows(m_referenceFiltered);
1512   // Build reverse lookup tables from stripe index to row number. These support
1513   // row-level accounting without carrying a custom row object through the code.
1514   std::vector<int> referenceRowByIndex(m_referenceFiltered.size(), -1);
1515   std::vector<int> referencePositionByIndex(m_referenceFiltered.size(), -1);
1516   for (size_t row = 0; row < referenceRows.size(); ++row)
1517   {
1518     for (size_t position = 0; position < referenceRows[row].second.size(); ++position)
1519     {
1520       const size_t referenceIndex = referenceRows[row].second[position];
1521       if (referenceIndex < m_referenceFiltered.size())
1522       {
1523         referenceRowByIndex[referenceIndex] = static_cast<int>(row);
1524         referencePositionByIndex[referenceIndex] = static_cast<int>(position);
1525       }
1526     }
1527   }
1528 
1529   const auto measuredRows = build_topology_rows(m_measuredFiltered);
1530   std::vector<int> measuredRowByIndex(m_measuredFiltered.size(), -1);
1531   for (size_t row = 0; row < measuredRows.size(); ++row)
1532   {
1533     for (size_t index : measuredRows[row].second)
1534     {
1535       if (index < m_measuredFiltered.size())
1536       {
1537         measuredRowByIndex[index] = static_cast<int>(row);
1538       }
1539     }
1540   }
1541 
1542   std::vector<int> matchedReferenceStripesByRow(referenceRows.size(), 0);
1543   // Count how many stripes each selected branch allowed and how many were
1544   // actually matched. This catches branch-locking failures quickly in output.
1545   std::vector<int> matchedMeasuredStripesByRow(measuredRows.size(), 0);
1546   std::vector<int> allowedMeasuredStripesByReferenceRow(referenceRows.size(), 0);
1547   std::vector<int> allowedMeasuredRowsByReferenceRow(referenceRows.size(), 0);
1548   std::vector<std::vector<char>> allowedMeasuredRowSeen(referenceRows.size(), std::vector<char>(measuredRows.size(), false));
1549   for (const auto &match : m_seedMatches)
1550   {
1551     if (static_cast<size_t>(match[seed_reference_idx]) < referenceRowByIndex.size() && referenceRowByIndex[static_cast<size_t>(match[seed_reference_idx])] >= 0)
1552     {
1553       ++matchedReferenceStripesByRow[static_cast<size_t>(referenceRowByIndex[static_cast<size_t>(match[seed_reference_idx])])];
1554     }
1555     if (static_cast<size_t>(match[seed_measured_idx]) < measuredRowByIndex.size() && measuredRowByIndex[static_cast<size_t>(match[seed_measured_idx])] >= 0)
1556     {
1557       ++matchedMeasuredStripesByRow[static_cast<size_t>(measuredRowByIndex[static_cast<size_t>(match[seed_measured_idx])])];
1558     }
1559   }
1560   for (size_t measuredIndex = 0; measuredIndex < m_selectedAllowedReferenceRowByMeasured.size(); ++measuredIndex)
1561   {
1562     const int referenceRowForMeasured = m_selectedAllowedReferenceRowByMeasured[measuredIndex];
1563     if (referenceRowForMeasured < 0 || referenceRowForMeasured >= static_cast<int>(referenceRows.size()))
1564     {
1565       continue;
1566     }
1567     ++allowedMeasuredStripesByReferenceRow[static_cast<size_t>(referenceRowForMeasured)];
1568     if (measuredIndex < measuredRowByIndex.size() && measuredRowByIndex[measuredIndex] >= 0)
1569     {
1570       allowedMeasuredRowSeen[static_cast<size_t>(referenceRowForMeasured)][static_cast<size_t>(measuredRowByIndex[measuredIndex])] = true;
1571     }
1572   }
1573   for (size_t referenceRowIndex = 0; referenceRowIndex < allowedMeasuredRowSeen.size(); ++referenceRowIndex)
1574   {
1575     allowedMeasuredRowsByReferenceRow[referenceRowIndex] = static_cast<int>(std::count(allowedMeasuredRowSeen[referenceRowIndex].begin(), allowedMeasuredRowSeen[referenceRowIndex].end(), true));
1576   }
1577 
1578   TTree rowDiagnostics((std::string("t_row_assignment_diagnostics_") + m_sideName).c_str(), (std::string("Row assignment diagnostics - ") + m_sideName).c_str());
1579   // One tree holds both reference rows and measured rows. diagnosticKind marks
1580   // which side the row came from so plotting macros can split them later.
1581   int diagnosticKind = 0;
1582   int rowIndex = -1;
1583   double rowCenterR = 0.0;
1584   int rowSize = 0;
1585   int matchedStripeCount = 0;
1586   int allowedStripeCount = 0;
1587   int allowedRowCount = 0;
1588   int selectedBranch = 0;
1589   rowDiagnostics.Branch("kind", &diagnosticKind); // 0=reference row, 1=measured row
1590   rowDiagnostics.Branch("row_index", &rowIndex);
1591   rowDiagnostics.Branch("row_center_r", &rowCenterR);
1592   rowDiagnostics.Branch("row_size", &rowSize);
1593   rowDiagnostics.Branch("matched_stripe_count", &matchedStripeCount);
1594   rowDiagnostics.Branch("allowed_stripe_count", &allowedStripeCount);
1595   rowDiagnostics.Branch("allowed_row_count", &allowedRowCount);
1596   rowDiagnostics.Branch("selected_branch", &selectedBranch);
1597   selectedBranch = m_selectedBranchShift;
1598   diagnosticKind = 0;
1599   for (size_t row = 0; row < referenceRows.size(); ++row)
1600   {
1601     rowIndex = static_cast<int>(row);
1602     rowCenterR = referenceRows[row].first;
1603     rowSize = static_cast<int>(referenceRows[row].second.size());
1604     matchedStripeCount = matchedReferenceStripesByRow[row];
1605     allowedStripeCount = allowedMeasuredStripesByReferenceRow[row];
1606     allowedRowCount = allowedMeasuredRowsByReferenceRow[row];
1607     rowDiagnostics.Fill();
1608   }
1609   diagnosticKind = 1;
1610   for (size_t row = 0; row < measuredRows.size(); ++row)
1611   {
1612     rowIndex = static_cast<int>(row);
1613     rowCenterR = measuredRows[row].first;
1614     rowSize = static_cast<int>(measuredRows[row].second.size());
1615     matchedStripeCount = matchedMeasuredStripesByRow[row];
1616     allowedStripeCount = 0;
1617     allowedRowCount = 0;
1618     rowDiagnostics.Fill();
1619   }
1620   safe_write_object(&rowDiagnostics);
1621 
1622   TTree rowMapDiagnostics((std::string("t_row_mapping_diagnostics_") + m_sideName).c_str(), (std::string("Selected row mapping diagnostics - ") + m_sideName).c_str());
1623   // This tree is measured-row centered. For each measured row it records the
1624   // selected reference row that the branch mask allowed, plus match counts on
1625   // both sides of that row relation.
1626   int measuredRowIndex = -1;
1627   double measuredRowCenterR = 0.0;
1628   int measuredRowSize = 0;
1629   int allowedReferenceRow = -1;
1630   double allowedReferenceRowCenterR = 0.0;
1631   int allowedReferenceRowSize = 0;
1632   int measuredRowMatchedStripes = 0;
1633   int allowedReferenceRowMatchedStripes = 0;
1634   rowMapDiagnostics.Branch("measured_row_index", &measuredRowIndex);
1635   rowMapDiagnostics.Branch("measured_row_center_r", &measuredRowCenterR);
1636   rowMapDiagnostics.Branch("measured_row_size", &measuredRowSize);
1637   rowMapDiagnostics.Branch("allowed_reference_row", &allowedReferenceRow);
1638   rowMapDiagnostics.Branch("allowed_reference_row_center_r", &allowedReferenceRowCenterR);
1639   rowMapDiagnostics.Branch("allowed_reference_row_size", &allowedReferenceRowSize);
1640   rowMapDiagnostics.Branch("measured_row_matched_stripes", &measuredRowMatchedStripes);
1641   rowMapDiagnostics.Branch("allowed_reference_row_matched_stripes", &allowedReferenceRowMatchedStripes);
1642   rowMapDiagnostics.Branch("selected_branch", &selectedBranch);
1643   for (size_t measuredRow = 0; measuredRow < measuredRows.size(); ++measuredRow)
1644   {
1645     measuredRowIndex = static_cast<int>(measuredRow);
1646     measuredRowCenterR = measuredRows[measuredRow].first;
1647     measuredRowSize = static_cast<int>(measuredRows[measuredRow].second.size());
1648     measuredRowMatchedStripes = matchedMeasuredStripesByRow[measuredRow];
1649     allowedReferenceRow = -1;
1650     allowedReferenceRowCenterR = std::numeric_limits<double>::quiet_NaN();
1651     allowedReferenceRowSize = 0;
1652     allowedReferenceRowMatchedStripes = 0;
1653     for (size_t measuredIndex : measuredRows[measuredRow].second)
1654     {
1655       if (measuredIndex < m_selectedAllowedReferenceRowByMeasured.size() && m_selectedAllowedReferenceRowByMeasured[measuredIndex] >= 0)
1656       {
1657         allowedReferenceRow = m_selectedAllowedReferenceRowByMeasured[measuredIndex];
1658         break;
1659       }
1660     }
1661     if (allowedReferenceRow >= 0 && allowedReferenceRow < static_cast<int>(referenceRows.size()))
1662     {
1663       allowedReferenceRowCenterR = referenceRows[static_cast<size_t>(allowedReferenceRow)].first;
1664       allowedReferenceRowSize = static_cast<int>(referenceRows[static_cast<size_t>(allowedReferenceRow)].second.size());
1665       allowedReferenceRowMatchedStripes = matchedReferenceStripesByRow[static_cast<size_t>(allowedReferenceRow)];
1666     }
1667     rowMapDiagnostics.Fill();
1668   }
1669   safe_write_object(&rowMapDiagnostics);
1670 
1671   TTree phiDiagnostics((std::string("t_phi_assignment_diagnostics_") + m_sideName).c_str(), (std::string("Phi assignment diagnostics - ") + m_sideName).c_str());
1672   // Phi diagnostics compare the chosen reference stripe with its immediate
1673   // previous/next neighbors in the same reference row. This makes one-stripe
1674   // angular slips visible without rerunning the matcher.
1675   int matchIndex = 0;
1676   int measuredIndex = -1;
1677   int chosenReferenceIndex = -1;
1678   int referenceRow = -1;
1679   int referencePhiIndex = -1;
1680   int referenceRowSize = 0;
1681   double measuredPhi = 0.0;
1682   double measuredR = 0.0;
1683   double fittedDeltaR = 0.0;
1684   double fittedDeltaPhi = 0.0;
1685   double fittedRDeltaPhi = 0.0;
1686   double chosenReferencePhi = 0.0;
1687   double chosenReferenceR = 0.0;
1688   double chosenDeltaR = 0.0;
1689   double chosenDeltaPhi = 0.0;
1690   double chosenRDeltaPhi = 0.0;
1691   double chosenCost = 0.0;
1692   double chosenResidualDeltaR = 0.0;
1693   double chosenResidualDeltaPhi = 0.0;
1694   double chosenResidualRDeltaPhi = 0.0;
1695   int chosenWithinGate = 0;
1696   int prevReferenceIndex = -1;
1697   int prevWithinGate = 0;
1698   double prevReferencePhi = 0.0;
1699   double prevReferenceR = 0.0;
1700   double prevDeltaR = 0.0;
1701   double prevDeltaPhi = 0.0;
1702   double prevRDeltaPhi = 0.0;
1703   double prevCost = 0.0;
1704   double prevResidualDeltaR = 0.0;
1705   double prevResidualDeltaPhi = 0.0;
1706   double prevResidualRDeltaPhi = 0.0;
1707   int nextReferenceIndex = -1;
1708   int nextWithinGate = 0;
1709   double nextReferencePhi = 0.0;
1710   double nextReferenceR = 0.0;
1711   double nextDeltaR = 0.0;
1712   double nextDeltaPhi = 0.0;
1713   double nextRDeltaPhi = 0.0;
1714   double nextCost = 0.0;
1715   double nextResidualDeltaR = 0.0;
1716   double nextResidualDeltaPhi = 0.0;
1717   double nextResidualRDeltaPhi = 0.0;
1718 
1719   phiDiagnostics.Branch("match_index", &matchIndex);
1720   phiDiagnostics.Branch("measured_index", &measuredIndex);
1721   phiDiagnostics.Branch("chosen_reference_index", &chosenReferenceIndex);
1722   phiDiagnostics.Branch("reference_row", &referenceRow);
1723   phiDiagnostics.Branch("reference_phi_index", &referencePhiIndex);
1724   phiDiagnostics.Branch("reference_row_size", &referenceRowSize);
1725   phiDiagnostics.Branch("measured_phi", &measuredPhi);
1726   phiDiagnostics.Branch("measured_r", &measuredR);
1727   phiDiagnostics.Branch("fitted_delta_r", &fittedDeltaR);
1728   phiDiagnostics.Branch("fitted_delta_phi", &fittedDeltaPhi);
1729   phiDiagnostics.Branch("fitted_r_delta_phi", &fittedRDeltaPhi);
1730   phiDiagnostics.Branch("chosen_reference_phi", &chosenReferencePhi);
1731   phiDiagnostics.Branch("chosen_reference_r", &chosenReferenceR);
1732   phiDiagnostics.Branch("chosen_delta_r", &chosenDeltaR);
1733   phiDiagnostics.Branch("chosen_delta_phi", &chosenDeltaPhi);
1734   phiDiagnostics.Branch("chosen_r_delta_phi", &chosenRDeltaPhi);
1735   phiDiagnostics.Branch("chosen_cost", &chosenCost);
1736   phiDiagnostics.Branch("chosen_residual_delta_r", &chosenResidualDeltaR);
1737   phiDiagnostics.Branch("chosen_residual_delta_phi", &chosenResidualDeltaPhi);
1738   phiDiagnostics.Branch("chosen_residual_r_delta_phi", &chosenResidualRDeltaPhi);
1739   phiDiagnostics.Branch("chosen_within_gate", &chosenWithinGate);
1740   phiDiagnostics.Branch("prev_reference_index", &prevReferenceIndex);
1741   phiDiagnostics.Branch("prev_within_gate", &prevWithinGate);
1742   phiDiagnostics.Branch("prev_reference_phi", &prevReferencePhi);
1743   phiDiagnostics.Branch("prev_reference_r", &prevReferenceR);
1744   phiDiagnostics.Branch("prev_delta_r", &prevDeltaR);
1745   phiDiagnostics.Branch("prev_delta_phi", &prevDeltaPhi);
1746   phiDiagnostics.Branch("prev_r_delta_phi", &prevRDeltaPhi);
1747   phiDiagnostics.Branch("prev_cost", &prevCost);
1748   phiDiagnostics.Branch("prev_residual_delta_r", &prevResidualDeltaR);
1749   phiDiagnostics.Branch("prev_residual_delta_phi", &prevResidualDeltaPhi);
1750   phiDiagnostics.Branch("prev_residual_r_delta_phi", &prevResidualRDeltaPhi);
1751   phiDiagnostics.Branch("next_reference_index", &nextReferenceIndex);
1752   phiDiagnostics.Branch("next_within_gate", &nextWithinGate);
1753   phiDiagnostics.Branch("next_reference_phi", &nextReferencePhi);
1754   phiDiagnostics.Branch("next_reference_r", &nextReferenceR);
1755   phiDiagnostics.Branch("next_delta_r", &nextDeltaR);
1756   phiDiagnostics.Branch("next_delta_phi", &nextDeltaPhi);
1757   phiDiagnostics.Branch("next_r_delta_phi", &nextRDeltaPhi);
1758   phiDiagnostics.Branch("next_cost", &nextCost);
1759   phiDiagnostics.Branch("next_residual_delta_r", &nextResidualDeltaR);
1760   phiDiagnostics.Branch("next_residual_delta_phi", &nextResidualDeltaPhi);
1761   phiDiagnostics.Branch("next_residual_r_delta_phi", &nextResidualRDeltaPhi);
1762 
1763   for (size_t i = 0; i < m_seedMatches.size(); ++i)
1764   {
1765     const auto &match = m_seedMatches[i];
1766     matchIndex = static_cast<int>(i);
1767     measuredIndex = static_cast<int>(static_cast<size_t>(match[seed_measured_idx]));
1768     chosenReferenceIndex = static_cast<int>(static_cast<size_t>(match[seed_reference_idx]));
1769     measuredPhi = match[seed_phi];
1770     measuredR = match[seed_r];
1771     fittedDeltaR = m_globalFieldFitter->evaluate_delta_r(match[seed_phi], match[seed_r]);
1772     fittedDeltaPhi = m_globalFieldFitter->evaluate_delta_phi(match[seed_phi], match[seed_r]);
1773     fittedRDeltaPhi = m_globalFieldFitter->evaluate_r_delta_phi(match[seed_phi], match[seed_r]);
1774     referenceRow = static_cast<size_t>(match[seed_reference_idx]) < referenceRowByIndex.size() ? referenceRowByIndex[static_cast<size_t>(match[seed_reference_idx])] : -1;
1775     referencePhiIndex = static_cast<size_t>(match[seed_reference_idx]) < referencePositionByIndex.size() ? referencePositionByIndex[static_cast<size_t>(match[seed_reference_idx])] : -1;
1776     referenceRowSize = referenceRow >= 0 ? static_cast<int>(referenceRows[static_cast<size_t>(referenceRow)].second.size()) : 0;
1777     fill_candidate_diagnostics(static_cast<size_t>(match[seed_measured_idx]), static_cast<size_t>(match[seed_reference_idx]), chosenReferencePhi, chosenReferenceR, chosenDeltaR, chosenDeltaPhi, chosenRDeltaPhi, chosenCost, chosenResidualDeltaR, chosenResidualDeltaPhi, chosenResidualRDeltaPhi, chosenWithinGate);
1778 
1779     prevReferenceIndex = -1;
1780     prevWithinGate = 0;
1781     prevReferencePhi = prevReferenceR = prevDeltaR = prevDeltaPhi = prevRDeltaPhi = prevCost = prevResidualDeltaR = prevResidualDeltaPhi = prevResidualRDeltaPhi = std::numeric_limits<double>::quiet_NaN();
1782     nextReferenceIndex = -1;
1783     nextWithinGate = 0;
1784     nextReferencePhi = nextReferenceR = nextDeltaR = nextDeltaPhi = nextRDeltaPhi = nextCost = nextResidualDeltaR = nextResidualDeltaPhi = nextResidualRDeltaPhi = std::numeric_limits<double>::quiet_NaN();
1785     if (referenceRow >= 0 && referenceRowSize > 1 && referencePhiIndex >= 0)
1786     {
1787       // Neighbor indices wrap around the row because phi is periodic.
1788       const auto &rowIndices = referenceRows[static_cast<size_t>(referenceRow)].second;
1789       prevReferenceIndex = static_cast<int>(rowIndices[static_cast<size_t>((referencePhiIndex + referenceRowSize - 1) % referenceRowSize)]);
1790       nextReferenceIndex = static_cast<int>(rowIndices[static_cast<size_t>((referencePhiIndex + 1) % referenceRowSize)]);
1791       fill_candidate_diagnostics(static_cast<size_t>(match[seed_measured_idx]), static_cast<size_t>(prevReferenceIndex), prevReferencePhi, prevReferenceR, prevDeltaR, prevDeltaPhi, prevRDeltaPhi, prevCost, prevResidualDeltaR, prevResidualDeltaPhi, prevResidualRDeltaPhi, prevWithinGate);
1792       fill_candidate_diagnostics(static_cast<size_t>(match[seed_measured_idx]), static_cast<size_t>(nextReferenceIndex), nextReferencePhi, nextReferenceR, nextDeltaR, nextDeltaPhi, nextRDeltaPhi, nextCost, nextResidualDeltaR, nextResidualDeltaPhi, nextResidualRDeltaPhi, nextWithinGate);
1793     }
1794     phiDiagnostics.Fill();
1795   }
1796   safe_write_object(&phiDiagnostics);
1797 
1798   // Control values and support show where the field is directly constrained
1799   // versus reconstructed from neighboring active controls.
1800   TGraph controlPoints;
1801   controlPoints.SetName((std::string("gr_global_field_control_points_") + m_sideName).c_str());
1802   controlPoints.SetTitle((std::string("Global field control points - ") + m_sideName + ";#phi [rad];R [cm]").c_str());
1803   controlPoints.SetMarkerStyle(24);
1804 
1805   TGraph activeControlPoints;
1806   activeControlPoints.SetName((std::string("gr_global_field_active_control_points_") + m_sideName).c_str());
1807   activeControlPoints.SetTitle((std::string("Active global field control points - ") + m_sideName + ";#phi [rad];R [cm]").c_str());
1808   activeControlPoints.SetMarkerStyle(20);
1809 
1810   TGraph2D controlSupport;
1811   controlSupport.SetName((std::string("gr2_global_field_control_support_") + m_sideName).c_str());
1812   controlSupport.SetTitle((std::string("Global field control support - ") + m_sideName + ";#phi [rad];R [cm];Support").c_str());
1813 
1814   TGraph2D controlDeltaR;
1815   controlDeltaR.SetName((std::string("gr2_global_field_control_delta_r_") + m_sideName).c_str());
1816   controlDeltaR.SetTitle((std::string("Global field control #DeltaR - ") + m_sideName + ";#phi [rad];R [cm];#DeltaR [cm]").c_str());
1817 
1818   TGraph2D controlRDeltaPhi;
1819   controlRDeltaPhi.SetName((std::string("gr2_global_field_control_r_delta_phi_") + m_sideName).c_str());
1820   controlRDeltaPhi.SetTitle((std::string("Global field control R#Delta#phi - ") + m_sideName + ";#phi [rad];R [cm];R#Delta#phi [cm]").c_str());
1821 
1822   int activeIndex = 0;
1823   const auto fittedControlPoints = m_globalFieldFitter->control_points();
1824   for (size_t i = 0; i < fittedControlPoints.size(); ++i)
1825   {
1826     const auto &point = fittedControlPoints[i];
1827     controlPoints.SetPoint(static_cast<int>(i), point[control_point_phi], point[control_point_r]);
1828     controlSupport.SetPoint(static_cast<int>(i), point[control_point_phi], point[control_point_r], point[control_point_support]);
1829     controlDeltaR.SetPoint(static_cast<int>(i), point[control_point_phi], point[control_point_r], point[control_point_delta_r]);
1830     controlRDeltaPhi.SetPoint(static_cast<int>(i), point[control_point_phi], point[control_point_r], point[control_point_r_delta_phi]);
1831     if (point[control_point_active])
1832     {
1833       activeControlPoints.SetPoint(activeIndex++, point[control_point_phi], point[control_point_r]);
1834     }
1835   }
1836 
1837   safe_write_object(&controlPoints);
1838   safe_write_object(&activeControlPoints);
1839   safe_write_object(&controlSupport);
1840   safe_write_object(&controlDeltaR);
1841   safe_write_object(&controlRDeltaPhi);
1842 
1843   // Residuals compare cleaned observations with the exact selected hypothesis
1844   // field that is sampled into the production maps below.
1845   const std::string suffix = "_" + m_sideName;
1846   TH1D stripeResidualDeltaR(("h_stripe_residual_delta_r" + suffix).c_str(), ("Stripe fit residual #DeltaR" + suffix + ";fitted-observed #DeltaR [cm];Counts").c_str(), 160, -3.0, 3.0);
1847   TH1D stripeResidualDeltaPhi(("h_stripe_residual_delta_phi" + suffix).c_str(), ("Stripe fit residual #Delta#phi" + suffix + ";fitted-observed #Delta#phi [rad];Counts").c_str(), 160, -0.05, 0.05);
1848   TH1D stripeResidualRDeltaPhi(("h_stripe_residual_r_delta_phi" + suffix).c_str(), ("Stripe fit residual R#Delta#phi" + suffix + ";fitted-observed R#Delta#phi [cm];Counts").c_str(), 160, -3.0, 3.0);
1849   TH1D stripeResidualMagnitude(("h_stripe_residual_magnitude" + suffix).c_str(), ("Stripe fit residual magnitude" + suffix + ";sqrt((#delta#DeltaR)^{2}+(#deltaR#Delta#phi)^{2}) [cm];Counts").c_str(), 120, 0.0, 3.0);
1850   TH2D stripeResidualMagnitudeVsPosition(("h_stripe_residual_magnitude_vs_position" + suffix).c_str(), ("Stripe fit residual magnitude vs position" + suffix + ";measured #phi [rad];R [cm];Residual magnitude [cm]").c_str(), 80, 0.0, 2.0 * M_PI, 52, 20.0, 80.0);
1851   TH2D stripeResidualDeltaRVsR(("h_stripe_residual_delta_r_vs_r" + suffix).c_str(), ("Stripe #DeltaR residual vs R" + suffix + ";R [cm];fitted-observed #DeltaR [cm]").c_str(), 52, 20.0, 80.0, 160, -3.0, 3.0);
1852   TH2D stripeResidualDeltaPhiVsR(("h_stripe_residual_delta_phi_vs_r" + suffix).c_str(), ("Stripe #Delta#phi residual vs R" + suffix + ";R [cm];fitted-observed #Delta#phi [rad]").c_str(), 52, 20.0, 80.0, 160, -0.05, 0.05);
1853 
1854   stripeResidualDeltaR.SetDirectory(nullptr);
1855   stripeResidualDeltaPhi.SetDirectory(nullptr);
1856   stripeResidualRDeltaPhi.SetDirectory(nullptr);
1857   stripeResidualMagnitude.SetDirectory(nullptr);
1858   stripeResidualMagnitudeVsPosition.SetDirectory(nullptr);
1859   stripeResidualDeltaRVsR.SetDirectory(nullptr);
1860   stripeResidualDeltaPhiVsR.SetDirectory(nullptr);
1861   stripeResidualMagnitudeVsPosition.SetStats(false);
1862   stripeResidualDeltaRVsR.SetStats(false);
1863   stripeResidualDeltaPhiVsR.SetStats(false);
1864 
1865   for (const auto &match : m_seedMatches)
1866   {
1867     const double residualDeltaR = m_globalFieldFitter->evaluate_delta_r(match[seed_phi], match[seed_r]) - match[seed_delta_r];
1868     const double residualDeltaPhi = m_globalFieldFitter->evaluate_delta_phi(match[seed_phi], match[seed_r]) - match[seed_delta_phi];
1869     const double residualRDeltaPhi = m_globalFieldFitter->evaluate_r_delta_phi(match[seed_phi], match[seed_r]) - match[seed_r_delta_phi];
1870     const double magnitude = std::hypot(residualDeltaR, residualRDeltaPhi);
1871     stripeResidualDeltaR.Fill(residualDeltaR);
1872     stripeResidualDeltaPhi.Fill(residualDeltaPhi);
1873     stripeResidualRDeltaPhi.Fill(residualRDeltaPhi);
1874     stripeResidualMagnitude.Fill(magnitude);
1875     stripeResidualMagnitudeVsPosition.Fill(match[seed_phi], match[seed_r], magnitude);
1876     stripeResidualDeltaRVsR.Fill(match[seed_r], residualDeltaR);
1877     stripeResidualDeltaPhiVsR.Fill(match[seed_r], residualDeltaPhi);
1878   }
1879 
1880   safe_write_object(&stripeResidualDeltaR);
1881   safe_write_object(&stripeResidualDeltaPhi);
1882   safe_write_object(&stripeResidualRDeltaPhi);
1883   safe_write_object(&stripeResidualMagnitude);
1884   safe_write_object(&stripeResidualMagnitudeVsPosition);
1885   safe_write_object(&stripeResidualDeltaRVsR);
1886   safe_write_object(&stripeResidualDeltaPhiVsR);
1887 
1888   // Sample the continuous field on the standard distortion-map binning.
1889   auto *deltaR = new TH2D((std::string("hIntDistortionR_") + m_sideName).c_str(), (std::string("#DeltaR map, global field estimate - ") + m_sideName + ";#phi [rad];R [cm];#DeltaR [cm]").c_str(), 80, 0.0, 2.0 * M_PI, 52, 20.0, 80.0);
1890   auto *deltaPhi = new TH2D((std::string("hIntDistortionP_") + m_sideName).c_str(), (std::string("#Delta#phi map, global field estimate - ") + m_sideName + ";#phi [rad];R [cm];#Delta#phi [rad]").c_str(), 80, 0.0, 2.0 * M_PI, 52, 20.0, 80.0);
1891   auto *deltaZ = new TH2D((std::string("hIntDistortionZ_") + m_sideName).c_str(), (std::string("#DeltaZ map, global field estimate - ") + m_sideName + ";#phi [rad];R [cm];#DeltaZ [cm]").c_str(), 80, 0.0, 2.0 * M_PI, 52, 20.0, 80.0);
1892 
1893   for (int phiBin = 1; phiBin <= deltaR->GetNbinsX(); ++phiBin)
1894   {
1895     const double phi = deltaR->GetXaxis()->GetBinCenter(phiBin);
1896     for (int rBin = 1; rBin <= deltaR->GetNbinsY(); ++rBin)
1897     {
1898       const double r = deltaR->GetYaxis()->GetBinCenter(rBin);
1899       if (r < fit_r_min_cm || r > fit_r_max_cm)
1900       {
1901         continue;
1902       }
1903       deltaR->SetBinContent(phiBin, rBin, m_globalFieldFitter->evaluate_delta_r(phi, r));
1904       deltaPhi->SetBinContent(phiBin, rBin, m_globalFieldFitter->evaluate_r_delta_phi(phi, r) / r);
1905     }
1906   }
1907 
1908   deltaR->SetStats(false);
1909   deltaPhi->SetStats(false);
1910   deltaZ->SetStats(false);
1911   safe_write_object(deltaR);
1912   safe_write_object(deltaPhi);
1913   safe_write_object(deltaZ);
1914   delete deltaR;
1915   delete deltaPhi;
1916   delete deltaZ;
1917 }
1918 
1919 void StripeComparison::write_shifted_histogram(TH2 *sourceHistogram, const std::string &name, const std::string &title, double shiftSign)
1920 {
1921   // Move histogram content by the fitted field. shiftSign=-1 applies the
1922   // correction to measured data; shiftSign=+1 distorts the reference pattern
1923   // into measured coordinates.
1924   if (!sourceHistogram || !m_globalFieldFitter || !m_globalFieldFitter->is_valid())
1925   {
1926     return;
1927   }
1928 
1929   const int nPhiBins = sourceHistogram->GetNbinsX();
1930   const int nRBins = sourceHistogram->GetNbinsY();
1931   auto *shifted = new TH2D(name.c_str(), title.c_str(), nPhiBins, sourceHistogram->GetXaxis()->GetXmin(), sourceHistogram->GetXaxis()->GetXmax(), nRBins, sourceHistogram->GetYaxis()->GetXmin(), sourceHistogram->GetYaxis()->GetXmax());
1932   shifted->SetDirectory(nullptr);
1933 
1934   for (int phiBin = 1; phiBin <= nPhiBins; ++phiBin)
1935   {
1936     const double phi = sourceHistogram->GetXaxis()->GetBinCenter(phiBin);
1937     for (int rBin = 1; rBin <= nRBins; ++rBin)
1938     {
1939       const double content = sourceHistogram->GetBinContent(phiBin, rBin);
1940       if (content == 0.0)
1941       {
1942         continue;
1943       }
1944 
1945       const double r = sourceHistogram->GetYaxis()->GetBinCenter(rBin);
1946       const double shiftedR = r + shiftSign * m_globalFieldFitter->evaluate_delta_r(phi, r);
1947       double shiftedPhi = phi + shiftSign * m_globalFieldFitter->evaluate_delta_phi(phi, r);
1948       // Keep shifted phi inside the source histogram range before finding the
1949       // destination bin.
1950       const double phiMin = sourceHistogram->GetXaxis()->GetXmin();
1951       const double phiMax = sourceHistogram->GetXaxis()->GetXmax();
1952       const double phiWidth = phiMax - phiMin;
1953       while (shiftedPhi < phiMin)
1954       {
1955         shiftedPhi += phiWidth;
1956       }
1957       while (shiftedPhi >= phiMax)
1958       {
1959         shiftedPhi -= phiWidth;
1960       }
1961 
1962       const int shiftedPhiBin = shifted->GetXaxis()->FindBin(shiftedPhi);
1963       const int shiftedRBin = shifted->GetYaxis()->FindBin(shiftedR);
1964       if (shiftedPhiBin < 1 || shiftedPhiBin > nPhiBins || shiftedRBin < 1 || shiftedRBin > nRBins)
1965       {
1966         continue;
1967       }
1968       shifted->SetBinContent(shiftedPhiBin, shiftedRBin, shifted->GetBinContent(shiftedPhiBin, shiftedRBin) + content);
1969     }
1970   }
1971 
1972   shifted->SetStats(false);
1973   safe_write_object(shifted);
1974   delete shifted;
1975 }
1976 
1977 void StripeComparison::write_corrected_measured_histogram(TH2 *measuredHistogram)
1978 {
1979   // Diagnostic view: measured clusters after subtracting the fitted distortion.
1980   write_shifted_histogram(measuredHistogram, "hPetal_measured_corrected_" + m_sideName, "Measured cluster histogram shifted by fitted distortion map - " + m_sideName + ";#phi [rad];R [cm]", -1.0);
1981 }
1982 
1983 void StripeComparison::write_distorted_reference_histogram(TH2 *referenceHistogram)
1984 {
1985   // Diagnostic view: reference clusters after applying the fitted distortion.
1986   write_shifted_histogram(referenceHistogram, "hPetal_reference_distorted_" + m_sideName, "Reference cluster histogram shifted by fitted distortion map - " + m_sideName + ";#phi [rad];R [cm]", 1.0);
1987 }
1988 
1989 void StripeComparison::clear()
1990 {
1991   // Reset owned memory and all per-side containers so one object can process
1992   // both detector sides without stale state leaking between them.
1993   delete m_globalFieldFitter;
1994   m_globalFieldFitter = nullptr;
1995   m_sideName.clear();
1996   m_controlRPositions.clear();
1997   m_measuredFiltered.clear();
1998   m_referenceFiltered.clear();
1999   m_seedMatches.clear();
2000   m_globalObservations.clear();
2001   m_selectedBranchShift = 0;
2002   m_selectedReferenceRowByIndex.clear();
2003   m_selectedAllowedReferenceRowByMeasured.clear();
2004 }