Reference
Reference
Built from the rate card and the engine. Nothing typed by hand. Worked job 1: TA-33 · 45 mph · 1,500 ft · 3 days · Arizona.
The rate card
Every number the math uses is one row in sim/rates/arizona.csv. 37 rows. 36 rates not confirmed.
# sim/rates/arizona.csv
key,label,value,low,high,unit,status,source
hours.setup_base_minutes,Setup base time,20,15,30,min,default,
price.trip_fee,Fee per gear trip,150,,,$/trip,placeholder,
price.truck_crew_rate_per_hour,Crew rate,60,,,$/hour each,placeholder,
To keep a rate for every job: change value, set status to confirmed, put who said so in source. Then run ./run-tests.sh. It names every line that moved.
low and high are the cheap and dear ends. Blank = same as value. The prefix names the step that reads the row. A bad row fails on load with its line number.
| Status | Means | Rows |
| confirmed | Someone with authority said so. The source names who and when. | 1 |
| assumed | Reasoned from what we were told. Needs a yes. | 5 |
| placeholder | A round number so the math runs. Every rate starts here. | 15 |
| unknown | We don't know. Ask. | 1 |
| default | A generic starting value. Nobody has looked at it for Arizona yet. | 15 |
| Row | Value | Unit | Status |
| 1 · gear() |
|---|
Fewest flaggers on a flagging jobgear.min_flaggers_on_flagging_jobTwo-way traffic control needs one flagger per end | 2 | people | default |
| 2 · calendar() |
|---|
Overnight policycalendar.overnight9/23 review call: gear is often left on site between work days. Choices: leave_on_site, leave_in_place, remove_nightly | leave on site | — | assumed |
Crew stays on site all daycalendar.truck_crew_stays_on_site9/23 review call: the crew that sets up can be separate from the flaggers | no | — | assumed |
Extra panels when the zone movescalendar.move_extra_fraction | 0.2 (0.1–0.35) | share | default |
| 3 · hours() |
|---|
Drive time, yard to sitehours.drive_minutes_one_way | 35 (20–60) | min | default |
Load or unload at the yardhours.load_minutes_per_trip | 15 (10–25) | min | default |
Setup base timehours.setup_base_minutes | 20 (15–30) | min | default |
Setup time per panelhours.minutes_per_channelizerNumber check 2026-09-29: Caltrans cone-truck study ~0.1-0.2 min/device; panels on bases slower (~0.25-0.5). Confirm with AWP. | 0.4 (0.15–0.75) | min | assumed |
Setup time per signhours.minutes_per_sign | 5 (4–8) | min | default |
Setup time per other itemhours.minutes_per_device | 12 (8–20) | min | default |
Quick reset, share of a full setuphours.reset_fraction | 0.35 (0.25–0.5) | share | default |
Zone move, share of a full setuphours.move_fraction | 0.6 (0.4–0.8) | share | default |
Overnight stow, share of a full setuphours.stow_fraction | 0.3 (0.2–0.4) | share | default |
Full takedown, share of a full setuphours.takedown_fraction | 0.75 (0.6–0.9) | share | default |
| 4 · crew() |
|---|
Small job: up to this many panelscrew.small_job_max_channelizers | 40 | panels | default |
Small job crewcrew.small_job_truck_crew | 2 | people | default |
Medium job: up to this many panelscrew.medium_job_max_channelizers | 120 | panels | default |
Medium job crewcrew.medium_job_truck_crewNumber check 2026-09-29: AWP Illinois lane-closure package and Caltrans cone trucks use 2 people. Confirm with AWP. | 2 | people | assumed |
Large job crewcrew.large_job_truck_crewNumber check 2026-09-29: medium jobs dropped to 2 people (AWP Illinois package + Caltrans); large jobs step up one to 3. Confirm with AWP. | 3 | people | assumed |
Crew members per crew vehiclecrew.crew_members_per_vehicleOPI-411 §12 worked example; not an AWP number | 2 | people | default |
| 5 · price() |
|---|
How driving is chargedprice.trip_billing9/23 review call: drive time is recorded and charged as a delivery or a pickup. Choices: per_event, per_hour | per event | — | confirmed |
Fee per gear tripprice.trip_fee | 150 | $/trip | placeholder |
Fee per empty tripprice.zero_gear_trip_feeEmpty trips bill (9/23 review call). Same fee as a gear trip? Ask. | 150 | $/trip | unknown |
Truck cost per drive hour (per_hour billing only)price.truck_rate_per_hour | 45 | $/h | placeholder |
Crew rateprice.truck_crew_rate_per_hour | 60 | $/hour each | placeholder |
Flagger rateprice.flagger_rate_per_hour | 55 | $/hour each | placeholder |
Flagger minimum callprice.site_min_hours_per_day | 4 | h/day | placeholder |
Overtime starts afterprice.overtime_after_hours_per_day | 8 | h/day | placeholder |
Overtime multiplierprice.overtime_multiplier | 1.5 | × | placeholder |
Pay flaggers for travel timeprice.site_crew_travel_paidCommon practice; AWP's rule unknown | yes | — | placeholder |
Crew vehicleprice.crew_vehicle_rate_per_day | 60 | $/vehicle-day | placeholder |
Panel rentalprice.channelizer_rate_per_day | 1 | $/panel-day | placeholder |
Sign rentalprice.sign_rate_per_day | 5 | $/sign-day | placeholder |
Arrow board rentalprice.arrow_board_rate_per_day | 45 | $/board-day | placeholder |
Type III barricade rentalprice.type_iii_barricade_rate_per_day | 4 | $/barricade-day | placeholder |
Any other gear rentalprice.other_device_rate_per_dayShadow vehicle, work vehicle and anything else without its own row | 10 | $/item-day | placeholder |
Flat job feeprice.job_fee | 0 | $ | placeholder |
How the range is worked out
The price = every rate-card number at its usual value. The receipt adds up to it, to the cent.
The range = move one number to its low end, then its high end. Everything else stays put. Note how far the total moves.
Then add the moves the way independent errors add: square root of the sum of squares. Not every number wrong at once. Same job, same answer.
| Number | Total at its low end | At its high end | Down | Up |
| Panel count | $1,860.60 | $1,980.60 | −$60.00 | +$60.00 |
| Setup base time | $1,890.00 | $1,981.80 | −$30.60 | +$61.20 |
| Setup time per panel | $1,829.40 | $2,049.00 | −$91.20 | +$128.40 |
| Setup time per sign | $1,872.00 | $2,067.00 | −$48.60 | +$146.40 |
| Setup time per other item | $1,896.00 | $1,969.20 | −$24.60 | +$48.60 |
| Quick reset, share of a full setup | $1,882.20 | $1,978.20 | −$38.40 | +$57.60 |
| Overnight stow, share of a full setup | $1,882.20 | $1,959.00 | −$38.40 | +$38.40 |
| Full takedown, share of a full setup | $1,891.80 | $1,949.40 | −$28.80 | +$28.80 |
Down: $140.00. Up: $230.76. Worked job 1: $1,780.60 · $1,920.60 · $2,151.36.
Biggest swing: Biggest swing: setup time per panel, $1,829–$2,049 on its own.
API
Same engine as the page. No login today; BOM_AUTH_REQUIRED=1 turns it on. $HOST is wherever the tool runs. Field names stay as the code has them.
POST/simulate
A job by layout code, straight to the math. The full answer out. Worked job 1:
Request
curl -s -X POST $HOST/simulate \
-H 'Content-Type: application/json' \
-d '{"job": {"ta": "ta-33", "speed_mph": 45, "zone_length_ft": 1500, "days": 3}}'
Response, trimmed
{
"job": {
"ta": "ta-33",
"speed_mph": 45,
"zone_length_ft": 1500,
"days": 3,
"jurisdiction": "arizona"
},
"rate_card": {
"region": "arizona",
"file": "sim/rates/arizona.csv"
},
"range": {
"low": 1780.6,
"typical": 1920.6,
"high": 2151.36,
"reason": "Biggest swing: setup time per panel, $1,829–$2,049 on its own."
},
"categories": {
"labor": 585.6,
"trips": 900.0,
"equipment": 435.0,
"fees": 0.0
},
"lines": [
{
"id": "truck_work",
"category": "labor",
"what": "Crew · setup & takedown",
"qty": 9.76,
"unit": "hours",
"rate": 60,
"amount": 585.6,
"source": "price.truck_crew_rate_per_hour",
"how": "2 people × the minutes on each setup/takedown ÷ 60"
}
],
"total": 1920.6,
"calendar": { "days": 3, "trips": 6, "events": [ …12 events… ], … },
"unconfirmed": [
{
"key": "gear.min_flaggers_on_flagging_job",
"label": "Fewest flaggers on a flagging job",
"value": 2,
"low": 2,
"high": 2,
"unit": "people",
"status": "default",
"source": "Two-way traffic control needs one flagger per end"
},
…35 more
]
}
One job, one different rate: send overrides. That row counts as confirmed for this job.
curl -s -X POST $HOST/simulate \
-H 'Content-Type: application/json' \
-d '{"job": {"ta": "ta-33", "speed_mph": 45, "zone_length_ft": 1500, "days": 3}, "overrides": {"price.flagger_rate_per_hour": 62}}'
POST/quote
The quick-quote page’s API. Address and plain words in. Chips, questions, the quote and a summary out.
status is firm when speed, zone length and days are not guesses. to_confirm names any that are.
- The page is stricter. Checked means no guesses at all. So
firm can still show “1 guess to check” on the page.
summary_text is for people. It lists every guess. Its wording can change: read the fields, not the text.
- Changed in clarity pass 1: “Quote: $X typical … PROVISIONAL” is now “Say this: $X (range …)”, plus a guesses or Checked line.
GET/api/sim/meta
What the page needs to draw its form: the rate card, layouts, states.
{
"rates": [
{
"key": "gear.min_flaggers_on_flagging_job",
"label": "Fewest flaggers on a flagging job",
"value": 2,
"low": 2,
"high": 2,
"unit": "people",
"status": "default",
"source": "Two-way traffic control needs one flagger per end"
},
"…36 more rows"
],
"tas": [
{
"ta": "ta-1",
"title": "Work Beyond the Shoulder",
"validated": null
},
"…"
],
"jurisdictions": [
"alabama",
"…"
]
}
POST/v1/estimate · Salesforce
Takes the OPI-411 create payload as Salesforce builds it (OPI-411 §4). Answers per job type in EstimateResult’s shape.
In (trimmed)
{
"externalId": "MYAPP-123-tcp",
"workOrder": {
"accountId": "…"
},
"jobTypes": [
{
"jobType": "SINGLE_LANE_CLOSURE",
"startDate": "2026-10-01",
"startTime": "9:00AM",
"durationMinutes": 600,
"address": {
"full": "E Baseline Rd, Gilbert AZ",
"latitude": 33.3776,
"longitude": -111.7456
},
"description": "right lane, 1500 ft, 3 days"
}
]
}
Out, per job type (trimmed)
{
"status": "success",
"bom": {
"totals": {
"signCount": 8,
"deviceCount": 1,
"flaggerCount": 0,
"additionalItemCount": 60
},
"signs": [
{
"code": "W20-1",
"label": "ROAD_WORK_AHEAD",
"quantity": 2
},
"…"
],
"devices": [
{
"type": "ARROW_BOARD",
"quantity": 1
}
]
},
"equipment": [
{
"externalProductId": "…",
"quantity": 2
},
"…"
],
"keryk": {
"status": "firm",
"quote": {
"typical": 1921,
"low": 1781,
"high": 2151
},
"layout": "ta-33",
"summary_text": "…",
"unmapped_codes": [
"…"
]
}
}
- Same externalId, same body: the stored answer comes back. Retries never re-run.
- Same externalId, different body:
409. Send a new id for a changed job.
- Bad input:
422, with where and why.
- Panels are
additionalItemCount, not also a device line. Nothing counted twice.
- Job-type codes and product ids: placeholders in
service/config/opi411.json. So equipment[] is empty today.
POST$GEAR_SERVICE_URL · your gear engine
# sim/simlib/kitchen.py · gear()
url = service_url or os.environ.get("GEAR_SERVICE_URL")
if url:
kit = _gear_from_service(job, url, post or _post) # any OPI-411 EstimateResult service
else:
kit = _gear_from_bom(job) # our bom/ engine
What we send
{"ta": "ta-33", "speed_mph": 45, "zone_length_ft": 1500,
"jurisdiction": "arizona", "side": "right", "address": "E Baseline Rd, Gilbert AZ"}
What we read back
{"status": "success",
"bom": {"totals": {"flaggerCount": 0},
"signs": [{"code": "W20-1", "label": "ROAD_WORK_AHEAD", "quantity": 2}],
"devices": [{"type": "ARROW_BOARD", "quantity": 1},
{"type": "CONE", "quantity": 48}],
"channelizerRange": {"low": 40, "typical": 48, "high": 60}}}
status must be "success", or the quote stops with an error.
CONE, DRUM, VERTICAL_PANEL, TUBULAR_MARKER or CHANNELIZER devices become the panel count.
channelizerRange and label are optional. Nothing else is read.
- An env var, not a rate-card row, so a request can’t point the server at any URL.
- Not your shape? Tell us yours and we match it. Need an auth header? Name it.
Command line
From the repo root. No network, no key.
python3 sim/sim.py --ta ta-33 --speed 45 --zone 1500 --days 3Price worked job 1. Prints the receipt and day plan.
… --moves 2The zone moves on day 2.
… --jsonThe full answer as JSON.
… --set price.flagger_rate_per_hour=62Change one rate for this run.
./run-tests.shEvery test. The worked jobs in sim/tests/worked_jobs.md must match to the cent.
python3 sim/docs/build_guide.pyRebuild this page.
Calibrate
Real invoices are the answer key. Put real jobs in. See if the range catches them.
- Add a job to
sim/calibration/cases.json: as typed, plus the invoice total.
- Run
python3 sim/calibration/calibrate.py.
- Read how many landed inside the range. Adjust the rate card. Run again.
{
"id": "inv-0000000000",
"source": "AWP invoice INV-..., job date, planner",
"region": "arizona",
"job": {
"ta": "ta-10",
"speed_mph": 45,
"zone_length_ft": 800,
"days": 3
},
"overrides": {
"price.flagger_rate_per_hour": 57.81
},
"actual": {
"total": 4500.0,
"categories": {
"labor": 3200.0,
"trips": 900.0,
"equipment": 400.0
}
}
}
Today: 0 cases. Every time is a guess until invoices go in.
Glossary
Same thing, same word, everywhere. Source: sim/docs/GLOSSARY.md.
| Say this | Means | Replaces |
| the price · "Say this" | The one number to say. The receipt adds up to it. | typical, typical value |
| the range · low to high | Where the price will likely land. | likely range, one number at a time, cost range |
| a guess (dashed chip) | A fact nobody said. Check it. | assumed, provisional input |
| checked | No guesses left in the job. Rates can still be not confirmed. The API's firm is looser: no guess at speed, zone length or days. | firm |
| has a guess · "1 guess to check" | At least one dashed chip. | provisional |
| placeholder rate | A round number so the math runs. One kind of rate not confirmed. Never say "every rate is a placeholder". | default, stand-in |
| rates not confirmed | Rate-card rows nobody has signed off. | inputs not confirmed, unconfirmed inputs, sign-off |
| trip | One round trip by the truck. Out and back. | run, leg, event, drive back |
| gear trip · empty trip | Carries gear / carries nothing. Both bill. | zero-gear trip, empty run, trip with no gear |
| gear · gear list | What goes on the road. | BOM, kit, devices, equipment |
| pull list | The gear list printed for the yard. | (new word) |
| panels | The cones or panels that line the zone. The code still says channelizers. | channelizer, device |
| layout | The drawing that says which gear goes where. Plain name first, TA code small. | TA, typical application |
| crew · flaggers | Crew sets up (truck crew). Flaggers stay all day (site crew). | truck crew / site crew in order-taker copy |
| setup · stow · reset · takedown | The four jobs the crew does on site. | quick reset share, overnight stow share |
| biggest swing | The one rate that moves the price most. | biggest pull, biggest single pull |
| rate card | The one file with every rate. | arizona.csv (in prose) |
| chip | One fact about the job. | field, token |
| you said · from the map · you picked · a guess | Where a chip came from. | your words · looked up · you set · assumed |
| worked job 1 | TA-33, 45 mph, 1,500 ft, 3 days, Arizona. The one job every film, tour and page uses. | sample job, the Baseline job |