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.
@@ -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
+ ]
@@ -0,0 +1,7 @@
1
+ """``python -m parkingstudy``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
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
@@ -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."""