Skip to content

Add Itron Roseville AMI adapter and shared S3 drop-off transport helpers - #224

Open
dmarulli wants to merge 7 commits into
mainfrom
dmarulli/roseville-adapter
Open

Add Itron Roseville AMI adapter and shared S3 drop-off transport helpers#224
dmarulli wants to merge 7 commits into
mainfrom
dmarulli/roseville-adapter

Conversation

@dmarulli

@dmarulli dmarulli commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

What

New itron_roseville adapter for City of Roseville's Itron AMI data — the first adapter whose source is a utility-pushed S3 file drop rather than a vendor API/SFTP/database. Roseville exports two CSV views (Register: cumulative reads at 8-hour cadence; Interval: hourly consumption) via Informatica into a CaDC-owned S3 prefix.

The transport-level pieces that aren't Roseville-specific live in a new shared module amiadapters/adapters/s3_drop.py (following connections.py's precedent for shared transport helpers): cross-account client from source-secret keys, paginated subfolder-safe listing, filename date-token parsing + overlap selection with LastModified ordering, BOM-safe CSV→dataclass download. The module's documentation is deliberately operator-neutral — operator- and utility-specific context (whose bucket, whose naming spec, delivery mechanism) lives at the adapter level. The adapter keeps what is truly Roseville's: filename regex, row dataclasses, error sentinel, transform.

Naming follows the {vendor}_{utility} convention established by xylem_moulton_niguel (file, class, source type, base tables, docs, fixtures). Like that adapter, this one was built specially for Roseville and is not compatible with other utilities — the CSVs are Roseville IT's own database views, not Itron's native ChoiceConnect export; an Itron utility onboarding via Itron's standard hosted-SFTP path would need a different adapter.

Design decisions

  • Cross-account S3 access via source secrets. The bucket lives in a different AWS account than the pipeline (verified: the pipeline role can't read it), so ItronRosevilleSecrets carries a read-only IAM keypair — consistent with how every adapter treats source credentials. The boto3 client is built lazily in _extract (adapters are constructed at DAG-parse time).
  • Identifier mapping (documented in the docstring): device_id = meter_id = Meter_Serial_Number (every read row carries the serial; 1:1 meter:endpoint:location; meter swaps start a new SCD2 device). endpoint_id = EndpointID (Itron's OID arc 2.16.840.1.114416) — same convention as xylem_moulton_niguel's ert_id → endpoint_id.
  • Subfolder-blind listing (Delimiter="/"): an archive/ folder of superseded files is structurally invisible.
  • Corrections-win ordering: Roseville re-sends a rolling ~3-day correction window of un-finalized reads; files are processed oldest-LastModified-first so the newest delivered value wins per (meter, timestamp) in READINGS.
  • Register+Interval merge onto shared reads at exact (device_id, flowtime), xylem_datalake-style. Hour-ending timestamp semantics verified empirically (interval sums reconcile 8-hour register diffs exactly in 99.6% of 424,632 windows).
  • Data hygiene in transform (raw base tables keep everything): the Itron faulted-channel sentinel 4294967294 (2³²−2) is excluded on both file types; blank-timestamp and non-numeric-value rows are counted+skipped; BOM-tolerant decoding; s3_prefix trailing slash normalized (load-bearing with Delimiter="/"); tz-aware manual extract ranges normalized.
  • account_id is permanently unavailable by design (Roseville's AMI system receives no account info from CIS). Location_ID = Cayenta SERVICE_POINT, the billing join key — validated at 99.8% match against Roseville billing, both directions.
  • 4-day extract window tolerates a late push of the 3-day files; re-processing is idempotent.

Known limitations (documented in code + docs)

  • DST fall-back collapses the repeated 01:00 wall-clock hour (two indistinguishable rows; overwrite counted in a log counter). Shared with every local-time adapter in the repo.
  • Raw base-table MERGE dedupes re-delivered rows with the framework's per-column max() — lexicographic on VARCHAR; READINGS is the corrections-accurate record.
  • While Roseville uses month-suffix filenames, the current month's file overlaps every daily window and is re-processed each run (idempotent; resolves when date-range suffixes land).

Verification

  • 288-test suite passes (adapter tests, shared s3_drop tests, config/fixture registration incl. the all-config combined tests), black-formatted.
  • Transform validated against the full 2026-07 production files (485,601 register + 3,873,912 interval rows): 53,805 meters, 3,873,923 reads, 485,294 merged rows, 296 sentinels filtered — byte-exact against independently computed expectations; re-validated after the rename/refactor with identical results.
  • Adversarial multi-angle review (25 candidates, each verified) — all confirmed findings fixed.

Deploy checklist (not in this PR — requires prod access)

  1. Create a read-only IAM user in the CaDC account scoped to s3:GetObject/s3:ListBucket on the Roseville prefix.
  2. Run sql/itron-roseville-base.sql in Snowflake (base tables must exist before first run).
  3. python cli.py config add-source cadc_roseville itron_roseville America/Los_Angeles --config s3_bucket=cadc-ami --config s3_prefix=rosevillecityof/ --config s3_region=us-east-1 --sinks <snowflake_sink>
  4. python cli.py config update-secret cadc_roseville --source-type itron_roseville --secret aws_access_key_id=... --secret aws_secret_access_key=...
  5. Archive the superseded sample files in the prefix (old June samples + dummies) into archive/.
  6. Deploy (bash ./deploy.sh cadc).

dmarulli added 4 commits July 13, 2026 14:43
First adapter sourcing from a utility-pushed S3 file drop rather than a
vendor API/SFTP/DB. City of Roseville exports two Itron CSV views --
Register (cumulative reads, 8-hour cadence) and Interval (hourly
consumption) -- via Informatica into a CaDC-owned S3 prefix. The bucket
lives in the CaDC AWS account, so the adapter authenticates with a
read-only IAM keypair from source secrets rather than the EC2 instance
role.

Key behaviors:
- Lists top-level keys only (Delimiter='/'), so an archive/ subfolder of
  superseded files is never re-ingested; selects files whose filename
  date range (YYYYMM or YYYYMMDD_YYYYMMDD suffix) overlaps the extract
  range; processes files oldest-LastModified-first so re-delivered
  correction-window rows resolve newest-wins in READINGS.
- Register+Interval streams merge onto shared GeneralMeterReads at exact
  (device_id, flowtime), xylem_datalake-style. Interval timestamps mark
  the END of the measured hour (verified empirically: hour-ending
  alignment reconciles 99.6% of 8-hour register windows exactly).
- Excludes the Itron faulted-channel sentinel (4294967294 = 2**32-2) on
  both file types from transformed reads while preserving it in the raw
  base tables; skips reads with blank timestamps or non-numeric values
  (counted and logged, not fatal).
- Timestamps are Pacific wall-clock 'MM/DD/YYYY HH:MM:SS.ffffff';
  account_id is permanently unavailable in this feed by design --
  Location_ID equals Cayenta's SERVICE_POINT and is the billing join key.

Verified: 280-test suite passes; transform validated against the full
2026-07 production files (485,601 register + 3,873,912 interval rows ->
53,805 meters, 3,873,923 reads, exact expected merge/filter counts).
Following the xylem_moulton_niguel convention for utility-specific
adapters: state explicitly that this adapter was built specially for
Roseville and is not compatible with other utilities. The CSVs are
Roseville's own database views, not Itron's native ChoiceConnect export
-- an Itron utility onboarding via Itron's standard hosted-SFTP path
would need a different adapter.

Also record the device_id choice (Meter_Serial_Number; every read row
carries the serial, 1:1 meter:endpoint:location, meter swaps start a new
SCD2 device) and note that endpoint_id = Roseville's Itron radio OID,
consistent with xylem_moulton_niguel's ert_id -> endpoint_id mapping.
Two changes following repo conventions for utility-specific adapters:

1. Rename roseville -> itron_roseville everywhere (file, class, source
   type, config/secrets dataclasses, base tables, sql/docs/test/fixture
   files), matching the {vendor}_{utility} convention established by
   xylem_moulton_niguel. Safe to rename the base tables because they have
   not been created in Snowflake yet.

2. Extract the transport-level pieces that are not Roseville-specific
   into amiadapters/adapters/s3_drop.py (following connections.py's
   precedent for shared transport helpers): cross-account client from
   source-secret keys, paginated subfolder-safe listing, CaDC
   filename-date-token parsing and overlap selection with LastModified
   ordering, and BOM-safe CSV-to-dataclass download. The S3 drop-off is
   CaDC's standard intake pattern for utilities that push files, so the
   next S3-drop source can reuse these directly; the adapter keeps only
   what is truly Roseville's (filename regex, row dataclasses, sentinel,
   transform).

Also registers the new source type in the all-config/all-secrets combined
fixtures and their two consumers (test_can_create_adapters,
test_get_database_config), and fixes a typing.Pattern deprecation.

Verified: 288-test suite passes; transform re-validated against the full
2026-07 production files with identical exact counts.
Operator-specific context (whose bucket, whose naming spec, whose
delivery mechanism) belongs at the adapter level, not in shared framework
code. Rewrite the s3_drop module docstring to describe the generic
S3-drop-source mechanics on their own terms; the operator- and
utility-specific rationale stays in itron_roseville.py and its docs page.
@dmarulli dmarulli changed the title Add Roseville (Itron via S3 drop) AMI adapter Add Itron Roseville AMI adapter and shared S3 drop-off transport helpers Jul 13, 2026
Same review lens as the billing parser: encode the generating mechanism,
not the observed instances.

1. Fault-code filter is a threshold, not one magic number: Itron emits
   32-bit max-value error codes (4294967294 = 2**32-2 observed; neighbors
   like 2**32-1 equally plausible). Real registers sit orders of magnitude
   lower (largest genuine: ~97.8M CF), so values >= 4e9 are excluded from
   transformed reads (still preserved in raw base tables).

2. Unit normalization strips the Itron commodity suffix (_WAT) generically
   instead of mapping only CF_WAT, with the GAL->GALLON alias map_reading's
   vocabulary requires (same aliasing as xylem_datalake). A future GAL_WAT
   maps cleanly; unknown units still raise.

3. Observability: blank Meter_Serial_Number rows are now counted
   (missing_device_id) like every other skip, and register-value overwrites
   from re-delivered corrections increment overwritten_differing
   symmetrically with the interval path.

Verified: 291-test suite passes (new tests for threshold neighbors,
GAL_WAT conversion, missing-serial counting); transform re-validated
against the full 2026-07 production files with identical counts (53,805
meters / 3,873,923 reads / 485,294 merged / 296 fault rows excluded).
@dmarulli
dmarulli marked this pull request as ready for review July 14, 2026 19:11

@mdowell12 mdowell12 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 looking great! I left a couple nitpicks, feel free to ignore.

Comment thread amiadapters/adapters/s3_drop.py Outdated

logger = logging.getLogger(__name__)


Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nits:

Some of this code (probably like 20%) is duplicated in amiadapters/outputs/s3.py, so maybe the perfect implementation would abstract what's shared. I think it's fine to duplicate here though.

Also, do we think another adapter will use this, filename conventions included? If that feels unlikely, maybe this code belongs inside the adapter.

Comment thread amiadapters/adapters/itron_roseville.py Outdated
"""
Roseville pushes a file per day covering a rolling ~3-day window. We use
a 4-day extract window so a file pushed late (or a day skipped on our
side) is still picked up by filename-date overlap; re-processing is

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I've noticed in my work that Claude is overzealous with code comments. This one feels longer than necessary, e.g. I don't think it needs to discuss idempotent upserts which are the norm for the whole pipeline. I've been trying to cut out that fluff to keep comments useful.


def _get_s3_client(self):
if self._s3_client is None:
# The bucket lives in the CaDC AWS account, not the ami-connect

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 nice

@dmarulli

Copy link
Copy Markdown
Collaborator Author

@mdowell12 - thanks for the notes! Always appreciated. I've made some tweaks based on them. On the proposed s3 module stuff, I went back and forth just a bit, but decided to bring that logic inside the Roseville adapter for now, until there's actually a second adapter that would use it.

I'm just waiting on a final thumbs up from Roseville, then I will merge, deploy, etc.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants