datedict 1.0.4__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.
- datedict/DateDict.py +46 -0
- datedict/TimeDict.py +236 -0
- datedict/YearDict.py +24 -0
- datedict/__init__.py +6 -0
- datedict/common.py +19 -0
- datedict-1.0.4.dist-info/METADATA +50 -0
- datedict-1.0.4.dist-info/RECORD +9 -0
- datedict-1.0.4.dist-info/WHEEL +4 -0
- datedict-1.0.4.dist-info/licenses/LICENSE +21 -0
datedict/DateDict.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from datetime import date, timedelta
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from .TimeDict import TimeDict
|
|
6
|
+
|
|
7
|
+
from .common import ZERO
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .YearDict import YearDict
|
|
11
|
+
|
|
12
|
+
DateKey = str | date
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DateDict(TimeDict[DateKey]):
|
|
16
|
+
@classmethod
|
|
17
|
+
def _next_key(cls, key: DateKey) -> DateKey:
|
|
18
|
+
if isinstance(key, str):
|
|
19
|
+
key_date = date.fromisoformat(key)
|
|
20
|
+
else:
|
|
21
|
+
key_date = key
|
|
22
|
+
next_date = key_date + timedelta(days=1)
|
|
23
|
+
return next_date.strftime("%Y-%m-%d")
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def _inclusive_range(cls, start: DateKey, end: DateKey) -> list[DateKey]:
|
|
27
|
+
start_date: str = (
|
|
28
|
+
str(start) if isinstance(start, str) else start.strftime("%Y-%m-%d")
|
|
29
|
+
)
|
|
30
|
+
end_date: str = str(end) if isinstance(end, str) else end.strftime("%Y-%m-%d")
|
|
31
|
+
return super()._inclusive_range(start_date, end_date)
|
|
32
|
+
|
|
33
|
+
def to_yeardict(self) -> "YearDict":
|
|
34
|
+
"""
|
|
35
|
+
Convert the DateDict to a YearDict by summing values for each year.
|
|
36
|
+
"""
|
|
37
|
+
from .YearDict import YearDict
|
|
38
|
+
|
|
39
|
+
year_data: dict[int, Decimal] = {}
|
|
40
|
+
for k, v in self.data.items():
|
|
41
|
+
year = int(k[:4]) if isinstance(k, str) else k.year
|
|
42
|
+
year_data[year] = year_data.get(year, ZERO) + v
|
|
43
|
+
return YearDict(year_data)
|
|
44
|
+
|
|
45
|
+
def to_dict(self) -> dict[str, Decimal]:
|
|
46
|
+
return {str(k): v for k, v in self.data.items()}
|
datedict/TimeDict.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
from decimal import Decimal
|
|
2
|
+
from typing import Callable, Generic, Mapping, Protocol, Self, TypeVar
|
|
3
|
+
from .common import NAN, ONE, ZERO, Decimable, to_decimal
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
K = TypeVar("K", bound="SupportsLe")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SupportsLe(Protocol):
|
|
10
|
+
def __le__(self: K, other: K, /) -> bool: ...
|
|
11
|
+
def __lt__(self: K, other: K, /) -> bool: ...
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TimeDict(Generic[K]):
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def _next_key(cls, key: K) -> K:
|
|
18
|
+
_ = key
|
|
19
|
+
raise NotImplementedError("_next_key must be implemented in subclasses")
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def _inclusive_range(cls, start: K, end: K) -> list[K]:
|
|
23
|
+
keys = []
|
|
24
|
+
current = start
|
|
25
|
+
while current <= end:
|
|
26
|
+
keys.append(current)
|
|
27
|
+
current = cls._next_key(current)
|
|
28
|
+
return keys
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
data: Mapping[K, Decimable] = dict(),
|
|
33
|
+
strict: bool = True,
|
|
34
|
+
cumulative: bool = False,
|
|
35
|
+
) -> None:
|
|
36
|
+
keys = sorted(data.keys())
|
|
37
|
+
if not keys:
|
|
38
|
+
raise ValueError("Data cannot be empty.")
|
|
39
|
+
if strict:
|
|
40
|
+
# enforce contiguous coverage
|
|
41
|
+
if keys != self._inclusive_range(keys[0], keys[-1]):
|
|
42
|
+
raise ValueError(
|
|
43
|
+
"Data must cover all keys in the contiguous range. "
|
|
44
|
+
"To disable this check, set strict=False."
|
|
45
|
+
)
|
|
46
|
+
self.start, self.end = keys[0], keys[-1]
|
|
47
|
+
self.data = {k: to_decimal(v) for k, v in data.items()}
|
|
48
|
+
self.cumulative = cumulative
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def fill(cls, start: K, end: K, value: Decimable) -> "Self":
|
|
52
|
+
"""
|
|
53
|
+
Create a new graph with a specified range and value.
|
|
54
|
+
The range is defined by start and end.
|
|
55
|
+
"""
|
|
56
|
+
v = to_decimal(value)
|
|
57
|
+
return cls({k: v for k in cls._inclusive_range(start, end)})
|
|
58
|
+
|
|
59
|
+
def get(self, key: K, default: Decimal = NAN) -> Decimal:
|
|
60
|
+
"""
|
|
61
|
+
Get the value for a specific key.
|
|
62
|
+
If the key does not exist, return the default value.
|
|
63
|
+
If the value is None, return the default value.
|
|
64
|
+
"""
|
|
65
|
+
temp = self.data.get(key, NAN)
|
|
66
|
+
if temp.is_nan():
|
|
67
|
+
return default
|
|
68
|
+
return temp
|
|
69
|
+
|
|
70
|
+
def __getitem__(self, key: K) -> Decimal:
|
|
71
|
+
return self.data[key]
|
|
72
|
+
|
|
73
|
+
def __setitem__(self, key: K, value) -> None:
|
|
74
|
+
self.data[key] = to_decimal(value)
|
|
75
|
+
|
|
76
|
+
def crop(
|
|
77
|
+
self,
|
|
78
|
+
start: K | None = None,
|
|
79
|
+
end: K | None = None,
|
|
80
|
+
initial_value: Decimable = NAN,
|
|
81
|
+
) -> "Self":
|
|
82
|
+
if start is None and end is None:
|
|
83
|
+
return self
|
|
84
|
+
return type(self)(
|
|
85
|
+
{
|
|
86
|
+
k: (self.get(k, to_decimal(initial_value)))
|
|
87
|
+
for k in self._inclusive_range(
|
|
88
|
+
start if start is not None else self.start,
|
|
89
|
+
end if end is not None else self.end,
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def non_negative(self) -> "Self":
|
|
95
|
+
"""
|
|
96
|
+
Return a new TimeDict with all negative values set to zero.
|
|
97
|
+
"""
|
|
98
|
+
return type(self)(
|
|
99
|
+
{
|
|
100
|
+
k: (v if (not v.is_nan() and v >= ZERO) else ZERO)
|
|
101
|
+
for k, v in self.data.items()
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def sum(self, start: K | None = None, end: K | None = None) -> Decimal:
|
|
106
|
+
total = ZERO
|
|
107
|
+
for k in self._inclusive_range(
|
|
108
|
+
start if start is not None else self.start,
|
|
109
|
+
end if end is not None else self.end,
|
|
110
|
+
):
|
|
111
|
+
v = self.data.get(k, NAN)
|
|
112
|
+
if not v.is_nan():
|
|
113
|
+
total += v
|
|
114
|
+
return total
|
|
115
|
+
|
|
116
|
+
def _binary_op(
|
|
117
|
+
self,
|
|
118
|
+
other: "Decimable | Self",
|
|
119
|
+
op: Callable[[Decimal, Decimal], Decimal],
|
|
120
|
+
neutral: Decimal,
|
|
121
|
+
) -> "Self":
|
|
122
|
+
if isinstance(other, Decimable):
|
|
123
|
+
other_value = to_decimal(other)
|
|
124
|
+
return type(self)({k: op(v, other_value) for k, v in self.data.items()})
|
|
125
|
+
elif isinstance(other, type(self)):
|
|
126
|
+
return type(self)(
|
|
127
|
+
{
|
|
128
|
+
k: op(
|
|
129
|
+
self[k],
|
|
130
|
+
other.get(k, neutral),
|
|
131
|
+
)
|
|
132
|
+
for k in self.data.keys()
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
raise TypeError(
|
|
137
|
+
"Unsupported operand type(s) for operation: "
|
|
138
|
+
f"'TimeDict' and '{type(other)}'"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def __mul__(self, other: "Decimable | Self") -> "Self":
|
|
142
|
+
return self._binary_op(other, lambda x, y: x * y, ONE)
|
|
143
|
+
|
|
144
|
+
def __rmul__(self, other):
|
|
145
|
+
return self.__mul__(other)
|
|
146
|
+
|
|
147
|
+
def __neg__(self) -> "Self":
|
|
148
|
+
return self * Decimal("-1")
|
|
149
|
+
|
|
150
|
+
def __add__(self, other: "Decimable | Self") -> "Self":
|
|
151
|
+
return self._binary_op(other, lambda x, y: x + y, ZERO)
|
|
152
|
+
|
|
153
|
+
def __radd__(self, other: "Decimable | Self") -> "Self":
|
|
154
|
+
return self.__add__(other)
|
|
155
|
+
|
|
156
|
+
def __sub__(self, other: "Decimable | Self") -> "Self":
|
|
157
|
+
return self._binary_op(other, lambda x, y: x - y, ZERO)
|
|
158
|
+
|
|
159
|
+
def __rsub__(self, other: "Decimable | Self") -> "Self":
|
|
160
|
+
return (-self).__add__(other)
|
|
161
|
+
|
|
162
|
+
def __truediv__(self, other: "Decimable | Self") -> "Self":
|
|
163
|
+
return self._binary_op(other, lambda x, y: x / y, ONE)
|
|
164
|
+
|
|
165
|
+
def __eq__(self, other: object) -> bool:
|
|
166
|
+
if not isinstance(other, type(self)):
|
|
167
|
+
return False
|
|
168
|
+
for k in set(self.data.keys()).union(other.data.keys()):
|
|
169
|
+
s = self.get(k)
|
|
170
|
+
o = other.get(k)
|
|
171
|
+
if s.is_nan() and o.is_nan():
|
|
172
|
+
continue
|
|
173
|
+
if s != o:
|
|
174
|
+
return False
|
|
175
|
+
return True
|
|
176
|
+
|
|
177
|
+
def __str__(self) -> str:
|
|
178
|
+
return "\n".join(f"{k}: {v}" for k, v in sorted(self.data.items()))
|
|
179
|
+
|
|
180
|
+
def __repr__(self) -> str:
|
|
181
|
+
return f"{self.data!r}"
|
|
182
|
+
|
|
183
|
+
def to_array(self) -> list[Decimal]:
|
|
184
|
+
return [self.data[k] for k in self.data.keys()]
|
|
185
|
+
|
|
186
|
+
def to_dict(self) -> dict:
|
|
187
|
+
return self.data.copy()
|
|
188
|
+
|
|
189
|
+
def average(self) -> Decimal:
|
|
190
|
+
"""
|
|
191
|
+
Return the average of the values in the TimeDict.
|
|
192
|
+
If there are no valid (non-NaN) values, return ZERO.
|
|
193
|
+
"""
|
|
194
|
+
valid_values = [v for v in self.data.values() if not v.is_nan()]
|
|
195
|
+
if not valid_values:
|
|
196
|
+
return ZERO
|
|
197
|
+
return sum(valid_values) / Decimal(len(valid_values))
|
|
198
|
+
|
|
199
|
+
def to_cumulative(self) -> "Self":
|
|
200
|
+
"""
|
|
201
|
+
Convert the TimeDict to a cumulative TimeDict.
|
|
202
|
+
Each value is the sum of all previous values up to that key.
|
|
203
|
+
NaN values are ignored and left as NaN in the cumulative result.
|
|
204
|
+
"""
|
|
205
|
+
if self.cumulative:
|
|
206
|
+
raise ValueError("TimeDict is already cumulative.")
|
|
207
|
+
running_total = ZERO
|
|
208
|
+
cumulative_data = {}
|
|
209
|
+
for k in sorted(self.data.keys()):
|
|
210
|
+
current_value = self.data[k]
|
|
211
|
+
if current_value.is_nan():
|
|
212
|
+
cumulative_data[k] = NAN
|
|
213
|
+
else:
|
|
214
|
+
running_total += current_value
|
|
215
|
+
cumulative_data[k] = running_total
|
|
216
|
+
return type(self)(cumulative_data, cumulative=True)
|
|
217
|
+
|
|
218
|
+
def to_incremental(self) -> "Self":
|
|
219
|
+
"""
|
|
220
|
+
Convert the TimeDict to an incremental TimeDict.
|
|
221
|
+
Each value is the difference between the current cumulative value
|
|
222
|
+
and the previous cumulative value.
|
|
223
|
+
NaN values are ignored and left as NaN in the incremental result.
|
|
224
|
+
"""
|
|
225
|
+
if not self.cumulative:
|
|
226
|
+
raise ValueError("TimeDict is not cumulative.")
|
|
227
|
+
previous_value = ZERO
|
|
228
|
+
incremental_data = {}
|
|
229
|
+
for k in sorted(self.data.keys()):
|
|
230
|
+
current_value = self.data[k]
|
|
231
|
+
if current_value.is_nan():
|
|
232
|
+
incremental_data[k] = NAN
|
|
233
|
+
else:
|
|
234
|
+
incremental_data[k] = current_value - previous_value
|
|
235
|
+
previous_value = current_value
|
|
236
|
+
return type(self)(incremental_data, cumulative=False)
|
datedict/YearDict.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
from .TimeDict import TimeDict
|
|
3
|
+
from .common import NAN
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from .DateDict import DateDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class YearDict(TimeDict[int]):
|
|
10
|
+
@classmethod
|
|
11
|
+
def _next_key(cls, key: int) -> int:
|
|
12
|
+
return key + 1
|
|
13
|
+
|
|
14
|
+
def to_datedict(self) -> "DateDict":
|
|
15
|
+
from .DateDict import DateDict
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
Convert the YearDict to a DateDict.
|
|
19
|
+
Each year in the YearDict is expanded to cover all dates in that year.
|
|
20
|
+
"""
|
|
21
|
+
dd: DateDict = DateDict.fill(f"{self.start}-01-01", f"{self.end}-12-31", NAN)
|
|
22
|
+
for k in dd.data.keys():
|
|
23
|
+
dd.data[k] = self.get(int(k[:4]) if isinstance(k, str) else k.year, NAN)
|
|
24
|
+
return dd
|
datedict/__init__.py
ADDED
datedict/common.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from decimal import Decimal
|
|
2
|
+
from typing import Union
|
|
3
|
+
|
|
4
|
+
Decimable = Union[Decimal, str, int, float, None]
|
|
5
|
+
|
|
6
|
+
ZERO = Decimal("0")
|
|
7
|
+
ONE = Decimal("1")
|
|
8
|
+
NAN = Decimal("NaN")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def to_decimal(x: Decimable) -> Decimal:
|
|
12
|
+
if x is None:
|
|
13
|
+
return Decimal("NaN")
|
|
14
|
+
if isinstance(x, Decimal):
|
|
15
|
+
return x
|
|
16
|
+
if isinstance(x, (int, str)):
|
|
17
|
+
return Decimal(x)
|
|
18
|
+
if isinstance(x, float):
|
|
19
|
+
return Decimal(str(x))
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: datedict
|
|
3
|
+
Version: 1.0.4
|
|
4
|
+
Summary: DateDict and YearDict: date-aware dictionary structures
|
|
5
|
+
Project-URL: Homepage, https://gitlab.com/grisus/datedict
|
|
6
|
+
Project-URL: Issues, https://gitlab.com/grisus/datedict/-/issues
|
|
7
|
+
Author-email: Lorenzo Guideri <lorenzo.guideri@dec-energy.ch>
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2025 DEC Energy SA
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
31
|
+
Classifier: Operating System :: OS Independent
|
|
32
|
+
Classifier: Programming Language :: Python :: 3
|
|
33
|
+
Classifier: Typing :: Typed
|
|
34
|
+
Requires-Python: >=3.10
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# datedict
|
|
38
|
+
|
|
39
|
+
`datedict` bundles two data structures: `DateDict` and `YearDict`.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
```bash
|
|
43
|
+
pip install datedict
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from datedict import DateDict, YearDict
|
|
50
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
datedict/DateDict.py,sha256=nJsE2m8aPfYdPzOs61Jg7uvykDQd4o0YwrTfJkx5dtI,1441
|
|
2
|
+
datedict/TimeDict.py,sha256=20jUsuEZ2rW0XCJZF1h3scYDfu80rXWT69qN2VvQXqA,7831
|
|
3
|
+
datedict/YearDict.py,sha256=xEJYxAFY3zhR8wKejlac976jMwJfkKsak9t4DHTmMKM,707
|
|
4
|
+
datedict/__init__.py,sha256=XB30NC1fBiG4DAswy7JcmIiisDPqgiDGiHxoNQU7bMk,201
|
|
5
|
+
datedict/common.py,sha256=aUCsMV6roKGQIQXSFIJIN0cxAXIlK_YyRDWS1EIBu8E,424
|
|
6
|
+
datedict-1.0.4.dist-info/METADATA,sha256=eDzXvzTdBdlX2I8tRUnMaCK8jlN3kVyZRijsoKX8Aaw,1991
|
|
7
|
+
datedict-1.0.4.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
8
|
+
datedict-1.0.4.dist-info/licenses/LICENSE,sha256=GMmiaNfJSsljmGtIp9lCY6V-Dkc9NDZn0jSZuAbFe7Y,1070
|
|
9
|
+
datedict-1.0.4.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 DEC Energy SA
|
|
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.
|