Skip to content

⭐ Meter Simulator

Terrence Daniels edited this page Sep 17, 2026 · 5 revisions

A .NET Worker Service replaying a real household power-consumption dataset across a configurable city block (both sides of a street — however many meters the deployment's BuildingsPerSide value produces) and publishing each reading to Kafka's meter.readings.raw topic as an Avro-encoded event, stamped with its resolved accountId, one reading per meter every 5 seconds.

Composition

  • MeterReading — an immutable record matching the design doc's event contract (meterId, timestamp, kwh, readingId)
  • HouseholdPowerDataset — loads the embedded real UCI dataset excerpt once at startup
  • MeterReadingGenerator — replays the real dataset, one independent cursor per meter (seeded by a hash of meterId so every meter shows different real values from the same real month rather than identical echoes), converting real kilowatts to a real per-tick kWh amount
  • MeterSimulatorOptions + CityBlockMeterIdFactory (from the shared GridPulse.Shared library, also used by Account/Customer Service — see docs/architecture.md) — config in, meter-ID list out (see below)
  • CensusAddressValidator — confirms the configured address is real against the live U.S. Census Geocoding API before the app is allowed to start
  • AccountResolver / AccountLookupCache — resolves each configured meter's accountId from Account/Customer Service once at startup (fail-fast if any meter isn't registered), same pattern as the Census check
  • Worker — a BackgroundService that composes the above on a timer, publishing each reading (keyed by MeterId, preserving per-meter partition ordering, stamped with its accountId) via IProducer<string, MeterReadingRaw> and a real Confluent Schema Registry

Real data, not a random number generator

Readings were purely synthetic since Phase 1 (a random 0-5 kWh per tick). Replaced with a replay of a real, published dataset — UCI's Individual Household Electric Power Consumption — real minute-level power measurements from one real household. A monthly GitHub Actions job (refresh-power-dataset.yml) cycles the checked-in excerpt through the real archive's full December 2006–November 2010 span over time, since the full ~127MB archive exceeds GitHub's 100MB file limit. Full reasoning, including the kW→kWh conversion and per-meter cursor design, is in todo.md.

Configuration: no hardcoded site data, anywhere

StreetName, StartingAddress, BuildingsPerSide, City, and ZipCode are [Required]/[Range]-validated with no default values — the app refuses to start with a clean error (not a stack trace) if any are missing. CityBlockMeterIdFactory computes the actual meter-ID list (even side + odd side) from those numbers at startup, rather than a literal list living in source.

On top of that, CensusAddressValidator calls the real U.S. Census Bureau Geocoding API at startup to confirm the composed address ("{StartingAddress} {StreetName}, {City}, MI {ZipCode}") actually exists — a fictitious or mismatched street/city/zip refuses to start the app, not just a missing one.

AppHost supplies all five via Aspire's builder.AddParameter(...) (itself with no default), which resolves from dotnet user-secrets — never a file that gets committed. A real field deployment wouldn't go through AppHost at all; it'd set the same config as real OS/container environment variables.

Full reasoning — including several real self-corrections and bugs caught along the way — is in todo.md.

A genuine decoupling win from moving to Kafka

Meter Simulator no longer has any reference to Usage Aggregation at all — not in code, not in AppHost.cs. It publishes to a topic and doesn't know or care who (if anyone) is listening, exactly the point of an event-driven core loop over direct service-to-service calls.

Verified live, not just built

Ran the full AppHost and confirmed via captured process output: 924 of 924 readings published successfully to Kafka in one run, each with a unique ReadingId. A real startup-race bug was caught this way — the schema registry container reached "Running" before its HTTP server was actually ready to accept requests, causing early sends to fail; fixed with a proper HTTP health check on that resource so WaitFor genuinely waits for readiness. Re-verified after switching to the real dataset: genuinely different, gradually-changing real values landing per meter (not random jumps), confirmed directly in Postgres. Re-verified again after Phase 3's accountId resolution: every reading's accountId a real seeded account GUID, confirmed via a real fresh Kafka consumer.

Tested

15 xUnit tests cover MeterReadingGenerator (including a deterministic test asserting the exact kW→kWh conversion against a known real dataset value), MeterSimulatorOptions fail-fast validation, and CensusAddressValidator (mocked match/no-match/error responses, no real network calls in tests). CityBlockMeterIdFactory's own tests moved to Shared.Tests when it was extracted into GridPulse.Shared.

Clone this wiki locally