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/__init__.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""parkingstudy: parking occupancy and turnover survey analysis.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from parkingstudy import read_inventory, read_counts, capacity_assessment
|
|
6
|
+
|
|
7
|
+
inv = read_inventory("inventory.csv")
|
|
8
|
+
counts = read_counts("counts.csv")
|
|
9
|
+
for row in capacity_assessment(inv, counts, by="zone", target=0.85):
|
|
10
|
+
print(row.key, row.peak_occupied, row.effective_capacity, row.above_target)
|
|
11
|
+
|
|
12
|
+
Licence plates are anonymised on input with a salted keyed hash
|
|
13
|
+
(:class:`PlateHasher`); raw plates are never stored or written.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from ._time import parse_beat, parse_windows
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
from .errors import DataError, ParkingStudyError, PrivacyError
|
|
19
|
+
from .geojson import JoinResult, join_to_geojson, load_geojson, rows_by_key, write_geojson
|
|
20
|
+
from .heatmap import HeatmapTable, utilization_heatmap
|
|
21
|
+
from .inventory import GROUPINGS, Inventory, InventoryRecord
|
|
22
|
+
from .io import (
|
|
23
|
+
anonymise_csv,
|
|
24
|
+
read_counts,
|
|
25
|
+
read_inventory,
|
|
26
|
+
read_sightings,
|
|
27
|
+
records_to_rows,
|
|
28
|
+
write_rows,
|
|
29
|
+
)
|
|
30
|
+
from .lpr import (
|
|
31
|
+
Sighting,
|
|
32
|
+
Stay,
|
|
33
|
+
TurnoverRow,
|
|
34
|
+
accumulation_from_stays,
|
|
35
|
+
build_stays,
|
|
36
|
+
duration_distribution,
|
|
37
|
+
survey_hours_from_beats,
|
|
38
|
+
turnover_summary,
|
|
39
|
+
)
|
|
40
|
+
from .occupancy import (
|
|
41
|
+
DEFAULT_PRACTICAL_FACTORS,
|
|
42
|
+
DEFAULT_TARGET,
|
|
43
|
+
CapacityAssessment,
|
|
44
|
+
Count,
|
|
45
|
+
OccupancyRow,
|
|
46
|
+
SpaceHours,
|
|
47
|
+
accumulation_curve,
|
|
48
|
+
capacity_assessment,
|
|
49
|
+
effective_capacity,
|
|
50
|
+
infer_beat_minutes,
|
|
51
|
+
occupancy_by_beat,
|
|
52
|
+
peak_occupancy,
|
|
53
|
+
practical_capacity,
|
|
54
|
+
space_hours,
|
|
55
|
+
)
|
|
56
|
+
from .pricing import (
|
|
57
|
+
POLICIES,
|
|
58
|
+
SFMTA_POLICY,
|
|
59
|
+
SFPARK_PILOT_POLICY,
|
|
60
|
+
PriceBand,
|
|
61
|
+
PriceRecommendation,
|
|
62
|
+
PricingPolicy,
|
|
63
|
+
mean_occupancy,
|
|
64
|
+
recommend_rate,
|
|
65
|
+
recommend_rates,
|
|
66
|
+
)
|
|
67
|
+
from .privacy import PlateHasher, anonymise_rows, generate_salt, normalize_plate
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
"__version__",
|
|
71
|
+
# errors
|
|
72
|
+
"ParkingStudyError",
|
|
73
|
+
"DataError",
|
|
74
|
+
"PrivacyError",
|
|
75
|
+
# time helpers
|
|
76
|
+
"parse_beat",
|
|
77
|
+
"parse_windows",
|
|
78
|
+
# inventory
|
|
79
|
+
"GROUPINGS",
|
|
80
|
+
"Inventory",
|
|
81
|
+
"InventoryRecord",
|
|
82
|
+
# occupancy
|
|
83
|
+
"DEFAULT_TARGET",
|
|
84
|
+
"DEFAULT_PRACTICAL_FACTORS",
|
|
85
|
+
"Count",
|
|
86
|
+
"OccupancyRow",
|
|
87
|
+
"CapacityAssessment",
|
|
88
|
+
"SpaceHours",
|
|
89
|
+
"occupancy_by_beat",
|
|
90
|
+
"peak_occupancy",
|
|
91
|
+
"effective_capacity",
|
|
92
|
+
"practical_capacity",
|
|
93
|
+
"capacity_assessment",
|
|
94
|
+
"accumulation_curve",
|
|
95
|
+
"infer_beat_minutes",
|
|
96
|
+
"space_hours",
|
|
97
|
+
# licence-plate survey
|
|
98
|
+
"Sighting",
|
|
99
|
+
"Stay",
|
|
100
|
+
"TurnoverRow",
|
|
101
|
+
"build_stays",
|
|
102
|
+
"turnover_summary",
|
|
103
|
+
"accumulation_from_stays",
|
|
104
|
+
"duration_distribution",
|
|
105
|
+
"survey_hours_from_beats",
|
|
106
|
+
# heatmap
|
|
107
|
+
"HeatmapTable",
|
|
108
|
+
"utilization_heatmap",
|
|
109
|
+
# pricing
|
|
110
|
+
"PriceBand",
|
|
111
|
+
"PricingPolicy",
|
|
112
|
+
"PriceRecommendation",
|
|
113
|
+
"SFMTA_POLICY",
|
|
114
|
+
"SFPARK_PILOT_POLICY",
|
|
115
|
+
"POLICIES",
|
|
116
|
+
"recommend_rate",
|
|
117
|
+
"recommend_rates",
|
|
118
|
+
"mean_occupancy",
|
|
119
|
+
# geojson
|
|
120
|
+
"JoinResult",
|
|
121
|
+
"join_to_geojson",
|
|
122
|
+
"load_geojson",
|
|
123
|
+
"write_geojson",
|
|
124
|
+
"rows_by_key",
|
|
125
|
+
# privacy
|
|
126
|
+
"PlateHasher",
|
|
127
|
+
"generate_salt",
|
|
128
|
+
"normalize_plate",
|
|
129
|
+
"anonymise_rows",
|
|
130
|
+
# io
|
|
131
|
+
"read_inventory",
|
|
132
|
+
"read_counts",
|
|
133
|
+
"read_sightings",
|
|
134
|
+
"anonymise_csv",
|
|
135
|
+
"write_rows",
|
|
136
|
+
"records_to_rows",
|
|
137
|
+
]
|
parkingstudy/__main__.py
ADDED
parkingstudy/_time.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Parsing of survey beat times and time-of-day windows (internal)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as _dt
|
|
6
|
+
from typing import Iterable, List, Sequence, Tuple, Union
|
|
7
|
+
|
|
8
|
+
from .errors import DataError
|
|
9
|
+
|
|
10
|
+
TimeLike = Union[str, _dt.datetime, _dt.time]
|
|
11
|
+
|
|
12
|
+
#: Date attached to beats given as bare clock times ("08:30").
|
|
13
|
+
BASE_DATE = _dt.date(2000, 1, 1)
|
|
14
|
+
|
|
15
|
+
Window = Tuple[int, int]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_beat(value: TimeLike) -> _dt.datetime:
|
|
19
|
+
"""Parse a beat time.
|
|
20
|
+
|
|
21
|
+
Accepts a :class:`datetime.datetime`, a :class:`datetime.time`, an ISO 8601
|
|
22
|
+
date-time string (``"2026-03-10T08:30"`` or ``"2026-03-10 08:30"``) or a bare
|
|
23
|
+
clock time (``"8:30"``, ``"08:30:00"``). Bare clock times are attached to a
|
|
24
|
+
fixed placeholder date so that single-day surveys need no date column.
|
|
25
|
+
Time zones are not supported: aware values are rejected.
|
|
26
|
+
"""
|
|
27
|
+
if isinstance(value, _dt.datetime):
|
|
28
|
+
result = value
|
|
29
|
+
elif isinstance(value, _dt.time):
|
|
30
|
+
result = _dt.datetime.combine(BASE_DATE, value)
|
|
31
|
+
else:
|
|
32
|
+
text = str(value).strip()
|
|
33
|
+
if not text:
|
|
34
|
+
raise DataError("empty beat time")
|
|
35
|
+
try:
|
|
36
|
+
if "-" in text[:10] and len(text) >= 10:
|
|
37
|
+
result = _dt.datetime.fromisoformat(text)
|
|
38
|
+
else:
|
|
39
|
+
parts = text.split(":")
|
|
40
|
+
if len(parts) not in (2, 3):
|
|
41
|
+
raise ValueError(text)
|
|
42
|
+
hour, minute = int(parts[0]), int(parts[1])
|
|
43
|
+
second = int(float(parts[2])) if len(parts) == 3 else 0
|
|
44
|
+
result = _dt.datetime.combine(BASE_DATE, _dt.time(hour, minute, second))
|
|
45
|
+
except ValueError as exc:
|
|
46
|
+
raise DataError(f"cannot parse beat time {value!r}") from exc
|
|
47
|
+
if result.tzinfo is not None:
|
|
48
|
+
raise DataError(f"time-zone aware beat times are not supported: {value!r}")
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def format_beat(value: _dt.datetime) -> str:
|
|
53
|
+
"""Format a beat as ``HH:MM`` (bare clock times) or ISO ``YYYY-MM-DDTHH:MM``."""
|
|
54
|
+
if value.date() == BASE_DATE:
|
|
55
|
+
return value.strftime("%H:%M")
|
|
56
|
+
return value.isoformat(timespec="minutes")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def minute_of_day(value: _dt.datetime) -> float:
|
|
60
|
+
"""Minutes since midnight."""
|
|
61
|
+
return value.hour * 60 + value.minute + value.second / 60.0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parse_clock(text: str) -> int:
|
|
65
|
+
parts = text.strip().split(":")
|
|
66
|
+
if len(parts) != 2:
|
|
67
|
+
raise DataError(f"expected HH:MM, got {text!r}")
|
|
68
|
+
hour, minute = int(parts[0]), int(parts[1])
|
|
69
|
+
if not (0 <= hour <= 24 and 0 <= minute < 60) or (hour == 24 and minute):
|
|
70
|
+
raise DataError(f"invalid clock time {text!r}")
|
|
71
|
+
return hour * 60 + minute
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_windows(text: Union[str, Iterable[Window], None]) -> Tuple[Window, ...]:
|
|
75
|
+
"""Parse time-of-day windows such as ``"07:00-09:00;16:00-18:30"``.
|
|
76
|
+
|
|
77
|
+
Windows are half-open ``[start, end)`` in minutes after midnight. A window
|
|
78
|
+
whose end is before its start wraps past midnight (``"22:00-06:00"``).
|
|
79
|
+
Already-parsed ``(start, end)`` pairs are passed through.
|
|
80
|
+
"""
|
|
81
|
+
if text is None:
|
|
82
|
+
return ()
|
|
83
|
+
if not isinstance(text, str):
|
|
84
|
+
return tuple((int(a), int(b)) for a, b in text)
|
|
85
|
+
windows: List[Window] = []
|
|
86
|
+
for chunk in text.replace(",", ";").split(";"):
|
|
87
|
+
chunk = chunk.strip()
|
|
88
|
+
if not chunk:
|
|
89
|
+
continue
|
|
90
|
+
if "-" not in chunk:
|
|
91
|
+
raise DataError(f"window must look like HH:MM-HH:MM, got {chunk!r}")
|
|
92
|
+
start, end = chunk.split("-", 1)
|
|
93
|
+
windows.append((_parse_clock(start), _parse_clock(end)))
|
|
94
|
+
return tuple(windows)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def in_windows(minute: float, windows: Sequence[Window]) -> bool:
|
|
98
|
+
"""Whether a minute-of-day falls inside any half-open window."""
|
|
99
|
+
for start, end in windows:
|
|
100
|
+
if start <= end:
|
|
101
|
+
if start <= minute < end:
|
|
102
|
+
return True
|
|
103
|
+
elif minute >= start or minute < end:
|
|
104
|
+
return True
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def minutes_between(a: _dt.datetime, b: _dt.datetime) -> float:
|
|
109
|
+
"""``b - a`` in minutes."""
|
|
110
|
+
return (b - a).total_seconds() / 60.0
|
parkingstudy/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
parkingstudy/cli.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Command-line interface: ``parkingstudy <command> ...``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Dict, List, Optional, Sequence
|
|
8
|
+
|
|
9
|
+
from ._version import __version__
|
|
10
|
+
from .errors import ParkingStudyError
|
|
11
|
+
from .geojson import join_to_geojson, load_geojson, rows_by_key, write_geojson
|
|
12
|
+
from .heatmap import utilization_heatmap
|
|
13
|
+
from .inventory import GROUPINGS
|
|
14
|
+
from .io import (
|
|
15
|
+
anonymise_csv,
|
|
16
|
+
read_counts,
|
|
17
|
+
read_inventory,
|
|
18
|
+
read_sightings,
|
|
19
|
+
records_to_rows,
|
|
20
|
+
write_rows,
|
|
21
|
+
)
|
|
22
|
+
from .lpr import build_stays, duration_distribution, turnover_summary
|
|
23
|
+
from .occupancy import (
|
|
24
|
+
DEFAULT_PRACTICAL_FACTORS,
|
|
25
|
+
DEFAULT_TARGET,
|
|
26
|
+
capacity_assessment,
|
|
27
|
+
infer_beat_minutes,
|
|
28
|
+
occupancy_by_beat,
|
|
29
|
+
space_hours,
|
|
30
|
+
)
|
|
31
|
+
from .pricing import POLICIES, mean_occupancy, recommend_rates
|
|
32
|
+
from .privacy import SALT_ENV_VAR, PlateHasher, generate_salt, resolve_hasher
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _add_output(p: argparse.ArgumentParser) -> None:
|
|
36
|
+
p.add_argument("-o", "--output", help="output file (default: standard output)")
|
|
37
|
+
p.add_argument("--format", choices=("csv", "json"), default="csv", help="output format")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _add_salt(p: argparse.ArgumentParser, required: bool = False) -> None:
|
|
41
|
+
g = p.add_argument_group("plate anonymisation (salt source)")
|
|
42
|
+
g.add_argument("--salt-file", help="file whose first line is the secret salt")
|
|
43
|
+
g.add_argument(
|
|
44
|
+
"--salt-env",
|
|
45
|
+
default=SALT_ENV_VAR if required else None,
|
|
46
|
+
help=f"environment variable holding the salt (default for hash-plates: {SALT_ENV_VAR})",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _factors(values: Optional[List[str]]) -> Dict[str, float]:
|
|
51
|
+
table = dict(DEFAULT_PRACTICAL_FACTORS)
|
|
52
|
+
for item in values or []:
|
|
53
|
+
name, _, val = item.partition("=")
|
|
54
|
+
if not val:
|
|
55
|
+
raise ParkingStudyError(f"--factor must look like facility=0.9, got {item!r}")
|
|
56
|
+
table[name.strip()] = float(val)
|
|
57
|
+
return table
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
61
|
+
"""The argument parser (exposed for documentation and tests)."""
|
|
62
|
+
parser = argparse.ArgumentParser(
|
|
63
|
+
prog="parkingstudy",
|
|
64
|
+
description="Parking occupancy and turnover survey analysis.",
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
67
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
68
|
+
|
|
69
|
+
p = sub.add_parser("salt", help="print a new random salt for plate hashing")
|
|
70
|
+
p.add_argument("--bytes", type=int, default=32, help="salt length in bytes (default 32)")
|
|
71
|
+
|
|
72
|
+
p = sub.add_parser("hash-plates", help="replace raw plates in a CSV with salted tokens")
|
|
73
|
+
p.add_argument("input")
|
|
74
|
+
p.add_argument("-o", "--output", required=True, help="anonymised CSV to write")
|
|
75
|
+
p.add_argument("--plate-col", default="plate")
|
|
76
|
+
p.add_argument("--token-col", default="plate_token")
|
|
77
|
+
_add_salt(p, required=True)
|
|
78
|
+
|
|
79
|
+
p = sub.add_parser("inventory", help="summarise supply by zone and space type")
|
|
80
|
+
p.add_argument("inventory")
|
|
81
|
+
p.add_argument(
|
|
82
|
+
"--group",
|
|
83
|
+
default="zone,space_type",
|
|
84
|
+
help="comma-separated grouping among zone, space_type, facility, block_face_id",
|
|
85
|
+
)
|
|
86
|
+
_add_output(p)
|
|
87
|
+
|
|
88
|
+
p = sub.add_parser("occupancy", help="occupancy by beat, or peak capacity assessment")
|
|
89
|
+
p.add_argument("inventory")
|
|
90
|
+
p.add_argument("counts")
|
|
91
|
+
p.add_argument("--by", choices=GROUPINGS, default="zone")
|
|
92
|
+
p.add_argument(
|
|
93
|
+
"--peak", action="store_true", help="one row per key: peak vs effective capacity"
|
|
94
|
+
)
|
|
95
|
+
p.add_argument("--space-hours", action="store_true", help="space-hour demand and supply")
|
|
96
|
+
p.add_argument("--target", type=float, default=DEFAULT_TARGET, help="target occupancy")
|
|
97
|
+
p.add_argument(
|
|
98
|
+
"--factor",
|
|
99
|
+
action="append",
|
|
100
|
+
metavar="FACILITY=F",
|
|
101
|
+
help="practical-capacity factor, e.g. on_street=0.85 (repeatable)",
|
|
102
|
+
)
|
|
103
|
+
p.add_argument("--beat-minutes", type=float, help="beat length (default: inferred)")
|
|
104
|
+
_add_output(p)
|
|
105
|
+
|
|
106
|
+
p = sub.add_parser("turnover", help="licence-plate survey: turnover, duration, overstay")
|
|
107
|
+
p.add_argument("inventory")
|
|
108
|
+
p.add_argument("sightings")
|
|
109
|
+
p.add_argument("--by", choices=GROUPINGS, default="zone")
|
|
110
|
+
p.add_argument("--beat-minutes", type=float, help="beat length (default: inferred)")
|
|
111
|
+
p.add_argument("--level", choices=("block_face", "zone"), default="block_face")
|
|
112
|
+
p.add_argument("--max-gap-beats", type=int, default=0)
|
|
113
|
+
p.add_argument("--overstay-basis", choices=("minimum", "estimate"), default="minimum")
|
|
114
|
+
p.add_argument("--stays", help="also write individual stays (tokens omitted) to this CSV")
|
|
115
|
+
p.add_argument(
|
|
116
|
+
"--durations", action="store_true", help="output the duration distribution instead"
|
|
117
|
+
)
|
|
118
|
+
_add_salt(p)
|
|
119
|
+
_add_output(p)
|
|
120
|
+
|
|
121
|
+
p = sub.add_parser("heatmap", help="occupancy rate table, key x time-of-day bin")
|
|
122
|
+
p.add_argument("inventory")
|
|
123
|
+
p.add_argument("counts")
|
|
124
|
+
p.add_argument("--by", choices=GROUPINGS, default="zone")
|
|
125
|
+
p.add_argument("--bin-minutes", type=int, default=60)
|
|
126
|
+
p.add_argument("-o", "--output", help="output CSV (default: standard output)")
|
|
127
|
+
|
|
128
|
+
p = sub.add_parser("pricing", help="demand-responsive rate recommendations")
|
|
129
|
+
p.add_argument("inventory", help="inventory CSV with an hourly_rate column")
|
|
130
|
+
p.add_argument("counts")
|
|
131
|
+
p.add_argument("--by", choices=("block_face", "zone"), default="block_face")
|
|
132
|
+
p.add_argument("--policy", choices=sorted(POLICIES), default="sfmta")
|
|
133
|
+
p.add_argument("--from", dest="start", help="first beat time of the rate period, HH:MM")
|
|
134
|
+
p.add_argument("--to", dest="end", help="end of the rate period (exclusive), HH:MM")
|
|
135
|
+
p.add_argument("--default-rate", type=float, help="rate for faces without hourly_rate")
|
|
136
|
+
_add_output(p)
|
|
137
|
+
|
|
138
|
+
p = sub.add_parser("geojson", help="join a result CSV to block-face GeoJSON")
|
|
139
|
+
p.add_argument("geojson", help="block-face FeatureCollection")
|
|
140
|
+
p.add_argument("results", help="result CSV produced by another command")
|
|
141
|
+
p.add_argument("-o", "--output", required=True)
|
|
142
|
+
p.add_argument("--id-property", default="block_face_id")
|
|
143
|
+
p.add_argument("--key-col", default="key", help="key column of the result CSV")
|
|
144
|
+
p.add_argument("--prefix", default="")
|
|
145
|
+
p.add_argument("--drop-unmatched", action="store_true")
|
|
146
|
+
return parser
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _clock(text: Optional[str]) -> Optional[float]:
|
|
150
|
+
if not text:
|
|
151
|
+
return None
|
|
152
|
+
hh, _, mm = text.partition(":")
|
|
153
|
+
return int(hh) * 60 + int(mm or 0)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _coerce(text: Optional[str]) -> object:
|
|
157
|
+
"""CSV cell to int, float, bool or None where unambiguous (for GeoJSON properties)."""
|
|
158
|
+
if text is None or text == "":
|
|
159
|
+
return None
|
|
160
|
+
if text in ("True", "False"):
|
|
161
|
+
return text == "True"
|
|
162
|
+
for conv in (int, float):
|
|
163
|
+
try:
|
|
164
|
+
return conv(text)
|
|
165
|
+
except ValueError:
|
|
166
|
+
pass
|
|
167
|
+
return text
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _run(args: argparse.Namespace) -> int:
|
|
171
|
+
cmd = args.command
|
|
172
|
+
if cmd == "salt":
|
|
173
|
+
print(generate_salt(args.bytes))
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
if cmd == "hash-plates":
|
|
177
|
+
hasher = resolve_hasher(salt_file=args.salt_file, salt_env=args.salt_env)
|
|
178
|
+
assert hasher is not None
|
|
179
|
+
n = anonymise_csv(
|
|
180
|
+
args.input,
|
|
181
|
+
args.output,
|
|
182
|
+
hasher,
|
|
183
|
+
plate_column=args.plate_col,
|
|
184
|
+
token_column=args.token_col,
|
|
185
|
+
)
|
|
186
|
+
print(f"{n} rows anonymised -> {args.output}", file=sys.stderr)
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
if cmd == "inventory":
|
|
190
|
+
inv = read_inventory(args.inventory)
|
|
191
|
+
group = [g.strip() for g in args.group.split(",") if g.strip()]
|
|
192
|
+
write_rows(inv.summary(group), args.output, fmt=args.format)
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
if cmd == "occupancy":
|
|
196
|
+
inv = read_inventory(args.inventory)
|
|
197
|
+
counts = read_counts(args.counts)
|
|
198
|
+
if args.peak:
|
|
199
|
+
res = capacity_assessment(
|
|
200
|
+
inv, counts, by=args.by, target=args.target, factors=_factors(args.factor)
|
|
201
|
+
)
|
|
202
|
+
rows = records_to_rows(res)
|
|
203
|
+
elif args.space_hours:
|
|
204
|
+
rows = records_to_rows(
|
|
205
|
+
space_hours(inv, counts, by=args.by, beat_minutes=args.beat_minutes)
|
|
206
|
+
)
|
|
207
|
+
else:
|
|
208
|
+
rows = [
|
|
209
|
+
{
|
|
210
|
+
"key": r.key,
|
|
211
|
+
"beat": r.beat,
|
|
212
|
+
"occupied": r.occupied,
|
|
213
|
+
"capacity": r.capacity,
|
|
214
|
+
"rate": r.rate,
|
|
215
|
+
}
|
|
216
|
+
for r in occupancy_by_beat(inv, counts, by=args.by)
|
|
217
|
+
]
|
|
218
|
+
write_rows(rows, args.output, fmt=args.format)
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
if cmd == "turnover":
|
|
222
|
+
inv = read_inventory(args.inventory)
|
|
223
|
+
plate_hasher: Optional[PlateHasher] = resolve_hasher(
|
|
224
|
+
salt_file=args.salt_file, salt_env=args.salt_env
|
|
225
|
+
)
|
|
226
|
+
sightings = read_sightings(args.sightings, plate_hasher)
|
|
227
|
+
beats = [s.beat for s in sightings]
|
|
228
|
+
beat_minutes = args.beat_minutes or infer_beat_minutes(beats)
|
|
229
|
+
stays = build_stays(
|
|
230
|
+
sightings,
|
|
231
|
+
beat_minutes,
|
|
232
|
+
inventory=inv,
|
|
233
|
+
level=args.level,
|
|
234
|
+
max_gap_beats=args.max_gap_beats,
|
|
235
|
+
)
|
|
236
|
+
if args.stays:
|
|
237
|
+
write_rows([s.as_dict(include_token=False) for s in stays], args.stays)
|
|
238
|
+
if args.durations:
|
|
239
|
+
rows = duration_distribution(stays)
|
|
240
|
+
else:
|
|
241
|
+
rows = records_to_rows(
|
|
242
|
+
turnover_summary(
|
|
243
|
+
stays, inv, by=args.by, beats=beats, overstay_basis=args.overstay_basis
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
write_rows(rows, args.output, fmt=args.format)
|
|
247
|
+
return 0
|
|
248
|
+
|
|
249
|
+
if cmd == "heatmap":
|
|
250
|
+
inv = read_inventory(args.inventory)
|
|
251
|
+
table = utilization_heatmap(
|
|
252
|
+
inv, read_counts(args.counts), by=args.by, bin_minutes=args.bin_minutes
|
|
253
|
+
)
|
|
254
|
+
text = table.to_csv(args.output)
|
|
255
|
+
if not args.output:
|
|
256
|
+
sys.stdout.write(text)
|
|
257
|
+
return 0
|
|
258
|
+
|
|
259
|
+
if cmd == "pricing":
|
|
260
|
+
inv = read_inventory(args.inventory)
|
|
261
|
+
counts = read_counts(args.counts)
|
|
262
|
+
start, end = _clock(args.start), _clock(args.end)
|
|
263
|
+
|
|
264
|
+
def in_period(minute: float) -> bool:
|
|
265
|
+
return (start is None or minute >= start) and (end is None or minute < end)
|
|
266
|
+
|
|
267
|
+
counts = [c for c in counts if in_period(c.beat.hour * 60 + c.beat.minute)]
|
|
268
|
+
occ = mean_occupancy(occupancy_by_beat(inv, counts, by=args.by))
|
|
269
|
+
rates: Dict[str, Optional[float]] = {}
|
|
270
|
+
if args.by == "block_face":
|
|
271
|
+
rates = {f: inv.hourly_rate(f) for f in inv.face_ids()}
|
|
272
|
+
else:
|
|
273
|
+
for zone in inv.zones():
|
|
274
|
+
priced = [
|
|
275
|
+
(inv.capacity(block_face_id=f), inv.hourly_rate(f))
|
|
276
|
+
for f in inv.face_ids()
|
|
277
|
+
if inv.zone_of(f) == zone
|
|
278
|
+
]
|
|
279
|
+
pairs = [(n, r) for n, r in priced if r is not None]
|
|
280
|
+
total = sum(n for n, _ in pairs)
|
|
281
|
+
rates[zone] = sum(n * r for n, r in pairs) / total if total else None
|
|
282
|
+
recs = recommend_rates(
|
|
283
|
+
occ, rates, policy=POLICIES[args.policy], default_rate=args.default_rate
|
|
284
|
+
)
|
|
285
|
+
write_rows(records_to_rows(recs), args.output, fmt=args.format)
|
|
286
|
+
return 0
|
|
287
|
+
|
|
288
|
+
if cmd == "geojson":
|
|
289
|
+
import csv
|
|
290
|
+
|
|
291
|
+
with open(args.results, encoding="utf-8-sig", newline="") as fh:
|
|
292
|
+
result_rows = [
|
|
293
|
+
{k: (v if k == args.key_col else _coerce(v)) for k, v in r.items()}
|
|
294
|
+
for r in csv.DictReader(fh)
|
|
295
|
+
]
|
|
296
|
+
joined = join_to_geojson(
|
|
297
|
+
load_geojson(args.geojson),
|
|
298
|
+
rows_by_key(result_rows, args.key_col),
|
|
299
|
+
id_property=args.id_property,
|
|
300
|
+
prefix=args.prefix,
|
|
301
|
+
keep_unmatched=not args.drop_unmatched,
|
|
302
|
+
)
|
|
303
|
+
write_geojson(joined.geojson, args.output)
|
|
304
|
+
print(
|
|
305
|
+
f"{joined.matched} keys joined; {len(joined.unmatched_features)} features and "
|
|
306
|
+
f"{len(joined.unmatched_keys)} result keys unmatched -> {args.output}",
|
|
307
|
+
file=sys.stderr,
|
|
308
|
+
)
|
|
309
|
+
return 0
|
|
310
|
+
|
|
311
|
+
raise AssertionError(cmd) # pragma: no cover
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
315
|
+
"""Entry point; returns the process exit code."""
|
|
316
|
+
parser = build_parser()
|
|
317
|
+
args = parser.parse_args(argv)
|
|
318
|
+
try:
|
|
319
|
+
return _run(args)
|
|
320
|
+
except (ParkingStudyError, ValueError, OSError) as exc:
|
|
321
|
+
print(f"parkingstudy: error: {exc}", file=sys.stderr)
|
|
322
|
+
return 2
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
if __name__ == "__main__": # pragma: no cover
|
|
326
|
+
sys.exit(main())
|
parkingstudy/errors.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Exception hierarchy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ParkingStudyError(Exception):
|
|
7
|
+
"""Base class for all errors raised by :mod:`parkingstudy`."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DataError(ParkingStudyError, ValueError):
|
|
11
|
+
"""Survey data is malformed, inconsistent or refers to unknown block faces."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PrivacyError(ParkingStudyError):
|
|
15
|
+
"""Raw licence plates would be processed or written without anonymisation."""
|