elec-tariffs 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- elec_tariffs/__init__.py +87 -0
- elec_tariffs/_version.py +3 -0
- elec_tariffs/engine.py +140 -0
- elec_tariffs/errors.py +57 -0
- elec_tariffs/models.py +104 -0
- elec_tariffs/py.typed +0 -0
- elec_tariffs/sources/__init__.py +22 -0
- elec_tariffs/sources/tempo.py +394 -0
- elec_tariffs/sources/trv.py +121 -0
- elec_tariffs-0.1.0.dist-info/METADATA +149 -0
- elec_tariffs-0.1.0.dist-info/RECORD +13 -0
- elec_tariffs-0.1.0.dist-info/WHEEL +4 -0
- elec_tariffs-0.1.0.dist-info/licenses/LICENSE +21 -0
elec_tariffs/__init__.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Cost French electricity consumption hour by hour under Base, HP/HC and Tempo."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .engine import (
|
|
5
|
+
TEMPO_OFF_PEAK,
|
|
6
|
+
HourCost,
|
|
7
|
+
cost_hour,
|
|
8
|
+
fixed_cost_per_hour,
|
|
9
|
+
is_off_peak,
|
|
10
|
+
period_at,
|
|
11
|
+
price_at,
|
|
12
|
+
split_hour,
|
|
13
|
+
tempo_day,
|
|
14
|
+
)
|
|
15
|
+
from .errors import (
|
|
16
|
+
ElecTariffsError,
|
|
17
|
+
MissingColorError,
|
|
18
|
+
MissingPriceError,
|
|
19
|
+
TempoAuthError,
|
|
20
|
+
TempoError,
|
|
21
|
+
TempoFormatError,
|
|
22
|
+
TempoRateLimitError,
|
|
23
|
+
TempoServerError,
|
|
24
|
+
TrvError,
|
|
25
|
+
TrvFormatError,
|
|
26
|
+
TrvServerError,
|
|
27
|
+
)
|
|
28
|
+
from .models import (
|
|
29
|
+
TRANCHES,
|
|
30
|
+
ContractKind,
|
|
31
|
+
ContractRules,
|
|
32
|
+
DayCalendar,
|
|
33
|
+
PricePeriod,
|
|
34
|
+
TempoColor,
|
|
35
|
+
TimeRange,
|
|
36
|
+
Tranche,
|
|
37
|
+
)
|
|
38
|
+
from .sources import (
|
|
39
|
+
FallbackTempoSource,
|
|
40
|
+
RteOfficialTempoClient,
|
|
41
|
+
RtePublicTempoClient,
|
|
42
|
+
TempoCalendarSource,
|
|
43
|
+
TrvClient,
|
|
44
|
+
parse_trv_csv,
|
|
45
|
+
season_bounds,
|
|
46
|
+
season_of,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"TEMPO_OFF_PEAK",
|
|
51
|
+
"TRANCHES",
|
|
52
|
+
"ContractKind",
|
|
53
|
+
"ContractRules",
|
|
54
|
+
"DayCalendar",
|
|
55
|
+
"ElecTariffsError",
|
|
56
|
+
"FallbackTempoSource",
|
|
57
|
+
"HourCost",
|
|
58
|
+
"MissingColorError",
|
|
59
|
+
"MissingPriceError",
|
|
60
|
+
"PricePeriod",
|
|
61
|
+
"RteOfficialTempoClient",
|
|
62
|
+
"RtePublicTempoClient",
|
|
63
|
+
"TempoAuthError",
|
|
64
|
+
"TempoCalendarSource",
|
|
65
|
+
"TempoColor",
|
|
66
|
+
"TempoError",
|
|
67
|
+
"TempoFormatError",
|
|
68
|
+
"TempoRateLimitError",
|
|
69
|
+
"TempoServerError",
|
|
70
|
+
"TimeRange",
|
|
71
|
+
"Tranche",
|
|
72
|
+
"TrvClient",
|
|
73
|
+
"TrvError",
|
|
74
|
+
"TrvFormatError",
|
|
75
|
+
"TrvServerError",
|
|
76
|
+
"__version__",
|
|
77
|
+
"cost_hour",
|
|
78
|
+
"fixed_cost_per_hour",
|
|
79
|
+
"is_off_peak",
|
|
80
|
+
"parse_trv_csv",
|
|
81
|
+
"period_at",
|
|
82
|
+
"price_at",
|
|
83
|
+
"season_bounds",
|
|
84
|
+
"season_of",
|
|
85
|
+
"split_hour",
|
|
86
|
+
"tempo_day",
|
|
87
|
+
]
|
elec_tariffs/_version.py
ADDED
elec_tariffs/engine.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Split an hour of consumption into tranches and cost it.
|
|
2
|
+
|
|
3
|
+
An hourly statistic row starts at the top of an hour. Every hour is costed as two
|
|
4
|
+
half hours with half the energy each: that is the finest boundary of an off-peak
|
|
5
|
+
range, and it assumes the consumption is even within the hour.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import date, datetime, time, timedelta
|
|
11
|
+
from typing import Final
|
|
12
|
+
|
|
13
|
+
from .errors import MissingColorError, MissingPriceError
|
|
14
|
+
from .models import (
|
|
15
|
+
ContractKind,
|
|
16
|
+
ContractRules,
|
|
17
|
+
DayCalendar,
|
|
18
|
+
PricePeriod,
|
|
19
|
+
TempoColor,
|
|
20
|
+
TimeRange,
|
|
21
|
+
Tranche,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
TEMPO_DAY_START: Final = time(6)
|
|
25
|
+
TEMPO_OFF_PEAK: Final = (TimeRange(start=time(22), end=time(6)),)
|
|
26
|
+
|
|
27
|
+
_TEMPO_TRANCHES: Final = {
|
|
28
|
+
(TempoColor.BLUE, True): Tranche.BLUE_HC,
|
|
29
|
+
(TempoColor.BLUE, False): Tranche.BLUE_HP,
|
|
30
|
+
(TempoColor.WHITE, True): Tranche.WHITE_HC,
|
|
31
|
+
(TempoColor.WHITE, False): Tranche.WHITE_HP,
|
|
32
|
+
(TempoColor.RED, True): Tranche.RED_HC,
|
|
33
|
+
(TempoColor.RED, False): Tranche.RED_HP,
|
|
34
|
+
}
|
|
35
|
+
_SATURDAY: Final = 5
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def tempo_day(local: datetime) -> date:
|
|
39
|
+
"""Return the Tempo day that `local` belongs to: they run from 6:00 to 6:00."""
|
|
40
|
+
wall = local.replace(tzinfo=None) - timedelta(hours=TEMPO_DAY_START.hour)
|
|
41
|
+
return wall.date()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_off_peak(value: time, ranges: Sequence[TimeRange]) -> bool:
|
|
45
|
+
"""Tell whether a time of day falls in one of the off-peak ranges."""
|
|
46
|
+
return any(time_range.contains(value) for time_range in ranges)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _tranche(local: datetime, rules: ContractRules, calendar: DayCalendar) -> Tranche:
|
|
50
|
+
"""Return the tranche of the half hour starting at `local`."""
|
|
51
|
+
if rules.kind is ContractKind.BASE:
|
|
52
|
+
return Tranche.BASE
|
|
53
|
+
if rules.kind is ContractKind.HPHC:
|
|
54
|
+
weekend = rules.weekend_off_peak and local.weekday() >= _SATURDAY
|
|
55
|
+
if weekend or is_off_peak(local.time(), rules.off_peak):
|
|
56
|
+
return Tranche.HC
|
|
57
|
+
return Tranche.HP
|
|
58
|
+
day = tempo_day(local)
|
|
59
|
+
color = calendar.get(day)
|
|
60
|
+
if color is None:
|
|
61
|
+
raise MissingColorError(day)
|
|
62
|
+
return _TEMPO_TRANCHES[color, is_off_peak(local.time(), TEMPO_OFF_PEAK)]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def split_hour(
|
|
66
|
+
start: datetime, kwh: float, rules: ContractRules, calendar: DayCalendar
|
|
67
|
+
) -> dict[Tranche, float]:
|
|
68
|
+
"""Split the energy of the hour starting at `start` (local, aware) into tranches.
|
|
69
|
+
|
|
70
|
+
Raise MissingColorError when a Tempo contract needs a color that is not known.
|
|
71
|
+
"""
|
|
72
|
+
if start.minute or start.second or start.microsecond:
|
|
73
|
+
msg = f"{start.isoformat()} is not the start of an hour"
|
|
74
|
+
raise ValueError(msg)
|
|
75
|
+
split: dict[Tranche, float] = {}
|
|
76
|
+
for half in (start, start.replace(minute=30)):
|
|
77
|
+
tranche = _tranche(half, rules, calendar)
|
|
78
|
+
split[tranche] = split.get(tranche, 0.0) + kwh / 2
|
|
79
|
+
return split
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def period_at(periods: Sequence[PricePeriod], day: date) -> PricePeriod:
|
|
83
|
+
"""Return the price period in force on `day`, or raise MissingPriceError."""
|
|
84
|
+
for period in periods:
|
|
85
|
+
if period.covers(day):
|
|
86
|
+
return period
|
|
87
|
+
raise MissingPriceError(day)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def price_at(periods: Sequence[PricePeriod], day: date, tranche: Tranche) -> float:
|
|
91
|
+
"""Return the price of a tranche on `day`, or raise MissingPriceError."""
|
|
92
|
+
period = period_at(periods, day)
|
|
93
|
+
if tranche not in period.prices:
|
|
94
|
+
raise MissingPriceError(day)
|
|
95
|
+
return period.prices[tranche]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def fixed_cost_per_hour(period: PricePeriod, start: datetime) -> float:
|
|
99
|
+
"""Spread the yearly fixed part evenly over the hours of the year of `start`."""
|
|
100
|
+
year = start.year
|
|
101
|
+
days = (date(year + 1, 1, 1) - date(year, 1, 1)).days
|
|
102
|
+
return period.fixed_per_year / days / 24
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True, kw_only=True)
|
|
106
|
+
class HourCost:
|
|
107
|
+
"""Energy (kWh) and variable cost per tranche of one hour, and its fixed cost."""
|
|
108
|
+
|
|
109
|
+
energy: dict[Tranche, float]
|
|
110
|
+
variable: dict[Tranche, float]
|
|
111
|
+
fixed: float
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def total(self) -> float:
|
|
115
|
+
"""Variable and fixed cost together."""
|
|
116
|
+
return sum(self.variable.values()) + self.fixed
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cost_hour(
|
|
120
|
+
start: datetime,
|
|
121
|
+
kwh: float,
|
|
122
|
+
rules: ContractRules,
|
|
123
|
+
periods: Sequence[PricePeriod],
|
|
124
|
+
calendar: DayCalendar,
|
|
125
|
+
) -> HourCost:
|
|
126
|
+
"""Cost the hour starting at `start` (local, aware).
|
|
127
|
+
|
|
128
|
+
Prices change at midnight local time, so the whole hour uses the prices of its
|
|
129
|
+
day. Raise MissingColorError or MissingPriceError when data is missing.
|
|
130
|
+
"""
|
|
131
|
+
energy = split_hour(start, kwh, rules, calendar)
|
|
132
|
+
period = period_at(periods, start.date())
|
|
133
|
+
variable: dict[Tranche, float] = {}
|
|
134
|
+
for tranche, tranche_kwh in energy.items():
|
|
135
|
+
if tranche not in period.prices:
|
|
136
|
+
raise MissingPriceError(start.date())
|
|
137
|
+
variable[tranche] = tranche_kwh * period.prices[tranche]
|
|
138
|
+
return HourCost(
|
|
139
|
+
energy=energy, variable=variable, fixed=fixed_cost_per_hour(period, start)
|
|
140
|
+
)
|
elec_tariffs/errors.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Exceptions raised by elec-tariffs."""
|
|
2
|
+
|
|
3
|
+
from datetime import date
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ElecTariffsError(Exception):
|
|
7
|
+
"""Base class of every error raised by this library."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MissingColorError(ElecTariffsError):
|
|
11
|
+
"""The Tempo color of a day is not known yet."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, day: date) -> None:
|
|
14
|
+
"""Keep the day whose color is missing."""
|
|
15
|
+
super().__init__(f"No Tempo color for {day.isoformat()}")
|
|
16
|
+
self.day = day
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MissingPriceError(ElecTariffsError):
|
|
20
|
+
"""No price period covers a day."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, day: date) -> None:
|
|
23
|
+
"""Keep the day that no price period covers."""
|
|
24
|
+
super().__init__(f"No price period covers {day.isoformat()}")
|
|
25
|
+
self.day = day
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TrvError(ElecTariffsError):
|
|
29
|
+
"""The regulated prices could not be read."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TrvServerError(TrvError):
|
|
33
|
+
"""data.gouv.fr could not be reached or answered with an error."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TrvFormatError(TrvError):
|
|
37
|
+
"""A regulated price file does not have the expected columns or values."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TempoError(ElecTariffsError):
|
|
41
|
+
"""The Tempo calendar could not be read."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TempoServerError(TempoError):
|
|
45
|
+
"""The calendar source could not be reached or answered with a server error."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TempoRateLimitError(TempoError):
|
|
49
|
+
"""The calendar source asks to slow down (HTTP 429)."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TempoAuthError(TempoError):
|
|
53
|
+
"""The RTE API refused the client credentials."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TempoFormatError(TempoError):
|
|
57
|
+
"""The calendar source answered with data in an unexpected format."""
|
elec_tariffs/models.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Contracts, tranches and prices."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import date, time
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ContractKind(StrEnum):
|
|
10
|
+
"""How a contract splits consumption into tranches."""
|
|
11
|
+
|
|
12
|
+
BASE = "base"
|
|
13
|
+
HPHC = "hphc"
|
|
14
|
+
TEMPO = "tempo"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TempoColor(StrEnum):
|
|
18
|
+
"""Color of a Tempo day."""
|
|
19
|
+
|
|
20
|
+
BLUE = "blue"
|
|
21
|
+
WHITE = "white"
|
|
22
|
+
RED = "red"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Tranche(StrEnum):
|
|
26
|
+
"""A part of the consumption billed at one price."""
|
|
27
|
+
|
|
28
|
+
BASE = "base"
|
|
29
|
+
HC = "hc"
|
|
30
|
+
HP = "hp"
|
|
31
|
+
BLUE_HC = "blue_hc"
|
|
32
|
+
BLUE_HP = "blue_hp"
|
|
33
|
+
WHITE_HC = "white_hc"
|
|
34
|
+
WHITE_HP = "white_hp"
|
|
35
|
+
RED_HC = "red_hc"
|
|
36
|
+
RED_HP = "red_hp"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
TRANCHES: Mapping[ContractKind, tuple[Tranche, ...]] = {
|
|
40
|
+
ContractKind.BASE: (Tranche.BASE,),
|
|
41
|
+
ContractKind.HPHC: (Tranche.HC, Tranche.HP),
|
|
42
|
+
ContractKind.TEMPO: (
|
|
43
|
+
Tranche.BLUE_HC,
|
|
44
|
+
Tranche.BLUE_HP,
|
|
45
|
+
Tranche.WHITE_HC,
|
|
46
|
+
Tranche.WHITE_HP,
|
|
47
|
+
Tranche.RED_HC,
|
|
48
|
+
Tranche.RED_HP,
|
|
49
|
+
),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
DayCalendar = Mapping[date, TempoColor]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, kw_only=True)
|
|
56
|
+
class TimeRange:
|
|
57
|
+
"""Off-peak hours from `start` to `end`, crossing midnight when end <= start."""
|
|
58
|
+
|
|
59
|
+
start: time
|
|
60
|
+
end: time
|
|
61
|
+
|
|
62
|
+
def __post_init__(self) -> None:
|
|
63
|
+
"""Only accept whole and half hours, the finest split an hourly row allows."""
|
|
64
|
+
for value in (self.start, self.end):
|
|
65
|
+
if value.minute not in (0, 30) or value.second or value.microsecond:
|
|
66
|
+
msg = f"{value.isoformat()} is not on a whole or half hour"
|
|
67
|
+
raise ValueError(msg)
|
|
68
|
+
|
|
69
|
+
def contains(self, value: time) -> bool:
|
|
70
|
+
"""Tell whether `value` falls in the range (start included, end excluded)."""
|
|
71
|
+
if self.start < self.end:
|
|
72
|
+
return self.start <= value < self.end
|
|
73
|
+
return value >= self.start or value < self.end
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, kw_only=True)
|
|
77
|
+
class ContractRules:
|
|
78
|
+
"""What decides the tranche of each half hour."""
|
|
79
|
+
|
|
80
|
+
kind: ContractKind
|
|
81
|
+
off_peak: tuple[TimeRange, ...] = ()
|
|
82
|
+
weekend_off_peak: bool = False
|
|
83
|
+
|
|
84
|
+
def __post_init__(self) -> None:
|
|
85
|
+
"""Refuse options that only make sense for an HP/HC contract."""
|
|
86
|
+
if self.kind is not ContractKind.HPHC and (
|
|
87
|
+
self.off_peak or self.weekend_off_peak
|
|
88
|
+
):
|
|
89
|
+
msg = "Off-peak ranges only apply to HP/HC contracts"
|
|
90
|
+
raise ValueError(msg)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True, kw_only=True)
|
|
94
|
+
class PricePeriod:
|
|
95
|
+
"""Prices in force from `start` to `end` (both included, `end` None if open)."""
|
|
96
|
+
|
|
97
|
+
start: date
|
|
98
|
+
end: date | None
|
|
99
|
+
fixed_per_year: float
|
|
100
|
+
prices: Mapping[Tranche, float] = field(default_factory=dict)
|
|
101
|
+
|
|
102
|
+
def covers(self, day: date) -> bool:
|
|
103
|
+
"""Tell whether the period applies on `day`."""
|
|
104
|
+
return self.start <= day and (self.end is None or day <= self.end)
|
elec_tariffs/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Where prices and Tempo colors come from."""
|
|
2
|
+
|
|
3
|
+
from .tempo import (
|
|
4
|
+
FallbackTempoSource,
|
|
5
|
+
RteOfficialTempoClient,
|
|
6
|
+
RtePublicTempoClient,
|
|
7
|
+
TempoCalendarSource,
|
|
8
|
+
season_bounds,
|
|
9
|
+
season_of,
|
|
10
|
+
)
|
|
11
|
+
from .trv import TrvClient, parse_trv_csv
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"FallbackTempoSource",
|
|
15
|
+
"RteOfficialTempoClient",
|
|
16
|
+
"RtePublicTempoClient",
|
|
17
|
+
"TempoCalendarSource",
|
|
18
|
+
"TrvClient",
|
|
19
|
+
"parse_trv_csv",
|
|
20
|
+
"season_bounds",
|
|
21
|
+
"season_of",
|
|
22
|
+
]
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Tempo calendar: the color of each day, from RTE.
|
|
2
|
+
|
|
3
|
+
Two sources give the same data:
|
|
4
|
+
|
|
5
|
+
- RTE's public Tempo page backend (`services-rte.com/cms/open_data`), without an
|
|
6
|
+
account. It is not documented and may change without notice, so its answers are
|
|
7
|
+
checked strictly.
|
|
8
|
+
- RTE's official "Tempo Like Supply Contract" API, which needs the client ID and
|
|
9
|
+
secret of an application created on data.rte-france.com.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import time as monotonic_time
|
|
14
|
+
from collections.abc import Callable, Sequence
|
|
15
|
+
from datetime import date, datetime, time, timedelta
|
|
16
|
+
from typing import Any, Final, Protocol
|
|
17
|
+
from zoneinfo import ZoneInfo
|
|
18
|
+
|
|
19
|
+
import aiohttp
|
|
20
|
+
|
|
21
|
+
from elec_tariffs._version import __version__
|
|
22
|
+
from elec_tariffs.errors import (
|
|
23
|
+
TempoAuthError,
|
|
24
|
+
TempoError,
|
|
25
|
+
TempoFormatError,
|
|
26
|
+
TempoRateLimitError,
|
|
27
|
+
TempoServerError,
|
|
28
|
+
)
|
|
29
|
+
from elec_tariffs.models import TempoColor
|
|
30
|
+
|
|
31
|
+
PUBLIC_BASE_URL: Final = "https://www.services-rte.com/cms/open_data/v1"
|
|
32
|
+
OFFICIAL_BASE_URL: Final = "https://digital.iservices.rte-france.com"
|
|
33
|
+
TOKEN_PATH: Final = "/token/oauth/" # noqa: S105 - a path, not a password
|
|
34
|
+
CALENDAR_PATH: Final = "/open_api/tempo_like_supply_contract/v1/tempo_like_calendars"
|
|
35
|
+
|
|
36
|
+
MAX_RED_DAYS: Final = 22
|
|
37
|
+
MAX_WHITE_DAYS: Final = 43
|
|
38
|
+
FIRST_SEASON: Final = "2014-2015"
|
|
39
|
+
OFFICIAL_MAX_DAYS: Final = 366
|
|
40
|
+
|
|
41
|
+
PARIS: Final = ZoneInfo("Europe/Paris")
|
|
42
|
+
USER_AGENT: Final = (
|
|
43
|
+
f"elec-tariffs/{__version__} (+https://github.com/damou8/elec-tariffs)"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
_SEASON_START_MONTH: Final = 9
|
|
47
|
+
_TIMEOUT: Final = aiohttp.ClientTimeout(total=30)
|
|
48
|
+
_TOKEN_MARGIN: Final = 60
|
|
49
|
+
_DEFAULT_TOKEN_LIFETIME: Final = 3600
|
|
50
|
+
_COLORS: Final = {
|
|
51
|
+
"BLUE": TempoColor.BLUE,
|
|
52
|
+
"WHITE": TempoColor.WHITE,
|
|
53
|
+
"RED": TempoColor.RED,
|
|
54
|
+
}
|
|
55
|
+
_HTTP_OK: Final = 200
|
|
56
|
+
_HTTP_UNAUTHORIZED: Final = 401
|
|
57
|
+
_HTTP_FORBIDDEN: Final = 403
|
|
58
|
+
_HTTP_TOO_MANY_REQUESTS: Final = 429
|
|
59
|
+
_HTTP_SERVER_ERROR: Final = 500
|
|
60
|
+
|
|
61
|
+
type Calendar = dict[date, TempoColor]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def season_of(day: date) -> str:
|
|
65
|
+
"""Return the Tempo season of a day, like "2025-2026" (September to August)."""
|
|
66
|
+
first = day.year if day.month >= _SEASON_START_MONTH else day.year - 1
|
|
67
|
+
return f"{first}-{first + 1}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def season_bounds(season: str) -> tuple[date, date]:
|
|
71
|
+
"""Return the first and last day of a season named like "2025-2026"."""
|
|
72
|
+
first = int(season.split("-", 1)[0])
|
|
73
|
+
return date(first, _SEASON_START_MONTH, 1), date(first + 1, 8, 31)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _seasons(start: date, end: date) -> list[str]:
|
|
77
|
+
first = int(season_of(start).split("-", 1)[0])
|
|
78
|
+
last = int(season_of(end).split("-", 1)[0])
|
|
79
|
+
return [f"{year}-{year + 1}" for year in range(first, last + 1)]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _color(value: Any) -> TempoColor:
|
|
83
|
+
color = _COLORS.get(value) if isinstance(value, str) else None
|
|
84
|
+
if color is None:
|
|
85
|
+
msg = f"Unknown Tempo color {value!r}"
|
|
86
|
+
raise TempoFormatError(msg)
|
|
87
|
+
return color
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_public_values(payload: Any) -> Calendar:
|
|
91
|
+
"""Read a `{"values": {"YYYY-MM-DD": "BLUE", ...}}` answer of the public source."""
|
|
92
|
+
values = payload.get("values") if isinstance(payload, dict) else None
|
|
93
|
+
if not isinstance(values, dict):
|
|
94
|
+
msg = "The answer has no 'values' object"
|
|
95
|
+
raise TempoFormatError(msg)
|
|
96
|
+
calendar: Calendar = {}
|
|
97
|
+
for key, value in values.items():
|
|
98
|
+
if key.endswith("-fallback"):
|
|
99
|
+
continue
|
|
100
|
+
try:
|
|
101
|
+
day = date.fromisoformat(key)
|
|
102
|
+
except ValueError as err:
|
|
103
|
+
msg = f"Unexpected key {key!r}"
|
|
104
|
+
raise TempoFormatError(msg) from err
|
|
105
|
+
calendar[day] = _color(value)
|
|
106
|
+
return calendar
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def check_season(calendar: Calendar, season: str) -> None:
|
|
110
|
+
"""Refuse a season with days outside it or more red or white days than allowed."""
|
|
111
|
+
first, last = season_bounds(season)
|
|
112
|
+
if any(not first <= day <= last for day in calendar):
|
|
113
|
+
msg = f"Days outside the {season} season"
|
|
114
|
+
raise TempoFormatError(msg)
|
|
115
|
+
colors = list(calendar.values())
|
|
116
|
+
if (
|
|
117
|
+
colors.count(TempoColor.RED) > MAX_RED_DAYS
|
|
118
|
+
or colors.count(TempoColor.WHITE) > MAX_WHITE_DAYS
|
|
119
|
+
):
|
|
120
|
+
msg = f"Too many red or white days in the {season} season"
|
|
121
|
+
raise TempoFormatError(msg)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _raise_for_status(status: int) -> None:
|
|
125
|
+
if status == _HTTP_OK:
|
|
126
|
+
return
|
|
127
|
+
if status in (_HTTP_UNAUTHORIZED, _HTTP_FORBIDDEN):
|
|
128
|
+
msg = f"Access refused (HTTP {status})"
|
|
129
|
+
raise TempoAuthError(msg)
|
|
130
|
+
if status == _HTTP_TOO_MANY_REQUESTS:
|
|
131
|
+
msg = "Too many requests (HTTP 429)"
|
|
132
|
+
raise TempoRateLimitError(msg)
|
|
133
|
+
if status >= _HTTP_SERVER_ERROR:
|
|
134
|
+
msg = f"Server error (HTTP {status})"
|
|
135
|
+
raise TempoServerError(msg)
|
|
136
|
+
msg = f"Unexpected answer (HTTP {status})"
|
|
137
|
+
raise TempoFormatError(msg)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class TempoCalendarSource(Protocol):
|
|
141
|
+
"""Something that gives the Tempo color of days."""
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def name(self) -> str:
|
|
145
|
+
"""Short, stable name of the source, used in logs and diagnostics."""
|
|
146
|
+
...
|
|
147
|
+
|
|
148
|
+
async def async_calendar(self, start: date, end: date) -> Calendar:
|
|
149
|
+
"""Return the known colors from `start` to `end`, both included.
|
|
150
|
+
|
|
151
|
+
Days not published yet are missing from the result.
|
|
152
|
+
"""
|
|
153
|
+
...
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class RtePublicTempoClient:
|
|
157
|
+
"""RTE's public Tempo page backend, no account needed."""
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self, session: aiohttp.ClientSession, *, base_url: str = PUBLIC_BASE_URL
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Use the caller's HTTP session; `base_url` replaces RTE's address."""
|
|
163
|
+
self._session = session
|
|
164
|
+
self._base_url = base_url.rstrip("/")
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def name(self) -> str:
|
|
168
|
+
"""Name of the source."""
|
|
169
|
+
return "rte_public"
|
|
170
|
+
|
|
171
|
+
async def _get(self, path: str, params: dict[str, str] | None = None) -> Any:
|
|
172
|
+
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
|
173
|
+
try:
|
|
174
|
+
async with self._session.get(
|
|
175
|
+
f"{self._base_url}/{path}",
|
|
176
|
+
params=params,
|
|
177
|
+
headers=headers,
|
|
178
|
+
timeout=_TIMEOUT,
|
|
179
|
+
) as response:
|
|
180
|
+
_raise_for_status(response.status)
|
|
181
|
+
return await response.json(content_type=None)
|
|
182
|
+
except TempoAuthError as err:
|
|
183
|
+
msg = f"The public Tempo source refused the request: {err}"
|
|
184
|
+
raise TempoFormatError(msg) from err
|
|
185
|
+
except ValueError as err:
|
|
186
|
+
msg = "The public Tempo source did not answer with JSON"
|
|
187
|
+
raise TempoFormatError(msg) from err
|
|
188
|
+
except (aiohttp.ClientError, TimeoutError) as err:
|
|
189
|
+
msg = f"Could not reach the public Tempo source: {err}"
|
|
190
|
+
raise TempoServerError(msg) from err
|
|
191
|
+
|
|
192
|
+
async def async_season(self, season: str) -> Calendar:
|
|
193
|
+
"""Return the colors of a season published so far."""
|
|
194
|
+
calendar = parse_public_values(await self._get("tempo", {"season": season}))
|
|
195
|
+
check_season(calendar, season)
|
|
196
|
+
return calendar
|
|
197
|
+
|
|
198
|
+
async def async_today_tomorrow(self) -> Calendar:
|
|
199
|
+
"""Return today's color and tomorrow's once published."""
|
|
200
|
+
return parse_public_values(await self._get("tempoLight"))
|
|
201
|
+
|
|
202
|
+
async def async_calendar(self, start: date, end: date) -> Calendar:
|
|
203
|
+
"""Return the known colors from `start` to `end`, both included."""
|
|
204
|
+
if (end - start).days <= 1:
|
|
205
|
+
light = await self.async_today_tomorrow()
|
|
206
|
+
if start in light and end in light:
|
|
207
|
+
return {
|
|
208
|
+
day: color for day, color in light.items() if start <= day <= end
|
|
209
|
+
}
|
|
210
|
+
calendar: Calendar = {}
|
|
211
|
+
for season in _seasons(start, end):
|
|
212
|
+
calendar.update(await self.async_season(season))
|
|
213
|
+
return {day: color for day, color in calendar.items() if start <= day <= end}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class RteOfficialTempoClient:
|
|
217
|
+
"""RTE's official Tempo Like Supply Contract API (OAuth2 client credentials)."""
|
|
218
|
+
|
|
219
|
+
def __init__(
|
|
220
|
+
self,
|
|
221
|
+
session: aiohttp.ClientSession,
|
|
222
|
+
client_id: str,
|
|
223
|
+
client_secret: str,
|
|
224
|
+
*,
|
|
225
|
+
base_url: str = OFFICIAL_BASE_URL,
|
|
226
|
+
clock: Callable[[], float] = monotonic_time.monotonic,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Use the caller's HTTP session and the application's credentials.
|
|
229
|
+
|
|
230
|
+
`base_url` replaces RTE's address and `clock` the monotonic clock that
|
|
231
|
+
tells when the token expires.
|
|
232
|
+
"""
|
|
233
|
+
self._session = session
|
|
234
|
+
self._base_url = base_url.rstrip("/")
|
|
235
|
+
credentials = f"{client_id}:{client_secret}".encode()
|
|
236
|
+
self._basic_auth = f"Basic {base64.b64encode(credentials).decode('ascii')}"
|
|
237
|
+
self._clock = clock
|
|
238
|
+
self._token: str | None = None
|
|
239
|
+
self._expires_at = 0.0
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def name(self) -> str:
|
|
243
|
+
"""Name of the source."""
|
|
244
|
+
return "rte_official"
|
|
245
|
+
|
|
246
|
+
async def _async_token(self, *, refresh: bool = False) -> str:
|
|
247
|
+
if self._token and not refresh and self._clock() < self._expires_at:
|
|
248
|
+
return self._token
|
|
249
|
+
try:
|
|
250
|
+
async with self._session.post(
|
|
251
|
+
f"{self._base_url}{TOKEN_PATH}",
|
|
252
|
+
data={"grant_type": "client_credentials"},
|
|
253
|
+
headers={"Authorization": self._basic_auth, "User-Agent": USER_AGENT},
|
|
254
|
+
timeout=_TIMEOUT,
|
|
255
|
+
) as response:
|
|
256
|
+
if response.status == 400: # noqa: PLR2004 - OAuth answers 400 for a bad client
|
|
257
|
+
msg = "RTE refused the client credentials (HTTP 400)"
|
|
258
|
+
raise TempoAuthError(msg)
|
|
259
|
+
_raise_for_status(response.status)
|
|
260
|
+
payload = await response.json(content_type=None)
|
|
261
|
+
except ValueError as err:
|
|
262
|
+
msg = "The RTE token endpoint did not answer with JSON"
|
|
263
|
+
raise TempoFormatError(msg) from err
|
|
264
|
+
except (aiohttp.ClientError, TimeoutError) as err:
|
|
265
|
+
msg = f"Could not reach RTE: {err}"
|
|
266
|
+
raise TempoServerError(msg) from err
|
|
267
|
+
token = payload.get("access_token") if isinstance(payload, dict) else None
|
|
268
|
+
if not isinstance(token, str) or not token:
|
|
269
|
+
msg = "The RTE token answer has no access_token"
|
|
270
|
+
raise TempoFormatError(msg)
|
|
271
|
+
lifetime = payload.get("expires_in", _DEFAULT_TOKEN_LIFETIME)
|
|
272
|
+
if not isinstance(lifetime, int):
|
|
273
|
+
lifetime = _DEFAULT_TOKEN_LIFETIME
|
|
274
|
+
self._token = token
|
|
275
|
+
self._expires_at = self._clock() + max(lifetime - _TOKEN_MARGIN, 0)
|
|
276
|
+
return token
|
|
277
|
+
|
|
278
|
+
async def async_check_credentials(self) -> None:
|
|
279
|
+
"""Get a token, raising TempoAuthError if RTE refuses the credentials."""
|
|
280
|
+
await self._async_token(refresh=True)
|
|
281
|
+
|
|
282
|
+
async def _async_get_calendar(
|
|
283
|
+
self, params: dict[str, str], *, refresh: bool
|
|
284
|
+
) -> tuple[int, Any]:
|
|
285
|
+
"""Return the status and, when it is 200, the JSON answer."""
|
|
286
|
+
token = await self._async_token(refresh=refresh)
|
|
287
|
+
headers = {
|
|
288
|
+
"Authorization": f"Bearer {token}",
|
|
289
|
+
"Accept": "application/json",
|
|
290
|
+
"User-Agent": USER_AGENT,
|
|
291
|
+
}
|
|
292
|
+
try:
|
|
293
|
+
async with self._session.get(
|
|
294
|
+
f"{self._base_url}{CALENDAR_PATH}",
|
|
295
|
+
params=params,
|
|
296
|
+
headers=headers,
|
|
297
|
+
timeout=_TIMEOUT,
|
|
298
|
+
) as response:
|
|
299
|
+
if response.status != _HTTP_OK:
|
|
300
|
+
return response.status, None
|
|
301
|
+
return response.status, await response.json(content_type=None)
|
|
302
|
+
except ValueError as err:
|
|
303
|
+
msg = "The RTE calendar did not answer with JSON"
|
|
304
|
+
raise TempoFormatError(msg) from err
|
|
305
|
+
except (aiohttp.ClientError, TimeoutError) as err:
|
|
306
|
+
msg = f"Could not reach RTE: {err}"
|
|
307
|
+
raise TempoServerError(msg) from err
|
|
308
|
+
|
|
309
|
+
async def _async_chunk(self, start: date, end: date) -> Calendar:
|
|
310
|
+
params = {
|
|
311
|
+
"start_date": datetime.combine(start, time(), PARIS).isoformat(),
|
|
312
|
+
"end_date": datetime.combine(
|
|
313
|
+
end + timedelta(days=1), time(), PARIS
|
|
314
|
+
).isoformat(),
|
|
315
|
+
}
|
|
316
|
+
status, payload = await self._async_get_calendar(params, refresh=False)
|
|
317
|
+
if status == _HTTP_UNAUTHORIZED:
|
|
318
|
+
# The token may have been revoked before it expired: get a new one once.
|
|
319
|
+
status, payload = await self._async_get_calendar(params, refresh=True)
|
|
320
|
+
_raise_for_status(status)
|
|
321
|
+
return parse_official_values(payload)
|
|
322
|
+
|
|
323
|
+
async def async_calendar(self, start: date, end: date) -> Calendar:
|
|
324
|
+
"""Return the known colors from `start` to `end`, both included."""
|
|
325
|
+
calendar: Calendar = {}
|
|
326
|
+
chunk_start = start
|
|
327
|
+
while chunk_start <= end:
|
|
328
|
+
chunk_end = min(end, chunk_start + timedelta(days=OFFICIAL_MAX_DAYS - 1))
|
|
329
|
+
calendar.update(await self._async_chunk(chunk_start, chunk_end))
|
|
330
|
+
chunk_start = chunk_end + timedelta(days=1)
|
|
331
|
+
return {day: color for day, color in calendar.items() if start <= day <= end}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def parse_official_values(payload: Any) -> Calendar:
|
|
335
|
+
"""Read a `tempo_like_calendars.values[]` answer of the official API."""
|
|
336
|
+
container = (
|
|
337
|
+
payload.get("tempo_like_calendars") if isinstance(payload, dict) else None
|
|
338
|
+
)
|
|
339
|
+
values = container.get("values") if isinstance(container, dict) else None
|
|
340
|
+
if not isinstance(values, list):
|
|
341
|
+
msg = "The answer has no 'tempo_like_calendars.values' list"
|
|
342
|
+
raise TempoFormatError(msg)
|
|
343
|
+
calendar: Calendar = {}
|
|
344
|
+
for item in values:
|
|
345
|
+
start = item.get("start_date") if isinstance(item, dict) else None
|
|
346
|
+
if not isinstance(start, str):
|
|
347
|
+
msg = f"Unexpected calendar item {item!r}"
|
|
348
|
+
raise TempoFormatError(msg)
|
|
349
|
+
try:
|
|
350
|
+
day = datetime.fromisoformat(start).date()
|
|
351
|
+
except ValueError as err:
|
|
352
|
+
msg = f"Unexpected start_date {start!r}"
|
|
353
|
+
raise TempoFormatError(msg) from err
|
|
354
|
+
calendar[day] = _color(item.get("value"))
|
|
355
|
+
return calendar
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class FallbackTempoSource:
|
|
359
|
+
"""Ask each source in turn and return the first answer."""
|
|
360
|
+
|
|
361
|
+
def __init__(self, sources: Sequence[TempoCalendarSource]) -> None:
|
|
362
|
+
"""Try `sources` in this order."""
|
|
363
|
+
if not sources:
|
|
364
|
+
msg = "At least one source is needed"
|
|
365
|
+
raise ValueError(msg)
|
|
366
|
+
self._sources = tuple(sources)
|
|
367
|
+
self.last_source: str | None = None
|
|
368
|
+
self.last_errors: dict[str, TempoError] = {}
|
|
369
|
+
|
|
370
|
+
@property
|
|
371
|
+
def name(self) -> str:
|
|
372
|
+
"""Names of the sources, in order."""
|
|
373
|
+
return "+".join(source.name for source in self._sources)
|
|
374
|
+
|
|
375
|
+
async def async_calendar(self, start: date, end: date) -> Calendar:
|
|
376
|
+
"""Return the first source's answer; raise the last error if all fail.
|
|
377
|
+
|
|
378
|
+
`last_source` names the source that answered, `last_errors` the errors of
|
|
379
|
+
the sources tried before it (or of all of them).
|
|
380
|
+
"""
|
|
381
|
+
self.last_errors = {}
|
|
382
|
+
self.last_source = None
|
|
383
|
+
error: TempoError | None = None
|
|
384
|
+
for source in self._sources:
|
|
385
|
+
try:
|
|
386
|
+
calendar = await source.async_calendar(start, end)
|
|
387
|
+
except TempoError as err:
|
|
388
|
+
self.last_errors[source.name] = err
|
|
389
|
+
error = err
|
|
390
|
+
continue
|
|
391
|
+
self.last_source = source.name
|
|
392
|
+
return calendar
|
|
393
|
+
assert error is not None # noqa: S101 - at least one source was tried
|
|
394
|
+
raise error
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Regulated prices (Tarif Bleu) published on data.gouv.fr.
|
|
2
|
+
|
|
3
|
+
Each CSV has one row per price period and subscribed power: start and end dates
|
|
4
|
+
(DD/MM/YYYY, an empty end for the prices in force), the yearly fixed part and the
|
|
5
|
+
price of each tranche, with and without taxes. Rows of powers not offered at the
|
|
6
|
+
time are empty.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import csv
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from datetime import date, datetime
|
|
12
|
+
from typing import Final
|
|
13
|
+
|
|
14
|
+
import aiohttp
|
|
15
|
+
|
|
16
|
+
from elec_tariffs.errors import TrvFormatError, TrvServerError
|
|
17
|
+
from elec_tariffs.models import ContractKind, PricePeriod, Tranche
|
|
18
|
+
|
|
19
|
+
TRV_URLS: Final[Mapping[ContractKind, str]] = {
|
|
20
|
+
ContractKind.BASE: "https://www.data.gouv.fr/fr/datasets/r/c13d05e5-9e55-4d03-bf7e-042a2ade7e49",
|
|
21
|
+
ContractKind.HPHC: "https://www.data.gouv.fr/fr/datasets/r/f7303b3a-93c7-4242-813d-84919034c416",
|
|
22
|
+
ContractKind.TEMPO: "https://www.data.gouv.fr/fr/datasets/r/0c3d1d36-c412-4620-8566-e5cbb4fa2b5a",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
_PRICE_COLUMNS: Final[Mapping[ContractKind, Mapping[Tranche, str]]] = {
|
|
26
|
+
ContractKind.BASE: {Tranche.BASE: "PART_VARIABLE_TTC"},
|
|
27
|
+
ContractKind.HPHC: {
|
|
28
|
+
Tranche.HC: "PART_VARIABLE_HC_TTC",
|
|
29
|
+
Tranche.HP: "PART_VARIABLE_HP_TTC",
|
|
30
|
+
},
|
|
31
|
+
ContractKind.TEMPO: {
|
|
32
|
+
Tranche.BLUE_HC: "PART_VARIABLE_HCBleu_TTC",
|
|
33
|
+
Tranche.BLUE_HP: "PART_VARIABLE_HPBleu_TTC",
|
|
34
|
+
Tranche.WHITE_HC: "PART_VARIABLE_HCBlanc_TTC",
|
|
35
|
+
Tranche.WHITE_HP: "PART_VARIABLE_HPBlanc_TTC",
|
|
36
|
+
Tranche.RED_HC: "PART_VARIABLE_HCRouge_TTC",
|
|
37
|
+
Tranche.RED_HP: "PART_VARIABLE_HPRouge_TTC",
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
_START: Final = "DATE_DEBUT"
|
|
41
|
+
_END: Final = "DATE_FIN"
|
|
42
|
+
_POWER: Final = "P_SOUSCRITE"
|
|
43
|
+
_FIXED: Final = "PART_FIXE_TTC"
|
|
44
|
+
_TIMEOUT: Final = aiohttp.ClientTimeout(total=30)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _date(value: str) -> date:
|
|
48
|
+
return datetime.strptime(value, "%d/%m/%Y").date() # noqa: DTZ007
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _number(value: str) -> float:
|
|
52
|
+
return float(value.replace(",", "."))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_trv_csv(text: str, kind: ContractKind, kva: int) -> list[PricePeriod]:
|
|
56
|
+
"""Return the price periods of one contract kind and power, oldest first.
|
|
57
|
+
|
|
58
|
+
Raise TrvFormatError when a column is missing or a value can't be read.
|
|
59
|
+
"""
|
|
60
|
+
reader = csv.DictReader(text.lstrip("").splitlines(), delimiter=";")
|
|
61
|
+
price_columns = _PRICE_COLUMNS[kind]
|
|
62
|
+
required = {_START, _END, _POWER, _FIXED, *price_columns.values()}
|
|
63
|
+
missing = required - set(reader.fieldnames or ())
|
|
64
|
+
if missing:
|
|
65
|
+
msg = f"Missing columns in the {kind} prices: {', '.join(sorted(missing))}"
|
|
66
|
+
raise TrvFormatError(msg)
|
|
67
|
+
|
|
68
|
+
periods: list[PricePeriod] = []
|
|
69
|
+
for raw in reader:
|
|
70
|
+
# A short row leaves None in its last columns; extra fields have no name.
|
|
71
|
+
row = {name: (value or "").strip() for name, value in raw.items() if name}
|
|
72
|
+
if row[_POWER] != str(kva) or not row[_FIXED]:
|
|
73
|
+
continue
|
|
74
|
+
try:
|
|
75
|
+
periods.append(
|
|
76
|
+
PricePeriod(
|
|
77
|
+
start=_date(row[_START]),
|
|
78
|
+
end=_date(row[_END]) if row[_END] else None,
|
|
79
|
+
fixed_per_year=_number(row[_FIXED]),
|
|
80
|
+
prices={
|
|
81
|
+
tranche: _number(row[column])
|
|
82
|
+
for tranche, column in price_columns.items()
|
|
83
|
+
},
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
except ValueError as err:
|
|
87
|
+
msg = f"Unreadable {kind} price row starting {row[_START]}: {err}"
|
|
88
|
+
raise TrvFormatError(msg) from err
|
|
89
|
+
return sorted(periods, key=lambda period: period.start)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TrvClient:
|
|
93
|
+
"""Download the regulated prices from data.gouv.fr."""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
session: aiohttp.ClientSession,
|
|
98
|
+
*,
|
|
99
|
+
urls: Mapping[ContractKind, str] = TRV_URLS,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Use the caller's HTTP session; `urls` replaces the data.gouv.fr files."""
|
|
102
|
+
self._session = session
|
|
103
|
+
self._urls = urls
|
|
104
|
+
|
|
105
|
+
async def async_price_periods(
|
|
106
|
+
self, kind: ContractKind, kva: int
|
|
107
|
+
) -> list[PricePeriod]:
|
|
108
|
+
"""Return the price periods of one contract kind and power, oldest first."""
|
|
109
|
+
try:
|
|
110
|
+
async with self._session.get(
|
|
111
|
+
self._urls[kind], timeout=_TIMEOUT
|
|
112
|
+
) as response:
|
|
113
|
+
response.raise_for_status()
|
|
114
|
+
text = await response.text(encoding="utf-8")
|
|
115
|
+
except UnicodeDecodeError as err:
|
|
116
|
+
msg = f"The {kind} prices are not UTF-8 text"
|
|
117
|
+
raise TrvFormatError(msg) from err
|
|
118
|
+
except (aiohttp.ClientError, TimeoutError) as err:
|
|
119
|
+
msg = f"Could not download the {kind} prices: {err}"
|
|
120
|
+
raise TrvServerError(msg) from err
|
|
121
|
+
return parse_trv_csv(text, kind, kva)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: elec-tariffs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cost French electricity consumption hour by hour under Base, HP/HC and Tempo contracts
|
|
5
|
+
Project-URL: Homepage, https://github.com/damou8/elec-tariffs
|
|
6
|
+
Project-URL: Issues, https://github.com/damou8/elec-tariffs/issues
|
|
7
|
+
Author: damou8
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: edf,electricity,home-assistant,linky,rte,tariff,tempo
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: AsyncIO
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Natural Language :: English
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Home Automation
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.14
|
|
21
|
+
Requires-Dist: aiohttp>=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# elec-tariffs
|
|
25
|
+
|
|
26
|
+
Cost French electricity consumption hour by hour under Base, HP/HC and Tempo contracts.
|
|
27
|
+
|
|
28
|
+
`elec-tariffs` takes hourly consumption (as a Linky meter reports it) and works out what it cost under one or more contracts: which tranche each hour falls into, its price on that day, and its share of the yearly subscription. It also downloads the data that needs:
|
|
29
|
+
|
|
30
|
+
- the regulated prices (Tarif Bleu) of every period since 2012, from [data.gouv.fr](https://www.data.gouv.fr/fr/datasets/historique-des-tarifs-reglementes-de-vente-delectricite-pour-les-consommateurs-residentiels/);
|
|
31
|
+
- the color of each Tempo day, from RTE.
|
|
32
|
+
|
|
33
|
+
It is async (aiohttp), fully typed and has no dependency on Home Assistant. It was written for the [Electricity Contracts](https://github.com/damou8/electricity_contracts) Home Assistant integration.
|
|
34
|
+
|
|
35
|
+
It is not affiliated with EDF, RTE or Enedis.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install elec-tariffs
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Python 3.14 or later.
|
|
44
|
+
|
|
45
|
+
## Use
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import asyncio
|
|
49
|
+
from datetime import date, datetime
|
|
50
|
+
from zoneinfo import ZoneInfo
|
|
51
|
+
|
|
52
|
+
import aiohttp
|
|
53
|
+
|
|
54
|
+
from elec_tariffs import (
|
|
55
|
+
ContractKind,
|
|
56
|
+
ContractRules,
|
|
57
|
+
RtePublicTempoClient,
|
|
58
|
+
TrvClient,
|
|
59
|
+
cost_hour,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
PARIS = ZoneInfo("Europe/Paris")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def main() -> None:
|
|
66
|
+
async with aiohttp.ClientSession() as session:
|
|
67
|
+
prices = await TrvClient(session).async_price_periods(ContractKind.TEMPO, 6)
|
|
68
|
+
colors = await RtePublicTempoClient(session).async_calendar(
|
|
69
|
+
date(2026, 1, 4), date(2026, 1, 5)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# 1.8 kWh used from 19:00 to 20:00 on 5 January 2026.
|
|
73
|
+
cost = cost_hour(
|
|
74
|
+
datetime(2026, 1, 5, 19, tzinfo=PARIS),
|
|
75
|
+
1.8,
|
|
76
|
+
ContractRules(kind=ContractKind.TEMPO),
|
|
77
|
+
prices,
|
|
78
|
+
colors,
|
|
79
|
+
)
|
|
80
|
+
print(cost.energy, cost.variable, cost.fixed, cost.total)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
asyncio.run(main())
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
An HP/HC contract takes its own off-peak ranges, to the half hour, and can make weekends off-peak:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from datetime import time
|
|
90
|
+
|
|
91
|
+
from elec_tariffs import ContractKind, ContractRules, TimeRange
|
|
92
|
+
|
|
93
|
+
rules = ContractRules(
|
|
94
|
+
kind=ContractKind.HPHC,
|
|
95
|
+
off_peak=(
|
|
96
|
+
TimeRange(start=time(12, 30), end=time(14)),
|
|
97
|
+
TimeRange(start=time(22), end=time(6, 30)),
|
|
98
|
+
),
|
|
99
|
+
weekend_off_peak=True,
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Prices you set yourself are `PricePeriod` objects: a first and last day (`None` while in force), the yearly fixed part and a price per tranche, all with taxes.
|
|
104
|
+
|
|
105
|
+
## How an hour is costed
|
|
106
|
+
|
|
107
|
+
- **Tranches.** Each hour is split into two half hours with half the energy each, so an off-peak range that starts or ends on a half hour is followed. This assumes the consumption is even within the hour.
|
|
108
|
+
- **Tempo.** A Tempo day runs from 6:00 to 6:00, so a night takes the color of the day that started the evening before. Off-peak hours are 22:00 to 6:00.
|
|
109
|
+
- **Prices** change at midnight on the first day of a price period (1 February and 1 August for the regulated prices). An hour uses the prices of its calendar day.
|
|
110
|
+
- **Subscription.** The yearly fixed part is spread evenly over the hours of the year (8,760, or 8,784 in a leap year).
|
|
111
|
+
- **Missing data.** An hour whose Tempo color or price isn't known raises `MissingColorError` or `MissingPriceError` instead of guessing.
|
|
112
|
+
|
|
113
|
+
## Tempo calendar sources
|
|
114
|
+
|
|
115
|
+
| Source | Account | Notes |
|
|
116
|
+
| --- | --- | --- |
|
|
117
|
+
| `RtePublicTempoClient` | none | The backend of RTE's public [Tempo calendar page](https://www.services-rte.com/fr/visualisez-les-donnees-publiees-par-rte/calendrier-des-offres-de-fourniture-de-type-tempo.html). It isn't documented and RTE can change it without notice, so its answers are checked strictly: an unexpected format raises `TempoFormatError` instead of giving wrong colors. |
|
|
118
|
+
| `RteOfficialTempoClient` | free [data.rte-france.com](https://data.rte-france.com/catalog/-/api/consumption/Tempo-Like-Supply-Contract/v1.1) application | RTE's documented "Tempo Like Supply Contract" API, with OAuth2 client credentials. The token is kept until it expires and renewed once if refused. Long ranges are split into requests of 366 days at most. **Not yet tested against the live API**: its tests follow the format used by other clients. |
|
|
119
|
+
|
|
120
|
+
`FallbackTempoSource` asks each source in turn, for example the public one first and the official one when it fails. It records which source answered and the errors of the others.
|
|
121
|
+
|
|
122
|
+
Past colors never change, so callers should cache them and only ask for the current season and the next day.
|
|
123
|
+
|
|
124
|
+
To create the official API application: sign up on data.rte-france.com, open the "Tempo Like Supply Contract" API, choose "S'abonner à l'API" (this creates an application), then copy its client ID and secret.
|
|
125
|
+
|
|
126
|
+
## Errors
|
|
127
|
+
|
|
128
|
+
Every error derives from `ElecTariffsError`:
|
|
129
|
+
|
|
130
|
+
- `MissingColorError`, `MissingPriceError` (with the `day` concerned);
|
|
131
|
+
- `TrvServerError`, `TrvFormatError` for data.gouv.fr;
|
|
132
|
+
- `TempoServerError` (network or 5xx), `TempoRateLimitError` (429), `TempoAuthError` (credentials refused) and `TempoFormatError` for the Tempo sources.
|
|
133
|
+
|
|
134
|
+
## Development
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
python3.14 -m venv .venv
|
|
138
|
+
.venv/bin/pip install -r requirements-dev.txt -e .
|
|
139
|
+
.venv/bin/ruff check && .venv/bin/ruff format --check && .venv/bin/mypy
|
|
140
|
+
.venv/bin/pytest --cov
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The tests use recorded answers in `tests/fixtures/` and a local HTTP server; they make no network requests.
|
|
144
|
+
|
|
145
|
+
Releases are published to PyPI by GitHub Actions when a GitHub release is published; the tag (`vX.Y.Z`) must match `src/elec_tariffs/_version.py`.
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
elec_tariffs/__init__.py,sha256=B_6bxtr5iG9oGkjpPmaDK0x9JV20zFsncN1ryJ6wYtM,1680
|
|
2
|
+
elec_tariffs/_version.py,sha256=SMtRk4qxf0OYKoTBdCtPkBUDYVklfxDXkbrQZ69TsZo,80
|
|
3
|
+
elec_tariffs/engine.py,sha256=ie2dKaImyTNN0ATN_OSB_2tTDBwmGtsZFhm-OxW01pI,4903
|
|
4
|
+
elec_tariffs/errors.py,sha256=iEGZLg0sKiIq7mHTaPDOGhKiiOGaKhdUHmbM4eyXF8U,1540
|
|
5
|
+
elec_tariffs/models.py,sha256=eXa_oyegUc3DjoyxIiDS5Oyv_8E00k4dh5n1czuSMtg,2880
|
|
6
|
+
elec_tariffs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
elec_tariffs/sources/__init__.py,sha256=UwPJLU6cZ5mM9KML5DNRwU2JXAZs5Z-K38MNXrT8Woc,454
|
|
8
|
+
elec_tariffs/sources/tempo.py,sha256=KJgXILZUNKYNti9QPn2VHFm5-P402F5GgmzhxYXXxVY,15270
|
|
9
|
+
elec_tariffs/sources/trv.py,sha256=YKmyApGN64Wsn9gPflPVTMxa-vyCbyNBranv8PT3ehs,4651
|
|
10
|
+
elec_tariffs-0.1.0.dist-info/METADATA,sha256=sYDLgyzVKbw2lwhLnKgDt_L0SKoi1Scpr55A7xUs864,6329
|
|
11
|
+
elec_tariffs-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
12
|
+
elec_tariffs-0.1.0.dist-info/licenses/LICENSE,sha256=0ETqjySAVmmSopcwiOYZ2G05CrpF11Uotvki5FeXvyo,1063
|
|
13
|
+
elec_tariffs-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 damou8
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|