parkingstudy 0.1.0__tar.gz
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-0.1.0/CHANGELOG.md +27 -0
- parkingstudy-0.1.0/CITATION.cff +21 -0
- parkingstudy-0.1.0/LICENSE +21 -0
- parkingstudy-0.1.0/MANIFEST.in +9 -0
- parkingstudy-0.1.0/PKG-INFO +232 -0
- parkingstudy-0.1.0/README.md +197 -0
- parkingstudy-0.1.0/examples/quickstart.py +121 -0
- parkingstudy-0.1.0/pyproject.toml +108 -0
- parkingstudy-0.1.0/setup.cfg +4 -0
- parkingstudy-0.1.0/src/parkingstudy/__init__.py +137 -0
- parkingstudy-0.1.0/src/parkingstudy/__main__.py +7 -0
- parkingstudy-0.1.0/src/parkingstudy/_time.py +110 -0
- parkingstudy-0.1.0/src/parkingstudy/_version.py +1 -0
- parkingstudy-0.1.0/src/parkingstudy/cli.py +326 -0
- parkingstudy-0.1.0/src/parkingstudy/errors.py +15 -0
- parkingstudy-0.1.0/src/parkingstudy/geojson.py +111 -0
- parkingstudy-0.1.0/src/parkingstudy/heatmap.py +117 -0
- parkingstudy-0.1.0/src/parkingstudy/inventory.py +248 -0
- parkingstudy-0.1.0/src/parkingstudy/io.py +251 -0
- parkingstudy-0.1.0/src/parkingstudy/lpr.py +421 -0
- parkingstudy-0.1.0/src/parkingstudy/occupancy.py +318 -0
- parkingstudy-0.1.0/src/parkingstudy/pricing.py +217 -0
- parkingstudy-0.1.0/src/parkingstudy/privacy.py +145 -0
- parkingstudy-0.1.0/src/parkingstudy/py.typed +0 -0
- parkingstudy-0.1.0/src/parkingstudy.egg-info/PKG-INFO +232 -0
- parkingstudy-0.1.0/src/parkingstudy.egg-info/SOURCES.txt +34 -0
- parkingstudy-0.1.0/src/parkingstudy.egg-info/dependency_links.txt +1 -0
- parkingstudy-0.1.0/src/parkingstudy.egg-info/entry_points.txt +2 -0
- parkingstudy-0.1.0/src/parkingstudy.egg-info/requires.txt +6 -0
- parkingstudy-0.1.0/src/parkingstudy.egg-info/top_level.txt +1 -0
- parkingstudy-0.1.0/tests/conftest.py +117 -0
- parkingstudy-0.1.0/tests/test_cli.py +230 -0
- parkingstudy-0.1.0/tests/test_core.py +203 -0
- parkingstudy-0.1.0/tests/test_lpr.py +157 -0
- parkingstudy-0.1.0/tests/test_outputs.py +189 -0
- parkingstudy-0.1.0/tests/test_privacy.py +103 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-11-09
|
|
8
|
+
### Added
|
|
9
|
+
- `Inventory` / `InventoryRecord`: spaces by block face, zone, space type and facility, with
|
|
10
|
+
time limits, free-text restrictions, part-time availability windows and hourly rates;
|
|
11
|
+
supply summaries.
|
|
12
|
+
- Occupancy counts by beat: `occupancy_by_beat`, `peak_occupancy`, `effective_capacity`
|
|
13
|
+
(85 % target, configurable), `practical_capacity` (overridable facility factors),
|
|
14
|
+
`capacity_assessment`, `accumulation_curve`, `space_hours`.
|
|
15
|
+
- Licence-plate beat surveys: `build_stays` (gap tolerance, session-aware censoring flags,
|
|
16
|
+
block-face or zone linking), `turnover_summary` (turnover, mean/median duration,
|
|
17
|
+
space-hours, utilization, overstay share on a conservative or best-estimate basis),
|
|
18
|
+
`accumulation_from_stays`, `duration_distribution`.
|
|
19
|
+
- `utilization_heatmap` tables (key x time-of-day bin) with CSV export.
|
|
20
|
+
- Demand-responsive pricing: `PricingPolicy`, `SFMTA_POLICY` (SFMTA 2017 meter policy),
|
|
21
|
+
`SFPARK_PILOT_POLICY`, `recommend_rate(s)`, `mean_occupancy`.
|
|
22
|
+
- Privacy: `PlateHasher` (HMAC-SHA256 with a secret salt), `generate_salt`,
|
|
23
|
+
`normalize_plate`, `anonymise_rows`, `anonymise_csv`; readers refuse raw plates without
|
|
24
|
+
a salt and no output contains raw plates.
|
|
25
|
+
- GeoJSON join of results to block faces (`join_to_geojson`).
|
|
26
|
+
- `parkingstudy` command line: `salt`, `hash-plates`, `inventory`, `occupancy`, `turnover`,
|
|
27
|
+
`heatmap`, `pricing`, `geojson`.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
cff-version: 1.2.0
|
|
2
|
+
message: "If you use this software, please cite it as below."
|
|
3
|
+
authors:
|
|
4
|
+
- family-names: "Eminoğlu"
|
|
5
|
+
given-names: "Yusuf"
|
|
6
|
+
email: "geospacephilo@gmail.com"
|
|
7
|
+
orcid: "https://orcid.org/0009-0005-6000-2934"
|
|
8
|
+
title: "parkingstudy"
|
|
9
|
+
version: "0.1.0"
|
|
10
|
+
date-released: "2026-11-09"
|
|
11
|
+
license: "MIT"
|
|
12
|
+
repository-code: "https://gitlab.com/geophilo1/parkingstudy_sdk"
|
|
13
|
+
url: "https://pypi.org/project/parkingstudy/"
|
|
14
|
+
abstract: "Parking occupancy and turnover survey analysis: supply inventory by block face, zone and space type; occupancy by time beat with peak, effective (85 % target) and practical capacity; licence-plate beat surveys giving durations, turnover, overstay and space-hours with salted HMAC plate anonymisation; zone-by-hour utilization heatmaps; configurable SFMTA/SFpark-style demand-responsive pricing rules; and GeoJSON joins to block faces."
|
|
15
|
+
keywords:
|
|
16
|
+
- parking
|
|
17
|
+
- parking occupancy
|
|
18
|
+
- parking turnover
|
|
19
|
+
- licence plate survey
|
|
20
|
+
- demand-responsive pricing
|
|
21
|
+
- transport planning
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yusuf Eminoğlu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: parkingstudy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Parking occupancy and turnover survey analysis: inventory, occupancy by beat, effective capacity, licence-plate turnover and overstay with salted plate hashing, heatmaps, demand-responsive pricing rules and GeoJSON block-face joins.
|
|
5
|
+
Author-email: Yusuf Eminoğlu <geospacephilo@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://gitlab.com/geophilo1/parkingstudy_sdk
|
|
8
|
+
Project-URL: Repository, https://gitlab.com/geophilo1/parkingstudy_sdk
|
|
9
|
+
Project-URL: Issues, https://gitlab.com/geophilo1/parkingstudy_sdk/-/work_items
|
|
10
|
+
Project-URL: Changelog, https://gitlab.com/geophilo1/parkingstudy_sdk/-/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: parking,parking-study,parking-occupancy,parking-turnover,licence-plate-survey,lpr,curb-management,demand-responsive-pricing,sfpark,transport-planning,urban-planning,geojson,gis
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Environment :: Console
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
23
|
+
Classifier: Intended Audience :: Science/Research
|
|
24
|
+
Classifier: Intended Audience :: Developers
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
32
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
33
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# parkingstudy
|
|
37
|
+
|
|
38
|
+
[](https://pypi.org/project/parkingstudy/)
|
|
39
|
+
[](https://pypi.org/project/parkingstudy/)
|
|
40
|
+
[](https://gitlab.com/geophilo1/parkingstudy_sdk/-/pipelines)
|
|
41
|
+
[](https://gitlab.com/geophilo1/parkingstudy_sdk/-/blob/main/LICENSE)
|
|
42
|
+
|
|
43
|
+
**Parking occupancy and turnover survey analysis for planners.**
|
|
44
|
+
|
|
45
|
+
`parkingstudy` turns the spreadsheets of a classic parking study into the numbers
|
|
46
|
+
a parking management plan needs:
|
|
47
|
+
|
|
48
|
+
- **Inventory**: spaces by block face, zone, space type and facility, with time limits,
|
|
49
|
+
restrictions, part-time windows (clearways, street cleaning) and hourly rates.
|
|
50
|
+
- **Occupancy counts by time beat**: occupancy rate, peak occupancy, effective capacity at
|
|
51
|
+
a target occupancy (85 % by default, configurable), practical capacity, accumulation
|
|
52
|
+
curve and space-hours of demand and supply.
|
|
53
|
+
- **Licence-plate (beat / LPR) surveys**: stays and durations, turnover, mean and median
|
|
54
|
+
duration, overstay share against posted time limits, utilization.
|
|
55
|
+
- **Heatmap tables** of occupancy by zone (or block face) x hour.
|
|
56
|
+
- **Demand-responsive pricing rules**: the SFMTA occupancy-band policy and the SFpark
|
|
57
|
+
pilot table, both as configurable policies.
|
|
58
|
+
- **GeoJSON join** of any result table to block-face geometries for mapping.
|
|
59
|
+
- **Privacy by design**: licence plates are replaced on input by a salted keyed hash
|
|
60
|
+
(HMAC-SHA256). Raw plates are never stored in memory objects or written to outputs.
|
|
61
|
+
|
|
62
|
+
Standard library only, no required dependencies. Requires Python 3.9 or newer.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install parkingstudy
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Quick start (command line)
|
|
71
|
+
|
|
72
|
+
Input files are plain CSV (column names are case-insensitive):
|
|
73
|
+
|
|
74
|
+
| file | required columns | optional columns |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| inventory | `block_face_id`, `spaces` | `zone`, `space_type`, `facility` (`on_street`/`off_street`), `time_limit_min`, `restriction`, `unavailable` (`07:00-09:00;16:00-18:00`), `hourly_rate` |
|
|
77
|
+
| counts | `block_face_id`, `beat`, `occupied` | `space_type`, `date` |
|
|
78
|
+
| sightings | `block_face_id`, `beat`, and `plate_token` **or** `plate` | `space_type`, `date` |
|
|
79
|
+
|
|
80
|
+
`beat` is a clock time (`08:30`) or an ISO date-time (`2026-03-10T08:30`).
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
# 1. Create a secret salt once per survey, keep it out of version control
|
|
84
|
+
parkingstudy salt > survey.salt
|
|
85
|
+
export PARKINGSTUDY_SALT=$(cat survey.salt)
|
|
86
|
+
|
|
87
|
+
# 2. Anonymise the field sheets: the plate column is replaced by a token column
|
|
88
|
+
parkingstudy hash-plates sightings_raw.csv -o sightings.csv
|
|
89
|
+
|
|
90
|
+
# 3. Supply summary, occupancy by beat and peak vs. 85 % effective capacity
|
|
91
|
+
parkingstudy inventory inventory.csv
|
|
92
|
+
parkingstudy occupancy inventory.csv counts.csv --by zone -o occupancy.csv
|
|
93
|
+
parkingstudy occupancy inventory.csv counts.csv --by zone --peak --target 0.85 \
|
|
94
|
+
--factor on_street=0.85 --factor off_street=0.90
|
|
95
|
+
parkingstudy occupancy inventory.csv counts.csv --by zone --space-hours
|
|
96
|
+
|
|
97
|
+
# 4. Turnover, duration and overstay from the plate survey
|
|
98
|
+
parkingstudy turnover inventory.csv sightings.csv --by block_face --beat-minutes 30 \
|
|
99
|
+
--stays stays.csv -o turnover.csv
|
|
100
|
+
parkingstudy turnover inventory.csv sightings.csv --durations # duration classes
|
|
101
|
+
|
|
102
|
+
# 5. Heatmap (zone x hour) and pricing recommendations for the 09:00-12:00 rate period
|
|
103
|
+
parkingstudy heatmap inventory.csv counts.csv --by zone --bin-minutes 60 -o heatmap.csv
|
|
104
|
+
parkingstudy pricing inventory.csv counts.csv --from 09:00 --to 12:00 --policy sfmta
|
|
105
|
+
|
|
106
|
+
# 6. Map it: join any result CSV (key column) to block-face GeoJSON
|
|
107
|
+
parkingstudy geojson block_faces.geojson turnover.csv -o turnover_map.geojson
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Every analysis command writes CSV (or `--format json`) to standard output or `-o FILE`.
|
|
111
|
+
`turnover` also accepts raw plates directly if a salt is supplied (`--salt-env VAR` or
|
|
112
|
+
`--salt-file FILE`); without one it refuses to read them.
|
|
113
|
+
|
|
114
|
+
## Quick start (Python)
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from parkingstudy import (
|
|
118
|
+
PlateHasher, read_inventory, read_counts, read_sightings,
|
|
119
|
+
capacity_assessment, build_stays, turnover_summary, utilization_heatmap,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
inv = read_inventory("inventory.csv")
|
|
123
|
+
counts = read_counts("counts.csv")
|
|
124
|
+
|
|
125
|
+
for row in capacity_assessment(inv, counts, by="zone", target=0.85):
|
|
126
|
+
print(row.key, row.peak_occupied, row.spaces, row.effective_capacity, row.above_target)
|
|
127
|
+
|
|
128
|
+
hasher = PlateHasher.from_env() # salt from $PARKINGSTUDY_SALT
|
|
129
|
+
sightings = read_sightings("sightings_raw.csv", hasher) # plates hashed while reading
|
|
130
|
+
stays = build_stays(sightings, beat_minutes=30, inventory=inv)
|
|
131
|
+
for r in turnover_summary(stays, inv, by="block_face"):
|
|
132
|
+
print(r.key, r.turnover, r.mean_duration_min, r.overstay_share)
|
|
133
|
+
|
|
134
|
+
print(utilization_heatmap(inv, counts, by="zone").to_csv())
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
See [`examples/quickstart.py`](examples/quickstart.py) for a complete synthetic survey.
|
|
138
|
+
|
|
139
|
+
## API overview
|
|
140
|
+
|
|
141
|
+
| area | objects |
|
|
142
|
+
|---|---|
|
|
143
|
+
| inventory | `InventoryRecord`, `Inventory` (`capacity`, `time_limit`, `hourly_rate`, `summary`) |
|
|
144
|
+
| occupancy | `Count`, `occupancy_by_beat`, `peak_occupancy`, `effective_capacity`, `practical_capacity`, `capacity_assessment`, `accumulation_curve`, `space_hours`, `infer_beat_minutes` |
|
|
145
|
+
| plate survey | `Sighting`, `build_stays`, `Stay`, `turnover_summary`, `TurnoverRow`, `accumulation_from_stays`, `duration_distribution` |
|
|
146
|
+
| heatmap | `utilization_heatmap`, `HeatmapTable` (`get`, `max_cell`, `to_rows`, `to_csv`) |
|
|
147
|
+
| pricing | `PricingPolicy`, `PriceBand`, `SFMTA_POLICY`, `SFPARK_PILOT_POLICY`, `recommend_rate`, `recommend_rates`, `mean_occupancy` |
|
|
148
|
+
| GeoJSON | `join_to_geojson`, `rows_by_key`, `load_geojson`, `write_geojson` |
|
|
149
|
+
| privacy | `PlateHasher`, `generate_salt`, `normalize_plate`, `anonymise_rows`, `anonymise_csv` |
|
|
150
|
+
| I/O | `read_inventory`, `read_counts`, `read_sightings`, `write_rows`, `records_to_rows` |
|
|
151
|
+
|
|
152
|
+
Aggregation everywhere uses `by="block_face" | "zone" | "total"`.
|
|
153
|
+
|
|
154
|
+
## Method and sources
|
|
155
|
+
|
|
156
|
+
**Occupancy.** Occupancy rate at a beat = vehicles / legal spaces available at that beat.
|
|
157
|
+
Spaces inside an `unavailable` window count as zero supply; vehicles parked there are still
|
|
158
|
+
counted, so rates above 100 % (illegal parking) are kept and flagged (`over_capacity`).
|
|
159
|
+
Zone and total rates sum vehicles and spaces of the block faces counted at that beat.
|
|
160
|
+
|
|
161
|
+
**Effective capacity** = spaces x target occupancy. The 85 % default is the
|
|
162
|
+
"85 percent rule" for kerb parking advocated by D. Shoup, *The High Cost of Free Parking*
|
|
163
|
+
(APA Planners Press, 2005): above roughly 85 % occupancy drivers start cruising for a space.
|
|
164
|
+
**Practical capacity** (also "effective supply") multiplies spaces by an efficiency factor
|
|
165
|
+
per facility type. The defaults (0.85 on-street, 0.90 off-street) are common planning rules
|
|
166
|
+
of thumb rather than values from a standard; pass your own `factors=` / `--factor`.
|
|
167
|
+
|
|
168
|
+
**Licence-plate beat survey.** A vehicle seen on consecutive beats at the same block face
|
|
169
|
+
(or zone, `level="zone"`) is one stay. With beat length *b*, first and last sightings
|
|
170
|
+
*t1* and *tn*: minimum duration = *tn - t1*; estimated duration = *tn - t1 + b* (each
|
|
171
|
+
sighting stands for one beat, the usual beat-survey convention). The true duration lies
|
|
172
|
+
between *tn - t1* and *tn - t1 + 2b*. Stays on the first or last beat of a survey session
|
|
173
|
+
are flagged as censored. `max_gap_beats` tolerates missed reads.
|
|
174
|
+
Turnover = vehicles / spaces over the survey period (and per hour). Space-hours of demand
|
|
175
|
+
= sum of estimated durations; utilization = space-hours / (spaces x survey hours).
|
|
176
|
+
**Overstay** compares a stay with the posted limit of its block face: the default
|
|
177
|
+
`basis="minimum"` counts only stays certainly longer than the limit (enforcement-grade,
|
|
178
|
+
conservative); `basis="estimate"` uses the estimated duration.
|
|
179
|
+
|
|
180
|
+
**Pricing.** `SFMTA_POLICY` follows SFMTA, *Demand-responsive parking pricing policies:
|
|
181
|
+
on-street parking meters and off-street lots* (5 December 2017): occupancy 80 % or above
|
|
182
|
+
raises the hourly rate by 0.25, 60 % to below 80 % leaves it unchanged, below 60 % lowers
|
|
183
|
+
it by 0.25, within 0.50 to 8.00 per hour. `SFPARK_PILOT_POLICY` is the four-band table
|
|
184
|
+
reported for the 2011-2013 SFpark pilot (80-100 % +0.25, 60-80 % hold, 30-60 % -0.25,
|
|
185
|
+
below 30 % -0.50, cap 6.00). Both are **configurable examples**: build a `PricingPolicy`
|
|
186
|
+
with your own bands, steps, currency, floor, cap and rounding.
|
|
187
|
+
|
|
188
|
+
**Plate anonymisation.** Plates are normalised (Unicode NFKC, upper case, letters and
|
|
189
|
+
digits only) and replaced by HMAC-SHA256 (RFC 2104) keyed with a secret salt of at least
|
|
190
|
+
16 bytes, truncated to 16 hex characters by default. Unlike an unsalted hash, tokens cannot
|
|
191
|
+
be reversed by enumerating the small space of valid plates without the salt. Use a fresh
|
|
192
|
+
salt per survey and destroy it afterwards. Licence plates are personal data under, for
|
|
193
|
+
example, the EU GDPR and the Turkish KVKK (Law No. 6698); check the rules that apply to
|
|
194
|
+
your survey.
|
|
195
|
+
|
|
196
|
+
## Limitations
|
|
197
|
+
|
|
198
|
+
- Beat surveys cannot see stays shorter than one beat that fall between two passes, so
|
|
199
|
+
turnover is underestimated and mean duration overestimated, more so with long beats.
|
|
200
|
+
- Zone-level stays take the time limit of the first block face where the vehicle was seen.
|
|
201
|
+
- A block face with several different time limits (for different space types) is not
|
|
202
|
+
assessed for overstay unless sightings carry a `space_type`.
|
|
203
|
+
- No time zones; beats are naive local times. Bare clock times are treated as one day.
|
|
204
|
+
- Practical-capacity factors and the SFpark pilot table are planning conventions and
|
|
205
|
+
secondary-source values; verify them for your context.
|
|
206
|
+
|
|
207
|
+
## Development
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
pip install -e ".[dev]"
|
|
211
|
+
python -m pytest
|
|
212
|
+
python -m ruff check . && python -m ruff format --check src tests
|
|
213
|
+
python -m mypy src
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
MIT License, see [LICENSE](LICENSE).
|
|
219
|
+
|
|
220
|
+
## Academic citation
|
|
221
|
+
|
|
222
|
+
If you use `parkingstudy` in research, please cite:
|
|
223
|
+
|
|
224
|
+
```bibtex
|
|
225
|
+
@software{eminoglu_parkingstudy,
|
|
226
|
+
author = {Eminoğlu, Yusuf},
|
|
227
|
+
title = {parkingstudy},
|
|
228
|
+
year = {2026},
|
|
229
|
+
version = {0.1.0},
|
|
230
|
+
url = {https://gitlab.com/geophilo1/parkingstudy_sdk}
|
|
231
|
+
}
|
|
232
|
+
```
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# parkingstudy
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/parkingstudy/)
|
|
4
|
+
[](https://pypi.org/project/parkingstudy/)
|
|
5
|
+
[](https://gitlab.com/geophilo1/parkingstudy_sdk/-/pipelines)
|
|
6
|
+
[](https://gitlab.com/geophilo1/parkingstudy_sdk/-/blob/main/LICENSE)
|
|
7
|
+
|
|
8
|
+
**Parking occupancy and turnover survey analysis for planners.**
|
|
9
|
+
|
|
10
|
+
`parkingstudy` turns the spreadsheets of a classic parking study into the numbers
|
|
11
|
+
a parking management plan needs:
|
|
12
|
+
|
|
13
|
+
- **Inventory**: spaces by block face, zone, space type and facility, with time limits,
|
|
14
|
+
restrictions, part-time windows (clearways, street cleaning) and hourly rates.
|
|
15
|
+
- **Occupancy counts by time beat**: occupancy rate, peak occupancy, effective capacity at
|
|
16
|
+
a target occupancy (85 % by default, configurable), practical capacity, accumulation
|
|
17
|
+
curve and space-hours of demand and supply.
|
|
18
|
+
- **Licence-plate (beat / LPR) surveys**: stays and durations, turnover, mean and median
|
|
19
|
+
duration, overstay share against posted time limits, utilization.
|
|
20
|
+
- **Heatmap tables** of occupancy by zone (or block face) x hour.
|
|
21
|
+
- **Demand-responsive pricing rules**: the SFMTA occupancy-band policy and the SFpark
|
|
22
|
+
pilot table, both as configurable policies.
|
|
23
|
+
- **GeoJSON join** of any result table to block-face geometries for mapping.
|
|
24
|
+
- **Privacy by design**: licence plates are replaced on input by a salted keyed hash
|
|
25
|
+
(HMAC-SHA256). Raw plates are never stored in memory objects or written to outputs.
|
|
26
|
+
|
|
27
|
+
Standard library only, no required dependencies. Requires Python 3.9 or newer.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install parkingstudy
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Quick start (command line)
|
|
36
|
+
|
|
37
|
+
Input files are plain CSV (column names are case-insensitive):
|
|
38
|
+
|
|
39
|
+
| file | required columns | optional columns |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| inventory | `block_face_id`, `spaces` | `zone`, `space_type`, `facility` (`on_street`/`off_street`), `time_limit_min`, `restriction`, `unavailable` (`07:00-09:00;16:00-18:00`), `hourly_rate` |
|
|
42
|
+
| counts | `block_face_id`, `beat`, `occupied` | `space_type`, `date` |
|
|
43
|
+
| sightings | `block_face_id`, `beat`, and `plate_token` **or** `plate` | `space_type`, `date` |
|
|
44
|
+
|
|
45
|
+
`beat` is a clock time (`08:30`) or an ISO date-time (`2026-03-10T08:30`).
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# 1. Create a secret salt once per survey, keep it out of version control
|
|
49
|
+
parkingstudy salt > survey.salt
|
|
50
|
+
export PARKINGSTUDY_SALT=$(cat survey.salt)
|
|
51
|
+
|
|
52
|
+
# 2. Anonymise the field sheets: the plate column is replaced by a token column
|
|
53
|
+
parkingstudy hash-plates sightings_raw.csv -o sightings.csv
|
|
54
|
+
|
|
55
|
+
# 3. Supply summary, occupancy by beat and peak vs. 85 % effective capacity
|
|
56
|
+
parkingstudy inventory inventory.csv
|
|
57
|
+
parkingstudy occupancy inventory.csv counts.csv --by zone -o occupancy.csv
|
|
58
|
+
parkingstudy occupancy inventory.csv counts.csv --by zone --peak --target 0.85 \
|
|
59
|
+
--factor on_street=0.85 --factor off_street=0.90
|
|
60
|
+
parkingstudy occupancy inventory.csv counts.csv --by zone --space-hours
|
|
61
|
+
|
|
62
|
+
# 4. Turnover, duration and overstay from the plate survey
|
|
63
|
+
parkingstudy turnover inventory.csv sightings.csv --by block_face --beat-minutes 30 \
|
|
64
|
+
--stays stays.csv -o turnover.csv
|
|
65
|
+
parkingstudy turnover inventory.csv sightings.csv --durations # duration classes
|
|
66
|
+
|
|
67
|
+
# 5. Heatmap (zone x hour) and pricing recommendations for the 09:00-12:00 rate period
|
|
68
|
+
parkingstudy heatmap inventory.csv counts.csv --by zone --bin-minutes 60 -o heatmap.csv
|
|
69
|
+
parkingstudy pricing inventory.csv counts.csv --from 09:00 --to 12:00 --policy sfmta
|
|
70
|
+
|
|
71
|
+
# 6. Map it: join any result CSV (key column) to block-face GeoJSON
|
|
72
|
+
parkingstudy geojson block_faces.geojson turnover.csv -o turnover_map.geojson
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Every analysis command writes CSV (or `--format json`) to standard output or `-o FILE`.
|
|
76
|
+
`turnover` also accepts raw plates directly if a salt is supplied (`--salt-env VAR` or
|
|
77
|
+
`--salt-file FILE`); without one it refuses to read them.
|
|
78
|
+
|
|
79
|
+
## Quick start (Python)
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from parkingstudy import (
|
|
83
|
+
PlateHasher, read_inventory, read_counts, read_sightings,
|
|
84
|
+
capacity_assessment, build_stays, turnover_summary, utilization_heatmap,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
inv = read_inventory("inventory.csv")
|
|
88
|
+
counts = read_counts("counts.csv")
|
|
89
|
+
|
|
90
|
+
for row in capacity_assessment(inv, counts, by="zone", target=0.85):
|
|
91
|
+
print(row.key, row.peak_occupied, row.spaces, row.effective_capacity, row.above_target)
|
|
92
|
+
|
|
93
|
+
hasher = PlateHasher.from_env() # salt from $PARKINGSTUDY_SALT
|
|
94
|
+
sightings = read_sightings("sightings_raw.csv", hasher) # plates hashed while reading
|
|
95
|
+
stays = build_stays(sightings, beat_minutes=30, inventory=inv)
|
|
96
|
+
for r in turnover_summary(stays, inv, by="block_face"):
|
|
97
|
+
print(r.key, r.turnover, r.mean_duration_min, r.overstay_share)
|
|
98
|
+
|
|
99
|
+
print(utilization_heatmap(inv, counts, by="zone").to_csv())
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
See [`examples/quickstart.py`](examples/quickstart.py) for a complete synthetic survey.
|
|
103
|
+
|
|
104
|
+
## API overview
|
|
105
|
+
|
|
106
|
+
| area | objects |
|
|
107
|
+
|---|---|
|
|
108
|
+
| inventory | `InventoryRecord`, `Inventory` (`capacity`, `time_limit`, `hourly_rate`, `summary`) |
|
|
109
|
+
| occupancy | `Count`, `occupancy_by_beat`, `peak_occupancy`, `effective_capacity`, `practical_capacity`, `capacity_assessment`, `accumulation_curve`, `space_hours`, `infer_beat_minutes` |
|
|
110
|
+
| plate survey | `Sighting`, `build_stays`, `Stay`, `turnover_summary`, `TurnoverRow`, `accumulation_from_stays`, `duration_distribution` |
|
|
111
|
+
| heatmap | `utilization_heatmap`, `HeatmapTable` (`get`, `max_cell`, `to_rows`, `to_csv`) |
|
|
112
|
+
| pricing | `PricingPolicy`, `PriceBand`, `SFMTA_POLICY`, `SFPARK_PILOT_POLICY`, `recommend_rate`, `recommend_rates`, `mean_occupancy` |
|
|
113
|
+
| GeoJSON | `join_to_geojson`, `rows_by_key`, `load_geojson`, `write_geojson` |
|
|
114
|
+
| privacy | `PlateHasher`, `generate_salt`, `normalize_plate`, `anonymise_rows`, `anonymise_csv` |
|
|
115
|
+
| I/O | `read_inventory`, `read_counts`, `read_sightings`, `write_rows`, `records_to_rows` |
|
|
116
|
+
|
|
117
|
+
Aggregation everywhere uses `by="block_face" | "zone" | "total"`.
|
|
118
|
+
|
|
119
|
+
## Method and sources
|
|
120
|
+
|
|
121
|
+
**Occupancy.** Occupancy rate at a beat = vehicles / legal spaces available at that beat.
|
|
122
|
+
Spaces inside an `unavailable` window count as zero supply; vehicles parked there are still
|
|
123
|
+
counted, so rates above 100 % (illegal parking) are kept and flagged (`over_capacity`).
|
|
124
|
+
Zone and total rates sum vehicles and spaces of the block faces counted at that beat.
|
|
125
|
+
|
|
126
|
+
**Effective capacity** = spaces x target occupancy. The 85 % default is the
|
|
127
|
+
"85 percent rule" for kerb parking advocated by D. Shoup, *The High Cost of Free Parking*
|
|
128
|
+
(APA Planners Press, 2005): above roughly 85 % occupancy drivers start cruising for a space.
|
|
129
|
+
**Practical capacity** (also "effective supply") multiplies spaces by an efficiency factor
|
|
130
|
+
per facility type. The defaults (0.85 on-street, 0.90 off-street) are common planning rules
|
|
131
|
+
of thumb rather than values from a standard; pass your own `factors=` / `--factor`.
|
|
132
|
+
|
|
133
|
+
**Licence-plate beat survey.** A vehicle seen on consecutive beats at the same block face
|
|
134
|
+
(or zone, `level="zone"`) is one stay. With beat length *b*, first and last sightings
|
|
135
|
+
*t1* and *tn*: minimum duration = *tn - t1*; estimated duration = *tn - t1 + b* (each
|
|
136
|
+
sighting stands for one beat, the usual beat-survey convention). The true duration lies
|
|
137
|
+
between *tn - t1* and *tn - t1 + 2b*. Stays on the first or last beat of a survey session
|
|
138
|
+
are flagged as censored. `max_gap_beats` tolerates missed reads.
|
|
139
|
+
Turnover = vehicles / spaces over the survey period (and per hour). Space-hours of demand
|
|
140
|
+
= sum of estimated durations; utilization = space-hours / (spaces x survey hours).
|
|
141
|
+
**Overstay** compares a stay with the posted limit of its block face: the default
|
|
142
|
+
`basis="minimum"` counts only stays certainly longer than the limit (enforcement-grade,
|
|
143
|
+
conservative); `basis="estimate"` uses the estimated duration.
|
|
144
|
+
|
|
145
|
+
**Pricing.** `SFMTA_POLICY` follows SFMTA, *Demand-responsive parking pricing policies:
|
|
146
|
+
on-street parking meters and off-street lots* (5 December 2017): occupancy 80 % or above
|
|
147
|
+
raises the hourly rate by 0.25, 60 % to below 80 % leaves it unchanged, below 60 % lowers
|
|
148
|
+
it by 0.25, within 0.50 to 8.00 per hour. `SFPARK_PILOT_POLICY` is the four-band table
|
|
149
|
+
reported for the 2011-2013 SFpark pilot (80-100 % +0.25, 60-80 % hold, 30-60 % -0.25,
|
|
150
|
+
below 30 % -0.50, cap 6.00). Both are **configurable examples**: build a `PricingPolicy`
|
|
151
|
+
with your own bands, steps, currency, floor, cap and rounding.
|
|
152
|
+
|
|
153
|
+
**Plate anonymisation.** Plates are normalised (Unicode NFKC, upper case, letters and
|
|
154
|
+
digits only) and replaced by HMAC-SHA256 (RFC 2104) keyed with a secret salt of at least
|
|
155
|
+
16 bytes, truncated to 16 hex characters by default. Unlike an unsalted hash, tokens cannot
|
|
156
|
+
be reversed by enumerating the small space of valid plates without the salt. Use a fresh
|
|
157
|
+
salt per survey and destroy it afterwards. Licence plates are personal data under, for
|
|
158
|
+
example, the EU GDPR and the Turkish KVKK (Law No. 6698); check the rules that apply to
|
|
159
|
+
your survey.
|
|
160
|
+
|
|
161
|
+
## Limitations
|
|
162
|
+
|
|
163
|
+
- Beat surveys cannot see stays shorter than one beat that fall between two passes, so
|
|
164
|
+
turnover is underestimated and mean duration overestimated, more so with long beats.
|
|
165
|
+
- Zone-level stays take the time limit of the first block face where the vehicle was seen.
|
|
166
|
+
- A block face with several different time limits (for different space types) is not
|
|
167
|
+
assessed for overstay unless sightings carry a `space_type`.
|
|
168
|
+
- No time zones; beats are naive local times. Bare clock times are treated as one day.
|
|
169
|
+
- Practical-capacity factors and the SFpark pilot table are planning conventions and
|
|
170
|
+
secondary-source values; verify them for your context.
|
|
171
|
+
|
|
172
|
+
## Development
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
pip install -e ".[dev]"
|
|
176
|
+
python -m pytest
|
|
177
|
+
python -m ruff check . && python -m ruff format --check src tests
|
|
178
|
+
python -m mypy src
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
MIT License, see [LICENSE](LICENSE).
|
|
184
|
+
|
|
185
|
+
## Academic citation
|
|
186
|
+
|
|
187
|
+
If you use `parkingstudy` in research, please cite:
|
|
188
|
+
|
|
189
|
+
```bibtex
|
|
190
|
+
@software{eminoglu_parkingstudy,
|
|
191
|
+
author = {Eminoğlu, Yusuf},
|
|
192
|
+
title = {parkingstudy},
|
|
193
|
+
year = {2026},
|
|
194
|
+
version = {0.1.0},
|
|
195
|
+
url = {https://gitlab.com/geophilo1/parkingstudy_sdk}
|
|
196
|
+
}
|
|
197
|
+
```
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Quick start: a synthetic one-morning parking survey analysed end to end.
|
|
2
|
+
|
|
3
|
+
Run with ``python examples/quickstart.py``. Everything is generated in memory;
|
|
4
|
+
the GeoJSON result is written to the system temporary directory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import random
|
|
11
|
+
import tempfile
|
|
12
|
+
|
|
13
|
+
from parkingstudy import (
|
|
14
|
+
Count,
|
|
15
|
+
Inventory,
|
|
16
|
+
InventoryRecord,
|
|
17
|
+
PlateHasher,
|
|
18
|
+
Sighting,
|
|
19
|
+
build_stays,
|
|
20
|
+
capacity_assessment,
|
|
21
|
+
generate_salt,
|
|
22
|
+
join_to_geojson,
|
|
23
|
+
mean_occupancy,
|
|
24
|
+
occupancy_by_beat,
|
|
25
|
+
recommend_rates,
|
|
26
|
+
rows_by_key,
|
|
27
|
+
turnover_summary,
|
|
28
|
+
utilization_heatmap,
|
|
29
|
+
write_geojson,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
rng = random.Random(42)
|
|
33
|
+
|
|
34
|
+
# 1. Supply inventory --------------------------------------------------------
|
|
35
|
+
inventory = Inventory(
|
|
36
|
+
[
|
|
37
|
+
InventoryRecord("N1", 12, "Centre", time_limit_min=60, hourly_rate=2.0),
|
|
38
|
+
InventoryRecord("N1", 2, "Centre", space_type="disabled"),
|
|
39
|
+
InventoryRecord("S1", 10, "Centre", time_limit_min=120, hourly_rate=2.0),
|
|
40
|
+
InventoryRecord("E1", 15, "Fringe", hourly_rate=1.0, unavailable="16:00-18:00"),
|
|
41
|
+
]
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# 2. Licence-plate beat survey, 08:00-12:00 every 30 minutes --------------------
|
|
45
|
+
beats = [f"{h:02d}:{m:02d}" for h in range(8, 12) for m in (0, 30)] + ["12:00"]
|
|
46
|
+
hasher = PlateHasher(generate_salt()) # in a real study: PlateHasher.from_env()
|
|
47
|
+
sightings = []
|
|
48
|
+
counts = []
|
|
49
|
+
for face, spaces in (("N1", 14), ("S1", 10), ("E1", 15)):
|
|
50
|
+
parked = {} # space index -> (raw plate, beats remaining)
|
|
51
|
+
for beat in beats:
|
|
52
|
+
for space in range(spaces):
|
|
53
|
+
plate, left = parked.get(space, (None, 0))
|
|
54
|
+
if left <= 0:
|
|
55
|
+
plate = None
|
|
56
|
+
if rng.random() < 0.75:
|
|
57
|
+
plate = (
|
|
58
|
+
f"35 {rng.choice('ABCDEFG')}{rng.choice('KLMN')} {rng.randint(100, 999)}"
|
|
59
|
+
)
|
|
60
|
+
left = rng.choice((1, 1, 2, 3, 4, 6))
|
|
61
|
+
if plate:
|
|
62
|
+
# The raw plate is hashed immediately and never stored.
|
|
63
|
+
sightings.append(Sighting.make(hasher(plate), face, beat))
|
|
64
|
+
parked[space] = (plate, left - 1)
|
|
65
|
+
else:
|
|
66
|
+
parked.pop(space, None)
|
|
67
|
+
n = sum(
|
|
68
|
+
1 for s in sightings if s.block_face_id == face and s.beat.strftime("%H:%M") == beat
|
|
69
|
+
)
|
|
70
|
+
counts.append(Count.make(face, beat, n))
|
|
71
|
+
|
|
72
|
+
# 3. Occupancy and capacity -----------------------------------------------------
|
|
73
|
+
print("Peak occupancy vs 85 % effective capacity")
|
|
74
|
+
for row in capacity_assessment(inventory, counts, by="zone"):
|
|
75
|
+
print(
|
|
76
|
+
f" {row.key:7s} peak {row.peak_occupied:3d}/{row.spaces} at {row.peak_beat:%H:%M}"
|
|
77
|
+
f" effective {row.effective_capacity:5.1f} surplus {row.surplus:+5.1f}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# 4. Turnover, duration and overstay -------------------------------------------
|
|
81
|
+
stays = build_stays(sightings, 30, inventory=inventory, beats=beats)
|
|
82
|
+
print("\nTurnover by block face")
|
|
83
|
+
for r in turnover_summary(stays, inventory, by="block_face", beats=beats):
|
|
84
|
+
share = "-" if r.overstay_share is None else f"{r.overstay_share:.0%}"
|
|
85
|
+
print(
|
|
86
|
+
f" {r.key}: {r.vehicles} vehicles, turnover {r.turnover:.2f}/space, "
|
|
87
|
+
f"mean stay {r.mean_duration_min:.0f} min, overstay {share}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# 5. Heatmap and pricing ---------------------------------------------------------
|
|
91
|
+
print("\nOccupancy heatmap (zone x hour)")
|
|
92
|
+
print(utilization_heatmap(inventory, counts, by="zone").to_csv(digits=2))
|
|
93
|
+
occ = mean_occupancy(occupancy_by_beat(inventory, counts))
|
|
94
|
+
rates = {f: inventory.hourly_rate(f) for f in inventory.face_ids()}
|
|
95
|
+
for rec in recommend_rates(occ, rates):
|
|
96
|
+
print(
|
|
97
|
+
f" {rec.key}: occupancy {rec.occupancy:.0%}, {rec.current_rate:.2f} -> {rec.new_rate:.2f}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# 6. Join to block-face geometry --------------------------------------------------
|
|
101
|
+
faces = {
|
|
102
|
+
"type": "FeatureCollection",
|
|
103
|
+
"features": [
|
|
104
|
+
{
|
|
105
|
+
"type": "Feature",
|
|
106
|
+
"properties": {"block_face_id": fid},
|
|
107
|
+
"geometry": {
|
|
108
|
+
"type": "LineString",
|
|
109
|
+
"coordinates": [[27.13, 38.42 + i * 1e-3], [27.14, 38.42 + i * 1e-3]],
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
for i, fid in enumerate(inventory.face_ids())
|
|
113
|
+
],
|
|
114
|
+
}
|
|
115
|
+
results = rows_by_key(
|
|
116
|
+
r.as_dict() for r in turnover_summary(stays, inventory, by="block_face", beats=beats)
|
|
117
|
+
)
|
|
118
|
+
joined = join_to_geojson(faces, results)
|
|
119
|
+
path = os.path.join(tempfile.gettempdir(), "parkingstudy_quickstart.geojson")
|
|
120
|
+
write_geojson(joined.geojson, path)
|
|
121
|
+
print(f"\n{joined.matched} block faces written to {path}")
|