
₹800: planning an electrician's heatwave day with OR-Tools on AWS Lambda
How I built a heat-exposure scheduling prototype for daily-wage work with OR-Tools, AWS Lambda, DynamoDB and Amplify—and what a ₹150 shortfall taught me about honest constraints.
Built for WeMakeDevs × AWS Environmental Hacks 2026, Heat & Water track.

The problem
Heatwave advice often asks people to avoid outdoor work during the hottest hours. For someone paid by the job, following that advice can mean losing income they need that day. To make that conflict concrete, I used Ravi, a fictional independent electrician in Lucknow, as the demo persona.
A weather app knows it will be 44 °C. It doesn't know Ravi has a rooftop repair booked for 09:00, a pump job at noon, an air-conditioned office job and a meter check in a covered market. It doesn't know he needs ₹800 today. I wanted to put those three inputs into one scheduling decision: his jobs, his income target and the hourly heat.
So the question I built for was narrow: given the jobs he already has, which schedule has the lowest modelled heat-exposure score while earning what he needs today? And if he can't, what does the heat cost him?
What ₹800 does
- Ravi types his jobs, or dictates them in Hindi in a browser that supports speech recognition. Each job has pay, a time window, a duration and an environment: direct sun, outdoor shade, indoor uncooled or indoor cooled.
- The backend screens every 15 minutes of the forecast with the US National Weather Service heat index and removes every slot that breaks a heat rule.
- A constraint solver searches for a plan that reaches ₹800 with the lowest modelled heat-exposure score. Travel between jobs and required recovery after uncooled work are scheduled as real blocks.
- If ₹800 can't be reached inside the rules, it says so, shows the exact shortfall and gives the reason for every job it dropped.
On the demo day the rooftop job moves from 09:00 (already in the danger band) to 06:30. The air-conditioned job takes the hottest hours. Target reached, with a 27% lower exposure score than the day as booked.
Then you slide the forecast hotter and the solver plans the whole day again:
| Forecast | Income | Why |
|---|---|---|
| +0 °C | ₹800 of ₹800 | Rooftop job moved to 06:30 |
| +3 °C | ₹650, ₹150 short | The ₹300 rooftop job is removed; the ₹150 meter check is added instead |
| +5 °C | ₹250 | Reaching the cooled job means travelling in extreme danger, and the meter job has no slot that passes the prototype rules and leaves time to rest before 19:00 |

The rule I held to: the target never relaxes a heat rule. If the money doesn't work, the app shows the trade-off and Ravi decides. He can move a customer or find indoor work. It never quietly schedules him onto a roof at 13:00.
The stack
| Piece | What it does |
|---|---|
| AWS Lambda (Python 3.12, 1024 MB) | FastAPI behind Mangum. Runs the heat index, the policy rules and the OR-Tools CP-SAT solver. The API reports solver runtime for each plan; this excludes network latency and Lambda startup time. |
| Amazon API Gateway (HTTP API) | Public API, throttled to 10 requests per second with a burst of 20. |
| Amazon DynamoDB | Single table. Every plan and replan is stored, each replan linked to its parent with its diff. 7-day TTL. |
| Amazon CloudWatch | Structured events: plan_generated for each plan, an additional plan_recalculated event for a replan, and jobs_parsed for job extraction. |
| AWS Amplify Hosting | The static Next.js 16 site, in English and Hindi. |
| Amazon Bedrock | Alternative text-to-job provider, selected when MODAL_API_KEY is unset. There is no automatic failover after a Modal error. |
| GLM 5.3 on Modal | Primary model for turning typed or transcribed Hindi/English text into draft job cards, called from Lambda. |
| Open-Meteo | Live forecast mode. The demo uses a labelled synthetic Lucknow heatwave. |
1
2
3
4
Amplify Hosting → browser (EN / हिंदी)
Browser → API Gateway → Lambda (FastAPI + CP-SAT) → DynamoDB
├→ CloudWatch events
└→ Modal job extraction (Bedrock selectable by configuration)How the solver decides
Each job becomes a set of candidate start times that survive the heat rules. Each candidate is an optional interval covering travel, then the job, then the rest block. A
NoOverlap constraint prevents those intervals from overlapping. Separate constraints enforce job selection, availability and the declared working hours.The planning objective has three ordered passes. The code below is abbreviated; each pass rebuilds the model and retains the relevant constraints:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Pass 1: find the highest income available within the constraints.
m.maximize(income)
# Solve and retain the best income found.
...
# Pass 2: reach the target if possible; otherwise preserve that income.
if max_income >= req.target_income:
m.add(income >= req.target_income)
else:
m.add(income == max_income)
m.minimize(exposure)
...
# Pass 3: retain the income floor and best exposure score found.
m.add(income >= min(max_income, req.target_income))
m.add(exposure == best_exposure)
m.minimize(drift)Separate passes make the priorities explicit without choosing arbitrary trade-off weights. The explanation layer then inspects eligibility and compares booked and selected times to generate reason codes; it does not trace a decision to a particular solver pass. Each solve has a 1.5-second wall-clock limit, a fixed seed and one worker. These settings support reproducibility, but time-limited runs can return different feasible results under different CPU load. The API warns when the main optimization passes are not proven optimal. A fourth solve constructs the booked-time baseline used for the exposure comparison.
Before returning a schedule, a validation function checks duplicate jobs, availability windows, fixed appointments, workday boundaries, heat eligibility, recovery and overlap. It reuses the policy helpers used during candidate generation, so it is an additional consistency check rather than a fully independent implementation. A failed check produces an API error instead of a schedule.
Where the language model fits (and where it doesn't)
Ravi can dictate "सुबह 9 बजे छत पर बिजली का काम है, 300 रुपये" in a supported browser. Browser speech recognition produces text; Lambda sends that text to GLM 5.3 on Modal with a requested
record_jobs tool schema. The parser accepts tool arguments and also handles JSON returned in text or reasoning fields. This request format does not guarantee valid output, so draft cards still need review and validation. Bedrock is an alternative provider selected by configuration, not an automatic retry path.The model fills fields. Ravi checks every one. The model never decides timing or safety. That stays in deterministic code that a test can pin down.
What fought back
Bedrock access. Bedrock calls were blocked on my account (a billing issue I couldn't clear in time), so job reading moved to a Modal-hosted GLM 5.3 endpoint. I kept the Bedrock implementation behind the same parser entry point. Unsetting
MODAL_API_KEY selects it, but working Bedrock permissions and account access are still required.A reasoning model that answered in the wrong field. GLM 5.3 sometimes spent its whole token budget thinking and returned an empty
content, with the JSON sitting in reasoning_content. The fix was a larger max_tokens and checking content, then reasoning_content, then reasoning. Failures are logged with which provider failed and how many reasoning characters came back.OR-Tools on Lambda without Docker. The Linux wheels unzip to about 158 MB, under Lambda's 250 MB limit.
deploy.sh installs them for manylinux x86_64, zips them, uploads through S3 and creates or updates every resource idempotently.Missing Lambda logs. My
logging.info events never showed up in CloudWatch. I switched to printing one JSON object per event, which CloudWatch picks up as-is.A timeout that looked like a ₹0 day. Under CPU contention the second solver pass sometimes timed out, and the API returned an empty plan. The "Before" card read ₹0. The max-income plan from the first pass was already valid, so a timeout in the second pass now returns that first-pass plan; a timeout in the third retains the second-pass result. A test forces the timeout and checks the response.
Bugs a review found before judges could. A review pass against the live demo before the video upload found three:
- Travel ignored the heat rules. At +5 °C the plan sent Ravi across the city in direct sun during extreme danger. The job slots were checked, but the travel minutes before them weren't. Travel now has an explicit rule: it is prohibited in extreme danger (
EXTREME_DANGER_NO_TRAVEL). Below that band, including Danger, the prototype permits travel and scores it as light activity in direct sun. That is a separate rule from the stricter direct-sun job restriction for workers not used to heat. - Rest ran past the workday. A required 30-minute rest ended at 19:30 on a day that ended at 19:00. A candidate is now dropped if its rest block doesn't fit, and the reason says so (
NO_TIME_FOR_REST). - Reopened replans lost their explanation. The before/after diff was computed on the fly and never stored. It's now saved to DynamoDB with the replan, and the URL keeps the replan id.
Each fix came with a test. The suite is at 31 tests, including the exact demo transition: ₹800 at +0 °C, ₹650 at +3 °C with only the rooftop job removed and ₹250 at +5 °C. If those expected income figures change, the relevant tests fail when the backend test suite runs. The frontend build does not itself run that suite.
What I learned
- Represent prototype restrictions as hard constraints. A heat rule is a constraint the solver can't trade away, not a big penalty. That's the only way "the target never relaxes a rule" is actually true.
- Make the honest answer the main feature. The most useful screen isn't the plan that works. It's ₹650 with "₹150 short, and here's exactly why."
- Keep the language model at the edges. It's great at turning Hindi speech into fields. It has no business deciding when someone climbs onto a roof.
- Explanations need structure behind them. Every reason in the UI is a code plus parameters from the solver (
BLOCKED_BY_HEAT_POLICY,NO_TIME_FOR_REST), translated into English or Hindi on the client. That's what made the Hindi version cheap to add. - Review the demo path like a stranger would. The passing demo path was not enough. Checking travel intervals, recovery boundaries and reopening a saved result exposed defects the existing tests missed.
Honest limits
This is a scheduling prototype, not a certified occupational safety assessment. The heat rules and exposure weights are prototype guardrails, not validated thresholds or a measure of medical risk. The heat index is a shade-based screening value, not WBGT. Demo weather and jobs are synthetic and labelled on every screen. A Feeling unwell? button on every page shows guidance to stop work and get help (108, 112).
Try it
AI tools used: Claude Code (Claude Opus 5.5) for code and docs, Codex CLI for the hero images, GLM 5.3 on Modal inside the product for job reading.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article