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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions apps/predbat/tests/test_integer_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -478,3 +478,106 @@ def test_metric_battery_value_scaling_step_resolves_export_margin(my_predbat):

print("✓ Test passed: metric_battery_value_scaling step {} keeps the first nudge ({:.4f}) clear of the {:.4f} flip point and keeps old 0.1-step values valid".format(step, first_nudge, flip_point))
return False


def test_get_arg_float_schema_int_default_not_truncated(my_predbat):
"""
Regression test for #4925: a fractional value for a key APPS_SCHEMA declares "float" must
survive get_arg() even when the call site passed a bare int as its default.

get_arg() coerces its return value on the *type* of the default it was handed, not on the
key's declared type, and applies that coercion to whatever value was resolved - real
configured value or not. So `get_arg("solcast_poll_hours", 8)` ran a genuinely configured 4.8
through `int(float(value))` and returned 4, shortening the Solcast poll TTL from 4.8h to 4h
and pushing a two-site hobbyist account past its 10 poll/day quota into nightly HTTP 429s.
Nothing warned: validate_config() checks the raw apps.yaml value against the float schema and
passes it, so apps.yaml still reads 4.8 while the runtime behaves as 4.

Fixed at the source in get_arg() by trusting APPS_SCHEMA over the literal the caller happened
to write, so this cannot depend on which call site does the reading. #4296 was the same
mechanism on the CONFIG_ITEMS route (see the get_ha_config tests above); this is the
apps.yaml/APPS_SCHEMA route, which get_ha_config's normalisation cannot reach because there
is no config_index entry to match.
"""
print("**** test_get_arg_float_schema_int_default_not_truncated ****")

original_args = my_predbat.args.copy()
try:
# The reported case: solcast_poll_hours read the way components.py's arg spec reads it.
my_predbat.args["solcast_poll_hours"] = 4.8
value = my_predbat.get_arg("solcast_poll_hours", 8)
assert value == 4.8, "get_arg('solcast_poll_hours', 8) should return 4.8, got {} ({})".format(value, type(value))
assert value * 60 == 288, "Solcast poll TTL should be 288 minutes, got {}".format(value * 60)

# A money value on the same path - a configured 12.5p/kWh must not be charged as 12p.
my_predbat.args["axle_pence_per_kwh"] = 12.5
value = my_predbat.get_arg("axle_pence_per_kwh", 100)
assert value == 12.5, "get_arg('axle_pence_per_kwh', 100) should return 12.5, got {} ({})".format(value, type(value))

# Not only the component path: a direct get_arg() call site with an int default is equally
# affected, which is why the fix lives in get_arg() rather than in Components.initialize().
my_predbat.args["octopus_saving_session_rate"] = 12.5
value = my_predbat.get_arg("octopus_saving_session_rate", 100)
assert value == 12.5, "get_arg('octopus_saving_session_rate', 100) should return 12.5, got {} ({})".format(value, type(value))

# An unset key still falls back to the caller's default, just typed to match the schema.
my_predbat.args.pop("solcast_poll_hours", None)
value = my_predbat.get_arg("solcast_poll_hours", 8)
assert value == 8 and isinstance(value, float), "Unset float-declared key should fall back to a float 8.0, got {} ({})".format(value, type(value))

# An integer-declared key must be unaffected - truncation there is the declared behaviour.
my_predbat.args["num_cars"] = 1.8
value = my_predbat.get_arg("num_cars", 1)
assert value == 1 and isinstance(value, int), "Integer-declared key should still coerce to int, got {} ({})".format(value, type(value))

# A boolean default must not be dragged into the float branch by isinstance(True, int).
my_predbat.args["forecast_solar_open_meteo_first"] = True
value = my_predbat.get_arg("forecast_solar_open_meteo_first", False)
assert value is True, "Boolean-declared key should stay boolean, got {} ({})".format(value, type(value))
finally:
my_predbat.args = original_args

print("✓ Test passed: a fractional value for a float-declared key survives an int default in get_arg")
return False


def test_component_arg_specs_resolve_float_declared_keys_as_float(my_predbat):
"""
Sweep for #4925 across the whole component framework: every COMPONENT_LIST arg spec whose
"config" key APPS_SCHEMA declares "float" must resolve a fractional value as a float, however
that spec's "default" literal happens to be written.

Components.initialize() passes the spec default straight into get_arg()
(`arg_dict[arg] = self.base.get_arg(arg_info["config"], default, indirect=indirect)`), so an
author writing `"default": 8` rather than `8.0` used to change the runtime type of the key.
This resolves each spec exactly the way initialize() does, rather than asserting on the
literals in config.py/components.py, so it stays true for arg specs added later.
"""
print("**** test_component_arg_specs_resolve_float_declared_keys_as_float ****")

from components import COMPONENT_LIST
from config import APPS_SCHEMA

original_args = my_predbat.args.copy()
checked = []
try:
for component_name, component_info in COMPONENT_LIST.items():
for arg, arg_info in component_info.get("args", {}).items():
config_name = arg_info.get("config", None)
if not config_name or arg_info.get("config_late_resolve", False):
continue
if "float" not in APPS_SCHEMA.get(config_name, {}).get("type", "").split("|"):
continue

my_predbat.args[config_name] = 4.8
value = my_predbat.get_arg(config_name, arg_info.get("default", None), indirect=arg_info.get("indirect", False))
my_predbat.args.pop(config_name, None)
assert value == 4.8, "{}.{} ({}) resolved a configured 4.8 as {} ({}) - a float-declared key must not be truncated by its arg spec default".format(component_name, arg, config_name, value, type(value))
checked.append(config_name)
finally:
my_predbat.args = original_args

assert checked, "No float-declared component arg specs found to check - has APPS_SCHEMA or COMPONENT_LIST changed shape?"

print("✓ Test passed: {} float-declared component arg spec(s) resolve fractional values intact: {}".format(len(checked), ", ".join(sorted(set(checked)))))
return False
4 changes: 4 additions & 0 deletions apps/predbat/unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,8 @@
test_get_ha_config_normalises_int_default_for_fractional_step,
test_metric_battery_cycle_fractional_value_not_truncated,
test_metric_battery_value_scaling_step_resolves_export_margin,
test_get_arg_float_schema_int_default_not_truncated,
test_component_arg_specs_resolve_float_declared_keys_as_float,
)
from tests.test_predbat_metrics_data_age import test_data_age_metrics_round_trip
from tests.test_metrics_dashboard_control_conflicts import test_control_conflicts_metrics_round_trip, test_control_conflicts_dashboard_renders_section
Expand Down Expand Up @@ -608,6 +610,8 @@ def main():
("get_ha_config_fractional_default", test_get_ha_config_normalises_int_default_for_fractional_step, "get_ha_config normalises int default to float for fractional-step items (#4296)", False),
("metric_battery_cycle_fractional", test_metric_battery_cycle_fractional_value_not_truncated, "metric_battery_cycle fractional value not truncated by get_arg (#4296)", False),
("metric_battery_value_scaling_step", test_metric_battery_value_scaling_step_resolves_export_margin, "metric_battery_value_scaling step resolves the export margin (#4840)", False),
("get_arg_float_schema", test_get_arg_float_schema_int_default_not_truncated, "get_arg trusts an APPS_SCHEMA float type over an int default (#4925)", False),
("component_arg_float_specs", test_component_arg_specs_resolve_float_declared_keys_as_float, "COMPONENT_LIST arg specs resolve float-declared keys as float (#4925)", False),
("data_age_metrics", test_data_age_metrics_round_trip, "Metrics dashboard data_age_days/data_age_required_days tests", False),
("control_conflicts_metrics", test_control_conflicts_metrics_round_trip, "Metrics dashboard control_conflicts round-trip tests", False),
("control_conflicts_dashboard", test_control_conflicts_dashboard_renders_section, "Metrics dashboard control_conflicts section render tests", False),
Expand Down
14 changes: 14 additions & 0 deletions apps/predbat/userinterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,20 @@ def get_arg(self, arg, default=None, indirect=True, combine=False, attribute=Non
Argument getter that can use HA state as well as fixed values
"""
value = None

if isinstance(default, int) and not isinstance(default, bool) and "float" in APPS_SCHEMA.get(arg, {}).get("type", "").split("|"):
# The default is not just a fallback for a missing value - the coercion at the end of this
# function is keyed on the *type* of this default and is applied to whatever value was
# resolved, real configured value or not. So a bare int default silently truncates a
# genuinely configured fractional value via int(float(value)), even though APPS_SCHEMA
# declares the key float and validate_config() accepted the fraction as valid (#4925:
# a configured solcast_poll_hours of 4.8 became 4, shortening the poll TTL to 4h and
# blowing the 10/day Solcast hobbyist quota; #4296 was the same mechanism on the
# CONFIG_ITEMS route, fixed in get_ha_config()). Trust the schema over the literal the
# caller happened to write, so no call site - a COMPONENT_LIST arg spec included - can
# change the type of a float-declared key by writing 8 instead of 8.0.
default = float(default)

if can_override:
can_override = CONFIG_API_OVERRIDE.get(arg, False)

Expand Down
Loading