waterledger-models 1.2.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.
- waterledger_models-1.2.0/.gitignore +5 -0
- waterledger_models-1.2.0/LICENSE +28 -0
- waterledger_models-1.2.0/PKG-INFO +28 -0
- waterledger_models-1.2.0/README.md +11 -0
- waterledger_models-1.2.0/pyproject.toml +26 -0
- waterledger_models-1.2.0/src/waterledger_models/__init__.py +13 -0
- waterledger_models-1.2.0/src/waterledger_models/datacenter/__init__.py +4 -0
- waterledger_models-1.2.0/src/waterledger_models/datacenter/cooling.py +213 -0
- waterledger_models-1.2.0/src/waterledger_models/datacenter/lake.py +63 -0
- waterledger_models-1.2.0/src/waterledger_models/runoff.py +134 -0
|
@@ -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,28 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: waterledger-models
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Water Ledger's models: weather-driven data center cooling water (towers, hybrid, closed loop, lake-source) with Monte Carlo uncertainty, staff water, and site runoff quality
|
|
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: numpy>=1.24
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# waterledger-models
|
|
19
|
+
|
|
20
|
+
The models behind [Water Ledger](https://waterledger.org): the water large new users take, use up and send back.
|
|
21
|
+
Light dependencies (numpy only), so other modeling frameworks can use them directly.
|
|
22
|
+
|
|
23
|
+
- `waterledger_models.datacenter.cooling`: weather-driven cooling water, hour by hour over decades of weather, for cooling
|
|
24
|
+
towers, hybrid, and closed loop with or without evaporative assist; Monte Carlo over the published parameter ranges; staff water.
|
|
25
|
+
- `waterledger_models.datacenter.lake`: lake-source (once-through) cooling.
|
|
26
|
+
- `waterledger_models.runoff`: runoff, sediment, nutrients, road salt and fuel from a developed site.
|
|
27
|
+
|
|
28
|
+
`MODEL_VERSION` is Water Ledger's model version (currently 1.2); the package version follows it.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# waterledger-models
|
|
2
|
+
|
|
3
|
+
The models behind [Water Ledger](https://waterledger.org): the water large new users take, use up and send back.
|
|
4
|
+
Light dependencies (numpy only), so other modeling frameworks can use them directly.
|
|
5
|
+
|
|
6
|
+
- `waterledger_models.datacenter.cooling`: weather-driven cooling water, hour by hour over decades of weather, for cooling
|
|
7
|
+
towers, hybrid, and closed loop with or without evaporative assist; Monte Carlo over the published parameter ranges; staff water.
|
|
8
|
+
- `waterledger_models.datacenter.lake`: lake-source (once-through) cooling.
|
|
9
|
+
- `waterledger_models.runoff`: runoff, sediment, nutrients, road salt and fuel from a developed site.
|
|
10
|
+
|
|
11
|
+
`MODEL_VERSION` is Water Ledger's model version (currently 1.2); the package version follows it.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "waterledger-models"
|
|
3
|
+
version = "1.2.0"
|
|
4
|
+
description = "Water Ledger's models: weather-driven data center cooling water (towers, hybrid, closed loop, lake-source) with Monte Carlo uncertainty, staff water, and site runoff quality"
|
|
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"]
|
|
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_models"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Water Ledger models: the water large new users take, use up and send back, starting with data centers, and runoff quality.
|
|
2
|
+
|
|
3
|
+
datacenter.cooling weather-driven cooling water by design, Monte Carlo over the published parameter ranges, staff water
|
|
4
|
+
datacenter.lake lake-source (once-through) cooling
|
|
5
|
+
runoff runoff, sediment, nutrients, salt and fuel from a developed site
|
|
6
|
+
|
|
7
|
+
MODEL_VERSION is Water Ledger's model version; every published estimate names the version that produced it.
|
|
8
|
+
"""
|
|
9
|
+
from waterledger_models import datacenter, runoff
|
|
10
|
+
|
|
11
|
+
__all__ = ["datacenter", "runoff", "MODEL_VERSION"]
|
|
12
|
+
MODEL_VERSION = "1.2"
|
|
13
|
+
__version__ = "1.2.0"
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Cooling water model v2 for Water Ledger: weather-driven, literature-aligned, with uncertainty.
|
|
2
|
+
|
|
3
|
+
Hour by hour over 30 years of weather:
|
|
4
|
+
heat rejected (BTU) = IT energy × heat factor, where the heat factor is seasonal: lower when a
|
|
5
|
+
waterside economizer carries the load (cool wet-bulb), higher when chillers run (compressor heat
|
|
6
|
+
can add up to ~20% to tower load; DOE FEMP).
|
|
7
|
+
evaporation = heat × evaporative ratio ÷ latent heat, with the evaporative ratio from Rutberg et al. 2011
|
|
8
|
+
(the dry-bulb relation USGS SIR 2013-5188 uses for cooling towers).
|
|
9
|
+
drift = a small fraction of circulating flow (EPA WaterSense at Work §6.3: 0.05–0.2%, <0.005% with
|
|
10
|
+
modern eliminators); circulating flow from heat ÷ (500 × range °F).
|
|
11
|
+
blowdown = evaporation ÷ (cycles of concentration − 1) (DOE FEMP BMP #10 water balance).
|
|
12
|
+
Designs differ in when they evaporate:
|
|
13
|
+
dry air-cooled / dry closed loop, designed to use no water for cooling: loop top-ups only, plus the staff water every design has
|
|
14
|
+
assist closed loop with evaporative assist only on the hottest hours (dry-bulb setpoint; Microsoft
|
|
15
|
+
publishes 85 °F / 29.4 °C for its direct-air designs)
|
|
16
|
+
hybrid dry coolers most of the year, evaporative in warm weather (ramp between two dry-bulb setpoints)
|
|
17
|
+
towers open cooling towers, evaporative all year (upper bound for typical practice)
|
|
18
|
+
Outputs per run: withdrawn (makeup), consumed (evaporation + drift + loop top-up), returned to sewer (blowdown).
|
|
19
|
+
Monte Carlo over parameter ranges gives percentiles, per 100 MW of IT capacity.
|
|
20
|
+
|
|
21
|
+
Staff water (restrooms, kitchens, cleaning) is not per kWh: it follows the people on site, which grow slowly with size, so it
|
|
22
|
+
is a separate term, staff_runs() and staff_q(), added to the per-100 MW results at the campus's size (model 1.2). No humidification
|
|
23
|
+
is counted: the designs modeled cool with liquid loops, chillers or towers and recirculate indoor air, so outside air comes in only
|
|
24
|
+
for ventilation [estimate]; a direct outside-air design would need some in Michigan's dry winters and is not modeled.
|
|
25
|
+
|
|
26
|
+
Parameter ranges and their sources are in RANGES and documented on the Water Ledger methods page
|
|
27
|
+
(docs/research/water-ledger-methods-alignment.md).
|
|
28
|
+
"""
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
KWH_BTU = 3412.14
|
|
32
|
+
HFG = 1050.0 # latent heat near tower temperatures, BTU/lb
|
|
33
|
+
LB_GAL = 8.34
|
|
34
|
+
DESIGNS = ["dry", "assist", "hybrid", "towers"]
|
|
35
|
+
LABELS = {
|
|
36
|
+
"dry": "Closed loop (designed to use no water for cooling)",
|
|
37
|
+
"assist": "Closed loop with evaporative assist on the hottest days",
|
|
38
|
+
"hybrid": "Hybrid: dry most of the year, evaporative in warm weather",
|
|
39
|
+
"towers": "Cooling towers (evaporative all year)",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# (low, high) uniform ranges sampled in the Monte Carlo; central values are the midpoints.
|
|
43
|
+
RANGES = {
|
|
44
|
+
"utilization": (0.60, 0.90), # average IT load ÷ IT capacity [estimate]
|
|
45
|
+
"hf_econ": (1.03, 1.08), # heat factor with economizer cooling (pumps, fans, losses) [estimate]
|
|
46
|
+
"hf_chiller": (1.15, 1.25), # heat factor with chillers running; compressor heat up to ~20% (DOE FEMP)
|
|
47
|
+
"wb_econ": (8.0, 12.0), # wet-bulb (°C) below which the economizer carries the load [estimate]
|
|
48
|
+
"wb_chiller": (16.0, 20.0), # wet-bulb (°C) above which chillers carry the load [estimate]
|
|
49
|
+
"er_adj": (-0.05, 0.05), # uncertainty on the Rutberg evaporative ratio (USGS ±5–15% at baseload)
|
|
50
|
+
"coc": (3.0, 7.0), # cycles of concentration (DOE FEMP: 2–4 typical, 6+ possible)
|
|
51
|
+
"range_f": (8.0, 15.0), # tower range, °F (USGS SIR 2013-5188 tower suite: 10–25)
|
|
52
|
+
"drift": (0.00005, 0.0005), # drift fraction of circulating flow (EPA: <0.005% modern to 0.05%)
|
|
53
|
+
"fill_gal_per_mw": (2000.0, 6000.0), # closed loop volume, gal per MW of IT capacity [estimate; Lowell reported a 1.2 million gallon fill]
|
|
54
|
+
"assist_on": (27.5, 31.0), # dry-bulb °C where assist starts (Microsoft 85 °F = 29.4 °C)
|
|
55
|
+
"assist_share": (0.10, 0.30), # assist water as a share of the full evaporative rate [estimate]
|
|
56
|
+
"hybrid_on": (21.0, 27.0), # dry-bulb °C where hybrid starts evaporating [estimate; cold-climate variant]
|
|
57
|
+
"hybrid_full": (30.0, 33.0), # dry-bulb °C where hybrid is fully evaporative [estimate]
|
|
58
|
+
}
|
|
59
|
+
# Share of the loop volume lost to small leaks and topped up each year (NIH 2023: closed loops typically lose under 5% a year), all of
|
|
60
|
+
# it counted as lost. Sampled on its own random stream (where model 1.1 drew its base-use share), so the other draws are unchanged.
|
|
61
|
+
TOPUP = (0.01, 0.05)
|
|
62
|
+
|
|
63
|
+
# Staff water [estimates]. People on site on a typical day (employees, security, contractors) = a fixed core plus a little per MW of
|
|
64
|
+
# IT capacity: Michigan's sales tax exemption needs at least 30 new jobs, and hyperscale campuses employ tens to a few hundred people.
|
|
65
|
+
# Gallons per person per day covers restrooms, kitchens and cleaning (office buildings use about 10 to 20; more with a cafeteria).
|
|
66
|
+
# About 10% of it is used up and the rest goes down the drain (USGS Great Lakes consumptive-use coefficients for commercial use).
|
|
67
|
+
STAFF = {"core": (20.0, 60.0), "per_mw": (0.1, 0.5), "gpcd": (15.0, 30.0), "used": (0.05, 0.15)}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def wet_bulb(t_c, rh):
|
|
71
|
+
"""Stull (2011) wet-bulb approximation, °C, valid for RH 5–99% and typical temperatures."""
|
|
72
|
+
rh = np.clip(rh, 5, 99)
|
|
73
|
+
return (t_c * np.arctan(0.151977 * np.sqrt(rh + 8.313659)) + np.arctan(t_c + rh) - np.arctan(rh - 1.676331)
|
|
74
|
+
+ 0.00391838 * rh ** 1.5 * np.arctan(0.023101 * rh) - 4.686035)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def evaporative_ratio(t_c, adj=0.0):
|
|
78
|
+
"""Rutberg et al. 2011 dry-bulb relation (as used in USGS SIR 2013-5188): share of heat rejected by evaporation."""
|
|
79
|
+
er = 1 - (-0.000279 * t_c ** 3 + 0.00109 * t_c ** 2 - 0.345 * t_c + 26.7) / 100
|
|
80
|
+
return np.clip(er + adj, 0.55, 1.0)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _ramp(x, lo, hi):
|
|
84
|
+
return np.clip((x - lo) / (hi - lo), 0.0, 1.0)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def hourly(tdb, twb, design, p):
|
|
88
|
+
"""Gallons per IT kWh, by hour: (withdrawn, consumed, returned)."""
|
|
89
|
+
hf = p["hf_econ"] + (p["hf_chiller"] - p["hf_econ"]) * _ramp(twb, p["wb_econ"], p["wb_chiller"])
|
|
90
|
+
btu = KWH_BTU * hf
|
|
91
|
+
evap_full = btu * evaporative_ratio(tdb, p["er_adj"]) / HFG / LB_GAL
|
|
92
|
+
circ = btu / (LB_GAL * p["range_f"]) # gal circulated per kWh (BTU / (8.34 × range))
|
|
93
|
+
if design == "towers":
|
|
94
|
+
w = np.ones_like(tdb)
|
|
95
|
+
elif design == "hybrid":
|
|
96
|
+
w = _ramp(tdb, p["hybrid_on"], p["hybrid_full"])
|
|
97
|
+
elif design == "assist":
|
|
98
|
+
w = _ramp(tdb, p["assist_on"], p["assist_on"] + 4.0) * p["assist_share"]
|
|
99
|
+
else:
|
|
100
|
+
w = np.zeros_like(tdb)
|
|
101
|
+
evap = evap_full * w
|
|
102
|
+
drift = circ * p["drift"] * (w > 0)
|
|
103
|
+
blow = evap / (p["coc"] - 1.0)
|
|
104
|
+
topup = np.full_like(tdb, p["fill_gal_per_mw"] * p["topup"] / 365.0 / (24 * 1000 * p["utilization"])) # gal/day per MW, as gal per IT kWh
|
|
105
|
+
withdrawn = evap + drift + blow + topup
|
|
106
|
+
consumed = evap + drift + topup
|
|
107
|
+
returned = blow
|
|
108
|
+
return withdrawn, consumed, returned
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def sample(rng, n):
|
|
112
|
+
runs = [{k: rng.uniform(*v) for k, v in RANGES.items()} for _ in range(n)]
|
|
113
|
+
extra = np.random.default_rng(11)
|
|
114
|
+
for r in runs:
|
|
115
|
+
r["topup"] = extra.uniform(*TOPUP)
|
|
116
|
+
return runs
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def central():
|
|
120
|
+
return {**{k: (a + b) / 2 for k, (a, b) in RANGES.items()}, "topup": sum(TOPUP) / 2}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def staff_runs(n=200, seed=13):
|
|
124
|
+
"""Staff water parameters per run, [core people, people per MW, gal per person a day, share used up], on their own stream."""
|
|
125
|
+
rng = np.random.default_rng(seed)
|
|
126
|
+
return [[float(rng.uniform(*STAFF[k])) for k in ("core", "per_mw", "gpcd", "used")] for _ in range(n)]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def staff_q(runs, mw):
|
|
130
|
+
"""Staff water at `mw` MW of IT capacity, MGD: {"withdrawn"|"consumed"|"returned": {"p10","p50","p90"}}. Same as WQ.staff on the page."""
|
|
131
|
+
g = np.array([(c + k * mw) * gpcd / 1e6 for c, k, gpcd, u in runs])
|
|
132
|
+
u = np.array([r[3] for r in runs])
|
|
133
|
+
q = lambda v: {"p10": float(np.quantile(v, 0.1)), "p50": float(np.quantile(v, 0.5)), "p90": float(np.quantile(v, 0.9))}
|
|
134
|
+
return {"withdrawn": q(g), "consumed": q(g * u), "returned": q(g * (1 - u))}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def daily_stats(day_index, ndays, years, gal_per_kwh_hour, kw, months=None):
|
|
138
|
+
"""From hourly gal/kWh and IT kW, compute daily MGD and summary statistics: average day, 1-in-10-year peak day (90th percentile
|
|
139
|
+
of annual maxima), record day, average year, and with `months` (each day's YYYY-MM) the highest month's average day and the
|
|
140
|
+
highest calendar year."""
|
|
141
|
+
daily = np.bincount(day_index, weights=gal_per_kwh_hour * kw, minlength=ndays) / 1e6
|
|
142
|
+
ann_max = np.array([daily[years == y].max() for y in np.unique(years)])
|
|
143
|
+
out = {"avg": float(daily.mean()), "peak_1in10": float(np.quantile(ann_max, 0.9)), "record": float(daily.max()),
|
|
144
|
+
"annual_mg": float(daily.mean() * 365.25)}
|
|
145
|
+
if months is not None:
|
|
146
|
+
out["max_month"] = float(max(daily[months == m].mean() for m in np.unique(months)))
|
|
147
|
+
out["worst_year_mg"] = float(max(daily[years == y].sum() for y in np.unique(years)))
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def summarize(days, mgd):
|
|
152
|
+
"""Statistics of one daily series (MGD by ISO date), for figures and single-run reports: average day, highest month, 1-in-10-year
|
|
153
|
+
peak day, record day and its date, average and highest year."""
|
|
154
|
+
days, v = np.array(days), np.array(mgd)
|
|
155
|
+
years, months = np.array([d[:4] for d in days]), np.array([d[:7] for d in days])
|
|
156
|
+
ann = [v[years == y] for y in np.unique(years)]
|
|
157
|
+
return {"avg": float(v.mean()), "max_month": float(max(v[months == m].mean() for m in np.unique(months))),
|
|
158
|
+
"peak_1in10": float(np.quantile([a.max() for a in ann], 0.9)), "record": float(v.max()), "record_day": str(days[v.argmax()]),
|
|
159
|
+
"annual_mg": float(v.mean() * 365.25), "worst_year_mg": float(max(a.sum() for a in ann))}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def daily(times, temps, rhs, design, mw, p=None):
|
|
163
|
+
"""One run's daily series at `mw` MW of IT capacity (central parameters by default), staff water at its central values included:
|
|
164
|
+
(days, withdrawn MGD, returned MGD). For seasonal figures and record days; the pages and reviews use the Monte Carlo percentiles."""
|
|
165
|
+
p = p or central()
|
|
166
|
+
tdb, rh = np.array(temps, dtype=float), np.array(rhs, dtype=float)
|
|
167
|
+
w, c, r = hourly(tdb, wet_bulb(tdb, rh), design, p)
|
|
168
|
+
days = np.array([t[:10] for t in times])
|
|
169
|
+
uniq, idx = np.unique(days, return_inverse=True)
|
|
170
|
+
kw = mw * 1000 * p["utilization"]
|
|
171
|
+
core, per_mw, gpcd, used = (sum(STAFF[k]) / 2 for k in ("core", "per_mw", "gpcd", "used"))
|
|
172
|
+
g = (core + per_mw * mw) * gpcd / 1e6
|
|
173
|
+
wd = np.bincount(idx, weights=w * kw, minlength=len(uniq)) / 1e6 + g
|
|
174
|
+
rd = np.bincount(idx, weights=r * kw, minlength=len(uniq)) / 1e6 + g * (1 - used)
|
|
175
|
+
return [str(d) for d in uniq], wd.tolist(), rd.tolist()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def run_location(times, temps, rhs, n=200, seed=7, mw=100.0):
|
|
179
|
+
"""Monte Carlo for every design at one location, per `mw` MW of IT capacity.
|
|
180
|
+
|
|
181
|
+
Returns {design: {stat: {"p10","p50","p90"}}} for withdrawn/consumed/returned average, peak and annual,
|
|
182
|
+
plus the central-parameter run. Peak = 1-in-10-year peak day (90th percentile of annual maxima).
|
|
183
|
+
"""
|
|
184
|
+
tdb = np.array(temps, dtype=float)
|
|
185
|
+
rh = np.array(rhs, dtype=float)
|
|
186
|
+
twb = wet_bulb(tdb, rh)
|
|
187
|
+
days = np.array([t[:10] for t in times])
|
|
188
|
+
uniq, day_index = np.unique(days, return_inverse=True)
|
|
189
|
+
years = np.array([d[:4] for d in uniq])
|
|
190
|
+
months = np.array([d[:7] for d in uniq])
|
|
191
|
+
rng = np.random.default_rng(seed)
|
|
192
|
+
runs = sample(rng, n)
|
|
193
|
+
out = {}
|
|
194
|
+
for design in DESIGNS:
|
|
195
|
+
acc = {}
|
|
196
|
+
for p in runs + [central()]:
|
|
197
|
+
kw = mw * 1000 * p["utilization"]
|
|
198
|
+
w, c, r = hourly(tdb, twb, design, p)
|
|
199
|
+
for name, arr in (("withdrawn", w), ("consumed", c), ("returned", r)):
|
|
200
|
+
s = daily_stats(day_index, len(uniq), years, arr, kw, months if name == "withdrawn" else None)
|
|
201
|
+
for stat, v in s.items():
|
|
202
|
+
acc.setdefault(f"{name}_{stat}", []).append(v)
|
|
203
|
+
# water use effectiveness: liters consumed per IT kWh (annual average), for comparison with LBNL/operators
|
|
204
|
+
acc.setdefault("wue_l_per_kwh", []).append(float(np.average(c)) * 3.78541) # consumed basis
|
|
205
|
+
acc.setdefault("wue_makeup_l_per_kwh", []).append(float(np.average(w)) * 3.78541) # LBNL/Lei basis (evaporation + drift + blowdown)
|
|
206
|
+
acc.setdefault("utilization", []).append(p["utilization"])
|
|
207
|
+
res = {}
|
|
208
|
+
for k, vals in acc.items():
|
|
209
|
+
v = np.array(vals[:-1])
|
|
210
|
+
res[k] = {"p10": float(np.quantile(v, 0.1)), "p50": float(np.quantile(v, 0.5)), "p90": float(np.quantile(v, 0.9)),
|
|
211
|
+
"central": float(vals[-1])}
|
|
212
|
+
out[design] = res
|
|
213
|
+
return out
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Lake-source (once-through) cooling: water taken from a Great Lake, run through heat exchangers once, and returned warmer.
|
|
2
|
+
|
|
3
|
+
Withdrawal follows from the heat load and the temperature rise: flow = heat / (density x specific heat x rise).
|
|
4
|
+
Nearly all of it is returned. The returned heat drives extra ("forced") evaporation from the lake surface, estimated with
|
|
5
|
+
the heat-balance method used by USGS for once-through power plants (Diehl et al. 2013, after Harbeck 1964 and Ward 1980):
|
|
6
|
+
the share of added heat that leaves as evaporation is dE / (dE + dC + dR), where evaporation and conduction share the same
|
|
7
|
+
wind function and radiation does not. Natural surface temperatures are NOAA GLERL daily long-term averages for each lake.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from waterledger_models.datacenter import cooling as cw
|
|
13
|
+
|
|
14
|
+
RHO, CP, LV = 1000.0, 4186.0, 2.45e6 # kg/m3, J/(kg K), J/kg
|
|
15
|
+
GAL_M3 = 264.172
|
|
16
|
+
P_MB = 1013.0
|
|
17
|
+
BOWEN = P_MB * 0.24 / (0.622 * 585.0) # psychrometric constant in mb per deg C (cp of air 0.24 cal/g/K, L 585 cal/g)
|
|
18
|
+
SIGMA = 1.17e-7 / 86400.0 # cal/(cm2 s K4)
|
|
19
|
+
EMIS = 0.97
|
|
20
|
+
|
|
21
|
+
# Ranges: utilization and heat factor reuse the cooling-water model; rise and wind are estimates (see methods page).
|
|
22
|
+
RANGES = {
|
|
23
|
+
"utilization": cw.RANGES["utilization"],
|
|
24
|
+
"heat_factor": (cw.RANGES["hf_econ"][0], cw.RANGES["hf_chiller"][1]),
|
|
25
|
+
"rise_c": (6.0, 10.0),
|
|
26
|
+
"wind_mph": (5.0, 15.0),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _es(t):
|
|
31
|
+
"""Saturation vapor pressure over water, mb (Magnus)."""
|
|
32
|
+
return 6.112 * np.exp(17.62 * t / (243.12 + t))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def evaporation_ratio(t, wind_mph):
|
|
36
|
+
"""Share of added heat lost by evaporation at natural water temperature t (deg C), linearized heat balance (Ward 1980; USGS 2013 eq. 12-20)."""
|
|
37
|
+
f = 7.0e-8 * wind_mph # Ward wind function, cal/(cm2 s mb)
|
|
38
|
+
de = (_es(t + 0.5) - _es(t - 0.5)) # mb per deg C
|
|
39
|
+
d_e = 585.0 * f * de
|
|
40
|
+
d_c = 585.0 * f * BOWEN
|
|
41
|
+
d_r = 4 * EMIS * SIGMA * (t + 273.15) ** 3
|
|
42
|
+
return d_e / (d_e + d_c + d_r)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_lake(water_temps, n=200, seed=11, mw=100.0):
|
|
46
|
+
"""Monte Carlo over the parameter ranges for one lake. water_temps: 366 daily long-term average surface temperatures (deg C).
|
|
47
|
+
Returns MGD per `mw` of IT capacity: withdrawn and returned (steady), forced evaporation average and its highest month."""
|
|
48
|
+
rng = np.random.default_rng(seed)
|
|
49
|
+
t = np.asarray(water_temps, dtype=float)
|
|
50
|
+
out = {"withdrawn": [], "evap_avg": [], "evap_peak_month": [], "er_avg": []}
|
|
51
|
+
for _ in range(n):
|
|
52
|
+
p = {k: rng.uniform(*v) for k, v in RANGES.items()}
|
|
53
|
+
heat_w = mw * 1e6 * p["utilization"] * p["heat_factor"]
|
|
54
|
+
m3s = heat_w / (RHO * CP * p["rise_c"])
|
|
55
|
+
out["withdrawn"].append(m3s * 86400 * GAL_M3 / 1e6)
|
|
56
|
+
er = evaporation_ratio(t, p["wind_mph"])
|
|
57
|
+
evap = er * heat_w / LV / RHO * 86400 * GAL_M3 / 1e6 # MGD by day of year
|
|
58
|
+
month = np.convolve(evap, np.ones(30) / 30, mode="valid")
|
|
59
|
+
out["evap_avg"].append(float(evap.mean()))
|
|
60
|
+
out["evap_peak_month"].append(float(month.max()))
|
|
61
|
+
out["er_avg"].append(float(er.mean()))
|
|
62
|
+
stat = lambda a: {"p10": float(np.percentile(a, 10)), "p50": float(np.percentile(a, 50)), "p90": float(np.percentile(a, 90))}
|
|
63
|
+
return {k: stat(v) for k, v in out.items()}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Runoff water quality before and after a campus is built: annual runoff and pollutant loads, the construction years,
|
|
2
|
+
road salt, generator fuel, runoff heat and what cooling-water blowdown carries to a sewer plant.
|
|
3
|
+
|
|
4
|
+
Screening level, for a review or a public estimate, not a calibrated model:
|
|
5
|
+
runoff EPA Simple Method, split by surface: R = P x 0.9 x Rv with Rv 0.95 on roofs and paving and 0.05 on turf or fields
|
|
6
|
+
(Rv = 0.05 + 0.9 x impervious share, Schueler 1987)
|
|
7
|
+
loads L (lb/yr) = 0.226 x R (in) x C (mg/L) x A (ac), with event mean concentrations C by surface
|
|
8
|
+
controls Michigan's post-construction standard (treat the first inch or 90% of storms, 80% TSS removal; EGLE MS4 permits,
|
|
9
|
+
Kent County Drain Commissioner rules), applied to the captured share of runoff with wet-pond removal rates;
|
|
10
|
+
infiltration as the stronger option
|
|
11
|
+
farmland sediment comes from field erosion, not runoff concentrations: delivered soil loss [A] 0.25-1.0 t/ac/yr (U.S. cropland
|
|
12
|
+
sheet-and-rill erosion averages 2.67 t/ac/yr, USDA NRI 2017, and only part of it leaves the field), carrying phosphorus
|
|
13
|
+
at the soil's content [A]; tile-drain nitrate is not counted, so farmland nitrogen here is low
|
|
14
|
+
PARAMS is the single source of every value; Water Ledger reads it from waters.json and the Water Review reads it here.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
LB = 0.226 # lb per (ac x in x mg/L)
|
|
18
|
+
GAL_PER_AC_IN = 27154.0
|
|
19
|
+
PJ = 0.9 # share of rain events that produce runoff (Simple Method)
|
|
20
|
+
|
|
21
|
+
# Event mean concentrations, mg/L: [lo, mid, hi]. Mid is the Michigan source where one exists.
|
|
22
|
+
PARAMS = {
|
|
23
|
+
"rv": {"imp": 0.95, "perv": 0.05, "sources": ["simple_method"]},
|
|
24
|
+
"emc": {
|
|
25
|
+
# Marquette, Michigan source areas (USGS WRIR 97-4242, Table 4, geometric means); TN = TKN + NO2+NO3
|
|
26
|
+
"roof": {"tss": [20, 24, 24], "tp": [0.03, 0.09, 0.09], "tn": [1.5, 2.3, 2.3], "sources": ["usgs_marquette", "mpca_emc"]},
|
|
27
|
+
"paved": {"tss": [75, 138, 201], "tp": [0.2, 0.21, 0.25], "tn": [1.5, 1.8, 2.5], "sources": ["usgs_marquette", "mpca_emc", "nsqd", "stepl"]},
|
|
28
|
+
"turf": {"tss": [11, 21, 70], "tp": [0.15, 0.19, 0.19], "tn": [1.5, 1.5, 1.5], "sources": ["mpca_emc", "stepl"]},
|
|
29
|
+
# Row crops: MPCA recommends no TSS EMC (range 50-160); TP 0.3 (STEPL) to 0.53 (MPCA review median); TN 1.9 (STEPL)
|
|
30
|
+
"farm": {"tss": [50, 100, 160], "tp": [0.3, 0.3, 0.53], "tn": [1.9, 1.9, 1.9], "sources": ["mpca_emc", "stepl"], "assumed": True},
|
|
31
|
+
"woods": {"tss": [26, 72, 140], "tp": [0.03, 0.09, 0.45], "tn": [0.2, 0.2, 0.2], "sources": ["mpca_emc", "stepl"]},
|
|
32
|
+
"built": {"tss": [75, 75, 201], "tp": [0.2, 0.2, 0.25], "tn": [2.0, 2.0, 2.5], "sources": ["mpca_emc", "stepl", "nsqd"]},
|
|
33
|
+
},
|
|
34
|
+
"before_imp": {"farm": 0.02, "woods": 0.01, "built": 0.60}, # [A] impervious share of the land as it is now
|
|
35
|
+
# Farmland field erosion reaching the field edge (t/ac/yr) and phosphorus on that soil (share by weight) [A]
|
|
36
|
+
"farm_soil": {"t_per_ac_yr": [0.25, 0.5, 1.0], "p_share": [0.0003, 0.0004, 0.0006], "sources": ["nri2017"]},
|
|
37
|
+
"after": {"imp": 0.50, "imp_range": [0.40, 0.60], "roof_share": 0.40}, # [A] roofs and paving; roofs as a share of that
|
|
38
|
+
# Michigan post-construction standard and removal by practice (share removed). Captured share of annual runoff [A].
|
|
39
|
+
"controls": {
|
|
40
|
+
"captured": [0.75, 0.85, 0.95],
|
|
41
|
+
"pond": {"tss": 0.80, "tp": 0.51, "tn": 0.26, "sources": ["egle_ms4", "kcdc_rules", "bmpdb2020"]},
|
|
42
|
+
"infiltration": {"tss": 0.95, "tp": 0.70, "tn": 0.51, "volume": 0.5, "sources": ["semcog_lid", "kcdc_rules"]},
|
|
43
|
+
"standard": "Treat the first inch of runoff or 90% of runoff-producing storms with at least 80% TSS removal (or 80 mg/L), and hold peak rate and volume to pre-development for storms up to the 2-year, 24-hour event",
|
|
44
|
+
},
|
|
45
|
+
# Construction: soil moved off bare ground per acre per year; EPA: construction sites shed sediment 10-20x agricultural land
|
|
46
|
+
"construction": {"t_per_ac_yr": [30, 100, 200], "ratio_to_farm": [10, 20], "years": [1, 2, 3], "sources": ["epa_fact26", "epa_mm8"],
|
|
47
|
+
"permits": "Part 91 soil erosion permit for 1 acre or more, or within 500 ft of a lake or stream; construction storm water permit-by-rule (R 323.2190) with a Notice of Coverage above 5 acres and weekly inspection by a certified operator"},
|
|
48
|
+
# Road salt on parking and drives: lb per 1,000 sq ft per event (MPCA targets; practice is often higher) x events per winter [A]
|
|
49
|
+
"salt": {"lb_per_ksf": [0.75, 1.5, 3.0], "events": [20, 40, 60], "paved_share": 0.60, "chloride_share": 0.607,
|
|
50
|
+
"chronic_mg_l": 150, "sources": ["mpca_salt", "egle_rule57"]},
|
|
51
|
+
# Backup generators: diesel at full load (Cat 3516C, 133.6 gal/h at 2,000 ekW) x hours stored on site [A]
|
|
52
|
+
"fuel": {"gal_per_mwh": 66.8, "hours": [12, 48, 72], "gen_mw_per_it_mw": 1.2, "unit_mw": 2.5, "spcc_gal": 1320,
|
|
53
|
+
"sources": ["cat3516c", "uptime2014", "amazon_mo2026", "wyofile2025", "cfr112"]},
|
|
54
|
+
# Runoff heat: summer runoff from pavement averages about 25 C (77 F) vs a 68 F (20 C) coldwater limit in June-August
|
|
55
|
+
"heat": {"paved_c": 24.9, "forest_c": 21.5, "cold_limit_f": 68, "cold_rise_f": 2, "sources": ["herb2007", "rule75", "stefan2010"]},
|
|
56
|
+
# Cooling-tower blowdown: dissolved solids = makeup x cycles; phosphate inhibitors up to ~6.5 mg/L as P; plant limit 1 mg/L
|
|
57
|
+
"blowdown": {"makeup_tds": {"lake": 170, "ground": 400}, "cycles": [3, 5, 7], "p_mg_l": [0, 3, 6.5], "plant_p_limit": 1.0,
|
|
58
|
+
"sources": ["epa_wsaw", "jea2024", "rule60", "egle_dc_faq"]},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _w(lst, i):
|
|
63
|
+
return lst[i] if isinstance(lst, list) else lst
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def runoff_in(rain_in, imp):
|
|
67
|
+
"""Annual runoff depth (in) from the whole site."""
|
|
68
|
+
rv = PARAMS["rv"]
|
|
69
|
+
return rain_in * PJ * (rv["imp"] * imp + rv["perv"] * (1 - imp))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def loads(acres, rain_in, before="farm", imp=None, roof_share=None):
|
|
73
|
+
"""Annual runoff (gallons) and loads (TSS tons, TP lb, TN lb) now, after with no controls, after with Michigan's standard
|
|
74
|
+
(wet ponds) and after with infiltration. Each value is [lo, mid, hi]."""
|
|
75
|
+
P, E = PARAMS, PARAMS["emc"]
|
|
76
|
+
imp = P["after"]["imp"] if imp is None else imp
|
|
77
|
+
roof = P["after"]["roof_share"] if roof_share is None else roof_share
|
|
78
|
+
rv = P["rv"]
|
|
79
|
+
r_imp, r_perv = rain_in * PJ * rv["imp"], rain_in * PJ * rv["perv"]
|
|
80
|
+
i0 = P["before_imp"][before]
|
|
81
|
+
out = {"runoff_gal": {}, "tss_t": {}, "tp_lb": {}, "tn_lb": {}}
|
|
82
|
+
out["runoff_gal"]["now"] = [acres * (i0 * r_imp + (1 - i0) * r_perv) * GAL_PER_AC_IN] * 3
|
|
83
|
+
out["runoff_gal"]["after"] = [acres * (imp * r_imp + (1 - imp) * r_perv) * GAL_PER_AC_IN] * 3
|
|
84
|
+
for k, unit in (("tss", "tss_t"), ("tp", "tp_lb"), ("tn", "tn_lb")):
|
|
85
|
+
div = 2000 if k == "tss" else 1
|
|
86
|
+
now_perv = E[before][k] if before != "built" else E["turf"][k]
|
|
87
|
+
now_imp = E["built"][k]
|
|
88
|
+
now = [LB * acres * (i0 * r_imp * _w(now_imp, i) + (1 - i0) * r_perv * _w(now_perv, i)) / div for i in range(3)]
|
|
89
|
+
if before == "farm":
|
|
90
|
+
fs = P["farm_soil"]
|
|
91
|
+
if k == "tss":
|
|
92
|
+
now = [acres * (1 - i0) * fs["t_per_ac_yr"][i] for i in range(3)]
|
|
93
|
+
elif k == "tp":
|
|
94
|
+
now = [now[i] + acres * (1 - i0) * fs["t_per_ac_yr"][i] * 2000 * fs["p_share"][i] for i in range(3)]
|
|
95
|
+
after = [LB * acres * (imp * r_imp * (roof * E["roof"][k][i] + (1 - roof) * E["paved"][k][i]) + (1 - imp) * r_perv * E["turf"][k][i]) / div for i in range(3)]
|
|
96
|
+
cap = P["controls"]["captured"]
|
|
97
|
+
# lo pairs with the most capture, hi with the least
|
|
98
|
+
pond = [after[i] * (1 - cap[2 - i] * P["controls"]["pond"][k]) for i in range(3)]
|
|
99
|
+
inf = P["controls"]["infiltration"]
|
|
100
|
+
infil = [after[i] * (1 - cap[2 - i] * (1 - (1 - inf["volume"]) * (1 - inf[k]))) for i in range(3)]
|
|
101
|
+
out[unit] = {"now": now, "after": after, "pond": pond, "infiltration": infil}
|
|
102
|
+
vol_inf = P["controls"]["infiltration"]["volume"]
|
|
103
|
+
out["runoff_gal"]["pond"] = out["runoff_gal"]["after"]
|
|
104
|
+
out["runoff_gal"]["infiltration"] = [out["runoff_gal"]["after"][0] * (1 - P["controls"]["captured"][2 - i] * vol_inf) for i in range(3)]
|
|
105
|
+
return out
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def construction(acres_disturbed, before="farm"):
|
|
109
|
+
"""Soil that can move off bare ground in a year of construction (tons, before controls catch part of it)."""
|
|
110
|
+
c = PARAMS["construction"]["t_per_ac_yr"]
|
|
111
|
+
return [acres_disturbed * x for x in c]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def salt(acres, imp=None):
|
|
115
|
+
"""Road salt a winter on parking and drives (tons of salt) and the chloride in it."""
|
|
116
|
+
S, A = PARAMS["salt"], PARAMS["after"]
|
|
117
|
+
imp = A["imp"] if imp is None else imp
|
|
118
|
+
paved_ksf = acres * imp * (1 - A["roof_share"]) * 43.56
|
|
119
|
+
t = [paved_ksf * S["lb_per_ksf"][i] * S["events"][i] / 2000 for i in range(3)]
|
|
120
|
+
return {"salt_t": t, "chloride_t": [x * S["chloride_share"] for x in t], "paved_ac": acres * imp * (1 - A["roof_share"])}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def fuel(it_mw):
|
|
124
|
+
"""Backup generators and diesel stored on site."""
|
|
125
|
+
F = PARAMS["fuel"]
|
|
126
|
+
gen_mw = it_mw * F["gen_mw_per_it_mw"]
|
|
127
|
+
return {"gen_mw": gen_mw, "units": round(gen_mw / F["unit_mw"]), "gal": [gen_mw * F["gal_per_mwh"] * h for h in F["hours"]]}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def blowdown(flow_mgd, source="lake"):
|
|
131
|
+
"""Dissolved solids (mg/L, lb/day) and phosphorus (lb/day) in cooling-water blowdown sent to a plant."""
|
|
132
|
+
B = PARAMS["blowdown"]
|
|
133
|
+
tds = [B["makeup_tds"][source] * c for c in B["cycles"]]
|
|
134
|
+
return {"tds_mg_l": tds, "tds_lb_day": [t * flow_mgd * 8.34 for t in tds], "p_lb_day": [p * flow_mgd * 8.34 for p in B["p_mg_l"]]}
|