Public Alerting & CAP Message Pipelines
An evacuation order is issued for a 4-square-kilometre polygon on the north edge of a fire. The Common Alerting Protocol (CAP) message carries the polygon exactly. The Wireless Emergency Alert derived from it reaches every handset attached to a cell sector that touches the polygon, which includes a sector whose tower serves a town 11 kilometres away. Two thousand people evacuate who were never in danger, and the roads they use are the ones the evacuation needed.
Problem Framing
Public alerting is the one workflow on this site whose output is read by the public rather than by responders, and that changes the failure modes completely. A defect in the common operating picture is seen by trained people who can question it; a defect in an alert is acted on immediately by tens of thousands of people who cannot. Over-alerting has a measurable cost in road capacity and in future compliance, and under-alerting has an obvious one.
The specific difficulty is that the alert area a responder authors is not the area that gets delivered. Every channel reshapes it, in a direction the author cannot see from the authoring tool.
The three areas are nested and progressively coarser, and each is correct for its channel. What goes wrong is treating the authored polygon as the delivered area — sizing a polygon tightly to avoid over-alerting, and then discovering that the cell-sector union it produced was four times larger than intended.
Prerequisites
- A CAP 1.2 authoring library and the official XSD, plus the specific profile of whichever alerting authority you submit through. CAP is a standard with per-authority profiles, and a message can be schema-valid and profile-invalid.
- An authoritative incident polygon in EPSG:4326 with axis order settled per the coordinate reference system standard — an inverted alert polygon is a category of error with an unusually large blast radius.
- The geographic code set your EAS path uses and, where available, the cell-sector geometry the WEA path will map onto, so the delivered areas can be estimated before release rather than observed afterwards.
- A named human approver. Nothing in this topic removes that requirement, and the design below is built around making their decision easy rather than replacing it.
The Fields That Carry Meaning
Most CAP elements are presentation. Five carry operational behaviour, and two of those are the ones most often got wrong.
references is the field that makes an update an update. A CAP message with msgType of Update but no references is, to most consumers, simply a second alert — so a device that received both now shows two evacuation orders with different boundaries and no indication which supersedes the other. That is worse than not updating.
expires is the field that bounds the damage from everything else. Devices lose messages; a cancel that never arrives leaves the alert live forever unless it expires on its own. Setting a conservative expiry and re-issuing while the hazard persists is strictly safer than a long expiry and a cancel you are trusting to arrive.
Validating Before Release
Each gate catches what the one before it cannot, and the ordering matters because the cheap ones eliminate most failures before a human is asked to look. Schema validation is milliseconds and catches structural defects. Profile validation catches the values that are legal in CAP and rejected by a particular gateway — this is where an event code that works in one state’s system fails in a neighbour’s.
Geometry validation is where this site’s usual concerns land: a closed ring, correct winding, inside the sender’s jurisdiction, and within the vertex budget the channel imposes. A polygon exceeding the budget is not rejected by most gateways; it is simplified, and the simplification is not yours.
The fourth gate is human and cannot be automated away. A message can pass all three technical gates and tell people to shelter in place when the incident commander ordered an evacuation.
Step-by-Step Implementation
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from lxml import etree
from shapely.geometry import Polygon
from shapely.geometry.base import BaseGeometry
logger = logging.getLogger("incidentgis.cap")
CAP_NS = "urn:oasis:names:tc:emergency:cap:1.2"
# Practical ceiling before gateways simplify the polygon themselves.
MAX_ALERT_VERTICES = 100
@dataclass(frozen=True)
class AlertIntent:
"""What the incident commander decided, separate from how it is delivered."""
event: str
headline: str
instruction: str
urgency: str # Immediate | Expected | Future | Past | Unknown
severity: str # Extreme | Severe | Moderate | Minor | Unknown
certainty: str # Observed | Likely | Possible | Unlikely | Unknown
approver: str
supersedes: tuple[str, ...] = ()
def _cap_polygon(geom: BaseGeometry) -> str:
"""CAP polygons are lat,lon pairs, space separated, first point repeated.
Note the coordinate order: CAP is latitude first, the opposite of GeoJSON.
Emitting lon,lat here produces a syntactically valid alert for somewhere
else entirely, which is the highest-consequence axis-order bug on the site.
"""
if not isinstance(geom, Polygon):
raise ValueError("alert areas must be a single closed polygon")
ring = list(geom.exterior.coords)
if len(ring) > MAX_ALERT_VERTICES:
raise ValueError(
f"{len(ring)} vertices exceeds the {MAX_ALERT_VERTICES} budget; "
"simplify deliberately rather than letting the gateway do it"
)
return " ".join(f"{lat:.6f},{lon:.6f}" for lon, lat in ring)
def build_alert(
intent: AlertIntent,
area: BaseGeometry,
*,
sender: str,
expires_in: timedelta = timedelta(hours=2),
identifier: str | None = None,
) -> bytes:
"""Build a CAP 1.2 alert from an incident polygon and a stated intent."""
if intent.supersedes and not identifier:
identifier = str(uuid.uuid4())
now = datetime.now(timezone.utc)
msg_type = "Update" if intent.supersedes else "Alert"
root = etree.Element("alert", nsmap={None: CAP_NS})
etree.SubElement(root, "identifier").text = identifier or str(uuid.uuid4())
etree.SubElement(root, "sender").text = sender
etree.SubElement(root, "sent").text = now.isoformat()
etree.SubElement(root, "status").text = "Actual"
etree.SubElement(root, "msgType").text = msg_type
etree.SubElement(root, "scope").text = "Public"
if intent.supersedes:
# Without references an Update is just a second alert, and a device
# that received both shows two orders with no way to rank them.
etree.SubElement(root, "references").text = " ".join(intent.supersedes)
info = etree.SubElement(root, "info")
etree.SubElement(info, "category").text = "Safety"
etree.SubElement(info, "event").text = intent.event
etree.SubElement(info, "urgency").text = intent.urgency
etree.SubElement(info, "severity").text = intent.severity
etree.SubElement(info, "certainty").text = intent.certainty
# A conservative expiry that is re-issued beats a long one plus a cancel
# you are trusting to arrive on every device.
etree.SubElement(info, "expires").text = (now + expires_in).isoformat()
etree.SubElement(info, "headline").text = intent.headline
etree.SubElement(info, "instruction").text = intent.instruction
area_el = etree.SubElement(info, "area")
etree.SubElement(area_el, "areaDesc").text = intent.headline
etree.SubElement(area_el, "polygon").text = _cap_polygon(area)
payload = etree.tostring(root, xml_declaration=True, encoding="UTF-8")
logger.info("cap_alert_built", extra={
"msg_type": msg_type, "approver": intent.approver,
"vertices": len(list(area.exterior.coords)),
"supersedes": intent.supersedes,
})
return payload
Configuration Reference
| Parameter | Env var | Default | Notes |
|---|---|---|---|
| Sender identifier | CAP_SENDER |
unset | The registered sender ID; a message with the wrong one is rejected at the gateway. |
| Default expiry | CAP_EXPIRES_MINUTES |
120 |
Short and re-issued beats long and cancelled. |
| Vertex budget | CAP_MAX_VERTICES |
100 |
Above this the gateway simplifies for you, with a shape you did not choose. |
| Simplification tolerance | CAP_SIMPLIFY_M |
50 |
Applied deliberately when over budget, in a projected CRS. |
| Overshoot warning ratio | CAP_OVERSHOOT_WARN |
1.5 |
Warn the approver when the estimated delivered area exceeds the authored one by this factor. |
| Profile | CAP_PROFILE |
unset | The receiving authority’s profile; schema-valid is not the same as accepted. |
| Approver required | CAP_REQUIRE_APPROVER |
true |
There is no supported value of false. |
Verification and Smoke Test
Validate against the XSD, then against the profile, then assert the polygon round-trips:
xmllint --noout --schema CAP-v1.2.xsd alert.xml && echo "schema ok"
python -m incidentgis.cap.profile_check --profile state-ipaws alert.xml
python -m incidentgis.cap.geometry_check --jurisdiction county-bernalillo alert.xml
The geometry check must confirm the emitted polygon parses back to the polygon that was authored — the latitude-first ordering makes a silent transposition the single highest-consequence defect this pipeline can ship.
Integration With Adjacent Workflows
The alert polygon comes from the reconciled perimeter that the conflict resolver produced, so an alert inherits every property of that reconciliation including its audit trail. Evacuation instructions should be consistent with the routes the evacuation routing layer is publishing, since an alert telling people to leave via a road the router has closed is worse than no alert.
Troubleshooting
Symptom: the gateway rejects a message that validates against the XSD. It is failing the authority’s profile. Compare the event code and category against that authority’s list, not against CAP’s.
Symptom: an update appears as a second alert on handsets. references is missing or names an identifier that was never sent. It must list the exact prior identifiers, space separated.
Symptom: the delivered area is far larger than the polygon. That is cell-sector or geographic-code delivery working normally. Estimate it before release and show the approver the ratio.
Symptom: the alert polygon appears in the wrong hemisphere. CAP is latitude-first and GeoJSON is longitude-first. Assert a round-trip parse, not a visual check.
Symptom: an old alert is still showing on some devices days later. expires was long or absent and the cancel did not reach them. Shorten the expiry and re-issue while the hazard persists.
Frequently Asked Questions
Why is the delivered alert area larger than the polygon we drew? Because each channel reshapes it. The CAP message itself carries the polygon verbatim, but a Wireless Emergency Alert is delivered by cell broadcast, so the effective area becomes the union of every cell sector intersecting the polygon and extends wherever a sector reaches beyond the boundary. An Emergency Alert System broadcast is delivered by geographic code, so it covers entire counties or subdivisions containing any part of the polygon. The three areas are nested and progressively coarser, and sizing a polygon tightly to limit over-alerting does not limit what the coarser channels deliver — which is why the estimated delivered area should be shown to the approver before release.
What makes a CAP update behave as an update rather than a new alert? The references element. A message with msgType set to Update but no references is, to most consumers, simply a second independent alert, so a device that received both displays two evacuation orders with different boundaries and no way to tell which supersedes the other — worse than not having updated at all. References must list the exact prior identifiers, space separated, and those identifiers must be ones that were actually sent. The related discipline is expires: devices lose messages, so a cancel that never arrives leaves an alert live indefinitely, and a short expiry that is re-issued while the hazard persists is safer than a long one plus a cancel you are trusting to arrive.
What is the highest-consequence technical defect in a CAP pipeline? Transposed coordinates. CAP polygons are written latitude first, which is the opposite of GeoJSON’s longitude-first ordering, so a pipeline that emits its internal representation directly produces a syntactically valid alert describing somewhere else entirely. It passes schema validation, passes profile validation, and is delivered. The defence is to parse the emitted polygon back and assert it equals the authored geometry, rather than checking it visually in an authoring tool that may use the same wrong convention on the way back in.
Related
- Conflict Resolution in Multi-Agency Edits — where the reconciled perimeter an alert area is derived from comes from.
- Evacuation Routing & Road Network Analysis — the routes an evacuation instruction must be consistent with.
- Fixing Axis Order Inversion in Cross-Agency GeoJSON — the same transposition failure, where the blast radius is smaller.
- Automated Attribute Validation Rules — the fail-closed validation discipline the four alert gates apply.
Up: Incident Mapping & Multi-Agency Sync Workflows