Drawdown

Methodology

How the numbers are actually computed

This site draws a hard line between what's observed, a real reading from a real station or a real regulatory record, and what's modeled: a value computed from other data, using a stated method. Every modeled number on this site comes from one of the two computations below. For each, this page shows the formula or algorithm in plain language, then the exact source file it runs from, read live from the repository, not retyped or paraphrased. Below that, the underlying data itself: live inventory counts and full CSV/JSON downloads. A transparency site should not make you scrape it.

Estimate

Estimated facility water use

Shown on each facility's page as a range of gallons per day. The formula is deliberately the simplest one that could work:

gallons_per_day_low  = capacity_mw × ratio.gallons_per_day_per_mw_low
gallons_per_day_high = capacity_mw × ratio.gallons_per_day_per_mw_high

capacity_mwis a facility's published or estimated power capacity. ratiois a published industry water-use ratio (gallons/day per MW) for that facility's cooling type, stored with its own source and source URL so it can be checked independently. If either input is missing, no estimate is shown. The result is a range, not a single precise number.

import type { WaterUseRatio } from "@/lib/facility-detail";

export type WaterUseEstimate = { low: number; high: number };

/**
 * The Pillar 1 water-use estimate formula — a facility's published capacity
 * times a published industry gal/day-per-MW ratio for its cooling type.
 * Shared between the on-page display (water-use-estimate.tsx) and the
 * monthly logging pipeline (facility-usage-log.ts) so both always agree.
 */
export function computeWaterUseEstimate(
  capacityMw: number | null,
  ratio: WaterUseRatio | null
): WaterUseEstimate | null {
  if (capacityMw == null || ratio == null) return null;
  return {
    low: capacityMw * ratio.gallons_per_day_per_mw_low,
    high: capacityMw * ratio.gallons_per_day_per_mw_high,
  };
}

export type CoolingScenario = {
  ratio: WaterUseRatio;
  low: number;
  high: number;
};

export type CapacityOnlyEstimate = {
  /** Lowest low across all cooling technologies (often 0: air cooling). */
  low: number;
  /** Highest high across all cooling technologies. */
  high: number;
  /** One row per cooling technology, sorted least to most water. */
  scenarios: CoolingScenario[];
};

/**
 * The capacity-only estimate, used when a facility's capacity is published
 * but its cooling technology is not: capacity times EVERY published ratio,
 * shown as one scenario per cooling technology. The headline range spans
 * from the least water-intensive technology's low to the most intensive
 * one's high — deliberately wide, because the width IS the finding: it is
 * exactly the uncertainty the operator could remove by disclosing a
 * cooling type. Never presented as a single number, and never averaged —
 * averaging cooling technologies would fabricate a middle no source
 * supports.
 */
export function computeCapacityOnlyEstimate(
  capacityMw: number | null,
  ratios: WaterUseRatio[]
): CapacityOnlyEstimate | null {
  if (capacityMw == null || ratios.length === 0) return null;

  const scenarios = ratios
    .map((ratio) => ({
      ratio,
      low: capacityMw * ratio.gallons_per_day_per_mw_low,
      high: capacityMw * ratio.gallons_per_day_per_mw_high,
    }))
    .sort((a, b) => a.high - b.high || a.low - b.low);

  return {
    low: Math.min(...scenarios.map((s) => s.low)),
    high: Math.max(...scenarios.map((s) => s.high)),
    scenarios,
  };
}
src/lib/water-use.ts on GitHub ↗
Estimate

Modeled water-table surface

The map's 3D surface estimates groundwater depth at points between wells, values never directly measured, using ordinary kriging, falling back to inverse-distance weighting (IDW)when there isn't enough nearby data to fit a kriging model. Both are classical geostatistical methods, not machine learning: no training, no black box, every step is a formula you can check by hand.

1. Empirical semivariogram

For every pair of wells, compute the squared difference in their readings against the distance between them:

semivariance(i, j) = 0.5 × (value_i − value_j)²

Bin those pairs by distance and average within each bin. This gives an empirical picture of how similar two wells' readings tend to be, purely as a function of how far apart they are.

2. Fit a theoretical model

A spherical and an exponential curve are both fit to the binned data via weighted least squares (bins with more well pairs count more), and whichever fits better wins. Each model has three parameters: a nugget (unexplained variance even at zero distance: measurement noise and micro-scale variation), a sill(the variance wells eventually saturate at, once they're far enough apart to be spatially uncorrelated), and a range(roughly how far “far enough apart” is). Fitting the nonlinear range parameter is done by trying a spread of candidate values and solving nugget/sill in closed form for each. This is a grid search, not an iterative optimizer, kept simple on purpose. If there aren't enough wells to fit anything meaningful, this returns nothing and the map falls back to IDW instead of kriging with a fabricated model.

3. Solve the local kriging system

For each point on the map, using only the nearby wells within the fitted range (not the full statewide network, which keeps the surface interactive and avoids far-away, uninformative wells destabilizing the solve), the classic Isaaks & Srivastava system is solved directly by Gaussian elimination:

Σⱼ λⱼ·γ(i, j) + μ = γ(i, 0)   for each neighbor i, subject to Σ λⱼ = 1

estimate = Σᵢ λᵢ · valueᵢ
variance = Σᵢ λᵢ · γ(i, 0) + μ

γ (gamma) is the fitted semivariogram from step 2, λ are the kriging weights being solved for, and μ is a Lagrange multiplier enforcing that the weights sum to 1 (an unbiasedness condition: the estimate is a proper weighted average of real readings, not an extrapolation past them). When a point is essentially on top of a well, kriging reproduces that well's exact reading, just like a real measurement.

4. Confidence and its limits

Kriging produces a real estimation variance per point, which in principle is a better confidence signal than simple distance. Measured against this well network's actual fitted model, though, the nugget consistently accounts for roughly 70% of the total variance: most of the spread between nearby wells' readings is noise, not something distance explains. A variance-based confidence would fade almost the entire map to minimum opacity, which is statistically honest but visually useless. Confidence on this site is instead based on distance to the nearest well. This is simpler and degrades smoothly the way a map reader expects. The real kriging variance is still computed and available in the code for anyone who wants the more rigorous number.

Kriging also assumes the water table's statistical behavior is consistent across the whole area being estimated (“stationarity”) and that it broadly tracks ground-surface topography, simplifications that hold better in shallow, well-connected aquifers than in deep or recharge-controlled ones. Where kriging can't run at all (too few wells, or a local neighborhood too sparse or unstable to solve), the surface falls back to inverse-distance weighting:

weight(distance) = 1 / distance²
estimate = Σ(weight × value) / Σ(weight)

IDW has no statistical basis beyond “closer wells matter more.” It is deliberately simpler and more conservative than kriging, used only when there isn't enough data to justify kriging's extra assumptions.

export type InterpolatedPoint = {
  /** Interpolated depth to water, in feet below surface. */
  depthFt: number;
  /**
   * 0-1. How much real data backs this estimate: 1 near a well with a
   * current reading, fading toward 0 far from any well. Drives the
   * surface's opacity so unsupported areas visibly fade rather than
   * looking as confident as real measurements.
   */
  confidence: number;
  /** Distance in miles to the nearest well that contributed a reading. */
  nearestWellMi: number;
};

/** A resolved reading (already "as of" some date) at a fixed location. */
export type InterpolationSample = { lat: number; lon: number; value: number };

/** Haversine-style approximation, accurate enough at Connecticut's scale. */
export function distanceMi(lat1: number, lon1: number, lat2: number, lon2: number) {
  const dLat = lat1 - lat2;
  const dLon = (lon1 - lon2) * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
  return Math.sqrt(dLat * dLat + dLon * dLon) * 69.0;
}

// Beyond this distance from every well, a point is considered pure
// extrapolation and rendered at minimum confidence rather than hidden
// outright — the fade communicates "not measured here" without a hard cliff.
const MAX_CONFIDENT_RADIUS_MI = 6;
// A near-zero floor made anything past the radius fade to almost exactly
// FADE_TOWARD_RGB in facility-map.tsx — indistinguishable from blank map.
// High enough that far areas still read as a dim tint of the real ramp
// color (a continuous graded surface), low enough that near-a-well areas
// still stand out clearly by comparison.
const MIN_CONFIDENCE = 0.38;
const IDW_POWER = 2;

/**
 * Distance-based confidence: 1 at a well, fading linearly to a floor at
 * MAX_CONFIDENT_RADIUS_MI. Shared by IDW and (as a fallback — see
 * kriging.ts) ordinary kriging, so both methods fade identically in the UI
 * regardless of which one actually produced a given point's estimate.
 */
export function distanceBasedConfidence(nearestMi: number): number {
  return Math.max(MIN_CONFIDENCE, 1 - nearestMi / MAX_CONFIDENT_RADIUS_MI);
}

/**
 * Inverse-distance-weighted interpolation — a well's own reading, blended
 * with progressively less influence from wells farther away. Deliberately
 * simple and auditable (no variogram fitting, no black-box fit) over
 * something like kriging, matching this site's "interpretable over
 * sophisticated" approach to Pillar 1.
 *
 * Takes already-resolved samples (one value per well, "as of" whatever
 * date the caller cares about) rather than raw readings history + a date:
 * resolving "as of" is a per-well, per-date operation, and doing it once
 * up front instead of inside this function keeps a surface render at
 * O(gridPoints × wells) instead of O(gridPoints × wells × readingsPerWell).
 */
export function idwInterpolate(
  targetLat: number,
  targetLon: number,
  samples: InterpolationSample[]
): InterpolatedPoint | null {
  let weightSum = 0;
  let valueSum = 0;
  let nearestMi = Infinity;

  for (const sample of samples) {
    const dist = distanceMi(targetLat, targetLon, sample.lat, sample.lon);
    if (dist < nearestMi) nearestMi = dist;

    // A well sitting essentially on the grid point dominates completely
    // rather than dividing by ~0.
    if (dist < 0.01) {
      weightSum = 1;
      valueSum = sample.value;
      nearestMi = dist;
      break;
    }

    const weight = 1 / Math.pow(dist, IDW_POWER);
    weightSum += weight;
    valueSum += weight * sample.value;
  }

  if (samples.length === 0 || weightSum === 0) return null;

  const depthFt = valueSum / weightSum;
  const confidence = distanceBasedConfidence(nearestMi);

  return { depthFt, confidence, nearestWellMi: nearestMi };
}
src/lib/interpolation.ts on GitHub ↗
Show full source: src/lib/kriging.ts
import {
  distanceMi,
  distanceBasedConfidence,
  idwInterpolate,
  type InterpolatedPoint,
} from "@/lib/interpolation";

export type InterpolationSample = { lat: number; lon: number; value: number };

export type VariogramModel = {
  type: "spherical" | "exponential";
  /** Semivariance at h=0+ (measurement/micro-scale error), feet². */
  nugget: number;
  /** Semivariance at h >= range — the total sill, feet². */
  sill: number;
  /** Miles. For the exponential model this is the "practical range" (h at
   *  which ~95% of the sill is reached), not the raw decay constant. */
  range: number;
};

export type KrigedPoint = InterpolatedPoint & {
  /** Ordinary kriging estimation variance, feet² — the real basis for
   *  `confidence`, kept separate in case it's useful on its own. */
  variance: number;
};

const NUM_LAG_BINS = 15;
const MIN_SAMPLES_TO_FIT = 10;
const MIN_BINS_TO_FIT = 4;
const NUM_RANGE_CANDIDATES = 25;

/**
 * Normalized variogram shape, 0 at h=0 rising to 1 at h >= range — the
 * nugget/sill scaling is applied by the caller so this can be shared
 * between fitting (candidate ranges) and evaluation (the fitted model).
 */
function shapeFn(h: number, range: number, type: VariogramModel["type"]): number {
  if (type === "spherical") {
    if (h >= range) return 1;
    const hr = h / range;
    return 1.5 * hr - 0.5 * hr ** 3;
  }
  // Exponential, "practical range" parameterization (matches the common
  // gstat/ArcGIS convention: g(range) ≈ 0.95, not the raw 1/e decay).
  return 1 - Math.exp((-3 * h) / range);
}

/**
 * The theoretical semivariogram, γ(h). Deliberately special-cases h≈0 to
 * return exactly 0 rather than the nugget: the nugget is a discontinuity
 * AT the origin (micro-scale variation between arbitrarily close points),
 * not the variogram's value at zero separation — the standard convention,
 * and what makes kriging an exact interpolator at a data point itself.
 */
function gammaAt(h: number, model: VariogramModel): number {
  if (h < 1e-6) return 0;
  return model.nugget + (model.sill - model.nugget) * shapeFn(h, model.range, model.type);
}

type VariogramBin = { lag: number; semivariance: number; count: number };

function computeEmpiricalVariogram(
  samples: InterpolationSample[]
): { bins: VariogramBin[]; maxLag: number } {
  const pairSquaredDiffs: { h: number; sv: number }[] = [];
  for (let i = 0; i < samples.length; i++) {
    for (let j = i + 1; j < samples.length; j++) {
      const h = distanceMi(samples[i].lat, samples[i].lon, samples[j].lat, samples[j].lon);
      if (h < 1e-6) continue; // coincident wells carry no lag information
      pairSquaredDiffs.push({ h, sv: 0.5 * (samples[i].value - samples[j].value) ** 2 });
    }
  }
  if (pairSquaredDiffs.length === 0) return { bins: [], maxLag: 0 };

  // Standard geostatistical practice: only trust the empirical variogram
  // out to roughly half the data's maximum extent — pairs get sparse and
  // the estimate gets noisy beyond that.
  const maxPairDist = pairSquaredDiffs.reduce((m, p) => Math.max(m, p.h), 0);
  const maxLag = maxPairDist * 0.5;
  const binWidth = maxLag / NUM_LAG_BINS;
  if (binWidth <= 0) return { bins: [], maxLag: 0 };

  const sums = new Array(NUM_LAG_BINS).fill(0);
  const counts = new Array(NUM_LAG_BINS).fill(0);
  for (const { h, sv } of pairSquaredDiffs) {
    if (h > maxLag) continue;
    const bin = Math.min(NUM_LAG_BINS - 1, Math.floor(h / binWidth));
    sums[bin] += sv;
    counts[bin] += 1;
  }

  const bins: VariogramBin[] = [];
  for (let b = 0; b < NUM_LAG_BINS; b++) {
    if (counts[b] === 0) continue;
    bins.push({ lag: (b + 0.5) * binWidth, semivariance: sums[b] / counts[b], count: counts[b] });
  }
  return { bins, maxLag };
}

type FittedCandidate = {
  type: VariogramModel["type"];
  nugget: number;
  sill: number;
  range: number;
  weightedSSE: number;
};

/**
 * Fits nugget/sill for a FIXED candidate range via weighted least squares.
 * For a fixed range, γ(h) = nugget·(1-g) + sill·g is linear in (nugget,
 * sill) — a plain 2-variable weighted regression, no iterative nonlinear
 * solver needed. Trying a spread of candidate ranges and keeping the best
 * (see fitModelForType) is a standard, auditable way to handle the one
 * genuinely nonlinear parameter without pulling in an optimization library.
 */
function fitNuggetSillForRange(
  bins: VariogramBin[],
  range: number,
  type: VariogramModel["type"]
): { nugget: number; sill: number } | null {
  let Sww11 = 0;
  let Sww12 = 0;
  let Sww22 = 0;
  let Swy1 = 0;
  let Swy2 = 0;
  for (const { lag, semivariance, count } of bins) {
    const g = shapeFn(lag, range, type);
    const x1 = 1 - g;
    const x2 = g;
    Sww11 += count * x1 * x1;
    Sww12 += count * x1 * x2;
    Sww22 += count * x2 * x2;
    Swy1 += count * x1 * semivariance;
    Swy2 += count * x2 * semivariance;
  }

  const det = Sww11 * Sww22 - Sww12 * Sww12;
  let nugget: number;
  let sill: number;
  if (Math.abs(det) < 1e-10) return null;
  nugget = (Swy1 * Sww22 - Swy2 * Sww12) / det;
  sill = (Sww11 * Swy2 - Sww12 * Swy1) / det;

  // Physically, nugget can't be negative and sill must exceed it. If the
  // unconstrained fit violates that, re-fit with nugget pinned to 0 (a
  // 1-variable regression for sill alone) rather than accept nonsense.
  if (nugget < 0 || sill <= nugget) {
    nugget = 0;
    let Swgg = 0;
    let Swgy = 0;
    for (const { lag, semivariance, count } of bins) {
      const g = shapeFn(lag, range, type);
      Swgg += count * g * g;
      Swgy += count * g * semivariance;
    }
    if (Swgg < 1e-10) return null;
    sill = Swgy / Swgg;
    if (sill <= 0) return null;
  }

  return { nugget, sill };
}

function fitModelForType(
  bins: VariogramBin[],
  type: VariogramModel["type"],
  maxLag: number
): FittedCandidate | null {
  const minRange = bins[0].lag;
  let best: FittedCandidate | null = null;

  for (let i = 0; i < NUM_RANGE_CANDIDATES; i++) {
    const t = i / (NUM_RANGE_CANDIDATES - 1);
    // Search up to 1.5x the fitted-lag window — the true range can exceed
    // the window we trusted for the empirical estimate itself.
    const range = minRange + t * (maxLag * 1.5 - minRange);
    if (range <= 0) continue;

    const fit = fitNuggetSillForRange(bins, range, type);
    if (!fit) continue;

    let weightedSSE = 0;
    for (const { lag, semivariance, count } of bins) {
      const predicted = fit.nugget + (fit.sill - fit.nugget) * shapeFn(lag, range, type);
      weightedSSE += count * (semivariance - predicted) ** 2;
    }

    if (!best || weightedSSE < best.weightedSSE) {
      best = { type, nugget: fit.nugget, sill: fit.sill, range, weightedSSE };
    }
  }

  return best;
}

/**
 * Fits an ordinary-kriging variogram model from real well data — spherical
 * and exponential are both tried, and whichever gives the lower weighted
 * SSE against the empirical (binned) semivariogram wins. Returns null when
 * there isn't enough data to fit anything meaningful (too few wells, or a
 * degenerate/flat empirical variogram) — callers should fall back to a
 * simpler method rather than krige with a fabricated model.
 */
export function fitVariogram(samples: InterpolationSample[]): VariogramModel | null {
  if (samples.length < MIN_SAMPLES_TO_FIT) return null;

  const { bins, maxLag } = computeEmpiricalVariogram(samples);
  if (bins.length < MIN_BINS_TO_FIT || maxLag <= 0) return null;

  const candidates = (["spherical", "exponential"] as const)
    .map((type) => fitModelForType(bins, type, maxLag))
    .filter((c): c is FittedCandidate => c !== null);

  if (candidates.length === 0) return null;

  const best = candidates.reduce((a, b) => (a.weightedSSE <= b.weightedSSE ? a : b));
  return { type: best.type, nugget: best.nugget, sill: best.sill, range: best.range };
}

const K_NEIGHBORS = 14;
// Ordinary kriging is well-posed with fewer, but the local system gets
// numerically flaky and the estimate loses meaning — below this, callers
// should fall back rather than trust the result.
const MIN_NEIGHBORS = 3;
const DUPLICATE_WELL_THRESHOLD_MI = 0.05;
// A spherical/exponential variogram is essentially saturated (γ ≈ sill)
// beyond its own range — a well at 3x the range carries the same "no
// correlation" signal as one at 30x, while making the local system worse
// conditioned (rows for mutually-saturated, far-apart wells become nearly
// identical). Bounding the neighbor search to this radius is standard
// local-kriging practice, not just a numerical patch: found empirically
// that without it, even well-supported points had kriging variance
// meeting or exceeding the sill everywhere, because the fixed "14 nearest
// regardless of distance" neighborhood was pulling in far, uninformative
// wells that destabilized the system for every query point.
const SEARCH_RADIUS_FACTOR = 1.5;

/** Gaussian elimination with partial pivoting on the augmented [A|b] system. */
function solveLinearSystem(A: number[][], b: number[]): number[] | null {
  const n = b.length;
  const M = A.map((row, i) => [...row, b[i]]);

  for (let col = 0; col < n; col++) {
    let pivotRow = col;
    let maxAbs = Math.abs(M[col][col]);
    for (let r = col + 1; r < n; r++) {
      if (Math.abs(M[r][col]) > maxAbs) {
        maxAbs = Math.abs(M[r][col]);
        pivotRow = r;
      }
    }
    if (maxAbs < 1e-10) return null; // singular — caller should handle null
    if (pivotRow !== col) {
      [M[col], M[pivotRow]] = [M[pivotRow], M[col]];
    }
    for (let r = col + 1; r < n; r++) {
      const factor = M[r][col] / M[col][col];
      for (let c = col; c <= n; c++) M[r][c] -= factor * M[col][c];
    }
  }

  const x = new Array(n).fill(0);
  for (let row = n - 1; row >= 0; row--) {
    let sum = M[row][n];
    for (let c = row + 1; c < n; c++) sum -= M[row][c] * x[c];
    x[row] = sum / M[row][row];
  }
  return x;
}

/**
 * Ordinary kriging at a single point, using a LOCAL neighborhood (up to
 * k=14 wells within SEARCH_RADIUS_FACTOR × the fitted range, not the full
 * statewide set) — solving an n×n system per query point is the expensive
 * part of kriging, and with up to ~2,500 query points per surface render,
 * keeping n small is what keeps this interactive. See facility-map.tsx /
 * this session's history for the O(gridPoints × wells × readingsPerWell)
 * bug this project already hit once with naive per-point IDW — this is
 * the same category of risk with a higher per-point cost, hence the small
 * bounded neighborhood.
 *
 * Falls back to plain IDW, per query point, when the local neighborhood
 * is too sparse (fewer than MIN_NEIGHBORS wells within range) or the
 * resulting system is singular — both are genuine "not enough real,
 * correlated data here" situations, not something a bigger or
 * differently-chosen neighborhood would fix (see SEARCH_RADIUS_FACTOR's
 * comment for why simply grabbing more distant wells makes this worse,
 * not better).
 *
 * Uses the classic Isaaks & Srivastava formulation directly in terms of
 * the semivariogram γ (no covariance conversion needed): solve
 *   Σ_j λ_j·γ(i,j) + μ = γ(i,0)  for each neighbor i, subject to Σ λ_j = 1
 * for the kriging weights λ and Lagrange multiplier μ, then
 *   estimate = Σ λ_i · value_i
 *   variance = Σ λ_i · γ(i,0) + μ
 */
export function krigeAt(
  targetLat: number,
  targetLon: number,
  samples: InterpolationSample[],
  model: VariogramModel
): KrigedPoint | null {
  if (samples.length === 0) return null;

  const withDist = samples
    .map((s) => ({ s, dist: distanceMi(targetLat, targetLon, s.lat, s.lon) }))
    .sort((a, b) => a.dist - b.dist);

  // A grid point essentially on top of a well reproduces that well's
  // reading exactly — kriging's exact-interpolator property, and the same
  // override IDW uses for coincident points.
  if (withDist[0].dist < 0.01) {
    return {
      depthFt: withDist[0].s.value,
      confidence: 1,
      nearestWellMi: withDist[0].dist,
      variance: 0,
    };
  }

  // Candidates within the search radius, deduped (two wells a few hundred
  // feet apart carry almost no independent information and can make the
  // local system numerically unstable), capped at K_NEIGHBORS.
  const searchRadiusMi = model.range * SEARCH_RADIUS_FACTOR;
  const neighbors: typeof withDist = [];
  for (const candidate of withDist) {
    if (candidate.dist > searchRadiusMi) break; // sorted ascending — nothing further qualifies either
    const isDuplicate = neighbors.some(
      (n) => distanceMi(n.s.lat, n.s.lon, candidate.s.lat, candidate.s.lon) < DUPLICATE_WELL_THRESHOLD_MI
    );
    if (isDuplicate) continue;
    neighbors.push(candidate);
    if (neighbors.length >= K_NEIGHBORS) break;
  }

  if (neighbors.length < MIN_NEIGHBORS) {
    // Not enough real, correlated data nearby for kriging to mean anything
    // here — fall back to plain IDW rather than force an unstable system
    // using far-away, uninformative wells. This is the same fallback
    // fitVariogram's null case uses at the dataset level, applied per
    // query point: genuinely undersampled areas get IDW's simpler, always-
    // graceful degradation instead of a numerically shaky kriging result.
    const idw = idwInterpolate(targetLat, targetLon, samples);
    return idw ? { ...idw, variance: model.sill } : null;
  }

  const k = neighbors.length;
  const A: number[][] = Array.from({ length: k + 1 }, () => new Array(k + 1).fill(0));
  const b: number[] = new Array(k + 1).fill(0);

  for (let i = 0; i < k; i++) {
    for (let j = 0; j < k; j++) {
      A[i][j] =
        i === j
          ? 0
          : gammaAt(
              distanceMi(neighbors[i].s.lat, neighbors[i].s.lon, neighbors[j].s.lat, neighbors[j].s.lon),
              model
            );
    }
    A[i][k] = 1;
    A[k][i] = 1;
    b[i] = gammaAt(neighbors[i].dist, model);
  }
  b[k] = 1;

  const solution = solveLinearSystem(A, b);
  if (!solution) {
    // Singular even after radius-bounding and dedup — same fallback as
    // the too-sparse case above, rather than leaving a hole in the surface.
    const idw = idwInterpolate(targetLat, targetLon, samples);
    return idw ? { ...idw, variance: model.sill } : null;
  }

  const weights = solution.slice(0, k);
  const mu = solution[k];

  let depthFt = 0;
  for (let i = 0; i < k; i++) depthFt += weights[i] * neighbors[i].s.value;

  let variance = mu;
  for (let i = 0; i < k; i++) variance += weights[i] * b[i];
  variance = Math.max(0, variance); // guard tiny negative numerical noise

  // Confidence uses the same distance-based heuristic as IDW rather than
  // variance/sill, by deliberate choice, not oversight. Measured against
  // this well network's actual fitted model, nugget/sill is consistently
  // ~0.7 — most of the semivariance is unexplained even at vanishingly
  // small separation, so γ(h) (and thus kriging variance) jumps to nearly
  // the sill within a fraction of a mile of any well. A variance-based
  // confidence would floor almost the entire surface, which is an honest
  // reflection of how noisy/weakly-correlated this data is, but unusable
  // as a smooth visual fade — most of the map would render at minimum
  // opacity regardless of well proximity. The real variance is still
  // returned below for anyone who wants the statistically rigorous value.
  const confidence = distanceBasedConfidence(withDist[0].dist);

  return { depthFt, confidence, nearestWellMi: withDist[0].dist, variance };
}
src/lib/kriging.ts on GitHub ↗

5. Forecast mode: projecting forward in time

Everything above interpolates in space, at a single moment in time — kriging itself has no notion of a trend. Forecast mode adds one, entirely upstream: each well's own reading history is projected forward to a chosen future date, and those projected values are fed through the exact same kriging code, unchanged, in place of measured ones. This is classical time-series decomposition — a median-based seasonal index plus a robust trend line — not a trained model.

For each well: its monthly-median readings are grouped by calendar month to find a typical seasonal offset (the same month-matching approach the attribution methodology uses to remove seasonality from a decline signal), then deseasonalized and fit with a Theil-Sen slope— the median of every pairwise slope between two readings, robust to outliers without an iterative solver. The projection is anchored at the well's own last real reading, not a regression intercept, so projecting to that well's own last-observed date reproduces it exactly.

A well observed for too short a span, or a horizon too far past what that span can support, is dropped from the forecast entirely rather than extrapolated indefinitely — the same “return nothing rather than fabricate” rule kriging itself follows. Each surviving well also carries a trend confidence, separate from kriging's own spatial confidence, so the map can fade a cell by two independent kinds of doubt — how well-supported the surface is in space, and how well-supported each nearby well's trend is in time — without collapsing them into one invented number.

Show full source: src/lib/forecast.ts
import { monthlyMedians, type Reading } from "@/lib/attribution/did";

/**
 * Per-well time-series projection: given a well's own reading history,
 * project forward to a future date. This is classical time-series
 * decomposition — a median-based seasonal index plus a Theil-Sen robust
 * trend line — not a trained model. It follows the same house rule as
 * kriging.ts and attribution/did.ts: return null rather than fabricate
 * a trend from too little data.
 *
 * The output is a plain depth value at a location, so it slots into
 * kriging.ts's existing InterpolationSample shape unchanged — a forecast
 * surface is just kriging fed projected values instead of measured ones.
 * This file has no dependency on, and is never imported by,
 * src/lib/attribution/* beyond reusing Reading/monthlyMedians as a type
 * and a deseasonalizing helper.
 */

const MS_PER_YEAR = 365.25 * 24 * 60 * 60 * 1000;

/**
 * Minimum distinct months of data required before fitting a trend. This
 * counts MONTHS, not raw readings — mirrors did.ts's MIN_MATCHED_MONTHS
 * reasoning: a hundred readings clustered in one week tell you nothing
 * about a multi-year trend, only how many times a well was checked.
 */
export const MIN_READINGS_TO_PROJECT = 8;
/** A "trend" fit from a record shorter than this is noise, not signal. */
export const MIN_SPAN_YEARS_TO_PROJECT = 2;
/**
 * A well observed for N years can support a projection out to roughly
 * this many multiples of N — beyond that, the well's own history simply
 * can't back the extrapolation and it's dropped rather than guessed.
 */
export const MAX_HORIZON_TO_SPAN_RATIO = 2;

function median(values: number[]): number {
  const sorted = [...values].sort((a, b) => a - b);
  const mid = Math.floor(sorted.length / 2);
  return sorted.length % 2 === 1
    ? sorted[mid]
    : (sorted[mid - 1] + sorted[mid]) / 2;
}

/** "YYYY-MM" -> fractional year, for a common numeric time axis. */
function monthKeyToYears(key: string): number {
  const year = Number(key.slice(0, 4));
  const month = Number(key.slice(5, 7));
  return year + (month - 1) / 12;
}

function yearsBetween(fromIso: string, toIso: string): number {
  return (Date.parse(toIso) - Date.parse(fromIso)) / MS_PER_YEAR;
}

export type TrendModel = {
  /** Deseasonalized trend, feet per year (positive = deepening/declining). */
  slopeFtPerYear: number;
  /** The well's own last reading — the projection's anchor point, not a
   *  regression intercept, so projecting to this exact date reproduces
   *  this exact value (the same "exact interpolator at a data point"
   *  property kriging.ts guarantees spatially). */
  anchorDate: string;
  anchorValue: number;
  /** "01".."12" -> typical deviation from this well's overall median.
   *  Months never observed are simply absent (treated as 0), not
   *  fabricated from neighboring months. */
  seasonalOffsets: Map<string, number>;
  observedSpanYears: number;
};

/**
 * Fits a well's deseasonalized trend from its own reading history.
 * Returns null when there isn't enough real data to fit anything
 * meaningful — callers should drop the well from the forecast rather
 * than krige with a fabricated trend.
 */
export function fitTrend(readings: Reading[]): TrendModel | null {
  const monthly = monthlyMedians(readings);
  if (monthly.size < MIN_READINGS_TO_PROJECT) return null;

  const entries = [...monthly.entries()].sort(([a], [b]) => (a < b ? -1 : 1));
  const times = entries.map(([key]) => monthKeyToYears(key));
  const observedSpanYears = times[times.length - 1] - times[0];
  if (observedSpanYears < MIN_SPAN_YEARS_TO_PROJECT) return null;

  const overallMedian = median(entries.map(([, v]) => v));

  // Seasonal index: each calendar month's typical deviation from this
  // well's overall median, median-based (same robustness choice did.ts
  // makes for its own per-month deltas) rather than a mean.
  const byMonthOfYear = new Map<string, number[]>();
  for (const [key, value] of entries) {
    const mm = key.slice(5, 7);
    const list = byMonthOfYear.get(mm);
    if (list) list.push(value - overallMedian);
    else byMonthOfYear.set(mm, [value - overallMedian]);
  }
  const seasonalOffsets = new Map<string, number>();
  for (const [mm, deviations] of byMonthOfYear) {
    seasonalOffsets.set(mm, median(deviations));
  }

  const deseasonalized = entries.map(
    ([key, value]) => value - (seasonalOffsets.get(key.slice(5, 7)) ?? 0)
  );

  // Theil-Sen: the median of all pairwise slopes. Same "median of
  // pairwise/bucketed quantities, no iterative solver" register as
  // did.ts's per-month deltas and kriging.ts's small fixed weighted-
  // least-squares system -- O(n^2) pairs is the same complexity class
  // as kriging's own O(wells^2) empirical variogram.
  const slopes: number[] = [];
  for (let i = 0; i < times.length; i++) {
    for (let j = i + 1; j < times.length; j++) {
      const dt = times[j] - times[i];
      if (dt < 1e-9) continue;
      slopes.push((deseasonalized[j] - deseasonalized[i]) / dt);
    }
  }
  if (slopes.length === 0) return null;
  const slopeFtPerYear = median(slopes);

  // Anchor at the well's own actual last reading (exact date/value, not
  // the last month's median) so the projection reproduces it exactly.
  const sortedReadings = [...readings].sort((a, b) => (a.date < b.date ? -1 : 1));
  const last = sortedReadings[sortedReadings.length - 1];

  return {
    slopeFtPerYear,
    anchorDate: last.date,
    anchorValue: last.value,
    seasonalOffsets,
    observedSpanYears,
  };
}

export type ForecastPoint = {
  value: number;
  horizonYears: number;
  /**
   * 0-1, purely about how much THIS WELL's own trend can be trusted at
   * this horizon -- independent of, and never folded into, kriging's
   * spatial confidence/variance (kriging.ts keeps those conceptually
   * distinct on purpose; this is a second, parallel axis of doubt, not
   * folded into either).
   */
  trendConfidence: number;
};

/**
 * Projects a fitted trend to a target date. Returns null when the
 * requested horizon exceeds what this well's own observed span can
 * support -- MAX_HORIZON_TO_SPAN_RATIO times its observed span -- rather
 * than extrapolating indefinitely from a short record.
 */
export function projectReading(
  model: TrendModel,
  targetDate: string
): ForecastPoint | null {
  const horizonYears = yearsBetween(model.anchorDate, targetDate);
  const maxHorizon = MAX_HORIZON_TO_SPAN_RATIO * model.observedSpanYears;
  if (Math.abs(horizonYears) > maxHorizon) return null;

  const targetMonth = targetDate.slice(5, 7);
  const anchorMonth = model.anchorDate.slice(5, 7);
  const seasonalDelta =
    (model.seasonalOffsets.get(targetMonth) ?? 0) -
    (model.seasonalOffsets.get(anchorMonth) ?? 0);

  const value = model.anchorValue + model.slopeFtPerYear * horizonYears + seasonalDelta;
  const trendConfidence = maxHorizon > 0 ? 1 - Math.abs(horizonYears) / maxHorizon : 0;

  return { value, horizonYears, trendConfidence };
}

/** Convenience: fitTrend + projectReading, null-propagating. */
export function projectWellSeries(
  readings: Reading[],
  targetDate: string
): ForecastPoint | null {
  const model = fitTrend(readings);
  return model ? projectReading(model, targetDate) : null;
}

/** Stable key for matching the same well between two InterpolationSample
 *  arrays (e.g. current vs. forecast values) by coordinate. */
export function wellKey(lat: number, lon: number): string {
  return `${lat},${lon}`;
}

/** A "range" from a single well can't express disagreement between wells. */
export const MIN_WELLS_FOR_SPREAD = 2;

export type ForecastSpread = {
  rangeLowFt: number;
  rangeHighFt: number;
  contributingWells: number;
};

/**
 * The spread of individual near wells' own projected change at a point --
 * NOT the single kriged/IDW point estimate, but how much the raw
 * contributing wells disagree with each other, matched to their current
 * readings by lat/lon. A real, auditable number (map-view.tsx's hover
 * readout shows it as "wells individually project X to Y ft"), not a
 * fabricated confidence interval. Returns null below MIN_WELLS_FOR_SPREAD
 * matched wells, same "nothing rather than invent a number" rule as the
 * rest of this file.
 */
export function computeForecastSpread(
  nearbyForecastWells: { lat: number; lon: number; forecastDepthFt: number }[],
  currentDepthByKey: Map<string, number>
): ForecastSpread | null {
  const changes: number[] = [];
  for (const well of nearbyForecastWells) {
    const currentDepthFt = currentDepthByKey.get(wellKey(well.lat, well.lon));
    if (currentDepthFt == null) continue;
    changes.push(well.forecastDepthFt - currentDepthFt);
  }
  if (changes.length < MIN_WELLS_FOR_SPREAD) return null;

  return {
    rangeLowFt: Math.min(...changes),
    rangeHighFt: Math.max(...changes),
    contributingWells: changes.length,
  };
}
src/lib/forecast.ts on GitHub ↗
Assessment

Attribution evidence score

The site's third data class asks the question the project exists to ask: is the water table's behavior near a facility consistent with that facility's estimated draw — and how strong is the evidence? The answer is a 0–100 rubric, not a probability: a deterministic sum of published sub-scores computed from auditable inputs, never shown without its component breakdown. Missing data can cap the score; it can never inflate it. Every monthly run is stored with its inputs, well sets, windows, and method version, so old scores stay reproducible. Facility-page prose is assembled from the locked templates in the source below — free-form causal language is structurally impossible.

As of today, across all 16 tracked facilities:

4
No local pathway
7
Not yet operating
5
Data-limited (score withheld)
0
Scored

Gate 1 — Pathway (0–35 points)

A facility can only draw down the local water table if it withdraws water locally. A confirmed on-site groundwater withdrawal scores 35; a claimed but unverified one 20; an unknown source scores 0 and caps the whole score at 25; a municipal, surface-water, or closed-loop supply ends the assessment with no local score at all — that facility's estimated draw lands somewhere real, but not on this water table. No tracked facility has a confirmed on-site well today: most remain capped under the unknown-source ceiling, and the few with a published municipal or surface-water source instead end their assessment with no local score at all, for the same reason.

Gate 2 — Detectability (0–15 points)

Before asking whether a drawdown signal exists, compute whether the estimated draw could produce one at the nearest monitored well — a Theis order-of-magnitude bound, not a groundwater model:

s(r) = (Q / 4πT) · W(u),   u = r²S / (4Tt)

An expected signal of at least 1 ft scores 15; 0.2–1 ft scores 8 (marginal); under 0.2 ft scores 0 and caps Gate 3 at 10 points, because “not detectable” is the expected outcome — for most facilities the honest sentence is that even at the high estimate, the draw would be invisible at the nearest well, and indeed no signal is present. Transmissivity and storativity assumptions are pinned in the source below and stored on every run.

Gate 3 — Signal (0–50 points)

A difference-in-differences comparison on monthly-median depth to water, matched month-of-year to month-of-year to remove seasonality: wells within 2 miles of the facility against background wells 5–15 miles away with comparable baseline depth, across the assessment's baseline and post-anchor windows.

ΔDiD = (near_post − near_base) − (control_post − control_base)   [ft, + = relative decline]

Temporal points (0–30) scale with a placebo permutation test — how often random control subsets produce a pseudo-signal as large — and with physical consistency against the Gate-2 bound: a “signal” far larger than the estimate could produce scores lower, because it implicates something other than the facility. Robustness points (0–20) come from leave-one-well-out stability, well count, record completeness, and the precipitation record: any candidate decline during a >1σ dry anomaly is flagged drought-confounded and its temporal points are zeroed. Fewer than 3 usable controls, or no usable pre-anchor baseline, withholds the score entirely. Facilities not yet operating are labeled “baseline period — no attribution possible yet”: absence of operation is not evidence about the facility.

/**
 * The attribution rubric: three sequential gates, each able to zero-out or
 * cap everything downstream (docs/attribution-methodology.md). The score
 * is a RUBRIC, not a probability — a deterministic sum of published
 * sub-scores from auditable inputs. Missing data can cap it, never
 * inflate it. Prose shown on facility pages comes ONLY from the locked
 * templates at the bottom of this file, keyed by grade: free-form causal
 * language is structurally impossible.
 */

export type WaterSourceType =
  | "on_site_well"
  | "municipal"
  | "surface_water"
  | "closed_loop_minimal"
  | "unknown";

export type PathwayEvidence = "permit_confirmed" | "claimed_unverified" | null;

export type SignalInputs = {
  /** ΔDiD in feet; positive = decline near the facility vs. controls. */
  didDeltaFt: number | null;
  permutationP: number | null;
  /** Precip anomaly of treatment window in σ; null = no usable record. */
  precipAnomalySigma: number | null;
  nearWellCount: number;
  controlCount: number;
  /** Fraction of window-months with data at the median near well (0–1). */
  coverage: number;
  /** Leave-one-out max deviation in ft; null with < 2 near wells. */
  leaveOneOutDeviationFt: number | null;
};

export type ScoreInputs = {
  waterSourceType: WaterSourceType;
  pathwayEvidence: PathwayEvidence;
  /** Only 'operating' facilities get a full Gate-3 run. */
  operating: boolean;
  /** False when no dated milestone or year_built anchors the windows. */
  hasAnchor: boolean;
  /** Null when no estimate exists (no published capacity). */
  expectedDrawdownMidFt: number | null;
  expectedDrawdownHighFt: number | null;
  detectabilityPoints: number;
  gate3Cap: number;
  signal: SignalInputs | null;
  /** Estimate quality: basis + whether the ratio range spans more than 3x. */
  estimateBasis: "capacity_and_cooling" | "capacity_range_all_cooling" | null;
  ratioSpansOver3x: boolean;
};

export type Grade =
  | "no_pathway"
  | "baseline_only"
  | "insufficient_data"
  | "insufficient_controls"
  | "no_evidence"
  | "weak"
  | "moderate"
  | "strong"
  | "very_strong";

export type Flag =
  | "drought_confounded"
  | "insufficient_controls"
  | "sparse_treatment_wells"
  | "estimate_uncertain"
  | "no_estimate"
  | "no_precip_record"
  | "no_near_wells"
  | "no_anchor_date"
  | "insufficient_baseline";

export type ScoreResult = {
  pathwayPoints: number;
  detectabilityPoints: number;
  signalPoints: number;
  robustnessPoints: number;
  /** Null when the grade withholds a score entirely. */
  score: number | null;
  grade: Grade;
  flags: Flag[];
};

export const PATHWAY_MAX = 35;
export const DETECTABILITY_MAX = 15;
export const SIGNAL_MAX = 30;
export const ROBUSTNESS_MAX = 20;

/** An unknown water source caps the whole score here. */
export const UNKNOWN_PATHWAY_CAP = 25;
/** Below this ΔDiD (ft) a "signal" is treated as noise, not evidence. */
export const NOISE_FLOOR_FT = 0.05;
export const MIN_CONTROLS = 3;

export function pathwayPoints(
  source: WaterSourceType,
  evidence: PathwayEvidence
): number {
  if (source !== "on_site_well") return 0;
  if (evidence === "permit_confirmed") return 35;
  if (evidence === "claimed_unverified") return 20;
  return 0;
}

/**
 * Temporal-signal points (0–30). Scales with (a) how unlikely the ΔDiD is
 * under the placebo distribution and (b) whether its size is physically
 * consistent with the Gate-2 expectation — a "signal" far larger than the
 * estimate could produce scores LOWER, because it implicates something
 * other than the facility.
 */
export function signalPoints(
  didDeltaFt: number | null,
  permutationP: number | null,
  expectedHighFt: number | null
): number {
  if (didDeltaFt == null || permutationP == null) return 0;
  if (didDeltaFt <= NOISE_FLOOR_FT) return 0; // no decline, or within noise

  const significance =
    permutationP <= 0.05 ? 1.0
    : permutationP <= 0.1 ? 0.7
    : permutationP <= 0.2 ? 0.4
    : 0;
  if (significance === 0) return 0;

  let consistency = 1.0;
  if (expectedHighFt != null && expectedHighFt > 0) {
    const ratio = didDeltaFt / expectedHighFt;
    if (ratio > 5) consistency = 0.2;
    else if (ratio > 2) consistency = 0.6;
  }

  return Math.round(SIGNAL_MAX * significance * consistency);
}

/** Robustness points (0–20): stability + well count + coverage + confounds. */
export function robustnessPoints(signal: SignalInputs): {
  points: number;
  flags: Flag[];
} {
  const flags: Flag[] = [];
  let points = 0;

  // Leave-one-out stability (0–8) — only meaningful with ≥ 2 near wells.
  if (signal.leaveOneOutDeviationFt != null && signal.didDeltaFt != null) {
    const reference = Math.max(Math.abs(signal.didDeltaFt), 0.1);
    if (signal.leaveOneOutDeviationFt <= 0.25 * reference) points += 8;
    else if (signal.leaveOneOutDeviationFt <= 0.5 * reference) points += 4;
  }

  // Well count (0–4).
  if (signal.nearWellCount >= 3) points += 4;
  else if (signal.nearWellCount === 2) points += 2;
  if (signal.nearWellCount < 3) flags.push("sparse_treatment_wells");

  // Record completeness (0–4).
  if (signal.coverage >= 0.6) points += 4;
  else if (signal.coverage >= 0.3) points += 2;

  // Confound check (0–4): a candidate decline during a >1σ dry anomaly is
  // automatically flagged; an unknown precip record earns nothing.
  if (signal.precipAnomalySigma == null) {
    flags.push("no_precip_record");
  } else if (signal.precipAnomalySigma <= -1) {
    if ((signal.didDeltaFt ?? 0) > NOISE_FLOOR_FT) flags.push("drought_confounded");
  } else {
    points += 4;
  }

  return { points: Math.min(points, ROBUSTNESS_MAX), flags };
}

const SCORE_BANDS: { min: number; grade: Grade }[] = [
  { min: 85, grade: "very_strong" },
  { min: 65, grade: "strong" },
  { min: 40, grade: "moderate" },
  { min: 15, grade: "weak" },
  { min: 0, grade: "no_evidence" },
];

export function computeAttributionScore(inputs: ScoreInputs): ScoreResult {
  const flags: Flag[] = [];
  const pathway = pathwayPoints(inputs.waterSourceType, inputs.pathwayEvidence);

  if (
    inputs.estimateBasis !== "capacity_and_cooling" ||
    inputs.ratioSpansOver3x
  ) {
    flags.push("estimate_uncertain");
  }
  if (inputs.estimateBasis == null) flags.push("no_estimate");

  // Gate 1 hard exit: a facility whose water demonstrably lands somewhere
  // other than the local water table gets no LOCAL attribution score at
  // all. Its systemic impact is real — the template says so — but scoring
  // it here would claim evidence the framework cannot have.
  if (
    inputs.waterSourceType === "municipal" ||
    inputs.waterSourceType === "surface_water" ||
    inputs.waterSourceType === "closed_loop_minimal"
  ) {
    return {
      pathwayPoints: 0,
      detectabilityPoints: inputs.detectabilityPoints,
      signalPoints: 0,
      robustnessPoints: 0,
      score: null,
      grade: "no_pathway",
      flags,
    };
  }

  // Pre-operational: absence of operation is not evidence about the
  // facility — the score is withheld, not zero.
  if (!inputs.operating) {
    return {
      pathwayPoints: pathway,
      detectabilityPoints: inputs.detectabilityPoints,
      signalPoints: 0,
      robustnessPoints: 0,
      score: null,
      grade: "baseline_only",
      flags,
    };
  }

  // An operating facility with no dated anchor (no milestone, no
  // year_built) has no baseline/treatment windows to compare — a distinct
  // failure from having no nearby wells, and flagged as such.
  const signal = inputs.signal;
  if (!inputs.hasAnchor) {
    flags.push("no_anchor_date");
    if (signal == null || signal.nearWellCount === 0) flags.push("no_near_wells");
    return {
      pathwayPoints: pathway,
      detectabilityPoints: inputs.detectabilityPoints,
      signalPoints: 0,
      robustnessPoints: 0,
      score: null,
      grade: "insufficient_data",
      flags,
    };
  }

  if (signal == null || signal.nearWellCount === 0) {
    flags.push("no_near_wells");
    return {
      pathwayPoints: pathway,
      detectabilityPoints: inputs.detectabilityPoints,
      signalPoints: 0,
      robustnessPoints: 0,
      score: null,
      grade: "insufficient_data",
      flags,
    };
  }

  if (signal.controlCount < MIN_CONTROLS) {
    flags.push("insufficient_controls");
    return {
      pathwayPoints: pathway,
      detectabilityPoints: inputs.detectabilityPoints,
      signalPoints: 0,
      robustnessPoints: 0,
      score: null,
      grade: "insufficient_controls",
      flags,
    };
  }

  if (signal.didDeltaFt == null) {
    // Near wells exist but no usable baseline/treatment overlap.
    flags.push("insufficient_baseline");
    return {
      pathwayPoints: pathway,
      detectabilityPoints: inputs.detectabilityPoints,
      signalPoints: 0,
      robustnessPoints: 0,
      score: null,
      grade: "insufficient_data",
      flags,
    };
  }

  const temporal = signalPoints(
    signal.didDeltaFt,
    signal.permutationP,
    inputs.expectedDrawdownHighFt
  );
  const robustness = robustnessPoints(signal);
  flags.push(...robustness.flags);

  // A drought-confounded "signal" cannot count as temporal evidence.
  const droughtConfounded = flags.includes("drought_confounded");
  let gate3 = (droughtConfounded ? 0 : temporal) + robustness.points;
  gate3 = Math.min(gate3, inputs.gate3Cap);

  let score = pathway + inputs.detectabilityPoints + gate3;
  if (inputs.waterSourceType === "unknown") {
    score = Math.min(score, UNKNOWN_PATHWAY_CAP);
  }

  let grade = SCORE_BANDS.find((band) => score >= band.min)!.grade;
  // very_strong is reserved: it additionally requires a confirmed pathway,
  // a clear placebo test, and clean confound checks.
  if (
    grade === "very_strong" &&
    !(pathway === PATHWAY_MAX && (signal.permutationP ?? 1) <= 0.05 && flags.length === 0)
  ) {
    grade = "strong";
  }

  return {
    pathwayPoints: pathway,
    detectabilityPoints: inputs.detectabilityPoints,
    signalPoints: droughtConfounded ? 0 : temporal,
    robustnessPoints: robustness.points,
    score,
    grade,
    flags,
  };
}

/**
 * The ONLY sentences the UI may render as a conclusion. Keyed by grade;
 * assembled, never edited, at display time.
 */
export const CONCLUSION_TEMPLATES: Record<Grade, string> = {
  no_pathway:
    "No local withdrawal pathway — estimated draw does not reach this water table directly.",
  baseline_only: "Facility not yet operating; baseline being recorded.",
  insufficient_data:
    "The monitoring record around this facility is too sparse to assess.",
  insufficient_controls:
    "Too few comparable background wells to run a controlled comparison.",
  no_evidence:
    "No evidence linking this facility to local water-table change.",
  weak: "Weak evidence; observed changes are within regional variability.",
  moderate:
    "Moderate evidence; a local decline consistent in size with the estimate, not explained by regional controls.",
  strong: "Strong evidence of estimate-consistent local drawdown.",
  very_strong:
    "Very strong evidence: confirmed withdrawal pathway, a clear local decline relative to controls, and clean confound checks.",
};

/**
 * Short grade labels for compact display (a facility list/scoreboard),
 * as opposed to CONCLUSION_TEMPLATES' full locked sentences. Withheld
 * grades (score null) get a plain-language reason rather than a jargon
 * name, since today every tracked facility falls in that group.
 */
export const GRADE_LABELS: Record<Grade, string> = {
  no_pathway: "No local pathway",
  baseline_only: "Not yet operating",
  insufficient_data: "Insufficient data",
  insufficient_controls: "Insufficient controls",
  no_evidence: "No evidence",
  weak: "Weak",
  moderate: "Moderate",
  strong: "Strong",
  very_strong: "Very strong",
};

/** Locked pathway sentences — Gate 1 rendered, keyed by classification. */
export const PATHWAY_STATEMENTS: Record<WaterSourceType, string> = {
  on_site_well:
    "This facility withdraws groundwater on site — its estimated draw acts on the local water table.",
  municipal:
    "This facility is supplied by a public water system — its estimated draw lands on that system's sources, not this local water table.",
  surface_water:
    "This facility withdraws surface water — its estimated draw acts on a river or pond, not this local water table.",
  closed_loop_minimal:
    "This facility's cooling is closed-loop with minimal water draw.",
  unknown:
    "The operator has not disclosed where this facility's water comes from. Until a source is published, the assessment is capped: an unclassified pathway can never raise the score.",
};

/**
 * Locked detectability sentence forms. The numbers are computed; the
 * sentences are not.
 */
export function detectabilityStatement(
  expectedMidFt: number | null,
  nearestWellMi: number | null
): string {
  if (expectedMidFt == null || nearestWellMi == null) {
    return "Expected drawdown at the nearest monitored well could not be computed (no water-use estimate is available).";
  }
  const distance = nearestWellMi.toFixed(1);
  if (expectedMidFt >= 1.0) {
    return `At the high end of the estimate, the draw should produce a clearly visible signal (~${expectedMidFt.toFixed(1)} ft) at the nearest monitored well ${distance} mi away.`;
  }
  if (expectedMidFt >= 0.2) {
    return `At the high end of the estimate, the expected signal at the nearest monitored well ${distance} mi away is marginal (~${expectedMidFt.toFixed(1)} ft) and may be lost in noise.`;
  }
  return `Even at the high end of the estimate, the draw would be invisible (under 0.2 ft) at the nearest monitored well ${distance} mi away — "not detectable" is the expected outcome.`;
}

export const FLAG_LABELS: Record<Flag, string> = {
  drought_confounded: "Drought-confounded window",
  insufficient_controls: "Too few control wells",
  sparse_treatment_wells: "Sparse treatment wells",
  estimate_uncertain: "Estimate inputs uncertain",
  no_estimate: "No water-use estimate available",
  no_precip_record: "No usable precipitation record",
  no_near_wells: "No monitored well within 2 miles",
  no_anchor_date: "No dated operational milestone",
  insufficient_baseline: "No usable pre-anchor baseline",
};
src/lib/attribution/score.ts on GitHub ↗
Show full source: src/lib/attribution/detectability.ts
/**
 * Gate 2 of the attribution framework: BEFORE asking whether a drawdown
 * signal exists near a facility, compute whether the facility's estimated
 * draw COULD produce a visible one at the nearest monitored well. A
 * Theis/Cooper–Jacob order-of-magnitude bound — not a groundwater model:
 *
 *   s(r) = (Q / 4πT) · W(u),   u = r²S / (4Tt)
 *
 * where Q is the pumping rate, T transmissivity, S storativity, r the
 * distance to the observation point, t elapsed pumping time, and W(u) the
 * Theis well function (the exponential integral E₁(u)).
 *
 * Assumptions are pinned here and stored on every assessment row, so old
 * runs stay reproducible when they change. Transmissivity defaults are the
 * glacial-stratified-drift range from the design document
 * (docs/attribution-methodology.md §Gate 2) — the aquifer setting of the
 * high-yield valley-fill deposits USGS observation wells in this network
 * sit in. Till/bedrock (T ≈ 10–1,000 ft²/day) would predict LARGER
 * drawdown at the same Q; using the drift range is the conservative
 * choice, since a smaller expected signal caps Gate 3 harder (the score
 * can be capped by assumptions, never inflated by them).
 */

export type DetectabilityAssumptions = {
  /** ft²/day — low end of assumed transmissivity range. */
  transmissivityLowFt2PerDay: number;
  /** ft²/day — high end of assumed transmissivity range. */
  transmissivityHighFt2PerDay: number;
  /** Dimensionless specific yield / storativity (unconfined ≈ 0.05–0.2). */
  storativity: number;
};

export const DEFAULT_ASSUMPTIONS: DetectabilityAssumptions = {
  transmissivityLowFt2PerDay: 1_000,
  transmissivityHighFt2PerDay: 20_000,
  storativity: 0.1,
};

const GALLONS_PER_CUBIC_FOOT = 7.48052;
const EULER_MASCHERONI = 0.5772156649015329;

/**
 * Theis well function W(u) = E₁(u), the exponential integral.
 * u ≤ 1: the standard convergent series W(u) = −γ − ln u + Σ (−1)ⁿ⁺¹ uⁿ/(n·n!).
 * u > 1: the Abramowitz & Stegun 5.1.56 rational approximation (max
 * relative error well under 1% — more than enough for an
 * order-of-magnitude screen).
 */
export function wellFunction(u: number): number {
  if (!(u > 0)) return Infinity;
  if (u <= 1) {
    let sum = -EULER_MASCHERONI - Math.log(u);
    let term = 1;
    for (let n = 1; n <= 40; n++) {
      term *= -u / n;
      const contribution = -term / n;
      sum += contribution;
      if (Math.abs(contribution) < 1e-12) break;
    }
    return sum;
  }
  const numerator = u * u + 2.334733 * u + 0.250621;
  const denominator = u * u + 3.330657 * u + 1.681534;
  return (Math.exp(-u) / u) * (numerator / denominator);
}

/** Theis drawdown, in feet, at distance r after t days of pumping at Q. */
export function expectedDrawdownFt(params: {
  pumpingGalPerDay: number;
  distanceFt: number;
  elapsedDays: number;
  transmissivityFt2PerDay: number;
  storativity: number;
}): number {
  const {
    pumpingGalPerDay,
    distanceFt,
    elapsedDays,
    transmissivityFt2PerDay: t,
    storativity,
  } = params;
  if (pumpingGalPerDay <= 0 || distanceFt <= 0 || elapsedDays <= 0 || t <= 0) {
    return 0;
  }
  const q = pumpingGalPerDay / GALLONS_PER_CUBIC_FOOT; // ft³/day
  const u = (distanceFt * distanceFt * storativity) / (4 * t * elapsedDays);
  return (q / (4 * Math.PI * t)) * wellFunction(u);
}

export type DetectabilityResult = {
  /** Expected drawdown at the HIGH transmissivity bound (smallest signal). */
  expectedLowFt: number;
  /** Expected drawdown at the LOW transmissivity bound (largest signal). */
  expectedHighFt: number;
  /** At the geometric-mean transmissivity — the value the points use. */
  expectedMidFt: number;
  points: number;
  /** Max points Gate 3 may contribute given this expectation. */
  gate3Cap: number;
  assumptions: DetectabilityAssumptions;
};

export const DETECTABILITY_MAX_POINTS = 15;
/** Below this expected drawdown, "not detectable" is the expected outcome. */
export const DETECTABLE_FT = 1.0;
export const MARGINAL_FT = 0.2;
/** Gate 3 cap when the expected signal is below MARGINAL_FT. */
export const UNDETECTABLE_GATE3_CAP = 10;
export const GATE3_MAX_POINTS = 50;

/**
 * Gate 2, per the rubric: points come from the estimate's HIGH end at the
 * middle (geometric mean) of the transmissivity range; the low/high bounds
 * are stored alongside so the assumption's spread is always visible.
 */
export function assessDetectability(params: {
  pumpingGalPerDayHigh: number;
  nearestWellDistanceFt: number;
  elapsedDays: number;
  assumptions?: DetectabilityAssumptions;
}): DetectabilityResult {
  const assumptions = params.assumptions ?? DEFAULT_ASSUMPTIONS;
  const { transmissivityLowFt2PerDay: tLow, transmissivityHighFt2PerDay: tHigh } =
    assumptions;
  const tMid = Math.sqrt(tLow * tHigh);

  const at = (t: number) =>
    expectedDrawdownFt({
      pumpingGalPerDay: params.pumpingGalPerDayHigh,
      distanceFt: params.nearestWellDistanceFt,
      elapsedDays: params.elapsedDays,
      transmissivityFt2PerDay: t,
      storativity: assumptions.storativity,
    });

  const expectedMidFt = at(tMid);
  const points =
    expectedMidFt >= DETECTABLE_FT ? DETECTABILITY_MAX_POINTS
    : expectedMidFt >= MARGINAL_FT ? 8
    : 0;

  // Transient Theis drawdown is NOT monotonic in T: low T raises the
  // Q/4πT coefficient but also slows the cone's outward propagation
  // (larger u), so which T bound produces more drawdown at a fixed
  // distance and time depends on both. Report the envelope, not the
  // endpoints.
  const endpoints = [at(tLow), at(tMid), at(tHigh)];

  return {
    expectedLowFt: Math.min(...endpoints),
    expectedHighFt: Math.max(...endpoints),
    expectedMidFt,
    points,
    gate3Cap: expectedMidFt < MARGINAL_FT ? UNDETECTABLE_GATE3_CAP : GATE3_MAX_POINTS,
    assumptions,
  };
}
src/lib/attribution/detectability.ts on GitHub ↗
Observed

Data inventory & downloads

What this site currently holds, how fresh it is, and full CSV/JSON downloads of everything above and below. Counts and dates here are queried live, not maintained by hand.

Observed variables

VariableSourceReadingsLatest reading
Groundwater levelUSGS59,775Jun 29, 2026
PrecipitationNOAA29,918Jul 17, 2026
StreamflowUSGS168,380Jul 19, 2026

In the catalog but not yet wired to a live source: Heat index, Drought index, Noise level. They appear here the day they have real data, not before.

Regulatory records

  • 1,731 CT DEEP water diversion permit records, source list dated Nov 26, 2025
  • 167 MassDEP Water Management Act permit records, source list dated Jul 23, 2026
  • 49 EPA ECHO compliance records, last queried Jul 23, 2026

Estimate history

319 months of monthly water-use estimate snapshots, latest Jul 19, 2026. Each snapshot records the estimate and the exact inputs used to produce it, so past numbers stay checkable even after facility data changes, including months where an estimate could not be produced at all.

Downloads

  • Tracked facilities: every field, including capacity/cooling source URLsCSV · JSON
  • Lifecycle milestones: every dated event for every facility, each with its own sourceCSV · JSON
  • Monitoring stations: every tracked USGS/NOAA station and the facility it's linked toCSV · JSON
  • Water-use estimate history: every monthly snapshot with its inputs and ratio sourceCSV · JSON
  • Attribution assessment history: every score with all four components, DiD statistics, assumptions, and flagsCSV · JSON
  • CT DEEP water diversion permits confirmed to a tracked facilityCSV · JSON
  • MassDEP Water Management Act permits confirmed to a tracked facilityCSV · JSON
  • EPA ECHO compliance records confirmed to a tracked facilityCSV · JSON
  • Regional streamflow gauges not linked to any facility, monthly medians (shown on the map)CSV · JSON

Observed readings, per facility (full resolution)

  • Androscoggin Mill Data CenterCSV · JSON
  • Aphorio Carter – TrumbullCSV · JSON
  • Atlas Capital Group – BloomfieldCSV · JSON
  • Charter Development – WallingfordCSV · JSON
  • CyrusOne – NorwalkCSV · JSON
  • Equinix BO2 – BillericaCSV · JSON
  • Gray Wolf Data Centers – BristolCSV · JSON
  • Markley Group – LowellCSV · JSON
  • Markley Group – One Summer StreetCSV · JSON
  • NE Edge – KillinglyCSV · JSON
  • NE Edge – Waterford (Millstone)CSV · JSON
  • O&G Industries – Beacon FallsCSV · JSON
  • Sanford Woods Industrial and Technical CampusCSV · JSON
  • Smithfield Data CenterCSV · JSON
  • TierPoint – WaterburyCSV · JSON
  • Westfield Data Center CampusCSV · JSON

Machine-readable freshness (for uptime monitors): /api/health. Returns HTTP 503 whenever any live-sourced variable has gone stale, so any external pinger doubles as an ingestion-failure alarm.

Looking for how the raw data itself is sourced, rather than how it's modeled? See the data-sourcing section on the home page.