Modern Python workflow for a compact experimental particle-physics analysis using CERN Open Data from LHCb. The project reconstructs three-body B-meson candidates, studies the invariant-mass spectrum, builds a sideband-labelled signal/background classifier, and extends the workflow toward CP-asymmetry and Dalitz-plot studies.
Heavy-flavour decays are a central probe of flavour physics, CP violation, and possible indirect effects of physics beyond the Standard Model. This project focuses on reconstructed B-meson candidates decaying into three charged hadrons, using a simple kaon-mass hypothesis for the daughters. The analysis follows the structure of a small HEP workflow: ROOT data loading, candidate selection, invariant-mass reconstruction, signal modelling, charge-separated comparisons, phase-space visualisation, and machine-learning diagnostics.
The input samples are LHCb Open Data ROOT ntuples stored in data/raw/:
B2HHH_MagnetDown.rootB2HHH_MagnetUp.rootPhaseSpaceSimulation.root
The main notebooks use the DecayTree from B2HHH_MagnetDown.root. The additional samples are kept for future extensions such as magnet-polarity comparisons, acceptance studies, or simulation-based checks.
- Explore the ROOT tree and document relevant reconstructed branches.
- Reconstruct the invariant mass of B -> hhh candidates.
- Apply transparent physics-motivated quality and PID selections.
- Fit the selected B-candidate mass spectrum with a Gaussian signal plus linear background model.
- Define a signal window and sideband regions for labelled ML studies.
- Train and evaluate a Random Forest signal/background classifier.
- Compare B+ and B- candidates through a raw integrated CP-asymmetry estimator.
- Build pairwise invariant-mass variables for Dalitz-plot studies.
- Produce publication-style figures saved in
figures/.
The B-candidate mass is computed from the relativistic four-vector sum of the three daughter tracks:
m_B^2 = E_total^2 - |p_total|^2
The helper function compute_b_mass(df) in src/reconstruction/invariant_mass.py performs this calculation using the daughter momentum branches H*_PX, H*_PY, and H*_PZ.
The baseline selection keeps candidates with:
- good B-vertex quality,
- sizable flight distance,
- daughter tracks compatible with the kaon PID hypothesis.
These cuts are intentionally simple and interpretable, making the workflow suitable for a portfolio or thesis-application project.
The notebook notebooks/03_signal_background.ipynb builds proxy labels from mass regions:
- signal-like candidates: central B-mass window,
- background-like candidates: lower and upper mass sidebands.
The classifier uses reconstruction-quality and PID variables, excluding the mass itself to avoid learning the label definition directly.
The notebook notebooks/04_cp_asymmetry.ipynb separates candidates by total reconstructed charge:
- B+ candidates have total daughter charge +1,
- B- candidates have total daughter charge -1.
It overlays the B+ and B- invariant-mass spectra and computes a simple raw integrated asymmetry in the signal window:
A_CP = (N(B-) - N(B+)) / (N(B-) + N(B+))
This is a first diagnostic quantity. A realistic measurement would fit signal yields, subtract backgrounds, combine magnet polarities, and correct detector and production asymmetries.
The notebook notebooks/05_dalitz_analysis.ipynb reconstructs the pairwise two-body invariant-mass-squared variables:
m_K1K2_sqm_K2K3_sqm_K1K3_sq
These variables are used to produce Dalitz-style two-dimensional density maps. The reusable kinematic functions live in src/physics/dalitz.py.
Mass reconstruction after selection:
Signal-plus-background mass fit:
Signal/background ROC curve:
Random Forest feature importance:
Charge-separated B-candidate mass spectra:
Dalitz density map:
lhcb-b2hhh-analysis/
├── data/
│ ├── raw/ # ROOT inputs, ignored by Git
│ └── processed/ # derived tables, ignored by Git
├── figures/ # saved analysis figures
├── notebooks/
│ ├── 01_data_exploration.ipynb
│ ├── 02_mass_reconstruction.ipynb
│ ├── 03_signal_background.ipynb
│ ├── 04_cp_asymmetry.ipynb
│ ├── 05_dalitz_analysis.ipynb
│ └── 05_machine_learning.ipynb
├── reports/
├── src/
│ ├── physics/
│ │ ├── dalitz.py
│ │ └── selection.py
│ ├── reconstruction/
│ │ └── invariant_mass.py
│ ├── plotting/
│ │ └── mass_plots.py
│ └── ml/
│ └── signal_background.py
├── environment.yml
├── requirements.txt
└── README.md
- ROOT files for HEP ntuple storage
uprootfor reading ROOT data in Pythonpandasfor tabular event processingnumpyandscipyfor numerical reconstruction and fittingmatplotlibfor publication-style plotsscikit-learnfor Random Forest classification
Using conda:
conda env create -f environment.yml
conda activate lhcb-b2hhh
python -m ipykernel install --user --name lhcb-b2hhh --display-name "Python (lhcb-b2hhh)"Using pip:
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python -m ipykernel install --user --name lhcb-b2hhh --display-name "Python (lhcb-b2hhh)"Start JupyterLab from the project root:
jupyter labRecommended notebook order:
notebooks/01_data_exploration.ipynbnotebooks/02_mass_reconstruction.ipynbnotebooks/03_signal_background.ipynbnotebooks/04_cp_asymmetry.ipynbnotebooks/05_dalitz_analysis.ipynb
Figures are saved automatically in figures/.
The reusable fitting infrastructure lives in src/fitting/. To run the first
nominal binned invariant-mass fit of the selected B-candidate spectrum:
python scripts/run_mass_fit.pyThe script reuses the shared loading, reconstruction, and baseline-selection
helpers from src.physics.selection, then writes:
results/fits/mass_fit_results.jsonresults/fits/mass_fit_plot.png
The default nominal signal model is now a common-mean double Gaussian with a finite-range exponential background. This is still a first nominal mass fit, not yet a full systematic study with alternative shapes, fit ranges, or charge-separated yield corrections.
To fit the B+ and B- candidate mass spectra separately and compute the raw fitted-yield asymmetry:
python scripts/run_charge_separated_fits.pyOutputs are saved in results/fits/charge_separated/, including the two
charge-specific fit JSON files, fit plots, and raw_asymmetry.json.
This is a raw asymmetry only. It is not corrected for detector-induced charge asymmetry, B production asymmetry, trigger or reconstruction effects, or selection-induced charge biases.
To validate the charge-separated fit and raw-asymmetry extraction on pseudo-data generated from the nominal fitted model:
python studies/toy_mc/run_raw_asymmetry_toys.pyThe study writes per-toy results, a JSON summary, and diagnostic plots to
results/toy_mc/raw_asymmetry/. This checks whether the statistical fitting
machinery recovers injected yields and raw asymmetry on nominal toys; it is not
a detector-level systematic study.
A 10-toy run is only a technical smoke test. Use at least 100 toys for a meaningful first validation, and prefer 500 or more toys when quoting stable pull-width estimates.
To compare charge-separated raw-asymmetry fits across implemented fit-model variations:
python studies/systematics/run_fit_model_variations.pyA model variation is only used as a systematic uncertainty if it provides an acceptable description of both charge-separated mass spectra. Stress-test models are kept in the results table, but rejected models are not included in the recommended model systematic. The first accepted background-shape alternative compares the nominal double-Gaussian plus exponential model with a double-Gaussian plus finite-range linear background.
To compute a bin-by-bin raw asymmetry over the selected-candidate Dalitz plane:
python studies/dalitz/run_local_raw_asymmetry.pyOutputs are saved in results/dalitz/local_asymmetry/, including the per-bin
CSV table, a JSON summary, and maps of local raw asymmetry, local significance,
and selected-candidate counts. This is a raw local asymmetry diagnostic only. It
is not corrected for detector efficiency, background contamination, production
asymmetry, migration across Dalitz bins, or phase-space acceptance.
ROOT files and processed data products are ignored by Git to keep the repository lightweight. The README references figures in figures/, so keep representative output plots tracked when preparing the public repository.





