parkingstudy 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.
- parkingstudy/__init__.py +137 -0
- parkingstudy/__main__.py +7 -0
- parkingstudy/_time.py +110 -0
- parkingstudy/_version.py +1 -0
- parkingstudy/cli.py +326 -0
- parkingstudy/errors.py +15 -0
- parkingstudy/geojson.py +111 -0
- parkingstudy/heatmap.py +117 -0
- parkingstudy/inventory.py +248 -0
- parkingstudy/io.py +251 -0
- parkingstudy/lpr.py +421 -0
- parkingstudy/occupancy.py +318 -0
- parkingstudy/pricing.py +217 -0
- parkingstudy/privacy.py +145 -0
- parkingstudy/py.typed +0 -0
- parkingstudy-0.1.0.dist-info/METADATA +232 -0
- parkingstudy-0.1.0.dist-info/RECORD +21 -0
- parkingstudy-0.1.0.dist-info/WHEEL +5 -0
- parkingstudy-0.1.0.dist-info/entry_points.txt +2 -0
- parkingstudy-0.1.0.dist-info/licenses/LICENSE +21 -0
- parkingstudy-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""Occupancy counts by time beat: occupancy rates, peaks, capacity and space-hours.
|
|
2
|
+
|
|
3
|
+
Terminology follows common parking-study practice:
|
|
4
|
+
|
|
5
|
+
* **Occupancy rate** at a beat = vehicles counted / legal spaces available at
|
|
6
|
+
that beat. Illegally parked vehicles may push it above 1.0; this is kept and
|
|
7
|
+
flagged rather than clipped.
|
|
8
|
+
* **Effective capacity** = spaces x target occupancy. The default target of
|
|
9
|
+
85 % is the widely used "85 percent rule" (Shoup, *The High Cost of Free
|
|
10
|
+
Parking*, APA Planners Press, 2005, ch. 12-13): above it drivers start
|
|
11
|
+
cruising for a vacant space. The target is a policy choice and configurable.
|
|
12
|
+
* **Practical capacity** (also called "effective supply") = spaces x an
|
|
13
|
+
efficiency factor per facility type, reflecting the cushion needed for
|
|
14
|
+
turnover, misparking and search. :data:`DEFAULT_PRACTICAL_FACTORS` holds
|
|
15
|
+
commonly quoted planning values (0.85 on-street, 0.90 off-street); they are
|
|
16
|
+
rules of thumb, not a standard, and are fully overridable.
|
|
17
|
+
* **Space-hours** of demand = sum over beats of vehicles x beat length. Supply
|
|
18
|
+
space-hours = sum over beats of available spaces x beat length.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import datetime as _dt
|
|
24
|
+
from collections import OrderedDict
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from typing import Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
|
|
27
|
+
|
|
28
|
+
from ._time import TimeLike, format_beat, minutes_between, parse_beat
|
|
29
|
+
from .errors import DataError
|
|
30
|
+
from .inventory import Inventory, check_grouping
|
|
31
|
+
|
|
32
|
+
#: Default target occupancy (Shoup's "85 percent rule").
|
|
33
|
+
DEFAULT_TARGET = 0.85
|
|
34
|
+
|
|
35
|
+
#: Practical-capacity efficiency factors by facility type. Planning rules of
|
|
36
|
+
#: thumb (not taken from a primary standard): override for your study.
|
|
37
|
+
DEFAULT_PRACTICAL_FACTORS: Mapping[str, float] = {"on_street": 0.85, "off_street": 0.90}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Count:
|
|
42
|
+
"""Vehicles counted on one block face at one beat.
|
|
43
|
+
|
|
44
|
+
``space_type`` restricts the count to one inventory line; leave it empty when
|
|
45
|
+
the whole block face was counted.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
block_face_id: str
|
|
49
|
+
beat: _dt.datetime
|
|
50
|
+
occupied: int
|
|
51
|
+
space_type: str = ""
|
|
52
|
+
|
|
53
|
+
def __post_init__(self) -> None:
|
|
54
|
+
object.__setattr__(self, "beat", parse_beat(self.beat))
|
|
55
|
+
if int(self.occupied) != self.occupied or self.occupied < 0:
|
|
56
|
+
raise DataError(f"{self.block_face_id}: occupied must be a non-negative integer")
|
|
57
|
+
object.__setattr__(self, "occupied", int(self.occupied))
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def make(cls, block_face_id: str, beat: TimeLike, occupied: int, space_type: str = "") -> Count:
|
|
61
|
+
"""Build a count, parsing ``beat`` from a string if needed."""
|
|
62
|
+
return cls(block_face_id, parse_beat(beat), occupied, space_type)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class OccupancyRow:
|
|
67
|
+
"""Aggregated occupancy at one beat for one key (block face, zone or total)."""
|
|
68
|
+
|
|
69
|
+
key: str
|
|
70
|
+
beat: _dt.datetime
|
|
71
|
+
occupied: int
|
|
72
|
+
capacity: int
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def rate(self) -> Optional[float]:
|
|
76
|
+
"""Occupied / capacity, or ``None`` when no space is available."""
|
|
77
|
+
return self.occupied / self.capacity if self.capacity else None
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def over_capacity(self) -> bool:
|
|
81
|
+
"""More vehicles than legal spaces (illegal or double parking)."""
|
|
82
|
+
return self.occupied > self.capacity
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def occupancy_by_beat(
|
|
86
|
+
inventory: Inventory, counts: Iterable[Count], by: str = "block_face"
|
|
87
|
+
) -> List[OccupancyRow]:
|
|
88
|
+
"""Occupancy per beat, aggregated to block faces, zones or the whole study area.
|
|
89
|
+
|
|
90
|
+
Capacity of each count is the inventory capacity of the counted block face
|
|
91
|
+
(or of its ``space_type`` line) *at that beat*, so spaces removed by an
|
|
92
|
+
``unavailable`` window do not inflate the denominator. When aggregating,
|
|
93
|
+
only the block faces actually counted at a beat contribute capacity.
|
|
94
|
+
Rows are sorted by key then beat.
|
|
95
|
+
"""
|
|
96
|
+
check_grouping(by)
|
|
97
|
+
seen = set()
|
|
98
|
+
occ: Dict[Tuple[str, _dt.datetime], List[int]] = OrderedDict()
|
|
99
|
+
for c in counts:
|
|
100
|
+
ident = (c.block_face_id, c.space_type, c.beat)
|
|
101
|
+
if ident in seen:
|
|
102
|
+
raise DataError(f"duplicate count for {c.block_face_id!r} at {c.beat}")
|
|
103
|
+
seen.add(ident)
|
|
104
|
+
cap = inventory.capacity(
|
|
105
|
+
block_face_id=c.block_face_id, space_type=c.space_type or None, at=c.beat
|
|
106
|
+
)
|
|
107
|
+
key = inventory.key_of(c.block_face_id, by)
|
|
108
|
+
acc = occ.setdefault((key, c.beat), [0, 0])
|
|
109
|
+
acc[0] += c.occupied
|
|
110
|
+
acc[1] += cap
|
|
111
|
+
rows = [OccupancyRow(k, b, v[0], v[1]) for (k, b), v in occ.items()]
|
|
112
|
+
rows.sort(key=lambda r: (r.key, r.beat))
|
|
113
|
+
return rows
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def peak_occupancy(
|
|
117
|
+
rows: Iterable[OccupancyRow], basis: str = "vehicles"
|
|
118
|
+
) -> Dict[str, OccupancyRow]:
|
|
119
|
+
"""Peak row per key.
|
|
120
|
+
|
|
121
|
+
``basis="vehicles"`` (default) takes the beat with the most parked vehicles,
|
|
122
|
+
the peak accumulation used for supply/demand comparison; ``basis="rate"``
|
|
123
|
+
takes the highest occupancy rate (they differ when capacity varies by time).
|
|
124
|
+
Ties go to the earliest beat.
|
|
125
|
+
"""
|
|
126
|
+
if basis not in ("vehicles", "rate"):
|
|
127
|
+
raise ValueError("basis must be 'vehicles' or 'rate'")
|
|
128
|
+
best: Dict[str, OccupancyRow] = OrderedDict()
|
|
129
|
+
for row in sorted(rows, key=lambda r: (r.key, r.beat)):
|
|
130
|
+
cur = best.get(row.key)
|
|
131
|
+
if cur is None:
|
|
132
|
+
best[row.key] = row
|
|
133
|
+
continue
|
|
134
|
+
if basis == "vehicles":
|
|
135
|
+
if row.occupied > cur.occupied:
|
|
136
|
+
best[row.key] = row
|
|
137
|
+
else:
|
|
138
|
+
new = -1.0 if row.rate is None else row.rate
|
|
139
|
+
old = -1.0 if cur.rate is None else cur.rate
|
|
140
|
+
if new > old:
|
|
141
|
+
best[row.key] = row
|
|
142
|
+
return best
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def effective_capacity(spaces: float, target: float = DEFAULT_TARGET) -> float:
|
|
146
|
+
"""Spaces that can be occupied while meeting the target occupancy (``spaces * target``)."""
|
|
147
|
+
if not 0 < target <= 1:
|
|
148
|
+
raise ValueError("target must be in (0, 1]")
|
|
149
|
+
if spaces < 0:
|
|
150
|
+
raise ValueError("spaces must not be negative")
|
|
151
|
+
return spaces * target
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def practical_capacity(
|
|
155
|
+
inventory: Inventory,
|
|
156
|
+
*,
|
|
157
|
+
factors: Optional[Mapping[str, float]] = None,
|
|
158
|
+
block_face_id: Optional[str] = None,
|
|
159
|
+
zone: Optional[str] = None,
|
|
160
|
+
at: Optional[_dt.datetime] = None,
|
|
161
|
+
) -> float:
|
|
162
|
+
"""Sum of available spaces x facility efficiency factor.
|
|
163
|
+
|
|
164
|
+
``factors`` maps :attr:`InventoryRecord.facility` to a factor in (0, 1];
|
|
165
|
+
defaults to :data:`DEFAULT_PRACTICAL_FACTORS`.
|
|
166
|
+
"""
|
|
167
|
+
table = DEFAULT_PRACTICAL_FACTORS if factors is None else factors
|
|
168
|
+
total = 0.0
|
|
169
|
+
for rec in inventory.select(block_face_id=block_face_id, zone=zone):
|
|
170
|
+
if rec.facility not in table:
|
|
171
|
+
raise DataError(
|
|
172
|
+
f"no practical-capacity factor for facility {rec.facility!r}; pass factors="
|
|
173
|
+
)
|
|
174
|
+
factor = table[rec.facility]
|
|
175
|
+
if not 0 < factor <= 1:
|
|
176
|
+
raise ValueError("practical-capacity factors must be in (0, 1]")
|
|
177
|
+
total += rec.available_at(at) * factor
|
|
178
|
+
return total
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True)
|
|
182
|
+
class CapacityAssessment:
|
|
183
|
+
"""Peak demand against effective and practical capacity for one key."""
|
|
184
|
+
|
|
185
|
+
key: str
|
|
186
|
+
spaces: int
|
|
187
|
+
peak_beat: _dt.datetime
|
|
188
|
+
peak_occupied: int
|
|
189
|
+
peak_rate: Optional[float]
|
|
190
|
+
target: float
|
|
191
|
+
effective_capacity: float
|
|
192
|
+
practical_capacity: float
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def surplus(self) -> float:
|
|
196
|
+
"""Effective capacity minus peak vehicles (negative = deficit)."""
|
|
197
|
+
return self.effective_capacity - self.peak_occupied
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def above_target(self) -> bool:
|
|
201
|
+
"""Peak occupancy exceeds the target occupancy."""
|
|
202
|
+
return self.peak_occupied > self.effective_capacity + 1e-9
|
|
203
|
+
|
|
204
|
+
def as_dict(self) -> Dict[str, object]:
|
|
205
|
+
"""Plain-dict form for CSV / JSON output."""
|
|
206
|
+
return {
|
|
207
|
+
"key": self.key,
|
|
208
|
+
"spaces": self.spaces,
|
|
209
|
+
"peak_beat": format_beat(self.peak_beat),
|
|
210
|
+
"peak_occupied": self.peak_occupied,
|
|
211
|
+
"peak_rate": self.peak_rate,
|
|
212
|
+
"target": self.target,
|
|
213
|
+
"effective_capacity": self.effective_capacity,
|
|
214
|
+
"practical_capacity": self.practical_capacity,
|
|
215
|
+
"surplus": self.surplus,
|
|
216
|
+
"above_target": self.above_target,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def capacity_assessment(
|
|
221
|
+
inventory: Inventory,
|
|
222
|
+
counts: Iterable[Count],
|
|
223
|
+
*,
|
|
224
|
+
by: str = "zone",
|
|
225
|
+
target: float = DEFAULT_TARGET,
|
|
226
|
+
factors: Optional[Mapping[str, float]] = None,
|
|
227
|
+
) -> List[CapacityAssessment]:
|
|
228
|
+
"""Compare peak accumulation with effective and practical capacity per key.
|
|
229
|
+
|
|
230
|
+
Capacity values refer to the spaces available at the peak beat.
|
|
231
|
+
"""
|
|
232
|
+
rows = occupancy_by_beat(inventory, counts, by=by)
|
|
233
|
+
out: List[CapacityAssessment] = []
|
|
234
|
+
for key, peak in peak_occupancy(rows, basis="vehicles").items():
|
|
235
|
+
if by == "block_face":
|
|
236
|
+
prac = practical_capacity(inventory, factors=factors, block_face_id=key, at=peak.beat)
|
|
237
|
+
elif by == "zone":
|
|
238
|
+
prac = practical_capacity(inventory, factors=factors, zone=key, at=peak.beat)
|
|
239
|
+
else:
|
|
240
|
+
prac = practical_capacity(inventory, factors=factors, at=peak.beat)
|
|
241
|
+
out.append(
|
|
242
|
+
CapacityAssessment(
|
|
243
|
+
key=key,
|
|
244
|
+
spaces=peak.capacity,
|
|
245
|
+
peak_beat=peak.beat,
|
|
246
|
+
peak_occupied=peak.occupied,
|
|
247
|
+
peak_rate=peak.rate,
|
|
248
|
+
target=target,
|
|
249
|
+
effective_capacity=effective_capacity(peak.capacity, target),
|
|
250
|
+
practical_capacity=prac,
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
return out
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def accumulation_curve(
|
|
257
|
+
inventory: Inventory, counts: Iterable[Count], by: str = "total"
|
|
258
|
+
) -> Dict[str, List[Tuple[_dt.datetime, int]]]:
|
|
259
|
+
"""Parking accumulation (vehicles parked) through the survey, per key."""
|
|
260
|
+
curve: Dict[str, List[Tuple[_dt.datetime, int]]] = OrderedDict()
|
|
261
|
+
for row in occupancy_by_beat(inventory, counts, by=by):
|
|
262
|
+
curve.setdefault(row.key, []).append((row.beat, row.occupied))
|
|
263
|
+
return curve
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def infer_beat_minutes(beats: Iterable[_dt.datetime]) -> float:
|
|
267
|
+
"""Survey beat length: the smallest positive gap between distinct beat times."""
|
|
268
|
+
times = sorted(set(beats))
|
|
269
|
+
if len(times) < 2:
|
|
270
|
+
raise DataError("at least two distinct beats are needed to infer the beat length")
|
|
271
|
+
return min(minutes_between(a, b) for a, b in zip(times, times[1:]))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@dataclass(frozen=True)
|
|
275
|
+
class SpaceHours:
|
|
276
|
+
"""Space-hour demand and supply for one key."""
|
|
277
|
+
|
|
278
|
+
key: str
|
|
279
|
+
demand: float
|
|
280
|
+
supply: float
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def utilization(self) -> Optional[float]:
|
|
284
|
+
"""Demand / supply space-hours (time-averaged occupancy)."""
|
|
285
|
+
return self.demand / self.supply if self.supply else None
|
|
286
|
+
|
|
287
|
+
def as_dict(self) -> Dict[str, object]:
|
|
288
|
+
"""Plain-dict form."""
|
|
289
|
+
return {
|
|
290
|
+
"key": self.key,
|
|
291
|
+
"demand_space_hours": self.demand,
|
|
292
|
+
"supply_space_hours": self.supply,
|
|
293
|
+
"utilization": self.utilization,
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def space_hours(
|
|
298
|
+
inventory: Inventory,
|
|
299
|
+
counts: Sequence[Count],
|
|
300
|
+
*,
|
|
301
|
+
by: str = "zone",
|
|
302
|
+
beat_minutes: Optional[float] = None,
|
|
303
|
+
) -> List[SpaceHours]:
|
|
304
|
+
"""Space-hours of demand and supply, each beat representing ``beat_minutes``.
|
|
305
|
+
|
|
306
|
+
``beat_minutes`` defaults to :func:`infer_beat_minutes` over the counts.
|
|
307
|
+
"""
|
|
308
|
+
if beat_minutes is None:
|
|
309
|
+
beat_minutes = infer_beat_minutes(c.beat for c in counts)
|
|
310
|
+
if beat_minutes <= 0:
|
|
311
|
+
raise ValueError("beat_minutes must be positive")
|
|
312
|
+
hours = beat_minutes / 60.0
|
|
313
|
+
acc: Dict[str, List[float]] = OrderedDict()
|
|
314
|
+
for row in occupancy_by_beat(inventory, counts, by=by):
|
|
315
|
+
a = acc.setdefault(row.key, [0.0, 0.0])
|
|
316
|
+
a[0] += row.occupied * hours
|
|
317
|
+
a[1] += row.capacity * hours
|
|
318
|
+
return [SpaceHours(k, v[0], v[1]) for k, v in acc.items()]
|
parkingstudy/pricing.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Demand-responsive pricing recommendations from occupancy bands.
|
|
2
|
+
|
|
3
|
+
Two ready-made policies are provided; every number is a configurable policy
|
|
4
|
+
parameter, so build your own :class:`PricingPolicy` for local currency, steps,
|
|
5
|
+
thresholds and caps.
|
|
6
|
+
|
|
7
|
+
:data:`SFMTA_POLICY` (default) follows SFMTA, *Demand-responsive parking pricing
|
|
8
|
+
policies: on-street parking meters and off-street lots* (5 December 2017),
|
|
9
|
+
section "On-street metered parking and metered lots":
|
|
10
|
+
|
|
11
|
+
* occupancy 80 % or above: hourly rate raised by 0.25;
|
|
12
|
+
* 60 % or above but below 80 %: not changed;
|
|
13
|
+
* below 60 %: lowered by 0.25;
|
|
14
|
+
* minimum rate 0.50 per hour, maximum 8.00 per hour (special events excluded),
|
|
15
|
+
changes no more often than once per four weeks.
|
|
16
|
+
|
|
17
|
+
:data:`SFPARK_PILOT_POLICY` reproduces the four-band table commonly reported for
|
|
18
|
+
the 2011-2013 SFpark pilot (80-100 %: +0.25, 60-80 %: hold, 30-60 %: -0.25,
|
|
19
|
+
below 30 %: -0.50, cap 6.00 per hour). It was taken from secondary summaries of
|
|
20
|
+
the SFMTA pilot evaluation, not checked against the primary table, and its
|
|
21
|
+
0.25 floor is an assumption.
|
|
22
|
+
|
|
23
|
+
Band boundaries are lower-inclusive (80 % falls in the "raise" band), as in the
|
|
24
|
+
SFMTA wording "80 percent or above".
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import math
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from typing import Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
|
|
32
|
+
|
|
33
|
+
from .errors import DataError
|
|
34
|
+
from .occupancy import OccupancyRow
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class PriceBand:
|
|
39
|
+
"""Occupancy band ``[lower, upper)`` and the rate change applied in it."""
|
|
40
|
+
|
|
41
|
+
lower: float
|
|
42
|
+
upper: float
|
|
43
|
+
change: float
|
|
44
|
+
label: str = ""
|
|
45
|
+
|
|
46
|
+
def contains(self, occupancy: float) -> bool:
|
|
47
|
+
"""Whether ``occupancy`` lies in ``[lower, upper)``."""
|
|
48
|
+
return self.lower <= occupancy < self.upper
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class PricingPolicy:
|
|
53
|
+
"""Band table plus rate floor, cap and rounding increment.
|
|
54
|
+
|
|
55
|
+
Bands must be sorted, contiguous and cover ``[0, inf)``; make the last band
|
|
56
|
+
open-ended (``upper=math.inf``) so occupancies above 100 % (illegal parking)
|
|
57
|
+
still match.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
bands: Tuple[PriceBand, ...]
|
|
61
|
+
min_rate: float = 0.0
|
|
62
|
+
max_rate: float = math.inf
|
|
63
|
+
rounding: float = 0.01
|
|
64
|
+
name: str = "custom"
|
|
65
|
+
|
|
66
|
+
def __post_init__(self) -> None:
|
|
67
|
+
bands = tuple(sorted(self.bands, key=lambda b: b.lower))
|
|
68
|
+
if not bands:
|
|
69
|
+
raise ValueError("a pricing policy needs at least one band")
|
|
70
|
+
if bands[0].lower > 0:
|
|
71
|
+
raise ValueError("the first band must start at 0")
|
|
72
|
+
for a, b in zip(bands, bands[1:]):
|
|
73
|
+
if not math.isclose(a.upper, b.lower):
|
|
74
|
+
raise ValueError(f"bands must be contiguous ({a.upper} != {b.lower})")
|
|
75
|
+
if not math.isinf(bands[-1].upper):
|
|
76
|
+
raise ValueError("the last band must be open-ended (upper=math.inf)")
|
|
77
|
+
if self.min_rate < 0 or self.max_rate < self.min_rate:
|
|
78
|
+
raise ValueError("need 0 <= min_rate <= max_rate")
|
|
79
|
+
if self.rounding <= 0:
|
|
80
|
+
raise ValueError("rounding must be positive")
|
|
81
|
+
object.__setattr__(self, "bands", bands)
|
|
82
|
+
|
|
83
|
+
def band_for(self, occupancy: float) -> PriceBand:
|
|
84
|
+
"""Band containing an occupancy rate (0-1 scale)."""
|
|
85
|
+
if occupancy < 0 or math.isnan(occupancy):
|
|
86
|
+
raise DataError(f"invalid occupancy {occupancy!r}")
|
|
87
|
+
for band in self.bands:
|
|
88
|
+
if band.contains(occupancy):
|
|
89
|
+
return band
|
|
90
|
+
raise DataError(f"occupancy {occupancy!r} not covered by any band") # pragma: no cover
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
#: SFMTA on-street meter policy of 5 December 2017 (verified against the SFMTA PDF).
|
|
94
|
+
SFMTA_POLICY = PricingPolicy(
|
|
95
|
+
bands=(
|
|
96
|
+
PriceBand(0.0, 0.60, -0.25, "below 60%: lower"),
|
|
97
|
+
PriceBand(0.60, 0.80, 0.0, "60-80%: hold"),
|
|
98
|
+
PriceBand(0.80, math.inf, 0.25, "80% or above: raise"),
|
|
99
|
+
),
|
|
100
|
+
min_rate=0.50,
|
|
101
|
+
max_rate=8.00,
|
|
102
|
+
rounding=0.01,
|
|
103
|
+
name="SFMTA demand-responsive meter pricing (2017)",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
#: SFpark pilot four-band table (secondary sources; floor assumed, see module docstring).
|
|
107
|
+
SFPARK_PILOT_POLICY = PricingPolicy(
|
|
108
|
+
bands=(
|
|
109
|
+
PriceBand(0.0, 0.30, -0.50, "below 30%: lower 0.50"),
|
|
110
|
+
PriceBand(0.30, 0.60, -0.25, "30-60%: lower 0.25"),
|
|
111
|
+
PriceBand(0.60, 0.80, 0.0, "60-80%: hold"),
|
|
112
|
+
PriceBand(0.80, math.inf, 0.25, "80% or above: raise"),
|
|
113
|
+
),
|
|
114
|
+
min_rate=0.25, # assumption, not verified
|
|
115
|
+
max_rate=6.00,
|
|
116
|
+
rounding=0.01,
|
|
117
|
+
name="SFpark pilot (2011-2013), as reported",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
#: Named policies for the CLI.
|
|
121
|
+
POLICIES: Dict[str, PricingPolicy] = {"sfmta": SFMTA_POLICY, "sfpark-pilot": SFPARK_PILOT_POLICY}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class PriceRecommendation:
|
|
126
|
+
"""Recommended new hourly rate for one key."""
|
|
127
|
+
|
|
128
|
+
key: str
|
|
129
|
+
occupancy: float
|
|
130
|
+
current_rate: float
|
|
131
|
+
new_rate: float
|
|
132
|
+
band: str
|
|
133
|
+
clamped: bool = field(default=False)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def change(self) -> float:
|
|
137
|
+
"""``new_rate - current_rate``."""
|
|
138
|
+
return round(self.new_rate - self.current_rate, 10)
|
|
139
|
+
|
|
140
|
+
def as_dict(self) -> Dict[str, object]:
|
|
141
|
+
"""Plain-dict form."""
|
|
142
|
+
return {
|
|
143
|
+
"key": self.key,
|
|
144
|
+
"occupancy": self.occupancy,
|
|
145
|
+
"current_rate": self.current_rate,
|
|
146
|
+
"new_rate": self.new_rate,
|
|
147
|
+
"change": self.change,
|
|
148
|
+
"band": self.band,
|
|
149
|
+
"clamped": self.clamped,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _round_to(value: float, step: float) -> float:
|
|
154
|
+
return round(round(value / step) * step, 10)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def recommend_rate(
|
|
158
|
+
occupancy: float,
|
|
159
|
+
current_rate: float,
|
|
160
|
+
policy: PricingPolicy = SFMTA_POLICY,
|
|
161
|
+
key: str = "",
|
|
162
|
+
) -> PriceRecommendation:
|
|
163
|
+
"""Apply the band rule to one occupancy rate (0-1 scale) and current hourly rate."""
|
|
164
|
+
if current_rate < 0:
|
|
165
|
+
raise DataError("current_rate must not be negative")
|
|
166
|
+
band = policy.band_for(occupancy)
|
|
167
|
+
raw = current_rate + band.change
|
|
168
|
+
clamped_value = min(max(raw, policy.min_rate), policy.max_rate)
|
|
169
|
+
new_rate = _round_to(clamped_value, policy.rounding)
|
|
170
|
+
new_rate = min(max(new_rate, policy.min_rate), policy.max_rate)
|
|
171
|
+
return PriceRecommendation(
|
|
172
|
+
key=key,
|
|
173
|
+
occupancy=occupancy,
|
|
174
|
+
current_rate=current_rate,
|
|
175
|
+
new_rate=new_rate,
|
|
176
|
+
band=band.label,
|
|
177
|
+
clamped=not math.isclose(raw, clamped_value),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def recommend_rates(
|
|
182
|
+
occupancy: Mapping[str, Optional[float]],
|
|
183
|
+
rates: Mapping[str, Optional[float]],
|
|
184
|
+
policy: PricingPolicy = SFMTA_POLICY,
|
|
185
|
+
default_rate: Optional[float] = None,
|
|
186
|
+
) -> List[PriceRecommendation]:
|
|
187
|
+
"""Recommendations for many keys.
|
|
188
|
+
|
|
189
|
+
Keys whose occupancy is ``None`` or whose rate is missing (and no
|
|
190
|
+
``default_rate``) are skipped.
|
|
191
|
+
"""
|
|
192
|
+
out: List[PriceRecommendation] = []
|
|
193
|
+
for key, occ in occupancy.items():
|
|
194
|
+
rate = rates.get(key)
|
|
195
|
+
if rate is None:
|
|
196
|
+
rate = default_rate
|
|
197
|
+
if occ is None or rate is None:
|
|
198
|
+
continue
|
|
199
|
+
out.append(recommend_rate(occ, rate, policy, key=key))
|
|
200
|
+
return out
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def mean_occupancy(
|
|
204
|
+
rows: Iterable[OccupancyRow], keys: Optional[Sequence[str]] = None
|
|
205
|
+
) -> Dict[str, Optional[float]]:
|
|
206
|
+
"""Pooled occupancy (sum of vehicles / sum of spaces) per key from occupancy rows.
|
|
207
|
+
|
|
208
|
+
``rows`` are :class:`~parkingstudy.occupancy.OccupancyRow` objects, typically
|
|
209
|
+
pre-filtered to the pricing time band (e.g. weekday 09:00-12:00).
|
|
210
|
+
"""
|
|
211
|
+
acc: Dict[str, List[int]] = {}
|
|
212
|
+
for r in rows:
|
|
213
|
+
a = acc.setdefault(r.key, [0, 0])
|
|
214
|
+
a[0] += r.occupied
|
|
215
|
+
a[1] += r.capacity
|
|
216
|
+
order = list(keys) if keys is not None else list(acc)
|
|
217
|
+
return {k: (acc[k][0] / acc[k][1] if k in acc and acc[k][1] else None) for k in order}
|
parkingstudy/privacy.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Anonymisation of licence plates.
|
|
2
|
+
|
|
3
|
+
Licence plates are personal data in most jurisdictions (for example under the
|
|
4
|
+
EU GDPR and the Turkish KVKK, Law No. 6698). Turnover analysis only needs to
|
|
5
|
+
know that two sightings belong to the *same* vehicle, so plates are replaced
|
|
6
|
+
on input by a keyed hash (HMAC-SHA256, RFC 2104) computed with a secret salt.
|
|
7
|
+
|
|
8
|
+
* The same plate and salt always give the same token, so stays can be linked.
|
|
9
|
+
* Without the salt the token cannot be reversed by brute-forcing the small
|
|
10
|
+
space of valid plates, which an unsalted hash would allow.
|
|
11
|
+
* Use a fresh salt per survey and destroy it afterwards to make tokens
|
|
12
|
+
unlinkable across surveys.
|
|
13
|
+
|
|
14
|
+
Nothing in this package stores or writes raw plates: :class:`~parkingstudy.lpr.Sighting`
|
|
15
|
+
holds only the token, and the CSV readers refuse a raw plate column unless a
|
|
16
|
+
:class:`PlateHasher` is supplied.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import hmac
|
|
23
|
+
import os
|
|
24
|
+
import secrets
|
|
25
|
+
import unicodedata
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Dict, Iterable, Iterator, Mapping, Optional, Union
|
|
28
|
+
|
|
29
|
+
from .errors import PrivacyError
|
|
30
|
+
|
|
31
|
+
#: Environment variable read by :meth:`PlateHasher.from_env`.
|
|
32
|
+
SALT_ENV_VAR = "PARKINGSTUDY_SALT"
|
|
33
|
+
|
|
34
|
+
#: Minimum salt length in bytes (128 bits).
|
|
35
|
+
MIN_SALT_BYTES = 16
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def generate_salt(nbytes: int = 32) -> str:
|
|
39
|
+
"""Return a new random salt as a hex string (``nbytes`` of entropy)."""
|
|
40
|
+
if nbytes < MIN_SALT_BYTES:
|
|
41
|
+
raise ValueError(f"salt must be at least {MIN_SALT_BYTES} bytes")
|
|
42
|
+
return secrets.token_hex(nbytes)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def normalize_plate(raw: str) -> str:
|
|
46
|
+
"""Canonical form of a plate before hashing.
|
|
47
|
+
|
|
48
|
+
Unicode NFKC normalisation, upper-casing and removal of every character that
|
|
49
|
+
is not a letter or digit, so ``"34 abc-123"`` and ``"34ABC123"`` give the same
|
|
50
|
+
token. Returns an empty string for blank input.
|
|
51
|
+
"""
|
|
52
|
+
text = unicodedata.normalize("NFKC", str(raw)).upper()
|
|
53
|
+
return "".join(ch for ch in text if ch.isalnum())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PlateHasher:
|
|
57
|
+
"""Keyed (salted) hashing of licence plates with HMAC-SHA256.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
salt:
|
|
62
|
+
Secret key, ``str`` or ``bytes``, at least 16 bytes long. Generate one
|
|
63
|
+
with :func:`generate_salt` and keep it out of version control.
|
|
64
|
+
length:
|
|
65
|
+
Number of hex characters kept from the digest (8-64, default 16 = 64
|
|
66
|
+
bits, ample to avoid collisions within one survey).
|
|
67
|
+
prefix:
|
|
68
|
+
Text prepended to every token, so tokens are recognisable as such.
|
|
69
|
+
|
|
70
|
+
The salt is never shown by ``repr`` or ``str``.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, salt: Union[str, bytes], *, length: int = 16, prefix: str = "v_") -> None:
|
|
74
|
+
key = salt.encode("utf-8") if isinstance(salt, str) else bytes(salt)
|
|
75
|
+
if len(key) < MIN_SALT_BYTES:
|
|
76
|
+
raise PrivacyError(
|
|
77
|
+
f"salt is too short ({len(key)} bytes); use at least {MIN_SALT_BYTES} bytes, "
|
|
78
|
+
"e.g. parkingstudy.generate_salt()"
|
|
79
|
+
)
|
|
80
|
+
if not 8 <= length <= 64:
|
|
81
|
+
raise ValueError("length must be between 8 and 64 hex characters")
|
|
82
|
+
self._key = key
|
|
83
|
+
self.length = length
|
|
84
|
+
self.prefix = prefix
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_env(cls, var: str = SALT_ENV_VAR, **kwargs: object) -> PlateHasher:
|
|
88
|
+
"""Build a hasher from the salt stored in an environment variable."""
|
|
89
|
+
value = os.environ.get(var)
|
|
90
|
+
if not value:
|
|
91
|
+
raise PrivacyError(f"environment variable {var} is not set or empty")
|
|
92
|
+
return cls(value, **kwargs) # type: ignore[arg-type]
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def from_file(cls, path: Union[str, Path], **kwargs: object) -> PlateHasher:
|
|
96
|
+
"""Build a hasher from the first line of a salt file."""
|
|
97
|
+
value = Path(path).read_text(encoding="utf-8").strip()
|
|
98
|
+
return cls(value, **kwargs) # type: ignore[arg-type]
|
|
99
|
+
|
|
100
|
+
def hash(self, raw_plate: str) -> str:
|
|
101
|
+
"""Return the anonymous token for a plate."""
|
|
102
|
+
plate = normalize_plate(raw_plate)
|
|
103
|
+
if not plate:
|
|
104
|
+
raise PrivacyError("empty licence plate cannot be hashed")
|
|
105
|
+
digest = hmac.new(self._key, plate.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
106
|
+
return self.prefix + digest[: self.length]
|
|
107
|
+
|
|
108
|
+
__call__ = hash
|
|
109
|
+
|
|
110
|
+
def __repr__(self) -> str:
|
|
111
|
+
return f"PlateHasher(salt=<hidden>, length={self.length}, prefix={self.prefix!r})"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def anonymise_rows(
|
|
115
|
+
rows: Iterable[Mapping[str, str]],
|
|
116
|
+
hasher: PlateHasher,
|
|
117
|
+
*,
|
|
118
|
+
plate_column: str = "plate",
|
|
119
|
+
token_column: str = "plate_token",
|
|
120
|
+
) -> Iterator[Dict[str, str]]:
|
|
121
|
+
"""Yield copies of ``rows`` with ``plate_column`` replaced by ``token_column``.
|
|
122
|
+
|
|
123
|
+
The raw plate column is dropped from every output row.
|
|
124
|
+
"""
|
|
125
|
+
for row in rows:
|
|
126
|
+
if plate_column not in row:
|
|
127
|
+
raise PrivacyError(f"plate column {plate_column!r} not found")
|
|
128
|
+
out = {k: v for k, v in row.items() if k != plate_column}
|
|
129
|
+
out[token_column] = hasher.hash(row[plate_column] or "")
|
|
130
|
+
yield out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def resolve_hasher(
|
|
134
|
+
salt: Optional[str] = None,
|
|
135
|
+
salt_file: Optional[Union[str, Path]] = None,
|
|
136
|
+
salt_env: Optional[str] = None,
|
|
137
|
+
) -> Optional[PlateHasher]:
|
|
138
|
+
"""Helper for front ends: build a hasher from whichever salt source is given."""
|
|
139
|
+
if salt is not None:
|
|
140
|
+
return PlateHasher(salt)
|
|
141
|
+
if salt_file is not None:
|
|
142
|
+
return PlateHasher.from_file(salt_file)
|
|
143
|
+
if salt_env is not None:
|
|
144
|
+
return PlateHasher.from_env(salt_env)
|
|
145
|
+
return None
|
parkingstudy/py.typed
ADDED
|
File without changes
|