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/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
+ ]