Recipe 14.8 Architecture and Implementation: Ambulance Routing and Dispatch
Companion to Recipe 14.8: Ambulance Routing and Dispatch. This page covers the AWS architecture, services, prerequisites, and pseudocode. For the problem framing and the conceptual approach, start with the main recipe.
The AWS Implementation
Why These Services
Amazon Location Service for travel time and routing. You need road-network travel times that account for current traffic conditions. Amazon Location Service gives you this: route calculations between arbitrary points with real-time traffic awareness, including route matrices (many-to-many travel times), which is exactly what you need when evaluating multiple candidate units against a call location. It replaces the need to build and maintain your own road network graph.
AWS Lambda for the real-time dispatch function. The dispatch decision is a short-lived, stateless computation: take the current fleet state and call details, score candidate units, return the best assignment. Lambda gives you automatic scaling during high-call-volume periods and no infrastructure to manage. Provisioned concurrency is mandatory for the dispatch function (not optional). A cold start adding 2 seconds to a cardiac arrest dispatch is unacceptable. Set provisioned concurrency to handle your peak simultaneous dispatch rate (typically 3-5x your average concurrent dispatches to handle multi-casualty incident bursts). Monitor the ProvisionedConcurrencySpilloverInvocations metric; any spillover means a dispatch request hit a cold start. Target: zero spillover invocations.
Amazon DynamoDB for fleet state. Unit locations, statuses, and capabilities change constantly. DynamoDB's single-digit-millisecond reads and writes make it ideal for the fleet state store. The dispatch function reads current state on every call; DynamoDB handles this access pattern without breaking a sweat. DynamoDB Streams can trigger downstream processing when state changes (e.g., a unit becomes available, triggering a coverage recalculation).
Amazon ElastiCache (Redis) for pre-computed travel time matrices. Computing travel times on every dispatch call adds latency. For the most common origin-destination pairs (station locations to high-demand zones), pre-compute and cache travel times in Redis. Update the cache every few minutes with fresh traffic data. The dispatch function checks the cache first; only falls back to Location Service for cache misses.
AWS Step Functions for the repositioning workflow. The background repositioning optimizer is a multi-step workflow: gather current fleet state, compute coverage levels, identify gaps, run the solver, issue move-up commands, wait for acknowledgment. Step Functions orchestrates this cleanly with built-in retry logic and state management.
Amazon SageMaker for demand forecasting. The demand forecast model (predicting where calls will come from in the next few hours) is a time-series ML model trained on historical call data. SageMaker hosts the trained model behind a real-time endpoint that the repositioning optimizer queries.
Amazon Kinesis Data Streams for GPS ingestion. Ambulance GPS units report location every 5 to 15 seconds. That's a high-throughput stream of small messages. Kinesis ingests these, and a Lambda consumer updates the fleet state in DynamoDB. This decouples the GPS feed from the state store and handles burst traffic gracefully.
Amazon EventBridge for hospital status updates. Hospitals publish diversion status, ED census, and bed availability through various mechanisms. EventBridge provides a clean event bus for these updates, routing them to the appropriate consumers (the hospital selection component of the dispatch optimizer).
Failover and Degradation Strategy
This is a life-safety system. The optimization layer must never become a single point of failure for dispatch operations. The CAD system retains its native proximity-based dispatch capability at all times, and the optimizer operates as an advisory overlay.
Timeout-triggered fallback. If the dispatch optimizer Lambda does not return a recommendation within 3 seconds, the CAD system automatically falls back to its native dispatch logic (closest available unit by straight-line or pre-computed road distance). The 3-second threshold is aggressive by design: a Priority 1 cardiac arrest cannot wait for a retry loop.
Graceful degradation by component:
| Component Down | Fallback Behavior |
|---|---|
| ElastiCache (travel time cache) | Lambda calls Location Service directly (adds 200-500ms latency, still within budget) |
| Amazon Location Service | Use last-known cached travel times (stale but usable) or Haversine distance with speed estimate |
| DynamoDB (fleet state) | Lambda maintains a 30-second in-memory snapshot; dispatch from snapshot, flag as degraded |
| SageMaker (demand forecast) | Repositioning optimizer pauses; dispatch continues unaffected (it doesn't depend on forecast) |
| Kinesis (GPS stream) | Fleet positions go stale; after 60 seconds without update, flag units as "position uncertain" and widen the candidate pool |
| Full optimizer outage | CAD system dispatches natively using proximity; alert on-call engineering team |
Monitoring and alerting:
- Fallback rate: Track the percentage of dispatches that hit the 3-second timeout and fall back to CAD-native logic. Target: < 0.1% in steady state. Alert if fallback rate exceeds 1% over a 5-minute window.
- Component health: CloudWatch alarms on each dependency (ElastiCache hit rate, DynamoDB throttles, Location Service error rate, Lambda duration P99). Any alarm triggers a page to the on-call team.
- Degraded mode flag: When operating in any degraded state, tag all dispatch decisions with a
degraded_componentsfield listing which services are impaired. This supports post-incident quality review.
The principle: the system should always produce an answer. A slightly suboptimal dispatch (proximity-based) is infinitely better than no dispatch while waiting for the optimizer to recover.
Dispatcher-in-the-Loop
The architecture diagram shows the optimizer sending assignments directly to the MDT (Mobile Data Terminal). In practice, dispatch decisions flow through a human dispatcher who accepts or overrides the recommendation. This is not optional for a life-safety system.
Operating modes by call priority:
| Priority | Mode | Behavior |
|---|---|---|
| 1 (life-threatening) | Auto-dispatch with confirmation | System dispatches the top-ranked unit immediately AND presents the recommendation to the dispatcher console. Dispatcher can override within 15 seconds; after that, the assignment stands. This preserves speed for cardiac arrest while allowing the dispatcher to catch obvious errors. |
| 2-3 (urgent, non-critical) | Recommendation mode | System presents top 3 candidates on the dispatcher console with scores, travel times, and coverage impact. Dispatcher selects one and confirms. Timeout: 30 seconds before auto-selecting the top candidate. |
| 4-5 (non-emergency) | Recommendation mode | Same as Priority 2-3 but no timeout. Dispatcher selects at their own pace. |
Dispatcher console component: The dispatcher sees a ranked list of candidate units with travel time estimates, capability match, coverage impact indicator, and any flags (unit near end-of-shift, position uncertain, etc.). Accept/reject is a single click. If the dispatcher overrides, they select a reason code: "local knowledge," "crew request," "unit condition," "other."
Override tracking for model improvement: Every dispatcher accept/reject action is logged to the dispatch audit table. Fields include: recommended_unit_id, dispatched_unit_id, override_reason_code, dispatcher_id, decision_latency_ms. Aggregate override data feeds a monthly model review: if dispatchers consistently override in a specific scenario (e.g., always rejecting Unit X for calls near the river), the model is missing information that should be incorporated.
Architecture Diagram
flowchart TB
subgraph "Real-Time Dispatch (< 2 sec)"
A[911 Call / CAD] -->|Dispatch Request| B[API Gateway]
B --> C[Lambda: Dispatch Optimizer]
C -->|Read Fleet State| D[DynamoDB: Fleet State]
C -->|Travel Times| E[ElastiCache: Route Matrix]
C -->|Fallback| F[Amazon Location Service]
C -->|Hospital Status| G[DynamoDB: Hospital Status]
C -->|Recommendation| DC[Dispatcher Console]
DC -->|Accept/Override| H[CAD System / MDT]
C -.->|Auto-dispatch P1 + confirm| H
end
subgraph "Fleet Tracking"
I[Ambulance GPS] -->|Authenticated API| AG[API Gateway: GPS Ingress]
AG -->|Validated Events| J[Kinesis Data Streams]
J --> K[Lambda: GPS Processor]
K -->|Update Position| D
end
subgraph "Background Optimization (every 2-5 min)"
L[EventBridge: Schedule] --> M[Step Functions: Repositioning]
M -->|Current State| D
M -->|Demand Forecast| N[SageMaker Endpoint]
M -->|Solve Coverage| O[Lambda: MIP Solver]
O -->|Move-Up Commands| H
end
subgraph "Hospital Integration"
P[Hospital Systems] -->|Diversion/Census| Q[EventBridge]
Q --> R[Lambda: Hospital Status Updater]
R --> G
end
subgraph "Audit"
C -->|Decision Record| AT[DynamoDB: Audit Trail]
DC -->|Accept/Reject| AT
end
style C fill:#ff9,stroke:#333
style D fill:#9ff,stroke:#333
style N fill:#f9f,stroke:#333
style AT fill:#fcc,stroke:#333
Prerequisites
| Requirement | Details |
|---|---|
| AWS Services | Amazon Location Service, AWS Lambda, Amazon DynamoDB, Amazon ElastiCache (Redis), AWS Step Functions, Amazon SageMaker, Amazon Kinesis Data Streams, Amazon EventBridge, Amazon API Gateway |
| IAM Permissions | geo:CalculateRoute, geo:CalculateRouteMatrix, dynamodb:GetItem, dynamodb:PutItem, dynamodb:Query, kinesis:PutRecord, kinesis:GetRecords, sagemaker:InvokeEndpoint, states:StartExecution. ElastiCache Redis access is controlled via security groups (Lambda in the same VPC/subnet with appropriate SG rules). If using IAM-based authentication (Redis 7.0+), scope elasticache:Connect to the specific replication group ARN. |
| BAA | Required. Patient location, call details, and destination hospital are PHI under HIPAA. |
| Encryption | DynamoDB: encryption at rest (default). ElastiCache: in-transit and at-rest encryption enabled. Kinesis: server-side encryption with customer-managed KMS CMK (automatic annual rotation enabled); restrict kms:Decrypt to the GPS processor Lambda execution role and authorized administrative principals. Use a separate CMK for the GPS stream to enable independent access control. All API calls over TLS. |
| VPC | Production: Lambda functions in VPC with VPC endpoints for DynamoDB, Kinesis, SageMaker, and CloudWatch Logs. ElastiCache must be in VPC (it always is). Location Service accessed via VPC endpoint (preferred; keeps all traffic within the AWS network). Use NAT Gateway only if the Location Service VPC endpoint is not available in your region. |
| CloudTrail | Enabled for all API calls. Dispatch decisions are auditable events. |
| Sample Data | Synthetic call records with timestamps, locations, priorities. Synthetic fleet positions. Never use real patient data in dev. NEMSIS (National EMS Information System) provides de-identified dataset structures for testing. |
| Cost Estimate | Location Service route calculations: $0.04 per request (batch matrix calls reduce this). Lambda: negligible at typical call volumes. DynamoDB: on-demand pricing, ~$50-200/month for a mid-size fleet. SageMaker endpoint: ~$100-500/month depending on instance. ElastiCache: ~$50-200/month for a small Redis cluster. Total: $2,000-8,000/month for a metro-area EMS system. |
Ingredients
| AWS Service | Role |
|---|---|
| Amazon Location Service | Road-network travel time calculations with real-time traffic |
| AWS Lambda | Real-time dispatch scoring, GPS processing, hospital status updates |
| Amazon DynamoDB | Fleet state store (unit positions, statuses, capabilities) and hospital status |
| Amazon ElastiCache (Redis) | Cached travel time matrices for low-latency dispatch lookups |
| AWS Step Functions | Orchestrates background repositioning optimization workflow |
| Amazon SageMaker | Hosts demand forecast model for coverage optimization |
| Amazon Kinesis Data Streams | Ingests high-throughput GPS location stream from fleet |
| Amazon EventBridge | Routes hospital status events and triggers scheduled repositioning |
| Amazon API Gateway | Exposes dispatch API to CAD system integration |
| AWS KMS | Encryption key management for all data stores |
| Amazon CloudWatch | Metrics, alarms, dashboards for response time monitoring |
Pseudocode Walkthrough
Step 1: Ingest fleet GPS and maintain state. Every ambulance reports its GPS position every 5 to 15 seconds. These positions flow through Kinesis into a Lambda processor that updates the fleet state table. The state table is the single source of truth for "where is every unit right now and what are they doing?" Without this real-time state, the dispatch optimizer is working with stale information, and stale information in EMS means sending a unit that's actually 15 minutes away instead of the one that's 3 minutes away. The state table also tracks unit status transitions: available, dispatched, en route, on scene, transporting, at hospital, returning. Each transition updates the record and potentially triggers a coverage recalculation.
GPS device authentication and data validation. GPS/AVL (Automatic Vehicle Location) devices on ambulances typically connect through a vendor gateway (the AVL vendor's cloud platform) that forwards position reports to your infrastructure. The vendor gateway authenticates to your GPS ingress API Gateway using API keys or IAM auth (SigV4). Each device has a registered identifier tied to a known unit in your fleet roster. The GPS processor Lambda validates every incoming position fix before updating fleet state:
- Coordinate bounds check: Latitude and longitude must fall within your service area bounding box (with a generous buffer for mutual aid runs). Reject coordinates that are clearly impossible (e.g., latitude 0, longitude 0 - a common GPS device default when it loses signal lock).
- Speed plausibility: Compare the reported speed (or calculate speed from consecutive positions) against physical limits. An ambulance cannot travel at 300 km/h. If computed speed exceeds a threshold (say, 200 km/h), flag the fix as suspect and log it, but do not update fleet state with it.
- Timestamp recency: Reject GPS fixes with timestamps more than 60 seconds old. Stale positions being treated as current is a common failure mode when network connectivity is intermittent.
- Impossible movement detection: If a unit's position jumps more than 5 km between consecutive fixes (within a 15-second reporting interval), flag it as a potential device malfunction or GPS spoofing. Hold the previous known-good position and alert the fleet operations team. Two consecutive implausible fixes from the same device should trigger a device health investigation.
These validations run before the DynamoDB write. Invalid fixes are logged to a separate "GPS anomalies" table for fleet maintenance to review (battery issues, antenna problems, device firmware bugs).
FUNCTION process_gps_update(gps_event):
// GPS event contains: unit_id, latitude, longitude, timestamp, speed, heading
unit_id = gps_event.unit_id
latitude = gps_event.latitude
longitude = gps_event.longitude
timestamp = gps_event.timestamp
// Update the fleet state table with the new position.
// Use a conditional write to avoid overwriting with an older GPS fix
// (out-of-order delivery is possible with streaming systems).
UPDATE fleet_state_table
SET latitude = latitude,
longitude = longitude,
last_gps_time = timestamp,
speed = gps_event.speed,
heading = gps_event.heading
WHERE unit_id = unit_id
AND last_gps_time < timestamp // only accept newer fixes
// If conditional write fails (out-of-order fix), catch the exception and discard.
// This is expected behavior, not an error. Log at DEBUG level for troubleshooting.
// Do NOT retry or raise; the newer fix is already in the table.
// If the unit is currently en route to a call, recalculate ETA.
unit_record = GET fleet_state_table WHERE unit_id = unit_id
IF unit_record.status == "EN_ROUTE":
new_eta = calculate_travel_time(latitude, longitude, unit_record.destination)
UPDATE fleet_state_table SET current_eta = new_eta WHERE unit_id = unit_id
Step 2: Score candidate units for dispatch. When a call comes in, the dispatch optimizer needs to evaluate every available unit that meets the capability requirement. For each candidate, it computes a composite score that balances response time (primary), coverage impact (secondary), and operational factors (crew fatigue, fuel level, time remaining on shift). The scoring function is the heart of the system. A pure "closest unit" approach is fast but myopic. The scoring function adds system-level awareness: "yes, Unit 3 is closest, but sending it leaves the entire north zone uncovered, and Unit 5 is only 90 seconds farther." This is where optimization beats human intuition at scale.
FUNCTION score_candidates(call, available_units, fleet_state, coverage_model):
// call contains: location (lat/lng), priority (1-5), required_capability (ALS/BLS),
// nature_code, patient_age
candidates = []
FOR each unit in available_units:
// Filter: unit must meet capability requirement
IF call.required_capability == "ALS" AND unit.capability != "ALS":
CONTINUE // skip this unit, it can't handle this call
// Get travel time from unit's current position to call location.
// Check cache first; fall back to routing service for cache miss.
travel_time = get_travel_time(
origin = (unit.latitude, unit.longitude),
destination = call.location
)
// Calculate coverage impact: what happens to system coverage if we send this unit?
// This is the key differentiator from simple proximity dispatch.
coverage_impact = coverage_model.evaluate_removal(unit.unit_id, unit.zone)
// coverage_impact is a score from 0 (no impact) to 1 (critical gap created)
// Operational factors
hours_on_shift = (current_time - unit.shift_start) / 3600
fatigue_penalty = 0.0
IF hours_on_shift > 10:
fatigue_penalty = 0.1 * (hours_on_shift - 10) // slight penalty for long shifts
// Composite score (lower is better)
// Weights are configurable and should be tuned per system
score = (
0.60 * normalize(travel_time, max=20) // response time (dominant factor)
+ 0.25 * coverage_impact // coverage preservation
+ 0.10 * fatigue_penalty // crew welfare
+ 0.05 * normalize(unit.calls_today, max=10) // workload balance
)
// Priority 1 calls: override coverage concern, pure speed matters
IF call.priority == 1:
score = 0.90 * normalize(travel_time, max=20) + 0.10 * coverage_impact
candidates.append({
unit_id: unit.unit_id,
travel_time: travel_time,
score: score,
coverage_gap: coverage_impact
})
// Sort by score (ascending = best first)
SORT candidates BY score ASC
RETURN candidates
Step 3: Select destination hospital. Once a unit is assigned and the patient is assessed on scene, the system recommends a destination hospital. This is not always "the closest ED." A STEMI patient needs a cath lab. A stroke patient needs a certified stroke center. A trauma patient needs a Level I or II trauma center. And even when multiple hospitals meet the clinical requirement, you want to factor in current capacity: an ED with 40 patients boarding in the hallway and a 3-hour wait is not a good destination even if it's 2 minutes closer. Hospital diversion status, ED census, and specialty availability all feed into this decision.
FUNCTION select_hospital(patient_needs, unit_location, hospital_status_table):
// patient_needs contains: required_capabilities (list), acuity_level, special_requirements
// Example: required_capabilities = ["cath_lab", "interventional_cardiology"]
eligible_hospitals = []
FOR each hospital in hospital_status_table:
// Hard filter: hospital must have required capabilities
IF NOT all(cap in hospital.capabilities FOR cap in patient_needs.required_capabilities):
CONTINUE
// Hard filter: hospital must not be on diversion for this patient type
IF hospital.diversion_status == "FULL_DIVERSION":
CONTINUE
IF hospital.diversion_status == "CONDITIONAL" AND patient_needs.acuity_level < 3:
CONTINUE // conditional diversion: only accepting critical patients
// Calculate transport time from unit's current location to hospital
transport_time = get_travel_time(unit_location, hospital.location)
// Capacity score: lower ED census relative to capacity is better
capacity_ratio = hospital.current_ed_census / hospital.ed_capacity
capacity_score = capacity_ratio // 0.0 = empty, 1.0 = at capacity
// Composite destination score (lower is better)
dest_score = (
0.50 * normalize(transport_time, max=30) // transport time matters most
+ 0.35 * capacity_score // avoid overwhelmed EDs
+ 0.15 * (1.0 IF hospital.has_specialty_bed ELSE 0.5) // specialty bed availability
)
eligible_hospitals.append({
hospital_id: hospital.id,
name: hospital.name,
transport_time: transport_time,
score: dest_score,
capabilities: hospital.capabilities
})
SORT eligible_hospitals BY score ASC
RETURN eligible_hospitals[0] // recommend the best option
Step 4: Background coverage optimization (repositioning). This runs every 2 to 5 minutes, or whenever a significant state change occurs (unit dispatched, unit becomes available, demand spike detected). It solves the coverage problem: given current unit positions and predicted demand, are there zones where response time would exceed the target if a call came in? If so, which idle unit should reposition to close the gap? This is where the heavier optimization lives. Because it's not blocking an active emergency, it can take 10 to 30 seconds to solve. The solver formulates this as a set-covering problem: minimize the number of unit moves while ensuring every demand zone has at least one unit within the target response time.
FUNCTION optimize_repositioning(fleet_state, demand_forecast, coverage_threshold):
// Get all idle (available) units and their current positions
idle_units = [u FOR u IN fleet_state WHERE u.status == "AVAILABLE"]
// Get demand zones with predicted call probability for next 30 minutes
demand_zones = demand_forecast.get_zone_probabilities(horizon_minutes=30)
// For each demand zone, check if any idle unit can reach it within threshold
uncovered_zones = []
FOR each zone in demand_zones:
IF zone.predicted_calls < 0.1:
CONTINUE // very low probability, don't worry about it
// Find the fastest unit that could reach this zone's centroid
min_time = INFINITY
FOR each unit in idle_units:
time = get_travel_time(unit.location, zone.centroid)
min_time = MIN(min_time, time)
IF min_time > coverage_threshold:
uncovered_zones.append({
zone: zone,
current_best: min_time,
demand_weight: zone.predicted_calls
})
IF uncovered_zones is empty:
RETURN [] // coverage is adequate, no moves needed
// Solve: which idle units should move where to cover the gaps?
// Formulate as assignment problem: minimize total repositioning cost
// while covering all high-priority gaps.
moves = solve_coverage_assignment(
units = idle_units,
gaps = uncovered_zones,
max_moves = 3, // don't move more than 3 units at once
move_cost_weight = 0.3, // penalize long repositioning drives
coverage_weight = 0.7 // prioritize closing coverage gaps
)
// Issue move-up commands
FOR each move in moves:
SEND move_up_command(
unit_id = move.unit_id,
destination = move.target_position,
reason = "Coverage gap in zone " + move.zone.id
)
RETURN moves
Step 5: Demand forecasting. The repositioning optimizer needs to know where calls are likely to come from. This is a spatial-temporal forecasting problem. Historical call data shows strong patterns: more calls in residential areas during evenings, more in commercial districts during business hours, spikes near bars after midnight on weekends, seasonal patterns around holidays. The forecast model takes time-of-day, day-of-week, weather, and special events as inputs and produces a probability distribution over the service area grid. This doesn't need to be perfect. Even a rough forecast that captures the major patterns dramatically improves proactive positioning versus purely reactive dispatch.
FUNCTION forecast_demand(current_time, weather, special_events, historical_data):
// Divide service area into grid zones (typically 1km x 1km for urban, larger for rural)
// For each zone, predict call probability in the next 30-minute window
features = {
hour_of_day: current_time.hour,
day_of_week: current_time.weekday,
month: current_time.month,
is_holiday: check_holiday_calendar(current_time),
temperature: weather.temperature,
precipitation: weather.precipitation_mm,
special_events: encode_events(special_events) // concerts, sports, etc.
}
// Call the trained ML model (hosted on SageMaker endpoint)
zone_predictions = invoke_sagemaker_endpoint(
endpoint_name = "ems-demand-forecast",
payload = features
)
// zone_predictions is a map: zone_id -> predicted_call_count (float, 0 to N)
// Normalize to probabilities for the coverage optimizer
total_predicted = SUM(zone_predictions.values())
zone_probabilities = {
zone_id: count / total_predicted
FOR zone_id, count IN zone_predictions
}
RETURN zone_probabilities
Curious how this looks in Python? The pseudocode above covers the concepts. If you'd like to see sample Python code that demonstrates these patterns using boto3, check out the Python Example. It walks through each step with inline comments and notes on what you'd need to change for a real deployment.
Dispatch Decision Audit Trail
Every dispatch decision must be logged as an immutable audit record. In EMS, dispatch records are legal documents. They may be subpoenaed in malpractice cases, reviewed by medical directors for quality assurance, or examined by regulatory bodies during accreditation audits. The audit trail is not optional.
What gets logged (full decision payload):
| Field | Description |
|---|---|
call_id |
Unique identifier from the CAD system |
call_priority |
Priority level at time of dispatch |
call_location |
GPS coordinates of the incident |
nature_code |
Dispatch nature code (chest pain, trauma, etc.) |
all_candidate_scores |
Every unit evaluated, with individual score components (travel time, coverage impact, fatigue, workload) |
fleet_state_snapshot |
Positions and statuses of all units at decision time |
travel_times_used |
The travel time values that informed scoring (and their source: cache hit vs. Location Service call) |
assigned_unit_id |
Which unit the optimizer recommended |
dispatcher_action |
Accept, override, or auto-dispatched (Priority 1) |
override_reason_code |
If overridden, why (local knowledge, crew request, etc.) |
dispatcher_id |
Who made the final dispatch decision |
decision_timestamp |
When the optimizer produced the recommendation |
confirmation_timestamp |
When the dispatcher confirmed (or when auto-dispatch timeout expired) |
recommended_hospital |
Hospital recommendation at dispatch time |
Storage: DynamoDB table with partition key call_id and sort key decision_timestamp. Enable DynamoDB point-in-time recovery. For immutability, use an IAM policy that denies dynamodb:DeleteItem and dynamodb:UpdateItem on the audit table for all principals except a break-glass administrative role. Alternatively, export audit records to S3 with Object Lock (Governance or Compliance mode) for tamper-evident long-term storage.
Retention: Minimum 7 to 10 years. State EMS record retention requirements vary (California requires 7 years, New York requires 6 years from last patient contact, others vary). Default to 10 years to cover the most stringent state requirements plus a buffer for late-filed litigation. Use DynamoDB TTL for the active table combined with automated export to S3 Glacier Deep Archive after 12 months. The S3 bucket uses Object Lock in Compliance mode, meaning nobody (including root) can delete records before the retention period expires.
Access control: The audit table is write-once from the dispatch Lambda and the dispatcher console. Read access is restricted to: medical director role, quality assurance team, legal/compliance team, and system administrators. All reads are logged via CloudTrail. Any bulk export requires a formal request through your compliance workflow.
Expected Results
Sample dispatch decision output:
{ "call_id": "CAD-2026-0601-1847", "call_priority": 1, "call_location": {"lat": 38.9072, "lng": -77.0369}, "nature_code": "CHEST_PAIN", "assigned_unit": { "unit_id": "MEDIC-7", "capability": "ALS", "estimated_response_time_seconds": 312, "current_location": {"lat": 38.9121, "lng": -77.0298}, "route_distance_km": 2.1 }, "recommended_hospital": { "hospital_id": "HOSP-GWU", "name": "GW University Hospital", "capabilities": ["cath_lab", "interventional_cardiology", "level_1_trauma"], "estimated_transport_minutes": 8, "current_ed_census": 24, "ed_capacity": 45 }, "coverage_impact": { "zone_gap_created": false, "nearest_backup_unit": "MEDIC-12", "backup_response_time_seconds": 480 }, "decision_timestamp": "2026-06-01T18:47:03.221Z", "solver_time_ms": 847 }
Performance benchmarks:
| Metric | Typical Value |
|---|---|
| Dispatch decision latency | 500ms to 2 seconds |
| GPS state update latency | < 200ms (Kinesis to DynamoDB) |
| Repositioning solve time | 10 to 30 seconds |
| Demand forecast inference | < 500ms |
| Response time improvement vs. proximity-only | 1 to 3 minutes average reduction |
| Coverage maintenance | > 95% of zones within threshold |
| Hospital selection accuracy | > 90% agreement with retrospective clinical review |
Where it struggles:
- Simultaneous multi-casualty incidents. The optimizer is designed for steady-state operations. A mass casualty event (MCI) overwhelms the model because it violates the assumption of independent, sequential calls. MCI protocols require a different decision framework entirely.
- GPS dead zones. Parking garages, tunnels, dense urban canyons. If you lose GPS for 2 minutes, the fleet state is stale and dispatch decisions degrade.
- Rapid demand spikes. A sudden weather event or large-scale accident can generate a burst of calls that exceeds fleet capacity. The optimizer can only assign units that exist; it can't create new ones.
- Inter-agency coordination. Many metro areas have overlapping EMS jurisdictions (fire department, private ambulance, hospital-based EMS). The optimizer only controls units it can see. Mutual aid decisions still require human coordination.
Why This Isn't Production-Ready
This architecture gives you a working dispatch optimization system in a sandbox. Here's what stands between this and handling real 911 calls:
Simulation environment. You cannot test dispatch optimization changes on live emergency calls. Before deploying any change to scoring weights, coverage thresholds, or solver parameters, you need a discrete-event simulator that replays months of historical call patterns against your fleet model. Compare response time distributions between the old and new logic across thousands of simulated days. This simulator is a prerequisite for production, not an enhancement.
Dispatcher UI and workflow integration. The architecture defines the dispatcher-in-the-loop mechanism, but building the actual dispatcher console (real-time candidate ranking, one-click accept/override, coverage map visualization, override reason tracking) is a significant frontend and UX effort. Dispatchers work under extreme time pressure; the UI must be faster and more intuitive than their existing workflow or they will reject it.
CAD system integration testing. The optimizer must integrate with your CAD vendor's system (Tyler New World, Hexagon, Motorola PremierOne, or similar). These are proprietary platforms with their own APIs, message formats, and latency characteristics. Integration testing and certification with the CAD vendor is typically a 3 to 6 month effort involving the vendor's professional services team.
Regulatory and medical direction approval. An optimization system that influences which ambulance responds to a cardiac arrest requires approval from your EMS medical director (who has legal authority over clinical protocols), your governing EMS authority (state or regional), and potentially your accreditation body. This is not a rubber stamp. Expect a structured evaluation period where the system runs in shadow mode (recommendations logged but not acted on) for 60 to 90 days while clinical leadership reviews decision quality.
Travel time model calibration. The Haversine or even Location Service travel times are starting points. Production accuracy requires calibrating against your fleet's actual GPS trace history: how long does it really take your ambulances to get from point A to point B at 3 PM on a Tuesday? Build this calibration from 6+ months of historical run data before trusting the optimizer's time estimates for patient-care decisions.
Failover load testing. The fallback paths described in the Failover section must be tested under realistic failure conditions. Inject faults (kill ElastiCache, throttle DynamoDB, black-hole Location Service) and verify the system degrades gracefully and the CAD system takes over within the 3-second timeout. Run these tests monthly.
Variations and Extensions
Multi-Agency Coordination
In metro areas with multiple EMS providers (fire-based, hospital-based, private), extend the optimizer to consider mutual aid units. This requires data sharing agreements, standardized status reporting across agencies, and a shared dispatch protocol. The technical challenge is modest (just more units in the candidate pool). The organizational challenge is enormous. Start with a read-only view of partner agency positions, then graduate to cross-agency dispatch recommendations.
Predictive Dispatch (Pre-Positioning for Events)
For planned events (concerts, sporting events, parades), pre-position units based on historical call patterns for similar events. This is a batch optimization problem: given the event location, expected attendance, duration, and historical incident rates for similar events, where should you stage units and how many? Solve this hours or days in advance. The demand forecast model can be extended with event features to improve predictions.
Dynamic Routing with Real-Time Traffic Rerouting
Once a unit is dispatched and en route, continue monitoring the route for traffic changes. If an accident blocks the planned route, automatically compute an alternative and push it to the unit's MDT (Mobile Data Terminal). This requires continuous route monitoring (not just a one-time calculation at dispatch) and integration with the in-vehicle navigation system. The marginal improvement is small (maybe 30 seconds saved on rare occasions), but in cardiac arrest, 30 seconds matters.
Additional Resources
AWS Documentation
- Amazon Location Service Route Calculator: Route calculation API including travel time matrices
- Amazon Location Service Pricing: Per-request pricing for route calculations
- Amazon Kinesis Data Streams Developer Guide: High-throughput streaming ingestion for GPS data
- Amazon SageMaker Real-Time Inference: Hosting ML models for low-latency prediction
- AWS Step Functions Developer Guide: Orchestrating multi-step optimization workflows
- Amazon ElastiCache for Redis: In-memory caching for travel time lookups
- DynamoDB Streams: Change data capture for fleet state events
Industry References
- NEMSIS (National EMS Information System): National standard for EMS data collection and reporting. Useful for understanding data structures and benchmarking.
Related Concepts
- Vehicle Routing Problem (VRP) and its dynamic variants
- Set Covering Location Problem (SCLP) for station placement
- System Status Management (SSM) for dynamic redeployment
- Medical Priority Dispatch System (MPDS) for call triage protocols
Estimated Implementation Time
| Phase | Duration | What You Get |
|---|---|---|
| Basic | 3-4 months | Proximity-based dispatch with travel time (replaces straight-line distance), basic fleet state tracking, single-hospital recommendation |
| Production-ready | 8-12 months | Full scoring function with coverage awareness, background repositioning, demand forecasting, hospital capacity integration, dispatcher UI, simulation environment |
| With variations | 14-18 months | Multi-agency coordination, predictive pre-positioning for events, dynamic rerouting, full analytics dashboard with response time reporting |
Tags: optimization ยท vehicle-routing ยท real-time ยท ems ยท dispatch ยท geospatial ยท operations-research ยท coverage ยท fleet-management
| โ 14.7: OR Case Sequencing | Chapter 14 Index | 14.9: Chemotherapy Scheduling โ |
โ Main Recipe 14.8 ยท Python Example ยท Chapter Preface