feat(engine): streams as an answer type + distance-bounded flow tracing - #32
feat(engine): streams as an answer type + distance-bounded flow tracing#32prayaslashkari wants to merge 8 commits into
Conversation
Recreates UC1-CQ2c ("what streams are downstream at most N km from
facilities of industry X?") from David's notebook, which needed two
things the query builder could not express.
Streams as an answerable entity. Flowlines previously reached the map
only as a decorative layer traced from the anchors; hyf:HY_FlowPath was
not selectable in either block. Adds 'streams' to EntityType with an
FTYPE filter, IRI hydration, and map wiring. When the target is a
flowline the ?s2target hop is replaced with a direct bind — that hop
would otherwise match any flowline sharing a cell with the answer — and
the supporting stream layer is skipped so flowlines aren't drawn twice.
Cumulative distance cutoff. downstream/upstream traces were the full
transitive closure. Adds an optional maxDistanceKm that sums
nhdplusv2:hasFlowPathLength over the segments between seed and
candidate and filters on the total. Unset, the emitted SPARQL is
byte-identical to before, so existing questions are unaffected.
The bound is not just a filter: "samples within 30 km downstream of NH
airports" returns 27 sample points in ~15s, where the unbounded form
times out against the federation gateway.
Two things worth knowing for review:
The notebook and this implementation do not agree, and the notebook is
wrong. Its outer block re-joins facilities on schema1:address as a
required triple, a predicate only 13 of 144 NH airport facilities carry,
so it silently discards 91% of its own anchor set. Drop that triple and
it returns 1,547 flowlines where we return 1,605 on the same seeding —
we are a superset. Address stays OPTIONAL here.
Neighbour-cell expansion is kept, per discussion: we seed from the
facility's S2 cell and its 8 neighbours where the notebook uses the
facility's own cell only. That is the app-wide convention and accounts
for the rest of the difference (2,757 vs 1,605 for NH airports at 30km).
scripts/flow-distance-check.mjs runs the real planner against the live
endpoints and asserts the step plan, the superset relation, that the
bound actually excludes flowlines, and that every hydrated stream has
drawable geometry.
|
This PR was not deployed automatically as @prayaslashkari does not have access to the Railway project. In order to get automatic PR deploys, please add @prayaslashkari to your workspace on Railway. |
The "+1" from David's second UC1-CQ2c notebook. A distance budget runs
out at whatever segment happens to fit, which is an artifact of how
NHDPlus split the river rather than a real feature — the drawn path
stops mid-channel. Extending one segment past the boundary means the
flowpath visibly crosses the threshold instead of ending at it, which
is the notebook's stated intent: the total may deliberately exceed the
limit.
Always on when a cutoff is set. A checkbox for a 1%-at-30km difference
is a control nobody would understand, and "within 30 km" already reads
as approximate.
Implemented as a zero-or-one property path, which yields the endpoint
and its immediate neighbour in one triple. A UNION says the same thing,
but QLever — which the notebooks run against — returns unbound results
for MIN() over a variable bound inside a UNION, verified on a two-row
test case with no data involved. The path form works on both hosts.
Same reason `?a = ?b` rather than sameTerm(): QLever has not
implemented sameTerm.
Fringe segments cannot inherit their parent's distance, since that
would report a sub-threshold number for a flowline outside the
threshold. They carry the parent's path plus the parent's own length —
the distance to where the fringe segment begins, which never
understates. A flowline reachable as both a valid endpoint and a fringe
keeps the smaller value, since MIN runs over both.
Effect scales inversely with the cutoff, as the fringe is a larger
share of a smaller answer (NH airports):
5 km 1,533 -> 1,669 (+8.9%)
10 km 2,019 -> 2,096 (+3.8%)
30 km 2,757 -> 2,784 (+1.0%)
Verified against apps.okn.us: 2,784 at 30 km, 1,669 at 5 km, 3,490
unbounded (unchanged), and flowline distances spanning 0.01–32.23 km
with the fringe correctly reporting past the threshold. FRINK is
returning 503 across all five endpoints right now, so the committed
check script has not been re-run against it — the two hosts were
verified to return identical result sets earlier in this work.
|
Added the "+1" hop from David's second notebook ( A distance budget runs out at whatever segment happens to fit, which is an artifact of how NHDPlus split the river rather than a real feature — without this the drawn path stops mid-channel. Extending one segment past the boundary means the flowpath visibly crosses the threshold, which is the notebook's stated intent: the total may deliberately exceed the limit. Always on when a cutoff is set. A checkbox for a 1%-at-30km difference is a control nobody would understand, and "within 30 km" already reads as approximate. Easy to make it a toggle if you disagree. Effect scales inversely with the cutoffThe fringe is a larger share of a smaller answer set (NH airports, NAICS 488119):
At 30 km it's noise. At 5 km it's a tenth of the result, so it matters most exactly where a researcher is being most selective. Note this also changes entity answers, not just the drawing — with streams as Block A the fringe is cosmetic, but for "samples downstream of X" it admits sample points sitting on the fringe segment, past the stated distance. That's the notebook's semantics, and it's why the popup number matters (below). Two implementation notes worth reviewZero-or-one property path, not a UNION. SELECT ?k (MIN(?v) AS ?minv) WHERE {
VALUES (?k ?a ?b) { ("x" 1.0 2.0) ("y" 5.0 3.0) }
{ BIND(?a AS ?v) } UNION { BIND(xsd:float(?a) + xsd:float(?b) AS ?v) }
} GROUP BY ?k→ QLever returns Fringe distance. Fringe segments can't inherit their parent's VerificationFRINK is returning 503 across all five endpoints as I write this, so That 32.23 km fringe value is the check that matters — it's a flowline past the boundary honestly reporting a distance past the boundary.
|
FRINK retired https://frink.apps.renci.org/<kg>/sparql; all five knowledge graphs now return 503 there and are served from https://apps.okn.us/<kg>/sparql instead. Reported by David Kedrowski, who hit the same break in his notebooks. Verified all five endpoints live on the new host (200, CORS *), and replayed the real pipeline through planPipeline/executePipeline against them: 7 of 8 prebuilt queries succeed and all discovery queries return live data rather than falling back to hardcoded constants. The Indiana downstream prebuilt still fails, but with a QLever memory-limit error ("Tried to allocate 819.2 MB, but only 743.3 MB were available"), not a routing failure. Tracked separately.
fix(endpoints): move SPARQL endpoints from FRINK to apps.okn.us
The app deploys on Railway from main, but the repo still carried a Render blueprint from the publish workflow and no mention of Railway anywhere. The stale file was actively misleading about where production runs. Removes render.yaml, documents the real deploy target in the README, and adds the W35 changelog covering this and the FRINK endpoint migration.
chore: drop stale render.yaml, document Railway as the deploy target
Recreates UC1-CQ2c from David's notebooks: "What streams are downstream at most N km from facilities of industry X?"
1. What this adds
Two capabilities the query builder didn't have:
Streams are now an answer, not just scenery. Flowlines only ever appeared as a decorative layer traced from the anchors — you couldn't ask for them. (
waterBodiesishyf:HY_WaterBody: lakes and ponds, which the notebook explicitly swaps out for flowlines.) Streams are now selectable in either block, with a stream-type filter.Downstream/upstream can be distance-limited. Traces were the full transitive closure — they ran to the ocean and returned half the state. There's now a Within N km of flow dropdown (5/10/30/50/100 km), plus the notebook's "+1" behaviour: the trace extends one segment past the cutoff so a flowpath ends at a river feature rather than mid-channel.
What this opens up for researchers
One caveat worth repeating in any write-up: this is connectivity, not fate-and-transport. It says water flows from here to there within N km. It says nothing about whether contamination arrives, or at what concentration — no discharge volumes, no dilution, no groundwater. It's a screening layer.
2. How it's implemented
'streams'joinsEntityTypewith FTYPE filtering, IRI hydration and map wiring;maxDistanceKmjoinsSpatialRelationship. When unset, the emitted SPARQL is byte-identical to today's — I diffed generated queries for three existing question shapes to confirm nothing moved.The distance cutoff. Sum the segment lengths between seed and candidate, then filter on the total. The seed block is duplicated inside the subquery — without it the
SUMruns over the entire national flowline graph:{ SELECT DISTINCT ?upstream_flowline ?ds_flowline WHERE { { SELECT ?upstream_flowline ?_flEnd (SUM(?_flLen) AS ?_plen) WHERE { { SELECT ?upstream_flowline ?_flMid ?_flEnd WHERE { { SELECT DISTINCT ?upstream_flowline WHERE { ...anchor seed... } } ?upstream_flowline hyf:downstreamFlowPathTC ?_flMid . ?_flMid hyf:downstreamFlowPathTC ?_flEnd . } } ?_flMid nhdplusv2:hasFlowPathLength/qudt:quantityValue/qudt:numericValue ?_flLen . } GROUP BY ?upstream_flowline ?_flEnd } FILTER (xsd:float(?_plen) < xsd:float(30)) ?_flEnd hyf:downstreamFlowPath? ?ds_flowline . # the "+1" } }The "+1" is that last line — a zero-or-one path, giving the endpoint and its immediate neighbour in one triple. A
UNIONsays the same thing, but QLever (which the notebooks run against) returns unbound results forMIN()over a UNION-bound variable; I isolated it on a two-row case with no data involved. The path form works on both hosts. Same reason for?a = ?boversameTerm()— QLever hasn't implementedsameTerm.Streams as the target get
BIND(?ds_flowline AS ?streamC)instead of the usual?s2targethop, which would otherwise match any flowline sharing an S2 cell with the answer. The supporting stream layer is skipped in that case, so flowlines aren't drawn twice.Flow distance for popups uses
MIN()per flowline (shortest qualifying path). Fringe segments can't inherit their parent's distance — that would report a sub-threshold number for a flowline outside the threshold — so they carry the parent's path plus the parent's own length.3. How this differs from the notebook
schema1:addresson facilitiesOPTIONALMIN()across seedsThe first row is a bug, and it's the whole story of the count difference — it's why the notebook's map shows about five facilities for a state with 144:
The other three rows are deliberate, and none is new to this PR.
Not carried over: the notebook's navy/blue seed-vs-downstream colour split, its S2-cell overlay, and street address in the facility popup (we have name, industry and an EPA FRS link). All cosmetic.
Also worth knowing:
downstreamFlowPathTCin this KG doesn't reach the coast. Unbounded gives 3,490 streams vs 2,784 at 30 km — a narrower closure than the notebook's parameters imply.4. Verification
scripts/flow-distance-check.mjsruns the real planner against the live endpoints and asserts the step plan, the superset relation, that the bound actually excludes flowlines, and that every hydrated stream has drawable geometry. Plain.mjsfollowingscripts/contrast-check.mjs; no test framework added.Measured (NH, NAICS 488119):
Flowline distances at a 30 km cutoff span 0.01–32.23 km — the over-threshold value is the fringe honestly reporting itself as past the boundary.
The bound also makes previously impossible questions possible: "samples within 30 km downstream of NH airports" returns 27 sample points in ~15 s, where the unbounded form times out against the gateway.
npm run buildclean.npm run lintshows the same 15 pre-existing errors asdevelopment— none new.To try it
Block A =
Streams / Flowlines· Relationship =Downstream of+Within 30 km of flow· Block C =Facilities, industry488119, regionNew Hampshire.Put the region on Block C, not Block A. On Block A the anchor becomes every 488119 facility in the country (11,010 across 10,703 S2 cells) and the trace takes minutes — correct, since a Maine stream can be fed from New Hampshire or Quebec, but rarely what you want.