Skip to content

Commit 9811ffa

Browse files
committed
Merge branch 'develop' into feat/fan-club-discount
2 parents 9429df5 + 4e59e29 commit 9811ffa

File tree

11 files changed

+434
-14
lines changed

11 files changed

+434
-14
lines changed

_docs/setup/cost_tracker.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ Each item within the `tracked_changes` and `untracked_changes` have the followin
8888
| `rate` | `float` | The rate the consumption is charged at. This is in pounds and pence (e.g. 1.01 = £1.01) |
8989
| `consumption` | `float` | The consumption value of the specified period. This will be in `kwh`. |
9090
| `cost` | `float` | The cost of the consumption at the specified rate. This is in pounds and pence (e.g. 1.01 = £1.01) |
91+
| `cost_raw` | `float` | The raw cost of the consumption at the specified rate. This is in pounds and pence, but not rounded. This is to account for low cost devices |
9192

9293
#### Variants
9394

custom_components/octopus_energy/api_client/heat_pump.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ class Connectivity(BaseModel):
1111

1212

1313
class Telemetry(BaseModel):
14-
temperatureInCelsius: float
14+
temperatureInCelsius: Optional[float]
1515
humidityPercentage: Optional[float]
16-
retrievedAt: str
16+
retrievedAt: Optional[str]
1717

1818

1919
class Sensor(BaseModel):

custom_components/octopus_energy/cost_tracker/__init__.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
from datetime import datetime, timedelta
2+
import logging
23

34
from homeassistant.components.sensor import (
45
SensorStateClass,
56
)
67

78
from homeassistant.helpers.entity import DeviceInfo
89

10+
from ..utils.conversions import pence_to_pounds_pence, round_pounds, value_inc_vat_to_pounds
11+
from ..utils.cost import consumption_cost_in_pence
12+
13+
_LOGGER = logging.getLogger(__name__)
14+
915
def get_device_info_from_device_entry(device_entry):
1016
if device_entry is None:
1117
return None
@@ -152,4 +158,82 @@ def accumulate_cost(current: datetime, accumulative_data: list, new_cost: float,
152158

153159
return AccumulativeCostTrackerResult(new_accumulative_data, total_consumption, total_cost)
154160

155-
161+
def __get_to(item):
162+
return (item["end"].timestamp(), item["end"].fold)
163+
164+
def __sort_consumption(consumption_data):
165+
sorted = consumption_data.copy()
166+
sorted.sort(key=__get_to)
167+
return sorted
168+
169+
def calculate_consumption_and_cost(
170+
consumption_data,
171+
rate_data,
172+
standing_charge,
173+
last_reset,
174+
minimum_consumption_records = 0,
175+
target_rate = None
176+
):
177+
if (consumption_data is not None and len(consumption_data) >= minimum_consumption_records and rate_data is not None and len(rate_data) > 0 and standing_charge is not None):
178+
179+
sorted_consumption_data = __sort_consumption(consumption_data)
180+
181+
# Only calculate our consumption if our data has changed
182+
if (last_reset is None or last_reset < sorted_consumption_data[0]["start"]):
183+
184+
charges = []
185+
total_cost = 0
186+
total_consumption = 0
187+
188+
for consumption in sorted_consumption_data:
189+
consumption_value = consumption["consumption"]
190+
consumption_from = consumption["start"]
191+
consumption_to = consumption["end"]
192+
193+
try:
194+
rate = next(r for r in rate_data if r["start"] == consumption_from and r["end"] == consumption_to)
195+
except StopIteration:
196+
raise Exception(f"Failed to find rate for consumption between {consumption_from} and {consumption_to}")
197+
198+
value = rate["value_inc_vat"]
199+
200+
if target_rate is not None and value != target_rate:
201+
continue
202+
203+
total_consumption = total_consumption + consumption_value
204+
cost = pence_to_pounds_pence(consumption_cost_in_pence(consumption_value, value))
205+
cost_raw = (consumption_value * value) / 100
206+
total_cost = total_cost + cost_raw
207+
208+
current_charge = {
209+
"start": rate["start"],
210+
"end": rate["end"],
211+
"rate": value_inc_vat_to_pounds(value),
212+
"consumption": consumption_value,
213+
"cost": cost,
214+
"cost_raw": cost_raw,
215+
}
216+
217+
charges.append(current_charge)
218+
219+
total_cost = round_pounds(total_cost)
220+
total_cost_plus_standing_charge = total_cost + pence_to_pounds_pence(standing_charge)
221+
222+
last_reset = sorted_consumption_data[0]["start"] if len(sorted_consumption_data) > 0 else None
223+
last_calculated_timestamp = sorted_consumption_data[-1]["end"] if len(sorted_consumption_data) > 0 else None
224+
225+
result = {
226+
"standing_charge": pence_to_pounds_pence(standing_charge),
227+
"total_cost_without_standing_charge": total_cost,
228+
"total_cost": total_cost_plus_standing_charge,
229+
"total_consumption": total_consumption,
230+
"last_reset": last_reset,
231+
"last_evaluated": last_calculated_timestamp,
232+
"charges": charges,
233+
}
234+
235+
return result
236+
else:
237+
_LOGGER.debug(f'Skipping consumption and cost calculation as last reset has not changed - last_reset: {last_reset}; consumption start: {sorted_consumption_data[0]["start"]}')
238+
else:
239+
_LOGGER.debug(f'Skipping consumption and cost calculation due to lack of data; consumption: {len(consumption_data) if consumption_data is not None else 0}; rates: {len(rate_data) if rate_data is not None else 0}; standing_charge: {standing_charge}')

custom_components/octopus_energy/cost_tracker/cost_tracker.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141

4242
from ..coordinators.electricity_rates import ElectricityRatesCoordinatorResult
4343
from . import add_consumption, get_device_info_from_device_entry
44-
from ..electricity import calculate_electricity_consumption_and_cost
44+
from ..cost_tracker import calculate_consumption_and_cost
4545
from ..utils.rate_information import get_rate_index, get_unique_rates
4646
from ..utils.attributes import dict_to_typed_dict
4747

@@ -283,7 +283,7 @@ def _recalculate_cost(self, current: datetime, tracked_consumption_data: list, u
283283
unique_rate_index = get_rate_index(len(unique_rates), self._peak_type)
284284
target_rate = unique_rates[unique_rate_index] if unique_rate_index is not None else None
285285

286-
tracked_result = calculate_electricity_consumption_and_cost(
286+
tracked_result = calculate_consumption_and_cost(
287287
tracked_consumption_data,
288288
rates,
289289
0,
@@ -292,7 +292,7 @@ def _recalculate_cost(self, current: datetime, tracked_consumption_data: list, u
292292
target_rate=target_rate
293293
)
294294

295-
untracked_result = calculate_electricity_consumption_and_cost(
295+
untracked_result = calculate_consumption_and_cost(
296296
untracked_consumption_data,
297297
rates,
298298
0,
@@ -309,15 +309,17 @@ def _recalculate_cost(self, current: datetime, tracked_consumption_data: list, u
309309
"end": charge["end"],
310310
"rate": charge["rate"],
311311
"consumption": charge["consumption"],
312-
"cost": charge["cost"]
312+
"cost": charge["cost"],
313+
"cost_raw": charge["cost_raw"]
313314
}, tracked_result["charges"]))
314315

315316
self._attributes["untracked_charges"] = list(map(lambda charge: {
316317
"start": charge["start"],
317318
"end": charge["end"],
318319
"rate": charge["rate"],
319320
"consumption": charge["consumption"],
320-
"cost": charge["cost"]
321+
"cost": charge["cost"],
322+
"cost_raw": charge["cost_raw"]
321323
}, untracked_result["charges"]))
322324

323325
self._attributes["total_consumption"] = tracked_result["total_consumption"] + untracked_result["total_consumption"]

custom_components/octopus_energy/heat_pump/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,9 @@ def mock_heat_pump_status_and_configuration():
4444
"retrievedAt": (now - timedelta(seconds=random.randrange(1, 120))).strftime("%Y-%m-%dT%H:%M:%S.%f%z")
4545
},
4646
"telemetry": {
47-
"temperatureInCelsius": -273 + (random.randrange(1, 20) * 0.1),
47+
"temperatureInCelsius": None,
4848
"humidityPercentage": None,
49-
"retrievedAt": (now - timedelta(seconds=random.randrange(1, 120))).strftime("%Y-%m-%dT%H:%M:%S.%f%z")
49+
"retrievedAt": None
5050
}
5151
},
5252
{

custom_components/octopus_energy/heat_pump/sensor_humidity.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def _handle_coordinator_update(self) -> None:
9292
for sensor in sensors:
9393
if sensor.code == self._sensor.code and sensor.telemetry is not None:
9494
self._state = sensor.telemetry.humidityPercentage
95-
self._attributes["retrieved_at"] = datetime.fromisoformat(sensor.telemetry.retrievedAt)
95+
self._attributes["retrieved_at"] = datetime.fromisoformat(sensor.telemetry.retrievedAt) if sensor.telemetry.retrievedAt is not None else None
9696

9797
self._last_updated = current
9898

custom_components/octopus_energy/heat_pump/sensor_temperature.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ def _handle_coordinator_update(self) -> None:
9393
for sensor in sensors:
9494
if sensor.code == self._sensor.code and sensor.telemetry is not None:
9595
self._state = sensor.telemetry.temperatureInCelsius
96-
self._attributes["retrieved_at"] = datetime.fromisoformat(sensor.telemetry.retrievedAt)
96+
self._attributes["retrieved_at"] = datetime.fromisoformat(sensor.telemetry.retrievedAt) if sensor.telemetry.retrievedAt is not None else None
9797

9898
self._last_updated = current
9999

custom_components/octopus_energy/intelligent/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ def __init__(self,
259259
"SENSI",
260260
"SMARTCAR",
261261
"TESLA",
262+
"TESLA_V2",
262263
"SMART_PEAR",
263264
"HYPERVOLT",
264265
"INDRA"

tests/unit/api_client/test_heat_pump.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ def test_when_valid_dictionary_returned_then_it_can_be_parsed_into_heat_pump_obj
3636
"retrievedAt": "2025-05-09T17:28:51.555000+00:00"
3737
},
3838
"telemetry": {
39-
"temperatureInCelsius": -90.3,
39+
"temperatureInCelsius": None,
4040
"humidityPercentage": None,
41-
"retrievedAt": "2025-05-09T17:28:44.152000+00:00"
41+
"retrievedAt": None
4242
}
4343
},
4444
{

0 commit comments

Comments
 (0)