/** * 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()}.`, }, }; }