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
parkingstudy/io.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""CSV readers and writers for inventories, counts, sightings and result tables.
|
|
2
|
+
|
|
3
|
+
Column names (case-insensitive, surrounding spaces ignored):
|
|
4
|
+
|
|
5
|
+
* **inventory**: ``block_face_id``, ``spaces`` (required); ``zone``,
|
|
6
|
+
``space_type``, ``facility``, ``time_limit_min``, ``restriction``,
|
|
7
|
+
``unavailable`` (``"07:00-09:00;16:00-18:00"``), ``hourly_rate``.
|
|
8
|
+
* **counts**: ``block_face_id``, ``beat``, ``occupied`` (required);
|
|
9
|
+
``space_type``; ``date`` (``YYYY-MM-DD``, combined with a clock-time beat).
|
|
10
|
+
* **sightings**: ``block_face_id``, ``beat`` and either ``plate_token``
|
|
11
|
+
(already anonymised) or ``plate`` (raw, hashed on read; a
|
|
12
|
+
:class:`~parkingstudy.privacy.PlateHasher` is then mandatory); optional
|
|
13
|
+
``space_type`` and ``date``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import csv
|
|
19
|
+
import datetime as _dt
|
|
20
|
+
import io
|
|
21
|
+
import json
|
|
22
|
+
import math
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Dict, Iterable, Iterator, List, Mapping, Optional, Sequence, TextIO, Union
|
|
26
|
+
|
|
27
|
+
from ._time import format_beat, parse_beat
|
|
28
|
+
from .errors import DataError, PrivacyError
|
|
29
|
+
from .inventory import Inventory, InventoryRecord
|
|
30
|
+
from .lpr import Sighting
|
|
31
|
+
from .occupancy import Count
|
|
32
|
+
from .privacy import PlateHasher
|
|
33
|
+
|
|
34
|
+
PathLike = Union[str, Path]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _rows(path: PathLike) -> Iterator[Dict[str, str]]:
|
|
38
|
+
with open(path, encoding="utf-8-sig", newline="") as fh:
|
|
39
|
+
reader = csv.DictReader(fh)
|
|
40
|
+
if reader.fieldnames is None:
|
|
41
|
+
return
|
|
42
|
+
names = [(n or "").strip().lower() for n in reader.fieldnames]
|
|
43
|
+
for line_no, raw in enumerate(reader, start=2):
|
|
44
|
+
row = {
|
|
45
|
+
names[i]: (v or "").strip()
|
|
46
|
+
for i, v in enumerate(raw.get(f) for f in reader.fieldnames)
|
|
47
|
+
}
|
|
48
|
+
if not any(row.values()):
|
|
49
|
+
continue
|
|
50
|
+
row["__line__"] = str(line_no)
|
|
51
|
+
yield row
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _req(row: Mapping[str, str], name: str, path: PathLike) -> str:
|
|
55
|
+
value = row.get(name, "")
|
|
56
|
+
if value == "":
|
|
57
|
+
raise DataError(f"{path}:{row.get('__line__')}: missing value for {name!r}")
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _opt_float(row: Mapping[str, str], name: str, path: PathLike) -> Optional[float]:
|
|
62
|
+
value = row.get(name, "")
|
|
63
|
+
if value == "":
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
return float(value)
|
|
67
|
+
except ValueError:
|
|
68
|
+
raise DataError(
|
|
69
|
+
f"{path}:{row.get('__line__')}: {name} is not a number: {value!r}"
|
|
70
|
+
) from None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _int(row: Mapping[str, str], name: str, path: PathLike) -> int:
|
|
74
|
+
value = _req(row, name, path)
|
|
75
|
+
try:
|
|
76
|
+
number = float(value)
|
|
77
|
+
except ValueError:
|
|
78
|
+
raise DataError(
|
|
79
|
+
f"{path}:{row.get('__line__')}: {name} is not a number: {value!r}"
|
|
80
|
+
) from None
|
|
81
|
+
if number != int(number):
|
|
82
|
+
raise DataError(f"{path}:{row.get('__line__')}: {name} must be a whole number")
|
|
83
|
+
return int(number)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _beat(row: Mapping[str, str], path: PathLike) -> _dt.datetime:
|
|
87
|
+
beat = _req(row, "beat", path)
|
|
88
|
+
date = row.get("date", "")
|
|
89
|
+
try:
|
|
90
|
+
if date:
|
|
91
|
+
return parse_beat(f"{date}T{beat}" if len(beat) <= 8 else beat)
|
|
92
|
+
return parse_beat(beat)
|
|
93
|
+
except DataError as exc:
|
|
94
|
+
raise DataError(f"{path}:{row.get('__line__')}: {exc}") from None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def read_inventory(path: PathLike) -> Inventory:
|
|
98
|
+
"""Read an inventory CSV."""
|
|
99
|
+
records = []
|
|
100
|
+
for row in _rows(path):
|
|
101
|
+
try:
|
|
102
|
+
records.append(
|
|
103
|
+
InventoryRecord(
|
|
104
|
+
block_face_id=_req(row, "block_face_id", path),
|
|
105
|
+
spaces=_int(row, "spaces", path),
|
|
106
|
+
zone=row.get("zone", ""),
|
|
107
|
+
space_type=row.get("space_type", "") or "general",
|
|
108
|
+
facility=row.get("facility", "") or "on_street",
|
|
109
|
+
time_limit_min=_opt_float(row, "time_limit_min", path),
|
|
110
|
+
restriction=row.get("restriction", ""),
|
|
111
|
+
unavailable=row.get("unavailable", ""), # type: ignore[arg-type]
|
|
112
|
+
hourly_rate=_opt_float(row, "hourly_rate", path),
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
except DataError as exc:
|
|
116
|
+
if str(path) in str(exc):
|
|
117
|
+
raise
|
|
118
|
+
raise DataError(f"{path}:{row.get('__line__')}: {exc}") from None
|
|
119
|
+
return Inventory(records)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def read_counts(path: PathLike) -> List[Count]:
|
|
123
|
+
"""Read an occupancy count CSV."""
|
|
124
|
+
return [
|
|
125
|
+
Count(
|
|
126
|
+
block_face_id=_req(row, "block_face_id", path),
|
|
127
|
+
beat=_beat(row, path),
|
|
128
|
+
occupied=_int(row, "occupied", path),
|
|
129
|
+
space_type=row.get("space_type", ""),
|
|
130
|
+
)
|
|
131
|
+
for row in _rows(path)
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def read_sightings(
|
|
136
|
+
path: PathLike,
|
|
137
|
+
hasher: Optional[PlateHasher] = None,
|
|
138
|
+
*,
|
|
139
|
+
plate_column: str = "plate",
|
|
140
|
+
token_column: str = "plate_token",
|
|
141
|
+
) -> List[Sighting]:
|
|
142
|
+
"""Read a licence-plate survey CSV, anonymising raw plates on the fly.
|
|
143
|
+
|
|
144
|
+
If the file has a ``token_column`` it is used as is. Otherwise raw plates in
|
|
145
|
+
``plate_column`` are hashed with ``hasher``; without a hasher a
|
|
146
|
+
:class:`~parkingstudy.errors.PrivacyError` is raised. Raw plates are never
|
|
147
|
+
kept in memory beyond the row being read.
|
|
148
|
+
"""
|
|
149
|
+
plate_column = plate_column.lower()
|
|
150
|
+
token_column = token_column.lower()
|
|
151
|
+
out: List[Sighting] = []
|
|
152
|
+
for row in _rows(path):
|
|
153
|
+
token = row.get(token_column, "")
|
|
154
|
+
if not token:
|
|
155
|
+
if plate_column not in row:
|
|
156
|
+
raise DataError(f"{path}: needs a {token_column!r} or {plate_column!r} column")
|
|
157
|
+
if hasher is None:
|
|
158
|
+
raise PrivacyError(
|
|
159
|
+
f"{path} contains raw plates; supply a PlateHasher (salt) to anonymise them"
|
|
160
|
+
)
|
|
161
|
+
token = hasher.hash(_req(row, plate_column, path))
|
|
162
|
+
out.append(
|
|
163
|
+
Sighting(
|
|
164
|
+
plate_token=token,
|
|
165
|
+
block_face_id=_req(row, "block_face_id", path),
|
|
166
|
+
beat=_beat(row, path),
|
|
167
|
+
space_type=row.get("space_type", ""),
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
return out
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def anonymise_csv(
|
|
174
|
+
src: PathLike,
|
|
175
|
+
dst: PathLike,
|
|
176
|
+
hasher: PlateHasher,
|
|
177
|
+
*,
|
|
178
|
+
plate_column: str = "plate",
|
|
179
|
+
token_column: str = "plate_token",
|
|
180
|
+
) -> int:
|
|
181
|
+
"""Copy a CSV replacing the raw plate column with tokens; returns rows written.
|
|
182
|
+
|
|
183
|
+
Column order is kept, with the token column in place of the plate column.
|
|
184
|
+
"""
|
|
185
|
+
with open(src, encoding="utf-8-sig", newline="") as fin:
|
|
186
|
+
reader = csv.DictReader(fin)
|
|
187
|
+
if reader.fieldnames is None or plate_column not in reader.fieldnames:
|
|
188
|
+
raise DataError(f"{src}: no {plate_column!r} column")
|
|
189
|
+
fields = [token_column if f == plate_column else f for f in reader.fieldnames]
|
|
190
|
+
n = 0
|
|
191
|
+
with open(dst, "w", encoding="utf-8", newline="") as fout:
|
|
192
|
+
writer = csv.DictWriter(fout, fieldnames=fields, lineterminator="\n")
|
|
193
|
+
writer.writeheader()
|
|
194
|
+
for row in reader:
|
|
195
|
+
raw = row.pop(plate_column, "") or ""
|
|
196
|
+
if not raw.strip():
|
|
197
|
+
continue
|
|
198
|
+
row[token_column] = hasher.hash(raw)
|
|
199
|
+
writer.writerow(row)
|
|
200
|
+
n += 1
|
|
201
|
+
return n
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _cell(value: Any, digits: Optional[int]) -> Any:
|
|
205
|
+
if isinstance(value, _dt.datetime):
|
|
206
|
+
return format_beat(value)
|
|
207
|
+
if isinstance(value, float):
|
|
208
|
+
if math.isinf(value) or math.isnan(value):
|
|
209
|
+
return None
|
|
210
|
+
return round(value, digits) if digits is not None else value
|
|
211
|
+
return value
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def write_rows(
|
|
215
|
+
rows: Sequence[Mapping[str, Any]],
|
|
216
|
+
target: Union[PathLike, TextIO, None] = None,
|
|
217
|
+
*,
|
|
218
|
+
fmt: str = "csv",
|
|
219
|
+
digits: Optional[int] = 4,
|
|
220
|
+
) -> str:
|
|
221
|
+
"""Write result rows as CSV or JSON to a path, a stream or stdout; return the text."""
|
|
222
|
+
clean = [{k: _cell(v, digits) for k, v in row.items()} for row in rows]
|
|
223
|
+
if fmt == "json":
|
|
224
|
+
text = json.dumps(clean, ensure_ascii=False, indent=1) + "\n"
|
|
225
|
+
elif fmt == "csv":
|
|
226
|
+
fields: List[str] = []
|
|
227
|
+
for row in clean:
|
|
228
|
+
for k in row:
|
|
229
|
+
if k not in fields:
|
|
230
|
+
fields.append(k)
|
|
231
|
+
buf = io.StringIO()
|
|
232
|
+
writer = csv.DictWriter(buf, fieldnames=fields, lineterminator="\n")
|
|
233
|
+
writer.writeheader()
|
|
234
|
+
for row in clean:
|
|
235
|
+
writer.writerow({k: ("" if v is None else v) for k, v in row.items()})
|
|
236
|
+
text = buf.getvalue()
|
|
237
|
+
else:
|
|
238
|
+
raise ValueError("fmt must be 'csv' or 'json'")
|
|
239
|
+
if target is None:
|
|
240
|
+
sys.stdout.write(text)
|
|
241
|
+
elif isinstance(target, (str, Path)):
|
|
242
|
+
with open(target, "w", encoding="utf-8", newline="") as fh:
|
|
243
|
+
fh.write(text)
|
|
244
|
+
else:
|
|
245
|
+
target.write(text)
|
|
246
|
+
return text
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def records_to_rows(items: Iterable[Any]) -> List[Dict[str, Any]]:
|
|
250
|
+
"""Convert result objects with ``as_dict()`` to plain dict rows."""
|
|
251
|
+
return [item.as_dict() for item in items]
|
parkingstudy/lpr.py
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""Licence-plate (beat) surveys: stays, durations, turnover and overstay.
|
|
2
|
+
|
|
3
|
+
In a licence-plate survey, surveyors (or an LPR camera vehicle) pass every
|
|
4
|
+
block face at a fixed interval, the *beat*, and record the plates of the
|
|
5
|
+
parked vehicles. A vehicle seen at consecutive beats on the same block face is
|
|
6
|
+
assumed to have stayed in between. With beat length ``b`` and a vehicle first
|
|
7
|
+
seen at ``t1`` and last seen at ``tn``:
|
|
8
|
+
|
|
9
|
+
* minimum duration = ``tn - t1`` (the vehicle was certainly there that long);
|
|
10
|
+
* estimated duration = ``tn - t1 + b``: each sighting represents one beat
|
|
11
|
+
interval, i.e. ``n * b`` for ``n`` consecutive sightings. This is the usual
|
|
12
|
+
beat-survey convention; the true duration lies in ``(tn - t1, tn - t1 + 2b)``.
|
|
13
|
+
|
|
14
|
+
Stays whose first sighting is on the first beat of a survey session are
|
|
15
|
+
*left-censored* (they may have started earlier); stays seen on the last beat are
|
|
16
|
+
*right-censored*. Both are flagged rather than dropped.
|
|
17
|
+
|
|
18
|
+
Turnover is reported two ways: vehicles per space over the survey period
|
|
19
|
+
(``vehicles / spaces``) and per hour (that value divided by the survey hours).
|
|
20
|
+
|
|
21
|
+
Only anonymous plate tokens are handled here; see :mod:`parkingstudy.privacy`.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import datetime as _dt
|
|
27
|
+
import statistics
|
|
28
|
+
from collections import OrderedDict
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
|
|
31
|
+
|
|
32
|
+
from ._time import TimeLike, format_beat, minutes_between, parse_beat
|
|
33
|
+
from .errors import DataError, PrivacyError
|
|
34
|
+
from .inventory import Inventory, check_grouping
|
|
35
|
+
|
|
36
|
+
#: Levels at which stays can be linked.
|
|
37
|
+
STAY_LEVELS = ("block_face", "zone")
|
|
38
|
+
|
|
39
|
+
#: Bases for overstay assessment.
|
|
40
|
+
OVERSTAY_BASES = ("minimum", "estimate")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Sighting:
|
|
45
|
+
"""One anonymous plate token observed on a block face at a beat."""
|
|
46
|
+
|
|
47
|
+
plate_token: str
|
|
48
|
+
block_face_id: str
|
|
49
|
+
beat: _dt.datetime
|
|
50
|
+
space_type: str = ""
|
|
51
|
+
|
|
52
|
+
def __post_init__(self) -> None:
|
|
53
|
+
if not str(self.plate_token).strip():
|
|
54
|
+
raise PrivacyError("empty plate token")
|
|
55
|
+
object.__setattr__(self, "beat", parse_beat(self.beat))
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def make(
|
|
59
|
+
cls, plate_token: str, block_face_id: str, beat: TimeLike, space_type: str = ""
|
|
60
|
+
) -> Sighting:
|
|
61
|
+
"""Build a sighting, parsing ``beat`` from a string if needed."""
|
|
62
|
+
return cls(plate_token, block_face_id, parse_beat(beat), space_type)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class Stay:
|
|
67
|
+
"""A continuous stay of one vehicle at one location."""
|
|
68
|
+
|
|
69
|
+
plate_token: str
|
|
70
|
+
level: str
|
|
71
|
+
location: str
|
|
72
|
+
first_seen: _dt.datetime
|
|
73
|
+
last_seen: _dt.datetime
|
|
74
|
+
sightings: int
|
|
75
|
+
beat_minutes: float
|
|
76
|
+
left_censored: bool
|
|
77
|
+
right_censored: bool
|
|
78
|
+
time_limit_min: Optional[float] = None
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def minimum_duration_min(self) -> float:
|
|
82
|
+
"""Time between first and last sighting."""
|
|
83
|
+
return minutes_between(self.first_seen, self.last_seen)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def estimated_duration_min(self) -> float:
|
|
87
|
+
"""Minimum duration plus one beat (``n`` sightings x beat when there is no gap)."""
|
|
88
|
+
return self.minimum_duration_min + self.beat_minutes
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def censored(self) -> bool:
|
|
92
|
+
"""Stay touches the start or end of a survey session."""
|
|
93
|
+
return self.left_censored or self.right_censored
|
|
94
|
+
|
|
95
|
+
def overstay(self, basis: str = "minimum") -> Optional[bool]:
|
|
96
|
+
"""Whether the stay exceeds the posted limit (``None`` without a limit).
|
|
97
|
+
|
|
98
|
+
``basis="minimum"`` (default) counts only stays *certainly* longer than
|
|
99
|
+
the limit: a conservative, enforcement-grade test. ``basis="estimate"``
|
|
100
|
+
uses the estimated duration and gives a higher, best-estimate share.
|
|
101
|
+
"""
|
|
102
|
+
if basis not in OVERSTAY_BASES:
|
|
103
|
+
raise ValueError(f"basis must be one of {OVERSTAY_BASES}")
|
|
104
|
+
if self.time_limit_min is None:
|
|
105
|
+
return None
|
|
106
|
+
duration = self.minimum_duration_min if basis == "minimum" else self.estimated_duration_min
|
|
107
|
+
return duration > self.time_limit_min + 1e-9
|
|
108
|
+
|
|
109
|
+
def as_dict(self, include_token: bool = True) -> Dict[str, object]:
|
|
110
|
+
"""Plain-dict form; ``include_token=False`` drops even the anonymous token."""
|
|
111
|
+
row: Dict[str, object] = {}
|
|
112
|
+
if include_token:
|
|
113
|
+
row["plate_token"] = self.plate_token
|
|
114
|
+
row.update(
|
|
115
|
+
{
|
|
116
|
+
"level": self.level,
|
|
117
|
+
"location": self.location,
|
|
118
|
+
"first_seen": format_beat(self.first_seen),
|
|
119
|
+
"last_seen": format_beat(self.last_seen),
|
|
120
|
+
"sightings": self.sightings,
|
|
121
|
+
"minimum_duration_min": self.minimum_duration_min,
|
|
122
|
+
"estimated_duration_min": self.estimated_duration_min,
|
|
123
|
+
"left_censored": self.left_censored,
|
|
124
|
+
"right_censored": self.right_censored,
|
|
125
|
+
"time_limit_min": self.time_limit_min,
|
|
126
|
+
"overstay": self.overstay("minimum"),
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
return row
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class _BeatIndex:
|
|
133
|
+
"""Ordered survey beats split into sessions (days or separate survey periods)."""
|
|
134
|
+
|
|
135
|
+
def __init__(self, beats: Iterable[_dt.datetime], beat_minutes: float) -> None:
|
|
136
|
+
self.beats = sorted(set(beats))
|
|
137
|
+
if not self.beats:
|
|
138
|
+
raise DataError("no beats")
|
|
139
|
+
self.index = {b: i for i, b in enumerate(self.beats)}
|
|
140
|
+
self.session: Dict[_dt.datetime, int] = {}
|
|
141
|
+
self.first_in_session: Dict[int, _dt.datetime] = {}
|
|
142
|
+
self.last_in_session: Dict[int, _dt.datetime] = {}
|
|
143
|
+
sid = 0
|
|
144
|
+
for i, b in enumerate(self.beats):
|
|
145
|
+
if i and minutes_between(self.beats[i - 1], b) > 1.5 * beat_minutes:
|
|
146
|
+
sid += 1
|
|
147
|
+
self.session[b] = sid
|
|
148
|
+
self.first_in_session.setdefault(sid, b)
|
|
149
|
+
self.last_in_session[sid] = b
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def build_stays(
|
|
153
|
+
sightings: Iterable[Sighting],
|
|
154
|
+
beat_minutes: float,
|
|
155
|
+
*,
|
|
156
|
+
inventory: Optional[Inventory] = None,
|
|
157
|
+
level: str = "block_face",
|
|
158
|
+
beats: Optional[Iterable[TimeLike]] = None,
|
|
159
|
+
max_gap_beats: int = 0,
|
|
160
|
+
) -> List[Stay]:
|
|
161
|
+
"""Link sightings of the same token into stays.
|
|
162
|
+
|
|
163
|
+
Parameters
|
|
164
|
+
----------
|
|
165
|
+
sightings:
|
|
166
|
+
Anonymous sightings.
|
|
167
|
+
beat_minutes:
|
|
168
|
+
Survey beat length in minutes.
|
|
169
|
+
inventory:
|
|
170
|
+
Supplies time limits and, for ``level="zone"``, the zone of each face.
|
|
171
|
+
level:
|
|
172
|
+
``"block_face"`` (a move to another face starts a new stay) or ``"zone"``
|
|
173
|
+
(moves within a zone are one stay, e.g. for area-wide time limits).
|
|
174
|
+
beats:
|
|
175
|
+
Full list of survey beat times. Defaults to the beats present in the
|
|
176
|
+
sightings; pass it explicitly when some beats recorded no vehicles.
|
|
177
|
+
max_gap_beats:
|
|
178
|
+
Number of missed beats tolerated inside one stay (default 0: a vehicle
|
|
179
|
+
absent at one beat has left). A tolerance of 1 absorbs occasional
|
|
180
|
+
missed reads at the cost of merging genuine short absences.
|
|
181
|
+
|
|
182
|
+
The time limit of a stay is the limit of the block face (and space type) of
|
|
183
|
+
its first sighting.
|
|
184
|
+
"""
|
|
185
|
+
if level not in STAY_LEVELS:
|
|
186
|
+
raise ValueError(f"level must be one of {STAY_LEVELS}")
|
|
187
|
+
if level == "zone" and inventory is None:
|
|
188
|
+
raise ValueError("level='zone' requires an inventory")
|
|
189
|
+
if beat_minutes <= 0:
|
|
190
|
+
raise ValueError("beat_minutes must be positive")
|
|
191
|
+
if max_gap_beats < 0:
|
|
192
|
+
raise ValueError("max_gap_beats must not be negative")
|
|
193
|
+
items = list(sightings)
|
|
194
|
+
beat_list = [parse_beat(b) for b in beats] if beats is not None else []
|
|
195
|
+
beat_list.extend(s.beat for s in items)
|
|
196
|
+
if not items:
|
|
197
|
+
return []
|
|
198
|
+
idx = _BeatIndex(beat_list, beat_minutes)
|
|
199
|
+
|
|
200
|
+
groups: Dict[Tuple[str, str], List[Sighting]] = OrderedDict()
|
|
201
|
+
for s in items:
|
|
202
|
+
if inventory is not None and s.block_face_id not in inventory:
|
|
203
|
+
raise DataError(f"block face {s.block_face_id!r} is not in the inventory")
|
|
204
|
+
loc = s.block_face_id
|
|
205
|
+
if level == "zone":
|
|
206
|
+
assert inventory is not None
|
|
207
|
+
loc = inventory.zone_of(s.block_face_id)
|
|
208
|
+
groups.setdefault((s.plate_token, loc), []).append(s)
|
|
209
|
+
|
|
210
|
+
max_minutes = (max_gap_beats + 1) * beat_minutes + 0.5 * beat_minutes
|
|
211
|
+
stays: List[Stay] = []
|
|
212
|
+
for (token, loc), group in groups.items():
|
|
213
|
+
group.sort(key=lambda s: s.beat)
|
|
214
|
+
runs: List[List[Sighting]] = []
|
|
215
|
+
for s in group:
|
|
216
|
+
if runs:
|
|
217
|
+
prev = runs[-1][-1]
|
|
218
|
+
if s.beat == prev.beat:
|
|
219
|
+
continue # same vehicle twice at one beat (e.g. two faces in a zone)
|
|
220
|
+
same_session = idx.session[s.beat] == idx.session[prev.beat]
|
|
221
|
+
step = idx.index[s.beat] - idx.index[prev.beat]
|
|
222
|
+
if (
|
|
223
|
+
same_session
|
|
224
|
+
and step <= max_gap_beats + 1
|
|
225
|
+
and minutes_between(prev.beat, s.beat) <= max_minutes
|
|
226
|
+
):
|
|
227
|
+
runs[-1].append(s)
|
|
228
|
+
continue
|
|
229
|
+
runs.append([s])
|
|
230
|
+
for run in runs:
|
|
231
|
+
first, last = run[0], run[-1]
|
|
232
|
+
sid = idx.session[first.beat]
|
|
233
|
+
limit = None
|
|
234
|
+
if inventory is not None:
|
|
235
|
+
limit = inventory.time_limit(first.block_face_id, first.space_type or None)
|
|
236
|
+
stays.append(
|
|
237
|
+
Stay(
|
|
238
|
+
plate_token=token,
|
|
239
|
+
level=level,
|
|
240
|
+
location=loc,
|
|
241
|
+
first_seen=first.beat,
|
|
242
|
+
last_seen=last.beat,
|
|
243
|
+
sightings=len(run),
|
|
244
|
+
beat_minutes=float(beat_minutes),
|
|
245
|
+
left_censored=first.beat == idx.first_in_session[sid],
|
|
246
|
+
right_censored=last.beat == idx.last_in_session[sid],
|
|
247
|
+
time_limit_min=limit,
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
stays.sort(key=lambda st: (st.location, st.first_seen, st.plate_token))
|
|
251
|
+
return stays
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def survey_hours_from_beats(beats: Iterable[TimeLike], beat_minutes: float) -> float:
|
|
255
|
+
"""Survey duration: number of distinct beats x beat length (each beat is one interval)."""
|
|
256
|
+
distinct = {parse_beat(b) for b in beats}
|
|
257
|
+
return len(distinct) * beat_minutes / 60.0
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@dataclass(frozen=True)
|
|
261
|
+
class TurnoverRow:
|
|
262
|
+
"""Turnover and duration statistics for one key."""
|
|
263
|
+
|
|
264
|
+
key: str
|
|
265
|
+
spaces: int
|
|
266
|
+
vehicles: int
|
|
267
|
+
survey_hours: float
|
|
268
|
+
space_hours: float
|
|
269
|
+
mean_duration_min: Optional[float]
|
|
270
|
+
median_duration_min: Optional[float]
|
|
271
|
+
overstays: int
|
|
272
|
+
assessed: int
|
|
273
|
+
censored: int
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def turnover(self) -> Optional[float]:
|
|
277
|
+
"""Vehicles per space over the survey period."""
|
|
278
|
+
return self.vehicles / self.spaces if self.spaces else None
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def turnover_per_hour(self) -> Optional[float]:
|
|
282
|
+
"""Vehicles per space per hour."""
|
|
283
|
+
t = self.turnover
|
|
284
|
+
return t / self.survey_hours if t is not None and self.survey_hours else None
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def utilization(self) -> Optional[float]:
|
|
288
|
+
"""Space-hours used / space-hours supplied (time-averaged occupancy)."""
|
|
289
|
+
supply = self.spaces * self.survey_hours
|
|
290
|
+
return self.space_hours / supply if supply else None
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def overstay_share(self) -> Optional[float]:
|
|
294
|
+
"""Share of time-limited stays that overstayed (``None`` if none limited)."""
|
|
295
|
+
return self.overstays / self.assessed if self.assessed else None
|
|
296
|
+
|
|
297
|
+
def as_dict(self) -> Dict[str, object]:
|
|
298
|
+
"""Plain-dict form."""
|
|
299
|
+
return {
|
|
300
|
+
"key": self.key,
|
|
301
|
+
"spaces": self.spaces,
|
|
302
|
+
"vehicles": self.vehicles,
|
|
303
|
+
"survey_hours": self.survey_hours,
|
|
304
|
+
"turnover": self.turnover,
|
|
305
|
+
"turnover_per_hour": self.turnover_per_hour,
|
|
306
|
+
"mean_duration_min": self.mean_duration_min,
|
|
307
|
+
"median_duration_min": self.median_duration_min,
|
|
308
|
+
"space_hours": self.space_hours,
|
|
309
|
+
"utilization": self.utilization,
|
|
310
|
+
"overstays": self.overstays,
|
|
311
|
+
"assessed_for_overstay": self.assessed,
|
|
312
|
+
"overstay_share": self.overstay_share,
|
|
313
|
+
"censored_stays": self.censored,
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _stay_key(stay: Stay, inventory: Inventory, by: str) -> str:
|
|
318
|
+
if stay.level == "block_face":
|
|
319
|
+
return inventory.key_of(stay.location, by)
|
|
320
|
+
if by == "block_face":
|
|
321
|
+
raise ValueError("zone-level stays cannot be summarised by block_face")
|
|
322
|
+
return stay.location if by == "zone" else "total"
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def turnover_summary(
|
|
326
|
+
stays: Sequence[Stay],
|
|
327
|
+
inventory: Inventory,
|
|
328
|
+
*,
|
|
329
|
+
by: str = "zone",
|
|
330
|
+
survey_hours: Optional[float] = None,
|
|
331
|
+
beats: Optional[Iterable[TimeLike]] = None,
|
|
332
|
+
overstay_basis: str = "minimum",
|
|
333
|
+
) -> List[TurnoverRow]:
|
|
334
|
+
"""Turnover, duration, space-hours and overstay statistics per key.
|
|
335
|
+
|
|
336
|
+
Survey hours come from ``survey_hours``, else from ``beats``
|
|
337
|
+
(:func:`survey_hours_from_beats`), else from the span of the stays
|
|
338
|
+
(first sighting to last sighting plus one beat, a single-session assumption).
|
|
339
|
+
Spaces are the full inventory of each key (part-time windows ignored);
|
|
340
|
+
keys of the inventory without any stay are reported with zero vehicles.
|
|
341
|
+
"""
|
|
342
|
+
check_grouping(by)
|
|
343
|
+
if overstay_basis not in OVERSTAY_BASES:
|
|
344
|
+
raise ValueError(f"overstay_basis must be one of {OVERSTAY_BASES}")
|
|
345
|
+
if survey_hours is None:
|
|
346
|
+
if not stays:
|
|
347
|
+
raise DataError("no stays and no survey_hours/beats given")
|
|
348
|
+
beat = stays[0].beat_minutes
|
|
349
|
+
if beats is not None:
|
|
350
|
+
survey_hours = survey_hours_from_beats(beats, beat)
|
|
351
|
+
else:
|
|
352
|
+
start = min(s.first_seen for s in stays)
|
|
353
|
+
end = max(s.last_seen for s in stays)
|
|
354
|
+
survey_hours = (minutes_between(start, end) + beat) / 60.0
|
|
355
|
+
if survey_hours <= 0:
|
|
356
|
+
raise ValueError("survey_hours must be positive")
|
|
357
|
+
|
|
358
|
+
if by == "block_face":
|
|
359
|
+
keys = inventory.face_ids()
|
|
360
|
+
elif by == "zone":
|
|
361
|
+
keys = inventory.zones()
|
|
362
|
+
else:
|
|
363
|
+
keys = ["total"]
|
|
364
|
+
grouped: Dict[str, List[Stay]] = OrderedDict((k, []) for k in keys)
|
|
365
|
+
for st in stays:
|
|
366
|
+
grouped.setdefault(_stay_key(st, inventory, by), []).append(st)
|
|
367
|
+
|
|
368
|
+
rows: List[TurnoverRow] = []
|
|
369
|
+
for key, group in grouped.items():
|
|
370
|
+
durations = [s.estimated_duration_min for s in group]
|
|
371
|
+
flags = [s.overstay(overstay_basis) for s in group]
|
|
372
|
+
assessed = [f for f in flags if f is not None]
|
|
373
|
+
rows.append(
|
|
374
|
+
TurnoverRow(
|
|
375
|
+
key=key,
|
|
376
|
+
spaces=inventory.capacity_for_key(key, by),
|
|
377
|
+
vehicles=len(group),
|
|
378
|
+
survey_hours=survey_hours,
|
|
379
|
+
space_hours=sum(durations) / 60.0,
|
|
380
|
+
mean_duration_min=statistics.mean(durations) if durations else None,
|
|
381
|
+
median_duration_min=statistics.median(durations) if durations else None,
|
|
382
|
+
overstays=sum(1 for f in assessed if f),
|
|
383
|
+
assessed=len(assessed),
|
|
384
|
+
censored=sum(1 for s in group if s.censored),
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
return rows
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def accumulation_from_stays(
|
|
391
|
+
stays: Iterable[Stay], beats: Iterable[TimeLike]
|
|
392
|
+
) -> List[Tuple[_dt.datetime, int]]:
|
|
393
|
+
"""Vehicles parked at each beat according to the stays (accumulation curve)."""
|
|
394
|
+
stay_list = list(stays)
|
|
395
|
+
times = sorted({parse_beat(b) for b in beats})
|
|
396
|
+
return [(t, sum(1 for s in stay_list if s.first_seen <= t <= s.last_seen)) for t in times]
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def duration_distribution(
|
|
400
|
+
stays: Iterable[Stay], edges_min: Sequence[float] = (30, 60, 120, 180, 240)
|
|
401
|
+
) -> List[Dict[str, object]]:
|
|
402
|
+
"""Frequency table of estimated durations in classes bounded by ``edges_min``.
|
|
403
|
+
|
|
404
|
+
Classes are ``<= e1``, ``(e1, e2]``, ..., ``> ek``.
|
|
405
|
+
"""
|
|
406
|
+
edges = sorted(edges_min)
|
|
407
|
+
durations = [s.estimated_duration_min for s in stays]
|
|
408
|
+
labels = [f"<= {edges[0]:g} min"]
|
|
409
|
+
labels += [f"{a:g}-{b:g} min" for a, b in zip(edges, edges[1:])]
|
|
410
|
+
labels.append(f"> {edges[-1]:g} min")
|
|
411
|
+
counts = [0] * len(labels)
|
|
412
|
+
for d in durations:
|
|
413
|
+
pos = 0
|
|
414
|
+
while pos < len(edges) and d > edges[pos] + 1e-9:
|
|
415
|
+
pos += 1
|
|
416
|
+
counts[pos] += 1
|
|
417
|
+
total = len(durations)
|
|
418
|
+
return [
|
|
419
|
+
{"class": lab, "stays": n, "share": (n / total if total else None)}
|
|
420
|
+
for lab, n in zip(labels, counts)
|
|
421
|
+
]
|