Back home

◢ 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.

Plain-English versionYou are here · Technical

◢ 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 rangeCategory
< 100 mVery high
100 – 500 mHigh
500 – 2,000 mModerate
≥ 2,000 mLow

Active volcano distance

categorizeVolcanoDistance() · lib/hazard.ts

Input rangeCategory
< 10 kmVery high
10 – 30 kmHigh
30 – 100 kmModerate
≥ 100 kmLow

Hazard polygons (flood / landslide / storm-surge)

categorizePolygonExposure() · lib/hazard.ts

Input rangeCategory
Inside Level 3 (NOAH high)High
Inside Level 2 (NOAH moderate)Moderate
Inside Level 1 (NOAH low)Low
≤ 50 m of any zoneHigh
50 – 200 m of Level 3Moderate
ElseNone

◢ 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

Component2 pts1 pt0 pts
Elevation (m)< 2020 – 40≥ 40
Slope (°)< 33 – 8≥ 8
Waterway distance (m)< 200200 – 500≥ 500

shaking_score (0 – 2)

Fault-distance band, with a coastal-correction floor

Distance to faultPoints
< 2 km2
2 – 5 km1
≥ 5 km0

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 rangeCategory
Inside Level-3 NOAH zone, debris flow, alluvial fan, or storm-surge SSA 1–2Very high
Inside Level-2 NOAH zone or storm-surge SSA 3High
Inside Level-1 NOAH zone or near-zone (50 m) of higher levelsModerate
Outside all flood/surge layersLow

Earthquake (fault + landslide combined)

earthquakePremium() · lib/hazard.ts

Input rangeCategory
Within 100 m of an active fault OR landslide Level 3Very high
100 – 500 m fault, OR landslide Level 2High
500 – 2,000 m fault, OR landslide Level 1Moderate
≥ 2,000 m from any fault, no landslide exposureLow

Volcanic (ashfall, lahar, PDC)

volcanicPremium() · lib/hazard.ts

Input rangeCategory
< 10 km from active volcanoVery high
10 – 30 kmHigh
30 – 100 kmModerate
≥ 100 kmLow

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 / nonestandard, moderate / highelevated, and very_highprohibitive. This isn’t a peso quote; it’s a budgeting signal.

◢ 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.