Skip to content

Part 7 of 7 · Utility meter reader series ~7 min read

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

The utility meter reader drawn with AWS service namesThree boxes across the top outside the AWS account. Meter data, arriving as a half-hourly feed or as bills. Weather data from a public source. And A monthly summary of findings rather than charts. Inside the account, three groups. S3 receiving the feed drop alongside EventBridge running daily and monthly passes. Three Lambda functions named ingest, normalise and analyse. And two DynamoDB tables named readings and meters. A note gives the region as us-east-1, one account, and states that readings are batched by day per meter and estimates are never silently mixed.AWS ACCOUNTMeter datahalf-hourly feed,or billsWeather dataa public sourceA monthly summaryfindings, not chartsS3 + EventBridgefeed drop,daily and monthlyLambda x3ingest, normalise, analyseDynamoDB x2readings, metersingroundsoutus-east-1. One account. Readings batched by day per meter; estimates never silently mixed.
Fig 1. The same shape as Part 1 with the service names filled in. Nothing here is new; it is the same three groups, named.
  • 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 dev and a prod stack 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

FunctionTriggerDoesTimeout / memory
um-ingestS3 put, or API for manual readingsValidates against the previous register; detects meter changes; marks the reading type120s / 1024 MB
um-normaliseEventBridge, dailyFetches degree days; computes per-day and per-degree-day figures; spreads catch-ups back60s / 1024 MB
um-analyseEventBridge, monthlyComputes baseload, slope and intercept, verifies open savings, sends the findings summary300s / 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

RoleAllowedOn
um-ingest-roles3:GetObject, dynamodb:PutItem, dynamodb:QueryThe feed prefix; readings; read-only on meters
um-normalise-roledynamodb:Query, dynamodb:UpdateItemBoth tables
um-analyse-roledynamodb:Query, dynamodb:UpdateItem, ses:SendEmailBoth 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