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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/predbat/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -1497,6 +1497,12 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
json_row["slot_minute"] = minute # aligned slot minute used by the override system
json_row["import_rate"] = rate_value_import
json_row["export_rate"] = rate_value_export
import_rate_adjust_type = self.rate_import_replicated.get(minute)
if import_rate_adjust_type is not None:
json_row["import_rate_adjust_type"] = import_rate_adjust_type
export_rate_adjust_type = self.rate_export_replicated.get(minute)
if export_rate_adjust_type is not None:
json_row["export_rate_adjust_type"] = export_rate_adjust_type
# Add adjusted rates (always included for client-side debug toggle)
json_row["import_rate_adjusted"] = dp2(rate_value_import / self.battery_loss / self.inverter_loss + self.metric_battery_cycle)
json_row["export_rate_adjusted"] = dp2(rate_value_export * self.battery_loss_discharge * self.inverter_loss - self.metric_battery_cycle)
Expand Down
145 changes: 145 additions & 0 deletions apps/predbat/tests/test_plan_json_rate_adjust.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# -----------------------------------------------------------------------------
# Predbat Home Battery System
# Copyright Trefor Southwell 2026 - All Rights Reserved
# This application maybe used for personal use only and not for commercial use
# -----------------------------------------------------------------------------
# fmt off
# pylint: disable=consider-using-f-string
# pylint: disable=line-too-long
# pylint: disable=attribute-defined-outside-init

from prediction import Prediction
from tests.test_infra import reset_inverter, reset_rates, update_rates_import


def run_test_plan_json_rate_adjust(my_predbat):
"""
Test that import_rate_adjust_type / export_rate_adjust_type fields
in the JSON plan output match the underlying rate_*_replicated values,
are omitted when None, and are present when an adjustment exists.
"""
print("**** Running plan JSON rate adjust type tests ****")
failed = False

# --- Test 1: adjust_symbol mapping ---
print("Test adjust_symbol mapping")
expected_symbols = {
"offset": "? ⅆ",
"future": "? ⚖",
"user": "=",
"manual": "ⅎ",
"increment": "±",
"saving": "$",
"unknown_type": "?",
}
for adjust_type, expected in expected_symbols.items():
result = my_predbat.adjust_symbol(adjust_type)
if result != expected:
print("ERROR: adjust_symbol('{}') expected '{}' got '{}'".format(adjust_type, expected, result))
failed = True

if my_predbat.adjust_symbol(None) != "":
print("ERROR: adjust_symbol(None) should return empty string")
failed = True

if my_predbat.adjust_symbol("") != "":
print("ERROR: adjust_symbol('') should return empty string")
failed = True

# --- Test 2: JSON plan rows via publish_html_plan ---
print("Test plan JSON output with rate_adjust_type fields")

# Set up minimal plan state (following test_optimise_all_windows pattern)
my_predbat.load_user_config()
my_predbat.fetch_config_options()
reset_inverter(my_predbat)
my_predbat.forecast_minutes = 24 * 60
my_predbat.end_record = 48 * 60
my_predbat.debug_enable = False
my_predbat.soc_max = 10.0
my_predbat.soc_kw = 5.0
my_predbat.num_inverters = 1
my_predbat.reserve = 0.5
my_predbat.set_charge_freeze = True

pv_step = {}
load_step = {}
for minute in range(0, my_predbat.forecast_minutes, 5):
pv_step[minute] = 0
load_step[minute] = 0.5 / (60 / 5)
my_predbat.load_minutes_step = load_step
my_predbat.load_minutes_step10 = load_step
my_predbat.pv_forecast_minute_step = pv_step
my_predbat.pv_forecast_minute10_step = pv_step
my_predbat.prediction = Prediction(my_predbat, pv_step, pv_step, load_step, load_step)

charge_window_best = [{"start": my_predbat.minutes_now, "end": my_predbat.minutes_now + 60, "average": 10.0}]
export_window_best = []
reset_rates(my_predbat, 10.0, 5.0)
update_rates_import(my_predbat, charge_window_best)

charge_limit_best = [0]
export_limits_best = []

# Run prediction with save="best" to populate all plan attributes
my_predbat.run_prediction(charge_limit_best, charge_window_best, export_window_best, export_limits_best, False, end_record=my_predbat.end_record, save="best")
my_predbat.charge_limit_best = charge_limit_best
my_predbat.export_limits_best = export_limits_best
my_predbat.charge_window_best = charge_window_best
my_predbat.export_window_best = export_window_best

# Set specific replicated rate types for known minutes
test_minute = my_predbat.minutes_now
my_predbat.rate_import_replicated = {test_minute: "future"}
my_predbat.rate_export_replicated = {test_minute: "manual"}

html_plan, raw_plan = my_predbat.publish_html_plan(pv_step, pv_step, load_step, load_step, my_predbat.end_record, publish=False)

if not raw_plan or "rows" not in raw_plan:
print("ERROR: raw_plan has no rows")
failed = True
else:
rows = raw_plan["rows"]
if len(rows) == 0:
print("ERROR: raw_plan has zero rows")
failed = True

# Find row with our adjusted minute
adjusted_row = None
non_adjusted_rows = []
for row in rows:
if row.get("slot_minute") == test_minute:
adjusted_row = row
elif "import_rate_adjust_type" not in row and "export_rate_adjust_type" not in row:
non_adjusted_rows.append(row)

# Verify adjusted row has correct type values
if adjusted_row is None:
print("WARNING: Could not find row for minute {} in plan output".format(test_minute))
else:
if adjusted_row.get("import_rate_adjust_type") != "future":
print("ERROR: Expected import_rate_adjust_type='future' got '{}'".format(adjusted_row.get("import_rate_adjust_type")))
failed = True
if adjusted_row.get("export_rate_adjust_type") != "manual":
print("ERROR: Expected export_rate_adjust_type='manual' got '{}'".format(adjusted_row.get("export_rate_adjust_type")))
failed = True

# Verify non-adjusted rows omit the keys entirely (attribute bloat prevention)
if len(non_adjusted_rows) > 0:
sample = non_adjusted_rows[0]
if "import_rate_adjust_type" in sample:
print("ERROR: Non-adjusted row should not contain import_rate_adjust_type key (attribute bloat)")
failed = True
if "export_rate_adjust_type" in sample:
print("ERROR: Non-adjusted row should not contain export_rate_adjust_type key (attribute bloat)")
failed = True
else:
print("WARNING: No non-adjusted rows found to verify key omission")

# Clean up
my_predbat.rate_import_replicated = {}
my_predbat.rate_export_replicated = {}

if not failed:
print("All plan JSON rate adjust type tests passed")
return failed
2 changes: 2 additions & 0 deletions apps/predbat/unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
from tests.test_balance_inverters import run_balance_inverters_tests
from tests.test_octopus_download_rates import test_octopus_download_rates_wrapper
from tests.test_integer_config import test_integer_config_entities, test_expose_config_preserves_integer
from tests.test_plan_json_rate_adjust import run_test_plan_json_rate_adjust
from tests.test_rate_replicate_missing_slots import test_rate_replicate
from tests.test_carbon import test_carbon
from tests.test_download import test_download
Expand Down Expand Up @@ -232,6 +233,7 @@ def main():
("ge_cloud", test_ge_cloud, "GE Cloud comprehensive tests (API, devices, EVC, inverter ops, events, publishing, config, downloads, cache)", False),
("integer_config", test_integer_config_entities, "Integer config entities tests", False),
("expose_config_integer", test_expose_config_preserves_integer, "Expose config preserves integer tests", False),
("plan_json_rate_adjust", run_test_plan_json_rate_adjust, "Plan JSON rate adjust type field tests", False),
# Download tests
("download", test_download, "Predbat download/update comprehensive tests (GitHub API, SHA1, install check, file ops)", False),
# Axle Energy VPP unit tests
Expand Down
26 changes: 24 additions & 2 deletions apps/predbat/web_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -6334,6 +6334,20 @@ def get_plan_renderer_js():
}
}

// Map rate adjust type to HTML symbol (matches output.py adjust_symbol)
function getAdjustSymbol(adjustType) {
if (!adjustType) return '';
switch (adjustType) {
case 'offset': return '? ⅆ';
case 'future': return '? ⚖';
case 'user': return '=';
case 'manual': return 'ⅎ';
case 'increment': return '±';
case 'saving': return '$';
default: return '?';
}
}

// Render plan table from JSON data
function renderPlanTable(jsonData, overrides, showDebug, editable) {
try {
Expand Down Expand Up @@ -6392,12 +6406,16 @@ def get_plan_renderer_js():
html += `<td id=time bgcolor=#FFFFFF>${timeDisplay}</td>`;
}

// Import rate - formatted bold if in charge window, with adjusted rate in parentheses if debug mode
// Import rate - formatted bold if in charge window, italic with symbol if estimated
const importBold = row.state && (row.state === 'Chrg' || row.state === 'HoldChrg' || row.state === 'FrzChrg');
let importText = row.import_rate.toFixed(2);
if (showDebug && row.import_rate_adjusted !== undefined) {
importText += ` (${row.import_rate_adjusted.toFixed(2)})`;
}
const importAdjust = row.import_rate_adjust_type ? ` ${getAdjustSymbol(row.import_rate_adjust_type)}` : '';
if (row.import_rate_adjust_type) {
importText = `<i>${importText}${importAdjust}</i>`;
}
if (importBold) {
importText = `<b>${importText}</b>`;
}
Expand All @@ -6407,11 +6425,15 @@ def get_plan_renderer_js():
html += `<td id=import ${cellStyle} bgcolor=${row.rate_color_import || '#FFFFFF'}>${importText}</td>`;
}

// Export rate - with adjusted rate in parentheses if debug mode
// Export rate - italic with symbol if estimated
let exportText = row.export_rate.toFixed(2);
if (showDebug && row.export_rate_adjusted !== undefined) {
exportText += ` (${row.export_rate_adjusted.toFixed(2)})`;
}
const exportAdjust = row.export_rate_adjust_type ? ` ${getAdjustSymbol(row.export_rate_adjust_type)}` : '';
if (row.export_rate_adjust_type) {
exportText = `<i>${exportText}${exportAdjust}</i>`;
}
if (editable) {
html += renderRateCell(row.export_rate, row.rate_color_export, 'export', row.time, timeDisplay, overrides, exportText, row.slot_minute);
} else {
Expand Down
Loading