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.
Files changed (143) hide show
  1. camber/__init__.py +3 -0
  2. camber/ahu.py +137 -0
  3. camber/api/__init__.py +6 -0
  4. camber/api/read.py +52 -0
  5. camber/api/server.py +101 -0
  6. camber/boilercycle.py +63 -0
  7. camber/bps.py +170 -0
  8. camber/carbon.py +57 -0
  9. camber/charts/__init__.py +2 -0
  10. camber/charts/box_reheat.py +69 -0
  11. camber/charts/carpet.py +59 -0
  12. camber/charts/cusum_chart.py +45 -0
  13. camber/charts/energy_signature.py +51 -0
  14. camber/charts/scatter.py +138 -0
  15. camber/charts/timeseries.py +41 -0
  16. camber/charts/zones_chart.py +47 -0
  17. camber/chiller.py +101 -0
  18. camber/chillerstaging.py +161 -0
  19. camber/chwplant.py +125 -0
  20. camber/chwpump.py +106 -0
  21. camber/cli.py +89 -0
  22. camber/comfort.py +189 -0
  23. camber/condenserwater.py +100 -0
  24. camber/config.py +209 -0
  25. camber/coolingtower.py +123 -0
  26. camber/cost.py +99 -0
  27. camber/demand.py +170 -0
  28. camber/eval.py +143 -0
  29. camber/fault_economics.py +316 -0
  30. camber/fdd_g36.py +466 -0
  31. camber/finance.py +121 -0
  32. camber/g36_reset.py +174 -0
  33. camber/iaq.py +100 -0
  34. camber/ingest/__init__.py +32 -0
  35. camber/ingest/bacnet.py +156 -0
  36. camber/ingest/base.py +38 -0
  37. camber/ingest/csv_perpoint.py +56 -0
  38. camber/ingest/csv_wide.py +41 -0
  39. camber/ingest/haystack.py +220 -0
  40. camber/ingest/modbus.py +115 -0
  41. camber/ingest/mqtt_stream.py +126 -0
  42. camber/ingest/opcua.py +182 -0
  43. camber/ingest/quality.py +200 -0
  44. camber/ingest/sql.py +144 -0
  45. camber/integrate/__init__.py +18 -0
  46. camber/integrate/tickets.py +148 -0
  47. camber/interop/__init__.py +13 -0
  48. camber/interop/better.py +82 -0
  49. camber/interop/brick.py +198 -0
  50. camber/interop/export.py +97 -0
  51. camber/interop/openei.py +95 -0
  52. camber/interop/psychro.py +54 -0
  53. camber/interop/pvlib_bridge.py +77 -0
  54. camber/interop/site_model.py +210 -0
  55. camber/interop/tariff_nrel.py +59 -0
  56. camber/inventory.py +119 -0
  57. camber/io.py +40 -0
  58. camber/leakvalve.py +93 -0
  59. camber/lighting.py +63 -0
  60. camber/loadprofile.py +70 -0
  61. camber/mandv/__init__.py +16 -0
  62. camber/mandv/caltrack.py +98 -0
  63. camber/mandv/categorical.py +86 -0
  64. camber/mandv/cusum.py +63 -0
  65. camber/mandv/ecm_savings.py +120 -0
  66. camber/mandv/intervalfit.py +116 -0
  67. camber/mandv/models.py +275 -0
  68. camber/mandv/nonroutine.py +167 -0
  69. camber/mandv/normalized.py +99 -0
  70. camber/mandv/resample.py +108 -0
  71. camber/mandv/retrofit_isolation.py +170 -0
  72. camber/mandv/stats.py +161 -0
  73. camber/mandv/towt.py +131 -0
  74. camber/mandv/weather.py +92 -0
  75. camber/mapping_confidence.py +107 -0
  76. camber/model/__init__.py +1 -0
  77. camber/model/entities.py +269 -0
  78. camber/model/mapping.py +84 -0
  79. camber/model/roles.py +146 -0
  80. camber/oafraction.py +107 -0
  81. camber/overcooling.py +136 -0
  82. camber/overcooling_severity.py +224 -0
  83. camber/plant.py +152 -0
  84. camber/points.py +116 -0
  85. camber/pv.py +88 -0
  86. camber/rcx.py +222 -0
  87. camber/realio.py +127 -0
  88. camber/reheat.py +163 -0
  89. camber/report/__init__.py +7 -0
  90. camber/report/audit.py +172 -0
  91. camber/report/fleet.py +204 -0
  92. camber/resolve.py +173 -0
  93. camber/rules/__init__.py +1 -0
  94. camber/rules/base.py +177 -0
  95. camber/rules/boiler_rule.py +76 -0
  96. camber/rules/boilercycle_rule.py +62 -0
  97. camber/rules/builtin.py +61 -0
  98. camber/rules/chiller_rule.py +70 -0
  99. camber/rules/chillerfleet_rule.py +62 -0
  100. camber/rules/chillerstaging_rule.py +74 -0
  101. camber/rules/chwplant_rule.py +66 -0
  102. camber/rules/chwpump_rule.py +66 -0
  103. camber/rules/condenserwater_rule.py +65 -0
  104. camber/rules/coolingtower_rule.py +74 -0
  105. camber/rules/hwplant_deltat_rule.py +66 -0
  106. camber/rules/hwpump_rule.py +56 -0
  107. camber/rules/iaq_rule.py +67 -0
  108. camber/rules/leakvalve_rule.py +59 -0
  109. camber/rules/oafraction_rule.py +80 -0
  110. camber/rules/overcooling_rule.py +67 -0
  111. camber/rules/overcooling_severity_rule.py +77 -0
  112. camber/rules/reheat_min_rule.py +78 -0
  113. camber/rules/reheat_rule.py +72 -0
  114. camber/rules/satreset_rule.py +72 -0
  115. camber/rules/setback_rule.py +64 -0
  116. camber/rules/simul_hc.py +62 -0
  117. camber/rules/static_rule.py +53 -0
  118. camber/rules/triage.py +183 -0
  119. camber/rules/zones_rule.py +77 -0
  120. camber/satreset.py +145 -0
  121. camber/schedules.py +53 -0
  122. camber/sensordrift.py +138 -0
  123. camber/sensorhealth.py +226 -0
  124. camber/setback.py +92 -0
  125. camber/soo.py +298 -0
  126. camber/soo_library.py +72 -0
  127. camber/staticpressure.py +150 -0
  128. camber/store/__init__.py +12 -0
  129. camber/store/bench.py +108 -0
  130. camber/store/parquet_store.py +355 -0
  131. camber/synth.py +68 -0
  132. camber/tariff.py +275 -0
  133. camber/units.py +58 -0
  134. camber/validation.py +135 -0
  135. camber/water.py +133 -0
  136. camber/zones.py +80 -0
  137. camber_toolkit-0.1.0.dist-info/METADATA +227 -0
  138. camber_toolkit-0.1.0.dist-info/RECORD +143 -0
  139. camber_toolkit-0.1.0.dist-info/WHEEL +5 -0
  140. camber_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  141. camber_toolkit-0.1.0.dist-info/licenses/LICENSE +202 -0
  142. camber_toolkit-0.1.0.dist-info/licenses/NOTICE +7 -0
  143. camber_toolkit-0.1.0.dist-info/top_level.txt +1 -0
camber/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """CAMBER — Commissioning, Analytics & M&V for Building Energy Re-tuning."""
2
+
3
+ __version__ = "0.1.0"
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
@@ -0,0 +1,6 @@
1
+ """Read API for the time-series store (capability-map §8)."""
2
+
3
+ from .read import ReadAPI
4
+ from .server import dispatch, make_server, serve
5
+
6
+ __all__ = ["ReadAPI", "dispatch", "make_server", "serve"]
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,2 @@
1
+ """Charts: diagnostic visuals — heating-vs-cooling scatter, reheat boxes, timeseries, zones,
2
+ load carpet (hour x date heatmap), CUSUM trajectory, and the energy-signature change-point plot."""
@@ -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