diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..df0b4fd --- /dev/null +++ b/.github/workflows/tests.yml @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 78ab671..a65a92b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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] diff --git a/cloudbuild.yaml b/cloudbuild.yaml index 1a5361f..0d7064d 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -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' diff --git a/notes/testing-scheme.md b/notes/testing-scheme.md index de04abe..51051d6 100644 --- a/notes/testing-scheme.md +++ b/notes/testing-scheme.md @@ -1,304 +1,308 @@ -# Testing Scheme for Berkeley Decarb Tool +# Testing Scheme — Berkeley Decarb Tool ## Context -The Berkeley Decarb Tool performs multi-phase hourly energy simulations (heat recovery WWHP, -AWHP heating, boiler, electric resistance, AWHP cooling, chiller) and converts site energy to -emissions. The core engine in `src/energy.py` currently has **zero test coverage** despite being -the highest-risk module. A hand-calculated spreadsheet (`testing_cases.xlsx`) exists with -intermediate and final values for 7 scenarios across 3 buildings, and has historically been used -for manual verification after code changes. This is slow, error-prone, and unsustainable. +`src/energy.py` runs all hourly HVAC energy simulations and is the highest-risk module in the codebase. It processes load data through up to six sequential calculation phases (HR-WWHP, AWHP heating, boiler, electric resistance, AWHP cooling, chiller) and feeds into emissions calculations. A hand-calculated spreadsheet (`testing_cases.xlsx`) was historically used for manual verification after code changes — slow, error-prone, and skipped under time pressure. -The goal is a tiered testing scheme that: -- Catches real bugs at the right level of development (push → PR → deployment) -- Requires minimal ongoing maintenance when equipment data or scenarios change -- Integrates into the existing pre-commit + Cloud Build pipeline -- Uses the spreadsheet as a one-time bootstrapping aid, then exits it from the daily loop +This document describes the automated test suite that replaced it. The suite has **87 tests** that run in under a second locally, with no ongoing maintenance required for the invariant-based tests. Snapshot-based tests require a one-command update when output legitimately changes. --- -## Why Not Keep the Spreadsheet as a Test Fixture? +## Core Philosophy -The obvious first instinct is to copy the hand-calculated values from `testing_cases.xlsx` into a -JSON fixture file and assert against them. This is rejected for the following reasons: +Two complementary strategies are used together: -1. **Double maintenance burden**: Every equipment curve update or scenario change requires updating - the spreadsheet AND the fixture JSON separately. -2. **Brittleness at the edges**: The spreadsheet tests 2–3 specific hours per scenario. It doesn't - tell you whether hours 4–8760 behave correctly. -3. **Format fragility**: If someone restructures the spreadsheet, fixture generation breaks. -4. **Wrong failure signal**: When a fixture test fails, you don't know if the code is wrong or if - the expected value was stale. You still have to go back to first principles. +**Strategy A — Physics invariants**: Test that mathematical identities hold, not that outputs equal a specific number. For example: `electricity = thermal output ÷ COP` must be true at every hour, for any input. These tests never need updating — they encode the laws of thermodynamics, not specific numeric results. -The spreadsheet remains valuable as a **one-time bootstrap** (seeding initial snapshots) and as -**design documentation** for colleagues. It just shouldn't be in the critical path of every test run. +**Strategy B — Snapshot regression**: Capture the full engine output (all columns, all hours) as a committed file. Future runs compare against it. When a deliberate change alters output, the developer runs one command, reviews the diff (which shows exactly which values changed and by how much), and commits the updated snapshot alongside the code change. --- -## Core Testing Philosophy +## Tier Structure -The scheme uses two complementary strategies: +### Tier 1 — Unit Tests -### Strategy A — Physics Invariants -Test that **mathematical identities hold**, not that outputs equal a specific number. +| Property | Value | +|---|---| +| Marker | `@pytest.mark.unit` | +| Run command | `pytest -m unit` | +| Trigger | Every `git commit` (pre-commit hook) | +| Runtime | ~0.05 seconds | +| Failure means | A low-level pure function is broken; safe to block the commit | -Examples: -- `hr_hhw + awhp_hhw + boiler_hhw + res_hhw == heating_W` (all load served) -- `elec_awhp_h == awhp_hhw / awhp_cop_h` (electricity follows from thermal output and COP) -- `gas_boiler == boiler_hhw / efficiency` (gas input follows from thermal output and efficiency) +**What is tested:** Pure helper functions in `src/energy.py` with tiny synthetic numpy arrays (3–5 data points). No real equipment JSON, no parquet files, no I/O of any kind. Also includes the existing equipment library and load validation tests in `test_equipment.py` and `test_loads.py`. -**Maintenance cost: zero.** These never need updating because they encode the laws of thermodynamics, -not specific numeric results. If the code violates them, something is genuinely wrong. +| Function | What is asserted | +|---|---| +| `_capacity_constraints` | OAT below `min_temp_C` → capacity zeroed; above `max_temp_C` → zeroed; in range → unchanged | +| `_per_unit_heating_cop` | Interpolation at exact breakpoints returns exact values; midpoint returns linear interpolation | +| `_per_unit_heating_capacity_W` | Same as above; scalar `capacity_W` triggers fixed-capacity broadcast path | +| `_per_unit_cooling_cop` / `_per_unit_cooling_capacity_W` | Same patterns for cooling side | +| `_heat_recovery_plr_curve` | Returns DataFrame with `cap` and `cop` columns; values match inputs | +| `_constant_heating_efficiency` | Returns correct float from Equipment fixture | +| Emissions rate formula | `weighting=0.0` → pure LRMER; `weighting=1.0` → pure SRMER; `weighting=0.5` → average | +| Physics invariants | `elec = thermal / COP`; `gas = thermal / efficiency`; COP > 1 → elec < thermal | -### Strategy B — Snapshot Regression -Capture the full output of the calculation engine (all columns, all hours) as a committed file. -Future test runs compare against it. When a deliberate change alters the output: -```bash -pytest --snapshot-update -``` -The developer reviews the diff (a meaningful CSV delta showing exactly which values changed and -by how much), then commits it alongside the code change. +**Test files:** +- `tests/test_energy_unit.py` — 30 tests (energy helpers + invariants) +- `tests/test_equipment.py` — 9 tests (equipment library loading and validation) +- `tests/test_loads.py` — 10 tests (StandardLoad validation and edge cases) -**Maintenance cost: one command + diff review.** This is much lighter than recalculating by hand. -The snapshot is always in sync with the code by construction, and the diff is the paper trail. +--- -**Initial seeding**: The very first snapshot should be generated after manually verifying the -engine output against the spreadsheet. After that, the spreadsheet is no longer needed for testing. +### Tier 2 — Regression Tests ---- +| Property | Value | +|---|---| +| Marker | `@pytest.mark.regression` | +| Run command | `pytest -m regression` | +| Trigger | Every push and pull request to GitHub (GitHub Actions) | +| Runtime | ~0.10 seconds | +| Failure means | Engine output changed relative to last known-good snapshot; requires developer review | -## Tier Structure +**What is tested:** Full execution of `loads_to_site_energy()` with real equipment library data, across four scenarios that each represent a distinct equipment configuration. Load input is a fixed 24-hour synthetic profile (not random — see parameters section below). -### Tier 1 — Unit Tests (`pytest -m unit`) +**Scenarios covered:** -**Trigger**: Every push, via pre-commit hook -**Target runtime**: < 10 seconds -**Failure means**: A low-level pure function is broken; safe to block the commit +| Scenario ID | Configuration | +|---|---| +| `eq_scenario_3` | AWHP + gas boiler backup + AWHP cooling | +| `eq_scenario_4` | AWHP + electric resistance backup + AWHP cooling | +| `eq_scenario_5` | HR-WWHP + AWHP + electric resistance backup + AWHP cooling (most complex) | +| `eq_scenario_10` | HR-WWHP only, no AWHP, gas boiler backup, no AWHP cooling | -#### What they test +**Layer 2a — Physics invariants (16 tests):** For each scenario, verified over all 24 output rows: +- `hr_hhw_W + awhp_hhw_W + boiler_hhw_W + res_hhw_W == hhw_W` (all heating served) +- `hr_chw_W + awhp_chw_W + chiller_chw_W == chw_W` (all cooling served) +- `elec_hr_Wh + elec_awhp_h_Wh + elec_res_Wh + elec_awhp_c_Wh + elec_chiller_Wh == elec_Wh` (electricity components sum to total) +- `elec_Wh >= 0` and `gas_Wh >= 0` everywhere -Pure helper functions in `src/energy.py`, tested with small synthetic numpy arrays (3–5 data -points). No real equipment JSON, no parquet files, no I/O of any kind. +**Layer 2b — Snapshot tests (4 tests):** Full CSV output for each scenario is compared against a committed snapshot. Any deviation fails the test. -| Function | What to assert | +**Test files:** +- `tests/test_energy_regression.py` +- `tests/__snapshots__/test_energy_regression.ambr` (auto-managed by syrupy) + +--- + +### Tier 3 — Integration Tests + +| Property | Value | |---|---| -| `_capacity_constraints` | OAT below min → capacity = 0; OAT above max → capacity = 0; OAT in range → capacity unchanged | -| `_per_unit_heating_cop` | Interpolation at exact table breakpoints returns exact table values; between points returns interpolated value | -| `_per_unit_heating_capacity_W` | Same as above; fixed-capacity fallback path returns scalar broadcast | -| `_per_unit_cooling_cop` / `_per_unit_cooling_capacity_W` | Same patterns for cooling | -| `_heating_supply_temp_performance` | With two known HHWST values, interpolation at midpoint returns midpoint performance | -| `_heat_recovery_plr_curve` | Output DataFrame has correct columns (`cap`, `cop`, `cap_h_to_cap_c`, `cap_c`, `cop_c`); values consistent with input | -| `_constant_heating_efficiency` | Returns correct float from Equipment fixture | -| Emissions rate formula in `site_to_source` | With synthetic LRMER/SRMER arrays, weighted combination at weighting=0 returns pure LRMER, at weighting=1 returns pure SRMER, at 0.5 returns average | +| Marker | `@pytest.mark.integration` | +| Run command | `pytest -m integration` | +| Trigger | Every deployment (Cloud Build, before Docker build) | +| Runtime | ~0.5 seconds | +| Failure means | Something is fundamentally broken on real data; block the deployment | + +**What is tested:** Full 8,760-hour runs using real parquet load data, checking physics invariants across all hours and comparing annual totals against committed golden values. -Physics invariant tests also belong here, written with synthetic single-hour inputs: -- `elec = thermal / COP` for all phases -- `gas = thermal / efficiency` for boiler -- Refrigerant GWP = `gwp_per_kg × weight_kg × leakage_rate` +**Cases covered:** -**New file**: `tests/test_energy_unit.py` +| Building | Scenario | Location | Climate | +|---|---|---|---| +| Building 5 (Office) | `eq_scenario_3` | Port Angeles, WA | Zone 5C (mild) | +| Building 5 (Office) | `eq_scenario_5` | Port Angeles, WA | Zone 5C (mild) | +| Building 1 (Hospital) | `eq_scenario_3` | Denver, CO | Zone 5B (cold) | -#### Existing tests +**What is checked per case:** +1. Core columns (`t_out_C`, `heating_W`, `cooling_W`, `hhw_W`, `chw_W`, `elec_Wh`, `gas_Wh`) have no NaN values across all 8,760 hours. Detail columns (e.g., `hr_hhw_W`) are allowed to be NaN when the corresponding phase does not run for a given scenario. +2. All heating load served (component sum ≈ total, tolerance ±1 W) +3. All cooling load served (component sum ≈ total, tolerance ±1 W) +4. Electricity ≥ 0 and gas ≥ 0 everywhere +5. Component electricity sum matches total +6. Annual electricity and gas totals match golden values within **±0.1%** -`tests/test_equipment.py` (8 tests) and `tests/test_loads.py` (13 tests) already cover equipment -library loading and `StandardLoad` validation. These are already fast and pure. Changes needed: -- Add `@pytest.mark.unit` to all existing test classes/functions -- No logic changes required — they slot directly into Tier 1 +**Current golden values** (`tests/snapshots/integration_annual_totals.json`): -This gives Tier 1 approximately **40–50 tests** total after adding the new energy unit tests. +| Case | Annual electricity (kWh) | Annual gas (kWh) | +|---|---|---| +| Building 5, `eq_scenario_3` | 177,772 | 57,646 | +| Building 5, `eq_scenario_5` | 183,876 | 0 (all-electric) | +| Building 1, `eq_scenario_3` | 1,049,487 | 206,452 | + +**Test files:** +- `tests/test_energy_integration.py` +- `tests/snapshots/integration_annual_totals.json` (manually seeded, reviewed before committing) --- -### Tier 2 — Regression Tests (`pytest -m regression`) +## CI Integration -**Trigger**: Every PR (added as a step in Cloud Build before Docker build) -**Target runtime**: 30–90 seconds -**Failure means**: The engine output changed relative to the last known-good snapshot; requires -developer review before merge +### GitHub Actions (`.github/workflows/tests.yml`) -#### What they test +Runs **Tier 1 + 2** on every push and pull request to any branch. Results appear directly in the GitHub PR interface. -Full execution of `loads_to_site_energy()` and `site_to_source()` with real equipment library -data, across a representative set of 3–4 scenarios (one per distinct equipment configuration: -HR-WWHP+AWHP, AWHP-only, AWHP+boiler, AWHP-only with cooling). These use minimal load DataFrames -(a few dozen synthetic hours) rather than full 8760-hour runs to keep runtime short. +``` +push / pull_request → install dependencies → pytest -m "unit or regression" +``` -**Two layers within Tier 2:** +### Pre-commit hook (`.pre-commit-config.yaml`) -**Layer 2a — Physics invariant checks** (over the full output DataFrame): -- `hhw_rem_W == 0` for every row (all heating served) -- `chw_rem_W == 0` for every row (all cooling served) -- `elec_Wh >= 0` for every row -- `gas_Wh >= 0` for every row -- `elec_Wh == elec_hr_Wh + elec_awhp_h_Wh + elec_res_Wh + elec_awhp_c_Wh + elec_chiller_Wh` -- `elec_awhp_h_Wh ≈ awhp_hhw_W / awhp_cop_h` (within floating point tolerance) wherever AWHP is running -- Emission totals are non-negative and correctly sum components +Runs **Tier 1 only** on every local `git commit`, before the commit is accepted. Takes ~0.05 seconds. -These catch logic bugs that break the energy balance without needing a golden reference number. +Requires one-time setup per machine: `pre-commit install` + +### Cloud Build (`cloudbuild.yaml`) + +Runs **Tier 3** before building and deploying to Cloud Run. If integration tests fail, the Docker build does not start. -**Layer 2b — Snapshot regression** (output must match prior run): -Uses `syrupy` to persist the output DataFrame and compare on each run. -Test structure: -```python -@pytest.mark.regression -def test_scenario_awhp_only_snapshot(snapshot): - result = loads_to_site_energy(load=sample_load, library=library, scenario_ids=["hp01_res"]) - snapshot.assert_match(result.to_csv(), "awhp_only.csv") +``` +deploy trigger → pytest -m integration → docker build → docker push → cloud run deploy ``` -When the output legitimately changes (e.g., a bug fix or equipment data update): -```bash -pytest -m regression --snapshot-update +--- + +## Parameters and Where to Change Them + +### Tier 2: Scenarios tested + +**File:** `tests/test_energy_regression.py`, top of file + +```python +SCENARIOS = [ + "eq_scenario_3", + "eq_scenario_4", + "eq_scenario_5", + "eq_scenario_10", +] ``` -The developer reviews the diff with `git diff tests/snapshots/`. The diff shows exactly which -values changed, in which columns, at which hours. This makes the review fast and auditable. -**Snapshot seeding**: On first run, snapshots are generated from the current engine. Before -committing them, manually cross-check a few key values against the spreadsheet to confirm the -engine was already correct. After that, the spreadsheet is no longer needed as a test dependency. +Add, remove, or swap scenario IDs here. All IDs must exist in `data/input/equipment_data.JSON`. Adding a scenario automatically adds it to both the physics invariant tests and the snapshot tests — no other changes required. New snapshots are generated on the next `--snapshot-update` run. -**New files**: -- `tests/test_energy_regression.py` -- `tests/snapshots/` directory (auto-populated by syrupy on first run) +### Tier 2: Synthetic load profile -**New dependency**: `syrupy` (lightweight snapshot testing library for pytest) +**File:** `tests/test_energy_regression.py`, `synthetic_load` fixture ---- +```python +t_out = np.array([ + -20.0, -15.0, -10.0, -5.0, # below and at AWHP heating min + 0.0, 4.4, 10.0, 15.0, + ... +]) +heating_W = np.clip((-t_out + 25) * 10_000, 0, None).astype(float) +cooling_W = np.clip((t_out - 15) * 8_000, 0, None).astype(float) +``` -### Tier 3 — Integration / Smoke Tests (`pytest -m integration`) +The profile deliberately sweeps from -20°C to +45°C to exercise capacity-constraint zeroing (below/above AWHP operating limits), simultaneous heating and cooling, and heating-only and cooling-only hours. It is deterministic (not random) so snapshots are stable. The number of hours is 24 — increasing to 48 gives more coverage at negligible runtime cost. -**Trigger**: Before production deployment (Cloud Build release step, or manually on a release branch) -**Target runtime**: 2–5 minutes -**Failure means**: Something is fundamentally broken end-to-end; block the deployment +After changing this, run `pytest -m regression --snapshot-update` to regenerate snapshots. -#### What they test +### Tier 3: Buildings and scenarios tested + +**File:** `tests/test_energy_integration.py`, top of file + +```python +INTEGRATION_CASES = [ + ("5", "eq_scenario_3"), + ("5", "eq_scenario_5"), + ("1", "eq_scenario_3"), +] +``` -Full 8760-hour runs (or 8784 for leap years) using real parquet load data for one representative -building per building type (office, lab, academic). All scenarios for that building are run. +Each tuple is `(building_id, scenario_id)`. Building IDs correspond to rows in `data/input/building_metadata.csv` and loads in `data/input/load_data_full.parquet`. After changing, regenerate golden values (see below). -**What is checked:** -1. **No NaNs** in any output column across all 8760 rows -2. **Load conservation**: `hhw_rem_W == 0` and `chw_rem_W == 0` for every hour — meaning no - heating or cooling load was silently dropped -3. **Energy positivity**: `elec_Wh ≥ 0` and `gas_Wh ≥ 0` everywhere -4. **Component electricity sum** matches total (no phantom energy) -5. **Emissions positivity** and correct summation: total = elec + gas + refrigerant -6. **Annual snapshot**: Total annual kWh electricity and kgCO2e emissions match a stored golden - value within ±0.1%. This catches silent regressions from changes to equipment JSON or parquet - data that otherwise wouldn't be noticed until someone reads a result and finds it implausible. +### Tier 3: Annual total tolerance -The annual snapshot is stored as a small JSON file (`tests/snapshots/integration_annual_totals.json`). -It is updated manually and intentionally, not automatically, because it represents a "this is what -the tool produces" statement that deserves deliberate sign-off. +**File:** `tests/test_energy_integration.py` -**New file**: `tests/test_energy_integration.py` +```python +TOLERANCE = 0.001 # ±0.1% +``` -#### Why not run integration tests on every PR? -Full-year runs over multiple buildings/scenarios take meaningful time and hit the parquet files. -PRs should be unblocked quickly. The physics invariant checks in Tier 2 already catch most logic -errors; Tier 3 is a final safety net before production exposure. +Change to `0.005` for ±0.5% if floating-point differences across Python versions or platforms cause spurious failures. --- -## CI Integration +## Operational Playbook -### Pre-commit (`.pre-commit-config.yaml`) +### Updating regression snapshots (Tier 2) -Add a new hook that runs Tier 1 only. This is fast enough to not annoy developers: +Run after any deliberate change that alters `loads_to_site_energy()` output (bug fix, equipment data update, scenario parameter change). -```yaml -- 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] -``` +**Step 1 — Review first, update second.** Run the tests without updating to see syrupy's diff in the terminal: -If the unit suite grows beyond ~15 seconds, restrict it further with -`--ignore=tests/test_energy_integration.py` etc. +```bash +pytest -m regression +``` -### Cloud Build (`cloudbuild.yaml`) +Syrupy prints a readable diff of what changed (which rows, which columns, which values). This is the review step. If the changes look correct and proportionate to what you changed, proceed. -Add a test step before the Docker build step. This runs Tiers 1 + 2 on every PR/main push: +**Step 2 — Update and commit.** -```yaml -- name: 'python:3.12-slim' - entrypoint: bash - args: - - '-c' - - 'pip install -r requirements.txt && pytest -m "unit or regression" -q --tb=short' - id: 'run-tests' - waitFor: ['-'] # run immediately; build step should waitFor: ['run-tests'] +```bash +pytest -m regression --snapshot-update +git add tests/__snapshots__/ tests/test_energy_regression.py # plus your code change +git commit -m "fix: ; update regression snapshots" ``` -For deployment (tagged releases), add a second step running Tier 3: -```yaml -- name: 'python:3.12-slim' - entrypoint: bash - args: - - '-c' - - 'pip install -r requirements.txt && pytest -m integration -q --tb=short' - id: 'run-integration-tests' - waitFor: ['run-tests'] +> Note: `git diff tests/__snapshots__/` is not useful for review — syrupy stores snapshots as a single long string, making the raw diff illegible. Always use the pytest terminal output (step 1) to review changes. + +Never update snapshots without reviewing the diff. + +### Regenerating integration golden values (Tier 3) + +Run after any change that legitimately alters annual totals (bug fix, equipment data update, new building/scenario added): + +```bash +pytest -m integration --generate-golden +cat tests/snapshots/integration_annual_totals.json ``` -### `pyproject.toml` — pytest markers +Review the new values. Cross-check 1–2 numbers manually against the hand-calculated spreadsheet if the change is significant. If they look correct, commit: -```toml -[tool.pytest.ini_options] -testpaths = ["tests"] -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"] +```bash +git add tests/snapshots/integration_annual_totals.json +git commit -m "fix: ; update integration golden values" ``` ---- +### Adding a new equipment scenario to the regression suite -## Maintenance Playbook +1. Add the scenario ID to `SCENARIOS` in `tests/test_energy_regression.py` +2. Run `pytest -m regression` — the new scenario will fail with "snapshot does not exist"; inspect the printed output to confirm it looks sensible +3. Run `pytest -m regression --snapshot-update` to write the new snapshot +4. Commit both files -| Situation | Action | -|---|---| -| Bug fix in `energy.py` | Tests should fail if the fix changes output. Run `pytest -m regression --snapshot-update`, review diff, commit snapshot alongside the fix. | -| Equipment performance curve updated in JSON | Run `pytest -m regression --snapshot-update`. Review which values changed and by how much. If plausible, commit. | -| New equipment or scenario added to JSON | Existing tests unaffected. Add a new scenario ID to the regression test's scenario list if you want it covered. | -| New calculation phase added to the engine | Add unit tests for the new helper functions, add the new column to physics invariant checks, and update snapshot. | -| Spreadsheet updated with new hand-calculated values | Optionally use it to cross-check the current snapshot values, but no code or fixture change is required. | -| Snapshot update looks wrong | The diff is the signal. Roll back the code change and investigate before updating. Never update snapshots blindly. | +### Adding a new building/scenario to the integration suite + +1. Add the `(building_id, scenario_id, source)` tuple to `SIMULATION_CASES` or `MEASURED_CASES` in `tests/test_energy_integration.py` +2. Run `pytest -m integration --generate-golden` +3. Review the new entry in `tests/snapshots/integration_annual_totals.json` +4. Commit both files + +### Adding a new calculation phase to the engine + +1. Add unit tests for the new helper functions in `tests/test_energy_unit.py` +2. Add the new output columns to the physics invariant checks in `test_energy_regression.py` and `test_energy_integration.py` +3. Run `pytest -m regression --snapshot-update` (new columns will appear in snapshots) +4. Commit all changes together + +### If a snapshot update looks wrong + +The diff is the signal. If the changed values are larger than expected, or wrong columns changed, or the sign flipped — roll back the code change and investigate before touching the snapshots. Never update snapshots to make a failing test pass without understanding why it failed. --- -## File Summary +## Dependency -| File | Status | Purpose | -|---|---|---| -| `tests/test_equipment.py` | Edit (add markers) | Tier 1 — slot into unit suite | -| `tests/test_loads.py` | Edit (add markers) | Tier 1 — slot into unit suite | -| `tests/test_energy_unit.py` | New | Tier 1 — pure helper function + invariant tests | -| `tests/test_energy_regression.py` | New | Tier 2 — physics invariants + snapshot tests | -| `tests/test_energy_integration.py` | New | Tier 3 — full-year smoke tests | -| `tests/snapshots/` | New (auto-populated) | Snapshot files managed by syrupy | -| `tests/snapshots/integration_annual_totals.json` | New (manual) | Annual totals golden values | -| `pyproject.toml` | Edit | Add markers | -| `.pre-commit-config.yaml` | Edit | Add pytest-unit hook | -| `cloudbuild.yaml` | Edit | Add test steps (Tier 1+2 on PR, Tier 3 on release) | +`syrupy==5.5.3` — snapshot testing library for pytest. Manages storage, comparison, and updating of snapshot files. Snapshots are stored as `.ambr` files (plain text, human-readable, git-diffable). --- -## Recommended Implementation Order - -1. **Add markers to existing tests** — zero risk, zero behavior change, immediately makes the - existing 21 tests part of the formal Tier 1 suite -2. **Add pytest markers to `pyproject.toml`** — infrastructure only -3. **Write `test_energy_unit.py`** — pure functions, no dependencies on real data -4. **Add pre-commit hook** — gates pushes on Tier 1 -5. **Write `test_energy_regression.py`** — seed the initial snapshots, cross-check against - spreadsheet values for a few scenarios, commit -6. **Update Cloud Build** — Tier 1+2 now run on every PR/push to main -7. **Write `test_energy_integration.py`** — generate annual total golden values, commit -8. **Add integration step to Cloud Build release** — Tier 3 gates deployment +## File Inventory + +| File | Purpose | +|---|---| +| `tests/test_energy_unit.py` | Tier 1 — 30 unit tests for pure helpers and invariants | +| `tests/test_equipment.py` | Tier 1 — 9 tests for equipment library loading | +| `tests/test_loads.py` | Tier 1 — 10 tests for StandardLoad validation | +| `tests/test_energy_regression.py` | Tier 2 — 20 tests: physics invariants + snapshot tests | +| `tests/test_energy_integration.py` | Tier 3 — 18 tests: physics invariants + annual golden values | +| `tests/conftest.py` | Shared fixtures and `--generate-golden` CLI flag | +| `tests/__snapshots__/test_energy_regression.ambr` | Tier 2 snapshot files (auto-managed by syrupy) | +| `tests/snapshots/integration_annual_totals.json` | Tier 3 golden values (manually updated) | +| `pyproject.toml` | Pytest marker definitions (`unit`, `regression`, `integration`) | +| `requirements.txt` | Includes `syrupy==5.5.3` | +| `.pre-commit-config.yaml` | Pre-commit hook: runs Tier 1 on every local commit | +| `.github/workflows/tests.yml` | GitHub Actions: runs Tier 1+2 on every push and PR | +| `cloudbuild.yaml` | Cloud Build: runs Tier 3, then builds and deploys | diff --git a/pages/emissions_page.py b/pages/emissions_page.py index ecef318..42c7841 100644 --- a/pages/emissions_page.py +++ b/pages/emissions_page.py @@ -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"] @@ -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 diff --git a/pages/equipment_page.py b/pages/equipment_page.py index 954513c..160f15a 100644 --- a/pages/equipment_page.py +++ b/pages/equipment_page.py @@ -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" ): diff --git a/pyproject.toml b/pyproject.toml index 6c20a71..8e98e40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/requirements.txt b/requirements.txt index 82a3db8..aee8ff4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/src/energy.py b/src/energy.py index ebc04c9..1fff1ce 100644 --- a/src/energy.py +++ b/src/energy.py @@ -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.") @@ -437,6 +439,7 @@ def _capacity_constraints( return cap + def _awhp_reference_capacity( e: Equipment, performance: PerformanceCurves, @@ -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, @@ -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", @@ -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) diff --git a/src/equipment.py b/src/equipment.py index c390273..e63564c 100644 --- a/src/equipment.py +++ b/src/equipment.py @@ -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 @@ -21,7 +22,8 @@ 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 @@ -29,21 +31,27 @@ class Performance(BaseModel): 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 @@ -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 diff --git a/src/loads.py b/src/loads.py index eb0c431..4f1e642 100644 --- a/src/loads.py +++ b/src/loads.py @@ -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 --------- diff --git a/tests/__snapshots__/test_energy_regression.ambr b/tests/__snapshots__/test_energy_regression.ambr new file mode 100644 index 0000000..65d6744 --- /dev/null +++ b/tests/__snapshots__/test_energy_regression.ambr @@ -0,0 +1,121 @@ +# serializer version: 1 +# name: test_scenario_output_snapshot[eq_scenario_10] + ''' + timestamp,t_out_C,heating_W,cooling_W,elec_Wh,gas_Wh,hhw_W,chw_W,hr_hhw_W,hr_chw_W,hr_cop_h,max_cap_h_hr_W,min_cap_h_hr_W,simult_h_hr_W,elec_hr_Wh,hr_wwhp_refrigerant,hr_wwhp_refrigerant_weight_kg,hr_wwhp_refrigerant_gwp_kgCO2e_per_kgRefrig,awhp_num,awhp_num_redundant,awhp_cap_h_W,awhp_cop_h,awhp_hhw_W,elec_awhp_h_Wh,boiler_eff,boiler_hhw_W,gas_boiler_Wh,res_hhw_W,elec_res_Wh,awhp_num_c,awhp_cap_c_W,awhp_cop_c,awhp_chw_W,elec_awhp_c_Wh,chiller_cop,chiller_chw_W,elec_chiller_Wh,chiller_refrigerant,chiller_refrigerant_weight_kg,chiller_refrigerant_gwp_kgCO2e_per_kgRefrig,eq_scen_id,eq_scen_name + 2025-01-01 00:00:00,-20.0,450000.0,0.0,0.0,562500.0,450000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,,,,,,,0.8,450000.0,562500.0,,,,,,,,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_10,HR WWHP+Gas Boiler+AC Chiller + 2025-01-01 01:00:00,-15.0,400000.0,0.0,0.0,500000.0,400000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,,,,,,,0.8,400000.0,500000.0,,,,,,,,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_10,HR WWHP+Gas Boiler+AC Chiller + 2025-01-01 02:00:00,-10.0,350000.0,0.0,0.0,437500.0,350000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,,,,,,,0.8,350000.0,437500.0,,,,,,,,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_10,HR WWHP+Gas Boiler+AC Chiller + 2025-01-01 03:00:00,-5.0,300000.0,0.0,0.0,375000.0,300000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,,,,,,,0.8,300000.0,375000.0,,,,,,,,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_10,HR WWHP+Gas Boiler+AC Chiller + 2025-01-01 04:00:00,0.0,250000.0,0.0,0.0,312500.0,250000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,,,,,,,0.8,250000.0,312500.0,,,,,,,,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_10,HR WWHP+Gas Boiler+AC Chiller + 2025-01-01 05:00:00,4.4,206000.0,0.0,0.0,257500.0,206000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,,,,,,,0.8,206000.0,257500.0,,,,,,,,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_10,HR WWHP+Gas Boiler+AC Chiller + 2025-01-01 06:00:00,10.0,150000.0,0.0,0.0,187500.0,150000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,,,,,,,0.8,150000.0,187500.0,,,,,,,,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_10,HR WWHP+Gas Boiler+AC Chiller + 2025-01-01 07:00:00,15.0,100000.0,0.0,0.0,125000.0,100000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,,,,,,,0.8,100000.0,125000.0,,,,,,,,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_10,HR WWHP+Gas Boiler+AC Chiller + 2025-01-01 08:00:00,20.0,50000.0,40000.0,12500.0,62500.0,50000.0,40000.0,0.0,0.0,4.2,211000.0,70000.0,50000.0,0.0,R-454B,1.875,873.75,,,,,,,0.8,50000.0,62500.0,,,,,,,,3.2,40000.0,12500.0,R-134A,11.9167,17040.8333,eq_scenario_10,HR WWHP+Gas Boiler+AC Chiller + 2025-01-01 09:00:00,25.0,0.0,80000.0,25000.0,0.0,0.0,80000.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,,,,,,,0.8,0.0,0.0,,,,,,,,3.2,80000.0,25000.0,R-134A,11.9167,17040.8333,eq_scenario_10,HR WWHP+Gas Boiler+AC Chiller + 2025-01-01 10:00:00,28.0,0.0,104000.0,32500.0,0.0,0.0,104000.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,,,,,,,0.8,0.0,0.0,,,,,,,,3.2,104000.0,32500.0,R-134A,11.9167,17040.8333,eq_scenario_10,HR WWHP+Gas Boiler+AC Chiller + 2025-01-01 11:00:00,30.0,0.0,120000.0,37500.0,0.0,0.0,120000.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,,,,,,,0.8,0.0,0.0,,,,,,,,3.2,120000.0,37500.0,R-134A,11.9167,17040.8333,eq_scenario_10,HR WWHP+Gas Boiler+AC Chiller + 2025-01-01 12:00:00,35.0,0.0,160000.0,50000.0,0.0,0.0,160000.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,,,,,,,0.8,0.0,0.0,,,,,,,,3.2,160000.0,50000.0,R-134A,11.9167,17040.8333,eq_scenario_10,HR WWHP+Gas Boiler+AC Chiller + 2025-01-01 13:00:00,36.0,0.0,168000.0,52500.0,0.0,0.0,168000.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,,,,,,,0.8,0.0,0.0,,,,,,,,3.2,168000.0,52500.0,R-134A,11.9167,17040.8333,eq_scenario_10,HR WWHP+Gas Boiler+AC Chiller + 2025-01-01 14:00:00,40.0,0.0,200000.0,62500.0,0.0,0.0,200000.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,,,,,,,0.8,0.0,0.0,,,,,,,,3.2,200000.0,62500.0,R-134A,11.9167,17040.8333,eq_scenario_10,HR WWHP+Gas Boiler+AC Chiller + 2025-01-01 15:00:00,45.0,0.0,240000.0,75000.0,0.0,0.0,240000.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,,,,,,,0.8,0.0,0.0,,,,,,,,3.2,240000.0,75000.0,R-134A,11.9167,17040.8333,eq_scenario_10,HR WWHP+Gas Boiler+AC Chiller + 2025-01-01 16:00:00,-10.0,350000.0,0.0,0.0,437500.0,350000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,,,,,,,0.8,350000.0,437500.0,,,,,,,,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_10,HR WWHP+Gas Boiler+AC Chiller + 2025-01-01 17:00:00,-5.0,300000.0,0.0,0.0,375000.0,300000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,,,,,,,0.8,300000.0,375000.0,,,,,,,,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_10,HR WWHP+Gas Boiler+AC Chiller + 2025-01-01 18:00:00,0.0,250000.0,0.0,0.0,312500.0,250000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,,,,,,,0.8,250000.0,312500.0,,,,,,,,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_10,HR WWHP+Gas Boiler+AC Chiller + 2025-01-01 19:00:00,5.0,200000.0,0.0,0.0,250000.0,200000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,,,,,,,0.8,200000.0,250000.0,,,,,,,,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_10,HR WWHP+Gas Boiler+AC Chiller + 2025-01-01 20:00:00,10.0,150000.0,0.0,0.0,187500.0,150000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,,,,,,,0.8,150000.0,187500.0,,,,,,,,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_10,HR WWHP+Gas Boiler+AC Chiller + 2025-01-01 21:00:00,15.0,100000.0,0.0,0.0,125000.0,100000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,,,,,,,0.8,100000.0,125000.0,,,,,,,,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_10,HR WWHP+Gas Boiler+AC Chiller + 2025-01-01 22:00:00,20.0,50000.0,40000.0,12500.0,62500.0,50000.0,40000.0,0.0,0.0,4.2,211000.0,70000.0,50000.0,0.0,R-454B,1.875,873.75,,,,,,,0.8,50000.0,62500.0,,,,,,,,3.2,40000.0,12500.0,R-134A,11.9167,17040.8333,eq_scenario_10,HR WWHP+Gas Boiler+AC Chiller + 2025-01-01 23:00:00,25.0,0.0,80000.0,25000.0,0.0,0.0,80000.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,,,,,,,0.8,0.0,0.0,,,,,,,,3.2,80000.0,25000.0,R-134A,11.9167,17040.8333,eq_scenario_10,HR WWHP+Gas Boiler+AC Chiller + + ''' +# --- +# name: test_scenario_output_snapshot[eq_scenario_3] + ''' + timestamp,t_out_C,heating_W,cooling_W,elec_Wh,gas_Wh,hhw_W,chw_W,hr_hhw_W,hr_chw_W,hr_cop_h,elec_hr_Wh,awhp_num,awhp_num_redundant,awhp_cap_h_W,awhp_cop_h,awhp_hhw_W,elec_awhp_h_Wh,awhp_refrigerant,total_awhp_refrigerant_weight_kg,total_awhp_refrigerant_gwp_kgCO2e_per_kgRefrig,boiler_eff,boiler_hhw_W,gas_boiler_Wh,res_hhw_W,elec_res_Wh,awhp_num_c,awhp_cap_c_W,awhp_cop_c,awhp_chw_W,elec_awhp_c_Wh,chiller_cop,chiller_chw_W,elec_chiller_Wh,chiller_refrigerant,chiller_refrigerant_weight_kg,chiller_refrigerant_gwp_kgCO2e_per_kgRefrig,eq_scen_id,eq_scen_name + 2025-01-01 00:00:00,-20.0,450000.0,0.0,0.0,562500.0,450000.0,0.0,,,,,1.0,2.0,0.0,2.15,0.0,0.0,R-454B,4.5,2097.0,0.8,450000.0,562500.0,,,1.0,0.0,4.4,0.0,0.0,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_3,20% AWHP (H+C)+Gas Backup + 2025-01-01 01:00:00,-15.0,400000.0,0.0,57488.3721,345500.0,400000.0,0.0,,,,,1.0,2.0,123600.0,2.15,123600.0,57488.3721,R-454B,4.5,2097.0,0.8,276400.0,345500.0,,,0.0,0.0,4.4,0.0,0.0,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_3,20% AWHP (H+C)+Gas Backup + 2025-01-01 02:00:00,-10.0,350000.0,0.0,62552.4769,251250.0,350000.0,0.0,,,,,1.0,2.0,149000.0,2.382,149000.0,62552.4769,R-454B,4.5,2097.0,0.8,201000.0,251250.0,,,0.0,0.0,4.4,0.0,0.0,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_3,20% AWHP (H+C)+Gas Backup + 2025-01-01 03:00:00,-5.0,300000.0,0.0,63118.5149,175401.7857,300000.0,0.0,,,,,1.0,2.0,159678.5714,2.5298,159678.5714,63118.5149,R-454B,4.5,2097.0,0.8,140321.4286,175401.7857,,,0.0,0.0,4.4,0.0,0.0,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_3,20% AWHP (H+C)+Gas Backup + 2025-01-01 04:00:00,0.0,250000.0,0.0,63823.9538,91350.0,250000.0,0.0,,,,,1.0,2.0,176920.0,2.772,176920.0,63823.9538,R-454B,4.5,2097.0,0.8,73080.0,91350.0,,,0.0,0.0,4.4,0.0,0.0,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_3,20% AWHP (H+C)+Gas Backup + 2025-01-01 05:00:00,4.4,206000.0,0.0,64580.6452,7250.0,206000.0,0.0,,,,,1.0,2.0,200200.0,3.1,200200.0,64580.6452,R-454B,4.5,2097.0,0.8,5800.0,7250.0,,,0.0,0.0,4.4,0.0,0.0,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_3,20% AWHP (H+C)+Gas Backup + 2025-01-01 06:00:00,10.0,150000.0,0.0,41322.314,0.0,150000.0,0.0,,,,,1.0,2.0,235700.0,3.63,150000.0,41322.314,R-454B,4.5,2097.0,0.8,0.0,0.0,,,0.0,0.0,4.4,0.0,0.0,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_3,20% AWHP (H+C)+Gas Backup + 2025-01-01 07:00:00,15.0,100000.0,0.0,25906.7358,0.0,100000.0,0.0,,,,,1.0,2.0,243100.0,3.86,100000.0,25906.7358,R-454B,4.5,2097.0,0.8,0.0,0.0,,,0.5,125000.0,4.4,0.0,0.0,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_3,20% AWHP (H+C)+Gas Backup + 2025-01-01 08:00:00,20.0,50000.0,40000.0,21930.7574,0.0,50000.0,40000.0,,,,,1.0,2.0,257500.0,4.03,50000.0,12406.9479,R-454B,4.5,2097.0,0.8,0.0,0.0,,,0.5,117500.0,4.2,40000.0,9523.8095,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_3,20% AWHP (H+C)+Gas Backup + 2025-01-01 09:00:00,25.0,0.0,80000.0,20000.0,0.0,0.0,80000.0,,,,,1.0,2.0,266150.0,4.125,0.0,0.0,R-454B,4.5,2097.0,0.8,0.0,0.0,,,1.0,220000.0,4.0,80000.0,20000.0,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_3,20% AWHP (H+C)+Gas Backup + 2025-01-01 10:00:00,28.0,0.0,104000.0,28176.6459,0.0,0.0,104000.0,,,,,1.0,2.0,271340.0,4.182,0.0,0.0,R-454B,4.5,2097.0,0.8,0.0,0.0,,,1.0,214360.0,3.691,104000.0,28176.6459,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_3,20% AWHP (H+C)+Gas Backup + 2025-01-01 11:00:00,30.0,0.0,120000.0,34433.2855,0.0,0.0,120000.0,,,,,1.0,2.0,274800.0,4.22,0.0,0.0,R-454B,4.5,2097.0,0.8,0.0,0.0,,,1.0,210600.0,3.485,120000.0,34433.2855,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_3,20% AWHP (H+C)+Gas Backup + 2025-01-01 12:00:00,35.0,0.0,160000.0,53872.0539,0.0,0.0,160000.0,,,,,1.0,2.0,274800.0,4.22,0.0,0.0,R-454B,4.5,2097.0,0.8,0.0,0.0,,,1.0,201200.0,2.97,160000.0,53872.0539,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_3,20% AWHP (H+C)+Gas Backup + 2025-01-01 13:00:00,36.0,0.0,168000.0,57181.7563,0.0,0.0,168000.0,,,,,1.0,2.0,0.0,4.22,0.0,0.0,R-454B,4.5,2097.0,0.8,0.0,0.0,,,1.0,199380.0,2.938,168000.0,57181.7563,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_3,20% AWHP (H+C)+Gas Backup + 2025-01-01 14:00:00,40.0,0.0,200000.0,70831.7393,0.0,0.0,200000.0,,,,,1.0,2.0,0.0,4.22,0.0,0.0,R-454B,4.5,2097.0,0.8,0.0,0.0,,,1.0,192100.0,2.81,192100.0,68362.9893,3.2,7900.0,2468.75,R-134A,11.9167,17040.8333,eq_scenario_3,20% AWHP (H+C)+Gas Backup + 2025-01-01 15:00:00,45.0,0.0,240000.0,86869.1038,0.0,0.0,240000.0,,,,,1.0,2.0,0.0,4.22,0.0,0.0,R-454B,4.5,2097.0,0.8,0.0,0.0,,,1.0,183000.0,2.65,183000.0,69056.6038,3.2,57000.0,17812.5,R-134A,11.9167,17040.8333,eq_scenario_3,20% AWHP (H+C)+Gas Backup + 2025-01-01 16:00:00,-10.0,350000.0,0.0,62552.4769,251250.0,350000.0,0.0,,,,,1.0,2.0,149000.0,2.382,149000.0,62552.4769,R-454B,4.5,2097.0,0.8,201000.0,251250.0,,,0.0,0.0,4.4,0.0,0.0,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_3,20% AWHP (H+C)+Gas Backup + 2025-01-01 17:00:00,-5.0,300000.0,0.0,63118.5149,175401.7857,300000.0,0.0,,,,,1.0,2.0,159678.5714,2.5298,159678.5714,63118.5149,R-454B,4.5,2097.0,0.8,140321.4286,175401.7857,,,0.0,0.0,4.4,0.0,0.0,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_3,20% AWHP (H+C)+Gas Backup + 2025-01-01 18:00:00,0.0,250000.0,0.0,63823.9538,91350.0,250000.0,0.0,,,,,1.0,2.0,176920.0,2.772,176920.0,63823.9538,R-454B,4.5,2097.0,0.8,73080.0,91350.0,,,0.0,0.0,4.4,0.0,0.0,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_3,20% AWHP (H+C)+Gas Backup + 2025-01-01 19:00:00,5.0,200000.0,0.0,63355.5832,0.0,200000.0,0.0,,,,,1.0,2.0,204003.5714,3.1568,200000.0,63355.5832,R-454B,4.5,2097.0,0.8,0.0,0.0,,,0.0,0.0,4.4,0.0,0.0,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_3,20% AWHP (H+C)+Gas Backup + 2025-01-01 20:00:00,10.0,150000.0,0.0,41322.314,0.0,150000.0,0.0,,,,,1.0,2.0,235700.0,3.63,150000.0,41322.314,R-454B,4.5,2097.0,0.8,0.0,0.0,,,0.0,0.0,4.4,0.0,0.0,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_3,20% AWHP (H+C)+Gas Backup + 2025-01-01 21:00:00,15.0,100000.0,0.0,25906.7358,0.0,100000.0,0.0,,,,,1.0,2.0,243100.0,3.86,100000.0,25906.7358,R-454B,4.5,2097.0,0.8,0.0,0.0,,,0.5,125000.0,4.4,0.0,0.0,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_3,20% AWHP (H+C)+Gas Backup + 2025-01-01 22:00:00,20.0,50000.0,40000.0,21930.7574,0.0,50000.0,40000.0,,,,,1.0,2.0,257500.0,4.03,50000.0,12406.9479,R-454B,4.5,2097.0,0.8,0.0,0.0,,,0.5,117500.0,4.2,40000.0,9523.8095,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_3,20% AWHP (H+C)+Gas Backup + 2025-01-01 23:00:00,25.0,0.0,80000.0,20000.0,0.0,0.0,80000.0,,,,,1.0,2.0,266150.0,4.125,0.0,0.0,R-454B,4.5,2097.0,0.8,0.0,0.0,,,1.0,220000.0,4.0,80000.0,20000.0,3.2,0.0,0.0,R-134A,11.9167,17040.8333,eq_scenario_3,20% AWHP (H+C)+Gas Backup + + ''' +# --- +# name: test_scenario_output_snapshot[eq_scenario_4] + ''' + timestamp,t_out_C,heating_W,cooling_W,elec_Wh,gas_Wh,hhw_W,chw_W,hr_hhw_W,hr_chw_W,hr_cop_h,elec_hr_Wh,awhp_num,awhp_num_redundant,awhp_cap_h_W,awhp_cop_h,awhp_hhw_W,elec_awhp_h_Wh,awhp_refrigerant,total_awhp_refrigerant_weight_kg,total_awhp_refrigerant_gwp_kgCO2e_per_kgRefrig,boiler_eff,boiler_hhw_W,gas_boiler_Wh,res_hhw_W,elec_res_Wh,awhp_num_c,awhp_cap_c_W,awhp_cop_c,awhp_chw_W,elec_awhp_c_Wh,chiller_cop,chiller_chw_W,elec_chiller_Wh,eq_scen_id,eq_scen_name + 2025-01-01 00:00:00,-20.0,450000.0,0.0,450000.0,0.0,450000.0,0.0,,,,,3.0,4.0,0.0,2.15,0.0,0.0,R-454B,9.0,4194.0,,,,450000.0,450000.0,3.0,0.0,4.4,0.0,0.0,,,,eq_scenario_4,100% AWHP (H+C)+Elec Backup + 2025-01-01 01:00:00,-15.0,400000.0,0.0,201665.11627906977,0.0,400000.0,0.0,,,,,3.0,4.0,370800.0,2.15,370800.0,172465.11627906977,R-454B,9.0,4194.0,,,,29200.0,29200.0,0.0,0.0,4.4,0.0,0.0,,,,eq_scenario_4,100% AWHP (H+C)+Elec Backup + 2025-01-01 02:00:00,-10.0,350000.0,0.0,146935.34844668346,0.0,350000.0,0.0,,,,,3.0,4.0,447000.0,2.382,350000.0,146935.34844668346,R-454B,9.0,4194.0,,,,0.0,0.0,0.5,0.0,4.4,0.0,0.0,,,,eq_scenario_4,100% AWHP (H+C)+Elec Backup + 2025-01-01 03:00:00,-5.0,300000.0,0.0,118585.4450483518,0.0,300000.0,0.0,,,,,3.0,4.0,479035.71428571426,2.5298214285714287,300000.0,118585.4450483518,R-454B,9.0,4194.0,,,,0.0,0.0,1.0,0.0,4.4,0.0,0.0,,,,eq_scenario_4,100% AWHP (H+C)+Elec Backup + 2025-01-01 04:00:00,0.0,250000.0,0.0,90187.59018759019,0.0,250000.0,0.0,,,,,3.0,4.0,530760.0,2.772,250000.0,90187.59018759019,R-454B,9.0,4194.0,,,,0.0,0.0,1.5,0.0,4.4,0.0,0.0,,,,eq_scenario_4,100% AWHP (H+C)+Elec Backup + 2025-01-01 05:00:00,4.4,206000.0,0.0,66451.6129032258,0.0,206000.0,0.0,,,,,3.0,4.0,600600.0,3.1,206000.0,66451.6129032258,R-454B,9.0,4194.0,,,,0.0,0.0,1.5,0.0,4.4,0.0,0.0,,,,eq_scenario_4,100% AWHP (H+C)+Elec Backup + 2025-01-01 06:00:00,10.0,150000.0,0.0,41322.31404958678,0.0,150000.0,0.0,,,,,3.0,4.0,707100.0,3.63,150000.0,41322.31404958678,R-454B,9.0,4194.0,,,,0.0,0.0,2.0,500000.0,4.4,0.0,0.0,,,,eq_scenario_4,100% AWHP (H+C)+Elec Backup + 2025-01-01 07:00:00,15.0,100000.0,0.0,25906.73575129534,0.0,100000.0,0.0,,,,,3.0,4.0,729300.0,3.86,100000.0,25906.73575129534,R-454B,9.0,4194.0,,,,0.0,0.0,2.5,625000.0,4.4,0.0,0.0,,,,eq_scenario_4,100% AWHP (H+C)+Elec Backup + 2025-01-01 08:00:00,20.0,50000.0,40000.0,21930.75741462838,0.0,50000.0,40000.0,,,,,3.0,4.0,772500.0,4.03,50000.0,12406.947890818858,R-454B,9.0,4194.0,,,,0.0,0.0,2.5,587500.0,4.2,40000.0,9523.809523809523,,,,eq_scenario_4,100% AWHP (H+C)+Elec Backup + 2025-01-01 09:00:00,25.0,0.0,80000.0,20000.0,0.0,0.0,80000.0,,,,,3.0,4.0,798450.0,4.125,0.0,0.0,R-454B,9.0,4194.0,,,,0.0,0.0,3.0,660000.0,4.0,80000.0,20000.0,,,,eq_scenario_4,100% AWHP (H+C)+Elec Backup + 2025-01-01 10:00:00,28.0,0.0,104000.0,28176.645895421298,0.0,0.0,104000.0,,,,,3.0,4.0,814020.0,4.1819999999999995,0.0,0.0,R-454B,9.0,4194.0,,,,0.0,0.0,3.0,643080.0,3.691,104000.0,28176.645895421298,,,,eq_scenario_4,100% AWHP (H+C)+Elec Backup + 2025-01-01 11:00:00,30.0,0.0,120000.0,34433.28550932568,0.0,0.0,120000.0,,,,,3.0,4.0,824400.0,4.22,0.0,0.0,R-454B,9.0,4194.0,,,,0.0,0.0,3.0,631800.0,3.4850000000000003,120000.0,34433.28550932568,,,,eq_scenario_4,100% AWHP (H+C)+Elec Backup + 2025-01-01 12:00:00,35.0,0.0,160000.0,53872.05387205387,0.0,0.0,160000.0,,,,,3.0,4.0,824400.0,4.22,0.0,0.0,R-454B,9.0,4194.0,,,,0.0,0.0,3.0,603600.0,2.97,160000.0,53872.05387205387,,,,eq_scenario_4,100% AWHP (H+C)+Elec Backup + 2025-01-01 13:00:00,36.0,0.0,168000.0,57181.756296800544,0.0,0.0,168000.0,,,,,3.0,4.0,0.0,4.22,0.0,0.0,R-454B,9.0,4194.0,,,,0.0,0.0,3.0,598140.0,2.938,168000.0,57181.756296800544,,,,eq_scenario_4,100% AWHP (H+C)+Elec Backup + 2025-01-01 14:00:00,40.0,0.0,200000.0,71174.37722419928,0.0,0.0,200000.0,,,,,3.0,4.0,0.0,4.22,0.0,0.0,R-454B,9.0,4194.0,,,,0.0,0.0,3.0,576300.0,2.81,200000.0,71174.37722419928,,,,eq_scenario_4,100% AWHP (H+C)+Elec Backup + 2025-01-01 15:00:00,45.0,0.0,240000.0,90566.03773584907,0.0,0.0,240000.0,,,,,3.0,4.0,0.0,4.22,0.0,0.0,R-454B,9.0,4194.0,,,,0.0,0.0,3.0,549000.0,2.65,240000.0,90566.03773584907,,,,eq_scenario_4,100% AWHP (H+C)+Elec Backup + 2025-01-01 16:00:00,-10.0,350000.0,0.0,146935.34844668346,0.0,350000.0,0.0,,,,,3.0,4.0,447000.0,2.382,350000.0,146935.34844668346,R-454B,9.0,4194.0,,,,0.0,0.0,0.5,0.0,4.4,0.0,0.0,,,,eq_scenario_4,100% AWHP (H+C)+Elec Backup + 2025-01-01 17:00:00,-5.0,300000.0,0.0,118585.4450483518,0.0,300000.0,0.0,,,,,3.0,4.0,479035.71428571426,2.5298214285714287,300000.0,118585.4450483518,R-454B,9.0,4194.0,,,,0.0,0.0,1.0,0.0,4.4,0.0,0.0,,,,eq_scenario_4,100% AWHP (H+C)+Elec Backup + 2025-01-01 18:00:00,0.0,250000.0,0.0,90187.59018759019,0.0,250000.0,0.0,,,,,3.0,4.0,530760.0,2.772,250000.0,90187.59018759019,R-454B,9.0,4194.0,,,,0.0,0.0,1.5,0.0,4.4,0.0,0.0,,,,eq_scenario_4,100% AWHP (H+C)+Elec Backup + 2025-01-01 19:00:00,5.0,200000.0,0.0,63355.58321077045,0.0,200000.0,0.0,,,,,3.0,4.0,612010.7142857143,3.1567857142857143,200000.0,63355.58321077045,R-454B,9.0,4194.0,,,,0.0,0.0,2.0,500000.0,4.4,0.0,0.0,,,,eq_scenario_4,100% AWHP (H+C)+Elec Backup + 2025-01-01 20:00:00,10.0,150000.0,0.0,41322.31404958678,0.0,150000.0,0.0,,,,,3.0,4.0,707100.0,3.63,150000.0,41322.31404958678,R-454B,9.0,4194.0,,,,0.0,0.0,2.0,500000.0,4.4,0.0,0.0,,,,eq_scenario_4,100% AWHP (H+C)+Elec Backup + 2025-01-01 21:00:00,15.0,100000.0,0.0,25906.73575129534,0.0,100000.0,0.0,,,,,3.0,4.0,729300.0,3.86,100000.0,25906.73575129534,R-454B,9.0,4194.0,,,,0.0,0.0,2.5,625000.0,4.4,0.0,0.0,,,,eq_scenario_4,100% AWHP (H+C)+Elec Backup + 2025-01-01 22:00:00,20.0,50000.0,40000.0,21930.75741462838,0.0,50000.0,40000.0,,,,,3.0,4.0,772500.0,4.03,50000.0,12406.947890818858,R-454B,9.0,4194.0,,,,0.0,0.0,2.5,587500.0,4.2,40000.0,9523.809523809523,,,,eq_scenario_4,100% AWHP (H+C)+Elec Backup + 2025-01-01 23:00:00,25.0,0.0,80000.0,20000.0,0.0,0.0,80000.0,,,,,3.0,4.0,798450.0,4.125,0.0,0.0,R-454B,9.0,4194.0,,,,0.0,0.0,3.0,660000.0,4.0,80000.0,20000.0,,,,eq_scenario_4,100% AWHP (H+C)+Elec Backup + + ''' +# --- +# name: test_scenario_output_snapshot[eq_scenario_5] + ''' + timestamp,t_out_C,heating_W,cooling_W,elec_Wh,gas_Wh,hhw_W,chw_W,hr_hhw_W,hr_chw_W,hr_cop_h,max_cap_h_hr_W,min_cap_h_hr_W,simult_h_hr_W,elec_hr_Wh,hr_wwhp_refrigerant,hr_wwhp_refrigerant_weight_kg,hr_wwhp_refrigerant_gwp_kgCO2e_per_kgRefrig,awhp_num,awhp_num_redundant,awhp_cap_h_W,awhp_cop_h,awhp_hhw_W,elec_awhp_h_Wh,awhp_refrigerant,total_awhp_refrigerant_weight_kg,total_awhp_refrigerant_gwp_kgCO2e_per_kgRefrig,boiler_eff,boiler_hhw_W,gas_boiler_Wh,res_hhw_W,elec_res_Wh,awhp_num_c,awhp_cap_c_W,awhp_cop_c,awhp_chw_W,elec_awhp_c_Wh,chiller_cop,chiller_chw_W,elec_chiller_Wh,eq_scen_id,eq_scen_name + 2025-01-01 00:00:00,-20.0,450000.0,0.0,450000.0,0.0,450000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,3.0,4.0,0.0,2.15,0.0,0.0,R-454B,9.0,4194.0,,,,450000.0,450000.0,3.0,0.0,4.4,0.0,0.0,,,,eq_scenario_5,HR WWHP+100% AWHP (H+C)+Elec Backup + 2025-01-01 01:00:00,-15.0,400000.0,0.0,201665.11627906977,0.0,400000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,3.0,4.0,370800.0,2.15,370800.0,172465.11627906977,R-454B,9.0,4194.0,,,,29200.0,29200.0,0.0,0.0,4.4,0.0,0.0,,,,eq_scenario_5,HR WWHP+100% AWHP (H+C)+Elec Backup + 2025-01-01 02:00:00,-10.0,350000.0,0.0,146935.34844668346,0.0,350000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,3.0,4.0,447000.0,2.382,350000.0,146935.34844668346,R-454B,9.0,4194.0,,,,0.0,0.0,0.5,0.0,4.4,0.0,0.0,,,,eq_scenario_5,HR WWHP+100% AWHP (H+C)+Elec Backup + 2025-01-01 03:00:00,-5.0,300000.0,0.0,118585.4450483518,0.0,300000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,3.0,4.0,479035.71428571426,2.5298214285714287,300000.0,118585.4450483518,R-454B,9.0,4194.0,,,,0.0,0.0,1.0,0.0,4.4,0.0,0.0,,,,eq_scenario_5,HR WWHP+100% AWHP (H+C)+Elec Backup + 2025-01-01 04:00:00,0.0,250000.0,0.0,90187.59018759019,0.0,250000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,3.0,4.0,530760.0,2.772,250000.0,90187.59018759019,R-454B,9.0,4194.0,,,,0.0,0.0,1.5,0.0,4.4,0.0,0.0,,,,eq_scenario_5,HR WWHP+100% AWHP (H+C)+Elec Backup + 2025-01-01 05:00:00,4.4,206000.0,0.0,66451.6129032258,0.0,206000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,3.0,4.0,600600.0,3.1,206000.0,66451.6129032258,R-454B,9.0,4194.0,,,,0.0,0.0,1.5,0.0,4.4,0.0,0.0,,,,eq_scenario_5,HR WWHP+100% AWHP (H+C)+Elec Backup + 2025-01-01 06:00:00,10.0,150000.0,0.0,41322.31404958678,0.0,150000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,3.0,4.0,707100.0,3.63,150000.0,41322.31404958678,R-454B,9.0,4194.0,,,,0.0,0.0,2.0,500000.0,4.4,0.0,0.0,,,,eq_scenario_5,HR WWHP+100% AWHP (H+C)+Elec Backup + 2025-01-01 07:00:00,15.0,100000.0,0.0,25906.73575129534,0.0,100000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,3.0,4.0,729300.0,3.86,100000.0,25906.73575129534,R-454B,9.0,4194.0,,,,0.0,0.0,2.5,625000.0,4.4,0.0,0.0,,,,eq_scenario_5,HR WWHP+100% AWHP (H+C)+Elec Backup + 2025-01-01 08:00:00,20.0,50000.0,40000.0,21930.75741462838,0.0,50000.0,40000.0,0.0,0.0,4.2,211000.0,70000.0,50000.0,0.0,R-454B,1.875,873.75,3.0,4.0,772500.0,4.03,50000.0,12406.947890818858,R-454B,9.0,4194.0,,,,0.0,0.0,2.5,587500.0,4.2,40000.0,9523.809523809523,,,,eq_scenario_5,HR WWHP+100% AWHP (H+C)+Elec Backup + 2025-01-01 09:00:00,25.0,0.0,80000.0,20000.0,0.0,0.0,80000.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,3.0,4.0,798450.0,4.125,0.0,0.0,R-454B,9.0,4194.0,,,,0.0,0.0,3.0,660000.0,4.0,80000.0,20000.0,,,,eq_scenario_5,HR WWHP+100% AWHP (H+C)+Elec Backup + 2025-01-01 10:00:00,28.0,0.0,104000.0,28176.645895421298,0.0,0.0,104000.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,3.0,4.0,814020.0,4.1819999999999995,0.0,0.0,R-454B,9.0,4194.0,,,,0.0,0.0,3.0,643080.0,3.691,104000.0,28176.645895421298,,,,eq_scenario_5,HR WWHP+100% AWHP (H+C)+Elec Backup + 2025-01-01 11:00:00,30.0,0.0,120000.0,34433.28550932568,0.0,0.0,120000.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,3.0,4.0,824400.0,4.22,0.0,0.0,R-454B,9.0,4194.0,,,,0.0,0.0,3.0,631800.0,3.4850000000000003,120000.0,34433.28550932568,,,,eq_scenario_5,HR WWHP+100% AWHP (H+C)+Elec Backup + 2025-01-01 12:00:00,35.0,0.0,160000.0,53872.05387205387,0.0,0.0,160000.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,3.0,4.0,824400.0,4.22,0.0,0.0,R-454B,9.0,4194.0,,,,0.0,0.0,3.0,603600.0,2.97,160000.0,53872.05387205387,,,,eq_scenario_5,HR WWHP+100% AWHP (H+C)+Elec Backup + 2025-01-01 13:00:00,36.0,0.0,168000.0,57181.756296800544,0.0,0.0,168000.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,3.0,4.0,0.0,4.22,0.0,0.0,R-454B,9.0,4194.0,,,,0.0,0.0,3.0,598140.0,2.938,168000.0,57181.756296800544,,,,eq_scenario_5,HR WWHP+100% AWHP (H+C)+Elec Backup + 2025-01-01 14:00:00,40.0,0.0,200000.0,71174.37722419928,0.0,0.0,200000.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,3.0,4.0,0.0,4.22,0.0,0.0,R-454B,9.0,4194.0,,,,0.0,0.0,3.0,576300.0,2.81,200000.0,71174.37722419928,,,,eq_scenario_5,HR WWHP+100% AWHP (H+C)+Elec Backup + 2025-01-01 15:00:00,45.0,0.0,240000.0,90566.03773584907,0.0,0.0,240000.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,3.0,4.0,0.0,4.22,0.0,0.0,R-454B,9.0,4194.0,,,,0.0,0.0,3.0,549000.0,2.65,240000.0,90566.03773584907,,,,eq_scenario_5,HR WWHP+100% AWHP (H+C)+Elec Backup + 2025-01-01 16:00:00,-10.0,350000.0,0.0,146935.34844668346,0.0,350000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,3.0,4.0,447000.0,2.382,350000.0,146935.34844668346,R-454B,9.0,4194.0,,,,0.0,0.0,0.5,0.0,4.4,0.0,0.0,,,,eq_scenario_5,HR WWHP+100% AWHP (H+C)+Elec Backup + 2025-01-01 17:00:00,-5.0,300000.0,0.0,118585.4450483518,0.0,300000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,3.0,4.0,479035.71428571426,2.5298214285714287,300000.0,118585.4450483518,R-454B,9.0,4194.0,,,,0.0,0.0,1.0,0.0,4.4,0.0,0.0,,,,eq_scenario_5,HR WWHP+100% AWHP (H+C)+Elec Backup + 2025-01-01 18:00:00,0.0,250000.0,0.0,90187.59018759019,0.0,250000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,3.0,4.0,530760.0,2.772,250000.0,90187.59018759019,R-454B,9.0,4194.0,,,,0.0,0.0,1.5,0.0,4.4,0.0,0.0,,,,eq_scenario_5,HR WWHP+100% AWHP (H+C)+Elec Backup + 2025-01-01 19:00:00,5.0,200000.0,0.0,63355.58321077045,0.0,200000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,3.0,4.0,612010.7142857143,3.1567857142857143,200000.0,63355.58321077045,R-454B,9.0,4194.0,,,,0.0,0.0,2.0,500000.0,4.4,0.0,0.0,,,,eq_scenario_5,HR WWHP+100% AWHP (H+C)+Elec Backup + 2025-01-01 20:00:00,10.0,150000.0,0.0,41322.31404958678,0.0,150000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,3.0,4.0,707100.0,3.63,150000.0,41322.31404958678,R-454B,9.0,4194.0,,,,0.0,0.0,2.0,500000.0,4.4,0.0,0.0,,,,eq_scenario_5,HR WWHP+100% AWHP (H+C)+Elec Backup + 2025-01-01 21:00:00,15.0,100000.0,0.0,25906.73575129534,0.0,100000.0,0.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,3.0,4.0,729300.0,3.86,100000.0,25906.73575129534,R-454B,9.0,4194.0,,,,0.0,0.0,2.5,625000.0,4.4,0.0,0.0,,,,eq_scenario_5,HR WWHP+100% AWHP (H+C)+Elec Backup + 2025-01-01 22:00:00,20.0,50000.0,40000.0,21930.75741462838,0.0,50000.0,40000.0,0.0,0.0,4.2,211000.0,70000.0,50000.0,0.0,R-454B,1.875,873.75,3.0,4.0,772500.0,4.03,50000.0,12406.947890818858,R-454B,9.0,4194.0,,,,0.0,0.0,2.5,587500.0,4.2,40000.0,9523.809523809523,,,,eq_scenario_5,HR WWHP+100% AWHP (H+C)+Elec Backup + 2025-01-01 23:00:00,25.0,0.0,80000.0,20000.0,0.0,0.0,80000.0,0.0,0.0,4.2,211000.0,70000.0,0.0,0.0,R-454B,1.875,873.75,3.0,4.0,798450.0,4.125,0.0,0.0,R-454B,9.0,4194.0,,,,0.0,0.0,3.0,660000.0,4.0,80000.0,20000.0,,,,eq_scenario_5,HR WWHP+100% AWHP (H+C)+Elec Backup + + ''' +# --- diff --git a/tests/conftest.py b/tests/conftest.py index 80ec79b..614ba15 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,3 @@ -"""Shared pytest fixtures for Berkeley Decarb Tool tests.""" - import numpy as np import pandas as pd import pytest @@ -10,6 +8,17 @@ from src.loads import StandardLoad from src.metadata import Metadata +"""Shared pytest fixtures and hooks for Berkeley Decarb Tool tests.""" + + +def pytest_addoption(parser): + parser.addoption( + "--generate-golden", + action="store_true", + default=False, + help="Regenerate integration_annual_totals.json golden values (Tier 3)", + ) + @pytest.fixture def sample_load_df(): diff --git a/tests/snapshots/integration_annual_totals.json b/tests/snapshots/integration_annual_totals.json new file mode 100644 index 0000000..7568ce6 --- /dev/null +++ b/tests/snapshots/integration_annual_totals.json @@ -0,0 +1,18 @@ +{ + "building_5_eq_scenario_3_simulation": { + "total_elec_kWh": 177772.68, + "total_gas_kWh": 57646.24 + }, + "building_5_eq_scenario_5_simulation": { + "total_elec_kWh": 183876.64, + "total_gas_kWh": 0.0 + }, + "building_1_eq_scenario_3_simulation": { + "total_elec_kWh": 1049487.14, + "total_gas_kWh": 206452.7 + }, + "building_180_eq_scenario_3_measured": { + "total_elec_kWh": 10497.92, + "total_gas_kWh": 0.0 + } +} \ No newline at end of file diff --git a/tests/test_energy_integration.py b/tests/test_energy_integration.py new file mode 100644 index 0000000..50a1ef6 --- /dev/null +++ b/tests/test_energy_integration.py @@ -0,0 +1,254 @@ +"""Tier 3 integration / smoke tests for src/energy.py. + +Simulation cases (full invariant suite): + 1. No NaNs in core output columns + 2. All heating and cooling load served (energy balance) + 3. Electricity and gas non-negative + 4. Component electricity sum matches total + 5. Annual totals match golden values within ±0.1% + +Measured cases (smoke test only): + Verifies the run completes without error and annual totals match golden + values. Hour-level invariants are not checked because real-world data + contains missing t_out_C values that propagate through AWHP calculations. + +Golden values are stored in tests/snapshots/integration_annual_totals.json. +They are populated once (manually seeded) and updated deliberately after a +code or data change that legitimately alters results. + +To generate golden values on first run: + pytest -m integration --generate-golden +""" + +import json +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from src import paths +from src.config import Columns as Col +from src.energy import loads_to_site_energy +from src.equipment import load_library +from src.loads import StandardLoad + +GOLDEN_FILE = Path(__file__).parent / "snapshots" / "integration_annual_totals.json" +TOLERANCE = 0.001 # ±0.1% + +# Simulation cases: clean inputs, full invariant suite applies. +SIMULATION_CASES = [ + ("5", "eq_scenario_3", "simulation"), # Office, Port Angeles (mild): AWHP + boiler + cooling + ( + "5", + "eq_scenario_5", + "simulation", + ), # Office, Port Angeles: HR-WWHP + AWHP + cooling + elec resistance + ("1", "eq_scenario_3", "simulation"), # Hospital, Denver (cold): AWHP + boiler + cooling +] + +# Measured cases: real-world data with missing t_out_C hours; smoke test only. +MEASURED_CASES = [ + ("180", "eq_scenario_3", "measured"), # Measured building: AWHP + boiler + cooling +] + +ALL_CASES = SIMULATION_CASES + MEASURED_CASES + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _load_building(building_id: str, source: str) -> StandardLoad: + df = pd.read_parquet( + paths.LOAD_DATA_PARQUET, + filters=[("building_id", "=", building_id), ("source", "=", source)], + ) + return StandardLoad(df[["timestamp", "t_out_C", "heating_W", "cooling_W"]].copy()) + + +def _case_key(building_id: str, scenario_id: str, source: str) -> str: + return f"building_{building_id}_{scenario_id}_{source}" + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def library_session(): + return load_library(paths.EQUIPMENT_JSON) + + +@pytest.fixture(scope="session") +def integration_results(library_session): + """Run all integration cases once per session and cache results.""" + cache = {} + for building_id, scenario_id, source in ALL_CASES: + load = _load_building(building_id, source) + df = loads_to_site_energy(load=load, library=library_session, scenario_ids=[scenario_id]) + cache[_case_key(building_id, scenario_id, source)] = df + return cache + + +# --------------------------------------------------------------------------- +# Invariant tests — simulation cases only +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +@pytest.mark.parametrize("building_id,scenario_id,source", SIMULATION_CASES) +def test_no_nans_in_summary_columns(building_id, scenario_id, source, integration_results): + """Core output columns must never be NaN. + + Detail columns (hr_hhw_W, boiler_eff, etc.) are intentionally NaN + when the corresponding phase doesn't run for a given scenario — those + are checked implicitly by the energy-balance invariants below. + """ + df = integration_results[_case_key(building_id, scenario_id, source)] + always_populated = [ + Col.T_OUT_C.value, + Col.HEATING_W.value, + Col.COOLING_W.value, + Col.HHW_W.value, + Col.CHW_W.value, + Col.ELEC_WH.value, + Col.GAS_WH.value, + ] + for col in always_populated: + assert ( + not df[col].isna().any() + ), f"[{building_id}/{scenario_id}] NaN found in column '{col}'" + + +@pytest.mark.integration +@pytest.mark.parametrize("building_id,scenario_id,source", SIMULATION_CASES) +def test_all_heating_served(building_id, scenario_id, source, integration_results): + df = integration_results[_case_key(building_id, scenario_id, source)] + heating_served = ( + df[Col.HR_HHW_W.value].fillna(0) + + df[Col.AWHP_HHW_W.value].fillna(0) + + df[Col.BOILER_HHW_W.value].fillna(0) + + df[Col.RES_HHW_W.value].fillna(0) + ) + assert np.allclose(heating_served, df[Col.HHW_W.value], atol=1.0), ( + f"[{building_id}/{scenario_id}] Unserved heating load. " + f"Max gap: {abs(heating_served - df[Col.HHW_W.value]).max():.2f} W" + ) + + +@pytest.mark.integration +@pytest.mark.parametrize("building_id,scenario_id,source", SIMULATION_CASES) +def test_all_cooling_served(building_id, scenario_id, source, integration_results): + df = integration_results[_case_key(building_id, scenario_id, source)] + cooling_served = ( + df[Col.HR_CHW_W.value].fillna(0) + + df[Col.AWHP_CHW_W.value].fillna(0) + + df[Col.CHILLER_CHW_W.value].fillna(0) + ) + assert np.allclose(cooling_served, df[Col.CHW_W.value], atol=1.0), ( + f"[{building_id}/{scenario_id}] Unserved cooling load. " + f"Max gap: {abs(cooling_served - df[Col.CHW_W.value]).max():.2f} W" + ) + + +@pytest.mark.integration +@pytest.mark.parametrize("building_id,scenario_id,source", SIMULATION_CASES) +def test_energy_positivity(building_id, scenario_id, source, integration_results): + df = integration_results[_case_key(building_id, scenario_id, source)] + assert ( + df[Col.ELEC_WH.value] >= -1e-6 + ).all(), f"[{building_id}/{scenario_id}] Negative electricity found" + assert ( + df[Col.GAS_WH.value] >= -1e-6 + ).all(), f"[{building_id}/{scenario_id}] Negative gas consumption found" + + +@pytest.mark.integration +@pytest.mark.parametrize("building_id,scenario_id,source", SIMULATION_CASES) +def test_component_electricity_sum(building_id, scenario_id, source, integration_results): + df = integration_results[_case_key(building_id, scenario_id, source)] + elec_sum = ( + df[Col.ELEC_HR_WH.value].fillna(0) + + df[Col.ELEC_AWHP_H_WH.value].fillna(0) + + df[Col.ELEC_RES_WH.value].fillna(0) + + df[Col.ELEC_AWHP_C_WH.value].fillna(0) + + df[Col.ELEC_CHILLER_WH.value].fillna(0) + ) + assert np.allclose(elec_sum, df[Col.ELEC_WH.value], atol=1.0), ( + f"[{building_id}/{scenario_id}] Component electricity sum mismatch. " + f"Max gap: {abs(elec_sum - df[Col.ELEC_WH.value]).max():.4f} Wh" + ) + + +# --------------------------------------------------------------------------- +# Annual golden values — all cases +# --------------------------------------------------------------------------- + + +def _compute_annual_totals(df: pd.DataFrame) -> dict: + return { + "total_elec_kWh": round(df[Col.ELEC_WH.value].sum() / 1000, 2), + "total_gas_kWh": round(df[Col.GAS_WH.value].sum() / 1000, 2), + } + + +@pytest.mark.integration +@pytest.mark.parametrize("building_id,scenario_id,source", ALL_CASES) +def test_annual_totals_match_golden(building_id, scenario_id, source, integration_results): + """Annual electricity and gas must be within ±0.1% of the committed golden values. + + If GOLDEN_FILE does not exist, this test is skipped with a message explaining + how to generate it. Run once with --generate-golden to create the file. + """ + if not GOLDEN_FILE.exists(): + pytest.skip( + f"Golden file not found at {GOLDEN_FILE}. " + "Run once with: pytest -m integration --generate-golden" + ) + + golden = json.loads(GOLDEN_FILE.read_text()) + key = _case_key(building_id, scenario_id, source) + + if key not in golden: + pytest.skip(f"No golden entry for key '{key}'. Re-run with --generate-golden.") + + df = integration_results[key] + actual = _compute_annual_totals(df) + expected = golden[key] + + for metric in ["total_elec_kWh", "total_gas_kWh"]: + exp_val = expected[metric] + act_val = actual[metric] + if exp_val == 0: + assert act_val == 0, f"[{key}] {metric}: expected 0, got {act_val}" + else: + rel_err = abs(act_val - exp_val) / abs(exp_val) + assert rel_err <= TOLERANCE, ( + f"[{key}] {metric}: expected {exp_val:.2f}, got {act_val:.2f} " + f"(relative error {rel_err:.4%} > {TOLERANCE:.1%})" + ) + + +# --------------------------------------------------------------------------- +# Golden file generation (opt-in via --generate-golden flag, defined in conftest.py) +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session", autouse=True) +def maybe_generate_golden(request, integration_results): + """Write golden file if --generate-golden flag is set.""" + if not request.config.getoption("--generate-golden", default=False): + return + + golden = {} + for building_id, scenario_id, source in ALL_CASES: + key = _case_key(building_id, scenario_id, source) + df = integration_results[key] + golden[key] = _compute_annual_totals(df) + + GOLDEN_FILE.parent.mkdir(parents=True, exist_ok=True) + GOLDEN_FILE.write_text(json.dumps(golden, indent=2)) + print(f"\nGolden values written to {GOLDEN_FILE}") diff --git a/tests/test_energy_regression.py b/tests/test_energy_regression.py new file mode 100644 index 0000000..84f57e2 --- /dev/null +++ b/tests/test_energy_regression.py @@ -0,0 +1,177 @@ +"""Tier 2 regression tests for src/energy.py. + +Runs loads_to_site_energy() with real equipment library data and a small +synthetic load (24 hours), covering four distinct equipment configurations. +Two layers: + 2a — Physics invariant checks: energy balance holds regardless of inputs. + 2b — Snapshot regression: full output must match the committed CSV snapshot. + +To seed or update snapshots after a deliberate change: + pytest -m regression --snapshot-update +Then review the diff with: git diff tests/__snapshots__/ +""" + +import numpy as np +import pandas as pd +import pytest + +from src import paths +from src.config import Columns as Col +from src.energy import loads_to_site_energy +from src.equipment import load_library +from src.loads import StandardLoad + +# --------------------------------------------------------------------------- +# Scenario IDs — one per distinct equipment configuration +# --------------------------------------------------------------------------- +SCENARIOS = [ + "eq_scenario_3", # AWHP + boiler backup + cooling + "eq_scenario_4", # AWHP + electric resistance backup + cooling + "eq_scenario_5", # HR-WWHP + AWHP + cooling + electric resistance backup + "eq_scenario_10", # HR-WWHP only (no AWHP) + boiler backup, no AWHP cooling +] + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def library(): + return load_library(paths.EQUIPMENT_JSON) + + +@pytest.fixture(scope="module") +def synthetic_load(): + """24-hour synthetic load covering cold, mild, warm, and hot OAT conditions. + + Deliberately spans the AWHP operating limits (-15°C to +35°C heating, + +5°C to +45°C cooling) so that capacity-constraint zeroing is exercised. + """ + t_out = np.array( + [ + -20.0, + -15.0, + -10.0, + -5.0, # below and at AWHP heating min + 0.0, + 4.4, + 10.0, + 15.0, # normal AWHP heating range + 20.0, + 25.0, + 28.0, + 30.0, # mild — simultaneous H+C possible + 35.0, + 36.0, + 40.0, + 45.0, # warm — above AWHP heating max + -10.0, + -5.0, + 0.0, + 5.0, # repeat cold block + 10.0, + 15.0, + 20.0, + 25.0, # repeat mild block + ] + ) + + # Heating load: peaks when cold, tapers to zero above ~25°C + heating_W = np.clip((-t_out + 25) * 10_000, 0, None).astype(float) + # Cooling load: zero when cold, builds above 15°C + cooling_W = np.clip((t_out - 15) * 8_000, 0, None).astype(float) + + df = pd.DataFrame( + { + "timestamp": pd.date_range("2025-01-01", periods=24, freq="h"), + "t_out_C": t_out, + "heating_W": heating_W, + "cooling_W": cooling_W, + } + ) + return StandardLoad(df) + + +# --------------------------------------------------------------------------- +# Layer 2a — Physics invariant checks +# --------------------------------------------------------------------------- + + +@pytest.mark.regression +@pytest.mark.parametrize("scenario_id", SCENARIOS) +def test_all_heating_load_served(scenario_id, library, synthetic_load): + df = loads_to_site_energy(load=synthetic_load, library=library, scenario_ids=[scenario_id]) + + heating_served = ( + df[Col.HR_HHW_W.value].fillna(0) + + df[Col.AWHP_HHW_W.value].fillna(0) + + df[Col.BOILER_HHW_W.value].fillna(0) + + df[Col.RES_HHW_W.value].fillna(0) + ) + assert np.allclose(heating_served, df[Col.HHW_W.value], atol=1.0), ( + f"[{scenario_id}] Heating load not fully served. " + f"Max gap: {abs(heating_served - df[Col.HHW_W.value]).max():.2f} W" + ) + + +@pytest.mark.regression +@pytest.mark.parametrize("scenario_id", SCENARIOS) +def test_all_cooling_load_served(scenario_id, library, synthetic_load): + df = loads_to_site_energy(load=synthetic_load, library=library, scenario_ids=[scenario_id]) + + cooling_served = ( + df[Col.HR_CHW_W.value].fillna(0) + + df[Col.AWHP_CHW_W.value].fillna(0) + + df[Col.CHILLER_CHW_W.value].fillna(0) + ) + assert np.allclose(cooling_served, df[Col.CHW_W.value], atol=1.0), ( + f"[{scenario_id}] Cooling load not fully served. " + f"Max gap: {abs(cooling_served - df[Col.CHW_W.value]).max():.2f} W" + ) + + +@pytest.mark.regression +@pytest.mark.parametrize("scenario_id", SCENARIOS) +def test_electricity_components_sum_to_total(scenario_id, library, synthetic_load): + df = loads_to_site_energy(load=synthetic_load, library=library, scenario_ids=[scenario_id]) + + elec_sum = ( + df[Col.ELEC_HR_WH.value].fillna(0) + + df[Col.ELEC_AWHP_H_WH.value].fillna(0) + + df[Col.ELEC_RES_WH.value].fillna(0) + + df[Col.ELEC_AWHP_C_WH.value].fillna(0) + + df[Col.ELEC_CHILLER_WH.value].fillna(0) + ) + assert np.allclose(elec_sum, df[Col.ELEC_WH.value], atol=1.0), ( + f"[{scenario_id}] Component electricity does not sum to total. " + f"Max gap: {abs(elec_sum - df[Col.ELEC_WH.value]).max():.4f} Wh" + ) + + +@pytest.mark.regression +@pytest.mark.parametrize("scenario_id", SCENARIOS) +def test_no_negative_energy(scenario_id, library, synthetic_load): + df = loads_to_site_energy(load=synthetic_load, library=library, scenario_ids=[scenario_id]) + + assert (df[Col.ELEC_WH.value] >= -1e-9).all(), f"[{scenario_id}] Negative electricity detected" + assert ( + df[Col.GAS_WH.value] >= -1e-9 + ).all(), f"[{scenario_id}] Negative gas consumption detected" + + +# --------------------------------------------------------------------------- +# Layer 2b — Snapshot regression +# --------------------------------------------------------------------------- + + +@pytest.mark.regression +@pytest.mark.parametrize("scenario_id", SCENARIOS) +def test_scenario_output_snapshot(snapshot, scenario_id, library, synthetic_load): + """Full output DataFrame must match committed snapshot. + + On first run this creates the snapshot. On subsequent runs it compares. + To update after a deliberate change: pytest -m regression --snapshot-update + """ + df = loads_to_site_energy(load=synthetic_load, library=library, scenario_ids=[scenario_id]) + assert snapshot == df.to_csv() diff --git a/tests/test_energy_unit.py b/tests/test_energy_unit.py new file mode 100644 index 0000000..0bbcd65 --- /dev/null +++ b/tests/test_energy_unit.py @@ -0,0 +1,315 @@ +"""Tier 1 unit tests for pure helper functions in src/energy.py. + +No file I/O, no real equipment JSON or parquet data. All inputs are +small synthetic numpy arrays built inline. +""" + +import numpy as np +import pytest + +from src.energy import ( + _capacity_constraints, + _constant_heating_efficiency, + _heat_recovery_plr_curve, + _per_unit_cooling_capacity_W, + _per_unit_cooling_cop, + _per_unit_heating_capacity_W, + _per_unit_heating_cop, +) +from src.equipment import Equipment, Performance, PerformanceCurves + +# --------------------------------------------------------------------------- +# Shared minimal fixtures (inline, no conftest dependency) +# --------------------------------------------------------------------------- + +T_OUT_BREAKPOINTS = [-10.0, 0.0, 10.0] +COP_AT_BREAKPOINTS = [3.0, 3.5, 4.0] +CAP_AT_BREAKPOINTS = [10_000.0, 12_000.0, 14_000.0] + + +def _make_awhp_heating( + t_out_C=None, + cap_W=None, + efficiency=None, +) -> Equipment: + t_out_C = t_out_C or T_OUT_BREAKPOINTS + return Equipment( + eq_id="test_awhp", + eq_type="heat_pump", + model="TestAWHP", + fuel="electricity", + performance={ + "heating": Performance(t_out_C=t_out_C, capacity_W=cap_W, efficiency=efficiency) + }, + ) + + +def _make_awhp_cooling(t_out_C=None, fixed_capacity_W=None) -> Equipment: + t_out_C = t_out_C or [20.0, 30.0, 40.0] + return Equipment( + eq_id="test_awhp_c", + eq_type="heat_pump", + model="TestAWHPCooling", + fuel="electricity", + capacity_W=fixed_capacity_W, + performance={"cooling": Performance(t_out_C=t_out_C)}, + ) + + +def _perf_fixed(cop_values=None, cap_values=None, min_t=-20.0, max_t=40.0) -> PerformanceCurves: + """PerformanceCurves as produced by _heating_supply_temp_performance for a fixed supply temp.""" + cop_values = cop_values or COP_AT_BREAKPOINTS + cap_values = cap_values or CAP_AT_BREAKPOINTS + perf = PerformanceCurves() + perf.cop = np.array([cop_values]) # shape (1, n_t_out) + perf.capacity_W = np.array([cap_values]) # shape (1, n_t_out) + perf.constraints = {"min_temp_C": min_t, "max_temp_C": max_t} + return perf + + +# --------------------------------------------------------------------------- +# _capacity_constraints +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestCapacityConstraints: + def test_below_min_zeroed(self): + t_out = np.array([-20.0, -5.0]) + cap = np.array([8_000.0, 9_000.0]) + perf = PerformanceCurves() + perf.constraints = {"min_temp_C": 0.0, "max_temp_C": 35.0} + result = _capacity_constraints(t_out, cap.copy(), perf, "heating") + assert result[0] == 0.0 + assert result[1] == 0.0 + + def test_above_max_zeroed(self): + t_out = np.array([36.0, 45.0]) + cap = np.array([8_000.0, 5_000.0]) + perf = PerformanceCurves() + perf.constraints = {"min_temp_C": 0.0, "max_temp_C": 35.0} + result = _capacity_constraints(t_out, cap.copy(), perf, "heating") + assert result[0] == 0.0 + assert result[1] == 0.0 + + def test_in_range_unchanged(self): + t_out = np.array([0.0, 10.0, 25.0]) + cap = np.array([10_000.0, 12_000.0, 9_000.0]) + perf = PerformanceCurves() + perf.constraints = {"min_temp_C": -5.0, "max_temp_C": 30.0} + result = _capacity_constraints(t_out, cap.copy(), perf, "heating") + assert np.allclose(result, cap) + + def test_mixed_in_and_out_of_range(self): + t_out = np.array([-5.0, 5.0, 40.0]) + cap = np.array([8_000.0, 10_000.0, 6_000.0]) + perf = PerformanceCurves() + perf.constraints = {"min_temp_C": 0.0, "max_temp_C": 35.0} + result = _capacity_constraints(t_out, cap.copy(), perf, "heating") + assert result[0] == 0.0 # below min + assert result[1] == 10_000.0 # in range + assert result[2] == 0.0 # above max + + +# --------------------------------------------------------------------------- +# _per_unit_heating_cop +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestHeatingCOP: + def test_exact_breakpoint_returns_exact_value(self): + e = _make_awhp_heating() + perf = _perf_fixed() + result = _per_unit_heating_cop(e, np.array([-10.0]), perf, "interpolate_HHWST_fixed") + assert np.allclose(result, [3.0]) + + def test_midpoint_interpolated(self): + e = _make_awhp_heating() + perf = _perf_fixed() + result = _per_unit_heating_cop(e, np.array([-5.0]), perf, "interpolate_HHWST_fixed") + assert np.allclose(result, [3.25]) # linear midpoint of 3.0 and 3.5 + + def test_upper_breakpoint(self): + e = _make_awhp_heating() + perf = _perf_fixed() + result = _per_unit_heating_cop(e, np.array([10.0]), perf, "interpolate_HHWST_fixed") + assert np.allclose(result, [4.0]) + + def test_vector_input(self): + e = _make_awhp_heating() + perf = _perf_fixed() + result = _per_unit_heating_cop( + e, np.array([-10.0, 0.0, 10.0]), perf, "interpolate_HHWST_fixed" + ) + assert np.allclose(result, [3.0, 3.5, 4.0]) + + +# --------------------------------------------------------------------------- +# _per_unit_heating_capacity_W +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestHeatingCapacity: + def test_ndarray_path_exact_breakpoint(self): + e = _make_awhp_heating() + perf = _perf_fixed() + result = _per_unit_heating_capacity_W(e, np.array([0.0]), perf, "interpolate_HHWST_fixed") + assert np.allclose(result, [12_000.0]) + + def test_ndarray_path_interpolated(self): + e = _make_awhp_heating() + perf = _perf_fixed() + result = _per_unit_heating_capacity_W(e, np.array([-5.0]), perf, "interpolate_HHWST_fixed") + assert np.allclose(result, [11_000.0]) # midpoint of 10k and 12k + + def test_fixed_scalar_capacity_broadcasts(self): + e = _make_awhp_heating() + perf = PerformanceCurves() + perf.capacity_W = 20_000.0 # scalar float, not ndarray → fixed-capacity fallback + perf.constraints = {"min_temp_C": -20.0, "max_temp_C": 40.0} + t_out = np.array([-5.0, 5.0, 15.0]) + result = _per_unit_heating_capacity_W(e, t_out, perf, "interpolate_HHWST_fixed") + assert np.allclose(result, [20_000.0, 20_000.0, 20_000.0]) + + def test_capacity_zeroed_outside_constraints(self): + e = _make_awhp_heating() + perf = _perf_fixed(min_t=0.0, max_t=20.0) + t_out = np.array([-5.0, 5.0, 25.0]) + result = _per_unit_heating_capacity_W(e, t_out, perf, "interpolate_HHWST_fixed") + assert result[0] == 0.0 # below min + assert result[2] == 0.0 # above max + + +# --------------------------------------------------------------------------- +# _per_unit_cooling_cop +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestCoolingCOP: + def test_exact_breakpoint(self): + e = _make_awhp_cooling(t_out_C=[20.0, 30.0, 40.0]) + perf = PerformanceCurves() + perf.cop = [5.0, 4.5, 4.0] + perf.constraints = {"min_temp_C": 15.0, "max_temp_C": 45.0} + result = _per_unit_cooling_cop(e, np.array([30.0]), perf) + assert np.allclose(result, [4.5]) + + def test_midpoint_interpolated(self): + e = _make_awhp_cooling(t_out_C=[20.0, 30.0, 40.0]) + perf = PerformanceCurves() + perf.cop = [5.0, 4.5, 4.0] + perf.constraints = {"min_temp_C": 15.0, "max_temp_C": 45.0} + result = _per_unit_cooling_cop(e, np.array([25.0]), perf) + assert np.allclose(result, [4.75]) # midpoint of 5.0 and 4.5 + + +# --------------------------------------------------------------------------- +# _per_unit_cooling_capacity_W +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestCoolingCapacity: + def test_list_path_exact_breakpoint(self): + e = _make_awhp_cooling(t_out_C=[20.0, 30.0, 40.0]) + perf = PerformanceCurves() + perf.capacity_W = [50_000.0, 45_000.0, 40_000.0] # list → uses interp + perf.constraints = {"min_temp_C": 15.0, "max_temp_C": 45.0} + result = _per_unit_cooling_capacity_W(e, np.array([30.0]), perf) + assert np.allclose(result, [45_000.0]) + + def test_list_path_interpolated(self): + e = _make_awhp_cooling(t_out_C=[20.0, 30.0, 40.0]) + perf = PerformanceCurves() + perf.capacity_W = [50_000.0, 45_000.0, 40_000.0] + perf.constraints = {"min_temp_C": 15.0, "max_temp_C": 45.0} + result = _per_unit_cooling_capacity_W(e, np.array([25.0]), perf) + assert np.allclose(result, [47_500.0]) + + def test_fixed_capacity_fallback(self): + e = _make_awhp_cooling(t_out_C=[20.0, 30.0, 40.0], fixed_capacity_W=48_000.0) + perf = PerformanceCurves() + perf.capacity_W = None # not a list → fallback to e.capacity_W + perf.constraints = {"min_temp_C": 15.0, "max_temp_C": 45.0} + result = _per_unit_cooling_capacity_W(e, np.array([25.0, 35.0]), perf) + assert np.allclose(result, [48_000.0, 48_000.0]) + + def test_capacity_zeroed_outside_constraints(self): + e = _make_awhp_cooling(t_out_C=[20.0, 30.0, 40.0]) + perf = PerformanceCurves() + perf.capacity_W = [50_000.0, 45_000.0, 40_000.0] + perf.constraints = {"min_temp_C": 22.0, "max_temp_C": 38.0} + result = _per_unit_cooling_capacity_W(e, np.array([20.0, 30.0, 40.0]), perf) + assert result[0] == 0.0 # 20 < 22 + assert result[1] == 45_000.0 # 30 in range + assert result[2] == 0.0 # 40 > 38 + + +# --------------------------------------------------------------------------- +# _heat_recovery_plr_curve +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestHeatRecoveryPLRCurve: + def test_returns_correct_columns(self): + e = Equipment( + eq_id="test_wwhp", + eq_type="heat_pump", + model="TestWWHP", + fuel="electricity", + performance={"heating": Performance(capacity_W=[100_000.0, 150_000.0, 200_000.0])}, + ) + perf = PerformanceCurves() + perf.cop = np.array([[4.0, 4.5, 5.0]]) + result = _heat_recovery_plr_curve(e, perf) + assert set(result.columns) == {"cap", "cop"} + + def test_values_match_inputs(self): + caps = [100_000.0, 150_000.0, 200_000.0] + cops = [4.0, 4.5, 5.0] + e = Equipment( + eq_id="test_wwhp", + eq_type="heat_pump", + model="TestWWHP", + fuel="electricity", + performance={"heating": Performance(capacity_W=caps)}, + ) + perf = PerformanceCurves() + perf.cop = np.array([cops]) + result = _heat_recovery_plr_curve(e, perf) + assert np.allclose(result["cap"].to_numpy(), caps) + assert np.allclose(result["cop"].to_numpy(), cops) + + +# --------------------------------------------------------------------------- +# _constant_heating_efficiency +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestConstantHeatingEfficiency: + def test_returns_correct_float(self): + e = Equipment( + eq_id="test_boiler", + eq_type="boiler", + model="TestBoiler", + fuel="natural_gas", + performance={"heating": Performance(efficiency=0.9)}, + ) + result = _constant_heating_efficiency(e) + assert result == pytest.approx(0.9) + + def test_high_efficiency(self): + e = Equipment( + eq_id="test_boiler_condensing", + eq_type="boiler", + model="TestCondensingBoiler", + fuel="natural_gas", + performance={"heating": Performance(efficiency=0.97)}, + ) + result = _constant_heating_efficiency(e) + assert result == pytest.approx(0.97) diff --git a/tests/test_equipment.py b/tests/test_equipment.py index 3696f42..9616ad9 100644 --- a/tests/test_equipment.py +++ b/tests/test_equipment.py @@ -9,6 +9,7 @@ ) +@pytest.mark.unit class TestEquipmentLibrary: """Tests for equipment library loading and manipulation.""" @@ -43,6 +44,7 @@ def test_get_nonexistent_scenario_raises(self, equipment_library): equipment_library.get_scenario("nonexistent_id") +@pytest.mark.unit class TestEquipmentScenario: """Tests for equipment scenario model.""" @@ -71,6 +73,7 @@ def test_scenario_has_required_fields(self, equipment_library): assert has_heating, f"Scenario {scenario.eq_scen_id} has no heating source" +@pytest.mark.unit class TestEquipment: """Tests for individual equipment models.""" diff --git a/tests/test_loads.py b/tests/test_loads.py index 5819a8e..27dc65f 100644 --- a/tests/test_loads.py +++ b/tests/test_loads.py @@ -1,11 +1,14 @@ """Tests for load data validation and processing.""" +import numpy as np import pandas as pd import pytest -from src.loads import STANDARD_COLUMNS, StandardLoad +from src.loads import STANDARD_COLUMNS, StandardLoad, ensure_datetime, get_load_data +from src.metadata import LoadData, Metadata +@pytest.mark.unit class TestStandardLoad: """Tests for StandardLoad validation and processing.""" @@ -70,25 +73,38 @@ def test_non_leap_year_detection(self, sample_load_df): assert not load.has_leap_day # Use falsy check instead of 'is False' +@pytest.mark.unit class TestLoadDataValidation: """Tests for edge cases in load data validation.""" - def test_negative_loads_allowed(self): - """Test that negative load values are allowed (heat recovery scenarios).""" + def test_negative_heating_raises(self): + """Test that negative heating load values are rejected.""" df = pd.DataFrame( { "timestamp": pd.date_range("2025-01-01", periods=100, freq="h"), "t_out_C": [20] * 100, - "heating_W": [-1000] * 100, # Negative heating (unusual but valid) + "heating_W": [-1000] * 100, "cooling_W": [500] * 100, } ) - # Should not raise an error - load = StandardLoad(df) - assert load is not None + with pytest.raises(ValueError, match="negative"): + StandardLoad(df) + + def test_negative_cooling_raises(self): + """Test that negative cooling load values are rejected.""" + df = pd.DataFrame( + { + "timestamp": pd.date_range("2025-01-01", periods=100, freq="h"), + "t_out_C": [20] * 100, + "heating_W": [1000] * 100, + "cooling_W": [-500] * 100, + } + ) + with pytest.raises(ValueError, match="negative"): + StandardLoad(df) def test_zero_loads_allowed(self): - """Test that zero load values are allowed.""" + """Zero is the valid lower bound for load values.""" df = pd.DataFrame( { "timestamp": pd.date_range("2025-01-01", periods=100, freq="h"), @@ -99,3 +115,222 @@ def test_zero_loads_allowed(self): ) load = StandardLoad(df) assert load is not None + + +# --------------------------------------------------------------------------- +# ensure_datetime — three timestamp format paths +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestEnsureDatetime: + def test_timestamp_column_becomes_datetime(self): + df = pd.DataFrame( + { + "timestamp": ["2025-01-01 00:00:00", "2025-01-01 01:00:00"], + "t_out_C": [10.0, 11.0], + "heating_W": [1000.0, 900.0], + "cooling_W": [0.0, 0.0], + } + ) + ensure_datetime(df) + assert pd.api.types.is_datetime64_any_dtype(df["timestamp"]) + + def test_hour_of_year_column_converts_correctly(self): + # HOY is 1-based: HOY 1 = Jan 1 00:00, HOY 2 = Jan 1 01:00 + df = pd.DataFrame( + { + "hour_of_year": [1, 2, 3], + "t_out_C": [5.0, 6.0, 7.0], + "heating_W": [1000.0, 900.0, 800.0], + "cooling_W": [0.0, 0.0, 0.0], + } + ) + ensure_datetime(df) + assert pd.api.types.is_datetime64_any_dtype(df["timestamp"]) + assert df["timestamp"].iloc[0] == pd.Timestamp("2025-01-01 00:00:00") + assert df["timestamp"].iloc[1] == pd.Timestamp("2025-01-01 01:00:00") + + def test_month_day_hour_columns_convert_correctly(self): + df = pd.DataFrame( + { + "month": [1, 1, 7], + "day": [1, 1, 15], + "hour": [0, 1, 0], + "t_out_C": [5.0, 6.0, 25.0], + "heating_W": [1000.0, 900.0, 0.0], + "cooling_W": [0.0, 0.0, 500.0], + } + ) + ensure_datetime(df) + assert pd.api.types.is_datetime64_any_dtype(df["timestamp"]) + assert df["timestamp"].iloc[0] == pd.Timestamp("2025-01-01 00:00:00") + assert df["timestamp"].iloc[2] == pd.Timestamp("2025-07-15 00:00:00") + + def test_no_valid_time_column_raises(self): + df = pd.DataFrame( + { + "t_out_C": [15.0], + "heating_W": [1000.0], + "cooling_W": [0.0], + } + ) + with pytest.raises(ValueError, match="No valid time column"): + ensure_datetime(df) + + +# --------------------------------------------------------------------------- +# StandardLoad.get_data_summary — the function that powers the UI quality flags +# --------------------------------------------------------------------------- + + +def _make_annual_load(hours=8760, year=2025, nan_col=None, nan_count=0): + """Helper: create a StandardLoad with optional NaN values in one column.""" + heating = [1000.0] * hours + if nan_col == "heating_W": + heating[:nan_count] = [np.nan] * nan_count + + cooling = [500.0] * hours + if nan_col == "cooling_W": + cooling[:nan_count] = [np.nan] * nan_count + + t_out = [15.0] * hours + if nan_col == "t_out_C": + t_out[:nan_count] = [np.nan] * nan_count + + return StandardLoad( + pd.DataFrame( + { + "timestamp": pd.date_range(f"{year}-01-01", periods=hours, freq="h"), + "t_out_C": t_out, + "heating_W": heating, + "cooling_W": cooling, + } + ) + ) + + +@pytest.mark.unit +class TestGetDataSummary: + def test_complete_data_flagged_as_complete(self): + summary = _make_annual_load().get_data_summary() + assert summary["is_complete"] is True + assert summary["hours_complete"] is True + assert summary["data_complete"] is True + assert summary["missing_hours"] == 0 + assert summary["has_missing_values"] is False + assert summary["total_missing_values"] == 0 + assert summary["num_hours"] == 8760 + + def test_short_dataset_missing_hours_detected(self): + summary = _make_annual_load(hours=8700).get_data_summary() + assert summary["is_complete"] is False + assert summary["hours_complete"] is False + assert summary["missing_hours"] == 60 + + def test_nan_values_detected_and_counted(self): + summary = _make_annual_load(nan_col="heating_W", nan_count=5).get_data_summary() + assert summary["is_complete"] is False + assert summary["data_complete"] is False + assert summary["has_missing_values"] is True + assert summary["total_missing_values"] == 5 + assert summary["column_stats"]["heating_W"]["missing_count"] == 5 + assert summary["column_stats"]["cooling_W"]["missing_count"] == 0 + + def test_per_column_completeness_percentage(self): + load = _make_annual_load(hours=100, nan_col="t_out_C", nan_count=10) + stats = load.get_data_summary()["column_stats"]["t_out_C"] + assert stats["missing_count"] == 10 + assert stats["completeness_pct"] == pytest.approx(90.0) + + def test_leap_year_expects_8784_hours(self): + summary = _make_annual_load(hours=8784, year=2024).get_data_summary() + assert summary["has_leap_day"] # np.bool_ from pandas .any() + assert summary["expected_hours"] == 8784 + assert summary["is_complete"] is True + assert summary["missing_hours"] == 0 + + def test_multi_year_data_flagged(self): + summary = _make_annual_load(hours=8760 * 2).get_data_summary() + assert summary["spans_multiple_years"] is True + + +# --------------------------------------------------------------------------- +# StandardLoad.limit_to_one_year +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestLimitToOneYear: + def test_single_year_data_unchanged(self): + load = _make_annual_load(hours=8760) + trimmed = load.limit_to_one_year() + assert trimmed.num_hours == 8760 + + def test_multi_year_data_trimmed_to_one_year(self): + load = _make_annual_load(hours=8760 * 2) + assert load.spans_multiple_years + trimmed = load.limit_to_one_year() + assert trimmed.num_hours == 8760 + assert not trimmed.spans_multiple_years + + def test_leap_year_result_retains_8784_hours(self): + load = _make_annual_load(hours=8784, year=2024) + trimmed = load.limit_to_one_year() + assert trimmed.num_hours == 8784 + assert trimmed.has_leap_day + + def test_trimmed_data_starts_from_original_start_date(self): + load = _make_annual_load(hours=8760 * 2) + start_before = load.df.index.min() + trimmed = load.limit_to_one_year() + assert trimmed.df.index.min() == start_before + + +# --------------------------------------------------------------------------- +# get_load_data — the function the app calls to read from parquet +# --------------------------------------------------------------------------- + + +@pytest.mark.regression +class TestGetLoadData: + def _meta(self, building_id, load_type, custom_path=None): + return Metadata.create( + building_id=building_id, + load_data=LoadData(load_type=load_type), + custom_load_path=custom_path, + ) + + def test_simulation_data_loads_for_valid_building(self): + load = get_load_data(self._meta("5", "simulation")) + assert isinstance(load, StandardLoad) + assert load.num_hours == 8760 + + def test_invalid_building_id_raises(self): + with pytest.raises(ValueError, match="No simulation load found"): + get_load_data(self._meta("99999", "simulation")) + + def test_missing_building_id_raises(self): + with pytest.raises(ValueError, match="building_id required"): + get_load_data(self._meta(None, "simulation")) + + def test_unsupported_load_type_raises(self): + with pytest.raises(NotImplementedError, match="Unsupported load type"): + get_load_data(self._meta("5", "unknown_type")) + + def test_custom_load_reads_from_parquet_file(self, tmp_path): + hours = 8760 + df = pd.DataFrame( + { + "timestamp": pd.date_range("2025-01-01", periods=hours, freq="h"), + "t_out_C": np.full(hours, 15.0), + "heating_W": np.full(hours, 1000.0), + "cooling_W": np.full(hours, 500.0), + } + ) + parquet_path = tmp_path / "custom_load.parquet" + df.to_parquet(parquet_path, index=False) + + load = get_load_data(self._meta(None, "custom", custom_path=str(parquet_path))) + assert isinstance(load, StandardLoad) + assert load.num_hours == hours diff --git a/utils/display_registry.py b/utils/display_registry.py index fc44c61..ad06f64 100644 --- a/utils/display_registry.py +++ b/utils/display_registry.py @@ -28,11 +28,10 @@ def get_equipment_name(cls, eq_id: str) -> str: def _build_equipment_lookup(cls): """Build the equipment ID to manufacturer+model name lookup dictionary.""" library = load_library("data/input/equipment_data.JSON") - cls._equipment_lookup = {eq.eq_id: - eq.model if eq.eq_manufacturer is None - else f"{eq.eq_manufacturer} {eq.model}" - for eq in library.equipment - } + cls._equipment_lookup = { + eq.eq_id: eq.model if eq.eq_manufacturer is None else f"{eq.eq_manufacturer} {eq.model}" + for eq in library.equipment + } @classmethod def clear_cache(cls):