105 lines
4.8 KiB
JavaScript
105 lines
4.8 KiB
JavaScript
/**
|
||
* hailProbability.js — conservative historical hail-risk model (spec §6).
|
||
*
|
||
* IMPORTANT framing (spec §6): the output is a HISTORICAL hazard estimate for the
|
||
* surrounding area, NOT a property-level forecast or guarantee. The model computes
|
||
* a RANGE (confidence band), not one overconfident number, and stores every
|
||
* assumption so the report can be defended.
|
||
*
|
||
* lambda (λ) = deduped hail events / years
|
||
* P(at least one hail event over N years) = 1 - e^(-λN)
|
||
*
|
||
* Pure function — no I/O. Fed by /api/storm-history (existing storm module, used
|
||
* read-only). Returns the hail_risk_snapshot shape (spec §12).
|
||
*/
|
||
|
||
function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }
|
||
function pct(n) { return Math.round(n * 100); }
|
||
|
||
/**
|
||
* @param {object} input
|
||
* eventCount {number} deduped hail events observed in the window
|
||
* windowYears {number} historical window analyzed
|
||
* radiusMiles {number} search radius around the property
|
||
* geocodeConfidence {'high'|'moderate'|'low'}
|
||
* maxHailInches {number|null}
|
||
* lastEventDate {string|null} ISO date of most recent hail event
|
||
* source {string} human-readable data source label
|
||
* generatedAt {string} ISO timestamp the snapshot was frozen
|
||
*/
|
||
export function computeHailRisk(input) {
|
||
const {
|
||
eventCount = 0,
|
||
windowYears = 5,
|
||
radiusMiles = 20,
|
||
geocodeConfidence = 'moderate',
|
||
maxHailInches = null,
|
||
lastEventDate = null,
|
||
source = 'NOAA / IEM Local Storm Reports',
|
||
generatedAt = new Date().toISOString(),
|
||
} = input || {};
|
||
|
||
const years = Math.max(1, windowYears);
|
||
const count = Math.max(0, eventCount);
|
||
|
||
// Annualized rate (events/year).
|
||
const lambda = count / years;
|
||
|
||
// Poisson confidence band using event-count standard error (√count).
|
||
const se = Math.sqrt(count);
|
||
const lambdaLow = Math.max(0, (count - se) / years);
|
||
const lambdaHigh = (count + se) / years;
|
||
|
||
const prob = (lam, n) => 1 - Math.exp(-lam * n);
|
||
const p10Base = prob(lambda, 10);
|
||
const p10Low = prob(lambdaLow, 10);
|
||
const p10High = prob(lambdaHigh, 10);
|
||
|
||
// Never present a 100% historical probability — that reads as a certainty/guarantee,
|
||
// which this model explicitly is not (spec §6). High-activity areas push p10High to
|
||
// ~1.0, so cap the DISPLAYED band just under certainty. Underlying p10* values still
|
||
// drive the exposure tier and scoring unchanged.
|
||
const MAX_DISPLAY_PROB = 0.95;
|
||
const dispLow = Math.min(p10Low, MAX_DISPLAY_PROB);
|
||
const dispBase = Math.min(p10Base, MAX_DISPLAY_PROB);
|
||
const dispHigh = Math.min(p10High, MAX_DISPLAY_PROB);
|
||
|
||
// Confidence score (0..1): sample size + window length + geocode precision.
|
||
const sampleScore = clamp(count / 8, 0, 1); // ~8+ events → strong
|
||
const windowScore = clamp(years / 10, 0, 1); // 10y window → full
|
||
const geoScore = geocodeConfidence === 'high' ? 1 : geocodeConfidence === 'moderate' ? 0.6 : 0.3;
|
||
const confidenceValue = +(0.4 * sampleScore + 0.3 * windowScore + 0.3 * geoScore).toFixed(2);
|
||
const confidenceLabel = confidenceValue >= 0.66 ? 'High' : confidenceValue >= 0.4 ? 'Moderate' : 'Low';
|
||
|
||
// Coarse exposure tier used downstream by the scoring engine (Phase 4).
|
||
const exposureTier = p10Base >= 0.66 ? 'high' : p10Base >= 0.33 ? 'moderate' : 'low';
|
||
|
||
return {
|
||
// hail_risk_snapshot fields (spec §12)
|
||
source,
|
||
windowYears: years,
|
||
radiusMiles,
|
||
eventCount: count,
|
||
annualRate: +lambda.toFixed(3),
|
||
maxHailInches,
|
||
lastEventDate,
|
||
probability10yr: { low: pct(dispLow), base: pct(dispBase), high: pct(dispHigh) },
|
||
confidence: confidenceLabel,
|
||
confidenceValue,
|
||
exposureTier,
|
||
generatedAt,
|
||
|
||
// Customer-facing wording (spec §6) — historical, never a forecast.
|
||
wording: {
|
||
annualRate: count === 0
|
||
? 'No qualifying hail events were found in the available reports for this area.'
|
||
: `This area has averaged about ${lambda.toFixed(1)} observed hail event(s) per year in the available dataset.`,
|
||
probability: count === 0
|
||
? 'There is not enough historical hail activity nearby to estimate a meaningful probability.'
|
||
: `Based on historical reports, the chance of at least one hail event in the next 10 years is estimated at ${pct(dispLow)}–${pct(dispHigh)}%, assuming future patterns resemble the past.`,
|
||
confidence: `Confidence: ${confidenceLabel}. This is based on county-level and nearby reports within ~${radiusMiles} miles, not a roof-specific inspection.`,
|
||
lastUpdated: `Storm data reflects reports through ${new Date(generatedAt).toLocaleDateString()}.`,
|
||
},
|
||
};
|
||
}
|