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/geojson.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Join survey results to block-face geometries in GeoJSON (RFC 7946)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, Union
|
|
10
|
+
|
|
11
|
+
from .errors import DataError, PrivacyError
|
|
12
|
+
|
|
13
|
+
#: Property names that must never be written to an output map.
|
|
14
|
+
_FORBIDDEN_PROPERTIES = ("plate", "plate_number", "licence_plate", "license_plate", "plaka")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class JoinResult:
|
|
19
|
+
"""Outcome of :func:`join_to_geojson`."""
|
|
20
|
+
|
|
21
|
+
geojson: Dict[str, Any]
|
|
22
|
+
matched: int
|
|
23
|
+
unmatched_features: Tuple[str, ...]
|
|
24
|
+
unmatched_keys: Tuple[str, ...]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_geojson(path: Union[str, Path]) -> Dict[str, Any]:
|
|
28
|
+
"""Read a GeoJSON FeatureCollection."""
|
|
29
|
+
with open(path, encoding="utf-8-sig") as fh:
|
|
30
|
+
data = json.load(fh)
|
|
31
|
+
if not isinstance(data, dict) or data.get("type") != "FeatureCollection":
|
|
32
|
+
raise DataError(f"{path}: not a GeoJSON FeatureCollection")
|
|
33
|
+
return data
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def write_geojson(data: Mapping[str, Any], path: Union[str, Path]) -> None:
|
|
37
|
+
"""Write GeoJSON as UTF-8."""
|
|
38
|
+
with open(path, "w", encoding="utf-8", newline="\n") as fh:
|
|
39
|
+
json.dump(data, fh, ensure_ascii=False, indent=1)
|
|
40
|
+
fh.write("\n")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def rows_by_key(
|
|
44
|
+
rows: Iterable[Mapping[str, Any]], key_field: str = "key"
|
|
45
|
+
) -> Dict[str, Dict[str, Any]]:
|
|
46
|
+
"""Index result rows (e.g. ``TurnoverRow.as_dict()``) by their key field.
|
|
47
|
+
|
|
48
|
+
The key field itself is dropped from the indexed dicts.
|
|
49
|
+
"""
|
|
50
|
+
out: Dict[str, Dict[str, Any]] = {}
|
|
51
|
+
for row in rows:
|
|
52
|
+
if key_field not in row:
|
|
53
|
+
raise DataError(f"result row has no {key_field!r} field")
|
|
54
|
+
key = str(row[key_field])
|
|
55
|
+
if key in out:
|
|
56
|
+
raise DataError(f"duplicate result key {key!r}")
|
|
57
|
+
out[key] = {k: v for k, v in row.items() if k != key_field}
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def join_to_geojson(
|
|
62
|
+
feature_collection: Mapping[str, Any],
|
|
63
|
+
results: Mapping[str, Mapping[str, Any]],
|
|
64
|
+
*,
|
|
65
|
+
id_property: str = "block_face_id",
|
|
66
|
+
prefix: str = "",
|
|
67
|
+
keep_unmatched: bool = True,
|
|
68
|
+
) -> JoinResult:
|
|
69
|
+
"""Attach result attributes to features whose ``id_property`` matches a result key.
|
|
70
|
+
|
|
71
|
+
The input collection is not modified. Feature ids are compared as strings;
|
|
72
|
+
the feature-level ``id`` member is used when ``id_property`` is ``"id"`` and
|
|
73
|
+
no such property exists. Unmatched features are kept (with no added
|
|
74
|
+
attributes) unless ``keep_unmatched=False``. Result attributes that look like
|
|
75
|
+
raw licence plates are refused.
|
|
76
|
+
"""
|
|
77
|
+
if feature_collection.get("type") != "FeatureCollection":
|
|
78
|
+
raise DataError("expected a GeoJSON FeatureCollection")
|
|
79
|
+
for attrs in results.values():
|
|
80
|
+
for name in attrs:
|
|
81
|
+
if name.lower() in _FORBIDDEN_PROPERTIES:
|
|
82
|
+
raise PrivacyError(f"refusing to write attribute {name!r} to a map")
|
|
83
|
+
features_out: List[Dict[str, Any]] = []
|
|
84
|
+
unmatched_features: List[str] = []
|
|
85
|
+
used = set()
|
|
86
|
+
for feature in feature_collection.get("features", []):
|
|
87
|
+
feat = copy.deepcopy(feature)
|
|
88
|
+
props = feat.get("properties") or {}
|
|
89
|
+
raw_id: Optional[Any] = props.get(id_property)
|
|
90
|
+
if raw_id is None and id_property == "id":
|
|
91
|
+
raw_id = feat.get("id")
|
|
92
|
+
fid = None if raw_id is None else str(raw_id)
|
|
93
|
+
if fid is not None and fid in results:
|
|
94
|
+
used.add(fid)
|
|
95
|
+
props = dict(props)
|
|
96
|
+
for name, value in results[fid].items():
|
|
97
|
+
props[prefix + name] = value
|
|
98
|
+
feat["properties"] = props
|
|
99
|
+
features_out.append(feat)
|
|
100
|
+
else:
|
|
101
|
+
unmatched_features.append("" if fid is None else fid)
|
|
102
|
+
if keep_unmatched:
|
|
103
|
+
features_out.append(feat)
|
|
104
|
+
out = {k: copy.deepcopy(v) for k, v in feature_collection.items() if k != "features"}
|
|
105
|
+
out["features"] = features_out
|
|
106
|
+
return JoinResult(
|
|
107
|
+
geojson=out,
|
|
108
|
+
matched=len(used),
|
|
109
|
+
unmatched_features=tuple(unmatched_features),
|
|
110
|
+
unmatched_keys=tuple(k for k in results if k not in used),
|
|
111
|
+
)
|
parkingstudy/heatmap.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Utilization heatmap tables: occupancy rate by key (zone / block face) x time of day."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Dict, Iterable, List, Optional, TextIO, Tuple, Union
|
|
9
|
+
|
|
10
|
+
from ._time import minute_of_day
|
|
11
|
+
from .inventory import Inventory
|
|
12
|
+
from .occupancy import Count, occupancy_by_beat
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class HeatmapTable:
|
|
17
|
+
"""Occupancy rates arranged as rows (keys) x columns (time-of-day bins).
|
|
18
|
+
|
|
19
|
+
``values[i][j]`` is the pooled occupancy rate of ``row_keys[i]`` in bin
|
|
20
|
+
``column_labels[j]``, or ``None`` when that cell had no counts or no supply.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
by: str
|
|
24
|
+
bin_minutes: int
|
|
25
|
+
row_keys: Tuple[str, ...]
|
|
26
|
+
column_labels: Tuple[str, ...]
|
|
27
|
+
values: Tuple[Tuple[Optional[float], ...], ...]
|
|
28
|
+
|
|
29
|
+
def get(self, key: str, label: str) -> Optional[float]:
|
|
30
|
+
"""Cell value by row key and column label."""
|
|
31
|
+
return self.values[self.row_keys.index(key)][self.column_labels.index(label)]
|
|
32
|
+
|
|
33
|
+
def max_cell(self) -> Optional[Tuple[str, str, float]]:
|
|
34
|
+
"""``(key, label, rate)`` of the highest cell, or ``None`` for an empty table."""
|
|
35
|
+
best: Optional[Tuple[str, str, float]] = None
|
|
36
|
+
for key, row in zip(self.row_keys, self.values):
|
|
37
|
+
for label, v in zip(self.column_labels, row):
|
|
38
|
+
if v is not None and (best is None or v > best[2]):
|
|
39
|
+
best = (key, label, v)
|
|
40
|
+
return best
|
|
41
|
+
|
|
42
|
+
def to_rows(self, digits: Optional[int] = 3) -> List[Dict[str, object]]:
|
|
43
|
+
"""One dict per key, columns named by bin start (``"08:00"``)."""
|
|
44
|
+
rows: List[Dict[str, object]] = []
|
|
45
|
+
for key, vals in zip(self.row_keys, self.values):
|
|
46
|
+
row: Dict[str, object] = {"key": key}
|
|
47
|
+
for label, v in zip(self.column_labels, vals):
|
|
48
|
+
row[label] = None if v is None else (round(v, digits) if digits is not None else v)
|
|
49
|
+
rows.append(row)
|
|
50
|
+
return rows
|
|
51
|
+
|
|
52
|
+
def to_csv(self, target: Union[str, TextIO, None] = None, digits: Optional[int] = 3) -> str:
|
|
53
|
+
"""Write the table as CSV to a path or stream and return the CSV text."""
|
|
54
|
+
buf = io.StringIO()
|
|
55
|
+
writer = csv.writer(buf, lineterminator="\n")
|
|
56
|
+
writer.writerow(["key", *self.column_labels])
|
|
57
|
+
for row in self.to_rows(digits):
|
|
58
|
+
writer.writerow(
|
|
59
|
+
["" if row[c] is None else row[c] for c in ["key", *self.column_labels]]
|
|
60
|
+
)
|
|
61
|
+
text = buf.getvalue()
|
|
62
|
+
if isinstance(target, str):
|
|
63
|
+
with open(target, "w", encoding="utf-8", newline="") as fh:
|
|
64
|
+
fh.write(text)
|
|
65
|
+
elif target is not None:
|
|
66
|
+
target.write(text)
|
|
67
|
+
return text
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _label(minute: int) -> str:
|
|
71
|
+
return f"{minute // 60:02d}:{minute % 60:02d}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def utilization_heatmap(
|
|
75
|
+
inventory: Inventory,
|
|
76
|
+
counts: Iterable[Count],
|
|
77
|
+
*,
|
|
78
|
+
by: str = "zone",
|
|
79
|
+
bin_minutes: int = 60,
|
|
80
|
+
) -> HeatmapTable:
|
|
81
|
+
"""Pooled occupancy rate for each key and time-of-day bin.
|
|
82
|
+
|
|
83
|
+
Each cell is the sum of vehicles over the sum of available spaces for all
|
|
84
|
+
beats (on all survey days) whose time of day falls in the bin, which weights
|
|
85
|
+
beats by supply rather than averaging rates. Columns run contiguously from
|
|
86
|
+
the earliest to the latest bin that has data.
|
|
87
|
+
"""
|
|
88
|
+
if bin_minutes <= 0 or 1440 % bin_minutes:
|
|
89
|
+
raise ValueError("bin_minutes must be a positive divisor of 1440")
|
|
90
|
+
rows = occupancy_by_beat(inventory, counts, by=by)
|
|
91
|
+
acc: Dict[Tuple[str, int], List[int]] = {}
|
|
92
|
+
keys: List[str] = []
|
|
93
|
+
for r in rows:
|
|
94
|
+
if r.key not in keys:
|
|
95
|
+
keys.append(r.key)
|
|
96
|
+
b = int(minute_of_day(r.beat) // bin_minutes) * bin_minutes
|
|
97
|
+
a = acc.setdefault((r.key, b), [0, 0])
|
|
98
|
+
a[0] += r.occupied
|
|
99
|
+
a[1] += r.capacity
|
|
100
|
+
if not acc:
|
|
101
|
+
return HeatmapTable(by, bin_minutes, (), (), ())
|
|
102
|
+
bins_present = [b for _, b in acc]
|
|
103
|
+
bins = list(range(min(bins_present), max(bins_present) + bin_minutes, bin_minutes))
|
|
104
|
+
values = []
|
|
105
|
+
for key in keys:
|
|
106
|
+
line: List[Optional[float]] = []
|
|
107
|
+
for b in bins:
|
|
108
|
+
cell = acc.get((key, b))
|
|
109
|
+
line.append(cell[0] / cell[1] if cell and cell[1] else None)
|
|
110
|
+
values.append(tuple(line))
|
|
111
|
+
return HeatmapTable(
|
|
112
|
+
by=by,
|
|
113
|
+
bin_minutes=bin_minutes,
|
|
114
|
+
row_keys=tuple(keys),
|
|
115
|
+
column_labels=tuple(_label(b) for b in bins),
|
|
116
|
+
values=tuple(values),
|
|
117
|
+
)
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Parking supply inventory: spaces by block face, zone and space type."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as _dt
|
|
6
|
+
from collections import OrderedDict
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Dict, Iterable, Iterator, List, Optional, Sequence, Set, Tuple
|
|
9
|
+
|
|
10
|
+
from ._time import Window, in_windows, minute_of_day, parse_windows
|
|
11
|
+
from .errors import DataError
|
|
12
|
+
|
|
13
|
+
#: Aggregation levels accepted by the ``by=`` arguments throughout the package.
|
|
14
|
+
GROUPINGS = ("block_face", "zone", "total")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def check_grouping(by: str) -> str:
|
|
18
|
+
"""Validate a ``by=`` argument."""
|
|
19
|
+
if by not in GROUPINGS:
|
|
20
|
+
raise ValueError(f"by must be one of {GROUPINGS}, got {by!r}")
|
|
21
|
+
return by
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class InventoryRecord:
|
|
26
|
+
"""One inventory line: a group of identical spaces on one block face.
|
|
27
|
+
|
|
28
|
+
A block face with several space types (general, disabled, loading, ...) is
|
|
29
|
+
described by several records sharing ``block_face_id``.
|
|
30
|
+
|
|
31
|
+
Attributes
|
|
32
|
+
----------
|
|
33
|
+
block_face_id:
|
|
34
|
+
Identifier of the block face (one side of a street between two
|
|
35
|
+
intersections) or of an off-street facility.
|
|
36
|
+
spaces:
|
|
37
|
+
Number of legal spaces. For unmarked kerb this is the kerb length
|
|
38
|
+
divided by a design stall length chosen by the analyst.
|
|
39
|
+
zone:
|
|
40
|
+
Study zone or sub-area the block face belongs to.
|
|
41
|
+
space_type:
|
|
42
|
+
Free label such as ``general``, ``disabled``, ``loading``, ``ev``.
|
|
43
|
+
facility:
|
|
44
|
+
``on_street`` or ``off_street`` (used for practical-capacity factors).
|
|
45
|
+
time_limit_min:
|
|
46
|
+
Posted maximum stay in minutes, or ``None`` when unrestricted.
|
|
47
|
+
restriction:
|
|
48
|
+
Free-text description of the regulation (e.g. ``"2h pay 08-19 Mon-Sat"``).
|
|
49
|
+
unavailable:
|
|
50
|
+
Time-of-day windows during which the spaces may not be used at all
|
|
51
|
+
(clearways, peak-hour no-parking, street cleaning), as ``(start, end)``
|
|
52
|
+
minutes after midnight or a string ``"07:00-09:00;16:00-18:00"``.
|
|
53
|
+
hourly_rate:
|
|
54
|
+
Current hourly price, used by the pricing rules.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
block_face_id: str
|
|
58
|
+
spaces: int
|
|
59
|
+
zone: str = ""
|
|
60
|
+
space_type: str = "general"
|
|
61
|
+
facility: str = "on_street"
|
|
62
|
+
time_limit_min: Optional[float] = None
|
|
63
|
+
restriction: str = ""
|
|
64
|
+
unavailable: Tuple[Window, ...] = field(default=())
|
|
65
|
+
hourly_rate: Optional[float] = None
|
|
66
|
+
|
|
67
|
+
def __post_init__(self) -> None:
|
|
68
|
+
if not str(self.block_face_id).strip():
|
|
69
|
+
raise DataError("block_face_id must not be empty")
|
|
70
|
+
if int(self.spaces) != self.spaces or self.spaces < 0:
|
|
71
|
+
raise DataError(f"{self.block_face_id}: spaces must be a non-negative integer")
|
|
72
|
+
if self.time_limit_min is not None and self.time_limit_min <= 0:
|
|
73
|
+
raise DataError(f"{self.block_face_id}: time_limit_min must be positive")
|
|
74
|
+
if self.hourly_rate is not None and self.hourly_rate < 0:
|
|
75
|
+
raise DataError(f"{self.block_face_id}: hourly_rate must not be negative")
|
|
76
|
+
object.__setattr__(self, "spaces", int(self.spaces))
|
|
77
|
+
object.__setattr__(self, "unavailable", parse_windows(self.unavailable))
|
|
78
|
+
|
|
79
|
+
def available_at(self, when: Optional[_dt.datetime] = None) -> int:
|
|
80
|
+
"""Legal spaces at a moment: 0 inside an ``unavailable`` window."""
|
|
81
|
+
if when is not None and self.unavailable:
|
|
82
|
+
if in_windows(minute_of_day(when), self.unavailable):
|
|
83
|
+
return 0
|
|
84
|
+
return self.spaces
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Inventory:
|
|
88
|
+
"""Collection of :class:`InventoryRecord` with lookup and aggregation helpers."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, records: Iterable[InventoryRecord]) -> None:
|
|
91
|
+
self._records: List[InventoryRecord] = list(records)
|
|
92
|
+
self._by_face: Dict[str, List[InventoryRecord]] = OrderedDict()
|
|
93
|
+
seen: Set[Tuple[str, str]] = set()
|
|
94
|
+
for rec in self._records:
|
|
95
|
+
key = (rec.block_face_id, rec.space_type)
|
|
96
|
+
if key in seen:
|
|
97
|
+
raise DataError(
|
|
98
|
+
f"duplicate inventory line for block face {rec.block_face_id!r}, "
|
|
99
|
+
f"space type {rec.space_type!r}"
|
|
100
|
+
)
|
|
101
|
+
seen.add(key)
|
|
102
|
+
rows = self._by_face.setdefault(rec.block_face_id, [])
|
|
103
|
+
if rows and rows[0].zone != rec.zone:
|
|
104
|
+
raise DataError(f"block face {rec.block_face_id!r} is assigned to several zones")
|
|
105
|
+
rows.append(rec)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def records(self) -> Tuple[InventoryRecord, ...]:
|
|
109
|
+
"""All records in input order."""
|
|
110
|
+
return tuple(self._records)
|
|
111
|
+
|
|
112
|
+
def __len__(self) -> int:
|
|
113
|
+
return len(self._records)
|
|
114
|
+
|
|
115
|
+
def __iter__(self) -> Iterator[InventoryRecord]:
|
|
116
|
+
return iter(self._records)
|
|
117
|
+
|
|
118
|
+
def __contains__(self, block_face_id: object) -> bool:
|
|
119
|
+
return block_face_id in self._by_face
|
|
120
|
+
|
|
121
|
+
def face_ids(self) -> List[str]:
|
|
122
|
+
"""Block face identifiers in input order."""
|
|
123
|
+
return list(self._by_face)
|
|
124
|
+
|
|
125
|
+
def zones(self) -> List[str]:
|
|
126
|
+
"""Zone names in input order."""
|
|
127
|
+
return list(OrderedDict.fromkeys(rec.zone for rec in self._records))
|
|
128
|
+
|
|
129
|
+
def zone_of(self, block_face_id: str) -> str:
|
|
130
|
+
"""Zone of a block face."""
|
|
131
|
+
return self._face(block_face_id)[0].zone
|
|
132
|
+
|
|
133
|
+
def key_of(self, block_face_id: str, by: str) -> str:
|
|
134
|
+
"""Aggregation key of a block face for ``by`` in :data:`GROUPINGS`."""
|
|
135
|
+
check_grouping(by)
|
|
136
|
+
if by == "block_face":
|
|
137
|
+
self._face(block_face_id)
|
|
138
|
+
return block_face_id
|
|
139
|
+
if by == "zone":
|
|
140
|
+
return self.zone_of(block_face_id)
|
|
141
|
+
return "total"
|
|
142
|
+
|
|
143
|
+
def _face(self, block_face_id: str) -> List[InventoryRecord]:
|
|
144
|
+
try:
|
|
145
|
+
return self._by_face[block_face_id]
|
|
146
|
+
except KeyError:
|
|
147
|
+
raise DataError(f"block face {block_face_id!r} is not in the inventory") from None
|
|
148
|
+
|
|
149
|
+
def select(
|
|
150
|
+
self,
|
|
151
|
+
*,
|
|
152
|
+
block_face_id: Optional[str] = None,
|
|
153
|
+
zone: Optional[str] = None,
|
|
154
|
+
space_type: Optional[str] = None,
|
|
155
|
+
) -> List[InventoryRecord]:
|
|
156
|
+
"""Records matching every filter that is not ``None``."""
|
|
157
|
+
candidates: Iterable[InventoryRecord]
|
|
158
|
+
if block_face_id is not None:
|
|
159
|
+
candidates = self._face(block_face_id)
|
|
160
|
+
else:
|
|
161
|
+
candidates = self._records
|
|
162
|
+
return [
|
|
163
|
+
rec
|
|
164
|
+
for rec in candidates
|
|
165
|
+
if (zone is None or rec.zone == zone)
|
|
166
|
+
and (space_type is None or rec.space_type == space_type)
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
def capacity(
|
|
170
|
+
self,
|
|
171
|
+
*,
|
|
172
|
+
block_face_id: Optional[str] = None,
|
|
173
|
+
zone: Optional[str] = None,
|
|
174
|
+
space_type: Optional[str] = None,
|
|
175
|
+
at: Optional[_dt.datetime] = None,
|
|
176
|
+
) -> int:
|
|
177
|
+
"""Number of legal spaces, optionally at a given moment (see ``unavailable``)."""
|
|
178
|
+
recs = self.select(block_face_id=block_face_id, zone=zone, space_type=space_type)
|
|
179
|
+
return sum(rec.available_at(at) for rec in recs)
|
|
180
|
+
|
|
181
|
+
def capacity_for_key(self, key: str, by: str, at: Optional[_dt.datetime] = None) -> int:
|
|
182
|
+
"""Capacity of an aggregation key produced by :meth:`key_of`."""
|
|
183
|
+
check_grouping(by)
|
|
184
|
+
if by == "block_face":
|
|
185
|
+
return self.capacity(block_face_id=key, at=at)
|
|
186
|
+
if by == "zone":
|
|
187
|
+
return self.capacity(zone=key, at=at)
|
|
188
|
+
return self.capacity(at=at)
|
|
189
|
+
|
|
190
|
+
def time_limit(self, block_face_id: str, space_type: Optional[str] = None) -> Optional[float]:
|
|
191
|
+
"""Posted time limit (minutes) applying to a block face.
|
|
192
|
+
|
|
193
|
+
With ``space_type`` the limit of that line is returned. Otherwise the limit
|
|
194
|
+
is returned only if all time-limited lines of the face share one value;
|
|
195
|
+
a face with mixed limits returns ``None`` (overstay not assessed).
|
|
196
|
+
"""
|
|
197
|
+
recs = self._face(block_face_id)
|
|
198
|
+
if space_type:
|
|
199
|
+
recs = [r for r in recs if r.space_type == space_type]
|
|
200
|
+
if not recs:
|
|
201
|
+
raise DataError(f"block face {block_face_id!r} has no {space_type!r} spaces")
|
|
202
|
+
limits = {r.time_limit_min for r in recs if r.time_limit_min is not None}
|
|
203
|
+
return limits.pop() if len(limits) == 1 else None
|
|
204
|
+
|
|
205
|
+
def hourly_rate(self, block_face_id: str) -> Optional[float]:
|
|
206
|
+
"""Space-weighted mean hourly rate of the priced lines of a face (``None`` if unpriced)."""
|
|
207
|
+
priced = [(r.spaces, r.hourly_rate) for r in self._face(block_face_id)]
|
|
208
|
+
pairs = [(n, float(rate)) for n, rate in priced if rate is not None]
|
|
209
|
+
if not pairs:
|
|
210
|
+
return None
|
|
211
|
+
spaces = sum(n for n, _ in pairs)
|
|
212
|
+
if spaces == 0:
|
|
213
|
+
return pairs[0][1]
|
|
214
|
+
return sum(n * rate for n, rate in pairs) / spaces
|
|
215
|
+
|
|
216
|
+
def summary(self, by: Sequence[str] = ("zone", "space_type")) -> List[Dict[str, object]]:
|
|
217
|
+
"""Supply table grouped by any of ``zone``, ``space_type``, ``facility``.
|
|
218
|
+
|
|
219
|
+
Each row gives the number of block faces, spaces, time-limited spaces,
|
|
220
|
+
priced spaces and part-time spaces (those with an ``unavailable`` window).
|
|
221
|
+
"""
|
|
222
|
+
allowed = ("zone", "space_type", "facility", "block_face_id")
|
|
223
|
+
for name in by:
|
|
224
|
+
if name not in allowed:
|
|
225
|
+
raise ValueError(f"summary grouping must be among {allowed}, got {name!r}")
|
|
226
|
+
counters: Dict[Tuple[str, ...], Dict[str, int]] = OrderedDict()
|
|
227
|
+
faces: Dict[Tuple[str, ...], Set[str]] = {}
|
|
228
|
+
for rec in self._records:
|
|
229
|
+
key = tuple(str(getattr(rec, name)) for name in by)
|
|
230
|
+
c = counters.setdefault(
|
|
231
|
+
key,
|
|
232
|
+
{"spaces": 0, "time_limited_spaces": 0, "priced_spaces": 0, "part_time_spaces": 0},
|
|
233
|
+
)
|
|
234
|
+
faces.setdefault(key, set()).add(rec.block_face_id)
|
|
235
|
+
c["spaces"] += rec.spaces
|
|
236
|
+
if rec.time_limit_min is not None:
|
|
237
|
+
c["time_limited_spaces"] += rec.spaces
|
|
238
|
+
if rec.hourly_rate:
|
|
239
|
+
c["priced_spaces"] += rec.spaces
|
|
240
|
+
if rec.unavailable:
|
|
241
|
+
c["part_time_spaces"] += rec.spaces
|
|
242
|
+
rows: List[Dict[str, object]] = []
|
|
243
|
+
for key, c in counters.items():
|
|
244
|
+
row: Dict[str, object] = dict(zip(by, key))
|
|
245
|
+
row["block_faces"] = len(faces[key])
|
|
246
|
+
row.update(c)
|
|
247
|
+
rows.append(row)
|
|
248
|
+
return rows
|