bdew_datetimes 0.12.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.
- _bdew_datetimes_version.py +1 -0
- bdew_datetimes/__init__.py +29 -0
- bdew_datetimes/calendar.py +104 -0
- bdew_datetimes/enums.py +68 -0
- bdew_datetimes/german_strom_and_gas_tag.py +99 -0
- bdew_datetimes/german_time_zone.py +6 -0
- bdew_datetimes/models.py +57 -0
- bdew_datetimes/periods.py +142 -0
- bdew_datetimes/py.typed +2 -0
- bdew_datetimes/utils.py +47 -0
- bdew_datetimes-0.12.0.dist-info/METADATA +216 -0
- bdew_datetimes-0.12.0.dist-info/RECORD +14 -0
- bdew_datetimes-0.12.0.dist-info/WHEEL +4 -0
- bdew_datetimes-0.12.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
version = "0.12.0"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
bdew_datetimes is a package that models the BDEW holiday, which is relevant for German utilities
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .calendar import BdewDefinedHolidays, create_bdew_calendar
|
|
6
|
+
from .german_time_zone import GERMAN_TIME_ZONE
|
|
7
|
+
from .models import Period
|
|
8
|
+
from .periods import (
|
|
9
|
+
add_frist,
|
|
10
|
+
get_next_working_day,
|
|
11
|
+
get_nth_working_day_of_month,
|
|
12
|
+
get_previous_working_day,
|
|
13
|
+
is_bdew_working_day,
|
|
14
|
+
)
|
|
15
|
+
from .utils import get_all_bdew_non_working_days, get_all_bdew_working_days
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"create_bdew_calendar",
|
|
19
|
+
"BdewDefinedHolidays",
|
|
20
|
+
"Period",
|
|
21
|
+
"is_bdew_working_day",
|
|
22
|
+
"get_next_working_day",
|
|
23
|
+
"get_previous_working_day",
|
|
24
|
+
"add_frist",
|
|
25
|
+
"get_nth_working_day_of_month",
|
|
26
|
+
"get_all_bdew_working_days",
|
|
27
|
+
"get_all_bdew_non_working_days",
|
|
28
|
+
"GERMAN_TIME_ZONE",
|
|
29
|
+
]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
contains the business logic (namely the BDEW calendar information)
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datetime import date
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from holidays import HolidayBase, HolidaySum
|
|
10
|
+
from holidays.constants import DEC, JUN # type: ignore[attr-defined]
|
|
11
|
+
from holidays.countries.germany import Germany
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BdewDefinedHolidays(HolidayBase):
|
|
15
|
+
"""
|
|
16
|
+
Special holidays considered by the BDEW, excluding nationwide or state specific holidays.
|
|
17
|
+
|
|
18
|
+
To get a full calendar with all holidays use the function `create_bdew_calendar`.
|
|
19
|
+
|
|
20
|
+
Currently, in Germany there are no observed holidays e.g. a Holiday is
|
|
21
|
+
moved to the next Workday, if it's on Sunday.
|
|
22
|
+
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, observed: bool = False, **kwargs: Any) -> None:
|
|
26
|
+
super().__init__(observed=observed, **kwargs)
|
|
27
|
+
|
|
28
|
+
def _populate(self, year: int) -> None:
|
|
29
|
+
self[date(year, DEC, 24)] = "Heiligabend"
|
|
30
|
+
self[date(year, DEC, 31)] = "Silvester"
|
|
31
|
+
if year == 2025:
|
|
32
|
+
self[date(2025, JUN, 6)] = (
|
|
33
|
+
"Sonderfeiertag"
|
|
34
|
+
# Anlässlich der (verschobenen) Einführung des 24h Lieferantenwechsels
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_relevant_subdivisions: list[str] = [
|
|
39
|
+
"BB", # Brandenburg.
|
|
40
|
+
"BE", # Berlin.
|
|
41
|
+
"BW", # Baden-Württemberg.
|
|
42
|
+
"BY", # Bayern.
|
|
43
|
+
"HB", # Bremen.
|
|
44
|
+
"HE", # Hessen.
|
|
45
|
+
"HH", # Hamburg.
|
|
46
|
+
"MV", # Mecklenburg-Vorpommern.
|
|
47
|
+
"NI", # Niedersachsen.
|
|
48
|
+
"NW", # Nordrhein-Westfalen.
|
|
49
|
+
"RP", # Rheinland-Pfalz.
|
|
50
|
+
"SH", # Schleswig-Holstein.
|
|
51
|
+
"SL", # Saarland.
|
|
52
|
+
"SN", # Sachsen.
|
|
53
|
+
"ST", # Sachsen-Anhalt.
|
|
54
|
+
"TH", # Thüringen.
|
|
55
|
+
# This list contains an explicit list of all BDEW calendar relevant Germany subdivisions.
|
|
56
|
+
# It does _not_ include the 'Augsburg' subdivision which has been added in holidays v0.78.
|
|
57
|
+
# https://github.com/vacanza/holidays/commit/feab68b4377369ad52eeee9e98fc8316ec0c6f86
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def create_bdew_calendar() -> HolidaySum:
|
|
62
|
+
"""Creates a calendar containing all days considered by the BDEW.
|
|
63
|
+
|
|
64
|
+
Currently, in Germany there are no observed holidays e.g. a Holiday is
|
|
65
|
+
moved to the next Workday, if it's on Sunday.
|
|
66
|
+
|
|
67
|
+
"Nach GPKE und GeLi Gas sind alle jene Tage Werktage, die weder Sonnabend,
|
|
68
|
+
Sonntag oder gesetzlicher Feiertag sind. Generell gelten der 24.12. und
|
|
69
|
+
der 31.12. als Feiertage. Angaben ohne Gewähr."
|
|
70
|
+
|
|
71
|
+
https://www.bdew.de/media/documents/GPKE-GeLiGas-Feiertagskalender-2022-Version-1-1_Uu1w6UQ.pdf
|
|
72
|
+
|
|
73
|
+
[Accessed 2022-11-02]``
|
|
74
|
+
|
|
75
|
+
Returns
|
|
76
|
+
-------
|
|
77
|
+
holidays.HolidaySum
|
|
78
|
+
dict-like holiday calendar, to check if a date/datetime is a holiday
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
# First we need the BDEW specific holidays.
|
|
82
|
+
calendar = BdewDefinedHolidays(language="de")
|
|
83
|
+
|
|
84
|
+
# the type is wrong at assignment but correct after the first loop iteration
|
|
85
|
+
result: HolidaySum = calendar # type: ignore[assignment]
|
|
86
|
+
original_language_before_adding_subdivisions = result.language
|
|
87
|
+
# If a day is holiday in any subdivision, the holiday is valid nationwide.
|
|
88
|
+
# Therefore, we add all subdivisions of Germany to the BDEW specific holidays.
|
|
89
|
+
# Currently, in Germany holidays are not observed.
|
|
90
|
+
for subdivision in _relevant_subdivisions:
|
|
91
|
+
# the method __add__ expects a Union[int, "HolidayBase", "HolidaySum"] as `other`
|
|
92
|
+
# here, we're dealing with a child instance of HolidayBase
|
|
93
|
+
result += Germany(subdiv=subdivision, observed=False, language="de") # type: ignore[assignment]
|
|
94
|
+
if result.language is None:
|
|
95
|
+
# This is a workaround to a problem in holidays 0.20-0.53 (at least):
|
|
96
|
+
# When adding the subdivisions, the language attribute is lost,
|
|
97
|
+
# although holiday base and subdivision share the same language.
|
|
98
|
+
# The problem happens here:
|
|
99
|
+
# https://github.com/vacanza/python-holidays/blob/v0.53/holidays/holiday_base.py#L1164
|
|
100
|
+
result.language = original_language_before_adding_subdivisions
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
__all__ = ["BdewDefinedHolidays", "create_bdew_calendar"]
|
bdew_datetimes/enums.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""enums used inside the package"""
|
|
2
|
+
|
|
3
|
+
from enum import Enum, StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Division(Enum):
|
|
7
|
+
"""
|
|
8
|
+
Allows to distinguish divisions used by German utilities, German "Sparte".
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
STROM = 1 #: electricity
|
|
12
|
+
GAS = 2 #: gas
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DayType(StrEnum):
|
|
16
|
+
"""
|
|
17
|
+
An enum to differentiate between calendar days and working days.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
WORKING_DAY = "WT" #: working day, German "Werktag"
|
|
21
|
+
CALENDAR_DAY = "KT" #: calendar day, German "Kalendertag"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EndDateType(Enum):
|
|
25
|
+
"""
|
|
26
|
+
An enum to distinguish inclusive and exclusive end dates.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
INCLUSIVE = 1
|
|
30
|
+
"""
|
|
31
|
+
If a contract ends with the year 2022 and the end date is denoted as "2022-12-31",
|
|
32
|
+
then the end date is inclusive. Most dates in human (spoken) communication are meant
|
|
33
|
+
inclusively.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
EXCLUSIVE = 2
|
|
37
|
+
"""
|
|
38
|
+
If a contract ends with the year 2022 and the end date is denoted as "2023-01-01",
|
|
39
|
+
then the end date is exclusive. Most end dates handled by technical systems are meant
|
|
40
|
+
exclusively.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MonthType(Enum):
|
|
45
|
+
"""
|
|
46
|
+
When calculating periods defined as 'nth working day of a month' the
|
|
47
|
+
BNetzA regulations distinguish between two types of month which are
|
|
48
|
+
modelled in this enum.
|
|
49
|
+
Some periods refer to the "Liefermonat", others to the "Fristenmonat".
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
LIEFERMONAT = 1
|
|
53
|
+
"""
|
|
54
|
+
The "Liefermonat" is the month in which the supply starts.
|
|
55
|
+
"""
|
|
56
|
+
FRISTENMONAT = 2
|
|
57
|
+
"""
|
|
58
|
+
The grid operators prefer a key date based handling of supply contracts.
|
|
59
|
+
The key date in these cases is usually expressed as a specific working day
|
|
60
|
+
in the so called "Fristenmonat".
|
|
61
|
+
The "Fristenmonat" starts at the first day of the month
|
|
62
|
+
_before_ the "Liefermonat".
|
|
63
|
+
Quote: 'Nach der Festlegung BK6-06-009 (GPKE) der Monat vor dem Liefermonat.'
|
|
64
|
+
"""
|
|
65
|
+
# source: https://www.bundesnetzagentur.de/DE/Beschlusskammern/1_GZ/BK6-GZ/_bis_2010/2006/BK6-06-009/BK6-06-009_Beschluss_download.pdf?__blob=publicationFile&v=5
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
__all__ = ["Division", "EndDateType", "MonthType", "DayType"]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A module to evaluate datetimes and whether they are "on the edge"
|
|
3
|
+
of a German "Stromtag" or "Gastag" respectively
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datetime import datetime, time
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
# The problem with the stdlib zoneinfo is, that the availability of timezones
|
|
10
|
+
# via ZoneInfo(zone_key) depends on the OS and system on which you're running
|
|
11
|
+
# it. In some cases "Europe/Berlin" might be available, but generally it's not,
|
|
12
|
+
# and it's PITA to manually define timezones. So we're using pytz as a
|
|
13
|
+
# datasource for timezone information.
|
|
14
|
+
from pytz import utc
|
|
15
|
+
|
|
16
|
+
from bdew_datetimes.enums import Division
|
|
17
|
+
|
|
18
|
+
from .german_time_zone import GERMAN_TIME_ZONE
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_german_local_time(date_time: datetime) -> time:
|
|
22
|
+
"""
|
|
23
|
+
Returns the current german local time for the given datetime object.
|
|
24
|
+
"""
|
|
25
|
+
german_local_datetime = date_time.astimezone(GERMAN_TIME_ZONE)
|
|
26
|
+
return german_local_datetime.time()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def has_no_utc_offset(date_time: datetime) -> bool:
|
|
30
|
+
"""
|
|
31
|
+
Returns true if and only if date_time has an explicit offset to UTC.
|
|
32
|
+
If the UTC-offset is 0, it is exactly "+00:00".
|
|
33
|
+
"""
|
|
34
|
+
# the name of the function contains a negation because in German
|
|
35
|
+
# market communication it often matters that the UTC offset is 0.
|
|
36
|
+
original_time = date_time.time()
|
|
37
|
+
utc_time = date_time.astimezone(tz=utc).time()
|
|
38
|
+
return (
|
|
39
|
+
utc_time == original_time
|
|
40
|
+
and utc_time.hour == 0
|
|
41
|
+
and utc_time.minute == 0
|
|
42
|
+
and utc_time.second == 0
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_stromtag_limit(
|
|
47
|
+
date_time: datetime,
|
|
48
|
+
) -> bool: # the name is not as speaking as I'd like it to be
|
|
49
|
+
"""
|
|
50
|
+
Returns true if and only if the given date_time is the inclusive
|
|
51
|
+
start or exclusive end of a german "Stromtag".
|
|
52
|
+
The "Stromtag" is the balancing relevant day in the German electricity
|
|
53
|
+
market.
|
|
54
|
+
It starts and ends at midnight in German local time which can be
|
|
55
|
+
either 23:00 h or 22:00 h in UTC (depending on the daylight saving time in Germany).
|
|
56
|
+
"""
|
|
57
|
+
german_local_time = _get_german_local_time(date_time)
|
|
58
|
+
return (
|
|
59
|
+
german_local_time.hour == 0
|
|
60
|
+
and german_local_time.minute == 0
|
|
61
|
+
and german_local_time.second == 0
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_gastag_limit(
|
|
66
|
+
date_time: datetime,
|
|
67
|
+
) -> bool: # the name is not as speaking as I'd like it to be
|
|
68
|
+
"""
|
|
69
|
+
Returns true if and only if the given date_time is the inclusive start
|
|
70
|
+
or exclusive end of a german "Gastag".
|
|
71
|
+
The "Gastag" is the balancing relevant day in the German gas market.
|
|
72
|
+
It starts and ends at 6am in German local time which can be either
|
|
73
|
+
04:00 h or 05:00 h in UTC (depending on the daylight saving time in Germany).
|
|
74
|
+
"""
|
|
75
|
+
german_local_time = _get_german_local_time(date_time)
|
|
76
|
+
return (
|
|
77
|
+
german_local_time.hour == 6
|
|
78
|
+
and german_local_time.minute == 0
|
|
79
|
+
and german_local_time.second == 0
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def is_xtag_limit(date_time: datetime, division: Division) -> bool:
|
|
84
|
+
"""
|
|
85
|
+
Evaluates if it is the start/end of a provided division.
|
|
86
|
+
"""
|
|
87
|
+
xtag_evaluator: Callable[[datetime], bool]
|
|
88
|
+
if division == Division.STROM:
|
|
89
|
+
xtag_evaluator = is_stromtag_limit
|
|
90
|
+
elif division == Division.GAS:
|
|
91
|
+
xtag_evaluator = is_gastag_limit
|
|
92
|
+
else:
|
|
93
|
+
raise NotImplementedError(
|
|
94
|
+
f"The division must either be 'Strom' or 'Gas': '{division}'"
|
|
95
|
+
)
|
|
96
|
+
return xtag_evaluator(date_time)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
__all__ = ["is_gastag_limit", "is_stromtag_limit"]
|
bdew_datetimes/models.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""model classes used in this package"""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal, Union
|
|
5
|
+
|
|
6
|
+
from bdew_datetimes.enums import DayType, EndDateType
|
|
7
|
+
|
|
8
|
+
_DayTyp = Union[DayType, Literal["WT", "KT"]]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Period:
|
|
13
|
+
"""
|
|
14
|
+
A period is a German "Frist": A tuple that consists of a number of days and a day type.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
number_of_days: int
|
|
18
|
+
"""
|
|
19
|
+
number of days (might be any value <0, >0 or ==0)
|
|
20
|
+
"""
|
|
21
|
+
day_type: DayType
|
|
22
|
+
"""
|
|
23
|
+
the kind of days to add/subtract
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
number_of_days: int,
|
|
29
|
+
day_type: _DayTyp,
|
|
30
|
+
end_date_type: EndDateType = EndDateType.EXCLUSIVE,
|
|
31
|
+
):
|
|
32
|
+
"""
|
|
33
|
+
Initialize the Period by providing a number of days and a day_type which define the period.
|
|
34
|
+
|
|
35
|
+
"""
|
|
36
|
+
self.number_of_days = number_of_days
|
|
37
|
+
# If the Period is about something ending (e.g. a contract), then the user may
|
|
38
|
+
# provide an end_date_type.
|
|
39
|
+
# Internally we handle all end dates as exclusive, because:
|
|
40
|
+
# https://hf-kklein.github.io/exclusive_end_dates.github.io/
|
|
41
|
+
if end_date_type == EndDateType.INCLUSIVE:
|
|
42
|
+
if self.number_of_days > 0:
|
|
43
|
+
self.number_of_days = self.number_of_days - 1
|
|
44
|
+
elif self.number_of_days < 0:
|
|
45
|
+
self.number_of_days = self.number_of_days + 1
|
|
46
|
+
if isinstance(day_type, DayType):
|
|
47
|
+
pass
|
|
48
|
+
elif isinstance(day_type, str):
|
|
49
|
+
day_type = DayType(day_type)
|
|
50
|
+
else:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"'{day_type}' is not an allowed value; Check the typing"
|
|
53
|
+
)
|
|
54
|
+
self.day_type: DayType = day_type
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
__all__ = ["Period"]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
periods is a module that helps to calculate statutory periods
|
|
3
|
+
("gesetzliche Fristen") in the German energy market.
|
|
4
|
+
It is based on the chapter "Fristenberechnung" in the GPKE.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import datetime
|
|
8
|
+
from datetime import date
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from dateutil.relativedelta import relativedelta
|
|
12
|
+
from holidays import SAT, SUN # type: ignore[attr-defined]
|
|
13
|
+
|
|
14
|
+
from bdew_datetimes.calendar import create_bdew_calendar
|
|
15
|
+
from bdew_datetimes.enums import DayType, EndDateType, MonthType
|
|
16
|
+
from bdew_datetimes.german_time_zone import GERMAN_TIME_ZONE
|
|
17
|
+
from bdew_datetimes.models import Period
|
|
18
|
+
|
|
19
|
+
# https://www.bundesnetzagentur.de/DE/Beschlusskammern/1_GZ/BK6-GZ/2020/BK6-20-160/Mitteilung_Nr_2/Leseversion_GPKE.pdf
|
|
20
|
+
# pages 15 onwards
|
|
21
|
+
|
|
22
|
+
_bdew_calendar = create_bdew_calendar()
|
|
23
|
+
"""
|
|
24
|
+
a static calendar object that is used (module) internally
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_bdew_working_day(candidate: date) -> bool:
|
|
29
|
+
"""
|
|
30
|
+
Returns true if and only if the given candidate is a day relevant for the period calculation.
|
|
31
|
+
Returns false if the given candidate is either a BDEW holiday, a saturday or sunday.
|
|
32
|
+
"""
|
|
33
|
+
if candidate in _bdew_calendar:
|
|
34
|
+
return False
|
|
35
|
+
return candidate.weekday() not in (SAT, SUN)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_next_working_day(start_date: date) -> date:
|
|
39
|
+
"""
|
|
40
|
+
If start_date is a working day, the next (+1) working day is returned.
|
|
41
|
+
If this day is a BDEW holiday or falls on a weekend, the next working day
|
|
42
|
+
is returned.
|
|
43
|
+
"""
|
|
44
|
+
result = start_date + datetime.timedelta(
|
|
45
|
+
days=1
|
|
46
|
+
) # in any case the calculation starts at least at the next day
|
|
47
|
+
while not is_bdew_working_day(result):
|
|
48
|
+
# if the next day is a holiday, then we add days until we find a working day
|
|
49
|
+
result += datetime.timedelta(days=1)
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_previous_working_day(start_date: date) -> date:
|
|
54
|
+
"""
|
|
55
|
+
If start_date is a working day, the previous (-1) working day is returned.
|
|
56
|
+
If this day is a BDEW holiday or falls on a weekend, the previous working day
|
|
57
|
+
is returned.
|
|
58
|
+
"""
|
|
59
|
+
result = start_date - datetime.timedelta(
|
|
60
|
+
days=1
|
|
61
|
+
) # in any case the calculation starts at least at the next day
|
|
62
|
+
while not is_bdew_working_day(result):
|
|
63
|
+
# if the previous day is a holiday, then we subtract days until we find a working day
|
|
64
|
+
result -= datetime.timedelta(days=1)
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def add_frist(start: date, period: Period) -> date:
|
|
69
|
+
"""
|
|
70
|
+
Returns the date that is period after start.
|
|
71
|
+
"""
|
|
72
|
+
result: date = start
|
|
73
|
+
if period.number_of_days >= 0:
|
|
74
|
+
# the period calculation starts at the next working day, even if the number_of_days == 0
|
|
75
|
+
result = get_next_working_day(result)
|
|
76
|
+
# result is now the "Beginndatum" of the Fristenberechnung
|
|
77
|
+
if period.day_type == DayType.CALENDAR_DAY:
|
|
78
|
+
return result + datetime.timedelta(days=period.number_of_days)
|
|
79
|
+
# day_type is working day
|
|
80
|
+
if period.number_of_days == 0:
|
|
81
|
+
return result
|
|
82
|
+
if period.number_of_days > 0:
|
|
83
|
+
days_added = 0
|
|
84
|
+
while days_added < period.number_of_days:
|
|
85
|
+
result += datetime.timedelta(days=1)
|
|
86
|
+
if is_bdew_working_day(result):
|
|
87
|
+
# it's a usual working day; those are considered for the period calculation
|
|
88
|
+
days_added += 1
|
|
89
|
+
else: # implicitly: elif period.number_of_days < 0:
|
|
90
|
+
days_subtracted = 0
|
|
91
|
+
while days_subtracted <= abs(period.number_of_days):
|
|
92
|
+
result -= datetime.timedelta(days=1)
|
|
93
|
+
if is_bdew_working_day(result):
|
|
94
|
+
days_subtracted += 1
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_nth_working_day_of_month(
|
|
99
|
+
number_of_working_day_in_month: int,
|
|
100
|
+
month_type: MonthType = MonthType.LIEFERMONAT,
|
|
101
|
+
start: Optional[date] = None,
|
|
102
|
+
) -> date:
|
|
103
|
+
"""
|
|
104
|
+
Returns the nth working of the month, starting at start.
|
|
105
|
+
If start is None, then we use the local date/start of month in Germany.
|
|
106
|
+
Only year and month of the provided start date are taken into account.
|
|
107
|
+
The date.day is always discarded.
|
|
108
|
+
|
|
109
|
+
This function is useful if you're dealing with statutory periods from the
|
|
110
|
+
GPKE/MaBiS that are defined as "working days since start of month".
|
|
111
|
+
"""
|
|
112
|
+
if start is None:
|
|
113
|
+
start = GERMAN_TIME_ZONE.localize(datetime.datetime.utcnow()).date()
|
|
114
|
+
if month_type == MonthType.LIEFERMONAT:
|
|
115
|
+
# returns the "nter Werktag des Liefermonats"
|
|
116
|
+
start = get_previous_working_day(start.replace(day=1))
|
|
117
|
+
period = Period(
|
|
118
|
+
number_of_days=number_of_working_day_in_month,
|
|
119
|
+
day_type=DayType.WORKING_DAY,
|
|
120
|
+
end_date_type=EndDateType.INCLUSIVE,
|
|
121
|
+
)
|
|
122
|
+
result = add_frist(start, period)
|
|
123
|
+
elif month_type == MonthType.FRISTENMONAT:
|
|
124
|
+
# returns the "nter Werktag des Fristenmonats"
|
|
125
|
+
start_of_next_month = start.replace(day=1) + relativedelta(months=1)
|
|
126
|
+
result = get_nth_working_day_of_month(
|
|
127
|
+
number_of_working_day_in_month,
|
|
128
|
+
month_type=MonthType.LIEFERMONAT,
|
|
129
|
+
start=start_of_next_month,
|
|
130
|
+
)
|
|
131
|
+
else:
|
|
132
|
+
raise ValueError(f"Unhandled month_type {month_type}")
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
__all__ = [
|
|
137
|
+
"is_bdew_working_day",
|
|
138
|
+
"get_next_working_day",
|
|
139
|
+
"get_previous_working_day",
|
|
140
|
+
"add_frist",
|
|
141
|
+
"get_nth_working_day_of_month",
|
|
142
|
+
]
|
bdew_datetimes/py.typed
ADDED
bdew_datetimes/utils.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
helper functions
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from datetime import date, timedelta
|
|
6
|
+
|
|
7
|
+
from bdew_datetimes.calendar import create_bdew_calendar
|
|
8
|
+
|
|
9
|
+
_bdew_calendar = create_bdew_calendar()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _get_all(is_working_day: bool, year: int) -> list[date]:
|
|
13
|
+
"""
|
|
14
|
+
Returns a list of all BDEW working days or non-working days in the given year.
|
|
15
|
+
"""
|
|
16
|
+
days = []
|
|
17
|
+
current_date = date(year, 1, 1)
|
|
18
|
+
end_date = date(year, 12, 31)
|
|
19
|
+
while current_date <= end_date:
|
|
20
|
+
if _bdew_calendar.is_working_day(current_date) == is_working_day:
|
|
21
|
+
days.append(current_date)
|
|
22
|
+
current_date += timedelta(days=1)
|
|
23
|
+
|
|
24
|
+
return days
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_all_bdew_working_days(year: int) -> list[date]:
|
|
28
|
+
"""
|
|
29
|
+
Returns a list of all BDEW working days in the given year.
|
|
30
|
+
This includes all weekdays except saturday and sunday that are not BDEW holidays.
|
|
31
|
+
"""
|
|
32
|
+
return _get_all(is_working_day=True, year=year)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_all_bdew_non_working_days(
|
|
36
|
+
year: int, include_weekends: bool
|
|
37
|
+
) -> list[date]:
|
|
38
|
+
"""
|
|
39
|
+
Returns a list of all BDEW working days in the given year.
|
|
40
|
+
"""
|
|
41
|
+
non_working_days = _get_all(is_working_day=False, year=year)
|
|
42
|
+
if include_weekends:
|
|
43
|
+
return non_working_days
|
|
44
|
+
return [d for d in non_working_days if d.weekday() not in (5, 6)]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = ["get_all_bdew_working_days", "get_all_bdew_non_working_days"]
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bdew_datetimes
|
|
3
|
+
Version: 0.12.0
|
|
4
|
+
Summary: Generate and work with holidays of the BDEW-Calendar for power and gas in Germany
|
|
5
|
+
Project-URL: Changelog, https://github.com/mj0nez/bdew-datetimes/releases
|
|
6
|
+
Project-URL: Homepage, https://github.com/mj0nez/bdew-datetimes/
|
|
7
|
+
Author: Konstantin Klein
|
|
8
|
+
Author-email: Marcel Johannesmann <mj0nez@fn.de>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: bdew,edi@energy
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Requires-Dist: holidays>=0.84; python_version >= '3.10'
|
|
26
|
+
Requires-Dist: python-dateutil
|
|
27
|
+
Requires-Dist: pytz>=2022.7.1
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# bdew_datetimes
|
|
31
|
+
|
|
32
|
+

|
|
33
|
+
|
|
34
|
+
A collection of utils to work with datetimes and holidays in the German energy
|
|
35
|
+
market and is based on the [python-holiday](https://github.com/vacanza/holidays) package.
|
|
36
|
+
|
|
37
|
+
The implementation considers the publications of the **BDEW** (Bundesverband der Energie- und Wasserwirtschaft e. V.) and **EDI@Energy**, which provide boundaries and guidance for the data exchange on the german energy market.
|
|
38
|
+
|
|
39
|
+
### Current highlights:
|
|
40
|
+
|
|
41
|
+
- BDEW-holiday calendar
|
|
42
|
+
- allows dict like evaluation of dates and datetimes and contains all holidays considered by the BDEW
|
|
43
|
+
- Statutory Periods ("_Gesetzliche Fristen_")
|
|
44
|
+
- calculate dates of the kind "_x Werktage ab Stichtag_"
|
|
45
|
+
- calculate dates of the kind "_nter Werktag des Fristen- bzw. Liefermonats_"
|
|
46
|
+
- Gas-Day / Market Day evaluation
|
|
47
|
+
|
|
48
|
+
### Future Scope:
|
|
49
|
+
|
|
50
|
+
- providing subdivision holiday calendars to allow granular load profiles
|
|
51
|
+
|
|
52
|
+
## Quick Start and Examples
|
|
53
|
+
|
|
54
|
+
Install the package from [pypi](https://pypi.org/project/bdew-datetimes/):
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install bdew-datetimes
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Check if a date is a _specific_ BDEW Holidays
|
|
61
|
+
|
|
62
|
+
> [!NOTE]
|
|
63
|
+
> By "specific" we mean: Holidays that are neither nation- nor statewide holidays (those are defined in the [upstream package holidays](https://github.com/vacanza/holidays)) but defined by the BDEW directly.
|
|
64
|
+
> In 2025 those are: Sonderfeiertag 24h Lieferantenwechsel, Heiligabend and Silvester only.
|
|
65
|
+
|
|
66
|
+
The `HolidaySum` returned by `create_bdew_calendar` contains the BDEW specific holidays.
|
|
67
|
+
This means it contains those holidays which are _defined_ by BDEW which includes Heiligabend and Silvester as well as special days without Marktkommunikation but _not_ the local or nationwide holidays in Germany and its states.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from datetime import date
|
|
71
|
+
from bdew_datetimes import create_bdew_calendar
|
|
72
|
+
|
|
73
|
+
bdew_holidays = create_bdew_calendar() # this behaves like a dict
|
|
74
|
+
|
|
75
|
+
assert date(2022, 12, 31) in bdew_holidays # Silvester is a BDEW holiday
|
|
76
|
+
assert date(2022, 8, 8) in bdew_holidays is False # Augsburger Friedensfest is _not_ a BDEW holiday (but a holiday in Augsburg only)
|
|
77
|
+
assert date(2022, 12, 2) in bdew_holidays is False # The 12th of February is not a BDEW holiday
|
|
78
|
+
|
|
79
|
+
print(bdew_holidays.get('2022-01-01')) # prints "Neujahr"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The **union** (type `HolidaySum`) of both nation and state wide holidays **and** the BDEW holidays (only the latter is returned by `create_bdew_calendar`) is the relevant calendar for German utilities.
|
|
83
|
+
|
|
84
|
+
### Check if a given Date is a BDEW Working Day
|
|
85
|
+
|
|
86
|
+
BDEW working days are those days taken into account for the "Fristenberechnung".
|
|
87
|
+
The function `is_bdew_working_day` considers both national **and** state wide holidays **and** BDEW holidays:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from datetime import date
|
|
91
|
+
|
|
92
|
+
from bdew_datetimes import is_bdew_working_day
|
|
93
|
+
|
|
94
|
+
assert is_bdew_working_day(date(2023, 1, 1)) is False # Neujahr (national holiday)
|
|
95
|
+
assert is_bdew_working_day(date(2023, 1, 2)) is True # regular weekday
|
|
96
|
+
assert is_bdew_working_day(date(2023, 1, 6)) is False # Heilige Drei Könige (local holiday in parts of Germany)
|
|
97
|
+
assert is_bdew_working_day(date(2023, 4, 7)) is False # Karfreitag (national holiday, but based on an astronomical calendar)
|
|
98
|
+
assert is_bdew_working_day(date(2023, 12, 24)) is False # Heiligabend (BDEW holiday)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
You can also get the next or previous working day for any date:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from datetime import date
|
|
105
|
+
|
|
106
|
+
from bdew_datetimes import get_next_working_day, get_previous_working_day
|
|
107
|
+
|
|
108
|
+
assert get_next_working_day(date(2023, 1, 1)) == date(2023, 1, 2) # the next working day after Neujahr
|
|
109
|
+
assert get_previous_working_day(date(2023, 1, 1)) == date(2022, 12, 30) # the last working day of 2022
|
|
110
|
+
assert get_next_working_day(date(2023, 1, 20)) == date(2023, 1, 23) # the next working day after a friday is the next monday
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Calculate Statutory Periods
|
|
114
|
+
|
|
115
|
+
Statutory periods define the maximum time between e.g. the EDIFACT message for the "Anmeldung" and the actual start of supply ("Lieferbeginn").
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from datetime import date
|
|
119
|
+
|
|
120
|
+
from bdew_datetimes import add_frist
|
|
121
|
+
from bdew_datetimes import Period
|
|
122
|
+
from bdew_datetimes.enums import DayType, EndDateType
|
|
123
|
+
|
|
124
|
+
# Eingang der Anmeldung des LFN erfolgt am 04.07.2016. Der Mindestzeitraum von zehn WT
|
|
125
|
+
# beginnt am 05.07.2016 und endet am 18.07.2016. Frühestes zulässiges Anmeldedatum
|
|
126
|
+
# ist damit der 19.07.2016, sodass die Marktlokation dem LFN frühestens zum Beginn
|
|
127
|
+
# des vorgenannten Tages zugeordnet wird.
|
|
128
|
+
eingang_der_anmeldung = date(2016, 7, 4)
|
|
129
|
+
gesetzliche_frist = Period(
|
|
130
|
+
10,
|
|
131
|
+
DayType.WORKING_DAY,
|
|
132
|
+
end_date_type=EndDateType.EXCLUSIVE
|
|
133
|
+
# lieferbeginn is the exclusive end of the previous supply contract
|
|
134
|
+
)
|
|
135
|
+
fruehest_moeglicher_lieferbeginn = add_frist(eingang_der_anmeldung, gesetzliche_frist)
|
|
136
|
+
assert fruehest_moeglicher_lieferbeginn == date(2016, 7, 19)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Calculate "Liefer- and Fristenmonate"
|
|
140
|
+
|
|
141
|
+
Liefer- and Fristenmonat are concepts used in MaBiS and GPKE:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from datetime import date
|
|
145
|
+
|
|
146
|
+
from bdew_datetimes import get_nth_working_day_of_month
|
|
147
|
+
from bdew_datetimes.enums import MonthType
|
|
148
|
+
|
|
149
|
+
# returns the 18th working day of the current month in Germany
|
|
150
|
+
get_nth_working_day_of_month(18)
|
|
151
|
+
|
|
152
|
+
# the 18th working day of November 2023
|
|
153
|
+
assert get_nth_working_day_of_month(18, start=date(2023, 11, 1)) == date(2023, 11, 28)
|
|
154
|
+
|
|
155
|
+
# the 42th working day of Fristenmonat July 2023
|
|
156
|
+
assert get_nth_working_day_of_month(42, month_type=MonthType.FRISTENMONAT, start=date(2023, 7, 1)) == date(2023, 9, 29)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Notes
|
|
160
|
+
|
|
161
|
+
The BDEW considers all days as holidays, which are nationwide holidays and days, which are a holiday in at least one state.
|
|
162
|
+
Furthermore, the 24. and the 31. December are holidays as well.
|
|
163
|
+
Therefore, this package utilizes the composition of all available german holiday calendars and adds the two additional days.
|
|
164
|
+
|
|
165
|
+
Shifting holidays to the next weekday if they fall on a weekend is currently not considered.
|
|
166
|
+
|
|
167
|
+
## Development
|
|
168
|
+
|
|
169
|
+
This project uses [uv](https://docs.astral.sh/uv/) for dependency management & tooling and a [GNU Makefile](https://makefiletutorial.com/) to standardize common development commands. If you want to avoid `make` you can run each command manually, in this case please still refer to the [Makefile](./Makefile).
|
|
170
|
+
|
|
171
|
+
After installing uv & make, set up the development environment with:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
make dev
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Run the tests and the individual tooling via the corresponding target:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
make tests
|
|
181
|
+
|
|
182
|
+
make typing
|
|
183
|
+
|
|
184
|
+
make lint
|
|
185
|
+
|
|
186
|
+
make format
|
|
187
|
+
|
|
188
|
+
make spell-check
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Development
|
|
192
|
+
|
|
193
|
+
This project uses [uv](https://docs.astral.sh/uv/) for dependency management and tooling.
|
|
194
|
+
After installing uv, set up the development environment with:
|
|
195
|
+
```bash
|
|
196
|
+
uv sync --group dev
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Run the tests and the individual tooling via the corresponding dependency groups:
|
|
200
|
+
```bash
|
|
201
|
+
uv run --group tests pytest
|
|
202
|
+
uv run --group type_check mypy --strict src/bdew_datetimes tests
|
|
203
|
+
uv run --group linting ruff check .
|
|
204
|
+
uv run --group linting ruff format .
|
|
205
|
+
uv run --group spell_check codespell --ignore-words=domain-specific-terms.txt src/bdew_datetimes README.md
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
This library is licensed under the _MIT_ license, see the [LICENSE file](LICENSE).
|
|
211
|
+
|
|
212
|
+
## Users
|
|
213
|
+
|
|
214
|
+
This library is used by the following projects:
|
|
215
|
+
|
|
216
|
+
- [Hochfrequenz Fristenkalender](https://fristenkalender.hochfrequenz.de); the respective backend is also available [online](https://fristenkalender-api.happyfield-64ecc075.westeurope.azurecontainerapps.io/docs) and as [Docker image](https://github.com/Hochfrequenz/fristenkalender-functions/pkgs/container/fristenkalender-functions)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
_bdew_datetimes_version.py,sha256=0d_eblF-LMXh1_mZw-q2DqmmX_vZj5tYDy8MONmqkNU,19
|
|
2
|
+
bdew_datetimes/__init__.py,sha256=w-ffQrLFd9YWeky7keRjLoujBIyhFgiF3OSlWewX2_U,793
|
|
3
|
+
bdew_datetimes/calendar.py,sha256=a8SasRInjEXHZqDjLw-CvnkeFBrgtrcDlSTcW1F5IDo,3957
|
|
4
|
+
bdew_datetimes/enums.py,sha256=le--wNy25T3RsvIkMP0Nkyx2iNk3ju3DygojLSJWyrQ,2054
|
|
5
|
+
bdew_datetimes/german_strom_and_gas_tag.py,sha256=j_PBZBf4W_kRgteIcH-NpS9Dfwzwn7vw3k95Ecf54aM,3399
|
|
6
|
+
bdew_datetimes/german_time_zone.py,sha256=fBWU6SAkZsGQ9FSlxVUa8zssyRQ0xtjAk7p0nnmBJII,152
|
|
7
|
+
bdew_datetimes/models.py,sha256=73cUvResRAST0XEXYZdfyOCBy404avZWcLg8gTJx0n4,1697
|
|
8
|
+
bdew_datetimes/periods.py,sha256=p_Eh0EoWPd5Zbzpfs8SUeYoiMciSGyfA9QP5QLpRZko,5238
|
|
9
|
+
bdew_datetimes/py.typed,sha256=UMj6hnFihehUfxt0cdFZhinH-WhK5OI0ZC4Hxa6eiY0,170
|
|
10
|
+
bdew_datetimes/utils.py,sha256=RWD1gRxxARBgv_235nLzxm4ooQjQ1cS4FeiU2xtbGOg,1328
|
|
11
|
+
bdew_datetimes-0.12.0.dist-info/METADATA,sha256=PViXPJECHQNhhNPVaYL4q3mAcEMOBW7BRhNZfmA6_rE,9003
|
|
12
|
+
bdew_datetimes-0.12.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
13
|
+
bdew_datetimes-0.12.0.dist-info/licenses/LICENSE,sha256=5CQf-Y46R5vjfkEM6aIIq3LD2TtKDQeane0hR0shJCA,1063
|
|
14
|
+
bdew_datetimes-0.12.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022 mj0nez
|
|
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.
|