Repository for NIST-EPA Legionella study Python tools. Includes scripts for aerosol particle size distribution analysis, emission-rate calculations, data validation, visualization, and statistical modeling of shower-generated aerosols under varying temperature, humidity, and exhaust-fan conditions.
This project analyzes aerosol characteristics from shower-generated aerosols with a focus on potential Legionella transmission risks from shower fixtures. The research investigates:
- Aerosol particle size distribution analysis
- Emission-rate calculations using numerical approaches
- CO2 decay analysis for air change rate determination
- Temperature and relative humidity monitoring
- data validation, visualization, and statistical modeling
A second, related line of analysis (the moduair_* scripts and src/moduair_loader.py)
compares the study's two QuantAQ MODULAIR-PM sensors against the broader NIST
IAQ&V group's fleet of co-located MODULAIR-PM units, to characterize sensor
agreement and build candidate inter-sensor correction factors. See
MODULAIR-PM Fleet Comparison below.
Certain commercial equipment, instruments, software, or materials are identified in this repository in order to specify the experimental and analytical procedures adequately. Such identification is not intended to imply recommendation or endorsement of any product or service by NIST, nor is it intended to imply that the materials or equipment identified are necessarily the best available for the purpose.
The analysis procedures in this repository involve multiple stages of data processing and mathematical modeling, each contributing to the overall uncertainty in the final results. Key sources of uncertainty include:
CO₂-derived Air Change Rate (λ) Uncertainty:
- Statistical uncertainty from linear regression of log-transformed decay data (reported as standard error of the slope)
- Systematic uncertainty from assuming a well-mixed zone and constant infiltration fractions (α, β)
- Uncertainty in source concentration characterization evaluated through three methods: weighted average (α·C_outside + β·C_entry), outside-only, and entry-only
- Data quality filters including minimum concentration difference requirements (50 ppm) and sufficient data point thresholds
- Temporal alignment uncertainty between CO₂ injection timing and decay window selection
Particle Analysis Uncertainty:
- Penetration factor (p) uncertainty from temporal variability in indoor/outdoor concentration ratios during before/after windows
- Other process rate (β) uncertainty from numerical differentiation of concentration data, mitigated by 5th–95th percentile trimming and R²-based selection criteria
- Emission rate (E) uncertainty from propagation of errors in the mass balance equation and concentration time derivatives
- Forward Euler simulation uncertainty from accumulation of integration errors over the 2-hour prediction window
- Size-bin dependent uncertainties due to varying signal-to-noise ratios across the 0.35–10.0 µm range
General Measurement Uncertainties:
- Sensor-specific uncertainties: Aranet4 CO₂ sensors (±50 ppm + 3% of reading), QuantAQ MODULAIR-PM particle counters, HOBO UX100 temperature/relative humidity sensors
- Temporal synchronization uncertainty between different sensor systems sampling at 1-minute intervals
- Spatial representativeness limitations of point measurements in heterogeneous indoor environments
- Model structural uncertainty from assuming first-order decay processes and uniform mixing in the mass balance equations
Uncertainty quantification is implemented throughout the analysis pipeline, with standard deviations, standard errors, and confidence intervals reported where applicable. The three-source-method approach for λ calculation provides explicit assessment of structural uncertainty in ventilation rate determination. Where measurements fall below detection thresholds or fail quality criteria, results are appropriately flagged or excluded to prevent overinterpretation.
NIST_EPA_Legionella/
├── .vscode/ # VS Code configuration
│ └── settings.json
├── .env # Environment variables (API keys for QuantAQ, gitignored)
├── .gitignore
├── CODEMETA.yaml # NIST Software Portal metadata
├── CODEOWNERS # Repository ownership for PR reviews
├── LICENSE.md # NIST software licensing statement
├── README.md
├── fair-software.md # FAIR software principles compliance checklist
├── data_config.json # Active configuration (gitignored)
├── data_config.template.json # Configuration template
├── environment.yaml # Conda environment specification
├── run_analysis_workflow.py # Convenience script to run the full analysis pipeline
│
├── src/ # Core analysis modules (imported by scripts)
│ ├── __init__.py # Package initialization (re-exports data_paths)
│ ├── data_paths.py # Portable data access via data_config.json
│ ├── env_data_loader.py # Unified environmental sensor data loader
│ ├── event_manager.py # Event filtering, naming (MMDD_Temp_ToD_RNN), logging
│ ├── event_matching.py # Match CO2 injections to shower events by timing
│ ├── particle_data_loader.py # QuantAQ particle data loading and preparation
│ ├── particle_room_correction.py # Room-concentration correction for the "inside" series (C_room fleet average / pre-fleet ratio correction)
│ ├── particle_calculations.py # Penetration, deposition & emission calculations
│ ├── plot_co2.py # CO2 decay visualization functions
│ ├── plot_environmental.py # RH, temperature, wind visualization
│ ├── plot_particle.py # Particle decay visualization (re-exports boxplot/comparison functions)
│ ├── plot_particle_boxplots.py # Temperature-axis and shower-head boxplot functions
│ ├── plot_comparison.py # Categorical comparison boxplot families (door, fan, spray pattern, etc.)
│ ├── plot_style.py # Consistent matplotlib styling constants
│ ├── plot_utils.py # Central plotting re-exports (imports all plot_* modules)
│ ├── quantaq_utils.py # QuantAQ API client utilities
│ ├── sig_figs.py # Significant figures rounding and formatting
│ ├── moduair_loader.py # IAQ&V MODULAIR-PM fleet chunk loader (moduair_pm_chunks share)
│ └── deprecated/ # Deprecated/archived code
│ └── co2_decay_analysis.py # Previous version of CO2 analysis
│
├── scripts/ # Executable analysis scripts (run directly)
│ ├── __init__.py
│ │
│ │ # Data Download & Processing
│ ├── download_quantaq_data.py # Download QuantAQ sensor data from API (manual/one-off; campaign ended 2026-07-16)
│ ├── process_quantaq_data.py # Combine weekly chunks and process raw/final QuantAQ data
│ │
│ │ # Log Processing
│ ├── process_co2_log.py # Consolidate daily CO2 injection logs → state-change log
│ ├── process_shower_log.py # Consolidate daily shower logs → state-change log
│ ├── fix_log_files.py # Repair corrupted/empty log files
│ ├── analyze_log_files.py # Diagnose log file issues (unmatched events, etc.)
│ │
│ │ # Event Management
│ ├── event_registry.py # Unified event registry with synthetic event creation
│ │
│ │ # Analysis
│ ├── co2_decay_analysis.py # CO2 decay & air change rate (λ) analysis
│ ├── particle_decay_analysis.py # Particle penetration, deposition & 4-variant emission analysis (E1-E4; per-event figures)
│ ├── particle_emission_variant_figures.py # Run after particle_decay_analysis.py: per-bin E1/E2/E3/E4 comparison figures + all summary/boxplot/comparison figures
│ ├── rh_temp_other_analysis.py # RH, temperature & wind condition analysis
│ ├── export_event_timeseries.py # Export per-event predicted Ct for all size bins to CSV (--event N)
│ ├── export_config_timeseries.py # Export 1-min avg env/particle time series per config group to Excel
│ │
│ │ # Optional Figures
│ ├── co2_lambda_source_figure.py # Per-event λ_outside vs. λ_entry comparison figure with paired t-test
│ ├── hobo_onset_decay_figures.py # HOBO temp/RH onset & decay figures, plus per-event pre/post summary
│ │
│ │ # MODULAIR-PM Fleet Comparison (IAQ&V) — see README section below
│ ├── moduair_correction_factor.py # Inter-sensor concentration ratios + 195/813 Deming correction fit
│ ├── moduair_event_peak_times.py # Per-event, per-sensor delta peak time (shower ON to peak)
│ ├── moduair_bin2_timeseries.py # Per-bin time series across co-located bedroom fleet sensors
│ ├── moduair_cave_ratio.py # Inside sensor (195) vs. position-weighted fleet average (C_room)
│ └── export_config_timeseries_fleet.py # Per-sensor variant of export_config_timeseries.py across the fleet
│
└── docs/ # Documentation & instrument references
├── Data Analysis.docx # Data analysis planning notes
└── pdf_links.md # Public download links for instrument manuals and data sheets (not included due to copyright)
- Python 3.14+
- Conda (recommended for environment management)
-
Clone the repository:
git clone https://github.com/usnistgov/NIST_EPA_Legionella.git cd NIST_EPA_Legionella -
Create the conda environment:
conda env create -f environment.yaml conda activate epa_mh
-
Configure data paths:
cp data_config.template.json data_config.json
Edit
data_config.jsonto set the correctdata_rootpath for your machine. -
Set up API credentials (for QuantAQ data): Create a
.envfile with your QuantAQ API key:QUANTAQ_API_KEY=your_api_key_here
The src/data_paths.py module provides portable data access functions:
from src import get_instrument_path, get_instrument_file, get_instrument_files_for_date_range
# Get instrument data directory
path = get_instrument_path("Aranet4")
# Get data file for a specific date
file = get_instrument_file("Aranet4", "2026-01-15")
# Get files for a date range
files = get_instrument_files_for_date_range("QuantAQ", "2026-01-05", "2026-01-15")The experimental campaign ended 2026-07-16; the local QuantAQ cache is complete and frozen, so step 1 below is a manual/one-off tool, not something to re-run routinely.
-
Download data from the QuantAQ API (manual, one-off — only needed to rebuild a lost/missing local cache):
python scripts/download_quantaq_data.py
-
Process data (parse nested structures, merge raw/final data):
python scripts/process_quantaq_data.py
Plotting modules in src/ generate publication-quality figures used by the analysis scripts:
src/plot_co2.py- CO2 decay curves and air change rate analysissrc/plot_particle.py- Particle concentration decay and emission rates; re-exports boxplot/comparison functionssrc/plot_particle_boxplots.py- Temperature-axis and shower-head E_total/β/E/p boxplotssrc/plot_comparison.py- Categorical comparison boxplot families (door, fan, spray pattern, mannequin, head type)src/plot_environmental.py- RH, temperature, and wind time seriessrc/plot_style.py- Consistent matplotlib styling constants and helperssrc/plot_utils.py- Re-exports all plot_* functions for backward compatibility
All plot modules are imported by analysis scripts through src/plot_utils.py.
| Instrument | Model | Purpose | Data Location |
|---|---|---|---|
| AIO2 | Met One AIO 2 | Outdoor weather (10m tower) | MH DAQ/weather_station |
| Aranet4 | Aranet4 PRO | CO2 monitoring (Bedroom, Bathroom, Entry, Outside) | CO2 |
| HOBO_UX100 | Onset HOBO UX100 | RH/Temperature (6 locations: Bathroom1×2, Bathroom2, Bath/Bed, Bedroom1–3) | HOBOs |
| QuantAQ | MODULAIR-PM | PM monitoring (indoor: MOD-PM-00195, outdoor: MOD-PM-00785) | QuantAQ |
| Setra_264 | Setra 264 | Differential pressure | MH DAQ/indoor_daq |
| Vaisala_HMP155 | Vaisala HMP155 | RH/Temperature | MH DAQ/indoor_daq |
| Vaisala_HMP45A | Vaisala HMP45A | RH/Temperature | MH DAQ/indoor_daq |
- Chassis: NI cDAQ-9178 (8-slot USB)
- Modules: 5x NI 9201 (8-channel analog input, +/-10V)
The complete data analysis pipeline follows this sequence. To run all steps automatically:
python run_analysis_workflow.pyEach step's output is logged to output/logs/<script_name>.log. Or run steps individually:
Data collection is complete. The experimental campaign ended 2026-07-16 (
PROJECT_END_DATEindownload_quantaq_data.py), sodownload_quantaq_data.pyis no longer part of the routine pipeline inrun_analysis_workflow.py. Run it manually, one-off, only if the local QuantAQchunks/cache needs to be rebuilt:python scripts/download_quantaq_data.py
-
Data Processing: Process and merge raw QuantAQ sensor data
python scripts/process_quantaq_data.py
-
Log Processing: Consolidate daily 1-second log files into state-change logs
python scripts/process_co2_log.py python scripts/process_shower_log.py
-
Event Management: Run the event manager to match, name, and register events
python scripts/event_registry.py [--force] [--no-co2] [--output-dir DIR]
Flag Description --forceRegenerate registry even if it already exists --no-co2Skip CO2 analysis (registry will have no lambda values or PM exclusions) --output-dir DIROutput directory (default: data_root/output)This must be run before the analysis scripts so that events have consistent names (see Event Naming Convention below). Produces
event_log.csv. -
CO2 Analysis: Determine air change rates (λ) from CO2 decay
python scripts/co2_decay_analysis.py [--alpha FLOAT] [--beta FLOAT] [--output-dir DIR] [--no-plot] [--no-sig-figs] [--entry-stop]
Flag Description --alpha FLOATFraction of infiltration from outside (default: 0.5) --beta FLOATFraction of infiltration from entry zone (default: 0.5) --output-dir DIROutput directory (default: data_root/output)--no-plotDisable plot generation --no-sig-figsDisable significant figure rounding (default: 3 sig figs for files, 2 for figures) --entry-stopTruncate the decay window at the first point where C_bedroom ≤ C_entry + 50 ppm (falls back to C_outside if entry data is missing) — stops the fit once the remaining decay signal above background is too small to be reliable run_analysis_workflow.pyalways runs this step with--entry-stop. Fits with R² belowMIN_R_SQUARED(0.75) are rejected (λ set to NaN); this also drives the "Lambda R² less than 0.75" PM-exclusion check inevent_registry.py. -
Environmental Analysis: Characterize RH, temperature, and wind conditions
python scripts/rh_temp_other_analysis.py [--output-dir DIR] [--no-plots] [--max-plot-events N] [--events LIST] [--no-sig-figs]
Flag Description --output-dir DIROutput directory (default: data_root/output)--no-plotsSkip plot generation --max-plot-events NLimit time series plots to first N events (default: all) --events LISTComma-separated event numbers to plot, e.g. 1,3,5(overrides--max-plot-events)--no-sig-figsDisable significant figure rounding (default: 3 sig figs for files, 2 for figures) -
Particle Analysis: Calculate penetration, deposition, and emission rates (requires step 4 results)
python scripts/particle_decay_analysis.py [--output-dir DIR] [--no-plot] [--no-sig-figs]
Flag Description --output-dir DIROutput directory (default: data_root/output)--no-plotDisable per-event pm_decayfigure generation--no-sig-figsDisable significant figure rounding (default: 3 sig figs for files, 2 for figures) Four emission-rate variants (E1-E4) are computed per bin, per the report's Table 7 / Equation 10, rather than one value bounded by λ_outside/λ_entry alone. See Particle Decay & Emission Analysis below. Summary/boxplot/comparison figures moved to step 7 below; this step only generates the per-event
pm_decayfigures and the Excel results.New dependency: the indoor ("inside") concentration is now room-corrected by
src/particle_room_correction.pybefore analysis (see the "Room-Concentration Correction" note under Particle Decay & Emission Analysis), which requires the MODULAIR-PM fleet chunk files to be reachable on themoduair_pm_chunksshare configured indata_config.json(see MODULAIR-PM Fleet Comparison below). This reusesscripts/moduair_cave_ratio.py's functions in-memory — that script does not need to be run first. If the fleet chunks are unreachable, the step prints a[WARN]and falls back to uncorrected raw MOD-PM-00195 for the whole record. -
Particle Emission Variant Figures: Per-bin E1/E2/E3/E4 comparison figures, plus all summary/boxplot/comparison figures (requires step 6 results)
python scripts/particle_emission_variant_figures.py [--output-dir DIR]
Reads
particle_analysis_summary.xlsx; does not recompute p, β, or E. Produces the per-binbeta_other_{pair}_bin{N}.html/emission_{pair}_bin{N}.htmlcomparison figures for three variant pairs (entry_outside,outside_adjusted,outside_bathroom; 12 bins × 2 metrics × 3 pairs = 72 figures) underoutput/plots/particle/, plus the full summary/boxplot/comparison figure suite (bar charts, fixed- and metric-axis boxplots, and the 5 condition-comparison families) underoutput/plots/— moved here from step 6. -
Export Event Time Series (optional utility): Export predicted Ct for all size bins for a single event to CSV:
python scripts/export_event_timeseries.py --event N [--output PATH] [--output-dir DIR] [--no-sig-figs]
Flag Description --event INTEvent number to export (required) --output PATHOutput CSV file path (default: output/event_N_timeseries.csv)--output-dir DIRAnalysis output directory (default: data_root/output)--no-sig-figsDisable significant figure rounding -
Export Config Time Series (optional utility): Export 1-minute averaged environmental and particle time series per unique test configuration to Excel:
python scripts/export_config_timeseries.py [--no-sig-figs]
Flag Description --no-sig-figsDisable significant figure rounding Produces
output/event_config_timeseries.xlsxwith one sheet per configuration group. Each sheet contains the mean, standard deviation, maximum, and minimum (across replicate events) of bathroom RH/Temp, bath/bed hallway RH/Temp, bedroom RH/Temp, and all 12 OPC-N3 particle bins (0.35–10.0 µm) at 1-minute resolution from shower-on through the 2-hour deposition window. Flow rates in the 4.1–5.6 LPM range (standard and tagged variants) are grouped together; 1.4 LPM and 2.2 LPM events remain separate. A hyperlinked index sheet lists every configuration group with event counts and event numbers. -
CO2 Lambda Source Comparison Figure (optional utility): Build a per-event λ_outside vs. λ_entry comparison figure with a paired t-test (requires step 4 results):
python scripts/co2_lambda_source_figure.py
Reads
co2_lambda_summary.csv; does not recompute λ. Producesoutput/plots/co2/co2_lambda_entry_outside.html. -
HOBO Onset/Decay Figures (optional utility): Build interactive HOBO temp/RH figures restricted to the onset (shower-on to +60 min) and decay (shower-off +60 to +120 min) windows around each event, plus a per-event pre/post summary:
python scripts/hobo_onset_decay_figures.py
Produces
output/plots/hobo/hobo_{temp,rh}_onset_decay.htmlandoutput/plots/hobo/hobo_{temp,rh}_pre_post.html.
Each analysis module produces CSV/Excel summaries and optional visualizations in the output/ directory.
Events are named using a structured format generated by scripts/event_registry.py. This naming convention must be applied (step 4 in the workflow above) before running the analysis scripts.
Format: MMDD_W##[_ShowerHead[_SprayPattern]][_Mannequin]_DoorPos[_Fan]_RNN
| Component | Description | Values |
|---|---|---|
MMDD |
Month and day of the event | e.g., 0115 for January 15 |
W## |
Water temperature code (°C) | W11, W14, W22, W23, W24, W25, W30, W37, W38, W40, W43, W48, W49, W52, W53 |
ShowerHead |
Shower head type; omitted for Standard head | Pepco, FilterWand, Used |
SprayPattern |
Spray pattern; varies by head type | Wide, Narrow, Mid, rainfall, 12Nozzle, SingleWide1, SingleWide2 |
Mannequin |
Appended only when a mannequin was present | Mannequin |
DoorPos |
Bathroom-to-bedroom (bath) door position | Open, Closed, Ajar |
Fan |
Appended only when the bath fan ran during the test | Fan |
RNN |
Replicate number | R01, R02, R03, etc. |
Examples:
0115_W48_Open_R01- January 15, 48 °C, standard head, bath door open, replicate 10122_W11_Open_R03- January 22, 11 °C, standard head, replicate 30224_W52_Pepco_Wide_Open_R01- February 24, 52 °C, Pepco head, wide spray0309_W40_Pepco_Narrow_Open_R01- March 9, 40 °C, Pepco head, narrow spray0311_W40_Pepco_Narrow_Mannequin_Open_R01- March 11, mannequin present0408_W38_Pepco_Wide_Closed_R01- April 8, 38 °C, Pepco head, wide spray, bath door closed
Note on config_key: The config_key stored in event_log.csv and spreadsheet outputs is a separate, more detailed identifier used for grouping in boxplots. It includes both door positions, fan status, and measured flow rate: W##[_ShowerHead[_SprayPattern]][_Mannequin]_BathDoorXxx_BdrmDoorXxx_FanXxx[_FlowRateX.XLPM] (e.g., W48_BathDoorOpen_BdrmDoorClosed_FanOff_FlowRate4.6LPM).
Test Parameter Timeline (Standard shower head):
- W48 (48 °C): From experiment start (2026-01-14); bath door open, bedroom door closed
- W11 (11 °C): Starting 2026-01-22 14:00
- W25 (25 °C): Starting 2026-02-02 17:00
- W30 (30 °C): Starting 2026-02-05 10:00
- W37 (37 °C): Starting 2026-02-09 10:00
- W23 (23 °C): Starting 2026-02-11 08:00 (data excluded; kept for documentation)
- W22 (22 °C): Starting 2026-02-13 11:00
- W43 (43 °C): Starting 2026-02-16 11:00
- W14 (14 °C): Starting 2026-02-18 10:23
- W53 (53 °C): Starting 2026-02-20 08:00
Test Parameter Timeline (Pepco shower head, first phase, installed 2026-02-24):
- W52 (52 °C), Wide spray: Starting 2026-02-24 08:00
- W49 (49 °C), Wide spray: Starting 2026-02-26 10:23
- W40 (40 °C), Narrow spray: Starting 2026-03-02 09:23
- W40 (40 °C), Wide spray: Starting 2026-03-04 10:23
- W40 (40 °C), Mid spray: Starting 2026-03-06 08:47
- W40 (40 °C), Narrow spray: Starting 2026-03-09 08:47
- W40 (40 °C), Narrow spray + Mannequin: Starting 2026-03-11 09:47
- W38 (38 °C), Narrow spray: Starting 2026-03-12 10:47
- W38 (38 °C), Narrow spray + Mannequin: Starting 2026-03-13 08:45
- W38 (38 °C), Narrow spray (no mannequin): Starting 2026-03-17 08:25
- W38 (38 °C), Narrow spray + Mannequin: Starting 2026-03-18 09:45
- W38 (38 °C), Wide spray: Starting 2026-03-19 08:25
- W38 (38 °C), Wide spray + Mannequin: Starting 2026-03-22 09:25
- W38 (38 °C), Wide spray (no mannequin): Starting 2026-03-27 08:25
Test Parameter Timeline (FilterWand shower head, installed 2026-04-10):
- W38 (38 °C), FilterWand (no spray pattern variable): Starting 2026-04-10 08:55
Test Parameter Timeline (Used shower heads, first phase, starting 2026-04-13):
- W38 (38 °C), Used-rainfall: Starting 2026-04-13 11:35
- W38 (38 °C), Used-12Nozzle: Starting 2026-04-15 08:25
- W38 (38 °C), Used-SingleWide: Starting 2026-04-17 08:35
- W38 (38 °C), Used-SingleNarrow: Starting 2026-04-20 08:35
Test Parameter Timeline (Pepco shower head, second phase, reinstalled 2026-04-23):
- W38 (38 °C), Narrow spray + Mannequin: Starting 2026-04-23 14:15
- W38 (38 °C), Narrow spray (no mannequin): Starting 2026-04-29 08:35
Test Parameter Timeline (FilterWand shower head, second phase, reinstalled 2026-05-22):
- W38 (38 °C), FilterWand (no spray pattern variable): Starting 2026-05-22 09:30
Test Parameter Timeline (Used shower heads, second phase, reinstalled 2026-05-26):
- W38 (38 °C), Used-rainfall: Starting 2026-05-26 10:30
- W38 (38 °C), Used-12Nozzle: Starting 2026-06-01 08:00
- W38 (38 °C), Used-SingleNarrow: Starting 2026-06-03 08:30
- W38 (38 °C), Used-SingleWide: Starting 2026-06-08 13:15
- W38 (38 °C), Used-rainfall: Starting 2026-06-11 10:30
- W38 (38 °C), Used-SingleNarrow + Mannequin: Starting 2026-06-22 11:45 (mannequin present through 2026-07-02 09:45)
- W38 (38 °C), Used-SingleNarrow: Starting 2026-06-26 07:45
Water temperature changes under the Used head (no shower head/spray change):
- W49 (49 °C): Starting 2026-07-14 10:15
- W24 (24 °C): Starting 2026-07-15 10:15
Measured shower head flow rate is tracked continuously across the whole campaign
(not just at head/pattern changes) and stored per event in config_key as an
optional _FlowRateX.XLPM suffix; see FLOW_RATE_TRANSITIONS in
src/event_manager.py for the full record.
Bath fan testing:
- Fan off: From experiment start through 2026-03-30
- Fan on (12 min per shower): Starting 2026-03-31 12:45
- Fan off: Starting ~2026-04-07 (confirmed off from 2026-04-10 08:55)
Bath door position changes:
- Bath door open (BathDoorOpen): From experiment start through 2026-04-07
- Bath door closed (BathDoorClosed): Starting 2026-04-08 09:15
- Bath door open (BathDoorOpen): Starting 2026-04-10 08:55
- Bath door ajar (BathDoorAjar): Starting 2026-05-04 08:30
- Bath door open (BathDoorOpen): Starting 2026-05-07 08:00
Bedroom door position changes:
- Bedroom door closed (BdrmDoorClosed): From experiment start (2026-01-14)
- Bedroom door ajar (BdrmDoorAjar): Starting 2026-05-01 17:10 (door blown ajar between May 1–4; intentionally kept ajar May 4–7)
- Bedroom door closed (BdrmDoorClosed): Starting 2026-05-07 08:00
Run the CO2 decay analysis to calculate air change rates (λ):
# Basic analysis (plots enabled by default)
python scripts/co2_decay_analysis.py
# Custom source fractions
python scripts/co2_decay_analysis.py --alpha 0.6 --beta 0.4 # default is 0.5/0.5
# Skip plots, write to a custom directory
python scripts/co2_decay_analysis.py --no-plot --output-dir /path/to/outputMethodology:
- Combines three Aranet4 sensor files: Bedroom, Entry, and Outside
- Applies 6-minute rolling average to reduce sensor noise
- Fixed 2-hour decay analysis window starting at :50 (10 minutes before the shower hour)
- Three source concentration methods for uncertainty: λ_average (mean of outside + entry), λ_outside, λ_entry
- Calculates λ via linear regression of the log-transformed decay: y = −ln[(C(t) − C_avg) / (C₀ − C_avg)], λ = slope of y vs. time
- Fits with R² below
MIN_R_SQUARED(0.75) are rejected (λ set to NaN); this also drives the "Lambda R² less than 0.75" PM-analysis exclusion check inevent_registry.py - Optional
--entry-stoptruncates the decay window at the first point where C_bedroom ≤ C_entry + 50 ppm (falls back to C_outside if entry data is missing);run_analysis_workflow.pyalways runs with this flag
Output Files:
output/co2_lambda_summary.csv- Per-event lambda resultsoutput/co2_lambda_overall_summary.csv- Overall statisticsoutput/plots/event_figures/co2_decay/event_NN-testname_co2_decay.png- Individual event decay plots with shower ON/OFF markersoutput/plots/lambda_summary.png- Summary bar chart of λ valuesoutput/plots/air_change_rate_boxplot.png- Box-and-whisker of λ by water temperature
Run the particle decay analysis to calculate penetration factors, deposition rates, and emission rates across 12 OPC-N3 size bins (0.35–10.0 µm):
# Run particle analysis (requires CO2 analysis results; per-event figures enabled by default)
python scripts/particle_decay_analysis.py
# Skip the per-event pm_decay figures
python scripts/particle_decay_analysis.py --no-plot
# Write to a custom directory without significant figure rounding
python scripts/particle_decay_analysis.py --output-dir /path/to/output --no-sig-figsMethodology:
The mass balance equation for indoor particle concentration:
V·dC/dt = p·Q·C_out - Q·C - β·C·V + E
dC/dt = p·λ·C_out - λ·C - β·C + E/V
Rather than commit to one set of inputs, four emission-rate variants (E1-E4) are computed per bin, per the report's Table 7 / Equation 10 — each variant fixes a full combination of air-change-rate source, concentration series, and control volume:
| Variant | Columns | λ source | Concentration series | Volume |
|---|---|---|---|---|
| E(t)1 | bin{n}_outside_* |
λ_outside | primary (raw C_bed1(t) / fleet C_room(t)) | 36.1086 m³ (bedroom) |
| E(t)2 | bin{n}_entry_* |
λ_entry | primary (same as E1) | 36.1086 m³ |
| E(t)3 | bin{n}_adjusted_* |
λ_outside | adjusted (ratio-corrected C_adjusted_room(t)) | 36.1086 m³ |
| E(t)4 | bin{n}_bathroom_* |
λ_outside | primary (same as E1) | 54.5868 m³ (bedroom + bathroom) |
E1 is the value the ~45 water-temp/spray-pattern/door/etc. boxplots and
comparison figures use. E2 brackets E1 against the entry-zone λ estimate. E3
is restricted to pre-cutover (single-monitor) events — post-cutover cells are
NaN — since C_adjusted_room(t) corrects a single-sensor position bias that
doesn't apply once the fleet is in place. E4 reuses E1's β (no volume term in
the β fit) and only reruns the emission/Ct steps with the larger volume. See
src/particle_emission_variants.py for the full per-bin implementation.
Room-concentration correction (src/particle_room_correction.py): the indoor
concentration C above is not the raw MOD-PM-00195 reading for every variant.
Two series are built, both sharing the same fleet-period replacement:
-
2026-06-03 to 2026-07-16 (the MODULAIR-PM fleet co-location window; see MODULAIR-PM Fleet Comparison), both series: C is replaced with C_room(t), the position-weighted fleet average, at every minute where more than 8 fleet sensors report; minutes at or below that threshold are left as NaN.
-
Before 2026-06-03, primary series (
build_raw_inside_data, feeds E1/E2/E4): C is left as the raw MOD-PM-00195 reading. -
Before 2026-06-03, adjusted series (
build_corrected_inside_data, feeds E3 only): C is corrected inside each event's analysis window (shower_on − 15 min through deposition_end) asC_adjusted_room(t) = C_bed1(t) · C_room,average(minute) / C_bed1,average(minute), using the per-minute averaged curvesscripts/moduair_cave_ratio.pycomputes from the fleet period, bucketed by the event's water temperature:< 38 °Cuses theW24ratio (n = 2 events),38–41 °Cinclusive usesW38-W41, and> 41 °CusesW49(n = 2 events) — the only three buckets the fleet comparison has a ratio curve for. Outside any event's window, the adjusted series is left raw.The penetration factor (step 1) always uses the raw MOD-PM-00195/outside reading for the primary series, both before and after 2026-06-03, since its before/after windows fall outside the range the fleet ratio curves cover; E3 computes its own independent penetration factor on the adjusted series.
particle_emission_variant_figures.py(workflow step 7) plots β and E per bin, per comparison pair, with a vertical line at 2026-06-03 marking this cutover.
- Penetration factor (p): Ratio of indoor to outdoor concentration averaged over before/after windows relative to the shower event; zeros excluded; capped at 1.
- QA: Minimum 10 valid points per window (
MIN_POINTS_PENETRATION); windows where this is not met are skipped. - Night event windows: before = 9 pm (day before) → 2 am; after = 9 am → 2 pm
- Day event windows: before = 9 am → 2 pm; after = 9 pm → 2 am (next day)
- QA: Minimum 10 valid points per window (
- Air change rate (λ): λ_outside and λ_entry loaded from CO2 decay analysis results (h⁻¹); steps 3–6 repeat once per variant (E4 reuses E1's β; see the variant table above).
- Other process rate (β, numerical): Step-by-step solution during the 2-hour (
DEPOSITION_WINDOW_HOURS) post-shower decay window; beta solved at each time step from the discrete mass balance:All estimates ≤β = 1/dt - λ - C_{t+1}/(C_t·dt) + p·λ·(C_out,t / C_t)MAX_OTHER_PROCESS_RATE(15 h⁻¹) are collected (no lower bound, to avoid upward bias). A 5th–95th percentile trim removes extreme outliers symmetrically. Beta is then selected via an R²-based four-step procedure (threshold R² = 0.80): (a) use unclamped trimmed mean — if R² ≥ 0.80 keep it; (b) clamp to ≥ 0 — if R² ≥ 0.80 keep it; (c) set β = 0 — if R² ≥ 0.80 keep it; (d) if β = 0 also fails → β = NaN (bin invalid, no Ct prediction plotted).- QA: Minimum 10 valid points in the decay window and after peak (
MIN_POINTS_OTHER_PROCESS);MAX_OTHER_PROCESS_RATE= 15 h⁻¹ upper cap (above this, values are noise spikes for sub-3 µm particles).
- QA: Minimum 10 valid points in the decay window and after peak (
- Emission rate (E): Mass balance rearranged and solved numerically at each time step during the shower-on-to-peak window; mean and std reported from positive values.
- QA: Minimum 3 valid consecutive steps (
MIN_POINTS_EMISSION); E_total clips negative per-step contributions to 0 before trapezoid integration.
- QA: Minimum 3 valid consecutive steps (
- Predicted concentration (Ct): Single continuous forward Euler simulation from shower ON to 2 hours after shower OFF. E = E_mean before peak_time, E = 0 after. Returned as two connected segments forming one continuous predicted Ct curve on figures. Decay phase starts from the predicted peak (end of emission simulation), not the measured peak.
- Emission R² (E_r_squared): R² of the emission-phase simulation vs. measured indoor concentration from shower ON to peak.
- Total emission (E_total): Trapezoidal rule area under the E_t vs. time curve from shower ON to peak; negative per-step contributions clipped to 0.
Output Files (this script):
Sheets/columns marked "doubled" carry the E1/E2 variants, bin{n}_outside_...
and bin{n}_entry_...; all_results additionally carries the E3
(bin{n}_adjusted_..., including its own independent
bin{n}_adjusted_p_mean/p_std/peak_time) and E4 (bin{n}_bathroom_...)
variants. The Excel workbook retains every event regardless of flow rate.
output/particle_analysis_summary.xlsx- Multi-sheet Excel workbook:all_results— Full results table (all metrics, all four variants, per event and bin)p_penetration— Penetration factors per event and bin (includes test_name); primary series (E1/E2/E4 share this)beta_deposition— Other process rates per event and bin (includes test_name); doubledbeta_r_squared— R² of forward Euler decay fit per event and bin (includes test_name); doubledE_emission— Emission rates per event and bin (includes test_name); doubledE_total_particles— Total emitted particle counts (E_total) per bin (includes test_name); doubledE_r_squared— R² of forward Euler emission-phase simulation per bin (includes test_name); doubledpeak_comparison— Measured vs. predicted concentration at peak_time and deposition_end (wide format, one row per event); predicted columns doubled
output/plots/event_figures/pm_decay/event_NN-testname_pm_decay.png- Individual event decay curves (four-panel), E1 (outside) variant only- Top panel: Measured concentration (solid) and continuous predicted Ct (dashed); shower ON/OFF dotted markers; optional outdoor PM overlay for events in
OUTDOOR_PM_EVENTS - Emission panels (×3): Bins 0–2, 3–6, and 7–11; per-step E_t as faint lines; E_mean as dashed horizontal line; emission R² annotation; x-axes match concentration panel
- Top panel: Measured concentration (solid) and continuous predicted Ct (dashed); shower ON/OFF dotted markers; optional outdoor PM overlay for events in
output/plots/event_figures/excluded_events/- Figures for duration-excluded (water-temperature-testing) events
Output Files (particle_emission_variant_figures.py, workflow step 7):
Reads particle_analysis_summary.xlsx's all_results sheet; does not
recompute p, β, or E. The boxplot/comparison figures below only include events
with a measured flow rate in FLOW_RATE_MIN–FLOW_RATE_MAX (4.1–5.6 LPM),
outside/entry (E1/E2) only.
output/plots/particle/beta_other_{pair}_bin{N}.html,output/plots/particle/emission_{pair}_bin{N}.html- Per-bin variant comparison figures (interactive), forpairinentry_outside(E2 vs. E1),outside_adjusted(E3 vs. E1, pre-cutover only),outside_bathroom(E4 vs. E1); 12 bins × 2 metrics × 3 pairs = 72 figures. A vertical line at 2026-06-03 marks the room-correction cutover.output/plots/penetration_summary.png- Bar chart summary of penetration factors by size (λ-independent, one file)output/plots/deposition_summary_{outside,entry}.png- Bar chart summary of other process rates by size, one file per λ sourceoutput/plots/emission_summary_{outside,entry}.png- Bar chart summary of emission rates by size, one file per λ sourceoutput/plots/emission_etotal_boxplot_{bin0-2,bin3-6,bin7-11}_{outside,entry}.png- E_total by water temperature (fixed temp axis, doubled per source: 6 files)output/plots/emission_etotal_boxplot.md- Event membership for the E_total boxplot (lists event numbers and config keys per box)output/plots/other_process_rate_boxplot_{bin0-2,bin3-6,bin7-11}_{outside,entry}.png- β by water temperature (fixed temp axis, doubled per source: 6 files)output/plots/other_process_rate_boxplot.md- Event membership for the β boxplotoutput/plots/emission_rate_boxplot_{bin0-2,bin3-6,bin7-11}_{outside,entry}.png- E_mean by water temperature (fixed temp axis, doubled per source: 6 files)output/plots/emission_rate_boxplot.md- Event membership for the E_mean boxplotoutput/plots/penetration_factor_boxplot_{bin0-2,bin3-6,bin7-11}.png- p by water temperature (fixed temp axis; λ-independent, 3 files)output/plots/penetration_factor_boxplot.md- Event membership for the penetration factor boxplotoutput/plots/emission_etotal_by_bedroom_rh_boxplot_{bin0-2,bin3-6,bin7-11}_{outside,entry}.png- E_total vs. bedroom RH (metric axis, doubled per source)output/plots/emission_etotal_by_bedroom_rh_boxplot.md- Event membership for the bedroom RH boxplotoutput/plots/emission_etotal_by_bedroom_temp_boxplot_{bin0-2,bin3-6,bin7-11}_{outside,entry}.png- E_total vs. bedroom temperature (metric axis, doubled per source)output/plots/emission_etotal_by_bedroom_temp_boxplot.md- Event membership for the bedroom temp boxplotoutput/plots/emission_etotal_by_acr_boxplot_{bin0-2,bin3-6,bin7-11}_{outside,entry}.png- E_total vs. air change rate (metric axis, doubled per source)output/plots/emission_etotal_by_acr_boxplot.md- Event membership for the ACR boxplotoutput/plots/emission_etotal_by_beta_boxplot_{bin0-2,bin3-6,bin7-11}_{outside,entry}.png- E_total vs. other process rate (metric axis, doubled per source)output/plots/emission_etotal_by_beta_boxplot.md- Event membership for the β boxplotoutput/plots/emission_etotal_by_p_boxplot_{bin0-2,bin3-6,bin7-11}_{outside,entry}.png- E_total vs. penetration factor (metric axis, doubled per source)output/plots/emission_etotal_by_p_boxplot.md- Event membership for the p boxplotoutput/plots/emission_etotal_by_showerhead_boxplot_{bin0-2,bin3-6,bin7-11}_{outside,entry}.png- E_total by shower head type (doubled per source)output/plots/emission_etotal_by_showerhead_boxplot.md- Event membership for the shower head boxplotoutput/plots/comparison/spray_pattern_{metric}_{bin0-2,bin3-6,bin7-11}.png- Comparison boxplots by spray pattern (5 metric families × 3 bin groups each)output/plots/comparison/head_type_{metric}_{bin0-2,bin3-6,bin7-11}.png- Comparison boxplots by shower head typeoutput/plots/comparison/mannequin_{metric}_{bin0-2,bin3-6,bin7-11}.png- Comparison boxplots by mannequin presenceoutput/plots/comparison/bath_door_{metric}_{bin0-2,bin3-6,bin7-11}.png- Comparison boxplots by bathroom door positionoutput/plots/comparison/bedroom_door_{metric}_{bin0-2,bin3-6,bin7-11}.png- Comparison boxplots by bedroom door positionoutput/plots/comparison/fan_status_{metric}_{bin0-2,bin3-6,bin7-11}.png- Comparison boxplots by fan status
See the room-concentration correction note above for what the primary vs. adjusted concentration series feed.
Run the environmental analysis to characterize conditions before and after shower events:
# Run RH/temperature/wind analysis (plots enabled by default)
python scripts/rh_temp_other_analysis.py
# Skip plots
python scripts/rh_temp_other_analysis.py --no-plots
# Plot only specific events
python scripts/rh_temp_other_analysis.py --events 1,3,5
# Limit time series plots to first 10 events
python scripts/rh_temp_other_analysis.py --max-plot-events 10Methodology:
- Monitors RH and temperature at multiple locations:
- Entry, Bedroom, Bathroom, Outside (Aranet4)
- Bedroom, Bathroom, Living Room (DAQ/Vaisala)
- Bathroom1, Bathroom2, Bath/Bed, Bedroom1, Bedroom2, Bedroom3 (HOBO UX100)
- Note: QuantAQ
met_rh/met_tempmeasure inside the flow cell and are excluded from RH/temp analysis
- Analyzes windspeed and direction from outdoor weather station (AIO2)
- Compares pre-shower baseline (30 min) to post-shower response (2 hours)
- Calculates mean, standard deviation, and min-max ranges
- Generates time series and box plot comparisons
Output Files:
output/rh_temp_wind_summary.xlsx- Multi-sheet Excel workbook:RH_Summary,Temp_Summary,WindSpeed_Summary,WindDir_Summary— per-sensor summary statisticsRH_Details,Temp_Details,WindSpeed_Details,WindDir_Details— per-event detail statisticsEvent_Log— event metadata (test_name, config_key, shower times, door position, fan status)Bedroom_Conditions— per-event bedroom RH and temperature (30-min pre-shower window); used by particle analysis for boxplot annotations; not rounded by sig figs
output/plots/event_figures/rh_timeseries/event_NN-testname_rh_timeseries.png- RH time series per eventoutput/plots/event_figures/temperature_timeseries/event_NN-testname_temperature_timeseries.png- Temperature per eventoutput/plots/event_figures/wind_timeseries/event_NN-testname_wind_timeseries.png- Wind data per eventoutput/plots/event_figures/excluded_events/- Time series figures for duration-excluded eventsoutput/plots/rh_pre_post_boxplot.png- Pre/post RH comparison across all eventsoutput/plots/temperature_pre_post_boxplot.png- Pre/post temperature comparisonoutput/plots/wind_speed_pre_post_boxplot.png- Pre/post wind speed comparisonoutput/plots/wind_direction_pre_post_boxplot.png- Pre/post wind direction comparison
A separate line of analysis compares the study's two QuantAQ MODULAIR-PM sensors (inside MOD-PM-00195, outside MOD-PM-00785) against the broader NIST Indoor Air Quality and Ventilation Group fleet of co-located MODULAIR-PM units deployed in the same test facility. It characterizes sensor agreement, builds candidate inter-sensor correction factors for the historical 195 record, and evaluates a position-weighted fleet average (C_room) as an alternative bedroom concentration reference.
This is no longer a purely exploratory side analysis. moduair_cave_ratio.py's
compute_room_frame / group_average_curve functions are reused in-memory by
src/particle_room_correction.py to build the room-corrected "inside" concentration
the main particle pipeline analyzes (see the room-concentration correction note under
Particle Decay & Emission Analysis). That means
particle_decay_analysis.py (workflow step 6) now also requires the moduair_pm_chunks
share below to be reachable — but it does not require running moduair_cave_ratio.py
as a script first.
Fleet data are produced by the separate NIST_moduair-pm repository and read
from the moduair_pm_chunks share configured in data_config.json — this is
independent of the QuantAQ API data downloaded by download_quantaq_data.py.
src/moduair_loader.py loads and aligns fleet sensor chunks onto a shared
1-minute time base; it does not download anything itself. All five scripts below
share a 2026-06-0{3,4} through 2026-07-16 default window, the period the
fleet was co-located with the study sensors, and take --start/--end/
--output-dir overrides.
# Inter-sensor correction factor ratios + 195/813 Deming fit
python scripts/moduair_correction_factor.py
# Per-event, per-sensor delta peak time (shower ON to peak)
python scripts/moduair_event_peak_times.py
# Per-bin time series across the co-located bedroom fleet
python scripts/moduair_bin2_timeseries.py
# Inside sensor (195) vs. position-weighted fleet average (C_room)
python scripts/moduair_cave_ratio.py
# Per-sensor variant of export_config_timeseries.py across the fleet
python scripts/export_config_timeseries_fleet.py [--no-sig-figs] [--sensors 195 401 ...]
python scripts/export_config_timeseries_fleet.py --c-room| Script | Purpose | Key Output |
|---|---|---|
moduair_correction_factor.py |
Bin-wise concentration ratios (195/813, 195/quad-average, 195/others) plus a Deming (orthogonal-distance) regression fit of 813 vs. 195 for a candidate correction | moduair_correction_factor_195_813_*.csv, moduair_correction_195_813_fit_*.csv, per-bin HTML figures |
moduair_event_peak_times.py |
Delta peak time (shower ON to peak) per sensor per event | moduair_event_peak_times.csv, event_delta_peak_times.html |
moduair_bin2_timeseries.py |
One interactive figure per bin (0–10) of the co-located bedroom fleet, styled after the NIST_moduair-pm weekly figures | plots/moduair_correction/bin{N}_timeseries.html |
moduair_cave_ratio.py |
C_bed1 (sensor 195) vs. C_room (position-weighted fleet average) scatter and time-ratio figures, restricted to samples where >8 fleet sensors report | plots/moduair_room/c_bed1_vs_c_room_bin{N}*.html, moduair_room_ratio_fit*.csv |
export_config_timeseries_fleet.py |
Per-sensor and C_room aggregated + raw event-configuration workbooks, same structure as export_config_timeseries.py |
output/event_config_timeseries_fleet/MOD-PM-<sn>/..., .../C_room/... |
Key packages (see environment.yaml for complete list):
- pandas, numpy - Data manipulation
- scipy - Numerical integration (trapezoidal rule for E_total calculation)
- matplotlib - Publication-quality figure generation
- bokeh - Interactive visualization
- requests - API communication
- python-dotenv - Environment variable management (.env file loading)
- pyyaml - Configuration management
- openpyxl - Excel file I/O
Instrument manuals and data sheets are not included in this repository due to copyright. Public download links are listed in docs/pdf_links.md.
- PI: Dustin G. Poppendieck — dustin.poppendieck@nist.gov
- Maintainer: Nathan Lima — nathan.lima@nist.gov
- NIST Organizational Unit: Engineering Laboratory
- Division: Building Energy and Environment Division
- Group: Indoor Air Quality and Ventilation Group
The experimental data associated with this project are not included in this repository. Data can be made available upon request by contacting the PI.
If you use this software, please cite it as:
@software{nist_epa_legionella,
author = {Lima, Nathan M. and Poppendieck, Dustin G.},
title = {NIST-EPA Legionella Study Analysis Tools},
year = {2026},
publisher = {National Institute of Standards and Technology},
doi = {10.18434/mds2-4153},
url = {https://github.com/usnistgov/NIST_EPA_Legionella}
}A peer-reviewed publication and/or NIST Technical Note associated with this study is in preparation. This section will be updated with a citable BibTeX entry and DOI when available.
This software is in the public domain and provided "as is" according to the LICENSE.md file. It was developed by employees of the National Institute of Standards and Technology (NIST), an agency of the Federal Government and is being made available as a public service. Pursuant to Title 17 United States Code Section 105, works of NIST employees are not subject to copyright protection in the United States. This software may be subject to foreign copyright. Permission in the United States and in foreign countries, to the extent that NIST may hold copyright, to use, copy, modify, create derivative works, and distribute this software and its documentation without fee is hereby granted on a non-exclusive basis, provided that this notice and disclaimer of warranty appears in all copies.
See LICENSE.md for the full NIST licensing statement.