MUSIC unknown
Analysis for the MUSIC active-target ionization chamber
Loading...
Searching...
No Matches
CalibrateBeam.cpp
Go to the documentation of this file.
1#include "CalibrateBeam.hpp"
2#include <Rtypes.h>
3
4const Int_t kMaxChannels = 35;
5// Raw total of one strip. Strips 0 and 17 are single-ended guards; 1-16 are
6// split L/R and their total is the sum of both ends.
7inline Double_t StripTotalAdc(const UShort_t *l, const UShort_t *r, Int_t s) {
8 return (s == 0 || s == 17) ? Double_t(l[s]) : Double_t(l[s]) + Double_t(r[s]);
9}
10
11// The strip whose total is paired with strip s to gate it: the one before it,
12// except strips 0 and 1 which share the (0, 1) pair because there is nothing
13// before strip 0.
14inline Int_t GatePartner(Int_t s) { return s <= 1 ? 0 : s - 1; }
15const Long64_t kMinSamples = 200;
16const Long64_t kSampleCap = 20000;
17// (L, R) pair cap for the L/R gain-matching passes. Larger than kSampleCap
18// because the short-side "shoulder" anchor is found in a narrow slice of the
19// pairs (long side ≈ 300-350 ADC) that only a small fraction of events
20// populate.
21const Long64_t kPairCap = 100000;
22
23// Defined below; forward-declared so both beam-peak fitters and the beam_peak
24// diagnostic plot share one histogram recipe regardless of definition order.
25void RobustPeakSeed(const std::vector<Float_t> &v, Double_t &mode,
26 Double_t &sigma);
27TH1F *MakeBeamPeakHist(const TString &name, const TString &title,
28 const std::vector<Float_t> &v, Double_t mode,
29 Double_t sigma);
30
31std::vector<ChannelCal> CalibrateBeam::BuildChannels() {
32 std::vector<ChannelCal> chans;
33 ChannelCal c{};
34 c.name = "Strip0";
35 c.side = 'S';
36 c.strip = 0;
37 chans.push_back(c);
38 c.name = "Strip17";
39 c.side = 'S';
40 c.strip = 17;
41 chans.push_back(c);
42 for (Int_t s = 1; s <= 16; s++) {
43 c.name = Form("L%d", s);
44 c.side = 'L';
45 c.strip = s;
46 chans.push_back(c);
47 c.name = Form("R%d", s);
48 c.side = 'R';
49 c.strip = s;
50 chans.push_back(c);
51 }
52 c.name = "Cathode";
53 c.side = 'C';
54 c.strip = -1;
55 chans.push_back(c);
56 return chans;
57}
58
60 if (c.side == 'C')
61 return 0;
62 if (c.side == 'S' && c.strip == 0)
63 return 1;
64 if (c.side == 'S' && c.strip == 17)
65 return 2;
66 if (c.side == 'L' && c.strip >= 1 && c.strip <= 16)
67 return 3 + (c.strip - 1);
68 if (c.side == 'R' && c.strip >= 1 && c.strip <= 16)
69 return 19 + (c.strip - 1);
70 return -1;
71}
72
73Char_t LongSide(Int_t strip) { return (strip % 2 == 0) ? 'R' : 'L'; }
74
75Bool_t IsBeamdEChannel(const ChannelCal &c) {
76 if (c.side == 'S')
77 return kTRUE;
78 if (c.side != 'L' && c.side != 'R')
79 return kFALSE;
80 if (c.strip < 1 || c.strip > 16)
81 return kFALSE;
82 return c.side == LongSide(c.strip);
83}
84
85Bool_t IsCalibrated(const ChannelCal &c) { return c.fit_adc > 0; }
86
87Double_t Gain(const ChannelCal &c) {
88 return c.gain >= 0.0 ? c.gain : 1.0 / c.fit_adc;
89}
90
92 if (c.fit_adc <= 0)
93 return 0.0;
94 const Double_t kFwhmPerSigma = 2.0 * TMath::Sqrt(2.0 * TMath::Log(2.0));
95 return 100.0 * kFwhmPerSigma * c.fit_sigma_adc / c.fit_adc;
96}
97
98inline Double_t ApplyCal(const ChannelCal &c, Double_t adc) {
99 return Gain(c) * adc;
100}
101
102// The beam gate for one strip: a 2D Gaussian on the (strip sx, strip sy) raw
103// totals. Gating a strip on itself and its neighbour selects single-particle
104// events in THAT strip; the gate is on totals, so it says nothing about how
105// the charge divides between the two ends, which is what the anchor fit reads.
106BeamFit2D FindBeamGateStrips(const FileSpec &spec, Int_t sx, Int_t sy,
107 const TString &run_label,
108 const TString &plot_subdir,
109 Bool_t save_plot = kTRUE) {
110 BeamFit2D out;
111
112 TString sub = FileSet::EventsName(spec) + ".root";
113 TFile *sf = IO::OpenForReading(sub);
114 if (!sf || sf->IsZombie()) {
115 if (sf)
116 delete sf;
117 return out;
118 }
119 TTree *tree = static_cast<TTree *>(sf->Get("events"));
120 if (!tree) {
121 sf->Close();
122 delete sf;
123 return out;
124 }
125 // Raw ADC, read before any calibration exists (this fit produces it), so read
126 // the branches directly. strip1 total = L1 + R1; strip2 total = L2 + R2.
127 UShort_t left_0_17_adc[18], rightdE_adc[18];
128 tree->SetBranchAddress("Left_0_17_dE", left_0_17_adc);
129 tree->SetBranchAddress("RightdE", rightdE_adc);
130
131 // Use 1024 bins (previously 256) so each bin is narrower (16 ADC for 37Cl).
132 // The sigma floor in ComputeMoments is 2 * bin_width; at the old 64 ADC/bin
133 // it clamped sigma to 128 ADC, hiding the true beam width and washing out
134 // correlation (rho ≈ 0.09 even though strip1/strip2 track the same beam).
135 const Int_t kBeamGateNBins = 1024;
136 TH2F *h =
137 new TH2F(Form("h2_gate_s%d_s%d_%s", sx, sy, run_label.Data()),
138 Form(";Strip%d #DeltaE [ADC];Strip%d #DeltaE [ADC]", sx, sy),
139 kBeamGateNBins, 0.0, Constants::ActiveStripEMaxAdc(),
140 kBeamGateNBins, 0.0, Constants::ActiveStripEMaxAdc());
141 h->SetDirectory(nullptr);
142 Long64_t n = tree->GetEntries();
143 for (Long64_t j = 0; j < n; j++) {
144 tree->GetEntry(j);
145 const Double_t tx = StripTotalAdc(left_0_17_adc, rightdE_adc, sx);
146 const Double_t ty = StripTotalAdc(left_0_17_adc, rightdE_adc, sy);
147 if (tx > 0.0 && ty > 0.0)
148 h->Fill(tx, ty);
149 }
150 sf->Close();
151 delete sf;
152
153 if (h->GetEntries() < 100) {
154 std::cerr << " " << run_label << ": too few events for the strip " << sy
155 << " beam gate (strips " << sx << " vs " << sy << ")"
156 << std::endl;
157 delete h;
158 return out;
159 }
160
161 const Double_t kSeedFrac = 0.30;
162 // With 1024 bins instead of 256, scale from 10→40 to keep the ADC seed
163 // window roughly 640 ADC (10 bins × 16384/256 = 640; 40 bins × 16384/1024 =
164 // 640).
165 const Int_t kSeedHalfBins = 40;
166 const Int_t kMomentRefineIters = 4;
167 const Double_t kMomentRefineNSigma = 2.5;
168 Double_t bw_x = h->GetXaxis()->GetBinWidth(1);
169 Double_t bw_y = h->GetYaxis()->GetBinWidth(1);
170 Int_t bx, by, bz;
171 h->GetMaximumBin(bx, by, bz);
172 Double_t peak_val = h->GetBinContent(bx, by);
173 Int_t lo_bx = std::max(1, bx - kSeedHalfBins);
174 Int_t hi_bx = std::min(h->GetNbinsX(), bx + kSeedHalfBins);
175 Int_t lo_by = std::max(1, by - kSeedHalfBins);
176 Int_t hi_by = std::min(h->GetNbinsY(), by + kSeedHalfBins);
177 Moments2D m = BeamFitUtils::ComputeMoments(h, lo_bx, hi_bx, lo_by, hi_by,
178 kSeedFrac * peak_val, bw_x, bw_y);
179 if (m.weight <= 0) {
180 std::cerr << " " << run_label << ": no bins above beam seed threshold"
181 << std::endl;
182 delete h;
183 return out;
184 }
185 // Iteratively re-center: recompute the moments inside a ±2.5σ window
186 // around the current centroid. At high rate (run84: 45 kHz) the pileup
187 // blob at ~2x the beam and the correlated diagonal band both pass the
188 // seed threshold; a single wide-window pass then reports a huge, highly
189 // correlated pseudo-blob (sigma ~650, rho 0.95) instead of the beam. The
190 // shrinking window converges onto the dominant (beam) blob.
191 for (Int_t iter = 0; iter < kMomentRefineIters; iter++) {
192 Int_t wlo_bx = std::max(
193 1, h->GetXaxis()->FindBin(m.mu_x - kMomentRefineNSigma * m.sigma_x));
194 Int_t whi_bx = std::min(
195 h->GetNbinsX(),
196 h->GetXaxis()->FindBin(m.mu_x + kMomentRefineNSigma * m.sigma_x));
197 Int_t wlo_by = std::max(
198 1, h->GetYaxis()->FindBin(m.mu_y - kMomentRefineNSigma * m.sigma_y));
199 Int_t whi_by = std::min(
200 h->GetNbinsY(),
201 h->GetYaxis()->FindBin(m.mu_y + kMomentRefineNSigma * m.sigma_y));
203 h, wlo_bx, whi_bx, wlo_by, whi_by, kSeedFrac * peak_val, bw_x, bw_y);
204 if (m_ref.weight <= 0)
205 break;
206 m = m_ref;
207 }
208 out.amp = peak_val;
209 out.mu_x = m.mu_x;
210 out.mu_y = m.mu_y;
211 out.sigma_x = m.sigma_x;
212 out.sigma_y = m.sigma_y;
213 out.rho = m.rho;
214 out.ok = kTRUE;
215 std::cout << " beam gate strip " << sy << " (strips " << sx << " vs " << sy
216 << "): mu=(" << out.mu_x << "," << out.mu_y << ") sigma=("
217 << out.sigma_x << "," << out.sigma_y << ") rho=" << out.rho
218 << std::endl;
219
220 if (save_plot) {
221 TCanvas *cv = PlottingUtils::GetConfiguredCanvas(kFALSE);
222 PlottingUtils::ConfigureAndDraw2DHistogram(h, cv);
223 // Draw the correlated 2D Gaussian ellipse: the TEllipse rotates
224 // according to the covariance eigen-decomposition so the drawn contour
225 // matches the actual InEllipseXY gate (χ² < n²).
226 Double_t sxx = out.sigma_x * out.sigma_x;
227 Double_t syy = out.sigma_y * out.sigma_y;
228 Double_t sxy = out.rho * out.sigma_x * out.sigma_y;
229 Double_t sum = sxx + syy;
230 Double_t diff = sxx - syy;
231 Double_t det = TMath::Sqrt(diff * diff + 4.0 * sxy * sxy);
232 Double_t lambda1 = 0.5 * (sum + det);
233 Double_t lambda2 = 0.5 * (sum - det);
234 Double_t theta = 0.5 * TMath::ATan2(2.0 * sxy, diff) * 180.0 / TMath::Pi();
235 Double_t n = 0.5 * (Constants::cfg.BEAM_GATE_NSIGMA_X +
236 Constants::cfg.BEAM_GATE_NSIGMA_Y);
237 TEllipse *e = new TEllipse(out.mu_x, out.mu_y, n * TMath::Sqrt(lambda1),
238 n * TMath::Sqrt(lambda2), 0, 360, theta);
239 e->SetFillStyle(0);
240 e->SetLineColor(kViolet + 2);
241 e->SetLineWidth(2);
242 e->Draw();
243 if (Constants::cfg.SAVE_PLOTS)
244 PlottingUtils::SaveFigure(cv, Form("beam_gate_s%02d", sy),
245 plot_subdir + "/beam_gate",
246 PlotSaveOptions::kLINEAR);
247 delete cv;
248 }
249 delete h;
250 return out;
251}
252
253inline Double_t Median(std::vector<Float_t> &v) {
254 if (v.empty())
255 return 0.0;
256 Int_t n = Int_t(v.size());
257 std::nth_element(v.begin(), v.begin() + n / 2, v.end());
258 Double_t med = Double_t(v[n / 2]);
259 if (n % 2 == 0) {
260 std::nth_element(v.begin(), v.begin() + n / 2 - 1, v.end());
261 med = 0.5 * (med + Double_t(v[n / 2 - 1]));
262 }
263 return med;
264}
265
266// IQR = Q3 - Q1; divide by 1.349 outside this helper for the Gaussian-sigma
267// approximation when needed.
268inline Double_t InterquartileRange(std::vector<Float_t> &v) {
269 if (v.size() < 4)
270 return 0.0;
271 Int_t n = Int_t(v.size());
272 Int_t i1 = n / 4;
273 Int_t i3 = (3 * n) / 4;
274 std::nth_element(v.begin(), v.begin() + i1, v.end());
275 Double_t q1 = Double_t(v[i1]);
276 std::nth_element(v.begin(), v.begin() + i3, v.end());
277 Double_t q3 = Double_t(v[i3]);
278 return q3 - q1;
279}
280
281// Robust peak/width estimate used to seed the beam-peak Gaussian fit, and as
282// the fallback anchor when the fit fails. Histograms only the
283// 5th-95th-percentile core so outlier ADC values can't stretch the binning,
284// takes the modal bin centre as the peak and IQR/1.349 as the width. Peak-like:
285// unlike the raw sample mean it is not pulled up by the straggling beam-dE
286// tail.
287void RobustPeakSeed(const std::vector<Float_t> &v, Double_t &mode,
288 Double_t &sigma) {
289 mode = 0.0;
290 sigma = 0.0;
291 Int_t n = Int_t(v.size());
292 if (n < 4)
293 return;
294 std::vector<Float_t> s(v);
295 std::sort(s.begin(), s.end());
296 Int_t i_lo = Int_t(0.05 * n);
297 Int_t i_hi = Int_t(0.95 * n);
298 Int_t i_q1 = Int_t(0.25 * n);
299 Int_t i_q3 = Int_t(0.75 * n);
300 if (i_hi >= n)
301 i_hi = n - 1;
302 Double_t p_lo = Double_t(s[i_lo]);
303 Double_t p_hi = Double_t(s[i_hi]);
304 Double_t med = Double_t(s[n / 2]);
305 sigma = (Double_t(s[i_q3]) - Double_t(s[i_q1])) / 1.349;
306 if (!(sigma > 0.0))
307 sigma = 0.05 * (med > 0.0 ? med : 1.0);
308 if (!(p_hi > p_lo)) {
309 mode = med;
310 return;
311 }
312 const Int_t nbins = 100;
313 TH1F h("robust_peak_seed_h", "", nbins, p_lo, p_hi);
314 h.SetDirectory(nullptr);
315 for (Int_t j = 0; j < n; j++)
316 if (Double_t(s[j]) >= p_lo && Double_t(s[j]) <= p_hi)
317 h.Fill(Double_t(s[j]));
318 mode = h.GetBinCenter(h.GetMaximumBin());
319 if (!(mode > 0.0))
320 mode = med;
321}
322
323// Per-channel beam-peak ADC histogram, shared by the fitters and the beam_peak
324// diagnostic plot so a fitted Gaussian's amplitude (counts per bin) always
325// matches the histogram it is drawn over. Range and bin count come from the
326// robust mode/width (mode +/- 4 sigma, ~6 bins/sigma, never finer than 1 ADC),
327// so the binning is chosen consistently per channel instead of from a fixed
328// count over the outlier-stretched min..max range. Caller owns the histogram.
329TH1F *MakeBeamPeakHist(const TString &name, const TString &title,
330 const std::vector<Float_t> &v, Double_t mode,
331 Double_t sigma) {
332 Double_t xlo = mode - 4.0 * sigma;
333 Double_t xhi = mode + 4.0 * sigma;
334 if (xlo < 0.0)
335 xlo = 0.0;
336 Double_t bin_target = TMath::Max(1.0, sigma / 6.0);
337 Int_t nbins = Int_t((xhi - xlo) / bin_target);
338 if (nbins < 20)
339 nbins = 20;
340 if (nbins > 200)
341 nbins = 200;
342 TH1F *h = new TH1F(name, title, nbins, xlo, xhi);
343 h->SetDirectory(nullptr);
344 for (Int_t j = 0; j < Int_t(v.size()); j++)
345 h->Fill(Double_t(v[j]));
346 return h;
347}
348
349// Beam-peak Gaussian fit, robustly seeded: estimate the peak/width from the
350// percentile-clipped mode (RobustPeakSeed), then fit "gaus" over only the peak
351// core (mode +/- 2 sigma). A second pass refits inside a NARROW window around
352// the first centroid: when the spectrum is bimodal (e.g. Strip0 with a
353// secondary bump below the beam peak), the IQR-based seed sigma is inflated
354// by the contamination and the wide first-pass window lets the fit average
355// both components — the narrow refit locks onto the dominant peak. This is
356// the primary (and only) fit in ReduceToAnchors.
357Bool_t FitBeamPeakGaussian(const std::vector<Float_t> &v, const TString &fname,
358 Double_t &peak_adc, Double_t &sigma_adc,
359 TF1 *&fit_out) {
360 fit_out = nullptr;
361 if (v.size() < kMinSamples)
362 return kFALSE;
363 Double_t mode = 0.0, rsigma = 0.0;
364 RobustPeakSeed(v, mode, rsigma);
365 if (!(mode > 0.0) || !(rsigma > 0.0))
366 return kFALSE;
367
368 TH1F *h = MakeBeamPeakHist(fname + "_h", "", v, mode, rsigma);
369 Double_t xlo = h->GetXaxis()->GetXmin();
370 Double_t xhi = h->GetXaxis()->GetXmax();
371 Double_t bw = h->GetBinWidth(1);
372 Double_t fit_lo = mode - 2.0 * rsigma;
373 Double_t fit_hi = mode + 2.0 * rsigma;
374 if (fit_lo < xlo)
375 fit_lo = xlo;
376 if (fit_hi > xhi)
377 fit_hi = xhi;
378 Double_t amp_seed = h->GetBinContent(h->FindBin(mode));
379
380 TF1 *f = new TF1(fname, "gaus", fit_lo, fit_hi);
381 f->SetNpx(1000);
382 f->SetParameters(amp_seed, mode, rsigma);
383 f->SetParLimits(1, fit_lo, fit_hi);
384 f->SetParLimits(2, bw, fit_hi - fit_lo);
385 TFitResultPtr r = h->Fit(f, "QSRNL");
386 if (!r.Get() || !r->IsValid()) {
387 delete f;
388 delete h;
389 return kFALSE;
390 }
391 peak_adc = f->GetParameter(1);
392 sigma_adc = std::fabs(f->GetParameter(2));
393
394 // Second pass: refit inside mu ± min(1.5*sigma_fit, 12% of mu). For a
395 // clean single Gaussian this window still spans the core and reproduces
396 // the first-pass result; for a contaminated spectrum it excludes the
397 // secondary component and re-centres onto the dominant peak.
398 Double_t half = 1.5 * sigma_adc;
399 Double_t half_cap = 0.12 * peak_adc;
400 if (half_cap < half)
401 half = half_cap;
402 if (half > 2.0 * bw) {
403 Double_t lo2 = peak_adc - half;
404 Double_t hi2 = peak_adc + half;
405 if (lo2 < xlo)
406 lo2 = xlo;
407 if (hi2 > xhi)
408 hi2 = xhi;
409 TF1 *f2 = new TF1(fname + "_p2", "gaus", lo2, hi2);
410 f2->SetNpx(1000);
411 f2->SetParameters(f->GetParameter(0), peak_adc, sigma_adc);
412 f2->SetParLimits(1, lo2, hi2);
413 f2->SetParLimits(2, bw, hi2 - lo2);
414 TFitResultPtr r2 = h->Fit(f2, "QSRNL");
415 if (r2.Get() && r2->IsValid() && f2->GetParameter(1) > 0 &&
416 std::fabs(f2->GetParameter(2)) > 0) {
417 peak_adc = f2->GetParameter(1);
418 sigma_adc = std::fabs(f2->GetParameter(2));
419 delete f;
420 f = f2;
421 } else {
422 delete f2;
423 }
424 }
425 delete h;
426 if (!(peak_adc > 0.0) || !(sigma_adc > 0.0)) {
427 delete f;
428 return kFALSE;
429 }
430 fit_out = f;
431 return kTRUE;
432}
433
434// Fit a Gaussian to the bucket and return (mu, sigma). Falls back to
435// (median, IQR/1.349) on fit failure. Sim per-channel deposits are
436// well-approximated by a Gaussian, so a direct fit gives a cleaner
437// (mu, sigma) than median/IQR estimators.
438Bool_t FitGaussianMuSigma(const std::vector<Float_t> &v, const TString &fname,
439 Double_t &mu, Double_t &sigma) {
440 if (v.size() < 50)
441 return kFALSE;
442 Float_t lo = v[0], hi = v[0];
443 for (Int_t j = 1; j < Int_t(v.size()); j++) {
444 if (v[j] < lo)
445 lo = v[j];
446 if (v[j] > hi)
447 hi = v[j];
448 }
449 Double_t pad = 0.05 * (Double_t(hi) - Double_t(lo));
450 if (pad < 1e-6)
451 pad = 1e-6;
452 const Int_t nbins = 75;
453 TH1F h(fname + "_h", "", nbins, Double_t(lo) - pad, Double_t(hi) + pad);
454 h.SetDirectory(nullptr);
455 for (Int_t j = 0; j < Int_t(v.size()); j++)
456 h.Fill(Double_t(v[j]));
457 TF1 fg(fname, "gaus", Double_t(lo) - pad, Double_t(hi) + pad);
458 Int_t pb = h.GetMaximumBin();
459 fg.SetParameters(h.GetBinContent(pb), h.GetBinCenter(pb), h.GetRMS());
460 TFitResultPtr r = h.Fit(&fg, "QSRN");
461 if (!r.Get() || !r->IsValid())
462 return kFALSE;
463 mu = fg.GetParameter(1);
464 sigma = std::fabs(fg.GetParameter(2));
465 return mu > 0 && sigma > 0;
466}
467
468// Paired (L, R) raw-ADC samples for one strip, collected UNGATED from events
469// where both ends fire, plus the uncapped "shoulder" slice: short-side values
470// from events where the LONG side reads low (charge went mostly to the short
471// end). The slice is collected separately because those events are rare — the
472// capped pair vectors fill up with beam events long before enough slice
473// events arrive.
475 std::vector<Float_t> l;
476 std::vector<Float_t> r;
477 // Beam-gated, long-side-triggered pairs. Unlike `l`/`r` these keep events
478 // where the short end did not fire (recorded as 0), so they describe the
479 // population the decode actually sums. `l`/`r` require both ends and are
480 // therefore biased towards anomalously large short-side signals.
481 std::vector<Float_t> gated_long;
482 std::vector<Float_t> gated_short;
483};
484
485// check_LR gain-match slice constants (37Cl_an_check_LR.ipynb, cell "Save
486// 2-pass calibration"). Defined before CollectAnchorSamplesOneSubfile because
487// the shoulder slice is selected during sample collection.
488
489// Every strip carries its own beam gate, on its own total and its neighbour's
490// (GatePartner). A strip is therefore anchored on events that were beam-like
491// THERE, rather than on events that were beam-like at strips 1 and 2 and
492// whatever they happened to be doing further down the chamber.
494 const std::vector<ChannelCal> &chans,
495 const BeamFit2D gate[18],
496 std::vector<std::vector<Float_t>> &samples,
497 StripPairSamples pairs[18]) {
498 Int_t n_chans = Int_t(chans.size());
499 samples.assign(n_chans, std::vector<Float_t>());
500 Bool_t any = kFALSE;
501 for (Int_t s = 0; s <= 17; s++)
502 any = any || gate[s].ok;
503 if (!any)
504 return;
505
506 TString sub = FileSet::EventsName(spec) + ".root";
507 TFile *sf = IO::OpenForReading(sub);
508 if (!sf || sf->IsZombie()) {
509 if (sf)
510 sf->Close();
511 return;
512 }
513 TTree *tree = static_cast<TTree *>(sf->Get("events"));
514 if (!tree) {
515 sf->Close();
516 delete sf;
517 return;
518 }
519 // Raw ADC, pre-calibration. Guard strips (S) and left ends (L) live in
520 // Left_0_17_dE; right ends in RightdE. Strip totals are L+R; the gate uses
521 // the strip1 total (L1+R1) and the strip2 total (L2+R2).
522 UShort_t left_0_17_adc[18], rightdE_adc[18];
523 Short_t cathode_adc = 0;
524 tree->SetBranchAddress("Left_0_17_dE", left_0_17_adc);
525 tree->SetBranchAddress("RightdE", rightdE_adc);
526 tree->SetBranchAddress("Cathode", &cathode_adc);
527
528 Long64_t n = tree->GetEntries();
529 for (Long64_t j = 0; j < n; j++) {
530 tree->GetEntry(j);
531 // (L, R) pairs for strips 1–16 where both ends fired — UNGATED, matching
532 // the check_LR notebook which runs on all events. The short-side anchor
533 // ("shoulder") is found in a slice where the LONG side reads low
534 // (~300-350 ADC), i.e. events where the charge went mostly to the short
535 // end. Those are reaction/off-position events that a beam gate would
536 // remove, so the pairs must not be beam-gated.
537 if (pairs) {
538 for (Int_t s = 1; s <= 16; s++) {
539 Int_t lv = Int_t(left_0_17_adc[s]);
540 Int_t rv = Int_t(rightdE_adc[s]);
541 if (lv > 0 && rv > 0 && Long64_t(pairs[s].l.size()) < kPairCap) {
542 pairs[s].l.push_back(Float_t(lv));
543 pairs[s].r.push_back(Float_t(rv));
544 }
545 }
546 }
547 // Which strips this event is beam-like in, each judged by its own gate.
548 Bool_t pass[18] = {kFALSE};
549 for (Int_t s = 1; s <= 17; s++) {
550 if (!gate[s].ok)
551 continue;
552 const Double_t x =
553 StripTotalAdc(left_0_17_adc, rightdE_adc, GatePartner(s));
554 const Double_t y = StripTotalAdc(left_0_17_adc, rightdE_adc, s);
555 if (x <= 0.0 || y <= 0.0)
556 continue;
557 pass[s] = BeamFitUtils::InEllipseXY(gate[s], x, y,
558 Constants::cfg.BEAM_GATE_NSIGMA_X,
559 Constants::cfg.BEAM_GATE_NSIGMA_Y);
560 }
561 // Strip 0 shares strip 1's gate, so it has to share strip 1's verdict too.
562 // The ellipse was fitted on the (strip 0, strip 1) plane; GatePartner(0) is
563 // 0, so testing it strip-0-against-itself puts strip 0's value on strip 1's
564 // axis, lands every event outside, and leaves the guard with no beam sample
565 // at all -- which zeroes its gain and then fails AllStripsFired downstream.
566 pass[0] = pass[1];
567 if (pairs) {
568 for (Int_t s = 1; s <= 16; s++) {
569 if (!pass[s] || Long64_t(pairs[s].gated_long.size()) >= kPairCap)
570 continue;
571 Bool_t l_is_long = (LongSide(s) == 'L');
572 Int_t long_v =
573 l_is_long ? Int_t(left_0_17_adc[s]) : Int_t(rightdE_adc[s]);
574 Int_t short_v =
575 l_is_long ? Int_t(rightdE_adc[s]) : Int_t(left_0_17_adc[s]);
576 if (long_v > 0) {
577 pairs[s].gated_long.push_back(Float_t(long_v));
578 pairs[s].gated_short.push_back(Float_t(short_v > 0 ? short_v : 0));
579 }
580 }
581 }
582 for (Int_t i = 0; i < n_chans; i++) {
583 if (Long64_t(samples[i].size()) >= kSampleCap)
584 continue;
585 const ChannelCal &c = chans[i];
586 // The cathode has no strip of its own, so it rides on strip 1's gate.
587 const Int_t s = (c.side == 'C') ? 1 : c.strip;
588 if (s < 0 || s > 17 || !pass[s])
589 continue;
590 Int_t v = 0;
591 if (c.side == 'S' || c.side == 'L')
592 v = Int_t(left_0_17_adc[c.strip]);
593 else if (c.side == 'R')
594 v = Int_t(rightdE_adc[c.strip]);
595 else if (c.side == 'C')
596 v = Int_t(cathode_adc);
597 if (v > 0)
598 samples[i].push_back(Float_t(v));
599 }
600 }
601 sf->Close();
602 delete sf;
603}
604
605// L/R gain matching, replicating the check_LR notebook's two-pass recipe
606// (37Cl_an_check_LR.ipynb, "Save 2-pass calibration" cell) exactly, minus the
607// eta/position correction:
608//
609// Pass 1 — per-side anchors:
610// * LONG side anchor = beam peak of the long side (histogram mode). The
611// per-channel anchor from ReduceToAnchors is exactly this (the beam
612// dominates the spectrum), so it is reused.
613// * SHORT side anchor = short-axis crossing of the charge-sharing ridge,
614// reached by extrapolating the ridge line to long = 0 (C_short =
615// -intercept/slope); see RidgeShortAnchor. The short end is never observed
616// collecting the full deposit -- the beam is collimated onto the long end
617// -- so the anchor comes from the ridge rather than from finding a peak. A
618// DC offset/pedestal moves the line's intercept off C_long, and using the
619// free intercept here (rather than assuming it equals C_long) absorbs it.
620// * gain = TARGET / anchor per side, with TARGET = 1.0 a.u. (the notebook
621// uses 1000 ADC; only the overall scale differs).
622//
623// Pass 2 — per-strip normalisation of the summed beam peak to 1.0 a.u.:
624// * pass 1 anchors the LONG side's own beam peak at 1.0, but at that peak
625// the long end only carries fraction (1-f) of the strip's charge, so the
626// summed total reads 1+f. f varies strip to strip (it grows downstream and
627// differs by parity), which is what shows up as a sawtooth in the summed
628// trace.
629// * the peak is measured on `gated_long`/`gated_short`: beam-gated events
630// where the LONG end fired, with a silent short end recorded as 0. That is
631// the population the decode sums. The `l`/`r` pairs must not be used here:
632// they require both ends to fire and so are biased towards large
633// short-side signals.
634// * both gains are scaled by 1/peak, which leaves the L/R ratio from pass 1
635// untouched, so a wrong short anchor misallocates charge within a strip
636// but no longer shifts the strip total.
637// * when IGNORE_SHORT_STRIPS is set the decode keeps only the long end, so
638// the long end alone is normalised to 1.0 and pass 1 already provides it.
639//
640// Pairs are collected UNGATED (all events with both ends firing) because the
641// ridge needs the off-centre crossings that a beam gate removes. Strips whose
642// ridge cannot be fitted fall back to the MEDIAN anchor of the strips that
643// could; that fallback assumes a common electronics scale across strips, which
644// the measured ridge slopes can be used to check rather than assume.
645const Int_t kGmBins = 512;
646// Ridge-slope short anchor. In the long-vs-short plane a fixed deposit divided
647// between the two ends of a strip traces
648// ADC_long/C_long + ADC_short/C_short = 1,
649// a line of slope -C_long/C_short. The slope is the same for every deposit
650// energy, so the ridge direction alone gives the anchor ratio and C_short need
651// never be observed directly -- which matters because the beam is collimated
652// onto the long end and almost never deposits its full charge on the short one.
653// The plane also contains pile-up bands at 2x, 3x the single-particle deposit;
654// the 2-particle band falls into the single-particle window once
655// short > ~0.55*C_short, so the fit is capped well below that.
656const Double_t kRidgeShortMaxFrac =
657 0.5; // cap on short, as a fraction of C_long
658const Double_t kRidgeBandLo = 0.30; // single-particle band, x C_long
659const Double_t kRidgeBandHi = 1.45;
660const Int_t kRidgeSlices = 60;
661const Long64_t kRidgeMinPerSlice = 200;
662// Absolute noise floor per slice when the per-slice minimum is relaxed for
663// thinly-spread ridges: below this a slice median is dominated by shot noise.
664const Long64_t kRidgeNoiseFloor = 20;
665const Int_t kRidgeMinPts = 6;
666// C_short/C_long is a preamp gain ratio, so it is order unity; outside this
667// range the ridge fit has failed in a way the intercept test cannot see.
668const Double_t kRidgeRatioLo = 0.30;
669const Double_t kRidgeRatioHi = 3.00;
670const Double_t kGmEsumLo = 0.8; // a.u. eSum peak search window (pass 2)
671const Double_t kGmEsumHi = 2.5;
672
673// Everything SaveRidgeFitPlots needs to redraw a fit: the slice medians it was
674// fitted to, the window they were taken from, and the resulting line. Filled
675// even when the fit is later rejected, so a bad fit can be looked at.
676struct RidgeFit {
677 std::vector<Double_t> x, y, ey; // slice centres, medians, median errors
678 Double_t lo = 0.0, hi = 0.0; // short-axis fit window
679 Double_t slope = 0.0, intercept = 0.0;
680 Double_t c_long = 0.0, c_short = 0.0;
681 Bool_t fitted = kFALSE; // a line was fitted (it may still be rejected)
682 // Diagnostic: how far a strip got before the fit, so a "not measurable"
683 // log line can say which guard rejected it instead of only slope/intercept.
684 Long64_t n_gated = 0; // pairs handed to RidgeShortAnchor
685 Long64_t n_short_window = 0; // stayed inside the short window
686 Long64_t n_long_band = 0; // also inside the long band
687 Int_t n_slices = 0; // slices with >= kRidgeMinPerSlice
688 Long64_t min_per_slice = 0; // adaptive per-slice minimum used this strip
689 const char *fail = ""; // guarded reason when it returned 0
690};
691
692// Theil-Sen robust line: slope = median of pairwise slopes, intercept = median
693// residual. Unlike OLS ("pol1" via a chi-square fit) a single outlying slice
694// median cannot bend the slope: it is the intercept-correcting companion to the
695// fact that a slice median is itself a robust estimate. Returns kFALSE when the
696// slice set is too small or degenerate (all-same x).
697Bool_t TheilSenLine(const std::vector<Double_t> &x,
698 const std::vector<Double_t> &y, Double_t &slope,
699 Double_t &intercept) {
700 const Int_t n = Int_t(x.size());
701 if (n < 2)
702 return kFALSE;
703 std::vector<Double_t> slopes;
704 slopes.reserve(n * (n - 1) / 2);
705 for (Int_t i = 0; i < n; i++)
706 for (Int_t j = i + 1; j < n; j++) {
707 Double_t dx = x[j] - x[i];
708 if (dx == 0.0)
709 continue;
710 slopes.push_back((y[j] - y[i]) / dx);
711 }
712 if (slopes.empty())
713 return kFALSE;
714 std::sort(slopes.begin(), slopes.end());
715 const Int_t m = Int_t(slopes.size());
716 slope =
717 (m % 2 == 1) ? slopes[m / 2] : 0.5 * (slopes[m / 2 - 1] + slopes[m / 2]);
718 std::vector<Double_t> resid;
719 resid.reserve(n);
720 for (Int_t i = 0; i < n; i++)
721 resid.push_back(y[i] - slope * x[i]);
722 std::sort(resid.begin(), resid.end());
723 const Int_t r = Int_t(resid.size());
724 intercept =
725 (r % 2 == 1) ? resid[r / 2] : 0.5 * (resid[r / 2 - 1] + resid[r / 2]);
726 return kTRUE;
727}
728
729// Slope of the single-particle charge-sharing ridge, returning C_short at the
730// fitted line's short-axis crossing, C_short = -intercept/slope (equivalently
731// C_long/|slope| when the intercept sits exactly at C_long). Returns 0 when the
732// ridge is not measurable.
733Double_t RidgeShortAnchor(const std::vector<Float_t> &v_short,
734 const std::vector<Float_t> &v_long, Double_t c_long,
735 Double_t &slope_out, Double_t &intercept_out,
736 RidgeFit *dbg = nullptr) {
737 slope_out = 0.0;
738 intercept_out = 0.0;
739 if (dbg) {
740 dbg->c_long = c_long;
741 dbg->n_gated = Long64_t(v_short.size());
742 }
743 if (c_long <= 0 || v_short.size() != v_long.size() || v_short.size() < 500) {
744 if (dbg)
745 dbg->fail = c_long <= 0 ? "c_long<=0"
746 : v_short.size() != v_long.size()
747 ? "size mismatch"
748 : "fewer than 500 gated pairs";
749 return 0.0;
750 }
751 const Double_t hi = kRidgeShortMaxFrac * c_long;
752 const Double_t lo = 0.04 * c_long;
753 std::vector<std::vector<Double_t>> slice(kRidgeSlices);
754 for (Int_t j = 0; j < Int_t(v_short.size()); j++) {
755 Double_t sh = Double_t(v_short[j]), lg = Double_t(v_long[j]);
756 if (sh < lo || sh >= hi)
757 continue;
758 if (dbg)
759 dbg->n_short_window++;
760 if (lg <= kRidgeBandLo * c_long || lg >= kRidgeBandHi * c_long)
761 continue;
762 if (dbg)
763 dbg->n_long_band++;
764 Int_t b = Int_t((sh - lo) / (hi - lo) * kRidgeSlices);
765 if (b >= 0 && b < kRidgeSlices)
766 slice[b].push_back(lg);
767 }
768 std::vector<Double_t> x, y, ey;
769 {
770 // Per-slice minimum. Try the strict floor first (kRidgeMinPerSlice): this
771 // is what odd strips pass with, so their fit is byte-for-byte unchanged. If
772 // it fails to fill kRidgeMinPts slices, relax -- a strip whose ridge is
773 // spread thinly over many short bins (the even strips here: beam off-axis
774 // left, they share far more, so ~50/bin instead of >200) is being starved
775 // by a population-independent constant. Halve the floor and retry until
776 // kRidgeMinPts slices fill or the noise floor is hit, so a thinly spread
777 // ridge still fits without letting a couple-count slice through.
778 Long64_t min_per_slice = kRidgeMinPerSlice;
779 for (;;) {
780 x.clear();
781 y.clear();
782 ey.clear();
783 for (Int_t b = 0; b < kRidgeSlices; b++) {
784 if (Long64_t(slice[b].size()) < min_per_slice)
785 continue;
786 std::sort(slice[b].begin(), slice[b].end());
787 Double_t med = slice[b][slice[b].size() / 2];
788 Double_t iqr =
789 slice[b][slice[b].size() * 3 / 4] - slice[b][slice[b].size() / 4];
790 x.push_back(lo + (b + 0.5) * (hi - lo) / kRidgeSlices);
791 y.push_back(med);
792 ey.push_back(1.253 * (iqr / 1.349) /
793 TMath::Sqrt(Double_t(slice[b].size())));
794 }
795 if (Int_t(x.size()) >= kRidgeMinPts || min_per_slice <= kRidgeNoiseFloor)
796 break;
797 // Halve the floor and retry.
798 min_per_slice = TMath::Max(kRidgeNoiseFloor, min_per_slice / 2);
799 }
800 if (dbg)
801 dbg->min_per_slice = min_per_slice;
802 }
803 if (dbg) {
804 dbg->x = x;
805 dbg->y = y;
806 dbg->ey = ey;
807 dbg->lo = lo;
808 dbg->hi = hi;
809 dbg->n_slices = Int_t(x.size());
810 }
811 if (Int_t(x.size()) < kRidgeMinPts) {
812 if (dbg)
813 dbg->fail = "fewer than kRidgeMinPts filled slices";
814 return 0.0;
815 }
816 // Robust regression: median of pairwise slopes, so an outlying slice median
817 // (e.g. the 2-/3-particle bands or a low-long background) cannot bend the
818 // slope the way the chi-square "pol1" fit could. Returns kFALSE on a
819 // degenerate slice set.
820 Double_t slope = 0.0, inter = 0.0;
821 if (!TheilSenLine(x, y, slope, inter)) {
822 if (dbg)
823 dbg->fail = "degenerate slice set (TheilSenLine)";
824 return 0.0;
825 }
826 slope_out = slope;
827 intercept_out = inter;
828 if (dbg) {
829 dbg->slope = slope;
830 dbg->intercept = inter;
831 dbg->fitted = kTRUE;
832 if (slope < 0)
833 dbg->c_short = -inter / slope;
834 }
835 if (slope >= 0) {
836 if (dbg)
837 dbg->fail = "slope >= 0";
838 return 0.0;
839 }
840 // The line must pass through C_long on the long axis; a large departure means
841 // the band selection did not isolate the single-particle ridge. The window is
842 // kept loose enough to admit a genuine intercept offset down to 0.80 C_long.
843 if (inter < 0.80 * c_long || inter > 1.20 * c_long) {
844 if (dbg)
845 dbg->fail = "intercept outside [0.80, 1.20]*C_long";
846 return 0.0;
847 }
848 // Short anchor from the fitted line itself: where it crosses the short axis
849 // (long = 0), i.e. -intercept/slope. Using the free intercept rather than
850 // assuming it sits exactly at C_long absorbs a DC offset/pedestal; while the
851 // intercept is inside the sanity window, this is the honest crossing.
852 return -inter / slope;
853}
854
855// One plot per strip under <plot_subdir>/ridge, named ridge_s<NN>: the
856// beam-gated long-vs-short plane, the slice medians the fit was actually given
857// (black), and the fitted line (violet). Drawn for every strip, including the
858// ones whose fit was rejected, so a bad ridge can be seen rather than inferred
859// from the slope in the log.
860void SaveRidgeFitPlots(const StripPairSamples pairs[18], const RidgeFit dbg[18],
861 const TString &plot_subdir) {
862 TString subdir = plot_subdir + "/ridge";
863 for (Int_t s = 1; s <= 16; s++) {
864 const RidgeFit &d = dbg[s];
865 const StripPairSamples &p = pairs[s];
866 if (d.c_long <= 0 || p.gated_short.empty())
867 continue;
868 Bool_t l_is_long = (LongSide(s) == 'L');
869 // A little past the fit window, so the cap the fit stops at is visible.
870 Double_t xhi = (d.hi > 0 ? d.hi : kRidgeShortMaxFrac * d.c_long) * 1.35;
871 TH2F *h = new TH2F(Form("h_ridge_s%d", s),
872 Form(";Strip %d Short (%c) #DeltaE [ADC];Strip %d Long "
873 "(%c) #DeltaE [ADC]",
874 s, l_is_long ? 'R' : 'L', s, l_is_long ? 'L' : 'R'),
875 200, 0.0, xhi, 200, 0.0, 1.60 * d.c_long);
876 for (Int_t j = 0; j < Int_t(p.gated_short.size()); j++)
877 h->Fill(Double_t(p.gated_short[j]), Double_t(p.gated_long[j]));
878 TCanvas *cv = PlottingUtils::GetConfiguredCanvas(kFALSE);
879 PlottingUtils::Configure2DHistogram(h, cv);
880 h->Draw("COLZ");
881 TGraphErrors *g = nullptr;
882 if (!d.x.empty()) {
883 g = new TGraphErrors(Int_t(d.x.size()), &d.x[0], &d.y[0], nullptr,
884 &d.ey[0]);
885 g->SetMarkerStyle(20);
886 g->SetMarkerSize(0.8);
887 g->SetMarkerColor(kBlack);
888 g->SetLineColor(kBlack);
889 g->Draw("P SAME");
890 }
891 // Extended to short = 0 on purpose: where the line lands there is the
892 // anchor the calibration takes, and it should sit on C_long.
893 TLine *lf = nullptr;
894 if (d.fitted) {
895 lf = new TLine(0.0, d.intercept, xhi, d.intercept + d.slope * xhi);
896 lf->SetLineColor(kViolet + 2);
897 lf->SetLineWidth(2);
898 lf->Draw();
899 }
900 if (Constants::cfg.SAVE_PLOTS)
901 PlottingUtils::SaveFigure(cv, Form("ridge_s%02d", s), subdir,
902 PlotSaveOptions::kLINEAR);
903 delete cv;
904 delete g;
905 delete lf;
906 delete h;
907 }
908}
909
910// Histogram-mode peak finder, mirroring the notebook's find_peak(): histogram
911// `v` over [lo, hi] with kGmBins bins, skip the first skip_frac of bins (to
912// avoid the threshold pile), return the max-bin centre. Returns 0 when empty.
913Double_t GmFindPeak(const std::vector<Float_t> &v, Double_t lo, Double_t hi,
914 Double_t skip_frac) {
915 if (v.empty() || hi <= lo)
916 return 0.0;
917 std::vector<Long64_t> h(kGmBins, 0);
918 Double_t bw = (hi - lo) / kGmBins;
919 for (Int_t j = 0; j < Int_t(v.size()); j++) {
920 Double_t x = Double_t(v[j]);
921 if (x < lo || x >= hi)
922 continue;
923 Int_t b = Int_t((x - lo) / bw);
924 if (b >= 0 && b < kGmBins)
925 h[b]++;
926 }
927 Int_t skip = Int_t(kGmBins * skip_frac);
928 Int_t bmax = -1;
929 Long64_t vmax = 0;
930 for (Int_t b = skip; b < kGmBins; b++) {
931 if (h[b] > vmax) {
932 vmax = h[b];
933 bmax = b;
934 }
935 }
936 if (bmax < 0 || vmax <= 0)
937 return 0.0;
938 return lo + (bmax + 0.5) * bw;
939}
940
941void ComputeLRGainMatch(std::vector<ChannelCal> &chans,
942 const StripPairSamples pairs[18],
943 const TString &plot_subdir) {
944 RidgeFit ridge_dbg[18];
945 Int_t idx_l[18], idx_r[18];
946 for (Int_t s = 0; s < 18; s++) {
947 idx_l[s] = -1;
948 idx_r[s] = -1;
949 }
950 for (Int_t i = 0; i < Int_t(chans.size()); i++) {
951 if (chans[i].strip >= 1 && chans[i].strip <= 16) {
952 if (chans[i].side == 'L')
953 idx_l[chans[i].strip] = i;
954 else if (chans[i].side == 'R')
955 idx_r[chans[i].strip] = i;
956 }
957 }
958
959 // ── Pass 1: per-side anchors ──
960 // LONG side: its own beam peak, already fitted by ReduceToAnchors.
961 // SHORT side: from the charge-sharing ridge slope, the same method on both
962 // parities. Strips whose ridge is not measurable fall back to the median
963 // of the strips that did measure one.
964
965 Bool_t matched[18] = {kFALSE};
966 Double_t short_anchor_adc[18] = {0};
967 std::vector<Double_t> ratios_found[2];
968 for (Int_t s = 1; s <= 16; s++) {
969 if (idx_l[s] < 0 || idx_r[s] < 0)
970 continue;
971 Bool_t l_is_long = (LongSide(s) == 'L');
972 ChannelCal &c_long = chans[l_is_long ? idx_l[s] : idx_r[s]];
973 ChannelCal &c_short = chans[l_is_long ? idx_r[s] : idx_l[s]];
974 if (!IsCalibrated(c_long)) {
975 std::cerr << " strip " << s
976 << ": long side uncalibrated; skipping L/R gain match"
977 << std::endl;
978 continue;
979 }
980 Double_t peak_short = 0.0;
981
982 {
983 const StripPairSamples &p = pairs[s];
984 // Fit the ridge on beam-gated events. The gate cuts on the strip-1 and
985 // strip-2 SUMS, so it removes pile-up and junk without touching where a
986 // particle crossed on any other strip -- exactly the off-centre
987 // crossings the ridge is made of. Fitting the ungated pairs instead
988 // admits the 2- and 3-particle bands and a low-long background that
989 // grows with short, which drags the slice medians down and steepens the
990 // slope.
991 const std::vector<Float_t> &v_short = p.gated_short;
992 const std::vector<Float_t> &v_long = p.gated_long;
993 Double_t slope = 0.0, inter = 0.0;
994 peak_short = RidgeShortAnchor(v_short, v_long, c_long.fit_adc, slope,
995 inter, &ridge_dbg[s]);
996 if (peak_short > 0)
997 std::cout << " strip " << s << " ridge slope=" << Form("%.3f", slope)
998 << " intercept/C_long="
999 << Form("%.3f",
1000 c_long.fit_adc > 0 ? inter / c_long.fit_adc : 0.0)
1001 << " short_anchor=" << Form("%.1f", peak_short) << " ADC"
1002 << std::endl;
1003 else
1004 std::cerr << " strip " << s << ": ridge not measurable "
1005 << "(slope=" << Form("%.3f", slope)
1006 << ", intercept=" << Form("%.1f", inter) << ")"
1007 << (ridge_dbg[s].fail[0] ? Form(" [%s]", ridge_dbg[s].fail)
1008 : "")
1009 << " gated=" << Form("%lld", ridge_dbg[s].n_gated)
1010 << " short_win=" << Form("%lld", ridge_dbg[s].n_short_window)
1011 << " long_band=" << Form("%lld", ridge_dbg[s].n_long_band)
1012 << " slices=" << ridge_dbg[s].n_slices
1013 << " min_per_slice=" << ridge_dbg[s].min_per_slice
1014 << std::endl;
1015 }
1016
1017 if (peak_short <= 0)
1018 continue;
1019 // C_short/C_long is the ratio of the two preamp gains, so it is order
1020 // unity. A ridge too flat to measure still crosses the long axis near
1021 // C_long, so the intercept test above cannot catch it -- but it sends
1022 // C_long/|slope| to absurd values, which this does catch.
1023 Double_t ratio = peak_short / c_long.fit_adc;
1024 if (ratio < kRidgeRatioLo || ratio > kRidgeRatioHi) {
1025 std::cerr << " strip " << s << ": ridge ratio " << Form("%.2f", ratio)
1026 << " outside [" << kRidgeRatioLo << ", " << kRidgeRatioHi
1027 << "]; rejecting anchor " << Form("%.1f", peak_short) << " ADC"
1028 << std::endl;
1029 continue;
1030 }
1031 short_anchor_adc[s] = peak_short;
1032 ratios_found[s % 2].push_back(ratio);
1033 chans[l_is_long ? idx_r[s] : idx_l[s]].ridge_ratio = ratio;
1034 std::cout << " strip " << s << " short_anchor=" << Form("%.1f", peak_short)
1035 << " ADC (ratio " << Form("%.3f", ratio) << ")" << std::endl;
1036 }
1037
1038 SaveRidgeFitPlots(pairs, ridge_dbg, plot_subdir);
1039
1040 // Fall back on the median RATIO rather than the median anchor: the ratio is a
1041 // property of the two preamps, so it carries across strips, whereas an anchor
1042 // in ADC does not -- each strip has its own C_long. Taken per parity because
1043 // the L and R channels are on separate preamps and their ratios differ
1044 // systematically (odd ~1.2, even ~0.9 on 87Rb).
1045 Double_t median_ratio[2] = {0.0, 0.0};
1046 std::vector<Double_t> all_ratios;
1047 for (Int_t par = 0; par < 2; par++) {
1048 std::vector<Double_t> &v = ratios_found[par];
1049 all_ratios.insert(all_ratios.end(), v.begin(), v.end());
1050 if (v.size() < 2)
1051 continue;
1052 std::sort(v.begin(), v.end());
1053 Int_t m = Int_t(v.size());
1054 median_ratio[par] =
1055 (m % 2 == 1) ? v[m / 2] : 0.5 * (v[m / 2 - 1] + v[m / 2]);
1056 }
1057 Double_t global_ratio = 0.0;
1058 if (!all_ratios.empty()) {
1059 std::sort(all_ratios.begin(), all_ratios.end());
1060 Int_t m = Int_t(all_ratios.size());
1061 global_ratio = (m % 2 == 1)
1062 ? all_ratios[m / 2]
1063 : 0.5 * (all_ratios[m / 2 - 1] + all_ratios[m / 2]);
1064 }
1065 std::cout << " ridge ratio medians: odd=" << Form("%.3f", median_ratio[1])
1066 << " even=" << Form("%.3f", median_ratio[0])
1067 << " all=" << Form("%.3f", global_ratio) << std::endl;
1068
1069 for (Int_t s = 1; s <= 16; s++) {
1070 if (idx_l[s] < 0 || idx_r[s] < 0)
1071 continue;
1072 Bool_t l_is_long = (LongSide(s) == 'L');
1073 ChannelCal &c_long = chans[l_is_long ? idx_l[s] : idx_r[s]];
1074 ChannelCal &c_short = chans[l_is_long ? idx_r[s] : idx_l[s]];
1075 if (!IsCalibrated(c_long))
1076 continue;
1077 if (short_anchor_adc[s] <= 0) {
1078 Double_t r = median_ratio[s % 2] > 0 ? median_ratio[s % 2] : global_ratio;
1079 if (r <= 0) {
1080 std::cerr << " strip " << s
1081 << ": no ridge and no ratio fallback; keeping "
1082 "independent gains"
1083 << std::endl;
1084 continue;
1085 }
1086 short_anchor_adc[s] = r * c_long.fit_adc;
1087 std::cout << " strip " << s << " short_anchor=ratio fallback "
1088 << Form("%.3f", r)
1089 << " x C_long = " << Form("%.1f", short_anchor_adc[s]) << " ADC"
1090 << (median_ratio[s % 2] > 0 ? "" : " (global, parity had none)")
1091 << std::endl;
1092 }
1093 c_long.gain = 1.0 / c_long.fit_adc;
1094 c_short.gain = 1.0 / short_anchor_adc[s];
1095 matched[s] = kTRUE;
1096 std::cout << " strip " << s
1097 << " L/R match: long peak=" << Form("%.1f", c_long.fit_adc)
1098 << " ADC short anchor=" << Form("%.1f", short_anchor_adc[s])
1099 << " ADC" << std::endl;
1100 }
1101
1102 // ── Pass 2: check, do not correct ──
1103 // Both anchors are now measured (long from its beam peak, short from the
1104 // ridge slope), so gain_L*L + gain_R*R already peaks at 1.0 a.u. for a beam
1105 // event by construction. A departure means the ridge fit for that strip is
1106 // wrong, and is reported rather than absorbed into the gains -- rescaling
1107 // here would hide exactly the failure worth seeing.
1108 Double_t esum_peak[18] = {0};
1109 for (Int_t s = 1; s <= 16; s++) {
1110 if (!matched[s])
1111 continue;
1112 const StripPairSamples &p = pairs[s];
1113 Bool_t l_is_long = (LongSide(s) == 'L');
1114 Double_t g_long = Gain(chans[l_is_long ? idx_l[s] : idx_r[s]]);
1115 Double_t g_short = Gain(chans[l_is_long ? idx_r[s] : idx_l[s]]);
1116 std::vector<Float_t> esum;
1117 esum.reserve(p.gated_long.size());
1118 for (Int_t j = 0; j < Int_t(p.gated_long.size()); j++)
1119 esum.push_back(Float_t(g_long * Double_t(p.gated_long[j]) +
1120 g_short * Double_t(p.gated_short[j])));
1121 esum_peak[s] = GmFindPeak(esum, kGmEsumLo, kGmEsumHi, 0.0);
1122 }
1123 for (Int_t s = 1; s <= 16; s++) {
1124 if (!matched[s])
1125 continue;
1126 if (esum_peak[s] <= 0) {
1127 std::cerr << " strip " << s << ": no summed beam peak in (" << kGmEsumLo
1128 << ", " << kGmEsumHi << ") a.u." << std::endl;
1129 continue;
1130 }
1131 Double_t dev = esum_peak[s] - 1.0;
1132 std::cout << " strip " << s
1133 << " summed beam peak=" << Form("%.4f", esum_peak[s]) << " a.u. ("
1134 << Form("%+.1f%%", 100.0 * dev) << ")"
1135 << (TMath::Abs(dev) > 0.05 ? " <-- check ridge fit" : "")
1136 << std::endl;
1137 }
1138}
1139
1140// Cathode uses median + IQR/1.349 (asymmetric tail not as clean and the user
1141// prefers to keep cathode on the existing approach). All other channels
1142// (S guard strips + L/R long anodes) use a robust mode-seeded Gaussian fit
1143// over the peak core (mode ± 2σ), anchored to the fitted centroid. Fall back
1144// to the robust mode itself on fit failure.
1145void ReduceToAnchors(std::vector<ChannelCal> &chans,
1146 std::vector<std::vector<Float_t>> &samples,
1147 std::vector<TF1 *> &fits_out, const TString &run_label,
1148 const StripPairSamples pairs[18],
1149 const TString &plot_subdir) {
1150 Int_t n_chans = Int_t(chans.size());
1151 fits_out.assign(n_chans, nullptr);
1152
1153 for (Int_t i = 0; i < n_chans; i++) {
1154 ChannelCal &c = chans[i];
1155 std::vector<Float_t> &v = samples[i];
1156 c.n_samples = Long64_t(v.size());
1157
1158 if (Long64_t(v.size()) < kMinSamples) {
1159 c.fit_adc = 0;
1160 c.fit_sigma_adc = 0;
1161 continue;
1162 }
1163 if (c.side == 'C') {
1164 // Cathode: median + IQR (asymmetric tail, no clean peak).
1165 c.fit_adc = Median(v);
1166 c.fit_sigma_adc = InterquartileRange(v) / 1.349;
1167 } else {
1168 // Primary: robust mode-seeded Gaussian fit of the peak core. Last
1169 // resort: the robust mode itself (peak-like), never the tail-biased
1170 // sample mean.
1171 Double_t peak = 0, sig = 0;
1172 TF1 *fit = nullptr;
1173 TString fname =
1174 Form("f_peak_gaus_%s_%s", c.name.Data(), run_label.Data());
1175 if (FitBeamPeakGaussian(v, fname, peak, sig, fit)) {
1176 c.fit_adc = peak;
1177 c.fit_sigma_adc = sig;
1178 fits_out[i] = fit;
1179 } else {
1180 // Fit failed; anchor on the robust mode. Still "calibrated", but
1181 // no fit curve is drawn -- flag it, tagged long/short, since a
1182 // long-side fallback is a real miscalibration risk.
1183 Double_t mode = 0.0, rsigma = 0.0;
1184 RobustPeakSeed(v, mode, rsigma);
1185 c.fit_adc = mode;
1186 c.fit_sigma_adc = rsigma;
1187 TString kind = (c.side == 'S') ? "guard"
1188 : (c.side == LongSide(c.strip)) ? "long"
1189 : "short";
1190 std::cerr << " [fit-fallback " << kind << "] " << c.name
1191 << ": peak fit failed; using mode anchor "
1192 << Form("%.1f", c.fit_adc) << " ADC (n=" << c.n_samples << ")"
1193 << std::endl;
1194 }
1195 }
1196 std::cout << " " << c.name << " anchor[ADC]=" << c.fit_adc
1197 << " sig=" << c.fit_sigma_adc << " (n=" << c.n_samples << ")"
1198 << std::endl;
1199 }
1200
1201 // After all per-channel peaks are fitted, run the check_LR-style two-pass
1202 // L/R gain matching for strips 1–16: long-side beam peak + short-side
1203 // shoulder anchors, then a per-strip eSum median alignment applied to the
1204 // short side only. Sets the ChannelCal::gain overrides; strips where the
1205 // shoulder cannot be found keep the independent 1/fit_adc gains.
1206 if (pairs)
1207 ComputeLRGainMatch(chans, pairs, plot_subdir);
1208}
1209
1210void WriteEresTomlRaw(const TString &out_subpath,
1211 const Double_t eres_vals[35]) {
1212 toml::table eres_tbl;
1213 eres_tbl.insert("Cathode", eres_vals[0]);
1214 eres_tbl.insert("S0", eres_vals[1]);
1215 eres_tbl.insert("S17", eres_vals[2]);
1216 for (Int_t s = 1; s <= 16; s++) {
1217 std::string key = "L" + std::to_string(s);
1218 eres_tbl.insert(key, eres_vals[3 + (s - 1)]);
1219 }
1220 for (Int_t s = 1; s <= 16; s++) {
1221 std::string key = "R" + std::to_string(s);
1222 eres_tbl.insert(key, eres_vals[19 + (s - 1)]);
1223 }
1224 toml::table detector_tbl;
1225 detector_tbl.insert("eres", eres_tbl);
1226 toml::table root_tbl;
1227 root_tbl.insert("detector", detector_tbl);
1228
1229 // The eres calibration TOML is a small, version-controlled input (a control
1230 // file), not bulk output: write it into the repo's control/ dir alongside the
1231 // other Calibration_Run*_eres.toml, regardless of where root_files point.
1232 TString out_dir = Paths::DatasetDir() + "/sim_control";
1233 gSystem->mkdir(out_dir, kTRUE);
1234 TString out_full = out_dir + "/" + out_subpath;
1235 std::ofstream f(out_full.Data());
1236 if (!f) {
1237 std::cerr << "Cannot write eres TOML: " << out_full << std::endl;
1238 return;
1239 }
1240 f << root_tbl << std::endl;
1241 std::cout << " wrote eres TOML: " << out_full << std::endl;
1242}
1243
1244// Writes a one-row `calibration` tree into the open file `dst` (typically a
1245// per-subfile .cal.root). Layout matches AggregateEresTomlForRun's reader.
1246// `align` carries the beam-energy window and per-strip alignment factors;
1247// pass nullptr when neither has been computed yet (branches are written as
1248// zero).
1249void WriteCalibrationTree(TFile *dst, const std::vector<ChannelCal> &chans,
1250 const StripAlignmentResult *align) {
1251 dst->cd();
1252 if (TObject *old = dst->Get("calibration"))
1253 old->Delete();
1254 TTree *cal = new TTree("calibration", "Per-channel normMUSIC calibration");
1255 Float_t gain[kMaxChannels] = {0};
1256 Float_t fit_adc[kMaxChannels] = {0}, fit_sigma[kMaxChannels] = {0};
1257 Long64_t fit_n[kMaxChannels] = {0};
1258 Bool_t ok[kMaxChannels] = {0};
1259 // Per-strip gains laid out to match the events tree exactly: GainLeft[s]
1260 // multiplies Left_0_17_dE[s] (s=0/17 are the single-ended guards, s=1..16 the
1261 // left ends), GainRight[s] multiplies RightdE[s] (0 at the guards). This is
1262 // what EnergyView reads to calibrate on the fly -- no per-event a.u. is
1263 // stored.
1264 Float_t gain_left[18] = {0}, gain_right[18] = {0};
1265 Float_t gain_cathode = 0.0f;
1266 // Per-strip ridge ratio measured in THIS subfile, 0 where the ridge was not
1267 // measurable. AggregateRidgeRatiosForRun medians these across a run.
1268 Float_t ridge_ratio[18] = {0};
1269 Float_t long_anchor[18] = {0};
1270 Int_t n_actual = TMath::Min(Int_t(chans.size()), kMaxChannels);
1271 for (Int_t k = 0; k < n_actual; k++) {
1272 const ChannelCal &c = chans[k];
1273 ok[k] = IsCalibrated(c) || c.gain > 0;
1274 gain[k] = ok[k] ? Float_t(Gain(c)) : 0.0f;
1275 fit_adc[k] = Float_t(c.fit_adc);
1276 fit_sigma[k] = Float_t(c.fit_sigma_adc);
1277 fit_n[k] = c.n_samples;
1278 if (c.strip >= 1 && c.strip <= 16 && (c.side == 'L' || c.side == 'R')) {
1279 if (c.ridge_ratio > 0)
1280 ridge_ratio[c.strip] = Float_t(c.ridge_ratio);
1281 if (c.side == LongSide(c.strip))
1282 long_anchor[c.strip] = Float_t(c.fit_adc);
1283 }
1284 if (c.side == 'S' && c.strip >= 0 && c.strip <= 17)
1285 gain_left[c.strip] = gain[k];
1286 else if (c.side == 'L' && c.strip >= 1 && c.strip <= 16)
1287 gain_left[c.strip] = gain[k];
1288 else if (c.side == 'R' && c.strip >= 1 && c.strip <= 16)
1289 gain_right[c.strip] = gain[k];
1290 else if (c.side == 'C')
1291 gain_cathode = gain[k];
1292 }
1293 cal->Branch("Gain", gain, Form("Gain[%d]/F", kMaxChannels));
1294 cal->Branch("Ok", ok, Form("Ok[%d]/O", kMaxChannels));
1295 cal->Branch("FitADC", fit_adc, Form("FitADC[%d]/F", kMaxChannels));
1296 cal->Branch("FitSigmaADC", fit_sigma,
1297 Form("FitSigmaADC[%d]/F", kMaxChannels));
1298 cal->Branch("FitN", fit_n, Form("FitN[%d]/L", kMaxChannels));
1299 cal->Branch("GainLeft", gain_left, "GainLeft[18]/F");
1300 cal->Branch("GainRight", gain_right, "GainRight[18]/F");
1301 cal->Branch("GainCathode", &gain_cathode, "GainCathode/F");
1302 cal->Branch("RidgeRatio", ridge_ratio, "RidgeRatio[18]/F");
1303 cal->Branch("LongAnchor", long_anchor, "LongAnchor[18]/F");
1304
1305 // Beam-energy window and per-strip multiplicative alignment factors
1306 // matching the notebook approach (pol3 reference / centroid).
1307 // EnergyView applies total_corrected = factor * total after the
1308 // per-channel gain. Default factor = 1.0 (identity).
1309 Float_t beam_e_min = 0.0f, beam_e_max = 0.0f;
1310 Float_t strip_factor[18];
1311 for (Int_t s = 0; s < 18; s++)
1312 strip_factor[s] = 1.0f;
1313 if (align) {
1314 beam_e_min = Float_t(align->beam_e_min);
1315 beam_e_max = Float_t(align->beam_e_max);
1316 if (align->ok) {
1317 for (Int_t s = 0; s < 18; s++)
1318 strip_factor[s] = Float_t(align->factors[s]);
1319 }
1320 }
1321 cal->Branch("BeamEMin", &beam_e_min, "BeamEMin/F");
1322 cal->Branch("BeamEMax", &beam_e_max, "BeamEMax/F");
1323 cal->Branch("StripFactor", strip_factor, "StripFactor[18]/F");
1324 cal->Fill();
1325 cal->Write("calibration", TObject::kOverwrite);
1326}
1327
1328// Per-channel ADC histogram of the samples that fed each beam anchor. One file
1329// per channel under <plot_subdir>/beam_peak, named beam_peak_<channel>.
1331 const std::vector<ChannelCal> &chans,
1332 const std::vector<std::vector<Float_t>> &samples,
1333 const std::vector<TF1 *> &fits, const TString &plot_subdir) {
1334 TString subdir = plot_subdir + "/beam_peak";
1335 for (Int_t i = 0; i < Int_t(chans.size()); i++) {
1336 const ChannelCal &c = chans[i];
1337 const std::vector<Float_t> &v = samples[i];
1338 if (Long64_t(v.size()) < kMinSamples)
1339 continue;
1340 // Same binning recipe the fit used, so the overlaid Gaussian's amplitude
1341 // matches this histogram exactly.
1342 Double_t mode = 0.0, sigma = 0.0;
1343 RobustPeakSeed(v, mode, sigma);
1344 TH1F *h = MakeBeamPeakHist(Form("h_beam_peak_%s", c.name.Data()),
1345 Form(";%s #DeltaE [ADC];Counts", c.name.Data()),
1346 v, mode, sigma);
1347 TCanvas *cv = PlottingUtils::GetConfiguredCanvas(kFALSE);
1348 PlottingUtils::ConfigureAndDrawHistogram(h, kBlack);
1349 TF1 *fit = fits[i];
1350 if (fit) {
1351 fit->SetLineColor(kViolet + 2);
1352 fit->SetLineWidth(2);
1353 fit->Draw("L SAME");
1354 }
1355 if (Constants::cfg.SAVE_PLOTS)
1356 PlottingUtils::SaveFigure(cv, Form("beam_peak_%s", c.name.Data()), subdir,
1357 PlotSaveOptions::kLINEAR);
1358 delete cv;
1359 delete h;
1360 }
1361}
1362
1363// Writes the per-channel gain table (tree "calibration") into the subfile's own
1364// events file. No per-event calibrated tree is produced: downstream readers
1365// recover a.u. on the fly via gain x raw ADC (EnergyView), so the raw events
1366// tree plus this one-row gain table fully determine every calibrated value.
1368 const std::vector<ChannelCal> &chans,
1369 const StripAlignmentResult *align) {
1370 TString events_subpath = FileSet::EventsName(spec) + ".root";
1371 TFile *f = IO::OpenForWriting(events_subpath, "UPDATE");
1372 if (!f || f->IsZombie()) {
1373 std::cerr << "Cannot open " << events_subpath << " to write calibration"
1374 << std::endl;
1375 if (f)
1376 delete f;
1377 return;
1378 }
1379 WriteCalibrationTree(f, chans, align);
1380 std::cout << " wrote calibration into " << events_subpath << std::endl;
1381 f->Close();
1382 delete f;
1383}
1384
1385// Per-channel calibrated overlay for one subfile, via AttachCalSidecar (the
1386// same path downstream macros use). The sidecar must already be on disk.
1388 const std::vector<ChannelCal> &chans,
1389 const TString &plot_subdir,
1390 const TString &file_label) {
1391 const Int_t kNStrips = 18;
1392 const Int_t nbins = 300;
1393 const Double_t emin = Constants::cfg.STRIP_DE_MIN_NORMED;
1394 const Double_t emax = Constants::cfg.STRIP_DE_MAX_NORMED;
1395 TH1D *h[kNStrips];
1396 for (Int_t s = 0; s < kNStrips; s++) {
1397 h[s] = new TH1D(Form("h_dynrange_%s_S%d", file_label.Data(), s),
1398 ";#DeltaE [a.u.];Counts", nbins, emin, emax);
1399 h[s]->SetDirectory(nullptr);
1400 }
1401 TString sub = FileSet::EventsName(spec) + ".root";
1402 TFile *sf = IO::OpenForReading(sub);
1403 if (!sf || sf->IsZombie()) {
1404 if (sf)
1405 sf->Close();
1406 for (Int_t s = 0; s < kNStrips; s++)
1407 delete h[s];
1408 return;
1409 }
1410 TTree *tree = static_cast<TTree *>(sf->Get("events"));
1411 if (!tree) {
1412 sf->Close();
1413 delete sf;
1414 for (Int_t s = 0; s < kNStrips; s++)
1415 delete h[s];
1416 return;
1417 }
1418 EnergyView ev;
1419 ev.Attach(tree);
1420 if (!ev.is_normed) {
1421 sf->Close();
1422 delete sf;
1423 for (Int_t s = 0; s < kNStrips; s++)
1424 delete h[s];
1425 return;
1426 }
1427 Long64_t n = tree->GetEntries();
1428 for (Long64_t j = 0; j < n; j++) {
1429 tree->GetEntry(j);
1430 ev.Decode();
1431 for (Int_t s = 0; s < kNStrips; s++) {
1432 Double_t v = ev.total[s];
1433 if (v > 0)
1434 h[s]->Fill(v);
1435 }
1436 }
1437 sf->Close();
1438 delete sf;
1439 std::vector<Int_t> colors = PlottingUtils::GetDefaultColors();
1440 Double_t y_top = 0;
1441 for (Int_t s = 0; s < kNStrips; s++) {
1442 Double_t m = h[s]->GetMaximum();
1443 if (m > y_top)
1444 y_top = m;
1445 }
1446 TCanvas *cv = PlottingUtils::GetConfiguredCanvas(kFALSE);
1447 cv->SetRightMargin(0.20);
1448 Bool_t first = kTRUE;
1449 for (Int_t s = 0; s < kNStrips; s++) {
1450 Int_t color = colors[s % Int_t(colors.size())];
1451 h[s]->SetLineColor(color);
1452 h[s]->SetLineWidth(2);
1453 h[s]->SetMaximum(1.15 * y_top);
1454 h[s]->Draw(first ? "HIST" : "HIST SAME");
1455 first = kFALSE;
1456 }
1457 TLegend *leg = PlottingUtils::AddLegend(0.81, 0.99, 0.10, 0.95);
1458 for (Int_t s = 0; s < kNStrips; s++)
1459 leg->AddEntry(h[s], Form("S%d", s), "l");
1460 leg->Draw();
1461 if (Constants::cfg.SAVE_PLOTS)
1462 PlottingUtils::SaveFigure(cv, "dynamic_range_check", plot_subdir,
1463 PlotSaveOptions::kLOG);
1464 delete cv;
1465 delete leg;
1466 for (Int_t s = 0; s < kNStrips; s++)
1467 delete h[s];
1468}
1469
1470// Overlay (one color per channel, log-y) of ONLY the events used for
1471// calibration: the beam anchor samples, converted to a.u. via each channel's
1472// gain. Same axes/style as SaveDynamicRangeOverlay but restricted to
1473// calibration events rather than the full spectrum.
1475 const std::vector<ChannelCal> &chans,
1476 const std::vector<std::vector<Float_t>> &samples,
1477 const TString &plot_subdir, const TString &file_label) {
1478 const Int_t n_chans = Int_t(chans.size());
1479 const Int_t nbins = 300;
1480 const Double_t emin = Constants::cfg.STRIP_DE_MIN_NORMED;
1481 const Double_t emax = Constants::cfg.STRIP_DE_MAX_NORMED;
1482 std::vector<TH1D *> h(n_chans, nullptr);
1483 for (Int_t i = 0; i < n_chans; i++) {
1484 const ChannelCal &c = chans[i];
1485 if (!IsBeamdEChannel(c) || !IsCalibrated(c))
1486 continue;
1487 TString hname =
1488 Form("h_calibrange_%s_%s", file_label.Data(), c.name.Data());
1489 h[i] = new TH1D(hname, ";#DeltaE [a.u.];Counts", nbins, emin, emax);
1490 h[i]->SetDirectory(nullptr);
1491 const std::vector<Float_t> &v = samples[i];
1492 for (Int_t j = 0; j < Int_t(v.size()); j++) {
1493 Double_t val = ApplyCal(c, Double_t(v[j]));
1494 if (val > 0)
1495 h[i]->Fill(val);
1496 }
1497 }
1498
1499 std::vector<Int_t> colors = PlottingUtils::GetDefaultColors();
1500 Double_t y_top = 0;
1501 for (Int_t i = 0; i < n_chans; i++) {
1502 if (!h[i])
1503 continue;
1504 Double_t m = h[i]->GetMaximum();
1505 if (m > y_top)
1506 y_top = m;
1507 }
1508 TCanvas *cv = PlottingUtils::GetConfiguredCanvas(kFALSE);
1509 cv->SetRightMargin(0.20);
1510 Bool_t first = kTRUE;
1511 for (Int_t i = 0; i < n_chans; i++) {
1512 if (!h[i])
1513 continue;
1514 Int_t color = colors[i % Int_t(colors.size())];
1515 h[i]->SetLineColor(color);
1516 h[i]->SetLineWidth(2);
1517 h[i]->SetMaximum(1.15 * y_top);
1518 h[i]->Draw(first ? "HIST" : "HIST SAME");
1519 first = kFALSE;
1520 }
1521 TLegend *leg = PlottingUtils::AddLegend(0.81, 0.99, 0.10, 0.95);
1522 for (Int_t i = 0; i < n_chans; i++) {
1523 if (!h[i])
1524 continue;
1525 leg->AddEntry(h[i], chans[i].name.Data(), "l");
1526 }
1527 leg->Draw();
1528 if (Constants::cfg.SAVE_PLOTS)
1529 PlottingUtils::SaveFigure(cv, "dynamic_range_calib_events", plot_subdir,
1530 PlotSaveOptions::kLOG);
1531 delete cv;
1532 delete leg;
1533 for (Int_t i = 0; i < n_chans; i++)
1534 delete h[i];
1535}
1536
1537// Derives the beam-energy window from the Strip0 (entrance guard) beam-peak
1538// fit. The notebook (37Cl_an.ipynb cell 10) fits a Gaussian to raw stp0 ADC
1539// and takes mu ± 3*sigma. Here, Strip0's beam peak is already fitted in
1540// ReduceToAnchors (fit_adc / fit_sigma_adc), so we convert to a.u. via the
1541// channel's own gain. In a.u. the peak sits at 1.0 by construction, so the
1542// window is 1.0 ± 3*sigma/fit_adc.
1543void DeriveBeamEnergyWindow(const std::vector<ChannelCal> &chans,
1544 StripAlignmentResult &align) {
1545 const Double_t kBeamNSigma = 3.0;
1546 for (Int_t i = 0; i < Int_t(chans.size()); i++) {
1547 const ChannelCal &c = chans[i];
1548 if (c.side == 'S' && c.strip == 0 && IsCalibrated(c)) {
1549 Double_t g = Gain(c);
1550 align.beam_e_min = g * (c.fit_adc - kBeamNSigma * c.fit_sigma_adc);
1551 align.beam_e_max = g * (c.fit_adc + kBeamNSigma * c.fit_sigma_adc);
1552 std::cout << " beam energy window (from Strip0): [" << align.beam_e_min
1553 << ", " << align.beam_e_max << "] a.u." << std::endl;
1554 return;
1555 }
1556 }
1557 std::cerr << " beam energy window: Strip0 not calibrated, using [0, 0]"
1558 << std::endl;
1559}
1560
1561// Per-strip multiplicative alignment, matching the notebook
1562// (37Cl_an.ipynb cell 5). Decodes events with the per-channel gains already on
1563// disk, finds each strip's beam-peak centroid from the eSum 2D histogram,
1564// fits a robust degree-3 polynomial reference trend through the centroids
1565// (strips 1-16 only — the single-ended anodes sit at a different scale), and
1566// derives a multiplicative factor = reference[s] / centroid[s] for 1-16.
1567// Strips 0/17 get factor = 1.0 / centroid (push beam peak to 1.0 a.u.).
1568//
1569// Uses ALL events (not beam-gated): the beam dominates every strip's
1570// histogram by a wide margin.
1572 const TString &plot_subdir,
1573 const TString &file_label) {
1574 const Int_t kNStrips = 18;
1575 const Int_t kNHistBins = 200;
1576 const Double_t kHistMin = 0.0;
1577 const Double_t kHistMax = 10.0;
1578 const Int_t kSmoothTimes = 5;
1579 const Double_t kMisalignPct = 1.5;
1580 const Int_t kMaxIter = 4;
1581 const Int_t kPolyDeg = 3;
1582 const Double_t kGausFitHalfWidth = 0.15;
1583
1584 StripAlignmentResult result;
1585 for (Int_t s = 0; s < kNStrips; s++) {
1586 result.factors[s] = 1.0;
1587 result.centroids[s] = 0.0;
1588 }
1589
1590 TString sub = FileSet::EventsName(spec) + ".root";
1591 TFile *sf = IO::OpenForReading(sub);
1592 if (!sf || sf->IsZombie()) {
1593 if (sf)
1594 sf->Close();
1595 delete sf;
1596 return result;
1597 }
1598 TTree *tree = static_cast<TTree *>(sf->Get("events"));
1599 if (!tree) {
1600 sf->Close();
1601 delete sf;
1602 return result;
1603 }
1604 EnergyView ev;
1605 ev.Attach(tree);
1606 if (!ev.is_normed) {
1607 std::cerr << " " << file_label
1608 << ": calibration tree not found -- skipping alignment"
1609 << std::endl;
1610 sf->Close();
1611 delete sf;
1612 return result;
1613 }
1614
1615 TH2D *h2 = new TH2D(Form("h2_strip_align_%s", file_label.Data()),
1616 ";Strip number;#DeltaE [a.u.]", kNStrips, -0.5,
1617 kNStrips - 0.5, kNHistBins, kHistMin, kHistMax);
1618 h2->SetDirectory(nullptr);
1619
1620 Long64_t n = tree->GetEntries();
1621 Long64_t n_used = 0;
1622 for (Long64_t j = 0; j < n; j++) {
1623 tree->GetEntry(j);
1624 ev.Decode();
1625 Bool_t any = kFALSE;
1626 for (Int_t s = 0; s < kNStrips; s++) {
1627 Double_t v = ev.total[s];
1628 if (v <= 0)
1629 continue;
1630 h2->Fill(Double_t(s), v);
1631 any = kTRUE;
1632 }
1633 if (any)
1634 n_used++;
1635 }
1636 sf->Close();
1637 delete sf;
1638
1639 std::cout << " strip alignment: " << n_used << " events decoded"
1640 << std::endl;
1641
1642 Double_t beam_centroids[kNStrips] = {0};
1643 Bool_t beam_ok[kNStrips] = {kFALSE};
1644
1645 for (Int_t s = 0; s < kNStrips; s++) {
1646 Int_t bin_ix = s + 1;
1647 TH1D *proj = h2->ProjectionY(
1648 Form("hproj_align_%s_s%d", file_label.Data(), s), bin_ix, bin_ix);
1649 proj->SetDirectory(nullptr);
1650 Long64_t n_entries = Long64_t(proj->GetEntries());
1651 if (n_entries < kMinSamples) {
1652 std::cerr << " strip " << s << ": too few entries for alignment ("
1653 << n_entries << ")" << std::endl;
1654 delete proj;
1655 continue;
1656 }
1657 proj->Smooth(kSmoothTimes);
1658
1659 // Strip 0/17: single-ended anode, beam at ~1.0, pileup at ~2.0.
1660 // Strips 1-16: total is bimodal when the short side doesn't fire
1661 // (long-only ≈ 0.5) — pick the peak nearest 1.0, which is the
1662 // full-strip beam peak (both sides contributing, eSum ≈ 1.0).
1663 Double_t peak_target = 1.0;
1664 Double_t search_lo, search_hi;
1665 if (s == 0 || s == 17) {
1666 search_lo = 0.3;
1667 search_hi = 1.6;
1668 } else {
1669 search_lo = 0.6;
1670 search_hi = 1.5;
1671 }
1672 Int_t b_lo = proj->FindBin(search_lo);
1673 Int_t b_hi = proj->FindBin(search_hi);
1674 Int_t b_max = -1;
1675 Double_t best_dist = 1e9;
1676 for (Int_t b = b_lo; b <= b_hi; b++) {
1677 Double_t v = proj->GetBinContent(b);
1678 if (v <= 0)
1679 continue;
1680 // Local maximum check: higher than neighbours
1681 if (b > b_lo && proj->GetBinContent(b - 1) >= v)
1682 continue;
1683 if (b < b_hi && proj->GetBinContent(b + 1) > v)
1684 continue;
1685 Double_t bc = proj->GetBinCenter(b);
1686 Double_t d = TMath::Abs(bc - peak_target);
1687 if (d < best_dist) {
1688 best_dist = d;
1689 b_max = b;
1690 }
1691 }
1692 // Fallback: global maximum if no local peaks found near 1.0
1693 if (b_max < 0) {
1694 Double_t val_max = 0;
1695 for (Int_t b = b_lo; b <= b_hi; b++) {
1696 Double_t v = proj->GetBinContent(b);
1697 if (v > val_max) {
1698 val_max = v;
1699 b_max = b;
1700 }
1701 }
1702 }
1703 if (b_max < 0) {
1704 delete proj;
1705 continue;
1706 }
1707 Double_t seed_peak = proj->GetBinCenter(b_max);
1708 Double_t seed_h = proj->GetBinContent(b_max);
1709 if (seed_h <= 0 || seed_peak <= 0) {
1710 delete proj;
1711 continue;
1712 }
1713
1714 // Sub-bin precision: Gaussian fit around the smoothed max-bin seed
1715 Double_t fit_lo = seed_peak - kGausFitHalfWidth;
1716 Double_t fit_hi = seed_peak + kGausFitHalfWidth;
1717 if (fit_lo < kHistMin)
1718 fit_lo = kHistMin;
1719 if (fit_hi > kHistMax)
1720 fit_hi = kHistMax;
1721 TF1 *fg = new TF1("f_align_peak_refine", "gaus", fit_lo, fit_hi);
1722 fg->SetParameters(seed_h, seed_peak, 0.05);
1723 fg->SetParLimits(1, fit_lo, fit_hi);
1724 TFitResultPtr r = proj->Fit(fg, "QSRN");
1725 Double_t beam_peak = fg->GetParameter(1);
1726 if (!(r.Get() && r->IsValid() && beam_peak > 0))
1727 beam_peak = seed_peak;
1728 delete fg;
1729
1730 beam_centroids[s] = beam_peak;
1731 beam_ok[s] = kTRUE;
1732 std::cout << " strip " << s << " beam=" << Form("%.4f", beam_peak)
1733 << " a.u. (n=" << n_entries << ")" << std::endl;
1734 delete proj;
1735 }
1736
1737 // Robust pol3 reference trend through strips 1-16 centroids.
1738 // Iteratively drop the strip with the worst residual > kMisalignPct.
1739 TGraph *g_cent = new TGraph(kNStrips);
1740 Int_t np = 0;
1741 for (Int_t s = 1; s <= 16; s++) {
1742 if (beam_ok[s]) {
1743 g_cent->SetPoint(np, Double_t(s), beam_centroids[s]);
1744 np++;
1745 }
1746 }
1747 g_cent->Set(np);
1748
1749 std::set<Int_t> outliers;
1750 for (Int_t iter = 0; iter < kMaxIter; iter++) {
1751 if (g_cent->GetN() <= kPolyDeg + 1)
1752 break;
1753 TF1 *fpol = new TF1(Form("fpol_align_%s_iter%d", file_label.Data(), iter),
1754 "pol3", -0.5, kNStrips - 0.5);
1755 TFitResultPtr r = g_cent->Fit(fpol, "QSRN");
1756 if (!r.Get() || !r->IsValid()) {
1757 delete fpol;
1758 break;
1759 }
1760 Double_t worst_pct = 0;
1761 Int_t worst_idx = -1;
1762 for (Int_t i = 0; i < g_cent->GetN(); i++) {
1763 Double_t x, y;
1764 g_cent->GetPoint(i, x, y);
1765 Double_t pred = fpol->Eval(x);
1766 if (pred <= 0)
1767 continue;
1768 Double_t resid_pct = TMath::Abs(y - pred) / pred * 100.0;
1769 if (resid_pct > worst_pct) {
1770 worst_pct = resid_pct;
1771 worst_idx = i;
1772 }
1773 }
1774 delete fpol;
1775 if (worst_pct < kMisalignPct || worst_idx < 0)
1776 break;
1777 Double_t rx, ry;
1778 g_cent->GetPoint(worst_idx, rx, ry);
1779 std::cout << " strip " << Int_t(rx) << " outlier "
1780 << Form("%.1f", worst_pct) << "% — dropped" << std::endl;
1781 outliers.insert(Int_t(rx));
1782 g_cent->RemovePoint(worst_idx);
1783 }
1784
1785 TF1 *fbeam = nullptr;
1786 if (g_cent->GetN() > kPolyDeg + 1) {
1787 fbeam = new TF1(Form("fbeam_align_%s", file_label.Data()), "pol3", -0.5,
1788 kNStrips - 0.5);
1789 g_cent->Fit(fbeam, "QSRN");
1790 std::cout << " strip alignment reference: pol3 fitted through "
1791 << g_cent->GetN() << " strips" << std::endl;
1792 }
1793
1794 // Notebook (37Cl_an.ipynb cell 5): factors = reference / centroid,
1795 // where reference is the pol3 trend through strips 1-16 centroids.
1796 // Strips 0/17 are single-ended anodes not in the pol3 fit; push to 1.0.
1797 Int_t valid_strips = 0;
1798 for (Int_t s = 0; s < kNStrips; s++) {
1799 if (!beam_ok[s])
1800 continue;
1801 Double_t centro = beam_centroids[s];
1802 if (centro <= 0)
1803 continue;
1804 result.centroids[s] = centro;
1805 if (s >= 1 && s <= 16 && fbeam) {
1806 Double_t ref = fbeam->Eval(Double_t(s));
1807 if (ref > 0)
1808 result.factors[s] = ref / centro;
1809 else
1810 result.factors[s] = 1.0 / centro;
1811 } else {
1812 result.factors[s] = 1.0 / centro;
1813 }
1814 valid_strips++;
1815 std::cout << " strip " << s << " centroid=" << Form("%.4f", centro)
1816 << " factor=" << Form("%.4f", result.factors[s]) << std::endl;
1817 }
1818 result.ok = (valid_strips >= 4) ? kTRUE : kFALSE;
1819
1820 // Diagnostic plot
1821 {
1822 TCanvas *cv = PlottingUtils::GetConfiguredCanvas(kFALSE);
1823 PlottingUtils::ConfigureAndDraw2DHistogram(h2, cv);
1824 TGraph *g_beam_plot = new TGraph(kNStrips);
1825 Int_t nb = 0;
1826 for (Int_t s = 0; s < kNStrips; s++) {
1827 if (beam_ok[s]) {
1828 g_beam_plot->SetPoint(nb, Double_t(s), beam_centroids[s]);
1829 nb++;
1830 }
1831 }
1832 g_beam_plot->Set(nb);
1833 if (nb > 0) {
1834 g_beam_plot->SetMarkerStyle(20);
1835 g_beam_plot->SetMarkerColor(kOrange);
1836 g_beam_plot->Draw("P SAME");
1837 }
1838 if (fbeam) {
1839 fbeam->SetLineColor(kViolet + 2);
1840 fbeam->SetLineWidth(2);
1841 fbeam->Draw("SAME");
1842 }
1843 if (Constants::cfg.SAVE_PLOTS)
1844 PlottingUtils::SaveFigure(cv, "strip_alignment_check", plot_subdir,
1845 PlotSaveOptions::kLINEAR);
1846 delete cv;
1847 delete g_beam_plot;
1848 }
1849
1850 delete fbeam;
1851 delete g_cent;
1852 delete h2;
1853
1854 return result;
1855}
1856
1858 const FileSpec &spec, const std::vector<ChannelCal> &chans_template) {
1859 TString file_label = FileSet::FileLabel(spec);
1860 TString plot_subdir = "beam_calibration/" + file_label;
1861 std::cout << "Beam calibration: " << file_label << std::endl;
1862
1863 // One gate per strip, on that strip's total against its neighbour's. A
1864 // strip whose gate cannot be fitted is left out of the calibration rather
1865 // than dragging the whole subfile down with it.
1866 BeamFit2D gate[18];
1867 Int_t n_gates = 0;
1868 {
1869 std::lock_guard<std::mutex> lock(g_plot_mutex);
1870 for (Int_t s = 1; s <= 17; s++) {
1871 gate[s] =
1872 FindBeamGateStrips(spec, GatePartner(s), s, file_label, plot_subdir);
1873 if (gate[s].ok)
1874 n_gates++;
1875 else
1876 std::cerr << " " << file_label << ": strip " << s
1877 << " beam gate failed; that strip is not calibrated here"
1878 << std::endl;
1879 }
1880 // Strip 0 shares strip 1's gate: there is no strip before it, so the
1881 // (0, 1) pair is the only one available to either.
1882 gate[0] = gate[1];
1883 }
1884 if (n_gates == 0) {
1885 std::cerr << " " << file_label << ": every per-strip beam gate failed"
1886 << std::endl;
1887 return;
1888 }
1889
1890 std::vector<ChannelCal> chans = chans_template;
1891 std::vector<std::vector<Float_t>> samples;
1892 StripPairSamples pairs[18];
1893 CollectAnchorSamplesOneSubfile(spec, chans, gate, samples, pairs);
1894 std::vector<TF1 *> peak_fits;
1895 {
1896 std::lock_guard<std::mutex> lock(g_plot_mutex);
1897 ReduceToAnchors(chans, samples, peak_fits, file_label, pairs, plot_subdir);
1898 SaveBeamPeakChannelHistograms(chans, samples, peak_fits, plot_subdir);
1899 }
1900 for (Int_t i = 0; i < Int_t(peak_fits.size()); i++)
1901 delete peak_fits[i];
1902 peak_fits.clear();
1903
1904 // Every channel that failed to calibrate is silently forced to gain 0 (reads
1905 // 0 a.u. and drops out of the strip total), so spell out which ones and why.
1906 // The long/short tag makes a long-side failure -- the dominant signal, which
1907 // should never starve -- easy to spot: grep "[uncalibrated long]".
1908 for (Int_t i = 0; i < Int_t(chans.size()); i++) {
1909 const ChannelCal &c = chans[i];
1910 if (IsCalibrated(c))
1911 continue;
1912 TString kind;
1913 if (c.side == 'C')
1914 kind = "cathode";
1915 else if (c.side == 'S')
1916 kind = "guard";
1917 else
1918 kind = (c.side == LongSide(c.strip)) ? "long" : "short";
1919 TString why;
1920 if (c.n_samples < kMinSamples)
1921 why =
1922 Form("too few beam samples (%lld < %lld)", c.n_samples, kMinSamples);
1923 else
1924 why = Form("bad exp anchor (fit_adc=%.1f)", c.fit_adc);
1925 std::cerr << " [uncalibrated " << kind << "] " << c.name << " -> gain 0; "
1926 << why << " (beam n=" << c.n_samples << ")" << std::endl;
1927 }
1928
1929 for (Int_t i = 0; i < Int_t(chans.size()); i++)
1930 if (IsCalibrated(chans[i]))
1931 std::cout << " " << chans[i].name << " gain=" << Gain(chans[i])
1932 << " a.u./ADC resolution="
1933 << Form("%.2f", ResolutionFWHMPercent(chans[i])) << "% FWHM"
1934 << std::endl;
1935
1936 // Derive beam energy window from Strip0 (mu ± 3*sigma in a.u.).
1938 DeriveBeamEnergyWindow(chans, align);
1939
1940 // Write initial calibration tree: per-channel gains + beam window. The
1941 // alignment step needs this on disk so EnergyView can decode events in a.u.
1942 WriteCalibrationToEvents(spec, chans, &align);
1943
1944 // Per-strip multiplicative alignment: decode events with the per-channel
1945 // gains just written, find each strip's beam-peak centroid, fit a pol3
1946 // reference trend, and derive factors = reference / centroid. Stored in
1947 // the calibration tree and applied by EnergyView as total *= factor.
1948 // gains just written, find each strip's beam-peak AND pileup-peak centroids,
1949 // and derive a linear correction (slope + intercept) that maps beam→1.0 and
1950 // pileup→2.0, flattening the Bragg curve. The polynomial trend is used only
1951 // Preserve beam energy window from DeriveBeamEnergyWindow across the
1952 // alignment call (which returns a fresh StripAlignmentResult).
1953 Double_t beam_e_min = align.beam_e_min;
1954 Double_t beam_e_max = align.beam_e_max;
1955 {
1956 std::lock_guard<std::mutex> lock(g_plot_mutex);
1957 align = FindStripCentroidAlignment(spec, plot_subdir, file_label);
1958 }
1959 align.beam_e_min = beam_e_min;
1960 align.beam_e_max = beam_e_max;
1961 if (align.ok) {
1962 WriteCalibrationToEvents(spec, chans, &align);
1963 }
1964
1965 {
1966 std::lock_guard<std::mutex> lock(g_plot_mutex);
1967 SaveDynamicRangeOverlay(spec, chans, plot_subdir, file_label);
1968 }
1969 {
1970 std::lock_guard<std::mutex> lock(g_plot_mutex);
1971 SaveCalibSampleOverlay(chans, samples, plot_subdir, file_label);
1972 }
1973
1974 std::cout << " " << file_label << " calibration complete." << std::endl;
1975}
1976
1977// Replace each subfile's short-side gain with one built from the run-level
1978// median ridge ratio.
1979//
1980// C_short/C_long is a ratio of preamp gains, so it is fixed per channel and
1981// does not vary subfile to subfile -- the measured scatter is ~1%, well below
1982// the ~7% spread between strips. But a single subfile often cannot fit the
1983// ridge on the low-occupancy short ends (on 87Rb the even strips fit in only
1984// ~45% of subfiles, and 37Cl's upstream strips almost never do), so per-subfile
1985// fitting leaves a large fraction of strips on a fallback.
1986//
1987// Aggregating fixes that without re-reading any event data: take the median
1988// ratio per strip over the subfiles that did measure it, then rebuild every
1989// subfile's short gain as 1/(ratio * C_long), using that subfile's own C_long
1990// so per-subfile gain drift is preserved. Strips that no subfile could fit keep
1991// whatever the per-subfile fallback gave them.
1993 Int_t run, const std::vector<FileSpec> &specs) {
1994 std::vector<Double_t> per_strip[18];
1995 for (Int_t k = 0; k < Int_t(specs.size()); k++) {
1996 TString sub = FileSet::EventsName(specs[k]) + ".root";
1997 TFile *cf = IO::OpenForReading(sub);
1998 if (!cf || cf->IsZombie()) {
1999 if (cf)
2000 delete cf;
2001 continue;
2002 }
2003 TTree *t = static_cast<TTree *>(cf->Get("calibration"));
2004 if (!t || t->GetEntries() < 1 || !t->GetBranch("RidgeRatio")) {
2005 cf->Close();
2006 delete cf;
2007 continue;
2008 }
2009 Float_t rr[18] = {0};
2010 t->SetBranchAddress("RidgeRatio", rr);
2011 t->GetEntry(0);
2012 for (Int_t s = 1; s <= 16; s++)
2013 if (rr[s] > 0)
2014 per_strip[s].push_back(Double_t(rr[s]));
2015 cf->Close();
2016 delete cf;
2017 }
2018
2019 Double_t med[18] = {0};
2020 std::cout << "Run " << run << " ridge-ratio aggregation:" << std::endl;
2021 for (Int_t s = 1; s <= 16; s++) {
2022 std::vector<Double_t> &v = per_strip[s];
2023 if (v.size() < 3) {
2024 std::cerr << " strip " << s << ": only " << v.size()
2025 << " subfiles measured the ridge; leaving per-subfile gains"
2026 << std::endl;
2027 continue;
2028 }
2029 std::sort(v.begin(), v.end());
2030 Int_t m = Int_t(v.size());
2031 med[s] = (m % 2 == 1) ? v[m / 2] : 0.5 * (v[m / 2 - 1] + v[m / 2]);
2032 Double_t lo = v[m / 4], hi = v[(3 * m) / 4];
2033 std::cout << " strip " << s << " ratio=" << Form("%.4f", med[s])
2034 << " IQR " << Form("%.4f", lo) << "-" << Form("%.4f", hi)
2035 << " from " << m << " subfiles" << std::endl;
2036 }
2037
2038 Int_t n_rewritten = 0;
2039 for (Int_t k = 0; k < Int_t(specs.size()); k++) {
2040 TString sub = FileSet::EventsName(specs[k]) + ".root";
2041 TFile *cf = IO::OpenForWriting(sub, "UPDATE");
2042 if (!cf || cf->IsZombie()) {
2043 if (cf)
2044 delete cf;
2045 continue;
2046 }
2047 TTree *t = static_cast<TTree *>(cf->Get("calibration"));
2048 if (!t || t->GetEntries() < 1 || !t->GetBranch("LongAnchor")) {
2049 cf->Close();
2050 delete cf;
2051 continue;
2052 }
2053 Float_t gl[18] = {0}, gr[18] = {0}, la[18] = {0}, rr[18] = {0};
2054 t->SetBranchAddress("GainLeft", gl);
2055 t->SetBranchAddress("GainRight", gr);
2056 t->SetBranchAddress("LongAnchor", la);
2057 t->SetBranchAddress("RidgeRatio", rr);
2058 t->GetEntry(0);
2059 Bool_t changed = kFALSE;
2060 for (Int_t s = 1; s <= 16; s++) {
2061 if (med[s] <= 0 || la[s] <= 0)
2062 continue;
2063 Double_t anchor = med[s] * Double_t(la[s]);
2064 if (anchor <= 0)
2065 continue;
2066 if (LongSide(s) == 'L')
2067 gr[s] = Float_t(1.0 / anchor);
2068 else
2069 gl[s] = Float_t(1.0 / anchor);
2070 rr[s] = Float_t(med[s]);
2071 changed = kTRUE;
2072 }
2073 if (changed) {
2074 TTree *nt = t->CloneTree(0);
2075 nt->Fill();
2076 cf->cd();
2077 nt->Write("calibration", TObject::kOverwrite);
2078 n_rewritten++;
2079 }
2080 cf->Close();
2081 delete cf;
2082 }
2083 std::cout << " rewrote short gains in " << n_rewritten << " subfiles"
2084 << std::endl;
2085}
2086
2088 Int_t run, const std::vector<FileSpec> &specs) {
2089 const Int_t n_eres = 35;
2090 std::vector<std::vector<Double_t>> fwhm_per_chan(n_eres);
2091
2092 std::vector<ChannelCal> tmpl = CalibrateBeam::BuildChannels();
2093 Int_t n_chans = Int_t(tmpl.size());
2094
2095 for (Int_t s = 0; s < Int_t(specs.size()); s++) {
2096 // The calibration tree now lives inside each subfile's events file.
2097 TString cal_sub = FileSet::EventsName(specs[s]) + ".root";
2098 TFile *cf = IO::OpenForReading(cal_sub);
2099 if (!cf || cf->IsZombie()) {
2100 if (cf)
2101 delete cf;
2102 continue;
2103 }
2104 TTree *t = static_cast<TTree *>(cf->Get("calibration"));
2105 if (!t) {
2106 cf->Close();
2107 delete cf;
2108 continue;
2109 }
2110 Float_t fit_adc[kMaxChannels] = {0};
2111 Float_t fit_sigma[kMaxChannels] = {0};
2112 Bool_t ok[kMaxChannels] = {0};
2113 t->SetBranchAddress("FitADC", fit_adc);
2114 t->SetBranchAddress("Ok", ok);
2115 t->SetBranchAddress("FitSigmaADC", fit_sigma);
2116 if (t->GetEntries() < 1) {
2117 cf->Close();
2118 delete cf;
2119 continue;
2120 }
2121 t->GetEntry(0);
2122
2123 for (Int_t i = 0; i < n_chans && i < kMaxChannels; i++) {
2124 if (!ok[i])
2125 continue;
2126 Double_t sig_adc = fit_sigma[i];
2127 if (sig_adc <= 0 || fit_adc[i] <= 0)
2128 continue;
2129 // Relative resolution in % FWHM, straight from the raw-ADC peak fit —
2130 // independent of the normMUSIC gain/normalization by construction.
2131 const Double_t kFwhmPerSigma = 2.0 * TMath::Sqrt(2.0 * TMath::Log(2.0));
2132 Double_t fwhm_pct =
2133 100.0 * kFwhmPerSigma * Double_t(sig_adc) / Double_t(fit_adc[i]);
2134 Int_t idx = ChannelToEresIndex(tmpl[i]);
2135 if (idx >= 0 && idx < n_eres)
2136 fwhm_per_chan[idx].push_back(fwhm_pct);
2137 }
2138 cf->Close();
2139 delete cf;
2140 }
2141
2142 Double_t eres_vals[35];
2143 for (Int_t i = 0; i < n_eres; i++)
2144 eres_vals[i] = -1.0;
2145 for (Int_t i = 0; i < n_eres; i++) {
2146 std::vector<Double_t> &v = fwhm_per_chan[i];
2147 if (v.empty())
2148 continue;
2149 std::sort(v.begin(), v.end());
2150 Int_t m = Int_t(v.size());
2151 eres_vals[i] = (m % 2 == 1) ? v[m / 2] : 0.5 * (v[m / 2 - 1] + v[m / 2]);
2152 }
2153 std::cout << "Run " << run << ": writing per-channel %FWHM medians"
2154 << std::endl;
2155 WriteEresTomlRaw(Form("Calibration_Run%d_eres.toml", run), eres_vals);
2156}
2157
2158void CalibrateBeam::Run(const TString &file_label) {
2159 const TString project_root = Paths::DatasetDir();
2160 InitUtils::SetROOTPreferences(PlotSaveFormat::kPNG,
2161 Paths::ResultsDir() + "/plots",
2162 Paths::ResultsDir() + "/root_files");
2163 gROOT->SetBatch(kTRUE);
2164
2165 std::vector<FileSpec> specs;
2166 if (file_label.IsNull()) {
2168 if (specs.empty()) {
2169 std::cerr << "No file specs from FileSet::BuildProcessedFileSpecs()"
2170 << std::endl;
2171 return;
2172 }
2173 } else {
2174 FileSpec s = FileSet::ResolveFileSpec(file_label);
2175 if (s.run < 0) {
2176 std::cerr << "Could not resolve file label '" << file_label << "'"
2177 << std::endl;
2178 return;
2179 }
2180 specs.push_back(s);
2181 }
2182
2183 std::vector<ChannelCal> chans = CalibrateBeam::BuildChannels();
2184
2185 Int_t n_specs = Int_t(specs.size());
2186
2187 std::set<Int_t> runs;
2188 for (Int_t k = 0; k < n_specs; k++)
2189 runs.insert(specs[k].run);
2190
2191 Int_t n_workers =
2192 TMath::Min(Int_t(std::thread::hardware_concurrency()), n_specs);
2193 n_workers = TMath::Min(n_workers, Constants::cfg.MAX_FUSED_WORKERS);
2194 if (n_workers < 1)
2195 n_workers = 1;
2196 std::cout << "calibrate-beam: " << n_specs << " subfiles on " << n_workers
2197 << " workers" << std::endl;
2198
2199 std::queue<Int_t> work;
2200 for (Int_t k = 0; k < n_specs; k++)
2201 work.push(k);
2202 std::mutex work_mutex;
2203
2204 std::vector<std::thread> workers;
2205 for (Int_t w = 0; w < n_workers; w++) {
2206 workers.emplace_back([&]() {
2207 while (true) {
2208 Int_t k;
2209 {
2210 std::lock_guard<std::mutex> lk(work_mutex);
2211 if (work.empty())
2212 return;
2213 k = work.front();
2214 work.pop();
2215 }
2216 CalibrateBeamOneSubfile(specs[k], chans);
2217 }
2218 });
2219 }
2220 for (Int_t w = 0; w < Int_t(workers.size()); w++)
2221 workers[w].join();
2222
2223 for (std::set<Int_t>::const_iterator it = runs.begin(); it != runs.end();
2224 ++it) {
2225 std::vector<FileSpec> run_specs;
2226 for (Int_t k = 0; k < n_specs; k++)
2227 if (specs[k].run == *it)
2228 run_specs.push_back(specs[k]);
2229 AggregateEresTomlForRun(*it, run_specs);
2230 }
2231}
void ComputeLRGainMatch(std::vector< ChannelCal > &chans, const StripPairSamples pairs[18], const TString &plot_subdir)
void DeriveBeamEnergyWindow(const std::vector< ChannelCal > &chans, StripAlignmentResult &align)
const Long64_t kRidgeMinPerSlice
const Int_t kGmBins
const Double_t kRidgeBandHi
void RobustPeakSeed(const std::vector< Float_t > &v, Double_t &mode, Double_t &sigma)
const Double_t kRidgeRatioLo
const Int_t kMaxChannels
TH1F * MakeBeamPeakHist(const TString &name, const TString &title, const std::vector< Float_t > &v, Double_t mode, Double_t sigma)
BeamFit2D FindBeamGateStrips(const FileSpec &spec, Int_t sx, Int_t sy, const TString &run_label, const TString &plot_subdir, Bool_t save_plot=kTRUE)
Bool_t TheilSenLine(const std::vector< Double_t > &x, const std::vector< Double_t > &y, Double_t &slope, Double_t &intercept)
const Double_t kRidgeBandLo
Double_t RidgeShortAnchor(const std::vector< Float_t > &v_short, const std::vector< Float_t > &v_long, Double_t c_long, Double_t &slope_out, Double_t &intercept_out, RidgeFit *dbg=nullptr)
const Long64_t kMinSamples
Bool_t FitBeamPeakGaussian(const std::vector< Float_t > &v, const TString &fname, Double_t &peak_adc, Double_t &sigma_adc, TF1 *&fit_out)
void ReduceToAnchors(std::vector< ChannelCal > &chans, std::vector< std::vector< Float_t > > &samples, std::vector< TF1 * > &fits_out, const TString &run_label, const StripPairSamples pairs[18], const TString &plot_subdir)
Double_t StripTotalAdc(const UShort_t *l, const UShort_t *r, Int_t s)
Double_t GmFindPeak(const std::vector< Float_t > &v, Double_t lo, Double_t hi, Double_t skip_frac)
Bool_t IsBeamdEChannel(const ChannelCal &c)
Char_t LongSide(Int_t strip)
const Long64_t kSampleCap
void WriteEresTomlRaw(const TString &out_subpath, const Double_t eres_vals[35])
void SaveBeamPeakChannelHistograms(const std::vector< ChannelCal > &chans, const std::vector< std::vector< Float_t > > &samples, const std::vector< TF1 * > &fits, const TString &plot_subdir)
Double_t ApplyCal(const ChannelCal &c, Double_t adc)
Double_t InterquartileRange(std::vector< Float_t > &v)
void WriteCalibrationTree(TFile *dst, const std::vector< ChannelCal > &chans, const StripAlignmentResult *align)
Int_t GatePartner(Int_t s)
void SaveRidgeFitPlots(const StripPairSamples pairs[18], const RidgeFit dbg[18], const TString &plot_subdir)
const Double_t kGmEsumLo
void CollectAnchorSamplesOneSubfile(const FileSpec &spec, const std::vector< ChannelCal > &chans, const BeamFit2D gate[18], std::vector< std::vector< Float_t > > &samples, StripPairSamples pairs[18])
StripAlignmentResult FindStripCentroidAlignment(const FileSpec &spec, const TString &plot_subdir, const TString &file_label)
const Double_t kRidgeShortMaxFrac
Double_t Median(std::vector< Float_t > &v)
Double_t ResolutionFWHMPercent(const ChannelCal &c)
const Int_t kRidgeSlices
Bool_t FitGaussianMuSigma(const std::vector< Float_t > &v, const TString &fname, Double_t &mu, Double_t &sigma)
Bool_t IsCalibrated(const ChannelCal &c)
const Int_t kRidgeMinPts
void SaveDynamicRangeOverlay(const FileSpec &spec, const std::vector< ChannelCal > &chans, const TString &plot_subdir, const TString &file_label)
Int_t ChannelToEresIndex(const ChannelCal &c)
Double_t Gain(const ChannelCal &c)
const Double_t kRidgeRatioHi
const Long64_t kRidgeNoiseFloor
void WriteCalibrationToEvents(const FileSpec &spec, const std::vector< ChannelCal > &chans, const StripAlignmentResult *align)
const Double_t kGmEsumHi
const Long64_t kPairCap
std::mutex g_plot_mutex
Serialises all plotting and canvas work.
Definition FileSet.cpp:3
static Moments2D ComputeMoments(TH2F *h, Int_t lo_bx, Int_t hi_bx, Int_t lo_by, Int_t hi_by, Double_t thresh, Double_t bw_x, Double_t bw_y)
Second moments of a histogram region above a threshold.
Definition BeamFit2D.cpp:20
static Bool_t InEllipseXY(const BeamFit2D &b, Double_t x, Double_t y, Double_t nx, Double_t ny)
Whether a point lies inside the fitted beam ellipse.
Definition BeamFit2D.cpp:3
static void SaveCalibSampleOverlay(const std::vector< ChannelCal > &chans, const std::vector< std::vector< Float_t > > &samples, const TString &plot_subdir, const TString &file_label)
Save the per-channel calibration overlay figure.
static void Run(const TString &file_label="")
Calibrate every configured subfile, then aggregate per run.
static void CalibrateBeamOneSubfile(const FileSpec &spec, const std::vector< ChannelCal > &chans_template)
Calibrate one subfile end to end.
static void AggregateEresTomlForRun(Int_t run, const std::vector< FileSpec > &specs)
Aggregate the run's energy-resolution measurements into its TOML.
static std::vector< ChannelCal > BuildChannels()
One ChannelCal per readout channel in the active channel map.
static void AggregateRidgeRatiosForRun(Int_t run, const std::vector< FileSpec > &specs)
Replace each subfile's ridge ratio with the run-level median.
static TString EventsName(const FileSpec &s)
Filename of the built-events ROOT file for a subfile.
Definition FileSet.cpp:289
static TString FileLabel(const FileSpec &s)
Human-readable label identifying a subfile.
Definition FileSet.cpp:296
static FileSpec ResolveFileSpec(const TString &file_label)
Recover the FileSpec behind a label from FileLabel().
Definition FileSet.cpp:332
static std::vector< FileSpec > BuildProcessedFileSpecs()
Every subfile that already has processed output.
Definition FileSet.cpp:259
static TString DatasetDir()
Absolute path to the active dataset directory, analysis/<iso>.
Definition Paths.cpp:60
static TString ResultsDir()
Absolute path to the directory receiving generated output.
Definition Paths.cpp:24
Double_t ActiveStripEMaxAdc()
Upper bound of the per-strip energy range, in ADC.
const DatasetConfig & cfg
The active dataset's configuration, flat block.
A fitted 2-D Gaussian beam spot.
Definition BeamFit2D.hpp:16
Double_t rho
Correlation coefficient, in [-1, 1].
Definition BeamFit2D.hpp:20
Double_t sigma_y
Widths along each axis.
Definition BeamFit2D.hpp:19
Double_t mu_x
Definition BeamFit2D.hpp:18
Double_t amp
Peak amplitude.
Definition BeamFit2D.hpp:17
Double_t mu_y
Centroid.
Definition BeamFit2D.hpp:18
Bool_t ok
Whether the fit converged.
Definition BeamFit2D.hpp:21
Double_t sigma_x
Definition BeamFit2D.hpp:19
One readout channel's beam-peak calibration.
Char_t side
Readout end: 'L' or 'R'.
TString name
Channel name from the active channel map.
Double_t gain
Left/right gain-match override.
Double_t fit_adc
Fitted beam-peak centroid, in ADC.
Int_t strip
Anode strip index this channel reads.
Long64_t n_samples
Events entering the fit.
Double_t ridge_ratio
Short channels only: C_short / C_long from this subfile's own ridge fit, or 0 when the ridge was not ...
Double_t fit_sigma_adc
Fitted beam-peak width, in ADC.
A view over one event's energies, decoding raw ADC into calibrated units.
void Decode()
Decode the currently loaded entry into the value members.
Bool_t Attach(TTree *t)
Bind to an events tree and set up the branch addresses.
Double_t total[18]
Summed energy per strip, after strip_factor.
Bool_t is_normed
Whether a calibration tree was found.
One input file: a run number and the subfile suffix within it.
Definition FileSet.hpp:40
Int_t run
Run number.
Definition FileSet.hpp:41
Second moments of a 2-D distribution over a bin range.
Definition BeamFit2D.hpp:30
Double_t rho
Correlation coefficient, in [-1, 1].
Definition BeamFit2D.hpp:33
Double_t sigma_y
Weighted RMS widths.
Definition BeamFit2D.hpp:32
Double_t sigma_x
Definition BeamFit2D.hpp:32
Double_t mu_x
Definition BeamFit2D.hpp:31
Double_t mu_y
Weighted centroid.
Definition BeamFit2D.hpp:31
Double_t weight
Total weight included; zero means the range held nothing above threshold.
Definition BeamFit2D.hpp:34
Double_t slope
Long64_t n_gated
Double_t hi
Double_t lo
std::vector< Double_t > y
Long64_t min_per_slice
Double_t c_short
const char * fail
std::vector< Double_t > ey
Double_t intercept
Long64_t n_long_band
Double_t c_long
Long64_t n_short_window
std::vector< Double_t > x
Output of the two post-gain calibration steps.
Double_t centroids[18]
Measured beam-peak centroid per strip, in calibrated units.
Double_t beam_e_max
Upper edge of the beam window.
Double_t factors[18]
Per-strip multiplicative alignment factors.
Double_t beam_e_min
Lower edge of the beam window, calibrated units.
Bool_t ok
Whether the steps produced usable results.
std::vector< Float_t > gated_long
std::vector< Float_t > gated_short
std::vector< Float_t > r
std::vector< Float_t > l