Skip to content

Commit

Permalink
feat: Added manual reset mode to cost trackers (1.5 hours dev time)
Browse files Browse the repository at this point in the history
  • Loading branch information
BottlecapDave authored Jan 1, 2025
1 parent 37b69c8 commit 0bd38af
Show file tree
Hide file tree
Showing 9 changed files with 141 additions and 9 deletions.
13 changes: 13 additions & 0 deletions _docs/setup/cost_tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ This should be true if the tracked entity's state increases over time (true) or

However, there have [been reports](https://github.com/BottlecapDave/HomeAssistant-OctopusEnergy/issues/901) of some sensors misbehaving. To counteract this, if there is less than a 10% decrease, then the difference between the new and old state will be recorded.

### Don't automatically reset the cost sensor

By default, the sensor will automatically reset when a new reading has been received and it's a different day to the previous reading. There may be times that you want to track costs for something over this threshold (e.g. how much it last cost you to charge your car). In these scenarios, you can turn off the automatic resets. In this scenario, you are in charge of resetting the core sensor via it's [available service](../services.md#octopus_energyreset_cost_tracker).

!!! info

The weekly and monthly sensors will reset as normal with this settings turned on.

!!! warning

Not resetting this sensor for long periods of time can cause Home Assistant warnings around recording of state. This results in the attributes of the sensor not being persisted to the database for long term storage. This is a known limitation of the feature and cannot be fixed due to how the sensor tracks cost.

### Week sensor day reset

This is the day of the week the accumulative week sensor should reset. This defaults to Monday.
Expand Down Expand Up @@ -61,6 +73,7 @@ This is in pounds and pence (e.g. 1.01 = £1.01).
| `target_entity_id` | `string` | The entity whose consumption data is being tracked (based on config) |
| `entity_accumulative_value` | `boolean` | Determines if the tracked entity has accumulative data (based on config) |
| `account_id` | `string` | The id of the account the cost tracker is for (based on config) |
| `is_manual` | `boolean` | Determines if the tracker only resets when manually reset via the available service |
| `is_tracking` | `boolean` | Determines if the tracker is currently tracking consumption/cost data |
| `tracked_changes` | `array` | The collection of tracked entity changes where the costs have been tracked in 30 minute increments |
| `untracked_changes` | `array` | The collection of tracked entity changes where the costs have **not** been tracked in 30 minute increments |
Expand Down
4 changes: 4 additions & 0 deletions custom_components/octopus_energy/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from .config.target_rates import merge_target_rate_config, validate_target_rate_config
from .config.main import async_validate_main_config, merge_main_config
from .const import (
CONFIG_COST_TRACKER_MANUAL_RESET,
CONFIG_FAVOUR_DIRECT_DEBIT_RATES,
CONFIG_KIND_ROLLING_TARGET_RATE,
CONFIG_MAIN_HOME_PRO_ADDRESS,
Expand Down Expand Up @@ -326,6 +327,7 @@ async def __async_setup_cost_tracker_schema__(self, account_id: str):
selector.EntitySelectorConfig(domain="sensor", device_class=[SensorDeviceClass.ENERGY]),
),
vol.Optional(CONFIG_COST_TRACKER_ENTITY_ACCUMULATIVE_VALUE, default=False): bool,
vol.Required(CONFIG_COST_TRACKER_MANUAL_RESET, default=False): bool,
vol.Required(CONFIG_COST_TRACKER_WEEKDAY_RESET, default="0"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=get_weekday_options(),
Expand Down Expand Up @@ -839,6 +841,7 @@ async def __async_setup_cost_tracker_schema__(self, config, errors):
selector.EntitySelectorConfig(domain="sensor", device_class=[SensorDeviceClass.ENERGY]),
),
vol.Optional(CONFIG_COST_TRACKER_ENTITY_ACCUMULATIVE_VALUE): bool,
vol.Required(CONFIG_COST_TRACKER_MANUAL_RESET): bool,
vol.Required(CONFIG_COST_TRACKER_WEEKDAY_RESET): selector.SelectSelector(
selector.SelectSelectorConfig(
options=get_weekday_options(),
Expand All @@ -854,6 +857,7 @@ async def __async_setup_cost_tracker_schema__(self, config, errors):
CONFIG_COST_TRACKER_ENTITY_ACCUMULATIVE_VALUE: config[CONFIG_COST_TRACKER_ENTITY_ACCUMULATIVE_VALUE],
CONFIG_COST_TRACKER_WEEKDAY_RESET: f"{config[CONFIG_COST_TRACKER_WEEKDAY_RESET]}" if CONFIG_COST_TRACKER_WEEKDAY_RESET in config else "0",
CONFIG_COST_TRACKER_MONTH_DAY_RESET: config[CONFIG_COST_TRACKER_MONTH_DAY_RESET] if CONFIG_COST_TRACKER_MONTH_DAY_RESET in config else 1,
CONFIG_COST_TRACKER_MANUAL_RESET: config[CONFIG_COST_TRACKER_MANUAL_RESET] if CONFIG_COST_TRACKER_MANUAL_RESET in config else False
}
),
errors=errors
Expand Down
1 change: 1 addition & 0 deletions custom_components/octopus_energy/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
CONFIG_COST_TRACKER_ENTITY_ACCUMULATIVE_VALUE = "entity_accumulative_value"
CONFIG_COST_TRACKER_WEEKDAY_RESET = "weekday_reset"
CONFIG_COST_TRACKER_MONTH_DAY_RESET = "month_day_reset"
CONFIG_COST_TRACKER_MANUAL_RESET = "manual_reset"

CONFIG_TARIFF_COMPARISON_NAME = "name"
CONFIG_TARIFF_COMPARISON_MPAN_MPRN = "mpan_mprn"
Expand Down
12 changes: 7 additions & 5 deletions custom_components/octopus_energy/cost_tracker/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def add_consumption(current: datetime,
old_last_reset: datetime,
is_accumulative_value: bool,
is_tracking: bool,
is_manual_reset_enabled: bool = False,
state_class: str = None):
if new_value is None:
return
Expand Down Expand Up @@ -85,12 +86,13 @@ def add_consumption(current: datetime,
new_tracked_consumption_data = tracked_consumption_data.copy()
new_untracked_consumption_data = untracked_consumption_data.copy()

# If we've gone into a new day, then reset the consumption result
if ((new_tracked_consumption_data is not None and len(new_tracked_consumption_data) > 0 and
(new_tracked_consumption_data[0]["start"].year != start_of_day.year or new_tracked_consumption_data[0]["start"].month != start_of_day.month or new_tracked_consumption_data[0]["start"].day != start_of_day.day)) or
# If we've gone into a new day, then reset the consumption result, unless manual reset is enabled
if (is_manual_reset_enabled == False and
((new_tracked_consumption_data is not None and len(new_tracked_consumption_data) > 0 and
(new_tracked_consumption_data[0]["start"].year != start_of_day.year or new_tracked_consumption_data[0]["start"].month != start_of_day.month or new_tracked_consumption_data[0]["start"].day != start_of_day.day)) or

(new_untracked_consumption_data is not None and len(new_untracked_consumption_data) > 0 and
(new_untracked_consumption_data[0]["start"].year != start_of_day.year or new_untracked_consumption_data[0]["start"].month != start_of_day.month or new_untracked_consumption_data[0]["start"].day != start_of_day.day))):
(new_untracked_consumption_data is not None and len(new_untracked_consumption_data) > 0 and
(new_untracked_consumption_data[0]["start"].year != start_of_day.year or new_untracked_consumption_data[0]["start"].month != start_of_day.month or new_untracked_consumption_data[0]["start"].day != start_of_day.day)))):
new_tracked_consumption_data = []
new_untracked_consumption_data = []

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

from ..const import (
CONFIG_COST_TRACKER_ENTITY_ACCUMULATIVE_VALUE,
CONFIG_COST_TRACKER_MANUAL_RESET,
CONFIG_COST_TRACKER_TARGET_ENTITY_ID,
CONFIG_COST_TRACKER_NAME,
DOMAIN,
Expand Down Expand Up @@ -211,6 +212,7 @@ async def _async_calculate_cost(self, event: Event[EventStateChangedData]):
old_last_reset,
self._config[CONFIG_COST_TRACKER_ENTITY_ACCUMULATIVE_VALUE],
self._attributes["is_tracking"] if "is_tracking" in self._attributes else True,
CONFIG_COST_TRACKER_MANUAL_RESET in self._config and self._config[CONFIG_COST_TRACKER_MANUAL_RESET] == True,
new_state.attributes["state_class"] if "state_class" in new_state.attributes else None)


Expand Down Expand Up @@ -321,7 +323,7 @@ def _reset_if_new_day(self, current: datetime):
self._last_reset = start_of_day
return True

if self._last_reset.date() != current.date():
if self._last_reset.date() != current.date() and (CONFIG_COST_TRACKER_MANUAL_RESET not in self._config or self._config[CONFIG_COST_TRACKER_MANUAL_RESET] == False):
self._state = 0
self._attributes["tracked_charges"] = []
self._attributes["untracked_charges"] = []
Expand Down
12 changes: 10 additions & 2 deletions custom_components/octopus_energy/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,11 @@
"target_entity_id": "The entity to track the costs for.",
"entity_accumulative_value": "Tracked entity state is accumulative",
"weekday_reset": "The day when the week cost sensor should reset",
"month_day_reset": "The day when the month cost sensor should reset"
"month_day_reset": "The day when the month cost sensor should reset",
"manual_reset": "Don't automatically reset the cost sensor"
},
"data_description": {
"manual_reset": "If enabled, you will need to manually reset this sensor via the available service. You will also not get week and month sensors. This may produce Home Assistant warnings if not reset for long periods of times."
}
},
"cost_tracker_account": {
Expand Down Expand Up @@ -226,7 +230,11 @@
"target_entity_id": "The entity to track the costs for.",
"entity_accumulative_value": "Tracked entity state is accumulative",
"weekday_reset": "The day when the week cost sensor should reset",
"month_day_reset": "The day when the month cost sensor should reset"
"month_day_reset": "The day when the month cost sensor should reset",
"manual_reset": "Don't automatically reset the cost sensor"
},
"data_description": {
"manual_reset": "If enabled, you will need to manually reset this sensor via the available service. You will also not get week and month sensors. This may produce Home Assistant warnings if not reset for long periods of times."
}
},
"tariff_comparison": {
Expand Down
1 change: 0 additions & 1 deletion requirements.test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ mock
homeassistant
pydantic
psutil-home-assistant
pydantic
sqlalchemy
fnvhash
fnv_hash_fast
28 changes: 28 additions & 0 deletions tests/unit/api_client/test_heat_pump.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,34 @@ def test_when_valid_dictionary_returned_then_it_can_be_parsed_into_heat_pump_obj
}
}
]
},
"octoHeatPumpLifetimePerformance": {
"seasonalCoefficientOfPerformance": "3.5",
"heatOutput": {
"unit": "KILOWATT_HOUR",
"value": "100.4"
},
"energyInput": {
"unit": "KILOWATT_HOUR",
"value": "100.2"
},
"readAt": "2024-12-31T09:10:00+00:00"
},
"octoHeatPumpLivePerformance": {
"coefficientOfPerformance": "3.4",
"outdoorTemperature": {
"unit": "DEGREES_CELSIUS",
"value": "30.1"
},
"heatOutput": {
"value": "10.5",
"unit": "KILOWATT"
},
"powerInput": {
"unit": "KILOWATT",
"value": "5.4"
},
"readAt": "2024-12-31T09:10:00+00:00"
}
}

Expand Down
75 changes: 75 additions & 0 deletions tests/unit/cost_tracker/test_add_consumption.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ async def test_when_state_class_total_increasing_and_new_value_less_than_old_val
old_last_reset,
is_accumulative_value,
is_tracking,
False,
"total_increasing")

# Assert
Expand Down Expand Up @@ -232,6 +233,7 @@ async def test_when_state_class_total_increasing_and_new_value_less_than_old_val
old_last_reset,
is_accumulative_value,
is_tracking,
False,
"total_increasing")

# Assert
Expand Down Expand Up @@ -267,6 +269,7 @@ async def test_when_state_class_total_increasing_and_new_value_greater_than_old_
old_last_reset,
is_accumulative_value,
is_tracking,
False,
"total_increasing")

# Assert
Expand Down Expand Up @@ -385,6 +388,78 @@ async def test_when_consumption_exists_and_new_day_starts_then_consumption_added
assert len(result.tracked_consumption_data) == 0
assert_consumption(result.untracked_consumption_data, datetime.strptime("2022-02-28T10:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), datetime.strptime("2022-02-28T10:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z"), 0.1)

@pytest.mark.asyncio
@pytest.mark.parametrize("is_tracking", [(True),(False)])
async def test_when_consumption_exists_and_new_day_starts_and_manual_reset_is_enabled_then_consumption_added_to_existing_day(is_tracking: bool):

# Arrange
existing_start = datetime.strptime("2022-02-27T10:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z")
existing_end = existing_start + timedelta(minutes=30)

current = datetime.strptime(f"2022-02-28T10:15:00+00:00", "%Y-%m-%dT%H:%M:%S%z")
expected_start = datetime.strptime("2022-02-28T10:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z")
expected_end = expected_start + timedelta(minutes=30)

tracked_consumption_data = []
untracked_consumption_data = []

if is_tracking:
tracked_consumption_data.append({
"start": existing_start,
"end": existing_end,
"consumption": 0.2
})
else:
untracked_consumption_data.append({
"start": existing_start,
"end": existing_end,
"consumption": 0.2
})

new_value = 1.2
old_value = 1.1
new_last_reset = None
old_last_reset = None
is_accumulative_value = True

# Act
result = add_consumption(current,
tracked_consumption_data,
untracked_consumption_data,
new_value,
old_value,
new_last_reset,
old_last_reset,
is_accumulative_value,
is_tracking,
is_manual_reset_enabled=True)

# Assert
assert result is not None

if is_tracking:
assert len(result.untracked_consumption_data) == 0

assert len(result.tracked_consumption_data) == 2
assert result.tracked_consumption_data[0]["start"] == tracked_consumption_data[0]["start"]
assert result.tracked_consumption_data[0]["end"] == tracked_consumption_data[0]["end"]
assert round(result.tracked_consumption_data[0]["consumption"], 8) == round(tracked_consumption_data[0]["consumption"], 8)

assert result.tracked_consumption_data[1]["start"] == expected_start
assert result.tracked_consumption_data[1]["end"] == expected_end
assert round(result.tracked_consumption_data[1]["consumption"], 8) == round(0.1, 8)
else:
assert len(result.tracked_consumption_data) == 0

assert len(result.untracked_consumption_data) == 2
assert result.untracked_consumption_data[0]["start"] == untracked_consumption_data[0]["start"]
assert result.untracked_consumption_data[0]["end"] == untracked_consumption_data[0]["end"]
assert round(result.untracked_consumption_data[0]["consumption"], 8) == round(untracked_consumption_data[0]["consumption"], 8)

assert result.untracked_consumption_data[1]["start"] == expected_start
assert result.untracked_consumption_data[1]["end"] == expected_end
assert round(result.untracked_consumption_data[1]["consumption"], 8) == round(0.1, 8)

@pytest.mark.asyncio
@pytest.mark.parametrize("new_value,old_value", [(None, None),
(0, None),
Expand Down

0 comments on commit 0bd38af

Please sign in to comment.