ANASEN_analysis/pccal/fit_pc_energy_calibration.C

496 lines
19 KiB
C

#include <TSystemDirectory.h>
#include <TSystemFile.h>
#include <TList.h>
#include <TString.h>
#include <TCanvas.h>
#include <TH2D.h>
#include <TF1.h>
#include <TAxis.h>
#include <TFile.h>
#include <TStyle.h>
#include <TSystem.h>
#include <TROOT.h>
#include <TPaveText.h>
#include <TLine.h>
#include <fstream>
#include <iostream>
#include <vector>
#include <iomanip>
#include <cmath>
#include <algorithm>
// Optional dataset_filter (e.g., "17F" or "27Al") prevents mixing different
// gas pressure/temperature environments which causes gain smearing.
// Leave blank ("") to pool all files.
//
// known_dead_wires: wire indices (0-23 anode, 24-47 cathode) known to be
// non-functional regardless of what their calibration data looks like. Whatever
// slope their own points would produce -- least-squares or robust -- isn't a real
// calibration and is excluded here rather than trusted. Update this list as more
// dead channels are identified; currently anode 9 and 12.
void fit_pc_energy_calibration(const std::string& dataset_filter = "",
const std::vector<int>& known_dead_wires = {9, 12})
{
std::vector<bool> isDead(48, false);
for (int w : known_dead_wires)
if (w >= 0 && w < 48)
isDead[w] = true;
std::vector<std::pair<double, double>> pts[48]; // [wire] -> (ADC, dE_gas MeV)
TSystemDirectory dir("pc_calib_raw", "pc_calib_raw");
TList *files = dir.GetListOfFiles();
if (!files)
{
std::cerr << "fit_pc_energy_calibration: pc_calib_raw/ not found or empty -- "
<< "run TrackRecon.C with PC energy calibration enabled first." << std::endl;
return;
}
int nFiles = 0;
long long nOverflowCut = 0;
long long nOverflowCutPerWire[48] = {0};
TIter next(files);
TSystemFile *f;
// Read all points from all files into the single pooled array
while ((f = (TSystemFile *)next()))
{
TString name = f->GetName();
if (f->IsDirectory() || !name.BeginsWith("points_") || !name.EndsWith(".dat"))
continue;
// Apply dataset safeguard if requested
if (!dataset_filter.empty() && !name.Contains(dataset_filter.c_str()))
continue;
std::ifstream infile(std::string("pc_calib_raw/") + name.Data());
if (!infile.is_open())
continue;
int wire;
double adc, dE_gas;
while (infile >> wire >> adc >> dE_gas)
{
if (wire >= 0 && wire < 48)
{
if (adc < 64000.0) {
pts[wire].push_back({adc, dE_gas});
} else {
nOverflowCut++;
nOverflowCutPerWire[wire]++;
}
}
}
++nFiles;
}
std::cout << "fit_pc_energy_calibration: read " << nFiles
<< " run file(s) from pc_calib_raw/ (Filter: '" << dataset_filter << "')" << std::endl;
std::cout << "fit_pc_energy_calibration: cut " << nOverflowCut
<< " points due to ADC >= 64k overflow." << std::endl;
std::cout << "fit_pc_energy_calibration: per-wire overflow breakdown (wire: cut / kept):" << std::endl;
for (int wire = 0; wire < 48; ++wire)
{
long long kept = static_cast<long long>(pts[wire].size());
if (nOverflowCutPerWire[wire] == 0 && kept == 0)
continue;
double fracCut = (nOverflowCutPerWire[wire] + kept > 0)
? 100.0 * nOverflowCutPerWire[wire] / (nOverflowCutPerWire[wire] + kept)
: 0.0;
std::cout << " " << (wire < 24 ? "anode " : "cathode ") << (wire < 24 ? wire : wire - 24)
<< ": " << nOverflowCutPerWire[wire] << " cut / " << kept << " kept ("
<< fracCut << "% cut)" << std::endl;
}
// --- Adaptive lower-bound cut (anode only) -----------------------------
// Raw ADC per wire can be genuinely bimodal (pileup, accidental coincidences,
// wrong-topology leakage) with a lower population that would otherwise bias
// both the least-squares slope and the median-based peak-matching fallback.
// Rather than one fixed ADC cutoff -- which can't be right for every wire at
// once when gains differ (the valley between the two populations sits at a
// genuinely different absolute ADC per wire) -- this finds each wire's own
// dominant peak from a coarse histogram of its own points, preferring the
// highest-ADC prominent peak over a lower one, and cuts at the valley just
// below it. Points below that valley are dropped before any fit/median runs.
const double kPeakProminenceFrac = 0.25; // a local max must reach this fraction
// of the tallest bin to count as a peak
const int kFloorHistBins = 60;
auto findAdaptiveFloor = [&](const std::vector<double> &adcVals) -> double
{
if (adcVals.size() < 10)
return 0.0; // too few points to say anything about shape -- don't cut
double maxADC = *std::max_element(adcVals.begin(), adcVals.end());
if (maxADC <= 0.0)
return 0.0;
std::vector<int> hist(kFloorHistBins, 0);
double binW = maxADC / kFloorHistBins;
for (double v : adcVals)
{
int b = std::min(kFloorHistBins - 1, static_cast<int>(v / binW));
if (b >= 0) hist[b]++;
}
int tallest = *std::max_element(hist.begin(), hist.end());
if (tallest <= 0)
return 0.0;
// Scan from the highest-ADC bin down; the first local maximum that's
// prominent enough is taken as "the" peak.
int peakBin = -1;
for (int b = kFloorHistBins - 1; b >= 1; --b)
{
if (hist[b] < kPeakProminenceFrac * tallest)
continue;
bool isLocalMax = hist[b] >= hist[b - 1] && (b == kFloorHistBins - 1 || hist[b] >= hist[b + 1]);
if (isLocalMax)
{
peakBin = b;
break;
}
}
if (peakBin <= 0)
return 0.0; // no clear peak below the top edge -- nothing to cut against
// Walk down from the peak to the valley: the floor is the bin where the
// count stops falling and starts rising again (the start of a lower
// population), or the histogram runs out.
int valleyBin = peakBin;
for (int b = peakBin - 1; b >= 0; --b)
{
if (hist[b] > hist[valleyBin])
break;
valleyBin = b;
}
return valleyBin * binW;
};
std::vector<std::pair<double, double>> ptsFit[48]; // math uses this; pts[] stays raw for display
for (int wire = 0; wire < 48; ++wire)
ptsFit[wire] = pts[wire];
std::vector<double> floorADC(48, 0.0);
for (int wire = 0; wire < 24; ++wire) // anode only
{
std::vector<double> adcVals;
adcVals.reserve(pts[wire].size());
for (const auto &p : pts[wire])
adcVals.push_back(p.first);
double floor = findAdaptiveFloor(adcVals);
if (floor <= 0.0)
continue;
floorADC[wire] = floor;
size_t before = pts[wire].size();
std::vector<std::pair<double, double>> kept;
kept.reserve(pts[wire].size());
for (const auto &p : pts[wire])
if (p.first >= floor)
kept.push_back(p);
ptsFit[wire] = std::move(kept);
std::cout << "fit_pc_energy_calibration: anode wire " << wire << " adaptive floor = "
<< floor << " ADC -- kept " << ptsFit[wire].size() << "/" << before << " point(s)" << std::endl;
}
// --- Pass 1: per-wire stats and the standard least-squares fit ---
// Split out from plotting so we can look at ALL wires' fitted slopes before
// deciding whether any individual one needs the robust fallback below.
std::vector<double> slope_lsq(48, 1.0), maxX_arr(48, 0.0), maxY_arr(48, 0.0), n_arr(48, 0.0);
std::vector<bool> ok_arr(48, false);
for (int wire = 0; wire < 48; ++wire)
{
double n = static_cast<double>(ptsFit[wire].size());
n_arr[wire] = n;
bool ok = (n >= 2);
double maxX = 0.0, maxY = 0.0; // display bounds: from the raw (unfiltered) data
for (const auto &p : pts[wire])
{
if (p.first > maxX) maxX = p.first;
if (p.second > maxY) maxY = p.second;
}
maxX_arr[wire] = maxX;
maxY_arr[wire] = maxY;
double sxx = 0, sxy = 0; // fit sums: from the filtered data
for (const auto &p : ptsFit[wire])
{
sxx += p.first * p.first;
sxy += p.first * p.second;
}
if (ok)
{
if (std::isfinite(sxx) && std::abs(sxx) > 1e-12)
slope_lsq[wire] = sxy / sxx;
else
ok = false;
}
ok_arr[wire] = ok;
}
auto medianOf = [](std::vector<double> v) -> double
{
if (v.empty())
return 0.0;
std::sort(v.begin(), v.end());
size_t n = v.size();
return (n % 2 == 1) ? v[n / 2] : 0.5 * (v[n / 2 - 1] + v[n / 2]);
};
// Median least-squares slope on each side (anode vs cathode gain structures differ,
// so they're compared separately, not pooled) -- the reference point for flagging
// any single wire's fit as an outlier.
std::vector<double> anodeGoodSlopes, cathodeGoodSlopes;
for (int wire = 0; wire < 48; ++wire)
{
if (!ok_arr[wire] || isDead[wire])
continue;
(wire < 24 ? anodeGoodSlopes : cathodeGoodSlopes).push_back(slope_lsq[wire]);
}
double medianAnodeSlope = medianOf(anodeGoodSlopes);
double medianCathodeSlope = medianOf(cathodeGoodSlopes);
// A least-squares slope more than this factor away from its side's median (or a
// wire with too few points to fit at all) gets a peak-matched slope instead: that
// wire's own ADC peak lined up against the A1C2 consensus dE_gas peak pooled from
// every trusted wire on its side (computed below). Flagged wires are marked
// method=2 in the output file and "PEAK-MATCHED (a1c2 consensus)" in their
// diagnostic plot -- check those plots rather than trusting this blindly, since a
// genuinely multi-modal raw distribution needs a different fix, not a different average.
const double kOutlierRatioHi = 1.75;
const double kOutlierRatioLo = 1.0 / kOutlierRatioHi;
std::cout << "fit_pc_energy_calibration: median anode slope = " << medianAnodeSlope
<< ", median cathode slope = " << medianCathodeSlope << std::endl;
// A1C2 consensus dE_gas peak: the target quantity (Ee - Ex from source-to-SX3
// geometry) doesn't depend on which wire recorded the ADC -- it's the same
// physical prediction for every event. Pooling it across every wire that DID get
// a trusted direct fit ("calibrated using the a1c2 method") gives a far more
// precise reference than any single low-statistics wire's own handful of points
// could, which is what the wires below actually get matched against.
std::vector<bool> isOutlier(48, false);
for (int wire = 0; wire < 48; ++wire)
{
if (!ok_arr[wire] || isDead[wire])
continue;
double medianSide = (wire < 24) ? medianAnodeSlope : medianCathodeSlope;
double ratio = (medianSide > 0.0) ? (slope_lsq[wire] / medianSide) : 1.0;
isOutlier[wire] = (medianSide > 0.0) && (ratio > kOutlierRatioHi || ratio < kOutlierRatioLo);
}
std::vector<double> anodeConsensusPts, cathodeConsensusPts;
int nTrustedAnode = 0, nTrustedCathode = 0;
for (int wire = 0; wire < 48; ++wire)
{
if (!ok_arr[wire] || isDead[wire] || isOutlier[wire])
continue;
auto &bucket = (wire < 24) ? anodeConsensusPts : cathodeConsensusPts;
for (const auto &p : ptsFit[wire])
bucket.push_back(p.second);
(wire < 24 ? nTrustedAnode : nTrustedCathode)++;
}
double consensusAnodeDEgas = medianOf(anodeConsensusPts);
double consensusCathodeDEgas = medianOf(cathodeConsensusPts);
std::cout << "fit_pc_energy_calibration: A1C2 consensus anode dE_gas peak = "
<< consensusAnodeDEgas << " MeV from " << anodeConsensusPts.size()
<< " point(s) across " << nTrustedAnode << " trusted anode wire(s)" << std::endl;
std::cout << "fit_pc_energy_calibration: consensus cathode dE_gas peak = "
<< consensusCathodeDEgas << " MeV from " << cathodeConsensusPts.size()
<< " point(s) across " << nTrustedCathode << " trusted cathode wire(s)" << std::endl;
// --- Setup ROOT Diagnostic Graphics ---
gROOT->SetBatch(kTRUE); // Run silently without popping up windows
gStyle->SetOptStat(0);
gStyle->SetPalette(kBird);
// Create output directory for individual PNGs
gSystem->mkdir("pc_calib_plots", kTRUE);
TFile *fOut = new TFile("pc_calib_diagnostics.root", "RECREATE");
TCanvas *cAnodes = new TCanvas("cAnodes", "Anode Calibrations", 1800, 1200);
cAnodes->Divide(6, 4, 0.01, 0.01);
TCanvas *cCathodes = new TCanvas("cCathodes", "Cathode Calibrations", 1800, 1200);
cCathodes->Divide(6, 4, 0.01, 0.01);
std::string outFilename = dataset_filter.empty()
? "pc_energy_calibration.dat"
: "pc_energy_calibration_" + dataset_filter + ".dat";
std::ofstream outfile(outFilename);
outfile << std::scientific << std::setprecision(6);
// --- Pass 2: finalize each wire's slope (lsq / robust fallback / identity / known-dead), plot, write ---
for (int wire = 0; wire < 48; ++wire)
{
double n = n_arr[wire];
bool ok = ok_arr[wire];
double slope = 1.0, intercept = 0.0;
int method = 0; // 0 = identity/no data, 1 = least-squares, 2 = peak-matched (a1c2 consensus), 3 = known dead
if (isDead[wire])
{
method = 3;
std::cerr << "fit_pc_energy_calibration: wire " << wire
<< " is in known_dead_wires -- writing identity regardless of what its "
<< n << " raw point(s) would otherwise fit to." << std::endl;
}
else if (ok && !isOutlier[wire])
{
slope = slope_lsq[wire];
method = 1;
}
else if (n >= 1)
{
// Either the least-squares slope is a >1.75x outlier vs its side's median, or
// there weren't even enough points (n<2) to attempt a direct fit at all. Either
// way: take this wire's own ADC peak (median of whatever points it has) and line
// it up with the A1C2-consensus dE_gas peak from the trusted wires on its side,
// rather than trusting a noisy few-point regression or falling back to identity.
std::vector<double> xs;
xs.reserve(ptsFit[wire].size());
for (const auto &p : ptsFit[wire])
xs.push_back(p.first);
double medX = medianOf(xs);
double targetY = (wire < 24) ? consensusAnodeDEgas : consensusCathodeDEgas;
if (medX > 1e-9 && targetY > 0.0)
{
slope = targetY / medX;
method = 2;
std::cerr << "fit_pc_energy_calibration: wire " << wire << " "
<< (ok ? "least-squares slope (" + std::to_string(slope_lsq[wire]) + ") is an outlier vs its side's median"
: "has too few points (" + std::to_string(static_cast<int>(n)) + ") for a direct fit")
<< " -- using peak-matched slope (own ADC median=" << medX
<< " vs consensus dE_gas=" << targetY << ") = " << slope
<< " instead. Check pc_calib_plots/wire_" << Form("%02d", wire)
<< ".png to confirm this makes sense." << std::endl;
}
else if (ok)
{
// No usable consensus target (e.g. this side has no trusted wires at all) --
// fall back to whatever least-squares gave, even though it was flagged.
slope = slope_lsq[wire];
method = 1;
}
// else: n>=1 but medX<=0 and no lsq fit either -- falls through to identity below.
}
if (method == 0 && !isDead[wire]) {
std::cerr << "fit_pc_energy_calibration: wire " << wire << " has " << n
<< " point(s) and no usable consensus target -- writing identity (slope=1, intercept=0)" << std::endl;
}
outfile << wire << " " << slope << " " << intercept << " " << method << "\n";
// 2. Generate and Fill 2D Density Histogram
double maxX = maxX_arr[wire], maxY = maxY_arr[wire];
if (maxX <= 0) maxX = 64000.0;
if (maxY <= 0) maxY = 10.0;
TString wName = Form("%s %02d", wire < 24 ? "Anode" : "Cathode", wire < 24 ? wire : wire - 24);
TH2D *h2 = new TH2D(Form("h2_wire_%d", wire),
wName + "; ADC; dE_{gas} (MeV)",
150, 0, maxX * 1.05,
150, 0, maxY * 1.05);
for (const auto &p : pts[wire]) {
h2->Fill(p.first, p.second);
}
// 3. Setup Fit Line and Stats Box
// Known-dead wires never get a fit line drawn, even if they have raw points
// (wire 9 does) -- whatever structure is in that data isn't trusted as real
// calibration, but the raw scatter is still worth keeping for reference/crosstalk
// diagnosis.
TF1 *fitLine = nullptr;
TPaveText *pt = nullptr;
TLine *floorLine = nullptr;
bool drawFit = (n > 0) && !isDead[wire];
if (drawFit) {
fitLine = new TF1(Form("fit_%d", wire), "[0]*x", 0, maxX * 1.05); // Formula is strictly y = m*x
fitLine->SetParameter(0, slope);
fitLine->SetLineColor(method == 2 ? kMagenta : kRed);
fitLine->SetLineWidth(2);
pt = new TPaveText(0.15, 0.72, 0.55, 0.88, "NDC");
pt->SetFillColor(kWhite);
pt->SetBorderSize(1);
pt->AddText(Form("N = %.0f", n));
pt->AddText(Form("m = %.2e", slope));
pt->AddText("b = 0 (Fixed)");
if (method == 2)
pt->AddText("PEAK-MATCHED (a1c2 consensus)");
if (floorADC[wire] > 0.0)
pt->AddText(Form("floor cut @ %.0f ADC", floorADC[wire]));
}
// Adaptive floor marker: drawn whenever a cut was found, even for wires
// that ended up on the identity/dead path, so it's visible on every plot
// where it was computed, not just the ones that used it in a fit.
if (wire < 24 && floorADC[wire] > 0.0)
{
floorLine = new TLine(floorADC[wire], 0, floorADC[wire], maxY * 1.05);
floorLine->SetLineColor(kGreen + 2);
floorLine->SetLineStyle(2);
floorLine->SetLineWidth(2);
}
// ------------------------------------------------------------
// 4. Save High-Res Individual PNG
// ------------------------------------------------------------
TCanvas cTemp("cTemp", "cTemp", 800, 600);
cTemp.SetGridx();
cTemp.SetGridy();
if (drawFit) {
h2->Draw("COLZ");
fitLine->Draw("SAME");
pt->Draw();
if (floorLine) floorLine->Draw("SAME");
} else if (isDead[wire]) {
h2->SetTitle(wName + (n > 0 ? " (KNOWN DEAD -- excluded from fit)" : " (KNOWN DEAD, NO DATA)"));
h2->Draw(n > 0 ? "COLZ" : "");
if (floorLine) floorLine->Draw("SAME");
} else {
h2->SetTitle(wName + " (DEAD/NO DATA)");
h2->Draw();
if (floorLine) floorLine->Draw("SAME");
}
cTemp.SaveAs(Form("pc_calib_plots/wire_%02d.png", wire));
// ------------------------------------------------------------
// 5. Draw onto Global PDF Canvas
// ------------------------------------------------------------
TVirtualPad* pad = (wire < 24) ? cAnodes->cd(wire + 1) : cCathodes->cd(wire - 24 + 1);
pad->SetGridx();
pad->SetGridy();
if (drawFit)
{
h2->DrawClone("COLZ");
fitLine->DrawClone("SAME");
pt->DrawClone();
if (floorLine) floorLine->DrawClone("SAME");
}
else if (n > 0)
{
h2->DrawClone("COLZ"); // known-dead wire with data: show the raw scatter, no fit
if (floorLine) floorLine->DrawClone("SAME");
}
else
{
h2->DrawClone();
}
// Write histogram to ROOT file
fOut->cd();
h2->Write();
}
outfile.close();
fOut->Close();
std::cout << "fit_pc_energy_calibration: wrote " << outFilename << std::endl;
std::cout << "fit_pc_energy_calibration: individual high-res PNGs saved to pc_calib_plots/" << std::endl;
}