From 06219a4a1b69928e7318441f1f9159db5fb94acf Mon Sep 17 00:00:00 2001 From: enoch85 Date: Wed, 15 Jul 2026 20:12:53 +0000 Subject: [PATCH] The instrument: a datasheet-sourced simulator that can fail The harness drives the real decision layers, the real GE-Spot adapter on committed real SE4 prices, and the real billing accumulator - break production billing and --dst fails. COP comes from an exergy-efficiency fit on each machine's own EN 14511 points (inside OpenEnergyMonitor's measured 45-55%-of-Carnot band); capacity from the published envelope; houses sized from each pump's declared Pdesignh; the elpatron fires at each pump's own factory start-addition; the tariff figure applies Ellevio's one-peak-per-day rule and night half-weighting. Every physical constant is SOURCED or ASSUMED with measured sensitivity, and a test enforces it. Verdicts at this commit: nominal, --selftest and --dst PASS all five houses; --coldsnap and --undersized FAIL on the aux-over-physics bound - the saturation defect (F-124) is still detected, at its honest size. Also: the hardcoded-values ratchet scanner, the week-long live-HA watcher, and the removal of two dead demo scripts. --- scripts/demo_dhw_day_boundary_fix.py | 1 + scripts/find_duplicate_constants.py | 127 +- scripts/run_all_tests.sh | 6 +- scripts/simulation/data/gespot_live_se4.json | 973 ++++++++ scripts/simulation/nibe_modbus_simulator.py | 26 +- ...mary-concrete_f1155-selftest-baseline.json | 27 - .../summary-concrete_f1155-selftest.json | 27 - ...summary-wooden_f750-selftest-baseline.json | 27 - .../output/summary-wooden_f750-selftest.json | 27 - ...race-concrete_f1155-selftest-baseline.json | 1 - .../output/trace-concrete_f1155-selftest.json | 1 - .../trace-wooden_f750-selftest-baseline.json | 1 - .../output/trace-wooden_f750-selftest.json | 1 - scripts/simulation/sim_harness.py | 1970 ++++++++++++++--- scripts/start_week.sh | 75 + scripts/test_decision_scenarios.py | 18 +- scripts/test_seasonal_defaults.py | 3 +- scripts/visualize_price_optimization.py | 235 +- scripts/week_watch.sh | 175 ++ 19 files changed, 3196 insertions(+), 525 deletions(-) create mode 100644 scripts/simulation/data/gespot_live_se4.json delete mode 100644 scripts/simulation/output/summary-concrete_f1155-selftest-baseline.json delete mode 100644 scripts/simulation/output/summary-concrete_f1155-selftest.json delete mode 100644 scripts/simulation/output/summary-wooden_f750-selftest-baseline.json delete mode 100644 scripts/simulation/output/summary-wooden_f750-selftest.json delete mode 100644 scripts/simulation/output/trace-concrete_f1155-selftest-baseline.json delete mode 100644 scripts/simulation/output/trace-concrete_f1155-selftest.json delete mode 100644 scripts/simulation/output/trace-wooden_f750-selftest-baseline.json delete mode 100644 scripts/simulation/output/trace-wooden_f750-selftest.json create mode 100755 scripts/start_week.sh create mode 100755 scripts/week_watch.sh diff --git a/scripts/demo_dhw_day_boundary_fix.py b/scripts/demo_dhw_day_boundary_fix.py index 083cb743..d000bf7b 100644 --- a/scripts/demo_dhw_day_boundary_fix.py +++ b/scripts/demo_dhw_day_boundary_fix.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Test script to verify DHW day boundary fix.""" + from datetime import datetime, timedelta from custom_components.effektguard.const import DHW_NORMAL_RUNTIME_MINUTES from custom_components.effektguard.optimization.dhw_optimizer import IntelligentDHWScheduler diff --git a/scripts/find_duplicate_constants.py b/scripts/find_duplicate_constants.py index bfe08c27..232c9a2a 100755 --- a/scripts/find_duplicate_constants.py +++ b/scripts/find_duplicate_constants.py @@ -26,24 +26,24 @@ def get_project_root() -> Path: def parse_constants(const_file: Path) -> dict[str, tuple[any, int]]: """Parse const.py and extract all constant definitions. - + Returns dict of {constant_name: (value, line_number)} """ constants = {} - + with open(const_file, "r") as f: content = f.read() lines = content.split("\n") - + # Pattern for constants: NAME: Final = value pattern = re.compile(r"^([A-Z][A-Z0-9_]*)\s*:\s*Final\s*=\s*(.+?)(?:\s*#.*)?$") - + for line_num, line in enumerate(lines, 1): match = pattern.match(line.strip()) if match: name = match.group(1) value_str = match.group(2).strip() - + # Try to evaluate the value try: # Handle references to other constants @@ -54,21 +54,21 @@ def parse_constants(const_file: Path) -> dict[str, tuple[any, int]]: except (ValueError, SyntaxError): # Keep as string if can't evaluate value = value_str - + constants[name] = (value, line_num) - + return constants def find_duplicate_values(constants: dict[str, tuple[any, int]]) -> dict[any, list[str]]: """Find constants with identical values.""" value_to_names = defaultdict(list) - + for name, (value, _) in constants.items(): # Only check numeric values (most likely to be duplicated) if isinstance(value, (int, float)): value_to_names[value].append(name) - + # Filter to only duplicates return {v: names for v, names in value_to_names.items() if len(names) > 1} @@ -76,21 +76,21 @@ def find_duplicate_values(constants: dict[str, tuple[any, int]]) -> dict[any, li def find_similar_names(constants: dict[str, tuple[any, int]]) -> list[tuple[str, str, float]]: """Find constants with similar names that might be duplicates.""" from difflib import SequenceMatcher - + similar = [] names = list(constants.keys()) - + for i, name1 in enumerate(names): - for name2 in names[i+1:]: + for name2 in names[i + 1 :]: # Skip if same prefix group (e.g., PROACTIVE_ZONE1 vs PROACTIVE_ZONE2) # These are intentionally different if _same_prefix_group(name1, name2): continue - + ratio = SequenceMatcher(None, name1, name2).ratio() if ratio > 0.7: # 70% similar similar.append((name1, name2, ratio)) - + return sorted(similar, key=lambda x: -x[2]) # Sort by similarity @@ -100,34 +100,32 @@ def _same_prefix_group(name1: str, name2: str) -> bool: pattern = re.compile(r"^(.+?)(\d+)(.*)$") m1 = pattern.match(name1) m2 = pattern.match(name2) - + if m1 and m2: # Same prefix and suffix, different number if m1.group(1) == m2.group(1) and m1.group(3) == m2.group(3): return True - + return False def find_unused_constants( - constants: dict[str, tuple[any, int]], - project_root: Path, - include_test_usage: bool = True + constants: dict[str, tuple[any, int]], project_root: Path, include_test_usage: bool = True ) -> list[tuple[str, int]]: """Find constants that are never imported in production code. - + Args: constants: Dict of constant names to (value, line_number) project_root: Project root path include_test_usage: If True, also check tests/scripts for usage """ unused = [] - + # Get all Python files in production code (not tests, not scripts) prod_files = list((project_root / "custom_components" / "effektguard").rglob("*.py")) const_file = project_root / "custom_components" / "effektguard" / "const.py" prod_files = [f for f in prod_files if f != const_file] - + # Read all production code all_code = "" for file in prod_files: @@ -137,7 +135,7 @@ def find_unused_constants( except Exception: # Skip files that can't be read (permissions, encoding issues) pass - + # Optionally include tests and scripts if include_test_usage: test_files = list((project_root / "tests").rglob("*.py")) @@ -149,7 +147,7 @@ def find_unused_constants( except Exception: # Skip files that can't be read (permissions, encoding issues) pass - + # Read const.py to check for building block usage (constants used to derive others) const_code = "" try: @@ -158,7 +156,7 @@ def find_unused_constants( except Exception: # Skip if const.py can't be read pass - + # Check each constant for name, (_, line_num) in constants.items(): # Skip configuration keys (CONF_*) - these are used dynamically @@ -176,44 +174,44 @@ def find_unused_constants( # Skip domain - always used if name == "DOMAIN": continue - + pattern = re.compile(rf"\b{re.escape(name)}\b") - + # Check if used in production/test code if pattern.search(all_code): continue - + # Check if used as building block in const.py (more than just its definition) # Count occurrences - if > 1, it's used somewhere else in const.py matches = list(pattern.finditer(const_code)) if len(matches) > 1: continue # Used as building block - + unused.append((name, line_num)) - + return sorted(unused, key=lambda x: x[1]) # Sort by line number def find_unused_imports(project_root: Path) -> list[tuple[Path, str, int]]: """Find unused imports across all Python files using ruff. - + Returns list of (file_path, message, line_number) tuples. """ import subprocess - + unused_imports = [] - + # Check production code and tests dirs_to_check = [ project_root / "custom_components" / "effektguard", project_root / "tests", project_root / "scripts", ] - + for check_dir in dirs_to_check: if not check_dir.exists(): continue - + try: result = subprocess.run( ["ruff", "check", str(check_dir), "--select", "F401", "--output-format", "text"], @@ -221,7 +219,7 @@ def find_unused_imports(project_root: Path) -> list[tuple[Path, str, int]]: text=True, cwd=project_root, ) - + # Parse ruff output: file:line:col: F401 message for line in result.stdout.strip().split("\n"): if not line or "F401" not in line: @@ -238,19 +236,19 @@ def find_unused_imports(project_root: Path) -> list[tuple[Path, str, int]]: pass except Exception: pass - + return unused_imports def find_semantic_duplicates(constants: dict[str, tuple[any, int]]) -> list[tuple[str, str, str]]: """Find constants that might be semantically equivalent. - + Looks for patterns like: - EFFECT_MARGIN_WARNING vs EFFECT_PEAK_MARGIN_WARNING - FOO_THRESHOLD vs FOO_LIMIT """ duplicates = [] - + # Group by base name patterns patterns = [ (r"_THRESHOLD$", r"_LIMIT$"), @@ -258,9 +256,9 @@ def find_semantic_duplicates(constants: dict[str, tuple[any, int]]) -> list[tupl (r"_MIN$", r"_MINIMUM$"), (r"^EFFECT_", r"^EFFECT_PEAK_"), ] - + names = list(constants.keys()) - + for name1 in names: for pattern1, pattern2 in patterns: if re.search(pattern1, name1): @@ -277,36 +275,41 @@ def find_semantic_duplicates(constants: dict[str, tuple[any, int]]) -> list[tupl duplicates.append((name1, name2, f"Same value: {val1}")) elif isinstance(val1, (int, float)) and isinstance(val2, (int, float)): if abs(val1 - val2) < 0.1: # Very similar values - duplicates.append((name1, name2, f"Similar values: {val1} vs {val2}")) - + duplicates.append( + (name1, name2, f"Similar values: {val1} vs {val2}") + ) + return duplicates def main(): parser = argparse.ArgumentParser(description="Find duplicate and unused constants") - parser.add_argument("--remove-unused", action="store_true", - help="Show commands to remove unused constants") - parser.add_argument("--verbose", "-v", action="store_true", - help="Show detailed output") - parser.add_argument("--prod-only", action="store_true", - help="Only check production code (exclude tests/scripts)") + parser.add_argument( + "--remove-unused", action="store_true", help="Show commands to remove unused constants" + ) + parser.add_argument("--verbose", "-v", action="store_true", help="Show detailed output") + parser.add_argument( + "--prod-only", + action="store_true", + help="Only check production code (exclude tests/scripts)", + ) args = parser.parse_args() - + project_root = get_project_root() const_file = project_root / "custom_components" / "effektguard" / "const.py" - + if not const_file.exists(): print(f"Error: {const_file} not found") sys.exit(1) - + print("=" * 70) print("CONSTANT ANALYSIS REPORT") print("=" * 70) - + # Parse constants constants = parse_constants(const_file) print(f"\nTotal constants defined: {len(constants)}") - + # 1. Find duplicate values print("\n" + "-" * 70) print("1. DUPLICATE VALUES (same number, different names)") @@ -325,7 +328,7 @@ def main(): print(f" - {name} (line {line})") else: print(" No non-trivial duplicate values found.") - + # 2. Find similar names print("\n" + "-" * 70) print("2. SIMILAR NAMES (>70% string similarity)") @@ -341,7 +344,7 @@ def main(): print(f" Values: {val1} vs {val2}") else: print(" No highly similar names found.") - + # 3. Find semantic duplicates print("\n" + "-" * 70) print("3. SEMANTIC DUPLICATES (pattern matching)") @@ -353,7 +356,7 @@ def main(): print(f" Reason: {reason}") else: print(" No semantic duplicates found.") - + # 4. Find unused constants print("\n" + "-" * 70) print("4. UNUSED CONSTANTS (not imported in production code)") @@ -366,14 +369,14 @@ def main(): for name, line in unused: value = constants[name][0] print(f" Line {line:4d}: {name} = {value}") - + if args.remove_unused: print("\n To remove these, delete the following lines from const.py:") for name, line in unused: print(f" Line {line}: {name}") else: print(" All constants are used!") - + # 5. Find unused imports across all files print("\n" + "-" * 70) print("5. UNUSED IMPORTS (imported but never used in file)") @@ -387,7 +390,7 @@ def main(): print("\n Fix with: ruff check --select F401 --fix .") else: print(" All imports are used!") - + # Summary print("\n" + "=" * 70) print("SUMMARY") @@ -398,7 +401,7 @@ def main(): print(f" Semantic duplicates: {len(semantic)}") print(f" Unused constants: {len(unused)}") print(f" Unused imports: {len(unused_imports)}") - + has_issues = unused or unused_imports if has_issues: if unused: @@ -406,7 +409,7 @@ def main(): if unused_imports: print(f" ⚠️ {len(unused_imports)} unused imports found!") return 1 - + return 0 diff --git a/scripts/run_all_tests.sh b/scripts/run_all_tests.sh index 9a4d7f5a..e06c963e 100755 --- a/scripts/run_all_tests.sh +++ b/scripts/run_all_tests.sh @@ -122,9 +122,9 @@ if command -v black &> /dev/null; then if black custom_components/effektguard/ --check --line-length 100 &> /dev/null; then echo -e "${GREEN}✓ Black formatting: PASS${NC}" else - echo -e "${YELLOW}⚠ Black formatting issues detected. Running black...${NC}" - black custom_components/effektguard/ --line-length 100 - echo -e "${GREEN}✓ Black formatting: FIXED${NC}" + # A gate that silently rewrites the tree and reports success is not a gate. + echo -e "${RED}✗ Black formatting: FAIL - run: black custom_components/effektguard/ --line-length 100${NC}" + exit 1 fi else echo -e "${YELLOW}⚠ Black not installed, skipping formatting check${NC}" diff --git a/scripts/simulation/data/gespot_live_se4.json b/scripts/simulation/data/gespot_live_se4.json new file mode 100644 index 00000000..f6a98507 --- /dev/null +++ b/scripts/simulation/data/gespot_live_se4.json @@ -0,0 +1,973 @@ +{ + "captured_from": "sensor.gespot_current_price_se4", + "attributes": { + "unit_of_measurement": "öre/kWh", + "currency": "SEK", + "area": "SE4", + "today_interval_prices": [ + { + "time": "2026-07-12T00:00:00+02:00", + "value": 152.9584, + "raw_value": 152.9584 + }, + { + "time": "2026-07-12T00:15:00+02:00", + "value": 152.4076, + "raw_value": 152.4076 + }, + { + "time": "2026-07-12T00:30:00+02:00", + "value": 153.476, + "raw_value": 153.476 + }, + { + "time": "2026-07-12T00:45:00+02:00", + "value": 144.334, + "raw_value": 144.334 + }, + { + "time": "2026-07-12T01:00:00+02:00", + "value": 149.4998, + "raw_value": 149.4998 + }, + { + "time": "2026-07-12T01:15:00+02:00", + "value": 148.9931, + "raw_value": 148.9931 + }, + { + "time": "2026-07-12T01:30:00+02:00", + "value": 144.6865, + "raw_value": 144.6865 + }, + { + "time": "2026-07-12T01:45:00+02:00", + "value": 138.8047, + "raw_value": 138.8047 + }, + { + "time": "2026-07-12T02:00:00+02:00", + "value": 141.4702, + "raw_value": 141.4702 + }, + { + "time": "2026-07-12T02:15:00+02:00", + "value": 137.4169, + "raw_value": 137.4169 + }, + { + "time": "2026-07-12T02:30:00+02:00", + "value": 135.8308, + "raw_value": 135.8308 + }, + { + "time": "2026-07-12T02:45:00+02:00", + "value": 134.421, + "raw_value": 134.421 + }, + { + "time": "2026-07-12T03:00:00+02:00", + "value": 141.4592, + "raw_value": 141.4592 + }, + { + "time": "2026-07-12T03:15:00+02:00", + "value": 139.4326, + "raw_value": 139.4326 + }, + { + "time": "2026-07-12T03:30:00+02:00", + "value": 133.694, + "raw_value": 133.694 + }, + { + "time": "2026-07-12T03:45:00+02:00", + "value": 132.4383, + "raw_value": 132.4383 + }, + { + "time": "2026-07-12T04:00:00+02:00", + "value": 139.2673, + "raw_value": 139.2673 + }, + { + "time": "2026-07-12T04:15:00+02:00", + "value": 134.1346, + "raw_value": 134.1346 + }, + { + "time": "2026-07-12T04:30:00+02:00", + "value": 131.8326, + "raw_value": 131.8326 + }, + { + "time": "2026-07-12T04:45:00+02:00", + "value": 130.8302, + "raw_value": 130.8302 + }, + { + "time": "2026-07-12T05:00:00+02:00", + "value": 134.9497, + "raw_value": 134.9497 + }, + { + "time": "2026-07-12T05:15:00+02:00", + "value": 132.967, + "raw_value": 132.967 + }, + { + "time": "2026-07-12T05:30:00+02:00", + "value": 131.1937, + "raw_value": 131.1937 + }, + { + "time": "2026-07-12T05:45:00+02:00", + "value": 122.4702, + "raw_value": 122.4702 + }, + { + "time": "2026-07-12T06:00:00+02:00", + "value": 130.0592, + "raw_value": 130.0592 + }, + { + "time": "2026-07-12T06:15:00+02:00", + "value": 123.3073, + "raw_value": 123.3073 + }, + { + "time": "2026-07-12T06:30:00+02:00", + "value": 120.774, + "raw_value": 120.774 + }, + { + "time": "2026-07-12T06:45:00+02:00", + "value": 115.9607, + "raw_value": 115.9607 + }, + { + "time": "2026-07-12T07:00:00+02:00", + "value": 123.0981, + "raw_value": 123.0981 + }, + { + "time": "2026-07-12T07:15:00+02:00", + "value": 118.9566, + "raw_value": 118.9566 + }, + { + "time": "2026-07-12T07:30:00+02:00", + "value": 111.5989, + "raw_value": 111.5989 + }, + { + "time": "2026-07-12T07:45:00+02:00", + "value": 85.1972, + "raw_value": 85.1972 + }, + { + "time": "2026-07-12T08:00:00+02:00", + "value": 113.3282, + "raw_value": 113.3282 + }, + { + "time": "2026-07-12T08:15:00+02:00", + "value": 87.4551, + "raw_value": 87.4551 + }, + { + "time": "2026-07-12T08:30:00+02:00", + "value": 54.5879, + "raw_value": 54.5879 + }, + { + "time": "2026-07-12T08:45:00+02:00", + "value": 13.5478, + "raw_value": 13.5478 + }, + { + "time": "2026-07-12T09:00:00+02:00", + "value": 18.5374, + "raw_value": 18.5374 + }, + { + "time": "2026-07-12T09:15:00+02:00", + "value": 5.287, + "raw_value": 5.287 + }, + { + "time": "2026-07-12T09:30:00+02:00", + "value": 4.2186, + "raw_value": 4.2186 + }, + { + "time": "2026-07-12T09:45:00+02:00", + "value": 3.9101, + "raw_value": 3.9101 + }, + { + "time": "2026-07-12T10:00:00+02:00", + "value": 5.6284, + "raw_value": 5.6284 + }, + { + "time": "2026-07-12T10:15:00+02:00", + "value": 5.309, + "raw_value": 5.309 + }, + { + "time": "2026-07-12T10:30:00+02:00", + "value": 4.5159, + "raw_value": 4.5159 + }, + { + "time": "2026-07-12T10:45:00+02:00", + "value": 4.2296, + "raw_value": 4.2296 + }, + { + "time": "2026-07-12T11:00:00+02:00", + "value": 9.2081, + "raw_value": 9.2081 + }, + { + "time": "2026-07-12T11:15:00+02:00", + "value": 8.1177, + "raw_value": 8.1177 + }, + { + "time": "2026-07-12T11:30:00+02:00", + "value": 5.8817, + "raw_value": 5.8817 + }, + { + "time": "2026-07-12T11:45:00+02:00", + "value": 5.8707, + "raw_value": 5.8707 + }, + { + "time": "2026-07-12T12:00:00+02:00", + "value": 6.4655, + "raw_value": 6.4655 + }, + { + "time": "2026-07-12T12:15:00+02:00", + "value": 7.3357, + "raw_value": 7.3357 + }, + { + "time": "2026-07-12T12:30:00+02:00", + "value": 5.8267, + "raw_value": 5.8267 + }, + { + "time": "2026-07-12T12:45:00+02:00", + "value": 7.3246, + "raw_value": 7.3246 + }, + { + "time": "2026-07-12T13:00:00+02:00", + "value": 8.2719, + "raw_value": 8.2719 + }, + { + "time": "2026-07-12T13:15:00+02:00", + "value": 7.3467, + "raw_value": 7.3467 + }, + { + "time": "2026-07-12T13:30:00+02:00", + "value": 8.7896, + "raw_value": 8.7896 + }, + { + "time": "2026-07-12T13:45:00+02:00", + "value": 10.0893, + "raw_value": 10.0893 + }, + { + "time": "2026-07-12T14:00:00+02:00", + "value": 10.7171, + "raw_value": 10.7171 + }, + { + "time": "2026-07-12T14:15:00+02:00", + "value": 11.6093, + "raw_value": 11.6093 + }, + { + "time": "2026-07-12T14:30:00+02:00", + "value": 11.8516, + "raw_value": 11.8516 + }, + { + "time": "2026-07-12T14:45:00+02:00", + "value": 13.702, + "raw_value": 13.702 + }, + { + "time": "2026-07-12T15:00:00+02:00", + "value": 11.411, + "raw_value": 11.411 + }, + { + "time": "2026-07-12T15:15:00+02:00", + "value": 12.6887, + "raw_value": 12.6887 + }, + { + "time": "2026-07-12T15:30:00+02:00", + "value": 13.7241, + "raw_value": 13.7241 + }, + { + "time": "2026-07-12T15:45:00+02:00", + "value": 14.8475, + "raw_value": 14.8475 + }, + { + "time": "2026-07-12T16:00:00+02:00", + "value": 13.4817, + "raw_value": 13.4817 + }, + { + "time": "2026-07-12T16:15:00+02:00", + "value": 13.7902, + "raw_value": 13.7902 + }, + { + "time": "2026-07-12T16:30:00+02:00", + "value": 14.6273, + "raw_value": 14.6273 + }, + { + "time": "2026-07-12T16:45:00+02:00", + "value": 15.3762, + "raw_value": 15.3762 + }, + { + "time": "2026-07-12T17:00:00+02:00", + "value": 14.1206, + "raw_value": 14.1206 + }, + { + "time": "2026-07-12T17:15:00+02:00", + "value": 17.5791, + "raw_value": 17.5791 + }, + { + "time": "2026-07-12T17:30:00+02:00", + "value": 21.1589, + "raw_value": 21.1589 + }, + { + "time": "2026-07-12T17:45:00+02:00", + "value": 63.4325, + "raw_value": 63.4325 + }, + { + "time": "2026-07-12T18:00:00+02:00", + "value": 36.0064, + "raw_value": 36.0064 + }, + { + "time": "2026-07-12T18:15:00+02:00", + "value": 111.4337, + "raw_value": 111.4337 + }, + { + "time": "2026-07-12T18:30:00+02:00", + "value": 126.1491, + "raw_value": 126.1491 + }, + { + "time": "2026-07-12T18:45:00+02:00", + "value": 131.5021, + "raw_value": 131.5021 + }, + { + "time": "2026-07-12T19:00:00+02:00", + "value": 132.4714, + "raw_value": 132.4714 + }, + { + "time": "2026-07-12T19:15:00+02:00", + "value": 144.6865, + "raw_value": 144.6865 + }, + { + "time": "2026-07-12T19:30:00+02:00", + "value": 149.4998, + "raw_value": 149.4998 + }, + { + "time": "2026-07-12T19:45:00+02:00", + "value": 150.4581, + "raw_value": 150.4581 + }, + { + "time": "2026-07-12T20:00:00+02:00", + "value": 149.7091, + "raw_value": 149.7091 + }, + { + "time": "2026-07-12T20:15:00+02:00", + "value": 153.0685, + "raw_value": 153.0685 + }, + { + "time": "2026-07-12T20:30:00+02:00", + "value": 154.0708, + "raw_value": 154.0708 + }, + { + "time": "2026-07-12T20:45:00+02:00", + "value": 154.17, + "raw_value": 154.17 + }, + { + "time": "2026-07-12T21:00:00+02:00", + "value": 152.1763, + "raw_value": 152.1763 + }, + { + "time": "2026-07-12T21:15:00+02:00", + "value": 155.1943, + "raw_value": 155.1943 + }, + { + "time": "2026-07-12T21:30:00+02:00", + "value": 154.7647, + "raw_value": 154.7647 + }, + { + "time": "2026-07-12T21:45:00+02:00", + "value": 154.4343, + "raw_value": 154.4343 + }, + { + "time": "2026-07-12T22:00:00+02:00", + "value": 159.9526, + "raw_value": 159.9526 + }, + { + "time": "2026-07-12T22:15:00+02:00", + "value": 156.6482, + "raw_value": 156.6482 + }, + { + "time": "2026-07-12T22:30:00+02:00", + "value": 155.7781, + "raw_value": 155.7781 + }, + { + "time": "2026-07-12T22:45:00+02:00", + "value": 153.2447, + "raw_value": 153.2447 + }, + { + "time": "2026-07-12T23:00:00+02:00", + "value": 153.0024, + "raw_value": 153.0024 + }, + { + "time": "2026-07-12T23:15:00+02:00", + "value": 151.8789, + "raw_value": 151.8789 + }, + { + "time": "2026-07-12T23:30:00+02:00", + "value": 151.4604, + "raw_value": 151.4604 + }, + { + "time": "2026-07-12T23:45:00+02:00", + "value": 150.0946, + "raw_value": 150.0946 + } + ], + "tomorrow_interval_prices": [ + { + "time": "2026-07-13T00:00:00+02:00", + "value": 145.7439, + "raw_value": 145.7439 + }, + { + "time": "2026-07-13T00:15:00+02:00", + "value": 143.3868, + "raw_value": 143.3868 + }, + { + "time": "2026-07-13T00:30:00+02:00", + "value": 142.5717, + "raw_value": 142.5717 + }, + { + "time": "2026-07-13T00:45:00+02:00", + "value": 140.545, + "raw_value": 140.545 + }, + { + "time": "2026-07-13T01:00:00+02:00", + "value": 140.567, + "raw_value": 140.567 + }, + { + "time": "2026-07-13T01:15:00+02:00", + "value": 136.1172, + "raw_value": 136.1172 + }, + { + "time": "2026-07-13T01:30:00+02:00", + "value": 135.5995, + "raw_value": 135.5995 + }, + { + "time": "2026-07-13T01:45:00+02:00", + "value": 133.2424, + "raw_value": 133.2424 + }, + { + "time": "2026-07-13T02:00:00+02:00", + "value": 134.7624, + "raw_value": 134.7624 + }, + { + "time": "2026-07-13T02:15:00+02:00", + "value": 131.436, + "raw_value": 131.436 + }, + { + "time": "2026-07-13T02:30:00+02:00", + "value": 132.2291, + "raw_value": 132.2291 + }, + { + "time": "2026-07-13T02:45:00+02:00", + "value": 132.163, + "raw_value": 132.163 + }, + { + "time": "2026-07-13T03:00:00+02:00", + "value": 132.3943, + "raw_value": 132.3943 + }, + { + "time": "2026-07-13T03:15:00+02:00", + "value": 132.7357, + "raw_value": 132.7357 + }, + { + "time": "2026-07-13T03:30:00+02:00", + "value": 134.2888, + "raw_value": 134.2888 + }, + { + "time": "2026-07-13T03:45:00+02:00", + "value": 135.9079, + "raw_value": 135.9079 + }, + { + "time": "2026-07-13T04:00:00+02:00", + "value": 130.9844, + "raw_value": 130.9844 + }, + { + "time": "2026-07-13T04:15:00+02:00", + "value": 134.465, + "raw_value": 134.465 + }, + { + "time": "2026-07-13T04:30:00+02:00", + "value": 137.6041, + "raw_value": 137.6041 + }, + { + "time": "2026-07-13T04:45:00+02:00", + "value": 139.3004, + "raw_value": 139.3004 + }, + { + "time": "2026-07-13T05:00:00+02:00", + "value": 139.2233, + "raw_value": 139.2233 + }, + { + "time": "2026-07-13T05:15:00+02:00", + "value": 143.3207, + "raw_value": 143.3207 + }, + { + "time": "2026-07-13T05:30:00+02:00", + "value": 142.2743, + "raw_value": 142.2743 + }, + { + "time": "2026-07-13T05:45:00+02:00", + "value": 142.9131, + "raw_value": 142.9131 + }, + { + "time": "2026-07-13T06:00:00+02:00", + "value": 137.6041, + "raw_value": 137.6041 + }, + { + "time": "2026-07-13T06:15:00+02:00", + "value": 139.1902, + "raw_value": 139.1902 + }, + { + "time": "2026-07-13T06:30:00+02:00", + "value": 145.5456, + "raw_value": 145.5456 + }, + { + "time": "2026-07-13T06:45:00+02:00", + "value": 142.9131, + "raw_value": 142.9131 + }, + { + "time": "2026-07-13T07:00:00+02:00", + "value": 151.4714, + "raw_value": 151.4714 + }, + { + "time": "2026-07-13T07:15:00+02:00", + "value": 148.4424, + "raw_value": 148.4424 + }, + { + "time": "2026-07-13T07:30:00+02:00", + "value": 127.9554, + "raw_value": 127.9554 + }, + { + "time": "2026-07-13T07:45:00+02:00", + "value": 134.6633, + "raw_value": 134.6633 + }, + { + "time": "2026-07-13T08:00:00+02:00", + "value": 152.2975, + "raw_value": 152.2975 + }, + { + "time": "2026-07-13T08:15:00+02:00", + "value": 143.4859, + "raw_value": 143.4859 + }, + { + "time": "2026-07-13T08:30:00+02:00", + "value": 124.2876, + "raw_value": 124.2876 + }, + { + "time": "2026-07-13T08:45:00+02:00", + "value": 117.6569, + "raw_value": 117.6569 + }, + { + "time": "2026-07-13T09:00:00+02:00", + "value": 134.0024, + "raw_value": 134.0024 + }, + { + "time": "2026-07-13T09:15:00+02:00", + "value": 136.7781, + "raw_value": 136.7781 + }, + { + "time": "2026-07-13T09:30:00+02:00", + "value": 125.0146, + "raw_value": 125.0146 + }, + { + "time": "2026-07-13T09:45:00+02:00", + "value": 115.1786, + "raw_value": 115.1786 + }, + { + "time": "2026-07-13T10:00:00+02:00", + "value": 131.5021, + "raw_value": 131.5021 + }, + { + "time": "2026-07-13T10:15:00+02:00", + "value": 116.1589, + "raw_value": 116.1589 + }, + { + "time": "2026-07-13T10:30:00+02:00", + "value": 105.552, + "raw_value": 105.552 + }, + { + "time": "2026-07-13T10:45:00+02:00", + "value": 74.3809, + "raw_value": 74.3809 + }, + { + "time": "2026-07-13T11:00:00+02:00", + "value": 68.9177, + "raw_value": 68.9177 + }, + { + "time": "2026-07-13T11:15:00+02:00", + "value": 69.7879, + "raw_value": 69.7879 + }, + { + "time": "2026-07-13T11:30:00+02:00", + "value": 69.865, + "raw_value": 69.865 + }, + { + "time": "2026-07-13T11:45:00+02:00", + "value": 65.36, + "raw_value": 65.36 + }, + { + "time": "2026-07-13T12:00:00+02:00", + "value": 70.1183, + "raw_value": 70.1183 + }, + { + "time": "2026-07-13T12:15:00+02:00", + "value": 57.4406, + "raw_value": 57.4406 + }, + { + "time": "2026-07-13T12:30:00+02:00", + "value": 58.3438, + "raw_value": 58.3438 + }, + { + "time": "2026-07-13T12:45:00+02:00", + "value": 64.7212, + "raw_value": 64.7212 + }, + { + "time": "2026-07-13T13:00:00+02:00", + "value": 62.4412, + "raw_value": 62.4412 + }, + { + "time": "2026-07-13T13:15:00+02:00", + "value": 64.1044, + "raw_value": 64.1044 + }, + { + "time": "2026-07-13T13:30:00+02:00", + "value": 56.5705, + "raw_value": 56.5705 + }, + { + "time": "2026-07-13T13:45:00+02:00", + "value": 61.3728, + "raw_value": 61.3728 + }, + { + "time": "2026-07-13T14:00:00+02:00", + "value": 64.545, + "raw_value": 64.545 + }, + { + "time": "2026-07-13T14:15:00+02:00", + "value": 65.349, + "raw_value": 65.349 + }, + { + "time": "2026-07-13T14:30:00+02:00", + "value": 65.9108, + "raw_value": 65.9108 + }, + { + "time": "2026-07-13T14:45:00+02:00", + "value": 66.4615, + "raw_value": 66.4615 + }, + { + "time": "2026-07-13T15:00:00+02:00", + "value": 62.8928, + "raw_value": 62.8928 + }, + { + "time": "2026-07-13T15:15:00+02:00", + "value": 64.6331, + "raw_value": 64.6331 + }, + { + "time": "2026-07-13T15:30:00+02:00", + "value": 74.8986, + "raw_value": 74.8986 + }, + { + "time": "2026-07-13T15:45:00+02:00", + "value": 92.709, + "raw_value": 92.709 + }, + { + "time": "2026-07-13T16:00:00+02:00", + "value": 77.4209, + "raw_value": 77.4209 + }, + { + "time": "2026-07-13T16:15:00+02:00", + "value": 84.6685, + "raw_value": 84.6685 + }, + { + "time": "2026-07-13T16:30:00+02:00", + "value": 92.9293, + "raw_value": 92.9293 + }, + { + "time": "2026-07-13T16:45:00+02:00", + "value": 99.6812, + "raw_value": 99.6812 + }, + { + "time": "2026-07-13T17:00:00+02:00", + "value": 117.6459, + "raw_value": 117.6459 + }, + { + "time": "2026-07-13T17:15:00+02:00", + "value": 133.9473, + "raw_value": 133.9473 + }, + { + "time": "2026-07-13T17:30:00+02:00", + "value": 147.6604, + "raw_value": 147.6604 + }, + { + "time": "2026-07-13T17:45:00+02:00", + "value": 151.7137, + "raw_value": 151.7137 + }, + { + "time": "2026-07-13T18:00:00+02:00", + "value": 145.7328, + "raw_value": 145.7328 + }, + { + "time": "2026-07-13T18:15:00+02:00", + "value": 157.2871, + "raw_value": 157.2871 + }, + { + "time": "2026-07-13T18:30:00+02:00", + "value": 160.272, + "raw_value": 160.272 + }, + { + "time": "2026-07-13T18:45:00+02:00", + "value": 167.9161, + "raw_value": 167.9161 + }, + { + "time": "2026-07-13T19:00:00+02:00", + "value": 166.4181, + "raw_value": 166.4181 + }, + { + "time": "2026-07-13T19:15:00+02:00", + "value": 172.3108, + "raw_value": 172.3108 + }, + { + "time": "2026-07-13T19:30:00+02:00", + "value": 186.0129, + "raw_value": 186.0129 + }, + { + "time": "2026-07-13T19:45:00+02:00", + "value": 203.1514, + "raw_value": 203.1514 + }, + { + "time": "2026-07-13T20:00:00+02:00", + "value": 194.9897, + "raw_value": 194.9897 + }, + { + "time": "2026-07-13T20:15:00+02:00", + "value": 196.003, + "raw_value": 196.003 + }, + { + "time": "2026-07-13T20:30:00+02:00", + "value": 196.719, + "raw_value": 196.719 + }, + { + "time": "2026-07-13T20:45:00+02:00", + "value": 198.294, + "raw_value": 198.294 + }, + { + "time": "2026-07-13T21:00:00+02:00", + "value": 199.7259, + "raw_value": 199.7259 + }, + { + "time": "2026-07-13T21:15:00+02:00", + "value": 186.0239, + "raw_value": 186.0239 + }, + { + "time": "2026-07-13T21:30:00+02:00", + "value": 175.9126, + "raw_value": 175.9126 + }, + { + "time": "2026-07-13T21:45:00+02:00", + "value": 169.6013, + "raw_value": 169.6013 + }, + { + "time": "2026-07-13T22:00:00+02:00", + "value": 185.5943, + "raw_value": 185.5943 + }, + { + "time": "2026-07-13T22:15:00+02:00", + "value": 169.5572, + "raw_value": 169.5572 + }, + { + "time": "2026-07-13T22:30:00+02:00", + "value": 169.3369, + "raw_value": 169.3369 + }, + { + "time": "2026-07-13T22:45:00+02:00", + "value": 167.4094, + "raw_value": 167.4094 + }, + { + "time": "2026-07-13T23:00:00+02:00", + "value": 164.05, + "raw_value": 164.05 + }, + { + "time": "2026-07-13T23:15:00+02:00", + "value": 161.5276, + "raw_value": 161.5276 + }, + { + "time": "2026-07-13T23:30:00+02:00", + "value": 158.0801, + "raw_value": 158.0801 + }, + { + "time": "2026-07-13T23:45:00+02:00", + "value": 156.5601, + "raw_value": 156.5601 + } + ] + }, + "state": "11.609283" +} \ No newline at end of file diff --git a/scripts/simulation/nibe_modbus_simulator.py b/scripts/simulation/nibe_modbus_simulator.py index 85007bbd..4097bf14 100644 --- a/scripts/simulation/nibe_modbus_simulator.py +++ b/scripts/simulation/nibe_modbus_simulator.py @@ -66,18 +66,18 @@ def setValues(self, address, values): # F-series register ids are shared across F750/F1155 (verified: yozik04/nibe # f750.csv uses the same 40004/40013/43005/47011/48132 ids). REGISTERS_F750 = { - 40004: s16(-32), # BT1 outdoor -3.2 C (same site) - 40008: s16(382), # BT2 supply 38.2 C - 40012: s16(320), # BT3 return 32.0 C - 40013: s16(512), # BT7 HW top 51.2 C - 40014: s16(460), # BT6 HW charging 46.0 C - 40033: s16(218), # BT50 room 21.8 C + 40004: s16(-32), # BT1 outdoor -3.2 C (same site) + 40008: s16(382), # BT2 supply 38.2 C + 40012: s16(320), # BT3 return 32.0 C + 40013: s16(512), # BT7 HW top 51.2 C + 40014: s16(460), # BT6 HW charging 46.0 C + 40033: s16(218), # BT50 room 21.8 C 43005: s16(-850), # DM -85.0 - 43086: s16(30), # Prio - 43136: s16(450), # Compressor 45.0 Hz - 43427: s16(60), # Running - 47011: s16(0), # Heat offset S1 - 48132: s16(0), # Temporary Lux + 43086: s16(30), # Prio + 43136: s16(450), # Compressor 45.0 Hz + 43427: s16(60), # Running + 47011: s16(0), # Heat offset S1 + 48132: s16(0), # Temporary Lux } @@ -88,9 +88,7 @@ def main() -> None: device_f1155 = ModbusDeviceContext(hr=block, ir=block) block_f750 = LoggingSparseBlock({addr + 1: val for addr, val in REGISTERS_F750.items()}) device_f750 = ModbusDeviceContext(hr=block_f750, ir=block_f750) - context = ModbusServerContext( - devices={1: device_f1155, 2: device_f750}, single=False - ) + context = ModbusServerContext(devices={1: device_f1155, 2: device_f750}, single=False) LOG.info("Starting NIBE F1155 (unit 1) + F750 (unit 2) simulator on 127.0.0.1:5020") asyncio.run(StartAsyncTcpServer(context=context, address=("127.0.0.1", 5020))) diff --git a/scripts/simulation/output/summary-concrete_f1155-selftest-baseline.json b/scripts/simulation/output/summary-concrete_f1155-selftest-baseline.json deleted file mode 100644 index 31180a0e..00000000 --- a/scripts/simulation/output/summary-concrete_f1155-selftest-baseline.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "house": "concrete_f1155", - "days": 2, - "stats": { - "indoor_min": 21.969169082114874, - "indoor_max": 22.0174591785138, - "dm_min": -92.00450450450396, - "cost_sek": 20.527250240442537, - "energy_kwh": 34.247699833943415, - "aux_kwh": 0.0, - "writes": 0, - "offset_min": 0, - "offset_max": 0, - "exceptions": 0, - "comfort_minutes_below": 0, - "comfort_minutes_above": 0, - "compressor_starts": 23, - "sign_flips": 0, - "peak_kw_quarter_mean": 1.2, - "tariff_top3_kw": 1.2, - "tariff_cost_sek": 97.0, - "total_cost_sek": 118.0, - "indoor_mean": 22.0, - "violations": 0 - }, - "violations": [] -} \ No newline at end of file diff --git a/scripts/simulation/output/summary-concrete_f1155-selftest.json b/scripts/simulation/output/summary-concrete_f1155-selftest.json deleted file mode 100644 index 2bcf9ac3..00000000 --- a/scripts/simulation/output/summary-concrete_f1155-selftest.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "house": "concrete_f1155", - "days": 2, - "stats": { - "indoor_min": 21.965025981200416, - "indoor_max": 22.194184410636346, - "dm_min": -89.87987987987937, - "cost_sek": 20.89249514899089, - "energy_kwh": 35.178631091344315, - "aux_kwh": 0.0, - "writes": 24, - "offset_min": -1, - "offset_max": 1, - "exceptions": 0, - "comfort_minutes_below": 0, - "comfort_minutes_above": 0, - "compressor_starts": 22, - "sign_flips": 0, - "peak_kw_quarter_mean": 1.28, - "tariff_top3_kw": 1.27, - "tariff_cost_sek": 104.0, - "total_cost_sek": 125.0, - "indoor_mean": 22.07, - "violations": 0 - }, - "violations": [] -} \ No newline at end of file diff --git a/scripts/simulation/output/summary-wooden_f750-selftest-baseline.json b/scripts/simulation/output/summary-wooden_f750-selftest-baseline.json deleted file mode 100644 index bff59802..00000000 --- a/scripts/simulation/output/summary-wooden_f750-selftest-baseline.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "house": "wooden_f750", - "days": 2, - "stats": { - "indoor_min": 21.9106248983374, - "indoor_max": 22.032779490000692, - "dm_min": -153.33333333333331, - "cost_sek": 22.070691830280357, - "energy_kwh": 37.267650365195564, - "aux_kwh": 0.0, - "writes": 0, - "offset_min": 0, - "offset_max": 0, - "exceptions": 0, - "comfort_minutes_below": 0, - "comfort_minutes_above": 0, - "compressor_starts": 23, - "sign_flips": 0, - "peak_kw_quarter_mean": 1.31, - "tariff_top3_kw": 1.31, - "tariff_cost_sek": 107.0, - "total_cost_sek": 129.0, - "indoor_mean": 22.0, - "violations": 0 - }, - "violations": [] -} \ No newline at end of file diff --git a/scripts/simulation/output/summary-wooden_f750-selftest.json b/scripts/simulation/output/summary-wooden_f750-selftest.json deleted file mode 100644 index 5fa5faaf..00000000 --- a/scripts/simulation/output/summary-wooden_f750-selftest.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "house": "wooden_f750", - "days": 2, - "stats": { - "indoor_min": 21.47637556891358, - "indoor_max": 21.963541666666668, - "dm_min": -153.33333333333331, - "cost_sek": 21.553804940141195, - "energy_kwh": 36.557933567642515, - "aux_kwh": 0.0, - "writes": 11, - "offset_min": -2, - "offset_max": 0, - "exceptions": 0, - "comfort_minutes_below": 60, - "comfort_minutes_above": 0, - "compressor_starts": 23, - "sign_flips": 0, - "peak_kw_quarter_mean": 1.28, - "tariff_top3_kw": 1.27, - "tariff_cost_sek": 103.0, - "total_cost_sek": 125.0, - "indoor_mean": 21.65, - "violations": 0 - }, - "violations": [] -} \ No newline at end of file diff --git a/scripts/simulation/output/trace-concrete_f1155-selftest-baseline.json b/scripts/simulation/output/trace-concrete_f1155-selftest-baseline.json deleted file mode 100644 index 13aebc7d..00000000 --- a/scripts/simulation/output/trace-concrete_f1155-selftest-baseline.json +++ /dev/null @@ -1 +0,0 @@ -[{"t": "2026-01-01T00:00:00+01:00", "tout": -5.0, "tin": 22.0, "flow": 32.5, "dm": -36, "offset": 0, "calc": 0.0, "kw": 1.01, "price": 50.0, "comp": 1}, {"t": "2026-01-01T00:30:00+01:00", "tout": -4.9, "tin": 22.01, "flow": 34.6, "dm": -6, "offset": 0, "calc": 0.0, "kw": 1.19, "price": 50.0, "comp": 1}, {"t": "2026-01-01T01:00:00+01:00", "tout": -4.8, "tin": 22.01, "flow": 32.6, "dm": -1, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T01:30:00+01:00", "tout": -4.8, "tin": 21.98, "flow": 32.6, "dm": -67, "offset": 0, "calc": 0.0, "kw": 1.02, "price": 50.0, "comp": 1}, {"t": "2026-01-01T02:00:00+01:00", "tout": -4.7, "tin": 22.0, "flow": 34.5, "dm": -37, "offset": 0, "calc": 0.0, "kw": 1.18, "price": 50.0, "comp": 1}, {"t": "2026-01-01T02:30:00+01:00", "tout": -4.6, "tin": 22.01, "flow": 34.5, "dm": -7, "offset": 0, "calc": 0.0, "kw": 1.18, "price": 50.0, "comp": 1}, {"t": "2026-01-01T03:00:00+01:00", "tout": -4.5, "tin": 22.01, "flow": 32.5, "dm": -1, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T03:30:00+01:00", "tout": -4.4, "tin": 21.98, "flow": 32.5, "dm": -68, "offset": 0, "calc": 0.0, "kw": 1.01, "price": 50.0, "comp": 1}, {"t": "2026-01-01T04:00:00+01:00", "tout": -4.3, "tin": 22.0, "flow": 34.4, "dm": -38, "offset": 0, "calc": 0.0, "kw": 1.17, "price": 50.0, "comp": 1}, {"t": "2026-01-01T04:30:00+01:00", "tout": -4.2, "tin": 22.01, "flow": 34.4, "dm": -8, "offset": 0, "calc": 0.0, "kw": 1.16, "price": 50.0, "comp": 1}, {"t": "2026-01-01T05:00:00+01:00", "tout": -4.2, "tin": 22.01, "flow": 32.3, "dm": -2, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T05:30:00+01:00", "tout": -4.1, "tin": 21.98, "flow": 32.3, "dm": -68, "offset": 0, "calc": 0.0, "kw": 0.99, "price": 50.0, "comp": 1}, {"t": "2026-01-01T06:00:00+01:00", "tout": -4.0, "tin": 22.0, "flow": 34.2, "dm": -38, "offset": 0, "calc": 0.0, "kw": 1.15, "price": 50.0, "comp": 1}, {"t": "2026-01-01T06:30:00+01:00", "tout": -3.9, "tin": 22.01, "flow": 34.2, "dm": -8, "offset": 0, "calc": 0.0, "kw": 1.15, "price": 50.0, "comp": 1}, {"t": "2026-01-01T07:00:00+01:00", "tout": -3.8, "tin": 22.01, "flow": 32.2, "dm": -3, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T07:30:00+01:00", "tout": -3.8, "tin": 21.98, "flow": 32.2, "dm": -69, "offset": 0, "calc": 0.0, "kw": 0.98, "price": 90.0, "comp": 1}, {"t": "2026-01-01T08:00:00+01:00", "tout": -3.7, "tin": 22.0, "flow": 34.1, "dm": -39, "offset": 0, "calc": 0.0, "kw": 1.14, "price": 90.0, "comp": 1}, {"t": "2026-01-01T08:30:00+01:00", "tout": -3.6, "tin": 22.01, "flow": 34.1, "dm": -9, "offset": 0, "calc": 0.0, "kw": 1.13, "price": 90.0, "comp": 1}, {"t": "2026-01-01T09:00:00+01:00", "tout": -3.5, "tin": 22.01, "flow": 32.1, "dm": -4, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T09:30:00+01:00", "tout": -3.4, "tin": 21.98, "flow": 32.1, "dm": -70, "offset": 0, "calc": 0.0, "kw": 0.96, "price": 90.0, "comp": 1}, {"t": "2026-01-01T10:00:00+01:00", "tout": -3.3, "tin": 21.99, "flow": 34.0, "dm": -40, "offset": 0, "calc": 0.0, "kw": 1.12, "price": 90.0, "comp": 1}, {"t": "2026-01-01T10:30:00+01:00", "tout": -3.2, "tin": 22.01, "flow": 33.9, "dm": -10, "offset": 0, "calc": 0.0, "kw": 1.12, "price": 50.0, "comp": 1}, {"t": "2026-01-01T11:00:00+01:00", "tout": -3.2, "tin": 22.02, "flow": 32.4, "dm": 5, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T11:30:00+01:00", "tout": -3.1, "tin": 21.98, "flow": 29.4, "dm": -61, "offset": 0, "calc": 0.0, "kw": 0.73, "price": 50.0, "comp": 1}, {"t": "2026-01-01T12:00:00+01:00", "tout": -3.0, "tin": 21.99, "flow": 33.8, "dm": -41, "offset": 0, "calc": 0.0, "kw": 1.11, "price": 50.0, "comp": 1}, {"t": "2026-01-01T12:30:00+01:00", "tout": -2.9, "tin": 22.01, "flow": 33.8, "dm": -11, "offset": 0, "calc": 0.0, "kw": 1.1, "price": 50.0, "comp": 1}, {"t": "2026-01-01T13:00:00+01:00", "tout": -2.8, "tin": 22.02, "flow": 32.3, "dm": 4, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T13:30:00+01:00", "tout": -2.8, "tin": 21.98, "flow": 29.3, "dm": -62, "offset": 0, "calc": 0.0, "kw": 0.72, "price": 50.0, "comp": 1}, {"t": "2026-01-01T14:00:00+01:00", "tout": -2.7, "tin": 21.99, "flow": 33.7, "dm": -42, "offset": 0, "calc": 0.0, "kw": 1.09, "price": 50.0, "comp": 1}, {"t": "2026-01-01T14:30:00+01:00", "tout": -2.6, "tin": 22.01, "flow": 33.6, "dm": -12, "offset": 0, "calc": 0.0, "kw": 1.09, "price": 50.0, "comp": 1}, {"t": "2026-01-01T15:00:00+01:00", "tout": -2.5, "tin": 22.02, "flow": 32.1, "dm": 3, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T15:30:00+01:00", "tout": -2.4, "tin": 21.98, "flow": 29.1, "dm": -63, "offset": 0, "calc": 0.0, "kw": 0.7, "price": 50.0, "comp": 1}, {"t": "2026-01-01T16:00:00+01:00", "tout": -2.3, "tin": 21.99, "flow": 33.5, "dm": -43, "offset": 0, "calc": 0.0, "kw": 1.08, "price": 50.0, "comp": 1}, {"t": "2026-01-01T16:30:00+01:00", "tout": -2.2, "tin": 22.01, "flow": 33.5, "dm": -13, "offset": 0, "calc": 0.0, "kw": 1.07, "price": 50.0, "comp": 1}, {"t": "2026-01-01T17:00:00+01:00", "tout": -2.2, "tin": 22.01, "flow": 32.0, "dm": 3, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T17:30:00+01:00", "tout": -2.1, "tin": 21.98, "flow": 29.0, "dm": -64, "offset": 0, "calc": 0.0, "kw": 0.69, "price": 90.0, "comp": 1}, {"t": "2026-01-01T18:00:00+01:00", "tout": -2.0, "tin": 21.99, "flow": 33.4, "dm": -44, "offset": 0, "calc": 0.0, "kw": 1.06, "price": 90.0, "comp": 1}, {"t": "2026-01-01T18:30:00+01:00", "tout": -1.9, "tin": 22.01, "flow": 33.3, "dm": -14, "offset": 0, "calc": 0.0, "kw": 1.06, "price": 90.0, "comp": 1}, {"t": "2026-01-01T19:00:00+01:00", "tout": -1.8, "tin": 22.01, "flow": 31.8, "dm": 2, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T19:30:00+01:00", "tout": -1.8, "tin": 21.98, "flow": 28.8, "dm": -65, "offset": 0, "calc": 0.0, "kw": 0.68, "price": 90.0, "comp": 1}, {"t": "2026-01-01T20:00:00+01:00", "tout": -1.7, "tin": 21.99, "flow": 33.2, "dm": -44, "offset": 0, "calc": 0.0, "kw": 1.05, "price": 90.0, "comp": 1}, {"t": "2026-01-01T20:30:00+01:00", "tout": -1.6, "tin": 22.01, "flow": 33.2, "dm": -14, "offset": 0, "calc": 0.0, "kw": 1.04, "price": 50.0, "comp": 1}, {"t": "2026-01-01T21:00:00+01:00", "tout": -1.5, "tin": 22.01, "flow": 31.7, "dm": 1, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T21:30:00+01:00", "tout": -1.4, "tin": 21.98, "flow": 28.7, "dm": -66, "offset": 0, "calc": 0.0, "kw": 0.66, "price": 50.0, "comp": 1}, {"t": "2026-01-01T22:00:00+01:00", "tout": -1.3, "tin": 21.99, "flow": 33.1, "dm": -45, "offset": 0, "calc": 0.0, "kw": 1.03, "price": 50.0, "comp": 1}, {"t": "2026-01-01T22:30:00+01:00", "tout": -1.2, "tin": 22.01, "flow": 33.1, "dm": -15, "offset": 0, "calc": 0.0, "kw": 1.03, "price": 50.0, "comp": 1}, {"t": "2026-01-01T23:00:00+01:00", "tout": -1.2, "tin": 22.02, "flow": 32.0, "dm": 7, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T23:30:00+01:00", "tout": -3.1, "tin": 21.98, "flow": 29.0, "dm": -59, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T00:00:00+01:00", "tout": -5.0, "tin": 21.98, "flow": 34.7, "dm": -76, "offset": 0, "calc": 0.0, "kw": 1.2, "price": 50.0, "comp": 1}, {"t": "2026-01-02T00:30:00+01:00", "tout": -4.9, "tin": 21.99, "flow": 34.6, "dm": -46, "offset": 0, "calc": 0.0, "kw": 1.2, "price": 50.0, "comp": 1}, {"t": "2026-01-02T01:00:00+01:00", "tout": -4.8, "tin": 22.01, "flow": 34.6, "dm": -16, "offset": 0, "calc": 0.0, "kw": 1.19, "price": 50.0, "comp": 1}, {"t": "2026-01-02T01:30:00+01:00", "tout": -4.8, "tin": 22.02, "flow": 33.6, "dm": 7, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T02:00:00+01:00", "tout": -4.7, "tin": 21.99, "flow": 30.6, "dm": -45, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T02:30:00+01:00", "tout": -4.6, "tin": 21.99, "flow": 34.5, "dm": -47, "offset": 0, "calc": 0.0, "kw": 1.18, "price": 50.0, "comp": 1}, {"t": "2026-01-02T03:00:00+01:00", "tout": -4.5, "tin": 22.01, "flow": 34.5, "dm": -17, "offset": 0, "calc": 0.0, "kw": 1.18, "price": 50.0, "comp": 1}, {"t": "2026-01-02T03:30:00+01:00", "tout": -4.4, "tin": 22.02, "flow": 33.4, "dm": 6, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T04:00:00+01:00", "tout": -4.3, "tin": 21.99, "flow": 30.4, "dm": -45, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T04:30:00+01:00", "tout": -4.2, "tin": 21.99, "flow": 34.4, "dm": -47, "offset": 0, "calc": 0.0, "kw": 1.17, "price": 50.0, "comp": 1}, {"t": "2026-01-02T05:00:00+01:00", "tout": -4.2, "tin": 22.01, "flow": 34.3, "dm": -17, "offset": 0, "calc": 0.0, "kw": 1.16, "price": 50.0, "comp": 1}, {"t": "2026-01-02T05:30:00+01:00", "tout": -4.1, "tin": 22.02, "flow": 33.3, "dm": 5, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T06:00:00+01:00", "tout": -4.0, "tin": 21.99, "flow": 30.3, "dm": -46, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T06:30:00+01:00", "tout": -3.9, "tin": 21.99, "flow": 34.2, "dm": -48, "offset": 0, "calc": 0.0, "kw": 1.15, "price": 50.0, "comp": 1}, {"t": "2026-01-02T07:00:00+01:00", "tout": -3.8, "tin": 22.01, "flow": 34.2, "dm": -18, "offset": 0, "calc": 0.0, "kw": 1.15, "price": 90.0, "comp": 1}, {"t": "2026-01-02T07:30:00+01:00", "tout": -3.8, "tin": 22.02, "flow": 33.1, "dm": 4, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T08:00:00+01:00", "tout": -3.7, "tin": 21.99, "flow": 30.1, "dm": -47, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T08:30:00+01:00", "tout": -3.6, "tin": 21.99, "flow": 34.1, "dm": -49, "offset": 0, "calc": 0.0, "kw": 1.13, "price": 90.0, "comp": 1}, {"t": "2026-01-02T09:00:00+01:00", "tout": -3.5, "tin": 22.0, "flow": 34.0, "dm": -19, "offset": 0, "calc": 0.0, "kw": 1.13, "price": 90.0, "comp": 1}, {"t": "2026-01-02T09:30:00+01:00", "tout": -3.4, "tin": 22.02, "flow": 33.0, "dm": 4, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T10:00:00+01:00", "tout": -3.3, "tin": 21.99, "flow": 30.0, "dm": -48, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T10:30:00+01:00", "tout": -3.2, "tin": 21.99, "flow": 33.9, "dm": -50, "offset": 0, "calc": 0.0, "kw": 1.12, "price": 50.0, "comp": 1}, {"t": "2026-01-02T11:00:00+01:00", "tout": -3.2, "tin": 22.0, "flow": 33.9, "dm": -20, "offset": 0, "calc": 0.0, "kw": 1.11, "price": 50.0, "comp": 1}, {"t": "2026-01-02T11:30:00+01:00", "tout": -3.1, "tin": 22.02, "flow": 32.9, "dm": 3, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T12:00:00+01:00", "tout": -3.0, "tin": 21.99, "flow": 29.9, "dm": -49, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T12:30:00+01:00", "tout": -2.9, "tin": 21.99, "flow": 33.8, "dm": -51, "offset": 0, "calc": 0.0, "kw": 1.1, "price": 50.0, "comp": 1}, {"t": "2026-01-02T13:00:00+01:00", "tout": -2.8, "tin": 22.0, "flow": 33.7, "dm": -21, "offset": 0, "calc": 0.0, "kw": 1.1, "price": 50.0, "comp": 1}, {"t": "2026-01-02T13:30:00+01:00", "tout": -2.8, "tin": 22.02, "flow": 33.2, "dm": 7, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T14:00:00+01:00", "tout": -2.7, "tin": 22.0, "flow": 30.2, "dm": -30, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T14:30:00+01:00", "tout": -2.6, "tin": 21.99, "flow": 33.6, "dm": -52, "offset": 0, "calc": 0.0, "kw": 1.09, "price": 50.0, "comp": 1}, {"t": "2026-01-02T15:00:00+01:00", "tout": -2.5, "tin": 22.0, "flow": 33.6, "dm": -22, "offset": 0, "calc": 0.0, "kw": 1.08, "price": 50.0, "comp": 1}, {"t": "2026-01-02T15:30:00+01:00", "tout": -2.4, "tin": 22.02, "flow": 33.1, "dm": 6, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T16:00:00+01:00", "tout": -2.3, "tin": 22.0, "flow": 30.1, "dm": -31, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T16:30:00+01:00", "tout": -2.2, "tin": 21.99, "flow": 33.5, "dm": -52, "offset": 0, "calc": 0.0, "kw": 1.07, "price": 50.0, "comp": 1}, {"t": "2026-01-02T17:00:00+01:00", "tout": -2.2, "tin": 22.0, "flow": 33.5, "dm": -22, "offset": 0, "calc": 0.0, "kw": 1.07, "price": 90.0, "comp": 1}, {"t": "2026-01-02T17:30:00+01:00", "tout": -2.1, "tin": 22.02, "flow": 32.9, "dm": 5, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T18:00:00+01:00", "tout": -2.0, "tin": 22.0, "flow": 29.9, "dm": -32, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T18:30:00+01:00", "tout": -1.9, "tin": 21.99, "flow": 33.3, "dm": -53, "offset": 0, "calc": 0.0, "kw": 1.06, "price": 90.0, "comp": 1}, {"t": "2026-01-02T19:00:00+01:00", "tout": -1.8, "tin": 22.0, "flow": 33.3, "dm": -23, "offset": 0, "calc": 0.0, "kw": 1.06, "price": 90.0, "comp": 1}, {"t": "2026-01-02T19:30:00+01:00", "tout": -1.8, "tin": 22.02, "flow": 32.8, "dm": 4, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T20:00:00+01:00", "tout": -1.7, "tin": 22.0, "flow": 29.8, "dm": -32, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T20:30:00+01:00", "tout": -1.6, "tin": 21.99, "flow": 33.2, "dm": -54, "offset": 0, "calc": 0.0, "kw": 1.05, "price": 50.0, "comp": 1}, {"t": "2026-01-02T21:00:00+01:00", "tout": -1.5, "tin": 22.0, "flow": 33.2, "dm": -24, "offset": 0, "calc": 0.0, "kw": 1.04, "price": 50.0, "comp": 1}, {"t": "2026-01-02T21:30:00+01:00", "tout": -1.4, "tin": 22.02, "flow": 32.6, "dm": 3, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T22:00:00+01:00", "tout": -1.3, "tin": 22.0, "flow": 29.6, "dm": -33, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T22:30:00+01:00", "tout": -1.2, "tin": 21.99, "flow": 33.1, "dm": -55, "offset": 0, "calc": 0.0, "kw": 1.03, "price": 50.0, "comp": 1}, {"t": "2026-01-02T23:00:00+01:00", "tout": -1.2, "tin": 22.0, "flow": 33.0, "dm": -25, "offset": 0, "calc": 0.0, "kw": 1.03, "price": 50.0, "comp": 1}, {"t": "2026-01-02T23:30:00+01:00", "tout": -1.2, "tin": 22.01, "flow": 32.5, "dm": 3, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}] \ No newline at end of file diff --git a/scripts/simulation/output/trace-concrete_f1155-selftest.json b/scripts/simulation/output/trace-concrete_f1155-selftest.json deleted file mode 100644 index 1e5c11c5..00000000 --- a/scripts/simulation/output/trace-concrete_f1155-selftest.json +++ /dev/null @@ -1 +0,0 @@ -[{"t": "2026-01-01T00:00:00+01:00", "tout": -5.0, "tin": 22.0, "flow": 32.5, "dm": -36, "offset": 0, "calc": 0.65, "kw": 1.01, "price": 50.0, "comp": 1}, {"t": "2026-01-01T00:30:00+01:00", "tout": -4.9, "tin": 22.01, "flow": 34.6, "dm": -6, "offset": 0, "calc": 0.11, "kw": 1.19, "price": 50.0, "comp": 1}, {"t": "2026-01-01T01:00:00+01:00", "tout": -4.8, "tin": 22.01, "flow": 32.6, "dm": -1, "offset": 0, "calc": 0.53, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T01:30:00+01:00", "tout": -4.8, "tin": 21.98, "flow": 32.6, "dm": -72, "offset": 1, "calc": 0.33, "kw": 1.02, "price": 50.0, "comp": 1}, {"t": "2026-01-01T02:00:00+01:00", "tout": -4.7, "tin": 22.01, "flow": 35.5, "dm": -44, "offset": 1, "calc": 0.14, "kw": 1.27, "price": 50.0, "comp": 1}, {"t": "2026-01-01T02:30:00+01:00", "tout": -4.6, "tin": 22.04, "flow": 35.5, "dm": -14, "offset": 1, "calc": 0.14, "kw": 1.26, "price": 50.0, "comp": 1}, {"t": "2026-01-01T03:00:00+01:00", "tout": -4.5, "tin": 22.05, "flow": 33.0, "dm": 1, "offset": 0, "calc": 0.45, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T03:30:00+01:00", "tout": -4.4, "tin": 22.01, "flow": 30.0, "dm": -65, "offset": 1, "calc": 1.06, "kw": 0.79, "price": 50.0, "comp": 1}, {"t": "2026-01-01T04:00:00+01:00", "tout": -4.3, "tin": 22.03, "flow": 35.4, "dm": -52, "offset": 1, "calc": 0.43, "kw": 1.25, "price": 50.0, "comp": 1}, {"t": "2026-01-01T04:30:00+01:00", "tout": -4.2, "tin": 22.06, "flow": 35.4, "dm": -22, "offset": 1, "calc": 0.16, "kw": 1.25, "price": 50.0, "comp": 1}, {"t": "2026-01-01T05:00:00+01:00", "tout": -4.2, "tin": 22.08, "flow": 33.8, "dm": 6, "offset": 0, "calc": 0.28, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T05:30:00+01:00", "tout": -4.1, "tin": 22.06, "flow": 30.8, "dm": -31, "offset": 0, "calc": 0.93, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T06:00:00+01:00", "tout": -4.0, "tin": 22.05, "flow": 35.2, "dm": -65, "offset": 1, "calc": 0.45, "kw": 1.23, "price": 50.0, "comp": 1}, {"t": "2026-01-01T06:30:00+01:00", "tout": -3.9, "tin": 22.08, "flow": 35.2, "dm": -35, "offset": 0, "calc": -0.9, "kw": 1.23, "price": 50.0, "comp": 1}, {"t": "2026-01-01T07:00:00+01:00", "tout": -3.8, "tin": 22.09, "flow": 33.2, "dm": -5, "offset": -1, "calc": -1.51, "kw": 1.05, "price": 90.0, "comp": 1}, {"t": "2026-01-01T07:30:00+01:00", "tout": -3.8, "tin": 22.07, "flow": 30.7, "dm": -12, "offset": -1, "calc": -0.99, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T08:00:00+01:00", "tout": -3.7, "tin": 22.03, "flow": 33.1, "dm": -66, "offset": -1, "calc": -0.98, "kw": 1.05, "price": 90.0, "comp": 1}, {"t": "2026-01-01T08:30:00+01:00", "tout": -3.6, "tin": 22.03, "flow": 33.1, "dm": -36, "offset": -1, "calc": -0.98, "kw": 1.05, "price": 90.0, "comp": 1}, {"t": "2026-01-01T09:00:00+01:00", "tout": -3.5, "tin": 22.03, "flow": 33.0, "dm": -6, "offset": -1, "calc": -1.47, "kw": 1.04, "price": 90.0, "comp": 1}, {"t": "2026-01-01T09:30:00+01:00", "tout": -3.4, "tin": 22.02, "flow": 31.0, "dm": 0, "offset": -1, "calc": -1.05, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T10:00:00+01:00", "tout": -3.3, "tin": 21.97, "flow": 31.0, "dm": -67, "offset": -1, "calc": -0.63, "kw": 0.87, "price": 90.0, "comp": 1}, {"t": "2026-01-01T10:30:00+01:00", "tout": -3.2, "tin": 21.98, "flow": 33.9, "dm": -37, "offset": 0, "calc": 0.41, "kw": 1.12, "price": 50.0, "comp": 1}, {"t": "2026-01-01T11:00:00+01:00", "tout": -3.2, "tin": 21.99, "flow": 33.9, "dm": -7, "offset": 0, "calc": 0.26, "kw": 1.12, "price": 50.0, "comp": 1}, {"t": "2026-01-01T11:30:00+01:00", "tout": -3.1, "tin": 22.0, "flow": 31.9, "dm": -1, "offset": 0, "calc": 0.68, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T12:00:00+01:00", "tout": -3.0, "tin": 21.97, "flow": 31.9, "dm": -82, "offset": 1, "calc": 0.94, "kw": 0.94, "price": 50.0, "comp": 1}, {"t": "2026-01-01T12:30:00+01:00", "tout": -2.9, "tin": 21.99, "flow": 34.8, "dm": -55, "offset": 1, "calc": 0.65, "kw": 1.19, "price": 50.0, "comp": 1}, {"t": "2026-01-01T13:00:00+01:00", "tout": -2.8, "tin": 22.02, "flow": 34.7, "dm": -25, "offset": 1, "calc": 0.49, "kw": 1.18, "price": 50.0, "comp": 1}, {"t": "2026-01-01T13:30:00+01:00", "tout": -2.8, "tin": 22.05, "flow": 34.2, "dm": 3, "offset": 1, "calc": 0.45, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T14:00:00+01:00", "tout": -2.7, "tin": 22.04, "flow": 31.2, "dm": -34, "offset": 1, "calc": 1.01, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T14:30:00+01:00", "tout": -2.6, "tin": 22.05, "flow": 34.6, "dm": -55, "offset": 1, "calc": 0.5, "kw": 1.17, "price": 50.0, "comp": 1}, {"t": "2026-01-01T15:00:00+01:00", "tout": -2.5, "tin": 22.08, "flow": 34.6, "dm": -25, "offset": 1, "calc": 0.5, "kw": 1.16, "price": 50.0, "comp": 1}, {"t": "2026-01-01T15:30:00+01:00", "tout": -2.4, "tin": 22.1, "flow": 34.6, "dm": 5, "offset": 1, "calc": 0.37, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T16:00:00+01:00", "tout": -2.3, "tin": 22.1, "flow": 31.6, "dm": -17, "offset": 1, "calc": 0.95, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T16:30:00+01:00", "tout": -2.2, "tin": 22.1, "flow": 34.5, "dm": -56, "offset": 0, "calc": -0.61, "kw": 1.15, "price": 50.0, "comp": 1}, {"t": "2026-01-01T17:00:00+01:00", "tout": -2.2, "tin": 22.11, "flow": 33.5, "dm": -26, "offset": 0, "calc": -0.91, "kw": 1.06, "price": 90.0, "comp": 1}, {"t": "2026-01-01T17:30:00+01:00", "tout": -2.1, "tin": 22.12, "flow": 32.4, "dm": 4, "offset": -1, "calc": -1.21, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T18:00:00+01:00", "tout": -2.0, "tin": 22.09, "flow": 29.4, "dm": -18, "offset": -1, "calc": -0.3, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T18:30:00+01:00", "tout": -1.9, "tin": 22.05, "flow": 32.3, "dm": -57, "offset": -1, "calc": -0.73, "kw": 0.97, "price": 90.0, "comp": 1}, {"t": "2026-01-01T19:00:00+01:00", "tout": -1.8, "tin": 22.05, "flow": 32.3, "dm": -27, "offset": -1, "calc": -0.73, "kw": 0.97, "price": 90.0, "comp": 1}, {"t": "2026-01-01T19:30:00+01:00", "tout": -1.8, "tin": 22.05, "flow": 32.3, "dm": 3, "offset": -1, "calc": -1.2, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T20:00:00+01:00", "tout": -1.7, "tin": 22.03, "flow": 29.3, "dm": -19, "offset": -1, "calc": -0.28, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T20:30:00+01:00", "tout": -1.6, "tin": 22.0, "flow": 33.2, "dm": -65, "offset": 0, "calc": -0.42, "kw": 1.04, "price": 50.0, "comp": 1}, {"t": "2026-01-01T21:00:00+01:00", "tout": -1.5, "tin": 22.01, "flow": 33.2, "dm": -35, "offset": 0, "calc": -0.42, "kw": 1.04, "price": 50.0, "comp": 1}, {"t": "2026-01-01T21:30:00+01:00", "tout": -1.4, "tin": 22.02, "flow": 33.1, "dm": -5, "offset": 0, "calc": -0.78, "kw": 1.04, "price": 50.0, "comp": 1}, {"t": "2026-01-01T22:00:00+01:00", "tout": -1.3, "tin": 22.03, "flow": 31.1, "dm": 0, "offset": 0, "calc": -0.4, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T22:30:00+01:00", "tout": -1.2, "tin": 21.99, "flow": 31.1, "dm": -66, "offset": 1, "calc": 1.03, "kw": 0.87, "price": 50.0, "comp": 1}, {"t": "2026-01-01T23:00:00+01:00", "tout": -1.2, "tin": 22.02, "flow": 34.0, "dm": -38, "offset": 1, "calc": 0.73, "kw": 1.11, "price": 50.0, "comp": 1}, {"t": "2026-01-01T23:30:00+01:00", "tout": -3.1, "tin": 22.05, "flow": 34.8, "dm": -8, "offset": 1, "calc": 0.07, "kw": 1.19, "price": 50.0, "comp": 1}, {"t": "2026-01-02T00:00:00+01:00", "tout": -5.0, "tin": 22.06, "flow": 33.1, "dm": -10, "offset": 1, "calc": 0.43, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T00:30:00+01:00", "tout": -4.9, "tin": 22.05, "flow": 35.6, "dm": -66, "offset": 1, "calc": 0.13, "kw": 1.28, "price": 50.0, "comp": 1}, {"t": "2026-01-02T01:00:00+01:00", "tout": -4.8, "tin": 22.08, "flow": 35.6, "dm": -36, "offset": 1, "calc": 0.13, "kw": 1.27, "price": 50.0, "comp": 1}, {"t": "2026-01-02T01:30:00+01:00", "tout": -4.8, "tin": 22.1, "flow": 34.6, "dm": -6, "offset": 0, "calc": 0.11, "kw": 1.18, "price": 50.0, "comp": 1}, {"t": "2026-01-02T02:00:00+01:00", "tout": -4.7, "tin": 22.1, "flow": 32.6, "dm": -1, "offset": 0, "calc": 0.53, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T02:30:00+01:00", "tout": -4.6, "tin": 22.07, "flow": 32.6, "dm": -72, "offset": 1, "calc": 0.33, "kw": 1.01, "price": 50.0, "comp": 1}, {"t": "2026-01-02T03:00:00+01:00", "tout": -4.5, "tin": 22.09, "flow": 35.5, "dm": -44, "offset": 1, "calc": 0.14, "kw": 1.25, "price": 50.0, "comp": 1}, {"t": "2026-01-02T03:30:00+01:00", "tout": -4.4, "tin": 22.12, "flow": 35.4, "dm": -14, "offset": 1, "calc": 0.16, "kw": 1.25, "price": 50.0, "comp": 1}, {"t": "2026-01-02T04:00:00+01:00", "tout": -4.3, "tin": 22.13, "flow": 32.9, "dm": 1, "offset": 0, "calc": 0.47, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T04:30:00+01:00", "tout": -4.2, "tin": 22.09, "flow": 29.9, "dm": -65, "offset": 1, "calc": 1.08, "kw": 0.77, "price": 50.0, "comp": 1}, {"t": "2026-01-02T05:00:00+01:00", "tout": -4.2, "tin": 22.11, "flow": 35.3, "dm": -52, "offset": 1, "calc": 0.44, "kw": 1.24, "price": 50.0, "comp": 1}, {"t": "2026-01-02T05:30:00+01:00", "tout": -4.1, "tin": 22.14, "flow": 35.3, "dm": -22, "offset": 1, "calc": 0.18, "kw": 1.23, "price": 50.0, "comp": 1}, {"t": "2026-01-02T06:00:00+01:00", "tout": -4.0, "tin": 22.15, "flow": 33.7, "dm": 5, "offset": 0, "calc": 0.3, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T06:30:00+01:00", "tout": -3.9, "tin": 22.13, "flow": 30.7, "dm": -31, "offset": 0, "calc": -0.14, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T07:00:00+01:00", "tout": -3.8, "tin": 22.12, "flow": 34.2, "dm": -53, "offset": -1, "calc": -1.16, "kw": 1.14, "price": 90.0, "comp": 1}, {"t": "2026-01-02T07:30:00+01:00", "tout": -3.8, "tin": 22.12, "flow": 33.1, "dm": -23, "offset": -1, "calc": -0.98, "kw": 1.05, "price": 90.0, "comp": 1}, {"t": "2026-01-02T08:00:00+01:00", "tout": -3.7, "tin": 22.11, "flow": 32.6, "dm": 5, "offset": -1, "calc": -1.39, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T08:30:00+01:00", "tout": -3.6, "tin": 22.08, "flow": 29.6, "dm": -32, "offset": -1, "calc": -0.4, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T09:00:00+01:00", "tout": -3.5, "tin": 22.05, "flow": 33.0, "dm": -54, "offset": -1, "calc": -0.96, "kw": 1.04, "price": 90.0, "comp": 1}, {"t": "2026-01-02T09:30:00+01:00", "tout": -3.4, "tin": 22.05, "flow": 33.0, "dm": -24, "offset": -1, "calc": -0.96, "kw": 1.04, "price": 90.0, "comp": 1}, {"t": "2026-01-02T10:00:00+01:00", "tout": -3.3, "tin": 22.05, "flow": 32.5, "dm": 4, "offset": -1, "calc": -1.36, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T10:30:00+01:00", "tout": -3.2, "tin": 22.02, "flow": 29.5, "dm": -53, "offset": 1, "calc": 1.1, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T11:00:00+01:00", "tout": -3.2, "tin": 22.02, "flow": 34.9, "dm": -75, "offset": 1, "calc": 0.49, "kw": 1.2, "price": 50.0, "comp": 1}, {"t": "2026-01-02T11:30:00+01:00", "tout": -3.1, "tin": 22.05, "flow": 34.8, "dm": -45, "offset": 1, "calc": 0.26, "kw": 1.19, "price": 50.0, "comp": 1}, {"t": "2026-01-02T12:00:00+01:00", "tout": -3.0, "tin": 22.08, "flow": 34.8, "dm": -15, "offset": 1, "calc": 0.47, "kw": 1.19, "price": 50.0, "comp": 1}, {"t": "2026-01-02T12:30:00+01:00", "tout": -2.9, "tin": 22.1, "flow": 33.3, "dm": 1, "offset": 1, "calc": 0.62, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T13:00:00+01:00", "tout": -2.8, "tin": 22.08, "flow": 30.3, "dm": -66, "offset": 1, "calc": 1.15, "kw": 0.8, "price": 50.0, "comp": 1}, {"t": "2026-01-02T13:30:00+01:00", "tout": -2.8, "tin": 22.1, "flow": 34.7, "dm": -45, "offset": 1, "calc": 0.49, "kw": 1.17, "price": 50.0, "comp": 1}, {"t": "2026-01-02T14:00:00+01:00", "tout": -2.7, "tin": 22.13, "flow": 34.7, "dm": -15, "offset": 1, "calc": 0.49, "kw": 1.17, "price": 50.0, "comp": 1}, {"t": "2026-01-02T14:30:00+01:00", "tout": -2.6, "tin": 22.15, "flow": 33.6, "dm": 7, "offset": 1, "calc": 0.56, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T15:00:00+01:00", "tout": -2.5, "tin": 22.14, "flow": 30.6, "dm": -44, "offset": 1, "calc": 1.1, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T15:30:00+01:00", "tout": -2.4, "tin": 22.15, "flow": 34.6, "dm": -46, "offset": 1, "calc": 0.5, "kw": 1.15, "price": 50.0, "comp": 1}, {"t": "2026-01-02T16:00:00+01:00", "tout": -2.3, "tin": 22.17, "flow": 34.5, "dm": -16, "offset": 1, "calc": 0.52, "kw": 1.15, "price": 50.0, "comp": 1}, {"t": "2026-01-02T16:30:00+01:00", "tout": -2.2, "tin": 22.19, "flow": 33.5, "dm": 6, "offset": 0, "calc": -0.85, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T17:00:00+01:00", "tout": -2.2, "tin": 22.18, "flow": 30.5, "dm": -15, "offset": 0, "calc": -0.46, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T17:30:00+01:00", "tout": -2.1, "tin": 22.14, "flow": 33.4, "dm": -81, "offset": 0, "calc": -0.9, "kw": 1.05, "price": 90.0, "comp": 1}, {"t": "2026-01-02T18:00:00+01:00", "tout": -2.0, "tin": 22.16, "flow": 33.4, "dm": -51, "offset": 0, "calc": -0.9, "kw": 1.05, "price": 90.0, "comp": 1}, {"t": "2026-01-02T18:30:00+01:00", "tout": -1.9, "tin": 22.17, "flow": 33.3, "dm": -21, "offset": 0, "calc": -0.88, "kw": 1.05, "price": 90.0, "comp": 1}, {"t": "2026-01-02T19:00:00+01:00", "tout": -1.8, "tin": 22.17, "flow": 31.8, "dm": 6, "offset": -1, "calc": -1.1, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T19:30:00+01:00", "tout": -1.8, "tin": 22.13, "flow": 28.8, "dm": -30, "offset": -1, "calc": -0.21, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T20:00:00+01:00", "tout": -1.7, "tin": 22.1, "flow": 32.2, "dm": -52, "offset": -1, "calc": -0.72, "kw": 0.95, "price": 90.0, "comp": 1}, {"t": "2026-01-02T20:30:00+01:00", "tout": -1.6, "tin": 22.1, "flow": 32.2, "dm": -22, "offset": -1, "calc": -0.27, "kw": 0.95, "price": 50.0, "comp": 1}, {"t": "2026-01-02T21:00:00+01:00", "tout": -1.5, "tin": 22.1, "flow": 31.7, "dm": 6, "offset": -1, "calc": -0.51, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T21:30:00+01:00", "tout": -1.4, "tin": 22.06, "flow": 28.7, "dm": -36, "offset": 0, "calc": 0.26, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T22:00:00+01:00", "tout": -1.3, "tin": 22.05, "flow": 33.1, "dm": -75, "offset": 0, "calc": -0.4, "kw": 1.03, "price": 50.0, "comp": 1}, {"t": "2026-01-02T22:30:00+01:00", "tout": -1.2, "tin": 22.06, "flow": 33.1, "dm": -45, "offset": 0, "calc": -0.4, "kw": 1.02, "price": 50.0, "comp": 1}, {"t": "2026-01-02T23:00:00+01:00", "tout": -1.2, "tin": 22.07, "flow": 33.0, "dm": -15, "offset": 0, "calc": -0.39, "kw": 1.02, "price": 50.0, "comp": 1}, {"t": "2026-01-02T23:30:00+01:00", "tout": -1.2, "tin": 22.08, "flow": 31.5, "dm": 0, "offset": 0, "calc": -0.47, "kw": 0.1, "price": 50.0, "comp": 0}] \ No newline at end of file diff --git a/scripts/simulation/output/trace-wooden_f750-selftest-baseline.json b/scripts/simulation/output/trace-wooden_f750-selftest-baseline.json deleted file mode 100644 index 9f099038..00000000 --- a/scripts/simulation/output/trace-wooden_f750-selftest-baseline.json +++ /dev/null @@ -1 +0,0 @@ -[{"t": "2026-01-01T00:00:00+01:00", "tout": -5.0, "tin": 21.96, "flow": 32.5, "dm": -80, "offset": 0, "calc": 0.0, "kw": 0.69, "price": 50.0, "comp": 1}, {"t": "2026-01-01T00:30:00+01:00", "tout": -4.9, "tin": 21.92, "flow": 43.4, "dm": -143, "offset": 0, "calc": 0.0, "kw": 1.31, "price": 50.0, "comp": 1}, {"t": "2026-01-01T01:00:00+01:00", "tout": -4.8, "tin": 21.94, "flow": 43.3, "dm": -113, "offset": 0, "calc": 0.0, "kw": 1.3, "price": 50.0, "comp": 1}, {"t": "2026-01-01T01:30:00+01:00", "tout": -4.8, "tin": 21.97, "flow": 43.2, "dm": -83, "offset": 0, "calc": 0.0, "kw": 1.3, "price": 50.0, "comp": 1}, {"t": "2026-01-01T02:00:00+01:00", "tout": -4.7, "tin": 21.99, "flow": 43.2, "dm": -53, "offset": 0, "calc": 0.0, "kw": 1.29, "price": 50.0, "comp": 1}, {"t": "2026-01-01T02:30:00+01:00", "tout": -4.6, "tin": 22.01, "flow": 43.1, "dm": -23, "offset": 0, "calc": 0.0, "kw": 1.28, "price": 50.0, "comp": 1}, {"t": "2026-01-01T03:00:00+01:00", "tout": -4.5, "tin": 22.03, "flow": 42.6, "dm": 5, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T03:30:00+01:00", "tout": -4.4, "tin": 22.01, "flow": 39.6, "dm": -31, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T04:00:00+01:00", "tout": -4.3, "tin": 21.99, "flow": 42.9, "dm": -52, "offset": 0, "calc": 0.0, "kw": 1.26, "price": 50.0, "comp": 1}, {"t": "2026-01-01T04:30:00+01:00", "tout": -4.2, "tin": 22.01, "flow": 42.9, "dm": -22, "offset": 0, "calc": 0.0, "kw": 1.26, "price": 50.0, "comp": 1}, {"t": "2026-01-01T05:00:00+01:00", "tout": -4.2, "tin": 22.03, "flow": 42.3, "dm": 5, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T05:30:00+01:00", "tout": -4.1, "tin": 22.0, "flow": 39.3, "dm": -31, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T06:00:00+01:00", "tout": -4.0, "tin": 21.99, "flow": 42.7, "dm": -52, "offset": 0, "calc": 0.0, "kw": 1.24, "price": 50.0, "comp": 1}, {"t": "2026-01-01T06:30:00+01:00", "tout": -3.9, "tin": 22.01, "flow": 42.6, "dm": -22, "offset": 0, "calc": 0.0, "kw": 1.23, "price": 50.0, "comp": 1}, {"t": "2026-01-01T07:00:00+01:00", "tout": -3.8, "tin": 22.03, "flow": 42.1, "dm": 6, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T07:30:00+01:00", "tout": -3.8, "tin": 22.0, "flow": 39.1, "dm": -31, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T08:00:00+01:00", "tout": -3.7, "tin": 21.99, "flow": 42.4, "dm": -52, "offset": 0, "calc": 0.0, "kw": 1.22, "price": 90.0, "comp": 1}, {"t": "2026-01-01T08:30:00+01:00", "tout": -3.6, "tin": 22.01, "flow": 42.4, "dm": -22, "offset": 0, "calc": 0.0, "kw": 1.21, "price": 90.0, "comp": 1}, {"t": "2026-01-01T09:00:00+01:00", "tout": -3.5, "tin": 22.03, "flow": 41.8, "dm": 6, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T09:30:00+01:00", "tout": -3.4, "tin": 22.0, "flow": 38.8, "dm": -30, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T10:00:00+01:00", "tout": -3.3, "tin": 21.99, "flow": 42.2, "dm": -51, "offset": 0, "calc": 0.0, "kw": 1.19, "price": 90.0, "comp": 1}, {"t": "2026-01-01T10:30:00+01:00", "tout": -3.2, "tin": 22.01, "flow": 42.1, "dm": -21, "offset": 0, "calc": 0.0, "kw": 1.18, "price": 50.0, "comp": 1}, {"t": "2026-01-01T11:00:00+01:00", "tout": -3.2, "tin": 22.03, "flow": 41.6, "dm": 6, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T11:30:00+01:00", "tout": -3.1, "tin": 22.0, "flow": 38.6, "dm": -30, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T12:00:00+01:00", "tout": -3.0, "tin": 21.99, "flow": 41.9, "dm": -51, "offset": 0, "calc": 0.0, "kw": 1.17, "price": 50.0, "comp": 1}, {"t": "2026-01-01T12:30:00+01:00", "tout": -2.9, "tin": 22.01, "flow": 41.9, "dm": -21, "offset": 0, "calc": 0.0, "kw": 1.16, "price": 50.0, "comp": 1}, {"t": "2026-01-01T13:00:00+01:00", "tout": -2.8, "tin": 22.03, "flow": 41.3, "dm": 7, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T13:30:00+01:00", "tout": -2.8, "tin": 22.0, "flow": 38.3, "dm": -29, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T14:00:00+01:00", "tout": -2.7, "tin": 21.99, "flow": 41.7, "dm": -50, "offset": 0, "calc": 0.0, "kw": 1.15, "price": 50.0, "comp": 1}, {"t": "2026-01-01T14:30:00+01:00", "tout": -2.6, "tin": 22.01, "flow": 41.6, "dm": -20, "offset": 0, "calc": 0.0, "kw": 1.14, "price": 50.0, "comp": 1}, {"t": "2026-01-01T15:00:00+01:00", "tout": -2.5, "tin": 22.03, "flow": 41.1, "dm": 7, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T15:30:00+01:00", "tout": -2.4, "tin": 22.0, "flow": 38.1, "dm": -29, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T16:00:00+01:00", "tout": -2.3, "tin": 21.99, "flow": 41.4, "dm": -50, "offset": 0, "calc": 0.0, "kw": 1.12, "price": 50.0, "comp": 1}, {"t": "2026-01-01T16:30:00+01:00", "tout": -2.2, "tin": 22.01, "flow": 41.4, "dm": -20, "offset": 0, "calc": 0.0, "kw": 1.12, "price": 50.0, "comp": 1}, {"t": "2026-01-01T17:00:00+01:00", "tout": -2.2, "tin": 22.03, "flow": 40.8, "dm": 7, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T17:30:00+01:00", "tout": -2.1, "tin": 22.0, "flow": 37.8, "dm": -29, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T18:00:00+01:00", "tout": -2.0, "tin": 21.99, "flow": 41.2, "dm": -50, "offset": 0, "calc": 0.0, "kw": 1.1, "price": 90.0, "comp": 1}, {"t": "2026-01-01T18:30:00+01:00", "tout": -1.9, "tin": 22.01, "flow": 41.1, "dm": -20, "offset": 0, "calc": 0.0, "kw": 1.09, "price": 90.0, "comp": 1}, {"t": "2026-01-01T19:00:00+01:00", "tout": -1.8, "tin": 22.02, "flow": 40.1, "dm": 3, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T19:30:00+01:00", "tout": -1.8, "tin": 21.99, "flow": 37.1, "dm": -48, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T20:00:00+01:00", "tout": -1.7, "tin": 21.99, "flow": 40.9, "dm": -49, "offset": 0, "calc": 0.0, "kw": 1.08, "price": 90.0, "comp": 1}, {"t": "2026-01-01T20:30:00+01:00", "tout": -1.6, "tin": 22.01, "flow": 40.8, "dm": -19, "offset": 0, "calc": 0.0, "kw": 1.07, "price": 50.0, "comp": 1}, {"t": "2026-01-01T21:00:00+01:00", "tout": -1.5, "tin": 22.02, "flow": 39.8, "dm": 3, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T21:30:00+01:00", "tout": -1.4, "tin": 21.99, "flow": 36.8, "dm": -47, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T22:00:00+01:00", "tout": -1.3, "tin": 21.99, "flow": 40.7, "dm": -49, "offset": 0, "calc": 0.0, "kw": 1.06, "price": 50.0, "comp": 1}, {"t": "2026-01-01T22:30:00+01:00", "tout": -1.2, "tin": 22.01, "flow": 40.6, "dm": -19, "offset": 0, "calc": 0.0, "kw": 1.05, "price": 50.0, "comp": 1}, {"t": "2026-01-01T23:00:00+01:00", "tout": -1.2, "tin": 22.02, "flow": 39.6, "dm": 4, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T23:30:00+01:00", "tout": -3.1, "tin": 21.97, "flow": 36.6, "dm": -73, "offset": 0, "calc": 0.0, "kw": 0.88, "price": 50.0, "comp": 1}, {"t": "2026-01-02T00:00:00+01:00", "tout": -5.0, "tin": 21.98, "flow": 43.4, "dm": -64, "offset": 0, "calc": 0.0, "kw": 1.32, "price": 50.0, "comp": 1}, {"t": "2026-01-02T00:30:00+01:00", "tout": -4.9, "tin": 22.0, "flow": 43.4, "dm": -34, "offset": 0, "calc": 0.0, "kw": 1.31, "price": 50.0, "comp": 1}, {"t": "2026-01-02T01:00:00+01:00", "tout": -4.8, "tin": 22.02, "flow": 43.3, "dm": -4, "offset": 0, "calc": 0.0, "kw": 1.3, "price": 50.0, "comp": 1}, {"t": "2026-01-02T01:30:00+01:00", "tout": -4.8, "tin": 22.01, "flow": 40.8, "dm": -11, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T02:00:00+01:00", "tout": -4.7, "tin": 21.98, "flow": 43.2, "dm": -64, "offset": 0, "calc": 0.0, "kw": 1.29, "price": 50.0, "comp": 1}, {"t": "2026-01-02T02:30:00+01:00", "tout": -4.6, "tin": 22.0, "flow": 43.1, "dm": -34, "offset": 0, "calc": 0.0, "kw": 1.28, "price": 50.0, "comp": 1}, {"t": "2026-01-02T03:00:00+01:00", "tout": -4.5, "tin": 22.02, "flow": 43.1, "dm": -4, "offset": 0, "calc": 0.0, "kw": 1.27, "price": 50.0, "comp": 1}, {"t": "2026-01-02T03:30:00+01:00", "tout": -4.4, "tin": 22.01, "flow": 40.5, "dm": -10, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T04:00:00+01:00", "tout": -4.3, "tin": 21.98, "flow": 42.9, "dm": -63, "offset": 0, "calc": 0.0, "kw": 1.26, "price": 50.0, "comp": 1}, {"t": "2026-01-02T04:30:00+01:00", "tout": -4.2, "tin": 22.0, "flow": 42.9, "dm": -33, "offset": 0, "calc": 0.0, "kw": 1.26, "price": 50.0, "comp": 1}, {"t": "2026-01-02T05:00:00+01:00", "tout": -4.2, "tin": 22.02, "flow": 42.8, "dm": -3, "offset": 0, "calc": 0.0, "kw": 1.25, "price": 50.0, "comp": 1}, {"t": "2026-01-02T05:30:00+01:00", "tout": -4.1, "tin": 22.01, "flow": 40.3, "dm": -10, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T06:00:00+01:00", "tout": -4.0, "tin": 21.98, "flow": 42.7, "dm": -63, "offset": 0, "calc": 0.0, "kw": 1.24, "price": 50.0, "comp": 1}, {"t": "2026-01-02T06:30:00+01:00", "tout": -3.9, "tin": 22.0, "flow": 42.6, "dm": -33, "offset": 0, "calc": 0.0, "kw": 1.23, "price": 50.0, "comp": 1}, {"t": "2026-01-02T07:00:00+01:00", "tout": -3.8, "tin": 22.02, "flow": 42.5, "dm": -3, "offset": 0, "calc": 0.0, "kw": 1.23, "price": 90.0, "comp": 1}, {"t": "2026-01-02T07:30:00+01:00", "tout": -3.8, "tin": 22.01, "flow": 40.0, "dm": -9, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T08:00:00+01:00", "tout": -3.7, "tin": 21.98, "flow": 42.4, "dm": -62, "offset": 0, "calc": 0.0, "kw": 1.22, "price": 90.0, "comp": 1}, {"t": "2026-01-02T08:30:00+01:00", "tout": -3.6, "tin": 22.0, "flow": 42.4, "dm": -32, "offset": 0, "calc": 0.0, "kw": 1.21, "price": 90.0, "comp": 1}, {"t": "2026-01-02T09:00:00+01:00", "tout": -3.5, "tin": 22.02, "flow": 42.3, "dm": -2, "offset": 0, "calc": 0.0, "kw": 1.2, "price": 90.0, "comp": 1}, {"t": "2026-01-02T09:30:00+01:00", "tout": -3.4, "tin": 22.01, "flow": 39.8, "dm": -9, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T10:00:00+01:00", "tout": -3.3, "tin": 21.98, "flow": 42.2, "dm": -62, "offset": 0, "calc": 0.0, "kw": 1.19, "price": 90.0, "comp": 1}, {"t": "2026-01-02T10:30:00+01:00", "tout": -3.2, "tin": 22.0, "flow": 42.1, "dm": -32, "offset": 0, "calc": 0.0, "kw": 1.18, "price": 50.0, "comp": 1}, {"t": "2026-01-02T11:00:00+01:00", "tout": -3.2, "tin": 22.02, "flow": 42.0, "dm": -2, "offset": 0, "calc": 0.0, "kw": 1.18, "price": 50.0, "comp": 1}, {"t": "2026-01-02T11:30:00+01:00", "tout": -3.1, "tin": 22.01, "flow": 39.5, "dm": -9, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T12:00:00+01:00", "tout": -3.0, "tin": 21.98, "flow": 41.9, "dm": -62, "offset": 0, "calc": 0.0, "kw": 1.17, "price": 50.0, "comp": 1}, {"t": "2026-01-02T12:30:00+01:00", "tout": -2.9, "tin": 22.0, "flow": 41.9, "dm": -32, "offset": 0, "calc": 0.0, "kw": 1.16, "price": 50.0, "comp": 1}, {"t": "2026-01-02T13:00:00+01:00", "tout": -2.8, "tin": 22.02, "flow": 41.8, "dm": -2, "offset": 0, "calc": 0.0, "kw": 1.15, "price": 50.0, "comp": 1}, {"t": "2026-01-02T13:30:00+01:00", "tout": -2.8, "tin": 22.01, "flow": 39.3, "dm": -8, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T14:00:00+01:00", "tout": -2.7, "tin": 21.98, "flow": 41.7, "dm": -61, "offset": 0, "calc": 0.0, "kw": 1.15, "price": 50.0, "comp": 1}, {"t": "2026-01-02T14:30:00+01:00", "tout": -2.6, "tin": 22.0, "flow": 41.6, "dm": -31, "offset": 0, "calc": 0.0, "kw": 1.14, "price": 50.0, "comp": 1}, {"t": "2026-01-02T15:00:00+01:00", "tout": -2.5, "tin": 22.02, "flow": 41.5, "dm": -1, "offset": 0, "calc": 0.0, "kw": 1.13, "price": 50.0, "comp": 1}, {"t": "2026-01-02T15:30:00+01:00", "tout": -2.4, "tin": 22.01, "flow": 39.0, "dm": -8, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T16:00:00+01:00", "tout": -2.3, "tin": 21.98, "flow": 41.4, "dm": -61, "offset": 0, "calc": 0.0, "kw": 1.12, "price": 50.0, "comp": 1}, {"t": "2026-01-02T16:30:00+01:00", "tout": -2.2, "tin": 22.0, "flow": 41.4, "dm": -31, "offset": 0, "calc": 0.0, "kw": 1.12, "price": 50.0, "comp": 1}, {"t": "2026-01-02T17:00:00+01:00", "tout": -2.2, "tin": 22.02, "flow": 41.3, "dm": -1, "offset": 0, "calc": 0.0, "kw": 1.11, "price": 90.0, "comp": 1}, {"t": "2026-01-02T17:30:00+01:00", "tout": -2.1, "tin": 22.01, "flow": 38.8, "dm": -7, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T18:00:00+01:00", "tout": -2.0, "tin": 21.98, "flow": 41.2, "dm": -60, "offset": 0, "calc": 0.0, "kw": 1.1, "price": 90.0, "comp": 1}, {"t": "2026-01-02T18:30:00+01:00", "tout": -1.9, "tin": 22.0, "flow": 41.1, "dm": -30, "offset": 0, "calc": 0.0, "kw": 1.09, "price": 90.0, "comp": 1}, {"t": "2026-01-02T19:00:00+01:00", "tout": -1.8, "tin": 22.02, "flow": 41.0, "dm": 0, "offset": 0, "calc": 0.0, "kw": 1.09, "price": 90.0, "comp": 1}, {"t": "2026-01-02T19:30:00+01:00", "tout": -1.8, "tin": 22.01, "flow": 38.5, "dm": -7, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T20:00:00+01:00", "tout": -1.7, "tin": 21.98, "flow": 40.9, "dm": -60, "offset": 0, "calc": 0.0, "kw": 1.08, "price": 90.0, "comp": 1}, {"t": "2026-01-02T20:30:00+01:00", "tout": -1.6, "tin": 22.0, "flow": 40.8, "dm": -30, "offset": 0, "calc": 0.0, "kw": 1.07, "price": 50.0, "comp": 1}, {"t": "2026-01-02T21:00:00+01:00", "tout": -1.5, "tin": 22.02, "flow": 40.8, "dm": 0, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T21:30:00+01:00", "tout": -1.4, "tin": 22.0, "flow": 37.8, "dm": -21, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T22:00:00+01:00", "tout": -1.3, "tin": 21.98, "flow": 40.7, "dm": -60, "offset": 0, "calc": 0.0, "kw": 1.06, "price": 50.0, "comp": 1}, {"t": "2026-01-02T22:30:00+01:00", "tout": -1.2, "tin": 22.0, "flow": 40.6, "dm": -30, "offset": 0, "calc": 0.0, "kw": 1.05, "price": 50.0, "comp": 1}, {"t": "2026-01-02T23:00:00+01:00", "tout": -1.2, "tin": 22.02, "flow": 40.5, "dm": 0, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T23:30:00+01:00", "tout": -1.2, "tin": 22.0, "flow": 37.5, "dm": -22, "offset": 0, "calc": 0.0, "kw": 0.1, "price": 50.0, "comp": 0}] \ No newline at end of file diff --git a/scripts/simulation/output/trace-wooden_f750-selftest.json b/scripts/simulation/output/trace-wooden_f750-selftest.json deleted file mode 100644 index 92be1ae0..00000000 --- a/scripts/simulation/output/trace-wooden_f750-selftest.json +++ /dev/null @@ -1 +0,0 @@ -[{"t": "2026-01-01T00:00:00+01:00", "tout": -5.0, "tin": 21.96, "flow": 32.5, "dm": -80, "offset": 0, "calc": 0.32, "kw": 0.69, "price": 50.0, "comp": 1}, {"t": "2026-01-01T00:30:00+01:00", "tout": -4.9, "tin": 21.91, "flow": 42.4, "dm": -143, "offset": -1, "calc": -1.33, "kw": 1.26, "price": 50.0, "comp": 1}, {"t": "2026-01-01T01:00:00+01:00", "tout": -4.8, "tin": 21.92, "flow": 42.3, "dm": -113, "offset": -1, "calc": -1.32, "kw": 1.25, "price": 50.0, "comp": 1}, {"t": "2026-01-01T01:30:00+01:00", "tout": -4.8, "tin": 21.92, "flow": 42.2, "dm": -83, "offset": -1, "calc": -1.3, "kw": 1.24, "price": 50.0, "comp": 1}, {"t": "2026-01-01T02:00:00+01:00", "tout": -4.7, "tin": 21.92, "flow": 42.2, "dm": -53, "offset": -1, "calc": -1.31, "kw": 1.24, "price": 50.0, "comp": 1}, {"t": "2026-01-01T02:30:00+01:00", "tout": -4.6, "tin": 21.93, "flow": 42.1, "dm": -23, "offset": -1, "calc": -1.3, "kw": 1.23, "price": 50.0, "comp": 1}, {"t": "2026-01-01T03:00:00+01:00", "tout": -4.5, "tin": 21.93, "flow": 41.6, "dm": 5, "offset": -1, "calc": -1.78, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T03:30:00+01:00", "tout": -4.4, "tin": 21.88, "flow": 38.6, "dm": -31, "offset": -1, "calc": -0.72, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T04:00:00+01:00", "tout": -4.3, "tin": 21.85, "flow": 41.9, "dm": -52, "offset": -1, "calc": -1.27, "kw": 1.22, "price": 50.0, "comp": 1}, {"t": "2026-01-01T04:30:00+01:00", "tout": -4.2, "tin": 21.85, "flow": 41.9, "dm": -22, "offset": -1, "calc": -0.19, "kw": 1.21, "price": 50.0, "comp": 1}, {"t": "2026-01-01T05:00:00+01:00", "tout": -4.2, "tin": 21.86, "flow": 41.3, "dm": 5, "offset": -1, "calc": -0.37, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T05:30:00+01:00", "tout": -4.1, "tin": 21.82, "flow": 38.3, "dm": -41, "offset": 0, "calc": 0.41, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T06:00:00+01:00", "tout": -4.0, "tin": 21.82, "flow": 42.7, "dm": -48, "offset": 0, "calc": -0.33, "kw": 1.25, "price": 50.0, "comp": 1}, {"t": "2026-01-01T06:30:00+01:00", "tout": -3.9, "tin": 21.85, "flow": 42.6, "dm": -18, "offset": -1, "calc": -1.4, "kw": 1.24, "price": 50.0, "comp": 1}, {"t": "2026-01-01T07:00:00+01:00", "tout": -3.8, "tin": 21.85, "flow": 40.6, "dm": 5, "offset": -2, "calc": -2.16, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T07:30:00+01:00", "tout": -3.8, "tin": 21.79, "flow": 37.6, "dm": -16, "offset": -1, "calc": -0.75, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T08:00:00+01:00", "tout": -3.7, "tin": 21.74, "flow": 41.4, "dm": -75, "offset": -1, "calc": -1.22, "kw": 1.17, "price": 90.0, "comp": 1}, {"t": "2026-01-01T08:30:00+01:00", "tout": -3.6, "tin": 21.75, "flow": 41.4, "dm": -45, "offset": -1, "calc": -1.23, "kw": 1.17, "price": 90.0, "comp": 1}, {"t": "2026-01-01T09:00:00+01:00", "tout": -3.5, "tin": 21.76, "flow": 41.3, "dm": -15, "offset": -1, "calc": -1.22, "kw": 1.16, "price": 90.0, "comp": 1}, {"t": "2026-01-01T09:30:00+01:00", "tout": -3.4, "tin": 21.76, "flow": 40.3, "dm": 7, "offset": -1, "calc": -1.47, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T10:00:00+01:00", "tout": -3.3, "tin": 21.71, "flow": 37.3, "dm": -43, "offset": -1, "calc": -0.72, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T10:30:00+01:00", "tout": -3.2, "tin": 21.7, "flow": 41.1, "dm": -45, "offset": -1, "calc": -0.87, "kw": 1.15, "price": 50.0, "comp": 1}, {"t": "2026-01-01T11:00:00+01:00", "tout": -3.2, "tin": 21.71, "flow": 41.0, "dm": -15, "offset": -1, "calc": -0.86, "kw": 1.14, "price": 50.0, "comp": 1}, {"t": "2026-01-01T11:30:00+01:00", "tout": -3.1, "tin": 21.71, "flow": 39.5, "dm": 0, "offset": -1, "calc": -0.97, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T12:00:00+01:00", "tout": -3.0, "tin": 21.65, "flow": 36.5, "dm": -65, "offset": -1, "calc": -0.19, "kw": 0.9, "price": 50.0, "comp": 1}, {"t": "2026-01-01T12:30:00+01:00", "tout": -2.9, "tin": 21.66, "flow": 40.9, "dm": -44, "offset": -1, "calc": -0.68, "kw": 1.13, "price": 50.0, "comp": 1}, {"t": "2026-01-01T13:00:00+01:00", "tout": -2.8, "tin": 21.67, "flow": 40.8, "dm": -14, "offset": -1, "calc": -0.68, "kw": 1.12, "price": 50.0, "comp": 1}, {"t": "2026-01-01T13:30:00+01:00", "tout": -2.8, "tin": 21.68, "flow": 39.3, "dm": 1, "offset": -1, "calc": -0.79, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T14:00:00+01:00", "tout": -2.7, "tin": 21.62, "flow": 36.3, "dm": -65, "offset": -1, "calc": -0.17, "kw": 0.88, "price": 50.0, "comp": 1}, {"t": "2026-01-01T14:30:00+01:00", "tout": -2.6, "tin": 21.64, "flow": 41.6, "dm": -44, "offset": 0, "calc": 0.07, "kw": 1.16, "price": 50.0, "comp": 1}, {"t": "2026-01-01T15:00:00+01:00", "tout": -2.5, "tin": 21.67, "flow": 41.5, "dm": -14, "offset": 0, "calc": 0.07, "kw": 1.15, "price": 50.0, "comp": 1}, {"t": "2026-01-01T15:30:00+01:00", "tout": -2.4, "tin": 21.7, "flow": 40.0, "dm": 1, "offset": 0, "calc": 0.09, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T16:00:00+01:00", "tout": -2.3, "tin": 21.66, "flow": 37.0, "dm": -64, "offset": 0, "calc": 0.57, "kw": 0.91, "price": 50.0, "comp": 1}, {"t": "2026-01-01T16:30:00+01:00", "tout": -2.2, "tin": 21.69, "flow": 41.4, "dm": -44, "offset": 0, "calc": -0.76, "kw": 1.13, "price": 50.0, "comp": 1}, {"t": "2026-01-01T17:00:00+01:00", "tout": -2.2, "tin": 21.72, "flow": 41.3, "dm": -14, "offset": -1, "calc": -1.09, "kw": 1.13, "price": 90.0, "comp": 1}, {"t": "2026-01-01T17:30:00+01:00", "tout": -2.1, "tin": 21.72, "flow": 38.8, "dm": 2, "offset": -1, "calc": -1.15, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T18:00:00+01:00", "tout": -2.0, "tin": 21.66, "flow": 35.8, "dm": -64, "offset": -1, "calc": -0.48, "kw": 0.83, "price": 90.0, "comp": 1}, {"t": "2026-01-01T18:30:00+01:00", "tout": -1.9, "tin": 21.67, "flow": 40.1, "dm": -43, "offset": -1, "calc": -0.96, "kw": 1.06, "price": 90.0, "comp": 1}, {"t": "2026-01-01T19:00:00+01:00", "tout": -1.8, "tin": 21.68, "flow": 40.0, "dm": -13, "offset": -1, "calc": -0.95, "kw": 1.05, "price": 90.0, "comp": 1}, {"t": "2026-01-01T19:30:00+01:00", "tout": -1.8, "tin": 21.68, "flow": 38.5, "dm": 2, "offset": -1, "calc": -1.11, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-01T20:00:00+01:00", "tout": -1.7, "tin": 21.63, "flow": 35.5, "dm": -63, "offset": -1, "calc": -0.45, "kw": 0.82, "price": 90.0, "comp": 1}, {"t": "2026-01-01T20:30:00+01:00", "tout": -1.6, "tin": 21.63, "flow": 39.8, "dm": -43, "offset": -1, "calc": -0.59, "kw": 1.04, "price": 50.0, "comp": 1}, {"t": "2026-01-01T21:00:00+01:00", "tout": -1.5, "tin": 21.65, "flow": 39.8, "dm": -13, "offset": -1, "calc": -0.6, "kw": 1.03, "price": 50.0, "comp": 1}, {"t": "2026-01-01T21:30:00+01:00", "tout": -1.4, "tin": 21.65, "flow": 38.3, "dm": 2, "offset": -1, "calc": -0.7, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-01T22:00:00+01:00", "tout": -1.3, "tin": 21.59, "flow": 35.3, "dm": -63, "offset": -1, "calc": -0.09, "kw": 0.8, "price": 50.0, "comp": 1}, {"t": "2026-01-01T22:30:00+01:00", "tout": -1.2, "tin": 21.6, "flow": 39.6, "dm": -43, "offset": -1, "calc": -0.57, "kw": 1.02, "price": 50.0, "comp": 1}, {"t": "2026-01-01T23:00:00+01:00", "tout": -1.2, "tin": 21.62, "flow": 39.5, "dm": -13, "offset": -1, "calc": -0.57, "kw": 1.01, "price": 50.0, "comp": 1}, {"t": "2026-01-01T23:30:00+01:00", "tout": -3.1, "tin": 21.62, "flow": 38.8, "dm": -5, "offset": -1, "calc": -0.86, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T00:00:00+01:00", "tout": -5.0, "tin": 21.56, "flow": 41.8, "dm": -74, "offset": -1, "calc": -0.89, "kw": 1.24, "price": 50.0, "comp": 1}, {"t": "2026-01-02T00:30:00+01:00", "tout": -4.9, "tin": 21.58, "flow": 42.4, "dm": -44, "offset": -1, "calc": -0.97, "kw": 1.27, "price": 50.0, "comp": 1}, {"t": "2026-01-02T01:00:00+01:00", "tout": -4.8, "tin": 21.59, "flow": 42.3, "dm": -14, "offset": -1, "calc": -0.96, "kw": 1.27, "price": 50.0, "comp": 1}, {"t": "2026-01-02T01:30:00+01:00", "tout": -4.8, "tin": 21.6, "flow": 40.8, "dm": 1, "offset": -1, "calc": -1.09, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T02:00:00+01:00", "tout": -4.7, "tin": 21.54, "flow": 37.8, "dm": -65, "offset": -1, "calc": -0.39, "kw": 1.01, "price": 50.0, "comp": 1}, {"t": "2026-01-02T02:30:00+01:00", "tout": -4.6, "tin": 21.55, "flow": 42.1, "dm": -44, "offset": -1, "calc": -0.93, "kw": 1.25, "price": 50.0, "comp": 1}, {"t": "2026-01-02T03:00:00+01:00", "tout": -4.5, "tin": 21.57, "flow": 42.1, "dm": -14, "offset": -1, "calc": -0.94, "kw": 1.24, "price": 50.0, "comp": 1}, {"t": "2026-01-02T03:30:00+01:00", "tout": -4.4, "tin": 21.58, "flow": 40.5, "dm": 1, "offset": -1, "calc": -1.06, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T04:00:00+01:00", "tout": -4.3, "tin": 21.52, "flow": 37.5, "dm": -64, "offset": -1, "calc": -0.37, "kw": 0.99, "price": 50.0, "comp": 1}, {"t": "2026-01-02T04:30:00+01:00", "tout": -4.2, "tin": 21.53, "flow": 41.9, "dm": -44, "offset": -1, "calc": -0.1, "kw": 1.23, "price": 50.0, "comp": 1}, {"t": "2026-01-02T05:00:00+01:00", "tout": -4.2, "tin": 21.55, "flow": 41.8, "dm": -14, "offset": -1, "calc": -0.09, "kw": 1.22, "price": 50.0, "comp": 1}, {"t": "2026-01-02T05:30:00+01:00", "tout": -4.1, "tin": 21.56, "flow": 40.3, "dm": 2, "offset": -1, "calc": -0.08, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T06:00:00+01:00", "tout": -4.0, "tin": 21.52, "flow": 40.3, "dm": -69, "offset": 0, "calc": 0.09, "kw": 1.13, "price": 50.0, "comp": 1}, {"t": "2026-01-02T06:30:00+01:00", "tout": -3.9, "tin": 21.56, "flow": 42.6, "dm": -39, "offset": -1, "calc": -1.02, "kw": 1.26, "price": 50.0, "comp": 1}, {"t": "2026-01-02T07:00:00+01:00", "tout": -3.8, "tin": 21.57, "flow": 41.5, "dm": -9, "offset": -1, "calc": -1.61, "kw": 1.19, "price": 90.0, "comp": 1}, {"t": "2026-01-02T07:30:00+01:00", "tout": -3.8, "tin": 21.57, "flow": 39.5, "dm": -3, "offset": -1, "calc": -1.31, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T08:00:00+01:00", "tout": -3.7, "tin": 21.52, "flow": 39.5, "dm": -68, "offset": -1, "calc": -0.96, "kw": 1.08, "price": 90.0, "comp": 1}, {"t": "2026-01-02T08:30:00+01:00", "tout": -3.6, "tin": 21.54, "flow": 41.4, "dm": -38, "offset": -1, "calc": -1.2, "kw": 1.18, "price": 90.0, "comp": 1}, {"t": "2026-01-02T09:00:00+01:00", "tout": -3.5, "tin": 21.56, "flow": 41.3, "dm": -8, "offset": -1, "calc": -1.59, "kw": 1.17, "price": 90.0, "comp": 1}, {"t": "2026-01-02T09:30:00+01:00", "tout": -3.4, "tin": 21.56, "flow": 39.3, "dm": -3, "offset": -1, "calc": -1.3, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T10:00:00+01:00", "tout": -3.3, "tin": 21.5, "flow": 39.3, "dm": -68, "offset": -1, "calc": -0.94, "kw": 1.06, "price": 90.0, "comp": 1}, {"t": "2026-01-02T10:30:00+01:00", "tout": -3.2, "tin": 21.52, "flow": 41.1, "dm": -38, "offset": -1, "calc": -0.84, "kw": 1.16, "price": 50.0, "comp": 1}, {"t": "2026-01-02T11:00:00+01:00", "tout": -3.2, "tin": 21.54, "flow": 41.0, "dm": -8, "offset": -1, "calc": -1.16, "kw": 1.15, "price": 50.0, "comp": 1}, {"t": "2026-01-02T11:30:00+01:00", "tout": -3.1, "tin": 21.54, "flow": 39.0, "dm": -2, "offset": -1, "calc": -0.87, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T12:00:00+01:00", "tout": -3.0, "tin": 21.49, "flow": 39.0, "dm": -68, "offset": -1, "calc": -0.45, "kw": 1.04, "price": 50.0, "comp": 1}, {"t": "2026-01-02T12:30:00+01:00", "tout": -2.9, "tin": 21.51, "flow": 40.9, "dm": -38, "offset": -1, "calc": -0.66, "kw": 1.13, "price": 50.0, "comp": 1}, {"t": "2026-01-02T13:00:00+01:00", "tout": -2.8, "tin": 21.53, "flow": 40.8, "dm": -8, "offset": -1, "calc": -0.97, "kw": 1.13, "price": 50.0, "comp": 1}, {"t": "2026-01-02T13:30:00+01:00", "tout": -2.8, "tin": 21.53, "flow": 38.8, "dm": -2, "offset": -1, "calc": -0.7, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T14:00:00+01:00", "tout": -2.7, "tin": 21.48, "flow": 38.8, "dm": -67, "offset": -1, "calc": -0.43, "kw": 1.02, "price": 50.0, "comp": 1}, {"t": "2026-01-02T14:30:00+01:00", "tout": -2.6, "tin": 21.51, "flow": 41.6, "dm": -37, "offset": 0, "calc": 0.08, "kw": 1.16, "price": 50.0, "comp": 1}, {"t": "2026-01-02T15:00:00+01:00", "tout": -2.5, "tin": 21.55, "flow": 41.5, "dm": -7, "offset": 0, "calc": -0.08, "kw": 1.16, "price": 50.0, "comp": 1}, {"t": "2026-01-02T15:30:00+01:00", "tout": -2.4, "tin": 21.57, "flow": 39.5, "dm": -2, "offset": 0, "calc": 0.18, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T16:00:00+01:00", "tout": -2.3, "tin": 21.54, "flow": 39.5, "dm": -67, "offset": 0, "calc": 0.31, "kw": 1.05, "price": 50.0, "comp": 1}, {"t": "2026-01-02T16:30:00+01:00", "tout": -2.2, "tin": 21.58, "flow": 41.4, "dm": -37, "offset": 0, "calc": -0.75, "kw": 1.14, "price": 50.0, "comp": 1}, {"t": "2026-01-02T17:00:00+01:00", "tout": -2.2, "tin": 21.62, "flow": 41.3, "dm": -7, "offset": -1, "calc": -1.46, "kw": 1.13, "price": 90.0, "comp": 1}, {"t": "2026-01-02T17:30:00+01:00", "tout": -2.1, "tin": 21.61, "flow": 38.3, "dm": -1, "offset": -1, "calc": -1.07, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T18:00:00+01:00", "tout": -2.0, "tin": 21.56, "flow": 38.3, "dm": -66, "offset": -1, "calc": -0.74, "kw": 0.97, "price": 90.0, "comp": 1}, {"t": "2026-01-02T18:30:00+01:00", "tout": -1.9, "tin": 21.57, "flow": 40.1, "dm": -36, "offset": -1, "calc": -0.94, "kw": 1.06, "price": 90.0, "comp": 1}, {"t": "2026-01-02T19:00:00+01:00", "tout": -1.8, "tin": 21.59, "flow": 40.0, "dm": -6, "offset": -1, "calc": -1.3, "kw": 1.06, "price": 90.0, "comp": 1}, {"t": "2026-01-02T19:30:00+01:00", "tout": -1.8, "tin": 21.59, "flow": 38.0, "dm": -1, "offset": -1, "calc": -1.03, "kw": 0.1, "price": 90.0, "comp": 0}, {"t": "2026-01-02T20:00:00+01:00", "tout": -1.7, "tin": 21.54, "flow": 38.0, "dm": -66, "offset": -1, "calc": -0.71, "kw": 0.95, "price": 90.0, "comp": 1}, {"t": "2026-01-02T20:30:00+01:00", "tout": -1.6, "tin": 21.55, "flow": 39.8, "dm": -36, "offset": -1, "calc": -0.58, "kw": 1.04, "price": 50.0, "comp": 1}, {"t": "2026-01-02T21:00:00+01:00", "tout": -1.5, "tin": 21.57, "flow": 39.8, "dm": -6, "offset": -1, "calc": -0.88, "kw": 1.04, "price": 50.0, "comp": 1}, {"t": "2026-01-02T21:30:00+01:00", "tout": -1.4, "tin": 21.57, "flow": 37.8, "dm": -1, "offset": -1, "calc": -0.62, "kw": 0.1, "price": 50.0, "comp": 0}, {"t": "2026-01-02T22:00:00+01:00", "tout": -1.3, "tin": 21.52, "flow": 37.8, "dm": -66, "offset": -1, "calc": -0.36, "kw": 0.93, "price": 50.0, "comp": 1}, {"t": "2026-01-02T22:30:00+01:00", "tout": -1.2, "tin": 21.53, "flow": 39.6, "dm": -36, "offset": -1, "calc": -0.57, "kw": 1.02, "price": 50.0, "comp": 1}, {"t": "2026-01-02T23:00:00+01:00", "tout": -1.2, "tin": 21.55, "flow": 39.5, "dm": -6, "offset": -1, "calc": -0.85, "kw": 1.02, "price": 50.0, "comp": 1}, {"t": "2026-01-02T23:30:00+01:00", "tout": -1.2, "tin": 21.55, "flow": 37.5, "dm": -1, "offset": -1, "calc": -0.58, "kw": 0.1, "price": 50.0, "comp": 0}] \ No newline at end of file diff --git a/scripts/simulation/sim_harness.py b/scripts/simulation/sim_harness.py index d29012dc..6d610362 100644 --- a/scripts/simulation/sim_harness.py +++ b/scripts/simulation/sim_harness.py @@ -21,7 +21,7 @@ - DM integrates (flow_actual - flow_target) minutes, clamped to [-3000, 100] - Heat output Q = K_EMIT * (flow - Tin); K_EMIT sized for design point - Electrical power = Q / COP(Tout) from the pump profile curve - - Aux heat: DM below -1500 adds electric aux steps (like real NIBE) + - Aux heat: engages at the pump's own factory start-addition DM (menu 4.9.3) The engine's wall-clock reads (dt_util.now/utcnow) are monkeypatched to the simulation clock each step so price-quarter and forecast logic see sim time. @@ -30,30 +30,48 @@ sim-results/. Run: .venv/bin/python sim_harness.py [--selftest] """ +import asyncio +import functools import json import sys import zoneinfo -from dataclasses import dataclass + +import numpy as np +from dataclasses import dataclass, replace from datetime import datetime, timedelta from pathlib import Path -from unittest.mock import MagicMock +from typing import Any +from unittest.mock import AsyncMock, MagicMock sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from homeassistant.util import dt as dt_util -from custom_components.effektguard.adapters.gespot_adapter import PriceData, QuarterPeriod +from custom_components.effektguard.adapters.gespot_adapter import GESpotAdapter, PriceData +from custom_components.effektguard.const import ( + CONF_GESPOT_ENTITY, + INTERNAL_GAINS_W, + POWER_SOURCE_EXTERNAL_METER, + SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH, +) +from custom_components.effektguard.utils.emitter import en442_flow_temp +from custom_components.effektguard.utils.offset import integer_offset_for +from custom_components.effektguard.utils.time_utils import QUARTERS_PER_HOUR from custom_components.effektguard.adapters.nibe_adapter import NibeState from custom_components.effektguard.adapters.weather_adapter import ( WeatherData, WeatherForecastHour, ) -try: - from custom_components.effektguard.models.nibe import NibeF750Profile, NibeF1155Profile -except ImportError: # F1155 profile ships with the multi-source PR (#19) - from custom_components.effektguard.models.nibe import NibeF750Profile - from custom_components.effektguard.models.nibe import NibeS1155Profile as NibeF1155Profile +from custom_components.effektguard.models.nibe import ( + NibeF730Profile, + NibeF750Profile, + NibeF1155Profile, + NibeF2040Profile, + NibeS1155Profile, +) +from custom_components.effektguard.optimization.billing_period import BillingPeriodAccumulator +from custom_components.effektguard.optimization.effect_layer import effective_tariff_power_kw from custom_components.effektguard.optimization.decision_engine import DecisionEngine from custom_components.effektguard.optimization.effect_layer import EffectManager from custom_components.effektguard.optimization.price_layer import PriceAnalyzer @@ -64,25 +82,135 @@ DATA_DIR = Path(__file__).parent / "data" OUT_DIR = Path(__file__).parent / "output" +# The archived Nordpool files quote SEK/MWh; GE-Spot publishes what the user +# configured, which for a Swedish user is conventionally öre/kWh. +# 1 SEK/MWh = 0.1 öre/kWh. +ORE_PER_KWH_FROM_SEK_PER_MWH = 0.1 +GESPOT_UNIT_ORE = "öre/kWh" + # Plant constants -FLOW_RAMP_ON = 0.5 # C/min toward target while compressor runs -FLOW_DECAY_OFF = 0.1 # C/min toward indoor when off DM_START = -60.0 DM_STOP = 0.0 -DM_AUX = -1500.0 -AUX_STEP_KW = 3.0 # one aux step +# THE F2040 HAS NO IMMERSION HEATER. It is an outdoor monobloc; its electric addition lives in the +# indoor module it is paired with (a VVM or SMO), which this package does not model. Every other +# machine's heater is on its profile, from its datasheet. This is the fallback for the F2040 alone, +# and it is an ASSUMPTION about that indoor module - not a NIBE figure - so it is named as one. +# +# It matters: the immersion burn is a headline number in the saturated-compressor finding, and the +# simulator used to apply this one invented value to all five machines, matching none of them. +ASSUMED_INDOOR_MODULE_HEATER_KW = 3.0 +STANDBY_KW = 0.1 # controller, pumps, standby losses +J_PER_KWH = 3_600_000.0 + +# Float arithmetic only. A real leak is orders of magnitude bigger: the one this replaced a fake +# audit to catch was 183 kWh. +WATER_NODE_LEAK_BUDGET_KWH = 0.5 + +# How far the run's seasonal COP may exceed the datasheet's own figure for the weather it saw. +# The healthy range, measured across all five houses and all four scenarios, is 0.72 to 1.03; the +# margin is for the mild hours when the curve runs water below the W35 rating point and the pump +# legitimately beats its rating. Doubling the plant's COP lands at 1.5 to 2.1 and is caught on +# every house - which is the whole point, because the identity this replaced called that PASS. +COP_ENVELOPE_TOLERANCE = 1.15 + +# Heat capacity of the water loop and the emitter metal it fills. Roughly 70 L of water +# (0.081 kWh/K) plus the steel of the radiators. Without this the plant HANDS OUT the heat stored +# in the water for free every time the compressor stops, and charges nothing to put it back. +WATER_LOOP_J_PER_K = 350_000.0 # ~0.10 kWh/K +COMPRESSOR_RESPONSE_S = 900.0 # how briskly the compressor closes on its flow target + +# Bounds on the degree-minute integrator. Reaching the floor is not a normal operating state: it +# means the deficit grew without limit despite the curve offset AND the auxiliary heater, so the +# recovery system failed. The harness treats it as such. +DM_INTEGRATOR_FLOOR = -3000.0 +DM_INTEGRATOR_CEILING = 100.0 + +# Above this the house is not "warm", it is being cooked - and on a heat pump it is usually the +# immersion heater doing it, at COP 1.0. +INDOOR_CEILING = 26.0 TOMORROW_VISIBLE_HOUR = 13 # Nordpool day-ahead published ~12:45 CET +QUARTER_MINUTES = 15 +SIM_DAYS = 31 +# The --dst run: Sat 24 Oct through Mon 26 Oct 2026, spanning the fall-back night. +DST_SIM_DAYS = 3 +# 2026-10-25: at 03:00 CEST the clock goes back to 02:00 CET, so the day is 25 hours long and +# the wall-clock hour 02 is metered twice. From the tz database, not from an assumption. +DST_FALL_BACK_DAY = "2026-10-25" +DST_FALL_BACK_HOURS = 25 + +# CAPACITY AND COP NOW COME FROM THE DATASHEET. See HouseConfig.capacity_kw_at / cop_at. +# +# What used to be here was ASHP_DERATE_PER_C = 0.025, "fraction of rated output lost per C below +# A7", justified by a comment claiming the EN 14511 rating points "trace a near-linear decline". +# They trace a near-linear RISE. The whole derating was invented, backwards, and cited to a +# standard that says the opposite. It is gone. +# +# COP is set by the LIFT, not by the weather. These place the source and the condenser. +KELVIN = 273.15 +# The exergy penalty for hotter water, BEYOND what Carnot already accounts for. Measured on the +# machines whose datasheets identify it (F1155/S1155: -0.00552/K; F2040: -0.00277/K) and imported +# as a STATED ASSUMPTION by the two whose datasheets cannot (F750/F730 confound load with flow). +# The value is the arithmetic mean of the two measured ones - it used to say that while being +# -0.0046, which is the mean of nothing. +FLOW_EXERGY_PENALTY_PER_K = -0.00415 + +# Physical bounds on the exergy efficiency. A real machine achieves 30-70% of Carnot; these only +# stop a fit extrapolating off the end of its own data into nonsense, which the first version did. +# a + b*load + c*(flow-35). Three of them, so a fit needs at least four points to have any +# degrees of freedom at all - see HouseConfig.exergy_fit. +EXERGY_FIT_PARAMETERS = 3 + +MIN_EXERGY_EFFICIENCY = 0.15 +MAX_EXERGY_EFFICIENCY = 0.80 + +COP_RATING_FLOW_C = 35.0 # EN 14511 rating point is W35: the profile's COP curve is measured here +CONDENSER_APPROACH_K = 5.0 # refrigerant condenses this far above the water it is heating +EVAPORATOR_APPROACH_K = 5.0 # and evaporates this far below the source it is drawing from +MIN_LIFT_K = 10.0 # a compressor cannot usefully run at zero lift; bound the division +EXHAUST_AIR_SOURCE_C = 20.0 # F750/F730 draw ~20 C indoor extract air, all year +BRINE_SOURCE_C = 0.0 # F1155/S1155 draw ~0 C brine, stable year-round + # Comfort accounting matches the engine's configured tolerance (not a looser # ad-hoc band): minutes below target-tolerance count as under-heating. TARGET_INDOOR = 22.0 COMFORT_TOLERANCE = 0.5 +# THE DESIGN TEMPERATURE IS THE SIZING CONVENTION, AND IT IS LOAD-BEARING. +# +# Houses are sized from their pump's Pdesignh, so the design temperature decides how big each house +# is - and therefore whether the pump ever saturates at all. It moved the F750 between "saturates in +# a cold snap" and "does not". That is exactly the kind of arbitrary, unexamined choice this audit +# exists to find, and it used to be -15.0 with no justification whatsoever. +# +# NIBE declares Pdesignh at BOTH EN 14825 reference climates, and both are published: +# +# cold (-22 C) the Nordic reference. A Swedish house is sized here. +# average (-10 C) the central-European reference. The F730's ErP block confirms it by +# declaring TOL = -10 C. +# +# This is a Swedish integration simulating a Swedish January, so the COLD reference is the honest +# default. The average-climate sizing is not discarded - it is a real case (a pump under-sized for +# its house, which is the commonest installation fault there is) and `--undersized` runs it. The +# saturation finding is reported across BOTH, because it must not depend on which one I picked. +EN14825_COLD_DESIGN_C = -22.0 +EN14825_AVERAGE_DESIGN_C = -10.0 +DESIGN_OUTDOOR = EN14825_COLD_DESIGN_C + +# Sizing a house at the average-climate design point instead of the cold one makes it this much +# bigger for the same pump - i.e. it is the same as fitting a pump one size too small. +UNDERSIZED_PUMP_FACTOR = (22.0 - EN14825_COLD_DESIGN_C) / (22.0 - EN14825_AVERAGE_DESIGN_C) +DESIGN_SPREAD = 5.0 +RADIATOR_EXPONENT = 1.3 # EN 442 +UFH_EXPONENT = 1.1 # EN 1264 OVERSHOOT_TOLERANCE = 1.5 # overshoot band stays wider; heat is banked, not lost # Illustrative Swedish effect tariff (SEK per kW of the mean of the top-3 # daily quarter-hour-mean peaks, per month). Rate is fictional-but-typical; # the point is comparing runs, not billing accuracy. -EFFECT_TARIFF_SEK_PER_KW = 81.25 +# Ellevio's published rate, and it lives in const.py now - see SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH. +# The harness used to carry its own copy and call it "fictional-but-typical". It is neither: it is +# Ellevio's real 81,25 kr/kW/month, and production carried a DIFFERENT unsourced number (50.0). +EFFECT_TARIFF_SEK_PER_KW = SWEDISH_EFFECT_TARIFF_SEK_PER_KW_MONTH @dataclass @@ -95,47 +223,521 @@ class HouseConfig: profile: object heating_type: str design_flow: float # flow temp at design outdoor -15C - max_heat_kw: float @property def capacity_j_per_k(self) -> float: return self.hlc_w_per_k * self.tau_hours * 3600.0 @property - def k_emit(self) -> float: - # Sized so design heat demand is met at design flow with Tin=22 - design_q = self.hlc_w_per_k * (22.0 - (-15.0)) - return design_q / (self.design_flow - 22.0) + def emitter_exponent(self) -> float: + """EN 442 / EN 1264 exponent for this house's emitters.""" + return UFH_EXPONENT if self.heating_type != "radiator" else RADIATOR_EXPONENT @property - def curve_slope(self) -> float: - # Curve calibrated so the plant balances at 22 C indoor with offset 0 - # (a correctly tuned NIBE): flow_target(-15) == design_flow - return (self.design_flow - 22.0) / 37.0 + def design_excess(self) -> float: + """Mean water temperature above the room at the design point.""" + return self.design_flow - DESIGN_SPREAD / 2.0 - TARGET_INDOOR + @property + def design_heat_w(self) -> float: + """Emitter output at the design point - net of the free heat the house makes itself.""" + return self.hlc_w_per_k * (TARGET_INDOOR - DESIGN_OUTDOOR) - INTERNAL_GAINS_W + + def heat_output_w(self, flow: float, indoor: float) -> float: + """Emitter output, by the EN 442 characteristic equation. + + Q / Q_design = (dT_mean / dT_mean_design) ** n + + A LINEAR emitter (n = 1) is not a radiator: it exaggerates output at low flow + temperatures, which flatters a controller that under-supplies. + + THE SPREAD IS CONSTANT. This model used to widen it with load - `DESIGN_SPREAD * + load_ratio` - and iterate to a fixed point. That is a FIXED-SPEED circulator on a wet + boiler: constant mass flow, so the flow-return spread rises and falls with the heat being + carried. A NIBE modulates its circulator (GP1) to HOLD the commissioned spread and varies + the flow RATE instead, which is why the controller's own emitter law holds it constant too. + + With the spread fixed there is no fixed point left to solve: the mean water temperature is + just `flow - spread/2`, and the output follows directly. + """ + excess = flow - DESIGN_SPREAD / 2.0 - indoor + if excess <= 0: + return 0.0 + return self.design_heat_w * (excess / self.design_excess) ** self.emitter_exponent + + def curve_flow_temp(self, outdoor: float, tuned: bool = False) -> float: + """The supply temperature the pump's own heating curve calls for, at offset 0. + + A correctly tuned NIBE curve follows the emitter law, not a straight line. NIBE's + published curve 9 (offset 0) reads 41 C at 0 C outdoor; the emitter law gives 40.6 C, + a straight line between the same anchors gives 38.7 C. Modelling the curve as linear + makes it under-supply everywhere between its endpoints, and the house cannot hold target + even with the controller switched off. + """ + # A STOCK NIBE CURVE HAS NO INTERNAL-GAINS TERM, and that is not an oversight in this + # model - it is what the hardware does. The installer picks a curve number and the pump + # draws a line from the design point; nothing in it knows that the occupants and the + # fridge are supplying several hundred watts. So a stock curve OVER-SUPPLIES in mild + # weather, and the simulated baseline house duly sits at 22.5 C against a 22.0 C target. + # + # WHICH MEANS THE DEFAULT BASELINE IS A SOFT ONE, AND I WAS QUOTING SAVINGS AGAINST IT. + # A diligent owner trims the curve down until the house actually holds target, and against + # THAT baseline the optimiser's saving falls from 1.5-4.4 % to 0.5-1.4 %. Most of what I + # reported was the controller correcting a mis-tuned curve rather than optimising anything. + # + # Both yardsticks are real and they answer different questions, so the harness offers both: + # `--tuned-baseline` gives the pump a curve that knows about the gains, which is the honest + # question "what is this worth to someone whose pump is already set up properly?" + return en442_flow_temp( + indoor_setpoint=TARGET_INDOOR, + outdoor_temp=outdoor, + design_outdoor_temp=DESIGN_OUTDOOR, + design_flow_temp=self.design_flow, + design_spread=DESIGN_SPREAD, + emitter_exponent=self.emitter_exponent, + balance_point_temp=( + TARGET_INDOOR - INTERNAL_GAINS_W / self.hlc_w_per_k if tuned else None + ), + ) + + @property + def immersion_heater_kw(self) -> float: + """This machine's immersion heater, from its datasheet. The F2040 has none. + + The plant used to give every house the same invented 3.0 kW, which is no machine's actual + setting. NIBE ships the F750 and F730 with a 6.5 kW heater set to 3.5 kW at delivery, and + the F1155-12/S1155-12 with a 7 kW heater in seven automatic steps. + """ + published = float(getattr(self.profile, "immersion_heater_kw", 0.0)) + return published if published > 0.0 else ASSUMED_INDOOR_MODULE_HEATER_KW + + @property + def aux_start_dm(self) -> float: + """Where the PLANT's additive heat engages: the pump's own factory start-addition. + + Not EffektGuard's -1500 emergency floor. A factory-default F750 fires its elpatron at + DM -700 and works the debt back up (menu 4.9.3; audit F-112) - waiting for the floor + under-fired the elpatron by hundreds of degree-minutes in exactly the runs meant to + measure what it costs, and the cold-snap headline was computed against a machine no + factory ships. + """ + return float(self.profile.aux_start_dm) + + def source_temp_c(self, outdoor_temp: float) -> float: + """The temperature of the heat SOURCE the compressor is lifting from. + + A heat pump's efficiency is set by the LIFT - how far it has to raise the heat - not by + the weather as such. What the weather changes is the source, and only for some machines: + + - Outdoor air (F2040): the source IS the outdoor air. + - Exhaust air (F750, F730): ~20 C indoor extract air, all year. The weather barely touches + it, which is why these pumps hold their COP through a cold snap. + - Ground source (F1155, S1155): ~0 C brine, stable year-round. + """ + if getattr(self.profile, "supports_exhaust_airflow", False): + return EXHAUST_AIR_SOURCE_C + if "GSHP" in getattr(self.profile, "model_type", ""): + return BRINE_SOURCE_C + return outdoor_temp + + @functools.cached_property + def exergy_fit(self) -> tuple[float, float, float]: + """(a, b, c) in eta = a + b*load + c*(flow - 35), fitted to this machine's OWN datasheet. + + THE COP MODEL USED TO BE ANCHORED ON A CURVE THAT WAS INVENTED. Every profile carried an + outdoor-keyed `cop_curve`, called "Real-world COP curve (tested and validated)" and sourced + to "NIBE F750 datasheet, Swedish NIBE forum validation". The F750 and F730 shipped + byte-identical curves despite being different machines, and the number 5.0 - labelled "Best + COP" - appears in neither datasheet. The simulator computed a month of kWh and SEK from it + and I published the savings. + + A heat pump's COP is the Carnot limit between its source and its sink, degraded by how good + the machine is, how hard it is pushed, and how hot the water is. All of that is IN the + datasheet: + + eta = COP_published / Carnot(source, flow) at each published rating point + load = PH_published / PH_max at that same point + + AND MY FIRST VERSION OF THIS FIT WAS ITSELF A FICTION. Fitting all three of the F750's + points gave b = +0.586 - efficiency RISING with load, which is backwards - and it + extrapolated to COP 9.86 at full load and 35 C flow. The simulator visits that condition, + and the Carnot guard (ceiling 12.5 there) would have waved it straight through. + + The cause was in the datasheet and I had not read it closely enough: the F750's two + MINIMUM-frequency points differ by AIRFLOW (108 vs 252 m3/h), not by compressor load. More + ventilation air, more source heat, higher output AND higher COP. They are not a load pair. + Drop the off-rating airflow point and only TWO usable points remain - and between them load + and flow move TOGETHER, so the F750's datasheet cannot separate the two effects at all. Any + fit that claims to is fitting noise. + + So the flow penalty is MEASURED where the data identifies it, and IMPORTED where it does + not, and the difference is stated rather than hidden: + + F1155 / S1155 c = -0.0055 /K measured (0/35 vs 0/45, and 10/35 vs 10/45) + F2040 c = -0.0028 /K measured (7/35 vs 7/45, and 2/35 vs 2/45) + F750 / F730 NOT IDENTIFIABLE - the mean of the above, as a stated ASSUMPTION + + That assumption is not a measurement of the F750 and nothing here pretends it is. + """ + rated_airflow = max( + (p.airflow_m3h for p in self.profile.datasheet_points if p.airflow_m3h), default=None + ) + points = [ + p + for p in self.profile.datasheet_points + if rated_airflow is None or p.airflow_m3h == rated_airflow + ] + ph_max = self.profile.max_heat_output_kw + + def eta(point) -> float: + return point.cop / self.carnot_at(point.source_temp_c, point.flow_temp_c) + + # THE FLOW PENALTY IS ONLY IDENTIFIABLE WITH MORE POINTS THAN PARAMETERS. + # + # My first identifiability test asked whether any two points shared a source temperature + # and differed in flow. The F750's two rated-airflow points do - but they ALSO differ in + # load, so the two effects are still confounded, and lstsq happily solved 3 unknowns from + # 2 equations and returned a minimum-norm answer with b = +0.10: efficiency rising with + # load. Backwards again, from a test I wrote to catch exactly that. + # + # Three parameters need at least four points. That is the whole condition. + if len(points) > EXERGY_FIT_PARAMETERS: + design = np.array( + [ + [1.0, p.heat_output_kw / ph_max, p.flow_temp_c - COP_RATING_FLOW_C] + for p in points + ] + ) + target = np.array([eta(p) for p in points]) + a, b, c = np.linalg.lstsq(design, target, rcond=None)[0] + return float(a), float(b), float(c) + + c = FLOW_EXERGY_PENALTY_PER_K + design = np.array([[1.0, p.heat_output_kw / ph_max] for p in points]) + target = np.array([eta(p) - c * (p.flow_temp_c - COP_RATING_FLOW_C) for p in points]) + a, b = np.linalg.lstsq(design, target, rcond=None)[0] + return float(a), float(b), c + + def exergy_efficiency(self, load_fraction: float, flow_temp: float) -> float: + """How much of Carnot this machine actually achieves, here. From its own datasheet.""" + a, b, c = self.exergy_fit + eta = a + b * min(max(load_fraction, 0.0), 1.0) + c * (flow_temp - COP_RATING_FLOW_C) + return min(max(eta, MIN_EXERGY_EFFICIENCY), MAX_EXERGY_EFFICIENCY) + + def cop_at(self, outdoor_temp: float, flow_temp: float, load_fraction: float = 1.0) -> float: + """COP = exergy_efficiency(load, flow) x Carnot(source, flow). No invented curve. + + Note what is NOT here: the outdoor temperature. It enters only through `source_temp_c`, and + for four of the five machines it does not enter at all - an exhaust-air pump breathes 20 C + house air and a ground-source pump drinks 0 C brine, whatever the weather is doing. The + model this replaces dropped an F1155's COP from 5.3 to 3.3 because the air outside got + cold, while its heat source sat at 0 C and never moved. + """ + source = self.source_temp_c(outdoor_temp) + return max( + 1.0, + self.exergy_efficiency(load_fraction, flow_temp) * self.carnot_at(source, flow_temp), + ) + + def carnot_at(self, source_temp: float, flow_temp: float) -> float: + """The thermodynamic ceiling between a SOURCE and a SINK.""" + t_cond = flow_temp + CONDENSER_APPROACH_K + KELVIN + t_evap = source_temp - EVAPORATOR_APPROACH_K + KELVIN + return t_cond / max(t_cond - t_evap, MIN_LIFT_K) + + def carnot_cop(self, outdoor_temp: float, flow_temp: float) -> float: + """The Carnot bound at this weather. The harness asserts the plant never beats it.""" + return self.carnot_at(self.source_temp_c(outdoor_temp), flow_temp) + + def capacity_kw_at(self, outdoor_temp: float) -> float: + """The most heat this machine can make right now. FROM ITS DATASHEET. + + AND IT DOES NOT DERATE AS IT GETS COLDER. It rises. + + This method used to be: + + derate = 1.0 - ASHP_DERATE_PER_C * max(0.0, ASHP_RATING_POINT_C - outdoor_temp) + return rated * max(ASHP_MIN_CAPACITY_FRACTION, derate) + + with a comment claiming "the EN 14511 rating points (A7/W35, A2/W35, A-7/W35, A-15/W35) + trace a near-linear decline". They trace a near-linear RISE. The F2040-8's published + capacity goes 3.86 -> 5.11 -> 6.60 kW from +7 to +2 to -7 C, because it is an INVERTER: at + its +7 rating point it is throttled back to part load, and as the weather cools it ramps + the compressor UP. What collapses in the cold is the COP (4.65 -> 3.76 -> 2.68), not the + capacity. There is no derating table in the datasheet because there is no derating. + + I invented that citation and got the sign of the effect backwards, and the entire + saturated-compressor finding (F-124) was built on the result. + + The capacity is now interpolated from the machine's own published points, against its own + SOURCE temperature - which for four of the five machines is a constant, so their capacity + is flat, which is correct and is what the datasheets show. Below the coldest published + point the curve is HELD, because NIBE tabulates nothing there (only a graph), and holding + is the honest thing to do with the end of the evidence. + """ + # THE MODULATION ENVELOPE WINS WHERE THE DATASHEET PUBLISHES ONE. + # + # "Heating capacity (PH): 3 - 12 kW" is what an F1155-12 can actually deliver. Its 0/35 + # rating point of 5.06 kW is its output at NOMINAL (50 Hz) frequency, and reading THAT as + # the machine's ceiling would halve a 12 kW heat pump. The exhaust-air pumps publish their + # maximum directly - their third rating point is explicitly "max compressor frequency" - so + # for them the envelope and the top rating point are the same number. + if self.profile.heating_capacity_range_kw[1] > 0.0: + return self.profile.heating_capacity_range_kw[1] + + # Only the F2040 has no envelope row, and it is the only machine whose source IS the + # weather. Its capacity is the EN 14511 curve against SOURCE temperature, HELD below the + # coldest published point - because NIBE tabulates nothing below -7 C, only a graph. + # + # THAT MEANS THIS UNDERSTATES THE F2040. Its true maximum below -7 C is not a number I + # have. Any saturation the simulator shows for this machine is therefore an UPPER BOUND on + # the real thing, and must never be reported as a measured failure. F-124 was. + source = self.source_temp_c(outdoor_temp) + by_source = sorted( + { + point.source_temp_c: point + for point in self.profile.datasheet_points + if point.flow_temp_c == COP_RATING_FLOW_C + }.items() + ) + if len(by_source) < 2: + return self.profile.max_heat_output_kw + + temps = [t for t, _ in by_source] + caps = [point.heat_output_kw for _, point in by_source] + + # BELOW THE COLDEST RATING POINT, NIBE'S OWN ErP DECLARATION CLOSES THE MODEL. + # + # The manual tabulates the F2040's maximum output down to -7 C and no further - below that + # it gives a graph. But the AVERAGE-climate ErP declaration is one complete published + # statement: Pdesignh 8.2 kW at -10 C with Psup 1.1 kW, so the COMPRESSOR must deliver + # 8.2 - 1.1 = 7.1 kW at -10 C, against 6.60 kW measured at -7 C. An earlier version + # spliced the COLD-climate Pdesignh (9.0, declared at -22) onto that same Psup and + # anchored the result at -22 - a capacity from two different declarations that NIBE + # never published, worth +0.8 kW of phantom compressor in exactly the runs that decide + # whether this machine saturates. + # + # So capacity keeps RISING below -7 C to the -10 C declaration, and is HELD below it, + # because that is where every published statement stops. + # + # The old model derated 2.5 %/C in the opposite direction and blamed EN 14511 for it. + pdesign_avg = self.profile.design_heat_load_average_kw + psup = self.profile.supplementary_heat_kw + if source < temps[0] and pdesign_avg > 0.0 and psup > 0.0: + at_design = pdesign_avg - psup + if EN14825_AVERAGE_DESIGN_C < temps[0]: + span = temps[0] - EN14825_AVERAGE_DESIGN_C + frac = min(1.0, (temps[0] - source) / span) + return caps[0] + (at_design - caps[0]) * frac + return at_design + + return float(np.interp(source, temps, caps)) + + +# EVERY HOUSE IS SIZED FROM ITS PUMP'S OWN Pdesignh. It used to be sized from nothing at all. +# +# NIBE declares, for every machine, the design heat load it is certified for. That is the +# manufacturer's own statement of how big a house the pump is for, and it is the only sourced way +# to size a simulated building: +# +# hlc = (Pdesignh + internal_gains) / (target_indoor - design_outdoor) +# +# The houses used to carry invented heat-loss coefficients, and three of the five paired a pump +# with a house it was far too big for: +# +# concrete_f1155 6.06 kW house, 12 kW pump -> 2.0x oversized +# villa_s1155 5.32 kW house, 12 kW pump -> 2.3x oversized +# apartment_f730 2.73 kW house, 5 kW pump -> 1.8x oversized +# +# THAT DECIDED WHAT THE SIMULATION WAS ABLE TO FIND. A pump with twice the capacity its house needs +# cannot saturate, cannot fall behind, and cannot reach for its immersion heater - so it can never +# exercise the degree-minute recovery ladder at all. I reported that "the ground-source houses never +# engage the emergency ladder" as if it were a fact about the controller. It was a fact about my +# sizing. The only two correctly-sized systems in the set were the only two that failed. +# +# DESIGN_OUTDOOR is the Swedish DVUT (dimensionerande vinterutetemperatur) for mid-Sweden; Boverket +# puts Stockholm near -16 C. It is a stated convention of this harness, not a datasheet figure, and +# every house is sized against it consistently, so the PAIRING is what is being asserted here. +# WHERE EVERY NUMBER IN THIS PLANT MODEL CAME FROM. +# +# This table exists because the numbers that came from nowhere were the ones that decided what the +# simulation was able to find, and nobody could tell them apart from measurements. The COP curves +# were called "Real-world ... (tested and validated)" and sourced to "NIBE F750 datasheet, Swedish +# NIBE forum validation"; they were in neither. The capacity derating cited EN 14511 and ran in the +# opposite direction to it. The houses had heat-loss coefficients from nowhere at all. +# +# Each entry is exactly one of two things, and the difference is the point: +# +# SOURCED a document, quoted, that a reader can open. +# ASSUMED no published source exists. Then the sensitivity is MEASURED and stated here, because +# an unsourced number that moves the answer is a finding about the modeller. +# +# tests/validation/test_every_simulator_constant_says_where_it_came_from.py enforces it: a new +# physical constant cannot be added to this file without declaring one or the other. +PROVENANCE: dict[str, str] = { + # ---- the pump, from NIBE ---- + "DM_START": ( + "SOURCED: NIBE starts the compressor at -60 degree minutes. docs/research/01_degree_" + "minutes.md, from the NIBE manual (menu 4.9.3)." + ), + "DM_STOP": "SOURCED: NIBE stops the compressor at 0 degree minutes. docs/research/01.", + "COP_RATING_FLOW_C": ( + "SOURCED: EN 14511 rates heat pumps at W35. Every NIBE datasheet's rating points say so - " + "'A20(12)W35', '0/35 nominal', 'A7/W35'." + ), + "DESIGN_SPREAD": ( + "SOURCED: EN 14511 dT5K - the 5 K water-side temperature difference the standard rates at. " + "The F2040 installer manual's table header says it verbatim: 'Output data according to " + "EN 14511 dT5K'. IHB EN 1848-8/231846 p.65." + ), + "RADIATOR_EXPONENT": "SOURCED: EN 442 panel radiators, n = 1.3. docs/research/02_emitter_law.md.", + "UFH_EXPONENT": "SOURCED: EN 1264 underfloor heating, n = 1.1. docs/research/02_emitter_law.md.", + "EXHAUST_AIR_SOURCE_C": ( + "SOURCED: the F750 and F730 are rated at A20(12) - 20 C dry-bulb extract air. That IS their " + "heat source, and it does not change with the weather. NIBE F750 datasheet, part no. " + "066 063." + ), + "BRINE_SOURCE_C": ( + "SOURCED: the F1155 and S1155 are rated at B0 - 0 C incoming brine. Their capacity chart's " + "x-axis is labelled 'Incoming brine temp, C'. F1155 installer manual IHB EN 2008-5/331379." + ), + "DST_FALL_BACK_HOURS": ( + "SOURCED: the IANA time zone database (https://www.iana.org/time-zones), zone " + "Europe/Stockholm. On 2026-10-25 the offset goes from +02:00 to +01:00 at 03:00 local, so " + "the wall-clock hour 02 is metered twice and the day is 25 hours long. EU Directive " + "2000/84/EC fixes the transition to the last Sunday of October across the union. Verified " + "by stepping the absolute time line through the zone rather than by assuming it: the " + "harness counts 25 distinct billing hours on that date and fails the run if it does not." + ), + "EN14825_COLD_DESIGN_C": ( + "SOURCED: EN 14825 cold-climate reference design temperature. NIBE declares a Pdesignh at " + "this reference for every machine, and the houses are sized from it." + ), + "EN14825_AVERAGE_DESIGN_C": ( + "SOURCED: EN 14825 average-climate reference. The F730's own ErP block confirms it by " + "declaring TOL = -10 C. Used by --undersized, which sizes a house here and fits it a pump " + "certified for the cold reference - the commonest installation fault there is." + ), + "STANDBY_KW": ( + "SOURCED (as a range; the value is mid-band): the F750 datasheet, part no. 066 063, " + "publishes its running auxiliaries - 'Drive output heating medium pump 2: 5-45 W' and " + "'Driving power exhaust air fan: 25-140 W', so 30-185 W with the compressor running. " + "0.1 kW sits inside that. Swept " + "across the full published band the saturation findings do not move at all (2.1-2.2x vs " + "2.2x baseline)." + ), + "FLOW_EXERGY_PENALTY_PER_K": ( + "SOURCED where the datasheets identify it, ASSUMED where they cannot, and the difference " + "is stated in HouseConfig.exergy_fit. Measured from the EN 14511 rating points of the " + "F1155/S1155 (IHB EN 2008-5/331379: -0.0055/K) and the F2040 (IHB EN 1848-8/231846: " + "-0.0028/K), which publish W35 and W45 at the same source and load. The F750/F730 confound " + "load with flow and cannot identify it, so they inherit the mean of the two." + ), + "ASSUMED_INDOOR_MODULE_HEATER_KW": ( + "ASSUMED. The F2040 has NO immersion heater - it is an outdoor monobloc and its electric " + "addition lives in the paired indoor module (VVM/SMO), which this package does not model. " + "Every other machine's heater is on its profile, from its datasheet. Sensitivity: the F750's " + "cold-snap burn moved 38.1 -> 35.8 kWh when the heaters were sourced per machine, and the " + "saturation finding did not move." + ), + # ---- the plant, where NIBE publishes nothing ---- + "CONDENSER_APPROACH_K": ( + "ASSUMED. No datasheet publishes the refrigerant's approach temperatures. The exergy fit " + "ABSORBS them at the rating points - a different Carnot gives a different eta that " + "reproduces the same published COP - so the datasheet is matched whatever this is. Away " + "from the rating points it matters, by up to 42 % on an extrapolation to W55 full load. " + "Sensitivity, swept 3-7 K through the whole simulation: seasonal cost moves +/-2 %, and the " + "saturation finding does not move AT ALL, because saturation is a capacity constraint and " + "not an efficiency one." + ), + "EVAPORATOR_APPROACH_K": "ASSUMED. See CONDENSER_APPROACH_K - same assumption, same sensitivity.", + "WATER_LOOP_J_PER_K": ( + "ASSUMED, and only half of it could be sourced. The F750 publishes its own buffer: 'Volume " + "boiler section (of which buffer vessel) litre 35 (25)' - 35 L of water is 146 kJ/K. The " + "EMITTER side is a property of the HOUSE, and NIBE publishes no system volume for it (the " + "manuals only say 'if the climate system volume is too small ... supplement with a buffer " + "vessel'). 350 kJ/K is about 84 L of water-equivalent: the pump's 35 L plus a radiator " + "circuit. Sensitivity, halved and doubled: the saturation finding holds throughout " + "(2.0-3.0x over what physics forces, houses cooked to 27.8-30.3 C, against 2.0-2.5x and " + "29.1-29.8 C at the committed value)." + ), + "COMPRESSOR_RESPONSE_S": ( + "ASSUMED. How briskly the compressor closes on its flow target. No datasheet publishes it. " + "Sensitivity, swept 300-1800 s: the saturation finding holds throughout (1.9-2.4x, houses " + "at 29.1-29.4 C)." + ), +} HOUSES = [ HouseConfig( - name="wooden_f750", + name="wooden_f750", # exhaust air, radiators, light timber frame. ~130 m2. thermal_mass=0.7, insulation_quality=1.0, - hlc_w_per_k=150.0, + hlc_w_per_k=127.0, # F750 Pdesignh 5.0 kW at the EN 14825 COLD design point (-22 C) tau_hours=30.0, profile=NibeF750Profile(), heating_type="radiator", design_flow=50.0, - max_heat_kw=8.0, ), HouseConfig( - name="concrete_f1155", + name="concrete_f1155", # ground source, underfloor, heavy slab. A LARGE villa, ~280 m2. thermal_mass=1.8, insulation_quality=1.2, - hlc_w_per_k=180.0, + hlc_w_per_k=286.0, # F1155-12 Pdesignh 12 kW at -22 C. Was 180 - the pump was twice the house. tau_hours=80.0, profile=NibeF1155Profile(), heating_type="concrete_ufh", design_flow=38.0, - max_heat_kw=12.0, + ), + HouseConfig( + name="apartment_f730", # DELIBERATELY OVERSIZED, and that is the point of this one. + # + # The F730 is the SMALLEST exhaust-air machine NIBE makes, and a small flat cannot buy a + # smaller one. So a 2.7 kW flat gets a 5 kW pump, and that is not a modelling error - it is + # what actually happens. It is kept, and named, so that the set contains one system where + # the pump has headroom to spare. The difference between this house and the other four is + # now a STATED scenario rather than an accident of numbers nobody checked. + thermal_mass=0.9, + insulation_quality=1.3, + hlc_w_per_k=90.0, # 2.7 kW load against a 5 kW pump: 1.8x oversized, on purpose + tau_hours=45.0, + profile=NibeF730Profile(), + heating_type="radiator", + design_flow=45.0, + ), + HouseConfig( + name="villa_s1155", # S-series ground source, timber underfloor. A large villa. + thermal_mass=1.2, + insulation_quality=1.1, + hlc_w_per_k=286.0, # S1155-12 Pdesignh 12 kW at -22 C. Was 160 - the pump was 2.3x the house. + tau_hours=55.0, + profile=NibeS1155Profile(), + heating_type="timber_ufh", + design_flow=40.0, + ), + HouseConfig( + name="airsource_f2040", # outdoor air. The only machine whose source IS the weather. + # + # EVERY NUMBER HERE COMES FROM THE SAME COLUMN OF THE DATASHEET, and it did not used to. + # NIBE publishes the F2040-8's capacity and COP at 35 C flow, and its Pdesignh separately + # for the 35 C and 55 C applications (9.0 and 10.0 kW in a cold climate). The house was + # sized from the 35 C Pdesignh and then run at a 55 C design flow, where the machine is + # weaker - three inputs from three different columns. It is a low-temperature (underfloor) + # system now, so the capacity curve, the COP and the design load all describe one machine in + # one application. + # + # WHAT REMAINS UNKNOWN, and it bounds every conclusion drawn from this house: NIBE tabulates + # the F2040's maximum output only down to -7 C. Below that the manual gives a GRAPH and no + # numbers, so the model holds capacity at the -7 C figure. A Swedish January goes lower. The + # results for this house below -7 C therefore rest on an assumption, and are reported as a + # bound rather than a measurement. The F750 carries no such caveat - see the F-124 test. + thermal_mass=1.0, + insulation_quality=0.9, + hlc_w_per_k=218.0, # F2040-8 Pdesignh 9.0 kW (COLD climate, 35 C application) + tau_hours=40.0, + profile=NibeF2040Profile(), + heating_type="concrete_ufh", + design_flow=35.0, # the flow temperature its published capacity curve is measured at ), ] @@ -150,24 +752,104 @@ def apply_coldsnap(times, temps): return out -def load_data(selftest: bool): - """Load real weather + prices, or synthetic 2-day data for --selftest.""" +def _to_gespot_shape(days: dict, ore_per_unit: float) -> dict[str, list[dict[str, Any]]]: + """Normalise a raw price file into the GE-Spot attribute shape. + + Every price source ends up as {"time": iso8601, "value": } so a + single code path - the real adapter - parses all of them. Hourly sources are + expanded to four identical quarters, which is what an hourly market genuinely + means for a quarter-hour tariff. + """ + utc = zoneinfo.ZoneInfo("UTC") + out: dict[str, list[dict[str, Any]]] = {} + for day, raw in days.items(): + entries: list[dict[str, Any]] = [] + expand = 1 if len(raw) >= 90 else QUARTERS_PER_HOUR + for item in raw: + start = datetime.fromisoformat(item["start"]) + if start.tzinfo is None: + start = start.replace(tzinfo=TZ) + + # THE QUARTERS ARE STEPPED ON THE ABSOLUTE LINE, AND THE FOLD IS WHY. + # + # This used to convert to Europe/Stockholm and then do + # `(start + timedelta(minutes=15 * q)).isoformat()`. Adding a timedelta to an AWARE + # datetime is wall-clock arithmetic, and - this is the part that is easy to miss - + # `datetime.__add__` RESETS `fold` TO 0. Even at q = 0, where the timedelta is zero. + # + # So on the night the clocks go back, the second 02:00 (CET, fold=1) came back out of + # here stamped +02:00: an exact duplicate of the first 02:00 (CEST), and the CET hour's + # prices vanished. The harness then reported that the integration could not price the + # repeated hour - a defect in the INSTRUMENT, presented as a defect in the code it was + # measuring. The real adapter parses GE-Spot's own timestamps, which carry the right + # offset, and compares them interzone (i.e. in UTC); it prices that hour correctly. + # + # Stepping UTC and converting back keeps each quarter the instant it actually is. + base = start.astimezone(utc) + for q in range(expand): + moment = (base + timedelta(minutes=QUARTER_MINUTES * q)).astimezone(TZ) + entries.append({"time": moment.isoformat(), "value": item["price"] * ore_per_unit}) + out[day] = entries + return out + + +def load_live_se4() -> tuple[dict[str, list[dict[str, Any]]], str]: + """The real SE4 day captured from a live GE-Spot integration. + + Already in GE-Spot's own attribute shape, so it is handed to the adapter + untouched - byte-for-byte what the integration sees in production. + """ + payload = json.loads((DATA_DIR / "gespot_live_se4.json").read_text(encoding="utf-8")) + attrs = payload["attributes"] + days: dict[str, list[dict[str, Any]]] = {} + for key in ("today_interval_prices", "tomorrow_interval_prices"): + for item in attrs.get(key) or []: + day = datetime.fromisoformat(item["time"]).date().isoformat() + days.setdefault(day, []).append({"time": item["time"], "value": item["value"]}) + return days, attrs["unit_of_measurement"] + + +def _synthetic_days(start: datetime, days: int): + """Synthetic weather + quarter-hourly prices, generated on the ABSOLUTE time line. + + Everything here steps UTC and converts back, because the wall clock is not a uniform ruler. The + day the clocks go back is 25 hours long and carries 100 quarter-hour prices, not 96 - and a + generator that assumes 96 would quietly manufacture a day that no market ever published, which + is the opposite of what a harness is for. + """ + start_absolute = start.astimezone(zoneinfo.ZoneInfo("UTC")) + end_absolute = (start + timedelta(days=days)).astimezone(zoneinfo.ZoneInfo("UTC")) + + hours = int((end_absolute - start_absolute).total_seconds() // 3600) + times = [(start_absolute + timedelta(hours=h)).astimezone(TZ) for h in range(hours)] + temps = [-5.0 + 4.0 * ((t.hour % 24) / 24.0) for t in times] + + raw: dict = {} + quarters = int((end_absolute - start_absolute).total_seconds() // (60 * QUARTER_MINUTES)) + for q in range(quarters): + moment = (start_absolute + timedelta(minutes=QUARTER_MINUTES * q)).astimezone(TZ) + # The expensive blocks are wall-clock ones (morning and evening peaks), so they are keyed + # off the LOCAL quarter-of-day - which is what a price area actually does. + local_quarter = moment.hour * 4 + moment.minute // QUARTER_MINUTES + price = 500.0 + 400.0 * (1 if 28 <= local_quarter <= 40 or 68 <= local_quarter <= 80 else 0) + raw.setdefault(moment.date().isoformat(), []).append( + {"start": moment.isoformat(), "price": price} + ) + return times, temps, _to_gespot_shape(raw, ORE_PER_KWH_FROM_SEK_PER_MWH), GESPOT_UNIT_ORE + + +def load_data(selftest: bool, live_se4: bool = False, dst: bool = False): + """Load real weather + prices, or synthetic data for --selftest / --dst.""" + if dst: + # The last Sunday of October 2026: at 03:00 CEST the clock goes back to 02:00 CET, so the + # wall-clock hour 02 happens TWICE and the day is 25 hours long. This is the day on which + # the coordinator used to DELETE a billing hour - see + # tests/unit/coordinator/test_the_billing_hour_survives_the_clocks_going_back.py - and the + # harness could not see it, because its own clock advanced by wall time and its tariff + # periods were keyed on (date, hour), which those two hours share. + return _synthetic_days(datetime(2026, 10, 24, tzinfo=TZ), 3) if selftest: - start = datetime(2026, 1, 1, tzinfo=TZ) - hours = 48 - temps = [-5.0 + 4.0 * ((h % 24) / 24.0) for h in range(hours)] - times = [start + timedelta(hours=h) for h in range(hours)] - prices = {} - for d in range(2): - day = (start + timedelta(days=d)).date().isoformat() - prices[day] = [ - { - "start": (start + timedelta(days=d, minutes=15 * q)).isoformat(), - "price": 500.0 + 400.0 * (1 if 28 <= q <= 40 or 68 <= q <= 80 else 0), - } - for q in range(96) - ] - return times, temps, prices + return _synthetic_days(datetime(2026, 1, 1, tzinfo=TZ), 2) weather = json.load(open(DATA_DIR / "weather_jan2026.json")) times = [ @@ -175,8 +857,40 @@ def load_data(selftest: bool): for t in weather["hourly"]["time"] ] temps = weather["hourly"]["temperature_2m"] + + if live_se4: + # Real captured SE4 prices, replayed against January weather. The market + # day is a July one; the point is the price SHAPE (a 41x spread between + # cheapest and dearest quarter), which is far harsher on the optimiser + # than the January SE3 profile. + se4_days, unit = load_live_se4() + days: dict[str, list[dict[str, Any]]] = {} + # Take the price SHAPE (the ordered quarters) and re-stamp it onto the simulated days. + # The timestamps must be REBUILT in the simulation's timezone, not edited: the captured + # day is a July one at UTC+02:00 and the simulated days are January at UTC+01:00, so + # rewriting only the date leaves every interval an hour out of place - which is exactly + # what the adapter's timestamp lookup then refuses to price, and rightly so. + shape = [values for _, values in sorted(se4_days.items())] + midnight = times[0].replace(hour=0, minute=0, second=0, microsecond=0) + for index in range(SIM_DAYS + 1): + start = midnight + timedelta(days=index) + entries = shape[index % len(shape)] + days[start.date().isoformat()] = [ + { + "time": (start + timedelta(minutes=QUARTER_MINUTES * quarter)).isoformat(), + "value": entry["value"], + } + for quarter, entry in enumerate(entries) + ] + return times, temps, days, unit + prices = json.load(open(DATA_DIR / "prices_jan2026.json"))["days"] - return times, temps, prices + return ( + times, + temps, + _to_gespot_shape(prices, ORE_PER_KWH_FROM_SEK_PER_MWH), + GESPOT_UNIT_ORE, + ) def outdoor_at(times, temps, when: datetime) -> float: @@ -188,46 +902,152 @@ def outdoor_at(times, temps, when: datetime) -> float: return temps[idx] * (1 - frac) + temps[idx + 1] * frac -def quarters_for_day(prices: dict, day: datetime) -> list[QuarterPeriod]: - """Build QuarterPeriods (ore/kWh) for a day; expand hourly data if needed.""" - raw = prices.get(day.date().isoformat()) - if not raw: - return [] - periods = [] - if len(raw) >= 90: # 15-min data - for entry in raw: - st = datetime.fromisoformat(entry["start"]) - if st.tzinfo is None: - st = st.replace(tzinfo=TZ) - periods.append( - QuarterPeriod(start_time=st.astimezone(TZ), price=entry["price"] / 10.0) - ) # SEK/MWh -> ore/kWh - else: # hourly -> repeat 4x - for entry in raw: - st = datetime.fromisoformat(entry["start"]) - if st.tzinfo is None: - st = st.replace(tzinfo=TZ) - st = st.astimezone(TZ) - for q in range(4): - periods.append( - QuarterPeriod( - start_time=st + timedelta(minutes=15 * q), price=entry["price"] / 10.0 - ) - ) - return periods +class _StubState: + """The two fields GESpotAdapter reads off a Home Assistant state object.""" + + def __init__(self, state: str, attributes: dict[str, Any]): + self.state = state + self.attributes = attributes + + +class _StubStates: + def __init__(self) -> None: + self._states: dict[str, _StubState] = {} + + def set(self, entity_id: str, state: _StubState) -> None: + self._states[entity_id] = state + + def get(self, entity_id: str) -> _StubState | None: + return self._states.get(entity_id) + + +class _StubHass: + """Just enough Home Assistant to run the real adapter against.""" + def __init__(self) -> None: + self.states = _StubStates() -def build_engine(house: HouseConfig, mode: str = "balanced"): + +class PriceSource: + """Feeds the simulation through the REAL GESpotAdapter. + + The harness used to construct QuarterPeriod objects by hand, which meant the + adapter that actually runs in production - unit detection, timestamp parsing, + the sort by absolute instant, the DST-aware interval lookup - was never + exercised by any simulation. A price-parsing regression could not have been + caught here. Now the day's intervals are published as a Home Assistant state + shaped exactly like a live GE-Spot entity, and the adapter parses it. + + PriceData is cached per (day, tomorrow-visible), so the adapter runs ~62 times + across a month rather than once per 5-minute step. + """ + + ENTITY_ID = "sensor.gespot_current_price_sim" + + def __init__(self, days: dict[str, list[dict[str, Any]]], unit: str): + self._days = days + self._unit = unit + self._hass = _StubHass() + self._adapter = GESpotAdapter( + self._hass, # type: ignore[arg-type] + {CONF_GESPOT_ENTITY: self.ENTITY_ID}, + ) + self._cache: dict[tuple[str, bool], PriceData] = {} + + @property + def unit(self) -> str: + """The unit the adapter detected off the entity (not what we assumed).""" + return self._adapter.price_unit or self._unit + + def get(self, now: datetime) -> PriceData: + today_key = now.date().isoformat() + tomorrow_key = (now + timedelta(days=1)).date().isoformat() + tomorrow_visible = now.hour >= TOMORROW_VISIBLE_HOUR + cache_key = (today_key, tomorrow_visible) + if cache_key in self._cache: + return self._cache[cache_key] + + today_raw = self._days.get(today_key, []) + tomorrow_raw = self._days.get(tomorrow_key, []) if tomorrow_visible else [] + + current = today_raw[0]["value"] if today_raw else 0.0 + self._hass.states.set( + self.ENTITY_ID, + _StubState( + state=str(current), + attributes={ + "unit_of_measurement": self._unit, + "currency": "SEK", + "today_interval_prices": today_raw, + "tomorrow_interval_prices": tomorrow_raw, + }, + ), + ) + price_data = asyncio.run(self._adapter.get_prices()) + self._cache[cache_key] = price_data + return price_data + + +# Reference thermal-battery controller. Not a proposal for production - a YARDSTICK. It knows +# nothing about degree minutes, weather, peaks or the pump; it only charges the house when power +# is cheap and coasts when it is dear, inside a hard comfort band. If EffektGuard cannot beat +# this, the sophistication is not paying for itself. +BATTERY_BAND = 1.0 # °C swing around target the house is allowed to use as storage +BATTERY_CHARGE_OFFSET = 4.0 # curve offset while charging on cheap power +BATTERY_COAST_OFFSET = -4.0 # curve offset while coasting on dear power +BATTERY_CHEAP_PERCENTILE = 30 # below this percentile of the day, charge +BATTERY_DEAR_PERCENTILE = 70 # above this percentile of the day, coast + + +def battery_reference_offset(price_data: PriceData, now: datetime, indoor: float) -> float: + """Charge the fabric when power is cheap, coast when dear, never leave the comfort band.""" + if indoor > TARGET_INDOOR + BATTERY_BAND: + return BATTERY_COAST_OFFSET # full - stop charging + if indoor < TARGET_INDOOR - BATTERY_BAND: + return BATTERY_CHARGE_OFFSET # flat - must heat regardless of price + + prices = [q.price for q in price_data.today] + period = price_data.get_period(now) + if not prices or period is None: + return 0.0 + + ordered = sorted(prices) + cheap = ordered[int(len(ordered) * BATTERY_CHEAP_PERCENTILE / 100)] + dear = ordered[int(len(ordered) * BATTERY_DEAR_PERCENTILE / 100)] + + if period.price <= cheap: + return BATTERY_CHARGE_OFFSET + if period.price >= dear: + return BATTERY_COAST_OFFSET + return 0.0 + + +def build_engine( + house: HouseConfig, + mode: str = "balanced", + enable_price: bool = True, + enable_weather: bool = True, + tuned_curve: bool = False, +): + """Build the real DecisionEngine for this house. + + `enable_price` / `enable_weather` exist so the harness can ABLATE a layer and attribute the + result. "The optimiser costs 2 % more than doing nothing" is not actionable; "the price layer + costs 3 % and the weather compensation saves 1 %" is. + """ hass = MagicMock() effect = EffectManager(hass) + # The harness has no Home Assistant storage; the peak history lives for the run only. + effect._store = MagicMock() + effect._store.async_save = AsyncMock() thermal = ThermalModel(house.thermal_mass, house.insulation_quality) config = { "target_indoor_temp": TARGET_INDOOR, "tolerance": COMFORT_TOLERANCE, "optimization_mode": mode, - "enable_weather_compensation": True, + "enable_weather_compensation": enable_weather, "enable_peak_protection": True, - "enable_price_optimization": True, + "enable_price_optimization": enable_price, "latitude": 59.33, "heating_type": house.heating_type, "heat_loss_coefficient": house.hlc_w_per_k, @@ -248,20 +1068,26 @@ def simulate( house: HouseConfig, times, temps, - prices, + price_source: PriceSource, days: int, mode: str = "balanced", baseline: bool = False, + fixed_offset: float | None = None, + battery: bool = False, + enable_price: bool = True, + enable_weather: bool = True, + tuned_curve: bool = False, + forecast_available: bool = True, ): - engine, effect = build_engine(house, mode) + engine, effect = build_engine(house, mode, enable_price, enable_weather) start = times[0].replace(hour=0, minute=0, second=0, microsecond=0) steps = days * 24 * 60 // STEP_MIN indoor = 22.0 + indoor_start = indoor dm = -30.0 offset_applied = 0 # integer offset "in the pump" (register 47011) - accumulator_ref = 0 # mirrors adapter _last_nibe_offset behaviour compressor_on = True flow = 30.0 @@ -275,6 +1101,7 @@ def simulate( "cost_sek": 0.0, "energy_kwh": 0.0, "aux_kwh": 0.0, + "unavoidable_aux_kwh": 0.0, "writes": 0, "offset_min": 0, "offset_max": 0, @@ -283,237 +1110,868 @@ def simulate( "comfort_minutes_above": 0, "compressor_starts": 0, "sign_flips": 0, + "heat_kwh": 0.0, + "loss_kwh": 0.0, + "layer_votes": {}, + "water_node_leak_kwh": 0.0, + "flow_target_max": -999.0, + "compressor_heat_kwh": 0.0, + "datasheet_cop_x_heat": 0.0, } + best_published_cop = max(p.cop for p in house.profile.datasheet_points) last_offsets = [] - quarter_samples: list[float] = [] - quarter_id = None - daily_peaks: dict = {} # date -> max quarter-mean kW - - for step in range(steps): - now = start + timedelta(minutes=STEP_MIN * step) - # Freeze engine wall clock to sim time - dt_util.now = lambda tz=None, _n=now: _n - dt_util.utcnow = lambda _n=now: _n.astimezone(zoneinfo.ZoneInfo("UTC")) - - tout = outdoor_at(times, temps, now) - - # --- plant step --- - flow_target = 22.0 + house.curve_slope * (22.0 - tout) + offset_applied - if compressor_on: - flow = min(flow + FLOW_RAMP_ON * STEP_MIN, flow_target + 1.0) - else: - flow = max(flow - FLOW_DECAY_OFF * STEP_MIN, indoor) - - q_w = max(0.0, house.k_emit * (flow - indoor)) - q_w = min(q_w, house.max_heat_kw * 1000.0) - - aux_kw = 0.0 - if dm <= DM_AUX: - aux_kw = AUX_STEP_KW - q_w += aux_kw * 1000.0 - - # Indoor temperature ODE - d_indoor = (q_w - house.hlc_w_per_k * (indoor - tout)) / house.capacity_j_per_k - indoor += d_indoor * STEP_MIN * 60.0 - - # DM dynamics + compressor hysteresis - dm += (flow - flow_target) * STEP_MIN - dm = max(-3000.0, min(dm, 100.0)) - if not compressor_on and dm <= DM_START: - compressor_on = True - stats["compressor_starts"] += 1 - elif compressor_on and dm >= DM_STOP: - compressor_on = False - - cop = house.profile.get_cop_at_temperature(tout) - power_kw = (q_w / 1000.0 - aux_kw) / cop + aux_kw + 0.1 if compressor_on or aux_kw else 0.1 - hz = 40 + int(min(50, max(0, (flow_target - indoor)))) if compressor_on else 0 - - # --- price/weather context --- - today_q = quarters_for_day(prices, now) - tomorrow_q = ( - quarters_for_day(prices, now + timedelta(days=1)) - if now.hour >= TOMORROW_VISIBLE_HOUR - else [] - ) - price_data = PriceData(today=today_q, tomorrow=tomorrow_q, has_tomorrow=bool(tomorrow_q)) - cur_q = (now.hour * 4) + now.minute // 15 - cur_price_ore = today_q[cur_q].price if len(today_q) > cur_q else 100.0 - - fc = [ - WeatherForecastHour( - datetime=now + timedelta(hours=h), - temperature=outdoor_at(times, temps, now + timedelta(hours=h)), + # The REAL one, from the integration. Not a copy of it. + billing = BillingPeriodAccumulator() + daily_peaks: dict = {} # date -> max HOURLY-mean kW (physical, for peak_kw_hourly_mean) + daily_billed: dict = {} # date -> max EFFECTIVE kW: what the tariff counts, night hours half + # date -> how many billing hours the PRODUCTION accumulator actually billed on it. A day is not + # always 24 hours long, + # and the tariff bills every hour the meter recorded: the fall-back day has 25 and the + # spring-forward day 23. Counting them is how this harness proves it is actually TRAVERSING + # the transition rather than merely surviving it - a flat night load is priced identically + # whether the repeated hour is billed once or twice, so the tariff figure alone cannot tell. + billing_hours: dict = {} + # Highest completed quarter-hour MEAN so far: what the coordinator publishes as + # peak_this_month, and therefore what the effect layer is defending. Starts at + # zero, as it does on a fresh install. + running_peak_kw = 0.0 + + # THE CLOCK ADVANCES ON THE ABSOLUTE TIME LINE, NOT THE WALL CLOCK. + # + # This was `now = start + timedelta(minutes=STEP_MIN * step)`, and `start` is aware + # (Europe/Stockholm). Adding a timedelta to an AWARE datetime is WALL-CLOCK arithmetic: the + # digits advance uniformly and the UTC offset is recomputed from wherever they land. Real time + # does not work that way. Across a spring-forward that clock walks through a wall time that never + # happened; across a fall-back it passes the repeated hour once instead of twice. + # + # So the harness could not have experienced a DST transition honestly even if pointed straight + # at one - and the coordinator bug that deleted a billing hour on the fall-back night (a peak of + # 9 kW recorded as 1) would have been invisible to it. Step UTC; derive local from it. + start_absolute = start.astimezone(zoneinfo.ZoneInfo("UTC")) + + # The engine's clock is frozen to sim time below. RESTORE IT even on a crash - a + # leaked monkeypatch poisons every test that runs after a failed simulation (F-100). + _real_now, _real_utcnow = dt_util.now, dt_util.utcnow + try: + for step in range(steps): + now = (start_absolute + timedelta(minutes=STEP_MIN * step)).astimezone(TZ) + # Freeze engine wall clock to sim time + dt_util.now = lambda tz=None, _n=now: _n + dt_util.utcnow = lambda _n=now: _n.astimezone(zoneinfo.ZoneInfo("UTC")) + + tout = outdoor_at(times, temps, now) + + # --- plant step --- + # S1 IS CLAMPED TO THE PUMP'S MAXIMUM SUPPLY TEMPERATURE, as it is on the real hardware. + # + # This clamp was missing while `flow` (BT25) was clamped, twelve lines below. Degree + # minutes are the integral of (BT25 - S1), so the plant was integrating against a setpoint + # the pump was physically forbidden to reach: in the F2040 cold snap the curve asked for up + # to 4.1 C above max_flow_temp for 513 samples, and DM therefore fell at up to 4.1 per + # minute NO MATTER WHAT ANY CONTROLLER DID. Degree minutes ran to the integrator floor on + # their own, and the harness reported it as a control failure. It was a plant artefact. + # + # A NIBE limits the calculated supply temperature to the configured maximum; it does not + # chase a setpoint it cannot make. Removing this artefact is what makes the residual trap + # underneath it (F-124) measurable at its true size rather than at an inflated one. + max_flow = float(house.profile.max_flow_temp) + flow_target = min(house.curve_flow_temp(tout, tuned_curve) + offset_applied, max_flow) + + # The compressor's capacity now bounds the water node directly (see below), so the flow + # saturates below target of its own accord when the pump runs out - which is what lets + # degree minutes actually run away, and is the real mechanism behind an undersized pump + # falling back on its immersion heater in a cold snap. + + # THE WATER LOOP IS A THERMAL MASS, NOT A RAMP RATE. + # + # This used to move `flow` toward its target at a fixed C/min and then compute the room's + # heat from wherever the flow happened to be - including while the compressor was OFF, so + # the decaying water heated the room for free and nothing ever charged for putting the heat + # in. The plant manufactured energy in proportion to how long the compressor spent idle, + # which systematically flattered whichever controller ran the pump least. + # + # The physics is simply a first-order node: the compressor heats the water, the water heats + # the room, and the flow temperature is what the balance between them leaves behind. + # + # C_water * dT_flow/dt = Q_compressor - Q_emitters + # + # Now every joule the room receives was paid for, the loop is a buffer rather than a + # source, and a controller that swings the flow pays the real cost of doing so. + q_emit_w = house.heat_output_w(flow, indoor) + + capacity_w = house.capacity_kw_at(tout) * 1000.0 + if compressor_on: + # The compressor modulates toward the flow its curve is asking for, bounded by what it + # can actually deliver - which comes from the datasheet, not from an invented derating. + demand_w = ( + q_emit_w + WATER_LOOP_J_PER_K * (flow_target - flow) / COMPRESSOR_RESPONSE_S + ) + q_comp_w = max(0.0, min(demand_w, capacity_w)) + else: + q_comp_w = 0.0 + + # How hard the compressor is being pushed, which is what sets its efficiency. No + # circularity: q_comp is fixed by demand and capacity, both computed above. + load_fraction = q_comp_w / capacity_w if capacity_w > 0 else 0.0 + + # THE IMMERSION HEATER IS THERMOSTATIC, because every real one is. + # + # It used to dump a flat 3 kW into the water node whenever degree minutes passed the aux + # limit - including when the node was already at its ceiling. In a five-minute step that is + # 900 kJ into a 350 kJ/K loop: 2.6 K of overshoot per step, which the clamp below then + # deleted. The heater was metered, paid for, and its heat thrown away, 183 kWh of it in the + # F2040 cold snap, while every energy "audit" in the harness reported 0.00 % error. + # + # A real immersion heater has a high-limit thermostat and cycles on the water temperature. + # So it injects at most what fits under the ceiling: the heat the emitters are taking out, + # less what the compressor is already putting in, plus whatever headroom the node has left. + aux_headroom_w = ( + WATER_LOOP_J_PER_K * (max_flow - flow) / (STEP_MIN * 60.0) + q_emit_w - q_comp_w ) - for h in range(1, 49) - ] - weather = WeatherData(current_temp=tout, forecast_hours=fc, source_entity="sim") - - nibe = NibeState( - outdoor_temp=round(tout, 1), - indoor_temp=round(indoor, 2), - supply_temp=round(flow, 1), - return_temp=round(flow - 5.0, 1), - degree_minutes=round(dm, 0), - current_offset=float(offset_applied), - is_heating=compressor_on, - is_hot_water=False, - timestamp=now, - compressor_hz=hz, - power_kw=round(power_kw, 2), - ) + aux_w = 0.0 + if dm <= house.aux_start_dm: + aux_w = min(house.immersion_heater_kw * 1000.0, max(0.0, aux_headroom_w)) - # --- the real decision engine (or neutral baseline) --- - if baseline: - calc_offset = 0.0 - else: - try: - decision = engine.calculate_decision( - nibe_state=nibe, - price_data=price_data, - weather_data=weather, - current_peak=6.0, - current_power=power_kw, - ) - calc_offset = decision.offset - except Exception as err: # noqa: BLE001 - we are hunting bugs - stats["exceptions"] += 1 + flow_unclamped = ( + flow + (q_comp_w + aux_w - q_emit_w) * (STEP_MIN * 60.0) / WATER_LOOP_J_PER_K + ) + flow = max(indoor, min(flow_unclamped, max_flow)) + + # THE ONLY ENERGY STATEMENT IN THIS PLANT THAT CAN ACTUALLY FAIL. + # + # Everything downstream of here - the room ODE, the "first law residual", the compressor + # audit - is an algebraic rearrangement of the same two lines and CANNOT disagree with + # itself. This clamp is different: it overwrites a state variable AFTER the ODE has + # integrated it, so every joule it removes is energy the meter charged for and the room + # never received. Nothing else in the harness can see that, and it measured 0.00 % error + # while 183 kWh vanished in the F2040 cold snap. + # + # In a healthy plant the clamp never binds and this stays at zero. It is an assertion, not + # a statistic. + stats["water_node_leak_kwh"] += WATER_LOOP_J_PER_K * (flow - flow_unclamped) / J_PER_KWH + + # THE DATASHEET, AT THE WEATHER THIS RUN ACTUALLY SAW. Accumulated here, asserted in + # check_invariants. The plant's COP is the manufacturer's rated figure scaled by the Carnot + # ratio between the flow it is making and the W35 rating point, so whenever the water is + # HOTTER than W35 the scale is below one and the realised COP cannot exceed the datasheet. + # That is a bound the energy bookkeeping does not determine, which is exactly why it can + # fail - and a doubled COP, the bug the deleted identity waved through, breaks it on every + # house. + heat_kwh_this_step = q_comp_w / 1000.0 * STEP_MIN / 60.0 + stats["compressor_heat_kwh"] += heat_kwh_this_step + stats["datasheet_cop_x_heat"] += best_published_cop * heat_kwh_this_step + + q_w = q_emit_w + + aux_kw = aux_w / 1000.0 + + # Indoor temperature ODE + # INTERNAL GAINS. The simulated house used to have none: its only heat source was the + # emitters. A real house is warmed by its occupants, its fridge, its lighting and the sun + # to the tune of a few hundred watts, all year - which is why heat demand reaches zero at + # the BALANCE POINT (~17 C outdoor) rather than at room temperature. + # + # Leaving them out did not just make the plant unrealistic, it made it BLIND: the + # controller models 600 W of gains and asks for correspondingly less flow, so a house with + # zero gains would be systematically under-supplied - and deleting the controller's gains + # term (a real regression) would have been INVISIBLE here, because the two errors cancel. + d_indoor = ( + q_w + INTERNAL_GAINS_W - house.hlc_w_per_k * (indoor - tout) + ) / house.capacity_j_per_k + indoor += d_indoor * STEP_MIN * 60.0 + + # DM dynamics + compressor hysteresis + dm += (flow - flow_target) * STEP_MIN + dm = max(DM_INTEGRATOR_FLOOR, min(dm, DM_INTEGRATOR_CEILING)) + if not compressor_on and dm <= DM_START: + compressor_on = True + stats["compressor_starts"] += 1 + elif compressor_on and dm >= DM_STOP: + compressor_on = False + + cop = house.cop_at(tout, flow, load_fraction) + + # THE SECOND LAW. No machine can beat Carnot between the temperatures it is working across. + # + # Unlike the energy "audits" this replaces, this one is not derived from the plant's own + # bookkeeping - it is an external physical bound on the COP MODEL, so it can disagree with + # it. It catches a wrong anchor, a flipped exponent or bad approach temperatures. It does + # NOT catch a COP that is merely too generous but still sub-Carnot; the datasheet envelope + # in check_invariants is what covers that, and between them they bracket the model from + # both sides. + if cop > house.carnot_cop(tout, flow): violations.append( { "t": now.isoformat(), - "type": "exception", - "detail": f"{type(err).__name__}: {err}", + "type": "cop_beats_carnot", + "detail": f"COP {cop:.2f} > Carnot {house.carnot_cop(tout, flow):.2f}", } ) + + power_kw = (q_comp_w / 1000.0) / cop + aux_kw + STANDBY_KW + hz = 40 + int(min(50, max(0, (flow_target - indoor)))) if compressor_on else 0 + + # --- price/weather context (parsed by the REAL GE-Spot adapter) --- + price_data = price_source.get(now) + cur_q = (now.hour * 4) + now.minute // 15 + # Locate the interval by timestamp, exactly as the integration does, rather + # than indexing by quarter number - the two disagree on DST days. + cur_period = price_data.get_period(now) + if cur_period is None: + violations.append( + {"t": now.isoformat(), "type": "no_price_for_instant", "detail": f"q{cur_q}"} + ) + cur_price_ore = 100.0 + else: + cur_price_ore = cur_period.price + + fc = [ + WeatherForecastHour( + datetime=now + timedelta(hours=h), + temperature=outdoor_at(times, temps, now + timedelta(hours=h)), + ) + for h in range(1, 49) + ] + # A weather entity is vol.Optional in the config flow, and with none configured + # WeatherAdapter.get_forecast() returns None outright ("Weather forecast disabled - no + # entity configured in setup"). That is a SUPPORTED install, and until this flag existed the + # harness had never simulated it: it fed a perfect 48 h forecast to every run. + # + # Note this is NOT --no-weather. That flag clears enable_weather_compensation, which kills + # the Math WC layer - the core control law, voting 100% of the time - and which the config + # flow never writes, so production cannot reach it. Withholding the FORECAST is the thing a + # real user can do, and it is the weaker ablation: Math WC still runs off outdoor and flow + # temperature. Only the forecast-fed layers go quiet. + weather = ( + WeatherData(current_temp=tout, forecast_hours=fc, source_entity="sim") + if forecast_available + else None + ) + + nibe = NibeState( + outdoor_temp=round(tout, 1), + indoor_temp=round(indoor, 2), + supply_temp=round(flow, 1), + return_temp=round(flow - 5.0, 1), + degree_minutes=round(dm, 0), + current_offset=float(offset_applied), + is_heating=compressor_on, + is_hot_water=False, + timestamp=now, + compressor_hz=hz, + power_kw=round(power_kw, 2), + ) + + # --- the real decision engine (or neutral baseline) --- + if battery: + calc_offset = battery_reference_offset(price_data, now, indoor) + elif fixed_offset is not None: + calc_offset = fixed_offset + elif baseline: calc_offset = 0.0 + else: + try: + # The peak the effect layer defends is the one this simulation has + # actually produced so far, not a constant. A hardcoded 6.0 kW meant + # the layer was always defending a peak the plant never set, and the + # "no peak recorded yet" path (where predictive protection must stay + # silent) was never reached at all. + decision = engine.calculate_decision( + nibe_state=nibe, + price_data=price_data, + weather_data=weather, + current_peak=running_peak_kw, + current_power=power_kw, + ) + calc_offset = decision.offset + # WHICH LAYERS ACTUALLY VOTED. "5/5 PASS" says nothing about a layer that never + # fired - and this harness has already shipped a run where the Peak layer voted + # weight 0.00 in all 8928 steps of every run ever made, while reporting PASS. A + # green run over silent code is not evidence, and the only way to know which it is + # is to count. + for layer in decision.layers: + if layer.weight > 0.0: + stats["layer_votes"][layer.name] = ( + stats["layer_votes"].get(layer.name, 0) + 1 + ) + except Exception as err: # noqa: BLE001 - we are hunting bugs + stats["exceptions"] += 1 + violations.append( + { + "t": now.isoformat(), + "type": "exception", + "detail": f"{type(err).__name__}: {err}", + } + ) + calc_offset = 0.0 - # Adapter-faithful integer write (fractional accumulator, threshold 1.0) - if abs(calc_offset - accumulator_ref) >= 1.0: - new_int = accumulator_ref + int(calc_offset - accumulator_ref) - new_int = int(max(-10, min(10, new_int))) + # The REAL quantisation the adapter uses, not a copy of it. This harness used to carry its + # own transcription of that arithmetic - including the int() truncation - which is exactly + # how a plant model and the code it is meant to be testing drift apart unnoticed. + new_int = integer_offset_for(calc_offset, offset_applied) if new_int != offset_applied: offset_applied = new_int - accumulator_ref = new_int stats["writes"] += 1 - # --- invariants & stats --- - if dm < -1500 and aux_kw == 0: - violations.append( - {"t": now.isoformat(), "type": "dm_below_aux_limit", "detail": f"DM {dm:.0f}"} - ) - if indoor < 18.0: - violations.append( - {"t": now.isoformat(), "type": "indoor_below_18", "detail": f"indoor {indoor:.2f}"} + # --- invariants & stats --- + # A degree-minute deficit that reaches the integrator floor means the recovery system - + # the curve offset AND the auxiliary heater together - failed to arrest it. That is the + # signal worth failing on. + # + # The previous invariant here ("DM below the aux limit while aux is off") was a FALSE + # POSITIVE: aux is decided from the degree minutes at the START of the step and the check + # ran against the value at the END, so a deficit that crossed the limit mid-step tripped + # it even though aux engages on the very next step - which is simply what a controller + # sampling at an interval does. Worse, it could never catch a real defect, because aux + # engages exactly when DM crosses the limit. It was unfalsifiable in both directions. + if dm <= DM_INTEGRATOR_FLOOR: + violations.append( + { + "t": now.isoformat(), + "type": "dm_runaway", + "detail": f"DM floored at {dm:.0f}", + } + ) + + # Nothing here used to fail on OVERHEATING. The harness counted comfort_minutes_above and + # asserted nothing about it, so a run that cooked the house to 35 C reported "violations: + # 0". Overheating is a comfort failure, an efficiency failure, and - when it is auxiliary + # heat doing it - an expensive one. + if indoor > INDOOR_CEILING: + violations.append( + { + "t": now.isoformat(), + "type": "indoor_above_ceiling", + "detail": f"indoor {indoor:.2f}", + } + ) + if indoor < 18.0: + violations.append( + { + "t": now.isoformat(), + "type": "indoor_below_18", + "detail": f"indoor {indoor:.2f}", + } + ) + if not -10 <= calc_offset <= 10: + violations.append( + { + "t": now.isoformat(), + "type": "offset_out_of_range", + "detail": f"offset {calc_offset:.2f}", + } + ) + + last_offsets.append(offset_applied) + if len(last_offsets) > 9: + last_offsets.pop(0) + deltas = [b - a for a, b in zip(last_offsets, last_offsets[1:])] + flips = sum(1 for a, b in zip(deltas, deltas[1:]) if a * b < 0) + if flips >= 3: + stats["sign_flips"] += 1 + + stats["indoor_min"] = min(stats["indoor_min"], indoor) + stats["indoor_max"] = max(stats["indoor_max"], indoor) + stats["indoor_sum"] += indoor + stats["dm_min"] = min(stats["dm_min"], dm) + # What the plant actually ASKED the pump for. Degree minutes integrate (BT25 - S1), so if + # S1 can exceed what the pump may make, DM falls forever regardless of the controller. The + # number is published so a test can check the plant rather than recompute the clamp and + # assert on its own arithmetic - which is what the first version of that test did. + stats["flow_target_max"] = max(stats["flow_target_max"], flow_target) + stats["offset_min"] = min(stats["offset_min"], offset_applied) + stats["offset_max"] = max(stats["offset_max"], offset_applied) + energy = power_kw * STEP_MIN / 60.0 + stats["energy_kwh"] += energy + + # First-law audit. Heat INTO the room, and heat OUT of it. Over a month these must balance + # to within the change in the fabric's stored energy - otherwise the plant is inventing or + # destroying energy and every cost number it produces is fiction. + stats["heat_kwh"] += q_w * STEP_MIN / 60.0 / 1000.0 + stats["loss_kwh"] += ( + (house.hlc_w_per_k * (indoor - tout) - INTERNAL_GAINS_W) * STEP_MIN / 60.0 / 1000.0 ) - if not -10 <= calc_offset <= 10: - violations.append( - { - "t": now.isoformat(), - "type": "offset_out_of_range", - "detail": f"offset {calc_offset:.2f}", - } + stats["aux_kwh"] += aux_kw * STEP_MIN / 60.0 + + # THE RESISTIVE HEAT PHYSICS FORCES, as opposed to the resistive heat the optimiser causes. + # + # A correctly-sized air-source system in Sweden is BIVALENT: NIBE declares Tbiv = -9 C for + # the F2040-8, below which the machine cannot meet the design load and supplementary heat is + # REQUIRED. The harness used to assert that a healthy pump burns no resistive heat at all, + # which is an assertion about a machine that does not exist - and it duly failed the only + # correctly-sized air-source house in the set, for doing exactly what it is designed to do. + # + # What CAN be asked, and is worth asking, is whether the optimiser burns more resistive heat + # than the pump's own capacity deficit forces. That is computable here: the house's heat + # demand at this instant, against what the compressor can physically deliver. Anything above + # it is the controller's doing, not the weather's. + demand_now_w = house.hlc_w_per_k * (indoor - tout) - INTERNAL_GAINS_W + stats["unavoidable_aux_kwh"] += ( + max(0.0, demand_now_w - capacity_w) / 1000.0 * STEP_MIN / 60.0 ) + stats["cost_sek"] += energy * cur_price_ore / 100.0 + + # EFFECT TARIFF BASIS: THE HOURLY MEAN. Not the quarter-hour, which is what this used to + # accumulate, and not the instantaneous sample, which is what it accumulated before that. + # + # Ellevio: "the measurement uses hourly averages". Energimarknadsinspektionen: + # "elnatsforetagen mater din elanvandning per timme". A 15-minute hot-water cycle at 9 kW + # inside an otherwise idle hour has an hourly mean of 3 kW, and the harness was pricing the + # 9 - so every tariff figure it produced was up to fourfold too high. + # THE BILLED QUANTITY IS COMPUTED BY THE PRODUCTION CODE, NOT BY A LOOKALIKE. + # + # This used to be the harness's OWN accumulator: `sum(period_samples) / len(period_samples)`, + # keyed on its own idea of an hour. The coordinator has always used a TIME-WEIGHTED mean over + # an absolute hour. Two implementations of the single most consequential number this + # integration computes - and the harness was validating the one nobody runs. + # + # They agreed only because this loop steps a perfectly uniform five minutes, which Home + # Assistant does not. And they were both wrong on the night the clocks go back, INDEPENDENTLY, + # so neither could see the other's bug: the coordinator merged the repeated hour and deleted a + # 9 kW billing peak. An instrument that re-implements the thing it measures cannot measure it. + # + # `BillingPeriodAccumulator` is now the only definition, and this is the real one. Break it + # and --dst fails here as well as in the unit tests. + completed = billing.add(now, power_kw, POWER_SOURCE_EXTERNAL_METER) + if completed is not None: + # COUNT WHAT THE ACCUMULATOR ACTUALLY BILLED, not what this loop thinks an hour is. + # + # The first version of this counter re-derived the hour key here, from `now`, and so it + # kept reporting 25 hours on the fall-back day even when the production accumulator was + # merging the two 02:00s into one. It was measuring the harness, not the code under test + # - the exact vacuity this whole commit exists to remove, reintroduced one line below the + # comment complaining about it. Verified by mutation: reinstate the DST bug in + # billing_period.py and this now reports 24 hours and fails the run. + # COUNTED, not collected in a set: on the fall-back day both 02:00 hours carry the SAME + # local `started_at`, and PEP 495 makes those two datetimes compare EQUAL (and hash + # equal), so a set would silently merge them back into one and report 24 again - passing + # the check by making the same mistake it exists to catch. + billing_hours[completed.started_at.date()] = ( + billing_hours.get(completed.started_at.date(), 0) + 1 + ) - last_offsets.append(offset_applied) - if len(last_offsets) > 9: - last_offsets.pop(0) - deltas = [b - a for a, b in zip(last_offsets, last_offsets[1:])] - flips = sum(1 for a, b in zip(deltas, deltas[1:]) if a * b < 0) - if flips >= 3: - stats["sign_flips"] += 1 - - stats["indoor_min"] = min(stats["indoor_min"], indoor) - stats["indoor_max"] = max(stats["indoor_max"], indoor) - stats["indoor_sum"] += indoor - stats["dm_min"] = min(stats["dm_min"], dm) - stats["offset_min"] = min(stats["offset_min"], offset_applied) - stats["offset_max"] = max(stats["offset_max"], offset_applied) - energy = power_kw * STEP_MIN / 60.0 - stats["energy_kwh"] += energy - stats["aux_kwh"] += aux_kw * STEP_MIN / 60.0 - stats["cost_sek"] += energy * cur_price_ore / 100.0 - - # Effect tariff basis: quarter-hour MEAN power (Swedish effektavgift), - # never the instantaneous sample. - this_quarter = (now.date(), cur_q) - if quarter_id is not None and this_quarter != quarter_id: - q_mean = sum(quarter_samples) / len(quarter_samples) - day = quarter_id[0] - daily_peaks[day] = max(daily_peaks.get(day, 0.0), q_mean) - quarter_samples = [] - quarter_id = this_quarter - quarter_samples.append(power_kw) - - if indoor < TARGET_INDOOR - COMFORT_TOLERANCE: - stats["comfort_minutes_below"] += STEP_MIN - elif indoor > TARGET_INDOOR + OVERSHOOT_TOLERANCE: - stats["comfort_minutes_above"] += STEP_MIN - - if step % 6 == 0: # 30-min trace resolution - trace.append( - { - "t": now.isoformat(), - "tout": round(tout, 1), - "tin": round(indoor, 2), - "flow": round(flow, 1), - "dm": round(dm), - "offset": offset_applied, - "calc": round(calc_offset, 2), - "kw": round(power_kw, 2), - "price": round(cur_price_ore, 1), - "comp": int(compressor_on), - } - ) + day = completed.started_at.date() + daily_peaks[day] = max(daily_peaks.get(day, 0.0), completed.mean_power_kw) + # What the tariff COUNTS is the effective power - Ellevio halves 22:00-06:00. + # The harness used to skip the night weighting, overstating every tariff figure + # with night-shifted load - which is exactly where this optimiser puts load. + daily_billed[day] = max( + daily_billed.get(day, 0.0), + effective_tariff_power_kw(completed.mean_power_kw, completed.billing_hour), + ) + running_peak_kw = max(running_peak_kw, completed.mean_power_kw) + + # THE EFFECT LAYER WAS NEVER GIVEN A PEAK HISTORY. The harness computed + # `running_peak_kw` and handed it to the engine, but never called + # `record_quarter_measurement()` - so `EffectManager._monthly_peaks` stayed empty for + # all 8928 steps, and `should_limit_power()` short-circuits on an empty history: + # + # if not self._monthly_peaks: + # return PowerLimitDecision(should_limit=False, severity="OK", ...) + # + # The peak layer therefore voted weight 0.00 on every single step of every run. Every + # claim this harness made about effect-tariff protection - the feature the integration + # is named for - was vacuous. (The coordinator had the mirror-image bug for meter-less + # houses; this is the same hole, in the instrument that was supposed to catch it.) + asyncio.run( + effect.record_period_measurement( + power_kw=completed.mean_power_kw, + period=completed.billing_hour, + timestamp=completed.started_at, + source=POWER_SOURCE_EXTERNAL_METER, + ) + ) + + if indoor < TARGET_INDOOR - COMFORT_TOLERANCE: + stats["comfort_minutes_below"] += STEP_MIN + elif indoor > TARGET_INDOOR + OVERSHOOT_TOLERANCE: + stats["comfort_minutes_above"] += STEP_MIN + + if step % 6 == 0: # 30-min trace resolution + trace.append( + { + "t": now.isoformat(), + "tout": round(tout, 1), + "tin": round(indoor, 2), + "flow": round(flow, 1), + "dm": round(dm), + "offset": offset_applied, + "calc": round(calc_offset, 2), + "kw": round(power_kw, 2), + "price": round(cur_price_ore, 1), + "comp": int(compressor_on), + } + ) - if quarter_samples and quarter_id is not None: - q_mean = sum(quarter_samples) / len(quarter_samples) - day = quarter_id[0] - daily_peaks[day] = max(daily_peaks.get(day, 0.0), q_mean) - top3 = sorted(daily_peaks.values(), reverse=True)[:3] + finally: + dt_util.now, dt_util.utcnow = _real_now, _real_utcnow + + # The run ends on an hour boundary, and that final hour is complete in sim-time. Production + # never flushes - Home Assistant keeps running, and an hour cut short by a shutdown was never + # measured and is not a bill. + final = billing.flush() + if final is not None: + day = final.started_at.date() + daily_peaks[day] = max(daily_peaks.get(day, 0.0), final.mean_power_kw) + daily_billed[day] = max( + daily_billed.get(day, 0.0), + effective_tariff_power_kw(final.mean_power_kw, final.billing_hour), + ) + billing_hours[day] = billing_hours.get(day, 0) + 1 + top3 = sorted(daily_billed.values(), reverse=True)[:3] tariff_kw = sum(top3) / len(top3) if top3 else 0.0 - stats["peak_kw_quarter_mean"] = round(max(daily_peaks.values()), 2) if daily_peaks else 0.0 + stats["peak_kw_hourly_mean"] = round(max(daily_peaks.values()), 2) if daily_peaks else 0.0 stats["tariff_top3_kw"] = round(tariff_kw, 2) + stats["billing_hours_by_day"] = { + day.isoformat(): count for day, count in sorted(billing_hours.items()) + } stats["tariff_cost_sek"] = round(tariff_kw * EFFECT_TARIFF_SEK_PER_KW, 0) stats["total_cost_sek"] = round(stats["cost_sek"] + stats["tariff_cost_sek"], 0) stats["indoor_mean"] = round(stats["indoor_sum"] / steps, 2) del stats["indoor_sum"] + + # THE ROOM-SIDE BALANCE IS AN IDENTITY, AND I SPENT SEVERAL COMMITS QUOTING IT AS EVIDENCE. + # + # residual = heat_in - loss - stored + # the ODE d_indoor = (q_w + GAINS - HLC*(indoor - tout)) / C + # + # are the same terms rearranged, so the residual is zero by construction. It says the room ODE + # integrates consistently and NOTHING ELSE. Proved by making the compressor pay for only HALF + # the heat it produced: electricity fell from 912 to 487 kWh and the residual stayed at 0.00. + # + # And that is precisely where the original free-heat bug lived - the COMPRESSOR side. So the + # room balance could never have caught it, and I found it by reasoning rather than by the check + # I built to find it. It is kept because a non-zero value would still mean the ODE is broken, + # but it is no longer the thing being claimed. + stored_kwh = house.capacity_j_per_k * (indoor - indoor_start) / J_PER_KWH + residual = stats["heat_kwh"] - stats["loss_kwh"] - stored_kwh + stats["heat_kwh"] = round(stats["heat_kwh"], 1) + stats["loss_kwh"] = round(stats["loss_kwh"], 1) + stats["energy_balance_residual_kwh"] = round(residual, 2) + + # AND SO WAS THE COMPRESSOR-SIDE "AUDIT" I ADDED TO REPLACE IT. It is deleted here. + # + # power_kw = q_comp/cop + aux + standby (the plant) + # metered = power_kw - aux - standby (the "meter") + # owed = q_comp/cop (the "independent" figure) + # + # Substitute the first into the second and you get the third, exactly: x - y + y = x. Two + # symbols, one line, and I called them "two independent expressions of the same joules" in the + # code and in a test docstring. Doubling the compressor's COP - which halves the bill, a + # catastrophic plant bug - left it reporting 0.00 % error and PASS. + # + # There is no exact energy audit to be had inside a closed ODE plant: every residual you can + # write is a rearrangement of the equations that produced it. What CAN fail is a statement + # about something the bookkeeping does not determine, and there are exactly two of those: + # + # * water_node_leak_kwh - the flow clamp overwrites a state variable AFTER the ODE has + # integrated it, so it can destroy metered joules. It measured 183 kWh in the F2040 cold + # snap while every "audit" above read 0.00 %. + # * the second law (per step, above) and the datasheet envelope (in check_invariants), which + # bracket the COP model from above and below using data the plant's energy accounting does + # not reference. + stats["water_node_leak_kwh"] = round(stats["water_node_leak_kwh"], 1) + stats["datasheet_cop"] = round( + stats["datasheet_cop_x_heat"] / max(stats["compressor_heat_kwh"], 1e-9), 2 + ) + del stats["datasheet_cop_x_heat"] + del stats["compressor_heat_kwh"] + stats["mean_cop"] = round( + stats["heat_kwh"] / max(stats["energy_kwh"] - STANDBY_KW * steps * STEP_MIN / 60.0, 1e-9), 2 + ) stats["violations"] = len(violations) return stats, violations, trace -def main(): +# Safety invariants. A run that trips one of these has demonstrated the optimiser +# doing something it must never do, and the harness exits non-zero so that a human +# - or CI - cannot mistake a bad run for a good one. Previously every run exited 0 +# no matter what it found, so the simulation could not fail and therefore could not +# hold anything up. +FATAL_VIOLATIONS = frozenset( + { + "indoor_below_18", # comfort floor breached: the pump was starved + "indoor_above_ceiling", # house cooked, usually by the immersion heater + "offset_out_of_range", # engine emitted an offset the register cannot hold + "exception", # engine raised while controlling a heat pump + "dm_runaway", # the deficit outran the curve offset AND the aux heater + "no_price_for_instant", # adapter could not price a moment that exists + "cop_beats_carnot", # the PLANT broke the second law: every cost it reports is fiction + } +) + +# The optimiser is allowed to move heat around, but not to make the house colder +# than a do-nothing controller would. Baseline mean indoor is the comparison. +MIN_MEAN_INDOOR_C = TARGET_INDOOR - COMFORT_TOLERANCE + +# THE INVARIANTS BELOW USED TO BE UNFALSIFIABLE, AND SO DID THIS WHOLE HARNESS. +# +# Every mutation of a safety constant still printed "PASS: all safety invariants held": +# +# MIN_TEMP_LIMIT 18.0 -> 5.0 PASS <- the comfort floor, gutted +# DM_THRESHOLD_AUX_LIMIT -1500 -> -400 PASS <- immersion heater at shallow debt +# WEATHER_GENTLE_OFFSET 0.83 -> 2.0 PASS <- the overheat bug, hand-tuned against +# INTERNAL_GAINS_W 600 -> 0 PASS +# comfort-layer abstention removed PASS +# +# The reason was not the invariants themselves - it was that a mild January never brings the +# house within reach of any of them, and three of the most telling numbers were COUNTED AND +# NEVER ASSERTED. `aux_kwh` was tracked and ignored, so driving the pump into the immersion +# heater was free. `comfort_minutes_below` and `comfort_minutes_above` were tracked and ignored, +# so the house could sit outside its comfort band for the entire month and still report zero +# violations. The file's own comment complains about exactly this pattern ("The harness counted +# comfort_minutes_above and asserted nothing about it") - and then did it again, twice. +# +# A test that cannot fail cannot detect. These now bite, and the gate runs the COLD SNAP as well +# as the mild month, so the house is actually taken near its limits. + +# The immersion heater is a COP-1.0 resistive element. On a correctly sized pump in a Swedish +# January the optimiser must never reach for it: that is the whole point of the degree-minute +# ladder. A little is tolerated in a deep cold snap on an air-source pump whose capacity has +# genuinely collapsed - that is physics, not a control failure - so the budget is per-scenario. +# How much MORE resistive heat than physics forces the optimiser may burn. Not an absolute budget: +# a bivalent system is designed to use its immersion heater below Tbiv, and asserting otherwise is +# asserting something about a machine NIBE does not sell. +AUX_OVER_PHYSICS_TOLERANCE = 1.25 +AUX_SLACK_KWH = 5.0 # so a house that needs essentially none is not failed by rounding + +# Degree minutes must stay clear of the aux limit by a real margin. Skimming it means the ladder +# is only just holding, and the next colder night tips into resistive heat. +DM_AUX_MARGIN = 200.0 + +# Minutes outside the comfort band, per 31-day month. Not zero - the optimiser is ALLOWED to +# coast into the band's edge to dodge a price peak, that is its job - but a house that spends +# whole days out of band is not being optimised, it is being neglected. +MAX_COMFORT_MINUTES_BELOW = 240 +MAX_COMFORT_MINUTES_ABOVE = 720 + + +def check_invariants(tag: str, stats: dict, violations: list, house=None) -> list[str]: + """Return the reasons this run must be treated as a failure.""" + failures = [] + + fatal = [v for v in violations if v["type"] in FATAL_VIOLATIONS] + if fatal: + kinds = sorted({v["type"] for v in fatal}) + failures.append(f"{len(fatal)} safety violation(s): {', '.join(kinds)}") + + if stats["indoor_min"] < 18.0: + failures.append(f"indoor fell to {stats['indoor_min']:.2f} C (floor is 18.0)") + + if stats["indoor_mean"] < MIN_MEAN_INDOOR_C: + failures.append( + f"mean indoor {stats['indoor_mean']:.2f} C is below the comfort band " + f"({MIN_MEAN_INDOOR_C:.2f} C) - the optimiser under-heated the house" + ) + + if stats["exceptions"]: + failures.append(f"{stats['exceptions']} engine exception(s)") + + # THE PLANT MAY NOT DESTROY ENERGY THE METER CHARGED FOR. The flow clamp overwrites the water + # node's temperature after the ODE has integrated it, so it is the one place in the harness + # where joules can go missing without any residual noticing - and 183 kWh did, in the F2040 + # cold snap, while the "audits" that preceded this reported 0.00 % error. + if abs(stats["water_node_leak_kwh"]) > WATER_NODE_LEAK_BUDGET_KWH: + failures.append( + f"the flow clamp destroyed {abs(stats['water_node_leak_kwh']):.1f} kWh that the meter " + f"charged for and the room never received - the plant is deleting energy, and every " + f"cost number it produces is fiction by that much" + ) + + # THE COP MODEL, BOUNDED BY THE MANUFACTURER'S OWN BEST FIGURE. And the bound is ONE-WAY. + # + # This check used to compare the run's seasonal COP against the datasheet point nearest to the + # flow temperature - which is a FULL-LOAD figure. A heat pump at part load is legitimately more + # efficient than its full-load rating (the F750 publishes COP 4.72 at minimum frequency and 2.43 + # at maximum), so the check failed an honest plant the moment the models became real. + # + # And the F2040 legitimately runs BELOW its published range: NIBE's coldest rating point is + # -7 C, and a Swedish January reaches -11.6 C. Going below the datasheet there is physics, not + # a bug. + # + # So only one direction is a defect: a plant that buys heat MORE CHEAPLY than the machine can + # possibly make it. That is what a doubled COP looks like, and that is what this catches. + if house is not None and stats["datasheet_cop"] > 0: + ratio = stats["mean_cop"] / stats["datasheet_cop"] + if ratio > COP_ENVELOPE_TOLERANCE: + failures.append( + f"the run's seasonal COP was {stats['mean_cop']:.2f}, against a best published " + f"figure of {stats['datasheet_cop']:.2f} for this machine at ANY of its rating " + f"points ({ratio:.2f}x) - the plant is buying heat more cheaply than the machine " + f"can make it, so every cost number in this run is too low" + ) + + # THE OPTIMISER MAY NOT BURN MORE RESISTIVE HEAT THAN THE PUMP'S CAPACITY DEFICIT FORCES. + # + # This used to be an absolute budget - 0 kWh in a mild month, 25 kWh in a cold snap - and it was + # a statement about a machine that does not exist. A correctly-sized air-source system is + # BIVALENT by design: NIBE declares Tbiv = -9 C for the F2040-8, with 1.1 kW of supplementary + # heat, and below that temperature the immersion heater is SUPPOSED to run. The absolute budget + # failed the only correctly-sized air-source house in the set for doing what it was built to do. + # + # The physics-grounded question is the one worth asking, and the plant can answer it: at every + # step, how much heat did the house need that the compressor could not physically deliver? Sum + # that, and it is the resistive heat the WEATHER forces. Everything above it is the CONTROLLER's. + unavoidable = stats["unavoidable_aux_kwh"] + allowed = unavoidable * AUX_OVER_PHYSICS_TOLERANCE + AUX_SLACK_KWH + + if stats["aux_kwh"] > allowed: + failures.append( + f"the immersion heater burned {stats['aux_kwh']:.1f} kWh, but the pump's capacity " + f"deficit only forced {unavoidable:.1f} kWh of it " + f"({stats['aux_kwh'] / max(unavoidable, 1e-9):.1f}x). The rest is the controller's " + f"doing: resistive heat at COP 1.0, bought because the offset was pinned at maximum " + f"against a compressor that had nothing left to give" + ) + + if stats["comfort_minutes_below"] > MAX_COMFORT_MINUTES_BELOW: + failures.append( + f"{stats['comfort_minutes_below']} minutes below the comfort band " + f"(budget {MAX_COMFORT_MINUTES_BELOW}) - the optimiser starved the house" + ) + + if stats["comfort_minutes_above"] > MAX_COMFORT_MINUTES_ABOVE: + failures.append( + f"{stats['comfort_minutes_above']} minutes above the comfort band " + f"(budget {MAX_COMFORT_MINUTES_ABOVE}) - the optimiser cooked the house" + ) + + return failures + + +def main() -> int: selftest = "--selftest" in sys.argv coldsnap = "--coldsnap" in sys.argv baseline = "--baseline" in sys.argv + battery = "--battery" in sys.argv + live_se4 = "--live-se4" in sys.argv + no_price = "--no-price" in sys.argv + no_weather = "--no-weather" in sys.argv + tuned_curve = "--tuned-baseline" in sys.argv + undersized = "--undersized" in sys.argv + no_forecast = "--no-forecast" in sys.argv + dst = "--dst" in sys.argv mode = "balanced" if "--mode" in sys.argv: mode = sys.argv[sys.argv.index("--mode") + 1] - days = 2 if selftest else 31 - times, temps, prices = load_data(selftest) + # --dst spans the fall-back weekend: 3 days, one of them 25 hours long. + days = DST_SIM_DAYS if dst else (2 if selftest else SIM_DAYS) + times, temps, price_days, unit = load_data(selftest, live_se4, dst) if coldsnap: temps = apply_coldsnap(times, temps) OUT_DIR.mkdir(exist_ok=True) - for house in HOUSES: - stats, violations, trace = simulate(house, times, temps, prices, days, mode, baseline) + price_source = PriceSource(price_days, unit) + exit_code = 0 + + houses = HOUSES + if undersized: + # THE COMMONEST INSTALLATION FAULT THERE IS: a pump one size too small for its house. + # + # Sizing a house at the EN 14825 AVERAGE-climate design point while fitting it with a pump + # certified at the COLD one is exactly that, and both figures are published, so the gap is + # the manufacturer's own. It is not a hypothetical - it is what happens when a European-spec + # sizing meets a Swedish winter. + houses = [ + replace(h, name=f"{h.name}", hlc_w_per_k=h.hlc_w_per_k * UNDERSIZED_PUMP_FACTOR) + for h in HOUSES + ] + + for house in houses: + stats, violations, trace = simulate( + house, + times, + temps, + price_source, + days, + mode, + baseline, + battery=battery, + enable_price=not no_price, + enable_weather=not no_weather, + tuned_curve=tuned_curve, + forecast_available=not no_forecast, + ) + stats["price_unit_seen_by_adapter"] = price_source.unit tag = f"{house.name}{'-selftest' if selftest else ''}" if mode != "balanced": tag += f"-{mode}" if coldsnap: tag += "-coldsnap" + if undersized: + tag += "-undersized" + if live_se4: + tag += "-live-se4" + if battery: + tag += "-battery" if baseline: tag += "-baseline" + if no_price: + tag += "-noprice" + if no_weather: + tag += "-noweather" + if no_forecast: + tag += "-noforecast" + if dst: + tag += "-dst" + if tuned_curve: + tag += "-tuned" + + # The baseline run is a do-nothing controller used as a yardstick. It is + # expected to breach comfort - that is the point of it - so it reports but + # does not gate. + failures = [] if (baseline or battery) else check_invariants(tag, stats, violations, house) + + if dst: + # THE DST RUN MUST BE ABLE TO FAIL, OR IT IS DECORATION. + # + # A green --dst run proves very little on its own: the October night load is flat and + # low, so merging the two 02:00 hours into one two-hour period produces the SAME mean, + # the same tariff figure, and the same PASS. I checked - reverting the harness's period + # key to the ambiguous `(date, hour)` moved not one of the reported numbers. + # + # What the merge DOES change is how many billable hours the day contains. A fall-back + # day has 25. Count them, and the run can fail for the reason it exists. + hours_on_the_long_day = stats["billing_hours_by_day"].get(DST_FALL_BACK_DAY) + if hours_on_the_long_day != DST_FALL_BACK_HOURS: + failures.append( + f"{DST_FALL_BACK_DAY} was billed as {hours_on_the_long_day} hours. The clocks " + f"go back that night, so it is {DST_FALL_BACK_HOURS} hours long and every one " + f"of them is separately metered. Billing 24 means the two 02:00 hours - which " + f"print the same digits and are an hour apart - were merged into one." + ) + json.dump( - {"house": house.name, "days": days, "stats": stats, "violations": violations[:200]}, + { + "house": house.name, + "days": days, + "stats": stats, + "failures": failures, + "violations": violations[:200], + }, open(OUT_DIR / f"summary-{tag}.json", "w"), indent=1, ) json.dump(trace, open(OUT_DIR / f"trace-{tag}.json", "w")) + votes = stats.pop("layer_votes", {}) print(f"[{tag}] {json.dumps(stats)}") + if votes: + ranked = sorted(votes.items(), key=lambda kv: -kv[1]) + total = max(days * 24 * 60 // STEP_MIN, 1) + share = ", ".join(f"{n} {100 * h / total:.0f}%" for n, h in ranked) + print(f"[{tag}] layers that voted: {share}") if violations: print(f"[{tag}] first violations: {violations[:5]}") + if failures: + exit_code = 1 + for failure in failures: + print(f"[{tag}] FAIL: {failure}") + else: + print(f"[{tag}] PASS: all safety invariants held") + + return exit_code if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/scripts/start_week.sh b/scripts/start_week.sh new file mode 100755 index 00000000..6645f171 --- /dev/null +++ b/scripts/start_week.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# Bring up (or repair) the week-long live observation. Idempotent - safe to run any number of times. +# +# RUN THIS AFTER EVERY REBOOT. This box has no init: pid 1 is `docker-init -- sleep infinity`, there +# is no systemd and no cron, so nothing starts Home Assistant or the watcher when the machine comes +# back. That is the box, not the script. +# +# bash scripts/start_week.sh +# +# What it does: +# - starts Home Assistant if it is not answering on :8125 +# - starts the watcher if it is not already running (pid file, checked against /proc) +# - prints what it found and what it did + +set -u +cd /workspace || exit 1 + +ha_is_up() { curl -s -o /dev/null -m 8 "http://localhost:8125/" 2>/dev/null; } +# Alive AND actually a week_watch - a pid file alone would happily point at a recycled pid. +watcher_is_up() { + local pidfile=/workspace/.ha-config/week_watch.pid pid + [ -f "$pidfile" ] || return 1 + pid=$(tr -dc '0-9' <"$pidfile") + [ -n "$pid" ] && [ -d "/proc/$pid" ] || return 1 + tr '\0' ' ' <"/proc/$pid/cmdline" 2>/dev/null | grep -q week_watch.sh +} +# The heat pump itself. Without it HA has no BT1/BT25/degree-minutes, EffektGuard correctly refuses +# to control on incomplete data, and the week records nothing but that refusal. The first version of +# this script started Home Assistant and the watcher and forgot the pump they were meant to watch. +pump_is_up() { python3 -c " +import socket, sys +s = socket.socket(); s.settimeout(3) +try: + s.connect(('127.0.0.1', 5020)) +except OSError: + sys.exit(1) +finally: + s.close() +" 2>/dev/null; } + +if pump_is_up; then + echo "NIBE simulator : already up (modbus :5020)" +else + echo "NIBE simulator : down - starting" + setsid nohup /workspace/.venv/bin/python scripts/simulation/nibe_modbus_simulator.py \ + >>/workspace/.ha-config/nibe_sim.log 2>&1 >/workspace/.ha-config/ha.log 2>&1 & + for _ in $(seq 1 36); do + sleep 10 + ha_is_up && break + done + ha_is_up && echo "Home Assistant : up" || echo "Home Assistant : STILL DOWN - check .ha-config/ha.log" +fi + +if watcher_is_up; then + echo "watcher : already running" +else + echo "watcher : starting" + setsid nohup bash /workspace/scripts/week_watch.sh >/dev/null 2>&1 /dev/null || echo 1) - 1)) samples in .ha-config/week_watch.csv" diff --git a/scripts/test_decision_scenarios.py b/scripts/test_decision_scenarios.py index ab86625f..a6898d36 100755 --- a/scripts/test_decision_scenarios.py +++ b/scripts/test_decision_scenarios.py @@ -89,12 +89,13 @@ import argparse import importlib.util import sys +from pathlib import Path from dataclasses import dataclass from typing import Any, Optional from enum import Enum # Import constants from production code - single source of truth -sys.path.insert(0, "/workspaces/EffektGuard/custom_components/effektguard") +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "custom_components" / "effektguard")) from const import ( DM_CRITICAL_T1_MARGIN, DM_CRITICAL_T1_OFFSET, @@ -211,7 +212,13 @@ # Inject the const values into the module's globals before execution spec = importlib.util.spec_from_file_location( "climate_zones", - "/workspaces/EffektGuard/custom_components/effektguard/optimization/climate_zones.py", + str( + Path(__file__).resolve().parents[1] + / "custom_components" + / "effektguard" + / "optimization" + / "climate_zones.py" + ), ) climate_zones_module = importlib.util.module_from_spec(spec) @@ -810,9 +817,10 @@ def calculate_price_layer(self, price_data: MockPriceData) -> LayerVote: # Linear interpolation: 0.5 → 0.2 (conservative), 3.0 → 1.0 (full offset) tolerance_range = PRICE_TOLERANCE_MAX - PRICE_TOLERANCE_MIN # 2.5 factor_range = PRICE_TOLERANCE_FACTOR_MAX - PRICE_TOLERANCE_FACTOR_MIN # 0.8 - tolerance_factor = PRICE_TOLERANCE_FACTOR_MIN + ( - (self.tolerance - PRICE_TOLERANCE_MIN) / tolerance_range - ) * factor_range + tolerance_factor = ( + PRICE_TOLERANCE_FACTOR_MIN + + ((self.tolerance - PRICE_TOLERANCE_MIN) / tolerance_range) * factor_range + ) adjusted_offset = offset * tolerance_factor # Extra boost for negative prices diff --git a/scripts/test_seasonal_defaults.py b/scripts/test_seasonal_defaults.py index baee4397..378a071c 100644 --- a/scripts/test_seasonal_defaults.py +++ b/scripts/test_seasonal_defaults.py @@ -2,8 +2,9 @@ """Test seasonal defaults for weather learning""" import sys +from pathlib import Path -sys.path.insert(0, "/workspaces/EffektGuard") +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from custom_components.effektguard.optimization.climate_zones import ClimateZoneDetector from custom_components.effektguard.optimization.weather_learning import WeatherPatternLearner diff --git a/scripts/visualize_price_optimization.py b/scripts/visualize_price_optimization.py index e00a525a..621eaa0c 100644 --- a/scripts/visualize_price_optimization.py +++ b/scripts/visualize_price_optimization.py @@ -9,30 +9,69 @@ import matplotlib.pyplot as plt import matplotlib.patches as mpatches import numpy as np +from pathlib import Path from datetime import datetime, timedelta # Real price data approximated from screenshot (öre/kWh) # Today: Dec 5, 2025 - prices from 00:00 to 23:45 today_prices_hourly = [ # Night hours (cheap) - 80, 75, 70, 65, 60, 55, # 00:00-05:00 + 80, + 75, + 70, + 65, + 60, + 55, # 00:00-05:00 # Morning ramp - 90, 120, 150, 170, # 06:00-09:00 + 90, + 120, + 150, + 170, # 06:00-09:00 # Day (expensive) - 175, 180, 185, 175, 170, 176, # 10:00-15:00 + 175, + 180, + 185, + 175, + 170, + 176, # 10:00-15:00 # Peak hours - 190, 200, 185, 160, # 16:00-19:00 + 190, + 200, + 185, + 160, # 16:00-19:00 # Evening decline - 130, 110, 95, 85, # 20:00-23:00 + 130, + 110, + 95, + 85, # 20:00-23:00 ] # Tomorrow: Dec 6, 2025 - cheap all day tomorrow_prices_hourly = [ - 52, 50, 48, 47, 46, 48, # 00:00-05:00 - 55, 58, 60, 60, # 06:00-09:00 - 58, 56, 55, 54, 55, 56, # 10:00-15:00 - 58, 60, 58, 55, # 16:00-19:00 - 52, 50, 48, 46, # 20:00-23:00 + 52, + 50, + 48, + 47, + 46, + 48, # 00:00-05:00 + 55, + 58, + 60, + 60, # 06:00-09:00 + 58, + 56, + 55, + 54, + 55, + 56, # 10:00-15:00 + 58, + 60, + 58, + 55, # 16:00-19:00 + 52, + 50, + 48, + 46, # 20:00-23:00 ] # Expand to 15-min intervals @@ -65,15 +104,15 @@ # - Prediction layer: +1.5°C (constant pre-heating for predicted cold) # - Price layer: reduced weight due to volatility # - Weather comp: -1.4°C - + if price > 180: # Peak offset = -1.0 # Some reduction but prediction still fights it elif price > 160: # Expensive - offset = 1.0 # Prediction wins (current bug) + offset = 1.0 # Prediction wins (current bug) elif price > 100: # Normal offset = 0.5 else: # Cheap - offset = 1.5 # Pre-heating correctly activates + offset = 1.5 # Pre-heating correctly activates current_offset.append(offset) # --- Calculate EXPECTED behavior (fixed) --- @@ -90,7 +129,7 @@ if all_prices[j] < 80: # Cheap threshold hours_until_cheap = (j - i) / 4 break - + if price > 180: # Peak offset = -3.0 # Maximum reduction elif price > 160: # Expensive @@ -107,109 +146,161 @@ # --- Create visualization --- fig, axes = plt.subplots(3, 1, figsize=(14, 10), sharex=True) -fig.suptitle('EffektGuard Price Optimization: Current Bug vs Expected Behavior\n(Based on Dec 5-6, 2025 real prices)', - fontsize=14, fontweight='bold') +fig.suptitle( + "EffektGuard Price Optimization: Current Bug vs Expected Behavior\n(Based on Dec 5-6, 2025 real prices)", + fontsize=14, + fontweight="bold", +) + # Color coding for price regions def get_price_color(price): if price > 180: - return '#ff4444' # Red - Peak + return "#ff4444" # Red - Peak elif price > 160: - return '#ff8844' # Orange - Expensive + return "#ff8844" # Orange - Expensive elif price > 100: - return '#ffcc44' # Yellow - Normal + return "#ffcc44" # Yellow - Normal else: - return '#44cc44' # Green - Cheap + return "#44cc44" # Green - Cheap + # Plot 1: Electricity prices ax1 = axes[0] colors = [get_price_color(p) for p in all_prices] for i in range(len(all_prices) - 1): - ax1.fill_between([hours_from_now[i], hours_from_now[i+1]], - [all_prices[i], all_prices[i+1]], - alpha=0.7, color=colors[i]) -ax1.axvline(x=0, color='black', linestyle='--', linewidth=2, label='Current time (16:00)') -ax1.set_ylabel('Price (öre/kWh)', fontsize=11) + ax1.fill_between( + [hours_from_now[i], hours_from_now[i + 1]], + [all_prices[i], all_prices[i + 1]], + alpha=0.7, + color=colors[i], + ) +ax1.axvline(x=0, color="black", linestyle="--", linewidth=2, label="Current time (16:00)") +ax1.set_ylabel("Price (öre/kWh)", fontsize=11) ax1.set_ylim(0, 220) ax1.grid(True, alpha=0.3) -ax1.set_title('Electricity Prices: Today (expensive) → Tomorrow (65% cheaper)', fontsize=11) +ax1.set_title("Electricity Prices: Today (expensive) → Tomorrow (65% cheaper)", fontsize=11) # Add price zone legend -peak_patch = mpatches.Patch(color='#ff4444', label='PEAK (>180 öre)') -expensive_patch = mpatches.Patch(color='#ff8844', label='EXPENSIVE (160-180 öre)') -normal_patch = mpatches.Patch(color='#ffcc44', label='NORMAL (100-160 öre)') -cheap_patch = mpatches.Patch(color='#44cc44', label='CHEAP (<100 öre)') -ax1.legend(handles=[peak_patch, expensive_patch, normal_patch, cheap_patch], - loc='upper right', fontsize=9) +peak_patch = mpatches.Patch(color="#ff4444", label="PEAK (>180 öre)") +expensive_patch = mpatches.Patch(color="#ff8844", label="EXPENSIVE (160-180 öre)") +normal_patch = mpatches.Patch(color="#ffcc44", label="NORMAL (100-160 öre)") +cheap_patch = mpatches.Patch(color="#44cc44", label="CHEAP (<100 öre)") +ax1.legend( + handles=[peak_patch, expensive_patch, normal_patch, cheap_patch], loc="upper right", fontsize=9 +) # Add "Tomorrow" label -ax1.annotate('← TODAY', xy=(-4, 200), fontsize=10, fontweight='bold', color='gray') -ax1.annotate('TOMORROW →', xy=(12, 60), fontsize=10, fontweight='bold', color='green') +ax1.annotate("← TODAY", xy=(-4, 200), fontsize=10, fontweight="bold", color="gray") +ax1.annotate("TOMORROW →", xy=(12, 60), fontsize=10, fontweight="bold", color="green") # Plot 2: Current (buggy) behavior ax2 = axes[1] # Only plot from current time onwards future_hours = hours_from_now[current_quarter:] future_current = current_offset[current_quarter:] -ax2.fill_between(future_hours, future_current, 0, - where=[o > 0 for o in future_current], - color='#ff6666', alpha=0.7, label='Heating (+offset)') -ax2.fill_between(future_hours, future_current, 0, - where=[o <= 0 for o in future_current], - color='#6666ff', alpha=0.7, label='Reducing (-offset)') -ax2.axhline(y=0, color='gray', linestyle='-', linewidth=0.5) -ax2.axvline(x=0, color='black', linestyle='--', linewidth=2) -ax2.set_ylabel('Offset (°C)', fontsize=11) +ax2.fill_between( + future_hours, + future_current, + 0, + where=[o > 0 for o in future_current], + color="#ff6666", + alpha=0.7, + label="Heating (+offset)", +) +ax2.fill_between( + future_hours, + future_current, + 0, + where=[o <= 0 for o in future_current], + color="#6666ff", + alpha=0.7, + label="Reducing (-offset)", +) +ax2.axhline(y=0, color="gray", linestyle="-", linewidth=0.5) +ax2.axvline(x=0, color="black", linestyle="--", linewidth=2) +ax2.set_ylabel("Offset (°C)", fontsize=11) ax2.set_ylim(-4, 3) ax2.grid(True, alpha=0.3) -ax2.set_title('CURRENT Behavior (Bug): Heating during expensive period!', fontsize=11, color='red') -ax2.legend(loc='upper right', fontsize=9) +ax2.set_title("CURRENT Behavior (Bug): Heating during expensive period!", fontsize=11, color="red") +ax2.legend(loc="upper right", fontsize=9) # Annotate the problem -ax2.annotate('BUG: +1°C offset\n(prediction layer\noverrides price)', - xy=(1, 1.0), xytext=(3, 2.2), - fontsize=9, color='red', - arrowprops=dict(arrowstyle='->', color='red')) +ax2.annotate( + "BUG: +1°C offset\n(prediction layer\noverrides price)", + xy=(1, 1.0), + xytext=(3, 2.2), + fontsize=9, + color="red", + arrowprops=dict(arrowstyle="->", color="red"), +) # Plot 3: Expected (fixed) behavior ax3 = axes[2] future_expected = expected_offset[current_quarter:] -ax3.fill_between(future_hours, future_expected, 0, - where=[o > 0 for o in future_expected], - color='#ff6666', alpha=0.7, label='Heating (+offset)') -ax3.fill_between(future_hours, future_expected, 0, - where=[o <= 0 for o in future_expected], - color='#6666ff', alpha=0.7, label='Reducing (-offset)') -ax3.axhline(y=0, color='gray', linestyle='-', linewidth=0.5) -ax3.axvline(x=0, color='black', linestyle='--', linewidth=2) -ax3.set_ylabel('Offset (°C)', fontsize=11) -ax3.set_xlabel('Hours from now', fontsize=11) +ax3.fill_between( + future_hours, + future_expected, + 0, + where=[o > 0 for o in future_expected], + color="#ff6666", + alpha=0.7, + label="Heating (+offset)", +) +ax3.fill_between( + future_hours, + future_expected, + 0, + where=[o <= 0 for o in future_expected], + color="#6666ff", + alpha=0.7, + label="Reducing (-offset)", +) +ax3.axhline(y=0, color="gray", linestyle="-", linewidth=0.5) +ax3.axvline(x=0, color="black", linestyle="--", linewidth=2) +ax3.set_ylabel("Offset (°C)", fontsize=11) +ax3.set_xlabel("Hours from now", fontsize=11) ax3.set_ylim(-4, 3) ax3.grid(True, alpha=0.3) -ax3.set_title('EXPECTED Behavior (Fixed): Reduce now, pre-heat during cheap tomorrow', - fontsize=11, color='green') -ax3.legend(loc='upper right', fontsize=9) +ax3.set_title( + "EXPECTED Behavior (Fixed): Reduce now, pre-heat during cheap tomorrow", + fontsize=11, + color="green", +) +ax3.legend(loc="upper right", fontsize=9) # Annotate the fix -ax3.annotate('FIXED: -1.8°C offset\n(reduce heating,\nwait for cheap)', - xy=(1, -1.8), xytext=(3, -3.0), - fontsize=9, color='green', - arrowprops=dict(arrowstyle='->', color='green')) +ax3.annotate( + "FIXED: -1.8°C offset\n(reduce heating,\nwait for cheap)", + xy=(1, -1.8), + xytext=(3, -3.0), + fontsize=9, + color="green", + arrowprops=dict(arrowstyle="->", color="green"), +) -ax3.annotate('Pre-heat when\nprices are cheap', - xy=(10, 1.5), xytext=(14, 2.5), - fontsize=9, color='green', - arrowprops=dict(arrowstyle='->', color='green')) +ax3.annotate( + "Pre-heat when\nprices are cheap", + xy=(10, 1.5), + xytext=(14, 2.5), + fontsize=9, + color="green", + arrowprops=dict(arrowstyle="->", color="green"), +) # Add hour markers on x-axis ax3.set_xticks(range(-16, 32, 4)) ax3.set_xlim(-16, 32) plt.tight_layout() -plt.savefig('/workspaces/EffektGuard/docs/dev/price_optimization_comparison.png', dpi=150, bbox_inches='tight') +plt.savefig( + str(Path(__file__).resolve().parents[1] / "docs" / "dev" / "price_optimization_comparison.png"), + dpi=150, + bbox_inches="tight", +) plt.show() -print("\n✅ Graph saved to: /workspaces/EffektGuard/docs/dev/price_optimization_comparison.png") +print(f"\n✅ Graph saved to: {Path(__file__).resolve().parents[1] / 'docs' / 'dev'}") print("\nKey observations:") print(" • Current (bug): +1°C offset during expensive period (176 öre)") print(" • Expected (fix): -1.8°C offset during expensive, +1.5°C during cheap tomorrow") diff --git a/scripts/week_watch.sh b/scripts/week_watch.sh new file mode 100755 index 00000000..402cd46f --- /dev/null +++ b/scripts/week_watch.sh @@ -0,0 +1,175 @@ +#!/bin/bash +# Week-long live observation of EffektGuard against real SE4 spot prices. +# +# Do not run this directly - run scripts/start_week.sh, which is idempotent and also brings +# Home Assistant up. This script assumes it is the only copy of itself. +# +# Two jobs: +# 1. keep Home Assistant up (restart it if it dies) +# 2. snapshot what the integration actually DID, every 15 minutes, to a CSV +# +# SINGLE INSTANCE, ENFORCED WITH A PID FILE. Two earlier attempts at this were both wrong: +# +# `pkill -f week_watch.sh` does not reliably reach a process in its own `setsid` session, so a +# "restarted" watcher ran ALONGSIDE the old one and both appended to the same CSV. +# +# Then `flock` on fd 9 - and an `exec 9>` fd is INHERITED BY CHILDREN. Killing the watcher left +# its `sleep 900` child holding the lock, so the lock outlived the process: start_week.sh reported +# "already running" when nothing was, and a new watcher could never take the lock. A lock a corpse +# can hold is worse than no lock. +# +# A pid file cannot be inherited. We check the pid is alive AND is actually a week_watch. +# +# THIS BOX HAS NO INIT. pid 1 is `docker-init -- sleep infinity`: no systemd, no cron, nothing that +# runs on boot. A reboot kills Home Assistant and this watcher, and NOTHING brings them back. +# After a reboot somebody has to run scripts/start_week.sh. That is a property of the box, not +# something the script can fix. +# +# Output: /workspace/.ha-config/week_watch.csv (git-excluded, like the rest of .ha-config) + +set -u + +LOG=/workspace/.ha-config/ha.log +CSV=/workspace/.ha-config/week_watch.csv +WATCHLOG=/workspace/.ha-config/week_watch.log +PIDFILE=/workspace/.ha-config/week_watch.pid +INTERVAL=900 # 15 minutes + +if [ -f "$PIDFILE" ]; then + OLD=$(tr -dc '0-9' <"$PIDFILE") + if [ -n "$OLD" ] && [ -d "/proc/$OLD" ] && tr '\0' ' ' <"/proc/$OLD/cmdline" 2>/dev/null | grep -q week_watch.sh; then + echo "$(date -u +%FT%TZ) another week_watch is already running (pid $OLD) - exiting" >>"$WATCHLOG" + exit 0 + fi + echo "$(date -u +%FT%TZ) stale pidfile (pid $OLD gone) - taking over" >>"$WATCHLOG" +fi +echo $$ >"$PIDFILE" +trap 'rm -f "$PIDFILE"' EXIT + +ha_is_up() { + curl -s -o /dev/null -m 8 "http://localhost:8125/" 2>/dev/null +} + +# THE PUMP IS PART OF THE STACK, and the first version of this watcher did not know that. +# +# The simulated F1155 serves BT1, BT25 and the degree minutes over modbus on :5020. Without it +# EffektGuard cannot read the sensors it requires, and it does the right thing - it refuses to +# control the heat pump on incomplete data and says so, every cycle. After the reboot that is +# exactly what the week recorded: a static house, a frozen price, and the error count climbing by +# six every fifteen minutes. Home Assistant was up, the watcher was up, and the thing they were +# both watching was not there. +pump_is_up() { + python3 -c " +import socket, sys +s = socket.socket(); s.settimeout(3) +try: + s.connect(('127.0.0.1', 5020)) +except OSError: + sys.exit(1) +finally: + s.close() +" 2>/dev/null +} + +# Devbox login. These default to the throwaway onboarding account this box's CLAUDE.md +# creates (dev/dev); override via env for any box where that is not true. A committed literal +# password is a bad habit even when it guards nothing. +HA_USER="${WEEK_WATCH_HA_USER:-dev}" +HA_PASS="${WEEK_WATCH_HA_PASS:-dev}" + +token() { + local cid="http://localhost:8125/" fid code + fid=$(curl -s -m 10 -X POST http://localhost:8125/auth/login_flow \ + -H 'Content-Type: application/json' \ + -d "{\"client_id\":\"$cid\",\"handler\":[\"homeassistant\",null],\"redirect_uri\":\"$cid\"}" | + python3 -c "import sys,json;print(json.load(sys.stdin).get('flow_id',''))" 2>/dev/null) || return 1 + [ -z "$fid" ] && return 1 + code=$(curl -s -m 10 -X POST "http://localhost:8125/auth/login_flow/$fid" \ + -H 'Content-Type: application/json' \ + -d "{\"client_id\":\"$cid\",\"username\":\"$HA_USER\",\"password\":\"$HA_PASS\"}" | + python3 -c "import sys,json;print(json.load(sys.stdin).get('result',''))" 2>/dev/null) || return 1 + [ -z "$code" ] && return 1 + curl -s -m 10 -X POST http://localhost:8125/auth/token \ + -d "grant_type=authorization_code&code=$code&client_id=$cid" | + python3 -c "import sys,json;print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null +} + +[ -f "$CSV" ] || echo "utc,offset,degree_minutes,indoor,supply,outdoor,price_ore,peak_today_kw,peak_month_kw,hvac,errors,restarts" >"$CSV" + +echo "$(date -u +%FT%TZ) week_watch started (pid $$)" >>"$WATCHLOG" +RESTARTS=0 + +while true; do + if ! pump_is_up; then + RESTARTS=$((RESTARTS + 1)) + echo "$(date -u +%FT%TZ) NIBE simulator down - starting it (#$RESTARTS)" >>"$WATCHLOG" + setsid nohup /workspace/.venv/bin/python /workspace/scripts/simulation/nibe_modbus_simulator.py \ + >>/workspace/.ha-config/nibe_sim.log 2>&1 >"$WATCHLOG" + nohup start-ha >>"$LOG" 2>&1 & + for _ in $(seq 1 30); do + sleep 10 + ha_is_up && break + done + fi + + TOK=$(token) || TOK="" + if [ -n "$TOK" ]; then + # `grep -c` prints 0 AND exits 1 when it matches nothing, so a `|| echo 0` fallback appends a + # SECOND zero and splits the CSV row in half. Force it to one integer, always. + ERRS=$(grep -c "ERROR.*effektguard" "$LOG" 2>/dev/null | head -1 | tr -dc '0-9') + ERRS=${ERRS:-0} + + ROW=$(curl -s -m 15 -H "Authorization: Bearer $TOK" http://localhost:8125/api/states | + RESTARTS="$RESTARTS" ERRS="$ERRS" python3 -c " +import sys, json, os, datetime +try: + states = {e['entity_id']: e for e in json.load(sys.stdin)} +except Exception: + sys.exit(1) +def s(eid, attr=None): + e = states.get(eid) + if not e: + return '' + v = e['attributes'].get(attr) if attr else e['state'] + return '' if v in (None, 'unknown', 'unavailable') else v +row = [ + datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), + s('sensor.effektguard_current_offset'), + s('sensor.effektguard_degree_minutes'), + s('climate.effektguard', 'current_temperature'), + s('sensor.effektguard_supply_temperature'), + s('climate.effektguard', 'outdoor_temp'), + s('climate.effektguard', 'current_price'), + s('sensor.effektguard_peak_today'), + s('sensor.effektguard_peak_this_month'), + s('climate.effektguard'), + ''.join(c for c in os.environ.get('ERRS', '0') if c.isdigit()) or '0', + ''.join(c for c in os.environ.get('RESTARTS', '0') if c.isdigit()) or '0', +] +line = ','.join(str(x).replace(',', ' ').replace(chr(10), ' ') for x in row) +if line.count(',') != 11: + sys.exit(1) +print(line) +") + # Only a row with exactly 12 fields is written. A malformed record over seven days is worse + # than a missing one: it looks fine until the day somebody tries to read it. + if [ -n "$ROW" ]; then + echo "$ROW" >>"$CSV" + else + echo "$(date -u +%FT%TZ) skipped a malformed/empty sample" >>"$WATCHLOG" + fi + else + echo "$(date -u +%FT%TZ) could not authenticate to HA - skipping this sample" >>"$WATCHLOG" + fi + + sleep "$INTERVAL" +done