Skip to content

Ventilation verification — ASHRAE 62.1 VRP & DCV

camber.ventilation does the explicit code-rate ventilation check that complements the CO₂ proxy in camber.iaq and the OA-fraction diagnostic in camber.oafraction.

  • Ventilation Rate Procedure (VRP) — is the delivered outdoor air at least the ASHRAE 62.1 requirement for the zone?
  • Demand-Controlled Ventilation (DCV) — is outdoor air actually raised when CO₂ (or occupancy) rises, or is it static (DCV not working)?

flowchart TD
  frame["role-frame (OA airflow, CO2, occupancy)"] --> vrp["assess_62_1 (VRP)"]
  frame --> gate["occupied · fan on · economizer off"]
  gate --> dcv["assess_dcv (DCV)"]
  rates["OA_RATES_62_1 (Table 6.1)"] --> req["required_oa_cfm (Voz)"]
  req --> vrp
  vrp --> vstatus["status: under / adequate / over"]
  dcv --> dstatus["status: functioning / static / uncorrelated"]
  vstatus --> rule1["ventilation_rate_62_1 rule"]
  dstatus --> rule2["dcv_verification / dcv_system_verification rules"]
  rule1 --> finding["Finding (warn / fault)"]
  rule2 --> finding
Two independent checks: is enough OA delivered (VRP), and does OA modulate with demand (DCV).

The VRP requirement

ASHRAE 62.1 sets the zone outdoor-air requirement as

Vbz = Rp·Pz + Ra·Az          (people term + area term)
Voz = Vbz / Ez               (corrected for air-distribution effectiveness)

required_oa_cfm(area_sqft, population, *, rp, ra, ez=1.0) computes Voz. The Rp/Ra rates come from OA_RATES_62_1 (62.1 Table 6.1, a public-standard subset) via oa_rates_for(space_type), or you pass them explicitly.

from camber.ventilation import assess_62_1

# a 2,000 ft² office for 10 people, metering ~120 cfm OA over occupied hours
r = assess_62_1(
    oa_cfm_series,
    area_sqft=2000,
    population=10,
    space_type="office",
    occupied_mask=occ,
    aggregate="median",
)
r.required_cfm  # 170.0   (5·10 + 0.06·2000)
r.status  # "under" -> deficit 50 cfm

measured_oa_cfm may be a scalar or a time series; a series is filtered by occupied_mask and reduced by aggregate. Status is under (ratio < under_tol), over (ratio > over_factor, an energy penalty), or adequate.

Option flags — assess_62_1

flag default effect
space_type — look up Rp/Ra from the 62.1 table
rp, ra from table override the people / area rates
ez 1.0 zone air-distribution effectiveness (raises the requirement when < 1)
aggregate "median" reduce a series: median/mean/min/p05/p95
occupied_mask None restrict a series to occupied hours
under_tol 0.9 flag under below this fraction of required
over_factor 1.5 flag over above this multiple of required

System-level VRP (multiple-zone systems)

assess_62_1 compares an air handler's outdoor air with one zone's Voz. An air handler that serves several zones must bring in the system intake instead (0.92, #17):

Vou = D·Σ(Rp·Pz) + Σ(Ra·Az)     D = Ps / ΣPz          (uncorrected OA intake, occupant diversity)
Vot = Vou / Ev                                          (multiple-zone recirculating system)

Judged against one zone, a correctly ventilated four-zone unit reads "over-ventilated 5×", and a unit 60 % short of its system requirement can read "adequate".

Sources. The maintainer has no licensed 62.1-2022 copy, so the procedure follows the free ASHRAE 62.1-2016 Addendum f (Vou, D and the simplified Ev; numbered §6.2.5 there and §6.2.4 in 62.1-2019) and public secondary sources (design guides and ASHRAE Journal columns on the addendum); the standard's text is not quoted. The 2019/2022 section and table numbers, the default occupant densities and the Ez rows are unverified against those editions.

Which Ev. system_outdoor_air(zones, method=...) offers both:

  • simplified (the default): Ev = 0.88·D + 0.22 for D < 0.60, else 0.75. It needs only each zone's area, population and rates — what a site can usually state — and it is the path the addendum added for exactly that reason. The addendum limits it on VAV systems to zones whose minimum primary airflow is at least 1.5 × Voz (per the secondary sources; wording unverified): give vpz_min_cfm and zones below it are listed (vpz_min_short) with a caveat to use the appendix method. In the simplified procedure Ez does not change Vot.
  • appendix: the multiple-zone calculation of the standard's normative appendix for a single supply system without secondary recirculation — Zpz = Voz / Vpz, Xs = Vou / Vps, Evz = 1 + Xs − Zpz, Ev = min Evz. It needs every zone's primary airflow (vpz_cfm, else vpz_min_cfm, the critical condition of a VAV zone) and the system's vps_cfm (default ΣVpz, which over-states Vps and the requirement when zone peaks do not coincide; caveated).

system_type="single_zone" gives Vot = Voz, and "100pct_oa" gives Vot = ΣVoz. Voz uses the zone air-distribution effectiveness by mode: DEFAULT_EZ_COOLING = 1.0 (ceiling supply of cool air) and DEFAULT_EZ_HEATING = 0.8 (ceiling supply of warm air with ceiling return, the maintainer-approved heating default). Floor supply and displacement have other values; set ez_cooling / ez_heating per zone. Without a system population (ps, or d), D = 1: the largest Vou and, simplified, Ev = 0.75 — a conservative requirement that can over-state it, said in a caveat.

Measured OA. assess_system_62_1(oa, requirement, heating_mask=, judged_mask=) judges each sample against its mode's Vot and takes the median ratio: under below 0.9, over above 1.5, else adequate. With no flow station, estimate_oa_cfm estimates OA from (MAT − RAT)/(OAT − RAT) × supply airflow on samples with |OAT − RAT| ≥ 10 °F, with ±2 °F per sensor and ±10 % on supply flow propagated to a band; a verdict must then hold across the band, or it is uncertain. The rule refuses the estimate when one of the temperatures is a copy of another point or MAT leaves the OA/RA band (the sensor-health checks).

The rule. VentilationSystemVRP (ventilation_system_62_1, a fleet rule, not auto-registered) runs from the config's ventilation section:

"ventilation": {
  "zones": "zones.csv",
  "systems": {"AHU-1": {"ps": 40}, "AHU-2": {"method": "appendix", "vps_cfm": 9000}}
}

zones.csv has one row per zone: zone, area_sqft, population, space_type (or rp, ra), and optionally system, ez_cooling, ez_heating, vpz_min_cfm, vpz_cfm, area_assumed, population_assumed. A zone without system joins the air handler that serves it through the config topology (the zone's nearest ancestor with an OA signal; ids match ignoring case and separators). Occupied (a trended OCCUPANCY point, else weekdays 07–18), fan-on samples are judged; heating-valve samples (above 5 %) against the heating Vot.

Outcome Severity
no zones configured, a zone missing area / population / rates, no OA flow and no usable estimate info, declined, with the reason
under-ventilated fault
over-ventilated (a conditioning-energy penalty) warn
adequate ok
uncertain (estimate band straddles a threshold) or too few samples info

Severity is capped at warn when any zone's area or population is marked assumed (a caveat lists them), when OA is estimated from temperatures, or when membership was not declared — read from a semantic (Brick) model or the naming heuristic, or all zones given to the only OA source. When the topology lists more terminals under the unit than the zones table does, the finding says Vot covers the listed zones only.

On open data (catalog examples; not gated benchmarks):

  • lbnl-b59 publishes no per-zone areas or populations, only two office floors of 2,325 m². With a stated assumption — that area split equally over the 51 underfloor terminals (981 ft² each) at the office default of 5 people per 1,000 ft² (unverified) — and the Brick model's terminal → RTU assignment (disputed at the 0.89 intake, so capped at warn), the four RTUs need Vot 1,110–2,000 cfm (D = 1) and measured 5,570–7,490 cfm of OA over occupied hours from 2020-04-11 (median): over-ventilated 3.4–5.6×, every RTU, and still 3.5–5.8× at D = 0.5. The temperature estimate on the same hours reads 0.4–0.7 of the flow stations (0.16 on RTU04, whose return air copies its supply air); with the stations removed the rule says uncertain on RTU01–03 and declines RTU04. Neither the stations' nor the MAT sensors' accuracy is known (the MAT sensors fail the flow balance; see SENSOR-HEALTH.md).
  • ornl-frp-vav (one RTU and ten VAV boxes, no OA flow station, no published room areas): with an assumed 300 ft² per room and the office default density, the 31 one-day runs read over on 7 (OA estimated at 930–1,850 cfm against Vot 340 cfm), uncertain on 4 and too few samples on 20 (15-minute data; mild days with |OAT − RAT| < 10 °F leave nothing to estimate from). There is no ground truth for OA, so this exercises the plumbing only.

Verdict thresholds are validated on synthetic systems only; no licence-clean dataset has design zone areas and populations with measured OA.

DCV verification

from camber.ventilation import assess_dcv, economizer_active_mask

econ, basis = economizer_active_mask(idx, econ_cmd=econ_cmd)  # or oat= + heat_valve=
res = assess_dcv(
    oa_signal, co2_series, occupied_mask=occ, economizer_mask=econ, co2_setpoint=1000, oa_floor=720
)
res.status  # "functioning" | "static" | "uncorrelated" | "insufficient"
res.reason  # why "insufficient" (see the table below)
res.demand_lift  # CO₂ when OA was raised minus CO₂ when OA sat at its floor

oa_signal can be OA flow, OA fraction, or OA-damper position. demand_signal is one of three kinds, detected automatically (demand_kind="auto") or stated:

kind what it is lift threshold
co2 zone CO₂, ppm min_lift_ppm (50 ppm)
presence anything within 0..1 — an occupancy point, or its hourly mean min_lift_occupancy (0.2)
count occupant count: non-negative, below any plausible CO₂ min_lift_people (1 person)

Until 0.82.0 anything not strictly 0/1 was read as CO₂, so an occupant count, or a presence point averaged to 10-minute means, was filtered out as implausible CO₂ and the verdict came back too_few_samples with no hint why.

Which samples are judged

DCV is only responsible for OA some of the time, so the rest is excluded before anything is judged:

  • Unoccupied — outside occupied_mask. The rules build it from the schedule AND-ed with a trended OCCUPANCY point, minus WARMUP / COOLDOWN.
  • Fan off or in transition — SUPPLY_FAN_STATUS below 0.95 (an hourly mean below 1 is a partial hour whose OA mean is diluted).
  • Economizing — an air handler's OA damper follows max(economizer, DCV minimum), so while it economizes OA tracks outdoor temperature, not CO₂. economizer_active_mask finds those samples from the best evidence available: a trended ECON_CMD (any nonzero hourly mean counts); else OAT + HEAT_VALVE (a sequenced SAT loop holds OA at minimum while heating, and the high limit locks the economizer out above 75 °F — the top of the ASHRAE 90.1 fixed dry-bulb range, so it excludes more hours when unsure); else OAT alone; else nothing, which the rule caveats.
  • OA closed — at or below 2% of its 95th percentile. DCV never shuts OA fully while a space is occupied (62.1 keeps an area-based floor), so a closed damper inside the occupied window is warm-up, a fan transition or a fault. Left in, an un-flagged morning warm-up closure made a stuck DCV look like it responded. Reported as closed_pct.

The verdict

A DCV controller maps demand to OA: a proportional reset raises OA as CO₂ climbs past its engage level; an integral loop raises OA only once CO₂ reaches setpoint and holds it there. Either way, the samples where OA is raised are the samples where demand is high. So assess_dcv compares CO₂ when OA is raised (above 25% of its robust p5–p95 range) with CO₂ when OA sits at its floor (within 5%) — demand_lift.

Conditioning on OA rather than on CO₂ matters for integral control: a PI loop holds CO₂ just under setpoint with OA at its floor much of the time, so "OA when CO₂ is high" is mostly the floor, while "CO₂ when OA was raised" stays high. The comparison is median-based, so a few outliers do not move it, and it reads in ppm. The Pearson correlation the check used before is still reported as correlation, as a diagnostic only. An integral loop weakens it (0.48–0.66 in simulation, against 0.92–0.99 for a proportional reset) without breaking it; what actually broke the old check was the economizer, closed-damper samples and outliers, described above.

status when
insufficient too few samples; demand never varied; CO₂ never reached the engage level (co2_setpoint − 200, else 800 ppm) where DCV should respond; or OA was raised too rarely to compare. Not evidence either way — a lightly occupied building is not a broken DCV
static demand reached the DCV range and OA did not move (robust modulation < min_modulation)
functioning demand_lift ≥ min_lift_ppm (occupancy: ≥ min_lift_occupancy)
uncorrelated OA modulates, but not upward with demand

Occupancy demand is judged within each hour of day (weekdays and weekends apart). Occupancy follows the clock, and so does a valve on a time clock or a damper doing thermal duty, so a plain comparison credits a schedule with "responding to occupancy". Only outdoor air that is higher on busier days at the same hour counts. Outdoor air that is never both raised and at its floor within one hour reads insufficient with reason="schedule_confounded". raised_when_vacant_pct reports how often OA was raised with the space empty. An occupancy-based verdict is still weaker than a CO₂ one: a supply that rises with occupants' heat gain in a 100% outdoor-air system genuinely ventilates more per person, and occupancy data alone cannot say whether a thermostat or a DCV loop did it.

CO₂ demand is judged within the hour of day too (0.93, #37; stratify_hour=True). A valve that opens on a morning clock and on CO₂ mixes the two in a pooled comparison: its clock-driven opening at low morning CO₂ drags the raised-valve median down, and a working CO₂ response reads "uncorrelated" -- or reads "functioning" on a second sensor, depending on which one you trust (an office room in the B4B data did both). Within each hour of day (weekdays and weekends apart) the clock cancels, so the lift is the per-hour median difference, averaged over the hours that hold at least three raised and three at-floor samples. Where fewer than min_bin such pairs exist the pooled lift decides, as before; lift_basis says which was used and demand_lift_pooled is always reported. A pooled verdict carries a caveat, because a valve on a pure time clock never has both states within one hour and so cannot be told apart from one that follows CO₂.

Sub-checks, each None when it cannot be evaluated:

  • co2_breach_at_min_pct (needs co2_setpoint) — share of judged samples with CO₂ above setpoint while OA is at its floor: under-ventilation the DCV is not answering.
  • below_floor_pct (needs oa_floor, same units as the OA signal) — OA below the floor, over every occupied sample: it is measured before the economizer and closed-damper exclusions, because an economizer only raises OA and a damper shut while occupied is the deepest below-floor case there is. (Until 0.82.0 it ran after them, so OA below the floor while the economizer might be active vanished into "not judged".) For 62.1 dynamic reset the floor is the area component Ra·Az (§6.2.7; numbering varies by edition). For a multiple-zone system the true intake floor is higher than ΣRa·Az, so this under-flags — the safe direction.
  • excess_at_low_demand_pct (needs oa_floor) — share of low-CO₂ samples with OA held above the floor: DCV installed but not saving energy, the reason it exists (ASHRAE 90.1 §6.4.3.8).

Option flags — assess_dcv

flag default effect
occupied_mask None restrict to occupied samples
economizer_mask None exclude (possibly) economizing samples — see economizer_active_mask
co2_setpoint None sets the engage level and enables the breach sub-check
dcv_engage_ppm setpoint − 200, else 800 CO₂ by which DCV should already be raising OA
min_modulation 0.1 min robust OA range (p95−p5)/p95; below ⇒ "static"
min_lift_ppm 50 min demand_lift (CO₂) for "functioning"
min_lift_occupancy 0.2 min demand_lift (presence fraction) for "functioning"
min_lift_people 1.0 min demand_lift (occupant count) for "functioning"
demand_kind "auto" co2 / presence / count, or detect
min_demand_span 150 min CO₂ p90−p10 to judge at all
oa_floor, floor_tol None, 0.10 floor for the floor sub-checks
min_samples, min_bin 24, 6 sample gates for the verdict and each bin
closed_frac 0.02 OA at or below this share of its p95 is "closed"
stratify_hour True take the CO₂ lift within the hour of day (0.93); False = pooled
min_corr — deprecated (0.82), ignored; warns

Rules

  • dcv_verification (DemandControlledVentilation) — config-free, auto-registered. Runs on equipment carrying both an OA signal (OA_AIRFLOW, else OA_DAMPER) and CO₂ — an air handler with a return-air CO₂ sensor, or a single-zone unit. A VAV zone's frame (CO₂, no OA) returns nothing; the fleet rule covers it. Severity: ok functioning; warn static, or uncorrelated with the economizer excluded; info insufficient, or uncorrelated when the economizer could not be excluded (OA may be following outdoor temperature); warn when OA sits above the floor on ≥ excess_warn_pct of low-demand samples; fault when the breach or below-floor share reaches breach_fault_pct / below_floor_fault_pct. Flags: co2_setpoint, dcv_engage_ppm, min_modulation, min_lift_ppm, econ_high_limit_f, oa_floor_cfm (a number, or {equip: cfm}), the three thresholds, occupied_only.

Occupied hours: a trended OCCUPANCY point replaces the schedule (so a 24/7 space isn't cut to a weekday window); otherwise start_hour / end_hour / occupied_days (default Mon–Fri 07–18). Unventilated while occupied: samples inside the occupied hours with CO₂ ≥ unventilated_co2_ppm (default co2_setpoint, else 1100 ppm) and the fan off or the OA shut are reported as unventilated_high_co2_hours / _pct, and are a fault once they add up to unventilated_fault_hours (4 h). The verdict itself judges modulation, not outages, so without this a lecture theatre at its CO₂ sensor's full scale with the fan off read "not judged". CAMBER temperatures are °F: when the economizer is inferred from OAT and nearly every sample is excluded, the finding says an OAT in °C would do that.

Below the floor with the fan off (0.98, #93). An occupied hour below oa_floor_cfm because the supply fan was off is a schedule the fan did not keep, not a minimum-OA problem. The fan is read from SUPPLY_FAN_STATUS, else from SUPPLY_FAN_SPEED at or below fan_off_speed_pct (5 %). Those hours are named in the summary and a caveat ("supply fan off while scheduled occupied") and counted as fan_off_occupied_pct / fan_off_occupied_hours, and below_floor_pct is the shortfall with the fan running. The share test still reads every below-floor sample (below_floor_total_pct), so the severity does not move. With no fan signal nothing changes. Duration (opt-in): below_floor_fault_hours (default None) makes a contiguous below-floor episode of that many occupied hours a fault whatever its share, because a short, concentrated event otherwise vanishes in a long record. The longest episode is reported either way (below_floor_longest_h, _start, _fan_off_h). It counts consecutive occupied samples, so it continues across the night between two occupied days, and the summary says when the fan was off for most of it.

Which OA signal (0.93, #37). The best signal judges every sample it covers, a lesser one only the samples the better ones miss: OA_AIRFLOW, then OA_DAMPER. An air handler whose flow station is masked for a period is judged there on its damper position and on its flow elsewhere; metrics["oa_segments"] lists each signal's verdict and date span, the first segment with a verdict leads the finding, and the worst severity wins. With full_outdoor_air=True (a 100 % outdoor-air unit: no return air, no economizer damper, every cfm the fan moves is outdoor air) the supply AIRFLOW and then the SUPPLY_FAN_SPEED follow as proxies; no economizer exclusion applies (economizer_basis="full_outdoor_air"), and the finding says the fan may also speed up for cooling. - dcv_system_verification (DcvSystemVerification) — the fleet twin, auto-registered. Most buildings put CO₂ on the zones and OA on the air handler, so no single frame has both. This groups zones to their serving air handler through the served-by topology — each zone's nearest ancestor that carries an OA signal, so a Brick chain air handler → VAV → zone works (semantic, or the naming heuristic run_fleet builds — which caps severity at warn), drops zone CO₂ that is implausible, stuck flat, or stays more than 300 ppm above outdoor when unoccupied, takes the per-timestamp maximum (the critical zone should drive the reset; agg="mean" is available) and judges each air handler as above. With no usable grouping and exactly one OA source, all zones join it, with a caveat; with several, unattributed zones are declined rather than guessed. One finding, with a per_ahu breakdown. - co2_ventilation (CO2Ventilation) — CO₂ adequacy: under-ventilated when CO₂ rises more than 700 ppm above outdoor, over-ventilated when it stays within 150 ppm (warn at 60 % of occupied hours). Since 0.93 (#38) an economizer's outdoor air is not over-ventilation: the economizer-mode hours (iaq.economizer_mode_mask — a trended ECON_CMD; else the OAT below the 75 °F high limit and the OA damper more than 5 points above its minimum, or the unit on ~100 % outside air by freecooling.integrated_economizer_mask) are left out of over_vent_pct and reported apart (econ_hours_pct, over_vent_econ_pct, over_vent_all_pct); with fewer than 10 hours left, over-ventilation is not judged. An OAT alone excludes nothing (a 100 % outdoor-air unit has no economizer). Under-ventilation is judged on every occupied hour. Occupied hours (0.107, #200) are a trended OCCUPANCY point's, which replaces the assumed weekday 07-18 schedule; a finding judged on the schedule says so in a caveat, and occupancy_basis names the source. Flags: exclude_economizer, oa_damper_min_pct (default: the damper's 5th percentile while open), econ_high_limit_f. - co2_ventilation_system (CO2VentilationSystem, 0.93) — the fleet twin: a zone frame carries CO₂ but not the damper, so each CO₂ zone is joined to its serving air handler (served-by topology, else the naming heuristic; one economizing unit and no grouping: every zone joins it) and judged with that unit's economizer-mode hours excluded. A zone no unit can be attributed to is judged without the exclusion, with a caveat. One finding with a per_zone breakdown. - ventilation_system_62_1 (VentilationSystemVRP, 0.92) — the system-level VRP above; runs from the config's ventilation section. - ventilation_rate_62_1 (VentilationRateProcedure) — needs the zone's design inputs, so it's instantiated explicitly (not auto-registered). It judges one zone; for an air handler serving several zones use ventilation_system_62_1:

from camber.rules.ventilation_rule import VentilationRateProcedure

rule = VentilationRateProcedure(area_sqft=2000, population=10, space_type="office")
finding = rule.analyze("AHU-1", role_frame)  # fault on under-ventilation

Validation and limits

The verdict logic is tested against camber.faultlab.dcv_sim, a well-mixed zone CO₂ mass balance (V·dC/dt = N·G − Q·(C − C_out)) under proportional, integral and static DCV with an economizer, warm-up closures and a fan schedule — and, since 0.82.0, against open real-building data (licence-clean, not vendored; datasets cited by DOI):

Data What it establishes
Lab room running a known DCV law, 3 + 6 L/s per person (Zenodo 10.5281/zenodo.18299691, CC BY) functioning on CO₂ (lift 392 ppm), on the occupant count and on the controller's own occupancy estimate. The CO₂ mass balance closes at 5.3–5.8 mL/s per person, close to the 5.0 mL/s (0.0105 cfm) the simulator assumes for a sedentary adult
Three office rooms, camera counts + VAV damper (data descriptor doi:10.1038/s41597-019-0274-4, CC0) functioning on counts in all three (lift 2–20 people within the hour); on CO₂ one room, the other two never reached the engage level
Office building with no DCV, 4 rooftop units, 11 CO₂ zones, Brick model (Dryad 10.7941/D1N33Q) insufficient on every unit — never a false functioning (specificity); all 11 zones attributed through the Brick air handler → VAV → zone chain. Below the 750 cfm floor (the config's assumed Ra·Az per unit) in 2.0–4.2 % of occupied hours over the OA-flow record (April–December 2020): info, under the 10 % below_floor_fault_pct. Those hours are days with the supply fans off while the schedule says occupied (October and December 2020; fan speed feedback 1.2–2.6 %, and −24.9 on 2020-12-17), not the wildfire smoke mode. Since 0.98 the finding says so: "supply fan off while scheduled occupied" for 41, 82, 40 and 40 h, and below_floor_pct (the shortfall with the fan running) is 0.1, 0.0, 0.1 and 0.0 %; the longest episode is 22 h (RTU02: 64 h, 2020-10-08 to 10-15), so below_floor_fault_hours: 4 would flag every unit (a fault, shown as warn because this run's zone grouping is heuristic). The smoke mode stays unflagged: from 2020-08-24 to 09-06 the dampers were held at their 10 % minimum and the units still took in 1,130–3,625 cfm (median by unit), so the smoke-mode weeks alone read 0–0.9 % below the floor (re-measured in 0.98; earlier versions of this page called the smoke mode a below-floor fault on two units). Since 0.93 the months before the OA-flow record (Aug 2019 – Mar 2020) are judged on the damper position: insufficient too, zone CO₂ never varying 150 ppm. co2_ventilation_system: 57–71 % of occupied hours are economizer mode and set apart; the zones stay within 150 ppm of outdoor in 99–100 % of the remaining minimum-damper hours — over-ventilated at the minimum, not only while economizing
Lecture theatre, fan speed + two CO₂ sensors (Zenodo 10.5281/zenodo.3406555, CDLA-Permissive) fault: 110 occupied hours with the fan off and CO₂ at the sensor's 2000 ppm full scale. Since 0.93 the unit (100 % outdoor air) is judged on its fan speed: insufficient on the fan-on hours (CO₂ never varies enough), and the fan-off hours at full-scale CO₂ remain a fault
Rooms with a schedule-driven ventilation valve (github energietransitie/b4b-windesheim, CC BY) not functioning on presence once the lift is taken within the hour — a time clock is not DCV. On CO₂ (0.93, within the hour): one room reads functioning on both its sensors, where the pooled lift read "uncorrelated" on one; the other stays sensor-dependent (functioning on its desk sensor, a 12 ppm same-hour lift on the BMS sensor)
  • Without ECON_CMD the economizer state is inferred, and the OAT-only fallback discards all mild weather — expect insufficient more often. That is the honest answer, not a defect.
  • Damper position is not OA flow; the floor sub-checks need OA_AIRFLOW in cfm.
  • Return-air CO₂ averages the zones and dilutes the critical one. dcv_verification attaches this caveat to air handlers and to equipment of unknown class, not to terminals or fan coils, whose CO₂ is the room's own (0.98).
  • A fixed OA damper on a variable-speed supply fan moves OA flow with fan speed, and fan speed tends to follow occupancy through cooling load — that can mimic DCV. Not detected.
  • The fleet rule's max aggregation follows the worst trusted zone sensor; the plausibility, stuck and unoccupied-offset guards remove the obvious bad ones, not all.

Scope

This verifies the rate from trends; it is not a substitute for a stamped 62.1 ventilation calculation. The Table 6.1 defaults are a convenience — confirm the zone's category and design population against the project's mechanical schedule.

Further reading

PNNL's AHU Minimum Outdoor-Air Operation guide (PNNL-SA-88958) backs the DCV, CO₂ and 62.1 rules, and training chapter 5 covers the air handler (links only; see References). When a DCV finding fires, its advisory action follows the cause: a working DCV whose outdoor air stays above its floor at low demand is told to lower that floor, not to "enable DCV" (see ASO).