22
33The graph has two layers wired through the GCS IO manager:
44
5- shared source assets per-product pipeline
6- ["sources", param, mode, scope, k] ──▶ <product_id> ──▶ <product_id>/geoserver
7- (combine) (publish)
8-
9- - **shared source assets** — keyed ``["sources", <parameter>, <mode>, <scope>,
10- <source_key>]``. One per *distinct* (parameter, mode, scope, source) tuple
11- across **all** products. ``mode`` is ``summary`` or ``timeseries`` (the
12- backend only distinguishes these two unification modes); ``scope`` encodes the
13- spatial filter (``state_NM`` / ``county_Bernalillo`` / ``all``). Because the
14- key is product-independent, every product that needs the same source under the
15- same parameter/mode/scope shares one asset — the unification runs **once** per
16- run instead of once per product. Each asset unifies a single parameter for a
17- single source and emits its records/sites/timeseries.
5+ shared source assets per-product pipeline
6+ ["sources", param, scope, k] ──▶ <product_id> ──▶ <product_id>/geoserver
7+ (combine) (publish)
8+
9+ - **shared source assets** — keyed ``["sources", <parameter>, <scope>,
10+ <source_key>]``. One per *distinct* (parameter, scope, source) tuple across
11+ **all** products. ``scope`` encodes the spatial filter (``state_NM`` /
12+ ``county_Bernalillo`` / ``all``). The source is fetched **once** and unified
13+ for *both* summary and timeseries (see ``unify_source_both`` — every
14+ connector's fetch is mode-agnostic, so summary and timeseries differ only in
15+ how the same observations are transformed), so the asset carries records
16+ (summary) and sites/timeseries together. Because the key is
17+ product-independent *and* mode-independent, every product that needs the same
18+ source under the same parameter/scope — whether a summary or a timeseries
19+ product — shares one asset and one fetch.
1820- **combine asset** — keyed ``[product_id]``. Reads its source inputs back
19- (``ins``), merges them, writes the OGC GeoJSON collection, uploads to GCS.
21+ (``ins``), takes the slice it needs (records for summary-type products,
22+ sites/timeseries for timeseries-type), writes the OGC GeoJSON collection,
23+ uploads to GCS.
2024- **geoserver asset** — keyed ``[product_id, "geoserver"]``. Downloads the
2125 combined GeoJSON, converts to GeoPackage, publishes it as a GeoServer layer.
2226
2327Job layout (see ``definitions.py``) makes the sharing pay off **and** keeps each
24- run's lineage complete. Products are grouped into *cohorts* by
25- (group, mode, scope) — the products that can share source assets. One job per
26- cohort materializes that cohort's whole graph in a single run: each shared
27- source unifies once (it is one asset key, selected once), then every member
28- combine reads it back through the GCS IO manager and publishes. So a source is
29- never fetched twice in a run, while the full sources → combine → geoserver
30- lineage stays visible for every product. (Cross-product dedup requires the
31- sharing products to run together; that is exactly what a cohort is.)
28+ run's lineage complete. Products are grouped into *cohorts* by (group, scope) —
29+ the products that can share source assets. One job per cohort materializes that
30+ cohort's whole graph in a single run: each shared source is fetched once (it is
31+ one asset key, selected once), then every member combine reads it back through
32+ the GCS IO manager and publishes. So a source is never fetched twice in a run —
33+ not across products and not across summary/timeseries — while the full
34+ sources → combine → geoserver lineage stays visible for every product.
35+ (Cross-product dedup requires the sharing products to run together; that is
36+ exactly what a cohort is, and is why summary + timeseries products now share a
37+ cohort.)
3238
3339Design notes:
3440- Source and geoserver assets never hard-fail. They catch their own errors and
4046 combine asset rebuilds record objects before dumping.
4147
4248Known limitation — per-analyte source fetches (potential future optimization):
43- Sharing is deduped at the ``(parameter, mode, scope, source)`` grain, which
44- collapses duplication *across products* (e.g. ``sulfate/summary/state_NM/wqp``
45- is one asset shared by nm_major_chemistry and nm_mcl_exceedance). It does NOT
49+ Sharing is deduped at the ``(parameter, scope, source)`` grain, which
50+ collapses duplication *across products* (e.g. ``sulfate/state_NM/wqp`` is one
51+ asset shared by nm_major_chemistry and nm_mcl_exceedance) and *across modes*
52+ (one ``wqp`` asset serves both summary and timeseries products). It does NOT
4653 collapse *across analytes*: a source appears once per analyte (e.g. ``wqp``
47- has ~13 summary source assets, one per analyte). This is because the backend
48- unifies a single parameter per pass (``unify_source`` uses one
49- ``config.parameter``), so each analyte is a separate sweep of the same wells
50- even though one provider query (WQP/AMP/...) typically returns all analytes at
51- once. Collapsing this would need a **backend** change — multi-analyte
52- unification that fetches a source once and emits per-analyte records — after
53- which the source key could drop ``parameter`` (e.g.
54- ``["sources", "analytes", mode, scope, source]``) and the analyte combines
55- would each filter the shared multi-analyte payload. That is the bulk of the
56- remaining redundant API pulls for analyte products; it touches DIE core, not
57- this asset graph, so it is intentionally out of scope here.
54+ has ~13 source assets, one per analyte). This is because the backend unifies a
55+ single parameter per pass (``unify_source_both`` uses one ``config.parameter``),
56+ so each analyte is a separate sweep of the same wells even though one provider
57+ query (WQP/AMP/...) typically returns all analytes at once. Collapsing this
58+ would need a **backend** change — multi-analyte unification that fetches a
59+ source once and emits per-analyte records — after which the source key could
60+ drop ``parameter`` (e.g. ``["sources", "analytes", scope, source]``) and the
61+ analyte combines would each filter the shared multi-analyte payload. That is
62+ the bulk of the remaining redundant API pulls for analyte products; it touches
63+ DIE core, not this asset graph, so it is intentionally out of scope here.
5864"""
5965import tempfile
6066import traceback
7783 dump_trend_collection ,
7884)
7985from backend .record import ParameterRecord , SiteRecord , SummaryRecord
80- from backend .unifier import unify_source
86+ from backend .unifier import unify_source_both
8187from orchestration .logging_bridge import forward_die_logs
8288from orchestration .resources .die_config import DIEConfigResource
8389from orchestration .resources .gcs import GCSResource
9096# truth for the ogc_mcl_exceedance product.
9197_MCL_KEY = "config/mcl.json"
9298
93- # Output types whose unification runs in summary mode. The backend only
94- # distinguishes summary vs timeseries; everything else unifies as timeseries.
95- _SUMMARY_OUTPUT_TYPES = ("ogc_summary" , "ogc_major_chemistry" , "ogc_mcl_exceedance" )
96-
9799# Classic major-ion suite for the ogc_major_chemistry product. One feature per
98100# well, with each analyte's latest value/units/date as properties.
99101_MAJOR_CHEMISTRY = [
109111
110112# A single shared source asset's identity. Two products that produce the same
111113# SourceSpec share one asset (the namedtuple is hashable, so dedup is just set
112- # membership). ``group`` follows the parameter, not the product.
113- SourceSpec = namedtuple ("SourceSpec" , "parameter mode scope source_key group" )
114+ # membership). ``group`` follows the parameter, not the product. There is no
115+ # ``mode`` field: a source is fetched once and unified for *both* summary and
116+ # timeseries (see build_shared_source_asset / unify_source_both), so a summary
117+ # product and a timeseries product over the same (parameter, scope, source)
118+ # share one asset and one fetch.
119+ SourceSpec = namedtuple ("SourceSpec" , "parameter scope source_key group" )
114120
115121
116122def _product_params (product : dict ) -> list [str ]:
@@ -126,11 +132,6 @@ def _product_params(product: dict) -> list[str]:
126132 return [product ["parameter" ]]
127133
128134
129- def _product_mode (product : dict ) -> str :
130- """``summary`` or ``timeseries`` — the unification mode the product needs."""
131- return "summary" if product .get ("output_type" ) in _SUMMARY_OUTPUT_TYPES else "timeseries"
132-
133-
134135def _spatial_scope (product : dict ) -> str :
135136 """A stable string identity for the product's spatial filter. Sources with
136137 different spatial extents unify to different results, so the extent is part
@@ -171,20 +172,19 @@ def _param_source_keys(product: dict, parameter: str) -> list[str]:
171172
172173def product_source_specs (product : dict ) -> list [SourceSpec ]:
173174 """Every shared source asset this product depends on, one per
174- (parameter, source) pair. ``mode`` and ``scope`` are constant for a product;
175- the parameter and source vary. Returned in a stable order."""
176- mode = _product_mode (product )
175+ (parameter, source) pair. ``scope`` is constant for a product; the parameter
176+ and source vary. Returned in a stable order."""
177177 scope = _spatial_scope (product )
178178 specs : list [SourceSpec ] = []
179179 for param in _product_params (product ):
180180 group = _group_for_param (param )
181181 for source_key in _param_source_keys (product , param ):
182- specs .append (SourceSpec (param , mode , scope , source_key , group ))
182+ specs .append (SourceSpec (param , scope , source_key , group ))
183183 return specs
184184
185185
186186def shared_source_key (spec : SourceSpec ) -> dg .AssetKey :
187- return dg .AssetKey (["sources" , spec .parameter , spec .mode , spec . scope , spec .source_key ])
187+ return dg .AssetKey (["sources" , spec .parameter , spec .scope , spec .source_key ])
188188
189189
190190def _in_name (spec : SourceSpec ) -> str :
@@ -196,19 +196,26 @@ def _in_name(spec: SourceSpec) -> str:
196196
197197
198198def build_shared_source_asset (spec : SourceSpec ) -> dg .AssetsDefinition :
199- """Build the shared asset that unifies one source for one (parameter, mode,
200- scope) — keyed product-independently so every product needing it shares it.
199+ """Build the shared asset that unifies one source for one (parameter, scope)
200+ — keyed product-independently so every product needing it shares it.
201+
202+ The source is fetched once and unified for *both* summary and timeseries
203+ (unify_source_both), so the asset carries records (summary) and
204+ sites/timeseries together; summary and timeseries products over the same
205+ (parameter, scope, source) share this one asset and one fetch.
201206
202207 The asset never raises: on failure it records the traceback and fails its
203208 ``returned_data`` check (WARN) instead, so a broken source does not block any
204209 product's combine asset. Output ships as plain ``_payload`` dicts for
205210 IO-manager pickling (see module docstring)."""
206211 src_key = shared_source_key (spec )
207- # Synthetic product spec driving config: only parameter, mode, and spatial
208- # filter affect a single source's unification (sources include/exclude only
209- # selects which sources a product consumes — irrelevant here).
212+ # Synthetic product spec driving config: only parameter and spatial filter
213+ # affect a single source's unification (sources include/exclude only selects
214+ # which sources a product consumes — irrelevant here; mode is handled by
215+ # unify_source_both, which produces both). output_type is nominal — the
216+ # driver toggles summary/timeseries itself.
210217 synth_product = {
211- "output_type" : "ogc_summary" if spec . mode == "summary" else " ogc_timeseries" ,
218+ "output_type" : "ogc_timeseries" ,
212219 "spatial_filter" : _scope_to_spatial_filter (spec .scope ),
213220 }
214221
@@ -226,15 +233,19 @@ def _source_asset(
226233 timeseries : list [list [dict ]] = []
227234 try :
228235 # A source that doesn't provide this parameter is skipped by
229- # unify_source (source_pair → None).
236+ # unify_source_both (source_pair → None).
230237 with forward_die_logs (context ):
231238 config = die_config .get_config (synth_product , parameter = spec .parameter )
232- persister = unify_source (config , spec .source_key )
239+ # One fetch, both modes: summary records + timeseries sites/obs.
240+ summary_persister , timeseries_persister = unify_source_both (
241+ config , spec .source_key
242+ )
233243 # Ship plain dicts across the IO manager; rebuild in combine.
234- records .extend (r ._payload for r in persister .records )
235- sites .extend (s ._payload for s in persister .sites )
244+ records .extend (r ._payload for r in summary_persister .records )
245+ sites .extend (s ._payload for s in timeseries_persister .sites )
236246 timeseries .extend (
237- [o ._payload for o in site_ts ] for site_ts in persister .timeseries
247+ [o ._payload for o in site_ts ]
248+ for site_ts in timeseries_persister .timeseries
238249 )
239250 except Exception :
240251 error = traceback .format_exc ()
@@ -251,7 +262,6 @@ def _source_asset(
251262 metadata = {
252263 "source" : spec .source_key ,
253264 "parameter" : spec .parameter ,
254- "mode" : spec .mode ,
255265 "scope" : spec .scope ,
256266 "record_count" : len (records ),
257267 "site_count" : len (sites ),
0 commit comments