waterledger-data 0.1.0__tar.gz

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.
@@ -0,0 +1,5 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ .venv/
4
+ dist/
5
+ .pytest_cache/
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Northline Hydrology
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.5
2
+ Name: waterledger-data
3
+ Version: 0.1.0
4
+ Summary: Public U.S. water data for any place, fetched once and cached: Census places, EPA water systems and sewer plants, USGS watersheds, EPA ATTAINS, NOAA weather and design storms, EIA utilities
5
+ Project-URL: Homepage, https://waterledger.org
6
+ Project-URL: Source, https://github.com/northlinehydrology/waterledger
7
+ Project-URL: Issues, https://github.com/northlinehydrology/waterledger/issues
8
+ Author-email: Northline Hydrology <info@northlinehydrology.com>
9
+ License-Expression: BSD-3-Clause
10
+ License-File: LICENSE
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Hydrology
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: matplotlib>=3.7
16
+ Requires-Dist: numpy>=1.24
17
+ Requires-Dist: openpyxl>=3.1
18
+ Requires-Dist: pandas>=2.0
19
+ Description-Content-Type: text/markdown
20
+
21
+ # waterledger-data
22
+
23
+ Public U.S. water data for any place, fetched once and cached, as used by [Water Ledger](https://waterledger.org):
24
+ Census cities and townships, EPA water system service areas, USGS watersheds, EPA ATTAINS impaired waters, EPA ECHO and
25
+ CWNS sewer plants, NOAA hourly weather and Atlas 14 design storms, and EIA electric utilities.
26
+
27
+ Downloads are cached under `WATERLEDGER_CACHE` (default `~/.cache/waterledger`) or the `cache` path in a `waterledger.toml`.
@@ -0,0 +1,7 @@
1
+ # waterledger-data
2
+
3
+ Public U.S. water data for any place, fetched once and cached, as used by [Water Ledger](https://waterledger.org):
4
+ Census cities and townships, EPA water system service areas, USGS watersheds, EPA ATTAINS impaired waters, EPA ECHO and
5
+ CWNS sewer plants, NOAA hourly weather and Atlas 14 design storms, and EIA electric utilities.
6
+
7
+ Downloads are cached under `WATERLEDGER_CACHE` (default `~/.cache/waterledger`) or the `cache` path in a `waterledger.toml`.
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "waterledger-data"
3
+ version = "0.1.0"
4
+ description = "Public U.S. water data for any place, fetched once and cached: Census places, EPA water systems and sewer plants, USGS watersheds, EPA ATTAINS, NOAA weather and design storms, EIA utilities"
5
+ readme = "README.md"
6
+ license = "BSD-3-Clause"
7
+ authors = [{ name = "Northline Hydrology", email = "info@northlinehydrology.com" }]
8
+ requires-python = ">=3.11"
9
+ classifiers = [
10
+ "Programming Language :: Python :: 3",
11
+ "Intended Audience :: Science/Research",
12
+ "Topic :: Scientific/Engineering :: Hydrology",
13
+ ]
14
+ dependencies = ["numpy>=1.24", "pandas>=2.0", "matplotlib>=3.7", "openpyxl>=3.1"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://waterledger.org"
18
+ Source = "https://github.com/northlinehydrology/waterledger"
19
+ Issues = "https://github.com/northlinehydrology/waterledger/issues"
20
+
21
+ [build-system]
22
+ requires = ["hatchling"]
23
+ build-backend = "hatchling.build"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/waterledger_data"]
@@ -0,0 +1,19 @@
1
+ """Water Ledger data: public U.S. data for any place, fetched once and cached (see waterledger_data.config).
2
+
3
+ climate hourly weather (ERA5 via Open-Meteo, NOAA ISD stations, NASA POWER) and NOAA Atlas 14 design storms
4
+ places Census cities and townships, EPA water system service areas, USGS watersheds
5
+ waters EPA ATTAINS impaired waters, ECHO and CWNS sewer plants, county rainfall, household wells
6
+ power electric utility territories and EIA-861 loads
7
+
8
+ Submodules load on first use, so `import waterledger_data` stays light.
9
+ """
10
+ import importlib
11
+
12
+ __version__ = "0.1.0"
13
+ _SUB = ("climate", "config", "places", "power", "waters")
14
+
15
+
16
+ def __getattr__(name):
17
+ if name in _SUB:
18
+ return importlib.import_module(f"waterledger_data.{name}")
19
+ raise AttributeError(name)
@@ -0,0 +1,126 @@
1
+ """Public climate inputs for a site: hourly weather and NOAA Atlas 14 design-storm depths.
2
+
3
+ Both are fetched once and cached under engine/data/raw/ (gitignored); rerunning reads the cache.
4
+ """
5
+ import ast, json, os, re, urllib.request
6
+
7
+ from waterledger_data.config import CACHE as RAW
8
+
9
+ # Atlas 14 PFDS rows and columns, in the order the NOAA service returns them
10
+ PF_DURATIONS = ["5-min", "10-min", "15-min", "30-min", "60-min", "2-hr", "3-hr", "6-hr", "12-hr", "24-hr",
11
+ "2-day", "3-day", "4-day", "7-day", "10-day", "20-day", "30-day", "45-day", "60-day"]
12
+ PF_ARI = [1, 2, 5, 10, 25, 50, 100, 200, 500, 1000]
13
+
14
+
15
+ def _fetch(url, path):
16
+ if not os.path.exists(path):
17
+ os.makedirs(os.path.dirname(path), exist_ok=True)
18
+ with urllib.request.urlopen(url, timeout=300) as r, open(path, "wb") as f:
19
+ f.write(r.read())
20
+ return path
21
+
22
+
23
+ def hourly_weather(lat, lon, start, end, name):
24
+ """Hourly 2 m air temperature (C) and relative humidity (%) from the Open-Meteo archive (ERA5 reanalysis).
25
+
26
+ Returns (times, temp_c, rh_pct) as lists. Reanalysis smooths local extremes slightly; a full engagement
27
+ cross-checks against NOAA ISD hourly observations at the nearest airport.
28
+ """
29
+ url = (f"https://archive-api.open-meteo.com/v1/archive?latitude={lat}&longitude={lon}&start_date={start}&end_date={end}"
30
+ "&hourly=temperature_2m,relative_humidity_2m&temperature_unit=celsius&timezone=America%2FDetroit")
31
+ with open(_fetch(url, os.path.join(RAW, f"openmeteo_{name}.json"))) as f:
32
+ h = json.load(f)["hourly"]
33
+ return h["time"], h["temperature_2m"], h["relative_humidity_2m"]
34
+
35
+
36
+ POWER_HOURLY = "https://power.larc.nasa.gov/api/temporal/hourly/point"
37
+
38
+
39
+ def power_hourly(lat, lon, name, first=2001, last=2024):
40
+ """Hourly 2 m air temperature (C) and relative humidity (%) from NASA POWER (MERRA-2 reanalysis, 0.5 x 0.625 degree), in local
41
+ standard time, for the states without a hand-built weather set. Free, with no daily quota, so every state can be covered;
42
+ fetched in 8-year pieces (the service's limit per request) and cached. Returns (times, temp_c, rh_pct) like hourly_weather."""
43
+ t, T, RH = [], [], []
44
+ for y0 in range(first, last + 1, 8):
45
+ y1 = min(y0 + 7, last)
46
+ url = (f"{POWER_HOURLY}?parameters=T2M,RH2M&community=RE&longitude={lon:.4f}&latitude={lat:.4f}"
47
+ f"&start={y0}0101&end={y1}1231&format=JSON&time-standard=LST")
48
+ with open(_fetch(url, os.path.join(RAW, "power_hourly", f"{name}_{y0}_{y1}.json"))) as f:
49
+ p = json.load(f)["properties"]["parameter"]
50
+ for k in p["T2M"]:
51
+ a, b = p["T2M"][k], p["RH2M"].get(k)
52
+ if a is None or b is None or a <= -900 or b <= -900: # -999 marks missing source data
53
+ continue
54
+ t.append(f"{k[:4]}-{k[4:6]}-{k[6:8]}T{k[8:10]}:00")
55
+ T.append(a); RH.append(b)
56
+ return t, T, RH
57
+
58
+
59
+ ISD_HISTORY = "https://www.ncei.noaa.gov/pub/data/noaa/isd-history.csv"
60
+ ISD_LITE = "https://noaa-isd-pds.s3.amazonaws.com/isd-lite/data/{y}/{usaf}-{wban}-{y}.gz"
61
+
62
+
63
+ def isd_stations(first=1995, last=2024):
64
+ """US weather stations in NOAA's Integrated Surface Database whose record spans first..last (mostly airports with a WBAN
65
+ number): [{id, name, state, lat, lon}]."""
66
+ import csv
67
+ path = _fetch(ISD_HISTORY, os.path.join(RAW, "isd", "isd-history.csv"))
68
+ out = []
69
+ for r in csv.DictReader(open(path)):
70
+ if r["CTRY"] != "US" or r["WBAN"] == "99999" or not r["LAT"] or not r["BEGIN"]:
71
+ continue
72
+ if r["BEGIN"] > f"{first}0101" or r["END"] < f"{last}1001":
73
+ continue
74
+ out.append({"id": f"{r['USAF']}-{r['WBAN']}", "name": r["STATION NAME"].title(), "state": r["STATE"],
75
+ "lat": float(r["LAT"]), "lon": float(r["LON"])})
76
+ return out
77
+
78
+
79
+ def isd_hourly(station, first=1995, last=2024, min_share=0.85):
80
+ """Hourly 2 m air temperature (C) and relative humidity (%) observed at an ISD station (ISD-Lite: hourly temperature and dew point),
81
+ in local standard time from the station's longitude, for the states without a hand-built weather set. Gaps up to 6 hours are
82
+ filled linearly and longer ones with the station's mean for that month and hour, so every day has 24 hours. Returns
83
+ (times, temp_c, rh_pct, share_observed), or None when fewer than `min_share` of hours were observed."""
84
+ import gzip
85
+ import numpy as np
86
+ import pandas as pd
87
+ usaf, wban = station["id"].split("-")
88
+ rows = []
89
+ for y in range(first, last + 1):
90
+ path = os.path.join(RAW, "isd", "lite", f"{station['id']}-{y}.gz")
91
+ try:
92
+ _fetch(ISD_LITE.format(y=y, usaf=usaf, wban=wban), path)
93
+ except Exception:
94
+ continue
95
+ with gzip.open(path, "rt") as f:
96
+ for ln in f:
97
+ p = ln.split()
98
+ t, d = int(p[4]), int(p[5])
99
+ if t != -9999 and d != -9999:
100
+ rows.append((f"{p[0]}-{p[1]}-{p[2]} {p[3]}", t / 10, d / 10))
101
+ if not rows:
102
+ return None
103
+ df = pd.DataFrame(rows, columns=["t", "T", "Td"]).drop_duplicates("t")
104
+ df.index = pd.to_datetime(df.pop("t")) + pd.Timedelta(hours=round(station["lon"] / 15)) # UTC to local standard time
105
+ full = pd.date_range(f"{first}-01-01 00:00", f"{last}-12-31 23:00", freq="h")
106
+ df = df[~df.index.duplicated()].reindex(full)
107
+ share = float(df["T"].notna().mean())
108
+ if share < min_share:
109
+ return None
110
+ df = df.interpolate(limit=6, limit_area="inside")
111
+ clim = df.groupby([df.index.month, df.index.hour]).transform("mean")
112
+ df = df.fillna(clim)
113
+ a = lambda x: np.exp(17.625 * x / (243.04 + x)) # Magnus form (Alduchov and Eskridge 1996)
114
+ rh = np.clip(100 * a(df["Td"].values) / a(df["T"].values), 1, 100)
115
+ return [i.strftime("%Y-%m-%dT%H:00") for i in df.index], df["T"].tolist(), rh.tolist(), share
116
+
117
+
118
+ def atlas14_depths(lat, lon, name):
119
+ """NOAA Atlas 14 partial-duration precipitation depths (inches) as {duration: {ARI years: depth}}, plus metadata."""
120
+ url = (f"https://hdsc.nws.noaa.gov/cgi-bin/new/cgi_readH5.py?lat={lat:.4f}&lon={lon:.4f}"
121
+ "&type=pf&data=depth&units=english&series=pds")
122
+ with open(_fetch(url, os.path.join(RAW, f"noaa_pfds_{name}.txt"))) as f:
123
+ txt = f.read()
124
+ q = ast.literal_eval(re.search(r"quantiles = (\[.*?\]);", txt, re.S).group(1))
125
+ meta = dict(re.findall(r"^(volume|version|region) = '([^']*)';", txt, re.M))
126
+ return {d: {a: float(v) for a, v in zip(PF_ARI, row)} for d, row in zip(PF_DURATIONS, q)}, meta
@@ -0,0 +1,40 @@
1
+ """Where Water Ledger keeps its downloads and writes its site data, and other local paths.
2
+
3
+ Resolved once, at import, from the first of:
4
+ 1. environment variables: WATERLEDGER_CACHE, WATERLEDGER_SITE, and WATERLEDGER_<NAME> for any other setting;
5
+ 2. a waterledger.toml in the working directory or a parent, whose [paths] table names them (relative to the file):
6
+ [paths]
7
+ cache = "data/raw"
8
+ site = "site"
9
+ 3. defaults: ~/.cache/waterledger for downloads and ./site for the site.
10
+ """
11
+ import os
12
+ import tomllib
13
+
14
+
15
+ def _find(start=None):
16
+ d = os.path.abspath(start or os.getcwd())
17
+ while True:
18
+ f = os.path.join(d, "waterledger.toml")
19
+ if os.path.exists(f):
20
+ return f
21
+ up = os.path.dirname(d)
22
+ if up == d:
23
+ return None
24
+ d = up
25
+
26
+
27
+ CONFIG_FILE = _find()
28
+ _paths = {}
29
+ if CONFIG_FILE:
30
+ with open(CONFIG_FILE, "rb") as f:
31
+ _paths = {k: os.path.normpath(os.path.join(os.path.dirname(CONFIG_FILE), v)) for k, v in tomllib.load(f).get("paths", {}).items()}
32
+
33
+
34
+ def setting(name, default=None):
35
+ """A path setting: WATERLEDGER_<NAME> in the environment, else waterledger.toml's [paths], else `default`."""
36
+ return os.environ.get("WATERLEDGER_" + name.upper()) or _paths.get(name) or default
37
+
38
+
39
+ CACHE = setting("cache", os.path.join(os.path.expanduser("~"), ".cache", "waterledger"))
40
+ SITE = setting("site", os.path.abspath("site"))
@@ -0,0 +1,237 @@
1
+ """Michigan municipalities and their public water context, cached under engine/data/raw/places/.
2
+
3
+ Sources (all public):
4
+ Census TIGERweb county subdivisions (every city and township) and counties
5
+ EPA Community Water System Service Area Boundaries (reported or modeled), via ArcGIS REST
6
+ USGS Watershed Boundary Dataset HUC12 and HUC8, via The National Map REST
7
+ NOAA Atlas 14 precipitation frequency (24-hour depths), via the PFDS service
8
+ """
9
+ import concurrent.futures as cf, json, os, time, urllib.parse, urllib.request
10
+
11
+ import numpy as np
12
+
13
+ from waterledger_data.config import CACHE
14
+ from matplotlib.path import Path
15
+
16
+ from waterledger_data.climate import atlas14_depths
17
+
18
+ RAW = os.path.join(CACHE, "places")
19
+ TIGER = "https://tigerweb.geo.census.gov/arcgis/rest/services/TIGERweb"
20
+ EPA_SA = "https://services.arcgis.com/cJ9YHowT8TU7DUyn/arcgis/rest/services/Water_System_Boundaries/FeatureServer/0/query"
21
+ WBD = "https://hydro.nationalmap.gov/arcgis/rest/services/wbd/MapServer"
22
+ LSAD = {"25": "city", "44": "township", "49": "charter township"}
23
+
24
+
25
+ def _get(url, params, retries=5, post=False):
26
+ data = urllib.parse.urlencode(params).encode() if post else None
27
+ q = url if post else url + "?" + urllib.parse.urlencode(params)
28
+ for i in range(retries):
29
+ try:
30
+ with urllib.request.urlopen(q, data=data, timeout=120) as r:
31
+ d = json.load(r)
32
+ if isinstance(d, dict) and "error" in d:
33
+ raise RuntimeError(str(d["error"])[:200])
34
+ return d
35
+ except Exception:
36
+ if i == retries - 1:
37
+ raise
38
+ time.sleep(3 * (i + 1))
39
+
40
+
41
+ def _cached(name, fn):
42
+ path = os.path.join(RAW, name)
43
+ if os.path.exists(path):
44
+ with open(path) as f:
45
+ return json.load(f)
46
+ os.makedirs(RAW, exist_ok=True)
47
+ data = fn()
48
+ with open(path, "w") as f:
49
+ json.dump(data, f)
50
+ return data
51
+
52
+
53
+ def counties():
54
+ def fetch():
55
+ d = _get(f"{TIGER}/State_County/MapServer/1/query", {"where": "STATE='26'", "outFields": "GEOID,NAME,BASENAME,COUNTY,INTPTLAT,INTPTLON",
56
+ "returnGeometry": "true", "outSR": 4326, "maxAllowableOffset": 0.01, "geometryPrecision": 3, "f": "json"})
57
+ return [{"geoid": f["attributes"]["GEOID"], "name": f["attributes"]["BASENAME"], "lat": float(f["attributes"]["INTPTLAT"]),
58
+ "lon": float(f["attributes"]["INTPTLON"]), "rings": f["geometry"]["rings"]} for f in d["features"]]
59
+ return _cached("counties.json", fetch)
60
+
61
+
62
+ def municipalities():
63
+ def fetch():
64
+ d = _get(f"{TIGER}/Places_CouSub_ConCity_SubMCD/MapServer/1/query", {"where": "STATE='26'", "outFields": "GEOID,NAME,BASENAME,COUNTY,LSADC,INTPTLAT,INTPTLON,AREALAND",
65
+ "returnGeometry": "false", "resultRecordCount": 5000, "f": "json"})
66
+ out = []
67
+ for f in d["features"]:
68
+ a = f["attributes"]
69
+ if a["LSADC"] not in LSAD or not a["AREALAND"]:
70
+ continue
71
+ out.append({"geoid": a["GEOID"], "name": a["NAME"], "base": a["BASENAME"], "kind": LSAD[a["LSADC"]], "county_fips": "26" + a["COUNTY"],
72
+ "lat": round(float(a["INTPTLAT"]), 4), "lon": round(float(a["INTPTLON"]), 4)})
73
+ return out
74
+ return _cached("municipalities.json", fetch)
75
+
76
+
77
+ def boundaries(cnties):
78
+ """Generalized municipal boundary polygons (Census TIGERweb), fetched county by county."""
79
+ def fetch():
80
+ out = {}
81
+ for c in cnties:
82
+ d = _get(f"{TIGER}/Places_CouSub_ConCity_SubMCD/MapServer/1/query", {"where": f"STATE='26' AND COUNTY='{c['geoid'][2:]}'", "outFields": "GEOID",
83
+ "returnGeometry": "true", "outSR": 4326, "maxAllowableOffset": 0.002, "geometryPrecision": 4, "f": "json"})
84
+ for f in d["features"]:
85
+ out[f["attributes"]["GEOID"]] = f["geometry"]["rings"]
86
+ return out
87
+ return _cached("boundaries.json", fetch)
88
+
89
+
90
+ def _poly(rings):
91
+ return json.dumps({"rings": rings, "spatialReference": {"wkid": 4326}})
92
+
93
+
94
+ def _inside(x, y, rings):
95
+ c = False
96
+ for ring in rings:
97
+ n = len(ring)
98
+ for i in range(n):
99
+ x1, y1 = ring[i][0], ring[i][1]
100
+ x2, y2 = ring[(i + 1) % n][0], ring[(i + 1) % n][1]
101
+ if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / (y2 - y1) + x1:
102
+ c = not c
103
+ return c
104
+
105
+
106
+ def _inside_many(pts, rings):
107
+ """Even-odd point-in-polygon for many points at once (holes and multipart rings handled by parity)."""
108
+ pts = np.asarray(pts, dtype=float)
109
+ hits = np.zeros(len(pts), dtype=int)
110
+ for ring in rings:
111
+ if len(ring) >= 3:
112
+ hits += Path(np.asarray(ring, dtype=float)[:, :2]).contains_points(pts)
113
+ return hits % 2 == 1
114
+
115
+
116
+ def _samples(rings, n=24):
117
+ xs = [p[0] for r in rings for p in r]
118
+ ys = [p[1] for r in rings for p in r]
119
+ x0, x1, y0, y1 = min(xs), max(xs), min(ys), max(ys)
120
+ pts = [(x0 + (i + 0.5) * (x1 - x0) / n, y0 + (j + 0.5) * (y1 - y0) / n) for i in range(n) for j in range(n)]
121
+ return [p for p, ok in zip(pts, _inside_many(pts, rings)) if ok]
122
+
123
+
124
+ def _overlaps(url, rings, fields, extra_where=None):
125
+ params = {"geometry": _poly(rings), "geometryType": "esriGeometryPolygon", "inSR": 4326, "spatialRel": "esriSpatialRelIntersects",
126
+ "outFields": fields, "returnGeometry": "true", "outSR": 4326, "maxAllowableOffset": 0.002, "geometryPrecision": 4, "f": "json"}
127
+ if extra_where:
128
+ params["where"] = extra_where
129
+ d = _get(url, params, post=True)
130
+ pts = _samples(rings)
131
+ out = []
132
+ for f in d.get("features", []):
133
+ g = (f.get("geometry") or {}).get("rings")
134
+ if not g or not pts:
135
+ continue
136
+ share = _inside_many(pts, g).mean()
137
+ if share >= 0.05:
138
+ out.append((float(share), f["attributes"]))
139
+ out.sort(key=lambda t: -t[0])
140
+ return out
141
+
142
+
143
+ def _water_systems(rings):
144
+ """Community water systems serving the municipality, with the share of its area each service area covers (EPA; reported or modeled boundaries)."""
145
+ rows = _overlaps(EPA_SA, rings, "PWSID,PWS_Name,Population_Served_Count,Service_Connections_Count,Data_Provider_Type", "PWSID LIKE 'MI%'")
146
+ return [{"pwsid": a["PWSID"], "name": a["PWS_Name"], "pop": a.get("Population_Served_Count"), "connections": a.get("Service_Connections_Count"),
147
+ "boundary": a.get("Data_Provider_Type"), "share": round(sh, 2)} for sh, a in rows[:4]]
148
+
149
+
150
+ MI_ENVELOPE = "-90.5,41.6,-82.1,48.4"
151
+
152
+
153
+ def _layer_polys(layer, fields, name, page=500):
154
+ """Every WBD polygon in a layer that intersects Michigan, generalized, fetched in pages and cached."""
155
+ def fetch():
156
+ out, off = [], 0
157
+ while True:
158
+ d = _get(f"{WBD}/{layer}/query", {"where": "1=1", "geometry": MI_ENVELOPE, "geometryType": "esriGeometryEnvelope", "inSR": 4326,
159
+ "spatialRel": "esriSpatialRelIntersects", "outFields": fields, "returnGeometry": "true", "outSR": 4326,
160
+ "maxAllowableOffset": 0.002, "geometryPrecision": 4, "orderByFields": "OBJECTID",
161
+ "resultOffset": off, "resultRecordCount": page, "f": "json"}, post=True)
162
+ feats = d.get("features", [])
163
+ for f in feats:
164
+ if (f.get("geometry") or {}).get("rings"):
165
+ out.append({**f["attributes"], "rings": f["geometry"]["rings"]})
166
+ print(name, len(out))
167
+ if len(feats) < page:
168
+ return out
169
+ off += page
170
+ polys = _cached(name, fetch)
171
+ for q in polys:
172
+ xs = [pt[0] for r in q["rings"] for pt in r]
173
+ ys = [pt[1] for r in q["rings"] for pt in r]
174
+ q["bbox"] = (min(xs), min(ys), max(xs), max(ys))
175
+ return polys
176
+
177
+
178
+ def _local_shares(rings, polys, pts=None):
179
+ """Share of a municipality's sampled points inside each polygon (bounding boxes first), shares under 5% dropped."""
180
+ pts = pts if pts is not None else _samples(rings)
181
+ if not pts:
182
+ return []
183
+ x0, y0 = min(p[0] for p in pts), min(p[1] for p in pts)
184
+ x1, y1 = max(p[0] for p in pts), max(p[1] for p in pts)
185
+ out = []
186
+ for q in polys:
187
+ b = q["bbox"]
188
+ if b[0] > x1 or b[2] < x0 or b[1] > y1 or b[3] < y0:
189
+ continue
190
+ share = _inside_many(pts, q["rings"]).mean()
191
+ if share >= 0.05:
192
+ out.append((float(share), q))
193
+ out.sort(key=lambda t: -t[0])
194
+ return out
195
+
196
+
197
+ def _watersheds(rings, lat, lon, h12, h8):
198
+ """HUC12 subwatersheds covering the municipality with their shares, and the HUC8 at its internal point (USGS WBD)."""
199
+ rows = _local_shares(rings, h12)
200
+ at = [q for q in h8 if q["bbox"][0] <= lon <= q["bbox"][2] and q["bbox"][1] <= lat <= q["bbox"][3] and _inside(lon, lat, q["rings"])]
201
+ return {"huc12": [{"code": q["huc12"], "name": q["name"], "share": round(sh, 2)} for sh, q in rows[:5]],
202
+ "huc8": {"code": at[0]["huc8"], "name": at[0]["name"]} if at else None}
203
+
204
+
205
+ def enrich(munis, bounds, workers=8):
206
+ """Water systems (EPA query per place) and watersheds (local overlap with bulk USGS polygons) for each municipality. Cached per GEOID; safe to re-run."""
207
+ path = os.path.join(RAW, "enrich.json")
208
+ cache = json.load(open(path)) if os.path.exists(path) else {}
209
+ cache = {k: v for k, v in cache.items() if "error" not in v}
210
+ todo = [m for m in munis if m["geoid"] not in cache and m["geoid"] in bounds]
211
+ h12 = _layer_polys(6, "huc12,name", "wbd_huc12_mi.json")
212
+ h8 = _layer_polys(4, "huc8,name", "wbd_huc8_mi.json")
213
+
214
+ def one(m):
215
+ try:
216
+ rings = bounds[m["geoid"]]
217
+ return m["geoid"], {"water": _water_systems(rings), "watershed": _watersheds(rings, m["lat"], m["lon"], h12, h8)}
218
+ except Exception as e:
219
+ return m["geoid"], {"error": str(e)[:200]}
220
+
221
+ with cf.ThreadPoolExecutor(workers) as ex:
222
+ for i, (gid, res) in enumerate(ex.map(one, todo), 1):
223
+ cache[gid] = res
224
+ if i % 100 == 0:
225
+ json.dump(cache, open(path, "w"))
226
+ print("enriched", i, "of", len(todo))
227
+ json.dump(cache, open(path, "w"))
228
+ return cache
229
+
230
+
231
+ def county_design_storms(cnties):
232
+ """NOAA Atlas 14 24-hour depths (2-, 10-, 100-year) at each county's internal point."""
233
+ out = {}
234
+ for c in cnties:
235
+ pf, meta = atlas14_depths(c["lat"], c["lon"], f"county_{c['geoid']}")
236
+ out[c["geoid"]] = {k: pf["24-hr"][k] for k in (2, 10, 100)}
237
+ return out
@@ -0,0 +1,133 @@
1
+ """Michigan electric utilities: who serves each place and how big each utility's load is, cached under engine/data/raw/power/.
2
+
3
+ Sources (all public):
4
+ MPSC Electric Utility Service Areas (Michigan Public Service Commission GIS hub): the territory of every investor-owned,
5
+ cooperative and municipal utility in Michigan, via ArcGIS REST
6
+ EIA-861 Annual Electric Power Industry Report: each utility's Michigan retail sales and customers (bundled plus
7
+ delivery-only; short-form filers included) and summer and winter peak demand (Operational Data)
8
+
9
+ A place's utilities are the MPSC territories covering its sampled area, with the share each covers. The utility's size is
10
+ its own customers' load, not where its power is generated: Michigan's utilities buy and sell through the MISO market.
11
+ """
12
+ import glob, io, json, os, urllib.request, zipfile
13
+
14
+ from waterledger_data import places as pl
15
+ from waterledger_data.config import CACHE
16
+
17
+ RAW = os.path.join(CACHE, "power")
18
+ MPSC = "https://services3.arcgis.com/943LBv9FP414WfDO/arcgis/rest/services/ELECTRIC_UTILITY_SERVICE_AREA_MI_WFL1/FeatureServer/16/query"
19
+ EIA861 = "https://www.eia.gov/electricity/data/eia861/zip/f861{year}.zip"
20
+
21
+ # MPSC territory name -> EIA utility number (checked by hand against names and customer counts, Oct 2026)
22
+ EIA_ID = {
23
+ "DTE Electric Company": 5109, "Consumers Energy Company": 4254, "Indiana Michigan Power Company": 9324, "Upper Peninsula Power Company": 19578,
24
+ "Upper Michigan Energy Resources Corporation": 60631, "Alpena Power Company": 392, "Northern States Power Company - Wisconsin": 13780,
25
+ "Great Lakes Energy Cooperative": 38084, "Cloverland Electric Cooperative": 3828, "Cherryland Electric Cooperative": 3436,
26
+ "Midwest Energy Cooperative": 12377, "Presque Isle Electric & Gas Cooperative": 15340, "Homeworks Tri-County Electric Cooperative": 19396,
27
+ "Thumb Electric Cooperative": 18895, "Alger Delta Cooperative Electric Association": 305, "Ontonagon County Rural Electrification Association": 14156,
28
+ "Bayfield Electric Cooperative": 1367, "Lansing Board of Water & Light": 10704, "Holland Board of Public Works": 8723,
29
+ "Bay City Electric Light & Power": 1366, "Marquette Board of Light and Power": 11701, "Grand Haven Board of Light & Power": 7483,
30
+ "Traverse City Light & Power": 19125, "Wyandotte Municipal Services": 21048, "City of South Haven": 17562, "City of Niles": 13604,
31
+ "Coldwater Board of Public Utilities": 3915, "City of Escanaba": 24558, "City of Sturgis": 18252, "Zeeland Board of Public Works": 21158,
32
+ "Hillsdale Board of Public Utilities": 8631, "City of Petoskey": 14867, "City of Marshall": 11713, "City of Charlevoix": 3384,
33
+ "City of Harbor Springs": 8083, "City of Chelsea": 3415, "City of Eaton Rapids": 5630, "Lowell Light & Power": 11268, "City of Dowagiac": 5343,
34
+ "City of Gladstone": 7265, "City of Portland": 15246, "City of Negaunee": 13352, "City of Norway": 13826, "City of St Louis": 17886,
35
+ "Village of Paw Paw": 14581, "City of Crystal Falls": 4604, "Village of Union City": 19432, "Village of Clinton": 3813,
36
+ "Village of Newberry": 13525, "City of Hart": 8205, "Sebewaing Light & Water": 16873, "City of Croswell": 4562, "Village of L'Anse": 10508,
37
+ "City of Wakefield": 19975, "Village of Baraga": 1196, "City of Stephenson": 18074, "Village of Daggett": 4792,
38
+ }
39
+
40
+
41
+ def _cached(name, fn):
42
+ path = os.path.join(RAW, name)
43
+ if not os.path.exists(path):
44
+ os.makedirs(RAW, exist_ok=True)
45
+ json.dump(fn(), open(path, "w"))
46
+ return json.load(open(path))
47
+
48
+
49
+ def territories():
50
+ """MPSC service territories with generalized polygons."""
51
+ def fetch():
52
+ out, off = [], 0
53
+ while True:
54
+ d = pl._get(MPSC, {"where": "1=1", "outFields": "Name,Type,Customers,Counties_1", "returnGeometry": "true", "outSR": 4326,
55
+ "maxAllowableOffset": 0.002, "geometryPrecision": 4, "resultOffset": off, "resultRecordCount": 100, "f": "json"})
56
+ for f in d.get("features", []):
57
+ a = f["attributes"]
58
+ out.append({"name": a["Name"].strip(), "type": a["Type"], "customers": a["Customers"], "counties": a["Counties_1"],
59
+ "rings": (f.get("geometry") or {}).get("rings") or []})
60
+ if len(d.get("features", [])) < 100:
61
+ return out
62
+ off += 100
63
+ polys = _cached("mpsc_service_areas.json", fetch)
64
+ for q in polys:
65
+ xs = [p[0] for r in q["rings"] for p in r] or [0]
66
+ ys = [p[1] for r in q["rings"] for p in r] or [0]
67
+ q["bbox"] = (min(xs), min(ys), max(xs), max(ys))
68
+ return polys
69
+
70
+
71
+ def eia_year():
72
+ """The newest EIA-861 year on disk."""
73
+ have = sorted(glob.glob(os.path.join(RAW, "eia861_[0-9][0-9][0-9][0-9]")))
74
+ if not have:
75
+ raise FileNotFoundError("No EIA-861 data in " + RAW + "; run `python -m engine.update --only eia861`")
76
+ return int(have[-1][-4:])
77
+
78
+
79
+ def fetch_eia861(year):
80
+ """Download and unpack one year of EIA-861 (about 5 MB). EIA posts a year's final data the following fall."""
81
+ with urllib.request.urlopen(EIA861.format(year=year), timeout=300) as r:
82
+ z = zipfile.ZipFile(io.BytesIO(r.read()))
83
+ d = os.path.join(RAW, f"eia861_{year}")
84
+ os.makedirs(d, exist_ok=True)
85
+ for n in z.namelist():
86
+ if n.startswith(("Sales_Ult_Cust_", "Short_Form_", "Operational_Data_")):
87
+ open(os.path.join(d, os.path.basename(n)), "wb").write(z.read(n))
88
+ return d
89
+
90
+
91
+ def utility_loads(year=None):
92
+ """{EIA id: {"mwh", "customers", "summer_mw", "winter_mw"}} for Michigan. Sales are bundled plus delivery-only customers (everyone on
93
+ the utility's wires); short-form filers report sales only. Peaks are Michigan-reported only (a multistate utility's Michigan peak is absent)."""
94
+ import pandas as pd
95
+ year = year or eia_year()
96
+ d = os.path.join(RAW, f"eia861_{year}")
97
+ s = pd.read_excel(os.path.join(d, f"Sales_Ult_Cust_{year}.xlsx"), header=None, skiprows=3).iloc[:, [1, 3, 6, -2, -1]]
98
+ s.columns = ["id", "part", "state", "mwh", "cust"]
99
+ num = lambda col: pd.to_numeric(col, errors="coerce") # EIA marks withheld or missing values with '.' or '..'
100
+ s["mwh"], s["cust"] = num(s.mwh), num(s.cust)
101
+ s = s[(s.state == "MI") & s.part.isin(["A", "C"])].groupby("id")[["mwh", "cust"]].sum()
102
+ f = pd.read_excel(os.path.join(d, f"Short_Form_{year}.xlsx"))
103
+ f = f[f["State"] == "MI"].set_index("Utility Number")[["Total Sales (MWh)", "Total Customers"]]
104
+ f.columns = ["mwh", "cust"]
105
+ f["mwh"], f["cust"] = num(f.mwh), num(f.cust)
106
+ o = pd.read_excel(os.path.join(d, f"Operational_Data_{year}.xlsx"), header=None, skiprows=3).iloc[:, [1, 3, 6, 7]]
107
+ o.columns = ["id", "state", "summer", "winter"]
108
+ o["summer"], o["winter"] = num(o.summer), num(o.winter)
109
+ o = o[o.state == "MI"].set_index("id")
110
+ val = lambda v: float(v) if v == v and v is not None else None
111
+ out = {}
112
+ for uid, r in pd.concat([s, f]).iterrows():
113
+ if r.mwh != r.mwh:
114
+ continue
115
+ pk = o.loc[uid] if uid in o.index else None
116
+ out[int(uid)] = {"mwh": float(r.mwh), "customers": int(r.cust) if r.cust == r.cust else None,
117
+ "summer_mw": val(pk.summer) if pk is not None else None, "winter_mw": val(pk.winter) if pk is not None else None}
118
+ return out
119
+
120
+
121
+ def michigan_retail_mwh(year=None):
122
+ """All retail electricity sold in Michigan in a year: bundled sales plus energy-only (retail choice) sales, MWh."""
123
+ import pandas as pd
124
+ year = year or eia_year()
125
+ s = pd.read_excel(os.path.join(RAW, f"eia861_{year}", f"Sales_Ult_Cust_{year}.xlsx"), header=None, skiprows=3).iloc[:, [3, 6, -2]]
126
+ s.columns = ["part", "state", "mwh"]
127
+ s["mwh"] = pd.to_numeric(s.mwh, errors="coerce")
128
+ return float(s[(s.state == "MI") & s.part.isin(["A", "B"])].mwh.sum())
129
+
130
+
131
+ def place_utilities(rings, polys):
132
+ """Utilities whose MPSC territory covers the place, with the share of its sampled area each covers (5% or more)."""
133
+ return [(round(sh, 2), q["name"]) for sh, q in pl._local_shares(rings, [q for q in polys if q["rings"]])]
@@ -0,0 +1,353 @@
1
+ """Michigan receiving waters and sewer plants, cached under engine/data/raw/waters/.
2
+
3
+ Sources (all public):
4
+ EPA ATTAINS 2026 Integrated Report assessment units and parameters for Michigan (EGLE's 303(d)/305(b) listings),
5
+ via the ATTAINS_Assessment map service (no key needed)
6
+ USGS WBD HUC12 downstream links (tohuc), via The National Map REST
7
+ NOAA NCEI county annual precipitation, 1991-2020 mean (Climate at a Glance, nClimGrid)
8
+ EPA ECHO Michigan sewer plants (POTWs): location, receiving water, combined sewer flag, last year's average flow
9
+ """
10
+ import collections, functools, glob, json, os, re, urllib.parse, urllib.request
11
+
12
+ from waterledger_data.config import CACHE
13
+
14
+ RAW = os.path.join(CACHE, "waters")
15
+ ATTAINS = "https://gispub.epa.gov/arcgis/rest/services/OW/ATTAINS_Assessment/MapServer"
16
+ WBD6 = "https://hydro.nationalmap.gov/arcgis/rest/services/wbd/MapServer/6/query"
17
+ ECHO = "https://echodata.epa.gov/echo/cwa_rest_services"
18
+
19
+ # ATTAINS parameter groups -> plain groups. Runoff and wastewater can add to the first set; the "fish" group is legacy
20
+ # contamination (mercury, PCBs, PFAS in fish tissue) that a new campus would not change much.
21
+ GROUPS = {
22
+ "SEDIMENT": "sed", "TURBIDITY": "sed",
23
+ "CAUSE UNKNOWN - IMPAIRED BIOTA": "bio", "CAUSE UNKNOWN": "bio",
24
+ "NUTRIENTS": "nut", "ALGAL GROWTH": "nut", "NOXIOUS AQUATIC PLANTS": "nut",
25
+ "ORGANIC ENRICHMENT/OXYGEN DEPLETION": "do",
26
+ "PATHOGENS": "ecoli",
27
+ "TEMPERATURE": "temp",
28
+ "SALINITY/TOTAL DISSOLVED SOLIDS/CHLORIDES/SULFATES": "salt",
29
+ "HYDROLOGIC ALTERATION": "flow", "HABITAT ALTERATIONS": "flow",
30
+ "OIL AND GREASE": "oil",
31
+ "METALS (OTHER THAN MERCURY)": "metal", "PH/ACIDITY/CAUSTIC CONDITIONS": "metal",
32
+ "MERCURY": "fish", "POLYCHLORINATED BIPHENYLS (PCBS)": "fish", "PER- AND POLYFLUOROALKYL SUBSTANCES (PFAS)": "fish",
33
+ "DIOXINS": "fish", "PESTICIDES": "fish", "TOXIC ORGANICS": "fish", "TOTAL TOXICS": "fish",
34
+ }
35
+ LABELS = {
36
+ "sed": "Sediment", "bio": "Fish and stream insects in poor shape", "nut": "Phosphorus and algae", "do": "Low oxygen",
37
+ "ecoli": "Bacteria (E. coli)", "temp": "Water too warm", "salt": "Salt (chloride)", "flow": "Flow and habitat changes",
38
+ "oil": "Oil and grease", "metal": "Metals", "fish": "Chemicals in fish (mercury, PCBs, PFAS)",
39
+ }
40
+ ORDER = ["sed", "nut", "temp", "salt", "do", "bio", "flow", "ecoli", "oil", "metal", "fish"]
41
+
42
+
43
+ def _get(url, params, timeout=180):
44
+ with urllib.request.urlopen(url + "?" + urllib.parse.urlencode(params), timeout=timeout) as r:
45
+ return json.load(r)
46
+
47
+
48
+ def _paged(url, where, fields="*", page=2000):
49
+ rows, off = [], 0
50
+ while True:
51
+ d = _get(url, {"where": where, "outFields": fields, "returnGeometry": "false", "resultOffset": off,
52
+ "resultRecordCount": page, "orderByFields": "OBJECTID" if "ATTAINS" in url else "huc12", "f": "json"})
53
+ rows += [f["attributes"] for f in d.get("features", [])]
54
+ if len(d.get("features", [])) < page:
55
+ return rows
56
+ off += page
57
+
58
+
59
+ def _cached(name, fn):
60
+ path = os.path.join(RAW, name)
61
+ if not os.path.exists(path):
62
+ os.makedirs(RAW, exist_ok=True)
63
+ json.dump(fn(), open(path, "w"))
64
+ return json.load(open(path))
65
+
66
+
67
+ def attains():
68
+ """Michigan assessment units (names, categories) and their parameters, 2026 cycle."""
69
+ aus = {}
70
+ for p in sorted(glob.glob(os.path.join(RAW, "attains_[012]_*.json"))):
71
+ for f in json.load(open(p))["features"]:
72
+ a = f["attributes"]
73
+ aus[a["assessmentunitidentifier"]] = {"name": a["assessmentunitname"], "cat": a["ircategory"], "cycle": a["reportingcycle"]}
74
+ if not aus:
75
+ for layer in (0, 1, 2):
76
+ for a in _paged(f"{ATTAINS}/{layer}/query", "state='MI'", "assessmentunitidentifier,assessmentunitname,ircategory,reportingcycle"):
77
+ aus[a["assessmentunitidentifier"]] = {"name": a["assessmentunitname"], "cat": a["ircategory"], "cycle": a["reportingcycle"]}
78
+ params = _cached("attains_table8_mi.json", lambda: _paged(f"{ATTAINS}/8/query", "organizationid='21MICH'"))
79
+ return aus, params
80
+
81
+
82
+ @functools.lru_cache(None)
83
+ def huc12_links():
84
+ return {r["huc12"]: r for r in _cached("wbd_huc12_tohuc_mi.json", lambda: _paged(WBD6, "states LIKE '%MI%'", "huc12,name,tohuc,hutype", 1000))}
85
+
86
+
87
+ @functools.lru_cache(None)
88
+ def county_precip():
89
+ def fetch():
90
+ out = {}
91
+ for i in range(1, 166, 2):
92
+ d = _get(f"https://www.ncei.noaa.gov/access/monitoring/climate-at-a-glance/county/time-series/MI-{i:03d}/pcp/12/12/1991-2020/data.json", {})
93
+ v = [x["value"] for x in d["data"].values()]
94
+ out[f"26{i:03d}"] = {"name": d["description"]["title"].split(",")[0], "annual_in": round(sum(v) / len(v), 2), "n": len(v)}
95
+ return {"source": "NOAA NCEI Climate at a Glance, county time series (nClimGrid), January-December precipitation, mean 1991-2020", "counties": out}
96
+ return {k: v["annual_in"] for k, v in _cached("noaa_county_precip_1991_2020.json", fetch)["counties"].items()}
97
+
98
+
99
+ @functools.lru_cache(None)
100
+ def by_huc12():
101
+ """Impairments of the waters in each HUC12 (assessment units whose ID carries that HUC12), with whether a TMDL covers each,
102
+ plus the downstream HUC12 so the client can follow the water. Returns {huc12: {"n": name, "t": tohuc, "i": [[group, tmdl, waters]]}}."""
103
+ aus, params = attains()
104
+ links = huc12_links()
105
+ found = collections.defaultdict(lambda: collections.defaultdict(lambda: {"tmdl": 0, "w": set()}))
106
+ recoded = recode_aus()
107
+ for r in params:
108
+ if r["parameterstatus"] not in ("Cause", "Observed effect"):
109
+ continue
110
+ g = GROUPS.get(r["parametergroupname"])
111
+ m = re.match(r"MI(\d{12})", r["assessmentunitidentifier"] or "")
112
+ if not g or not m:
113
+ continue
114
+ e = found[recoded.get(r["assessmentunitidentifier"], m.group(1))][g]
115
+ e["tmdl"] |= int(r["parametercategorycode"] == "4A")
116
+ name = aus.get(r["assessmentunitidentifier"], {}).get("name") or ""
117
+ if name and " in HUC " not in name: # named lakes and rivers; generic "Rivers/Streams in HUC ..." units go unnamed
118
+ e["w"].add(name.title() if name.isupper() else name)
119
+ assessed = {recoded.get(a, re.match(r"MI(\d{12})", a).group(1)) for a, v in aus.items()
120
+ if re.match(r"MI(\d{12})", a) and not a.endswith(("-NA", "-NAL"))}
121
+ out = {}
122
+ for h, L in links.items():
123
+ rec = {"n": L["name"], "t": L["tohuc"] if L["tohuc"] in links else None, "a": int(h in assessed)}
124
+ if h in found:
125
+ rec["i"] = [[g, found[h][g]["tmdl"], sorted(found[h][g]["w"])[:3]] for g in ORDER if g in found[h]]
126
+ out[h] = rec
127
+ return out
128
+
129
+
130
+ ECHO_COLS = "1,2,4,13,15,26,27,28,29,30,31,53,56,62,150,158,159,161,168,172,173,175,176,178,179"
131
+
132
+
133
+ @functools.lru_cache(None)
134
+ def potws():
135
+ """Michigan wastewater plants from EPA's Clean Watersheds Needs Survey 2022 (design flow, location; includes lagoons and plants that
136
+ discharge to groundwater under state permits), with last year's average reported flow, receiving water and combined-sewer status
137
+ from EPA ECHO where an NPDES permit matches. ECHO flows that don't fit the plant's design flow are dropped (unit errors are common)."""
138
+ import csv
139
+
140
+ def fetch():
141
+ q = _get(f"{ECHO}.get_facilities", {"output": "JSON", "p_st": "MI", "p_pcomp": "POT", "responseset": 1000})["Results"]["QueryID"]
142
+ return _get(f"{ECHO}.get_qid", {"output": "JSON", "qid": q, "pageno": 1, "responseset": 1000, "qcolumns": ECHO_COLS})["Results"]["Facilities"]
143
+ rows = _cached("echo_potw_mi.json", lambda: {"Results": {"Facilities": fetch()}})
144
+ rows = rows["Results"]["Facilities"] if isinstance(rows, dict) else rows
145
+ echo = {r["SourceID"]: r for r in rows if r["CWPPermitStatusDesc"] not in ("Terminated", "Expired")}
146
+ cwns_design_flows() # downloads the CWNS tables if needed
147
+ for f in ("FACILITIES", "PHYSICAL_LOCATION"):
148
+ path = os.path.join(RAW, f"cwns2022_{f}.csv")
149
+ if not os.path.exists(path):
150
+ urllib.request.urlretrieve(f"{CWNS}/{f}.csv", path)
151
+ rd = lambda f: csv.DictReader(open(os.path.join(RAW, f"cwns2022_{f}.csv")))
152
+ fac = {r["CWNS_ID"]: r for r in rd("FACILITIES") if r["STATE_CODE"] == "MI" and r["INFRASTRUCTURE_TYPE"] == "Wastewater"}
153
+ flow = {r["CWNS_ID"]: float(r["CURRENT_DESIGN_FLOW"]) for r in rd("FLOW") if r["STATE_CODE"] == "MI" and r["FLOW_TYPE"] == "Total Flow" and r["CURRENT_DESIGN_FLOW"]}
154
+ loc = {r["CWNS_ID"]: r for r in rd("PHYSICAL_LOCATION") if r["STATE_CODE"] == "MI" and r["LATITUDE"]}
155
+ permits = collections.defaultdict(list)
156
+ for r in rd("FACILITY_PERMIT"):
157
+ if r["STATE_CODE"] == "MI":
158
+ permits[r["CWNS_ID"]].append(r["PERMIT_NUMBER"])
159
+ out = []
160
+ for cid, fr in fac.items():
161
+ if cid not in flow or cid not in loc:
162
+ continue
163
+ e = next((echo[pm] for pm in permits[cid] if pm in echo), None)
164
+ design = flow[cid]
165
+ f_echo = float(e["PastCalYrAverageFlow"]) if e and e["PastCalYrAverageFlow"] else None
166
+ if f_echo and not 0.1 <= f_echo / design <= 1.3:
167
+ f_echo = None
168
+ name = fr["FACILITY_NAME"].upper()
169
+ out.append({"id": (e or {}).get("SourceID") or (permits[cid] or [cid])[0], "name": name, "city": loc[cid]["CITY"],
170
+ "lat": float(loc[cid]["LATITUDE"]), "lon": float(loc[cid]["LONGITUDE"]),
171
+ "major": bool(e and e["CWPMajorMinorStatusFlag"] == "M"), "cso": bool(e and e["CWPCsoFlag"] == "Y"), "flow_mgd": f_echo, "design_mgd": design,
172
+ "receiving": (e or {}).get("CWPStateWaterBodyName"), "huc12": (e or {}).get("RadWBDHuc12"),
173
+ "lagoon": bool(re.search(r"LAGOON|WWSL|STABILIZATION", name))})
174
+ return out
175
+
176
+
177
+ TROUT = "https://services3.arcgis.com/Jdnp1TjADvSDxMAX/ArcGIS/rest/services/DNRFisheriesTroutRegsOPENDATA/FeatureServer/0/query"
178
+
179
+
180
+ @functools.lru_cache(None)
181
+ def trout_by_huc12():
182
+ """Designated trout streams (coldwater under R 323.1100(7)) per HUC12: number of designated segments whose midpoint falls inside
183
+ the HUC12 and a few stream names. Michigan DNR's layer; it lists only streams classified by type, so it can miss some designated waters."""
184
+ from waterledger_data import places as pl
185
+
186
+ def fetch():
187
+ out, off = [], 0
188
+ while True:
189
+ d = _get(TROUT, {"where": "Designated=1 AND isActive=1", "outFields": "DNRName,StreamType", "outSR": 4326, "geometryPrecision": 4,
190
+ "maxAllowableOffset": 0.002, "resultOffset": off, "resultRecordCount": 2000, "orderByFields": "OBJECTID", "f": "json"}, 300)
191
+ for f in d["features"]:
192
+ pts = [p for path in (f.get("geometry") or {}).get("paths", []) for p in path]
193
+ if pts:
194
+ out.append({"name": f["attributes"]["DNRName"], "type": f["attributes"]["StreamType"], "mid": pts[len(pts) // 2], "len": len(pts)})
195
+ if len(d["features"]) < 2000:
196
+ return {"source": "Michigan DNR, Trout and Salmon Regulations Stream Main, Designated=1 and isActive=1; segment midpoints", "segments": out}
197
+ off += 2000
198
+ segs = _cached("mdnr_trout_streams_mid.json", fetch)["segments"]
199
+ polys = pl._layer_polys(6, "huc12,name", "wbd_huc12_mi.json")
200
+ out = {}
201
+ for s in segs:
202
+ x, y = s["mid"]
203
+ for q in polys:
204
+ b = q["bbox"]
205
+ if b[0] <= x <= b[2] and b[1] <= y <= b[3] and pl._inside(x, y, q["rings"]):
206
+ e = out.setdefault(q["huc12"], {"n": 0, "names": set()})
207
+ e["n"] += 1
208
+ if s["name"]:
209
+ e["names"].add(s["name"])
210
+ break
211
+ return {h: {"n": e["n"], "names": sorted(e["names"])[:3]} for h, e in out.items()}
212
+
213
+
214
+ CWNS = "https://www.hydroshare.org/resource/3dab07fd628a4af2ba812f038b1af89f/data/contents/2022"
215
+
216
+
217
+ @functools.lru_cache(None)
218
+ def cwns_design_flows():
219
+ """Current design flow (MGD) of Michigan wastewater plants by NPDES permit, EPA Clean Watersheds Needs Survey 2022
220
+ (HydroShare mirror, Hodson et al. 2026). CWNS has no actual flows."""
221
+ import csv
222
+ for f in ("FLOW", "FACILITY_PERMIT"):
223
+ path = os.path.join(RAW, f"cwns2022_{f}.csv")
224
+ if not os.path.exists(path):
225
+ urllib.request.urlretrieve(f"{CWNS}/{f}.csv", path)
226
+ total = {}
227
+ for r in csv.DictReader(open(os.path.join(RAW, "cwns2022_FLOW.csv"))):
228
+ if r["STATE_CODE"] == "MI" and r["FLOW_TYPE"] == "Total Flow" and r["CURRENT_DESIGN_FLOW"]:
229
+ total[r["CWNS_ID"]] = float(r["CURRENT_DESIGN_FLOW"])
230
+ out = {}
231
+ for r in csv.DictReader(open(os.path.join(RAW, "cwns2022_FACILITY_PERMIT.csv"))):
232
+ if r["STATE_CODE"] == "MI" and r["CWNS_ID"] in total and r["PERMIT_NUMBER"]:
233
+ out[r["PERMIT_NUMBER"]] = total[r["CWNS_ID"]]
234
+ return out
235
+
236
+
237
+ def plant_name(n):
238
+ """'YCUA REGIONAL WWTP' -> 'YCUA Regional WWTP' (as on Water Ledger)."""
239
+ up = {"WWTP", "WRRF", "WWTF", "WTF", "WWSL", "STP", "RTB", "RTF", "CSO", "GLWA", "YCUA", "SCI"}
240
+ word = lambda w: w if w in up else ("County" if w == "CO" else w.capitalize())
241
+ return re.sub(r"[A-Za-z]+", lambda m: word(m.group(0).upper()), str(n).lower())
242
+
243
+
244
+ def locate(lat, lon):
245
+ """HUC12 and county containing a point (USGS WBD, Census TIGERweb)."""
246
+ from waterledger_data import places as pl
247
+ h = next((q for q in pl._layer_polys(6, "huc12,name", "wbd_huc12_mi.json")
248
+ if q["bbox"][0] <= lon <= q["bbox"][2] and q["bbox"][1] <= lat <= q["bbox"][3] and pl._inside(lon, lat, q["rings"])), None)
249
+ c = next((c for c in pl.counties() if pl._inside(lon, lat, c["rings"])), None)
250
+ return {"huc12": h["huc12"] if h else None, "huc12_name": h["name"] if h else None,
251
+ "county_fips": c["geoid"] if c else None, "county": c["name"] if c else None}
252
+
253
+
254
+ def downstream(huc12, hops=25):
255
+ """The HUC12s the water passes through, starting with the site's own: [{code, name, listings, trout}]."""
256
+ H, T = by_huc12(), trout_by_huc12()
257
+ out, h = [], huc12
258
+ while h and h in H and len(out) < hops:
259
+ out.append({"code": h, "name": H[h]["n"], "listings": H[h].get("i", []), "trout": T.get(h), "assessed": bool(H[h].get("a"))})
260
+ h = H[h]["t"]
261
+ return out
262
+
263
+
264
+ def huc_name(n, cap=False):
265
+ """In a sentence, 'Headwaters Little Rabbit River' -> 'the headwaters of the Little Rabbit River' (same rule as wq.js hucName)."""
266
+ m = re.match(r"^Headwaters (.+)$", n or "")
267
+ s = (f"the headwaters of {'the ' if re.search(r'River$', m.group(1)) else ''}{m.group(1)}" if m
268
+ else f"the {n}" if n and re.search(r"River$", n) and "-" not in n else (n or ""))
269
+ return s[:1].upper() + s[1:] if cap else s
270
+
271
+
272
+ def place_stem(name):
273
+ """'Gaines Charter Township' / 'Gaylord city' -> 'gaines' / 'gaylord', to match a plant's city."""
274
+ return re.sub(r"\s+(charter township|township|city)$", "", str(name).strip(), flags=re.I).lower()
275
+
276
+
277
+ def nearest_plants(lat, lon, k=3, km=40, stem=None):
278
+ """Sewer plants for a place, as on Water Ledger: a plant in the place's own city or township first (any size), then the nearest
279
+ plants of 0.5 MGD design or more within km."""
280
+ import math
281
+ rows = []
282
+ for p in potws():
283
+ d = 6371 * math.acos(min(1, math.sin(math.radians(lat)) * math.sin(math.radians(p["lat"])) +
284
+ math.cos(math.radians(lat)) * math.cos(math.radians(p["lat"])) * math.cos(math.radians(p["lon"] - lon))))
285
+ own = bool(stem) and p["city"].lower() == stem
286
+ if (own and d <= km) or (d <= km and p["design_mgd"] >= 0.5):
287
+ rows.append({**p, "km": round(d, 1), "own": own})
288
+ return sorted(rows, key=lambda r: (not r["own"], r["km"]))[:k]
289
+
290
+
291
+ @functools.lru_cache(None)
292
+ def recode_aus():
293
+ """USGS recoded some Michigan HUC12s after EGLE assigned assessment-unit IDs (e.g. the Huron River moved from 0409 to 0410).
294
+ For units whose ID carries a code no longer in WBD, find the current HUC12 from the unit's own geometry (a middle vertex).
295
+ Returns {assessment unit ID: current HUC12}."""
296
+ def fetch():
297
+ links = huc12_links()
298
+ aus, _ = attains()
299
+ stale = sorted(a for a in aus if re.match(r"MI(\d{12})", a) and re.match(r"MI(\d{12})", a).group(1) not in links)
300
+ pts = {}
301
+ for layer in (0, 1, 2):
302
+ for i in range(0, len(stale), 100):
303
+ ids = ",".join(f"'{a}'" for a in stale[i:i + 100])
304
+ d = _get(f"{ATTAINS}/{layer}/query", {"where": f"assessmentunitidentifier IN ({ids})", "outFields": "assessmentunitidentifier",
305
+ "returnGeometry": "true", "outSR": 4326, "maxAllowableOffset": 0.002, "geometryPrecision": 4, "f": "json"})
306
+ for f in d.get("features", []):
307
+ g = f.get("geometry") or {}
308
+ v = ([p for path in g.get("paths", []) for p in path] or [p for ring in g.get("rings", []) for p in ring]
309
+ or ([[g["x"], g["y"]]] if "x" in g else []))
310
+ if v:
311
+ if g.get("rings"):
312
+ v = [[sum(p[0] for p in v) / len(v), sum(p[1] for p in v) / len(v)]]
313
+ pts[f["attributes"]["assessmentunitidentifier"]] = v[len(v) // 2]
314
+ return pts
315
+ pts = _cached("attains_stale_au_points.json", fetch)
316
+ from waterledger_data import places as pl
317
+ polys = pl._layer_polys(6, "huc12,name", "wbd_huc12_mi.json")
318
+ out = {}
319
+ for au, (x, y) in pts.items():
320
+ for q in polys:
321
+ b = q["bbox"]
322
+ if b[0] <= x <= b[2] and b[1] <= y <= b[3] and pl._inside(x, y, q["rings"]):
323
+ out[au] = q["huc12"]
324
+ break
325
+ return out
326
+
327
+
328
+ WELLOGIC = "https://gisagoegle.state.mi.us/arcgis/rest/services/EGLE/DwOpenData/MapServer/3/query"
329
+
330
+
331
+ @functools.lru_cache(None)
332
+ def household_wells():
333
+ """Household wells logged in EGLE's Wellogic by county and township: how many draw from glacial deposits (drift) vs bedrock,
334
+ their average depth and static water level (ft). Returns {(county, township): {"drift": {...}, "rock": {...}}}."""
335
+ from waterledger_data import places as pl
336
+
337
+ def fetch():
338
+ out = []
339
+ for c in sorted({c["name"] for c in pl.counties()}):
340
+ stats = [{"statisticType": "count", "onStatisticField": "OBJECTID", "outStatisticFieldName": "n"},
341
+ {"statisticType": "avg", "onStatisticField": "WELL_DEPTH", "outStatisticFieldName": "depth"},
342
+ {"statisticType": "avg", "onStatisticField": "SWL", "outStatisticFieldName": "swl"}]
343
+ d = _get(WELLOGIC, {"where": f"COUNTY='{c}' AND WELL_TYPE='HOSHLD' AND AQ_TYPE IS NOT NULL", "outStatistics": json.dumps(stats),
344
+ "groupByFieldsForStatistics": "TOWNSHIP,AQ_TYPE", "f": "json"}, 300)
345
+ out += [{"county": c, **f["attributes"]} for f in d.get("features", [])]
346
+ return out
347
+ out = {}
348
+ for r in _cached("wellogic_household_by_township.json", fetch):
349
+ if not r["TOWNSHIP"]:
350
+ continue
351
+ e = out.setdefault((r["county"], r["TOWNSHIP"].strip().lower()), {})
352
+ e[r["AQ_TYPE"].lower()] = {"n": r["n"], "depth": round(r["depth"] or 0), "swl": round(r["swl"]) if r["swl"] else None}
353
+ return out