engineering case study

Meridian: testing fleet operations before launch

A simulation and experimentation platform for fleet, charging, dispatch, and market-expansion decisions.

Meridian is a self-serve product for the moment before a fleet-wide change ships. It combines probabilistic demand forecasting, constraint-aware optimization, and discrete-event simulation so an operations team can ask what a policy change costs before finding out in production.

Role

Simulation architecture, the forecasting and dispatch models, the FastAPI service, and the TypeScript frontend.

Built with

Next.js · TypeScript · Python · FastAPI · Pydantic · SimPy · OR-Tools · LightGBM · Pytest

Meridian is a fictional product. It is not affiliated with, derived from, or representative of any real ride-hail operator, and it uses no proprietary data. Every figure below is simulated output from synthetic demand.

Test a fleet change

Precomputed Meridian simulation · synthetic demand

Loading precomputed Meridian scenarios…

Engineering brief

Architecture
A monorepo: a Python FastAPI service running the simulation, and a Next.js TypeScript frontend. A LightGBM quantile model forecasts demand, scenario generation samples nights from the fitted spread, a SimPy model runs the night, and an OR-Tools solver assigns vehicles. Pydantic models are mirrored as TypeScript types.
The hard part
Making a run reproducible. A run is determined by its config and seed and carries an input snapshot with policy, model and engine versions, so a result someone disputes can be replayed exactly, including on another machine.
Testing
25 Pytest cases across config, demand, dispatch, runner behaviour and reproducibility. The reproducibility test caught a real bug: the served-zone set was a Python set, and per-process string hash randomisation fed a different order into the sampler, so identical seeds diverged across machines while the suite stayed green.
Known limitation
No routing engine: distance is straight-line times a circuity factor at one average speed, which is the largest single source of error. Demand is synthetic, there is no significance testing on the difference between arms, and nothing is calibrated against a change that actually shipped.
the problem

“It depends on the night” is a real answer, and a useless one

An operations team wants to expand a service area, prioritise airport pickups, or change how vehicles are dispatched. Someone asks what it will cost. The honest answer is that it depends on demand, on how much charging capacity is free that evening, and on where the fleet happens to be sitting when the demand arrives.

A spreadsheet becomes brittle once demand, vehicle state, charging queues, and dispatch policy interact over time. So these decisions get made from intuition and a partial rollout, and the failure mode is consistent: the change looks fine for a week, then a busy Friday exposes a constraint nobody was watching, usually somewhere other than where the change was made.

Meridian exists to move that discovery earlier. It runs the change against many sampled nights, reports the spread rather than a point estimate, and returns a decision with the reasoning and the guardrails attached.

users and jobs

Three teams, one artifact

The hard part of a tool like this is that the three groups who need it want different things from the same run, and a product that serves only one of them gets ignored by the other two.

Operations

Decide whether a policy or service-area change is safe to roll out, and know what to watch if it goes to pilot.

The verdict, the guardrails, and the zones that get worse rather than better.

Data science

Check that the forecast underneath a recommendation is calibrated, and that its uncertainty reached the answer.

Model version, holdout coverage, and the per-replication spread rather than a mean.

Engineering

Understand what a policy change actually requires, and reproduce a result that looks wrong.

A versioned config, a seed, an input snapshot, and a run that replays exactly.

The run output is built so one artifact answers all three: a verdict and guardrails on top for operations, the calibration and spread in the middle for data science, and a reproducible snapshot at the bottom for engineering.

Meridian experiment workspace: baseline and proposed configurations side by side, a list of assumptions in force, and a control rail with fleet size, charger count, airport battery reserve, and policy toggles.
The workspace states both arms and the assumptions in force before anything runs. Controls edit only the proposed arm, because editing both is how you end up comparing two things you no longer understand.Open full size ↗
design decisions

What the system refuses to do

The recommendation is a rule, not a model

A learned recommender would be less useful here, because the disagreement is almost never about the arithmetic. It is about the target. An operator has to be able to read why a change was called, disagree with a threshold, edit it in the config, and re-run. Making the verdict a transparent rule over the experiment’s own targets keeps that argument possible.

“Pilot” is the verdict that earns its keep

A binary launch-or-reject is too crude for this decision. A change that moves the primary metric a long way without quite reaching its goal is a pilot candidate: the goal may be wrong, or the remaining gap may be closable with capacity rather than policy. What earns a rejection is a change that does not meaningfully move the metric it was designed to move.

No metric is a bare point estimate

Every figure in the product is a mean with a P10–P90 band, and the uncertainty chart plots one bar pair per simulated night. That is not decoration. The central finding in the flagship experiment is that the proposed change clears its target on a typical night and breaches a guardrail on a busy one, and a table of means would have hidden it.

Both arms see the same night

Each replication samples demand once and runs baseline and proposed against that identical draw. Pairing removes weather from the comparison, so any difference between the arms is policy. It also buys far more signal per second of compute than simply running more independent nights.

Zones are a schematic, not a map

The service area is invented, so drawing it over real cartography would imply a real market. Circles sized by demand and filled by completion rate show which parts of the network a change helps and which it strains, which a choropleth of a real city would obscure.

Experiments are files, not database rows

An experiment definition is a YAML file that moves through pull request review like the code it tests. That is also what makes a config version and an input snapshot mean something: a result is tied to a definition someone approved.

system architecture

Forecast, sample, simulate, decide

A LightGBM quantile model learns P10, P50, and P90 demand for every zone and every fifteen-minute interval. Those quantiles are not a chart: scenario generation draws each night's arrivals from the fitted spread, so a zone with a genuinely uncertain forecast moves more than a zone with a tight one, and forecast uncertainty reaches the recommendation instead of stopping at a visualisation.

A SimPy model then runs the night. Vehicles are processes, chargers are a resource with real capacity so queueing emerges from contention rather than assumption, and riders leave if nobody arrives. Two dispatch policies compete on identical demand: greedy nearest-available, and a batched min-cost assignment solved with OR-Tools that encodes airport priority and a battery reserve as cost rather than as hard rules. Whether the optimisation earns its complexity is the question, so both are first-class.

A run is determined by its configuration and seed, and carries an input snapshot, policy version, model version, and engine version. Re-running reproduces the numbers exactly, including on another machine.

experiments/*.yaml  ──▶  FastAPI  ──▶  per replication:
(versioned config)                        sample_scenario   ← P10/P50/P90 spread
                                          FleetSim (SimPy)  ← vehicles, chargers, riders
                                            └ dispatch      ← greedy | OR-Tools
                                          aggregate → bands
                                          recommend → verdict
                                              │
Next.js UI  ◀───────────────────────────────  ┘
library · workspace · results

Building it surfaced more design errors than writing it. Dispatch was assigning vehicles twenty-two minutes away to riders with eleven minutes of patience, so both arms threw away trips for no reason. The optimiser went infeasible whenever pending requests outnumbered idle vehicles and quietly fell back to greedy, which meant the optimised arm was running the baseline during exactly the busy periods it existed for. Repositioning aimed one interval ahead while crossing the metro took longer than one interval, so vehicles arrived after the demand they were sent for.

The one worth keeping was reproducibility. The set of served zones was a Python set, and CPython randomises string hashing per process, so iterating it fed a different order into the destination draw on every run. Identical configs with identical seeds diverged between machines while passing every test, because an in-process repeat cannot catch it. The fix is small. Finding it required not trusting a green suite.

the flagship experiment

Late-night airport expansion

Can airport priority and service-area expansion meet a seven-minute pickup target without overloading overnight charging?

The baseline is current operations: 220 vehicles, two depots, thirty-six chargers, nearest-available dispatch, existing service area. The proposed arm keeps the same fleet and adds an airport-priority queue, a fifteen percent battery reserve for airport trips, demand-aware repositioning, and three new zones.

Simulated comparison of baseline and proposed arms across twenty paired replications.
MetricBaselineProposedChange
Airport P90 pickup, curbside9.8 min7.4 min−24%
Trip completion rate90.8%87.2%−3.6 pts
Completed trips2,5782,618+41
Deadhead share of miles26.8%31.6%+4.8 pts
P90 charger queue6.1 min12.1 min+99%
Cost per completed trip$3.61$3.74+3.4%

Twenty paired replications, seed 20260214. Simulated output from synthetic demand, not observed performance.

Meridian uncertainty view: two bar charts showing airport P90 pickup and P90 charger queue for each of twenty replications, with dashed target lines, beside a zone schematic and a table of zone-level completion rates.
One bar pair per simulated night. Bars past the dashed line missed the target that night, which is how the charging risk becomes visible at all.Open full size ↗
results and tradeoffs

Pilot recommended, and the reason is not dispatch

Airport pickup improves substantially and the fleet completes more trips overall. But trip completion rate drops three and a half points as expansion adds demand faster than the policy absorbs it, deadhead rises by nearly five points, and under upper-tail demand the charger queue reaches roughly double its baseline and breaches the twelve-minute guardrail.

The useful part is where the constraint turned out to live. The experiment was designed as a dispatch question, and dispatch is not what limits it. More vehicles finish a busier night needing charge at the same time, and thirty-six stalls cannot absorb that. A fleet-wide launch would have found this on a Friday.

So the recommendation is a staged pilot on the airport zone and two expansion zones, with charger headroom monitored nightly and depot capacity added before the full service area follows. The product also declines to overclaim: the proposed arm closes most of the gap to the seven-minute target without reaching it, and the summary says so rather than moving the goalposts.

Demand is synthetic

The model is trained on generated history, so its holdout metrics measure whether it learned its own generator, not whether it would forecast a real market. Everything downstream treats the model as a versioned artifact, so swapping in a real trip feed is a substitution rather than a rewrite.

There is no routing engine

Distance is straight-line times a circuity factor at a single average speed. Absolute pickup times are indicative; the comparison between two arms under identical geography is the result. This is the largest single source of error in the numbers.

No significance testing yet

With twenty replications, a small difference between arms is not distinguishable from noise, and the product does not currently say so. It is the most important missing piece of statistical rigour, and it is first on the roadmap for that reason.

Unit economics are illustrative

Cost per completed trip uses assumed vehicle-hour, per-mile, and charging figures. The direction of the change is meaningful; the level is arbitrary and labelled as such in the product.

Uncalibrated against reality

No backtest against a known past change exists, so Meridian is directionally useful and quantitatively unvalidated. The documentation says that in those words rather than leaving it implied.

production roadmap

What I would build next

Ordered by what would hurt first if this had to support real decisions rather than demonstrate how they could be supported.

  1. 01

    Real demand, on a schedule

    Replace generated history with a trip feed and retrain on a cadence, with drift monitoring. The model is already a versioned artifact with its coverage reported in every run, so this slots in without touching the simulation.

  2. 02

    Confidence on the difference, not the outcome

    Report intervals on the delta between arms, add power analysis to size replication counts, and stop sweeps early once the answer is settled. This is what turns “looks better” into “is better”.

  3. 03

    A job queue

    Runs move to a worker pool with persisted status and object-storage output, so a two-hundred-replication sweep is not an HTTP request. Run history stops being process memory.

  4. 04

    Routing and time-of-day speeds

    A real routing engine removes the biggest approximation in the model and makes absolute pickup times defensible rather than indicative.

  5. 05

    Calibration against a known change

    Backtest a change that already shipped and check the simulation’s predicted direction and magnitude. Until that exists, the tool supports decisions but cannot claim accuracy.

  6. 06

    Charging fidelity

    Charge curves rather than a linear rate, per-stall power limits, and depot electrical constraints. The flagship experiment is charging-bound, so this is where the answer is most sensitive to the model being wrong.

Not deployed as a hosted demo. It runs locally in two commands, and the README covers setup and a walkthrough.