Skip to content

Commit e3aaab4

Browse files
authored
feat: A better predicted estimated cost (#133)
* feat: A better predicted estimated cost * feat: A better predicted estimated cost * feat: a better calculation of days for estimated invoice * feat: a better calculation of days for estimated invoice * Additional fix * Add Sensor State Attributes
1 parent ce39349 commit e3aaab4

File tree

7 files changed

+201
-21
lines changed

7 files changed

+201
-21
lines changed

custom_components/iec/const.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,22 @@
2828
CONF_MAIN_CONTRACT_ID = "main_contract_id"
2929
STATICS_DICT_NAME = "statics"
3030
ATTRIBUTES_DICT_NAME = "entity_attributes"
31+
ESTIMATED_BILL_DICT_NAME = "estimated_bill"
3132
METER_ID_ATTR_NAME = "device_number"
3233
CONTRACT_ID_ATTR_NAME = "contract_id"
3334
IS_SMART_METER_ATTR_NAME = "is_smart_meter"
35+
TOTAL_EST_BILL_ATTR_NAME = "total_estimated_bill"
36+
EST_BILL_DAYS_ATTR_NAME = "total_bill_days"
37+
EST_BILL_CONSUMPTION_PRICE_ATTR_NAME = "consumption_price"
38+
EST_BILL_DELIVERY_PRICE_ATTR_NAME = "delivery_price"
39+
EST_BILL_DISTRIBUTION_PRICE_ATTR_NAME = "distribution_price"
40+
EST_BILL_TOTAL_KVA_PRICE_ATTR_NAME = "total_kva_price"
41+
EST_BILL_KWH_CONSUMPTION_ATTR_NAME = "estimated_kwh_consumption_in_bill"
3442
INVOICE_DICT_NAME = "invoice"
3543
CONTRACT_DICT_NAME = "contract"
3644
DAILY_READINGS_DICT_NAME = "daily_readings"
3745
FUTURE_CONSUMPTIONS_DICT_NAME = "future_consumption"
3846
STATIC_KWH_TARIFF = "kwh_tariff"
47+
STATIC_KVA_TARIFF = "kva_tariff"
3948
STATIC_BP_NUMBER = "bp_number"
4049
ELECTRIC_INVOICE_DOC_ID = "1"

custom_components/iec/coordinator.py

Lines changed: 151 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""Coordinator to handle IEC connections."""
2+
import calendar
23
import itertools
34
import logging
45
import socket
56
from datetime import datetime, timedelta
67
from typing import cast, Any # noqa: UP035
8+
from collections import Counter
79

810
import pytz
911
from homeassistant.components.recorder import get_instance
@@ -21,7 +23,7 @@
2123
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
2224
from iec_api.iec_client import IecClient
2325
from iec_api.models.contract import Contract
24-
from iec_api.models.device import Device
26+
from iec_api.models.device import Device, Devices
2527
from iec_api.models.exceptions import IECError
2628
from iec_api.models.jwt import JWT
2729
from iec_api.models.remote_reading import ReadingResolution, RemoteReading, FutureConsumptionInfo, RemoteReadingResponse
@@ -30,7 +32,10 @@
3032
from .const import DOMAIN, CONF_USER_ID, STATICS_DICT_NAME, STATIC_KWH_TARIFF, INVOICE_DICT_NAME, \
3133
FUTURE_CONSUMPTIONS_DICT_NAME, DAILY_READINGS_DICT_NAME, STATIC_BP_NUMBER, ILS, CONF_BP_NUMBER, \
3234
CONF_SELECTED_CONTRACTS, CONTRACT_DICT_NAME, EMPTY_INVOICE, ELECTRIC_INVOICE_DOC_ID, ATTRIBUTES_DICT_NAME, \
33-
CONTRACT_ID_ATTR_NAME, IS_SMART_METER_ATTR_NAME, METER_ID_ATTR_NAME
35+
CONTRACT_ID_ATTR_NAME, IS_SMART_METER_ATTR_NAME, METER_ID_ATTR_NAME, STATIC_KVA_TARIFF, ESTIMATED_BILL_DICT_NAME, \
36+
TOTAL_EST_BILL_ATTR_NAME, EST_BILL_DAYS_ATTR_NAME, EST_BILL_CONSUMPTION_PRICE_ATTR_NAME, \
37+
EST_BILL_DELIVERY_PRICE_ATTR_NAME, EST_BILL_DISTRIBUTION_PRICE_ATTR_NAME, EST_BILL_TOTAL_KVA_PRICE_ATTR_NAME, \
38+
EST_BILL_KWH_CONSUMPTION_ATTR_NAME
3439

3540
_LOGGER = logging.getLogger(__name__)
3641
TIMEZONE = pytz.timezone("Asia/Jerusalem")
@@ -59,7 +64,12 @@ def __init__(
5964
self._entry_data = config_entry.data
6065
self._today_readings = {}
6166
self._devices_by_contract_id = {}
67+
self._devices_by_meter_id = {}
68+
self._delivery_tariff_by_pahse = {}
69+
self._distribution_tariff_by_pahse = {}
70+
self._power_size_by_connection_size = {}
6271
self._kwh_tariff: float | None = None
72+
self._kva_tariff: float | None = None
6373
self._readings = {}
6474
self.api = IecClient(
6575
self._entry_data[CONF_USER_ID],
@@ -87,6 +97,16 @@ async def _get_devices_by_contract_id(self, contract_id) -> list[Device]:
8797
_LOGGER.exception(f"Failed fetching devices by contract {contract_id}", e)
8898
return devices
8999

100+
async def _get_devices_by_device_id(self, meter_id) -> Devices:
101+
devices = self._devices_by_meter_id.get(meter_id)
102+
if not devices:
103+
try:
104+
devices = await self.api.get_device_by_device_id(str(meter_id))
105+
self._devices_by_meter_id[meter_id] = devices
106+
except IECError as e:
107+
_LOGGER.exception(f"Failed fetching device details by meter id {meter_id}", e)
108+
return devices
109+
90110
async def _get_kwh_tariff(self) -> float:
91111
if not self._kwh_tariff:
92112
try:
@@ -95,6 +115,44 @@ async def _get_kwh_tariff(self) -> float:
95115
_LOGGER.exception("Failed fetching kWh Tariff", e)
96116
return self._kwh_tariff or 0.0
97117

118+
async def _get_kva_tariff(self) -> float:
119+
if not self._kva_tariff:
120+
try:
121+
self._kva_tariff = await self.api.get_kva_tariff()
122+
except IECError as e:
123+
_LOGGER.exception("Failed fetching KVA Tariff", e)
124+
return self._kva_tariff or 0.0
125+
126+
async def _get_delivery_tariff(self, phase) -> float:
127+
delivery_tariff = self._delivery_tariff_by_pahse.get(phase)
128+
if not delivery_tariff:
129+
try:
130+
delivery_tariff = await self.api.get_delivery_tariff(phase)
131+
self._delivery_tariff_by_pahse[phase] = delivery_tariff
132+
except IECError as e:
133+
_LOGGER.exception(f"Failed fetching Delivery Tariff by phase {phase}", e)
134+
return delivery_tariff or 0.0
135+
136+
async def _get_distribution_tariff(self, phase) -> float:
137+
distribution_tariff = self._distribution_tariff_by_pahse.get(phase)
138+
if not distribution_tariff:
139+
try:
140+
distribution_tariff = await self.api.get_distribution_tariff(phase)
141+
self._distribution_tariff_by_pahse[phase] = distribution_tariff
142+
except IECError as e:
143+
_LOGGER.exception(f"Failed fetching Distribution Tariff by phase {phase}", e)
144+
return distribution_tariff or 0.0
145+
146+
async def _get_power_size(self, connection_size) -> float:
147+
power_size = self._power_size_by_connection_size.get(connection_size)
148+
if not power_size:
149+
try:
150+
power_size = await self.api.get_power_size(connection_size)
151+
self._power_size_by_connection_size[connection_size] = power_size
152+
except IECError as e:
153+
_LOGGER.exception(f"Failed fetching Power Size by Connection Size {connection_size}", e)
154+
return power_size or 0.0
155+
98156
async def _get_readings(self, contract_id: int, device_id: str | int, device_code: str | int, date: datetime,
99157
resolution: ReadingResolution):
100158

@@ -191,13 +249,17 @@ async def _async_update_data(
191249
contracts: dict[int, Contract] = {int(c.contract_id): c for c in all_contracts if c.status == 1
192250
and int(c.contract_id) in self._contract_ids}
193251
localized_today = TIMEZONE.localize(datetime.today())
194-
tariff = await self._get_kwh_tariff()
252+
kwh_tariff = await self._get_kwh_tariff()
253+
kva_tariff = await self._get_kva_tariff()
195254

196255
data = {STATICS_DICT_NAME: {
197-
STATIC_KWH_TARIFF: tariff,
256+
STATIC_KWH_TARIFF: kwh_tariff,
257+
STATIC_KVA_TARIFF: kva_tariff,
198258
STATIC_BP_NUMBER: self._bp_number
199259
}}
200260

261+
estimated_bill_dict = None
262+
201263
_LOGGER.debug(f"All Contract Ids: {list(contracts.keys())}")
202264

203265
for contract_id in self._contract_ids:
@@ -311,12 +373,41 @@ async def _async_update_data(
311373
else:
312374
_LOGGER.debug("Failed fetching FutureConsumption, data in IEC API is corrupted")
313375

376+
devices_by_id: Devices = await self._get_devices_by_device_id(device.device_number)
377+
last_meter_read = int(devices_by_id.counter_devices[0].last_mr)
378+
last_meter_read_date = devices_by_id.counter_devices[0].last_mr_date
379+
phase_count = devices_by_id.counter_devices[0].connection_size.phase
380+
connection_size = (devices_by_id.counter_devices[0].
381+
connection_size.representative_connection_size)
382+
383+
distribution_tariff = await self._get_distribution_tariff(phase_count)
384+
delivery_tariff = await self._get_delivery_tariff(phase_count)
385+
power_size = await self._get_power_size(connection_size)
386+
387+
estimated_bill, fixed_price, consumption_price, total_days, delivery_price, distribution_price, \
388+
total_kva_price, estimated_kwh_consumption = (
389+
self._calculate_estimated_bill(device.device_number, future_consumption,
390+
last_meter_read, last_meter_read_date,
391+
kwh_tariff, kva_tariff, distribution_tariff,
392+
delivery_tariff, power_size, last_invoice))
393+
394+
estimated_bill_dict = {
395+
TOTAL_EST_BILL_ATTR_NAME: estimated_bill,
396+
EST_BILL_DAYS_ATTR_NAME: total_days,
397+
EST_BILL_CONSUMPTION_PRICE_ATTR_NAME: consumption_price,
398+
EST_BILL_DELIVERY_PRICE_ATTR_NAME: delivery_price,
399+
EST_BILL_DISTRIBUTION_PRICE_ATTR_NAME: distribution_price,
400+
EST_BILL_TOTAL_KVA_PRICE_ATTR_NAME: total_kva_price,
401+
EST_BILL_KWH_CONSUMPTION_ATTR_NAME: estimated_kwh_consumption
402+
}
403+
314404
data[str(contract_id)] = {CONTRACT_DICT_NAME: contracts.get(contract_id),
315405
INVOICE_DICT_NAME: last_invoice,
316406
FUTURE_CONSUMPTIONS_DICT_NAME: future_consumption,
317407
DAILY_READINGS_DICT_NAME: daily_readings,
318-
STATICS_DICT_NAME: {STATIC_KWH_TARIFF: tariff}, # workaround,
319-
ATTRIBUTES_DICT_NAME: attributes_to_add
408+
STATICS_DICT_NAME: {STATIC_KWH_TARIFF: kwh_tariff}, # workaround,
409+
ATTRIBUTES_DICT_NAME: attributes_to_add,
410+
ESTIMATED_BILL_DICT_NAME: estimated_bill_dict
320411
}
321412

322413
# Clean up for next cycle
@@ -493,3 +584,57 @@ async def _insert_statistics(self, contract_id: int, is_smart_meter: bool) -> No
493584
async_add_external_statistics(
494585
self.hass, cost_metadata, cost_statistics
495586
)
587+
588+
@staticmethod
589+
def _calculate_estimated_bill(meter_id, future_consumptions: dict[str, FutureConsumptionInfo | None],
590+
last_meter_read, last_meter_read_date, kwh_tariff,
591+
kva_tariff, distribution_tariff, delivery_tariff, power_size, last_invoice):
592+
future_consumption_info: FutureConsumptionInfo = future_consumptions[meter_id]
593+
future_consumption = future_consumption_info.total_import - last_meter_read
594+
595+
kva_price = power_size * kva_tariff / 365
596+
597+
total_kva_price = 0
598+
distribution_price = 0
599+
delivery_price = 0
600+
601+
consumption_price = future_consumption * kwh_tariff
602+
total_days = 0
603+
604+
today = TIMEZONE.localize(datetime.today())
605+
606+
if last_invoice != EMPTY_INVOICE:
607+
current_date = last_meter_read_date + timedelta(days=1)
608+
month_counter = Counter()
609+
610+
while current_date <= today.date():
611+
# Use (year, month) as the key for counting
612+
month_year = (current_date.year, current_date.month)
613+
month_counter[month_year] += 1
614+
615+
# Move to the next day
616+
current_date += timedelta(days=1)
617+
618+
for (year, month), days in month_counter.items():
619+
days_in_month = calendar.monthrange(year, month)[1]
620+
total_kva_price += kva_price * days
621+
distribution_price += (distribution_tariff / days_in_month) * days
622+
delivery_price += (delivery_tariff / days_in_month) * days
623+
total_days += days
624+
else:
625+
total_days = today.day
626+
days_in_current_month = calendar.monthrange(today.year, today.month)[1]
627+
628+
consumption_price = future_consumption * kwh_tariff
629+
total_kva_price = kva_price * total_days
630+
distribution_price = (distribution_tariff / days_in_current_month) * total_days
631+
delivery_price = (delivery_tariff / days_in_current_month) * total_days
632+
633+
_LOGGER.debug(f'Calculated estimated bill: No. of days: {total_days}, total KVA price: {total_kva_price}, '
634+
f'total distribution price: {distribution_price}, total delivery price: {delivery_price}, '
635+
f'consumption price: {consumption_price}')
636+
637+
fixed_price = total_kva_price + distribution_price + delivery_price
638+
total_estimated_bill = consumption_price + fixed_price
639+
return total_estimated_bill, fixed_price, consumption_price, total_days, \
640+
delivery_price, distribution_price, total_kva_price, future_consumption

custom_components/iec/manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@
1010
"iot_class": "cloud_polling",
1111
"issue_tracker": "https://github.com/guykh/iec-custom-component/issues",
1212
"loggers": ["iec_api"],
13-
"requirements": ["iec-api==0.2.16"],
14-
"version": "0.0.27"
13+
"requirements": ["iec-api==0.3.1"],
14+
"version": "0.0.28"
1515
}

custom_components/iec/sensor.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323
from .commons import find_reading_by_date, IecEntityType
2424
from .const import DOMAIN, ILS, STATICS_DICT_NAME, STATIC_KWH_TARIFF, FUTURE_CONSUMPTIONS_DICT_NAME, INVOICE_DICT_NAME, \
2525
ILS_PER_KWH, DAILY_READINGS_DICT_NAME, EMPTY_REMOTE_READING, CONTRACT_DICT_NAME, EMPTY_INVOICE, \
26-
ATTRIBUTES_DICT_NAME, METER_ID_ATTR_NAME
26+
ATTRIBUTES_DICT_NAME, METER_ID_ATTR_NAME, ESTIMATED_BILL_DICT_NAME, TOTAL_EST_BILL_ATTR_NAME, EST_BILL_DAYS_ATTR_NAME, \
27+
EST_BILL_CONSUMPTION_PRICE_ATTR_NAME, EST_BILL_DELIVERY_PRICE_ATTR_NAME, EST_BILL_DISTRIBUTION_PRICE_ATTR_NAME, \
28+
EST_BILL_TOTAL_KVA_PRICE_ATTR_NAME, EST_BILL_KWH_CONSUMPTION_ATTR_NAME
2729
from .coordinator import IecApiCoordinator
2830
from .iec_entity import IecEntity
2931

@@ -35,6 +37,7 @@ class IecEntityDescriptionMixin:
3537
"""Mixin values for required keys."""
3638

3739
value_fn: Callable[[dict | tuple], str | float | date] | None = None
40+
custom_attrs_fn: Callable[[dict | tuple], dict[str, str | int | float | date]] | None = None
3841

3942

4043
@dataclass(frozen=True, kw_only=True)
@@ -94,9 +97,9 @@ def _get_reading_by_date(readings: list[RemoteReading] | None, desired_date: dat
9497
# state_class=SensorStateClass.TOTAL,
9598
suggested_display_precision=3,
9699
value_fn=lambda data:
97-
(data[FUTURE_CONSUMPTIONS_DICT_NAME][data[ATTRIBUTES_DICT_NAME][METER_ID_ATTR_NAME]].future_consumption or 0)
98-
if (data[FUTURE_CONSUMPTIONS_DICT_NAME]
99-
and data[FUTURE_CONSUMPTIONS_DICT_NAME][data[ATTRIBUTES_DICT_NAME][METER_ID_ATTR_NAME]]) else None
100+
(data[ESTIMATED_BILL_DICT_NAME][EST_BILL_KWH_CONSUMPTION_ATTR_NAME] or 0)
101+
if (data[ESTIMATED_BILL_DICT_NAME]
102+
and data[ESTIMATED_BILL_DICT_NAME][EST_BILL_KWH_CONSUMPTION_ATTR_NAME]) else None
100103
),
101104
IecMeterEntityDescription(
102105
key="elec_forecasted_cost",
@@ -105,10 +108,14 @@ def _get_reading_by_date(readings: list[RemoteReading] | None, desired_date: dat
105108
# state_class=SensorStateClass.TOTAL,
106109
suggested_display_precision=2,
107110
# The API doesn't provide future *cost* so we can try to estimate it by the previous consumption
108-
value_fn=lambda data:
109-
((data[FUTURE_CONSUMPTIONS_DICT_NAME][data[ATTRIBUTES_DICT_NAME][METER_ID_ATTR_NAME]].future_consumption or 0)
110-
* data[STATICS_DICT_NAME][STATIC_KWH_TARIFF]) if (data[FUTURE_CONSUMPTIONS_DICT_NAME]
111-
and data[ATTRIBUTES_DICT_NAME][METER_ID_ATTR_NAME]) else None
111+
value_fn=lambda data: (data[ESTIMATED_BILL_DICT_NAME][TOTAL_EST_BILL_ATTR_NAME] or 0),
112+
custom_attrs_fn=lambda data: {
113+
EST_BILL_DAYS_ATTR_NAME: data[ESTIMATED_BILL_DICT_NAME][EST_BILL_DAYS_ATTR_NAME],
114+
EST_BILL_CONSUMPTION_PRICE_ATTR_NAME: data[ESTIMATED_BILL_DICT_NAME][EST_BILL_CONSUMPTION_PRICE_ATTR_NAME],
115+
EST_BILL_DELIVERY_PRICE_ATTR_NAME: data[ESTIMATED_BILL_DICT_NAME][EST_BILL_DELIVERY_PRICE_ATTR_NAME],
116+
EST_BILL_DISTRIBUTION_PRICE_ATTR_NAME: data[ESTIMATED_BILL_DICT_NAME][EST_BILL_DISTRIBUTION_PRICE_ATTR_NAME],
117+
EST_BILL_TOTAL_KVA_PRICE_ATTR_NAME: data[ESTIMATED_BILL_DICT_NAME][EST_BILL_TOTAL_KVA_PRICE_ATTR_NAME],
118+
} if data[ESTIMATED_BILL_DICT_NAME] else None
112119
),
113120
IecMeterEntityDescription(
114121
key="elec_today_consumption",
@@ -296,6 +303,11 @@ def __init__(
296303
if attributes_to_add:
297304
attributes.update(attributes_to_add)
298305

306+
if self.entity_description.custom_attrs_fn:
307+
attributes.update(self.entity_description.custom_attrs_fn(
308+
self.coordinator.data.get(self.contract_id, self.meter_id)
309+
))
310+
299311
if is_multi_contract:
300312
attributes["is_multi_contract"] = is_multi_contract
301313
self._attr_translation_placeholders = {"multi_contract": f" of {contract_id}"}

custom_components/iec/translations/en.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,17 @@
77
},
88
"sensor": {
99
"elec_forecasted_usage": {
10-
"name": "Next bill electric forecasted usage {multi_contract}"
10+
"name": "Next electric bill forecasted usage {multi_contract}"
1111
},
1212
"elec_forecasted_cost": {
13-
"name": "Next bill electric forecasted cost {multi_contract}"
13+
"name": "Next electric bill forecasted cost {multi_contract}",
14+
"state_attributes": {
15+
"total_bill_days": { "name": "Total Bill Days" },
16+
"consumption_price": { "name": "Consumption Price" },
17+
"delivery_price": { "name": "Delivery Price" },
18+
"distribution_price": { "name": "Distribution Price" },
19+
"total_kva_price": { "name": "Total KVA Price" }
20+
}
1421
},
1522
"elec_today_consumption": {
1623
"name": "Today electric consumption {multi_contract}"

custom_components/iec/translations/he.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@
1010
"name": "סך צריכת החשמל בחשבונית הבאה {multi_contract}"
1111
},
1212
"elec_forecasted_cost": {
13-
"name": "סך עלות צריכת החשמל בחשבונית הבאה {multi_contract}"
13+
"name": "סך עלות צריכת החשמל בחשבונית הבאה {multi_contract}",
14+
"state_attributes": {
15+
"total_bill_days": { "name": "מס׳ ימים לחיוב" },
16+
"consumption_price": { "name": "עלות צריכה" },
17+
"delivery_price": { "name": "עלות אספקה" },
18+
"distribution_price": { "name": "עלות חלוקה" },
19+
"total_kva_price": { "name": "עלות קיבולת לKVA" }
20+
}
1421
},
1522
"elec_today_consumption": {
1623
"name": "סך צריכת החשמל היום {multi_contract}"

requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
colorlog>=6.8.2
22
homeassistant==2024.2.0
3-
iec-api==0.2.16
3+
iec-api==0.3.1
44
pip>=21.0
5-
ruff>=0.2.2
5+
ruff>=0.5.6

0 commit comments

Comments
 (0)