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.
Files changed (36) hide show
  1. parkingstudy-0.1.0/CHANGELOG.md +27 -0
  2. parkingstudy-0.1.0/CITATION.cff +21 -0
  3. parkingstudy-0.1.0/LICENSE +21 -0
  4. parkingstudy-0.1.0/MANIFEST.in +9 -0
  5. parkingstudy-0.1.0/PKG-INFO +232 -0
  6. parkingstudy-0.1.0/README.md +197 -0
  7. parkingstudy-0.1.0/examples/quickstart.py +121 -0
  8. parkingstudy-0.1.0/pyproject.toml +108 -0
  9. parkingstudy-0.1.0/setup.cfg +4 -0
  10. parkingstudy-0.1.0/src/parkingstudy/__init__.py +137 -0
  11. parkingstudy-0.1.0/src/parkingstudy/__main__.py +7 -0
  12. parkingstudy-0.1.0/src/parkingstudy/_time.py +110 -0
  13. parkingstudy-0.1.0/src/parkingstudy/_version.py +1 -0
  14. parkingstudy-0.1.0/src/parkingstudy/cli.py +326 -0
  15. parkingstudy-0.1.0/src/parkingstudy/errors.py +15 -0
  16. parkingstudy-0.1.0/src/parkingstudy/geojson.py +111 -0
  17. parkingstudy-0.1.0/src/parkingstudy/heatmap.py +117 -0
  18. parkingstudy-0.1.0/src/parkingstudy/inventory.py +248 -0
  19. parkingstudy-0.1.0/src/parkingstudy/io.py +251 -0
  20. parkingstudy-0.1.0/src/parkingstudy/lpr.py +421 -0
  21. parkingstudy-0.1.0/src/parkingstudy/occupancy.py +318 -0
  22. parkingstudy-0.1.0/src/parkingstudy/pricing.py +217 -0
  23. parkingstudy-0.1.0/src/parkingstudy/privacy.py +145 -0
  24. parkingstudy-0.1.0/src/parkingstudy/py.typed +0 -0
  25. parkingstudy-0.1.0/src/parkingstudy.egg-info/PKG-INFO +232 -0
  26. parkingstudy-0.1.0/src/parkingstudy.egg-info/SOURCES.txt +34 -0
  27. parkingstudy-0.1.0/src/parkingstudy.egg-info/dependency_links.txt +1 -0
  28. parkingstudy-0.1.0/src/parkingstudy.egg-info/entry_points.txt +2 -0
  29. parkingstudy-0.1.0/src/parkingstudy.egg-info/requires.txt +6 -0
  30. parkingstudy-0.1.0/src/parkingstudy.egg-info/top_level.txt +1 -0
  31. parkingstudy-0.1.0/tests/conftest.py +117 -0
  32. parkingstudy-0.1.0/tests/test_cli.py +230 -0
  33. parkingstudy-0.1.0/tests/test_core.py +203 -0
  34. parkingstudy-0.1.0/tests/test_lpr.py +157 -0
  35. parkingstudy-0.1.0/tests/test_outputs.py +189 -0
  36. 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,9 @@
1
+ include LICENSE
2
+ include README.md
3
+ include CHANGELOG.md
4
+ include CITATION.cff
5
+ include pyproject.toml
6
+ include src/parkingstudy/py.typed
7
+ recursive-include src/parkingstudy *.py
8
+ recursive-include tests *.py
9
+ recursive-include examples *.py
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/parkingstudy.svg)](https://pypi.org/project/parkingstudy/)
39
+ [![Python versions](https://img.shields.io/pypi/pyversions/parkingstudy.svg)](https://pypi.org/project/parkingstudy/)
40
+ [![pipeline](https://gitlab.com/geophilo1/parkingstudy_sdk/badges/main/pipeline.svg)](https://gitlab.com/geophilo1/parkingstudy_sdk/-/pipelines)
41
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
+ [![PyPI version](https://img.shields.io/pypi/v/parkingstudy.svg)](https://pypi.org/project/parkingstudy/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/parkingstudy.svg)](https://pypi.org/project/parkingstudy/)
5
+ [![pipeline](https://gitlab.com/geophilo1/parkingstudy_sdk/badges/main/pipeline.svg)](https://gitlab.com/geophilo1/parkingstudy_sdk/-/pipelines)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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}")