rangekeeper 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.
@@ -0,0 +1,220 @@
1
+ from __future__ import annotations
2
+
3
+ import enum
4
+ import math
5
+ from typing import List, Dict, Union, Tuple
6
+ from collections import namedtuple
7
+
8
+ import pandas as pd
9
+ import numpy as np
10
+ from aenum import Enum
11
+
12
+
13
+ class Characteristic(Enum):
14
+ _init_ = 'value'
15
+
16
+ use = 'Use'
17
+ tenure = 'Tenure'
18
+ span = 'Span'
19
+ type = 'Type'
20
+
21
+
22
+ class Type:
23
+ name: str
24
+ code: str
25
+ supertype: Type
26
+ subtypes: [Type]
27
+
28
+ def __init__(
29
+ self,
30
+ name: str,
31
+ code: str = None,
32
+ supertype: Type = None):
33
+ self.name = name
34
+ self.code = code
35
+ self.supertype = supertype
36
+ self.subtypes = []
37
+
38
+ if supertype is not None:
39
+ supertype.subtypes.append(self)
40
+
41
+ def __str__(self):
42
+ code = ' :' + self.code if self.code is not None else ''
43
+ return self.name + code
44
+
45
+ def add_subtypes(
46
+ self,
47
+ subtypes: [Type]):
48
+ for subtype in subtypes:
49
+ self.subtypes.append(subtype)
50
+ subtype.supertype = self
51
+
52
+ def ancestors(self) -> [Type]:
53
+ ancestors = []
54
+ supertype = self.supertype
55
+ while supertype is not None:
56
+ ancestors.append(supertype)
57
+ supertype = supertype.supertype
58
+ return ancestors
59
+
60
+ def primogenitor(self) -> Type:
61
+ return self.ancestors()[-1]
62
+
63
+ def display(self):
64
+ print(' > '.join([ancestor.__str__() for ancestor in self.ancestors()]))
65
+
66
+
67
+ class Interval(pd.Interval):
68
+ def __init__(
69
+ self,
70
+ right: float,
71
+ left: float = 0.):
72
+ super().__init__(
73
+ left=left,
74
+ right=right)
75
+
76
+ def __str__(self):
77
+ return f'({self.left}, {self.right})'
78
+
79
+ def split(
80
+ self,
81
+ proportion: float) -> tuple[Interval, Interval]:
82
+ if proportion < 0 or proportion > 1:
83
+ raise ValueError('Error: proportion must be between 0 and 1')
84
+
85
+ split_point = self.left + (proportion * self.length)
86
+ left_interval = self.__class__(
87
+ right=split_point,
88
+ left=self.left)
89
+ right_interval = self.__class__(
90
+ right=self.right,
91
+ left=split_point)
92
+ return left_interval, right_interval
93
+
94
+ def subdivide(
95
+ self,
96
+ values: Union[int, List[float]]) -> List[Interval]:
97
+ if isinstance(values, int):
98
+ if values < 0:
99
+ raise ValueError('Error: divisors must be greater than 0')
100
+ lengths = [self.length / values for i in range(values)]
101
+ elif all(isinstance(fraction, float) for fraction in values):
102
+ if math.isclose(sum(values), 1) and all(0 < fraction < 1 for fraction in values):
103
+ lengths = [self.length * fraction for fraction in values]
104
+ else:
105
+ raise ValueError('Error: Sum of divisors must complete unit interval')
106
+ else:
107
+ raise TypeError('Error: values must be either number of divisors or split proportions')
108
+
109
+ parameters = [self.left + sum(lengths[:i]) for i in range(1, len(lengths) + 1)]
110
+ parameters.insert(0, self.left)
111
+ results = []
112
+ for i in range(len(parameters) - 1):
113
+ results.append(
114
+ self.__class__(
115
+ left=parameters[i],
116
+ right=parameters[i + 1]))
117
+ return results
118
+
119
+ def interval_value(self):
120
+ return self.right - self.left
121
+
122
+
123
+ class Segment:
124
+ name: str
125
+ bounds: Interval
126
+ parent: Segment
127
+ children: List[Segment]
128
+ characteristics: Dict[Characteristic, str]
129
+
130
+ def __init__(
131
+ self,
132
+ bounds: Interval,
133
+ characteristics: Dict[Characteristic, str] = None,
134
+ parent: Segment = None,
135
+ children: List[Segment] = None,
136
+ name: str = None):
137
+ self.name = name
138
+ self.bounds = bounds
139
+ self.characteristics = {} if characteristics is None else characteristics
140
+
141
+ if parent is not None:
142
+ parent.children.append(self)
143
+ self.parent = parent
144
+
145
+ if children is not None: # Need to run tests on this to check this works.
146
+ for child in children:
147
+ child.parent = self
148
+ self.children = [] if children is None else children
149
+
150
+ def to_frame(self):
151
+ frame = pd.DataFrame(
152
+ data=[list(self.characteristics.values())],
153
+ index=pd.IntervalIndex(data=[self.bounds]),
154
+ columns=[characteristic.value for characteristic in self.characteristics.keys()])
155
+ frame['Amount'] = self.bounds.length
156
+
157
+ if self.parent is not None:
158
+ frame['Proportion (of Parent)'] = "{:.0%}".format(self.bounds.length / self.parent.bounds.length)
159
+
160
+ return frame
161
+
162
+ def display(self):
163
+ print('\n')
164
+ floatfmt = "." + str(2) + "f"
165
+ print(self.to_frame().to_markdown(
166
+ tablefmt='github',
167
+ floatfmt=floatfmt))
168
+
169
+ def display_children(
170
+ self,
171
+ pivot: Characteristic = None,
172
+ decimals: int = 2,
173
+ ):
174
+ if len(self.children) == 0:
175
+ raise ValueError("Error: Segment has no children")
176
+
177
+ frame = pd.concat([child.to_frame() for child in self.children])
178
+ pivot_name = ''
179
+ if pivot is not None:
180
+ frame = pd.pivot_table(
181
+ data=frame,
182
+ index=[pivot.value],
183
+ aggfunc=np.sum
184
+ # columns=['Amount', 'Use', 'Proportion (of Parent)']
185
+ )
186
+ frame['Proportion (of Parent)'] = frame['Amount'] / self.bounds.length
187
+ frame['Proportion (of Parent)'] = frame['Proportion (of Parent)'].map("{:.0%}".format)
188
+ pivot_name = ' by ' + pivot.value
189
+
190
+ floatfmt = "." + str(decimals) + "f"
191
+
192
+ print('Children' + pivot_name)
193
+ print(frame.to_markdown(
194
+ tablefmt='github',
195
+ floatfmt=floatfmt))
196
+
197
+ # def display_hierarchy(self):
198
+
199
+ def split(self, proportion: float) -> tuple[Segment, Segment]:
200
+ split_lower, split_upper = self.bounds.split(proportion)
201
+ lower = Segment(
202
+ bounds=split_lower,
203
+ parent=self)
204
+ upper = Segment(
205
+ bounds=split_upper,
206
+ parent=self)
207
+ return (lower, upper)
208
+
209
+ def subdivide(
210
+ self,
211
+ divisions: List[Tuple[str, Dict[Characteristic, str], float]]) -> List[Segment]:
212
+ result = []
213
+ intervals = self.bounds.subdivide([division[2] for division in divisions])
214
+ for name, division, interval in zip([division[0] for division in divisions],[division[1] for division in divisions], intervals):
215
+ result.append(Segment(
216
+ name=name,
217
+ bounds=interval,
218
+ characteristics=division,
219
+ parent=self))
220
+ return result
rangekeeper/setup.py ADDED
@@ -0,0 +1,14 @@
1
+ from setuptools import setup
2
+
3
+ setup(
4
+ name='Rangekeeper',
5
+ version='0.2.0-alpha',
6
+ packages=['models', 'dynamics'],
7
+ package_dir={'': 'rangekeeper'},
8
+ url='https://github.com/daniel-fink/rangekeeper',
9
+ license='MPL-2.0',
10
+ author='Daniel Fink',
11
+ author_email='danfink@mit.edu',
12
+ description='Rangekeeper is a library assisting financial modelling in real estate scenario planning, decision-making, cashflow forecasting, and the like.'
13
+ )
14
+
rangekeeper/space.py ADDED
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+ from typing import Dict, List, Union, Optional
3
+ from decimal import Decimal
4
+ from pint import Quantity
5
+
6
+ import rangekeeper as rk
7
+
8
+
9
+ class Space(rk.graph.Element):
10
+ def __init__(
11
+ self,
12
+ name: str,
13
+ type: str,
14
+ measurements: Dict[rk.measure.Measure, Quantity] = None,
15
+ events: List[rk.graph.Event] = None,
16
+ attributes: Dict = None):
17
+ super().__init__(
18
+ name=name,
19
+ type=type,
20
+ measurements=measurements,
21
+ events=events,
22
+ attributes=attributes)
23
+
24
+
25
+ class Apartment(Space):
26
+ num_bed: int
27
+ num_bath: Decimal
28
+ num_balcony: int
29
+
30
+ def __init__(
31
+ self,
32
+ name: str,
33
+ type: str,
34
+ num_bed: int,
35
+ num_bath: Union[int, Decimal],
36
+ num_balcony: Optional[int] = None,
37
+ measurements: Dict[rk.measure.Measure, Quantity] = None,
38
+ attributes: Dict = None):
39
+ super().__init__(name, type, measurements, attributes)
40
+ self.num_bed = num_bed
41
+ self.num_bath = num_bath
42
+ self.num_balcony = num_balcony
rangekeeper/span.py ADDED
@@ -0,0 +1,226 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+
6
+ import rangekeeper as rk
7
+
8
+ class Span:
9
+ start_date: pd.Timestamp
10
+ end_date: pd.Timestamp
11
+ name: str = None
12
+ """
13
+ A `Span` is a pd.Interval of pd.Timestamps that bound its start and end dates
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ start_date: pd.Timestamp,
19
+ end_date: pd.Timestamp = None,
20
+ name: str = None):
21
+ """
22
+ Define a Span using dates and a name. If no end date is provided, it is
23
+ assumed to be a one-day Span (ie, the start and end dates are the same)
24
+ :param start_date:
25
+ :param end_date:
26
+ :param name:
27
+ """
28
+ if end_date is None:
29
+ end_date = start_date
30
+ elif end_date < start_date:
31
+ raise Exception('Error: end_date cannot be before start_date')
32
+
33
+ if name is None:
34
+ self.name = ''
35
+ else:
36
+ self.name = name
37
+
38
+ self._interval = pd.Interval(left=start_date, right=end_date)
39
+ self.start_date = self._interval.left
40
+ self.end_date = self._interval.right
41
+
42
+ def __str__(self):
43
+ return 'Span: {}\n' \
44
+ 'Start Date: {}\n' \
45
+ 'End Date: {}'.format(self.name, self.start_date.date(), self.end_date.date())
46
+
47
+ def __repr__(self):
48
+ return self.__str__()
49
+
50
+ @classmethod
51
+ def merge(
52
+ cls,
53
+ name: str,
54
+ spans: [Span]) -> Span:
55
+ """
56
+ Merge a set of Spans into a single Span
57
+ """
58
+ start_date = spans[0].start_date
59
+ end_date = spans[0].end_date
60
+ for span in spans:
61
+ if span.start_date < start_date:
62
+ start_date = span.start_date
63
+ if span.end_date > end_date:
64
+ end_date = span.end_date
65
+ return cls(name=name,
66
+ start_date=start_date,
67
+ end_date=end_date)
68
+
69
+ def shift(self,
70
+ num_periods: int,
71
+ period_type: rk.periodicity.Type,
72
+ bound: str = 'end',
73
+ name: str = None) -> Span:
74
+ """
75
+ Shift the Span's bounds by a specified period increment
76
+
77
+ :param num_periods: The number of periods to shift the bounds
78
+ :param period_type: The period type of the shift
79
+ :param bound: The bound to shift. Options are 'start', 'end', or 'both'
80
+ """
81
+ name = self.name if name is None else name
82
+ if bound == 'start':
83
+ return Span(
84
+ name=name,
85
+ start_date=rk.periodicity.offset_date(
86
+ date=self.start_date,
87
+ period_type=period_type,
88
+ num_periods=num_periods),
89
+ end_date=self.end_date)
90
+ elif bound == 'end':
91
+ return Span(
92
+ name=name,
93
+ start_date=self.start_date,
94
+ end_date=rk.periodicity.offset_date(
95
+ date=self.end_date,
96
+ period_type=period_type,
97
+ num_periods=num_periods))
98
+ elif bound == 'both':
99
+ return Span(
100
+ name=name,
101
+ start_date=rk.periodicity.offset_date(
102
+ date=self.start_date,
103
+ period_type=period_type,
104
+ num_periods=num_periods),
105
+ end_date=rk.periodicity.offset_date(
106
+ date=self.end_date,
107
+ period_type=period_type,
108
+ num_periods=num_periods))
109
+ else:
110
+ raise Exception('Error: invalid bound specified')
111
+
112
+ def to_index(
113
+ self,
114
+ period_type: rk.periodicity.Type) -> pd.PeriodIndex:
115
+ """
116
+ Return a pd.PeriodIndex of the Span at the specified periodicity
117
+ """
118
+ return rk.periodicity.period_index(
119
+ include_start=self.start_date,
120
+ period_type=period_type,
121
+ bound=self.end_date)
122
+
123
+ def duration(
124
+ self,
125
+ period_type: rk.periodicity.Type,
126
+ inclusive: bool = False) -> int:
127
+ """
128
+ Return the whole-period duration of the Span in the specified period_type
129
+ """
130
+ return rk.periodicity.duration(
131
+ start_date=self.start_date,
132
+ end_date=self.end_date,
133
+ period_type=period_type,
134
+ inclusive=inclusive)
135
+
136
+ @classmethod
137
+ def from_num_periods(
138
+ cls,
139
+ name: str,
140
+ date: pd.Timestamp,
141
+ period_type: rk.periodicity.Type,
142
+ num_periods: int) -> Span:
143
+ """
144
+ Create a Span from a date with a set number of periods of specified type.
145
+ If the number of periods is negative, the Span will end on the date specified.
146
+ If the number of periods is positive, the Span will start on the date specified.
147
+ """
148
+ if num_periods < 0:
149
+ end_date = date
150
+ start_date = rk.periodicity.offset_date(
151
+ date=date,
152
+ period_type=period_type,
153
+ num_periods=num_periods)
154
+ start_date = rk.periodicity.offset_date(
155
+ date=start_date,
156
+ period_type=rk.periodicity.Type.DAY,
157
+ num_periods=1)
158
+ return cls(name=name, start_date=start_date, end_date=end_date)
159
+ else:
160
+ start_date = date
161
+ end_date = rk.periodicity.offset_date(
162
+ date=date,
163
+ period_type=period_type,
164
+ num_periods=num_periods)
165
+ end_date = rk.periodicity.offset_date(
166
+ date=end_date,
167
+ period_type=rk.periodicity.Type.DAY,
168
+ num_periods=-1)
169
+ return cls(name=name, start_date=start_date, end_date=end_date)
170
+
171
+ @classmethod
172
+ def from_date_sequence(
173
+ cls,
174
+ names: [str],
175
+ dates: [pd.Timestamp]) -> [Span]:
176
+ """
177
+ Create a set of Spans from a sequence of dates.
178
+ Note: dates list length must be 1 longer than names
179
+ :param names:
180
+ :type names:
181
+ :param dates:
182
+ :type dates:
183
+ :return:
184
+ :rtype:
185
+ """
186
+ if len(names) != len(dates) - 1:
187
+ raise Exception('Error: number of Span names must equal number of Spans created'
188
+ ' (i.e. one less than number of dates)')
189
+
190
+ date_pairs = list(zip(dates, dates[1:]))
191
+ date_pairs = [(start, rk.periodicity.offset_date(end, rk.periodicity.Type.DAY, -1)) for (start, end) in date_pairs]
192
+
193
+ spans = []
194
+ for i in range(len(names)):
195
+ spans.append(cls(name=names[i], start_date=date_pairs[i][0], end_date=date_pairs[i][1]))
196
+ return spans
197
+
198
+ @classmethod
199
+ def from_num_periods_sequence(
200
+ cls,
201
+ names: [str],
202
+ period_type: rk.periodicity.Type,
203
+ durations: [int],
204
+ start_date: pd.Timestamp) -> [Span]:
205
+ """
206
+ Create a set of Spans from a sequence of durations of specified period type
207
+ :param names:
208
+ :type names:
209
+ :param period_type:
210
+ :type period_type:
211
+ :param durations:
212
+ :type durations:
213
+ :param start_date:
214
+ :type start_date:
215
+ :return:
216
+ :rtype:
217
+ """
218
+ if len(names) != len(durations):
219
+ raise Exception('Error: number of Span names must equal number of Spans')
220
+
221
+ dates = []
222
+ cumulative_durations = np.cumsum(durations)
223
+ for duration in cumulative_durations:
224
+ dates.append(rk.periodicity.offset_date(date=start_date, period_type=period_type, num_periods=duration))
225
+
226
+ return cls.from_date_sequence(names=names, dates=dates)