From f69a174009acc4cd7b437642e777195f93a9b37f Mon Sep 17 00:00:00 2001 From: t-kramer Date: Tue, 6 Oct 2026 12:31:10 -0700 Subject: [PATCH] status: testing v2 --- .github/workflows/deploy.yml | 5 +- .github/workflows/tests.yml | 26 + .gitignore | 5 +- .pre-commit-config.yaml | 9 + notes/testing-scheme.md | 438 +-- pyproject.toml | 7 + src/loads.py | 9 + .../__snapshots__/test_energy_regression.ambr | 271 ++ tests/app_helpers.py | 72 + tests/conftest.py | 27 + tests/fixtures/spreadsheet_cases.json | 2909 +++++++++++++++++ .../snapshots/integration_annual_totals.json | 288 ++ tests/test_app_smoke.py | 87 + tests/test_energy_integration.py | 226 ++ tests/test_energy_regression.py | 222 ++ tests/test_energy_unit.py | 315 ++ tests/test_equipment.py | 2 + tests/test_loads.py | 251 +- tests/test_verification_handcalc.py | 262 ++ tests/test_verification_reference.py | 33 + tests/test_verification_spreadsheet.py | 182 ++ tests/test_workflow.py | 265 ++ tests/verification/__init__.py | 1 + tests/verification/cases.py | 143 + tests/verification/equipment.py | 146 + tests/verification/extract_spreadsheet.py | 254 ++ tests/verification/reference_model.py | 210 ++ tests/verification/report.py | 106 + 28 files changed, 6469 insertions(+), 302 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 tests/__snapshots__/test_energy_regression.ambr create mode 100644 tests/app_helpers.py create mode 100644 tests/fixtures/spreadsheet_cases.json create mode 100644 tests/snapshots/integration_annual_totals.json create mode 100644 tests/test_app_smoke.py create mode 100644 tests/test_energy_integration.py create mode 100644 tests/test_energy_regression.py create mode 100644 tests/test_energy_unit.py create mode 100644 tests/test_verification_handcalc.py create mode 100644 tests/test_verification_reference.py create mode 100644 tests/test_verification_spreadsheet.py create mode 100644 tests/test_workflow.py create mode 100644 tests/verification/__init__.py create mode 100644 tests/verification/cases.py create mode 100644 tests/verification/equipment.py create mode 100644 tests/verification/extract_spreadsheet.py create mode 100644 tests/verification/reference_model.py create mode 100644 tests/verification/report.py diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 63ef093..2427a01 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -24,9 +24,8 @@ jobs: - name: Install dependencies run: pip install -r requirements.txt - - name: Run tests - run: pytest - continue-on-error: true # TODO: remove once test suite is complete + - name: Run full test suite (all tiers; gates deployment) + run: python -m pytest -q --tb=short deploy: name: Build & deploy to Cloud Run diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..c2f5648 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,26 @@ +name: Tests + +# Full suite on feature-branch pushes and PRs into development. +# Pushes/PRs to main run the same suite in deploy.yml, which gates deployment. +on: + push: + branches-ignore: [main] + pull_request: + branches: [development] + +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 full test suite + run: python -m pytest -q --tb=short diff --git a/.gitignore b/.gitignore index 2d813ab..9dc49f7 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,7 @@ test-measured-data.ipynb # AI assistant logs .claude/ -CLAUDE.md \ No newline at end of file +CLAUDE.md + +# Local hand-calculation spreadsheet (frozen into tests/fixtures/spreadsheet_cases.json) +tests/testing_cases.xlsx diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 78ab671..353a00a 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 tier) + entry: python -m pytest -m unit -x -q --tb=short + language: system + pass_filenames: false + stages: [pre-commit] diff --git a/notes/testing-scheme.md b/notes/testing-scheme.md index de04abe..b4dc4c8 100644 --- a/notes/testing-scheme.md +++ b/notes/testing-scheme.md @@ -1,304 +1,162 @@ -# Testing Scheme for 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. - -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 - ---- - -## Why Not Keep the Spreadsheet as a Test Fixture? - -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: - -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. - -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. - ---- - -## Core Testing Philosophy - -The scheme uses two complementary strategies: - -### Strategy A — Physics Invariants -Test that **mathematical identities hold**, not that outputs equal a specific number. - -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) - -**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. - -### 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. - -**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. +# Testing Scheme — Berkeley Decarb Tool + +## Purpose + +1. **Show the numbers are right.** Energy and emissions results are checked against + independently calculated values (hand calculations, an independent re-implementation, and + the team's earlier hand-calculation spreadsheet). +2. **Catch changes.** Any change that moves results, intended or not, fails a test, so it gets + reviewed instead of slipping through. +3. **Catch workflow/UI breakage** without a browser: broken pages, callbacks wired to missing + components, charts or downloads that crash. +4. **Need little upkeep.** Verification cases own their inputs, expected values are written as + formulas, regression baselines regenerate with one command, and chart options are read from + the live layout. + +Run everything with `python -m pytest` (about 90 s; all tiers except `app` finish in ~5 s). + +## Tiers + +Every test carries exactly one tier marker. `tests/conftest.py` fails collection if a test has +none, because CI selects by marker and an unmarked test would silently never run. + +| Marker | Files | What it proves | Data | Time | +|---|---|---|---|---| +| `unit` | `test_energy_unit.py`, `test_loads.py`, `test_equipment.py` | Helper functions: interpolation, capacity limits, COP lookups, load validation | synthetic | <1 s | +| `verification` | `test_verification_handcalc.py`, `test_verification_reference.py`, `test_verification_spreadsheet.py` | Results match independently calculated values | test-owned equipment; real loads/Cambium for the reference model | ~2 s | +| `regression` | `test_energy_regression.py`, `test_loads.py::TestGetLoadData` | Energy balance invariants; hourly energy and emissions snapshots | real library, 24 h synthetic load | <1 s | +| `integration` | `test_energy_integration.py` | Full-year runs: invariants plus golden annual energy and emissions totals (±0.1 %) | real parquet + Cambium | ~2 s | +| `app` | `test_app_smoke.py`, `test_workflow.py` | Server, pages and callback wiring; headless end-to-end Results workflow | real | ~85 s | + +### Verification + +**Hand calculations** — `test_verification_handcalc.py`. An 8-hour load profile and round-number +equipment defined in `tests/verification/equipment.py`, *not* the production library. Each +hour isolates one mechanism: AWHP at catalogue points, linear interpolation, operating limits, +backup pickup, the heating/cooling compressor split, sizing with redundancy, constant-rate +electricity, gas and refrigerant emissions, and fuel switching. Expected values are written as +the arithmetic, e.g. `120 / 2.5 + 100 / 2.0 + …`. + +**Independent reference model** — `tests/verification/reference_model.py` re-implements the +method documented in `docs/documentation/calculations.md` using only numpy/pandas and the raw +data files. It never imports `src/`. `test_verification_reference.py` compares annual +electricity, gas and emissions for buildings 83 (hospital, NY) and 35 (large office, SF) +× `eq_scenario_1/3/4` × constant and Cambium long-run grid +rates: 72 comparisons, tolerance 0.001 %. Scope is deliberately narrow (no heat recovery, no +fuel switching) to keep it cheap to maintain; the module docstring lists the method details the +user docs leave implicit. + +**Legacy hand-calculation spreadsheet** — `tests/testing_cases.xlsx` (7 scenarios × 3 +characteristic hours, 3 buildings; kept locally and git-ignored) is frozen into `tests/fixtures/spreadsheet_cases.json` by +`python -m tests.verification.extract_spreadsheet`, *including the equipment data it assumed* +(which has since changed in the library). The committed JSON is the source of truth for the +tests; the spreadsheet is only needed to re-extract it. `test_verification_spreadsheet.py` replays each case. +Values are tagged in the fixture as: +- **checks** — asserted within 0.2 % (the spreadsheet rounds COPs to two decimals); +- **superseded** — computed with a rule the tool has since replaced (AWHPs that are heating did + no cooling at all; now the idle compressors can cool). Not asserted; the new rule is covered by + the hand-calculation tests; +- **known differences** — open method questions, run as strict xfails (see below). + +**Report** — `python -m tests.verification.report` prints the tool-vs-reference table and +the spreadsheet agreement summary, generated by the same code the tests run +(`--csv out.csv` writes the full comparison). + +### Regression + +`test_energy_regression.py` runs four library scenarios (plus `eq_scenario_18` for fuel +switching in the emissions snapshot) on a 24-hour synthetic profile spanning the AWHP +operating limits. It checks energy balance invariants and compares the full hourly output with +syrupy snapshots in `tests/__snapshots__/`. + +### Integration + +`test_energy_integration.py` runs buildings 83 and 35 × `eq_scenario_1/3/4/5/10/18` (baseline, +partial and full AWHP, heat recovery, fuel switching) through the same call chain as the +Results page, using the app's default emission scenarios (Cambium MidCase long-run, 2025/2035/2045). +It checks hourly invariants (no NaN, loads fully served, component sums, emissions sums) and +annual totals against `tests/snapshots/integration_annual_totals.json`. A measured building +(182: gaps, leap year, two calendar years) runs as a smoke case. + +Golden values are regression anchors, not truth; the verification tier is the truth check. A +missing golden entry **fails** (it used to skip, which would hide gaps in CI). + +### App (workflow and UI) + +- `test_app_smoke.py` — `/`, every page path, `/_dash-layout` and `/_dash-dependencies` respond; + every page layout renders and serialises; every component id a callback reads or writes exists, + either in a layout or as a literal `id="…"` in the source (covers components built at runtime); + no output is written by two callbacks unless marked `allow_duplicate`. +- `test_workflow.py` — calls the real Results callbacks as plain functions with the stores a + user would have filled in: the auto-calculation (`initialize_results_page`), every chart in SI + and IP, and the ZIP export. It covers every predefined equipment scenario group, all four grid + emission sources, a measured building, a custom CSV upload, and rejection of negative loads. + One test sweeps every option of every chart control; the options are read from the live + layout. + +## Known issues pinned by tests + +These are deliberately encoded so they stay visible. A strict xfail turns red as soon as the +behaviour changes, prompting you to remove the marker. + +| Test | Issue | +|---|---| +| `test_verification_spreadsheet.py::test_known_method_differences` | AWHP sizing at a non-catalogue HHWST (42 °C) uses the nearest table (38 °C) for the reference capacity; the spreadsheet interpolates. −0.3 % units. | +| `test_energy_integration.py::test_measured_scenarios_cover_the_same_hours` | On measured data, hours with missing outdoor temperature become NaN for AWHP scenarios but not boiler/chiller ones, so scenario annual totals cover different hours (building 182: 16 vs 648 NaN hours). | +| `test_app_smoke.py::KNOWN_ORPHAN_IDS` | `results_page.show_project_summary` writes to `summary-project-info`, which no layout renders. | -**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. +## CI ---- +| Where | What runs | +|---|---| +| pre-commit (`.pre-commit-config.yaml`) | `pytest -m unit` (≈1 s) after ruff | +| `.github/workflows/tests.yml` | full suite on pushes to any branch except `main` and on PRs into `development` | +| `.github/workflows/deploy.yml` | full suite on pushes/PRs to `main`; deployment only runs if it passes | -## Tier Structure +The `cloudbuild.yaml` test step from the earlier `#132-testing-scheme` branch was not ported: +`deploy.yml` builds with `gcloud builds submit --tag`, which ignores `cloudbuild.yaml`. -### Tier 1 — Unit Tests (`pytest -m unit`) +## Playbook -**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 +**A snapshot or golden test fails after a code change.** +1. Decide whether the change *should* move results. If not, it's a bug: fix the code. +2. If it should, regenerate and review what moved: + - `pytest -m regression --snapshot-update`, then `git diff tests/__snapshots__/` + - `pytest -m integration --generate-golden`, then `git diff tests/snapshots/` +3. Check that the verification tier still passes. If a verification test fails, the change + contradicts an independent calculation. Don't "update" it; find out which side is wrong. +4. Commit the regenerated files with a message explaining *why* results moved. -#### What they test +Adding a new output column changes every regression snapshot. That's expected: confirm the diff +only adds the column. -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. +**A verification test fails.** Treat as a real discrepancy. If the method has intentionally +changed, update the hand calculation or reference model *and* the documentation +(`docs/documentation/calculations.md`) in the same commit. -| Function | What to assert | -|---|---| -| `_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 | - -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` - -**New file**: `tests/test_energy_unit.py` - -#### Existing tests +**Equipment library edited.** Verification is unaffected (it owns its equipment). Regression and +integration may move; follow the snapshot/golden steps above. -`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 +**New equipment scenario or group.** The workflow test picks up new scenario groups +automatically. Add the scenario to `SCENARIOS` in `test_energy_integration.py` if it exercises a +new engine path, then regenerate golden values. -This gives Tier 1 approximately **40–50 tests** total after adding the new energy unit tests. +**New chart option.** Nothing to do: `test_workflow.py` reads options from the layout. ---- +**New engine phase or method change.** Add a hand-calculated hour to +`test_verification_handcalc.py` showing the new behaviour, written as arithmetic. -### Tier 2 — Regression Tests (`pytest -m regression`) +**Spreadsheet corrected.** With the local `tests/testing_cases.xlsx` in place, rerun +`python -m tests.verification.extract_spreadsheet` and review the JSON diff. -**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 - -#### What they test - -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. +## Not covered yet -**Two layers within Tier 2:** - -**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 - -These catch logic bugs that break the energy balance without needing a golden reference number. - -**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") -``` - -When the output legitimately changes (e.g., a bug fix or equipment data update): -```bash -pytest -m regression --snapshot-update -``` -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. - -**New files**: -- `tests/test_energy_regression.py` -- `tests/snapshots/` directory (auto-populated by syrupy on first run) - -**New dependency**: `syrupy` (lightweight snapshot testing library for pytest) - ---- - -### Tier 3 — Integration / Smoke Tests (`pytest -m integration`) - -**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 - -#### What they test - -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. - -**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. - -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. - -**New file**: `tests/test_energy_integration.py` - -#### 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. - ---- - -## CI Integration - -### Pre-commit (`.pre-commit-config.yaml`) - -Add a new hook that runs Tier 1 only. This is fast enough to not annoy developers: - -```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] -``` - -If the unit suite grows beyond ~15 seconds, restrict it further with -`--ignore=tests/test_energy_integration.py` etc. - -### Cloud Build (`cloudbuild.yaml`) - -Add a test step before the Docker build step. This runs Tiers 1 + 2 on every PR/main push: - -```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'] -``` - -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'] -``` - -### `pyproject.toml` — pytest markers - -```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"] -``` - ---- - -## Maintenance Playbook - -| 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. | - ---- - -## File Summary - -| 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) | - ---- - -## 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 +- **Browser end-to-end** (Playwright): one happy-path click-through Loads → Equipment → + Emissions → Results, asserting no error notifications and no browser console errors. Run nightly + rather than per push. This would add coverage for clientside callbacks and component rendering, + which the headless tests cannot see. +- **Reference model scope**: heat recovery (HR-WWHP) and fuel switching are verified by hand + calculation and the spreadsheet, not by the full-year reference model. +- **Method validation** against EnergyPlus or measured plant data. The verification here shows the + tool implements its documented method correctly; it does not validate the method's + simplifications (no fan/pump energy, no part-load or defrost derates). diff --git a/pyproject.toml b/pyproject.toml index 4030c21..c89ee38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,4 +34,11 @@ indent-style = "space" testpaths = ["tests"] python_files = ["test_*.py"] python_functions = ["test_*"] +markers = [ + "unit: fast pure-function tests, synthetic data, no I/O (<5s total)", + "verification: engine + emissions checked against independent hand calculations and an independent reference model", + "regression: physics invariants + syrupy snapshots using the real equipment library", + "integration: full 8760-hour runs on real load and Cambium data, golden annual totals (1-3 min)", + "app: Dash server smoke tests and headless end-to-end workflow", +] addopts = ["-v", "--tb=short", "-ra"] diff --git a/src/loads.py b/src/loads.py index c3cac28..26a68b2 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..8b06bc8 --- /dev/null +++ b/tests/__snapshots__/test_energy_regression.ambr @@ -0,0 +1,271 @@ +# serializer version: 1 +# name: test_emissions_output_snapshot[eq_scenario_10] + ''' + timestamp,elec_Wh,gas_Wh,elec_emissions,gas_emissions,total_refrig_emissions,total_emissions + 2025-01-01 00:00:00,0.0,562500.0,0.0,134.55,895.7292,1030.2792 + 2025-01-01 01:00:00,0.0,500000.0,0.0,119.6,895.7292,1015.3292 + 2025-01-01 02:00:00,0.0,437500.0,0.0,104.65,895.7292,1000.3792 + 2025-01-01 03:00:00,0.0,375000.0,0.0,89.7,895.7292,985.4292 + 2025-01-01 04:00:00,0.0,312500.0,0.0,74.75,895.7292,970.4792 + 2025-01-01 05:00:00,0.0,257500.0,0.0,61.594,895.7292,957.3232 + 2025-01-01 06:00:00,0.0,187500.0,0.0,44.85,895.7292,940.5792 + 2025-01-01 07:00:00,0.0,125000.0,0.0,29.9,895.7292,925.6292 + 2025-01-01 08:00:00,12500.0,62500.0,7.5,14.95,895.7292,918.1792 + 2025-01-01 09:00:00,25000.0,0.0,15.0,0.0,895.7292,910.7292 + 2025-01-01 10:00:00,32500.0,0.0,19.5,0.0,895.7292,915.2292 + 2025-01-01 11:00:00,37500.0,0.0,22.5,0.0,895.7292,918.2292 + 2025-01-01 12:00:00,50000.0,0.0,30.0,0.0,895.7292,925.7292 + 2025-01-01 13:00:00,52500.0,0.0,31.5,0.0,895.7292,927.2292 + 2025-01-01 14:00:00,62500.0,0.0,37.5,0.0,895.7292,933.2292 + 2025-01-01 15:00:00,75000.0,0.0,45.0,0.0,895.7292,940.7292 + 2025-01-01 16:00:00,0.0,437500.0,0.0,104.65,895.7292,1000.3792 + 2025-01-01 17:00:00,0.0,375000.0,0.0,89.7,895.7292,985.4292 + 2025-01-01 18:00:00,0.0,312500.0,0.0,74.75,895.7292,970.4792 + 2025-01-01 19:00:00,0.0,250000.0,0.0,59.8,895.7292,955.5292 + 2025-01-01 20:00:00,0.0,187500.0,0.0,44.85,895.7292,940.5792 + 2025-01-01 21:00:00,0.0,125000.0,0.0,29.9,895.7292,925.6292 + 2025-01-01 22:00:00,12500.0,62500.0,7.5,14.95,895.7292,918.1792 + 2025-01-01 23:00:00,25000.0,0.0,15.0,0.0,895.7292,910.7292 + + ''' +# --- +# name: test_emissions_output_snapshot[eq_scenario_18] + ''' + timestamp,elec_Wh,gas_Wh,elec_emissions,gas_emissions,total_refrig_emissions,total_emissions + 2025-01-01 00:00:00,0.0,500000.0,0.0,119.6,209.7,329.3 + 2025-01-01 01:00:00,0.0,444444.4444,0.0,106.3111,209.7,316.0111 + 2025-01-01 02:00:00,146935.3484,0.0,88.1612,0.0,209.7,297.8612 + 2025-01-01 03:00:00,118585.445,0.0,71.1513,0.0,209.7,280.8513 + 2025-01-01 04:00:00,90187.5902,0.0,54.1126,0.0,209.7,263.8126 + 2025-01-01 05:00:00,66451.6129,0.0,39.871,0.0,209.7,249.571 + 2025-01-01 06:00:00,41322.314,0.0,24.7934,0.0,209.7,234.4934 + 2025-01-01 07:00:00,25906.7358,0.0,15.544,0.0,209.7,225.244 + 2025-01-01 08:00:00,21930.7574,0.0,13.1585,0.0,209.7,222.8585 + 2025-01-01 09:00:00,20000.0,0.0,12.0,0.0,209.7,221.7 + 2025-01-01 10:00:00,28176.6459,0.0,16.906,0.0,209.7,226.606 + 2025-01-01 11:00:00,34433.2855,0.0,20.66,0.0,209.7,230.36 + 2025-01-01 12:00:00,53872.0539,0.0,32.3232,0.0,209.7,242.0232 + 2025-01-01 13:00:00,57181.7563,0.0,34.3091,0.0,209.7,244.0091 + 2025-01-01 14:00:00,71174.3772,0.0,42.7046,0.0,209.7,252.4046 + 2025-01-01 15:00:00,90566.0377,0.0,54.3396,0.0,209.7,264.0396 + 2025-01-01 16:00:00,146935.3484,0.0,88.1612,0.0,209.7,297.8612 + 2025-01-01 17:00:00,118585.445,0.0,71.1513,0.0,209.7,280.8513 + 2025-01-01 18:00:00,90187.5902,0.0,54.1126,0.0,209.7,263.8126 + 2025-01-01 19:00:00,63355.5832,0.0,38.0133,0.0,209.7,247.7133 + 2025-01-01 20:00:00,41322.314,0.0,24.7934,0.0,209.7,234.4934 + 2025-01-01 21:00:00,25906.7358,0.0,15.544,0.0,209.7,225.244 + 2025-01-01 22:00:00,21930.7574,0.0,13.1585,0.0,209.7,222.8585 + 2025-01-01 23:00:00,20000.0,0.0,12.0,0.0,209.7,221.7 + + ''' +# --- +# name: test_emissions_output_snapshot[eq_scenario_3] + ''' + timestamp,elec_Wh,gas_Wh,elec_emissions,gas_emissions,total_refrig_emissions,total_emissions + 2025-01-01 00:00:00,0.0,562500.0,0.0,134.55,956.8917,1091.4417 + 2025-01-01 01:00:00,57488.3721,345500.0,34.493,82.6436,956.8917,1074.0283 + 2025-01-01 02:00:00,62552.4769,251250.0,37.5315,60.099,956.8917,1054.5222 + 2025-01-01 03:00:00,63118.5149,175401.7857,37.8711,41.9561,956.8917,1036.7189 + 2025-01-01 04:00:00,63823.9538,91350.0,38.2944,21.8509,956.8917,1017.037 + 2025-01-01 05:00:00,64580.6452,7250.0,38.7484,1.7342,956.8917,997.3743 + 2025-01-01 06:00:00,41322.314,0.0,24.7934,0.0,956.8917,981.6851 + 2025-01-01 07:00:00,25906.7358,0.0,15.544,0.0,956.8917,972.4357 + 2025-01-01 08:00:00,21930.7574,0.0,13.1585,0.0,956.8917,970.0501 + 2025-01-01 09:00:00,20000.0,0.0,12.0,0.0,956.8917,968.8917 + 2025-01-01 10:00:00,28176.6459,0.0,16.906,0.0,956.8917,973.7977 + 2025-01-01 11:00:00,34433.2855,0.0,20.66,0.0,956.8917,977.5516 + 2025-01-01 12:00:00,53872.0539,0.0,32.3232,0.0,956.8917,989.2149 + 2025-01-01 13:00:00,57181.7563,0.0,34.3091,0.0,956.8917,991.2007 + 2025-01-01 14:00:00,70831.7393,0.0,42.499,0.0,956.8917,999.3907 + 2025-01-01 15:00:00,86869.1038,0.0,52.1215,0.0,956.8917,1009.0131 + 2025-01-01 16:00:00,62552.4769,251250.0,37.5315,60.099,956.8917,1054.5222 + 2025-01-01 17:00:00,63118.5149,175401.7857,37.8711,41.9561,956.8917,1036.7189 + 2025-01-01 18:00:00,63823.9538,91350.0,38.2944,21.8509,956.8917,1017.037 + 2025-01-01 19:00:00,63355.5832,0.0,38.0133,0.0,956.8917,994.905 + 2025-01-01 20:00:00,41322.314,0.0,24.7934,0.0,956.8917,981.6851 + 2025-01-01 21:00:00,25906.7358,0.0,15.544,0.0,956.8917,972.4357 + 2025-01-01 22:00:00,21930.7574,0.0,13.1585,0.0,956.8917,970.0501 + 2025-01-01 23:00:00,20000.0,0.0,12.0,0.0,956.8917,968.8917 + + ''' +# --- +# name: test_emissions_output_snapshot[eq_scenario_4] + ''' + timestamp,elec_Wh,gas_Wh,elec_emissions,gas_emissions,total_refrig_emissions,total_emissions + 2025-01-01 00:00:00,450000.0,0.0,270.0,0.0,209.7,479.7 + 2025-01-01 01:00:00,201665.1163,0.0,120.9991,0.0,209.7,330.6991 + 2025-01-01 02:00:00,146935.3484,0.0,88.1612,0.0,209.7,297.8612 + 2025-01-01 03:00:00,118585.445,0.0,71.1513,0.0,209.7,280.8513 + 2025-01-01 04:00:00,90187.5902,0.0,54.1126,0.0,209.7,263.8126 + 2025-01-01 05:00:00,66451.6129,0.0,39.871,0.0,209.7,249.571 + 2025-01-01 06:00:00,41322.314,0.0,24.7934,0.0,209.7,234.4934 + 2025-01-01 07:00:00,25906.7358,0.0,15.544,0.0,209.7,225.244 + 2025-01-01 08:00:00,21930.7574,0.0,13.1585,0.0,209.7,222.8585 + 2025-01-01 09:00:00,20000.0,0.0,12.0,0.0,209.7,221.7 + 2025-01-01 10:00:00,28176.6459,0.0,16.906,0.0,209.7,226.606 + 2025-01-01 11:00:00,34433.2855,0.0,20.66,0.0,209.7,230.36 + 2025-01-01 12:00:00,53872.0539,0.0,32.3232,0.0,209.7,242.0232 + 2025-01-01 13:00:00,57181.7563,0.0,34.3091,0.0,209.7,244.0091 + 2025-01-01 14:00:00,71174.3772,0.0,42.7046,0.0,209.7,252.4046 + 2025-01-01 15:00:00,90566.0377,0.0,54.3396,0.0,209.7,264.0396 + 2025-01-01 16:00:00,146935.3484,0.0,88.1612,0.0,209.7,297.8612 + 2025-01-01 17:00:00,118585.445,0.0,71.1513,0.0,209.7,280.8513 + 2025-01-01 18:00:00,90187.5902,0.0,54.1126,0.0,209.7,263.8126 + 2025-01-01 19:00:00,63355.5832,0.0,38.0133,0.0,209.7,247.7133 + 2025-01-01 20:00:00,41322.314,0.0,24.7934,0.0,209.7,234.4934 + 2025-01-01 21:00:00,25906.7358,0.0,15.544,0.0,209.7,225.244 + 2025-01-01 22:00:00,21930.7574,0.0,13.1585,0.0,209.7,222.8585 + 2025-01-01 23:00:00,20000.0,0.0,12.0,0.0,209.7,221.7 + + ''' +# --- +# name: test_emissions_output_snapshot[eq_scenario_5] + ''' + timestamp,elec_Wh,gas_Wh,elec_emissions,gas_emissions,total_refrig_emissions,total_emissions + 2025-01-01 00:00:00,450000.0,0.0,270.0,0.0,253.3875,523.3875 + 2025-01-01 01:00:00,201665.1163,0.0,120.9991,0.0,253.3875,374.3866 + 2025-01-01 02:00:00,146935.3484,0.0,88.1612,0.0,253.3875,341.5487 + 2025-01-01 03:00:00,118585.445,0.0,71.1513,0.0,253.3875,324.5388 + 2025-01-01 04:00:00,90187.5902,0.0,54.1126,0.0,253.3875,307.5001 + 2025-01-01 05:00:00,66451.6129,0.0,39.871,0.0,253.3875,293.2585 + 2025-01-01 06:00:00,41322.314,0.0,24.7934,0.0,253.3875,278.1809 + 2025-01-01 07:00:00,25906.7358,0.0,15.544,0.0,253.3875,268.9315 + 2025-01-01 08:00:00,21930.7574,0.0,13.1585,0.0,253.3875,266.546 + 2025-01-01 09:00:00,20000.0,0.0,12.0,0.0,253.3875,265.3875 + 2025-01-01 10:00:00,28176.6459,0.0,16.906,0.0,253.3875,270.2935 + 2025-01-01 11:00:00,34433.2855,0.0,20.66,0.0,253.3875,274.0475 + 2025-01-01 12:00:00,53872.0539,0.0,32.3232,0.0,253.3875,285.7107 + 2025-01-01 13:00:00,57181.7563,0.0,34.3091,0.0,253.3875,287.6966 + 2025-01-01 14:00:00,71174.3772,0.0,42.7046,0.0,253.3875,296.0921 + 2025-01-01 15:00:00,90566.0377,0.0,54.3396,0.0,253.3875,307.7271 + 2025-01-01 16:00:00,146935.3484,0.0,88.1612,0.0,253.3875,341.5487 + 2025-01-01 17:00:00,118585.445,0.0,71.1513,0.0,253.3875,324.5388 + 2025-01-01 18:00:00,90187.5902,0.0,54.1126,0.0,253.3875,307.5001 + 2025-01-01 19:00:00,63355.5832,0.0,38.0133,0.0,253.3875,291.4008 + 2025-01-01 20:00:00,41322.314,0.0,24.7934,0.0,253.3875,278.1809 + 2025-01-01 21:00:00,25906.7358,0.0,15.544,0.0,253.3875,268.9315 + 2025-01-01 22:00:00,21930.7574,0.0,13.1585,0.0,253.3875,266.546 + 2025-01-01 23:00:00,20000.0,0.0,12.0,0.0,253.3875,265.3875 + + ''' +# --- +# 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_num,boiler_eff,boiler_hhw_W,boiler_hhw_W_ng_mode,boiler_eq_calc_type,boiler_cap_W,gas_boiler_Wh,gas_boiler_Wh_ng_mode,fuel_switching,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,,specific,190500.0,562500.0,,False,,,,,,,,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,,specific,190500.0,500000.0,,False,,,,,,,,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,,specific,190500.0,437500.0,,False,,,,,,,,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,,specific,190500.0,375000.0,,False,,,,,,,,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,,specific,190500.0,312500.0,,False,,,,,,,,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,,specific,190500.0,257500.0,,False,,,,,,,,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,,specific,190500.0,187500.0,,False,,,,,,,,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,,specific,190500.0,125000.0,,False,,,,,,,,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,,specific,190500.0,62500.0,,False,,,,,,,,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,,specific,190500.0,0.0,,False,,,,,,,,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,,specific,190500.0,0.0,,False,,,,,,,,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,,specific,190500.0,0.0,,False,,,,,,,,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,,specific,190500.0,0.0,,False,,,,,,,,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,,specific,190500.0,0.0,,False,,,,,,,,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,,specific,190500.0,0.0,,False,,,,,,,,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,,specific,190500.0,0.0,,False,,,,,,,,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,,specific,190500.0,437500.0,,False,,,,,,,,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,,specific,190500.0,375000.0,,False,,,,,,,,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,,specific,190500.0,312500.0,,False,,,,,,,,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,,specific,190500.0,250000.0,,False,,,,,,,,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,,specific,190500.0,187500.0,,False,,,,,,,,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,,specific,190500.0,125000.0,,False,,,,,,,,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,,specific,190500.0,62500.0,,False,,,,,,,,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,,specific,190500.0,0.0,,False,,,,,,,,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_num,boiler_eff,boiler_hhw_W,boiler_hhw_W_ng_mode,boiler_eq_calc_type,boiler_cap_W,gas_boiler_Wh,gas_boiler_Wh_ng_mode,fuel_switching,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,,specific,190500.0,562500.0,,False,,,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,,specific,190500.0,345500.0,,False,,,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,,specific,190500.0,251250.0,,False,,,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,,specific,190500.0,175401.7857,,False,,,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,,specific,190500.0,91350.0,,False,,,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,,specific,190500.0,7250.0,,False,,,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,,specific,190500.0,0.0,,False,,,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,,specific,190500.0,0.0,,False,,,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,,specific,190500.0,0.0,,False,,,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,,specific,190500.0,0.0,,False,,,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,,specific,190500.0,0.0,,False,,,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,,specific,190500.0,0.0,,False,,,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,,specific,190500.0,0.0,,False,,,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,,specific,190500.0,0.0,,False,,,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,,specific,190500.0,0.0,,False,,,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,,specific,190500.0,0.0,,False,,,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,,specific,190500.0,251250.0,,False,,,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,,specific,190500.0,175401.7857,,False,,,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,,specific,190500.0,91350.0,,False,,,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,,specific,190500.0,0.0,,False,,,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,,specific,190500.0,0.0,,False,,,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,,specific,190500.0,0.0,,False,,,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,,specific,190500.0,0.0,,False,,,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,,specific,190500.0,0.0,,False,,,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_num,boiler_eff,boiler_hhw_W,boiler_hhw_W_ng_mode,boiler_eq_calc_type,boiler_cap_W,gas_boiler_Wh,gas_boiler_Wh_ng_mode,fuel_switching,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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_num,boiler_eff,boiler_hhw_W,boiler_hhw_W_ng_mode,boiler_eq_calc_type,boiler_cap_W,gas_boiler_Wh,gas_boiler_Wh_ng_mode,fuel_switching,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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,,,,,,,,,False,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/app_helpers.py b/tests/app_helpers.py new file mode 100644 index 0000000..f44626a --- /dev/null +++ b/tests/app_helpers.py @@ -0,0 +1,72 @@ +"""Helpers for the app-tier tests: walk Dash layouts without a browser.""" + +import json +import re +from pathlib import Path + +from dash.development.base_component import Component + +ROOT = Path(__file__).resolve().parents[1] +_ID_IN_SOURCE = re.compile(r"""\bid\s*=\s*["']([\w\-:.]+)["']""") + + +def walk(node): + """Yield every Dash component in a layout tree (children and component-valued props).""" + if isinstance(node, Component): + yield node + for prop in node._prop_names: + value = getattr(node, prop, None) + if isinstance(value, Component | list | tuple): + yield from walk(value) + elif isinstance(node, list | tuple): + for item in node: + yield from walk(item) + + +def component_ids(layout) -> set[str]: + ids = set() + for comp in walk(layout): + cid = getattr(comp, "id", None) + if isinstance(cid, str): + ids.add(cid) + return ids + + +def find(layout, component_id: str) -> Component: + for comp in walk(layout): + if getattr(comp, "id", None) == component_id: + return comp + raise KeyError(component_id) + + +def option_values(layout, component_id: str) -> list: + """Values of a Select/SegmentedControl's `data` options, read from the live layout.""" + return [opt["value"] for opt in find(layout, component_id).data] + + +def ids_declared_in_source() -> set[str]: + """Every literal id="..." in the app source, including components built at runtime.""" + ids = set() + for folder in ("pages", "layout"): + for path in (ROOT / folder).glob("*.py"): + ids.update(_ID_IN_SOURCE.findall(path.read_text())) + ids.update(_ID_IN_SOURCE.findall((ROOT / "app.py").read_text())) + return ids + + +def callback_component_ids(dependencies: list[dict]) -> set[str]: + """String component ids referenced by /_dash-dependencies (pattern-matching ids skipped).""" + ids = set() + for dep in dependencies: + if not dep.get("no_output"): + for output in dep["output"].strip(".").split("..."): + ids.add(output.rsplit(".", 1)[0]) + for item in dep["inputs"] + dep["state"]: + if isinstance(item["id"], str): + ids.add(item["id"]) + return {i for i in ids if not i.startswith("{")} + + +def figure_has_data(fig) -> bool: + fig = json.loads(fig.to_json()) if hasattr(fig, "to_json") else fig + return any(trace for trace in fig.get("data", [])) diff --git a/tests/conftest.py b/tests/conftest.py index 953b9c2..fb5c4dd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,33 @@ from src.loads import StandardLoad from src.metadata import Metadata +TIER_MARKERS = {"unit", "verification", "regression", "integration", "app"} + + +def pytest_addoption(parser): + parser.addoption( + "--generate-golden", + action="store_true", + default=False, + help="Regenerate integration_annual_totals.json golden values (integration tier)", + ) + + +def pytest_collection_modifyitems(config, items): + """Fail collection if any test lacks a tier marker. + + CI selects tests by marker (e.g. -m "unit or regression"), so an unmarked + test would silently never run there. + """ + unmarked = [ + item.nodeid for item in items if not TIER_MARKERS & {m.name for m in item.iter_markers()} + ] + if unmarked: + raise pytest.UsageError( + f"Tests without a tier marker ({', '.join(sorted(TIER_MARKERS))}):\n " + + "\n ".join(unmarked) + ) + @pytest.fixture def sample_load_df(): diff --git a/tests/fixtures/spreadsheet_cases.json b/tests/fixtures/spreadsheet_cases.json new file mode 100644 index 0000000..38f39fa --- /dev/null +++ b/tests/fixtures/spreadsheet_cases.json @@ -0,0 +1,2909 @@ +{ + "source": "tests/testing_cases.xlsx", + "generated_by": "python -m tests.verification.extract_spreadsheet", + "equipment": [ + { + "eq_id": "bo02", + "eq_type": "backup_heating", + "eq_subtype": "hot_water", + "eq_calc_type": "specific", + "model": "GenericBoiler", + "fuel": "natural_gas", + "capacity_W": 190500, + "dimensions": { + "length": 1.06, + "width": 0.833, + "height": 2.01 + }, + "operating_weight_g": 305000, + "electrical": { + "mca": 10, + "voltage": 120, + "phase": 1 + }, + "performance": { + "heating": { + "efficiency": 0.9 + } + } + }, + { + "eq_id": "bo03", + "eq_type": "backup_heating", + "eq_subtype": "hot_water", + "eq_calc_type": "specific", + "model": "GenericBoiler", + "fuel": "natural_gas", + "capacity_W": 190500, + "dimensions": { + "length": 1.06, + "width": 0.833, + "height": 2.01 + }, + "operating_weight_g": 305000, + "electrical": { + "mca": 10, + "voltage": 120, + "phase": 1 + }, + "performance": { + "heating": { + "efficiency": 0.8 + } + } + }, + { + "eq_id": "hp01", + "eq_type": "heat_pump", + "eq_subtype": "air_to_water", + "eq_calc_type": "specific", + "model": "GenericAWHP", + "nominal_capacity_W": 210960, + "fuel": "electricity", + "refrigerant": "R-454B", + "refrigerant_weight_g": 54000, + "refrigerant_gwp": 466, + "dimensions": { + "length": 2.0, + "width": 2.5, + "height": 3.2 + }, + "operating_weight_g": 2900000, + "electrical": { + "mca": 140, + "voltage": 460, + "phase": 3 + }, + "max_output_dba": 90, + "performance": { + "heating": { + "t_out_C": [ + -15, + -12.2, + -6.7, + -1.1, + 4.4, + 10, + 15, + 20, + 30 + ], + "leaving_supply_t": { + "38": { + "cop": [ + 2.15, + 2.33, + 2.46, + 2.69, + 3.1, + 3.63, + 3.86, + 4.03, + 4.22 + ], + "capacity_W": [ + 123600, + 145200, + 154700, + 171100, + 200200, + 235700, + 243100, + 257500, + 274800 + ], + "constraints": { + "min_temp_C": -15, + "max_temp_C": 35 + } + }, + "52": { + "cop": [ + 1.97, + 1.97, + 1.97, + 2.12, + 2.42, + 2.84, + 2.88, + 3.04, + 3.2 + ], + "capacity_W": [ + 155500, + 155500, + 155500, + 169600, + 196300, + 229500, + 232400, + 248500, + 268900 + ], + "constraints": { + "min_temp_C": -10, + "max_temp_C": 35 + } + } + }, + "constraints": { + "min_temp_C": 38, + "max_temp_C": 52 + } + }, + "cooling": { + "t_out_C": [ + 15, + 25, + 35, + 45 + ], + "leaving_supply_t": { + "6.7": { + "cop": [ + 4.4, + 4.0, + 2.97, + 2.65 + ], + "capacity_W": [ + 250000, + 220000, + 201200, + 183000 + ], + "constraints": { + "min_temp_C": 5, + "max_temp_C": 45 + } + } + }, + "constraints": { + "min_temp_C": 6.7, + "max_temp_C": 6.7 + } + } + } + }, + { + "eq_id": "hp04", + "eq_type": "heat_pump", + "eq_subtype": "air_to_water", + "eq_calc_type": "specific", + "eq_manufacturer": "Aermec", + "model": "NRBHA800", + "nominal_capacity_W": 210960, + "fuel": "electricity", + "refrigerant": "R-410A", + "refrigerant_weight_g": 29030, + "refrigerant_gwp": 2088, + "dimensions": { + "length": 2.21, + "width": 2.46, + "height": 3.96 + }, + "operating_weight_g": 3079892, + "electrical": { + "mca": 129, + "voltage": 460, + "phase": 3 + }, + "max_output_dba": 87.5, + "performance": { + "heating": { + "t_out_C": [ + -15, + -10, + -5, + 0, + 5, + 7, + 10, + 15, + 20, + 25, + 30 + ], + "leaving_supply_t": { + "50": { + "cop": [ + 1.76, + 1.76, + 1.76, + 1.84, + 2.19, + 2.41, + 2.66, + 2.88, + 3.04, + 3.13, + 3.2 + ], + "capacity_W": [ + 136831, + 136831, + 136831, + 142105, + 170526, + 188985, + 210667, + 232349, + 248464, + 260184, + 268974 + ], + "constraints": { + "min_temp_C": -5, + "max_temp_C": 30 + } + }, + "45": { + "cop": [ + 1.89, + 1.89, + 1.94, + 2.04, + 2.44, + 2.69, + 2.95, + 3.19, + 3.35, + 3.45, + 3.51 + ], + "capacity_W": [ + 137196, + 137196, + 137196, + 143820, + 173745, + 192880, + 214823, + 236025, + 251542, + 262725, + 270929 + ], + "constraints": { + "min_temp_C": -10, + "max_temp_C": 30 + } + }, + "35": { + "cop": [ + 1.98, + 2.29, + 2.35, + 2.5, + 3.0, + 3.29, + 3.59, + 3.86, + 4.03, + 4.15, + 4.22 + ], + "capacity_W": [ + 118948, + 135599, + 139544, + 148258, + 180562, + 200729, + 222975, + 243083, + 257523, + 267647, + 274811 + ], + "constraints": { + "min_temp_C": -15, + "max_temp_C": 30 + } + } + }, + "constraints": { + "min_temp_C": 35, + "max_temp_C": 50 + } + }, + "cooling": { + "t_out_C": [ + 10, + 20, + 30, + 35, + 40 + ], + "leaving_supply_t": { + "7": { + "cop": [ + 5.53, + 4.43, + 3.49, + 3.05, + 2.66 + ], + "capacity_W": [ + 253568, + 232467, + 209959, + 198353, + 187099 + ], + "constraints": { + "min_temp_C": 5, + "max_temp_C": 40 + } + } + }, + "constraints": { + "min_temp_C": 7, + "max_temp_C": 7 + } + } + } + }, + { + "eq_id": "hp06", + "eq_type": "heat_pump", + "eq_subtype": "air_to_water", + "eq_calc_type": "specific", + "eq_manufacturer": "Trane", + "model": "AXM", + "nominal_capacity_W": 210960, + "fuel": "electricity", + "refrigerant": "R-454B", + "refrigerant_weight_g": 78018, + "refrigerant_gwp": 466, + "dimensions": { + "length": 1.22, + "width": 2.62, + "height": 2.45 + }, + "operating_weight_g": 2721554, + "electrical": { + "mca": 151, + "voltage": 460, + "phase": 3 + }, + "performance": { + "heating": { + "t_out_C": [ + -23.3, + -17.7, + -12.2, + -6.7, + -1.1, + 4.4, + 10 + ], + "leaving_supply_t": { + "43.3": { + "cop": [ + 2.09, + 2.32, + 2.37, + 2.57, + 2.87, + 3.19, + 3.67 + ], + "capacity_W": [ + 113070, + 128224, + 154712, + 169923, + 193875, + 219918, + 248389 + ], + "constraints": { + "min_temp_C": -27, + "max_temp_C": 35 + } + }, + "48.9": { + "cop": [ + 1.92, + 2.12, + 2.18, + 2.35, + 2.62, + 2.9, + 3.31 + ], + "capacity_W": [ + 116085, + 131012, + 155846, + 171631, + 195106, + 220565, + 247700 + ], + "constraints": { + "min_temp_C": -27, + "max_temp_C": 35 + } + }, + "54.4": { + "cop": [ + 1.95, + 1.95, + 2.01, + 2.17, + 2.4, + 2.65, + 3.01 + ], + "capacity_W": [ + 134227, + 134227, + 157746, + 174225, + 196994, + 221969, + 248331 + ], + "constraints": { + "min_temp_C": -18, + "max_temp_C": 35 + } + }, + "60": { + "cop": [ + 1.86, + 1.86, + 1.86, + 2.02, + 2.23, + 2.45, + 2.77 + ], + "capacity_W": [ + 160190, + 160190, + 160190, + 177478, + 200209, + 224551, + 250291 + ], + "constraints": { + "min_temp_C": -13, + "max_temp_C": 35 + } + } + }, + "constraints": { + "min_temp_C": 43.3, + "max_temp_C": 60 + } + }, + "cooling": { + "t_out_C": [ + 25, + 35, + 45 + ], + "leaving_supply_t": { + "6.7": { + "cop": [ + 4.0, + 2.89, + 2.65 + ], + "capacity_W": [ + 250000, + 203980, + 185000 + ], + "constraints": { + "min_temp_C": 5, + "max_temp_C": 40 + } + } + }, + "constraints": { + "min_temp_C": 6.7, + "max_temp_C": 6.7 + } + } + } + }, + { + "eq_id": "hr01", + "eq_type": "hr_heat_pump", + "eq_subtype": "water_to_water", + "eq_calc_type": "specific", + "model": "GenericHR_WWHP", + "nominal_capacity_W": 1054800, + "fuel": "electricity", + "refrigerant": "R-515B", + "refrigerant_weight_g": 200000, + "refrigerant_gwp": 466, + "dimensions": { + "length": 4.0, + "width": 1.5, + "height": 2.0 + }, + "operating_weight_g": 6000000, + "electrical": { + "mca": 500, + "voltage": 460, + "phase": 3 + }, + "performance": { + "heating": { + "capacity_W": [ + 323600, + 647300, + 704300, + 818800, + 933300, + 1048100 + ], + "leaving_supply_t": { + "32.2": { + "cop": [ + 6.7, + 5.8, + 5.9, + 5.94, + 5.97, + 6.0 + ] + }, + "73.9": { + "cop": [ + 2.28, + 2.28, + 2.3, + 2.33, + 2.35, + 2.37 + ] + } + }, + "constraints": { + "min_temp_C": 32.2, + "max_temp_C": 73.9 + } + } + } + }, + { + "eq_id": "hr03", + "eq_type": "hr_heat_pump", + "eq_subtype": "water_to_water_scroll", + "eq_calc_type": "specific", + "eq_manufacturer": "IMC", + "model": "GenericHR_WWHP", + "nominal_capacity_W": 210960, + "fuel": "electricity", + "refrigerant": "R-454B", + "refrigerant_weight_g": 29000, + "refrigerant_gwp": 466, + "dimensions": { + "length": 3.0, + "width": 2.25, + "height": 2.5 + }, + "operating_weight_g": 2612000, + "electrical": { + "mca": 140, + "voltage": 460, + "phase": 3 + }, + "max_output_dba": 90, + "performance": { + "heating": { + "capacity_W": [ + 70000, + 140000, + 211000 + ], + "leaving_supply_t": { + "48.9": { + "cop": [ + 4.2, + 4.0, + 3.68 + ] + }, + "60": { + "cop": [ + 3.3, + 3.1, + 2.68 + ] + } + }, + "constraints": { + "min_temp_C": 48.9, + "max_temp_C": 60 + } + } + } + }, + { + "eq_id": "res01", + "eq_type": "backup_heating", + "model": "GenericResHeater", + "eq_subtype": "electric", + "eq_calc_type": "generic", + "fuel": "electricity", + "performance": { + "heating": { + "efficiency": 1.0 + } + } + }, + { + "eq_id": "res02", + "eq_type": "backup_heating", + "model": "GenericResHeater", + "eq_calc_type": "specific", + "eq_subtype": "electric", + "fuel": "electricity", + "capacity_W": 180000, + "dimensions": { + "length": 1.34, + "width": 0.832, + "height": 1.75 + }, + "operating_weight_g": 928000, + "electrical": { + "mca": 273, + "voltage": 480, + "phase": 3 + }, + "performance": { + "heating": { + "efficiency": 1.0 + } + } + }, + { + "eq_id": "xs_chiller_703kW", + "eq_type": "chiller", + "eq_calc_type": "specific", + "model": "Spreadsheet chiller 703 kW", + "fuel": "electricity", + "capacity_W": 703200, + "refrigerant": "R-spreadsheet", + "refrigerant_weight_g": 90700.0, + "refrigerant_gwp": 2088, + "performance": { + "cooling": { + "efficiency": 2.84 + } + } + }, + { + "eq_id": "xs_chiller_352kW", + "eq_type": "chiller", + "eq_calc_type": "specific", + "model": "Spreadsheet chiller 352 kW", + "fuel": "electricity", + "capacity_W": 351600, + "refrigerant": "R-spreadsheet", + "refrigerant_weight_g": 45400.0, + "refrigerant_gwp": 2088, + "performance": { + "cooling": { + "efficiency": 2.84 + } + } + } + ], + "cases": [ + { + "id": "B1S1", + "scenario": { + "eq_scen_id": "B1S1", + "eq_scen_name": "B1S1", + "hr_wwhp": "hr03", + "hr_wwhp_performance_model": "interpolate_HHWST", + "hr_wwhp_h_supply_t": 48.9, + "awhp": "hp01", + "awhp_performance_model": "interpolate_HHWST_fixed", + "awhp_h_supply_t": 38, + "awhp_sizing_mode": "integer_sizing_peak_load", + "awhp_sizing_value": 1, + "awhp_redundancy": 1, + "awhp_use_cooling": true, + "awhp_sizing_priority": "heating", + "fuel_switching": false, + "backup_heating": "res02", + "chiller": "xs_chiller_703kW" + }, + "peak_heating_kW": 2805.01, + "peak_chiller_kW": 2108.01, + "emissions": { + "grid_scenario": "MidCase", + "gea_grid_region": "PJM_West", + "emission_type": "Includes pre-combustion", + "shortrun_weighting": 0, + "year": 2025, + "annual_refrig_leakage": 0.05, + "ng_rate_g_per_kWh": 239.2, + "refrig_emissions_kg_per_hour": 5.761682648401828 + }, + "hours": [ + { + "timestamp": "2001-03-31T07:00:00", + "t_out_C": 2.64, + "heating_kW": 663.25, + "cooling_kW": 0, + "checks": [ + { + "label": "HR HHW Output [kW]", + "column": "hr_hhw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "HR CHW Output [kW]", + "column": "hr_chw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "HR-WWHP Electricity [kWh]", + "column": "elec_hr_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Count (Heating)", + "column": "awhp_num", + "divisor": 1, + "value": 16 + }, + { + "label": "AWHP Heating COP", + "column": "awhp_cop_h", + "divisor": 1, + "value": 2.9688 + }, + { + "label": "AWHP Heating Cap [kW]", + "column": "awhp_cap_h_W", + "divisor": 1000, + "value": 3054.208 + }, + { + "label": "AWHP HHW Output [kW]", + "column": "awhp_hhw_W", + "divisor": 1000, + "value": 663.25 + }, + { + "label": "AWHP Heating Electricity [kWh]", + "column": "elec_awhp_h_Wh", + "divisor": 1000, + "value": 223.40676367555915 + }, + { + "label": "Resistance Heater Electricity [kWh]", + "column": "elec_res_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Cooling COP", + "column": "awhp_cop_c", + "divisor": 1, + "value": 4.4 + }, + { + "label": "AWHP CHW Output [kW]", + "column": "awhp_chw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Cooling Electricity [kWh]", + "column": "elec_awhp_c_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Chiller CHW Output [kW]", + "column": "chiller_chw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "Chiller Electricity [kWh]", + "column": "elec_chiller_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Total Electricity [kWh]", + "column": "elec_Wh", + "divisor": 1000, + "value": 223.40676367555915 + } + ], + "superseded": [ + { + "label": "AWHP Cooling Cap [kW]", + "column": "awhp_cap_c_W", + "divisor": 1000, + "value": 0, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + } + ], + "known_differences": [], + "elec_rate_g_per_kWh": 402.6, + "elec_emissions_kg": 89.94356305578012, + "gas_emissions_kg": 0 + }, + { + "timestamp": "2001-09-18T12:00:00", + "t_out_C": 27, + "heating_kW": 0, + "cooling_kW": 1602.33, + "checks": [ + { + "label": "HR HHW Output [kW]", + "column": "hr_hhw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "HR CHW Output [kW]", + "column": "hr_chw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "HR-WWHP Electricity [kWh]", + "column": "elec_hr_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Count (Heating)", + "column": "awhp_num", + "divisor": 1, + "value": 16 + }, + { + "label": "AWHP Heating COP", + "column": "awhp_cop_h", + "divisor": 1, + "value": 4.1629 + }, + { + "label": "AWHP Heating Cap [kW]", + "column": "awhp_cap_h_W", + "divisor": 1000, + "value": 4313.76 + }, + { + "label": "AWHP HHW Output [kW]", + "column": "awhp_hhw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Heating Electricity [kWh]", + "column": "elec_awhp_h_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Resistance Heater Electricity [kWh]", + "column": "elec_res_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Cooling COP", + "column": "awhp_cop_c", + "divisor": 1, + "value": 3.79 + }, + { + "label": "AWHP Cooling Cap [kW]", + "column": "awhp_cap_c_W", + "divisor": 1000, + "value": 3459.84 + }, + { + "label": "AWHP CHW Output [kW]", + "column": "awhp_chw_W", + "divisor": 1000, + "value": 1602.33 + }, + { + "label": "AWHP Cooling Electricity [kWh]", + "column": "elec_awhp_c_Wh", + "divisor": 1000, + "value": 422.77836411609496 + }, + { + "label": "Chiller CHW Output [kW]", + "column": "chiller_chw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "Chiller Electricity [kWh]", + "column": "elec_chiller_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Total Electricity [kWh]", + "column": "elec_Wh", + "divisor": 1000, + "value": 422.77836411609496 + } + ], + "superseded": [], + "known_differences": [], + "elec_rate_g_per_kWh": 408.29999999999995, + "elec_emissions_kg": 172.62040606860157, + "gas_emissions_kg": 0 + }, + { + "timestamp": "2001-12-31T07:00:00", + "t_out_C": 12.58, + "heating_kW": 749.56, + "cooling_kW": 212.33, + "checks": [ + { + "label": "HR HHW Output [kW]", + "column": "hr_hhw_W", + "divisor": 1000, + "value": 211 + }, + { + "label": "HR CHW Output [kW]", + "column": "hr_chw_W", + "divisor": 1000, + "value": 153.66304347826087 + }, + { + "label": "HR-WWHP Electricity [kWh]", + "column": "elec_hr_Wh", + "divisor": 1000, + "value": 57.336956521739125 + }, + { + "label": "AWHP Count (Heating)", + "column": "awhp_num", + "divisor": 1, + "value": 16 + }, + { + "label": "AWHP Heating COP", + "column": "awhp_cop_h", + "divisor": 1, + "value": 3.74868 + }, + { + "label": "AWHP Heating Cap [kW]", + "column": "awhp_cap_h_W", + "divisor": 1000, + "value": 3832.2943999999998 + }, + { + "label": "AWHP HHW Output [kW]", + "column": "awhp_hhw_W", + "divisor": 1000, + "value": 538.56 + }, + { + "label": "AWHP Heating Electricity [kWh]", + "column": "elec_awhp_h_Wh", + "divisor": 1000, + "value": 143.66657063286277 + }, + { + "label": "Resistance Heater Electricity [kWh]", + "column": "elec_res_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Cooling COP", + "column": "awhp_cop_c", + "divisor": 1, + "value": 4.4 + } + ], + "superseded": [ + { + "label": "AWHP Cooling Cap [kW]", + "column": "awhp_cap_c_W", + "divisor": 1000, + "value": 4000, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "AWHP CHW Output [kW]", + "column": "awhp_chw_W", + "divisor": 1000, + "value": 0, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "AWHP Cooling Electricity [kWh]", + "column": "elec_awhp_c_Wh", + "divisor": 1000, + "value": 0, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "Chiller CHW Output [kW]", + "column": "chiller_chw_W", + "divisor": 1000, + "value": 58.66695652173914, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "Chiller Electricity [kWh]", + "column": "elec_chiller_Wh", + "divisor": 1000, + "value": 20.6573790569504, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "Total Electricity [kWh]", + "column": "elec_Wh", + "divisor": 1000, + "value": 221.6609062115523, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + } + ], + "known_differences": [], + "elec_rate_g_per_kWh": 461.8, + "elec_emissions_kg": 102.36300648849485, + "gas_emissions_kg": 0 + } + ] + }, + { + "id": "B1S2", + "scenario": { + "eq_scen_id": "B1S2", + "eq_scen_name": "B1S2", + "hr_wwhp": null, + "hr_wwhp_performance_model": "interpolate_HHWST", + "hr_wwhp_h_supply_t": null, + "awhp": "hp01", + "awhp_performance_model": "interpolate_HHWST_reset", + "awhp_h_supply_t": 38, + "awhp_sizing_mode": "integer_sizing_peak_load", + "awhp_sizing_value": 1, + "awhp_redundancy": 2, + "awhp_use_cooling": true, + "awhp_sizing_priority": "heating", + "fuel_switching": false, + "backup_heating": "res01", + "chiller": "xs_chiller_703kW" + }, + "peak_heating_kW": 2805.01, + "peak_chiller_kW": 2108.01, + "emissions": { + "grid_scenario": "MidCase", + "gea_grid_region": "PJM_West", + "emission_type": "Includes pre-combustion", + "shortrun_weighting": 0.3, + "year": 2035, + "annual_refrig_leakage": 0.05, + "ng_rate_g_per_kWh": 200, + "refrig_emissions_kg_per_hour": 5.828178082191782 + }, + "hours": [ + { + "timestamp": "2001-03-31T07:00:00", + "t_out_C": 2.64, + "heating_kW": 663.25, + "cooling_kW": 0, + "checks": [ + { + "label": "AWHP Count (Heating)", + "column": "awhp_num", + "divisor": 1, + "value": 16 + }, + { + "label": "AWHP Heating COP", + "column": "awhp_cop_h", + "divisor": 1, + "value": 2.6497 + }, + { + "label": "AWHP Heating Cap [kW]", + "column": "awhp_cap_h_W", + "divisor": 1000, + "value": 3029.36 + }, + { + "label": "AWHP HHW Output [kW]", + "column": "awhp_hhw_W", + "divisor": 1000, + "value": 663.25 + }, + { + "label": "AWHP Heating Electricity [kWh]", + "column": "elec_awhp_h_Wh", + "divisor": 1000, + "value": 250.3113560025663 + }, + { + "label": "Resistance Heater Electricity [kWh]", + "column": "elec_res_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Cooling COP", + "column": "awhp_cop_c", + "divisor": 1, + "value": 4.4 + }, + { + "label": "AWHP CHW Output [kW]", + "column": "awhp_chw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Cooling Electricity [kWh]", + "column": "elec_awhp_c_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Chiller CHW Output [kW]", + "column": "chiller_chw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "Chiller Electricity [kWh]", + "column": "elec_chiller_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Total Electricity [kWh]", + "column": "elec_Wh", + "divisor": 1000, + "value": 250.3113560025663 + } + ], + "superseded": [ + { + "label": "AWHP Cooling Cap [kW]", + "column": "awhp_cap_c_W", + "divisor": 1000, + "value": 0, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + } + ], + "known_differences": [], + "elec_rate_g_per_kWh": 144.18, + "elec_emissions_kg": 36.08989130845001, + "gas_emissions_kg": 0 + }, + { + "timestamp": "2001-09-18T12:00:00", + "t_out_C": 27, + "heating_kW": 0, + "cooling_kW": 1602.33, + "checks": [ + { + "label": "AWHP Count (Heating)", + "column": "awhp_num", + "divisor": 1, + "value": 16 + }, + { + "label": "AWHP Heating COP", + "column": "awhp_cop_h", + "divisor": 1, + "value": 4.1629 + }, + { + "label": "AWHP Heating Cap [kW]", + "column": "awhp_cap_h_W", + "divisor": 1000, + "value": 4313.76 + }, + { + "label": "AWHP HHW Output [kW]", + "column": "awhp_hhw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Heating Electricity [kWh]", + "column": "elec_awhp_h_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Resistance Heater Electricity [kWh]", + "column": "elec_res_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Cooling COP", + "column": "awhp_cop_c", + "divisor": 1, + "value": 3.79 + }, + { + "label": "AWHP Cooling Cap [kW]", + "column": "awhp_cap_c_W", + "divisor": 1000, + "value": 3459.84 + }, + { + "label": "AWHP CHW Output [kW]", + "column": "awhp_chw_W", + "divisor": 1000, + "value": 1602.33 + }, + { + "label": "AWHP Cooling Electricity [kWh]", + "column": "elec_awhp_c_Wh", + "divisor": 1000, + "value": 422.77836411609496 + }, + { + "label": "Chiller CHW Output [kW]", + "column": "chiller_chw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "Chiller Electricity [kWh]", + "column": "elec_chiller_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Total Electricity [kWh]", + "column": "elec_Wh", + "divisor": 1000, + "value": 422.77836411609496 + } + ], + "superseded": [], + "known_differences": [], + "elec_rate_g_per_kWh": 287.98, + "elec_emissions_kg": 121.75171329815304, + "gas_emissions_kg": 0 + }, + { + "timestamp": "2001-12-31T07:00:00", + "t_out_C": 12.58, + "heating_kW": 749.56, + "cooling_kW": 212.33, + "checks": [ + { + "label": "AWHP Count (Heating)", + "column": "awhp_num", + "divisor": 1, + "value": 16 + }, + { + "label": "AWHP Heating COP", + "column": "awhp_cop_h", + "divisor": 1, + "value": 3.561 + }, + { + "label": "AWHP Heating Cap [kW]", + "column": "awhp_cap_h_W", + "divisor": 1000, + "value": 3803.368 + }, + { + "label": "AWHP HHW Output [kW]", + "column": "awhp_hhw_W", + "divisor": 1000, + "value": 749.56 + }, + { + "label": "AWHP Heating Electricity [kWh]", + "column": "elec_awhp_h_Wh", + "divisor": 1000, + "value": 210.49143499017129 + }, + { + "label": "Resistance Heater Electricity [kWh]", + "column": "elec_res_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Cooling COP", + "column": "awhp_cop_c", + "divisor": 1, + "value": 4.4 + } + ], + "superseded": [ + { + "label": "AWHP Cooling Cap [kW]", + "column": "awhp_cap_c_W", + "divisor": 1000, + "value": 4000, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "AWHP CHW Output [kW]", + "column": "awhp_chw_W", + "divisor": 1000, + "value": 0, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "AWHP Cooling Electricity [kWh]", + "column": "elec_awhp_c_Wh", + "divisor": 1000, + "value": 0, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "Chiller CHW Output [kW]", + "column": "chiller_chw_W", + "divisor": 1000, + "value": 212.33, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "Chiller Electricity [kWh]", + "column": "elec_chiller_Wh", + "divisor": 1000, + "value": 74.76408450704226, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "Total Electricity [kWh]", + "column": "elec_Wh", + "divisor": 1000, + "value": 285.2555194972135, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + } + ], + "known_differences": [], + "elec_rate_g_per_kWh": 329.34, + "elec_emissions_kg": 93.9460527912123, + "gas_emissions_kg": 0 + } + ] + }, + { + "id": "B1S3", + "scenario": { + "eq_scen_id": "B1S3", + "eq_scen_name": "B1S3", + "hr_wwhp": "hr03", + "hr_wwhp_performance_model": "interpolate_HHWST", + "hr_wwhp_h_supply_t": 52, + "awhp": "hp01", + "awhp_performance_model": "interpolate_HHWST_fixed", + "awhp_h_supply_t": 42, + "awhp_sizing_mode": "fractional_sizing_peak_load", + "awhp_sizing_value": 0.7, + "awhp_redundancy": 1, + "awhp_use_cooling": true, + "awhp_sizing_priority": "heating", + "fuel_switching": false, + "backup_heating": "bo03", + "chiller": "xs_chiller_352kW" + }, + "peak_heating_kW": 2805.01, + "peak_chiller_kW": 3108.79, + "emissions": { + "grid_scenario": "LowNGPrice", + "gea_grid_region": "PJM_West", + "emission_type": "Combustion only", + "shortrun_weighting": 0.3, + "year": 2045, + "annual_refrig_leakage": 0.05, + "ng_rate_g_per_kWh": 200, + "refrig_emissions_kg_per_hour": 6.684675799086759 + }, + "hours": [ + { + "timestamp": "2001-03-31T07:00:00", + "t_out_C": 2.64, + "heating_kW": 663.25, + "cooling_kW": 0, + "checks": [ + { + "label": "HR HHW Output [kW]", + "column": "hr_hhw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "HR CHW Output [kW]", + "column": "hr_chw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "HR-WWHP Electricity [kWh]", + "column": "elec_hr_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Heating COP", + "column": "awhp_cop_h", + "divisor": 1, + "value": 2.785 + }, + { + "label": "AWHP HHW Output [kW]", + "column": "awhp_hhw_W", + "divisor": 1000, + "value": 663.25 + }, + { + "label": "AWHP Heating Electricity [kWh]", + "column": "elec_awhp_h_Wh", + "divisor": 1000, + "value": 238.1508078994614 + }, + { + "label": "Total Gas [kWh]", + "column": "gas_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Cooling COP", + "column": "awhp_cop_c", + "divisor": 1, + "value": 4.4 + }, + { + "label": "AWHP CHW Output [kW]", + "column": "awhp_chw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Cooling Electricity [kWh]", + "column": "elec_awhp_c_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Chiller CHW Output [kW]", + "column": "chiller_chw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "Chiller Electricity [kWh]", + "column": "elec_chiller_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Total Electricity [kWh]", + "column": "elec_Wh", + "divisor": 1000, + "value": 238.1508078994614 + } + ], + "superseded": [ + { + "label": "AWHP Cooling Cap [kW]", + "column": "awhp_cap_c_W", + "divisor": 1000, + "value": 0, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + } + ], + "known_differences": [ + { + "label": "AWHP Count (Heating)", + "column": "awhp_num", + "divisor": 1, + "value": 11.13387715149997, + "reason": "AWHP sizing reference capacity at HHWST 42 \u00b0C: spreadsheet interpolates the 38 and 52 \u00b0C tables (176.35 kW at 0 \u00b0C); the tool uses the nearest table, 38 \u00b0C (176.92 kW), giving 11.098 instead of 11.134 units (-0.3 %). The spreadsheet's other two hours already use the tool's 11.1." + }, + { + "label": "AWHP Heating Cap [kW]", + "column": "awhp_cap_h_W", + "divisor": 1000, + "value": 2115.3587216449337, + "reason": "AWHP sizing reference capacity at HHWST 42 \u00b0C: spreadsheet interpolates the 38 and 52 \u00b0C tables (176.35 kW at 0 \u00b0C); the tool uses the nearest table, 38 \u00b0C (176.92 kW), giving 11.098 instead of 11.134 units (-0.3 %). The spreadsheet's other two hours already use the tool's 11.1." + } + ], + "elec_rate_g_per_kWh": 46.849999999999994, + "elec_emissions_kg": 11.157365350089766, + "gas_emissions_kg": 0 + }, + { + "timestamp": "2001-09-18T12:00:00", + "t_out_C": 27, + "heating_kW": 0, + "cooling_kW": 1602.33, + "checks": [ + { + "label": "HR HHW Output [kW]", + "column": "hr_hhw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "HR CHW Output [kW]", + "column": "hr_chw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "HR-WWHP Electricity [kWh]", + "column": "elec_hr_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Count (Heating)", + "column": "awhp_num", + "divisor": 1, + "value": 11.1 + }, + { + "label": "AWHP Heating COP", + "column": "awhp_cop_h", + "divisor": 1, + "value": 3.872 + }, + { + "label": "AWHP Heating Cap [kW]", + "column": "awhp_cap_h_W", + "divisor": 1000, + "value": 2971.010127 + }, + { + "label": "AWHP HHW Output [kW]", + "column": "awhp_hhw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Heating Electricity [kWh]", + "column": "elec_awhp_h_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Total Gas [kWh]", + "column": "gas_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Cooling COP", + "column": "awhp_cop_c", + "divisor": 1, + "value": 3.79 + }, + { + "label": "AWHP Cooling Cap [kW]", + "column": "awhp_cap_c_W", + "divisor": 1000, + "value": 2400.264 + }, + { + "label": "AWHP CHW Output [kW]", + "column": "awhp_chw_W", + "divisor": 1000, + "value": 1602.33 + }, + { + "label": "AWHP Cooling Electricity [kWh]", + "column": "elec_awhp_c_Wh", + "divisor": 1000, + "value": 422.77836411609496 + }, + { + "label": "Chiller CHW Output [kW]", + "column": "chiller_chw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "Chiller Electricity [kWh]", + "column": "elec_chiller_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Total Electricity [kWh]", + "column": "elec_Wh", + "divisor": 1000, + "value": 422.77836411609496 + } + ], + "superseded": [], + "known_differences": [], + "elec_rate_g_per_kWh": 98.77, + "elec_emissions_kg": 41.7578190237467, + "gas_emissions_kg": 0 + }, + { + "timestamp": "2001-12-31T07:00:00", + "t_out_C": 12.58, + "heating_kW": 749.56, + "cooling_kW": 212.33, + "checks": [ + { + "label": "HR HHW Output [kW]", + "column": "hr_hhw_W", + "divisor": 1000, + "value": 211 + }, + { + "label": "HR CHW Output [kW]", + "column": "hr_chw_W", + "divisor": 1000, + "value": 148.94117647058823 + }, + { + "label": "HR-WWHP Electricity [kWh]", + "column": "elec_hr_Wh", + "divisor": 1000, + "value": 62.05882352941177 + }, + { + "label": "AWHP Count (Heating)", + "column": "awhp_num", + "divisor": 1, + "value": 11.1 + }, + { + "label": "AWHP Heating COP", + "column": "awhp_cop_h", + "divisor": 1, + "value": 3.4959 + }, + { + "label": "AWHP Heating Cap [kW]", + "column": "awhp_cap_h_W", + "divisor": 1000, + "value": 2631.532167 + }, + { + "label": "AWHP HHW Output [kW]", + "column": "awhp_hhw_W", + "divisor": 1000, + "value": 538.56 + }, + { + "label": "AWHP Heating Electricity [kWh]", + "column": "elec_awhp_h_Wh", + "divisor": 1000, + "value": 154.05474984982408 + }, + { + "label": "Total Gas [kWh]", + "column": "gas_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Cooling COP", + "column": "awhp_cop_c", + "divisor": 1, + "value": 4.4 + } + ], + "superseded": [ + { + "label": "AWHP Cooling Cap [kW]", + "column": "awhp_cap_c_W", + "divisor": 1000, + "value": 2775, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "AWHP CHW Output [kW]", + "column": "awhp_chw_W", + "divisor": 1000, + "value": 0, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "AWHP Cooling Electricity [kWh]", + "column": "elec_awhp_c_Wh", + "divisor": 1000, + "value": 0, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "Chiller CHW Output [kW]", + "column": "chiller_chw_W", + "divisor": 1000, + "value": 63.38882352941178, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "Chiller Electricity [kWh]", + "column": "elec_chiller_Wh", + "divisor": 1000, + "value": 22.32000828500415, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "Total Electricity [kWh]", + "column": "elec_Wh", + "divisor": 1000, + "value": 238.43358166424, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + } + ], + "known_differences": [], + "elec_rate_g_per_kWh": 192.81, + "elec_emissions_kg": 45.97237888068211, + "gas_emissions_kg": 0 + } + ] + }, + { + "id": "B2S1", + "scenario": { + "eq_scen_id": "B2S1", + "eq_scen_name": "B2S1", + "hr_wwhp": null, + "hr_wwhp_performance_model": "interpolate_HHWST", + "hr_wwhp_h_supply_t": null, + "awhp": null, + "awhp_performance_model": "interpolate_HHWST_fixed", + "awhp_h_supply_t": null, + "awhp_sizing_mode": "integer_sizing_peak_load", + "awhp_sizing_value": 1, + "awhp_redundancy": 1, + "awhp_use_cooling": false, + "awhp_sizing_priority": "heating", + "fuel_switching": false, + "backup_heating": "res02", + "chiller": "xs_chiller_703kW" + }, + "peak_heating_kW": null, + "peak_chiller_kW": 482.7, + "emissions": { + "grid_scenario": "MidCase", + "gea_grid_region": "CAISO", + "emission_type": "Includes pre-combustion", + "shortrun_weighting": 0, + "year": 2025, + "annual_refrig_leakage": 0.15, + "ng_rate_g_per_kWh": 200, + "refrig_emissions_kg_per_hour": 3.2428356164383563 + }, + "hours": [ + { + "timestamp": "2022-03-10T11:00:00", + "t_out_C": 15.2, + "heating_kW": 100.99, + "cooling_kW": 98.91, + "checks": [ + { + "label": "Resistance Heater Electricity [kWh]", + "column": "elec_res_Wh", + "divisor": 1000, + "value": 100.99 + }, + { + "label": "Chiller CHW Output [kW]", + "column": "chiller_chw_W", + "divisor": 1000, + "value": 98.91 + }, + { + "label": "Chiller Electricity [kWh]", + "column": "elec_chiller_Wh", + "divisor": 1000, + "value": 34.82746478873239 + }, + { + "label": "Total Electricity [kWh]", + "column": "elec_Wh", + "divisor": 1000, + "value": 135.8174647887324 + } + ], + "superseded": [], + "known_differences": [], + "elec_rate_g_per_kWh": 160.5, + "elec_emissions_kg": 21.79870309859155, + "gas_emissions_kg": 0 + }, + { + "timestamp": "2022-09-06T11:00:00", + "t_out_C": 30.7, + "heating_kW": 0, + "cooling_kW": 439.98, + "checks": [ + { + "label": "Resistance Heater Electricity [kWh]", + "column": "elec_res_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Chiller CHW Output [kW]", + "column": "chiller_chw_W", + "divisor": 1000, + "value": 439.98 + }, + { + "label": "Chiller Electricity [kWh]", + "column": "elec_chiller_Wh", + "divisor": 1000, + "value": 154.92253521126761 + }, + { + "label": "Total Electricity [kWh]", + "column": "elec_Wh", + "divisor": 1000, + "value": 154.92253521126761 + } + ], + "superseded": [], + "known_differences": [], + "elec_rate_g_per_kWh": 52.4, + "elec_emissions_kg": 8.117940845070423, + "gas_emissions_kg": 0 + }, + { + "timestamp": "2021-12-20T23:00:00", + "t_out_C": 8.3, + "heating_kW": 203.75, + "cooling_kW": 17.17, + "checks": [ + { + "label": "Resistance Heater Electricity [kWh]", + "column": "elec_res_Wh", + "divisor": 1000, + "value": 203.75 + }, + { + "label": "Chiller CHW Output [kW]", + "column": "chiller_chw_W", + "divisor": 1000, + "value": 17.17 + }, + { + "label": "Chiller Electricity [kWh]", + "column": "elec_chiller_Wh", + "divisor": 1000, + "value": 6.045774647887325 + }, + { + "label": "Total Electricity [kWh]", + "column": "elec_Wh", + "divisor": 1000, + "value": 209.79577464788733 + } + ], + "superseded": [], + "known_differences": [], + "elec_rate_g_per_kWh": 673, + "elec_emissions_kg": 141.19255633802817, + "gas_emissions_kg": 0 + } + ] + }, + { + "id": "B2S2", + "scenario": { + "eq_scen_id": "B2S2", + "eq_scen_name": "B2S2", + "hr_wwhp": "hr03", + "hr_wwhp_performance_model": "interpolate_HHWST", + "hr_wwhp_h_supply_t": 60, + "awhp": "hp06", + "awhp_performance_model": "interpolate_HHWST_fixed", + "awhp_h_supply_t": 60, + "awhp_sizing_mode": "fixed_num_units", + "awhp_sizing_value": 3, + "awhp_redundancy": 1, + "awhp_use_cooling": false, + "awhp_sizing_priority": "heating", + "fuel_switching": false, + "backup_heating": "res02", + "chiller": "xs_chiller_703kW" + }, + "peak_heating_kW": null, + "peak_chiller_kW": 482.7, + "emissions": { + "grid_scenario": "MidCase", + "gea_grid_region": "SPP_South", + "emission_type": "Includes pre-combustion", + "shortrun_weighting": 0.5, + "year": 2025, + "annual_refrig_leakage": 0.05, + "ng_rate_g_per_kWh": 239.2, + "refrig_emissions_kg_per_hour": 1.9881344292237442 + }, + "hours": [ + { + "timestamp": "2022-03-10T11:00:00", + "t_out_C": 15.2, + "heating_kW": 100.99, + "cooling_kW": 98.91, + "checks": [ + { + "label": "HR HHW Output [kW]", + "column": "hr_hhw_W", + "divisor": 1000, + "value": 100.99 + }, + { + "label": "HR CHW Output [kW]", + "column": "hr_chw_W", + "divisor": 1000, + "value": 69.53873871068203 + }, + { + "label": "HR-WWHP Electricity [kWh]", + "column": "elec_hr_Wh", + "divisor": 1000, + "value": 31.45126128931797 + }, + { + "label": "AWHP Count (Heating)", + "column": "awhp_num", + "divisor": 1, + "value": 3 + }, + { + "label": "AWHP Heating COP", + "column": "awhp_cop_h", + "divisor": 1, + "value": 2.77 + }, + { + "label": "AWHP Heating Cap [kW]", + "column": "awhp_cap_h_W", + "divisor": 1000, + "value": 750.873 + }, + { + "label": "AWHP HHW Output [kW]", + "column": "awhp_hhw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Heating Electricity [kWh]", + "column": "elec_awhp_h_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Resistance Heater Electricity [kWh]", + "column": "elec_res_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Chiller CHW Output [kW]", + "column": "chiller_chw_W", + "divisor": 1000, + "value": 29.37126128931797 + }, + { + "label": "Chiller Electricity [kWh]", + "column": "elec_chiller_Wh", + "divisor": 1000, + "value": 10.34199341173168 + }, + { + "label": "Total Electricity [kWh]", + "column": "elec_Wh", + "divisor": 1000, + "value": 41.79325470104965 + } + ], + "superseded": [], + "known_differences": [], + "elec_rate_g_per_kWh": 614.55, + "elec_emissions_kg": 25.684044676530057, + "gas_emissions_kg": 0 + }, + { + "timestamp": "2022-09-06T11:00:00", + "t_out_C": 30.7, + "heating_kW": 0, + "cooling_kW": 439.98, + "checks": [ + { + "label": "HR HHW Output [kW]", + "column": "hr_hhw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "HR CHW Output [kW]", + "column": "hr_chw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "HR-WWHP Electricity [kWh]", + "column": "elec_hr_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Count (Heating)", + "column": "awhp_num", + "divisor": 1, + "value": 3 + }, + { + "label": "AWHP Heating COP", + "column": "awhp_cop_h", + "divisor": 1, + "value": 2.77 + }, + { + "label": "AWHP Heating Cap [kW]", + "column": "awhp_cap_h_W", + "divisor": 1000, + "value": 750.873 + }, + { + "label": "AWHP HHW Output [kW]", + "column": "awhp_hhw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Heating Electricity [kWh]", + "column": "elec_awhp_h_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Resistance Heater Electricity [kWh]", + "column": "elec_res_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Chiller CHW Output [kW]", + "column": "chiller_chw_W", + "divisor": 1000, + "value": 439.98 + }, + { + "label": "Chiller Electricity [kWh]", + "column": "elec_chiller_Wh", + "divisor": 1000, + "value": 154.92253521126761 + }, + { + "label": "Total Electricity [kWh]", + "column": "elec_Wh", + "divisor": 1000, + "value": 154.92253521126761 + } + ], + "superseded": [], + "known_differences": [], + "elec_rate_g_per_kWh": 745.2, + "elec_emissions_kg": 115.44827323943663, + "gas_emissions_kg": 0 + }, + { + "timestamp": "2021-12-20T23:00:00", + "t_out_C": 8.3, + "heating_kW": 203.75, + "cooling_kW": 17.17, + "checks": [ + { + "label": "HR HHW Output [kW]", + "column": "hr_hhw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "HR CHW Output [kW]", + "column": "hr_chw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "HR-WWHP Electricity [kWh]", + "column": "elec_hr_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Count (Heating)", + "column": "awhp_num", + "divisor": 1, + "value": 3 + }, + { + "label": "AWHP Heating COP", + "column": "awhp_cop_h", + "divisor": 1, + "value": 2.673 + }, + { + "label": "AWHP Heating Cap [kW]", + "column": "awhp_cap_h_W", + "divisor": 1000, + "value": 727.43121 + }, + { + "label": "AWHP HHW Output [kW]", + "column": "awhp_hhw_W", + "divisor": 1000, + "value": 203.75 + }, + { + "label": "AWHP Heating Electricity [kWh]", + "column": "elec_awhp_h_Wh", + "divisor": 1000, + "value": 76.225215114104 + }, + { + "label": "Resistance Heater Electricity [kWh]", + "column": "elec_res_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Chiller CHW Output [kW]", + "column": "chiller_chw_W", + "divisor": 1000, + "value": 17.17 + }, + { + "label": "Chiller Electricity [kWh]", + "column": "elec_chiller_Wh", + "divisor": 1000, + "value": 6.045774647887325 + }, + { + "label": "Total Electricity [kWh]", + "column": "elec_Wh", + "divisor": 1000, + "value": 82.27098976199133 + } + ], + "superseded": [], + "known_differences": [], + "elec_rate_g_per_kWh": 800.05, + "elec_emissions_kg": 65.82090535908117, + "gas_emissions_kg": 0 + } + ] + }, + { + "id": "B3S1", + "scenario": { + "eq_scen_id": "B3S1", + "eq_scen_name": "B3S1", + "hr_wwhp": "hr01", + "hr_wwhp_performance_model": "interpolate_HHWST", + "hr_wwhp_h_supply_t": 40, + "awhp": "hp01", + "awhp_performance_model": "interpolate_HHWST_fixed", + "awhp_h_supply_t": 38, + "awhp_sizing_mode": "fractional_sizing_peak_load", + "awhp_sizing_value": 0.3, + "awhp_redundancy": 1, + "awhp_use_cooling": true, + "awhp_sizing_priority": "heating", + "fuel_switching": false, + "backup_heating": "bo02", + "chiller": "xs_chiller_703kW" + }, + "peak_heating_kW": 325.09, + "peak_chiller_kW": 278.4, + "emissions": { + "grid_scenario": "MidCase", + "gea_grid_region": "CAISO", + "emission_type": "Includes pre-combustion", + "shortrun_weighting": 0, + "year": 2025, + "annual_refrig_leakage": 0.05, + "ng_rate_g_per_kWh": 200, + "refrig_emissions_kg_per_hour": 1.835714804023592 + }, + "hours": [ + { + "timestamp": "2023-04-19T04:00:00", + "t_out_C": 9, + "heating_kW": 302.95, + "cooling_kW": 0, + "checks": [ + { + "label": "HR HHW Output [kW]", + "column": "hr_hhw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "HR CHW Output [kW]", + "column": "hr_chw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "HR-WWHP Electricity [kWh]", + "column": "elec_hr_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Count (Heating)", + "column": "awhp_num", + "divisor": 1, + "value": 0.551249152159168 + }, + { + "label": "AWHP Heating COP", + "column": "awhp_cop_h", + "divisor": 1, + "value": 3.535 + }, + { + "label": "AWHP Heating Cap [kW]", + "column": "awhp_cap_h_W", + "divisor": 1000, + "value": 126.43489692612481 + }, + { + "label": "AWHP HHW Output [kW]", + "column": "awhp_hhw_W", + "divisor": 1000, + "value": 126.43489692612481 + }, + { + "label": "AWHP Heating Electricity [kWh]", + "column": "elec_awhp_h_Wh", + "divisor": 1000, + "value": 35.766590360997114 + }, + { + "label": "Total Gas [kWh]", + "column": "gas_Wh", + "divisor": 1000, + "value": 196.12789230430576 + }, + { + "label": "AWHP Cooling COP", + "column": "awhp_cop_c", + "divisor": 1, + "value": 4.4 + }, + { + "label": "AWHP CHW Output [kW]", + "column": "awhp_chw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Cooling Electricity [kWh]", + "column": "elec_awhp_c_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Chiller CHW Output [kW]", + "column": "chiller_chw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "Chiller Electricity [kWh]", + "column": "elec_chiller_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Total Electricity [kWh]", + "column": "elec_Wh", + "divisor": 1000, + "value": 35.766590360997114 + } + ], + "superseded": [ + { + "label": "AWHP Cooling Cap [kW]", + "column": "awhp_cap_c_W", + "divisor": 1000, + "value": 137.812288039792, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + } + ], + "known_differences": [], + "elec_rate_g_per_kWh": 353.3, + "elec_emissions_kg": 12.63633637454028, + "gas_emissions_kg": 39.22557846086115 + }, + { + "timestamp": "2022-07-14T11:00:00", + "t_out_C": 23.7, + "heating_kW": 10.52, + "cooling_kW": 13.67, + "checks": [ + { + "label": "HR HHW Output [kW]", + "column": "hr_hhw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "HR CHW Output [kW]", + "column": "hr_chw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "HR-WWHP Electricity [kWh]", + "column": "elec_hr_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Count (Heating)", + "column": "awhp_num", + "divisor": 1, + "value": 0.551249152159168 + }, + { + "label": "AWHP Heating COP", + "column": "awhp_cop_h", + "divisor": 1, + "value": 4.1003 + }, + { + "label": "AWHP Heating Cap [kW]", + "column": "awhp_cap_h_W", + "divisor": 1000, + "value": 145.47520250395658 + }, + { + "label": "AWHP HHW Output [kW]", + "column": "awhp_hhw_W", + "divisor": 1000, + "value": 10.52 + }, + { + "label": "AWHP Heating Electricity [kWh]", + "column": "elec_awhp_h_Wh", + "divisor": 1000, + "value": 2.5656659268833986 + }, + { + "label": "Total Gas [kWh]", + "column": "gas_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Cooling COP", + "column": "awhp_cop_c", + "divisor": 1, + "value": 4.05 + } + ], + "superseded": [ + { + "label": "AWHP Cooling Cap [kW]", + "column": "awhp_cap_c_W", + "divisor": 1000, + "value": 123.42468516843772, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "AWHP CHW Output [kW]", + "column": "awhp_chw_W", + "divisor": 1000, + "value": 0, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "AWHP Cooling Electricity [kWh]", + "column": "elec_awhp_c_Wh", + "divisor": 1000, + "value": 0, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "Chiller CHW Output [kW]", + "column": "chiller_chw_W", + "divisor": 1000, + "value": 13.67, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "Chiller Electricity [kWh]", + "column": "elec_chiller_Wh", + "divisor": 1000, + "value": 4.813380281690141, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "Total Electricity [kWh]", + "column": "elec_Wh", + "divisor": 1000, + "value": 7.3790462085735395, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + } + ], + "known_differences": [], + "elec_rate_g_per_kWh": 126, + "elec_emissions_kg": 0.929759822280266, + "gas_emissions_kg": 0 + }, + { + "timestamp": "2022-09-08T14:00:00", + "t_out_C": 36.6, + "heating_kW": 0, + "cooling_kW": 303.02, + "checks": [ + { + "label": "HR HHW Output [kW]", + "column": "hr_hhw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "HR CHW Output [kW]", + "column": "hr_chw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "HR-WWHP Electricity [kWh]", + "column": "elec_hr_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Count (Heating)", + "column": "awhp_num", + "divisor": 1, + "value": 0.551249152159168 + }, + { + "label": "AWHP Heating COP", + "column": "awhp_cop_h", + "divisor": 1, + "value": 4.22 + }, + { + "label": "AWHP Heating Cap [kW]", + "column": "awhp_cap_h_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP HHW Output [kW]", + "column": "awhp_hhw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Heating Electricity [kWh]", + "column": "elec_awhp_h_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Total Gas [kWh]", + "column": "gas_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Cooling COP", + "column": "awhp_cop_c", + "divisor": 1, + "value": 2.9188 + }, + { + "label": "AWHP Cooling Cap [kW]", + "column": "awhp_cap_c_W", + "divisor": 1000, + "value": 109.30609188333712 + }, + { + "label": "AWHP CHW Output [kW]", + "column": "awhp_chw_W", + "divisor": 1000, + "value": 109.30609188333712 + }, + { + "label": "AWHP Cooling Electricity [kWh]", + "column": "elec_awhp_c_Wh", + "divisor": 1000, + "value": 37.44898310378824 + }, + { + "label": "Chiller CHW Output [kW]", + "column": "chiller_chw_W", + "divisor": 1000, + "value": 193.71390811666288 + }, + { + "label": "Chiller Electricity [kWh]", + "column": "elec_chiller_Wh", + "divisor": 1000, + "value": 68.20912257628974 + }, + { + "label": "Total Electricity [kWh]", + "column": "elec_Wh", + "divisor": 1000, + "value": 105.65810568007798 + } + ], + "superseded": [], + "known_differences": [], + "elec_rate_g_per_kWh": 100.60000000000001, + "elec_emissions_kg": 10.629205431415844, + "gas_emissions_kg": 0 + } + ] + }, + { + "id": "B3S2", + "scenario": { + "eq_scen_id": "B3S2", + "eq_scen_name": "B3S2", + "hr_wwhp": null, + "hr_wwhp_performance_model": "interpolate_HHWST", + "hr_wwhp_h_supply_t": null, + "awhp": "hp04", + "awhp_performance_model": "interpolate_HHWST_fixed", + "awhp_h_supply_t": 45, + "awhp_sizing_mode": "integer_sizing_peak_load", + "awhp_sizing_value": 0.5, + "awhp_redundancy": 1, + "awhp_use_cooling": true, + "awhp_sizing_priority": "heating", + "fuel_switching": false, + "backup_heating": "res02", + "chiller": "xs_chiller_703kW" + }, + "peak_heating_kW": 325.09, + "peak_chiller_kW": 203.82, + "emissions": { + "grid_scenario": "HighRECost", + "gea_grid_region": "CAISO", + "emission_type": "Combustion only", + "shortrun_weighting": 0.2, + "year": 2045, + "annual_refrig_leakage": 0.05, + "ng_rate_g_per_kWh": 181, + "refrig_emissions_kg_per_hour": 2.1188671232876715 + }, + "hours": [ + { + "timestamp": "2023-04-19T04:00:00", + "t_out_C": 9, + "heating_kW": 302.95, + "cooling_kW": 0, + "checks": [ + { + "label": "AWHP Count (Heating)", + "column": "awhp_num", + "divisor": 1, + "value": 2 + }, + { + "label": "AWHP Heating COP", + "column": "awhp_cop_h", + "divisor": 1, + "value": 2.863 + }, + { + "label": "AWHP Heating Cap [kW]", + "column": "awhp_cap_h_W", + "divisor": 1000, + "value": 415.01732 + }, + { + "label": "AWHP HHW Output [kW]", + "column": "awhp_hhw_W", + "divisor": 1000, + "value": 302.95 + }, + { + "label": "AWHP Heating Electricity [kWh]", + "column": "elec_awhp_h_Wh", + "divisor": 1000, + "value": 105.81557806496681 + }, + { + "label": "Resistance Heater Electricity [kWh]", + "column": "elec_res_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Cooling COP", + "column": "awhp_cop_c", + "divisor": 1, + "value": 5.53 + }, + { + "label": "AWHP CHW Output [kW]", + "column": "awhp_chw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Cooling Electricity [kWh]", + "column": "elec_awhp_c_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Chiller CHW Output [kW]", + "column": "chiller_chw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "Chiller Electricity [kWh]", + "column": "elec_chiller_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Total Electricity [kWh]", + "column": "elec_Wh", + "divisor": 1000, + "value": 105.81557806496681 + } + ], + "superseded": [ + { + "label": "AWHP Cooling Cap [kW]", + "column": "awhp_cap_c_W", + "divisor": 1000, + "value": 507.136, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + } + ], + "known_differences": [], + "elec_rate_g_per_kWh": 86.82, + "elec_emissions_kg": 9.186908487600418, + "gas_emissions_kg": 0 + }, + { + "timestamp": "2022-07-14T11:00:00", + "t_out_C": 23.7, + "heating_kW": 10.52, + "cooling_kW": 13.67, + "checks": [ + { + "label": "AWHP Count (Heating)", + "column": "awhp_num", + "divisor": 1, + "value": 2 + }, + { + "label": "AWHP Heating COP", + "column": "awhp_cop_h", + "divisor": 1, + "value": 3.424 + }, + { + "label": "AWHP Heating Cap [kW]", + "column": "awhp_cap_h_W", + "divisor": 1000, + "value": 519.63484 + }, + { + "label": "AWHP HHW Output [kW]", + "column": "awhp_hhw_W", + "divisor": 1000, + "value": 10.52 + }, + { + "label": "AWHP Heating Electricity [kWh]", + "column": "elec_awhp_h_Wh", + "divisor": 1000, + "value": 3.072429906542056 + }, + { + "label": "Resistance Heater Electricity [kWh]", + "column": "elec_res_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Cooling COP", + "column": "awhp_cop_c", + "divisor": 1, + "value": 4.0822 + } + ], + "superseded": [ + { + "label": "AWHP Cooling Cap [kW]", + "column": "awhp_cap_c_W", + "divisor": 1000, + "value": 448.27808, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "AWHP CHW Output [kW]", + "column": "awhp_chw_W", + "divisor": 1000, + "value": 0, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "AWHP Cooling Electricity [kWh]", + "column": "elec_awhp_c_Wh", + "divisor": 1000, + "value": 0, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "Chiller CHW Output [kW]", + "column": "chiller_chw_W", + "divisor": 1000, + "value": 13.67, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "Chiller Electricity [kWh]", + "column": "elec_chiller_Wh", + "divisor": 1000, + "value": 4.813380281690141, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + }, + { + "label": "Total Electricity [kWh]", + "column": "elec_Wh", + "divisor": 1000, + "value": 7.885810188232197, + "reason": "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours where the AWHPs also heat" + } + ], + "known_differences": [], + "elec_rate_g_per_kWh": 22.66, + "elec_emissions_kg": 0.1786924588653416, + "gas_emissions_kg": 0 + }, + { + "timestamp": "2022-09-08T14:00:00", + "t_out_C": 36.6, + "heating_kW": 0, + "cooling_kW": 303.02, + "checks": [ + { + "label": "AWHP Count (Heating)", + "column": "awhp_num", + "divisor": 1, + "value": 2 + }, + { + "label": "AWHP Heating COP", + "column": "awhp_cop_h", + "divisor": 1, + "value": 3.51 + }, + { + "label": "AWHP Heating Cap [kW]", + "column": "awhp_cap_h_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP HHW Output [kW]", + "column": "awhp_hhw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Heating Electricity [kWh]", + "column": "elec_awhp_h_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Resistance Heater Electricity [kWh]", + "column": "elec_res_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "AWHP Cooling COP", + "column": "awhp_cop_c", + "divisor": 1, + "value": 2.9252 + }, + { + "label": "AWHP Cooling Cap [kW]", + "column": "awhp_cap_c_W", + "divisor": 1000, + "value": 389.50344 + }, + { + "label": "AWHP CHW Output [kW]", + "column": "awhp_chw_W", + "divisor": 1000, + "value": 303.02 + }, + { + "label": "AWHP Cooling Electricity [kWh]", + "column": "elec_awhp_c_Wh", + "divisor": 1000, + "value": 103.58949815397239 + }, + { + "label": "Chiller CHW Output [kW]", + "column": "chiller_chw_W", + "divisor": 1000, + "value": 0 + }, + { + "label": "Chiller Electricity [kWh]", + "column": "elec_chiller_Wh", + "divisor": 1000, + "value": 0 + }, + { + "label": "Total Electricity [kWh]", + "column": "elec_Wh", + "divisor": 1000, + "value": 103.58949815397239 + } + ], + "superseded": [], + "known_differences": [], + "elec_rate_g_per_kWh": 47.260000000000005, + "elec_emissions_kg": 4.895639682756735, + "gas_emissions_kg": 0 + } + ] + } + ] +} diff --git a/tests/snapshots/integration_annual_totals.json b/tests/snapshots/integration_annual_totals.json new file mode 100644 index 0000000..e9a082d --- /dev/null +++ b/tests/snapshots/integration_annual_totals.json @@ -0,0 +1,288 @@ +{ + "building_83_eq_scenario_1_simulated": { + "elec_kWh": 1103899.87, + "gas_kWh": 1924526.15, + "em_scenario_a": { + "elec_kWh": 1103899.87, + "gas_kWh": 1924526.15, + "total_kgCO2e": 898479.32, + "refrig_kgCO2e": 61346.98 + }, + "em_scenario_b": { + "elec_kWh": 1103899.87, + "gas_kWh": 1924526.15, + "total_kgCO2e": 753107.21, + "refrig_kgCO2e": 61346.98 + }, + "em_scenario_c": { + "elec_kWh": 1103899.87, + "gas_kWh": 1924526.15, + "total_kgCO2e": 521693.64, + "refrig_kgCO2e": 61346.98 + } + }, + "building_83_eq_scenario_3_simulated": { + "elec_kWh": 1487636.09, + "gas_kWh": 219400.32, + "em_scenario_a": { + "elec_kWh": 1487636.09, + "gas_kWh": 219400.32, + "total_kgCO2e": 598870.04, + "refrig_kgCO2e": 63863.38 + }, + "em_scenario_b": { + "elec_kWh": 1487636.09, + "gas_kWh": 219400.32, + "total_kgCO2e": 412836.4, + "refrig_kgCO2e": 63863.38 + }, + "em_scenario_c": { + "elec_kWh": 1487636.09, + "gas_kWh": 219400.32, + "total_kgCO2e": 116343.94, + "refrig_kgCO2e": 63863.38 + } + }, + "building_83_eq_scenario_4_simulated": { + "elec_kWh": 1409891.09, + "gas_kWh": 0.0, + "em_scenario_a": { + "elec_kWh": 1409891.09, + "gas_kWh": 0.0, + "total_kgCO2e": 500813.6, + "refrig_kgCO2e": 45930.78 + }, + "em_scenario_b": { + "elec_kWh": 1409891.09, + "gas_kWh": 0.0, + "total_kgCO2e": 326691.2, + "refrig_kgCO2e": 45930.78 + }, + "em_scenario_c": { + "elec_kWh": 1409891.09, + "gas_kWh": 0.0, + "total_kgCO2e": 45930.78, + "refrig_kgCO2e": 45930.78 + } + }, + "building_83_eq_scenario_5_simulated": { + "elec_kWh": 1171493.23, + "gas_kWh": 0.0, + "em_scenario_a": { + "elec_kWh": 1171493.23, + "gas_kWh": 0.0, + "total_kgCO2e": 430278.65, + "refrig_kgCO2e": 46979.27 + }, + "em_scenario_b": { + "elec_kWh": 1171493.23, + "gas_kWh": 0.0, + "total_kgCO2e": 284195.03, + "refrig_kgCO2e": 46979.27 + }, + "em_scenario_c": { + "elec_kWh": 1171493.23, + "gas_kWh": 0.0, + "total_kgCO2e": 46979.27, + "refrig_kgCO2e": 46979.27 + } + }, + "building_83_eq_scenario_10_simulated": { + "elec_kWh": 1121325.77, + "gas_kWh": 689329.84, + "em_scenario_a": { + "elec_kWh": 1121325.77, + "gas_kWh": 689329.84, + "total_kgCO2e": 609110.82, + "refrig_kgCO2e": 62395.47 + }, + "em_scenario_b": { + "elec_kWh": 1121325.77, + "gas_kWh": 689329.84, + "total_kgCO2e": 461672.64, + "refrig_kgCO2e": 62395.47 + }, + "em_scenario_c": { + "elec_kWh": 1121325.77, + "gas_kWh": 689329.84, + "total_kgCO2e": 227283.16, + "refrig_kgCO2e": 62395.47 + } + }, + "building_83_eq_scenario_18_simulated": { + "elec_kWh": 1409560.7, + "gas_kWh": 367.1, + "em_scenario_a": { + "elec_kWh": 1409560.7, + "gas_kWh": 367.1, + "total_kgCO2e": 500799.14, + "refrig_kgCO2e": 45930.78 + }, + "em_scenario_b": { + "elec_kWh": 1409560.7, + "gas_kWh": 367.1, + "total_kgCO2e": 326719.05, + "refrig_kgCO2e": 45930.78 + }, + "em_scenario_c": { + "elec_kWh": 1409560.7, + "gas_kWh": 367.1, + "total_kgCO2e": 46018.59, + "refrig_kgCO2e": 45930.78 + } + }, + "building_35_eq_scenario_1_simulated": { + "elec_kWh": 444279.95, + "gas_kWh": 552530.67, + "em_scenario_a": { + "elec_kWh": 444279.95, + "gas_kWh": 552530.67, + "total_kgCO2e": 275291.04, + "refrig_kgCO2e": 61346.98 + }, + "em_scenario_b": { + "elec_kWh": 444279.95, + "gas_kWh": 552530.67, + "total_kgCO2e": 214798.49, + "refrig_kgCO2e": 61346.98 + }, + "em_scenario_c": { + "elec_kWh": 444279.95, + "gas_kWh": 552530.67, + "total_kgCO2e": 200267.1, + "refrig_kgCO2e": 61346.98 + } + }, + "building_35_eq_scenario_3_simulated": { + "elec_kWh": 481538.42, + "gas_kWh": 11501.7, + "em_scenario_a": { + "elec_kWh": 481538.42, + "gas_kWh": 11501.7, + "total_kgCO2e": 148203.71, + "refrig_kgCO2e": 44672.58 + }, + "em_scenario_b": { + "elec_kWh": 481538.42, + "gas_kWh": 11501.7, + "total_kgCO2e": 73712.78, + "refrig_kgCO2e": 44672.58 + }, + "em_scenario_c": { + "elec_kWh": 481538.42, + "gas_kWh": 11501.7, + "total_kgCO2e": 55364.89, + "refrig_kgCO2e": 44672.58 + } + }, + "building_35_eq_scenario_4_simulated": { + "elec_kWh": 459525.19, + "gas_kWh": 0.0, + "em_scenario_a": { + "elec_kWh": 459525.19, + "gas_kWh": 0.0, + "total_kgCO2e": 128630.04, + "refrig_kgCO2e": 30514.58 + }, + "em_scenario_b": { + "elec_kWh": 459525.19, + "gas_kWh": 0.0, + "total_kgCO2e": 55951.02, + "refrig_kgCO2e": 30514.58 + }, + "em_scenario_c": { + "elec_kWh": 459525.19, + "gas_kWh": 0.0, + "total_kgCO2e": 38199.86, + "refrig_kgCO2e": 30514.58 + } + }, + "building_35_eq_scenario_5_simulated": { + "elec_kWh": 448994.53, + "gas_kWh": 0.0, + "em_scenario_a": { + "elec_kWh": 448994.53, + "gas_kWh": 0.0, + "total_kgCO2e": 126855.59, + "refrig_kgCO2e": 31563.07 + }, + "em_scenario_b": { + "elec_kWh": 448994.53, + "gas_kWh": 0.0, + "total_kgCO2e": 56259.02, + "refrig_kgCO2e": 31563.07 + }, + "em_scenario_c": { + "elec_kWh": 448994.53, + "gas_kWh": 0.0, + "total_kgCO2e": 39032.51, + "refrig_kgCO2e": 31563.07 + } + }, + "building_35_eq_scenario_10_simulated": { + "elec_kWh": 445085.27, + "gas_kWh": 480760.68, + "em_scenario_a": { + "elec_kWh": 445085.27, + "gas_kWh": 480760.68, + "total_kgCO2e": 259369.89, + "refrig_kgCO2e": 62395.47 + }, + "em_scenario_b": { + "elec_kWh": 445085.27, + "gas_kWh": 480760.68, + "total_kgCO2e": 198735.38, + "refrig_kgCO2e": 62395.47 + }, + "em_scenario_c": { + "elec_kWh": 445085.27, + "gas_kWh": 480760.68, + "total_kgCO2e": 184162.87, + "refrig_kgCO2e": 62395.47 + } + }, + "building_35_eq_scenario_18_simulated": { + "elec_kWh": 459525.19, + "gas_kWh": 0.0, + "em_scenario_a": { + "elec_kWh": 459525.19, + "gas_kWh": 0.0, + "total_kgCO2e": 128630.04, + "refrig_kgCO2e": 30514.58 + }, + "em_scenario_b": { + "elec_kWh": 459525.19, + "gas_kWh": 0.0, + "total_kgCO2e": 55951.02, + "refrig_kgCO2e": 30514.58 + }, + "em_scenario_c": { + "elec_kWh": 459525.19, + "gas_kWh": 0.0, + "total_kgCO2e": 38199.86, + "refrig_kgCO2e": 30514.58 + } + }, + "building_182_eq_scenario_3_measured": { + "elec_kWh": 209928.05, + "gas_kWh": 1594.57, + "em_scenario_a": { + "elec_kWh": 209928.05, + "gas_kWh": 1594.57, + "total_kgCO2e": 99087.93, + "refrig_kgCO2e": 22965.42 + }, + "em_scenario_b": { + "elec_kWh": 209928.05, + "gas_kWh": 1594.57, + "total_kgCO2e": 25740.88, + "refrig_kgCO2e": 22965.42 + }, + "em_scenario_c": { + "elec_kWh": 209928.05, + "gas_kWh": 1594.57, + "total_kgCO2e": 29050.79, + "refrig_kgCO2e": 22965.42 + } + } +} diff --git a/tests/test_app_smoke.py b/tests/test_app_smoke.py new file mode 100644 index 0000000..fce66b8 --- /dev/null +++ b/tests/test_app_smoke.py @@ -0,0 +1,87 @@ +"""App tier, part 1: server smoke tests (no browser). + +Catches broken imports, page layouts that raise, and callbacks wired to +component ids that no longer exist. The app runs with +suppress_callback_exceptions=True, so Dash itself stays silent about the last +category: a renamed id simply makes a control stop working. +""" + +import json + +import dash +import plotly +import pytest + +from tests.app_helpers import callback_component_ids, component_ids, ids_declared_in_source + +pytestmark = pytest.mark.app + +# Callbacks whose target component no longer exists anywhere in the app. +# Keep this list honest: the test below fails if an entry is fixed or removed. +KNOWN_ORPHAN_IDS = { + # pages/results_page.py::show_project_summary writes here, but nothing renders it. + "summary-project-info", +} + + +@pytest.fixture(scope="module") +def app(): + import app as app_module + + return app_module + + +@pytest.fixture(scope="module") +def client(app): + return app.app.server.test_client() + + +@pytest.fixture(scope="module") +def dependencies(client): + return client.get("/_dash-dependencies").get_json() + + +def _page_paths(): + import app # noqa: F401 (registers pages) + + return [page["path"] for page in dash.page_registry.values()] + + +@pytest.mark.parametrize("path", ["/", "/_dash-layout", "/_dash-dependencies"]) +def test_server_endpoints_respond(client, path): + assert client.get(path).status_code == 200 + + +@pytest.mark.parametrize("path", _page_paths()) +def test_every_page_path_responds(client, path): + assert client.get(path).status_code == 200 + + +def test_every_page_layout_renders_and_serialises(app): + for page in dash.page_registry.values(): + layout = page["layout"]() if callable(page["layout"]) else page["layout"] + json.dumps(layout, cls=plotly.utils.PlotlyJSONEncoder) + + +def test_callbacks_only_reference_existing_components(app, dependencies): + """Every id a callback reads or writes must exist in a layout or be built at runtime.""" + static = component_ids(app.serve_layout()) + for page in dash.page_registry.values(): + static |= component_ids(page["layout"]() if callable(page["layout"]) else page["layout"]) + known = static | ids_declared_in_source() + + missing = callback_component_ids(dependencies) - known + assert missing == KNOWN_ORPHAN_IDS, ( + f"Callbacks reference unknown component ids: {sorted(missing - KNOWN_ORPHAN_IDS)}; " + f"no longer orphaned (remove from KNOWN_ORPHAN_IDS): {sorted(KNOWN_ORPHAN_IDS - missing)}" + ) + + +def test_no_output_written_by_two_callbacks_without_allow_duplicate(dependencies): + seen = {} + for dep in dependencies: + for output in dep["output"].strip(".").split("..."): + if "@" in output: # allow_duplicate outputs carry a hash suffix + continue + assert output not in seen, f"{output} written by two callbacks" + seen[output] = dep diff --git a/tests/test_energy_integration.py b/tests/test_energy_integration.py new file mode 100644 index 0000000..24ed92e --- /dev/null +++ b/tests/test_energy_integration.py @@ -0,0 +1,226 @@ +"""Integration tier: full 8760-hour runs on real load and Cambium data. + +Runs the same call chain as the Results page (get_load_data -> loads_to_site_energy +-> site_to_source) and checks: + 1. Hour-level invariants (no NaN, loads fully served, non-negative energy, + component sums, emissions sums) on simulated buildings. + 2. Annual electricity, gas and emissions totals against committed golden + values (tests/snapshots/integration_annual_totals.json), within ±0.1 %. + 3. A measured-data smoke case (real data with gaps, spans two calendar years). + +Golden values are regression anchors, not independent truth (that is the job +of the verification tier). Regenerate deliberately after a change that should +alter results, then review the JSON diff before committing: + pytest -m integration --generate-golden +""" + +import json +from pathlib import Path + +import numpy as np +import pytest + +from src import paths +from src.config import Columns as Col +from src.energy import loads_to_site_energy, site_to_source +from src.equipment import load_library +from src.loads import get_load_data +from src.metadata import LoadData, Metadata + +pytestmark = pytest.mark.integration + +GOLDEN_FILE = Path(__file__).parent / "snapshots" / "integration_annual_totals.json" +TOLERANCE = 0.001 # ±0.1 % + +# Library buildings: 83 = Hospital, New York (cold); 35 = Large office, San Francisco (mild). +BUILDINGS = {"83": "NYISO", "35": "CAISO"} +SCENARIOS = [ + "eq_scenario_1", # gas boiler + chiller baseline + "eq_scenario_3", # 20 % AWHP (H+C) + gas backup + "eq_scenario_4", # 100 % AWHP (H+C) + electric backup + "eq_scenario_5", # HR-WWHP + 100 % AWHP + electric backup + "eq_scenario_10", # HR-WWHP + gas boiler + chiller + "eq_scenario_18", # 100 % AWHP + fuel switching +] +SIMULATED_CASES = [(b, s) for b in BUILDINGS for s in SCENARIOS] +MEASURED_CASE = ("182", "NorthernGrid_West", "eq_scenario_3") # gaps, leap year, 2 calendar years + + +def _metadata(building_id: str, load_type: str, region: str) -> Metadata: + """App defaults: Cambium MidCase long-run marginal, 2025/2035/2045, 5 % leakage.""" + metadata = Metadata.create(building_id=building_id, load_data=LoadData(load_type=load_type)) + metadata.set_gea_grid_region_for_all(region) + return metadata + + +def _key(building_id: str, scenario_id: str, load_type: str = "simulated") -> str: + return f"building_{building_id}_{scenario_id}_{load_type}" + + +def _run(building_id, scenario_id, load_type, region, library): + metadata = _metadata(building_id, load_type, region) + site = loads_to_site_energy(get_load_data(metadata), library, [scenario_id], detail=True) + return site, site_to_source(site, metadata=metadata) + + +@pytest.fixture(scope="module") +def results(): + """Run every case once per module: {key: (site_df, source_df)}.""" + library = load_library(paths.EQUIPMENT_JSON) + out = {_key(b, s): _run(b, s, "simulated", BUILDINGS[b], library) for b, s in SIMULATED_CASES} + b, region, s = MEASURED_CASE + out[_key(b, s, "measured")] = _run(b, s, "measured", region, library) + return out + + +# --------------------------------------------------------------------------- +# Hour-level invariants (simulated buildings) +# --------------------------------------------------------------------------- + +case_params = pytest.mark.parametrize( + "building_id, scenario_id", SIMULATED_CASES, ids=[_key(b, s) for b, s in SIMULATED_CASES] +) + + +@case_params +def test_no_nans_in_core_columns(building_id, scenario_id, results): + site, source = results[_key(building_id, scenario_id)] + for col in (Col.ELEC_WH, Col.GAS_WH, Col.HHW_W, Col.CHW_W): + assert not site[col.value].isna().any(), f"NaN in site '{col.value}'" + for col in (Col.ELEC_EMISSIONS_RATE_G_PER_KWH, Col.TOTAL_EMISSIONS_KG_CO2E): + assert not source[col.value].isna().any(), f"NaN in source '{col.value}'" + + +@case_params +def test_heating_and_cooling_fully_served(building_id, scenario_id, results): + """Checked after fuel switching (source frame), where dispatch is final.""" + _, df = results[_key(building_id, scenario_id)] + heat = sum( + df[c.value].fillna(0) + for c in (Col.HR_HHW_W, Col.AWHP_HHW_W, Col.BOILER_HHW_W, Col.RES_HHW_W) + ) + cool = sum(df[c.value].fillna(0) for c in (Col.HR_CHW_W, Col.AWHP_CHW_W, Col.CHILLER_CHW_W)) + assert np.allclose(heat, df[Col.HHW_W.value], atol=1.0), "unserved heating" + assert np.allclose(cool, df[Col.CHW_W.value], atol=1.0), "unserved cooling" + + +@case_params +def test_energy_non_negative_and_components_sum(building_id, scenario_id, results): + _, df = results[_key(building_id, scenario_id)] + assert (df[Col.ELEC_WH.value] >= -1e-6).all() + assert (df[Col.GAS_WH.value] >= -1e-6).all() + components = sum( + df[c.value].fillna(0) + for c in ( + Col.ELEC_HR_WH, + Col.ELEC_AWHP_H_WH, + Col.ELEC_RES_WH, + Col.ELEC_AWHP_C_WH, + Col.ELEC_CHILLER_WH, + ) + ) + assert np.allclose(components, df[Col.ELEC_WH.value], atol=1.0) + assert np.allclose(df[Col.GAS_BOILER_WH.value].fillna(0), df[Col.GAS_WH.value], atol=1.0) + + +@case_params +def test_emissions_components_sum(building_id, scenario_id, results): + _, df = results[_key(building_id, scenario_id)] + parts = ( + df[Col.ELEC_EMISSIONS_KG_CO2E.value] + + df[Col.GAS_EMISSIONS_KG_CO2E.value] + + df[Col.TOTAL_REFRIG_EMISSIONS_KG_CO2E.value] + ) + assert np.allclose(parts, df[Col.TOTAL_EMISSIONS_KG_CO2E.value], atol=1e-6) + + +# --------------------------------------------------------------------------- +# Annual golden values +# --------------------------------------------------------------------------- + + +def _annual_totals(site, source) -> dict: + totals = { + "elec_kWh": round(site[Col.ELEC_WH.value].sum() / 1000, 2), + "gas_kWh": round(site[Col.GAS_WH.value].sum() / 1000, 2), + } + for em_id, em in source.groupby(Col.EM_SCEN_ID.value): + totals[em_id] = { + "elec_kWh": round(em[Col.ELEC_WH.value].sum() / 1000, 2), # after fuel switching + "gas_kWh": round(em[Col.GAS_WH.value].sum() / 1000, 2), + "total_kgCO2e": round(em[Col.TOTAL_EMISSIONS_KG_CO2E.value].sum(), 2), + "refrig_kgCO2e": round(em[Col.TOTAL_REFRIG_EMISSIONS_KG_CO2E.value].sum(), 2), + } + return totals + + +def _flatten(d, prefix=""): + for k, v in d.items(): + if isinstance(v, dict): + yield from _flatten(v, f"{prefix}{k}.") + else: + yield f"{prefix}{k}", v + + +@pytest.fixture(scope="module") +def golden(request, results): + if request.config.getoption("--generate-golden"): + data = {key: _annual_totals(*frames) for key, frames in results.items()} + GOLDEN_FILE.parent.mkdir(parents=True, exist_ok=True) + GOLDEN_FILE.write_text(json.dumps(data, indent=2) + "\n") + print(f"\nGolden values written to {GOLDEN_FILE}; review `git diff` before committing.") + if not GOLDEN_FILE.exists(): + pytest.fail( + f"{GOLDEN_FILE} missing. Generate it with: pytest -m integration --generate-golden" + ) + return json.loads(GOLDEN_FILE.read_text()) + + +ALL_KEYS = [_key(b, s) for b, s in SIMULATED_CASES] + [ + _key(MEASURED_CASE[0], MEASURED_CASE[2], "measured") +] + + +@pytest.mark.parametrize("key", ALL_KEYS) +def test_annual_totals_match_golden(key, results, golden): + assert key in golden, f"No golden entry for {key}. Regenerate with --generate-golden." + actual = dict(_flatten(_annual_totals(*results[key]))) + expected = dict(_flatten(golden[key])) + assert actual.keys() == expected.keys() + drift = { + metric: (expected[metric], actual[metric]) + for metric in expected + if actual[metric] != pytest.approx(expected[metric], rel=TOLERANCE, abs=0.01) + } + assert not drift, f"[{key}] golden (expected, actual) beyond ±{TOLERANCE:.1%}: {drift}" + + +# --------------------------------------------------------------------------- +# Measured data +# --------------------------------------------------------------------------- + + +def test_measured_building_runs_end_to_end(results): + b, _, s = MEASURED_CASE + site, source = results[_key(b, s, "measured")] + assert len(site) in (8760, 8784) + assert source[Col.TOTAL_EMISSIONS_KG_CO2E.value].sum() > 0 + + +@pytest.mark.xfail( + strict=True, + reason=( + "Known issue: hours with missing outdoor temperature become NaN for AWHP scenarios but " + "not for boiler/chiller scenarios, so annual totals of different scenarios cover " + "different hours on measured data (building 182: 16 vs 648 NaN hours). Remove this " + "xfail once missing hours are handled consistently." + ), +) +def test_measured_scenarios_cover_the_same_hours(): + b, region, _ = MEASURED_CASE + library = load_library(paths.EQUIPMENT_JSON) + nan_hours = { + s: int(_run(b, s, "measured", region, library)[0][Col.ELEC_WH.value].isna().sum()) + for s in ("eq_scenario_1", "eq_scenario_4") + } + assert len(set(nan_hours.values())) == 1, nan_hours diff --git a/tests/test_energy_regression.py b/tests/test_energy_regression.py new file mode 100644 index 0000000..a8a3af7 --- /dev/null +++ b/tests/test_energy_regression.py @@ -0,0 +1,222 @@ +"""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. +Layers: + 2a — Physics invariant checks: energy balance holds regardless of inputs. + 2b — Snapshot regression: full output must match the committed CSV snapshot. + 2c — Emissions snapshot: site_to_source() with a constant grid rate (no + Cambium dependency), including a fuel-switching scenario. + +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.emissions import EmissionScenario +from src.energy import loads_to_site_energy, site_to_source +from src.equipment import load_library +from src.loads import StandardLoad +from src.metadata import Metadata + +# --------------------------------------------------------------------------- +# 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() + + +# --------------------------------------------------------------------------- +# Layer 2c — Emissions snapshot +# --------------------------------------------------------------------------- + +EMISSION_COLUMNS = [ + Col.ELEC_WH.value, + Col.GAS_WH.value, + Col.ELEC_EMISSIONS_KG_CO2E.value, + Col.GAS_EMISSIONS_KG_CO2E.value, + Col.TOTAL_REFRIG_EMISSIONS_KG_CO2E.value, + Col.TOTAL_EMISSIONS_KG_CO2E.value, +] + + +@pytest.mark.regression +@pytest.mark.parametrize("scenario_id", [*SCENARIOS, "eq_scenario_18"]) # 18: fuel switching +def test_emissions_output_snapshot(snapshot, scenario_id, library, synthetic_load): + """Hourly energy (after fuel switching) and emissions at a constant 600 g/kWh grid rate. + + 600 g/kWh makes the AWHP dirtier than the 90 % boiler in cold hours, so + eq_scenario_18 exercises fuel switching. + """ + metadata = Metadata.create( + emission_settings=[ + EmissionScenario( + em_scen_id="em_const", + em_scen_name="constant 600", + elec_emission_source="Constant (User-provided)", + elec_avg_emission_rate_gCO2e_per_kWh=600.0, + emission_type="Includes pre-combustion", + annual_refrig_leakage_percent=0.05, + ng_emission_rate_gCO2e_per_kWh=239.2, + year=2025, + ) + ] + ) + site = loads_to_site_energy(load=synthetic_load, library=library, scenario_ids=[scenario_id]) + out = site_to_source(site, metadata=metadata) + assert snapshot == out[EMISSION_COLUMNS].round(4).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 08ad5b8..a80eb8f 100644 --- a/tests/test_equipment.py +++ b/tests/test_equipment.py @@ -9,6 +9,8 @@ load_library, ) +pytestmark = pytest.mark.unit + class TestEquipmentLibrary: """Tests for equipment library loading and manipulation.""" diff --git a/tests/test_loads.py b/tests/test_loads.py index 5819a8e..71e9a71 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", "simulated")) + assert isinstance(load, StandardLoad) + assert load.num_hours == 8760 + + def test_invalid_building_id_raises(self): + with pytest.raises(ValueError, match="No simulated load found"): + get_load_data(self._meta("99999", "simulated")) + + def test_missing_building_id_raises(self): + with pytest.raises(ValueError, match="building_id required"): + get_load_data(self._meta(None, "simulated")) + + 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/tests/test_verification_handcalc.py b/tests/test_verification_handcalc.py new file mode 100644 index 0000000..89e0796 --- /dev/null +++ b/tests/test_verification_handcalc.py @@ -0,0 +1,262 @@ +"""Verification tier, part 1: hand-calculated closed-form cases. + +Every expected value below is written as plain arithmetic a reviewer can check by +hand, using the round-number equipment in tests/verification/equipment.py +(not the production library). These tests fail only if the engine's physics or +bookkeeping changes, never because the equipment library was edited. + +Eight-hour load profile (kW thermal); each hour isolates one mechanism: + + hr OAT heat cool what it exercises + 0 0 120 0 AWHP at a catalogue point (COP 2.5, 60 kW/unit); sets the sizing peak + 1 -10 100 0 AWHP at the lower catalogue point (COP 2.0, 50 kW/unit) + 2 -20 110 0 below AWHP operating limit -> backup serves 100 % + 3 5 80 0 linear interpolation between catalogue points (COP 2.75) + 4 -5 120 0 load above AWHP capacity (2 x 55 kW) -> backup serves the rest + 5 25 0 60 AWHP cooling, interpolated (COP 4.5, 95 kW/unit) + 6 20 40 100 simultaneous: one of four compressors heats, three cool + 7 35 0 300 above AWHP heating limit; cooling capacity exceeded -> chiller + +AWHP sizing (integer, 100 % of peak heating, N+1): + units = ceil(120 kW / 60 kW at the 0 °C reference) = 2, plus 1 redundant = 3 +""" + +import math + +import numpy as np +import pandas as pd +import pytest + +from src.config import Columns as Col +from src.emissions import EmissionScenario +from src.energy import loads_to_site_energy, site_to_source +from src.loads import StandardLoad +from src.metadata import Metadata +from tests.verification import equipment as V + +pytestmark = pytest.mark.verification + +kW = 1000.0 +OAT = [0.0, -10.0, -20.0, 5.0, -5.0, 25.0, 20.0, 35.0] +HEAT_kW = [120.0, 100.0, 110.0, 80.0, 120.0, 0.0, 40.0, 0.0] +COOL_kW = [0.0, 0.0, 0.0, 0.0, 0.0, 60.0, 100.0, 300.0] +N_HOURS = len(OAT) + +AWHP_UNITS = math.ceil(max(HEAT_kW) / 60.0) # 2 +AWHP_UNITS_TOTAL = AWHP_UNITS + 1 # N+1 redundancy + +REL = 1e-6 # engine rounds hourly Wh to 4 decimals + + +@pytest.fixture(scope="module") +def library(): + return V.verification_library() + + +@pytest.fixture(scope="module") +def load(): + return StandardLoad( + pd.DataFrame( + { + "timestamp": pd.date_range("2025-01-01", periods=N_HOURS, freq="h"), + "t_out_C": OAT, + "heating_W": np.array(HEAT_kW) * kW, + "cooling_W": np.array(COOL_kW) * kW, + } + ) + ) + + +def _site(library, load, scenario_id): + return loads_to_site_energy(load, library, [scenario_id], detail=True) + + +def _source(site_df, elec_rate, ng_rate=200.0, leakage=0.10): + metadata = Metadata.create( + emission_settings=[ + EmissionScenario( + em_scen_id="em_const", + em_scen_name="constant", + elec_emission_source="Constant (User-provided)", + elec_avg_emission_rate_gCO2e_per_kWh=elec_rate, + emission_type="Includes pre-combustion", + annual_refrig_leakage_percent=leakage, + ng_emission_rate_gCO2e_per_kWh=ng_rate, + year=2025, + ) + ] + ) + return site_to_source(site_df, metadata=metadata) + + +def _kWh(series): + return float(series.fillna(0).sum()) / 1000.0 + + +# --------------------------------------------------------------------------- +# Baseline: gas boiler + chiller (fixed efficiencies) +# --------------------------------------------------------------------------- + + +def test_baseline_gas_equals_heat_over_boiler_efficiency(library, load): + df = _site(library, load, "v_baseline") + assert _kWh(df[Col.GAS_WH.value]) == pytest.approx(sum(HEAT_kW) / V.BOILER_EFF, rel=REL) + + +def test_baseline_electricity_equals_cooling_over_chiller_cop(library, load): + df = _site(library, load, "v_baseline") + assert _kWh(df[Col.ELEC_WH.value]) == pytest.approx(sum(COOL_kW) / V.CHILLER_COP, rel=REL) + + +# --------------------------------------------------------------------------- +# AWHP heating + gas backup: catalogue lookup, interpolation, limits, sizing +# --------------------------------------------------------------------------- + + +def test_awhp_unit_count(library, load): + df = _site(library, load, "v_awhp_gas") + assert df[Col.AWHP_NUM.value].iloc[0] == AWHP_UNITS + assert df[Col.AWHP_NUM_R.value].iloc[0] == AWHP_UNITS_TOTAL + + +@pytest.mark.parametrize( + "hour, expected_cop, expected_served_kW", + [ + (0, 2.5, 120.0), # catalogue point, capacity 2 x 60 = 120 exactly meets load + (1, 2.0, 100.0), # catalogue point, capacity 2 x 50 = 100 exactly meets load + (2, 2.0, 0.0), # -20 °C is below the -10 °C limit: capacity forced to 0 + (3, 2.75, 80.0), # halfway between 0 °C (2.5) and 10 °C (3.0) + (4, 2.25, 110.0), # halfway between -10 °C and 0 °C; capacity 2 x 55 = 110 < 120 + ], +) +def test_awhp_hourly_cop_and_served_load(library, load, hour, expected_cop, expected_served_kW): + df = _site(library, load, "v_awhp_gas") + row = df.iloc[hour] + assert row[Col.AWHP_COP_H.value] == pytest.approx(expected_cop, rel=REL) + assert row[Col.AWHP_HHW_W.value] / kW == pytest.approx(expected_served_kW, rel=REL) + if expected_served_kW > 0: + assert row[Col.ELEC_AWHP_H_WH.value] / kW == pytest.approx( + expected_served_kW / expected_cop, rel=REL + ) + + +def test_awhp_gas_annual_totals(library, load): + df = _site(library, load, "v_awhp_gas") + awhp_elec_kWh = 120 / 2.5 + 100 / 2.0 + 80 / 2.75 + 110 / 2.25 + 40 / 4.0 + boiler_heat_kWh = 110 + (120 - 110) # hour 2 (out of range) + hour 4 (capacity shortfall) + assert _kWh(df[Col.GAS_WH.value]) == pytest.approx(boiler_heat_kWh / V.BOILER_EFF, rel=REL) + assert _kWh(df[Col.ELEC_WH.value]) == pytest.approx( + awhp_elec_kWh + sum(COOL_kW) / V.CHILLER_COP, rel=REL + ) + + +# --------------------------------------------------------------------------- +# AWHP heating + cooling + electric resistance backup +# --------------------------------------------------------------------------- + + +def test_electric_resistance_backup_is_one_to_one(library, load): + df = _site(library, load, "v_awhp_hc_elec") + assert _kWh(df[Col.GAS_WH.value]) == 0.0 + assert _kWh(df[Col.ELEC_RES_WH.value]) == pytest.approx(110 + 10, rel=REL) + + +@pytest.mark.parametrize( + "hour, cooling_units, expected_cop, expected_awhp_kW", + [ + # No heating: all 2 / 0.5 = 4 compressors cool -> 2 units x 95 kW (interp 25 °C) + (5, 2.0, 4.5, 60.0), + # Heating 40 of 160 kW needs ceil(4 x 40/160) = 1 compressor; 3 left = 1.5 units x 100 kW + (6, 1.5, 5.0, 100.0), + # 35 °C is above the heating limit (cap 0), so all 4 compressors cool: 2 x 85 = 170 kW + (7, 2.0, 3.5, 170.0), + ], +) +def test_awhp_cooling_compressor_split( + library, load, hour, cooling_units, expected_cop, expected_awhp_kW +): + df = _site(library, load, "v_awhp_hc_elec") + row = df.iloc[hour] + assert row[Col.AWHP_NUM_C.value] == pytest.approx(cooling_units) + assert row[Col.AWHP_COP_C.value] == pytest.approx(expected_cop, rel=REL) + assert row[Col.AWHP_CHW_W.value] / kW == pytest.approx(expected_awhp_kW, rel=REL) + + +def test_awhp_hc_elec_annual_electricity(library, load): + df = _site(library, load, "v_awhp_hc_elec") + awhp_heat_kWh = 120 / 2.5 + 100 / 2.0 + 80 / 2.75 + 110 / 2.25 + 40 / 4.0 + resistance_kWh = 110 + 10 + awhp_cool_kWh = 60 / 4.5 + 100 / 5.0 + 170 / 3.5 + chiller_kWh = (300 - 170) / V.CHILLER_COP + assert _kWh(df[Col.ELEC_WH.value]) == pytest.approx( + awhp_heat_kWh + resistance_kWh + awhp_cool_kWh + chiller_kWh, rel=REL + ) + + +# --------------------------------------------------------------------------- +# Emissions: constant grid rate, gas rate, refrigerant leakage +# --------------------------------------------------------------------------- + +ELEC_RATE = 400.0 # gCO2e/kWh +NG_RATE = 200.0 # gCO2e/kWh +LEAKAGE = 0.10 # 10 % of charge per year + + +def test_baseline_emissions(library, load): + out = _source(_site(library, load, "v_baseline"), ELEC_RATE, NG_RATE, LEAKAGE) + elec_kWh = sum(COOL_kW) / V.CHILLER_COP + gas_kWh = sum(HEAT_kW) / V.BOILER_EFF + chiller_units = math.ceil(max(COOL_kW) * kW / V.CHILLER_CAP_W) # 1 + refrig_kg = V.CHILLER_REFRIG_KG * chiller_units * V.CHILLER_GWP * LEAKAGE + + assert out[Col.ELEC_EMISSIONS_KG_CO2E.value].sum() == pytest.approx( + elec_kWh * ELEC_RATE / 1000, rel=REL + ) + assert out[Col.GAS_EMISSIONS_KG_CO2E.value].sum() == pytest.approx( + gas_kWh * NG_RATE / 1000, rel=REL + ) + assert out[Col.TOTAL_REFRIG_EMISSIONS_KG_CO2E.value].sum() == pytest.approx(refrig_kg, rel=REL) + assert out[Col.TOTAL_EMISSIONS_KG_CO2E.value].sum() == pytest.approx( + elec_kWh * ELEC_RATE / 1000 + gas_kWh * NG_RATE / 1000 + refrig_kg, rel=REL + ) + + +def test_refrigerant_leakage_counts_all_awhp_units_and_chiller(library, load): + """AWHP leakage uses the N+1 unit count; annual total is independent of profile length.""" + out = _source(_site(library, load, "v_awhp_hc_elec"), ELEC_RATE, NG_RATE, LEAKAGE) + awhp_kg = V.AWHP_REFRIG_KG * AWHP_UNITS_TOTAL * V.AWHP_GWP * LEAKAGE + chiller_kg = V.CHILLER_REFRIG_KG * 1 * V.CHILLER_GWP * LEAKAGE + assert out[Col.TOTAL_REFRIG_EMISSIONS_KG_CO2E.value].sum() == pytest.approx( + awhp_kg + chiller_kg, rel=REL + ) + + +# --------------------------------------------------------------------------- +# Fuel switching: AWHP hours move to gas when R/COP > NG/eff +# --------------------------------------------------------------------------- + + +def test_fuel_switching_moves_dirty_hours_to_gas(library, load): + # Gas heat intensity = 200 / 0.8 = 250 g/kWh_th. Electric: 600 / COP. + # hr0 COP 2.5 -> 240 stays electric hr1 COP 2.0 -> 300 switches to gas + # hr3 COP 2.75 -> 218 stays electric hr4 COP 2.25 -> 267 switches to gas + # hr6 COP 4.0 -> 150 stays electric (hr2 already all-gas: AWHP out of range) + out = _source(_site(library, load, "v_awhp_fuel_switching"), elec_rate=600.0, ng_rate=200.0) + + awhp_elec_kWh = 120 / 2.5 + 80 / 2.75 + 40 / 4.0 + gas_heat_kWh = 110 + 100 + 120 + assert _kWh(out[Col.ELEC_AWHP_H_WH.value]) == pytest.approx(awhp_elec_kWh, rel=REL) + assert _kWh(out[Col.GAS_WH.value]) == pytest.approx(gas_heat_kWh / V.BOILER_EFF, rel=REL) + assert _kWh(out[Col.ELEC_WH.value]) == pytest.approx( + awhp_elec_kWh + sum(COOL_kW) / V.CHILLER_COP, rel=REL + ) + # Boiler count is re-sized after switching: peak boiler load 120 kW / 100 kW per unit + assert out[Col.BOILER_NUM.value].iloc[0] == math.ceil(120 * kW / V.BOILER_CAP_W) + + +def test_fuel_switching_inactive_when_grid_is_clean(library, load): + """At 100 g/kWh no hour is dirtier than gas, so results match the non-switching scenario.""" + switching = _source(_site(library, load, "v_awhp_fuel_switching"), elec_rate=100.0) + plain = _source(_site(library, load, "v_awhp_gas"), elec_rate=100.0) + assert _kWh(switching[Col.GAS_WH.value]) == pytest.approx(_kWh(plain[Col.GAS_WH.value])) + assert _kWh(switching[Col.ELEC_WH.value]) == pytest.approx(_kWh(plain[Col.ELEC_WH.value])) diff --git a/tests/test_verification_reference.py b/tests/test_verification_reference.py new file mode 100644 index 0000000..4e482da --- /dev/null +++ b/tests/test_verification_reference.py @@ -0,0 +1,33 @@ +"""Verification tier, part 2: tool vs independent 8760-hour reference model. + +The reference (tests/verification/reference_model.py) re-implements the +documented method without importing src/, for two library buildings +(83, 35), three scenarios and two grid emission sources. Annual electricity, +gas and emissions must agree within REL_TOL. + +The only expected residual is the engine rounding hourly values to 4 decimals +(about 1e-7 relative on refrigerant emissions), so the tolerance is tight: a +real implementation change will show up here. + +To print the comparison as a table: + python -m tests.verification.report +""" + +import pytest + +from tests.verification.cases import ALL_CASES, METRICS, reference_result, tool_result + +pytestmark = pytest.mark.verification + +REL_TOL = 1e-5 # 0.001 % + + +@pytest.mark.parametrize("case", ALL_CASES, ids=lambda c: c.id) +def test_tool_matches_reference_model(case): + tool, reference = tool_result(case), reference_result(case) + mismatches = { + metric: (round(tool[metric], 3), round(reference[metric], 3)) + for metric in METRICS + if tool[metric] != pytest.approx(reference[metric], rel=REL_TOL, abs=1e-3) + } + assert not mismatches, f"{case.id}: tool vs reference {mismatches}" diff --git a/tests/test_verification_spreadsheet.py b/tests/test_verification_spreadsheet.py new file mode 100644 index 0000000..6d7a573 --- /dev/null +++ b/tests/test_verification_spreadsheet.py @@ -0,0 +1,182 @@ +"""Verification tier, part 3: legacy hand calculations (tests/testing_cases.xlsx). + +The spreadsheet was prepared by hand, independently of the code: 7 equipment +scenarios x 3 characteristic hours (heating-dominant, cooling-dominant, +balanced) on 3 buildings. Its inputs and expected values are frozen in +tests/fixtures/spreadsheet_cases.json (see tests/verification/extract_spreadsheet.py +for provenance and for the values tagged "superseded"). + +Each case runs the tool on the three spreadsheet hours plus two synthetic hours +that reproduce the spreadsheet's annual peaks, so AWHP and chiller sizing see +the same peak loads the hand calculation assumed. Sizing-only hours sit outside +every operating envelope (-40 °C, +60 °C), so they do not interact with the +checked hours. +""" + +import json +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from src.config import Columns as Col +from src.emissions import EmissionScenario +from src.energy import loads_to_site_energy, site_to_source +from src.equipment import EquipmentLibrary +from src.loads import StandardLoad +from src.metadata import Metadata + +pytestmark = pytest.mark.verification + +FIXTURE = json.loads((Path(__file__).parent / "fixtures" / "spreadsheet_cases.json").read_text()) +CASES = {c["id"]: c for c in FIXTURE["cases"]} +REL = 2e-3 # spreadsheet rounds COPs to 2 decimals (up to ~0.2 % on derived energy) +ABS = 1e-3 +PEAK_HEAT_TS = pd.Timestamp("2000-01-01 00:00") +PEAK_COOL_TS = pd.Timestamp("2000-01-01 01:00") + + +def _library(case): + return EquipmentLibrary(equipment=FIXTURE["equipment"], equipment_scenarios=[case["scenario"]]) + + +def _load(case): + hours = case["hours"] + peak_heat = case["peak_heating_kW"] or max(h["heating_kW"] for h in hours) + df = pd.DataFrame( + { + "timestamp": [PEAK_HEAT_TS, PEAK_COOL_TS] + + [pd.Timestamp(h["timestamp"]) for h in hours], + "t_out_C": [-40.0, 60.0] + [h["t_out_C"] for h in hours], + "heating_W": np.array([peak_heat, 0.0] + [h["heating_kW"] for h in hours]) * 1000, + "cooling_W": np.array([0.0, case["peak_chiller_kW"]] + [h["cooling_kW"] for h in hours]) + * 1000, + } + ) + return StandardLoad(df) + + +_site_cache: dict[str, pd.DataFrame] = {} + + +def _site(case_id): + if case_id not in _site_cache: + case = CASES[case_id] + _site_cache[case_id] = loads_to_site_energy( + _load(case), _library(case), [case_id], detail=True + ) + return _site_cache[case_id] + + +def _energy_params(): + for case in FIXTURE["cases"]: + for hour in case["hours"]: + for check in hour["checks"]: + yield pytest.param( + case["id"], + hour["timestamp"], + check, + id=f"{case['id']}-{hour['timestamp'][:13]}-{check['column']}", + ) + + +@pytest.mark.parametrize("case_id, timestamp, check", list(_energy_params())) +def test_hourly_energy_matches_hand_calculation(case_id, timestamp, check): + row = _site(case_id).loc[pd.Timestamp(timestamp)] + tool = row[check["column"]] + tool = 0.0 if pd.isna(tool) else tool / check["divisor"] + assert tool == pytest.approx(check["value"], rel=REL, abs=ABS), check["label"] + + +def _known_difference_params(): + for case in FIXTURE["cases"]: + for hour in case["hours"]: + for check in hour["known_differences"]: + yield pytest.param( + case["id"], + hour["timestamp"], + check, + id=f"{case['id']}-{hour['timestamp'][:13]}-{check['column']}", + marks=pytest.mark.xfail(strict=True, reason=check["reason"]), + ) + + +@pytest.mark.parametrize("case_id, timestamp, check", list(_known_difference_params())) +def test_known_method_differences(case_id, timestamp, check): + """Documented open differences; strict xfail flags it if the tool's behaviour changes.""" + test_hourly_energy_matches_hand_calculation(case_id, timestamp, check) + + +# --------------------------------------------------------------------------- +# Emissions — only cases using pure long-run marginal rates. The spreadsheet's +# other cases blend short- and long-run rates with a weighting input the tool +# no longer offers. +# --------------------------------------------------------------------------- + +LRMER_CASES = [c["id"] for c in FIXTURE["cases"] if c["emissions"]["shortrun_weighting"] == 0] + + +def _source(case_id): + em = CASES[case_id]["emissions"] + metadata = Metadata.create( + emission_settings=[ + EmissionScenario( + em_scen_id="em_sheet", + em_scen_name=case_id, + elec_emission_source="Marginal (Cambium, Long-run)", + grid_scenario=em["grid_scenario"], + gea_grid_region=em["gea_grid_region"], + emission_type=em["emission_type"], + annual_refrig_leakage_percent=em["annual_refrig_leakage"], + ng_emission_rate_gCO2e_per_kWh=em["ng_rate_g_per_kWh"], + year=em["year"], + ) + ] + ) + return site_to_source(_site(case_id), metadata=metadata) + + +@pytest.mark.parametrize("case_id", LRMER_CASES) +def test_hourly_grid_rate_and_emissions(case_id): + out = _source(case_id) + for hour in CASES[case_id]["hours"]: + row = out.loc[pd.Timestamp(hour["timestamp"])] + assert row[Col.ELEC_EMISSIONS_RATE_G_PER_KWH.value] == pytest.approx( + hour["elec_rate_g_per_kWh"], rel=REL + ), f"Cambium month-hour rate at {hour['timestamp']}" + assert row[Col.GAS_EMISSIONS_KG_CO2E.value] == pytest.approx( + hour["gas_emissions_kg"], rel=REL, abs=ABS + ) + if not any(s["column"] == "elec_Wh" for s in hour["superseded"]): + assert row[Col.ELEC_EMISSIONS_KG_CO2E.value] == pytest.approx( + hour["elec_emissions_kg"], rel=REL, abs=ABS + ) + + +@pytest.mark.parametrize("case_id", [c["id"] for c in FIXTURE["cases"]]) +def test_annual_refrigerant_emissions(case_id): + """Refrigerant inventory (unit counts x charge x GWP x leakage) for the year. + + The spreadsheet spreads the annual figure over 8760 h; the tool spreads it + over the rows supplied, so annual totals are compared. + """ + em = CASES[case_id]["emissions"] + metadata = Metadata.create( + emission_settings=[ + EmissionScenario( + em_scen_id="em_const", + em_scen_name="constant", + elec_emission_source="Constant (User-provided)", + elec_avg_emission_rate_gCO2e_per_kWh=0.0, + emission_type="Includes pre-combustion", + annual_refrig_leakage_percent=em["annual_refrig_leakage"], + ng_emission_rate_gCO2e_per_kWh=0.0, + year=2025, + ) + ] + ) + out = site_to_source(_site(case_id), metadata=metadata) + assert out[Col.TOTAL_REFRIG_EMISSIONS_KG_CO2E.value].sum() == pytest.approx( + em["refrig_emissions_kg_per_hour"] * 8760, rel=REL + ) diff --git a/tests/test_workflow.py b/tests/test_workflow.py new file mode 100644 index 0000000..01b49a6 --- /dev/null +++ b/tests/test_workflow.py @@ -0,0 +1,265 @@ +"""App tier, part 2: headless end-to-end workflow through the real Results callbacks. + +Builds the session stores the Loads/Equipment/Emissions pages would leave +behind, then calls the Results page callbacks as plain functions, as Dash +would: the auto-calculation (initialize_results_page), every chart in SI and +IP units, and the ZIP download. One test sweeps every option of every chart +control; options are read from the live layout, so new options are covered +without editing this file. +""" + +import base64 +import io +import shutil +import time +import uuid +import zipfile +from pathlib import Path + +import pandas as pd +import pytest + +from src import paths +from src.config import DEFAULT_SELECTIONS +from src.emissions import EmissionScenario +from src.equipment import load_library +from src.metadata import LoadData, Metadata +from tests.app_helpers import figure_has_data, option_values +from tests.app_helpers import find as _find + +pytestmark = pytest.mark.app + +UNIT_MODES = ("SI", "IP") + + +@pytest.fixture(scope="module", autouse=True) +def dash_app(): + """Pages can only be imported after the Dash app exists.""" + import app + + return app + + +@pytest.fixture(scope="module") +def results_page(): + from pages import results_page + + return results_page + + +@pytest.fixture(scope="module") +def results_layout(results_page): + return results_page.layout() + + +@pytest.fixture(scope="module") +def library_json(): + return load_library(paths.EQUIPMENT_JSON).model_dump() + + +def _all_emission_sources(region: str) -> list[EmissionScenario]: + common = dict( + grid_scenario="MidCase", + gea_grid_region=region, + emission_type="Includes pre-combustion", + annual_refrig_leakage_percent=0.05, + ng_emission_rate_gCO2e_per_kWh=239.2, + year=2030, + ) + sources = [ + ("em_scenario_a", "Marginal (Cambium, Long-run)"), + ("em_scenario_b", "Marginal (Cambium, Short-run)"), + ("em_scenario_c", "Average (Cambium)"), + ] + scenarios = [ + EmissionScenario(em_scen_id=i, em_scen_name=s, elec_emission_source=s, **common) + for i, s in sources + ] + scenarios.append( + EmissionScenario( + em_scen_id="em_scenario_d", + em_scen_name="Constant", + elec_emission_source="Constant (User-provided)", + elec_avg_emission_rate_gCO2e_per_kWh=250.0, + **{**common, "grid_scenario": None}, + ) + ) + return scenarios + + +class Session: + """The client-side stores a user would have filled in before opening Results.""" + + def __init__(self, metadata: Metadata, library_json: dict, scenario_ids: list[str]): + self.metadata_json = metadata.model_dump() + self.equipment_json = library_json + self.scenario_ids = scenario_ids + self.emission_ids = metadata.list_emission_scenarios() + self.session_data = {"session_id": f"pytest-{uuid.uuid4().hex}"} + + @property + def folder(self) -> Path: + return Path(f"/tmp/{self.session_data['session_id']}") + + +def _calculate(results_page, s: Session): + out = results_page.initialize_results_page( + time.time(), + s.metadata_json, + s.equipment_json, + s.scenario_ids, + s.scenario_ids, + s.emission_ids, + s.session_data, + None, + None, + None, + None, + None, + ) + notification = out[2] + assert notification is not None and notification != dash_no_update(), "no notification" + assert notification[0]["color"] == "green", notification[0]["message"] + return out + + +def dash_no_update(): + from dash import no_update + + return no_update + + +def _exercise_charts(results_page, layout, s: Session, exhaustive: bool): + """Render every Results chart. exhaustive=True also sweeps every control option.""" + eq, em = s.scenario_ids, s.emission_ids + rp = results_page + + def options(component_id): + values = option_values(layout, component_id) + default = getattr(_find(layout, component_id), "value", values[0]) + return values if exhaustive else [default] + + for unit in UNIT_MODES: + for freq in options("frequency-dropdown"): + fig = rp.update_meter_plot( + eq[0], em[0], ["stacked"], ["gas"], freq, unit, s.session_data + ) + assert figure_has_data(fig), f"meter plot empty ({freq}, {unit})" + assert figure_has_data( + rp.update_total_emissions_plot(eq, em[0], unit, s.session_data, eq) + ), f"energy & emissions plot empty ({unit})" + assert figure_has_data( + rp.update_emissions_bar_plot(em, unit, eq, eq, s.session_data) + ), f"grouped emissions bar empty ({unit})" + for em_type in options("heatmap-emission-type-dropdown"): + for em_id in em if exhaustive else em[:1]: + fig = rp.update_emissions_heatmap(eq[0], em_id, em_type, unit, s.session_data) + assert figure_has_data(fig), f"heatmap empty ({em_id}, {em_type}, {unit})" + for y_var in options("scatter-yvar-dropdown"): + for freq in options("scatter-frequency-dropdown"): + fig = rp.update_scatter_plot(eq, em[0], y_var, freq, unit, s.session_data) + assert figure_has_data(fig), f"scatter empty ({y_var}, {freq}, {unit})" + + +def _download(results_page, s: Session, unit: str) -> pd.DataFrame: + payload = results_page.download_results( + 1, + s.session_data, + unit, + s.metadata_json, + s.equipment_json, + s.scenario_ids, + s.emission_ids, + ) + with zipfile.ZipFile(io.BytesIO(base64.b64decode(payload["content"]))) as zf: + csv_name = next(n for n in zf.namelist() if n.startswith("results_")) + return pd.read_csv(zf.open(csv_name), low_memory=False) + + +def _run_workflow(results_page, layout, session: Session, exhaustive=False, download_units=()): + try: + _calculate(results_page, session) + _exercise_charts(results_page, layout, session, exhaustive) + for unit in download_units: # the export is slow (~7 s), so only where it adds coverage + df = _download(results_page, session, unit) + hours = len(df) / len(session.scenario_ids) / len(session.emission_ids) + assert hours in (8760, 8784), f"download has {len(df)} rows" + finally: + shutil.rmtree(session.folder, ignore_errors=True) + + +def _scenario_groups(): + library = load_library(paths.EQUIPMENT_JSON) + return {g.group_id: g.scenario_ids for g in library.scenario_groups} + + +@pytest.mark.parametrize("group_id", list(_scenario_groups())) +def test_library_building_every_scenario_group( + results_page, results_layout, library_json, group_id +): + """Building 83 (hospital, New York) with each predefined equipment scenario group.""" + metadata = Metadata.create(building_id="83", load_data=LoadData(load_type="simulated")) + metadata.set_gea_grid_region_for_all("NYISO") + session = Session(metadata, library_json, _scenario_groups()[group_id]) + _run_workflow(results_page, results_layout, session) + + +def test_every_emission_source_every_chart_option(results_page, results_layout, library_json): + """Long-run, short-run, average and constant grid factors; every chart control; export.""" + metadata = Metadata.create( + building_id="35", + load_data=LoadData(load_type="simulated"), + emission_settings=_all_emission_sources("CAISO"), + ) + session = Session(metadata, library_json, list(DEFAULT_SELECTIONS.EQUIPMENT_SCENARIO.value)) + _run_workflow(results_page, results_layout, session, exhaustive=True, download_units=UNIT_MODES) + + +def test_measured_building(results_page, results_layout, library_json): + """Real measured data: gaps, a leap year and two calendar years.""" + metadata = Metadata.create(building_id="182", load_data=LoadData(load_type="measured")) + metadata.set_gea_grid_region_for_all("NorthernGrid_West") + session = Session(metadata, library_json, list(DEFAULT_SELECTIONS.EQUIPMENT_SCENARIO.value)) + _run_workflow(results_page, results_layout, session, download_units=("SI",)) + + +def test_custom_csv_upload(results_page, results_layout, library_json): + """Upload path: CSV -> parse_custom_load_data -> custom load -> results.""" + from pages.loads_page import parse_custom_load_data + + source = pd.read_parquet( + paths.LOAD_DATA_PARQUET, + columns=["timestamp", "t_out_C", "heating_W", "cooling_W"], + filters=[("building_id", "=", "35"), ("source", "=", "simulated")], + ) + contents = "data:text/csv;base64," + base64.b64encode( + source.to_csv(index=False).encode() + ).decode("ascii") + + session_id = f"pytest-{uuid.uuid4().hex}" + try: + parsed = parse_custom_load_data(contents, "upload.csv", session_id) + assert parsed["status"] == "success", parsed["message"] + + metadata = Metadata.create( + load_data=LoadData(load_type="custom"), custom_load_path=parsed["filepath"] + ) + metadata.set_gea_grid_region_for_all("CAISO") + session = Session(metadata, library_json, ["eq_scenario_1", "eq_scenario_4"]) + session.session_data = {"session_id": session_id} + _run_workflow(results_page, results_layout, session) + finally: + shutil.rmtree(Path(f"/tmp/{session_id}"), ignore_errors=True) + + +def test_upload_with_negative_loads_is_rejected_cleanly(): + from pages.loads_page import parse_custom_load_data + + csv = ( + "timestamp,t_out_C,heating_W,cooling_W\n" + "2025-01-01 00:00,5,100,0\n2025-01-01 01:00,5,-100,0\n2025-01-01 02:00,5,100,0\n" + ) + contents = "data:text/csv;base64," + base64.b64encode(csv.encode()).decode("ascii") + result = parse_custom_load_data(contents, "bad.csv", f"pytest-{uuid.uuid4().hex}") + assert result["status"] == "error" + assert "negative" in result["message"] diff --git a/tests/verification/__init__.py b/tests/verification/__init__.py new file mode 100644 index 0000000..7935892 --- /dev/null +++ b/tests/verification/__init__.py @@ -0,0 +1 @@ +"""Verification helpers: frozen test equipment, independent reference model, report.""" diff --git a/tests/verification/cases.py b/tests/verification/cases.py new file mode 100644 index 0000000..59be604 --- /dev/null +++ b/tests/verification/cases.py @@ -0,0 +1,143 @@ +"""Tool-vs-reference comparison cases shared by the verification test and the report. + +Two contrasting library buildings: + 83 - Hospital, New York (NYISO): heating-dominated, cold climate + 35 - Large office, San Francisco (CAISO): mild climate +""" + +from dataclasses import dataclass +from functools import cache + +import numpy as np +import pandas as pd + +from src import paths +from src.config import Columns as Col +from src.emissions import EmissionScenario +from src.energy import loads_to_site_energy, site_to_source +from src.equipment import load_library +from src.loads import get_load_data +from src.metadata import LoadData, Metadata +from tests.verification import reference_model as ref + +BUILDINGS = {"83": "NYISO", "35": "CAISO"} +SCENARIOS = ref.SUPPORTED_SCENARIOS +NG_RATE = 239.2 # gCO2e/kWh, app default +LEAKAGE = 0.05 # app default + +GRIDS = { + "constant_400": dict(source="constant", constant_rate=400.0), + "cambium_lrmer_2025": dict(source="lrmer", grid_scenario="MidCase", year=2025), +} + +TOOL_SOURCE = { + "constant": "Constant (User-provided)", + "lrmer": "Marginal (Cambium, Long-run)", + "srmer": "Marginal (Cambium, Short-run)", + "average": "Average (Cambium)", +} + +METRICS = ("elec_kWh", "gas_kWh", "elec_kgCO2e", "gas_kgCO2e", "refrig_kgCO2e", "total_kgCO2e") + + +@dataclass(frozen=True) +class Case: + building_id: str + scenario_id: str + grid: str + + @property + def id(self) -> str: + return f"b{self.building_id}-{self.scenario_id}-{self.grid}" + + +ALL_CASES = [Case(b, s, g) for b in BUILDINGS for s in SCENARIOS for g in GRIDS] + + +def _tool_metadata(case: Case) -> Metadata: + grid = GRIDS[case.grid] + return Metadata.create( + building_id=case.building_id, + load_data=LoadData(load_type="simulated"), + emission_settings=[ + EmissionScenario( + em_scen_id="em_verification", + em_scen_name=case.grid, + elec_emission_source=TOOL_SOURCE[grid["source"]], + elec_avg_emission_rate_gCO2e_per_kWh=grid.get("constant_rate"), + grid_scenario=grid.get("grid_scenario"), + gea_grid_region=BUILDINGS[case.building_id], + emission_type="Includes pre-combustion", + annual_refrig_leakage_percent=LEAKAGE, + ng_emission_rate_gCO2e_per_kWh=NG_RATE, + year=grid.get("year", 2025), + ) + ], + ) + + +@cache +def _tool_library(): + return load_library(paths.EQUIPMENT_JSON) + + +def tool_result(case: Case) -> dict[str, float]: + """Run the tool along the same call chain the Results page uses.""" + metadata = _tool_metadata(case) + site = loads_to_site_energy( + get_load_data(metadata), _tool_library(), [case.scenario_id], detail=True + ) + out = site_to_source(site, metadata=metadata) + return { + "elec_kWh": out[Col.ELEC_WH.value].sum() / 1000, + "gas_kWh": out[Col.GAS_WH.value].sum() / 1000, + "elec_kgCO2e": out[Col.ELEC_EMISSIONS_KG_CO2E.value].sum(), + "gas_kgCO2e": out[Col.GAS_EMISSIONS_KG_CO2E.value].sum(), + "refrig_kgCO2e": out[Col.TOTAL_REFRIG_EMISSIONS_KG_CO2E.value].sum(), + "total_kgCO2e": out[Col.TOTAL_EMISSIONS_KG_CO2E.value].sum(), + } + + +@cache +def _ref_inputs(building_id: str): + equipment, scenarios = ref.read_library() + return equipment, scenarios, ref.read_building_load(building_id) + + +def reference_result(case: Case) -> dict[str, float]: + equipment, scenarios, load = _ref_inputs(case.building_id) + grid = GRIDS[case.grid] + rate = ref.hourly_grid_rate( + load["timestamp"], + source=grid["source"], + constant_rate=grid.get("constant_rate"), + grid_scenario=grid.get("grid_scenario"), + region=BUILDINGS[case.building_id], + year=grid.get("year"), + ) + return ref.run(load, scenarios[case.scenario_id], equipment, rate, NG_RATE, LEAKAGE) + + +def relative_difference(tool: float, reference: float) -> float: + if reference == 0: + return 0.0 if abs(tool) < 1e-6 else np.inf + return (tool - reference) / reference + + +def comparison_table(cases=ALL_CASES) -> pd.DataFrame: + rows = [] + for case in cases: + tool, reference = tool_result(case), reference_result(case) + for metric in METRICS: + rows.append( + { + "building": case.building_id, + "scenario": case.scenario_id, + "grid": case.grid, + "metric": metric, + "tool": tool[metric], + "reference": reference[metric], + "rel_diff": relative_difference(tool[metric], reference[metric]), + } + ) + return pd.DataFrame(rows) diff --git a/tests/verification/equipment.py b/tests/verification/equipment.py new file mode 100644 index 0000000..1a996b7 --- /dev/null +++ b/tests/verification/equipment.py @@ -0,0 +1,146 @@ +"""Frozen, test-owned equipment for hand-calculated verification cases. + +These definitions deliberately do NOT come from data/input/equipment_data.JSON: +editing the production library must never break the verification tier. Values +are round numbers so every expected result in test_verification_handcalc.py +can be written as a one-line formula. + +AWHP "V-AWHP" heating table at HHWST 40 °C (operating range -10 … 30 °C OAT): + + OAT [°C] -10 0 10 20 + COP 2.0 2.5 3.0 4.0 + Capacity [kW] 50 60 70 80 + +AWHP "V-AWHP" cooling table at CHWST 7 °C (operating range 10 … 45 °C OAT): + + OAT [°C] 20 30 40 + COP 5.0 4.0 3.0 + Capacity [kW] 100 90 80 +""" + +from src.equipment import EquipmentLibrary + +BOILER_EFF = 0.8 +BOILER_CAP_W = 100_000.0 +CHILLER_COP = 4.0 +CHILLER_CAP_W = 500_000.0 +CHILLER_REFRIG_KG = 100.0 +CHILLER_GWP = 1000.0 +AWHP_REFRIG_KG = 20.0 +AWHP_GWP = 500.0 + +AWHP_H_T = [-10.0, 0.0, 10.0, 20.0] +AWHP_H_COP = [2.0, 2.5, 3.0, 4.0] +AWHP_H_CAP_W = [50_000.0, 60_000.0, 70_000.0, 80_000.0] +AWHP_H_MIN_T, AWHP_H_MAX_T = -10.0, 30.0 + +AWHP_C_T = [20.0, 30.0, 40.0] +AWHP_C_COP = [5.0, 4.0, 3.0] +AWHP_C_CAP_W = [100_000.0, 90_000.0, 80_000.0] +AWHP_C_MIN_T, AWHP_C_MAX_T = 10.0, 45.0 + +_EQUIPMENT = [ + { + "eq_id": "v_boiler", + "eq_type": "backup_heating", + "eq_calc_type": "specific", + "model": "Verification gas boiler", + "fuel": "natural_gas", + "capacity_W": BOILER_CAP_W, + "performance": {"heating": {"efficiency": BOILER_EFF}}, + }, + { + "eq_id": "v_resistance", + "eq_type": "backup_heating", + "eq_calc_type": "generic", + "model": "Verification electric resistance", + "fuel": "electricity", + "performance": {"heating": {"efficiency": 1.0}}, + }, + { + "eq_id": "v_chiller", + "eq_type": "chiller", + "eq_calc_type": "specific", + "model": "Verification chiller", + "fuel": "electricity", + "capacity_W": CHILLER_CAP_W, + "refrigerant": "R-test", + "refrigerant_weight_g": CHILLER_REFRIG_KG * 1000, + "refrigerant_gwp": CHILLER_GWP, + "performance": {"cooling": {"efficiency": CHILLER_COP}}, + }, + { + "eq_id": "v_awhp", + "eq_type": "heat_pump", + "eq_calc_type": "specific", + "model": "Verification AWHP", + "fuel": "electricity", + "refrigerant": "R-test", + "refrigerant_weight_g": AWHP_REFRIG_KG * 1000, + "refrigerant_gwp": AWHP_GWP, + "performance": { + "heating": { + "t_out_C": AWHP_H_T, + "leaving_supply_t": { + "40": { + "cop": AWHP_H_COP, + "capacity_W": AWHP_H_CAP_W, + "constraints": {"min_temp_C": AWHP_H_MIN_T, "max_temp_C": AWHP_H_MAX_T}, + } + }, + "constraints": {"min_temp_C": 40, "max_temp_C": 40}, + }, + "cooling": { + "t_out_C": AWHP_C_T, + "leaving_supply_t": { + "7": { + "cop": AWHP_C_COP, + "capacity_W": AWHP_C_CAP_W, + "constraints": {"min_temp_C": AWHP_C_MIN_T, "max_temp_C": AWHP_C_MAX_T}, + } + }, + "constraints": {"min_temp_C": 7, "max_temp_C": 7}, + }, + }, + }, +] + +_SCENARIO_DEFAULTS = { + "hr_wwhp": None, + "awhp": None, + "awhp_performance_model": "interpolate_HHWST_fixed", + "awhp_h_supply_t": 40.0, + "awhp_sizing_mode": "integer_sizing_peak_load", + "awhp_sizing_value": 1.0, + "awhp_redundancy": 1, + "awhp_use_cooling": False, + "awhp_sizing_priority": "heating", + "backup_heating": "v_boiler", + "fuel_switching": False, + "chiller": "v_chiller", +} + +_SCENARIOS = { + "v_baseline": {"eq_scen_name": "Gas boiler + chiller"}, + "v_awhp_gas": {"eq_scen_name": "AWHP (H) + gas backup + chiller", "awhp": "v_awhp"}, + "v_awhp_hc_elec": { + "eq_scen_name": "AWHP (H+C) + electric backup + chiller", + "awhp": "v_awhp", + "awhp_use_cooling": True, + "backup_heating": "v_resistance", + }, + "v_awhp_fuel_switching": { + "eq_scen_name": "AWHP (H) + gas backup, fuel switching", + "awhp": "v_awhp", + "fuel_switching": True, + }, +} + + +def verification_library() -> EquipmentLibrary: + """Fresh EquipmentLibrary containing only the frozen verification equipment.""" + scenarios = [ + {"eq_scen_id": sid, **_SCENARIO_DEFAULTS, **overrides} + for sid, overrides in _SCENARIOS.items() + ] + return EquipmentLibrary(equipment=_EQUIPMENT, equipment_scenarios=scenarios) diff --git a/tests/verification/extract_spreadsheet.py b/tests/verification/extract_spreadsheet.py new file mode 100644 index 0000000..f7f0b1a --- /dev/null +++ b/tests/verification/extract_spreadsheet.py @@ -0,0 +1,254 @@ +"""One-time extraction of tests/testing_cases.xlsx into a frozen JSON fixture. + +The spreadsheet itself is git-ignored and kept locally; the committed JSON is +what the tests read. + + python -m tests.verification.extract_spreadsheet + +The spreadsheet holds hand calculations made independently of the code for +7 scenarios x 3 hours on 3 buildings. This script freezes BOTH its expected +values AND the equipment data it assumed into +tests/fixtures/spreadsheet_cases.json, so later edits to the production +equipment library cannot break the comparison. Rerun only if the spreadsheet +itself is corrected; review the JSON diff before committing. + +Where the spreadsheet's inputs differ from today's library, the spreadsheet +wins (it is the frozen reference). Known differences, applied as overrides: + * hr01 COP at 32.2 °C, first point: 6.7 (library now 5.7) + * hr01 refrigerant 200 kg, GWP 466 (library now 400 kg, GWP 292) + * hr03 refrigerant 29 kg (library now 45 kg) + * chillers: COP 2.84, GWP 2088, 703.2 kW / 90.7 kg (B1S3: 351.6 kW / 45.4 kg) + +Values the spreadsheet computed with a rule the tool has since replaced are +kept in the fixture but tagged "superseded" with the reason, and are not +asserted: + * AWHP cooling in hours where the AWHPs are also heating. The spreadsheet + gives such hours no AWHP cooling at all; the tool now lets the compressors + not needed for heating serve cooling (verified separately in + test_verification_handcalc.py::test_awhp_cooling_compressor_split). +""" + +import copy +import json +from pathlib import Path + +import openpyxl + +ROOT = Path(__file__).resolve().parents[2] +XLSX = ROOT / "tests" / "testing_cases.xlsx" +LIBRARY = ROOT / "data" / "input" / "equipment_data.JSON" +OUT = ROOT / "tests" / "fixtures" / "spreadsheet_cases.json" + +SCENARIO_DEFAULTS = { + "hr_wwhp": None, + "hr_wwhp_performance_model": "interpolate_HHWST", + "hr_wwhp_h_supply_t": None, + "awhp": None, + "awhp_performance_model": "interpolate_HHWST_fixed", + "awhp_h_supply_t": None, + "awhp_sizing_mode": "integer_sizing_peak_load", + "awhp_sizing_value": 1, + "awhp_redundancy": 1, + "awhp_use_cooling": False, + "awhp_sizing_priority": "heating", + "fuel_switching": False, +} + +# Transcribed from the "Scenarios" sheet (equipment IDs and settings per case). +CASES = { + "B1S1": dict(hr_wwhp="hr03", hr_wwhp_h_supply_t=48.9, awhp="hp01", awhp_h_supply_t=38, + awhp_use_cooling=True, backup_heating="res02", chiller="xs_chiller_703kW"), + "B1S2": dict(awhp="hp01", awhp_performance_model="interpolate_HHWST_reset", awhp_h_supply_t=38, + awhp_redundancy=2, awhp_use_cooling=True, backup_heating="res01", + chiller="xs_chiller_703kW"), + "B1S3": dict(hr_wwhp="hr03", hr_wwhp_h_supply_t=52, awhp="hp01", awhp_h_supply_t=42, + awhp_sizing_mode="fractional_sizing_peak_load", awhp_sizing_value=0.7, + awhp_use_cooling=True, backup_heating="bo03", chiller="xs_chiller_352kW"), + "B2S1": dict(backup_heating="res02", chiller="xs_chiller_703kW"), + "B2S2": dict(hr_wwhp="hr03", hr_wwhp_h_supply_t=60, awhp="hp06", awhp_h_supply_t=60, + awhp_sizing_mode="fixed_num_units", awhp_sizing_value=3, backup_heating="res02", + chiller="xs_chiller_703kW"), + "B3S1": dict(hr_wwhp="hr01", hr_wwhp_h_supply_t=40, awhp="hp01", awhp_h_supply_t=38, + awhp_sizing_mode="fractional_sizing_peak_load", awhp_sizing_value=0.3, + awhp_use_cooling=True, backup_heating="bo02", chiller="xs_chiller_703kW"), + "B3S2": dict(awhp="hp04", awhp_h_supply_t=45, awhp_sizing_value=0.5, awhp_use_cooling=True, + backup_heating="res02", chiller="xs_chiller_703kW"), +} # fmt: skip + +# (sheet section, sheet row label) -> (tool column, divisor from tool units to sheet units) +ENERGY_CHECKS = { + ("HR WWHP", "HR HHW Output [kW]"): ("hr_hhw_W", 1000), + ("HR WWHP", "HR CHW Output [kW]"): ("hr_chw_W", 1000), + ("HR WWHP", "HR-WWHP Electricity [kWh]"): ("elec_hr_Wh", 1000), + ("AWHP Heating", "AWHP Count (Heating)"): ("awhp_num", 1), + ("AWHP Heating", "AWHP Heating COP"): ("awhp_cop_h", 1), + ("AWHP Heating", "AWHP Heating Cap [kW]"): ("awhp_cap_h_W", 1000), + ("AWHP Heating", "AWHP HHW Output [kW]"): ("awhp_hhw_W", 1000), + ("AWHP Heating", "AWHP Heating Electricity [kWh]"): ("elec_awhp_h_Wh", 1000), + ("Boiler", "Total Gas [kWh]"): ("gas_Wh", 1000), + ("Res. Heat", "Resistance Heater Electricity [kWh]"): ("elec_res_Wh", 1000), + ("AWHP Cooling", "AWHP Cooling COP"): ("awhp_cop_c", 1), + ("AWHP Cooling", "AWHP Cooling Cap [kW]"): ("awhp_cap_c_W", 1000), + ("AWHP Cooling", "AWHP CHW Output [kW]"): ("awhp_chw_W", 1000), + ("AWHP Cooling", "AWHP Cooling Electricity [kWh]"): ("elec_awhp_c_Wh", 1000), + ("Chiller", "Chiller CHW Output [kW]"): ("chiller_chw_W", 1000), + ("Chiller", "Chiller Electricity [kWh]"): ("elec_chiller_Wh", 1000), + ("Chiller", "Total Electricity [kWh]"): ("elec_Wh", 1000), +} +AWHP_COOLING_DEPENDENT = { + "awhp_cap_c_W", "awhp_chw_W", "elec_awhp_c_Wh", "chiller_chw_W", "elec_chiller_Wh", "elec_Wh", +} # fmt: skip +# Genuine, still-open method differences. The test runs these as strict xfails, +# so a code change that resolves one turns the suite red until this list is updated. +KNOWN_DIFFERENCES = { + ("B1S3", 0, "awhp_num"): ( + "AWHP sizing reference capacity at HHWST 42 °C: spreadsheet interpolates the " + "38 and 52 °C tables (176.35 kW at 0 °C); the tool uses the nearest table, 38 °C " + "(176.92 kW), giving 11.098 instead of 11.134 units (-0.3 %). The spreadsheet's " + "other two hours already use the tool's 11.1." + ), +} +KNOWN_DIFFERENCES[("B1S3", 0, "awhp_cap_h_W")] = KNOWN_DIFFERENCES[("B1S3", 0, "awhp_num")] +SUPERSEDED_REASON = ( + "spreadsheet predates the AWHP compressor split: it allows no AWHP cooling in hours " + "where the AWHPs also heat" +) + + +def _read_sheet(wb, name): + """{(section, label): [21 values]} — 7 cases x 3 hours in columns D..X.""" + rows = list(wb[name].iter_rows(values_only=True)) + out, section = {}, None + for r in rows[4:]: + section = r[1] or section + if r[2]: + out[(section, r[2])] = list(r[3:24]) + return out, list(rows[3][3:24]) + + +def _chiller(eq_id, capacity_W, refrigerant_kg): + return { + "eq_id": eq_id, + "eq_type": "chiller", + "eq_calc_type": "specific", + "model": f"Spreadsheet chiller {capacity_W / 1000:.0f} kW", + "fuel": "electricity", + "capacity_W": capacity_W, + "refrigerant": "R-spreadsheet", + "refrigerant_weight_g": refrigerant_kg * 1000, + "refrigerant_gwp": 2088, + "performance": {"cooling": {"efficiency": 2.84}}, + } + + +def _equipment(): + library = {e["eq_id"]: e for e in json.loads(LIBRARY.read_text())["equipment"]} + used = {v for case in CASES.values() for k, v in case.items() if k in + ("hr_wwhp", "awhp", "backup_heating") and v} # fmt: skip + eq = {k: copy.deepcopy(library[k]) for k in sorted(used)} + eq["hr01"]["performance"]["heating"]["leaving_supply_t"]["32.2"]["cop"][0] = 6.7 + eq["hr01"]["refrigerant_weight_g"], eq["hr01"]["refrigerant_gwp"] = 200_000, 466 + eq["hr03"]["refrigerant_weight_g"] = 29_000 + eq["xs_chiller_703kW"] = _chiller("xs_chiller_703kW", 703_200, 90.7) + eq["xs_chiller_352kW"] = _chiller("xs_chiller_352kW", 351_600, 45.4) + return list(eq.values()) + + +def extract() -> dict: + wb = openpyxl.load_workbook(XLSX, data_only=True) + energy, timestamps = _read_sheet(wb, "Energy") + emis, _ = _read_sheet(wb, "Emissions") + scen_rows = {r[1]: r[2:9] for r in wb["Scenarios"].iter_rows(values_only=True) if r[1]} + + cases = [] + for i, (case_id, overrides) in enumerate(CASES.items()): + scenario = { + "eq_scen_id": case_id, + "eq_scen_name": case_id, + **SCENARIO_DEFAULTS, + **overrides, + } + cols = range(3 * i, 3 * i + 3) + awhp_cooling = bool(scenario["awhp"]) and scenario["awhp_use_cooling"] + peak_heat = energy[("AWHP Heating", "Peak HHW load (kW)")][cols[0]] + + hours = [] + for hour_idx, c in enumerate(cols): + awhp_heating_now = (energy[("AWHP Heating", "AWHP HHW Output [kW]")][c] or 0) > 0 + cooling_now = (energy[("Load", "Cooling Load [kW]")][c] or 0) > 0 + # With no cooling load only the reported cooling capacity differs; with a + # cooling load the whole AWHP-cooling / chiller split differs. + superseded_cols = ( + (AWHP_COOLING_DEPENDENT if cooling_now else {"awhp_cap_c_W"}) + if awhp_cooling and awhp_heating_now + else set() + ) + checks, superseded, known = [], [], [] + for (section, label), (column, divisor) in ENERGY_CHECKS.items(): + value = energy.get((section, label), [None] * 21)[c] + if value is None or isinstance(value, str): + continue + if section == "AWHP Cooling" and not awhp_cooling: + continue # not applicable: spreadsheet lists values for absent equipment + entry = {"label": label, "column": column, "divisor": divisor, "value": value} + if (case_id, hour_idx, column) in KNOWN_DIFFERENCES: + known.append( + {**entry, "reason": KNOWN_DIFFERENCES[(case_id, hour_idx, column)]} + ) + elif column in superseded_cols: + superseded.append({**entry, "reason": SUPERSEDED_REASON}) + else: + checks.append(entry) + hours.append( + { + "timestamp": timestamps[c].isoformat(), + "t_out_C": energy[("Load", "Outdoor Temp [°C]")][c], + "heating_kW": energy[("Load", "Heating Load [kW]")][c], + "cooling_kW": energy[("Load", "Cooling Load [kW]")][c], + "checks": checks, + "superseded": superseded, + "known_differences": known, + "elec_rate_g_per_kWh": emis[ + ("Electricity", "Elec Emissions Rate [g CO2e/kWh]") + ][c], + "elec_emissions_kg": emis[("Electricity", "Electricity Emissions [kg CO2e]")][ + c + ], + "gas_emissions_kg": emis[("Gas", "Gas Emissions [kg CO2e]")][c], + } + ) + + j = list(CASES).index(case_id) + cases.append( + { + "id": case_id, + "scenario": scenario, + "peak_heating_kW": peak_heat, + "peak_chiller_kW": emis[("Refrigerant", "Peak chiller output (kW)")][cols[0]], + "emissions": { + "grid_scenario": scen_rows["Grid Scenario"][j], + "gea_grid_region": scen_rows["GEA Grid Region"][j], + "emission_type": scen_rows["Emission Type"][j], + "shortrun_weighting": scen_rows["Short-run weighting"][j], + "year": scen_rows["Year"][j], + "annual_refrig_leakage": emis[("Refrigerant", "Annual leakage")][cols[0]], + "ng_rate_g_per_kWh": emis[("Gas", "Gas emissions rate (g/kWh)")][cols[0]], + "refrig_emissions_kg_per_hour": emis[ + ("Refrigerant", "Refrigerant Emissions [kg CO2e]") + ][cols[0]], + }, + "hours": hours, + } + ) + + return { + "source": "tests/testing_cases.xlsx", + "generated_by": "python -m tests.verification.extract_spreadsheet", + "equipment": _equipment(), + "cases": cases, + } + + +if __name__ == "__main__": + OUT.write_text(json.dumps(extract(), indent=1, default=str) + "\n") + print(f"Wrote {OUT.relative_to(ROOT)}") diff --git a/tests/verification/reference_model.py b/tests/verification/reference_model.py new file mode 100644 index 0000000..e53b7ae --- /dev/null +++ b/tests/verification/reference_model.py @@ -0,0 +1,210 @@ +"""Independent reference implementation of the documented calculation method. + +Written from docs/documentation/calculations.md and equipment.md, NOT from +src/energy.py, and deliberately imports nothing from src/. It reads the raw +equipment JSON, load parquet and Cambium parquet directly with json/pandas. +Agreement between this model and the tool therefore checks that the tool +implements its documented method correctly (an N-version comparison). It does +not validate the method itself against measured or EnergyPlus results. + +Scope (kept narrow to limit maintenance): scenarios without heat recovery, +fixed HHWST at a catalogue supply temperature, integer peak-load sizing, +gas or electric resistance backup, constant-COP chiller, no fuel switching. +That covers eq_scenario_1, eq_scenario_3 and eq_scenario_4. + +Method details the user docs leave implicit, taken from the equipment +specification and listed here so they can be reviewed: + * AWHP heating sizing reference capacity is the catalogue capacity at 0 °C OAT; + cooling reference is at 30 °C OAT. + * Performance tables are interpolated linearly in OAT and held constant + beyond the first/last table point; capacity is zero outside the unit's + operating OAT limits. + * Each AWHP has two compressors (50 % turndown). Compressors needed for + heating in an hour = ceil(2 n * heat_served / heat_capacity); the rest cool. + * Refrigerant charge counts all AWHP units including redundancy, and chiller + units = ceil(peak chiller load / unit capacity) when the chiller runs. + * Cambium rates are averaged to month x hour and mapped onto the load's + month and hour. +""" + +import json +import math +from pathlib import Path + +import numpy as np +import pandas as pd + +DATA = Path(__file__).resolve().parents[2] / "data" / "input" +EQUIPMENT_JSON = DATA / "equipment_data.JSON" +LOAD_PARQUET = DATA / "load_data_full.parquet" +EMISSION_PARQUET = DATA / "emission_data.parquet" + +SUPPORTED_SCENARIOS = ("eq_scenario_1", "eq_scenario_3", "eq_scenario_4") + + +def read_library(path: Path = EQUIPMENT_JSON) -> tuple[dict, dict]: + raw = json.loads(Path(path).read_text()) + equipment = {e["eq_id"]: e for e in raw["equipment"]} + scenarios = {s["eq_scen_id"]: s for s in raw["equipment_scenarios"]} + return equipment, scenarios + + +def read_building_load(building_id: str, source: str = "simulated") -> pd.DataFrame: + df = pd.read_parquet( + LOAD_PARQUET, + columns=["building_id", "source", "timestamp", "t_out_C", "heating_W", "cooling_W"], + filters=[("building_id", "=", building_id), ("source", "=", source)], + ) + df["timestamp"] = pd.to_datetime(df["timestamp"]) + return df.sort_values("timestamp").reset_index(drop=True) + + +def hourly_grid_rate( + timestamps: pd.Series, + source: str, + constant_rate: float | None = None, + grid_scenario: str | None = None, + region: str | None = None, + year: int | None = None, + include_precombustion: bool = True, +) -> np.ndarray: + """Electricity emission rate [gCO2e/kWh] for each load hour.""" + if source == "constant": + return np.full(len(timestamps), float(constant_rate)) + + prefix = {"lrmer": "lrmer_co2e", "srmer": "srmer_co2e", "average": "aer_load_co2e"}[source] + cols = [f"{prefix}_c"] + ([f"{prefix}_p"] if include_precombustion else []) + em = pd.read_parquet( + EMISSION_PARQUET, + columns=["emission_scenario", "gea_grid_region", "year", "month", "hour", *cols], + filters=[ + ("emission_scenario", "=", grid_scenario), + ("gea_grid_region", "=", region), + ("year", "=", year), + ], + ) + em["rate"] = em[cols].sum(axis=1) + month_hour = em.groupby(["month", "hour"])["rate"].mean() + keys = pd.MultiIndex.from_arrays([timestamps.dt.month, timestamps.dt.hour]) + return month_hour.reindex(keys).to_numpy() + + +def _table(perf: dict, supply_t: float) -> tuple[np.ndarray, np.ndarray, np.ndarray, float, float]: + """(t_out, cop, capacity, min_t, max_t) for an exact catalogue supply temperature.""" + match = [k for k in perf["leaving_supply_t"] if math.isclose(float(k), float(supply_t))] + if not match: + raise NotImplementedError( + f"Reference model needs a catalogue supply temperature, got {supply_t}" + ) + curve = perf["leaving_supply_t"][match[0]] + t = np.asarray(perf["t_out_C"], dtype=float) + order = np.argsort(t) + return ( + t[order], + np.asarray(curve["cop"], dtype=float)[order], + np.asarray(curve["capacity_W"], dtype=float)[order], + float(curve["constraints"]["min_temp_C"]), + float(curve["constraints"]["max_temp_C"]), + ) + + +def _lookup(t_tab, values, t_out): + return np.interp(t_out, t_tab, values) # np.interp holds end values constant + + +def _capacity(t_tab, cap_tab, t_min, t_max, t_out): + cap = _lookup(t_tab, cap_tab, t_out) + cap[(t_out < t_min) | (t_out > t_max)] = 0.0 + return cap + + +def run( + load: pd.DataFrame, + scenario: dict, + equipment: dict, + grid_rate_g_per_kWh: np.ndarray, + ng_rate_g_per_kWh: float, + leakage_fraction: float, +) -> dict[str, float]: + """Annual site energy [kWh] and emissions [kgCO2e] for one scenario.""" + if scenario.get("hr_wwhp") or scenario.get("fuel_switching"): + raise NotImplementedError("Reference model excludes heat recovery and fuel switching") + if scenario.get("awhp") and scenario["awhp_sizing_mode"] != "integer_sizing_peak_load": + raise NotImplementedError("Reference model supports integer peak-load sizing only") + + t_out = load["t_out_C"].to_numpy(dtype=float) + heat = load["heating_W"].to_numpy(dtype=float) + cool = load["cooling_W"].to_numpy(dtype=float) + + elec_Wh = np.zeros_like(heat) + gas_Wh = np.zeros_like(heat) + heat_rem = heat.copy() + cool_rem = cool.copy() + refrigerant_kg_co2e = 0.0 # full charge x GWP, before leakage + + # --- AWHP heating --- + if scenario.get("awhp"): + hp = equipment[scenario["awhp"]] + t_h, cop_h_tab, cap_h_tab, h_min, h_max = _table( + hp["performance"]["heating"], scenario["awhp_h_supply_t"] + ) + ref_cap = float(np.interp(0.0, t_h, cap_h_tab)) + units = max(1, math.ceil(heat_rem.max() * scenario["awhp_sizing_value"] / ref_cap)) + + cap_h = units * _capacity(t_h, cap_h_tab, h_min, h_max, t_out) + served_h = np.minimum(heat_rem, cap_h) + elec_Wh += served_h / _lookup(t_h, cop_h_tab, t_out) + heat_rem -= served_h + + total_units = units + scenario["awhp_redundancy"] + refrigerant_kg_co2e += ( + hp["refrigerant_weight_g"] / 1000 * total_units * hp["refrigerant_gwp"] + ) + + # --- backup heating --- + backup = equipment[scenario["backup_heating"]] + if backup["fuel"] == "natural_gas": + gas_Wh += heat_rem / backup["performance"]["heating"]["efficiency"] + else: + elec_Wh += heat_rem # electric resistance, 100 % efficient + heat_rem[:] = 0.0 + + # --- AWHP cooling with the compressors not needed for heating --- + if scenario.get("awhp") and scenario["awhp_use_cooling"]: + cperf = hp["performance"]["cooling"] + chwst = next(iter(cperf["leaving_supply_t"])) + t_c, cop_c_tab, cap_c_tab, c_min, c_max = _table(cperf, float(chwst)) + + compressors = 2 * units + with np.errstate(divide="ignore", invalid="ignore"): + used = np.where(cap_h > 0, np.ceil(compressors * served_h / cap_h), 0.0) + cooling_units = (compressors - used) / 2 + served_c = np.minimum( + cool_rem, cooling_units * _capacity(t_c, cap_c_tab, c_min, c_max, t_out) + ) + elec_Wh += served_c / _lookup(t_c, cop_c_tab, t_out) + cool_rem -= served_c + + # --- chiller --- + chiller = equipment[scenario["chiller"]] + if cool_rem.sum() > 0: + elec_Wh += cool_rem / chiller["performance"]["cooling"]["efficiency"] + if chiller.get("eq_calc_type") == "specific" and chiller.get("refrigerant_weight_g"): + n_chillers = max(1, math.ceil(cool_rem.max() / chiller["capacity_W"])) + refrigerant_kg_co2e += ( + chiller["refrigerant_weight_g"] / 1000 * n_chillers * chiller["refrigerant_gwp"] + ) + + elec_kWh = elec_Wh.sum() / 1000 + gas_kWh = gas_Wh.sum() / 1000 + elec_kg = float((elec_Wh / 1000 * grid_rate_g_per_kWh).sum() / 1000) + gas_kg = gas_kWh * ng_rate_g_per_kWh / 1000 + refrig_kg = refrigerant_kg_co2e * leakage_fraction + return { + "elec_kWh": float(elec_kWh), + "gas_kWh": float(gas_kWh), + "elec_kgCO2e": elec_kg, + "gas_kgCO2e": float(gas_kg), + "refrig_kgCO2e": float(refrig_kg), + "total_kgCO2e": elec_kg + float(gas_kg) + float(refrig_kg), + } diff --git a/tests/verification/report.py b/tests/verification/report.py new file mode 100644 index 0000000..4f190ea --- /dev/null +++ b/tests/verification/report.py @@ -0,0 +1,106 @@ +"""Print a verification report: tool vs independent reference, and spreadsheet agreement. + + python -m tests.verification.report # Markdown to stdout + python -m tests.verification.report --csv out.csv + +Numbers are generated by the same code the verification tests run, so the +report always reflects what the test suite checks. +""" + +import argparse +import logging + +import pandas as pd + +from tests.verification.cases import comparison_table + +LABELS = { + "elec_kWh": "Electricity [MWh]", + "gas_kWh": "Gas [MWh]", + "total_kgCO2e": "Total emissions [t CO2e]", +} +SCENARIO_NAMES = { + "eq_scenario_1": "Gas boiler + chiller", + "eq_scenario_3": "20% AWHP + gas backup", + "eq_scenario_4": "100% AWHP + electric backup", +} + + +def summary_table(full: pd.DataFrame) -> pd.DataFrame: + """Compact table: Cambium long-run grid, key metrics, MWh / t, tool vs reference.""" + t = full[(full["grid"] == "cambium_lrmer_2025") & full["metric"].isin(LABELS)].copy() + t[["tool", "reference"]] = t[["tool", "reference"]] / 1000 # kWh->MWh, kg->t + t["metric"] = t["metric"].map(LABELS) + t["scenario"] = t["scenario"].map(SCENARIO_NAMES) + t["Δ [%]"] = t["rel_diff"] * 100 + return t.rename( + columns={ + "building": "Building", + "scenario": "Scenario", + "metric": "Quantity", + "tool": "Tool", + "reference": "Independent", + } + )[["Building", "Scenario", "Quantity", "Tool", "Independent", "Δ [%]"]] + + +def to_markdown(df: pd.DataFrame) -> str: + """Minimal Markdown table (avoids a tabulate dependency).""" + + def fmt(v): + return f"{v:,.1f}" if isinstance(v, float) else str(v) + + t = df.copy() + t["Δ [%]"] = t["Δ [%]"].map(lambda v: f"{v:+.4f}") + lines = ["| " + " | ".join(t.columns) + " |", "|" + "---|" * len(t.columns)] + lines += ["| " + " | ".join(fmt(v) for v in row) + " |" for row in t.itertuples(index=False)] + return "\n".join(lines) + + +def spreadsheet_summary() -> str: + """Agreement with the legacy hand calculations in tests/testing_cases.xlsx.""" + from tests import test_verification_spreadsheet as sheet + + diffs, superseded, known = [], 0, 0 + for case in sheet.FIXTURE["cases"]: + site = sheet._site(case["id"]) + for hour in case["hours"]: + row = site.loc[pd.Timestamp(hour["timestamp"])] + superseded += len(hour["superseded"]) + known += len(hour["known_differences"]) + for check in hour["checks"]: + tool = row[check["column"]] + tool = 0.0 if pd.isna(tool) else tool / check["divisor"] + if abs(check["value"]) > 1e-9: + diffs.append(abs(tool - check["value"]) / abs(check["value"])) + n_cases = len(sheet.FIXTURE["cases"]) + return ( + f"Spreadsheet hand calculations: {len(diffs)} non-zero hourly values across {n_cases} scenarios " + f"agree within {max(diffs) * 100:.2f} % (median {pd.Series(diffs).median() * 100:.4f} %); " + f"{superseded} values superseded by a later method change; " + f"{known} open method difference(s)." + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--csv", help="also write the full comparison (all metrics/grids) to CSV") + args = parser.parse_args() + + logging.disable(logging.WARNING) + full = comparison_table() + + print(to_markdown(summary_table(full))) + worst = full.loc[full["rel_diff"].abs().idxmax()] + print( + f"\n{len(full)} comparisons; largest |Δ| = {abs(worst['rel_diff']) * 100:.2e} % " + f"({worst['building']}/{worst['scenario']}/{worst['grid']}/{worst['metric']})" + ) + print(spreadsheet_summary()) + if args.csv: + full.to_csv(args.csv, index=False) + print(f"Full comparison written to {args.csv}") + + +if __name__ == "__main__": + main()