← Trust Center overview Full technical document · rendered from our single source of record

Methods & Formulas

How UnitTrack computes what it publishes — every disclosure-control rule and every analysis, with the formula as implemented in code, its primary citation, and an honest note wherever the implementation simplifies the textbook method.

This is the companion to the Security & PII document. That one covers how your data is protected; this one covers how it is analyzed. Both follow the same rule: claims match the code. Where UnitTrack does less than the literature (e.g. a heuristic instead of an optimal solver), we say so here rather than imply otherwise.

How to read this. Each method gives: what it answers · the formula as coded · the default parameters · the primary source · and, where relevant, a Simplified note. File references point at the exact function so an auditor can check us.


1. The unit of analysis: a "cell"

Every published number is a cell — one group of customers and the value computed for them. Cells are the object disclosure control operates on, so their fields matter (udap_engine/aggregate/__init__.py, class Cell):

Field Meaning
keys the group — e.g. {rate_class: "RESIDENTIAL"} or {period: "2023-01"}
value the published statistic (see below)
contributor_count number of distinct contributors (customers/accounts) in the group
row_count number of meter reads in the group
top1_share the largest single contributor's share of the group total
top2_share the two largest contributors' combined share of the group total

How a cell's value is computed (_metric_value): the group total T = Σ value over its reads, then

The contributor shares — the inputs to the dominance rule — are, for a group whose per-contributor totals (each customer's summed consumption) are c₁ ≥ c₂ ≥ … ≥ cₘ with total T:

top1_share = c₁ / T
top2_share = (c₁ + c₂) / T

Rows missing the value field are skipped; a zero-total group yields shares of 0. Cells are sorted by descending value.

Grouping options (GROUPINGS): customer class, geography, service type, or a time grain (month / quarter / year). Geography is a real Census tract when a boundary layer covers the data, else a fallback grid (§4).


2. Statistical disclosure control (SDC)

The core of the product. Published aggregates pass a composed pipeline — primary threshold → dominance → complementary suppression — then a residual re-identification risk score. All of this is pure Python operating on Cell objects (udap_engine/sdc/__init__.py, apply_policy), with each suppression tagged by the rule and citation that fired it (RULE_CITATIONS) so the UI can show why a group was withheld, not just that it was.

2.1 Minimum-count threshold (k-anonymity)

A cell is suppressed if it has fewer than k distinct contributors (_primary_threshold):

suppress cell  if  contributor_count < k

Default: k = 15. Rationale: a published group of n distinct contributors bounds the chance of singling any individual out at 1/n; requiring n ≥ k caps that exposure. Note the count is of contributors, not reads — 15 meter reads from one customer is still one contributor and does not pass.

Primary source: Sweeney, k-anonymity (2002); ESSnet/Eurostat SDC Handbook (Hundepool et al.) — the minimum-frequency / threshold rule for tabular data. Regulatory match: CA CPUC D.14-05-016 ("15/15"); IL 220 ILCS 33.

2.2 (n,k)-dominance

Even with enough contributors, a group dominated by one or two large customers leaks: an insider who knows the total and the runners-up can back out the leader. So a cell is suppressed when its top-n contributors hold at least a p share of the total (_dominance):

suppress cell  if  topN_share ≥ p          (N = dominance_n ∈ {1, 2})

with topN_share = top1_share when n = 1, else top2_share.

Default: the safe-by-default governance policy (§2.7) governs every analysis — the single-customer 15/15 form, n = 1, p = 0.15. (n = 2, p = 0.85 exist only as the bare engine/AnalysisSpec fallback and are overridden by the governance policy before any analysis runs, so the workbench never publishes at the looser cap by default.) The comparison is (not >) so a cell sitting exactly on the limit is suppressed — matching regulator language of "p or more."

Primary source: ESSnet/Eurostat SDC Handbook §4.2 (sensitive cells in magnitude tables — the (n,k)-dominance / concentration rule). Chapter 5 covers frequency tables, so the magnitude dominance rule lives in Chapter 4.

2.3 Complementary (secondary) suppression

If a row of cells has exactly one primary hole, its value is recoverable as margin_total − (other published cells). Complementary suppression hides a second cell to break that subtraction (_complementary):

For each free dimension, group the cells into margins (cells sharing every key except that dimension). In any margin with exactly one suppressed cell and at least two still-published cells, suppress the smallest-valued published cell as cover. Repeat to a fixed point.

for each margin with |suppressed| == 1 and |published| ≥ 2:
    hide argmin_value(published cells)

The guard requires at least two published cells present before it acts, so at least one always survives — we never blank out the last remaining published cell (that would destroy the data rather than protect it), and this view publishes no explicit totals.

Primary source: ESSnet/Eurostat SDC Handbook §4.2.2 (complementary/secondary suppression for magnitude tabular data).

Simplified — honest note. This is a smallest-value heuristic, not the Handbook's optimal secondary suppression, which is a linear program that minimizes information loss. UnitTrack does not yet solve that LP; the heuristic protects the margin but may suppress more (or less efficiently) than the optimum. It also handles one-dimensional margins per free key rather than the full multi-table network. See §5.

Heuristic limit — the 2-cell margin (L5). Complementary suppression fires only when a margin has ≥ 3 cells (one suppressed, ≥ 2 still publishable): with exactly two cells it declines, because hiding the second would destroy the row rather than protect it, and this view publishes no explicit margin totals to difference against. The residual exposure is therefore an external total: if an analyst independently knows the row total (e.g. from a separately published figure), a single published cell in a 2-cell margin reveals its partner by subtraction. UnitTrack does not — and cannot in general — reason about totals published outside the tool; the session-global differencing risk (§2.4) surfaces cross-release exposure within a session, and users combining a UnitTrack release with an outside total should treat the pair under the same minimum-count rule. This is a documented boundary of the heuristic, not a defect in it.

2.4 Residual re-identification risk score

After suppression, the published set still carries some risk — bounded by its smallest surviving group (_score):

risk = 100 / min_group          (min_group = smallest contributor_count among published cells)
risk = 0                        if nothing is published
risk_score = round(risk)        reported 0–100

This is a worst-case measure, not an average: exposure is set by the single most vulnerable published group, the same logic behind minimum-count rules. Bands (_band): ≥ 15Low, 5–14Moderate, < 5High, nothing published → None — anchored to the 15/15 safe floor (1/15 ≈ 6.7% singling-out).

Primary source: Sweeney (2002); the smallest-group worst case underlies CPUC-style minimum-count rules.

Session-global risk (differencing). Risk is a property of the whole release set, not one table: publishing several overlapping breakdowns in a session lets someone difference them to recover a value that any single table's suppression protected. The workbench therefore keeps a per-session ledger of the group-by tables you publish (deduped by dimension, keeping the worst case per breakdown) and shows a session-global risk100 / (smallest published group across all of them) — beside the per-analysis score, flagging when two or more distinct breakdowns have been published. Honest scope: this surfaces the differencing setup and the worst-case exposure across releases; it does not attempt full differencing detection (intractable in general) — it is a warning, not a proof. reset_policy clears the session ledger.

2.5 Data retained (the utility side of the trade-off)

So the risk number is never read in isolation, every result also reports how much data survived (_score):

pct_records_published = 100 · Σ row_count(published) / Σ row_count(all)
pct_value_published   = 100 · Σ |value|(published)  / Σ |value|(all)

Risk and retained data are surfaced together — the explicit risk/utility trade-off.

2.5a Governing-standard citation (release level)

Beyond the per-cell suppression citations (§2.1–2.3), every release names the standard its thresholds implement, so an exported table can state the rule the whole release follows (policy_basis, carried on the result as policy_basis and stamped as a # Basis — line on CSV exports). When the settings match the utility-sector 15/15 rule (≥ 15 contributors and a single customer capped at ≤ 15%), the citation names CA CPUC D.14-05-016; otherwise it reports the generic minimum-frequency + (n,k)-dominance basis with the applied thresholds. This keeps the citability mandate true at the artifact level, not only in the UI.

2.5b Quasi-identifiers and geographic granularity (L8)

City, ZIP, and Census tract are quasi-identifiers. Even when a value is aggregated and passes the minimum-count and dominance rules, a fine geographic key combined with other released or externally-known attributes (rate class, a service type, an approximate usage band) can narrow a group toward a single household — Sweeney's classic result that ZIP + birthdate + sex re-identifies most of the US population is the canonical warning. UnitTrack applies the same k / dominance rules to geographic groupings as to any other dimension, so a sparsely-populated tract is suppressed like any small group; but users should treat finer geography as higher risk, prefer the coarsest geography that answers the question, and remember that publishing several geographic breakdowns compounds exposure (the session-global differencing risk, §2.4, tracks this within a session). This is guidance, not an automated control — geography is a lever the analyst chooses.

2.6 Differential privacy (alternative method)

As an alternative to suppression, a group's count can be released with calibrated noise instead of being hidden (udap_engine/dp/__init__.py, dp_counts). For a count query one person changes the answer by at most 1, so global sensitivity Δ = 1, and ε-differential privacy is achieved by adding Laplace noise at scale b = Δ/ε:

noisy = max(0, round( true + Laplace(0, 1/ε) ))

Smaller ε = more noise = stronger privacy. The app's default budget is ε = 1.0 (the recommended ceiling in INDUSTRY_MIN; clamped to 0.05–10). Negative draws are clamped to 0 and the result is rounded to an integer (both are standard post-processing).

Primary source: Dwork, McSherry, Nissim & Smith, Calibrating Noise to Sensitivity (2006); Dwork & Roth (2014); US Census Bureau 2020 Disclosure Avoidance System.

Simplified — read this before relying on DP. UnitTrack's DP is illustrative, not a production privacy guarantee, and suppression (§2.1–2.3) is the default governance path. Specifically:

2.7 Safe-by-default, warn-on-override

Disclosure control is global governance, not a per-analysis afterthought. The default policy is the industry-accepted minimum (INDUSTRY_MIN, anchored to CPUC 15/15): k = 15, single-customer dominance cap p = 0.15, complementary suppression on, tail-coding on, DP budget ceiling ε = 1.0, and a 15-account floor to publish a whole-population model.

Loosening any control toward higher risk raises a warning; tightening is silent (policy_warnings, direction-aware). Warnings are advisory (yellow); an actual active disclosure is decided red from the analysis result itself. A whole-population model can never be published below a 5-account hard floor, even with acknowledgment.

Primary source: CA CPUC D.14-05-016 ("15/15") as the sector's minimum; ESSnet SDC Handbook for the control definitions.


3. Analyses

Every analysis below runs on the customer's own ingested reads. In the standalone analyses (§3.2–§3.7) consumption is taken as a magnitude (abs()), so a reversed/negative read contributes size, not sign (the data-quality view, §3.7, separately counts the negatives); the base tabulation (§3.1) sums the signed value as stored, so a negative read there lowers the total. Dates parse YYYY-MM-DD, MM/DD/YYYY, or YYYY/MM/DD. Percentiles use NumPy's default linear interpolation.

3.1 Consumption tabulation

The base analysis: group reads by customer class, geography, service type, or a time grain, and publish count, mean, or sum per group (§1), through the full SDC pipeline (§2). A crosstab is the two-dimension form of the same tabulation, run through the same pipeline — including complementary suppression across the table's margins.

3.2 Efficiency benchmarking — EUI

Answers: how efficient is each building versus its same-class peers? (udap_engine/benchmark/__init__.py.)

Per building, energy is converted to a common unit (1 kWh = 3.412 kBtu, 1 therm = 100 kBtu), summed, annualized, and divided by floor area:

annual_kbtu = Σ|kbtu| · 365 / span_days           (span_days ≥ 60 required)
EUI         = annual_kbtu / floor_area             (kBtu/ft²/yr)

Per class (default k = 15), it reports the median, 25th, 75th percentile, and mean EUI; classes with fewer than k buildings are suppressed (but still count toward the overall median and building total).

Source energy is reported alongside site EUI: source_EUI = site_EUI × source-site ratio (grid electricity 2.80, natural gas 1.05; EPA Source Energy Technical Reference). A coarse national context ratio is also shown — the class median site EUI as a percent of the sector's all-fuel national-average site EUI (RECS 2020 residential / CBECS 2018 commercial). It carries no efficiency verdict: the reference is all-fuel while a dataset is usually one commodity, so it's context, not judgment.

Version-pinned constants. All reference numbers (source-site ratios, national EUIs, survey vintages) live in udap_engine/benchmark/constants.py under a single BENCHMARK_CONSTANTS_VERSION. Maintenance: when EIA publishes a new CBECS/RECS or EPA revises the ratios, update the value + its cited source there, bump the version (YYYY.N), and note it in the module changelog — nothing else hard-codes these numbers.

Primary source: EPA ENERGY STAR (the EUI / peer-percentile concept) and EPA Source Energy Technical Reference (site→source ratios); site energy conversions (1 kWh = 3.412 kBtu, 1 therm = 100 kBtu) per EPA Thermal Energy Conversions / EIA Monthly Energy Review Appendix A; and the national-context sector figures from EIA CBECS 2018 / RECS 2020 — which are means (distinct from ENERGY STAR's property-type medians), so the coarse "vs national avg" ratio is anchored to CBECS/RECS, not to the official ENERGY STAR benchmark.

Simplified. This is within-dataset EUI + peer percentiles, source EUI, and a coarse national anchor — not the official ENERGY STAR 1–100 score, which is a property-type, operationally-adjusted CBECS regression (needs worker/hours/etc. inputs and EPA's licensed model) we do not replicate. Energy content applies to electric and gas only; water volume is excluded (applicable = False).

3.3 Peak demand & load factor

Answers: how peaky is demand, and how much do customer peaks coincide? (udap_engine/demand/__init__.py; electric only — needs a peak_demand_kw column.)

load_factor  = average_load_kw / peak_load_kw                  (0–1)
coincidence  = coincident_peak / Σ individual peaks           (0–1)
diversity    = 1 / coincidence                                (≥ 1)

Per-class load-factor median/quartiles use the same k = 15 suppression. Load factors outside (0, 1] are dropped.

Primary source: NREL Uniform Methods Project Ch. 10 (Peak Demand) for load factor and peak demand. The coincidence and diversity factors here use the classic power-systems definitions (diversity = 1/coincidence); note different sources (e.g. BPA) define "coincidence factor" slightly differently, so treat the exact figure as convention — and see the directional-bias caveat below.

Simplified — and biased in a known direction. The coincident peak is an approximation: each account's single maximum peak is placed entirely in its peak_hour bucket and zero in every other hour, rather than counting each account's actual (nonzero) load at the system-peak hour — which requires interval data we don't have. Because non-peaking customers' real load at that hour is treated as zero, the coincident peak is systematically understated, so the reported coincidence factor is biased low and diversity factor biased high. Treat these two as directional/indicative only; load factor and per-account peaks are unaffected. A true coincidence factor needs time-aligned interval (AMI) demand.

3.4 Seasonal / outdoor use — winter baseline

Answers: how much use is seasonal (e.g. outdoor irrigation for water)? (udap_engine/seasonal/__init__.py.)

Reads are pooled by calendar month (across accounts and years). The baseline is the lowest monthly average; consumption above baseline is the seasonal component:

baseline    = min over months of monthly_avg
outdoor     = Σ_months max(0, monthly_avg − baseline)
outdoor_pct = 100 · outdoor / Σ monthly_avg

Requires ≥ 4 distinct months. Water (CCF) is converted to gallons for the daily figure (1 CCF = 748 gal).

Primary source: the minimum-month / winter-averaging logic follows AWWA conservation practice (winter-quarter/base-month averaging). EPA WaterSense and the Water Research Foundation's Residential End Uses of Water support the premise that outdoor use is a large, separable share — they do not define this specific subtract-the-lowest-month formula (WaterSense uses a landscape water budget; WRF used flow-trace disaggregation). 1 CCF = 748 gal (standard water-utility rounding, 748.05).

Simplified. This is the minimum-month method, not a degree-day (HDD/CDD) weather-normalized regression: "winter" is nominal (whatever month is lowest), the baseline is a single month, and months are pooled across years without per-account normalization. Sensitive to one anomalous low month.

3.5 Consumption distribution & inequality

Answers: how unequal is consumption across customers? (udap_engine/distribution/__init__.py; withheld entirely below 5 accounts.)

On annualized per-account consumption:

Gini = (2·Σ i·xᵢ)/(n·Σx) − (n+1)/n          (xᵢ ascending, 1-based i; 0 = equal)
CV   = std(x) / mean(x)                       (population std, ddof = 0)
high outliers = #{ xᵢ > Q3 + 1.5·IQR }        (Tukey)

plus p10/p25/median/p75/p90 and a downsampled Lorenz curve.

Primary source: Gini (1912); Lorenz (1905); Tukey (1977) for the IQR rule.

Simplified. CV uses the population standard deviation; the outlier count is the upper fence only (low outliers not counted). Optional tail-coding (off by default) withholds p10/p90 when a decile holds fewer than k = 15 accounts (ESSnet extreme-value protection; NIST SP 800-188 top/bottom-coding).

3.6 Spatial autocorrelation — Moran's I

Answers: is consumption geographically clustered, dispersed, or random? (udap_engine/spatial/__init__.py; needs ≥ 4 valued areas.)

Global Moran's I with binary rook-contiguity weights w over the published areas:

I   = (n / S0) · ( Σᵢ Σⱼ wᵢⱼ (xᵢ−x̄)(xⱼ−x̄) ) / ( Σᵢ (xᵢ−x̄)² )
E[I] = −1 / (n−1)
z    = (I − E[I]) / √Var(I)          Var per Cliff & Ord normality assumption
p    = erfc(|z| / √2)                 two-tailed normal

p < 0.05Clustered (if I > E[I]) or Dispersed; else Random.

Primary source: Moran (1950) for the global statistic; Cliff & Ord, Spatial Autocorrelation (1973) for the normal-theory inference.

Local statistics (WHERE the clusters are). Global Moran's I says whether the map clusters; the local statistics say where. For each area we compute:

The Analysis › Geography tab reports the count of significant hot/cold spots; the per-area detail is available in the engine (local_spatial_grid / _graph).

Simplified. The global p-value is the analytic normal-theory approximation, not a Monte-Carlo permutation (pseudo) p-value; likewise Gi uses its analytic normal z-score. The local Moran quadrant labels cluster type descriptively (its own permutation-based significance is not yet computed — Gi provides the tested hot/cold inference). Weights are binary rook contiguity (edge-sharing, not queen/diagonal) and not row-standardized, which differs from the row-standardized convention in many GIS packages. Islands (no neighbors) still count toward n.

3.7 Data quality

Answers: what's wrong with the data, surfaced not hidden? (udap_engine/quality/__init__.py.) Counts and percentages of: estimated reads (flag in a fixed truthy set), negative usage (consumption < 0), missing coordinates (lat or lon null/blank), and high statistical outliers (> Q3 + 1.5·IQR, Tukey, computed on ≥ 4 values). Bad rows are flagged, not dropped.

Primary source: Tukey (1977) for the IQR rule.

Simplified. Outliers are upper-fence only; missing-geo flags only null/empty (not a malformed non-empty coordinate); the estimated-flag set is a fixed literal list.

3.8 Weather normalization — degree-day regression

Answers: how much of consumption is weather-driven, and what would it be in a normal-weather year? (udap_engine/weather/__init__.py.) Monthly consumption is fit to heating and cooling degree days by least squares:

E   = β₀ + β_h·HDD + β_c·CDD        (base 65°F; ≥ 3 months required)
HDD = max(0, 65 − T̄)·days,   CDD = max(0, T̄ − 65)·days

β₀ is the weather-independent base load; β_h / β_c are the heating / cooling slopes. The model restates annual use at normal-year degree days and reports the weather-sensitive share of consumption. Temperatures come from the measured NOAA GHCN-Daily nearest station when a token is configured, otherwise a modeled climatology — which one was used is labeled in-app.

Primary sources: ASHRAE Guideline 14-2014; PRISM (Princeton Scorekeeping Method) (Fels 1986); base-65°F degree days per US EIA / NOAA; NOAA GHCN-Daily (Menne et al. 2012).

Gas (heating-only model). For gas datasets (therms / CCF) — which have no cooling load — the model drops the cooling term and fits the heating-only form E = β₀ + β_h·HDD (heating_only=True, auto-selected by fuel unit). β₀ is then non-weather gas use (water heating, cooking) and the HDD-sensitive part is space heating. This is more robust than forcing a spurious cooling coefficient onto a heating-only fuel, and is the original PRISM formulation (Fels 1986 developed the Princeton Scorekeeping Method for gas heating). The workbench labels the gas model as heating-only.

Simplified. Mean-temperature degree days (not hourly integration); a single fixed base temperature (65°F), not a fitted balance point; requires ≥ 3 months to fit.

3.9 Measurement & verification — IPMVP Option C

Answers: how much energy did an efficiency measure actually avoid, adjusted for weather? (udap_engine/mv/__init__.py.) The history is split at an intervention date into a baseline and a reporting period. A weather model E = β₀ + β_h·HDD + β_c·CDD is fit on the baseline months (least squares, the same degree-day regression as weather normalization), then projected onto the reporting period's actual degree days to give the adjusted baseline — the Customer Baseline Load (CBL), i.e. what the site would have used had nothing changed. Avoided energy is the difference from metered use:

Avoided energy = Σ (adjusted baseline) − Σ (reporting actual)

reported in absolute units and as a percent of the adjusted baseline. Needs ≥ 3 baseline months (to fit) and ≥ 1 reporting month.

Primary sources: IPMVP Core Concepts (EVO 10000-1:2016), Option C (whole-facility); ASHRAE Guideline 14-2014 (whole-building regression baseline); base-65°F degree days per US EIA / NOAA.

Simplified / honest scope. The routine adjustment is degree-days only (temperature), so it applies to weather-sensitive energy (electricity, gas), not water; non-routine adjustments (occupancy, schedule changes) are not modeled; and on the synthetic demo data — which has no real retrofit — avoided energy reads near 0% by construction, which is the method behaving correctly.

3.10 Energy burden & equity

Answers: what share of household income goes to energy, and where is it unaffordable? (udap_engine/burden/__init__.py.) Per census tract, each household's annualized consumption is priced at an average retail rate and divided by the tract's median household income:

burden%ₕₒᵤₛₑₕₒₗd = 100 · (annual kWh · $/kWh) ÷ tract median income tract burden = median household burden in the tract

Tracts are classified ≤ 6% affordable · 6–10% moderate · > 10% high. Tracts with fewer than k accounts, or no income on record, are suppressed (the same minimum-count rule as the maps).

Primary sources: DOE/NREL LEAD tool (tract-level burden); the 6% affordability cut point from the Home Energy Affordability Gap (Fisher, Sheehan & Colton), used by DOE/NREL LEAD and ACEEE (>10% = "severe"); US Census ACS 5-year B19013 (median household income); EIA average retail price (≈16.0¢/kWh US residential 2023). The affordable/moderate/high labels are ours; the cut points match ACEEE.

Simplified / honest scope. This is the burden from the metered commodity (this dataset's electricity or gas) — a lower bound on total home-energy burden (which sums all fuels). Income is tract-level (ACS median), so household burden varies only by consumption within a tract (the standard LEAD-style approximation, not household income). The default rate is an EIA state/US average — real deployments pass the utility's own rate. The federal Justice40 / CEJST screen was rescinded in 2025, so high-burden tracts are flagged directly from the data rather than via that (now-defunct) overlay.

3.11 Standard report & interchange formats

Answers: can the outputs drop into the formats utilities already file and exchange? (udap_engine/reports/__init__.py.) All three are emitted from the same disclosure-controlled gold cells and apply the minimum-count rule:

Primary sources: US EIA Form EIA-861; NAESB REQ.21 ESPI / Green Button; EPA ENERGY STAR SEP.

Simplified / honest scope. EIA-861 annualizes a partial year by read span; the Green Button feed carries the aggregate group total (an ADS profile), not per-customer intervals; the SEP export is the performance data — the official signable 1–100 scored SEP awaits the ENERGY STAR score.


4. Geography

Groupings and Moran's I operate on real Census tracts where a boundary layer covers the data: accounts are assigned to the tract their point falls in (point-in-polygon), using a bundled Travis County (Austin) layer for the demo and Census TIGERweb layers fetched at ingest for other regions (all public domain). Where no layer covers the data, UnitTrack falls back to a 6 × 6 square grid (GEO_GRID). Moran's rook-contiguity graph is built over whichever areas result (udap_app/core/analysis.py).


5. What is simplified or not yet done

Collected honestly in one place:


6. Citations at a glance

Method Primary source(s)
Minimum-count (k-anonymity) Sweeney (2002); ESSnet/Eurostat SDC Handbook §3
(n,k)-dominance ESSnet SDC Handbook §5.2 (Hundepool et al.)
Complementary suppression ESSnet SDC Handbook §5.4; ASA/SRMS cell-suppression overview
Re-identification risk score UnitTrack construct on Sweeney (2002) worst-case; threat basis: AMI/NILM re-id literature
Regulatory thresholds CA CPUC D.14-05-016 ("15/15"); IL Utility Data Access Act, 220 ILCS 33
Differential privacy Dwork et al. (2006); Dwork & Roth (2014); US Census 2020 DAS
Efficiency benchmarking (EUI) EPA ENERGY STAR Score Technical Reference; DOE/EIA CBECS
Peak demand / load factor NREL UMP Ch. 10; BPA M&V Peak Demand guide
Seasonal / outdoor use EPA WaterSense; AWWA; Water Research Foundation
Distribution & inequality Gini (1912); Lorenz (1905); Tukey (1977)
Spatial autocorrelation (global Moran's I) Moran (1950); Cliff & Ord (1973)
Data quality Tukey (1977)

Fuller provenance, including confidence tags (verified / sourced / gap), is in docs/RESEARCH_ANALYSIS_METRICS.md and docs/DISCLOSURE_CONTROL_DESIGN.md.