Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Unit & Regression Tests

on:
push:
pull_request:

jobs:
pytest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: '3.13'
cache: 'pip'

- name: Install dependencies
run: pip install -r requirements.txt

- name: Run unit and regression tests
run: python -m pytest -m "unit or regression" -q --tb=short
9 changes: 9 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,12 @@ repos:
- id: ruff
args: [--fix]
- id: ruff-format

- repo: local
hooks:
- id: pytest-unit
name: pytest (unit tests)
entry: python -m pytest -m unit -x -q --tb=short
language: system
pass_filenames: false
stages: [pre-commit]
10 changes: 10 additions & 0 deletions cloudbuild.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
steps:
# Run integration tests (Tier 3) — full 8760h runs, gated before deployment
- name: 'python:3.13-slim'
entrypoint: bash
args:
- '-c'
- 'pip install -r requirements.txt -q && python -m pytest -m integration -q --tb=short'
id: 'run-integration-tests'
waitFor: ['-']

# Build the container image
- name: 'gcr.io/cloud-builders/docker'
args: ['build', '-t', 'gcr.io/$PROJECT_ID/decarb-tool', '.']
waitFor: ['run-integration-tests']

# Push to Container Registry
- name: 'gcr.io/cloud-builders/docker'
Expand Down
444 changes: 224 additions & 220 deletions notes/testing-scheme.md

Large diffs are not rendered by default.

10 changes: 7 additions & 3 deletions pages/emissions_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,12 +309,14 @@ def handle_emission_group_selection(group_id, metadata_data, selected_ids, store
scen["year"] = default_year
scen["emission_type"] = default_emission_type
scen["ng_emission_rate_gCO2e_per_kWh"] = default_ng_emission_rate
pct_leakage = scen["annual_refrig_leakage_percent"]*100
pct_leakage = scen["annual_refrig_leakage_percent"] * 100
scen["em_scen_name"] = f"{pct_leakage:.0f}% leakage"
elif group_id == "emission_types":
# Set emission type, reset others to defaults
scen["emission_type"] = emission_types[idx % len(emission_types)]
scen["ng_emission_rate_gCO2e_per_kWh"] = ng_emission_rate_values[idx % len(ng_emission_rate_values)]
scen["ng_emission_rate_gCO2e_per_kWh"] = ng_emission_rate_values[
idx % len(ng_emission_rate_values)
]
scen["year"] = default_year
scen["annual_refrig_leakage_percent"] = default_leakage
scen["em_scen_name"] = scen["emission_type"]
Expand Down Expand Up @@ -977,6 +979,8 @@ def update_ng_rate_on_emission_type_change(emission_type, unit_mode):
ng_emission_rate = EmissionScenarioDefaults.NG_EMISSION_RATE_G_KWH.value

if unit_mode == "IP":
ng_emission_rate = format_value(ng_emission_rate, "ng_emission_rate_gCO2e_per_kWh", unit_mode, decimals=2)
ng_emission_rate = format_value(
ng_emission_rate, "ng_emission_rate_gCO2e_per_kWh", unit_mode, decimals=2
)

return ng_emission_rate
1 change: 1 addition & 0 deletions pages/equipment_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -1179,6 +1179,7 @@ def update_sizing_priority(use_cooling, sizing_mode):

# helper to build equipment options for Selects


def _build_equipment_options(
equipment_list, eq_type, unit_mode, include_none=False, none_label="None"
):
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,9 @@ indent-style = "space"
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
markers = [
"unit: fast pure-function and invariant tests, no I/O (<10s total)",
"regression: snapshot + invariant tests using real equipment library (~60s)",
"integration: full 8760-hour end-to-end runs with real load data (2-5min)",
]
addopts = ["-v", "--tb=short", "-ra"]
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ Pygments==2.19.2
pyparsing==3.2.3
pytest==9.0.2
pytest-cov==7.1.0
syrupy==5.5.3
python-dateutil==2.9.0.post0
python-discovery==1.2.0
pytz==2025.2
Expand Down
56 changes: 32 additions & 24 deletions src/energy.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,10 @@ def _equipment_data_validation(library: EquipmentLibrary, scenario_ids: list[str
awhp_c = library.get_equipment(scen.awhp)

if scen.awhp_sizing_priority is None:
raise ValueError(f"AWHP scenario '{scen.eq_scen_id}' requires 'awhp_sizing_priority'.")

raise ValueError(
f"AWHP scenario '{scen.eq_scen_id}' requires 'awhp_sizing_priority'."
)

if not awhp_c.performance_cooling:
raise ValueError(f"Equipment '{awhp_c.eq_id}' lacks cooling performance data.")

Expand Down Expand Up @@ -437,6 +439,7 @@ def _capacity_constraints(

return cap


def _awhp_reference_capacity(
e: Equipment,
performance: PerformanceCurves,
Expand All @@ -462,10 +465,8 @@ def _awhp_reference_capacity(
ref_temp_C = 30.0 # Conservative outdoor temperature for sizing
ref_supply_temp = supply_t

ref_capacity_W = e.performance[load_type].leaving_supply_t[
ref_supply_temp
].capacity_W

ref_capacity_W = e.performance[load_type].leaving_supply_t[ref_supply_temp].capacity_W

cap_ref = interp_vector(
e.performance[load_type].t_out_C,
ref_capacity_W,
Expand Down Expand Up @@ -711,30 +712,37 @@ def loads_to_site_energy(
# Determine reference capacity
if sizing_priority == "heating":
sizing_load = "hhw_W"
cap_ref = _awhp_reference_capacity(awhp_h, awhp_h_performance, awhp_h_supply_t, "heating")

cap_ref = _awhp_reference_capacity(
awhp_h, awhp_h_performance, awhp_h_supply_t, "heating"
)

elif sizing_priority == "cooling":
sizing_load = "chw_W"
cap_ref = _awhp_reference_capacity(awhp_c, awhp_c_performance, awhp_c_supply_t, "cooling")

cap_ref = _awhp_reference_capacity(
awhp_c, awhp_c_performance, awhp_c_supply_t, "cooling"
)

elif sizing_priority == "larger" and sizing_mode in [
"integer_sizing_peak_load",
"fractional_sizing_peak_load",
]:
cap_ref = {
"hhw_W": _awhp_reference_capacity(awhp_h, awhp_h_performance, awhp_h_supply_t, "heating"),
"chw_W": _awhp_reference_capacity(awhp_c, awhp_c_performance, awhp_c_supply_t, "cooling")
"hhw_W": _awhp_reference_capacity(
awhp_h, awhp_h_performance, awhp_h_supply_t, "heating"
),
"chw_W": _awhp_reference_capacity(
awhp_c, awhp_c_performance, awhp_c_supply_t, "cooling"
),
}
num = {
"hhw_W": float(df["hhw_W"].max()) * sizing_value / cap_ref["hhw_W"],
"chw_W": float(df["chw_W"].max()) * sizing_value / cap_ref["chw_W"]
"chw_W": float(df["chw_W"].max()) * sizing_value / cap_ref["chw_W"],
}
sizing_load = max(num, key = num.get)
sizing_load = max(num, key=num.get)
cap_ref = cap_ref[sizing_load]

logger.debug(f"{sizing_load} drives AWHP sizing.")


# Determine number of units
if sizing_mode in [
"integer_sizing_peak_load",
Expand Down Expand Up @@ -901,17 +909,17 @@ def loads_to_site_energy(
awhp_turndown = 0.5
num_compressors = awhp_num / awhp_turndown
# calculate number of "compressors" used to serve heating load
num_compressors_h = np.maximum(0,
np.ceil(
num_compressors
* df[Col.AWHP_HHW_W.value]
/ df[Col.AWHP_CAP_H_W.value]
)
)
num_compressors_h[np.isnan(num_compressors_h)] = 0 # for hours where AWHP heating capacity is 0
num_compressors_h = np.maximum(
0, np.ceil(num_compressors * df[Col.AWHP_HHW_W.value] / df[Col.AWHP_CAP_H_W.value])
)
num_compressors_h[np.isnan(num_compressors_h)] = (
0 # for hours where AWHP heating capacity is 0
)
# remaining compressors can serve cooling load
num_compressors_c = np.maximum(0, num_compressors - num_compressors_h)
awhp_num_c = num_compressors_c * awhp_turndown # number of compressors available to operate in cooling
awhp_num_c = (
num_compressors_c * awhp_turndown
) # number of compressors available to operate in cooling

cap_total_c_W = awhp_cap_c * awhp_num_c
served_c_W = np.minimum(df[Col.CHW_REM_W.value].to_numpy(), cap_total_c_W)
Expand Down
16 changes: 11 additions & 5 deletions src/equipment.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
# --- Models ---
class PerformanceCurves(BaseModel):
"""Equipment performance curves: coefficient of performance (COP), capacity, and outdoor air temperature constraints."""

cop: list[float] | None = None
capacity_W: list[float] | None = None
constraints: dict[str, float] | None = None
Expand All @@ -21,29 +22,36 @@ class Performance(BaseModel):
Leaving supply water temperatures, associated performance curves, and supply water temperature constraints.
Outdoor air temperature curve for AWHPs, capacity curve for WWHPs, constant efficiency for boilers/chillers.
"""
t_out_C: list[float] | None = None

t_out_C: list[float] | None = None
capacity_W: list[float] | None = None
leaving_supply_t: dict[str, PerformanceCurves] | None = None
efficiency: float | None = None
constraints: dict[str, float] | None = None


class Emissions(BaseModel):
""""Equipment emissions data."""
""" "Equipment emissions data."""

co2_kg_per_mwh: float


class Dimensions(BaseModel):
"""Equipment physical dimensions in metres."""

length: float | None = None
height: float | None = None
width: float | None = None


class Electrical(BaseModel):
"""Equipment electrical characteristics: minimum circuit amperage (MCA), voltage, and phase."""

mca: float | None = None
voltage: float | None = None
phase: int | None = None


class Equipment(BaseModel):
eq_id: str
eq_type: str
Expand Down Expand Up @@ -95,9 +103,7 @@ class EquipmentScenario(DotAccessMixin, BaseModel):
awhp_sizing_value: float
awhp_redundancy: int
awhp_use_cooling: bool
awhp_sizing_priority: (
Literal["heating", "cooling", "larger"] | None
) = None
awhp_sizing_priority: Literal["heating", "cooling", "larger"] | None = None
backup_heating: str | None = None
chiller: str | None = None

Expand Down
9 changes: 9 additions & 0 deletions src/loads.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ def _validate(df: pd.DataFrame) -> pd.DataFrame:
if bad_count > 0:
logger.warning(f"{bad_count} invalid values in column {col}, set to NaN")

# Reject negative load values — a negative heating or cooling load indicates
# a data error (unit mismatch, sign convention issue, etc.)
for col in ["heating_W", "cooling_W"]:
if (df[col].dropna() < 0).any():
raise ValueError(
f"Column '{col}' contains negative values. "
"Load values must be >= 0. Check for unit errors or sign convention issues."
)

return df

# --------- Factory methods ---------
Expand Down
Loading
Loading