@cyanheads/epa-mcp-server 0.3.3 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -5
- package/CLAUDE.md +5 -5
- package/Dockerfile +3 -3
- package/README.md +58 -23
- package/changelog/0.4.x/0.4.0.md +35 -0
- package/changelog/0.4.x/0.4.1.md +34 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/resources/definitions/facility.resource.d.ts +8 -1
- package/dist/mcp-server/resources/definitions/facility.resource.d.ts.map +1 -1
- package/dist/mcp-server/resources/definitions/facility.resource.js +13 -7
- package/dist/mcp-server/resources/definitions/facility.resource.js.map +1 -1
- package/dist/mcp-server/resources/definitions/index.d.ts +7 -1
- package/dist/mcp-server/resources/definitions/index.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-air-quality.tool.d.ts +23 -9
- package/dist/mcp-server/tools/definitions/get-air-quality.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-air-quality.tool.js +165 -93
- package/dist/mcp-server/tools/definitions/get-air-quality.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-facility.tool.d.ts +30 -2
- package/dist/mcp-server/tools/definitions/get-facility.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-facility.tool.js +211 -53
- package/dist/mcp-server/tools/definitions/get-facility.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-tri-releases.tool.d.ts +5 -2
- package/dist/mcp-server/tools/definitions/get-tri-releases.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-tri-releases.tool.js +18 -15
- package/dist/mcp-server/tools/definitions/get-tri-releases.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/index.d.ts +165 -32
- package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-facilities.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-facilities.tool.js +39 -26
- package/dist/mcp-server/tools/definitions/search-facilities.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-superfund.tool.d.ts +3 -1
- package/dist/mcp-server/tools/definitions/search-superfund.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-superfund.tool.js +13 -9
- package/dist/mcp-server/tools/definitions/search-superfund.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-tri-releases.tool.d.ts +17 -3
- package/dist/mcp-server/tools/definitions/search-tri-releases.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-tri-releases.tool.js +64 -12
- package/dist/mcp-server/tools/definitions/search-tri-releases.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-violations.tool.d.ts +20 -3
- package/dist/mcp-server/tools/definitions/search-violations.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-violations.tool.js +84 -15
- package/dist/mcp-server/tools/definitions/search-violations.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-water-systems.tool.d.ts +3 -1
- package/dist/mcp-server/tools/definitions/search-water-systems.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-water-systems.tool.js +22 -7
- package/dist/mcp-server/tools/definitions/search-water-systems.tool.js.map +1 -1
- package/dist/mcp-server/tools/format-lbs.d.ts +14 -0
- package/dist/mcp-server/tools/format-lbs.d.ts.map +1 -0
- package/dist/mcp-server/tools/format-lbs.js +16 -0
- package/dist/mcp-server/tools/format-lbs.js.map +1 -0
- package/dist/mcp-server/tools/format-location.d.ts +18 -0
- package/dist/mcp-server/tools/format-location.d.ts.map +1 -0
- package/dist/mcp-server/tools/format-location.js +26 -0
- package/dist/mcp-server/tools/format-location.js.map +1 -0
- package/dist/mcp-server/tools/format-tri-release.d.ts +16 -0
- package/dist/mcp-server/tools/format-tri-release.d.ts.map +1 -0
- package/dist/mcp-server/tools/format-tri-release.js +23 -0
- package/dist/mcp-server/tools/format-tri-release.js.map +1 -0
- package/dist/services/airnow/airnow-service.d.ts +10 -29
- package/dist/services/airnow/airnow-service.d.ts.map +1 -1
- package/dist/services/airnow/airnow-service.js +156 -97
- package/dist/services/airnow/airnow-service.js.map +1 -1
- package/dist/services/airnow/types.d.ts +62 -23
- package/dist/services/airnow/types.d.ts.map +1 -1
- package/dist/services/airnow/types.js +2 -1
- package/dist/services/airnow/types.js.map +1 -1
- package/dist/services/call-budget.d.ts +22 -0
- package/dist/services/call-budget.d.ts.map +1 -0
- package/dist/services/call-budget.js +28 -0
- package/dist/services/call-budget.js.map +1 -0
- package/dist/services/dmap/dmap-service.d.ts +76 -7
- package/dist/services/dmap/dmap-service.d.ts.map +1 -1
- package/dist/services/dmap/dmap-service.js +461 -144
- package/dist/services/dmap/dmap-service.js.map +1 -1
- package/dist/services/dmap/types.d.ts +17 -4
- package/dist/services/dmap/types.d.ts.map +1 -1
- package/dist/services/echo/echo-service.d.ts +38 -11
- package/dist/services/echo/echo-service.d.ts.map +1 -1
- package/dist/services/echo/echo-service.js +417 -223
- package/dist/services/echo/echo-service.js.map +1 -1
- package/dist/services/echo/types.d.ts +167 -80
- package/dist/services/echo/types.d.ts.map +1 -1
- package/dist/services/ejscreen/ejscreen-service.d.ts.map +1 -1
- package/dist/services/ejscreen/ejscreen-service.js +9 -3
- package/dist/services/ejscreen/ejscreen-service.js.map +1 -1
- package/package.json +7 -7
- package/server.json +9 -3
|
@@ -3,9 +3,124 @@
|
|
|
3
3
|
* Wraps data.epa.gov/dmapservice REST endpoints.
|
|
4
4
|
* @module services/dmap/dmap-service
|
|
5
5
|
*/
|
|
6
|
-
import { serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
|
|
6
|
+
import { JsonRpcErrorCode, serviceUnavailable, validationError, } from '@cyanheads/mcp-ts-core/errors';
|
|
7
7
|
import { httpErrorFromResponse, withRetry } from '@cyanheads/mcp-ts-core/utils';
|
|
8
8
|
import { getServerConfig } from '../../config/server-config.js';
|
|
9
|
+
import { callBudget } from '../call-budget.js';
|
|
10
|
+
/**
|
|
11
|
+
* Characters DMAP reads literally in a `contains` value. Live (2026-10-16), its path parser
|
|
12
|
+
* answers any other punctuation and every non-ASCII letter with HTTP 500 "The query could not be
|
|
13
|
+
* parsed" (apostrophes, brackets, parentheses, periods, slashes, Greek letters), `%` draws an nginx
|
|
14
|
+
* HTTP 403, a comma ends the value (`contains/1,3-Butadiene` matches every name containing "1"),
|
|
15
|
+
* and `_` and `?` match any one character.
|
|
16
|
+
*/
|
|
17
|
+
const CONTAINS_LITERAL = /^[A-Za-z0-9 -]$/;
|
|
18
|
+
/** Characters DMAP reads literally in an `equals` or `in` value, where `_` and `?` are not wildcards. */
|
|
19
|
+
const EXACT_LITERAL = /^[A-Za-z0-9 _?@-]$/;
|
|
20
|
+
/**
|
|
21
|
+
* `value` without slashes at either end, unless it is nothing but slashes. DMAP decodes its path
|
|
22
|
+
* before splitting it and drops empty segments, so an end slash never reached a filter: live
|
|
23
|
+
* (2026-10-16), `equals/98108THBNG7500E%2F`, `equals/%2F98108THBNG7500E`, and
|
|
24
|
+
* `contains/Seattle%2F` answer exactly as the bare values do. A slash inside a value shifts the
|
|
25
|
+
* path apart and is still rejected.
|
|
26
|
+
*/
|
|
27
|
+
function trimSlashes(value) {
|
|
28
|
+
let start = 0;
|
|
29
|
+
let end = value.length;
|
|
30
|
+
while (start < end && value[start] === '/')
|
|
31
|
+
start++;
|
|
32
|
+
while (end > start && value[end - 1] === '/')
|
|
33
|
+
end--;
|
|
34
|
+
return start === end ? value : value.slice(start, end);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A `contains` filter DMAP can carry. Each character it cannot read literally becomes `_`, its
|
|
38
|
+
* one-character wildcard, and `literal` keeps the text as given so fetchMatchingRows keeps only
|
|
39
|
+
* the rows that contain it; a `_` the caller wrote is a wildcard too, so it gets the same pass.
|
|
40
|
+
* Across all 860 TRI chemical names and every TRI county name, the wildcard form of a name
|
|
41
|
+
* matches no other name; Superfund city names carry spelling variants (COEUR D ALENE beside
|
|
42
|
+
* COEUR D'ALENE) that the exact pass drops.
|
|
43
|
+
*/
|
|
44
|
+
function containsFilter(column, text) {
|
|
45
|
+
const value = trimSlashes(text);
|
|
46
|
+
const characters = Array.from(value);
|
|
47
|
+
if (characters.every((c) => CONTAINS_LITERAL.test(c))) {
|
|
48
|
+
return { column, operator: 'contains', value };
|
|
49
|
+
}
|
|
50
|
+
const pattern = characters.map((c) => (CONTAINS_LITERAL.test(c) ? c : '_')).join('');
|
|
51
|
+
if (!/[A-Za-z0-9]/.test(pattern)) {
|
|
52
|
+
throw validationError(`"${value}" has no letter or digit to search on. EPA's DMAP service matches text by its letters, digits, spaces, and hyphens.`, { value });
|
|
53
|
+
}
|
|
54
|
+
return { column, operator: 'contains', value: pattern, literal: value };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* An `equals` or `in` filter as DMAP reads it: end slashes dropped (trimSlashes), and a value
|
|
58
|
+
* carrying a character DMAP cannot match rejected before any request.
|
|
59
|
+
*/
|
|
60
|
+
function exactFilter(filter) {
|
|
61
|
+
if (filter.operator === 'contains')
|
|
62
|
+
return filter;
|
|
63
|
+
const exact = { ...filter, value: trimSlashes(filter.value) };
|
|
64
|
+
const values = exact.operator === 'in' ? exact.value.split(',') : [exact.value];
|
|
65
|
+
for (const value of values) {
|
|
66
|
+
for (const character of value) {
|
|
67
|
+
if (!EXACT_LITERAL.test(character)) {
|
|
68
|
+
const recovery = character === ',' ? 'Pass one value per call.' : 'Remove it and retry.';
|
|
69
|
+
throw validationError(`"${value}" contains "${character}", a character EPA's DMAP service cannot match in an exact-value filter. ${recovery}`, { value, character });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return exact;
|
|
74
|
+
}
|
|
75
|
+
/** Rows read per page while a wildcard filter's exact matches are collected. */
|
|
76
|
+
const EXACT_TEXT_PAGE_ROWS = 100;
|
|
77
|
+
/**
|
|
78
|
+
* Pages read for a wildcard filter before returning what matched. Reaching it takes hundreds of
|
|
79
|
+
* near-variant rows ahead of the exact ones in DMAP's order.
|
|
80
|
+
*/
|
|
81
|
+
const EXACT_TEXT_MAX_PAGES = 3;
|
|
82
|
+
/** DMAP's front end refuses a longer request line with HTTP 400 "Request Line is too large". */
|
|
83
|
+
const DMAP_REQUEST_LINE_LIMIT = 4094;
|
|
84
|
+
/**
|
|
85
|
+
* Bytes of the client's request line DMAP leaves out of that count: live (2026-10-16), a
|
|
86
|
+
* 4,105-byte line is answered and a 4,106-byte one is refused as "(4095 > 4094)".
|
|
87
|
+
*/
|
|
88
|
+
const DMAP_UNCOUNTED_BYTES = 11;
|
|
89
|
+
/**
|
|
90
|
+
* Newest reporting year first, then submission number, so a row range pages deterministically.
|
|
91
|
+
* Keys must be table-qualified: DMAP resolves a bare column against the first table of a join
|
|
92
|
+
* (`reporting_year` there is HTTP 500). Without the doc_ctrl_num tiebreak, AK rows `1:50` and
|
|
93
|
+
* `51:100` shared 36 records.
|
|
94
|
+
*/
|
|
95
|
+
const NEWEST_FIRST = [
|
|
96
|
+
{ column: 'tri.tri_reporting_form.reporting_year', direction: 'desc' },
|
|
97
|
+
{ column: 'tri.tri_reporting_form.doc_ctrl_num', direction: 'asc' },
|
|
98
|
+
];
|
|
99
|
+
/**
|
|
100
|
+
* Request-line budget for a batched `in` list, under DMAP_REQUEST_LINE_LIMIT. DMAP counts 11 bytes
|
|
101
|
+
* fewer than the client sends, and the budget leaves a further ~300 bytes of margin.
|
|
102
|
+
*/
|
|
103
|
+
const IN_LIST_REQUEST_LINE_BUDGET = 3800;
|
|
104
|
+
/**
|
|
105
|
+
* Breakdown chunks in flight at once. An uncached tri.tri_release_qty request takes ~18 s however
|
|
106
|
+
* many submissions it names, so chunks run side by side: getTriReleases' 500 forms make at most
|
|
107
|
+
* three chunks, one wave.
|
|
108
|
+
*/
|
|
109
|
+
const BREAKDOWN_CONCURRENCY = 3;
|
|
110
|
+
/**
|
|
111
|
+
* One budget for every DMAP request a service call chains — a TRI search and its breakdown chunks,
|
|
112
|
+
* a ZIP's system read and its violation checks, every retry and backoff — so the call settles
|
|
113
|
+
* under the 60 s request timeout of MCP SDK clients. An expiry names how to narrow the call.
|
|
114
|
+
*/
|
|
115
|
+
const CALL_DEADLINE_MS = 50_000;
|
|
116
|
+
const dmapBudget = (task, narrow) => callBudget('DMAP', task, CALL_DEADLINE_MS, narrow);
|
|
117
|
+
/** Max tri.tri_release_qty rows read per submission (live submissions carry about 10). */
|
|
118
|
+
const RELEASE_QTY_ROWS_PER_SUBMISSION = 30;
|
|
119
|
+
/** The HTTP/1.1 request line a GET of `url` sends: method, path and query, protocol. */
|
|
120
|
+
function requestLineBytes(url) {
|
|
121
|
+
const { pathname, search } = new URL(url);
|
|
122
|
+
return Buffer.byteLength(`GET ${pathname}${search} HTTP/1.1`);
|
|
123
|
+
}
|
|
9
124
|
/** Parse a numeric field, returning undefined if absent/NaN. */
|
|
10
125
|
function parseNum(val) {
|
|
11
126
|
if (val === undefined || val === null || val === '')
|
|
@@ -13,22 +128,58 @@ function parseNum(val) {
|
|
|
13
128
|
const n = Number(val);
|
|
14
129
|
return Number.isNaN(n) ? undefined : n;
|
|
15
130
|
}
|
|
16
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* Round a computed TRI quantity to 12 significant digits. TRI reports at most 7, so this keeps
|
|
133
|
+
* every reported digit while dropping the binary-float noise a sum or unit conversion adds
|
|
134
|
+
* (0.000894 + 0.000014 = 0.0009080000000000001 → 0.000908). formatLbs renders the same 12 digits,
|
|
135
|
+
* so structuredContent and text carry one value.
|
|
136
|
+
*/
|
|
137
|
+
function roundQuantity(n) {
|
|
138
|
+
return Number(n.toPrecision(12));
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* tri_chem_id of dioxin and dioxin-like compounds. EPA's TRI program has facilities report this
|
|
142
|
+
* category in grams and every other chemical in pounds (TRI Basic Data Files documentation,
|
|
143
|
+
* UNIT_OF_MEASURE). The reporting-form and release-quantity tables carry no unit column, so the
|
|
144
|
+
* chemical ID is the marker on their rows (tri.tri_chem_info.unit_of_measure records Grams for
|
|
145
|
+
* N150, the only gram-reported chemical).
|
|
146
|
+
*/
|
|
147
|
+
const GRAM_REPORTED_CHEM_ID = 'N150';
|
|
148
|
+
/** Grams per avoirdupois pound, exact by definition. */
|
|
149
|
+
const GRAMS_PER_POUND = 453.59237;
|
|
150
|
+
function gramsToPounds(grams) {
|
|
151
|
+
return roundQuantity(grams / GRAMS_PER_POUND);
|
|
152
|
+
}
|
|
153
|
+
/** Normalize a raw TRI reporting form row; a gram-reported chemical's quantity becomes pounds. */
|
|
17
154
|
function normalizeTriRelease(raw) {
|
|
155
|
+
const inGrams = raw.tri_chem_id === GRAM_REPORTED_CHEM_ID;
|
|
18
156
|
const result = {
|
|
19
157
|
facilityId: raw.tri_facility_id ?? '',
|
|
20
158
|
// tri_reporting_form uses cas_chem_name, not chemical_name_text
|
|
21
159
|
chemicalName: raw.cas_chem_name ?? '',
|
|
22
160
|
reportingYear: Number(raw.reporting_year ?? 0),
|
|
161
|
+
...(inGrams && { reportedUnit: 'grams' }),
|
|
23
162
|
};
|
|
24
163
|
// one_time_release_qty is the only release qty in tri_reporting_form; the per-medium routine
|
|
25
|
-
// breakdown lives in tri.tri_release_qty and is merged in by
|
|
26
|
-
//
|
|
164
|
+
// breakdown lives in tri.tri_release_qty and is merged in by the callers through
|
|
165
|
+
// fetchReleaseBreakdown (always for getTriReleases, opt-in for searchTriReleases).
|
|
27
166
|
const oneTime = parseNum(raw.one_time_release_qty);
|
|
28
167
|
if (oneTime !== undefined)
|
|
29
|
-
result.totalReleasesInLbs = oneTime;
|
|
168
|
+
result.totalReleasesInLbs = inGrams ? gramsToPounds(oneTime) : oneTime;
|
|
30
169
|
return result;
|
|
31
170
|
}
|
|
171
|
+
/** Merge a submission's per-medium sums into its release, converting a gram-reported form to pounds. */
|
|
172
|
+
function withBreakdown(release, breakdown) {
|
|
173
|
+
if (!breakdown)
|
|
174
|
+
return release;
|
|
175
|
+
if (release.reportedUnit !== 'grams')
|
|
176
|
+
return { ...release, ...breakdown };
|
|
177
|
+
const pounds = {};
|
|
178
|
+
for (const [field, grams] of Object.entries(breakdown)) {
|
|
179
|
+
pounds[field] = gramsToPounds(grams);
|
|
180
|
+
}
|
|
181
|
+
return { ...release, ...pounds };
|
|
182
|
+
}
|
|
32
183
|
/**
|
|
33
184
|
* Map each tri.tri_release_qty environmental_medium code to the TriRelease field its quantity
|
|
34
185
|
* rolls up into, following EPA's on-site release taxonomy: air (fugitive + stack), water, land
|
|
@@ -49,8 +200,31 @@ const RELEASE_FIELD_BY_MEDIUM = {
|
|
|
49
200
|
'UNINJ IIV': 'releasesToUndergroundInjectionInLbs',
|
|
50
201
|
UNINJ8795: 'releasesToUndergroundInjectionInLbs',
|
|
51
202
|
};
|
|
52
|
-
/**
|
|
53
|
-
const
|
|
203
|
+
/** sdwis.violation rows read to find a state's violating water systems. */
|
|
204
|
+
const VIOLATION_SCAN_ROWS = 1000;
|
|
205
|
+
/**
|
|
206
|
+
* sdwis.violation compliance_status_code values for a violation still open. Live rows carry four
|
|
207
|
+
* codes: K and O have no rtc_date (unresolved); R has an rtc_date and resolving enforcement
|
|
208
|
+
* (returned to compliance); I has rtc_date equal to the system's pws_deactivation_date (resolved
|
|
209
|
+
* when the system closed).
|
|
210
|
+
*/
|
|
211
|
+
const OPEN_VIOLATION_FILTER = {
|
|
212
|
+
column: 'compliance_status_code',
|
|
213
|
+
operator: 'in',
|
|
214
|
+
value: 'K,O',
|
|
215
|
+
};
|
|
216
|
+
/** PWSIDs per `pwsid in` request — a ~2,500-character URL. */
|
|
217
|
+
const PWSID_CHUNK_SIZE = 200;
|
|
218
|
+
/**
|
|
219
|
+
* sdwis.water_system rows read for a ZIP violation check. Live ZIPs hold well under 200 systems
|
|
220
|
+
* (98104: 8, 59901: 194), so one read covers the ZIP and a check is at most
|
|
221
|
+
* 1 + ZIP_SYSTEM_SCAN_ROWS / PWSID_CHUNK_SIZE = 6 requests.
|
|
222
|
+
*/
|
|
223
|
+
const ZIP_SYSTEM_SCAN_ROWS = 1000;
|
|
224
|
+
/** The distinct PWSIDs named by a set of sdwis.violation rows. */
|
|
225
|
+
function pwsidSet(violations) {
|
|
226
|
+
return new Set(violations.map((r) => r.pwsid).filter((id) => typeof id === 'string'));
|
|
227
|
+
}
|
|
54
228
|
/** Normalize a raw SEMS envirofacts_site record. */
|
|
55
229
|
function normalizeSemsSite(raw) {
|
|
56
230
|
const lat = parseNum(raw.primary_latitude_decimal_val);
|
|
@@ -109,24 +283,58 @@ export class DmapService {
|
|
|
109
283
|
this.baseUrl = getServerConfig().dmapBaseUrl;
|
|
110
284
|
}
|
|
111
285
|
/**
|
|
112
|
-
* Build a DMAP REST URL for a multi-filter query.
|
|
113
|
-
* Format: {base}/{schema}.{table}/{col1}/{op1}/{val1}/and/{col2}/{op2}/{val2}/{first}:{last}
|
|
286
|
+
* Build a DMAP REST JSON URL for a multi-filter query.
|
|
287
|
+
* Format: {base}/{schema}.{table}/{col1}/{op1}/{val1}/and/{col2}/{op2}/{val2}/{first}:{last}/json
|
|
288
|
+
* Rows are 1-based and inclusive: `1:50` is 50 rows. DMAP reads a `first` of 0 as row 1, so
|
|
289
|
+
* `0:49` would return only 49.
|
|
290
|
+
*
|
|
291
|
+
* With `join`, an inner join follows the filters:
|
|
292
|
+
* `/join/{schema}.{table}/{leftColumn}/equals/{rightColumn}/and/{filter}…`. Filters before the
|
|
293
|
+
* join bind to the first table only (DMAP rejects a column the first table lacks); filters
|
|
294
|
+
* after the join comparison bind to the joined table. The range then counts joined rows.
|
|
295
|
+
*
|
|
296
|
+
* With `sort`, `/sort/{column}:{asc|desc},…` follows the filters and join, before the range.
|
|
297
|
+
*
|
|
298
|
+
* An `equals` or `in` value goes through exactFilter, so one carrying a character DMAP cannot
|
|
299
|
+
* match throws a ValidationError naming it; `contains` values arrive already rewritten by
|
|
300
|
+
* containsFilter.
|
|
114
301
|
*/
|
|
115
|
-
buildTableUrl(schema, table, filters, pagination) {
|
|
116
|
-
const
|
|
302
|
+
buildTableUrl(schema, table, filters, pagination, options = {}) {
|
|
303
|
+
const { join, sort } = options;
|
|
304
|
+
const toPath = (list) => list
|
|
305
|
+
.map(exactFilter)
|
|
117
306
|
.map((f) => `${encodeURIComponent(f.column)}/${f.operator}/${encodeURIComponent(f.value)}`)
|
|
118
307
|
.join('/and/');
|
|
308
|
+
const joinPath = join
|
|
309
|
+
? `/join/${join.table}/${join.on.left}/equals/${join.on.right}${join.filters.length > 0 ? `/and/${toPath(join.filters)}` : ''}`
|
|
310
|
+
: '';
|
|
311
|
+
const sortPath = sort ? `/sort/${sort.map((k) => `${k.column}:${k.direction}`).join(',')}` : '';
|
|
119
312
|
const pagePath = `${pagination.first}:${pagination.last}`;
|
|
120
|
-
return `${this.baseUrl}/${schema}.${table}/${
|
|
313
|
+
return `${this.baseUrl}/${schema}.${table}/${toPath(filters)}${joinPath}${sortPath}/${pagePath}/json`;
|
|
121
314
|
}
|
|
122
|
-
/**
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
315
|
+
/**
|
|
316
|
+
* Fetch a DMAP table result as JSON array. Every attempt and backoff ends within `budget`
|
|
317
|
+
* (shared by every request of one service call); expiry rejects with the budget's Timeout.
|
|
318
|
+
*
|
|
319
|
+
* A request line DMAP would count past DMAP_REQUEST_LINE_LIMIT is never sent: batched `in`
|
|
320
|
+
* lists are chunked to fit, so only caller-supplied filter text can push one over, and it fails
|
|
321
|
+
* as a ValidationError. DMAP answers HTTP 400 only from its front end, for a request it cannot
|
|
322
|
+
* parse, never for a filter value — those are HTTP 500. A 400 that still arrives is a request
|
|
323
|
+
* this server built wrong, not the caller's input, and maps to InternalError rather than the
|
|
324
|
+
* default InvalidParams.
|
|
325
|
+
*/
|
|
326
|
+
fetchTable(url, ctx, budget) {
|
|
327
|
+
const countedBytes = requestLineBytes(url) - DMAP_UNCOUNTED_BYTES;
|
|
328
|
+
if (countedBytes > DMAP_REQUEST_LINE_LIMIT) {
|
|
329
|
+
return Promise.reject(validationError(`The search filters are too long for EPA's DMAP service: the request would be ${countedBytes} bytes, over its ${DMAP_REQUEST_LINE_LIMIT}-byte limit. Shorten the text filters.`, { requestLineBytes: countedBytes }));
|
|
330
|
+
}
|
|
331
|
+
return withRetry(async ({ signal }) => {
|
|
332
|
+
const response = await fetch(url, { signal });
|
|
128
333
|
if (!response.ok) {
|
|
129
|
-
throw await httpErrorFromResponse(response, {
|
|
334
|
+
throw await httpErrorFromResponse(response, {
|
|
335
|
+
service: 'DMAP',
|
|
336
|
+
codeOverride: (status) => (status === 400 ? JsonRpcErrorCode.InternalError : undefined),
|
|
337
|
+
});
|
|
130
338
|
}
|
|
131
339
|
const text = await response.text();
|
|
132
340
|
if (/^\s*<(!DOCTYPE\s+html|html[\s>])/i.test(text)) {
|
|
@@ -143,7 +351,41 @@ export class DmapService {
|
|
|
143
351
|
operation: 'DmapService.fetchTable',
|
|
144
352
|
baseDelayMs: 1000,
|
|
145
353
|
signal: ctx.signal,
|
|
146
|
-
|
|
354
|
+
deadlineMs: budget.remainingMs(),
|
|
355
|
+
}).catch(budget.rethrow);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Fetch up to `wanted` rows. Without a wildcarded filter (containsFilter) that is one request
|
|
359
|
+
* ranged `1:wanted`. With one, DMAP's rows can include near-variants that match only through the
|
|
360
|
+
* wildcard, so pages of at least EXACT_TEXT_PAGE_ROWS are read and narrowed to rows containing
|
|
361
|
+
* the text as given, until `wanted` are found, DMAP runs out, or EXACT_TEXT_MAX_PAGES are read.
|
|
362
|
+
* A walk the page limit ends short of `wanted` carries a notice that more matches may exist.
|
|
363
|
+
*/
|
|
364
|
+
async fetchMatchingRows(urlForRange, filters, wanted, ctx, budget) {
|
|
365
|
+
const wildcarded = filters.filter((f) => f.literal !== undefined);
|
|
366
|
+
if (wildcarded.length === 0) {
|
|
367
|
+
return this.fetchTable(urlForRange({ first: 1, last: wanted }), ctx, budget);
|
|
368
|
+
}
|
|
369
|
+
const pageRows = Math.max(wanted, EXACT_TEXT_PAGE_ROWS);
|
|
370
|
+
const matches = [];
|
|
371
|
+
let exhausted = false;
|
|
372
|
+
for (let page = 0; page < EXACT_TEXT_MAX_PAGES && matches.length < wanted; page++) {
|
|
373
|
+
const first = page * pageRows + 1;
|
|
374
|
+
const rows = await this.fetchTable(urlForRange({ first, last: first + pageRows - 1 }), ctx, budget);
|
|
375
|
+
matches.push(...rows.filter((row) => wildcarded.every((f) => String(row[f.column] ?? '')
|
|
376
|
+
.toLowerCase()
|
|
377
|
+
.includes(f.literal.toLowerCase()))));
|
|
378
|
+
if (rows.length < pageRows) {
|
|
379
|
+
exhausted = true;
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (!exhausted && matches.length < wanted) {
|
|
384
|
+
const texts = wildcarded.map((f) => `"${f.literal}"`).join(' and ');
|
|
385
|
+
const kept = `${matches.length} ${matches.length === 1 ? 'match' : 'matches'}`;
|
|
386
|
+
ctx.enrich.notice(`${texts} ${wildcarded.length === 1 ? 'was' : 'were'} searched with a one-character wildcard in place of punctuation, and the first ${(EXACT_TEXT_MAX_PAGES * pageRows).toLocaleString('en-US')} rows matching the wildcard held ${kept} spelled as given, so more may exist past them. Narrow the search with other filters or more of the text.`);
|
|
387
|
+
}
|
|
388
|
+
return matches.slice(0, wanted);
|
|
147
389
|
}
|
|
148
390
|
/** Get TRI releases for a specific facility. */
|
|
149
391
|
async getTriReleases(params, ctx) {
|
|
@@ -155,45 +397,80 @@ export class DmapService {
|
|
|
155
397
|
}
|
|
156
398
|
if (params.chemicalName) {
|
|
157
399
|
// actual field is cas_chem_name, not chemical_name_text
|
|
158
|
-
filters.push(
|
|
159
|
-
column: 'cas_chem_name',
|
|
160
|
-
operator: 'contains',
|
|
161
|
-
value: params.chemicalName,
|
|
162
|
-
});
|
|
400
|
+
filters.push(containsFilter('cas_chem_name', params.chemicalName));
|
|
163
401
|
}
|
|
164
|
-
const
|
|
402
|
+
const budget = dmapBudget('TRI release lookup', 'Narrow it with year or chemical_name, or call again later.');
|
|
165
403
|
ctx.log.debug('DMAP TRI releases query', { facilityId: params.facilityId, year: params.year });
|
|
166
|
-
const rows = await this.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
404
|
+
const rows = await this.fetchMatchingRows((range) => this.buildTableUrl('tri', 'tri_reporting_form', filters, range), filters, 500, ctx, budget);
|
|
405
|
+
return this.withReleaseBreakdown(rows, rows.map(normalizeTriRelease), ctx, budget);
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Add each release's per-medium routine breakdown (tri.tri_release_qty, joined on doc_ctrl_num).
|
|
409
|
+
* `releases[i]` is the normalized form of `rows[i]`.
|
|
410
|
+
*/
|
|
411
|
+
async withReleaseBreakdown(rows, releases, ctx, budget) {
|
|
170
412
|
const docCtrlNums = [
|
|
171
413
|
...new Set(rows
|
|
172
414
|
.map((r) => r.doc_ctrl_num)
|
|
173
415
|
.filter((d) => typeof d === 'string' && d.length > 0)),
|
|
174
416
|
];
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
return breakdown ? { ...release, ...breakdown } : release;
|
|
417
|
+
if (docCtrlNums.length === 0)
|
|
418
|
+
return releases;
|
|
419
|
+
const breakdownByDoc = await this.fetchReleaseBreakdown(docCtrlNums, ctx, budget);
|
|
420
|
+
return releases.map((release, i) => {
|
|
421
|
+
const doc = rows[i]?.doc_ctrl_num;
|
|
422
|
+
return withBreakdown(release, typeof doc === 'string' ? breakdownByDoc.get(doc) : undefined);
|
|
182
423
|
});
|
|
183
424
|
}
|
|
184
425
|
/**
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
426
|
+
* Fetch the per-medium routine releases (tri.tri_release_qty) for a set of TRI submissions and
|
|
427
|
+
* roll them up into air/water/land/underground-injection sums keyed by doc_ctrl_num.
|
|
428
|
+
* getTriReleases does this on every call; searchTriReleases only when includeReleaseBreakdown
|
|
429
|
+
* is set — an uncached tri_release_qty request takes ~18 s upstream.
|
|
430
|
+
*
|
|
431
|
+
* The submissions travel as one `doc_ctrl_num in` list per request, split so each request line
|
|
432
|
+
* stays within IN_LIST_REQUEST_LINE_BUDGET (about 230 thirteen-digit numbers per request). The
|
|
433
|
+
* chunks run BREAKDOWN_CONCURRENCY at a time against the call's deadline, and their rows merge
|
|
434
|
+
* into one rollup; the lists are disjoint, so no row is counted twice.
|
|
188
435
|
*
|
|
189
436
|
* Sparsity is preserved: a row flagged release_na="1" (medium not applicable) or carrying only
|
|
190
437
|
* a release_range_code (no hard total_release) contributes nothing — a null quantity is never
|
|
191
438
|
* coerced to 0.
|
|
192
439
|
*/
|
|
193
|
-
async fetchReleaseBreakdown(docCtrlNums, ctx) {
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
440
|
+
async fetchReleaseBreakdown(docCtrlNums, ctx, budget) {
|
|
441
|
+
const urlFor = (docs) => this.buildTableUrl('tri', 'tri_release_qty', [{ column: 'doc_ctrl_num', operator: 'in', value: docs.join(',') }], { first: 1, last: docs.length * RELEASE_QTY_ROWS_PER_SUBMISSION });
|
|
442
|
+
const urls = [];
|
|
443
|
+
let chunk = [];
|
|
444
|
+
for (const doc of docCtrlNums) {
|
|
445
|
+
if (chunk.length > 0 &&
|
|
446
|
+
requestLineBytes(urlFor([...chunk, doc])) > IN_LIST_REQUEST_LINE_BUDGET) {
|
|
447
|
+
urls.push(urlFor(chunk));
|
|
448
|
+
chunk = [];
|
|
449
|
+
}
|
|
450
|
+
chunk.push(doc);
|
|
451
|
+
}
|
|
452
|
+
urls.push(urlFor(chunk));
|
|
453
|
+
ctx.log.debug('DMAP TRI release-qty breakdown query', {
|
|
454
|
+
submissions: docCtrlNums.length,
|
|
455
|
+
requests: urls.length,
|
|
456
|
+
});
|
|
457
|
+
const rows = [];
|
|
458
|
+
let next = 0;
|
|
459
|
+
let failed = false;
|
|
460
|
+
const worker = async () => {
|
|
461
|
+
while (!failed && next < urls.length) {
|
|
462
|
+
const url = urls[next++];
|
|
463
|
+
try {
|
|
464
|
+
rows.push(...(await this.fetchTable(url, ctx, budget)));
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
// The call has already failed; start no further chunks.
|
|
468
|
+
failed = true;
|
|
469
|
+
throw error;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
await Promise.all(Array.from({ length: Math.min(BREAKDOWN_CONCURRENCY, urls.length) }, worker));
|
|
197
474
|
const byDoc = new Map();
|
|
198
475
|
for (const row of rows) {
|
|
199
476
|
const doc = row.doc_ctrl_num;
|
|
@@ -211,74 +488,68 @@ export class DmapService {
|
|
|
211
488
|
if (amount === undefined)
|
|
212
489
|
continue;
|
|
213
490
|
const entry = byDoc.get(doc) ?? {};
|
|
214
|
-
entry[field] = (entry[field] ?? 0) + amount;
|
|
491
|
+
entry[field] = roundQuantity((entry[field] ?? 0) + amount);
|
|
215
492
|
byDoc.set(doc, entry);
|
|
216
493
|
}
|
|
217
494
|
return byDoc;
|
|
218
495
|
}
|
|
219
|
-
/**
|
|
496
|
+
/**
|
|
497
|
+
* Search TRI releases by state, optionally filtered by county, year, or chemical.
|
|
498
|
+
* tri.tri_reporting_form carries no state, so one request joins it to tri.tri_facility: the
|
|
499
|
+
* state and county filters bind to the facility table, the year and chemical filters to the
|
|
500
|
+
* forms, and the row range caps the joined rows at `limit`. Without `year` the joined rows are
|
|
501
|
+
* sorted newest reporting year first, so `limit` keeps the most recent filings; with `year` the
|
|
502
|
+
* request is unsorted (table order already pages deterministically). However sparse the
|
|
503
|
+
* filters, the search is one request; with includeReleaseBreakdown, fetchReleaseBreakdown adds
|
|
504
|
+
* the per-medium routine releases to every kept record (one request at the 200-record cap).
|
|
505
|
+
*/
|
|
220
506
|
async searchTriReleases(params, ctx) {
|
|
221
507
|
const limit = params.limit ?? 50;
|
|
222
508
|
const facilityFilters = [
|
|
223
509
|
{ column: 'state_abbr', operator: 'equals', value: params.state },
|
|
224
510
|
];
|
|
225
511
|
if (params.county) {
|
|
226
|
-
|
|
512
|
+
// DMAP matches county_name case-insensitively; `contains` catches states that store both a
|
|
513
|
+
// bare and a suffixed form of one county (CALCASIEU / CALCASIEU PARISH), at the cost of
|
|
514
|
+
// also matching a longer name that contains this one (LAKE / LAKE OF THE WOODS).
|
|
515
|
+
facilityFilters.push(containsFilter('county_name', params.county));
|
|
227
516
|
}
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
});
|
|
235
|
-
ctx.log.debug('DMAP TRI facility query', {
|
|
236
|
-
state: params.state,
|
|
237
|
-
firstFacilityRow,
|
|
517
|
+
const formFilters = [];
|
|
518
|
+
if (params.year !== undefined) {
|
|
519
|
+
formFilters.push({
|
|
520
|
+
column: 'reporting_year',
|
|
521
|
+
operator: 'equals',
|
|
522
|
+
value: String(params.year),
|
|
238
523
|
});
|
|
239
|
-
const facilities = await this.fetchTable(facilityUrl, ctx);
|
|
240
|
-
if (facilities.length === 0)
|
|
241
|
-
break;
|
|
242
|
-
const facilityIds = facilities
|
|
243
|
-
.map((facility) => facility.tri_facility_id)
|
|
244
|
-
.filter((id) => typeof id === 'string' && id.length > 0);
|
|
245
|
-
if (facilityIds.length > 0) {
|
|
246
|
-
const releaseFilters = [
|
|
247
|
-
{ column: 'tri_facility_id', operator: 'in', value: facilityIds.join(',') },
|
|
248
|
-
];
|
|
249
|
-
if (params.year !== undefined) {
|
|
250
|
-
releaseFilters.push({
|
|
251
|
-
column: 'reporting_year',
|
|
252
|
-
operator: 'equals',
|
|
253
|
-
value: String(params.year),
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
if (params.chemicalName) {
|
|
257
|
-
// actual field is cas_chem_name, not chemical_name_text
|
|
258
|
-
releaseFilters.push({
|
|
259
|
-
column: 'cas_chem_name',
|
|
260
|
-
operator: 'contains',
|
|
261
|
-
value: params.chemicalName,
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
const remaining = limit - releases.length;
|
|
265
|
-
const releaseUrl = this.buildTableUrl('tri', 'tri_reporting_form', releaseFilters, {
|
|
266
|
-
first: 1,
|
|
267
|
-
last: remaining,
|
|
268
|
-
});
|
|
269
|
-
const releaseRows = await this.fetchTable(releaseUrl, ctx);
|
|
270
|
-
const facilityMap = new Map(facilities.map((facility) => [facility.tri_facility_id, facility]));
|
|
271
|
-
for (const row of releaseRows.slice(0, remaining)) {
|
|
272
|
-
const release = normalizeTriRelease(row);
|
|
273
|
-
const facility = facilityMap.get(row.tri_facility_id);
|
|
274
|
-
releases.push(facility ? { ...release, facilityName: facility.facility_name ?? '' } : release);
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
if (facilities.length < TRI_FACILITY_BATCH_SIZE)
|
|
278
|
-
break;
|
|
279
|
-
firstFacilityRow += TRI_FACILITY_BATCH_SIZE;
|
|
280
524
|
}
|
|
281
|
-
|
|
525
|
+
if (params.chemicalName) {
|
|
526
|
+
// actual field is cas_chem_name, not chemical_name_text
|
|
527
|
+
formFilters.push(containsFilter('cas_chem_name', params.chemicalName));
|
|
528
|
+
}
|
|
529
|
+
const budget = dmapBudget('TRI release search', 'Narrow it with year, county, or chemical_name, leave include_release_breakdown off, or call again later.');
|
|
530
|
+
ctx.log.debug('DMAP TRI facility-form join query', {
|
|
531
|
+
state: params.state,
|
|
532
|
+
county: params.county,
|
|
533
|
+
year: params.year,
|
|
534
|
+
});
|
|
535
|
+
// Joined rows carry both tables' columns, so the exact pass reads county and chemical alike.
|
|
536
|
+
const rows = await this.fetchMatchingRows((range) => this.buildTableUrl('tri', 'tri_facility', facilityFilters, range, {
|
|
537
|
+
join: {
|
|
538
|
+
table: 'tri.tri_reporting_form',
|
|
539
|
+
on: { left: 'tri_facility_id', right: 'tri_facility_id' },
|
|
540
|
+
filters: formFilters,
|
|
541
|
+
},
|
|
542
|
+
...(params.year === undefined && { sort: NEWEST_FIRST }),
|
|
543
|
+
}), [...facilityFilters, ...formFilters], limit, ctx, budget);
|
|
544
|
+
const kept = rows.slice(0, limit);
|
|
545
|
+
const releases = kept.map((row) => ({
|
|
546
|
+
...normalizeTriRelease(row),
|
|
547
|
+
facilityName: row.facility_name ?? '',
|
|
548
|
+
...(row.county_name && { countyName: row.county_name }),
|
|
549
|
+
}));
|
|
550
|
+
return params.includeReleaseBreakdown
|
|
551
|
+
? this.withReleaseBreakdown(kept, releases, ctx, budget)
|
|
552
|
+
: releases;
|
|
282
553
|
}
|
|
283
554
|
/** Search Superfund sites by state or coordinates + radius. */
|
|
284
555
|
async searchSuperfund(params, ctx) {
|
|
@@ -288,7 +559,7 @@ export class DmapService {
|
|
|
288
559
|
filters.push({ column: 'fk_ref_state_code', operator: 'equals', value: params.state });
|
|
289
560
|
}
|
|
290
561
|
if (params.city) {
|
|
291
|
-
filters.push(
|
|
562
|
+
filters.push(containsFilter('city_name', params.city));
|
|
292
563
|
}
|
|
293
564
|
if (params.zipCode) {
|
|
294
565
|
filters.push({ column: 'zip_code', operator: 'equals', value: params.zipCode });
|
|
@@ -308,12 +579,8 @@ export class DmapService {
|
|
|
308
579
|
const pageSize = params.latitude !== undefined && params.longitude !== undefined
|
|
309
580
|
? Math.min(500, limit * 10)
|
|
310
581
|
: limit;
|
|
311
|
-
const url = this.buildTableUrl('sems', 'envirofacts_site', filters, {
|
|
312
|
-
first: 0,
|
|
313
|
-
last: pageSize - 1,
|
|
314
|
-
});
|
|
315
582
|
ctx.log.debug('DMAP Superfund query', { state: params.state, lat: params.latitude });
|
|
316
|
-
const rows = await this.
|
|
583
|
+
const rows = await this.fetchMatchingRows((range) => this.buildTableUrl('sems', 'envirofacts_site', filters, range), filters, pageSize, ctx, dmapBudget('Superfund site search', 'Narrow it with zip_code, city, or npl_status, or call again later.'));
|
|
317
584
|
let sites = rows.map(normalizeSemsSite);
|
|
318
585
|
if (params.latitude !== undefined && params.longitude !== undefined && params.radiusMiles) {
|
|
319
586
|
const originLat = params.latitude;
|
|
@@ -329,9 +596,9 @@ export class DmapService {
|
|
|
329
596
|
}
|
|
330
597
|
/** Fetch a single Superfund site by exact SEMS site ID. */
|
|
331
598
|
async searchSuperfundById(siteId, ctx) {
|
|
332
|
-
const url = this.buildTableUrl('sems', 'envirofacts_site', [{ column: 'site_id', operator: 'equals', value: siteId }], { first:
|
|
599
|
+
const url = this.buildTableUrl('sems', 'envirofacts_site', [{ column: 'site_id', operator: 'equals', value: siteId }], { first: 1, last: 1 });
|
|
333
600
|
ctx.log.debug('DMAP Superfund by ID', { siteId });
|
|
334
|
-
const rows = await this.fetchTable(url, ctx);
|
|
601
|
+
const rows = await this.fetchTable(url, ctx, dmapBudget('Superfund site lookup', 'Call again later.'));
|
|
335
602
|
return rows.map(normalizeSemsSite);
|
|
336
603
|
}
|
|
337
604
|
/** Search drinking water systems by state or ZIP code. */
|
|
@@ -355,47 +622,97 @@ export class DmapService {
|
|
|
355
622
|
}
|
|
356
623
|
if (filters.length === 0)
|
|
357
624
|
return [];
|
|
358
|
-
|
|
359
|
-
//
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
625
|
+
const budget = dmapBudget('water system search', 'Narrow it with zip_code or pws_type, or call again later.');
|
|
626
|
+
// sdwis.violation has no zip column: a ZIP's systems are checked by PWSID, a state is scanned.
|
|
627
|
+
if (params.hasViolation && params.zipCode) {
|
|
628
|
+
return this.searchViolatingWaterSystemsInZip(params.zipCode, filters, limit, ctx, budget);
|
|
629
|
+
}
|
|
630
|
+
if (params.hasViolation && params.state) {
|
|
631
|
+
return this.searchViolatingWaterSystems(params.state, filters, limit, ctx, budget);
|
|
632
|
+
}
|
|
633
|
+
const url = this.buildTableUrl('sdwis', 'water_system', filters, { first: 1, last: limit });
|
|
634
|
+
ctx.log.debug('DMAP water systems query', { state: params.state });
|
|
635
|
+
const rows = await this.fetchTable(url, ctx, budget);
|
|
636
|
+
return rows.slice(0, limit).map((r) => normalizeSdwisWaterSystem(r));
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Water systems in `state` with an open violation. water_system has no violation flag, so this
|
|
640
|
+
* reads the first VIOLATION_SCAN_ROWS open rows of sdwis.violation, then looks up the systems
|
|
641
|
+
* those rows name, PWSID_CHUNK_SIZE IDs per `pwsid in` request, until `limit` is filled. That is
|
|
642
|
+
* at most 1 + VIOLATION_SCAN_ROWS / PWSID_CHUNK_SIZE requests. A state with more open violation
|
|
643
|
+
* rows than the scan reads can have violators it never sees; the result then carries a notice.
|
|
644
|
+
*/
|
|
645
|
+
async searchViolatingWaterSystems(state, filters, limit, ctx, budget) {
|
|
646
|
+
const violUrl = this.buildTableUrl('sdwis', 'violation', [{ column: 'primacy_agency_code', operator: 'equals', value: state }, OPEN_VIOLATION_FILTER], { first: 1, last: VIOLATION_SCAN_ROWS });
|
|
647
|
+
ctx.log.debug('DMAP SDWIS violation query', { state });
|
|
648
|
+
const violRows = await this.fetchTable(violUrl, ctx, budget);
|
|
649
|
+
const violatingPwsids = pwsidSet(violRows);
|
|
650
|
+
if (violRows.length >= VIOLATION_SCAN_ROWS) {
|
|
651
|
+
ctx.enrich.notice(`The violation check stopped at the first ${VIOLATION_SCAN_ROWS.toLocaleString('en-US')} of ${state}'s open SDWIS violation records, which name ${violatingPwsids.size} distinct systems. Records past that point were not read, so these results may not include every system with an open violation in the state.`);
|
|
652
|
+
}
|
|
653
|
+
const ids = [...violatingPwsids];
|
|
654
|
+
const systems = [];
|
|
655
|
+
for (let i = 0; i < ids.length && systems.length < limit; i += PWSID_CHUNK_SIZE) {
|
|
656
|
+
const chunk = ids.slice(i, i + PWSID_CHUNK_SIZE);
|
|
657
|
+
const url = this.buildTableUrl('sdwis', 'water_system', [...filters, { column: 'pwsid', operator: 'in', value: chunk.join(',') }], { first: 1, last: limit - systems.length });
|
|
658
|
+
ctx.log.debug('DMAP water systems query', { state, chunkStart: i, chunkSize: chunk.length });
|
|
659
|
+
const rows = await this.fetchTable(url, ctx, budget);
|
|
660
|
+
for (const row of rows.slice(0, limit - systems.length)) {
|
|
661
|
+
systems.push(normalizeSdwisWaterSystem(row, violatingPwsids));
|
|
373
662
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
663
|
+
}
|
|
664
|
+
return systems;
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Water systems in `zipCode` with an open violation. sdwis.violation has no zip column, so this
|
|
668
|
+
* reads the ZIP's systems (up to ZIP_SYSTEM_SCAN_ROWS), then asks sdwis.violation for their open
|
|
669
|
+
* rows PWSID_CHUNK_SIZE IDs at a time, in ZIP order, until `limit` violators are found. A ZIP
|
|
670
|
+
* past the system read, or a chunk whose violation rows fill VIOLATION_SCAN_ROWS, can hide
|
|
671
|
+
* violators; the result then carries a notice.
|
|
672
|
+
*/
|
|
673
|
+
async searchViolatingWaterSystemsInZip(zipCode, filters, limit, ctx, budget) {
|
|
674
|
+
const url = this.buildTableUrl('sdwis', 'water_system', filters, {
|
|
675
|
+
first: 1,
|
|
676
|
+
last: ZIP_SYSTEM_SCAN_ROWS,
|
|
677
|
+
});
|
|
678
|
+
ctx.log.debug('DMAP water systems query', { zipCode });
|
|
679
|
+
const zipRows = await this.fetchTable(url, ctx, budget);
|
|
680
|
+
const notices = [];
|
|
681
|
+
if (zipRows.length >= ZIP_SYSTEM_SCAN_ROWS) {
|
|
682
|
+
notices.push(`The violation check read only the first ${ZIP_SYSTEM_SCAN_ROWS.toLocaleString('en-US')} water systems in ZIP ${zipCode}; systems past that point were not checked.`);
|
|
683
|
+
}
|
|
684
|
+
const candidates = zipRows.filter((r) => typeof r.pwsid === 'string');
|
|
685
|
+
const systems = [];
|
|
686
|
+
let truncatedChunk = false;
|
|
687
|
+
for (let i = 0; i < candidates.length && systems.length < limit; i += PWSID_CHUNK_SIZE) {
|
|
688
|
+
const chunk = candidates.slice(i, i + PWSID_CHUNK_SIZE);
|
|
689
|
+
const violUrl = this.buildTableUrl('sdwis', 'violation', [
|
|
690
|
+
{ column: 'pwsid', operator: 'in', value: chunk.map((r) => r.pwsid).join(',') },
|
|
691
|
+
OPEN_VIOLATION_FILTER,
|
|
692
|
+
], { first: 1, last: VIOLATION_SCAN_ROWS });
|
|
693
|
+
ctx.log.debug('DMAP SDWIS violation query', {
|
|
694
|
+
zipCode,
|
|
695
|
+
chunkStart: i,
|
|
696
|
+
chunkSize: chunk.length,
|
|
697
|
+
});
|
|
698
|
+
const violRows = await this.fetchTable(violUrl, ctx, budget);
|
|
699
|
+
if (violRows.length >= VIOLATION_SCAN_ROWS)
|
|
700
|
+
truncatedChunk = true;
|
|
701
|
+
const violatingPwsids = pwsidSet(violRows);
|
|
702
|
+
for (const row of chunk) {
|
|
703
|
+
if (systems.length >= limit)
|
|
704
|
+
break;
|
|
705
|
+
if (violatingPwsids.has(row.pwsid)) {
|
|
706
|
+
systems.push(normalizeSdwisWaterSystem(row, violatingPwsids));
|
|
392
707
|
}
|
|
393
708
|
}
|
|
394
709
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
710
|
+
if (truncatedChunk) {
|
|
711
|
+
notices.push(`The open violation records for a group of up to ${PWSID_CHUNK_SIZE} systems in ZIP ${zipCode} filled the ${VIOLATION_SCAN_ROWS.toLocaleString('en-US')}-row read; records past that point were not read, so these results may not include every system with an open violation in the ZIP.`);
|
|
712
|
+
}
|
|
713
|
+
if (notices.length > 0)
|
|
714
|
+
ctx.enrich.notice(notices.join(' '));
|
|
715
|
+
return systems;
|
|
399
716
|
}
|
|
400
717
|
}
|
|
401
718
|
// --- Init/accessor pattern ---
|