◢ Methodology · technical reference
The exact derivation,
formula by formula.
This page is the technical companion to /research. If the research page asks “what does CheckHazard read?”, this one answers “what does it actually compute?” — every threshold, every band, every line of categorization logic, written exactly as the production pipeline implements it.
◢ I · Spatial layer
The single PostGIS query that reads every hazard layer.
One round-trip resolves every spatial question we ask. PostGIS evaluates each predicate using GIST-indexed bounding-box prefiltering before exact geometry tests, so 15.7 million subdivided flood polygons answer in 15–20 ms.
Inputs. A geocoded point (lat, lng) in WGS-84.
Output. A single row of raw measurements (the HazardScore JSON object) consumed by the categorization layer.
The query against each hazard table follows one of two shapes — a binary point-in-polygon test, or a nearest-edge distance measurement when the point sits outside every polygon.
PostGIS · point in polygon
-- Is this point inside a flood polygon? Returns the worst level (1-3)
-- found at this location, or NULL if outside every flood zone.
SELECT MAX(var) AS flood_level
FROM flood_hazard
WHERE ST_Contains(geom, ST_SetSRID(ST_MakePoint($lng, $lat), 4326));PostGIS · nearest-edge distance
-- When outside, how far to the nearest flood polygon?
-- Geography type ensures the result is in meters, not degrees.
SELECT MIN(ST_Distance(
geom::geography,
ST_SetSRID(ST_MakePoint($lng, $lat), 4326)::geography
)) AS flood_nearest_m
FROM flood_hazard
-- GIST-indexed bbox prefilter — without this, dense Manila addresses
-- match 2,000+ subdivided pieces and the geography cast pushes the
-- query past 40s. With it, ~200 ms.
WHERE geom && ST_Expand(
ST_SetSRID(ST_MakePoint($lng, $lat), 4326),
2000.0 / 100000.0 -- ~2 km buffer in degrees at PH latitudes
);This shape — ST_Contains first, fall back to ST_Distance on a bounded set — repeats for five hazard tables (flood, landslide, storm surge, debris flow, alluvial fan) plus three line/point layers (active faults, named waterways, active volcanoes). Elevation and slope are sampled separately from the SRTM raster:
PostGIS · raster sample
-- Elevation in meters and slope in degrees, sampled from the
-- 100 m bilinear-resampled DEM and 50 m slope grid (production
-- replaces the native 30 m rasters with these resampled pairs).
SELECT
ST_Value(d.rast, p.geom)::float AS elevation_m,
ST_Value(s.rast, p.geom)::float AS slope_deg
FROM srtm_dem_100m d, srtm_slope_50m s,
(SELECT ST_SetSRID(ST_MakePoint($lng, $lat), 4326) AS geom) p
WHERE ST_Intersects(d.rast, p.geom)
AND ST_Intersects(s.rast, p.geom);◢ II · Categorization thresholds
Each raw number drops into a documented band.
Every categorizer is a sorted threshold list with an early return per band. Different signal, different cutoffs, same shape.
TypeScript · fault distance categorizer
export function categorizeFaultDistance(
distance_m: number | null,
): RiskCategory {
if (distance_m === null) return "none";
if (distance_m < 100) return "very_high";
if (distance_m < 500) return "high";
if (distance_m < 2000) return "moderate";
return "low";
}Every hazard signal has one of these functions. The cutoffs are picked from PH-specific sources — NSCP 2019 for fault setbacks, NOAH’s own Low/Moderate/High scheme for polygon levels, PHIVOLCS guidance for volcanic proximity. They are constants, not learned weights — a future change to the bands is a code review, not a retraining run.
Active fault distance
categorizeFaultDistance() · lib/hazard.ts
| Input range | Category | Reason |
|---|---|---|
| < 100 m | Very high | NSCP no-build buffer territory |
| 100 – 500 m | High | PHIVOLCS advisory zone |
| 500 – 2,000 m | Moderate | Strong-shaking band |
| ≥ 2,000 m | Low | Outside primary shake band |
Active volcano distance
categorizeVolcanoDistance() · lib/hazard.ts
| Input range | Category | Reason |
|---|---|---|
| < 10 km | Very high | Pyroclastic density current zone |
| 10 – 30 km | High | Lahar reach |
| 30 – 100 km | Moderate | Ashfall under strong wind |
| ≥ 100 km | Low | Outside typical impact range |
Hazard polygons (flood / landslide / storm-surge)
categorizePolygonExposure() · lib/hazard.ts
| Input range | Category | Reason |
|---|---|---|
| Inside Level 3 (NOAH high) | High | Inundated routinely |
| Inside Level 2 (NOAH moderate) | Moderate | Major-storm inundation |
| Inside Level 1 (NOAH low) | Low | Extreme-event only |
| ≤ 50 m of any zone | High | Proximity buffer applies (§ III) |
| 50 – 200 m of Level 3 | Moderate | Level-3 buffer applies (§ III) |
| Else | None | Outside hazard footprint |
◢ III · Proximity buffers
Two buffers absorb geocoding drift and edge-of-zone exposure.
Google Places resolves PH addresses to within ~10–50 m. NOAH polygon edges are simplified at 0.0001° (~11 m) tolerance. Two documented buffers handle the combined error.
Buffer #1 — 50 m proximity. A property measured outside any hazard polygon but within 50 m of its edge is reclassified as inside. Without this, houses on the lip of a flood zone would silently read as no risk.
Buffer #2 — 200 m Level-3 buffer.A Level-3 (NOAH High) polygon next door affects the property even if it doesn’t touch. Up to 200 m, we surface a moderate finding so the proximity is visible in the report.
TypeScript · proximity buffer logic
function categorizePolygonExposure(
insideLevel: HazardLevel | null, // 1 | 2 | 3 | null
nearestM: number | null,
nearestLevel: HazardLevel | null,
): RiskCategory {
// Inside the polygon — level decides directly.
if (insideLevel === 3) return "high";
if (insideLevel === 2) return "moderate";
if (insideLevel === 1) return "low";
if (nearestM === null) return "none";
// Buffer #1: within 50 m of any zone counts as inside.
if (nearestM <= 50) {
return nearestLevel === 3 ? "high"
: nearestLevel === 2 ? "moderate"
: "low";
}
// Buffer #2: 50–200 m of a Level-3 polygon promotes to moderate.
if (nearestM < 200 && nearestLevel === 3) return "moderate";
return "none";
}◢ IV · Liquefaction estimate
Soft soil × shaking, scored 0 to 12.
PHIVOLCS' authoritative liquefaction map is not openly licensed. Until it opens, we screen the property using terrain signals we have. The estimate is labeled as such, never sets the headline rating.
TypeScript · liquefaction score
function liquefactionScore(s: HazardScore): number {
const elev = bandScore(s.elevation_m, [20, 40]); // 2/1/0
const slope = bandScore(s.slope_deg, [3, 8]); // 2/1/0
const water = bandScore(s.waterway_distance_m,[200, 500]); // 2/1/0
const coastal = isCoastal(s) ? 1 : 0;
const softSoilScore = elev + slope + water + coastal; // 0–6
let shakingScore = bandScore(s.fault_distance_m, [2000, 5000]); // 2/1/0
if (shakingScore === 0 && coastalCorrection(s)) shakingScore = 1; // floor
return softSoilScore * shakingScore; // 0–12
}soft_soil_score (0 – 6)
Sum of three component scores + coastal bump
| Component | 2 pts | 1 pt | 0 pts |
|---|---|---|---|
| Elevation (m) | < 20 | 20 – 40 | ≥ 40 |
| Slope (°) | < 3 | 3 – 8 | ≥ 8 |
| Waterway distance (m) | < 200 | 200 – 500 | ≥ 500 |
shaking_score (0 – 2)
Fault-distance band, with a coastal-correction floor
| Distance to fault | Points |
|---|---|
| < 2 km | 2 |
| 2 – 5 km | 1 |
| ≥ 5 km | 0 |
Category mapping. The 0–12 score lands in one of four buckets: 0 – 1 → low, 2 – 3 → moderate, 4 – 7 → high, 8 – 12 → very_high. The finding always carries isEstimated: true so the composite-rating algorithm (§ VII) skips it when picking the headline.
◢ V · Insurance premium tier
Three independent ladders, never combined into one number.
PH property insurance prices each peril separately. Mixing them into a single quote loses signal — flood rider, earthquake rider, and volcanic rider can each carry a different severity for the same address.
Flood / surge / debris flow
floodPremium() · lib/hazard.ts
| Input range | Category | Reason |
|---|---|---|
| Inside Level-3 NOAH zone, debris flow, alluvial fan, or storm-surge SSA 1–2 | Very high | Prohibitive — major insurers decline; only specialty carriers + heavy retention |
| Inside Level-2 NOAH zone or storm-surge SSA 3 | High | Elevated — riders required, sublimits expected |
| Inside Level-1 NOAH zone or near-zone (50 m) of higher levels | Moderate | Standard premium with risk surcharge |
| Outside all flood/surge layers | Low | No flood-specific loading |
Earthquake (fault + landslide combined)
earthquakePremium() · lib/hazard.ts
| Input range | Category | Reason |
|---|---|---|
| Within 100 m of an active fault OR landslide Level 3 | Very high | Prohibitive — earthquake riders may decline outright |
| 100 – 500 m fault, OR landslide Level 2 | High | Elevated — 15–25% loading typical for NCR |
| 500 – 2,000 m fault, OR landslide Level 1 | Moderate | Standard rate with mild surcharge |
| ≥ 2,000 m from any fault, no landslide exposure | Low | Standard market rate |
Volcanic (ashfall, lahar, PDC)
volcanicPremium() · lib/hazard.ts
| Input range | Category | Reason |
|---|---|---|
| < 10 km from active volcano | Very high | Prohibitive — PDC-zone exclusions apply |
| 10 – 30 km | High | Elevated — lahar/ashfall riders priced individually |
| 30 – 100 km | Moderate | Standard rate; ashfall remains a possibility |
| ≥ 100 km | Low | No volcanic loading expected |
The Severity column maps directly to the report’s Insurance Guidance section, which uses three labels: standard, elevated, and prohibitive. Categories collapse so that low / none → standard, moderate / high → elevated, and very_high → prohibitive. This isn’t a peso quote; it’s a budgeting signal.
◢ VI · Legal constraints
NSCP 2019 fault setback + Water Code Article 51 easements.
Two PH laws are checked automatically. Properties on the wrong side are flagged as no-build under PH law — not a risk surcharge but a permit blocker.
TypeScript · legal-constraint checks
function computeLegalConstraints(score: HazardScore): LegalConstraints {
// 1. Fault setback — NSCP 2019 + PHIVOLCS advisory
const faultD = score.fault_distance_m;
const within5m = faultD !== null && faultD < 5; // hard no-build
const within500m = faultD !== null && faultD < 500; // advisory zone
// 2. Waterway easement — Water Code Article 51
const waterwayType = classifyWaterway(score.nearest_waterway);
const requiredM = easementRequirement(waterwayType); // 3 / 20 / 40
const compliant = (score.waterway_distance_m ?? Infinity) >= requiredM;
return {
faultSetback: { within5m, within500mAdvisory: within500m },
waterwayEasement: {
type: waterwayType,
requiredM,
measuredM: score.waterway_distance_m,
compliant,
},
};
}Fault trace setback
NSCP 2019 + PHIVOLCS
- Hard no-build buffer< 5 m from active fault trace
- Advisory zone (critical facilities)< 500 m
Water Code easement (Art. 51)
P.D. 1067 · Water Code of the Philippines
- Creek / estero3 m setback
- River20 m setback
- Coastline / shore40 m setback
◢ VII · Composite rating
The headline number is the weakest measured link.
Two principles drive the composite: worst-non-estimated wins, and Top Concerns are pinned regardless of severity. Both are encoded in the function below.
TypeScript · overall risk + Top Concerns
function computeSummary(findings: RiskFinding[]): Summary {
return {
overallCategory: overallRisk(findings),
stackCounts: countHighAndVeryHigh(findings),
topConcerns: pinKeyHazardsThenRankOthers(findings),
};
}
// Worst category across MEASURED findings only.
function overallRisk(findings: RiskFinding[]): RiskCategory {
let worst: RiskCategory = "none";
for (const f of findings) {
if (f.isEstimated) continue; // skip screens
if (RANK[f.category] > RANK[worst]) worst = f.category;
}
return worst;
}
// Flood / Landslide / Storm-surge always occupy the first three slots,
// regardless of severity. Everything else is then sorted worst-first.
function pinKeyHazardsThenRankOthers(
findings: RiskFinding[],
): RiskFinding[] {
const pinned = ["Flood", "Landslide", "Storm surge"];
const head = pinned
.map(name => findings.find(f => f.label === name))
.filter(Boolean) as RiskFinding[];
const rest = findings
.filter(f => !pinned.includes(f.label))
.sort((a, b) => RANK[b.category] - RANK[a.category]);
return [...head, ...rest];
}
const RANK: Record<RiskCategory, number> = {
none: 0, low: 1, moderate: 2, high: 3, very_high: 4,
};Why the worst, not the average? A property with three Low findings and one High finding is a high-risk property — averaging would hide the High behind the three Lows. The headline reflects the weakest link; the stack counter (measured High=1, Very High=0) shows the reader how many weak links there are.
Why skip estimated findings?Liquefaction is currently the only screening estimate (the authoritative PHIVOLCS map isn’t open data). Letting an estimate set the headline would erode trust if PHIVOLCS’s real value disagrees later. The estimate is shown in its own finding row with the Est tag — visible, but not load-bearing.
Implementation
All categorizers, the liquefaction estimate, the insurance ladders, the legal-constraint checks, and the composite-rating algorithm are implemented in lib/hazard.ts and lib/report.ts. The PostGIS function powering the spatial query layer is get_hazard_score(lat, lng). A 100-address PH stress test runs on every change to those files; any threshold edit must keep the test passing 100/100.