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.
@@ -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"]
@@ -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"]
@@ -0,0 +1,6 @@
1
+ """static timezone object for Berlin/Germany"""
2
+
3
+ from pytz import timezone
4
+
5
+ GERMAN_TIME_ZONE = timezone("Europe/Berlin")
6
+ __all__ = ["GERMAN_TIME_ZONE"]
@@ -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
+ ]
@@ -0,0 +1,2 @@
1
+ # This file marks the package as PEP561 compatible.
2
+ # Further reading: https://mypy.readthedocs.io/en/stable/installed_packages.html#creating-pep-561-compatible-packages
@@ -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
+ ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/mj0nez/bdew-datetimes/packaging_test.yml?style=plastic)![PyPI - Python Version](https://img.shields.io/pypi/pyversions/bdew-datetimes?style=plastic)![PyPI - License](https://img.shields.io/pypi/l/bdew-datetimes?style=plastic)![PyPI](https://img.shields.io/pypi/v/bdew-datetimes?style=plastic)
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.