Skip to content

Commit 113fdd6

Browse files
jirhikerclaude
andcommitted
feat(ingestion): wire the loader end to end
san_acacia_observations joins the pieces that existed separately: reconcile the vendor point to a well, choose the deployment its transducer hangs from, ask the database where that series got to, fetch forward, map, upsert, and extend the QC block. The seeding half of 3.2 turned out to be nothing. All 38 wells already have deployments, the parameter exists as `groundwater level` in feet -- the unit the adapter emits -- and existing observations already use it. So the series is chosen rather than created. Choosing it needs a rule, because a well carries several open deployments: a deployment is equipment, not a measured property. SO-0140 has a DiverLink, a Pressure Transducer and a Diver Cable, and only the transducer produces a water level. Picking any other would attribute a reading to a cable. That resolves cleanly for 35 of the 38 wells. Two have two open transducers and one has none; those are skipped and reported. Taking the lower id would be a silent guess about equipment, and a removed transducer is not used as a fallback -- writing current data against retired kit looks like success while being wrong. A well that cannot be resolved costs that well's readings for the run, not the other thirty-seven's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 6bf1b00 commit 113fdd6

5 files changed

Lines changed: 402 additions & 0 deletions

File tree

automated_ingestion/defs/assets/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from automated_ingestion.sources.san_acacia.ingest import (
2828
raw_san_acacia_locations,
2929
raw_san_acacia_readings,
30+
san_acacia_observations,
3031
)
3132

3233

@@ -37,6 +38,7 @@ def all_assets() -> list[AssetsDefinition]:
3738
database_connectivity,
3839
raw_san_acacia_locations,
3940
raw_san_acacia_readings,
41+
san_acacia_observations,
4042
]
4143

4244

automated_ingestion/sources/san_acacia/ingest.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
from dagster import AssetExecutionContext, MetadataValue, Output, asset
2929

30+
from automated_ingestion.defs.resources import OcotilloDatabase
3031
from automated_ingestion.sources.san_acacia.client import DiverHubClient
3132

3233

@@ -113,6 +114,189 @@ def raw_san_acacia_readings(context: AssetExecutionContext) -> Output[int]:
113114
)
114115

115116

117+
@asset(
118+
group_name="san_acacia",
119+
deps=[raw_san_acacia_readings],
120+
description="Water levels mapped to the Ocotillo model and loaded to Postgres.",
121+
)
122+
def san_acacia_observations(
123+
context: AssetExecutionContext, database: OcotilloDatabase
124+
) -> Output[int]:
125+
"""Load San Acacia water levels into `transducer_observation`.
126+
127+
Per well: match the vendor point to an Ocotillo well, choose the deployment
128+
its transducer hangs from, ask the database where that series got to, fetch
129+
forward from there, map, and upsert.
130+
131+
A well that cannot be resolved is skipped and counted, never guessed at.
132+
Ingestion does not create wells or pick between candidate deployments, so an
133+
unresolved well is a question for a person -- and skipping it costs that
134+
well's readings for this run, not the other thirty-seven's.
135+
"""
136+
from datetime import datetime, timezone
137+
138+
from automated_ingestion.ocotillo.loader import ensure_block, load_observations
139+
from automated_ingestion.shared.watermark import (
140+
PostgresWatermarkStore,
141+
resolve_start,
142+
)
143+
from automated_ingestion.sources.san_acacia.adapter import SanAcaciaAdapter
144+
from automated_ingestion.sources.san_acacia.client import GROUND_SURFACE_REFERENCE
145+
from automated_ingestion.sources.san_acacia.dlt_pipeline import (
146+
INITIAL_START,
147+
PROJECT_ID,
148+
READING_SPAN,
149+
)
150+
from automated_ingestion.sources.san_acacia.reconcile import (
151+
VendorPoint,
152+
reconcile,
153+
)
154+
from automated_ingestion.sources.san_acacia.resolve import (
155+
PARAMETER_NAME,
156+
resolve_deployment,
157+
)
158+
from domain.van_essen import parse_reading_timestamp
159+
160+
client = _client()
161+
points = [
162+
VendorPoint(monitoring_point_id=p["id"], name=p["name"])
163+
for p in client.monitoring_points(PROJECT_ID)
164+
]
165+
end = int(datetime.now(tz=timezone.utc).timestamp())
166+
floor = parse_reading_timestamp(INITIAL_START)
167+
168+
rows_loaded = 0
169+
skipped: list[dict[str, Any]] = []
170+
adapter_failures = 0
171+
172+
with database.session() as session:
173+
parameter_id = _parameter_id(session, PARAMETER_NAME)
174+
report = reconcile(points, _well_candidates(session))
175+
watermarks = PostgresWatermarkStore(session)
176+
177+
for match in report.matches:
178+
if match.needs_a_human:
179+
skipped.append({"point": match.point.name, "reason": match.kind.value})
180+
continue
181+
182+
thing_id = match.thing_id
183+
resolution = resolve_deployment(_deployments(session, thing_id))
184+
if resolution.needs_a_human:
185+
skipped.append(
186+
{"point": match.point.name, "reason": resolution.kind.value}
187+
)
188+
continue
189+
190+
start = resolve_start(watermarks, thing_id, parameter_id, floor)
191+
adapter = SanAcaciaAdapter()
192+
raw = (
193+
{
194+
"monitoring_point_id": match.point.monitoring_point_id,
195+
"dateAndTime": row["dateAndTime"],
196+
"level": row["level"],
197+
"unit": "cm",
198+
"reference": GROUND_SURFACE_REFERENCE,
199+
}
200+
for row in client.water_levels(
201+
match.point.monitoring_point_id,
202+
int(start.timestamp()),
203+
end,
204+
reference=GROUND_SURFACE_REFERENCE,
205+
span=READING_SPAN,
206+
)
207+
)
208+
209+
observations = list(adapter.to_observations(raw))
210+
adapter_failures += len(adapter.failures)
211+
if not observations:
212+
continue
213+
214+
result = load_observations(
215+
session,
216+
observations,
217+
resolution.deployment_id,
218+
parameter_id,
219+
release_status="public",
220+
)
221+
rows_loaded += result.rows_written
222+
ensure_block(
223+
session,
224+
thing_id=thing_id,
225+
parameter_id=parameter_id,
226+
start=min(o.observation_datetime for o in observations),
227+
end=max(o.observation_datetime for o in observations),
228+
release_status="public",
229+
)
230+
231+
if skipped:
232+
context.log.warning(
233+
"%s of %s wells skipped: %s",
234+
len(skipped),
235+
len(points),
236+
", ".join(f"{s['point']} ({s['reason']})" for s in skipped),
237+
)
238+
239+
return Output(
240+
rows_loaded,
241+
metadata={
242+
"rows_loaded": MetadataValue.int(rows_loaded),
243+
"wells_attempted": MetadataValue.int(len(points)),
244+
"wells_skipped": MetadataValue.int(len(skipped)),
245+
"adapter_failures": MetadataValue.int(adapter_failures),
246+
"skipped": MetadataValue.json(skipped),
247+
},
248+
)
249+
250+
251+
def _parameter_id(session: Any, name: str) -> int:
252+
from sqlalchemy import select
253+
254+
from db.parameter import Parameter
255+
256+
parameter_id = session.scalar(
257+
select(Parameter.id).where(Parameter.parameter_name == name)
258+
)
259+
if parameter_id is None:
260+
raise RuntimeError(
261+
f"No parameter named {name!r}. Ingestion does not create parameters; "
262+
"seed it before loading."
263+
)
264+
return parameter_id
265+
266+
267+
def _well_candidates(session: Any) -> list[Any]:
268+
"""Ocotillo wells the vendor points might be, narrowed by name prefix."""
269+
from sqlalchemy import select
270+
271+
from db.thing import Thing
272+
273+
from automated_ingestion.sources.san_acacia.reconcile import ThingCandidate
274+
275+
rows = session.execute(
276+
select(Thing.id, Thing.name).where(Thing.name.ilike("SO-%"))
277+
).all()
278+
return [ThingCandidate(thing_id=i, name=n) for i, n in rows]
279+
280+
281+
def _deployments(session: Any, thing_id: int) -> list[Any]:
282+
from sqlalchemy import select
283+
284+
from db.deployment import Deployment
285+
from db.sensor import Sensor
286+
287+
from automated_ingestion.sources.san_acacia.resolve import DeploymentCandidate
288+
289+
rows = session.execute(
290+
select(Deployment.id, Sensor.sensor_type, Deployment.removal_date)
291+
.join(Sensor, Sensor.id == Deployment.sensor_id)
292+
.where(Deployment.thing_id == thing_id)
293+
).all()
294+
return [
295+
DeploymentCandidate(deployment_id=i, sensor_type=t, removal_date=r)
296+
for i, t, r in rows
297+
]
298+
299+
116300
def _row_count(load_info: Any) -> int:
117301
"""Rows dlt reports as loaded, or 0 when it reports nothing."""
118302
try:
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# ===============================================================================
2+
# Copyright 2026 ross
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
# ===============================================================================
16+
"""
17+
Choosing which deployment a water level belongs to.
18+
19+
A San Acacia well carries several open deployments at once, because a deployment
20+
is a piece of equipment rather than a measured property. SO-0140 has three:
21+
22+
DiverLink DN431-1ch telemetry
23+
Pressure Transducer DI801 10m measures the water level
24+
Diver Cable AS2006-6m the cable
25+
26+
Only the pressure transducer produces the reading being ingested, so that is the
27+
deployment an observation hangs from. Picking any of the others would attribute
28+
a water level to a cable.
29+
30+
Like the reconciler, this never chooses between equally good candidates. Two
31+
open transducers on one well is a question about the equipment record, not
32+
something to resolve by taking the lower id.
33+
"""
34+
35+
from collections.abc import Iterable
36+
from dataclasses import dataclass
37+
from datetime import date
38+
from enum import Enum
39+
40+
WATER_LEVEL_SENSOR_TYPE = "Pressure Transducer"
41+
"""The sensor type whose deployment carries a water level.
42+
43+
Checked against staging: of the 38 San Acacia wells, 35 have exactly one open
44+
deployment of this type, 2 have two, and 1 has none. The other types present are
45+
`DiverLink`, `Diver Cable` and `Barometer`, none of which measure depth to
46+
water.
47+
"""
48+
49+
PARAMETER_NAME = "groundwater level"
50+
"""The Ocotillo parameter these readings are. Its `default_unit` is `ft`, which
51+
is what the adapter emits -- the conversion from the vendor's centimetres
52+
happens in `domain/van_essen.py`."""
53+
54+
55+
class ResolutionKind(str, Enum):
56+
RESOLVED = "resolved"
57+
AMBIGUOUS = "ambiguous"
58+
MISSING = "missing"
59+
60+
61+
@dataclass(frozen=True)
62+
class DeploymentCandidate:
63+
"""A deployment on the well, with the bit needed to judge it."""
64+
65+
deployment_id: int
66+
sensor_type: str
67+
removal_date: date | None = None
68+
69+
@property
70+
def is_open(self) -> bool:
71+
return self.removal_date is None
72+
73+
74+
@dataclass(frozen=True)
75+
class Resolution:
76+
"""Which deployment to load into, or why none was chosen."""
77+
78+
kind: ResolutionKind
79+
deployment_id: int | None = None
80+
candidates: tuple[int, ...] = ()
81+
82+
@property
83+
def needs_a_human(self) -> bool:
84+
return self.kind is not ResolutionKind.RESOLVED
85+
86+
87+
def resolve_deployment(candidates: Iterable[DeploymentCandidate]) -> Resolution:
88+
"""Pick the open pressure-transducer deployment, or refuse.
89+
90+
Closed deployments are excluded rather than preferred-against: a removed
91+
transducer is not where today's readings belong, and treating it as a
92+
fallback would quietly write current data against retired equipment.
93+
"""
94+
open_transducers = [
95+
c for c in candidates if c.is_open and c.sensor_type == WATER_LEVEL_SENSOR_TYPE
96+
]
97+
98+
if len(open_transducers) == 1:
99+
return Resolution(
100+
kind=ResolutionKind.RESOLVED,
101+
deployment_id=open_transducers[0].deployment_id,
102+
)
103+
if len(open_transducers) > 1:
104+
return Resolution(
105+
kind=ResolutionKind.AMBIGUOUS,
106+
candidates=tuple(c.deployment_id for c in open_transducers),
107+
)
108+
return Resolution(kind=ResolutionKind.MISSING)
109+
110+
111+
# ============= EOF =============================================

0 commit comments

Comments
 (0)