waterledger 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,34 @@
1
+ Metadata-Version: 2.5
2
+ Name: waterledger
3
+ Version: 0.1.0
4
+ Summary: Water Ledger: public estimates of what large new water users mean for local water and streams, state by state (import waterledger as wl)
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: openpyxl>=3.1
16
+ Requires-Dist: pandas>=2.0
17
+ Requires-Dist: waterledger-data>=0.1
18
+ Requires-Dist: waterledger-models<1.3,>=1.2
19
+ Description-Content-Type: text/markdown
20
+
21
+ # waterledger
22
+
23
+ Public estimates of what large new water users mean for local water and streams, state by state; the builder behind
24
+ [waterledger.org](https://waterledger.org).
25
+
26
+ ```python
27
+ import waterledger as wl
28
+ p = wl.scenario.place(wl.scenario.find("gaines charter township"))
29
+ wl.scenario.estimate(p, mw=300, cool="hybrid", dest="sewer")["wAvg"] # million gallons a day: lo, mid, hi
30
+ ```
31
+
32
+ ```bash
33
+ waterledger build mi # writes <site>/mi/data/*.json
34
+ ```
@@ -0,0 +1,14 @@
1
+ # waterledger
2
+
3
+ Public estimates of what large new water users mean for local water and streams, state by state; the builder behind
4
+ [waterledger.org](https://waterledger.org).
5
+
6
+ ```python
7
+ import waterledger as wl
8
+ p = wl.scenario.place(wl.scenario.find("gaines charter township"))
9
+ wl.scenario.estimate(p, mw=300, cool="hybrid", dest="sewer")["wAvg"] # million gallons a day: lo, mid, hi
10
+ ```
11
+
12
+ ```bash
13
+ waterledger build mi # writes <site>/mi/data/*.json
14
+ ```
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "waterledger"
3
+ version = "0.1.0"
4
+ description = "Water Ledger: public estimates of what large new water users mean for local water and streams, state by state (import waterledger as wl)"
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 = ["waterledger-models>=1.2,<1.3", "waterledger-data>=0.1", "pandas>=2.0", "openpyxl>=3.1"]
15
+
16
+ [project.scripts]
17
+ waterledger = "waterledger.cli:main"
18
+
19
+ [project.urls]
20
+ Homepage = "https://waterledger.org"
21
+ Source = "https://github.com/northlinehydrology/waterledger"
22
+ Issues = "https://github.com/northlinehydrology/waterledger/issues"
23
+
24
+ [build-system]
25
+ requires = ["hatchling"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/waterledger"]
@@ -0,0 +1,14 @@
1
+ """Water Ledger: public estimates of what large new water users mean for local water and streams.
2
+
3
+ import waterledger as wl
4
+ wl.models.datacenter.cooling the models (package waterledger-models)
5
+ wl.data.places public data for any place (package waterledger-data)
6
+ wl.scenario a proposal at a place, as waterledger.org shows it
7
+ wl.states.mi the builder for a state's site data (waterledger build mi)
8
+ """
9
+ import waterledger_data as data
10
+ import waterledger_models as models
11
+ from waterledger import scenario, states
12
+
13
+ __all__ = ["data", "models", "scenario", "states"]
14
+ __version__ = "0.1.0"
@@ -0,0 +1,21 @@
1
+ """waterledger build <state> [all|estimates|waters|power|moratoria]: write a state's site data (paths from waterledger_data.config)."""
2
+ import argparse
3
+
4
+ from waterledger.states import STATES, builder
5
+
6
+
7
+ def main(argv=None):
8
+ ap = argparse.ArgumentParser(prog="waterledger", description=__doc__)
9
+ sub = ap.add_subparsers(dest="cmd", required=True)
10
+ b = sub.add_parser("build", help="write a state's site data")
11
+ b.add_argument("state", choices=sorted(STATES))
12
+ b.add_argument("part", nargs="?", default="all", choices=["all", "estimates", "waters", "power", "moratoria"])
13
+ a = ap.parse_args(argv)
14
+ m = builder(a.state)
15
+ parts = {"estimates": m.main, "waters": m.build_waters, "power": m.build_power, "moratoria": m.build_moratoria}
16
+ for p in (["estimates", "waters", "power", "moratoria"] if a.part == "all" else [a.part]):
17
+ parts[p]()
18
+
19
+
20
+ if __name__ == "__main__":
21
+ main()
@@ -0,0 +1,169 @@
1
+ """A scenario: a proposal of a given kind at any Michigan place, before anything is filed. Public data only.
2
+
3
+ Reads Water Ledger's data files (<site>/mi/data/*.json), which waterledger.states.mi writes, so anything built on this
4
+ package and the public page use the same numbers; estimate() mirrors the page's est().
5
+ """
6
+ import json, math, os
7
+
8
+ from waterledger_data.config import SITE
9
+
10
+ DATA = os.path.join(SITE, "mi", "data")
11
+ DESIGNS = {"dry": "Closed loop (designed to use no water for cooling)", "hybrid": "Hybrid", "towers": "Cooling towers", "lake": "Lake-source (once-through)"}
12
+ SOURCE_LABEL = {"muni": "a public water system", "wells": "new wells in the aquifer", "lake": "lake surface water"}
13
+ DEST_LABEL = {"sewer": "the public sewer", "septic": "a septic system on site", "water": "a river or lake (its own discharge)", "reuse": "treatment and reuse on site"}
14
+ SYS_POP = 3300 # [A] a "sizable" public water system serves more than this many people (EPA's medium size and up)
15
+ SEWER_KM = 10 # [A] with no public water system mapped, a large sewer plant this close is assumed reachable by an extension
16
+ LAND_LABEL = {"farm": "farm fields", "woods": "woods or meadow", "built": "existing development"}
17
+ _cache = {}
18
+
19
+
20
+ def data(name):
21
+ if name not in _cache:
22
+ _cache[name] = json.load(open(os.path.join(DATA, name + ".json")))
23
+ return _cache[name]
24
+
25
+
26
+ def place(gid):
27
+ p = next((x for x in data("places")["places"] if x["g"] == str(gid)), None)
28
+ if p is None:
29
+ raise KeyError(f"No Michigan place with GEOID {gid}")
30
+ b = p["n"].rsplit(" ", 2)[0] if p["n"].endswith("charter township") else p["n"].rsplit(" ", 1)[0]
31
+ p = dict(p, base=b if p["k"] == "city" else b + (" Charter Township" if p["k"] == "charter township" else " Township"))
32
+ return p
33
+
34
+
35
+ def find(name, county=None):
36
+ """GEOID of a place by name ('Gaines', 'Gaines charter township'), optionally within a county."""
37
+ name = name.lower()
38
+ hits = [p for p in data("places")["places"] if p["n"].lower().startswith(name) and (county is None or p["c"].lower() == county.lower())]
39
+ exact = [p for p in hits if p["n"].lower() == name]
40
+ if exact:
41
+ return exact[0]["g"]
42
+ if len(hits) != 1:
43
+ raise KeyError(f"{name!r} matches {[p['n'] + ', ' + p['c'] for p in hits[:6]] or 'nothing'}; give the full name (e.g. 'saline township') or the GEOID")
44
+ return hits[0]["g"]
45
+
46
+
47
+ def lonlat(p):
48
+ from waterledger.states.mi import KX, LAT1, LON0, SC
49
+ return p["x"] / (KX * SC) + LON0, LAT1 - p["y"] / SC
50
+
51
+
52
+ def band(stat, x):
53
+ return {"lo": stat["p10"] * x, "mid": stat["p50"] * x, "hi": stat["p90"] * x}
54
+
55
+
56
+ def flow(D, region, cool, key, mw):
57
+ """A water flow at `mw` MW of IT capacity, lo/mid/hi in MGD (million gallons for *_annual_mg): the per-100 MW model results scaled
58
+ to the size, plus staff water at that size, which grows more slowly than the computers (model 1.2). The two ranges are added
59
+ end to end, which makes the range a little wide. Same as WQ.flow on the page."""
60
+ from waterledger_models.datacenter.cooling import staff_q
61
+ b = band(D["regions"][region]["per_100mw"][cool][key], mw / 100)
62
+ s = staff_q(D["staff"]["runs"], mw)[key.split("_")[0]]
63
+ k = 365.25 if key.endswith("_mg") else 1.0 # annual_mg and worst_year_mg are million gallons a year
64
+ return {"lo": b["lo"] + s["p10"] * k, "mid": b["mid"] + s["p50"] * k, "hi": b["hi"] + s["p90"] * k}
65
+
66
+
67
+ def estimate(p, mw, cool, dest, region=None):
68
+ """Water taken, used up and sent out (MGD, lo/mid/hi), as on the page."""
69
+ D = data("estimates")
70
+ x = mw / 100
71
+ region = region or p["r"]
72
+ if cool == "lake":
73
+ L = D["lake_source"][p["gl"]["lake"]]
74
+ w = band(L["withdrawn"], x)
75
+ return {"lake": True, "wAvg": w, "wPeak": w, "wAnn": {k: v * 365 for k, v in w.items()}, "cAvg": band(L["evap_avg"], x),
76
+ "cPeak": band(L["evap_peak_month"], x), "rAvg": w, "rPeak": w, "sewer": flow(D, region, "dry", "returned_avg", mw), "reuse": False}
77
+ F = lambda key: flow(D, region, cool, key, mw)
78
+ E = {"lake": False, "wAvg": F("withdrawn_avg"), "wPeak": F("withdrawn_peak_1in10"), "wAnn": F("withdrawn_annual_mg"),
79
+ "cAvg": F("consumed_avg"), "cPeak": F("consumed_peak_1in10"), "rAvg": F("returned_avg"),
80
+ "rPeak": F("returned_peak_1in10"), "reuse": False}
81
+ # what goes back out is what is taken less what is lost to evaporation, so the two add up (same as WQ.split on the page)
82
+ for k_r, k_w, k_c in (("rAvg", "wAvg", "cAvg"), ("rPeak", "wPeak", "cPeak")):
83
+ shown = lambda v: round(v, 4) if v < 0.1 else round(v, 2) # as the pages round flows, so shown numbers add up
84
+ m = max(0.0, shown(E[k_w]["mid"]) - shown(E[k_c]["mid"]))
85
+ E[k_r] = {"lo": min(E[k_r]["lo"], m), "mid": m, "hi": max(E[k_r]["hi"], m)}
86
+ E["sewer"] = E["rAvg"]
87
+ if dest == "reuse":
88
+ E["full"] = {"wAvg": E["wAvg"], "rAvg": E["rAvg"]}
89
+ E["wAvg"] = E["cAvg"]
90
+ E["wPeak"] = {k: E["wPeak"][k] - E["rPeak"][k] for k in ("lo", "mid", "hi")}
91
+ E["wAnn"] = {k: v * 365 for k, v in E["cAvg"].items()}
92
+ E["rAvg"] = E["rPeak"] = E["sewer"] = {"lo": 0, "mid": 0, "hi": 0}
93
+ E["reuse"] = True
94
+ return E
95
+
96
+
97
+ def septic_rule(gpd):
98
+ """Michigan's rules for sanitary sewage to a septic system (Part 22: R 323.2210, 2211, 2216), as on the page (WQ.septicRule)."""
99
+ return ("the county health department approves the design; no state permit is needed under 6,000 gallons a day" if gpd < 6000
100
+ else "it falls under a state permit-by-rule (6,000 to 10,000 gallons a day): the county health department approves it, and the flow is metered and reported to EGLE each year" if gpd < 10000
101
+ else "it needs a groundwater discharge permit from EGLE (above 10,000 gallons a day)")
102
+
103
+
104
+ def _plants_near(p, km=40):
105
+ """Sewer plants for a place exactly as the page finds them (WQ.plants: waters.json map coordinates, 1/60 degree = 1.852 km),
106
+ so the page and the engine pick the same destination at the SEWER_KM edge."""
107
+ from waterledger_data import waters as wi
108
+ st = wi.place_stem(p["n"])
109
+ rows = [{**q, "km": math.hypot(q["x"] - p["x"], q["y"] - p["y"]) * 1.852, "own": q["city"] == st} for q in data("waters")["plants"]]
110
+ return sorted([q for q in rows if q["km"] <= km and (q["own"] or q["design"] >= 0.5)], key=lambda q: (not q["own"], q["km"]))
111
+
112
+
113
+ def near_system(p):
114
+ """The nearest sizable public water system to a place, as on the page (WQ.nearSystem): serving more than SYS_POP people and
115
+ covering at least a quarter of a city or township, measured center to center (map coordinates, 1/60 degree = 1.852 km)."""
116
+ best = None
117
+ for q in data("places")["places"]:
118
+ if q["g"] == p["g"] or not q["w"]:
119
+ continue
120
+ for w in q["w"]:
121
+ if (w.get("pop") or 0) <= SYS_POP or (w.get("share") or 0) < 0.25:
122
+ continue
123
+ km = math.hypot(q["x"] - p["x"], q["y"] - p["y"]) * 1.852
124
+ if best is None or km < best["km"]:
125
+ best = {"w": w, "place": q, "km": km}
126
+ return best
127
+
128
+
129
+ def src_for(p, cool):
130
+ """Where a campus's water most likely comes from [A], as on the page (WQ.srcFor). A system EPA maps in the place. With none
131
+ mapped: closed loop from a sizable system within SEWER_KM (it extends its pipes); towers and hybrid from new wells, which avoid a
132
+ utility's peak-day limits; nothing within reach, new wells."""
133
+ if p["w"]:
134
+ return {"src": "muni", "near": None, "reach": False}
135
+ near = near_system(p)
136
+ reach = bool(near) and near["km"] <= SEWER_KM
137
+ return {"src": "muni" if reach and cool not in ("towers", "hybrid") else "wells", "near": near, "reach": reach}
138
+
139
+
140
+ def dest_for(p, cool, sew_mgd):
141
+ """Where a campus's wastewater most likely goes [A], as on the page (WQ.destFor). The sewer when the place has its own plant,
142
+ EPA maps a public water system in it (nearest large plant within 40 km), or none is mapped but a large plant is within SEWER_KM;
143
+ evaporative blowdown only if it fits in the plant's spare design flow. Otherwise closed loop's mostly sanitary flow goes to a
144
+ septic system on site, and blowdown to the campus's own treatment plant and a stream. The plant is in the page's form (n, design, flow, km)."""
145
+ pls = _plants_near(p)
146
+ q = pls[0] if pls else None
147
+ evap = cool in ("towers", "hybrid")
148
+ reach = bool(q) and (q["own"] or bool(p["w"]) or q["km"] <= SEWER_KM)
149
+ room = q["design"] - q["flow"] if q and q.get("flow") else None
150
+ why = "far" if not reach else "full" if evap and room is not None and sew_mgd > room else ""
151
+ return {"dest": ("water" if evap else "septic") if why else "sewer", "q": q, "sys": bool(p["w"]), "room": room, "why": why}
152
+
153
+
154
+ def scenario(gid, mw=None, cool="dry", src=None, dest=None, acres=None, before=None, imp=0.5, power="mi_mix", h12=0, name=None):
155
+ p = place(gid)
156
+ # the page's starting settings for a place [A]: cities a smaller campus on a smaller, built-up site; townships 300 MW on farmland
157
+ city = p["k"] == "city"
158
+ mw = mw or (100 if city else 300)
159
+ src = src or src_for(p, cool)["src"] # the page's starting water source for the place and cooling design
160
+ if src == "lake" and not p.get("gl"):
161
+ raise ValueError(f"{p['base']} is not within about 5 km of a Great Lake; lake surface water is not an option")
162
+ if cool == "lake" and src != "lake":
163
+ raise ValueError("Lake-source cooling needs lake surface water as the source")
164
+ if not dest:
165
+ dest = dest_for(p, cool, estimate(p, mw, cool, "sewer")["sewer"]["mid"])["dest"]
166
+ before = before or ("built" if p["k"] == "city" else "farm") # [A] as on the page: cities built up, townships farmland
167
+ acres = acres or (50 if city else 200)
168
+ return {"place": p, "mw": mw, "cool": cool, "src": src, "dest": dest, "acres": acres, "before": before, "imp": imp, "power": power,
169
+ "h12": min(h12, len(p["h12"]) - 1) if p["h12"] else None, "name": name, "E": estimate(p, mw, cool, dest)}
@@ -0,0 +1,16 @@
1
+ """One builder per state, each writing <site>/<state>/data/*.json. Michigan is the full version; others follow."""
2
+ import importlib
3
+
4
+ STATES = {"mi": "Michigan"}
5
+
6
+
7
+ def builder(state):
8
+ if state not in STATES:
9
+ raise KeyError(f"No builder for {state!r}; have {sorted(STATES)}")
10
+ return importlib.import_module(f"waterledger.states.{state}")
11
+
12
+
13
+ def __getattr__(name):
14
+ if name in STATES:
15
+ return builder(name)
16
+ raise AttributeError(name)
@@ -0,0 +1,609 @@
1
+ """Build Water Ledger Michigan's data files: <site>/mi/data/*.json.
2
+
3
+ Public data only. Every value carries a source id from SOURCES. Run from the repo root:
4
+ python -m waterledger.states.mi (or: waterledger build mi)
5
+ """
6
+ import datetime, json, math, os, re
7
+
8
+ import pandas as pd
9
+
10
+ from waterledger_data import places as pl
11
+ from waterledger_data.climate import hourly_weather
12
+ from waterledger_models.datacenter import cooling as cw
13
+ from waterledger_models.datacenter import lake as lc
14
+ from waterledger_data.config import CACHE, SITE as SITE_ROOT, setting
15
+
16
+ from waterledger_models import MODEL_VERSION
17
+ STATE = "mi" # Water Ledger is organized by state: site/waterledger/<state>/
18
+ SITE = os.path.join(SITE_ROOT, STATE)
19
+ OUT = os.path.join(SITE, "data", "estimates.json")
20
+ OUT_PLACES = os.path.join(SITE, "data", "places.json")
21
+ OUT_MAP = os.path.join(SITE, "data", "map.json")
22
+ OUT_WATERS = os.path.join(SITE, "data", "waters.json")
23
+ OUT_POWER = os.path.join(SITE, "data", "power.json")
24
+ RAW_BASE = CACHE
25
+ RAW_PLACES = os.path.join(RAW_BASE, "places")
26
+ RAW = os.path.join(RAW_BASE, "lit")
27
+
28
+ REGIONS = {
29
+ "southeast": {"name": "Southeast Michigan", "station": "Ann Arbor area", "lat": 42.25, "lon": -83.60},
30
+ "southwest": {"name": "Southwest Michigan", "station": "Kalamazoo area", "lat": 42.29, "lon": -85.59},
31
+ "west": {"name": "West Michigan", "station": "Grand Rapids area", "lat": 42.88, "lon": -85.52, "cache": "gaines"},
32
+ "central": {"name": "Central Michigan", "station": "Lansing area", "lat": 42.73, "lon": -84.55},
33
+ "thumb": {"name": "Saginaw Valley and the Thumb", "station": "Saginaw area", "lat": 43.42, "lon": -83.95},
34
+ "north": {"name": "Northwest Michigan", "station": "Traverse City area", "lat": 44.76, "lon": -85.62},
35
+ "northeast": {"name": "Northeast Michigan", "station": "Alpena area", "lat": 45.06, "lon": -83.43},
36
+ "up": {"name": "Central and Western Upper Peninsula", "station": "Marquette area", "lat": 46.54, "lon": -87.40},
37
+ }
38
+
39
+ SOURCES = {
40
+ "lbnl2024": ("Shehabi, A., et al. (2024). 2024 United States Data Center Energy Usage Report. Lawrence Berkeley National Laboratory, LBNL-2001637.", "https://doi.org/10.71468/P1WC7Q"),
41
+ "lei2022": ("Lei, N., & Masanet, E. (2022). Climate- and technology-specific PUE and WUE estimations for U.S. data centers using a hybrid statistical and thermodynamics-based approach. Resources, Conservation and Recycling 182: 106323.", "https://doi.org/10.1016/j.resconrec.2022.106323"),
42
+ "lei2025": ("Lei, N., Lu, J., Shehabi, A., & Masanet, E. (2025). The water use of data center workloads: A review and assessment of key determinants. Resources, Conservation and Recycling 219: 108310.", "https://doi.org/10.1016/j.resconrec.2025.108310"),
43
+ "usgs2013": ("Diehl, T. H., Harris, M. A., Murphy, J. C., Hutson, S. S., & Ladd, D. E. (2013). Methods for estimating water consumption for thermoelectric power plants in the United States. U.S. Geological Survey Scientific Investigations Report 2013-5188 (eq. 9 gives the Rutberg et al. 2011 dry-bulb evaporation relation).", "https://doi.org/10.3133/sir20135188"),
44
+ "femp_bmp10": ("U.S. Department of Energy, Federal Energy Management Program. Best Management Practice #10: Cooling Tower Management.", "https://www.energy.gov/femp/best-management-practice-10-cooling-tower-management"),
45
+ "femp_dc": ("U.S. Department of Energy, FEMP. Cooling Water Efficiency Opportunities for Federal Data Centers.", "https://www.energy.gov/cmei/femp/cooling-water-efficiency-opportunities-federal-data-centers"),
46
+ "epa_wsaw": ("U.S. EPA (2012). WaterSense at Work: Best Management Practices for Commercial and Institutional Facilities, §6.3 Cooling Towers.", "https://www.epa.gov/watersense/best-management-practices"),
47
+ "stull2011": ("Stull, R. (2011). Wet-bulb temperature from relative humidity and air temperature. Journal of Applied Meteorology and Climatology 50: 2267–2269.", "https://doi.org/10.1175/JAMC-D-11-0143.1"),
48
+ "msft2026": ("Microsoft (June 24, 2026). Inside Microsoft's two-decade push to cut water intensity while scaling for growth.", "https://blogs.microsoft.com/blog/2026/06/24/inside-microsofts-two-decade-push-to-cut-water-intensity-while-scaling-for-growth/"),
49
+ "icprb2025": ("Interstate Commission on the Potomac River Basin (2025). 2025 Washington Metropolitan Area Water Supply Study (ICP-596), §6.2 and Table 6-5.", "https://www.potomacriver.org/publications/2025-washington-metropolitan-area-water-supply-study-demand-and-resource-availability-forecast-for-the-year-2050/"),
50
+ "icprb2026": ("Interstate Commission on the Potomac River Basin (March 2026). Data Centers and Water Use in the Potomac River Basin (fact sheet).", "https://www.potomacriver.org/wp-content/uploads/2026/03/ICPRB.DataCentersandWaterUse.ICPRB_.March2026.pdf"),
51
+ "siddik2024": ("Siddik, M. A. B., Shehabi, A., Rao, P., & Marston, L. T. (2024). Spatially and temporally detailed water and carbon footprints of U.S. electricity generation and use. Water Resources Research 60(12). Data: LBNL Water IMPACT (HydroShare), 2022 v1.1.", "https://doi.org/10.1029/2024WR038350"),
52
+ "macknick2012": ("Macknick, J., et al. (2012). Operational water consumption and withdrawal factors for electricity generating technologies. Environmental Research Letters 7: 045802.", "https://doi.org/10.1088/1748-9326/7/4/045802"),
53
+ "peer2019": ("Peer, R. A. M., Grubert, E., & Sanders, K. T. (2019). A regional assessment of the water embedded in the US electricity system. Environmental Research Letters 14: 084014.", "https://doi.org/10.1088/1748-9326/ab2daa"),
54
+ "eia_mi": ("U.S. Energy Information Administration. Michigan electricity profile and EIA-923 generation data (2025).", "https://www.eia.gov/electricity/state/michigan/"),
55
+ "gcsaa2013": ("GCSAA (2015). Golf Course Environmental Profile: water use and conservation practices on U.S. golf courses (2013 survey, by region).", "https://gcsaa.org/docs/default-source/Environment/phase-2-water-use-survey-full-report.pdf"),
56
+ "usgs_cu2007": ("Shaffer, K. H., & Runkle, D. L. (2007). Consumptive water-use coefficients for the Great Lakes Basin and climatically similar areas. USGS Scientific Investigations Report 2007-5197 (commercial use: about 10% consumed).", "https://pubs.usgs.gov/sir/2007/5197/"),
57
+ "energystar_humid": ("U.S. EPA ENERGY STAR. 16 more ways to cut energy waste in the data center: make humidification adjustments (data centers in cool climates, particularly with free cooling, should not need to humidify for much of the year; after ASHRAE TC 9.9's 2014 electrostatic discharge study).", "https://www.energystar.gov/products/data_center_equipment/16-more-ways-cut-energy-waste-data-center/make-humidification-adjustments"),
58
+ "jlarc2024": ("Joint Legislative Audit and Review Commission, Virginia (December 9, 2024). Data Centers in Virginia 2024. Report 598 (a typical building: about 50 full-time workers once open, about half of them contractors).", "https://jlarc.virginia.gov/pdfs/reports/Rpt598.pdf"),
59
+ "mcl_205_54ee": ("Michigan General Sales Tax Act, MCL 205.54ee: data center equipment exemption (an enterprise data center needs at least 30 new jobs, among other conditions).", "https://legislature.mi.gov/Laws/MCL?objectName=mcl-205-54ee"),
60
+ "lowell_firstlook": ("Lowell's First Look (May 2026), citing Microsoft: about 2.2 million gallons a year excluding cooling, plus a one-time 1.2 million gallon fill; Lowell Township business park page. Primary document not yet located.", "https://www.lowelltwp.org/business_park/index.php"),
61
+ "gaines_sunnews": ("Sun and News / WOOD TV (2026): Microsoft at the April 15, 2026 Gaines Township hearing, about 2,000 gallons a day per building.", "https://www.yahoo.com/news/articles/gaines-township-research-noise-proposed-215143809.html"),
62
+ "saline_post": ("The Saline Post (July 2026), as reported by Planet Detroit and Sun Times News: about 20,000 gallons a day at the start of operations, a developer estimate. Primary document not yet located.", "https://planetdetroit.org/2025/10/saline-data-center-settlement/"),
63
+ "wgvu_switch": ("WGVU (December 16, 2025): Gaines Township's community development director, about 10 million gallons last year at the Switch data center (design 320 MW, Switch 2017; actual load not public).", "https://www.wgvunews.org/news/2025-12-16/gaines-township-to-consider-rezoning-for-future-microsoft-data-center"),
64
+ "nih_closedloop": ("NIH Office of Research Facilities (August 2023). Closed Loop Water Treatment, Technical Bulletin: closed systems typically need makeup water of less than 5% of their volume a year.", "https://orf.od.nih.gov/TechnicalResources/Documents/Technical%20Bulletins/23TB/Closed%20Loop%20Water%20Treatment%20-%20August%202023%20Technical%20Bulletin_508.pdf"),
65
+ "mpsc_territory": ("Michigan Public Service Commission. Electric Utility Service Areas (GIS hub, 2025): territories of Michigan's investor-owned, cooperative and municipal electric utilities.", "https://data-michiganpsc.hub.arcgis.com"),
66
+ "eia861": ("U.S. Energy Information Administration. Annual Electric Power Industry Report (Form EIA-861), 2025: retail sales and customers by utility and state, and summer and winter peak demand.", "https://www.eia.gov/electricity/data/eia861/"),
67
+ "egle2025": ("Michigan EGLE. Water Use Data, 2025 (by county and sector; reported by registered large-quantity withdrawals).", "https://www.michigan.gov/egle/about/organization/geologic-resources-management/groundwater/water-use/reporting/water-use-data"),
68
+ "epa_watersense": ("U.S. EPA WaterSense. How We Use Water: \"The average American family uses more than 300 gallons of water per day at home.\"", "https://www.epa.gov/watersense/how-we-use-water"),
69
+ "era5": ("Hourly 2 m temperature and humidity, 1995–2024, ECMWF ERA5 reanalysis via the Open-Meteo archive API.", "https://open-meteo.com/en/docs/historical-weather-api"),
70
+ "google2026": ("Google (2026). 2026 Environmental Report (data for 2025), data center water tables.", "https://storage.googleapis.com/gweb-mobius-cdn/sustainability/uploads/7f477eb723fe0c23d03f94b90a08882b9f28187d.pdf"),
71
+ "han2026": ("Han, Y., Li, P., Wierman, A., & Ren, S. (2026). Small Bottle, Big Pipe: Quantifying and addressing the impact of data centers on public water systems. arXiv:2603.02705 (preprint, not peer reviewed).", "https://arxiv.org/abs/2603.02705"),
72
+ "census_tiger": ("U.S. Census Bureau. TIGERweb county subdivisions (cities and townships) and counties, Michigan.", "https://tigerweb.geo.census.gov/"),
73
+ "epa_sa": ("U.S. EPA. Community Water System Service Area Boundaries (reported or modeled), with population served and connections from SDWIS.", "https://www.epa.gov/ground-water-and-drinking-water/community-water-system-service-area-boundaries"),
74
+ "usgs_wbd": ("U.S. Geological Survey. Watershed Boundary Dataset (HUC8 and HUC12).", "https://www.usgs.gov/national-hydrography/watershed-boundary-dataset"),
75
+ "noaa_atlas14": ("NOAA National Weather Service. Atlas 14, Volume 8 (Midwestern States), precipitation frequency estimates.", "https://hdsc.nws.noaa.gov/pfds/"),
76
+ "simple_method": ("Schueler, T. (1987). The Simple Method: runoff coefficient Rv = 0.05 + 0.009 × percent impervious (as used in EPA and state stormwater guidance).", "https://www.epa.gov/npdes/national-menu-best-management-practices-bmps-stormwater"),
77
+ "egle_impaired": ("Michigan EGLE. Water Quality and Pollution Control in Michigan: Sections 303(d), 305(b) and 314 Integrated Report (impaired waters and TMDLs).", "https://www.michigan.gov/egle/about/organization/water-resources/glwarm/integrated-report"),
78
+ "mcl327": ("Michigan Natural Resources and Environmental Protection Act, Part 327 Great Lakes Preservation: MCL 324.32701 (large quantity withdrawal: over 100,000 gallons a day averaged over any 30 days; waters of the state include the Great Lakes) and MCL 324.32723 (withdrawal permits: new withdrawals over 2,000,000 gallons a day, or over 1,000,000 in Zone C).", "https://www.legislature.mi.gov/Laws/MCL?objectName=mcl-324-32723"),
79
+ "wgvu2026": ("Limbaugh, D. (2026, May 26). Unregulated demands: how rising data center water use avoids Great Lakes Compact scrutiny. WGVU News. (Compact regional review at 5 million gallons a day of consumptive use, averaged over 90 days; most data centers connect to public water systems.)", "https://www.wgvunews.org/news/2026-05-26/unregulated-demands-how-rising-data-center-water-use-avoids-great-lakes-compact-scrutiny"),
80
+ "eia_res": ("U.S. Energy Information Administration (2025). Electric Sales, Revenue, and Average Price, 2024 data, Table 5A: residential average monthly bill by state. Michigan: 4,516,367 residential customers, 618 kWh per month on average.", "https://www.eia.gov/electricity/sales_revenue_price/"),
81
+ "msft2024": ("Solomon, S. (2024, December 9). Sustainable by design: Next-generation datacenters consume zero water for cooling. Microsoft Cloud blog. (Replacing evaporative cooling with chillers raises PUE: \"a nominal increase in our annual energy usage.\")", "https://www.microsoft.com/en-us/microsoft-cloud/blog/?p=251"),
82
+ "glerl": ("NOAA Great Lakes Environmental Research Laboratory. Great Lakes Long Term Average Surface Water Temperature, Daily (GLSEA).", "https://apps.glerl.noaa.gov/erddap/info/glerlLTAvgTemp/index.html"),
83
+ "lsc_cornell": ("7x24 Exchange (2014, Spring). Lake and river source cooling, magazine p. 26: Cornell Lake Source Cooling draws Cayuga Lake water at about 39 °F (3.9 °C) from 250 ft and returns it at about 56 °F (13.3 °C); Toronto's Enwave deep lake system draws 4 °C water from 83 m.", "https://7x24exchange.org/spring14_/7x24_Spring_Magazine/files/assets/basic-html/page26.html"),
84
+ "cfr125_81": ("40 CFR 125.81. Cooling water intake structures, new facilities: applies to point sources with a design intake flow over 2 million gallons a day that use at least 25% of the water withdrawn for cooling.", "https://www.law.cornell.edu/cfr/text/40/125.81"),
85
+ "mi_part22": ("Michigan Administrative Code, Part 22 groundwater quality rules: R 323.2210 (sanitary sewage under 6,000 gallons a day to a septic system approved by the county, district or city health department needs no state permit), R 323.2211 (6,000 to 10,000 gallons a day: permit by rule, health department approval, metered flow reported yearly) and R 323.2216 (larger sanitary discharges under an EGLE groundwater discharge permit).", "https://www.law.cornell.edu/regulations/michigan/Mich-Admin-Code-R-323-2210"),
86
+ "egle_npdes": ("Michigan EGLE. National Pollutant Discharge Elimination System (NPDES) program: rules and regulations (Part 31, Water Resources Protection, NREPA). Discharges of wastewater to surface waters need a permit.", "https://www.michigan.gov/egle/about/Organization/Water-Resources/npdes/rules-and-regulations"),
87
+ "naturalearth": ("Natural Earth. 1:50m lakes and states/provinces (shorelines, generalized). Public domain.", "https://www.naturalearthdata.com/downloads/50m-physical-vectors/"),
88
+ "mytton2021": ("Mytton, D. (2021). Data centre water consumption. npj Clean Water 4: 11.", "https://doi.org/10.1038/s41545-021-00101-w"),
89
+ # receiving waters, runoff quality, salt, fuel and sewer plants
90
+ "attains2026": ("U.S. EPA ATTAINS, Michigan 2026 Integrated Report assessment units and parameters (EGLE's Clean Water Act 303(d) and 305(b) listings; category 4A = TMDL in place), via the ATTAINS_Assessment map service.", "https://gispub.epa.gov/arcgis/rest/services/OW/ATTAINS_Assessment/MapServer"),
91
+ "mdnr_trout": ("Michigan DNR. Trout and Salmon Regulations, Stream Main (designated trout streams, active segments). Designated trout streams are coldwater under R 323.1100(7).", "https://services3.arcgis.com/Jdnp1TjADvSDxMAX/ArcGIS/rest/services/DNRFisheriesTroutRegsOPENDATA/FeatureServer/0"),
92
+ "noaa_cag": ("NOAA NCEI. Climate at a Glance, county time series (nClimGrid): annual precipitation, mean of 1991–2020.", "https://www.ncei.noaa.gov/access/monitoring/climate-at-a-glance/county/time-series"),
93
+ "echo": ("U.S. EPA ECHO, Clean Water Act facility search, Michigan publicly owned treatment works: location, receiving water, combined sewer flag and the past calendar year's average reported flow.", "https://echo.epa.gov/"),
94
+ "cwns2022": ("U.S. EPA Clean Watersheds Needs Survey 2022, current design flow by facility (Hodson et al. 2026 HydroShare compilation, CC BY 4.0). CWNS has no actual flows.", "https://www.hydroshare.org/resource/3dab07fd628a4af2ba812f038b1af89f/"),
95
+ "usgs_marquette": ("Steuer, J., Selbig, W., Hornewer, N., & Prey, J. (1997). Sources of contamination in an urban basin in Marquette, Michigan, and an analysis of concentrations, loads, and data quality. USGS Water-Resources Investigations Report 97-4242, Table 4 (rooftops, parking lots).", "https://pubs.usgs.gov/wri/1997/4242/report.pdf"),
96
+ "mpca_emc": ("Minnesota Pollution Control Agency. Minnesota Stormwater Manual: event mean concentrations of total suspended solids and of total phosphorus in stormwater runoff, by land use.", "https://stormwater.pca.state.mn.us/node/6015"),
97
+ "nsqd": ("Pitt, R. (2011). The National Stormwater Quality Database, Version 3.1, Tables 2–4 (EPA Rain Zone 1, which includes Michigan).", "https://yosemite.epa.gov/oa/eab_web_docket.nsf/Attachments%20By%20ParentFilingId/0579A9DB19C5446F85257CBA00593372/$FILE/Attachment%202%20-%20Pitt%2C%20National%20Stormwater%20Quality%20Database%20Version%203.1.pdf"),
98
+ "stepl": ("Tetra Tech for U.S. EPA (2018). Spreadsheet Tool for Estimating Pollutant Loads (STEPL) 4.4 User's Guide: urban and cropland/forest runoff concentrations.", "https://19january2021snapshot.epa.gov/sites/static/files/2021-01/documents/steplguide404.pdf"),
99
+ "egle_ms4": ("Michigan EGLE MS4 permit post-construction standard (example: Charter Township of Lansing, NPDES MI0059459, effective July 1, 2025, Part I.A.f): treat the first inch or 90% of runoff-producing storms with 80% TSS removal or 80 mg/L; hold rate and volume to pre-development up to the 2-year, 24-hour storm.", "https://lansingtwpmi.gov/wp-content/uploads/2026/05/Lansing-Township-Appendix-A-Stormwater-Discharge-Permit-and-Application-4-2026.pdf"),
100
+ "kcdc_rules": ("Kent County Drain Commissioner (2024). Site Development Rules, Procedures and Design Standards for Stormwater Management, rev. Dec 27, 2024 (Table 3 TSS removal; coldwater detention limits; applicability).", "https://www.kentcountymi.gov/DocumentCenter/View/2402/Site-Development-Rules-Procedures-and-Design-Standards-for-Stormwater-Management-PDF"),
101
+ "bmpdb2020": ("Clary, J., et al. (2020). International Stormwater BMP Database: 2020 Summary Statistics. Water Research Foundation Project 4968, Tables 2-2, 4-3 and 4-6 (wet pond medians in and out).", "https://www.waterrf.org/system/files/resource/2020-11/DRPT-4968_0.pdf"),
102
+ "semcog_lid": ("SEMCOG (2008). Low Impact Development Manual for Michigan, Table 9.5 (pollutant removal by practice, after the Center for Watershed Protection database).", "https://www.swmpc.org/wp-content/uploads//2026/01/lidmanual_1.pdf"),
103
+ "epa_fact26": ("U.S. EPA (2005). Stormwater Phase II Final Rule Fact Sheet 2.6, Construction Site Runoff Control: sediment runoff from construction sites is typically 10 to 20 times that of agricultural lands.", "https://www.epa.gov/sites/default/files/2015-11/documents/fact2-6.pdf"),
104
+ "epa_mm8": ("U.S. EPA (2005). National Management Measures to Control Nonpoint Source Pollution from Urban Areas, Management Measure 8 (construction), p. 8-2 and Table 8.1: construction sites 7.2 to 500 tons per acre per year; Wisconsin 30 to 200.", "https://www.epa.gov/sites/default/files/2015-09/documents/urban_ch08.pdf"),
105
+ "part91": ("Michigan Part 91 Soil Erosion and Sedimentation Control rules, R 323.1704(1): permit for earth changes of 1 acre or more or within 500 feet of a lake or stream; construction storm water permit-by-rule, R 323.2190.", "https://www.law.cornell.edu/regulations/michigan/Mich-Admin-Code-R-323-2190"),
106
+ "mpca_salt": ("Minnesota Pollution Control Agency (2023). Deicer fact sheet and application rate guidance (p-tr1-20): 0.75 to 3.0 lb of dry salt per 1,000 sq ft per event, by pavement temperature.", "https://www.pca.state.mn.us/sites/default/files/p-tr1-20.pdf"),
107
+ "egle_rule57": ("Michigan EGLE. Rule 57 Water Quality Values: chloride final chronic value 150 mg/L, aquatic maximum value 320 mg/L.", "https://www.michigan.gov/egle/about/organization/water-resources/glwarm/rule-57-water-quality-values"),
108
+ "cat3516c": ("Caterpillar. 3516C diesel generator set, 60 Hz, 1,650–2,000 ekW, spec sheet LEHE1376-09 (2023): 133.6 gal/h at 2,000 ekW standby, 100% load.", "https://penncatenergy.com/wp-content/uploads/2025/12/3516C-60-Hz-1650-2000-ekW-Spec-Sheet.pdf"),
109
+ "uptime2014": ("Heslin, K. (2014, June 18). Fuel system design and reliability. Uptime Institute Journal: the Tier Standard requires at least 12 hours of on-site fuel at N load.", "https://journal.uptimeinstitute.com/?p=3233"),
110
+ "amazon_mo2026": ("Ramboll for Amazon Data Services (May 2026). Air permit application, STL100 data center, New Florence, Missouri, to Missouri DNR: 476 generators of 2.75 MW with belly tanks and bulk storage (about 3.3 million gallons by Northline's reading of the tank table).", "https://dnr.mo.gov/sites/dnr/files/vfc/2026/05/main/amazon-data-center-new-florence-complete-application-may-2026.pdf"),
111
+ "wyofile2025": ("Bleizeffer, D. (2025, May 12). WyoFile: Microsoft's Cheyenne data centers, 128 generators and tanks holding up to 1.8 million gallons of diesel (per Wyoming DEQ).", "https://wyofile.com/"),
112
+ "cfr112": ("40 CFR 112 (Spill Prevention, Control, and Countermeasure): applies above 1,320 gallons of aboveground oil storage. Michigan Part 5 rules (R 324.2001 et seq.) add a Pollution Incident Prevention Plan for oil and salt above threshold quantities.", "https://www.law.cornell.edu/cfr/text/40/112.1"),
113
+ "herb2007": ("Herb, W., Janke, B., Mohseni, O., & Stefan, H. (2007). Estimation of runoff temperatures and heat export from different land and water surfaces. St. Anthony Falls Laboratory, University of Minnesota: average runoff temperature 24.9 °C from concrete vs 21.5 °C from forest.", "http://purl.umn.edu/113694"),
114
+ "stefan2010": ("Stefan, H., & Herb, W. (2010). SAFL Project Report 541 for MPCA: wet ponds add heat; infiltration is highly effective at reducing thermal loads from runoff.", "https://conservancy.umn.edu/server/api/core/bitstreams/80619f96-ab42-49bd-81e1-5804f5f9c61b/content"),
115
+ "rule75": ("Michigan Water Quality Standards, R 323.1075 (temperature): coldwater streams may not be warmed more than 2 °F above natural; monthly maximum 68 °F in June, July and August.", "https://www.law.cornell.edu/regulations/michigan/Mich-Admin-Code-R-323-1075"),
116
+ "jea2024": ("JEA (2024). Best Management Practice and Guidance Manual for Cooling Towers, Table 1: phosphate inhibitors up to 20 mg/L as PO4 (about 6.5 mg/L as P), molybdate, azoles, biocides in blowdown.", "https://www.jea.com/uploadedFiles/jea.com/Business_Resources/Industrial_Pretreatment/Commercial_Best_Management_Practices/JEA%20Cooling%20Tower%20BMP%20v.2024-CURRENT.pdf"),
117
+ "rule60": ("Michigan Water Quality Standards, R 323.1060: default effluent limit of 1 mg/L total phosphorus (monthly average) for point sources.", "https://www.law.cornell.edu/regulations/michigan/Mich-Admin-Code-R-323-1060"),
118
+ "egle_dc_faq": ("Michigan EGLE. FAQ: Data centers and environmental review (data centers are not categorical industrial users but may be designated significant industrial users; only plants with approved pretreatment programs can issue discharge permits).", "https://www.michigan.gov/egle/faqs/environmental-assistance/data-centers"),
119
+ "nri2017": ("USDA NRCS (2020). 2017 National Resources Inventory Summary Report: sheet and rill erosion on U.S. cropland averaged 2.67 tons per acre per year in 2017.", "https://www.nrcs.usda.gov/sites/default/files/2022-10/2017NRISummary_Final.pdf"),
120
+ "wellogic": ("Michigan EGLE. Wellogic water well records: household wells by township, the aquifer each draws from (glacial deposits or bedrock), well depth and static water level.", "https://gisagoegle.state.mi.us/arcgis/rest/services/EGLE/DwOpenData/MapServer/3"),
121
+ "wwat": ("Michigan EGLE. Water Withdrawal Assessment Tool (Part 327): new or increased withdrawals over 100,000 gallons a day are screened for how much they would reduce nearby streams' summer flow and harm their fish.", "https://www.egle.state.mi.us/wwat/"),
122
+ "wleb_dap": ("MDARD, EGLE and DNR. Michigan's Domestic Action Plan for the Western Lake Erie Basin (2025 update): a 40% cut in phosphorus loads from 2008 levels.", "https://www.michigan.gov/mdard/environment/conservation/wleb"),
123
+ }
124
+
125
+ SHORT = {"lbnl2024": "LBNL 2024", "lei2022": "Lei & Masanet 2022", "lei2025": "Lei et al. 2025", "usgs2013": "USGS 2013", "femp_bmp10": "DOE FEMP",
126
+ "femp_dc": "DOE FEMP", "epa_wsaw": "EPA 2012", "stull2011": "Stull 2011", "msft2026": "Microsoft 2026", "icprb2025": "ICPRB 2025",
127
+ "icprb2026": "ICPRB 2026", "siddik2024": "Siddik et al. 2024", "macknick2012": "Macknick et al. 2012", "peer2019": "Peer et al. 2019",
128
+ "eia_mi": "EIA", "egle2025": "EGLE 2025", "mpsc_territory": "MPSC", "usgs_cu2007": "Shaffer & Runkle 2007", "energystar_humid": "ENERGY STAR", "nih_closedloop": "NIH 2023", "jlarc2024": "JLARC 2024", "mcl_205_54ee": "MCL 205.54ee", "lowell_firstlook": "Lowell's First Look", "gaines_sunnews": "Sun and News 2026", "saline_post": "The Saline Post", "wgvu_switch": "WGVU 2025", "eia861": "EIA-861", "gcsaa2013": "GCSAA 2013", "epa_watersense": "EPA WaterSense", "era5": "ERA5", "google2026": "Google 2026",
129
+ "han2026": "Han et al. 2026", "mytton2021": "Mytton 2021", "census_tiger": "Census", "epa_sa": "EPA service areas", "usgs_wbd": "USGS WBD",
130
+ "noaa_atlas14": "NOAA Atlas 14", "simple_method": "Simple Method", "egle_impaired": "EGLE", "mcl327": "MCL Part 327", "wgvu2026": "WGVU 2026", "naturalearth": "Natural Earth", "glerl": "NOAA GLERL", "eia_res": "EIA 2024", "msft2024": "Microsoft 2024", "lsc_cornell": "Cornell LSC", "cfr125_81": "40 CFR 125.81", "egle_npdes": "EGLE NPDES", "mi_part22": "Mich. Part 22 rules",
131
+ "attains2026": "EPA ATTAINS 2026", "mdnr_trout": "Michigan DNR trout streams", "noaa_cag": "NOAA 1991–2020", "echo": "EPA ECHO", "cwns2022": "EPA CWNS 2022",
132
+ "usgs_marquette": "USGS Marquette 1997", "mpca_emc": "MPCA", "nsqd": "NSQD", "stepl": "EPA STEPL", "egle_ms4": "EGLE MS4 standard", "kcdc_rules": "Kent County rules",
133
+ "bmpdb2020": "BMP Database 2020", "semcog_lid": "SEMCOG 2008", "epa_fact26": "EPA 2005", "epa_mm8": "EPA MM8", "part91": "Part 91", "mpca_salt": "MPCA salt guidance",
134
+ "egle_rule57": "EGLE Rule 57", "cat3516c": "Caterpillar", "uptime2014": "Uptime Institute", "amazon_mo2026": "Amazon MO permit", "wyofile2025": "WyoFile 2025",
135
+ "cfr112": "40 CFR 112", "herb2007": "Herb et al. 2007", "stefan2010": "Stefan & Herb 2010", "rule75": "R 323.1075", "jea2024": "JEA 2024", "rule60": "R 323.1060",
136
+ "egle_dc_faq": "EGLE data center FAQ", "wleb_dap": "Lake Erie action plan", "nri2017": "USDA NRI 2017", "wellogic": "EGLE Wellogic", "wwat": "EGLE WWAT"}
137
+
138
+ PARAM_SOURCES = {
139
+ "utilization": ["lbnl2024"], "hf_econ": ["femp_dc"], "hf_chiller": ["femp_dc"], "wb_econ": ["femp_dc"], "wb_chiller": ["femp_dc"],
140
+ "er_adj": ["usgs2013"], "coc": ["femp_bmp10"], "range_f": ["usgs2013"], "drift": ["epa_wsaw"], "fill_gal_per_mw": ["lowell_firstlook"], "topup": ["nih_closedloop"],
141
+ "staff_core": ["jlarc2024", "mcl_205_54ee"], "staff_per_mw": ["jlarc2024"], "staff_gpcd": [], "staff_used": ["usgs_cu2007"],
142
+ "assist_on": ["msft2026"], "assist_share": ["lei2025"], "hybrid_on": ["lei2025", "han2026"], "hybrid_full": ["lei2025"],
143
+ }
144
+
145
+ POWER = {
146
+ "consumed_l_per_kwh": {"michigan_mix": 0.9, "miso_excl_hydro": 1.57, "miso_incl_hydro": 3.84,
147
+ "range": [0.68, 1.31], "sources": ["eia_mi", "macknick2012", "egle2025", "peer2019", "siddik2024"]},
148
+ "withdrawn_l_per_kwh": {"value": 57.0, "returned_share_note": "about 99% returned to the Great Lakes and rivers, warmer", "sources": ["siddik2024", "egle2025"]},
149
+ }
150
+
151
+ # Electricity overhead by cooling design: LBNL 2024 Fig. 4.4, large-scale facilities, simulated annual PUE (figure-read medians and boxes,
152
+ # U.S. climates). Closed loop spans LBNL's two waterless large-scale systems: liquid-cooled IT with dry coolers (about 1.10) and dry coolers
153
+ # with an air-cooled chiller (about 1.20). Lake-source is not in LBNL; the cooling-tower range stands in for it [estimate].
154
+ PUE_DESIGN = {
155
+ "dry": {"lo": 1.07, "mid": 1.15, "hi": 1.24, "basis": "LBNL 2024: liquid-cooled IT with dry coolers (median about 1.10) to dry coolers with air-cooled chillers (about 1.20)"},
156
+ "assist": {"lo": 1.07, "mid": 1.15, "hi": 1.24, "basis": "LBNL 2024: dry coolers with adiabatic assist, liquid-cooled IT (about 1.10) or air-cooled chiller (about 1.20)"},
157
+ "hybrid": {"lo": 1.11, "mid": 1.14, "hi": 1.17, "basis": "LBNL 2024: airside economizer with adiabatic cooling and water-cooled chiller"},
158
+ "towers": {"lo": 1.09, "mid": 1.12, "hi": 1.16, "basis": "LBNL 2024: waterside economizer with water-cooled chiller, air- or liquid-cooled IT"},
159
+ "lake": {"lo": 1.09, "mid": 1.12, "hi": 1.16, "basis": "Not simulated by LBNL; the cooling-tower range stands in [estimate]"},
160
+ }
161
+
162
+ # Water consumed at power plants per MWh, by what supplies the campus (gallons per MWh; Macknick et al. 2012 medians and ranges unless noted).
163
+ POWER_SUPPLY = {
164
+ "mi_mix": {"label": "Michigan's current mix", "gal_mwh": 238, "lo": 180, "hi": 346, "sources": ["eia_mi", "macknick2012", "egle2025"],
165
+ "note": "0.9 L/kWh (0.68 to 1.31): EIA 2025 generation mix with Macknick factors, checked against EGLE's reported power-sector use"},
166
+ "new_gas": {"label": "Gas plants", "gal_mwh": 205, "lo": 130, "hi": 300, "sources": ["macknick2012"],
167
+ "note": "Natural gas combined cycle with cooling towers"},
168
+ # Michigan's own plants (model 1.2; before, cooling-tower plants): the large coal plants (Monroe, Belle River, Campbell) and Cook
169
+ # nuclear are cooled with lake or river water passing through once; Fermi 2 has cooling towers. Nuclear weights Cook's two-thirds of
170
+ # Michigan's nuclear generation once-through and Fermi's third with towers. Same factors as the Michigan mix above.
171
+ "coal": {"label": "Michigan's coal plants", "gal_mwh": 250, "lo": 100, "hi": 317, "sources": ["macknick2012", "eia_mi"],
172
+ "note": "Coal cooled with lake or river water passing through once, as Michigan's large coal plants are"},
173
+ "nuclear": {"label": "Michigan's nuclear plants", "gal_mwh": 403, "lo": 260, "hi": 548, "sources": ["macknick2012", "eia_mi"],
174
+ "note": "Cook (about two-thirds of Michigan's nuclear generation) cooled with Lake Michigan water passing through once; Fermi 2 with cooling towers"},
175
+ "renew": {"label": "Wind and solar", "gal_mwh": 1, "lo": 0, "hi": 5, "sources": ["macknick2012"], "note": "Wind 0; utility solar about 1 (0 to 5) for panel washing"},
176
+ "miso": {"label": "MISO grid average", "gal_mwh": 415, "lo": 415, "hi": 415, "sources": ["siddik2024"], "note": "1.57 L/kWh, MISO excluding hydropower"},
177
+ }
178
+
179
+ CONTEXT = {"household_gpd": {"value": 300, "note": "more than 300 gallons per day per family at home", "sources": ["epa_watersense"]}}
180
+
181
+ TRACKER_MUNI = {"gaines": ("Gaines charter township", "Kent"), "lowell": ("Lowell charter township", "Kent"), "dorr": ("Dorr township", "Allegan"),
182
+ "ypsi": ("Ypsilanti charter township", "Washtenaw"), "augusta": ("Augusta charter township", "Washtenaw"), "saline": ("Saline township", "Washtenaw"),
183
+ "vanburen": ("Van Buren charter township", "Wayne"), "lyon": ("Lyon charter township", "Oakland"), "southfield": ("Southfield city", "Oakland"),
184
+ "howell": ("Howell township", "Livingston")}
185
+
186
+ LON0, LON1, LAT0, LAT1 = -90.5, -82.3, 41.65, 48.35
187
+ KX, SC = math.cos(math.radians(45)), 60.0
188
+
189
+
190
+ def proj(lon, lat):
191
+ return round((lon - LON0) * KX * SC, 1), round((LAT1 - lat) * SC, 1)
192
+
193
+
194
+ def _dp(pts, tol):
195
+ if len(pts) < 3:
196
+ return pts
197
+ (x1, y1), (x2, y2) = pts[0], pts[-1]
198
+ dx, dy = x2 - x1, y2 - y1
199
+ L = math.hypot(dx, dy) or 1e-9
200
+ i, dmax = 0, 0
201
+ for k, (x, y) in enumerate(pts[1:-1], 1):
202
+ d = abs(dy * x - dx * y + x2 * y1 - y2 * x1) / L
203
+ if d > dmax:
204
+ i, dmax = k, d
205
+ if dmax > tol:
206
+ return _dp(pts[:i + 1], tol)[:-1] + _dp(pts[i:], tol)
207
+ return [pts[0], pts[-1]]
208
+
209
+
210
+ def _ring_path(ring, tol, minpts=4):
211
+ p = [proj(*c[:2]) for c in ring]
212
+ p = p[:-1] if p[0] == p[-1] else p
213
+ m = len(p) // 2
214
+ p = _dp(p[:m + 1], tol)[:-1] + _dp(p[m:] + [p[0]], tol)[:-1]
215
+ if len(p) < minpts:
216
+ return ""
217
+ return "M" + "L".join(f"{x:.1f},{y:.1f}" for x, y in p) + "Z"
218
+
219
+
220
+ def _geom_path(geom, tol, minpts=4):
221
+ polys = geom["coordinates"] if geom["type"] == "MultiPolygon" else [geom["coordinates"]]
222
+ return "".join(_ring_path(r, tol, minpts) for poly in polys for r in poly)
223
+
224
+
225
+ def nearest_region(lat, lon):
226
+ def d(r):
227
+ return (lat - r["lat"]) ** 2 + ((lon - r["lon"]) * math.cos(math.radians(lat))) ** 2
228
+ return min(REGIONS, key=lambda k: d(REGIONS[k]))
229
+
230
+
231
+ def build_map(counties):
232
+ adm = json.load(open(os.path.join(RAW_PLACES, "adm50.json")))
233
+ mi = [f["geometry"] for f in adm["features"] if f["properties"].get("name") == "Michigan" and f["properties"].get("admin") == "United States of America"][0]
234
+ lakes = json.load(open(os.path.join(RAW_PLACES, "lakes50.json")))
235
+ lk = [f["geometry"] for f in lakes["features"] if f["properties"].get("name") in ("Lake Superior", "Lake Michigan", "Lake Huron", "Lake Erie", "Lake Saint Clair")]
236
+ W, H = proj(LON1, LAT0)
237
+ out = {"viewBox": f"0 0 {W} {H}", "land": _geom_path(mi, 0.25), "lakes": "".join(_geom_path(g, 0.35, 6) for g in lk),
238
+ "counties": {}, "stations": {k: dict(zip(("x", "y"), proj(r["lon"], r["lat"]))) for k, r in REGIONS.items()},
239
+ "source": "County lines: U.S. Census TIGERweb; shoreline and lakes: Natural Earth 1:50m"}
240
+ for c in counties:
241
+ out["counties"][c["geoid"]] = {"name": c["name"], "region": nearest_region(c["lat"], c["lon"]),
242
+ "d": "".join(_ring_path(r, 0.2, 3) for r in c["rings"])}
243
+ return out
244
+
245
+
246
+ GREAT_LAKES = ("Lake Superior", "Lake Michigan", "Lake Huron", "Lake Erie", "Lake Saint Clair")
247
+ SHORE_KM = 5.0
248
+
249
+
250
+ def great_lake_near(bounds):
251
+ """Nearest Great Lake (or Lake St. Clair) within SHORE_KM of each municipal boundary (Natural Earth 1:50m shoreline; Census boundaries)."""
252
+ import numpy as np
253
+ lakes = json.load(open(os.path.join(RAW_PLACES, "lakes50.json")))
254
+ pts = {}
255
+ for f in lakes["features"]:
256
+ n = f["properties"].get("name")
257
+ if n in GREAT_LAKES:
258
+ polys = f["geometry"]["coordinates"] if f["geometry"]["type"] == "MultiPolygon" else [f["geometry"]["coordinates"]]
259
+ pts[n] = np.array([pt for poly in polys for ring in poly for pt in ring])
260
+ kx = 111.32 * math.cos(math.radians(44.5))
261
+ out = {}
262
+ for gid, rings in bounds.items():
263
+ b = np.array([pt for r in rings for pt in r])
264
+ x0, y0 = b.min(0) - 0.1
265
+ x1, y1 = b.max(0) + 0.1
266
+ best = None
267
+ for n, L in pts.items():
268
+ near = L[(L[:, 0] >= x0) & (L[:, 0] <= x1) & (L[:, 1] >= y0) & (L[:, 1] <= y1)]
269
+ if not len(near):
270
+ continue
271
+ d = np.sqrt((((b[:, None, 0] - near[None, :, 0]) * kx) ** 2 + ((b[:, None, 1] - near[None, :, 1]) * 110.95) ** 2)).min()
272
+ if d <= SHORE_KM and (best is None or d < best[1]):
273
+ best = (n.replace("Saint", "St."), round(float(d), 1))
274
+ if best:
275
+ out[gid] = {"lake": best[0], "km": best[1]}
276
+ return out
277
+
278
+
279
+ def build_places(counties, munis, enrich, cmap, shore=None):
280
+ shore = shore or {}
281
+ cname = {c["geoid"]: c["name"] for c in counties}
282
+ out = []
283
+ for m in munis:
284
+ e = enrich.get(m["geoid"], {})
285
+ x, y = proj(m["lon"], m["lat"])
286
+ ws = e.get("watershed", {})
287
+ out.append({"g": m["geoid"], "n": m["name"], "k": m["kind"], "c": cname.get(m["county_fips"], ""), "cf": m["county_fips"],
288
+ "r": cmap["counties"].get(m["county_fips"], {}).get("region") or nearest_region(m["lat"], m["lon"]), "x": x, "y": y,
289
+ "w": e.get("water", []), "h12": ws.get("huc12"), "h8": ws.get("huc8"), "gl": shore.get(m["geoid"])})
290
+ out.sort(key=lambda p: (p["n"].lower(), p["c"]))
291
+ return out
292
+
293
+
294
+ GOLF = r"golf|country club|\bC\.?C\.?\b|links"
295
+ USE_GROUPS = {"Irrigation": "Farm irrigation", "Community Water Supply": "Public water systems", "Public Water Supply": "Public water systems",
296
+ "Public water supply": "Public water systems", "Industrial": "Industry", "Industrial/Manufacturing": "Industry",
297
+ "Mining": "Mining and dewatering", "Dewatering": "Mining and dewatering", "Livestock": "Livestock", "Bottled Water": "Bottled water",
298
+ "Power Generation": "Power plants"}
299
+
300
+
301
+ def egle_use_file():
302
+ """The newest EGLE facility water-use file on disk (engine/data/raw/lit/egle_water_use_facility_YYYY.xlsx). EGLE's site refuses
303
+ scripted downloads, so each year's file is downloaded by hand; `python -m engine.update --list` says when a newer year is due."""
304
+ import glob
305
+ files = sorted(glob.glob(os.path.join(RAW, "egle_water_use_facility_[0-9][0-9][0-9][0-9].xlsx")))
306
+ if not files:
307
+ raise FileNotFoundError("No EGLE water-use file in " + RAW + "; see docs/data-updates.md")
308
+ return files[-1]
309
+
310
+
311
+ def county_uses():
312
+ """EGLE 2025 reported withdrawals by facility, grouped by county into plain categories (million gallons a day, averaged over the year).
313
+ Golf courses are irrigation facilities whose names mark them as golf or country clubs; anything not grouped is 'Other'."""
314
+ df = pd.read_excel(egle_use_file(), sheet_name="Sheet1")
315
+ df["County"] = df["County"].astype(str).str.strip().replace({"Saint Clair": "St. Clair", "St Clair": "St. Clair", "Saint Joseph": "St. Joseph", "St Joseph": "St. Joseph"})
316
+ # Entries far outside the range of their kind are left out and reported (see methods). Irrigation: the largest entry
317
+ # is ~9x the next-largest farm statewide, which reads as a reporting error rather than a real withdrawal.
318
+ odd = (df["Purpose"] == "Irrigation") & (df["Total Gallons"] > 2e10)
319
+ flags = {r["County"]: f"One {r['Purpose'].lower()} entry ({r['Facility']}) reports {r['Total Gallons'] / 1e9:,.1f} billion gallons for 2025, about {r['Total Gallons'] / 365 / 1e6:,.0f} million gallons a day. That is many times the largest similar entry elsewhere in Michigan, so it is left out here as a likely reporting error."
320
+ for _, r in df[odd].iterrows()}
321
+ df = df[~odd].copy()
322
+ golf = (df["Purpose"] == "Irrigation") & df["Facility"].astype(str).str.contains(GOLF, case=False, regex=True)
323
+ df["group"] = df["Purpose"].map(USE_GROUPS).fillna("Other")
324
+ df.loc[golf, "group"] = "Golf courses"
325
+ out, n_golf = {}, {}
326
+ for (c, g), v in df.groupby(["County", "group"])["Total Gallons"].sum().items():
327
+ if c and c != "nan":
328
+ out.setdefault(c, {})[g] = round(float(v) / 365 / 1e6, 3)
329
+ for c, n in df[golf & (df["Total Gallons"] > 0)].groupby("County").size().items():
330
+ n_golf[c] = int(n)
331
+ per = df.loc[golf & (df["Total Gallons"] > 0), "Total Gallons"] / 365
332
+ stats = {"golf_courses_reporting": int(len(per)), "golf_median_gpd": round(float(per.median()), -2),
333
+ "golf_p25_gpd": round(float(per.quantile(0.25)), -2), "golf_p75_gpd": round(float(per.quantile(0.75)), -2)}
334
+ stats["flags"] = flags
335
+ return out, n_golf, stats
336
+
337
+
338
+ GREAT_LAKE_BY_HUC4 = {"0401": "Lake Superior", "0402": "Lake Superior", "0418": "Lake Superior", "0403": "Lake Michigan", "0404": "Lake Michigan",
339
+ "0405": "Lake Michigan", "0406": "Lake Michigan", "0419": "Lake Michigan", "0407": "Lake Huron", "0408": "Lake Huron",
340
+ "0420": "Lake Huron", "0424": "Lake Huron", "0409": "Lake St. Clair and the Detroit River", "0410": "Lake Erie"}
341
+
342
+
343
+ WATER_SOURCES = {"attains2026", "mi_part22", "mdnr_trout", "noaa_cag", "echo", "cwns2022", "usgs_marquette", "mpca_emc", "nsqd", "stepl", "egle_ms4", "kcdc_rules",
344
+ "bmpdb2020", "semcog_lid", "epa_fact26", "epa_mm8", "part91", "mpca_salt", "egle_rule57", "cat3516c", "uptime2014", "amazon_mo2026",
345
+ "wyofile2025", "cfr112", "herb2007", "stefan2010", "rule75", "jea2024", "rule60", "egle_dc_faq", "wleb_dap", "nri2017", "simple_method",
346
+ "usgs_wbd", "epa_wsaw", "noaa_atlas14", "wellogic", "wwat", "mcl327"}
347
+
348
+
349
+ def build_waters():
350
+ """site/waterledger/mi/data/waters.json: receiving-water impairments by HUC12 (with downstream links), sewer plants,
351
+ county rainfall and the runoff water quality parameters. Public data only."""
352
+ from waterledger_data import waters as wi
353
+ from waterledger_models import runoff as rq
354
+ h12 = wi.by_huc12()
355
+ trout = wi.trout_by_huc12()
356
+ for k, v in h12.items():
357
+ v["gl"] = GREAT_LAKE_BY_HUC4.get(k[:4])
358
+ if k in trout:
359
+ v["tr"] = [trout[k]["n"], trout[k]["names"]]
360
+ plants = []
361
+ for p in wi.potws():
362
+ x, y = proj(p["lon"], p["lat"])
363
+ # ECHO's past-year average flow is kept only where it is plausible against the design flow (unit errors are common at lagoons)
364
+ if p["flow_mgd"] and not (p["design_mgd"] and 0.1 <= p["flow_mgd"] / p["design_mgd"] <= 1.3):
365
+ p["flow_mgd"] = None
366
+ plants.append({"id": p["id"], "n": p["name"], "city": p["city"].lower(), "x": x, "y": y, "major": p["major"], "cso": p["cso"],
367
+ "lagoon": p["lagoon"], "flow": p["flow_mgd"], "design": p["design_mgd"], "recv": (p["receiving"] or "").title(), "h12": p["huc12"]})
368
+ # household wells by place: [drift wells, drift depth ft, drift water level ft, rock wells, rock depth, rock water level] (EGLE Wellogic)
369
+ wells, HW = {}, wi.household_wells()
370
+ for p in json.load(open(OUT_PLACES))["places"]:
371
+ w = HW.get((p["c"], wi.place_stem(p["n"])))
372
+ if w:
373
+ d, r = w.get("drift", {}), w.get("rock", {})
374
+ wells[p["g"]] = [d.get("n", 0), d.get("depth"), d.get("swl"), r.get("n", 0), r.get("depth"), r.get("swl")]
375
+ data = {"generated": datetime.date.today().isoformat(), "cycle": 2026, "groups": wi.LABELS, "wells": wells, "h12": h12, "plants": plants, "rain_in": wi.county_precip(), "runoff": rq.PARAMS,
376
+ "sources": {k: {"citation": c, "url": u, "short": SHORT.get(k, k)} for k, (c, u) in SOURCES.items() if k in WATER_SOURCES}}
377
+ with open(OUT_WATERS, "w") as f:
378
+ json.dump(round_tree(data, 3), f, separators=(",", ":"))
379
+ print("wrote", OUT_WATERS, os.path.getsize(OUT_WATERS), "bytes;", sum(1 for v in h12.values() if "i" in v), "HUC12s with listings;", len(plants), "plants")
380
+
381
+
382
+ def build_power():
383
+ """site/waterledger/mi/data/power.json: the electric utilities serving each place (MPSC territories, with the share of the place each
384
+ covers) and each utility's load (EIA-861: yearly Michigan retail sales as an average MW, and summer and winter peaks where reported),
385
+ plus Michigan's total retail sales. Public data only."""
386
+ from waterledger_data import power as pw
387
+ T, L, year = pw.territories(), pw.utility_loads(), pw.eia_year()
388
+ idx = {q["name"]: i for i, q in enumerate(T)}
389
+ utils = []
390
+ for q in T:
391
+ e = L[pw.EIA_ID[q["name"]]]
392
+ utils.append({"n": q["name"], "type": q["type"], "cust": e["customers"], "avg_mw": round(e["mwh"] / 8760, 1),
393
+ "summer_mw": e["summer_mw"], "winter_mw": e["winter_mw"]})
394
+ bounds = json.load(open(os.path.join(RAW_PLACES, "boundaries.json")))
395
+ by_place = {}
396
+ for p in json.load(open(OUT_PLACES))["places"]:
397
+ rings = bounds.get(p["g"])
398
+ if rings:
399
+ by_place[p["g"]] = [[idx[n], sh] for sh, n in pw.place_utilities(rings, T)]
400
+ mi = pw.michigan_retail_mwh()
401
+ data = {"generated": datetime.date.today().isoformat(), "eia_year": year, "michigan": {"mwh": round(mi), "avg_mw": round(mi / 8760)},
402
+ "utilities": utils, "places": by_place,
403
+ "sources": {k: {"citation": c, "url": u, "short": SHORT.get(k, k)} for k, (c, u) in SOURCES.items() if k in ("mpsc_territory", "eia861")}}
404
+ with open(OUT_POWER, "w") as f:
405
+ json.dump(data, f, separators=(",", ":"))
406
+ print("wrote", OUT_POWER, os.path.getsize(OUT_POWER), "bytes;", len(utils), "utilities;", sum(1 for v in by_place.values() if v), "places with a utility")
407
+
408
+
409
+ MORATORIA_CSV = setting("mi_moratoria_csv") # the hand-kept list of Michigan moratoria (public sources)
410
+ OUT_MORATORIA = os.path.join(SITE, "data", "moratoria.json")
411
+
412
+
413
+ def build_moratoria():
414
+ """site/waterledger/mi/data/moratoria.json: Michigan data center moratoria from public sources (docs/research/moratoria-michigan.csv),
415
+ each placed on the map. Cities and townships match Water Ledger's places; villages use the Census point and take their water
416
+ facts from the township around them; counties sit at the county's center."""
417
+ import csv
418
+ from waterledger_data import places as pl
419
+ places = json.load(open(OUT_PLACES))["places"]
420
+ counties = {c["name"]: c for c in pl.counties()}
421
+ bounds = json.load(open(os.path.join(RAW_PLACES, "boundaries.json")))
422
+ by = {(p["c"], p["n"].lower()): p for p in places}
423
+
424
+ def villages():
425
+ path = os.path.join(RAW_PLACES, "villages_mi.json")
426
+ if not os.path.exists(path):
427
+ d = pl._get("https://tigerweb.geo.census.gov/arcgis/rest/services/TIGERweb/Places_CouSub_ConCity_SubMCD/MapServer/4/query",
428
+ {"where": "STATE='26' AND LSADC='47'", "outFields": "BASENAME,GEOID,INTPTLAT,INTPTLON", "returnGeometry": "false", "resultRecordCount": 2000, "f": "json"})
429
+ json.dump([f["attributes"] for f in d["features"]], open(path, "w"))
430
+ return json.load(open(path))
431
+ vill = villages()
432
+
433
+ def township_at(lon, lat):
434
+ for g, rings in bounds.items():
435
+ if pl._inside(lon, lat, rings):
436
+ return g
437
+ return None
438
+
439
+ out, missing = [], []
440
+ for r in csv.DictReader(open(MORATORIA_CSV)):
441
+ name, kind, county = r["name"].strip(), r["kind"].strip().lower(), r["county"].strip().replace(" County", "")
442
+ rec = {k: (r.get(k) or "").strip() for k in ("name", "kind", "county", "action", "adopted", "length_months", "expires", "status", "proposal",
443
+ "source_title", "source_publisher", "source_date", "source_url", "verified", "notes")}
444
+ rec["county"] = county
445
+ g = x = y = None
446
+ if kind in ("city", "township", "charter township"):
447
+ stem = re.sub(r"^(city|village|charter township|township) of\s+", "", name, flags=re.I)
448
+ stem = re.sub(r"\s+(charter township|township|city)$", "", stem, flags=re.I).strip().lower()
449
+ for k in ([f"{stem} {kind}"] + ([f"{stem} charter township", f"{stem} township"] if "township" in kind else [f"{stem} city"])):
450
+ if (county, k) in by:
451
+ p = by[(county, k)]; g, x, y = p["g"], p["x"], p["y"]
452
+ break
453
+ elif kind == "village":
454
+ stem = re.sub(r"^village of\s+", "", name, flags=re.I).replace(" Village", "").strip().lower()
455
+ hits = [v for v in vill if v["BASENAME"].lower() == stem]
456
+ for v in hits:
457
+ lat, lon = float(v["INTPTLAT"]), float(v["INTPTLON"])
458
+ c = counties.get(county)
459
+ if c and not pl._inside(lon, lat, c["rings"]):
460
+ continue
461
+ x, y = proj(lon, lat)
462
+ g = township_at(lon, lat)
463
+ rec["within"] = g
464
+ break
465
+ elif kind == "county" and county in counties:
466
+ x, y = proj(counties[county]["lon"], counties[county]["lat"])
467
+ if x is None:
468
+ missing.append(f"{name} ({kind}, {county})")
469
+ rec.update(g=g if kind != "village" else None, x=x, y=y)
470
+ if kind == "village" and rec.get("within"):
471
+ rec["g"] = rec["within"] # water facts come from the township around the village
472
+ out.append(rec)
473
+ out.sort(key=lambda r: (r["county"], r["name"]))
474
+ json.dump({"updated": datetime.date.today().isoformat(), "source": "Public sources listed on each entry; compiled by Water Ledger", "moratoria": out},
475
+ open(OUT_MORATORIA, "w"), separators=(",", ":"))
476
+ print("wrote", OUT_MORATORIA, len(out), "entries;", len(missing), "not placed:", missing[:20])
477
+
478
+
479
+ def round_tree(x, nd=5):
480
+ if isinstance(x, dict):
481
+ return {k: round_tree(v, nd) for k, v in x.items()}
482
+ if isinstance(x, float):
483
+ return round(x, nd)
484
+ return x
485
+
486
+
487
+ def main(n=200):
488
+ regions = {}
489
+ for rid, r in REGIONS.items():
490
+ cache = f"{r.get('cache', rid)}_1995_2024"
491
+ t, T, RH = hourly_weather(r["lat"], r["lon"], "1995-01-01", "2024-12-31", cache)
492
+ res = cw.run_location(t, T, RH, n=n, mw=100.0)
493
+ regions[rid] = {"name": r["name"], "station": r["station"], "lat": r["lat"], "lon": r["lon"], "per_100mw": res}
494
+ print("ran", rid)
495
+
496
+ staff = cw.staff_runs(n)
497
+ Dv = {"regions": regions, "staff": {"runs": staff}}
498
+ from waterledger.scenario import flow, band
499
+ s2 = lambda v: f"{(round(v) if v < 100 else round(v / 10 ** (math.floor(math.log10(v)) - 1)) * 10 ** (math.floor(math.log10(v)) - 1)):,.0f}"
500
+ gal = lambda b: f"about {s2(b['mid'] * 1e6)} gal/day (likely {s2(b['lo'] * 1e6)} to {s2(b['hi'] * 1e6)})"
501
+ util = regions["west"]["per_100mw"]["dry"]["utilization"]["p50"]
502
+ w300 = flow(Dv, "west", "dry", "withdrawn_avg", 300)
503
+ lkwh = w300["mid"] * 1e6 * 3.78541 / (300 * 1000 * util * 24)
504
+ hyb = lambda key: flow(Dv, "west", "hybrid", key, 300)["mid"] * 1e6 / 300
505
+ # Van Buren: 1 GW of DTE contract over three buildings, one open loop; computers = contract / top of each design's PUE range [A]
506
+ it_t, it_d = 1000 / 3 / PUE_DESIGN["towers"]["hi"], 2000 / 3 / PUE_DESIGN["dry"]["hi"]
507
+ st = cw.staff_q(staff, it_t + it_d)
508
+ vb = {k: band(regions["southeast"]["per_100mw"]["towers"][f"withdrawn_{k}"], it_t / 100)["mid"] + band(regions["southeast"]["per_100mw"]["dry"][f"withdrawn_{k}"], it_d / 100)["mid"]
509
+ + st["withdrawn"]["p50"] for k in ("avg", "peak_1in10")}
510
+ saline_it = 1400 / PUE_DESIGN["dry"]["hi"]
511
+ mx = lambda r: f"{r:.1f}".removesuffix(".0") if r < 10 else s2(r) # a multiple, as on the page (WQ.mult)
512
+ low, gb, sal, sw = (flow(Dv, "west", "dry", "withdrawn_avg", 300), flow(Dv, "west", "dry", "withdrawn_avg", 100),
513
+ flow(Dv, "southeast", "dry", "withdrawn_avg", saline_it), flow(Dv, "west", "dry", "withdrawn_avg", 320))
514
+ l500 = flow(Dv, "west", "dry", "withdrawn_avg", 500)
515
+ inside = lambda v, b: b["lo"] <= v <= b["hi"]
516
+ not_counted = "landscape watering, humidification for designs that cool with outside air, and other site uses this page does not count"
517
+ validation = [
518
+ {"check": "Cooling towers, water use per IT kWh (makeup basis)", "published": "LBNL 2024 large tower-based facilities: median ~2.0–2.2 L/kWh, range ~1.75–2.75 (U.S. climates)",
519
+ "ours": f"{regions['west']['per_100mw']['towers']['wue_makeup_l_per_kwh']['p10']:.2f}–{regions['west']['per_100mw']['towers']['wue_makeup_l_per_kwh']['p90']:.2f} L/kWh (West Michigan)",
520
+ "reading": "Lower than the U.S. median, as expected for a cool climate; within the published range.", "sources": ["lbnl2024"]},
521
+ {"check": "Hybrid / evaporative assist (consumed)", "published": "Lei et al. 2025, climate zone 5A: ~0.1 (efficient) to ~0.7 (inefficient) L/kWh; operators 0.1–0.3",
522
+ "ours": f"{regions['west']['per_100mw']['hybrid']['wue_l_per_kwh']['p10']:.2f}–{regions['west']['per_100mw']['hybrid']['wue_l_per_kwh']['p90']:.2f} L/kWh",
523
+ "reading": "At or just below the efficient end of the published range, as expected in a cold climate.", "sources": ["lei2025", "lbnl2024"]},
524
+ {"check": "Closed loop, water taken per IT kWh", "published": "~0–0.1 L/kWh for designs that use no water for cooling (staff use, and humidification where outside air is used)",
525
+ "ours": f"About {lkwh:.3f} L/kWh at 300 MW, West Michigan, staff water included",
526
+ "reading": "Inside the range, near the bottom: these designs recirculate indoor air, so the humidification behind the top of LBNL's range is not counted. A weak check on its own; the Michigan figures below say more.", "sources": ["lbnl2024"]},
527
+ {"check": "Observed utility data, Northern Virginia (per MW of facility power capacity)", "published": "Air-cooled ~150 gal/day/MW; mixed (~60–70% water-cooled) 800–1,006 average, 2,435–3,060 peak day; evaporative peak up to 8,500",
528
+ "ours": f"Per MW of IT capacity at 300 MW, West Michigan: closed loop ~{w300['mid'] * 1e6 / 300:,.0f}; hybrid ~{hyb('withdrawn_avg'):,.0f} average and ~{hyb('withdrawn_peak_1in10'):,.0f} on a 1-in-10 peak day",
529
+ "reading": f"Closed loop is far below the metered air-cooled figure: the Virginia sites are older, many cool with outside air, and metered totals include {not_counted}; ICPRB's per-MW basis is facility capacity, not computing capacity. Hybrid is the same order of magnitude as the mixed sites.", "sources": ["icprb2025", "icprb2026"]},
530
+ {"check": "Google data center fleet, 2025", "published": "~0.94 L per kWh of facility electricity (10,523 million gallons over 42.4 TWh, all climates and designs)",
531
+ "ours": "Between our hybrid and cooling-tower estimates, as expected for a mixed global fleet", "reading": "Consistent.", "sources": ["google2026"]},
532
+ {"check": "Van Buren Township (Google; 1 GW DTE contract, three buildings, one open loop)", "published": "Developer estimate 2–3.6 MGD (township FAQ, Jan 2026)",
533
+ "ours": f"About {vb['avg']:.1f} MGD average, {vb['peak_1in10']:.1f} MGD 1-in-10 peak (one building's third of the contract with cooling towers, about {it_t:,.0f} MW of computers; two-thirds closed loop, about {it_d:,.0f} MW; Southeast Michigan)",
534
+ "reading": "Inside the developer's range." if 2.0 <= vb["avg"] <= 3.6 else "Outside the developer's range.", "sources": []},
535
+ {"check": "Lowell Charter Township (Microsoft), closed loop", "published": "About 6,000 gal/day (2.2 million gallons a year) excluding cooling, a Microsoft projection reported locally; size not published; primary document not yet located",
536
+ "ours": f"{gal(w300)} at 300 MW, the starting size; {gal(flow(Dv, 'west', 'dry', 'withdrawn_avg', 500))} at 500 MW",
537
+ "reading": f"The reported figure is about {mx(0.006027 / low['mid'])} times ours at 300 MW, " + ("inside" if inside(0.006027, low) else "outside") + " our likely range there, and " + ("inside" if inside(0.006027, l500) else "outside") + " it at 500 MW. The campus's size is not published, and ours counts staff water and loop top-ups only.", "sources": ["lowell_firstlook"]},
538
+ {"check": "Gaines Township (Microsoft), per building", "published": "About 2,000 gal/day per building (Microsoft at the April 15, 2026 hearing, as reported); building size and count not published",
539
+ "ours": f"{gal(flow(Dv, 'west', 'dry', 'withdrawn_avg', 100))} for one 100 MW building",
540
+ "reading": f"The reported figure is about {mx(0.002 / gb['mid'])} times our middle estimate, " + ("inside" if inside(0.002, gb) else "outside") + " our likely range.", "sources": ["gaines_sunnews"]},
541
+ {"check": "Saline Township (Related Digital for Oracle and OpenAI; 1.4 GW DTE contract), closed loop", "published": "About 20,000 gal/day at the start of operations, a developer estimate as reported locally; primary document not yet located",
542
+ "ours": f"{gal(flow(Dv, 'southeast', 'dry', 'withdrawn_avg', saline_it))} at full build-out, about {saline_it:,.0f} MW of computers",
543
+ "reading": f"The reported figure is about {mx(0.02 / sal['mid'])} times ours and " + ("inside" if inside(0.02, sal) else "above") + " our likely range, although it is for the start of operations and ours is for the full campus. It may include uses this page does not count, such as landscape watering, process water or a phased first fill; the primary document has not been found.", "sources": ["saline_post"]},
544
+ {"check": "Switch data center, Gaines Township (operating since 2017; metered, not projected)", "published": "About 10 million gallons in 2024, about 27,000 gal/day, as a township official reported; design 320 MW; actual load and cooling design not public",
545
+ "ours": f"{gal(flow(Dv, 'west', 'dry', 'withdrawn_avg', 320))} for a closed loop at 320 MW and 60–90% load",
546
+ "reading": f"Metered use is about {mx(0.0274 / sw['mid'])} times ours. Switch's cooling design and actual load are not public, and a metered total includes {not_counted}. Metered use at an operating site can run well above an applicant's projection.", "sources": ["wgvu_switch"]},
547
+ ]
548
+ print("VALIDATION"); [print(" ", v["check"], "|", v["ours"]) for v in validation]
549
+
550
+ cnt = pl.counties()
551
+ munis = pl.municipalities()
552
+ bounds = pl.boundaries(cnt)
553
+ enr = pl.enrich(munis, bounds)
554
+ cmap = build_map(cnt)
555
+ places = build_places(cnt, munis, enr, cmap, great_lake_near(bounds))
556
+ storms = pl.county_design_storms(cnt)
557
+ tracker_muni = {}
558
+ for tid, (name, county) in TRACKER_MUNI.items():
559
+ hit = [p["g"] for p in places if p["n"].lower() == name.lower() and p["c"] == county]
560
+ tracker_muni[tid] = hit[0] if hit else None
561
+ with open(OUT_PLACES, "w") as f:
562
+ json.dump({"source": "Municipalities: U.S. Census TIGERweb county subdivisions; water systems: EPA Community Water System Service Area Boundaries (reported or modeled); watersheds: USGS Watershed Boundary Dataset", "places": places}, f, separators=(",", ":"))
563
+ with open(OUT_MAP, "w") as f:
564
+ json.dump(cmap, f, separators=(",", ":"))
565
+ print("places", len(places), os.path.getsize(OUT_PLACES), "map", os.path.getsize(OUT_MAP), "tracker", tracker_muni)
566
+
567
+ uses, n_golf, golf_stats = county_uses()
568
+ lake_t = pd.read_csv(os.path.join(RAW, "glerl_lt_avg_temp.csv"), skiprows=[1])
569
+ lake_src = {}
570
+ for name, code in (("Lake Michigan", "m"), ("Lake Superior", "s"), ("Lake Huron", "h"), ("Lake Erie", "e"), ("Lake St. Clair", "e")):
571
+ t = lake_t[lake_t["lake"] == code].sort_values("dayOfYear")["temperature"].values
572
+ lake_src[name] = lc.run_lake(t, n=n)
573
+ lake_src["note"] = "Lake St. Clair is not in the GLERL series; Lake Erie temperatures stand in for it."
574
+ lake_src["ranges"] = {k: list(v) for k, v in lc.RANGES.items()}
575
+ data = {
576
+ "model_version": MODEL_VERSION,
577
+ "generated": datetime.date.today().isoformat(),
578
+ "units": "MGD = million gallons per day; per_100mw values are per 100 MW of IT capacity at the sampled utilization",
579
+ "percentiles": "p10/p50/p90 over a Monte Carlo of the parameter ranges; central = all parameters at mid-range",
580
+ "designs": cw.LABELS,
581
+ "parameters": {k: {"range": list(v), "sources": PARAM_SOURCES.get(k, [])}
582
+ for k, v in list(cw.RANGES.items()) + [("topup", cw.TOPUP)] + [("staff_" + k, v) for k, v in cw.STAFF.items()]},
583
+ "regions": regions,
584
+ "staff": {"runs": staff, "note": "Staff water per run: [core people, people per MW of IT capacity, gallons per person a day, share used up]; "
585
+ "added to the per_100mw results at the campus's size (scenario.flow, WQ.flow)"},
586
+ "power": POWER,
587
+ "pue_design": PUE_DESIGN,
588
+ "power_supply": POWER_SUPPLY,
589
+ "home_kwh_month": 618.2,
590
+ "context": CONTEXT,
591
+ "county_uses_mgd": uses,
592
+ "lake_source": lake_src,
593
+ "county_golf_courses": n_golf,
594
+ "golf": golf_stats,
595
+ "tracker_muni": tracker_muni,
596
+ "county_storms_in": storms,
597
+ "region_meta": {k: {"name": r["name"], "station": r["station"]} for k, r in REGIONS.items()},
598
+ "validation": validation,
599
+ "sources": {k: {"citation": c, "url": u, "short": SHORT.get(k, k)} for k, (c, u) in SOURCES.items()},
600
+ }
601
+ os.makedirs(os.path.dirname(OUT), exist_ok=True)
602
+ with open(OUT, "w") as f:
603
+ json.dump(round_tree(data), f, separators=(",", ":"))
604
+ print("wrote", OUT, os.path.getsize(OUT), "bytes")
605
+
606
+
607
+ if __name__ == "__main__":
608
+ import sys
609
+ {"waters": build_waters, "moratoria": build_moratoria, "power": build_power}.get((sys.argv[1:] or [""])[0], main)()