camber-toolkit 0.1.0__py3-none-any.whl
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.
- camber/__init__.py +3 -0
- camber/ahu.py +137 -0
- camber/api/__init__.py +6 -0
- camber/api/read.py +52 -0
- camber/api/server.py +101 -0
- camber/boilercycle.py +63 -0
- camber/bps.py +170 -0
- camber/carbon.py +57 -0
- camber/charts/__init__.py +2 -0
- camber/charts/box_reheat.py +69 -0
- camber/charts/carpet.py +59 -0
- camber/charts/cusum_chart.py +45 -0
- camber/charts/energy_signature.py +51 -0
- camber/charts/scatter.py +138 -0
- camber/charts/timeseries.py +41 -0
- camber/charts/zones_chart.py +47 -0
- camber/chiller.py +101 -0
- camber/chillerstaging.py +161 -0
- camber/chwplant.py +125 -0
- camber/chwpump.py +106 -0
- camber/cli.py +89 -0
- camber/comfort.py +189 -0
- camber/condenserwater.py +100 -0
- camber/config.py +209 -0
- camber/coolingtower.py +123 -0
- camber/cost.py +99 -0
- camber/demand.py +170 -0
- camber/eval.py +143 -0
- camber/fault_economics.py +316 -0
- camber/fdd_g36.py +466 -0
- camber/finance.py +121 -0
- camber/g36_reset.py +174 -0
- camber/iaq.py +100 -0
- camber/ingest/__init__.py +32 -0
- camber/ingest/bacnet.py +156 -0
- camber/ingest/base.py +38 -0
- camber/ingest/csv_perpoint.py +56 -0
- camber/ingest/csv_wide.py +41 -0
- camber/ingest/haystack.py +220 -0
- camber/ingest/modbus.py +115 -0
- camber/ingest/mqtt_stream.py +126 -0
- camber/ingest/opcua.py +182 -0
- camber/ingest/quality.py +200 -0
- camber/ingest/sql.py +144 -0
- camber/integrate/__init__.py +18 -0
- camber/integrate/tickets.py +148 -0
- camber/interop/__init__.py +13 -0
- camber/interop/better.py +82 -0
- camber/interop/brick.py +198 -0
- camber/interop/export.py +97 -0
- camber/interop/openei.py +95 -0
- camber/interop/psychro.py +54 -0
- camber/interop/pvlib_bridge.py +77 -0
- camber/interop/site_model.py +210 -0
- camber/interop/tariff_nrel.py +59 -0
- camber/inventory.py +119 -0
- camber/io.py +40 -0
- camber/leakvalve.py +93 -0
- camber/lighting.py +63 -0
- camber/loadprofile.py +70 -0
- camber/mandv/__init__.py +16 -0
- camber/mandv/caltrack.py +98 -0
- camber/mandv/categorical.py +86 -0
- camber/mandv/cusum.py +63 -0
- camber/mandv/ecm_savings.py +120 -0
- camber/mandv/intervalfit.py +116 -0
- camber/mandv/models.py +275 -0
- camber/mandv/nonroutine.py +167 -0
- camber/mandv/normalized.py +99 -0
- camber/mandv/resample.py +108 -0
- camber/mandv/retrofit_isolation.py +170 -0
- camber/mandv/stats.py +161 -0
- camber/mandv/towt.py +131 -0
- camber/mandv/weather.py +92 -0
- camber/mapping_confidence.py +107 -0
- camber/model/__init__.py +1 -0
- camber/model/entities.py +269 -0
- camber/model/mapping.py +84 -0
- camber/model/roles.py +146 -0
- camber/oafraction.py +107 -0
- camber/overcooling.py +136 -0
- camber/overcooling_severity.py +224 -0
- camber/plant.py +152 -0
- camber/points.py +116 -0
- camber/pv.py +88 -0
- camber/rcx.py +222 -0
- camber/realio.py +127 -0
- camber/reheat.py +163 -0
- camber/report/__init__.py +7 -0
- camber/report/audit.py +172 -0
- camber/report/fleet.py +204 -0
- camber/resolve.py +173 -0
- camber/rules/__init__.py +1 -0
- camber/rules/base.py +177 -0
- camber/rules/boiler_rule.py +76 -0
- camber/rules/boilercycle_rule.py +62 -0
- camber/rules/builtin.py +61 -0
- camber/rules/chiller_rule.py +70 -0
- camber/rules/chillerfleet_rule.py +62 -0
- camber/rules/chillerstaging_rule.py +74 -0
- camber/rules/chwplant_rule.py +66 -0
- camber/rules/chwpump_rule.py +66 -0
- camber/rules/condenserwater_rule.py +65 -0
- camber/rules/coolingtower_rule.py +74 -0
- camber/rules/hwplant_deltat_rule.py +66 -0
- camber/rules/hwpump_rule.py +56 -0
- camber/rules/iaq_rule.py +67 -0
- camber/rules/leakvalve_rule.py +59 -0
- camber/rules/oafraction_rule.py +80 -0
- camber/rules/overcooling_rule.py +67 -0
- camber/rules/overcooling_severity_rule.py +77 -0
- camber/rules/reheat_min_rule.py +78 -0
- camber/rules/reheat_rule.py +72 -0
- camber/rules/satreset_rule.py +72 -0
- camber/rules/setback_rule.py +64 -0
- camber/rules/simul_hc.py +62 -0
- camber/rules/static_rule.py +53 -0
- camber/rules/triage.py +183 -0
- camber/rules/zones_rule.py +77 -0
- camber/satreset.py +145 -0
- camber/schedules.py +53 -0
- camber/sensordrift.py +138 -0
- camber/sensorhealth.py +226 -0
- camber/setback.py +92 -0
- camber/soo.py +298 -0
- camber/soo_library.py +72 -0
- camber/staticpressure.py +150 -0
- camber/store/__init__.py +12 -0
- camber/store/bench.py +108 -0
- camber/store/parquet_store.py +355 -0
- camber/synth.py +68 -0
- camber/tariff.py +275 -0
- camber/units.py +58 -0
- camber/validation.py +135 -0
- camber/water.py +133 -0
- camber/zones.py +80 -0
- camber_toolkit-0.1.0.dist-info/METADATA +227 -0
- camber_toolkit-0.1.0.dist-info/RECORD +143 -0
- camber_toolkit-0.1.0.dist-info/WHEEL +5 -0
- camber_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- camber_toolkit-0.1.0.dist-info/licenses/LICENSE +202 -0
- camber_toolkit-0.1.0.dist-info/licenses/NOTICE +7 -0
- camber_toolkit-0.1.0.dist-info/top_level.txt +1 -0
camber/__init__.py
ADDED
camber/ahu.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""AHU-level diagnostics: simultaneous heating/cooling, economizer, SA behavior.
|
|
2
|
+
|
|
3
|
+
An AHU that carries both a chilled-water coil (``CHW_Valve`` %) and a hot-water
|
|
4
|
+
coil (``HHW_Valve`` %), plus mixed/return/supply air temps, OA damper, and an
|
|
5
|
+
economizer command, supports these checks:
|
|
6
|
+
|
|
7
|
+
1. **Simultaneous H/C at the AHU** -- CHW and HHW valves both open at once. This
|
|
8
|
+
is the central-plant analogue of the terminal-box reheat penalty and the most
|
|
9
|
+
direct read on coil-against-coil "fighting".
|
|
10
|
+
2. **Economizer faults** -- when outdoor air is cooler than return air and within
|
|
11
|
+
the economizer high-limit, the OA damper should modulate open for free
|
|
12
|
+
cooling; flag intervals where it stays shut while the CHW valve is cooling.
|
|
13
|
+
3. **Supply-air / mixed-air sanity** -- basic coverage + ranges so downstream
|
|
14
|
+
reset diagnostics have a vetted input.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass, asdict
|
|
20
|
+
|
|
21
|
+
import pandas as pd
|
|
22
|
+
|
|
23
|
+
from .schedules import occupied_mask
|
|
24
|
+
|
|
25
|
+
AHU_MEASURES = [
|
|
26
|
+
"CHW_Valve", "HHW_Valve", "SupplyAir", "MixedAir", "ReturnAir",
|
|
27
|
+
"OSA", "OA_Damper", "EconoCmd", "DuctStatic", "DuctStaticSP",
|
|
28
|
+
"Occupancy", "WarmUp", "CoolDown",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class AHUResult:
|
|
34
|
+
"""AHU simultaneous heating/cooling and economizer diagnostics for one unit."""
|
|
35
|
+
|
|
36
|
+
equip: str
|
|
37
|
+
n_intervals: int
|
|
38
|
+
n_considered: int
|
|
39
|
+
chw_open_pct: float
|
|
40
|
+
hhw_open_pct: float
|
|
41
|
+
simultaneous_hc_pct: float # both valves open
|
|
42
|
+
mean_overlap_when_simul: float # mean min(CHW,HHW) during overlap
|
|
43
|
+
econ_opportunity_pct: float # intervals economizer SHOULD help
|
|
44
|
+
econ_missed_pct: float # of opportunity, damper stayed shut while cooling
|
|
45
|
+
coverage_start: str
|
|
46
|
+
coverage_end: str
|
|
47
|
+
|
|
48
|
+
def as_dict(self):
|
|
49
|
+
"""Return the result as a plain dict."""
|
|
50
|
+
return asdict(self)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _pct(mask, n):
|
|
54
|
+
return round(100.0 * int(mask.sum()) / n, 2) if n else 0.0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _populated(df, col):
|
|
58
|
+
"""Return df[col] only if present and not entirely null, else None.
|
|
59
|
+
|
|
60
|
+
Lets occupied_mask AND in a real occupancy point when one exists, while
|
|
61
|
+
falling back to the weekday window when the BAS point is empty/absent.
|
|
62
|
+
"""
|
|
63
|
+
if col in df.columns and df[col].notna().any():
|
|
64
|
+
return df[col]
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def analyze_ahu(df, equip, *, valve_thr=5.0, econ_high_limit_f=70.0,
|
|
69
|
+
damper_min_open=20.0, occupied_only=True):
|
|
70
|
+
"""Compute AHU H/C + economizer metrics. ``df`` columns are measure names.
|
|
71
|
+
|
|
72
|
+
Threshold basis (economizer logic, PNNL Re-tuning Ch.6):
|
|
73
|
+
valve_thr=5.0 % -- valve <5% open counted as shut (noise deadband).
|
|
74
|
+
econ_high_limit_f=70 -- economizer high limit; above this OAT, free cooling is
|
|
75
|
+
disabled, so it's not an "opportunity". 70F is a common
|
|
76
|
+
dry-bulb high-limit; verify against the site's sequence.
|
|
77
|
+
damper_min_open=20.0 %-- below ~20% the OA damper is at its minimum-position
|
|
78
|
+
(ventilation only), i.e. not economizing. "20% damper is
|
|
79
|
+
never 20% OA" (Ch.6) -- this gates the *damper command*,
|
|
80
|
+
not the actual OA fraction.
|
|
81
|
+
The economizer-opportunity test also requires OA cooler than return air by a small
|
|
82
|
+
margin (oa < ra - 2.0 F); the 2F guard avoids flagging when OA and RA are
|
|
83
|
+
effectively equal (no useful free cooling, within sensor tolerance).
|
|
84
|
+
"""
|
|
85
|
+
if "CHW_Valve" not in df.columns or "HHW_Valve" not in df.columns:
|
|
86
|
+
return None
|
|
87
|
+
work = df.copy()
|
|
88
|
+
n_all = len(work)
|
|
89
|
+
if n_all == 0:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
if occupied_only:
|
|
93
|
+
work = work[occupied_mask(
|
|
94
|
+
work.index,
|
|
95
|
+
occ=_populated(work, "Occupancy"),
|
|
96
|
+
warmup=work["WarmUp"] if "WarmUp" in work.columns else None,
|
|
97
|
+
cooldown=work["CoolDown"] if "CoolDown" in work.columns else None,
|
|
98
|
+
)]
|
|
99
|
+
n = len(work)
|
|
100
|
+
if n == 0:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
chw = work["CHW_Valve"]
|
|
104
|
+
hhw = work["HHW_Valve"]
|
|
105
|
+
chw_open = chw > valve_thr
|
|
106
|
+
hhw_open = hhw > valve_thr
|
|
107
|
+
simul = chw_open & hhw_open
|
|
108
|
+
overlap_mag = work.loc[simul, ["CHW_Valve", "HHW_Valve"]].min(axis=1)
|
|
109
|
+
|
|
110
|
+
# Economizer: opportunity = cooling called (CHW open) AND OA cooler than return
|
|
111
|
+
# AND OA below high-limit. Missed = opportunity but OA damper effectively shut.
|
|
112
|
+
if "OSA" in work.columns and "ReturnAir" in work.columns:
|
|
113
|
+
oa = work["OSA"]
|
|
114
|
+
ra = work["ReturnAir"]
|
|
115
|
+
opp = chw_open & (oa < ra - 2.0) & (oa < econ_high_limit_f)
|
|
116
|
+
if "OA_Damper" in work.columns:
|
|
117
|
+
missed = opp & (work["OA_Damper"].fillna(0) < damper_min_open)
|
|
118
|
+
else:
|
|
119
|
+
missed = pd.Series(False, index=work.index)
|
|
120
|
+
opp_pct = _pct(opp, n)
|
|
121
|
+
missed_pct = round(100.0 * int(missed.sum()) / int(opp.sum()), 2) if opp.sum() else 0.0
|
|
122
|
+
else:
|
|
123
|
+
opp_pct = missed_pct = 0.0
|
|
124
|
+
|
|
125
|
+
return AHUResult(
|
|
126
|
+
equip=equip,
|
|
127
|
+
n_intervals=n_all,
|
|
128
|
+
n_considered=n,
|
|
129
|
+
chw_open_pct=_pct(chw_open, n),
|
|
130
|
+
hhw_open_pct=_pct(hhw_open, n),
|
|
131
|
+
simultaneous_hc_pct=_pct(simul, n),
|
|
132
|
+
mean_overlap_when_simul=round(float(overlap_mag.mean()), 1) if simul.any() else 0.0,
|
|
133
|
+
econ_opportunity_pct=opp_pct,
|
|
134
|
+
econ_missed_pct=missed_pct,
|
|
135
|
+
coverage_start=str(df.index.min()),
|
|
136
|
+
coverage_end=str(df.index.max()),
|
|
137
|
+
)
|
camber/api/__init__.py
ADDED
camber/api/read.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Read API facade over the time-series store (capability-map §8).
|
|
2
|
+
|
|
3
|
+
A small, transport-agnostic surface that returns JSON-serializable dicts for the
|
|
4
|
+
three things an external tool needs: the sites in the store, the catalog of stored
|
|
5
|
+
series, and point history. The HTTP layer in :mod:`camber.api.server` is a thin
|
|
6
|
+
wrapper over this; tests and in-process callers use the facade directly.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ReadAPI:
|
|
15
|
+
"""Query facade over a :class:`~camber.store.ParquetStore`."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, store):
|
|
18
|
+
self.store = store
|
|
19
|
+
|
|
20
|
+
def about(self) -> dict:
|
|
21
|
+
"""Service info: name, liveness flag, and the sites in the store."""
|
|
22
|
+
return {"service": "camber read-api", "ok": True,
|
|
23
|
+
"sites": self.store.sites()}
|
|
24
|
+
|
|
25
|
+
def sites(self) -> dict:
|
|
26
|
+
"""List the sites present in the store."""
|
|
27
|
+
return {"sites": self.store.sites()}
|
|
28
|
+
|
|
29
|
+
def points(self, *, site=None, equip=None, role=None) -> dict:
|
|
30
|
+
"""Catalog of stored series, optionally filtered by site/equip/role."""
|
|
31
|
+
keys = self.store.points(site=site)
|
|
32
|
+
rows = [{"site": k.site, "equip": k.equip, "role": k.role} for k in keys
|
|
33
|
+
if (equip is None or k.equip == equip)
|
|
34
|
+
and (role is None or k.role == role)]
|
|
35
|
+
return {"points": rows, "count": len(rows)}
|
|
36
|
+
|
|
37
|
+
def history(self, *, site=None, equip=None, role=None, start=None, end=None,
|
|
38
|
+
limit=None) -> dict:
|
|
39
|
+
"""Point history (long form) with ISO timestamps, optionally limited."""
|
|
40
|
+
long = self.store.read_long(
|
|
41
|
+
site=site,
|
|
42
|
+
equips=[equip] if equip else None,
|
|
43
|
+
roles=[role] if role else None,
|
|
44
|
+
start=start, end=end)
|
|
45
|
+
if not long.empty and limit:
|
|
46
|
+
long = long.head(int(limit))
|
|
47
|
+
rows = [] if long.empty else [
|
|
48
|
+
{"ts": pd.Timestamp(ts).isoformat(), "equip": eq, "role": rl,
|
|
49
|
+
"value": (None if pd.isna(v) else float(v))}
|
|
50
|
+
for ts, eq, rl, v in zip(long["ts"], long["equip"],
|
|
51
|
+
long["role"], long["value"])]
|
|
52
|
+
return {"history": rows, "count": len(rows)}
|
camber/api/server.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""HTTP server for the read API (stdlib only -- no web-framework dependency).
|
|
2
|
+
|
|
3
|
+
Routes are factored into a pure :func:`dispatch` function (method, path, query ->
|
|
4
|
+
(status, body)) so the routing is unit-testable without binding a socket; the
|
|
5
|
+
:class:`http.server` handler is a thin wrapper that parses the request, calls
|
|
6
|
+
``dispatch``, and writes JSON. Read-only: only GET is served.
|
|
7
|
+
|
|
8
|
+
Endpoints:
|
|
9
|
+
GET / | /about | /health -> service info
|
|
10
|
+
GET /sites -> {"sites": [...]}
|
|
11
|
+
GET /points?site=&equip=&role= -> {"points": [...], "count": n}
|
|
12
|
+
GET /history?site=&equip=&role=&start=&end=&limit= -> {"history": [...], "count": n}
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
19
|
+
from urllib.parse import parse_qs, urlparse
|
|
20
|
+
|
|
21
|
+
from .read import ReadAPI
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _q(query: dict, *keys):
|
|
25
|
+
"""Pick present single-valued query params from a parsed query dict."""
|
|
26
|
+
return {k: query[k][0] for k in keys if query.get(k)}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def dispatch(api: ReadAPI, method: str, path: str, query: dict):
|
|
30
|
+
"""Route a request to the read API. Returns ``(status_code, body_dict)``."""
|
|
31
|
+
if method != "GET":
|
|
32
|
+
return 405, {"error": "method not allowed", "method": method}
|
|
33
|
+
if path in ("/", "/about", "/health"):
|
|
34
|
+
return 200, api.about()
|
|
35
|
+
if path == "/sites":
|
|
36
|
+
return 200, api.sites()
|
|
37
|
+
if path == "/points":
|
|
38
|
+
return 200, api.points(**_q(query, "site", "equip", "role"))
|
|
39
|
+
if path == "/history":
|
|
40
|
+
kw = _q(query, "site", "equip", "role", "start", "end", "limit")
|
|
41
|
+
return 200, api.history(**kw)
|
|
42
|
+
return 404, {"error": "not found", "path": path}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ReadAPIHandler(BaseHTTPRequestHandler):
|
|
46
|
+
"""BaseHTTPRequestHandler bound to a ReadAPI via ``server.api``."""
|
|
47
|
+
|
|
48
|
+
def do_GET(self): # noqa: N802 (stdlib naming)
|
|
49
|
+
"""Parse the request, dispatch to the read API, and write the JSON response."""
|
|
50
|
+
parsed = urlparse(self.path)
|
|
51
|
+
query = parse_qs(parsed.query)
|
|
52
|
+
try:
|
|
53
|
+
status, body = dispatch(self.server.api, "GET", parsed.path, query)
|
|
54
|
+
except Exception as exc: # never leak a stack trace over the wire
|
|
55
|
+
status, body = 500, {"error": "internal error", "detail": str(exc)}
|
|
56
|
+
payload = json.dumps(body).encode("utf-8")
|
|
57
|
+
self.send_response(status)
|
|
58
|
+
self.send_header("Content-Type", "application/json")
|
|
59
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
60
|
+
self.end_headers()
|
|
61
|
+
self.wfile.write(payload)
|
|
62
|
+
|
|
63
|
+
def log_message(self, *args): # keep the test/CLI output quiet
|
|
64
|
+
"""Suppress the default per-request stderr logging."""
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def make_server(store, *, host: str = "127.0.0.1", port: int = 8080):
|
|
69
|
+
"""Create (but don't start) a threading HTTP server bound to ``store``.
|
|
70
|
+
|
|
71
|
+
``port=0`` binds an ephemeral port (read ``server.server_address[1]``). Call
|
|
72
|
+
``serve_forever()`` to run, or use this in a thread for tests.
|
|
73
|
+
"""
|
|
74
|
+
httpd = ThreadingHTTPServer((host, port), ReadAPIHandler)
|
|
75
|
+
httpd.api = ReadAPI(store)
|
|
76
|
+
return httpd
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def serve(store, *, host: str = "127.0.0.1", port: int = 8080): # pragma: no cover
|
|
80
|
+
"""Run the read API until interrupted (blocking)."""
|
|
81
|
+
httpd = make_server(store, host=host, port=port)
|
|
82
|
+
addr = httpd.server_address
|
|
83
|
+
print(f"camber read-api serving on http://{addr[0]}:{addr[1]} (Ctrl-C to stop)")
|
|
84
|
+
try:
|
|
85
|
+
httpd.serve_forever()
|
|
86
|
+
except KeyboardInterrupt:
|
|
87
|
+
httpd.shutdown()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__": # pragma: no cover
|
|
91
|
+
import os
|
|
92
|
+
import sys
|
|
93
|
+
|
|
94
|
+
from ..store import ParquetStore
|
|
95
|
+
|
|
96
|
+
# argv wins; otherwise env (CAMBER_STORE / _API_HOST / _API_PORT) — the container
|
|
97
|
+
# sets HOST=0.0.0.0 to be reachable, while a bare `python -m` stays on localhost.
|
|
98
|
+
root = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("CAMBER_STORE", "tsdb")
|
|
99
|
+
host = os.environ.get("CAMBER_API_HOST", "127.0.0.1")
|
|
100
|
+
port = int(sys.argv[2]) if len(sys.argv) > 2 else int(os.environ.get("CAMBER_API_PORT", "8080"))
|
|
101
|
+
serve(ParquetStore(root), host=host, port=port)
|
camber/boilercycle.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Boiler short-cycling diagnostic: firing starts per day from boiler status.
|
|
2
|
+
|
|
3
|
+
An oversized boiler (or one with too-tight staging/aquastat hysteresis) fires in
|
|
4
|
+
short bursts: on, satisfy the loop, off, repeat. Each start is purge-cycle losses,
|
|
5
|
+
thermal stress, and lower seasonal efficiency. Counting off->on transitions in the
|
|
6
|
+
boiler-status trend gives a starts-per-day rate that flags the pattern (PNNL Building
|
|
7
|
+
Re-tuning Ch.8; boiler minimum-cycle-time guidance).
|
|
8
|
+
|
|
9
|
+
This counts cycles across the whole window (a boiler can cycle at any hour, not just
|
|
10
|
+
occupied ones). Trend resolution bounds the count -- sub-interval cycles are invisible
|
|
11
|
+
-- so starts-per-day is a floor, reported as such; the cycling threshold is
|
|
12
|
+
manufacturer-dependent and injected by the rule.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, asdict
|
|
18
|
+
|
|
19
|
+
import pandas as pd
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class BoilerCyclingResult:
|
|
24
|
+
"""Boiler firing rate over the trend window."""
|
|
25
|
+
|
|
26
|
+
equip: str
|
|
27
|
+
n_days: float # observed span in days
|
|
28
|
+
starts_per_day: float # off->on transitions per day (a floor; see module doc)
|
|
29
|
+
runtime_pct: float # % of intervals the boiler is firing
|
|
30
|
+
n_starts: int
|
|
31
|
+
coverage_start: str
|
|
32
|
+
coverage_end: str
|
|
33
|
+
|
|
34
|
+
def as_dict(self):
|
|
35
|
+
"""Return the result as a plain dict."""
|
|
36
|
+
return asdict(self)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def analyze_boiler_cycling(
|
|
40
|
+
df: pd.DataFrame,
|
|
41
|
+
equip: str,
|
|
42
|
+
) -> BoilerCyclingResult | None:
|
|
43
|
+
"""Count boiler firing starts per day from the ``BoilerStatus`` (0/1) trend."""
|
|
44
|
+
if "BoilerStatus" not in df.columns:
|
|
45
|
+
return None
|
|
46
|
+
w = df[["BoilerStatus"]].dropna()
|
|
47
|
+
if len(w) < 10:
|
|
48
|
+
return None
|
|
49
|
+
span_days = (w.index.max() - w.index.min()).total_seconds() / 86400.0
|
|
50
|
+
span_days = max(span_days, 1.0)
|
|
51
|
+
|
|
52
|
+
running = w["BoilerStatus"] > 0.5
|
|
53
|
+
starts = int((running & ~running.shift(1, fill_value=False)).sum())
|
|
54
|
+
|
|
55
|
+
return BoilerCyclingResult(
|
|
56
|
+
equip=equip,
|
|
57
|
+
n_days=round(span_days, 1),
|
|
58
|
+
starts_per_day=round(starts / span_days, 2),
|
|
59
|
+
runtime_pct=round(100.0 * float(running.mean()), 1),
|
|
60
|
+
n_starts=starts,
|
|
61
|
+
coverage_start=str(df.index.min()),
|
|
62
|
+
coverage_end=str(df.index.max()),
|
|
63
|
+
)
|
camber/bps.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Building Performance Standards (BPS) compliance: limit checks, margin, penalty.
|
|
2
|
+
|
|
3
|
+
A growing number of jurisdictions impose **building performance standards** -- a
|
|
4
|
+
cap on a building's annual energy-use intensity (EUI) or greenhouse-gas emissions
|
|
5
|
+
intensity, enforced with an over-the-limit penalty. New York City's Local Law 97
|
|
6
|
+
(NYC Administrative Code Title 28, Article 320) is the motivating example: it sets
|
|
7
|
+
emissions-intensity limits per occupancy group and assesses a civil penalty for
|
|
8
|
+
each metric ton of CO2-equivalent over the limit. Other programs (Washington
|
|
9
|
+
State Clean Buildings, Boston BERDO, the federal/voluntary "national definition
|
|
10
|
+
of a zero-emissions building") follow the same shape: a metric, a limit, and a
|
|
11
|
+
cost for exceeding it.
|
|
12
|
+
|
|
13
|
+
This module is deliberately **jurisdiction-neutral**: it hard-codes no legal
|
|
14
|
+
limit or penalty rate. The caller supplies the limit and an optional generic
|
|
15
|
+
penalty (dollars per unit over) via :class:`BPSStandard`; this module computes the
|
|
16
|
+
compliance margin, percent of limit, over-amount, and resulting penalty. It also
|
|
17
|
+
provides :func:`emissions_intensity`, which converts a per-fuel energy breakdown
|
|
18
|
+
into an annual emissions intensity using caller-supplied emission factors (e.g.
|
|
19
|
+
EPA eGRID for electricity, EPA GHG factors for combustion fuels) so the result
|
|
20
|
+
can be checked against an emissions-type standard.
|
|
21
|
+
|
|
22
|
+
stdlib only -- no numpy/pandas needed for these scalar computations.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import math
|
|
28
|
+
from dataclasses import dataclass, asdict
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class BPSStandard:
|
|
33
|
+
"""A building performance standard: a metric, its limit, and an over-penalty.
|
|
34
|
+
|
|
35
|
+
``metric`` is ``"eui"`` (energy-use intensity) or ``"emissions"`` (emissions
|
|
36
|
+
intensity); both are evaluated as "lower is better" against ``limit`` in the
|
|
37
|
+
given ``unit``. ``penalty_per_unit_over`` is a generic cost (e.g. dollars) per
|
|
38
|
+
unit by which the value exceeds the limit -- supply the jurisdiction's rate, or
|
|
39
|
+
leave at 0 to skip penalty pricing.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
metric: str # "eui" | "emissions"
|
|
44
|
+
limit: float
|
|
45
|
+
unit: str = ""
|
|
46
|
+
penalty_per_unit_over: float = 0.0
|
|
47
|
+
|
|
48
|
+
def as_dict(self) -> dict:
|
|
49
|
+
"""Return the standard as a plain dict."""
|
|
50
|
+
return asdict(self)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class BPSResult:
|
|
55
|
+
"""Compliance of a single value against a :class:`BPSStandard`."""
|
|
56
|
+
|
|
57
|
+
standard_name: str
|
|
58
|
+
metric: str
|
|
59
|
+
value: float
|
|
60
|
+
limit: float
|
|
61
|
+
unit: str
|
|
62
|
+
compliant: bool
|
|
63
|
+
margin: float # limit - value (positive = headroom)
|
|
64
|
+
pct_of_limit: float # 100 * value / limit
|
|
65
|
+
over_amount: float # max(0, value - limit)
|
|
66
|
+
penalty: float # over_amount * penalty_per_unit_over
|
|
67
|
+
verdict: str # "compliant" | "over"
|
|
68
|
+
|
|
69
|
+
def as_dict(self) -> dict:
|
|
70
|
+
"""Return the result as a plain dict."""
|
|
71
|
+
return asdict(self)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def assess_bps(value: float, standard: BPSStandard) -> BPSResult | None:
|
|
75
|
+
"""Assess a measured ``value`` against a BPS ``standard`` (lower is better).
|
|
76
|
+
|
|
77
|
+
Returns ``None`` if the inputs are unusable (non-finite value, or a limit that
|
|
78
|
+
is non-finite or not positive -- a limit must be a positive cap to be
|
|
79
|
+
meaningful). Otherwise returns a :class:`BPSResult` with the compliance margin
|
|
80
|
+
(``limit - value``), the value as a percent of the limit, the over-amount, and
|
|
81
|
+
the resulting penalty.
|
|
82
|
+
"""
|
|
83
|
+
v = float(value)
|
|
84
|
+
lim = float(standard.limit)
|
|
85
|
+
if not math.isfinite(v) or not math.isfinite(lim) or lim <= 0:
|
|
86
|
+
return None
|
|
87
|
+
over = max(0.0, v - lim)
|
|
88
|
+
compliant = v <= lim
|
|
89
|
+
penalty = over * float(standard.penalty_per_unit_over)
|
|
90
|
+
return BPSResult(
|
|
91
|
+
standard_name=standard.name,
|
|
92
|
+
metric=standard.metric,
|
|
93
|
+
value=round(v, 4),
|
|
94
|
+
limit=round(lim, 4),
|
|
95
|
+
unit=standard.unit,
|
|
96
|
+
compliant=compliant,
|
|
97
|
+
margin=round(lim - v, 4),
|
|
98
|
+
pct_of_limit=round(100.0 * v / lim, 2),
|
|
99
|
+
over_amount=round(over, 4),
|
|
100
|
+
penalty=round(penalty, 2),
|
|
101
|
+
verdict="compliant" if compliant else "over",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Site-energy conversion to kBtu per the fuel's native unit (delivered/site energy).
|
|
106
|
+
# Caller can override or extend per project. (Source EUI would additionally apply
|
|
107
|
+
# site-to-source multipliers -- out of scope here; this is site EUI.)
|
|
108
|
+
EUI_FACTORS_KBTU: dict = {
|
|
109
|
+
"electricity": 3.412, # per kWh
|
|
110
|
+
"natural_gas": 100.0, # per therm
|
|
111
|
+
"propane": 91.6, # per gallon
|
|
112
|
+
"fuel_oil": 138.7, # per gallon (No. 2)
|
|
113
|
+
"district_chw": 12.0, # per ton-hour of chilled water
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def site_eui(energy_by_fuel: dict, area_sqft: float, *, factors: dict | None = None) -> float:
|
|
118
|
+
"""Site energy-use intensity (kBtu / sqft / yr) from a per-fuel annual energy breakdown.
|
|
119
|
+
|
|
120
|
+
``energy_by_fuel`` maps a fuel key (``"electricity"`` in kWh, ``"natural_gas"`` in
|
|
121
|
+
therms, ...) to that fuel's annual use; values are converted to kBtu via ``factors``
|
|
122
|
+
(defaults :data:`EUI_FACTORS_KBTU`, merged with any caller overrides) and summed over
|
|
123
|
+
the gross floor area. Fuels missing from ``factors`` contribute zero. Returns ``nan``
|
|
124
|
+
if ``area_sqft`` is not positive. This is *site* EUI (delivered energy), the metric
|
|
125
|
+
most BPS laws and ENERGY STAR site-EUI checks use.
|
|
126
|
+
"""
|
|
127
|
+
if not math.isfinite(area_sqft) or area_sqft <= 0:
|
|
128
|
+
return float("nan")
|
|
129
|
+
fac = {**EUI_FACTORS_KBTU, **(factors or {})}
|
|
130
|
+
total_kbtu = sum(float(energy) * float(fac.get(fuel, 0.0))
|
|
131
|
+
for fuel, energy in energy_by_fuel.items())
|
|
132
|
+
return total_kbtu / float(area_sqft)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def assess_eui(energy_by_fuel: dict, area_sqft: float, eui_limit: float, *,
|
|
136
|
+
name: str = "BPS EUI limit", penalty_per_unit_over: float = 0.0,
|
|
137
|
+
factors: dict | None = None) -> BPSResult | None:
|
|
138
|
+
"""End-to-end: compute site EUI from energy + area, then assess it against a limit.
|
|
139
|
+
|
|
140
|
+
A convenience over :func:`site_eui` + :func:`assess_bps`; ``eui_limit`` is the
|
|
141
|
+
standard's cap in kBtu/sqft/yr (an ENERGY STAR property-type target or a local BPS
|
|
142
|
+
limit), with an optional ``$/(kBtu/sqft)``-over penalty. Returns ``None`` if the EUI
|
|
143
|
+
can't be computed (non-positive area).
|
|
144
|
+
"""
|
|
145
|
+
eui = site_eui(energy_by_fuel, area_sqft, factors=factors)
|
|
146
|
+
if not math.isfinite(eui):
|
|
147
|
+
return None
|
|
148
|
+
return assess_bps(eui, BPSStandard(name=name, metric="eui", limit=eui_limit,
|
|
149
|
+
unit="kBtu/ft2/yr",
|
|
150
|
+
penalty_per_unit_over=penalty_per_unit_over))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def emissions_intensity(energy_by_fuel: dict, factors: dict,
|
|
154
|
+
area_sqft: float) -> float:
|
|
155
|
+
"""Annual emissions intensity (kgCO2e per sqft) from per-fuel energy.
|
|
156
|
+
|
|
157
|
+
``energy_by_fuel`` maps a fuel key (e.g. ``"electricity"``, ``"natural_gas"``)
|
|
158
|
+
to that fuel's annual energy in the unit the matching emission factor expects.
|
|
159
|
+
``factors`` maps the same keys to a caller-supplied emission factor in kgCO2e
|
|
160
|
+
per energy unit (e.g. EPA eGRID for grid electricity, EPA GHG factors for
|
|
161
|
+
combustion fuels). Fuels present in ``energy_by_fuel`` but missing from
|
|
162
|
+
``factors`` contribute zero. Returns ``nan`` if ``area_sqft`` is not positive.
|
|
163
|
+
"""
|
|
164
|
+
if not math.isfinite(area_sqft) or area_sqft <= 0:
|
|
165
|
+
return float("nan")
|
|
166
|
+
total = 0.0
|
|
167
|
+
for fuel, energy in energy_by_fuel.items():
|
|
168
|
+
factor = factors.get(fuel, 0.0)
|
|
169
|
+
total += float(energy) * float(factor)
|
|
170
|
+
return total / float(area_sqft)
|
camber/carbon.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Carbon (greenhouse-gas) accounting from building energy consumption.
|
|
2
|
+
|
|
3
|
+
Converts energy use by fuel to CO2-equivalent emissions using published emission
|
|
4
|
+
factors (EPA eGRID for grid electricity, EIA for fuels). Electricity factors vary
|
|
5
|
+
widely by grid region and over time, so the electricity default here is a neutral
|
|
6
|
+
placeholder -- pass the region's current eGRID factor for a real number. Fuel
|
|
7
|
+
combustion factors are more stable.
|
|
8
|
+
|
|
9
|
+
Factors are kg CO2e per unit; include CH4/N2O via their global-warming potentials
|
|
10
|
+
in the factor where a full CO2e is wanted.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
# kg CO2e per unit. Electricity is grid- and year-specific -- override it.
|
|
18
|
+
DEFAULT_FACTORS: dict = {
|
|
19
|
+
"electricity_kwh": 0.40, # placeholder; supply your eGRID subregion value (~0.4-0.9)
|
|
20
|
+
"natural_gas_therm": 5.30, # EIA combustion factor, kg CO2e/therm
|
|
21
|
+
"natural_gas_kwh": 0.181, # if gas is metered in kWh
|
|
22
|
+
"fuel_oil_gal": 10.21, # kg CO2e/gal (No. 2)
|
|
23
|
+
"propane_gal": 5.72, # kg CO2e/gal
|
|
24
|
+
"district_steam_kbtu": 0.066,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Emissions:
|
|
30
|
+
"""Emissions result: per-fuel and total CO2e."""
|
|
31
|
+
|
|
32
|
+
by_fuel: dict # fuel -> kg CO2e
|
|
33
|
+
total_kg: float # total kg CO2e
|
|
34
|
+
intensity_kg_sf: float # kg CO2e / ft2 (NaN if area not given)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def total_tonnes(self) -> float:
|
|
38
|
+
"""Total CO2e in metric tonnes."""
|
|
39
|
+
return round(self.total_kg / 1000.0, 4)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def emissions(consumption_by_fuel: dict, *, factors: dict | None = None,
|
|
43
|
+
gross_sf: float | None = None) -> Emissions:
|
|
44
|
+
"""Compute CO2e from ``{fuel_key: amount}`` using ``factors`` (kg CO2e/unit).
|
|
45
|
+
|
|
46
|
+
Fuel keys must match the factor keys (see :data:`DEFAULT_FACTORS`). Unknown
|
|
47
|
+
keys raise, so a typo can't silently drop a fuel from the footprint.
|
|
48
|
+
"""
|
|
49
|
+
f = {**DEFAULT_FACTORS, **(factors or {})}
|
|
50
|
+
by_fuel = {}
|
|
51
|
+
for fuel, amount in consumption_by_fuel.items():
|
|
52
|
+
if fuel not in f:
|
|
53
|
+
raise KeyError(f"no emission factor for '{fuel}'; supply via factors=")
|
|
54
|
+
by_fuel[fuel] = round(float(amount) * f[fuel], 4)
|
|
55
|
+
total = round(sum(by_fuel.values()), 4)
|
|
56
|
+
intensity = round(total / gross_sf, 6) if gross_sf else float("nan")
|
|
57
|
+
return Emissions(by_fuel=by_fuel, total_kg=total, intensity_kg_sf=intensity)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Per-box reheat visualization: reheat valve, space temp vs setpoints, OAT.
|
|
2
|
+
|
|
3
|
+
Visual proof for the top reheat offenders. One stacked figure per box:
|
|
4
|
+
(top) HW reheat valve % + box airflow vs setpoint
|
|
5
|
+
(bottom) space temp with heating/cooling setpoint band + OAT on 2nd axis
|
|
6
|
+
Reheat-at-high-OAT events are highlighted.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def box_reheat_figure(df, equip, *, oat=None, valve_thr=5.0,
|
|
15
|
+
cooling_cutoff_f=65.0, occupied_only=True):
|
|
16
|
+
"""Build a 2-panel reheat diagnostic figure for one box. Returns the Figure."""
|
|
17
|
+
import matplotlib.pyplot as plt
|
|
18
|
+
|
|
19
|
+
work = df.copy()
|
|
20
|
+
if occupied_only:
|
|
21
|
+
hour = work.index.hour + work.index.minute / 60.0
|
|
22
|
+
occ = (work.index.dayofweek < 5) & (hour >= 7) & (hour < 18)
|
|
23
|
+
for m in ("WarmUp", "CoolDown"):
|
|
24
|
+
if m in work.columns:
|
|
25
|
+
occ = occ & ~(work[m].fillna(0) > 0.5)
|
|
26
|
+
work = work[occ]
|
|
27
|
+
|
|
28
|
+
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(13, 7), sharex=True)
|
|
29
|
+
|
|
30
|
+
# --- panel 1: reheat valve + airflow ---
|
|
31
|
+
if "HWValve" in work.columns:
|
|
32
|
+
ax1.plot(work.index, work["HWValve"], color="#cc3333", lw=0.6,
|
|
33
|
+
label="HW reheat valve %")
|
|
34
|
+
ax1.set_ylabel("Reheat valve (%)", color="#cc3333")
|
|
35
|
+
ax1.set_ylim(0, 100)
|
|
36
|
+
if "ActFlow" in work.columns:
|
|
37
|
+
axf = ax1.twinx()
|
|
38
|
+
axf.plot(work.index, work["ActFlow"], color="#888888", lw=0.5, alpha=0.7,
|
|
39
|
+
label="Airflow")
|
|
40
|
+
if "ActFlowSP" in work.columns:
|
|
41
|
+
axf.plot(work.index, work["ActFlowSP"], color="#888888", lw=0.5,
|
|
42
|
+
ls="--", alpha=0.7, label="Airflow SP")
|
|
43
|
+
axf.set_ylabel("Airflow (cfm)", color="#888888")
|
|
44
|
+
ax1.set_title(f"{equip} — reheat diagnostic (occupied hours)")
|
|
45
|
+
ax1.legend(loc="upper left", fontsize=8)
|
|
46
|
+
|
|
47
|
+
# --- panel 2: space temp + setpoint band + OAT ---
|
|
48
|
+
if "SpaceTemp" in work.columns:
|
|
49
|
+
ax2.plot(work.index, work["SpaceTemp"], color="#2a7", lw=0.7,
|
|
50
|
+
label="Space temp")
|
|
51
|
+
if "ActHeatSP" in work.columns and "ActCoolSP" in work.columns:
|
|
52
|
+
ax2.fill_between(work.index, work["ActHeatSP"], work["ActCoolSP"],
|
|
53
|
+
color="#cccc44", alpha=0.15, label="Setpoint band")
|
|
54
|
+
ax2.set_ylabel("Temp (°F)")
|
|
55
|
+
if oat is not None:
|
|
56
|
+
oat_a = oat.reindex(work.index).ffill(limit=4)
|
|
57
|
+
ax2b = ax2.twinx()
|
|
58
|
+
ax2b.plot(work.index, oat_a, color="#999", lw=0.5, label="OAT")
|
|
59
|
+
ax2b.set_ylabel("OAT (°F)", color="#999")
|
|
60
|
+
# highlight reheat-at-high-OAT
|
|
61
|
+
if "HWValve" in work.columns:
|
|
62
|
+
hot = (work["HWValve"] > valve_thr) & (oat_a > cooling_cutoff_f)
|
|
63
|
+
ax2.scatter(work.index[hot], work["SpaceTemp"][hot] if "SpaceTemp" in work
|
|
64
|
+
else [cooling_cutoff_f] * int(hot.sum()),
|
|
65
|
+
color="red", s=4, zorder=5, label="reheat @ OAT>65°F")
|
|
66
|
+
ax2.legend(loc="upper left", fontsize=8)
|
|
67
|
+
ax2.set_xlabel("Time")
|
|
68
|
+
fig.tight_layout()
|
|
69
|
+
return fig
|