Engineering reference: the utility meter reader architecture
The first six posts are for the person deciding whether to build this. This one is for the person building it. Same system, no analogies: the services by name, the functions, the two tables, the reading types, and how baseload is computed.
Key takeaways
- Single region, single account. Every resource is regional; nothing is global except the IAM roles.
- 3 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
- 2 DynamoDB tables, each keyed so the concurrency story is a condition expression rather than a lock.
- One Bedrock model, called once, with a JSON schema it must fill or leave null.
- Nothing always-on: no instance, no container, no provisioned capacity.
The system, by service name
- Compute
- Database
- Management
Region and account
- Region:
us-east-1. Chosen because SES inbound receipt rules exist in only a subset of regions and this one has the widest Bedrock model availability. If your data has to stay elsewhere, check both constraints before moving: inbound SES is the binding one. - Account: one. This is a small system, and a separate account per environment costs more in wiring than it saves. A
devand aprodstack in the same account, with distinct resource prefixes, is the right size here. - Everything is regional. The only global resources are the IAM roles and policies. There is no CloudFront, no global table and no cross-region replication, because nothing here has a latency or durability requirement that would justify them.
Lambda inventory
| Function | Trigger | Does | Timeout / memory |
|---|---|---|---|
um-ingest | S3 put, or API for manual readings | Validates against the previous register; detects meter changes; marks the reading type | 120s / 1024 MB |
um-normalise | EventBridge, daily | Fetches degree days; computes per-day and per-degree-day figures; spreads catch-ups back | 60s / 1024 MB |
um-analyse | EventBridge, monthly | Computes baseload, slope and intercept, verifies open savings, sends the findings summary | 300s / 1024 MB |
Splitting this into separate functions is not about modularity. It is that only one of them needs Bedrock permissions and only one is reachable from the public internet, and neither of those is true if it is one handler behind a router.
IAM, scoped
| Role | Allowed | On |
|---|---|---|
um-ingest-role | s3:GetObject, dynamodb:PutItem, dynamodb:Query | The feed prefix; readings; read-only on meters |
um-normalise-role | dynamodb:Query, dynamodb:UpdateItem | Both tables |
um-analyse-role | dynamodb:Query, dynamodb:UpdateItem, ses:SendEmail | Both tables; one verified identity |
No role has a Resource: “*” on anything that writes, and every GetSecretValue grant names a single secret arn. That is why there is more than one secret rather than one JSON blob with everything in it.
DynamoDB schemas
Table: readings
PK meter_id S
SK date S 2026-08-22 — one item per meter per day
halfhourly L 48 values, or null for billed meters
total N the day’s consumption
kind S actual | estimated | derived
derived_from S set when a catch-up was spread back
degree_days N for the site’s weather location
per_day N total / days in period, for billed meters
baseload_kw N minimum sustained draw, 02:00-04:00
One item per meter per day rather than per reading. Forty-eight values
in a list is one write instead of forty-eight.
Table: meters
PK meter_id S
serial S changes when the meter is replaced
utility S electricity | gas | water
resolution S halfhourly | daily | billed
expected_baseload N from the half-hour exercise in Part 3
base_temp_c N 15.5 by default; stated on every report
slope N kWh per degree day, current
intercept N non-heating gas
changes L [{date, what, by}] — the change log
`changes` is the log that makes before-and-after verification possible.
Without a date to the day, no saving can be measured.
Inbound and outbound
- Half-hourly data comes from the supplier or data collector, usually as a daily file. Requesting it is often the single highest-value action available.
- Billed meters are entered manually or parsed from the bill, always with the estimate flag from the bill itself.
- Degree days are computed daily from a public weather source for each site’s location, cached, and never re-fetched per reading.
- A meter serial change closes the old series and opens a new one. The two are never concatenated into a single register.
The model call
- There is no model in this system. Baseload is a minimum, normalisation is division, and the slope is a straight line fit.
- The tempting use is disaggregating loads from the half-hourly curve. It is genuinely interesting and an afternoon with a clamp meter answers it better and cheaply.
- A defensible use is reading a scanned bill to extract the reading, the estimate flag and the period.
- The wrong use is generating explanations for movements. A movement with an invented explanation stops anybody looking for the real one.
- The cost page assumes none, which is why the bill is fixed.
Things worth knowing before you build it
- Mark every value actual, estimated or derived, and never compare across the three without saying so. This single field prevents the most common false alarm.
- Divide by days in the period before any comparison. A 28-day month against a 31-day month is ten per cent lower for no reason.
- Take the minimum sustained draw for baseload, not the average. An average over the small hours includes whatever cycled on during them.
- Keep a change log with exact dates. Verification is impossible without knowing the day something changed.
- State the base temperature on every degree-day report. It is an assumption and a building with high internal gains needs a different one.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts