Spaces:
Sleeping
Sleeping
Commit ·
c8a8b27
0
Parent(s):
DRISHTI deploy
Browse files- .gitattributes +1 -0
- .gitignore +13 -0
- .streamlit/config.toml +31 -0
- Dockerfile +23 -0
- README.md +161 -0
- app.py +745 -0
- data/processed/forecast.parquet +3 -0
- data/processed/hotspots.parquet +3 -0
- data/processed/meta.json +503 -0
- data/processed/offenders.parquet +3 -0
- data/processed/trends.parquet +3 -0
- data/processed/trends_byday.parquet +3 -0
- docs/methodology.md +63 -0
- experiments/mae_objectives.py +38 -0
- experiments/mae_tuning.py +103 -0
- hash.py +20 -0
- models/lgbm_intensity.txt +0 -0
- requirements.txt +16 -0
- src/__init__.py +0 -0
- src/auth.py +55 -0
- src/build_artifacts.py +87 -0
- src/config.py +55 -0
- src/data_prep.py +71 -0
- src/features.py +23 -0
- src/hotspots.py +50 -0
- src/i18n.py +135 -0
- src/impact_index.py +37 -0
- src/model.py +111 -0
- src/offenders.py +53 -0
- src/streamlit_app.py +40 -0
- src/trends.py +64 -0
.gitattributes
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# raw data is 110MB — never commit it; the app only needs data/processed/*
|
| 2 |
+
data/raw/
|
| 3 |
+
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.pyc
|
| 6 |
+
.ipynb_checkpoints/
|
| 7 |
+
.venv/
|
| 8 |
+
venv/
|
| 9 |
+
.DS_Store
|
| 10 |
+
.streamlit/secrets.toml
|
| 11 |
+
|
| 12 |
+
# never commit real credentials
|
| 13 |
+
auth_config.yaml
|
.streamlit/config.toml
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Define BOTH a light and dark purple theme natively. Streamlit themes every
|
| 2 |
+
# widget (including tables) consistently, and the user switches via ⋮ → Settings.
|
| 3 |
+
[theme]
|
| 4 |
+
base = "dark"
|
| 5 |
+
primaryColor = "#8B5CF6"
|
| 6 |
+
font = "sans serif"
|
| 7 |
+
|
| 8 |
+
[theme.dark]
|
| 9 |
+
backgroundColor = "#150C2E"
|
| 10 |
+
secondaryBackgroundColor = "#1E1640"
|
| 11 |
+
textColor = "#F4F1FF"
|
| 12 |
+
|
| 13 |
+
[theme.dark.sidebar]
|
| 14 |
+
backgroundColor = "#190F33"
|
| 15 |
+
secondaryBackgroundColor = "#241748"
|
| 16 |
+
|
| 17 |
+
[theme.light]
|
| 18 |
+
backgroundColor = "#F7F4FF"
|
| 19 |
+
secondaryBackgroundColor = "#FFFFFF"
|
| 20 |
+
textColor = "#1E1338"
|
| 21 |
+
|
| 22 |
+
[theme.light.sidebar]
|
| 23 |
+
backgroundColor = "#EFEAFB"
|
| 24 |
+
secondaryBackgroundColor = "#FFFFFF"
|
| 25 |
+
|
| 26 |
+
[server]
|
| 27 |
+
headless = true
|
| 28 |
+
enableXsrfProtection = true
|
| 29 |
+
# --- optional: enable HTTPS locally (see notes). Uncomment after creating certs ---
|
| 30 |
+
# sslCertFile = "cert.pem"
|
| 31 |
+
# sslKeyFile = "key.pem"
|
Dockerfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# LightGBM needs the OpenMP runtime (libgomp1); the slim image doesn't ship it.
|
| 4 |
+
RUN apt-get update && apt-get install -y --no-install-recommends libgomp1 \
|
| 5 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 6 |
+
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
# install Python deps first (better layer caching)
|
| 10 |
+
COPY requirements.txt .
|
| 11 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 12 |
+
|
| 13 |
+
# copy the app (data/raw and auth_config.yaml are gitignored, so never shipped)
|
| 14 |
+
COPY . .
|
| 15 |
+
|
| 16 |
+
# Hugging Face Spaces runs containers as a non-root user (UID 1000)
|
| 17 |
+
RUN useradd -m -u 1000 user && chown -R user:user /app
|
| 18 |
+
USER user
|
| 19 |
+
|
| 20 |
+
EXPOSE 8501
|
| 21 |
+
|
| 22 |
+
CMD ["streamlit", "run", "app.py", \
|
| 23 |
+
"--server.port=8501", "--server.address=0.0.0.0", "--server.headless=true"]
|
README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: ParkSight
|
| 3 |
+
emoji: 🅿️
|
| 4 |
+
colorFrom: purple
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 8501
|
| 8 |
+
pinned: false
|
| 9 |
+
short_description: Parking-induced congestion intelligence for Bengaluru
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# ParkSight — Parking-Induced Congestion Intelligence (Bengaluru)
|
| 13 |
+
|
| 14 |
+
AI-driven parking-violation intelligence that **detects illegal-parking
|
| 15 |
+
hotspots and quantifies their impact on traffic flow**, then **forecasts where
|
| 16 |
+
hotspots will flare up next** — turning reactive, patrol-based enforcement into
|
| 17 |
+
data-driven, targeted deployment.
|
| 18 |
+
|
| 19 |
+
Built for *Flipkart Gridlock Hackathon 2.0 — Theme 1*.
|
| 20 |
+
|
| 21 |
+
- **Detect** chronic hotspots over a city-wide H3 hex grid.
|
| 22 |
+
- **Quantify** flow impact with a transparent **Congestion Impact Index (CII)**.
|
| 23 |
+
- **Forecast** next-day intensity per zone with LightGBM.
|
| 24 |
+
- **Act** — a ranked, downloadable enforcement-priority list + interactive map.
|
| 25 |
+
|
| 26 |
+
> Results on the provided data: **298,445** parking violations → **2,534**
|
| 27 |
+
> impact zones; forecast MAE **0.71**, ~**34% better** than a same-weekday
|
| 28 |
+
> baseline.
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
+
## Architecture in one line
|
| 33 |
+
The heavy offline pipeline (`src/`) crunches the 110 MB CSV and writes a few
|
| 34 |
+
small artifacts (`data/processed/*`). The Streamlit app (`app.py`) reads only
|
| 35 |
+
those, so it loads in seconds and runs inside free-tier memory.
|
| 36 |
+
|
| 37 |
+
```
|
| 38 |
+
parking-impact/
|
| 39 |
+
├── app.py # Streamlit dashboard (serving layer)
|
| 40 |
+
├── requirements.txt
|
| 41 |
+
├── .streamlit/config.toml # dark theme
|
| 42 |
+
├── data/
|
| 43 |
+
│ ├── raw/violations.csv # the provided CSV (gitignored)
|
| 44 |
+
│ └── processed/ # hotspots.parquet · forecast.parquet · meta.json
|
| 45 |
+
├── src/ # offline pipeline
|
| 46 |
+
│ ├── config.py # ALL tunable knobs (weights, H3 res, lags ...)
|
| 47 |
+
│ ├── data_prep.py # load · parse · tz-fix · filter to parking
|
| 48 |
+
│ ├── features.py # H3 indexing
|
| 49 |
+
│ ├── hotspots.py # per-cell stats
|
| 50 |
+
│ ├── impact_index.py # the CII
|
| 51 |
+
│ ├── model.py # LightGBM next-day forecast
|
| 52 |
+
│ └── build_artifacts.py # runs the whole pipeline
|
| 53 |
+
├── models/lgbm_intensity.txt
|
| 54 |
+
└── docs/methodology.md # CII rationale (cite this in the deck)
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
---
|
| 58 |
+
|
| 59 |
+
## Step-by-step: run it locally
|
| 60 |
+
|
| 61 |
+
**1. Python 3.10–3.12 and a virtual env**
|
| 62 |
+
```bash
|
| 63 |
+
python -m venv .venv
|
| 64 |
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
**2. Install dependencies**
|
| 68 |
+
```bash
|
| 69 |
+
pip install -r requirements.txt
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
**3. Put the dataset in place**
|
| 73 |
+
Copy the provided CSV to `data/raw/violations.csv`.
|
| 74 |
+
|
| 75 |
+
**4. Build the artifacts (offline, run once)**
|
| 76 |
+
```bash
|
| 77 |
+
python -m src.build_artifacts
|
| 78 |
+
```
|
| 79 |
+
This writes `data/processed/hotspots.parquet`, `forecast.parquet`, `meta.json`
|
| 80 |
+
and `models/lgbm_intensity.txt`. Takes ~1–2 minutes.
|
| 81 |
+
|
| 82 |
+
**5. Launch the dashboard**
|
| 83 |
+
```bash
|
| 84 |
+
streamlit run app.py
|
| 85 |
+
```
|
| 86 |
+
Opens at `http://localhost:8501`.
|
| 87 |
+
|
| 88 |
+
---
|
| 89 |
+
|
| 90 |
+
## Deploy to a public URL (free)
|
| 91 |
+
|
| 92 |
+
The repo is deploy-ready for **either** platform. Commit the small
|
| 93 |
+
`data/processed/*` artifacts (they're tiny); the raw CSV stays gitignored.
|
| 94 |
+
|
| 95 |
+
### Option A — Hugging Face Spaces (recommended, 16 GB free RAM)
|
| 96 |
+
1. Create a Space → SDK = **Streamlit**.
|
| 97 |
+
2. Push this repo to the Space (entry file must be `app.py` at root — it is).
|
| 98 |
+
3. Commit `data/processed/*` so the app has data. Done — you get a public URL.
|
| 99 |
+
|
| 100 |
+
### Option B — Streamlit Community Cloud (simplest)
|
| 101 |
+
1. Push to GitHub (public).
|
| 102 |
+
2. share.streamlit.io → New app → pick the repo, main file `app.py`.
|
| 103 |
+
3. Set a custom subdomain. Every `git push` auto-redeploys.
|
| 104 |
+
|
| 105 |
+
---
|
| 106 |
+
|
| 107 |
+
## Tuning (for your 2 optimization days)
|
| 108 |
+
Everything is in `src/config.py`:
|
| 109 |
+
- `PARKING_SEVERITY` — re-weight violation types.
|
| 110 |
+
- `CII_WEIGHTS`, `CII_JUNCTION_ALPHA` — re-balance the index.
|
| 111 |
+
- `H3_RES` — 8 (coarser) ↔ 10 (finer) zones.
|
| 112 |
+
- `LAGS`, `ROLL_WINDOWS`, `MIN_CELL_VIOLATIONS`, `VALID_DAYS` — the model.
|
| 113 |
+
Re-run `python -m src.build_artifacts` after any change.
|
| 114 |
+
|
| 115 |
+
---
|
| 116 |
+
|
| 117 |
+
## Multi-language, theme, history & login
|
| 118 |
+
|
| 119 |
+
**Languages (English / ಕನ್ನಡ / हिन्दी)** — sidebar selector translates the UI and
|
| 120 |
+
powers **voice + text search in Kannada and Hindi**. Native-script place names
|
| 121 |
+
are resolved to the data's English localities via transliteration + fuzzy match
|
| 122 |
+
(e.g. ಶಿವಾಜಿನಗರ → Shivaji Nagar). Voice input uses the matching locale
|
| 123 |
+
(`kn-IN` / `hi-IN`). *Spoken read-back* uses the selected locale but depends on
|
| 124 |
+
the OS/browser having that voice installed — it may fall back to English; demo
|
| 125 |
+
in Chrome.
|
| 126 |
+
|
| 127 |
+
**Theme** — Streamlit already ships a reliable light/dark toggle in
|
| 128 |
+
⋮ → Settings → Theme. The sidebar toggle additionally flips the map basemap
|
| 129 |
+
(reliable) and nudges the chrome via CSS (partial — native per-user runtime
|
| 130 |
+
theming isn't supported yet, so the Settings menu is the robust path).
|
| 131 |
+
|
| 132 |
+
**Session history** — the sidebar logs your searches this session (downloadable,
|
| 133 |
+
clearable). Cross-session, per-user history needs the login add-on **plus** a
|
| 134 |
+
database — free-tier disks are wiped on restart, so a server-side history file
|
| 135 |
+
will not survive. For real persistence, write to a free Postgres (Neon/Supabase)
|
| 136 |
+
via `st.secrets`.
|
| 137 |
+
|
| 138 |
+
**Login add-on (optional, off by default)** — `src/auth.py` +
|
| 139 |
+
`auth_config.yaml` give bcrypt-hashed, role-based login (admin / officer) via
|
| 140 |
+
`streamlit-authenticator`. Enable by adding to the top of `app.py`:
|
| 141 |
+
```python
|
| 142 |
+
from src.auth import require_login
|
| 143 |
+
name, username, roles = require_login()
|
| 144 |
+
```
|
| 145 |
+
Run `python hash.py` to hash passwords and replace the plain-text values in
|
| 146 |
+
`auth_config.yaml` (which is gitignored — never commit real credentials). The
|
| 147 |
+
login `login()` signature varies by library version, so pin it and test locally.
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
| Form field | What to submit |
|
| 151 |
+
|---|---|
|
| 152 |
+
| Title | ParkSight — Parking-Induced Congestion Intelligence |
|
| 153 |
+
| Description | Problem, CII method, forecast, impact (see deck) |
|
| 154 |
+
| Theme | Poor Visibility on Parking-Induced Congestion |
|
| 155 |
+
| Snapshots | Screenshots of the map / priorities / forecast tabs |
|
| 156 |
+
| Video URL | 3-min screen-recorded demo (Loom / unlisted YouTube) |
|
| 157 |
+
| Presentation | Your pitch deck (PDF) |
|
| 158 |
+
| Demo Link | The public Spaces / Streamlit URL |
|
| 159 |
+
| Repository URL | Your GitHub repo |
|
| 160 |
+
| Source Code | A zip of this repo |
|
| 161 |
+
| Instructions to Run | The "run it locally" section above |
|
app.py
ADDED
|
@@ -0,0 +1,745 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ParkSight — Parking-Induced Congestion Intelligence for Bengaluru.
|
| 2 |
+
|
| 3 |
+
Serving layer: reads only the small precomputed artifacts in data/processed/.
|
| 4 |
+
Run locally: streamlit run app.py
|
| 5 |
+
"""
|
| 6 |
+
import io
|
| 7 |
+
import re
|
| 8 |
+
import json
|
| 9 |
+
import hashlib
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import pandas as pd
|
| 14 |
+
import pydeck as pdk
|
| 15 |
+
import plotly.graph_objects as go
|
| 16 |
+
import streamlit as st
|
| 17 |
+
import streamlit.components.v1 as components
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
from streamlit_option_menu import option_menu
|
| 21 |
+
HAS_MENU = True
|
| 22 |
+
except Exception:
|
| 23 |
+
HAS_MENU = False
|
| 24 |
+
try:
|
| 25 |
+
from streamlit_mic_recorder import speech_to_text
|
| 26 |
+
HAS_MIC = True
|
| 27 |
+
except Exception:
|
| 28 |
+
HAS_MIC = False
|
| 29 |
+
try:
|
| 30 |
+
from streamlit_autorefresh import st_autorefresh
|
| 31 |
+
HAS_AUTOREFRESH = True
|
| 32 |
+
except Exception:
|
| 33 |
+
HAS_AUTOREFRESH = False
|
| 34 |
+
|
| 35 |
+
from src.i18n import LANGS, SPEECH_LANG, t, build_area_vocab, resolve_area
|
| 36 |
+
|
| 37 |
+
ROOT = Path(__file__).resolve().parent
|
| 38 |
+
PROC = ROOT / "data" / "processed"
|
| 39 |
+
VIOLET = "#8B5CF6"
|
| 40 |
+
GOLD = "#D4AF37"
|
| 41 |
+
DONUT_COLORS = ["#8B5CF6", "#22D3EE", "#EC4899", "#6366F1", "#0EA5E9",
|
| 42 |
+
"#F472B6", "#A855F7", "#34D399", "#FB7185", "#818CF8"]
|
| 43 |
+
|
| 44 |
+
# ---- demo-grade login (self-contained, no fragile dependency) ----
|
| 45 |
+
# NOTE: demonstration auth — credentials are shown on the login screen so judges
|
| 46 |
+
# can always get in. Production would use a real identity provider. To DISABLE
|
| 47 |
+
# the login entirely, comment out the `require_login()` call below.
|
| 48 |
+
_USERS = {"admin": "Traffic Admin (BTP)", "officer": "Patrol Officer"}
|
| 49 |
+
_PW = {"admin": hashlib.sha256(b"admin123").hexdigest(),
|
| 50 |
+
"officer": hashlib.sha256(b"officer123").hexdigest()}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def require_login():
|
| 54 |
+
if st.session_state.get("authed"):
|
| 55 |
+
return
|
| 56 |
+
# keep the session logged in across theme-switch page reloads (demo only)
|
| 57 |
+
if st.query_params.get("auth") == "1":
|
| 58 |
+
st.session_state.authed = True
|
| 59 |
+
st.session_state.user = "Traffic Admin (BTP)"
|
| 60 |
+
return
|
| 61 |
+
st.markdown(
|
| 62 |
+
"<div style='background:linear-gradient(135deg,#7C3AED,#EC4899);"
|
| 63 |
+
"border-radius:18px;padding:26px 30px;color:#fff;margin-bottom:20px;'>"
|
| 64 |
+
"<div style='font-size:2rem;font-weight:800;'>दृष्टि — DRISHTI</div>"
|
| 65 |
+
"<div style='opacity:0.92;margin-top:4px;'>Digital Real-time Intelligence for "
|
| 66 |
+
"Smart Hotspot & Traffic Insights · हर सड़क पर नज़र, हर सफ़र आसान</div></div>",
|
| 67 |
+
unsafe_allow_html=True)
|
| 68 |
+
st.subheader("🔐 Secure sign-in")
|
| 69 |
+
with st.form("login_form"):
|
| 70 |
+
u = st.text_input("Username")
|
| 71 |
+
p = st.text_input("Password", type="password")
|
| 72 |
+
ok = st.form_submit_button("Sign in")
|
| 73 |
+
if ok:
|
| 74 |
+
if u in _PW and _PW[u] == hashlib.sha256(p.encode()).hexdigest():
|
| 75 |
+
st.session_state.authed = True
|
| 76 |
+
st.session_state.user = _USERS[u]
|
| 77 |
+
st.query_params["auth"] = "1"
|
| 78 |
+
st.rerun()
|
| 79 |
+
st.error("Invalid username or password.")
|
| 80 |
+
st.info("**Demo login** → `admin` / `admin123` · `officer` / `officer123`")
|
| 81 |
+
st.stop()
|
| 82 |
+
|
| 83 |
+
# sidebar starts open so the nav is always visible
|
| 84 |
+
st.set_page_config(page_title="DRISHTI · Bengaluru", page_icon="🚦",
|
| 85 |
+
layout="wide", initial_sidebar_state="expanded")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ---------------- data ----------------
|
| 89 |
+
@st.cache_data
|
| 90 |
+
def load():
|
| 91 |
+
hot = pd.read_parquet(PROC / "hotspots.parquet")
|
| 92 |
+
fc = pd.read_parquet(PROC / "forecast.parquet")
|
| 93 |
+
off = pd.read_parquet(PROC / "offenders.parquet")
|
| 94 |
+
meta = json.loads((PROC / "meta.json").read_text())
|
| 95 |
+
fc = fc.merge(hot[["h3", "location", "junction_name", "cii"]], on="h3", how="left")
|
| 96 |
+
return hot, fc, off, meta
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@st.cache_data
|
| 100 |
+
def load_trends():
|
| 101 |
+
return pd.read_parquet(PROC / "trends.parquet")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@st.cache_data
|
| 105 |
+
def load_byday():
|
| 106 |
+
return pd.read_parquet(PROC / "trends_byday.parquet")
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def cii_color(cii):
|
| 110 |
+
x = max(0.0, min(1.0, cii / 100.0))
|
| 111 |
+
g, y, r = (22, 163, 74), (245, 158, 11), (220, 38, 38)
|
| 112 |
+
if x < 0.5:
|
| 113 |
+
f, a, b = x / 0.5, g, y
|
| 114 |
+
else:
|
| 115 |
+
f, a, b = (x - 0.5) / 0.5, y, r
|
| 116 |
+
return [int(a[i] + (b[i] - a[i]) * f) for i in range(3)] + [185]
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def cii_to_hex(c):
|
| 120 |
+
r, g, b, _ = cii_color(c)
|
| 121 |
+
return f"rgb({r},{g},{b})"
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# ---------------- theme CSS (theme-AGNOSTIC: adapts to light & dark) ----------------
|
| 125 |
+
def inject_css():
|
| 126 |
+
# No hardcoded background/text colours -> the native Light/Dark theme (⋮ menu)
|
| 127 |
+
# stays fully consistent, including tables. Only shape + accent + font here.
|
| 128 |
+
st.markdown("""
|
| 129 |
+
<style>
|
| 130 |
+
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
|
| 131 |
+
html, body, [class*="css"], .stApp { font-family:'Plus Jakarta Sans',sans-serif; }
|
| 132 |
+
/* translucent violet glow works over both dark and light backgrounds */
|
| 133 |
+
.stApp { background-image:
|
| 134 |
+
radial-gradient(1000px 520px at 80% -10%, rgba(124,77,255,0.16), transparent 60%); }
|
| 135 |
+
footer { visibility:hidden; } /* keep the header so the ⋮ menu + sidebar toggle work */
|
| 136 |
+
.block-container { padding-top:1.6rem; }
|
| 137 |
+
h1,h2,h3,h4 { font-weight:700; letter-spacing:-0.01em; }
|
| 138 |
+
[data-testid="stMetric"] {
|
| 139 |
+
background:rgba(212,175,55,0.07); border:1px solid rgba(212,175,55,0.45);
|
| 140 |
+
border-radius:16px; padding:16px 18px;
|
| 141 |
+
box-shadow:0 8px 30px rgba(160,120,20,0.10); backdrop-filter:blur(8px); }
|
| 142 |
+
.stButton button, .stDownloadButton button {
|
| 143 |
+
background:linear-gradient(135deg,#7C3AED,#A855F7); color:#fff;
|
| 144 |
+
border:none; border-radius:10px; font-weight:600; }
|
| 145 |
+
.stTextInput input, [data-baseweb="select"] > div {
|
| 146 |
+
border:1px solid rgba(139,92,246,0.35) !important; border-radius:10px; }
|
| 147 |
+
</style>""", unsafe_allow_html=True)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# ---------------- voice ----------------
|
| 151 |
+
def play_tts(text, lang_code):
|
| 152 |
+
"""Reliable, multilingual TTS via gTTS (plays an MP3). Browser fallback if offline."""
|
| 153 |
+
try:
|
| 154 |
+
from gtts import gTTS
|
| 155 |
+
buf = io.BytesIO()
|
| 156 |
+
gTTS(text=text, lang=lang_code).write_to_fp(buf)
|
| 157 |
+
st.audio(buf.getvalue(), format="audio/mp3", autoplay=True)
|
| 158 |
+
return True
|
| 159 |
+
except Exception:
|
| 160 |
+
loc = {"en": "en-IN", "hi": "hi-IN", "kn": "kn-IN"}.get(lang_code, "en-IN")
|
| 161 |
+
safe = json.dumps(text)
|
| 162 |
+
components.html(f"""<script>
|
| 163 |
+
const u=new SpeechSynthesisUtterance({safe});u.lang="{loc}";
|
| 164 |
+
window.speechSynthesis.cancel();window.speechSynthesis.speak(u);</script>""",
|
| 165 |
+
height=0)
|
| 166 |
+
return False
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def parse_command(text):
|
| 170 |
+
low = (text or "").lower().strip()
|
| 171 |
+
out = {"speak": any(w in low for w in ["read", "speak", "say", "aloud", "tell",
|
| 172 |
+
"ಮಾತ", "ಓದ", "बोल", "पढ"])}
|
| 173 |
+
# which language to SPEAK the answer in (overrides the UI language)
|
| 174 |
+
if any(w in low for w in ["hindi", "हिंदी", "हिन्दी", "हिंदी में"]):
|
| 175 |
+
out["say_lang"] = "hi"
|
| 176 |
+
elif any(w in low for w in ["kannada", "ಕನ್ನಡ", "kannad"]):
|
| 177 |
+
out["say_lang"] = "kn"
|
| 178 |
+
elif "english" in low:
|
| 179 |
+
out["say_lang"] = "en"
|
| 180 |
+
m = re.search(r"(?:top|ಮೇಲಿನ|शीर्ष)\s*(\d+)", low)
|
| 181 |
+
if m:
|
| 182 |
+
out["topn"] = max(5, min(50, int(m.group(1))))
|
| 183 |
+
if any(w in low for w in ["worst", "high impact", "critical", "severe",
|
| 184 |
+
"ಕೆಟ್ಟ", "खराब", "गंभीर"]):
|
| 185 |
+
out["min_cii"] = 80
|
| 186 |
+
return out
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def speak_summary(rows, say_lang, n):
|
| 190 |
+
"""Build a spoken summary of the top-n zones in the requested language."""
|
| 191 |
+
rows = rows.head(n)
|
| 192 |
+
if say_lang == "hi":
|
| 193 |
+
parts = [f"शीर्ष {len(rows)} क्षेत्र।"]
|
| 194 |
+
for i, (_, r) in enumerate(rows.iterrows(), 1):
|
| 195 |
+
parts.append(f"{i}. {r['location'].split(',')[0]}, "
|
| 196 |
+
f"सी आई आई {r['cii']:.0f}, {int(r['n_violations'])} उल्लंघन।")
|
| 197 |
+
return " ".join(parts)
|
| 198 |
+
if say_lang == "kn":
|
| 199 |
+
parts = [f"ಮೇಲಿನ {len(rows)} ಪ್ರದೇಶಗಳು."]
|
| 200 |
+
for i, (_, r) in enumerate(rows.iterrows(), 1):
|
| 201 |
+
parts.append(f"{i}. {r['location'].split(',')[0]}, "
|
| 202 |
+
f"ಸಿ ಐ ಐ {r['cii']:.0f}, {int(r['n_violations'])} ಉಲ್ಲಂಘನೆ.")
|
| 203 |
+
return " ".join(parts)
|
| 204 |
+
nums = ["one", "two", "three", "four", "five", "six", "seven", "eight"]
|
| 205 |
+
parts = [f"Top {len(rows)} zones."]
|
| 206 |
+
for i, (_, r) in enumerate(rows.iterrows()):
|
| 207 |
+
label = nums[i] if i < len(nums) else str(i + 1)
|
| 208 |
+
parts.append(f"{label}: {r['location'].split(',')[0]}, "
|
| 209 |
+
f"C I I {r['cii']:.0f}, {int(r['n_violations'])} violations.")
|
| 210 |
+
return " ".join(parts)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
# ---------------- plotly helpers (let theme="streamlit" adapt to light/dark) ----------------
|
| 214 |
+
def _layout(fig, height=240, title=None):
|
| 215 |
+
fig.update_layout(height=height, margin=dict(l=10, r=10, t=36 if title else 8, b=8),
|
| 216 |
+
title=dict(text=title, font=dict(size=14)) if title else None,
|
| 217 |
+
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
|
| 218 |
+
showlegend=False)
|
| 219 |
+
return fig
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def line_chart(daily, title, mark_last=False):
|
| 223 |
+
fig = go.Figure(go.Scatter(x=daily["label"], y=daily["value"], mode="lines",
|
| 224 |
+
line=dict(color=VIOLET, width=2.5), fill="tozeroy",
|
| 225 |
+
fillcolor="rgba(139,92,246,0.22)"))
|
| 226 |
+
if mark_last and len(daily):
|
| 227 |
+
row = daily.iloc[-1]
|
| 228 |
+
fig.add_trace(go.Scatter(x=[row["label"]], y=[row["value"]], mode="markers",
|
| 229 |
+
marker=dict(color="#F472B6", size=13)))
|
| 230 |
+
fig.update_xaxes(showgrid=False)
|
| 231 |
+
return _layout(fig, title=title)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def bar_chart(d, title, ramp=False):
|
| 235 |
+
colors = [cii_to_hex(v / (max(d["value"]) or 1) * 100) for v in d["value"]] if ramp else VIOLET
|
| 236 |
+
fig = go.Figure(go.Bar(x=d["label"], y=d["value"], marker_color=colors))
|
| 237 |
+
fig.update_xaxes(showgrid=False)
|
| 238 |
+
return _layout(fig, title=title)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def rainbow_gauge(value, title):
|
| 242 |
+
stops = [(0.0, (34, 211, 238)), (0.4, (52, 211, 153)),
|
| 243 |
+
(0.7, (250, 204, 21)), (1.0, (244, 114, 182))]
|
| 244 |
+
|
| 245 |
+
def lerp(tt):
|
| 246 |
+
for i in range(len(stops) - 1):
|
| 247 |
+
t0, c0 = stops[i]
|
| 248 |
+
t1, c1 = stops[i + 1]
|
| 249 |
+
if tt <= t1:
|
| 250 |
+
f = (tt - t0) / (t1 - t0 + 1e-9)
|
| 251 |
+
return tuple(int(c0[j] + (c1[j] - c0[j]) * f) for j in range(3))
|
| 252 |
+
return stops[-1][1]
|
| 253 |
+
|
| 254 |
+
seg = 28
|
| 255 |
+
steps = [{"range": [i / seg * 100, (i + 1) / seg * 100],
|
| 256 |
+
"color": f"rgb{lerp((i + 0.5) / seg)}"} for i in range(seg)]
|
| 257 |
+
fig = go.Figure(go.Indicator(
|
| 258 |
+
mode="gauge+number", value=value, number={"suffix": "%", "font": {"size": 38}},
|
| 259 |
+
gauge={"axis": {"range": [0, 100], "tickwidth": 0},
|
| 260 |
+
"bar": {"color": "rgba(255,255,255,0)"}, "borderwidth": 0, "steps": steps}))
|
| 261 |
+
return _layout(fig, height=250, title=title)
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
# ---------------- gradient KPI card + donut ----------------
|
| 265 |
+
def sparkline_svg(series, color, w=120, h=36):
|
| 266 |
+
if not series or len(series) < 2:
|
| 267 |
+
return ""
|
| 268 |
+
mn, mx = min(series), max(series)
|
| 269 |
+
rng = (mx - mn) or 1
|
| 270 |
+
pts = " ".join(
|
| 271 |
+
f"{i/(len(series)-1)*w:.1f},{h - (v-mn)/rng*(h-7) - 4:.1f}"
|
| 272 |
+
for i, v in enumerate(series))
|
| 273 |
+
last = pts.split()[-1]
|
| 274 |
+
return (f"<svg width='{w}' height='{h}' viewBox='0 0 {w} {h}' "
|
| 275 |
+
f"preserveAspectRatio='none'><polyline points='{pts}' fill='none' "
|
| 276 |
+
f"stroke='{color}' stroke-width='2' stroke-linecap='round' "
|
| 277 |
+
f"stroke-linejoin='round'/><circle cx='{last.split(',')[0]}' "
|
| 278 |
+
f"cy='{last.split(',')[1]}' r='2.6' fill='{color}'/></svg>")
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def bars_svg(vals, colors, w=120, h=36):
|
| 282 |
+
mx = max(vals) or 1
|
| 283 |
+
bw = w / (len(vals) * 1.7)
|
| 284 |
+
gap = bw * 0.7
|
| 285 |
+
rects = "".join(
|
| 286 |
+
f"<rect x='{i*(bw+gap)+gap:.1f}' y='{h-(v/mx)*(h-5)-2:.1f}' width='{bw:.1f}' "
|
| 287 |
+
f"height='{(v/mx)*(h-5):.1f}' rx='2' fill='{colors[i]}'/>"
|
| 288 |
+
for i, v in enumerate(vals))
|
| 289 |
+
return f"<svg width='{w}' height='{h}' viewBox='0 0 {w} {h}'>{rects}</svg>"
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def _delta(series, lower_is_better=True):
|
| 293 |
+
if not series or len(series) < 14:
|
| 294 |
+
return ""
|
| 295 |
+
k = min(30, len(series) // 2)
|
| 296 |
+
recent = sum(series[-k:]) / k
|
| 297 |
+
prior = sum(series[-2 * k:-k]) / k
|
| 298 |
+
if prior == 0:
|
| 299 |
+
return ""
|
| 300 |
+
pct = (recent - prior) / prior * 100
|
| 301 |
+
up = pct >= 0
|
| 302 |
+
good = (not up) if lower_is_better else up
|
| 303 |
+
color = "#34D399" if good else "#F87171"
|
| 304 |
+
return (f"<span style='color:{color};font-weight:600;'>{'▲' if up else '▼'} "
|
| 305 |
+
f"{abs(pct):.1f}%</span> <span style='opacity:0.55;font-size:0.72rem;'>"
|
| 306 |
+
f"vs prev {k}d</span>")
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def kpi_card(icon, label, value, accent=VIOLET, series=None, viz="spark",
|
| 310 |
+
sub=None, lower_is_better=True):
|
| 311 |
+
if viz == "bars" and series:
|
| 312 |
+
chart = bars_svg(series, ["rgba(170,170,190,0.30)", accent])
|
| 313 |
+
elif series:
|
| 314 |
+
chart = sparkline_svg(series, accent)
|
| 315 |
+
else:
|
| 316 |
+
chart = ""
|
| 317 |
+
delta = sub if sub else _delta(series, lower_is_better)
|
| 318 |
+
delta_html = (f"<div style='font-size:0.78rem;margin-top:7px;'>{delta}</div>"
|
| 319 |
+
if delta else "")
|
| 320 |
+
st.markdown(
|
| 321 |
+
f"<div style='background:rgba(139,92,246,0.06);"
|
| 322 |
+
f"border:1px solid rgba(139,92,246,0.28);border-radius:16px;"
|
| 323 |
+
f"padding:16px 18px;height:152px;box-shadow:0 6px 20px rgba(80,40,160,0.10);'>"
|
| 324 |
+
f"<div style='display:flex;justify-content:space-between;align-items:flex-start;'>"
|
| 325 |
+
f"<div style='width:36px;height:36px;border-radius:10px;background:{accent}26;"
|
| 326 |
+
f"display:flex;align-items:center;justify-content:center;font-size:18px;'>{icon}</div>"
|
| 327 |
+
f"<div>{chart}</div></div>"
|
| 328 |
+
f"<div style='font-size:0.82rem;opacity:0.7;margin-top:10px;'>{label}</div>"
|
| 329 |
+
f"<div style='font-size:1.9rem;font-weight:800;line-height:1.1;'>{value}</div>"
|
| 330 |
+
f"{delta_html}</div>", unsafe_allow_html=True)
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def donut(labels, values, title):
|
| 334 |
+
fig = go.Figure(go.Pie(labels=list(labels), values=list(values), hole=0.62,
|
| 335 |
+
marker=dict(colors=DONUT_COLORS), textinfo="percent",
|
| 336 |
+
textfont=dict(color="#fff", size=12), sort=True))
|
| 337 |
+
fig.update_layout(height=300, margin=dict(l=10, r=10, t=42, b=10),
|
| 338 |
+
title=dict(text=title, font=dict(size=14)),
|
| 339 |
+
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
|
| 340 |
+
legend=dict(orientation="v", x=1, y=0.5, font=dict(size=11)))
|
| 341 |
+
return fig
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def active_theme():
|
| 345 |
+
"""Active theme ('light'/'dark'); defaults to dark on first run / older Streamlit."""
|
| 346 |
+
try:
|
| 347 |
+
tp = st.context.theme.type
|
| 348 |
+
return tp if tp in ("light", "dark") else "dark"
|
| 349 |
+
except Exception:
|
| 350 |
+
return "dark"
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
# ==================== APP ====================
|
| 354 |
+
inject_css()
|
| 355 |
+
require_login() # comment this line out to disable the login gate
|
| 356 |
+
hot, fc, off, meta = load()
|
| 357 |
+
THEME = active_theme()
|
| 358 |
+
ACCENT = "#FACC15" if THEME == "dark" else "#2563EB" # gold text -> yellow (dark) / blue (light)
|
| 359 |
+
area_vocab = build_area_vocab(hot)
|
| 360 |
+
hot["fill"] = hot["cii"].apply(cii_color)
|
| 361 |
+
mm = meta["model_metrics"]
|
| 362 |
+
|
| 363 |
+
NAV_KEYS = ["tab_map", "tab_ops", "tab_trends", "tab_rank", "tab_off", "tab_fc"]
|
| 364 |
+
NAV_ICONS = ["geo-alt-fill", "broadcast", "graph-up", "list-check",
|
| 365 |
+
"exclamation-triangle-fill", "magic"]
|
| 366 |
+
|
| 367 |
+
with st.sidebar:
|
| 368 |
+
st.markdown(
|
| 369 |
+
"<div style='display:flex;align-items:center;gap:11px;margin:2px 0 14px 0;'>"
|
| 370 |
+
"<div style='width:42px;height:42px;border-radius:12px;"
|
| 371 |
+
"background:linear-gradient(135deg,#7C3AED,#EC4899);color:#fff;font-weight:800;"
|
| 372 |
+
"font-size:23px;display:flex;align-items:center;justify-content:center;"
|
| 373 |
+
"box-shadow:0 4px 16px rgba(124,77,255,0.45);'>D</div>"
|
| 374 |
+
"<div><div style='font-weight:800;font-size:1.18rem;line-height:1.05;'>DRISHTI</div>"
|
| 375 |
+
"<div style='font-size:0.7rem;opacity:0.6;letter-spacing:0.02em;'>"
|
| 376 |
+
"दृष्टि · Bengaluru Traffic Police</div></div></div>",
|
| 377 |
+
unsafe_allow_html=True)
|
| 378 |
+
if st.session_state.get("user"):
|
| 379 |
+
lo1, lo2 = st.columns([2, 1])
|
| 380 |
+
lo1.markdown(
|
| 381 |
+
f"<div style='background:rgba(139,92,246,0.12);border-radius:10px;"
|
| 382 |
+
f"padding:7px 11px;font-size:0.82rem;'>👤 <b>{st.session_state['user']}</b>"
|
| 383 |
+
f"</div>", unsafe_allow_html=True)
|
| 384 |
+
if lo2.button("Log out", use_container_width=True):
|
| 385 |
+
st.session_state.clear()
|
| 386 |
+
st.query_params.clear()
|
| 387 |
+
st.rerun()
|
| 388 |
+
st.markdown("<div style='height:8px;'></div>", unsafe_allow_html=True)
|
| 389 |
+
lang_label = st.selectbox("🌐 " + t("language", "en"), list(LANGS.keys()))
|
| 390 |
+
lang = LANGS[lang_label]
|
| 391 |
+
st.markdown("<div style='font-size:0.7rem;font-weight:700;letter-spacing:0.08em;"
|
| 392 |
+
"opacity:0.45;margin:10px 0 2px 2px;'>NAVIGATION</div>",
|
| 393 |
+
unsafe_allow_html=True)
|
| 394 |
+
labels = [re.sub(r"^[^\w]+", "", t(k, lang)).strip() for k in NAV_KEYS]
|
| 395 |
+
if HAS_MENU:
|
| 396 |
+
choice = option_menu(
|
| 397 |
+
None, labels, icons=NAV_ICONS, default_index=0,
|
| 398 |
+
styles={"container": {"background-color": "transparent", "padding": "2px 0"},
|
| 399 |
+
"icon": {"color": "#9B8FC2", "font-size": "15px"},
|
| 400 |
+
"nav-link": {"color": "#9B8FC2", "font-size": "14px",
|
| 401 |
+
"border-radius": "10px", "margin": "3px 0",
|
| 402 |
+
"--hover-color": "rgba(139,92,246,0.15)"},
|
| 403 |
+
"nav-link-selected": {"background-color": VIOLET, "color": "#fff",
|
| 404 |
+
"font-weight": "600"}})
|
| 405 |
+
else:
|
| 406 |
+
st.caption("⚠️ run `pip install -r requirements.txt` for the icon nav")
|
| 407 |
+
choice = st.radio("Navigate", labels, label_visibility="collapsed")
|
| 408 |
+
section = NAV_KEYS[labels.index(choice)]
|
| 409 |
+
|
| 410 |
+
st.divider()
|
| 411 |
+
st.markdown("<div style='font-size:0.72rem;font-weight:700;letter-spacing:0.06em;"
|
| 412 |
+
"opacity:0.5;margin-bottom:6px;'>🎨 THEME</div>", unsafe_allow_html=True)
|
| 413 |
+
_cur = st.query_params.get("theme", "system")
|
| 414 |
+
_keep = "auth=1&" if st.session_state.get("authed") else ""
|
| 415 |
+
_seg = [("system", "◐", "System"), ("light", "☀", "Light"), ("dark", "🌙", "Dark")]
|
| 416 |
+
_html = ("<div style='display:flex;gap:4px;background:rgba(139,92,246,0.10);"
|
| 417 |
+
"border:1px solid rgba(139,92,246,0.25);border-radius:11px;padding:4px;'>")
|
| 418 |
+
for _val, _ic, _lab in _seg:
|
| 419 |
+
_active = (_cur == _val)
|
| 420 |
+
_href = "?" + _keep + ("" if _val == "system" else f"theme={_val}")
|
| 421 |
+
_href = _href.rstrip("&") or "?"
|
| 422 |
+
_style = ("background:#8B5CF6;color:#fff;box-shadow:0 2px 8px rgba(139,92,246,0.4);"
|
| 423 |
+
if _active else "color:#9B8FC2;")
|
| 424 |
+
_html += (f"<a href='{_href}' target='_self' style='flex:1;text-align:center;"
|
| 425 |
+
f"padding:7px 2px;border-radius:8px;text-decoration:none;line-height:1.25;"
|
| 426 |
+
f"font-size:0.7rem;font-weight:600;{_style}'>"
|
| 427 |
+
f"<div style='font-size:1.05rem;'>{_ic}</div>{_lab}</a>")
|
| 428 |
+
_html += "</div>"
|
| 429 |
+
st.markdown(_html, unsafe_allow_html=True)
|
| 430 |
+
st.divider()
|
| 431 |
+
if "history" not in st.session_state:
|
| 432 |
+
st.session_state.history = []
|
| 433 |
+
st.markdown("**" + t("history", lang) + "**")
|
| 434 |
+
if st.session_state.history:
|
| 435 |
+
for h in reversed(st.session_state.history[-8:]):
|
| 436 |
+
st.caption(f"• {h['q']} _( {h['t']} )_")
|
| 437 |
+
if st.button(t("clear_history", lang)):
|
| 438 |
+
st.session_state.history = []
|
| 439 |
+
st.rerun()
|
| 440 |
+
else:
|
| 441 |
+
st.caption(t("no_history", lang))
|
| 442 |
+
st.markdown(
|
| 443 |
+
f"<div style='margin-top:14px;background:rgba(52,211,153,0.10);"
|
| 444 |
+
f"border:1px solid rgba(52,211,153,0.3);border-radius:12px;padding:10px 12px;"
|
| 445 |
+
f"font-size:0.76rem;'>🟢 <b>Live</b> · {meta['n_cells']:,} zones monitored<br>"
|
| 446 |
+
f"<span style='opacity:0.65;'>Forecast MAE {mm['valid_mae']} · "
|
| 447 |
+
f"↓{mm.get('improvement_pct','')}% vs baseline</span></div>",
|
| 448 |
+
unsafe_allow_html=True)
|
| 449 |
+
|
| 450 |
+
# ----- header + KPIs (always) -----
|
| 451 |
+
st.markdown(
|
| 452 |
+
"<h1 style='line-height:1.55;margin:0 0 2px 0;padding-top:0.14em;font-size:2.55rem;"
|
| 453 |
+
"font-weight:800;letter-spacing:-0.01em;'>दृष्टि — DRISHTI</h1>",
|
| 454 |
+
unsafe_allow_html=True)
|
| 455 |
+
st.markdown(
|
| 456 |
+
f"<div style='color:{ACCENT};font-weight:600;font-size:1.02rem;'>"
|
| 457 |
+
"Digital Real-time Intelligence for Smart Hotspot & Traffic Insights</div>",
|
| 458 |
+
unsafe_allow_html=True)
|
| 459 |
+
st.caption("हर सड़क पर नज़र, हर सफ़र आसान · "
|
| 460 |
+
f"Bengaluru · {meta['date_range'][0]} → {meta['date_range'][1]} · "
|
| 461 |
+
f"{meta['n_records']:,} violations · {meta['n_cells']:,} zones")
|
| 462 |
+
|
| 463 |
+
sp = meta.get("kpi_sparks", {})
|
| 464 |
+
kc = st.columns(4)
|
| 465 |
+
with kc[0]:
|
| 466 |
+
kpi_card("🚗", t("kpi_violations", lang), f"{meta['n_records']:,}",
|
| 467 |
+
accent="#8B5CF6", series=sp.get("violations"))
|
| 468 |
+
with kc[1]:
|
| 469 |
+
kpi_card("📍", t("kpi_zones", lang), f"{meta['n_cells']:,}",
|
| 470 |
+
accent="#22D3EE", series=sp.get("zones"))
|
| 471 |
+
with kc[2]:
|
| 472 |
+
kpi_card("🔥", t("kpi_high", lang), f"{int((hot.cii >= 70).sum()):,}",
|
| 473 |
+
accent=ACCENT, series=sp.get("peak"))
|
| 474 |
+
with kc[3]:
|
| 475 |
+
bl = mm.get("baseline_lag7_mae", 1.0)
|
| 476 |
+
kpi_card("🎯", t("kpi_mae", lang), mm["valid_mae"], accent="#34D399",
|
| 477 |
+
series=[bl, mm["valid_mae"]], viz="bars",
|
| 478 |
+
sub=(f"<span style='color:#34D399;font-weight:600;'>↓ "
|
| 479 |
+
f"{mm['improvement_pct']}% vs baseline</span>"
|
| 480 |
+
if mm.get("improvement_pct") else None))
|
| 481 |
+
st.write("")
|
| 482 |
+
|
| 483 |
+
# ----- voice / command bar (always) -----
|
| 484 |
+
st.subheader("🎙️ " + t("voice_nav", lang))
|
| 485 |
+
cv1, cv2 = st.columns([3, 1])
|
| 486 |
+
with cv1:
|
| 487 |
+
typed = st.text_input(t("ask", lang), placeholder=t("placeholder", lang))
|
| 488 |
+
spoken = None
|
| 489 |
+
with cv2:
|
| 490 |
+
st.write("")
|
| 491 |
+
if HAS_MIC:
|
| 492 |
+
spoken = speech_to_text(language=SPEECH_LANG.get(lang, "en-IN"),
|
| 493 |
+
start_prompt=t("speak", lang), stop_prompt=t("stop", lang),
|
| 494 |
+
just_once=True, use_container_width=True, key="stt")
|
| 495 |
+
mic_status = ("🎤 mic ready (use Chrome, allow the mic, stay online)" if HAS_MIC
|
| 496 |
+
else "⚠️ mic component missing — run `pip install -r requirements.txt`")
|
| 497 |
+
st.caption(mic_status + " · 🔊 read-aloud works in English / Kannada / Hindi")
|
| 498 |
+
|
| 499 |
+
query = spoken or typed
|
| 500 |
+
if query:
|
| 501 |
+
cmd = parse_command(query)
|
| 502 |
+
res = hot.copy()
|
| 503 |
+
area = resolve_area(query, lang, area_vocab)
|
| 504 |
+
if area:
|
| 505 |
+
res = res[res["location"].str.contains(re.escape(area), case=False, na=False)]
|
| 506 |
+
if "min_cii" in cmd:
|
| 507 |
+
res = res[res["cii"] >= cmd["min_cii"]]
|
| 508 |
+
res = res.head(cmd.get("topn", 10))
|
| 509 |
+
st.session_state.history.append(
|
| 510 |
+
{"q": query, "t": datetime.now().strftime("%H:%M"), "lang": lang,
|
| 511 |
+
"area": area or "", "results": len(res)})
|
| 512 |
+
tag = f" → {area}" if area else ""
|
| 513 |
+
st.markdown(f"**{t('understood', lang)}:** _{query}_{tag} ({len(res)})")
|
| 514 |
+
st.dataframe(res[["cii_rank", "cii", "location", "junction_name", "n_violations",
|
| 515 |
+
"top_violation"]].rename(columns={
|
| 516 |
+
"cii_rank": "Rank", "cii": "CII", "location": "Location",
|
| 517 |
+
"junction_name": "Junction", "n_violations": "Violations",
|
| 518 |
+
"top_violation": "Top violation"}), use_container_width=True, hide_index=True)
|
| 519 |
+
if len(res):
|
| 520 |
+
say_lang = cmd.get("say_lang", lang) # query language overrides UI language
|
| 521 |
+
speak_n = min(len(res), cmd.get("topn", 3), 8)
|
| 522 |
+
summary = speak_summary(res, say_lang, speak_n)
|
| 523 |
+
lang_name = {"en": "English", "hi": "Hindi", "kn": "Kannada"}[say_lang]
|
| 524 |
+
if cmd.get("speak"):
|
| 525 |
+
play_tts(summary, say_lang)
|
| 526 |
+
if st.button(f"🔊 Read aloud ({lang_name})", key="read_btn"):
|
| 527 |
+
play_tts(summary, say_lang)
|
| 528 |
+
|
| 529 |
+
st.divider()
|
| 530 |
+
|
| 531 |
+
# ==================== sections ====================
|
| 532 |
+
if section == "tab_map":
|
| 533 |
+
min_cii = st.slider(t("min_cii", lang), 0, 100, 40, 5)
|
| 534 |
+
view = hot[hot.cii >= min_cii]
|
| 535 |
+
st.caption(f"{len(view):,} / {len(hot):,}")
|
| 536 |
+
layer = pdk.Layer("H3HexagonLayer", view, pickable=True, filled=True, extruded=True,
|
| 537 |
+
get_hexagon="h3", get_fill_color="fill",
|
| 538 |
+
get_elevation="cii", elevation_scale=18, opacity=0.55)
|
| 539 |
+
st.pydeck_chart(pdk.Deck(
|
| 540 |
+
layers=[layer],
|
| 541 |
+
initial_view_state=pdk.ViewState(latitude=12.97, longitude=77.59, zoom=11, pitch=45),
|
| 542 |
+
map_style="dark",
|
| 543 |
+
tooltip={"html": "<b>CII {cii}</b> (rank #{cii_rank})<br/>{location}<br/>"
|
| 544 |
+
"<b>{n_violations}</b> violations · <b>{active_days}</b> days<br/>"
|
| 545 |
+
"Top: {top_violation}"},
|
| 546 |
+
), use_container_width=True, height=560)
|
| 547 |
+
|
| 548 |
+
elif section == "tab_ops":
|
| 549 |
+
st.subheader("🚨 Live operations — congestion alerts & enforcement")
|
| 550 |
+
st.caption("⚠️ Simulated live feed: historical hotspots replayed as real-time alerts. "
|
| 551 |
+
"In production this is driven by live ANPR / e-challan feeds, with push "
|
| 552 |
+
"notifications to field officers and control-room displays.")
|
| 553 |
+
|
| 554 |
+
thr = st.slider("Trigger an alert when CII ≥", 50, 100, 75, 5)
|
| 555 |
+
alerts = (hot[hot.cii >= thr].sort_values("cii", ascending=False)
|
| 556 |
+
.head(20).reset_index(drop=True))
|
| 557 |
+
|
| 558 |
+
def rec_action(c):
|
| 559 |
+
if c >= 88:
|
| 560 |
+
return "🚨 Deploy patrol + initiate towing"
|
| 561 |
+
if c >= 78:
|
| 562 |
+
return "⚠️ On-spot enforcement / challan drive"
|
| 563 |
+
return "👁 Monitor + advisory signage"
|
| 564 |
+
alerts["Recommended action"] = alerts["cii"].apply(rec_action)
|
| 565 |
+
|
| 566 |
+
if st.checkbox("🔴 Live mode (auto-refresh)") and HAS_AUTOREFRESH and len(alerts):
|
| 567 |
+
n = st_autorefresh(interval=2500, key="ops")
|
| 568 |
+
latest = alerts.iloc[n % len(alerts)]
|
| 569 |
+
st.markdown(
|
| 570 |
+
"<div style='background:linear-gradient(135deg,#EF4444,#EC4899);"
|
| 571 |
+
"border-radius:14px;padding:14px 18px;color:#fff;font-weight:600;'>"
|
| 572 |
+
f"🔴 LIVE · {datetime.now().strftime('%H:%M:%S')} · "
|
| 573 |
+
f"{latest['location'].split(',')[0]} · CII {latest['cii']:.0f} · "
|
| 574 |
+
f"{latest['Recommended action']}</div>", unsafe_allow_html=True)
|
| 575 |
+
st.write("")
|
| 576 |
+
|
| 577 |
+
m = st.columns(3)
|
| 578 |
+
m[0].metric("Active alerts", len(alerts))
|
| 579 |
+
m[1].metric("Critical (CII ≥ 88)", int((alerts.cii >= 88).sum()))
|
| 580 |
+
m[2].metric("Zones monitored", f"{len(hot):,}")
|
| 581 |
+
|
| 582 |
+
st.dataframe(alerts[["cii_rank", "cii", "location", "junction_name", "n_violations",
|
| 583 |
+
"top_violation", "Recommended action"]].rename(columns={
|
| 584 |
+
"cii_rank": "Rank", "cii": "CII", "location": "Location",
|
| 585 |
+
"junction_name": "Junction", "n_violations": "Violations",
|
| 586 |
+
"top_violation": "Top violation"}),
|
| 587 |
+
use_container_width=True, hide_index=True, height=340)
|
| 588 |
+
|
| 589 |
+
st.markdown("#### 🚓 Impose a regulatory action")
|
| 590 |
+
if "dispatch_log" not in st.session_state:
|
| 591 |
+
st.session_state.dispatch_log = []
|
| 592 |
+
e1, e2 = st.columns(2)
|
| 593 |
+
zone = e1.selectbox("Zone", alerts["location"].tolist() if len(alerts) else ["—"])
|
| 594 |
+
act = e2.selectbox("Action", ["Deploy patrol", "Issue no-parking enforcement",
|
| 595 |
+
"Tow & fine", "Install signage / barricade",
|
| 596 |
+
"Escalate to control room", "Mark resolved"])
|
| 597 |
+
if st.button("📨 Dispatch action"):
|
| 598 |
+
st.session_state.dispatch_log.insert(0, {
|
| 599 |
+
"Time": datetime.now().strftime("%H:%M:%S"),
|
| 600 |
+
"Officer": st.session_state.get("user", "—"),
|
| 601 |
+
"Zone": zone.split(",")[0], "Action": act})
|
| 602 |
+
st.success(f"Dispatched: {act} → {zone.split(',')[0]}")
|
| 603 |
+
if st.session_state.dispatch_log:
|
| 604 |
+
st.markdown("**Dispatch log — this session**")
|
| 605 |
+
st.dataframe(pd.DataFrame(st.session_state.dispatch_log),
|
| 606 |
+
use_container_width=True, hide_index=True, height=200)
|
| 607 |
+
|
| 608 |
+
elif section == "tab_trends":
|
| 609 |
+
tr = load_trends()
|
| 610 |
+
byday = load_byday()
|
| 611 |
+
daily = tr[tr.kind == "daily"].sort_values("order").reset_index(drop=True)
|
| 612 |
+
DOW = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
| 613 |
+
|
| 614 |
+
cursor = None
|
| 615 |
+
live = st.checkbox(t("live_replay", lang), value=False)
|
| 616 |
+
if live and HAS_AUTOREFRESH:
|
| 617 |
+
n = st_autorefresh(interval=1200, key="replay")
|
| 618 |
+
cursor = n % len(daily)
|
| 619 |
+
row = daily.iloc[cursor]
|
| 620 |
+
lc1, lc2 = st.columns(2)
|
| 621 |
+
lc1.metric("Replay day", row["label"])
|
| 622 |
+
lc2.metric("Violations that day", int(row["value"]))
|
| 623 |
+
elif live and not HAS_AUTOREFRESH:
|
| 624 |
+
st.caption("Install `streamlit-autorefresh` to enable live replay.")
|
| 625 |
+
|
| 626 |
+
if cursor is not None:
|
| 627 |
+
# cumulative time-lapse for the line + animated bars
|
| 628 |
+
rday = daily.iloc[cursor]["label"]
|
| 629 |
+
dline = daily.iloc[:cursor + 1]
|
| 630 |
+
bh = (byday[(byday.dim == "hour") & (byday.date <= rday)]
|
| 631 |
+
.groupby("key")["value"].sum().reset_index())
|
| 632 |
+
bh.columns = ["label", "value"]
|
| 633 |
+
bh["order"] = bh["label"].astype(int)
|
| 634 |
+
hourly = bh.sort_values("order")
|
| 635 |
+
dd = dline.copy()
|
| 636 |
+
dd["dow"] = pd.to_datetime(dd["label"]).dt.dayofweek
|
| 637 |
+
bw = dd.groupby("dow")["value"].sum().reindex(range(7), fill_value=0)
|
| 638 |
+
dow = pd.DataFrame({"label": DOW, "value": bw.values, "order": range(7)})
|
| 639 |
+
line = line_chart(dline, t("t_daily", lang), mark_last=True)
|
| 640 |
+
else:
|
| 641 |
+
hourly = tr[tr.kind == "hourly"].sort_values("order")
|
| 642 |
+
dow = tr[tr.kind == "dow"].sort_values("order")
|
| 643 |
+
line = line_chart(daily, t("t_daily", lang))
|
| 644 |
+
|
| 645 |
+
veh = tr[tr.kind == "vehicle"].sort_values("value", ascending=False)
|
| 646 |
+
vtype = tr[tr.kind == "vtype"].sort_values("value", ascending=False)
|
| 647 |
+
|
| 648 |
+
st.plotly_chart(line, use_container_width=True)
|
| 649 |
+
r1 = st.columns(2)
|
| 650 |
+
with r1[0]:
|
| 651 |
+
st.plotly_chart(bar_chart(hourly, t("t_hourly", lang), ramp=True), use_container_width=True)
|
| 652 |
+
with r1[1]:
|
| 653 |
+
st.plotly_chart(bar_chart(dow, t("t_dow", lang)), use_container_width=True)
|
| 654 |
+
r2 = st.columns(2)
|
| 655 |
+
with r2[0]:
|
| 656 |
+
st.plotly_chart(donut(veh["label"], veh["value"], t("t_vehicle", lang)),
|
| 657 |
+
use_container_width=True)
|
| 658 |
+
with r2[1]:
|
| 659 |
+
st.plotly_chart(donut(vtype["label"], vtype["value"], t("t_vtype", lang)),
|
| 660 |
+
use_container_width=True)
|
| 661 |
+
g = st.columns([1, 2, 1])
|
| 662 |
+
with g[1]:
|
| 663 |
+
conc = round(hot.head(100)["n_violations"].sum() / meta["n_records"] * 100, 1)
|
| 664 |
+
st.plotly_chart(rainbow_gauge(conc, t("t_gauge", lang)), use_container_width=True)
|
| 665 |
+
|
| 666 |
+
elif section == "tab_rank":
|
| 667 |
+
topn = st.slider(t("top_n_zones", lang), 5, 50, 20, 5)
|
| 668 |
+
cols = ["cii_rank", "cii", "location", "junction_name", "police_station",
|
| 669 |
+
"n_violations", "active_days", "persistence", "peak_share", "top_violation"]
|
| 670 |
+
tbl = hot[cols].head(topn).copy()
|
| 671 |
+
tbl["persistence"] = (tbl["persistence"] * 100).round(0).astype(int).astype(str) + "%"
|
| 672 |
+
tbl["peak_share"] = (tbl["peak_share"] * 100).round(0).astype(int).astype(str) + "%"
|
| 673 |
+
st.dataframe(tbl.rename(columns={
|
| 674 |
+
"cii_rank": "Rank", "cii": "CII", "location": "Location",
|
| 675 |
+
"junction_name": "Nearest junction", "police_station": "Police station",
|
| 676 |
+
"n_violations": "Violations", "active_days": "Active days",
|
| 677 |
+
"persistence": "Persistence", "peak_share": "Peak share",
|
| 678 |
+
"top_violation": "Top violation"}),
|
| 679 |
+
use_container_width=True, hide_index=True, height=520)
|
| 680 |
+
st.download_button(t("dl_priorities", lang), hot[cols].head(topn).to_csv(index=False),
|
| 681 |
+
"enforcement_priorities.csv", mime="text/csv")
|
| 682 |
+
|
| 683 |
+
elif section == "tab_off":
|
| 684 |
+
s = meta.get("offender_summary", {})
|
| 685 |
+
o1, o2, o3 = st.columns(3)
|
| 686 |
+
o1.metric(t("repeat_offenders", lang), f"{s.get('repeat_offenders', 0):,}")
|
| 687 |
+
o2.metric(t("share_violations", lang), f"{s.get('repeat_share_pct', 0)}%")
|
| 688 |
+
o3.metric(t("worst_vehicle", lang), f"{s.get('worst_count', 0)}")
|
| 689 |
+
|
| 690 |
+
st.markdown("##### Offender intelligence")
|
| 691 |
+
cc = st.columns(2)
|
| 692 |
+
with cc[0]:
|
| 693 |
+
topo = off.head(12)
|
| 694 |
+
st.plotly_chart(bar_chart(
|
| 695 |
+
pd.DataFrame({"label": topo["vehicle_number"], "value": topo["n_violations"]}),
|
| 696 |
+
"Top 12 offenders · violations", ramp=True), use_container_width=True)
|
| 697 |
+
with cc[1]:
|
| 698 |
+
vt = off.groupby("vehicle_type").size().sort_values(ascending=False)
|
| 699 |
+
st.plotly_chart(donut(vt.index.tolist(), vt.values.tolist(),
|
| 700 |
+
"Offenders by vehicle type"), use_container_width=True)
|
| 701 |
+
zh = off.groupby("n_zones").size().reset_index(name="cnt").sort_values("n_zones")
|
| 702 |
+
st.plotly_chart(bar_chart(
|
| 703 |
+
pd.DataFrame({"label": zh["n_zones"].astype(str) + " zone(s)", "value": zh["cnt"]}),
|
| 704 |
+
"Spatial spread · how many distinct zones each offender hits"),
|
| 705 |
+
use_container_width=True)
|
| 706 |
+
|
| 707 |
+
q = st.text_input(t("search_vehicle", lang), key="off_search")
|
| 708 |
+
view = off
|
| 709 |
+
if q:
|
| 710 |
+
view = off[off["vehicle_number"].str.contains(q, case=False, na=False)
|
| 711 |
+
| off["top_location"].str.contains(q, case=False, na=False)]
|
| 712 |
+
n_off = st.slider(t("top_n_off", lang), 5, 100, 25, 5, key="off_n")
|
| 713 |
+
st.dataframe(view.head(n_off)[["rank", "vehicle_number", "n_violations", "n_zones",
|
| 714 |
+
"vehicle_type", "top_location", "first_seen",
|
| 715 |
+
"last_seen"]].rename(columns={
|
| 716 |
+
"rank": "Rank", "vehicle_number": "Vehicle (anon.)", "n_violations": "Violations",
|
| 717 |
+
"n_zones": "Zones hit", "vehicle_type": "Type", "top_location": "Most-seen location",
|
| 718 |
+
"first_seen": "First seen", "last_seen": "Last seen"}),
|
| 719 |
+
use_container_width=True, hide_index=True, height=460)
|
| 720 |
+
st.download_button(t("dl_offenders", lang), view.head(n_off).to_csv(index=False),
|
| 721 |
+
"repeat_offenders.csv", mime="text/csv", key="off_dl")
|
| 722 |
+
|
| 723 |
+
elif section == "tab_fc":
|
| 724 |
+
day = fc["forecast_for"].iat[0]
|
| 725 |
+
st.caption(f"{day}")
|
| 726 |
+
topf = fc.head(25).copy()
|
| 727 |
+
fc_layer = pdk.Layer("ScatterplotLayer", topf, pickable=True, get_position="[lon, lat]",
|
| 728 |
+
get_radius="pred_intensity * 6 + 60",
|
| 729 |
+
get_fill_color="[244, 114, 182, 160]")
|
| 730 |
+
st.pydeck_chart(pdk.Deck(
|
| 731 |
+
layers=[fc_layer],
|
| 732 |
+
initial_view_state=pdk.ViewState(latitude=12.97, longitude=77.59, zoom=11, pitch=0),
|
| 733 |
+
map_style="dark",
|
| 734 |
+
tooltip={"html": "Risk #{risk_rank} · {pred_intensity}<br/>{location}"},
|
| 735 |
+
), use_container_width=True, height=520)
|
| 736 |
+
st.dataframe(topf[["risk_rank", "location", "junction_name",
|
| 737 |
+
"pred_intensity", "cii"]].rename(columns={
|
| 738 |
+
"risk_rank": "Risk rank", "location": "Location", "junction_name": "Nearest junction",
|
| 739 |
+
"pred_intensity": "Predicted intensity", "cii": "Current CII"}),
|
| 740 |
+
use_container_width=True, hide_index=True)
|
| 741 |
+
|
| 742 |
+
st.divider()
|
| 743 |
+
st.caption(f"CII = severity-weighted volume (45%) + persistence (30%) + peak "
|
| 744 |
+
f"concentration (25%), amplified near junctions. Forecast: LightGBM, "
|
| 745 |
+
f"MAE {mm['valid_mae']} ({mm['improvement_pct']}% better than baseline).")
|
data/processed/forecast.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a3dc6af6e39b79339ec846f8910b3aa1474501ba5bb1c81fb15cd16b7e441707
|
| 3 |
+
size 38055
|
data/processed/hotspots.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:da19eea574bc73e04e05843033d342fba40cde8d2b4c61139aed3824e098183f
|
| 3 |
+
size 197264
|
data/processed/meta.json
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"date_range": [
|
| 3 |
+
"2023-11-10",
|
| 4 |
+
"2024-04-08"
|
| 5 |
+
],
|
| 6 |
+
"total_days": 151,
|
| 7 |
+
"n_records": 298445,
|
| 8 |
+
"n_cells": 2534,
|
| 9 |
+
"h3_res": 9,
|
| 10 |
+
"severity_weights": {
|
| 11 |
+
"PARKING IN A MAIN ROAD": 1.0,
|
| 12 |
+
"PARKING NEAR ROAD CROSSING": 0.9,
|
| 13 |
+
"PARKING NEAR TRAFFIC LIGHT OR ZEBRA CROSS": 0.9,
|
| 14 |
+
"DOUBLE PARKING": 0.85,
|
| 15 |
+
"PARKING NEAR BUSTOP/SCHOOL/HOSPITAL ETC": 0.7,
|
| 16 |
+
"PARKING OPPOSITE TO ANOTHER PARKED VEHICLE": 0.6,
|
| 17 |
+
"PARKING ON FOOTPATH": 0.5,
|
| 18 |
+
"WRONG PARKING": 0.5,
|
| 19 |
+
"PARKING OTHER THAN BUS STOP": 0.45,
|
| 20 |
+
"NO PARKING": 0.4
|
| 21 |
+
},
|
| 22 |
+
"cii_weights": {
|
| 23 |
+
"volume": 0.45,
|
| 24 |
+
"persistence": 0.3,
|
| 25 |
+
"peak": 0.25
|
| 26 |
+
},
|
| 27 |
+
"cii_junction_alpha": 0.5,
|
| 28 |
+
"offender_summary": {
|
| 29 |
+
"distinct_vehicles": 231890,
|
| 30 |
+
"repeat_offenders": 35585,
|
| 31 |
+
"repeat_share_pct": 34.2,
|
| 32 |
+
"worst_count": 55
|
| 33 |
+
},
|
| 34 |
+
"model_metrics": {
|
| 35 |
+
"valid_mae": 0.708,
|
| 36 |
+
"valid_rmse": 2.747,
|
| 37 |
+
"baseline_lag7_mae": 1.078,
|
| 38 |
+
"improvement_pct": 34.4,
|
| 39 |
+
"n_modeled_cells": 1047,
|
| 40 |
+
"best_iteration": 282
|
| 41 |
+
},
|
| 42 |
+
"kpi_sparks": {
|
| 43 |
+
"violations": [
|
| 44 |
+
1173,
|
| 45 |
+
2094,
|
| 46 |
+
2521,
|
| 47 |
+
1594,
|
| 48 |
+
2194,
|
| 49 |
+
2202,
|
| 50 |
+
2327,
|
| 51 |
+
2719,
|
| 52 |
+
2974,
|
| 53 |
+
2554,
|
| 54 |
+
1758,
|
| 55 |
+
2111,
|
| 56 |
+
2206,
|
| 57 |
+
2047,
|
| 58 |
+
1003,
|
| 59 |
+
1965,
|
| 60 |
+
2253,
|
| 61 |
+
1661,
|
| 62 |
+
2316,
|
| 63 |
+
1998,
|
| 64 |
+
1834,
|
| 65 |
+
2175,
|
| 66 |
+
1898,
|
| 67 |
+
2215,
|
| 68 |
+
1621,
|
| 69 |
+
1398,
|
| 70 |
+
1943,
|
| 71 |
+
2249,
|
| 72 |
+
2207,
|
| 73 |
+
2226,
|
| 74 |
+
2321,
|
| 75 |
+
1537,
|
| 76 |
+
2061,
|
| 77 |
+
2418,
|
| 78 |
+
2437,
|
| 79 |
+
1517,
|
| 80 |
+
1828,
|
| 81 |
+
2342,
|
| 82 |
+
1472,
|
| 83 |
+
2102,
|
| 84 |
+
2187,
|
| 85 |
+
2346,
|
| 86 |
+
1987,
|
| 87 |
+
2244,
|
| 88 |
+
2711,
|
| 89 |
+
1394,
|
| 90 |
+
1912,
|
| 91 |
+
2301,
|
| 92 |
+
2111,
|
| 93 |
+
2056,
|
| 94 |
+
1926,
|
| 95 |
+
2775,
|
| 96 |
+
1460,
|
| 97 |
+
2351,
|
| 98 |
+
1969,
|
| 99 |
+
2182,
|
| 100 |
+
2464,
|
| 101 |
+
2535,
|
| 102 |
+
2991,
|
| 103 |
+
1644,
|
| 104 |
+
2366,
|
| 105 |
+
2122,
|
| 106 |
+
2081,
|
| 107 |
+
2111,
|
| 108 |
+
2022,
|
| 109 |
+
2662,
|
| 110 |
+
1744,
|
| 111 |
+
2690,
|
| 112 |
+
2243,
|
| 113 |
+
2395,
|
| 114 |
+
2404,
|
| 115 |
+
2046,
|
| 116 |
+
2924,
|
| 117 |
+
2455,
|
| 118 |
+
2052,
|
| 119 |
+
2207,
|
| 120 |
+
2508,
|
| 121 |
+
1228,
|
| 122 |
+
1208,
|
| 123 |
+
1075,
|
| 124 |
+
1293,
|
| 125 |
+
2334,
|
| 126 |
+
1713,
|
| 127 |
+
1854,
|
| 128 |
+
1994,
|
| 129 |
+
1885,
|
| 130 |
+
1832,
|
| 131 |
+
1499,
|
| 132 |
+
2056,
|
| 133 |
+
2244,
|
| 134 |
+
1943,
|
| 135 |
+
1771,
|
| 136 |
+
1946,
|
| 137 |
+
2219,
|
| 138 |
+
1515,
|
| 139 |
+
1763,
|
| 140 |
+
1780,
|
| 141 |
+
2046,
|
| 142 |
+
2094,
|
| 143 |
+
1900,
|
| 144 |
+
2049,
|
| 145 |
+
1479,
|
| 146 |
+
1631,
|
| 147 |
+
1637,
|
| 148 |
+
1730,
|
| 149 |
+
2085,
|
| 150 |
+
2432,
|
| 151 |
+
2051,
|
| 152 |
+
1737,
|
| 153 |
+
1793,
|
| 154 |
+
1650,
|
| 155 |
+
2045,
|
| 156 |
+
1587,
|
| 157 |
+
2002,
|
| 158 |
+
2106,
|
| 159 |
+
1316,
|
| 160 |
+
2002,
|
| 161 |
+
1928,
|
| 162 |
+
1758,
|
| 163 |
+
1922,
|
| 164 |
+
2054,
|
| 165 |
+
1879,
|
| 166 |
+
1113,
|
| 167 |
+
2289,
|
| 168 |
+
2099,
|
| 169 |
+
1961,
|
| 170 |
+
1938,
|
| 171 |
+
1656,
|
| 172 |
+
1708,
|
| 173 |
+
1381,
|
| 174 |
+
1497,
|
| 175 |
+
1369,
|
| 176 |
+
1503,
|
| 177 |
+
1441,
|
| 178 |
+
1543,
|
| 179 |
+
2070,
|
| 180 |
+
1274,
|
| 181 |
+
1604,
|
| 182 |
+
1887,
|
| 183 |
+
2153,
|
| 184 |
+
1570,
|
| 185 |
+
2267,
|
| 186 |
+
2576,
|
| 187 |
+
1689,
|
| 188 |
+
2175,
|
| 189 |
+
1871,
|
| 190 |
+
2037,
|
| 191 |
+
1418,
|
| 192 |
+
1872,
|
| 193 |
+
2326,
|
| 194 |
+
2044
|
| 195 |
+
],
|
| 196 |
+
"zones": [
|
| 197 |
+
157,
|
| 198 |
+
314,
|
| 199 |
+
326,
|
| 200 |
+
224,
|
| 201 |
+
359,
|
| 202 |
+
327,
|
| 203 |
+
351,
|
| 204 |
+
329,
|
| 205 |
+
343,
|
| 206 |
+
331,
|
| 207 |
+
227,
|
| 208 |
+
330,
|
| 209 |
+
319,
|
| 210 |
+
311,
|
| 211 |
+
133,
|
| 212 |
+
298,
|
| 213 |
+
295,
|
| 214 |
+
212,
|
| 215 |
+
330,
|
| 216 |
+
311,
|
| 217 |
+
319,
|
| 218 |
+
353,
|
| 219 |
+
290,
|
| 220 |
+
301,
|
| 221 |
+
206,
|
| 222 |
+
201,
|
| 223 |
+
311,
|
| 224 |
+
359,
|
| 225 |
+
338,
|
| 226 |
+
327,
|
| 227 |
+
328,
|
| 228 |
+
215,
|
| 229 |
+
332,
|
| 230 |
+
317,
|
| 231 |
+
321,
|
| 232 |
+
303,
|
| 233 |
+
307,
|
| 234 |
+
294,
|
| 235 |
+
216,
|
| 236 |
+
303,
|
| 237 |
+
358,
|
| 238 |
+
318,
|
| 239 |
+
353,
|
| 240 |
+
324,
|
| 241 |
+
333,
|
| 242 |
+
187,
|
| 243 |
+
287,
|
| 244 |
+
285,
|
| 245 |
+
333,
|
| 246 |
+
299,
|
| 247 |
+
314,
|
| 248 |
+
335,
|
| 249 |
+
209,
|
| 250 |
+
327,
|
| 251 |
+
322,
|
| 252 |
+
331,
|
| 253 |
+
312,
|
| 254 |
+
347,
|
| 255 |
+
353,
|
| 256 |
+
199,
|
| 257 |
+
329,
|
| 258 |
+
341,
|
| 259 |
+
321,
|
| 260 |
+
316,
|
| 261 |
+
341,
|
| 262 |
+
315,
|
| 263 |
+
231,
|
| 264 |
+
371,
|
| 265 |
+
321,
|
| 266 |
+
342,
|
| 267 |
+
302,
|
| 268 |
+
316,
|
| 269 |
+
316,
|
| 270 |
+
223,
|
| 271 |
+
316,
|
| 272 |
+
303,
|
| 273 |
+
322,
|
| 274 |
+
110,
|
| 275 |
+
195,
|
| 276 |
+
154,
|
| 277 |
+
189,
|
| 278 |
+
319,
|
| 279 |
+
294,
|
| 280 |
+
305,
|
| 281 |
+
309,
|
| 282 |
+
255,
|
| 283 |
+
260,
|
| 284 |
+
194,
|
| 285 |
+
316,
|
| 286 |
+
303,
|
| 287 |
+
295,
|
| 288 |
+
264,
|
| 289 |
+
235,
|
| 290 |
+
303,
|
| 291 |
+
178,
|
| 292 |
+
278,
|
| 293 |
+
286,
|
| 294 |
+
289,
|
| 295 |
+
309,
|
| 296 |
+
294,
|
| 297 |
+
293,
|
| 298 |
+
171,
|
| 299 |
+
271,
|
| 300 |
+
245,
|
| 301 |
+
252,
|
| 302 |
+
268,
|
| 303 |
+
348,
|
| 304 |
+
291,
|
| 305 |
+
241,
|
| 306 |
+
300,
|
| 307 |
+
264,
|
| 308 |
+
310,
|
| 309 |
+
277,
|
| 310 |
+
285,
|
| 311 |
+
260,
|
| 312 |
+
198,
|
| 313 |
+
287,
|
| 314 |
+
308,
|
| 315 |
+
254,
|
| 316 |
+
307,
|
| 317 |
+
270,
|
| 318 |
+
261,
|
| 319 |
+
183,
|
| 320 |
+
303,
|
| 321 |
+
295,
|
| 322 |
+
335,
|
| 323 |
+
312,
|
| 324 |
+
262,
|
| 325 |
+
237,
|
| 326 |
+
156,
|
| 327 |
+
235,
|
| 328 |
+
257,
|
| 329 |
+
273,
|
| 330 |
+
253,
|
| 331 |
+
305,
|
| 332 |
+
301,
|
| 333 |
+
185,
|
| 334 |
+
267,
|
| 335 |
+
302,
|
| 336 |
+
307,
|
| 337 |
+
274,
|
| 338 |
+
311,
|
| 339 |
+
341,
|
| 340 |
+
216,
|
| 341 |
+
336,
|
| 342 |
+
303,
|
| 343 |
+
317,
|
| 344 |
+
227,
|
| 345 |
+
294,
|
| 346 |
+
328,
|
| 347 |
+
227
|
| 348 |
+
],
|
| 349 |
+
"peak": [
|
| 350 |
+
322,
|
| 351 |
+
652,
|
| 352 |
+
982,
|
| 353 |
+
386,
|
| 354 |
+
666,
|
| 355 |
+
672,
|
| 356 |
+
511,
|
| 357 |
+
718,
|
| 358 |
+
1007,
|
| 359 |
+
826,
|
| 360 |
+
498,
|
| 361 |
+
710,
|
| 362 |
+
631,
|
| 363 |
+
589,
|
| 364 |
+
282,
|
| 365 |
+
668,
|
| 366 |
+
644,
|
| 367 |
+
678,
|
| 368 |
+
573,
|
| 369 |
+
635,
|
| 370 |
+
412,
|
| 371 |
+
552,
|
| 372 |
+
516,
|
| 373 |
+
765,
|
| 374 |
+
340,
|
| 375 |
+
529,
|
| 376 |
+
504,
|
| 377 |
+
685,
|
| 378 |
+
666,
|
| 379 |
+
713,
|
| 380 |
+
615,
|
| 381 |
+
377,
|
| 382 |
+
549,
|
| 383 |
+
574,
|
| 384 |
+
588,
|
| 385 |
+
329,
|
| 386 |
+
380,
|
| 387 |
+
677,
|
| 388 |
+
554,
|
| 389 |
+
701,
|
| 390 |
+
446,
|
| 391 |
+
566,
|
| 392 |
+
501,
|
| 393 |
+
728,
|
| 394 |
+
754,
|
| 395 |
+
408,
|
| 396 |
+
596,
|
| 397 |
+
726,
|
| 398 |
+
603,
|
| 399 |
+
452,
|
| 400 |
+
500,
|
| 401 |
+
1127,
|
| 402 |
+
602,
|
| 403 |
+
709,
|
| 404 |
+
450,
|
| 405 |
+
599,
|
| 406 |
+
687,
|
| 407 |
+
609,
|
| 408 |
+
947,
|
| 409 |
+
536,
|
| 410 |
+
777,
|
| 411 |
+
493,
|
| 412 |
+
444,
|
| 413 |
+
478,
|
| 414 |
+
479,
|
| 415 |
+
922,
|
| 416 |
+
394,
|
| 417 |
+
777,
|
| 418 |
+
733,
|
| 419 |
+
604,
|
| 420 |
+
770,
|
| 421 |
+
526,
|
| 422 |
+
933,
|
| 423 |
+
926,
|
| 424 |
+
624,
|
| 425 |
+
784,
|
| 426 |
+
644,
|
| 427 |
+
405,
|
| 428 |
+
320,
|
| 429 |
+
370,
|
| 430 |
+
371,
|
| 431 |
+
714,
|
| 432 |
+
491,
|
| 433 |
+
545,
|
| 434 |
+
465,
|
| 435 |
+
460,
|
| 436 |
+
637,
|
| 437 |
+
585,
|
| 438 |
+
581,
|
| 439 |
+
683,
|
| 440 |
+
493,
|
| 441 |
+
548,
|
| 442 |
+
481,
|
| 443 |
+
783,
|
| 444 |
+
483,
|
| 445 |
+
486,
|
| 446 |
+
467,
|
| 447 |
+
721,
|
| 448 |
+
402,
|
| 449 |
+
645,
|
| 450 |
+
555,
|
| 451 |
+
527,
|
| 452 |
+
531,
|
| 453 |
+
524,
|
| 454 |
+
491,
|
| 455 |
+
529,
|
| 456 |
+
402,
|
| 457 |
+
440,
|
| 458 |
+
518,
|
| 459 |
+
455,
|
| 460 |
+
316,
|
| 461 |
+
651,
|
| 462 |
+
364,
|
| 463 |
+
558,
|
| 464 |
+
575,
|
| 465 |
+
264,
|
| 466 |
+
468,
|
| 467 |
+
557,
|
| 468 |
+
587,
|
| 469 |
+
599,
|
| 470 |
+
527,
|
| 471 |
+
521,
|
| 472 |
+
322,
|
| 473 |
+
788,
|
| 474 |
+
488,
|
| 475 |
+
627,
|
| 476 |
+
470,
|
| 477 |
+
471,
|
| 478 |
+
443,
|
| 479 |
+
443,
|
| 480 |
+
286,
|
| 481 |
+
284,
|
| 482 |
+
447,
|
| 483 |
+
378,
|
| 484 |
+
410,
|
| 485 |
+
686,
|
| 486 |
+
324,
|
| 487 |
+
308,
|
| 488 |
+
513,
|
| 489 |
+
680,
|
| 490 |
+
543,
|
| 491 |
+
573,
|
| 492 |
+
828,
|
| 493 |
+
684,
|
| 494 |
+
633,
|
| 495 |
+
646,
|
| 496 |
+
616,
|
| 497 |
+
419,
|
| 498 |
+
592,
|
| 499 |
+
947,
|
| 500 |
+
444
|
| 501 |
+
]
|
| 502 |
+
}
|
| 503 |
+
}
|
data/processed/offenders.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2ecb4d5fa48b4a710454fa6afa3355e4d6a771c03be4d20101a8cd1cb73bc3d8
|
| 3 |
+
size 20041
|
data/processed/trends.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9e4e3d2ba5eaeb71beaf3da62eab97479f3591fff1be16c16b82c41af10a1bff
|
| 3 |
+
size 5847
|
data/processed/trends_byday.parquet
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:cae90c7bc913a452e66d3098ceb8002d1fdf4db0804186eede01006a740d9c62
|
| 3 |
+
size 15406
|
docs/methodology.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Methodology — Congestion Impact Index (CII)
|
| 2 |
+
|
| 3 |
+
## Problem framing
|
| 4 |
+
The brief asks us to *detect illegal-parking hotspots and quantify their impact
|
| 5 |
+
on traffic flow*. The dataset contains 298k+ parking-enforcement records with
|
| 6 |
+
location, time, and violation type, but **no direct traffic-speed/volume
|
| 7 |
+
measurement**. Rather than claim a flow metric we do not have, we build a
|
| 8 |
+
**transparent proxy** for flow impact, derived only from enforcement data, with
|
| 9 |
+
every weight exposed and defensible.
|
| 10 |
+
|
| 11 |
+
## Pipeline
|
| 12 |
+
1. **Clean** — keep records inside the Bengaluru bounding box with valid
|
| 13 |
+
timestamps; parse the multi-label `violation_type`; keep only parking-
|
| 14 |
+
relevant violations.
|
| 15 |
+
2. **Spatial unit** — index every record to an **H3 resolution-9 hexagon**
|
| 16 |
+
(~174 m edge ≈ a block / deployable patrol zone). Deterministic and
|
| 17 |
+
map-friendly.
|
| 18 |
+
3. **Per-cell statistics** — severity-weighted volume, active-day persistence,
|
| 19 |
+
peak-hour concentration, junction proximity.
|
| 20 |
+
4. **CII** — combine the components (below).
|
| 21 |
+
5. **Forecast** — a LightGBM model predicts next-day intensity per cell.
|
| 22 |
+
|
| 23 |
+
## Severity weighting
|
| 24 |
+
Each violation type is weighted by how much it physically blocks *moving*
|
| 25 |
+
traffic (not legal severity):
|
| 26 |
+
|
| 27 |
+
| Violation | Weight | Why |
|
| 28 |
+
|---|---|---|
|
| 29 |
+
| Parking in a main road | 1.00 | blocks the carriageway |
|
| 30 |
+
| Near road crossing / traffic light | 0.90 | blocks turning / sightlines |
|
| 31 |
+
| Double parking | 0.85 | removes a live lane |
|
| 32 |
+
| Near bus-stop / school / hospital | 0.70 | high-churn frontage |
|
| 33 |
+
| On footpath | 0.50 | pushes pedestrians into the road |
|
| 34 |
+
| Wrong parking | 0.50 | partial obstruction |
|
| 35 |
+
| No parking | 0.40 | designated-clear zone |
|
| 36 |
+
|
| 37 |
+
A record's severity is the **max** weight across its violations.
|
| 38 |
+
|
| 39 |
+
## The index
|
| 40 |
+
For each cell, with rank-normalised components in [0, 1]:
|
| 41 |
+
|
| 42 |
+
```
|
| 43 |
+
base = 0.45·volume + 0.30·persistence + 0.25·peak_concentration
|
| 44 |
+
CII = base × (1 + 0.50·junction_share) # then scaled to 0–100
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
- **Volume** — total severity-weighted violations (how bad, weighted).
|
| 48 |
+
- **Persistence** — active days ÷ total days. Separates a *chronic* daily
|
| 49 |
+
bottleneck from a one-off spike. (In this data the worst cells are active
|
| 50 |
+
~149/151 days.)
|
| 51 |
+
- **Peak concentration** — share of violations in commute windows (08–11,
|
| 52 |
+
17–20). A blockage that happens exactly at rush hour hurts flow more.
|
| 53 |
+
- **Junction proximity** — amplifies cells near junctions, where a blockage
|
| 54 |
+
cascades upstream.
|
| 55 |
+
|
| 56 |
+
All weights live in `src/config.py` and can be re-tuned in seconds.
|
| 57 |
+
|
| 58 |
+
## Forecast model
|
| 59 |
+
LightGBM regression on a (cell × day) panel. Features: cell location + junction
|
| 60 |
+
share, calendar (day-of-week, month, weekend), and **lag/rolling** features
|
| 61 |
+
(1/7/14/28-day lags, 7/28-day rolling mean & max). Target: next-day
|
| 62 |
+
severity-weighted intensity. Validated on the final 21 days, compared against a
|
| 63 |
+
naive "same weekday last week" baseline.
|
experiments/mae_objectives.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Quick check: is any other objective better than L1 on MAE? (same split)"""
|
| 2 |
+
import warnings, numpy as np, pandas as pd, lightgbm as lgb
|
| 3 |
+
from src import config
|
| 4 |
+
from src.data_prep import load_clean
|
| 5 |
+
from src.features import add_h3
|
| 6 |
+
from src.model import build_panel, FEATURES
|
| 7 |
+
warnings.filterwarnings("ignore")
|
| 8 |
+
|
| 9 |
+
df = add_h3(load_clean())
|
| 10 |
+
panel, _, all_dates = build_panel(df)
|
| 11 |
+
m = panel.dropna(subset=[f"lag_{max(config.LAGS)}"]).copy()
|
| 12 |
+
cut = all_dates.max() - pd.Timedelta(days=config.VALID_DAYS)
|
| 13 |
+
tr, va = m[m.date <= cut], m[m.date > cut]
|
| 14 |
+
yv = va["y"].values
|
| 15 |
+
base = float(np.mean(np.abs(va["lag_7"].values - yv)))
|
| 16 |
+
print(f"baseline MAE {base:.3f}\n")
|
| 17 |
+
|
| 18 |
+
def run(obj, label, extra=None):
|
| 19 |
+
p = dict(metric="mae", learning_rate=0.05, num_leaves=63, min_data_in_leaf=50,
|
| 20 |
+
feature_fraction=0.8, bagging_fraction=0.8, bagging_freq=1,
|
| 21 |
+
seed=config.RANDOM_STATE, verbose=-1, objective=obj)
|
| 22 |
+
if extra: p.update(extra)
|
| 23 |
+
d = lgb.Dataset(tr[FEATURES], tr["y"])
|
| 24 |
+
dv = lgb.Dataset(va[FEATURES], va["y"], reference=d)
|
| 25 |
+
mdl = lgb.train(p, d, 1500, valid_sets=[dv],
|
| 26 |
+
callbacks=[lgb.early_stopping(80), lgb.log_evaluation(0)])
|
| 27 |
+
pred = np.clip(mdl.predict(va[FEATURES], num_iteration=mdl.best_iteration), 0, None)
|
| 28 |
+
mae = float(np.mean(np.abs(pred - yv)))
|
| 29 |
+
print(f" {label:<34} MAE={mae:.3f} ({100*(base-mae)/base:+.1f}%)")
|
| 30 |
+
return mae
|
| 31 |
+
|
| 32 |
+
run("regression_l1", "L1 / MAE [CURRENT]")
|
| 33 |
+
run("huber", "Huber")
|
| 34 |
+
run("poisson", "Poisson")
|
| 35 |
+
for vp in (1.1, 1.3, 1.5):
|
| 36 |
+
run("tweedie", f"Tweedie (variance_power={vp})", {"tweedie_variance_power": vp})
|
| 37 |
+
run("regression_l1", "L1 + tuned (leaves 31, lr .03)",
|
| 38 |
+
{"num_leaves": 31, "learning_rate": 0.03, "min_data_in_leaf": 100, "lambda_l2": 2.0})
|
experiments/mae_tuning.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Offline experiment: can we lower validation MAE without breaking the pipeline?
|
| 2 |
+
|
| 3 |
+
Tests objective choices and extra features on the SAME time-split as production,
|
| 4 |
+
so the comparison is apples-to-apples. Prints a table; ships nothing.
|
| 5 |
+
"""
|
| 6 |
+
import warnings
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import lightgbm as lgb
|
| 10 |
+
|
| 11 |
+
from src import config
|
| 12 |
+
from src.data_prep import load_clean
|
| 13 |
+
from src.features import add_h3
|
| 14 |
+
from src.model import build_panel, FEATURES as BASE_FEATURES
|
| 15 |
+
|
| 16 |
+
warnings.filterwarnings("ignore")
|
| 17 |
+
np.random.seed(config.RANDOM_STATE)
|
| 18 |
+
|
| 19 |
+
print("Loading + indexing data ...")
|
| 20 |
+
df = add_h3(load_clean())
|
| 21 |
+
panel, keep_cells, all_dates = build_panel(df)
|
| 22 |
+
|
| 23 |
+
# ---- add candidate features on top of the production panel ----
|
| 24 |
+
panel = panel.sort_values(["h3", "date"]).reset_index(drop=True)
|
| 25 |
+
g = panel.groupby("h3")["y"]
|
| 26 |
+
yshift = g.shift(1)
|
| 27 |
+
# extra short/seasonal lags
|
| 28 |
+
for L in [2, 3, 21]:
|
| 29 |
+
panel[f"lag_{L}"] = g.shift(L)
|
| 30 |
+
# rolling std (volatility) + a mid window mean
|
| 31 |
+
for W in [7, 14, 28]:
|
| 32 |
+
panel[f"roll_std_{W}"] = (yshift.groupby(panel["h3"]).rolling(W, min_periods=2)
|
| 33 |
+
.std().reset_index(level=0, drop=True))
|
| 34 |
+
panel["roll_mean_14"] = (yshift.groupby(panel["h3"]).rolling(14, min_periods=1)
|
| 35 |
+
.mean().reset_index(level=0, drop=True))
|
| 36 |
+
# EWMA (recency-weighted level)
|
| 37 |
+
panel["ewm_7"] = (yshift.groupby(panel["h3"]).ewm(span=7, min_periods=1)
|
| 38 |
+
.mean().reset_index(level=0, drop=True))
|
| 39 |
+
# same-weekday mean over the last 4 occurrences (weekly seasonality, no leak)
|
| 40 |
+
sd = panel.groupby(["h3", "dow"])["y"].shift(1)
|
| 41 |
+
panel["samedow_mean4"] = (sd.groupby([panel["h3"], panel["dow"]])
|
| 42 |
+
.rolling(4, min_periods=1).mean()
|
| 43 |
+
.reset_index(level=[0, 1], drop=True))
|
| 44 |
+
# expanding cell mean (shifted -> no leak)
|
| 45 |
+
panel["cell_expmean"] = (g.apply(lambda s: s.shift(1).expanding().mean())
|
| 46 |
+
.reset_index(level=0, drop=True))
|
| 47 |
+
|
| 48 |
+
panel["roll_std_7"] = panel["roll_std_7"].fillna(0)
|
| 49 |
+
panel["roll_std_14"] = panel["roll_std_14"].fillna(0)
|
| 50 |
+
panel["roll_std_28"] = panel["roll_std_28"].fillna(0)
|
| 51 |
+
|
| 52 |
+
EXTRA = ["lag_2", "lag_3", "lag_21", "roll_std_7", "roll_std_14", "roll_std_28",
|
| 53 |
+
"roll_mean_14", "ewm_7", "samedow_mean4", "cell_expmean"]
|
| 54 |
+
|
| 55 |
+
model_df = panel.dropna(subset=[f"lag_{max(config.LAGS)}"]).copy()
|
| 56 |
+
cutoff = all_dates.max() - pd.Timedelta(days=config.VALID_DAYS)
|
| 57 |
+
train = model_df[model_df["date"] <= cutoff]
|
| 58 |
+
valid = model_df[model_df["date"] > cutoff]
|
| 59 |
+
yv = valid["y"].values
|
| 60 |
+
base_mae = float(np.mean(np.abs(valid["lag_7"].values - yv)))
|
| 61 |
+
print(f"rows train/valid: {len(train):,}/{len(valid):,} | "
|
| 62 |
+
f"same-weekday baseline MAE: {base_mae:.3f}\n")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def run(feats, objective, label, log=False, extra_params=None):
|
| 66 |
+
p = dict(metric="mae", learning_rate=0.05, num_leaves=63, min_data_in_leaf=50,
|
| 67 |
+
feature_fraction=0.8, bagging_fraction=0.8, bagging_freq=1,
|
| 68 |
+
seed=config.RANDOM_STATE, verbose=-1, objective=objective)
|
| 69 |
+
if extra_params:
|
| 70 |
+
p.update(extra_params)
|
| 71 |
+
ytr = np.log1p(train["y"]) if log else train["y"]
|
| 72 |
+
dtr = lgb.Dataset(train[feats], ytr)
|
| 73 |
+
dva = lgb.Dataset(valid[feats], (np.log1p(valid["y"]) if log else valid["y"]),
|
| 74 |
+
reference=dtr)
|
| 75 |
+
m = lgb.train(p, dtr, num_boost_round=1200, valid_sets=[dva],
|
| 76 |
+
callbacks=[lgb.early_stopping(80), lgb.log_evaluation(0)])
|
| 77 |
+
pred = m.predict(valid[feats], num_iteration=m.best_iteration)
|
| 78 |
+
if log:
|
| 79 |
+
pred = np.expm1(pred)
|
| 80 |
+
mae = float(np.mean(np.abs(np.clip(pred, 0, None) - yv)))
|
| 81 |
+
imp = 100 * (base_mae - mae) / base_mae
|
| 82 |
+
print(f" {label:<46} MAE={mae:.3f} ({imp:+.1f}% vs baseline) it={m.best_iteration}")
|
| 83 |
+
return mae
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
print("=== production config (reproduce) ===")
|
| 87 |
+
run(BASE_FEATURES, "regression", "L2 objective, base features [CURRENT]")
|
| 88 |
+
print("\n=== change the training objective only (base features) ===")
|
| 89 |
+
run(BASE_FEATURES, "regression_l1", "L1/MAE objective")
|
| 90 |
+
run(BASE_FEATURES, "huber", "Huber objective")
|
| 91 |
+
run(BASE_FEATURES, "fair", "Fair objective")
|
| 92 |
+
print("\n=== add features ===")
|
| 93 |
+
run(BASE_FEATURES + EXTRA, "regression", "L2 + extra features")
|
| 94 |
+
run(BASE_FEATURES + EXTRA, "regression_l1", "L1 + extra features")
|
| 95 |
+
run(BASE_FEATURES + EXTRA, "huber", "Huber + extra features")
|
| 96 |
+
print("\n=== best combo + mild regularization ===")
|
| 97 |
+
run(BASE_FEATURES + EXTRA, "regression_l1", "L1 + extra + L2 reg + smaller leaves",
|
| 98 |
+
extra_params=dict(lambda_l2=1.0, num_leaves=48, min_data_in_leaf=80))
|
| 99 |
+
run(BASE_FEATURES + EXTRA, "huber", "Huber + extra + reg",
|
| 100 |
+
extra_params=dict(lambda_l2=1.0, num_leaves=48, min_data_in_leaf=80))
|
| 101 |
+
print("\n=== log1p target variants ===")
|
| 102 |
+
run(BASE_FEATURES + EXTRA, "regression", "log1p + L2 + extra", log=True)
|
| 103 |
+
run(BASE_FEATURES + EXTRA, "regression_l1", "log1p + L1 + extra", log=True)
|
hash.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate a bcrypt hash for a password, to paste into auth_config.yaml.
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
python hash.py
|
| 5 |
+
> Password to hash: ********
|
| 6 |
+
<prints the hash>
|
| 7 |
+
|
| 8 |
+
Then replace the plain-text `password:` value in auth_config.yaml with the hash.
|
| 9 |
+
"""
|
| 10 |
+
import getpass
|
| 11 |
+
|
| 12 |
+
import streamlit_authenticator as stauth
|
| 13 |
+
|
| 14 |
+
if __name__ == "__main__":
|
| 15 |
+
pw = getpass.getpass("Password to hash: ")
|
| 16 |
+
try:
|
| 17 |
+
hashed = stauth.Hasher([pw]).generate()[0] # older API
|
| 18 |
+
except Exception:
|
| 19 |
+
hashed = stauth.Hasher.hash(pw) # newer API
|
| 20 |
+
print(hashed)
|
models/lgbm_intensity.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
requirements.txt
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit>=1.46
|
| 2 |
+
pandas>=2.0
|
| 3 |
+
numpy>=1.24
|
| 4 |
+
pyarrow>=14.0
|
| 5 |
+
h3>=4.0
|
| 6 |
+
lightgbm>=4.0
|
| 7 |
+
pydeck>=0.9
|
| 8 |
+
streamlit-mic-recorder>=0.0.8
|
| 9 |
+
indic-transliteration>=2.3
|
| 10 |
+
streamlit-autorefresh>=1.0
|
| 11 |
+
streamlit-option-menu>=0.3.6
|
| 12 |
+
gTTS>=2.5
|
| 13 |
+
plotly>=5.20
|
| 14 |
+
# --- optional login add-on (see src/auth.py) ---
|
| 15 |
+
streamlit-authenticator>=0.3.2
|
| 16 |
+
PyYAML>=6.0
|
src/__init__.py
ADDED
|
File without changes
|
src/auth.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OPTIONAL login wrapper (streamlit-authenticator).
|
| 2 |
+
|
| 3 |
+
This is NOT wired into app.py by default, so the core demo always runs. To turn
|
| 4 |
+
it on, add these two lines at the top of app.py (right after the imports):
|
| 5 |
+
|
| 6 |
+
from src.auth import require_login
|
| 7 |
+
name, username, roles = require_login()
|
| 8 |
+
|
| 9 |
+
Security notes:
|
| 10 |
+
* Passwords are bcrypt-hashed by the library (auto_hash=True). For real
|
| 11 |
+
security, pre-hash with `python hash.py` and store ONLY the hash in
|
| 12 |
+
auth_config.yaml — never commit plain-text passwords to a public repo.
|
| 13 |
+
* The login version's `login()` signature has changed across releases; this
|
| 14 |
+
reads auth state from st.session_state (the stable pattern). Pin your
|
| 15 |
+
installed version and test the flow locally.
|
| 16 |
+
"""
|
| 17 |
+
import yaml
|
| 18 |
+
from yaml.loader import SafeLoader
|
| 19 |
+
|
| 20 |
+
import streamlit as st
|
| 21 |
+
import streamlit_authenticator as stauth
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def get_authenticator(path="auth_config.yaml"):
|
| 25 |
+
with open(path) as f:
|
| 26 |
+
config = yaml.load(f, Loader=SafeLoader)
|
| 27 |
+
return stauth.Authenticate(
|
| 28 |
+
config["credentials"],
|
| 29 |
+
config["cookie"]["name"],
|
| 30 |
+
config["cookie"]["key"],
|
| 31 |
+
config["cookie"]["expiry_days"],
|
| 32 |
+
auto_hash=True,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def require_login(path="auth_config.yaml"):
|
| 37 |
+
"""Render the login widget; stop the app unless the user is authenticated.
|
| 38 |
+
|
| 39 |
+
Returns (name, username, roles) once logged in.
|
| 40 |
+
"""
|
| 41 |
+
authenticator = get_authenticator(path)
|
| 42 |
+
authenticator.login(location="main")
|
| 43 |
+
|
| 44 |
+
status = st.session_state.get("authentication_status")
|
| 45 |
+
if status is False:
|
| 46 |
+
st.error("Invalid username or password.")
|
| 47 |
+
st.stop()
|
| 48 |
+
if status is None:
|
| 49 |
+
st.info("Please log in to continue.")
|
| 50 |
+
st.stop()
|
| 51 |
+
|
| 52 |
+
authenticator.logout("Logout", "sidebar")
|
| 53 |
+
return (st.session_state.get("name"),
|
| 54 |
+
st.session_state.get("username"),
|
| 55 |
+
st.session_state.get("roles") or [])
|
src/build_artifacts.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run the full offline pipeline: raw CSV -> small artifacts the app serves.
|
| 2 |
+
|
| 3 |
+
python -m src.build_artifacts # run from the repo root
|
| 4 |
+
|
| 5 |
+
Outputs:
|
| 6 |
+
data/processed/hotspots.parquet per-cell stats + CII
|
| 7 |
+
data/processed/forecast.parquet next-day predicted intensity per cell
|
| 8 |
+
data/processed/meta.json config, date range, model metrics
|
| 9 |
+
models/lgbm_intensity.txt saved LightGBM model
|
| 10 |
+
"""
|
| 11 |
+
import json
|
| 12 |
+
|
| 13 |
+
from src import config
|
| 14 |
+
from src.data_prep import load_clean
|
| 15 |
+
from src.features import add_h3
|
| 16 |
+
from src.hotspots import build_cell_stats
|
| 17 |
+
from src.impact_index import add_cii
|
| 18 |
+
from src.model import train_and_forecast
|
| 19 |
+
from src.offenders import build_offender_stats, offender_summary
|
| 20 |
+
from src.trends import build_trends, build_byday
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def main():
|
| 24 |
+
config.DATA_PROCESSED.mkdir(parents=True, exist_ok=True)
|
| 25 |
+
config.MODELS_DIR.mkdir(parents=True, exist_ok=True)
|
| 26 |
+
|
| 27 |
+
print("[1/4] Loading & cleaning ...")
|
| 28 |
+
df = load_clean()
|
| 29 |
+
print(f" clean parking records: {len(df):,}")
|
| 30 |
+
|
| 31 |
+
df = add_h3(df)
|
| 32 |
+
total_days = (df["ts"].dt.date.max() - df["ts"].dt.date.min()).days + 1
|
| 33 |
+
|
| 34 |
+
print("[2/4] Building hotspot stats & CII ...")
|
| 35 |
+
stats = add_cii(build_cell_stats(df, total_days))
|
| 36 |
+
stats.to_parquet(config.HOTSPOTS_PARQUET, index=False)
|
| 37 |
+
print(f" {len(stats):,} cells -> {config.HOTSPOTS_PARQUET.name}")
|
| 38 |
+
|
| 39 |
+
print("[2b] Repeat-offender intelligence ...")
|
| 40 |
+
offenders = build_offender_stats(df, top_n=500)
|
| 41 |
+
offenders.to_parquet(config.OFFENDERS_PARQUET, index=False)
|
| 42 |
+
off_summary = offender_summary(df)
|
| 43 |
+
print(f" {len(offenders):,} offenders -> {config.OFFENDERS_PARQUET.name} | {off_summary}")
|
| 44 |
+
|
| 45 |
+
print("[2c] Trend aggregates ...")
|
| 46 |
+
build_trends(df).to_parquet(config.TRENDS_PARQUET, index=False)
|
| 47 |
+
build_byday(df).to_parquet(config.TRENDS_BYDAY_PARQUET, index=False)
|
| 48 |
+
print(f" -> {config.TRENDS_PARQUET.name}, {config.TRENDS_BYDAY_PARQUET.name}")
|
| 49 |
+
|
| 50 |
+
print("[3/4] Training LightGBM & forecasting ...")
|
| 51 |
+
model, forecast, metrics = train_and_forecast(df)
|
| 52 |
+
model.save_model(str(config.MODEL_PATH))
|
| 53 |
+
forecast.to_parquet(config.FORECAST_PARQUET, index=False)
|
| 54 |
+
print(f" {len(forecast):,} cells -> {config.FORECAST_PARQUET.name}")
|
| 55 |
+
print(f" metrics: {metrics}")
|
| 56 |
+
|
| 57 |
+
print("[4/4] Writing meta.json ...")
|
| 58 |
+
# small real daily series for the KPI sparklines
|
| 59 |
+
dord = sorted(df["date"].unique())
|
| 60 |
+
gd = df.groupby("date")
|
| 61 |
+
kpi_sparks = dict(
|
| 62 |
+
violations=gd["id"].size().reindex(dord, fill_value=0).astype(int).tolist(),
|
| 63 |
+
zones=gd["h3"].nunique().reindex(dord, fill_value=0).astype(int).tolist(),
|
| 64 |
+
peak=(df[df["is_peak"]].groupby("date")["id"].size()
|
| 65 |
+
.reindex(dord, fill_value=0).astype(int).tolist()),
|
| 66 |
+
)
|
| 67 |
+
meta = dict(
|
| 68 |
+
date_range=[df["ts"].dt.date.min().isoformat(),
|
| 69 |
+
df["ts"].dt.date.max().isoformat()],
|
| 70 |
+
total_days=total_days,
|
| 71 |
+
n_records=int(len(df)),
|
| 72 |
+
n_cells=int(len(stats)),
|
| 73 |
+
h3_res=config.H3_RES,
|
| 74 |
+
severity_weights=config.PARKING_SEVERITY,
|
| 75 |
+
cii_weights=config.CII_WEIGHTS,
|
| 76 |
+
cii_junction_alpha=config.CII_JUNCTION_ALPHA,
|
| 77 |
+
offender_summary=off_summary,
|
| 78 |
+
model_metrics=metrics,
|
| 79 |
+
kpi_sparks=kpi_sparks,
|
| 80 |
+
)
|
| 81 |
+
config.META_JSON.write_text(json.dumps(meta, indent=2))
|
| 82 |
+
print(f" -> {config.META_JSON.name}")
|
| 83 |
+
print("Done. You can now run: streamlit run app.py")
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
if __name__ == "__main__":
|
| 87 |
+
main()
|
src/config.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Central configuration for the parking congestion-impact pipeline.
|
| 2 |
+
|
| 3 |
+
Every tunable knob lives here so the rest of the code stays declarative and
|
| 4 |
+
the choices are transparent (useful when you defend the design to judges).
|
| 5 |
+
"""
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
# ---- Paths -----------------------------------------------------------------
|
| 9 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 10 |
+
DATA_RAW = ROOT / "data" / "raw" / "violations.csv"
|
| 11 |
+
DATA_PROCESSED = ROOT / "data" / "processed"
|
| 12 |
+
MODELS_DIR = ROOT / "models"
|
| 13 |
+
HOTSPOTS_PARQUET = DATA_PROCESSED / "hotspots.parquet"
|
| 14 |
+
FORECAST_PARQUET = DATA_PROCESSED / "forecast.parquet"
|
| 15 |
+
OFFENDERS_PARQUET = DATA_PROCESSED / "offenders.parquet"
|
| 16 |
+
TRENDS_PARQUET = DATA_PROCESSED / "trends.parquet"
|
| 17 |
+
TRENDS_BYDAY_PARQUET = DATA_PROCESSED / "trends_byday.parquet"
|
| 18 |
+
META_JSON = DATA_PROCESSED / "meta.json"
|
| 19 |
+
MODEL_PATH = MODELS_DIR / "lgbm_intensity.txt"
|
| 20 |
+
|
| 21 |
+
# ---- Geography (Bengaluru bounding box, for sanity filtering) --------------
|
| 22 |
+
LAT_MIN, LAT_MAX = 12.70, 13.40
|
| 23 |
+
LON_MIN, LON_MAX = 77.30, 77.90
|
| 24 |
+
H3_RES = 9 # ~174 m edge hexagons ~ a block / deployable patrol zone
|
| 25 |
+
|
| 26 |
+
# ---- Time ------------------------------------------------------------------
|
| 27 |
+
TZ = "Asia/Kolkata"
|
| 28 |
+
PEAK_WINDOWS = [(8, 11), (17, 20)] # morning & evening commute hours (IST)
|
| 29 |
+
|
| 30 |
+
# ---- Severity weights: how much each violation blocks MOVING traffic -------
|
| 31 |
+
# Tied to physical flow disruption, not legal severity. Tune and justify.
|
| 32 |
+
PARKING_SEVERITY = {
|
| 33 |
+
"PARKING IN A MAIN ROAD": 1.00, # blocks carriageway
|
| 34 |
+
"PARKING NEAR ROAD CROSSING": 0.90,
|
| 35 |
+
"PARKING NEAR TRAFFIC LIGHT OR ZEBRA CROSS": 0.90,
|
| 36 |
+
"DOUBLE PARKING": 0.85, # blocks a lane
|
| 37 |
+
"PARKING NEAR BUSTOP/SCHOOL/HOSPITAL ETC": 0.70,
|
| 38 |
+
"PARKING OPPOSITE TO ANOTHER PARKED VEHICLE": 0.60,
|
| 39 |
+
"PARKING ON FOOTPATH": 0.50, # pushes pedestrians to road
|
| 40 |
+
"WRONG PARKING": 0.50,
|
| 41 |
+
"PARKING OTHER THAN BUS STOP": 0.45,
|
| 42 |
+
"NO PARKING": 0.40,
|
| 43 |
+
}
|
| 44 |
+
# Any violation not in this map contributes 0 (non-parking, e.g. number plate).
|
| 45 |
+
|
| 46 |
+
# ---- Congestion Impact Index (CII) -----------------------------------------
|
| 47 |
+
CII_WEIGHTS = {"volume": 0.45, "persistence": 0.30, "peak": 0.25} # sum = 1
|
| 48 |
+
CII_JUNCTION_ALPHA = 0.50 # junction-proximity amplification strength
|
| 49 |
+
|
| 50 |
+
# ---- Modelling -------------------------------------------------------------
|
| 51 |
+
MIN_CELL_VIOLATIONS = 20 # only model cells with >= this many records
|
| 52 |
+
LAGS = [1, 7, 14, 28] # day lags for the intensity forecast
|
| 53 |
+
ROLL_WINDOWS = [7, 28] # rolling-window features
|
| 54 |
+
VALID_DAYS = 21 # last N days held out for validation
|
| 55 |
+
RANDOM_STATE = 42
|
src/data_prep.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Load and clean the raw violation data into a tidy parking-records frame."""
|
| 2 |
+
import ast
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
import pandas as pd
|
| 6 |
+
|
| 7 |
+
from src import config
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _parse_list(x):
|
| 11 |
+
"""`violation_type` arrives as a JSON-ish string like ["NO PARKING"]."""
|
| 12 |
+
if isinstance(x, list):
|
| 13 |
+
return x
|
| 14 |
+
if not isinstance(x, str) or not x.strip():
|
| 15 |
+
return []
|
| 16 |
+
try:
|
| 17 |
+
return json.loads(x)
|
| 18 |
+
except Exception:
|
| 19 |
+
try:
|
| 20 |
+
return ast.literal_eval(x)
|
| 21 |
+
except Exception:
|
| 22 |
+
return []
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def record_severity(violations):
|
| 26 |
+
"""Max flow-disruption weight across a record's violations (0 if none parking)."""
|
| 27 |
+
return max((config.PARKING_SEVERITY.get(v, 0.0) for v in violations), default=0.0)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _is_peak(hour):
|
| 31 |
+
return any(lo <= hour < hi for lo, hi in config.PEAK_WINDOWS)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def load_clean():
|
| 35 |
+
"""Return one row per parking violation with engineered time/severity fields."""
|
| 36 |
+
df = pd.read_csv(config.DATA_RAW, low_memory=False)
|
| 37 |
+
|
| 38 |
+
# --- coordinates: drop missing / out-of-Bengaluru ---
|
| 39 |
+
df = df.dropna(subset=["latitude", "longitude"])
|
| 40 |
+
df = df[df["latitude"].between(config.LAT_MIN, config.LAT_MAX)
|
| 41 |
+
& df["longitude"].between(config.LON_MIN, config.LON_MAX)].copy()
|
| 42 |
+
|
| 43 |
+
# --- timestamps -> IST ---
|
| 44 |
+
df["created_dt"] = pd.to_datetime(df["created_datetime"], errors="coerce", utc=True)
|
| 45 |
+
df = df.dropna(subset=["created_dt"]).copy()
|
| 46 |
+
df["ts"] = df["created_dt"].dt.tz_convert(config.TZ)
|
| 47 |
+
df["date"] = df["ts"].dt.date
|
| 48 |
+
df["hour"] = df["ts"].dt.hour
|
| 49 |
+
df["dow"] = df["ts"].dt.dayofweek
|
| 50 |
+
df["month"] = df["ts"].dt.month
|
| 51 |
+
df["is_peak"] = df["hour"].apply(_is_peak)
|
| 52 |
+
|
| 53 |
+
# --- violations & severity ---
|
| 54 |
+
df["violations"] = df["violation_type"].apply(_parse_list)
|
| 55 |
+
df["severity"] = df["violations"].apply(record_severity)
|
| 56 |
+
df = df[df["severity"] > 0].copy() # keep only parking-relevant records
|
| 57 |
+
|
| 58 |
+
# --- junction presence ---
|
| 59 |
+
df["junction_name"] = df["junction_name"].fillna("No Junction")
|
| 60 |
+
df["has_junction"] = (df["junction_name"].str.strip().str.lower() != "no junction")
|
| 61 |
+
|
| 62 |
+
keep = ["id", "latitude", "longitude", "location", "police_station",
|
| 63 |
+
"junction_name", "has_junction", "vehicle_type", "vehicle_number", "violations",
|
| 64 |
+
"severity", "ts", "date", "hour", "dow", "month", "is_peak"]
|
| 65 |
+
return df[keep].reset_index(drop=True)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
if __name__ == "__main__":
|
| 69 |
+
d = load_clean()
|
| 70 |
+
print(d.shape)
|
| 71 |
+
print(d.head())
|
src/features.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Spatial (H3) indexing helpers, version-robust across h3 v3 and v4."""
|
| 2 |
+
import h3
|
| 3 |
+
|
| 4 |
+
from src import config
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def latlng_to_cell(lat, lng, res=config.H3_RES):
|
| 8 |
+
if hasattr(h3, "latlng_to_cell"): # h3 v4
|
| 9 |
+
return h3.latlng_to_cell(lat, lng, res)
|
| 10 |
+
return h3.geo_to_h3(lat, lng, res) # h3 v3
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def cell_to_latlng(cell):
|
| 14 |
+
if hasattr(h3, "cell_to_latlng"): # h3 v4
|
| 15 |
+
return tuple(h3.cell_to_latlng(cell))
|
| 16 |
+
return tuple(h3.h3_to_geo(cell)) # h3 v3
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def add_h3(df):
|
| 20 |
+
"""Attach an H3 cell id to every record (the unit we aggregate hotspots over)."""
|
| 21 |
+
df = df.copy()
|
| 22 |
+
df["h3"] = [latlng_to_cell(la, lo) for la, lo in zip(df["latitude"], df["longitude"])]
|
| 23 |
+
return df
|
src/hotspots.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Aggregate cleaned records into per-H3-cell hotspot statistics."""
|
| 2 |
+
from collections import Counter
|
| 3 |
+
|
| 4 |
+
import pandas as pd
|
| 5 |
+
|
| 6 |
+
from src import config
|
| 7 |
+
from src.features import cell_to_latlng
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _mode_or_blank(series):
|
| 11 |
+
s = series.dropna()
|
| 12 |
+
return s.mode().iat[0] if not s.empty else ""
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _main_junction(series):
|
| 16 |
+
s = series.fillna("No Junction")
|
| 17 |
+
s = s[s.str.strip().str.lower() != "no junction"]
|
| 18 |
+
return s.mode().iat[0] if not s.empty else ""
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _top_violation(series):
|
| 22 |
+
c = Counter(v for lst in series for v in lst if v in config.PARKING_SEVERITY)
|
| 23 |
+
return c.most_common(1)[0][0] if c else ""
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def build_cell_stats(df, total_days):
|
| 27 |
+
"""One row per H3 cell with the components the CII is built from."""
|
| 28 |
+
g = df.groupby("h3")
|
| 29 |
+
stats = g.agg(
|
| 30 |
+
n_violations=("id", "size"),
|
| 31 |
+
weighted_volume=("severity", "sum"),
|
| 32 |
+
active_days=("date", "nunique"),
|
| 33 |
+
peak_violations=("is_peak", "sum"),
|
| 34 |
+
junction_share=("has_junction", "mean"),
|
| 35 |
+
)
|
| 36 |
+
stats["persistence"] = stats["active_days"] / float(total_days)
|
| 37 |
+
stats["peak_share"] = stats["peak_violations"] / stats["n_violations"]
|
| 38 |
+
|
| 39 |
+
# representative centroid for each hexagon (for map centring / scatter)
|
| 40 |
+
centroids = {c: cell_to_latlng(c) for c in stats.index}
|
| 41 |
+
stats["lat"] = [centroids[c][0] for c in stats.index]
|
| 42 |
+
stats["lon"] = [centroids[c][1] for c in stats.index]
|
| 43 |
+
|
| 44 |
+
# human-readable context
|
| 45 |
+
stats["location"] = g["location"].agg(_mode_or_blank)
|
| 46 |
+
stats["police_station"] = g["police_station"].agg(_mode_or_blank)
|
| 47 |
+
stats["junction_name"] = g["junction_name"].agg(_main_junction)
|
| 48 |
+
stats["top_violation"] = g["violations"].agg(_top_violation)
|
| 49 |
+
|
| 50 |
+
return stats.reset_index()
|
src/i18n.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Light i18n layer.
|
| 2 |
+
|
| 3 |
+
Two jobs:
|
| 4 |
+
1. UI string translations for English / Kannada / Hindi.
|
| 5 |
+
2. Resolve a native-script (Kannada/Hindi) place query to the English place
|
| 6 |
+
names in the data, via transliteration + fuzzy matching.
|
| 7 |
+
|
| 8 |
+
NOTE: the Kannada/Hindi UI strings are standard civic terms but should be
|
| 9 |
+
sanity-checked by a native speaker before the finale.
|
| 10 |
+
"""
|
| 11 |
+
import re
|
| 12 |
+
import difflib
|
| 13 |
+
import unicodedata
|
| 14 |
+
|
| 15 |
+
from indic_transliteration import sanscript
|
| 16 |
+
from indic_transliteration.sanscript import transliterate
|
| 17 |
+
|
| 18 |
+
# label -> code, and code -> Web Speech API locale
|
| 19 |
+
LANGS = {"English": "en", "ಕನ್ನಡ": "kn", "हिन्दी": "hi"}
|
| 20 |
+
SPEECH_LANG = {"en": "en-IN", "kn": "kn-IN", "hi": "hi-IN"}
|
| 21 |
+
|
| 22 |
+
STRINGS = {
|
| 23 |
+
"title": {
|
| 24 |
+
"en": "ParkSight — Parking-Induced Congestion Intelligence",
|
| 25 |
+
"kn": "ಪಾರ್ಕ್ಸೈಟ್ — ಪಾರ್ಕಿಂಗ್ ದಟ್ಟಣೆ ವಿಶ್ಲೇಷಣೆ",
|
| 26 |
+
"hi": "पार्कसाइट — पार्किंग जनित भीड़ विश्लेषण"},
|
| 27 |
+
"kpi_violations": {"en": "Parking violations", "kn": "ಪಾರ್ಕಿಂಗ್ ಉಲ್ಲಂಘನೆಗಳು",
|
| 28 |
+
"hi": "पार्किंग उल्लंघन"},
|
| 29 |
+
"kpi_zones": {"en": "Impact zones", "kn": "ಪ್ರಭಾವ ವಲಯಗಳು", "hi": "प्रभाव क्षेत्र"},
|
| 30 |
+
"kpi_high": {"en": "High-impact (CII ≥ 70)", "kn": "ಹೆಚ್ಚು-ಪ್ರಭಾವ (CII ≥ 70)",
|
| 31 |
+
"hi": "उच्च-प्रभाव (CII ≥ 70)"},
|
| 32 |
+
"kpi_mae": {"en": "Forecast MAE", "kn": "ಮುನ್ಸೂಚನೆ MAE", "hi": "पूर्वानुमान MAE"},
|
| 33 |
+
"voice_nav": {"en": "Voice / command navigation", "kn": "ಧ್ವನಿ / ಆದೇಶ ಸಂಚಲನೆ",
|
| 34 |
+
"hi": "वॉइस / कमांड नेविगेशन"},
|
| 35 |
+
"ask": {"en": "Ask in plain language", "kn": "ಕನ್ನಡದಲ್ಲಿ ಹುಡುಕಿ", "hi": "हिंदी में खोजें"},
|
| 36 |
+
"placeholder": {"en": "e.g. 'show worst zones in Shivaji Nagar' · 'read top 5'",
|
| 37 |
+
"kn": "ಉದಾ: 'ಶಿವಾಜಿನಗರದ ಕೆಟ್ಟ ವಲಯಗಳು'",
|
| 38 |
+
"hi": "उदा: 'शिवाजी नगर के सबसे खराब क्षेत्र'"},
|
| 39 |
+
"speak": {"en": "🎤 Speak", "kn": "🎤 ಮಾತನಾಡಿ", "hi": "🎤 बोलें"},
|
| 40 |
+
"stop": {"en": "⏹ Stop", "kn": "⏹ ನಿಲ್ಲಿಸಿ", "hi": "⏹ रोकें"},
|
| 41 |
+
"understood": {"en": "Understood", "kn": "ಅರ್ಥವಾಯಿತು", "hi": "समझ गया"},
|
| 42 |
+
"tab_map": {"en": "🗺️ Impact map", "kn": "🗺️ ಪ್ರಭಾವ ನಕ್ಷೆ", "hi": "🗺️ प्रभाव नक्शा"},
|
| 43 |
+
"tab_ops": {"en": "🚨 Live alerts", "kn": "🚨 ಲೈವ್ ಎಚ್ಚರಿಕೆ", "hi": "🚨 लाइव अलर्ट"},
|
| 44 |
+
"tab_rank": {"en": "📋 Enforcement priorities", "kn": "📋 ಜಾರಿ ಆದ್ಯತೆಗಳು",
|
| 45 |
+
"hi": "📋 प्रवर्तन प्राथमिकताएँ"},
|
| 46 |
+
"tab_off": {"en": "🚨 Repeat offenders", "kn": "🚨 ಪುನರಾವರ್ತಿತ ಅಪರಾಧಿಗಳು",
|
| 47 |
+
"hi": "🚨 बार-बार उल्लंघनकर्ता"},
|
| 48 |
+
"tab_fc": {"en": "🔮 Tomorrow's forecast", "kn": "🔮 ನಾಳಿನ ಮುನ್ಸೂಚನೆ",
|
| 49 |
+
"hi": "🔮 कल का पूर्वानुमान"},
|
| 50 |
+
"min_cii": {"en": "Minimum CII to display", "kn": "ಪ್ರದರ್ಶಿಸಲು ಕನಿಷ್ಠ CII",
|
| 51 |
+
"hi": "दिखाने हेतु न्यूनतम CII"},
|
| 52 |
+
"top_n_zones": {"en": "Show top N zones", "kn": "ಮೇಲಿನ N ವಲಯಗಳನ್ನು ತೋರಿಸಿ",
|
| 53 |
+
"hi": "शीर्ष N क्षेत्र दिखाएँ"},
|
| 54 |
+
"dl_priorities": {"en": "⬇️ Download enforcement priorities (CSV)",
|
| 55 |
+
"kn": "⬇️ ಜಾರಿ ಆದ್ಯತೆಗಳನ್ನು ಡೌನ್ಲೋಡ್ ಮಾಡಿ (CSV)",
|
| 56 |
+
"hi": "⬇️ प्रवर्तन प्राथमिकताएँ डाउनलोड करें (CSV)"},
|
| 57 |
+
"repeat_offenders": {"en": "Repeat offenders", "kn": "ಪುನರಾವರ್ತಿತ ಅಪರಾಧಿಗಳು",
|
| 58 |
+
"hi": "बार-बार उल्लंघनकर्ता"},
|
| 59 |
+
"share_violations": {"en": "Share of all violations", "kn": "ಎಲ್ಲಾ ಉಲ್ಲಂಘನೆಗಳ ಪಾಲು",
|
| 60 |
+
"hi": "कुल उल्लंघनों में हिस्सा"},
|
| 61 |
+
"worst_vehicle": {"en": "Worst single vehicle", "kn": "ಅತಿ ಕೆಟ್ಟ ಏಕೈಕ ವಾಹನ",
|
| 62 |
+
"hi": "सबसे खराब एकल वाहन"},
|
| 63 |
+
"search_vehicle": {"en": "Search a vehicle ID or area",
|
| 64 |
+
"kn": "ವಾಹನ ID ಅಥವಾ ಪ್ರದೇಶ ಹುಡುಕಿ", "hi": "वाहन ID या क्षेत्र खोजें"},
|
| 65 |
+
"top_n_off": {"en": "Show top N offenders", "kn": "ಮೇಲಿನ N ಅಪರಾಧಿಗಳನ್ನು ತೋರಿಸಿ",
|
| 66 |
+
"hi": "शीर्ष N उल्लंघनकर्ता दिखाएँ"},
|
| 67 |
+
"dl_offenders": {"en": "⬇️ Download offender watchlist (CSV)",
|
| 68 |
+
"kn": "⬇️ ಅಪರಾಧಿಗಳ ಪಟ್ಟಿ ಡೌನ್ಲೋಡ್ ಮಾಡಿ (CSV)",
|
| 69 |
+
"hi": "⬇️ उल्लंघनकर्ता सूची डाउनलोड करें (CSV)"},
|
| 70 |
+
"language": {"en": "Language", "kn": "ಭಾಷೆ", "hi": "भाषा"},
|
| 71 |
+
"theme": {"en": "Theme", "kn": "ಥೀಮ್", "hi": "थीम"},
|
| 72 |
+
"dark": {"en": "🌙 Dark", "kn": "🌙 ಕಪ್ಪು", "hi": "🌙 डार्क"},
|
| 73 |
+
"light": {"en": "☀️ Light", "kn": "☀️ ಬೆಳಕು", "hi": "☀️ लाइट"},
|
| 74 |
+
"history": {"en": "🕘 Your searches this session", "kn": "🕘 ಈ ಅವಧಿಯ ಹುಡುಕಾಟಗಳು",
|
| 75 |
+
"hi": "🕘 इस सत्र की खोजें"},
|
| 76 |
+
"clear_history": {"en": "Clear history", "kn": "ಇತಿಹಾಸ ಅಳಿಸಿ", "hi": "इतिहास साफ़ करें"},
|
| 77 |
+
"no_history": {"en": "No searches yet.", "kn": "ಇನ್ನೂ ಹುಡುಕಾಟಗಳಿಲ್ಲ.",
|
| 78 |
+
"hi": "अभी तक कोई खोज नहीं।"},
|
| 79 |
+
"tab_trends": {"en": "📈 Trends", "kn": "📈 ಪ್ರವೃತ್ತಿಗಳು", "hi": "📈 रुझान"},
|
| 80 |
+
"t_daily": {"en": "Daily violations", "kn": "ದೈನಂದಿನ ಉಲ್ಲಂಘನೆಗಳು", "hi": "दैनिक उल्लंघन"},
|
| 81 |
+
"t_hourly": {"en": "By hour of day", "kn": "ಗಂಟೆಯ ಪ್ರಕಾರ", "hi": "घंटे के अनुसार"},
|
| 82 |
+
"t_vehicle": {"en": "By vehicle type", "kn": "ವಾಹನ ಪ್ರಕಾರ", "hi": "वाहन प्रकार के अनुसार"},
|
| 83 |
+
"t_vtype": {"en": "By violation type", "kn": "ಉಲ್ಲಂಘನೆ ಪ್ರಕಾರ", "hi": "उल्लंघन प्रकार के अनुसार"},
|
| 84 |
+
"t_dow": {"en": "By day of week", "kn": "ವಾರದ ದಿನದ ಪ್ರಕಾರ", "hi": "सप्ताह के दिन अनुसार"},
|
| 85 |
+
"t_gauge": {"en": "Top-100 zones · share of all violations",
|
| 86 |
+
"kn": "ಮೇಲಿನ 100 ವಲಯಗಳ ಪಾಲು", "hi": "शीर्ष 100 क्षेत्रों का हिस्सा"},
|
| 87 |
+
"live_replay": {"en": "▶ Live replay (simulated from historical data)",
|
| 88 |
+
"kn": "▶ ಲೈವ್ ರಿಪ್ಲೇ (ಐತಿಹಾಸಿಕ ದತ್ತಾಂಶದಿಂದ)",
|
| 89 |
+
"hi": "▶ लाइव रीप्ले (ऐतिहासिक डेटा से)"},
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def t(key, lang):
|
| 94 |
+
"""Translate a UI string key into the chosen language (falls back to English)."""
|
| 95 |
+
return STRINGS.get(key, {}).get(lang) or STRINGS.get(key, {}).get("en", key)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _norm(s):
|
| 99 |
+
s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode()
|
| 100 |
+
s = re.sub(r"[^a-z]", "", s.lower())
|
| 101 |
+
s = re.sub(r"([a-z])\1+", r"\1", s) # collapse doubled letters
|
| 102 |
+
return s.replace("nagara", "nagar")
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def romanize(text, lang):
|
| 106 |
+
scr = {"kn": sanscript.KANNADA, "hi": sanscript.DEVANAGARI}.get(lang)
|
| 107 |
+
if scr is None:
|
| 108 |
+
return text
|
| 109 |
+
try:
|
| 110 |
+
return transliterate(text, scr, sanscript.IAST)
|
| 111 |
+
except Exception:
|
| 112 |
+
return text
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def build_area_vocab(hot):
|
| 116 |
+
"""Distinct locality names from the hotspot location strings."""
|
| 117 |
+
areas = set()
|
| 118 |
+
for loc in hot["location"].dropna():
|
| 119 |
+
for p in [x.strip() for x in str(loc).split(",")][1:4]:
|
| 120 |
+
pl = p.lower()
|
| 121 |
+
if p and "bengaluru" not in pl and "karnataka" not in pl and "pin" not in pl:
|
| 122 |
+
areas.add(p)
|
| 123 |
+
return sorted(areas)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def resolve_area(query, lang, area_vocab):
|
| 127 |
+
"""Map a (possibly Kannada/Hindi) place query to an English locality name."""
|
| 128 |
+
has_native = any(ord(c) > 127 for c in query)
|
| 129 |
+
rom = romanize(query, lang) if (has_native or lang != "en") else query
|
| 130 |
+
key = _norm(rom)
|
| 131 |
+
if not key:
|
| 132 |
+
return None
|
| 133 |
+
norm_map = {_norm(a): a for a in area_vocab}
|
| 134 |
+
best = difflib.get_close_matches(key, list(norm_map.keys()), n=1, cutoff=0.55)
|
| 135 |
+
return norm_map[best[0]] if best else None
|
src/impact_index.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compute the Congestion Impact Index (CII) per hotspot cell.
|
| 2 |
+
|
| 3 |
+
CII is a transparent, rank-normalised blend of four ideas:
|
| 4 |
+
- severity-weighted VOLUME (how much weighted illegal parking happens here)
|
| 5 |
+
- PERSISTENCE (chronic every-day spot vs one-off spike)
|
| 6 |
+
- PEAK-hour concentration (bad exactly when the road is busiest)
|
| 7 |
+
- junction proximity (blocking near junctions cascades downstream)
|
| 8 |
+
|
| 9 |
+
It is explicitly a *proxy* for traffic-flow impact, derived only from
|
| 10 |
+
enforcement data, with every weight exposed in config.py.
|
| 11 |
+
"""
|
| 12 |
+
from src import config
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _norm(series):
|
| 16 |
+
"""Rank-based normalisation to [0, 1] (robust to heavy-tailed counts)."""
|
| 17 |
+
return series.rank(method="average", pct=True).fillna(0.0)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def add_cii(stats):
|
| 21 |
+
stats = stats.copy()
|
| 22 |
+
w = config.CII_WEIGHTS
|
| 23 |
+
|
| 24 |
+
v = _norm(stats["weighted_volume"])
|
| 25 |
+
p = _norm(stats["persistence"])
|
| 26 |
+
k = _norm(stats["peak_share"])
|
| 27 |
+
|
| 28 |
+
base = w["volume"] * v + w["persistence"] * p + w["peak"] * k
|
| 29 |
+
junction = stats["junction_share"].clip(0, 1)
|
| 30 |
+
cii = base * (1 + config.CII_JUNCTION_ALPHA * junction)
|
| 31 |
+
|
| 32 |
+
# scale to a friendly 0-100
|
| 33 |
+
cii = (cii - cii.min()) / (cii.max() - cii.min() + 1e-9) * 100
|
| 34 |
+
stats["cii"] = cii.round(1)
|
| 35 |
+
stats["cii_rank"] = stats["cii"].rank(ascending=False, method="min").astype(int)
|
| 36 |
+
|
| 37 |
+
return stats.sort_values("cii", ascending=False).reset_index(drop=True)
|
src/model.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LightGBM model: forecast next-day severity-weighted violation intensity
|
| 2 |
+
per hotspot cell. Turns the project from descriptive ("where it's bad now")
|
| 3 |
+
into predictive ("where it will be bad tomorrow").
|
| 4 |
+
"""
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import lightgbm as lgb
|
| 8 |
+
|
| 9 |
+
from src import config
|
| 10 |
+
|
| 11 |
+
FEATURES = (
|
| 12 |
+
["lat", "lon", "junction", "dow", "month", "day", "is_weekend"]
|
| 13 |
+
+ [f"lag_{L}" for L in config.LAGS]
|
| 14 |
+
+ [f"roll_mean_{W}" for W in config.ROLL_WINDOWS]
|
| 15 |
+
+ [f"roll_max_{W}" for W in config.ROLL_WINDOWS]
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def build_panel(df):
|
| 20 |
+
"""Build a (cell x day) panel of weighted-violation intensity with lags."""
|
| 21 |
+
counts = df.groupby("h3")["id"].size()
|
| 22 |
+
keep_cells = counts[counts >= config.MIN_CELL_VIOLATIONS].index
|
| 23 |
+
d = df[df["h3"].isin(keep_cells)].copy()
|
| 24 |
+
|
| 25 |
+
daily = (d.groupby(["h3", "date"])
|
| 26 |
+
.agg(y=("severity", "sum"), n=("id", "size"))
|
| 27 |
+
.reset_index())
|
| 28 |
+
daily["date"] = pd.to_datetime(daily["date"])
|
| 29 |
+
|
| 30 |
+
# complete the grid so quiet days become explicit zeros
|
| 31 |
+
all_dates = pd.date_range(daily["date"].min(), daily["date"].max(), freq="D")
|
| 32 |
+
idx = pd.MultiIndex.from_product([keep_cells, all_dates], names=["h3", "date"])
|
| 33 |
+
panel = daily.set_index(["h3", "date"]).reindex(idx).reset_index()
|
| 34 |
+
panel["y"] = panel["y"].fillna(0.0)
|
| 35 |
+
panel["n"] = panel["n"].fillna(0.0)
|
| 36 |
+
|
| 37 |
+
# cell-static features
|
| 38 |
+
cell_meta = (d.groupby("h3")
|
| 39 |
+
.agg(lat=("latitude", "mean"),
|
| 40 |
+
lon=("longitude", "mean"),
|
| 41 |
+
junction=("has_junction", "mean"))
|
| 42 |
+
.reset_index())
|
| 43 |
+
panel = panel.merge(cell_meta, on="h3", how="left")
|
| 44 |
+
|
| 45 |
+
# calendar features
|
| 46 |
+
panel["dow"] = panel["date"].dt.dayofweek
|
| 47 |
+
panel["month"] = panel["date"].dt.month
|
| 48 |
+
panel["day"] = panel["date"].dt.day
|
| 49 |
+
panel["is_weekend"] = (panel["dow"] >= 5).astype(int)
|
| 50 |
+
|
| 51 |
+
# lag & rolling features, strictly per cell and time-ordered
|
| 52 |
+
panel = panel.sort_values(["h3", "date"]).reset_index(drop=True)
|
| 53 |
+
for L in config.LAGS:
|
| 54 |
+
panel[f"lag_{L}"] = panel.groupby("h3")["y"].shift(L)
|
| 55 |
+
panel["_yshift"] = panel.groupby("h3")["y"].shift(1)
|
| 56 |
+
for W in config.ROLL_WINDOWS:
|
| 57 |
+
panel[f"roll_mean_{W}"] = (panel.groupby("h3")["_yshift"]
|
| 58 |
+
.rolling(W, min_periods=1).mean()
|
| 59 |
+
.reset_index(level=0, drop=True))
|
| 60 |
+
panel[f"roll_max_{W}"] = (panel.groupby("h3")["_yshift"]
|
| 61 |
+
.rolling(W, min_periods=1).max()
|
| 62 |
+
.reset_index(level=0, drop=True))
|
| 63 |
+
panel = panel.drop(columns="_yshift")
|
| 64 |
+
return panel, keep_cells, all_dates
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def train_and_forecast(df):
|
| 68 |
+
panel, keep_cells, all_dates = build_panel(df)
|
| 69 |
+
model_df = panel.dropna(subset=[f"lag_{max(config.LAGS)}"]).copy()
|
| 70 |
+
|
| 71 |
+
cutoff = all_dates.max() - pd.Timedelta(days=config.VALID_DAYS)
|
| 72 |
+
train = model_df[model_df["date"] <= cutoff]
|
| 73 |
+
valid = model_df[model_df["date"] > cutoff]
|
| 74 |
+
|
| 75 |
+
# objective=regression_l1 (MAE) matches the metric and is far better on the
|
| 76 |
+
# right-skewed daily counts than L2 (which chases rare high-count days).
|
| 77 |
+
params = dict(objective="regression_l1", metric="mae",
|
| 78 |
+
learning_rate=0.05, num_leaves=63, min_data_in_leaf=50,
|
| 79 |
+
feature_fraction=0.8, bagging_fraction=0.8, bagging_freq=1,
|
| 80 |
+
seed=config.RANDOM_STATE, verbose=-1)
|
| 81 |
+
dtrain = lgb.Dataset(train[FEATURES], train["y"])
|
| 82 |
+
dvalid = lgb.Dataset(valid[FEATURES], valid["y"], reference=dtrain)
|
| 83 |
+
model = lgb.train(params, dtrain, num_boost_round=800,
|
| 84 |
+
valid_sets=[dvalid],
|
| 85 |
+
callbacks=[lgb.early_stopping(60), lgb.log_evaluation(0)])
|
| 86 |
+
|
| 87 |
+
# --- validation metrics vs a naive "same as last week" baseline ---
|
| 88 |
+
pred_v = model.predict(valid[FEATURES], num_iteration=model.best_iteration)
|
| 89 |
+
yv = valid["y"].values
|
| 90 |
+
mae = float(np.mean(np.abs(pred_v - yv)))
|
| 91 |
+
rmse = float(np.sqrt(np.mean((pred_v - yv) ** 2)))
|
| 92 |
+
base_mae = float(np.mean(np.abs(valid["lag_7"].values - yv)))
|
| 93 |
+
|
| 94 |
+
# --- next-day forecast: most recent row per cell ---
|
| 95 |
+
last_rows = model_df.sort_values("date").groupby("h3").tail(1).copy()
|
| 96 |
+
fc = model.predict(last_rows[FEATURES], num_iteration=model.best_iteration)
|
| 97 |
+
forecast = last_rows[["h3", "lat", "lon"]].copy()
|
| 98 |
+
forecast["pred_intensity"] = np.clip(fc, 0, None).round(2)
|
| 99 |
+
forecast["forecast_for"] = (all_dates.max() + pd.Timedelta(days=1)).date().isoformat()
|
| 100 |
+
forecast = forecast.sort_values("pred_intensity", ascending=False).reset_index(drop=True)
|
| 101 |
+
forecast["risk_rank"] = forecast.index + 1
|
| 102 |
+
|
| 103 |
+
metrics = dict(
|
| 104 |
+
valid_mae=round(mae, 3),
|
| 105 |
+
valid_rmse=round(rmse, 3),
|
| 106 |
+
baseline_lag7_mae=round(base_mae, 3),
|
| 107 |
+
improvement_pct=round(100 * (base_mae - mae) / base_mae, 1) if base_mae else None,
|
| 108 |
+
n_modeled_cells=int(len(keep_cells)),
|
| 109 |
+
best_iteration=int(model.best_iteration),
|
| 110 |
+
)
|
| 111 |
+
return model, forecast, metrics
|
src/offenders.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Repeat-offender intelligence from the (anonymised) `vehicle_number` column.
|
| 2 |
+
|
| 3 |
+
This is the honest, data-grounded reframe of "license plate recognition": the
|
| 4 |
+
plate is already present in the records, so instead of OCR-from-images we surface
|
| 5 |
+
*chronic* offenders across time and space — something a single-snapshot CV
|
| 6 |
+
pipeline structurally cannot do. All IDs are anonymised, so this is privacy-safe.
|
| 7 |
+
"""
|
| 8 |
+
import pandas as pd
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _clean_plates(df):
|
| 12 |
+
v = df.dropna(subset=["vehicle_number"]).copy()
|
| 13 |
+
v = v[~v["vehicle_number"].astype(str).str.lower().isin(["nan", "none", ""])]
|
| 14 |
+
return v
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _mode(series):
|
| 18 |
+
s = series.dropna()
|
| 19 |
+
return s.mode().iat[0] if not s.empty else ""
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def build_offender_stats(df, top_n=500):
|
| 23 |
+
"""Top repeat offenders with where/what/when context."""
|
| 24 |
+
v = _clean_plates(df)
|
| 25 |
+
g = v.groupby("vehicle_number")
|
| 26 |
+
stats = g.agg(
|
| 27 |
+
n_violations=("id", "size"),
|
| 28 |
+
weighted_severity=("severity", "sum"),
|
| 29 |
+
n_zones=("h3", "nunique"),
|
| 30 |
+
vehicle_type=("vehicle_type", _mode),
|
| 31 |
+
top_location=("location", _mode),
|
| 32 |
+
first_seen=("ts", "min"),
|
| 33 |
+
last_seen=("ts", "max"),
|
| 34 |
+
).reset_index()
|
| 35 |
+
stats["first_seen"] = stats["first_seen"].dt.date.astype(str)
|
| 36 |
+
stats["last_seen"] = stats["last_seen"].dt.date.astype(str)
|
| 37 |
+
stats["weighted_severity"] = stats["weighted_severity"].round(1)
|
| 38 |
+
stats = stats.sort_values("n_violations", ascending=False).reset_index(drop=True)
|
| 39 |
+
stats["rank"] = stats.index + 1
|
| 40 |
+
return stats.head(top_n)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def offender_summary(df):
|
| 44 |
+
"""Headline KPIs about repeat offending."""
|
| 45 |
+
v = _clean_plates(df)
|
| 46 |
+
counts = v["vehicle_number"].value_counts()
|
| 47 |
+
repeat = counts[counts >= 2]
|
| 48 |
+
return dict(
|
| 49 |
+
distinct_vehicles=int(counts.size),
|
| 50 |
+
repeat_offenders=int(repeat.size),
|
| 51 |
+
repeat_share_pct=round(100 * repeat.sum() / len(v), 1),
|
| 52 |
+
worst_count=int(counts.max()),
|
| 53 |
+
)
|
src/streamlit_app.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import altair as alt
|
| 2 |
+
import numpy as np
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import streamlit as st
|
| 5 |
+
|
| 6 |
+
"""
|
| 7 |
+
# Welcome to Streamlit!
|
| 8 |
+
|
| 9 |
+
Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
|
| 10 |
+
If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
|
| 11 |
+
forums](https://discuss.streamlit.io).
|
| 12 |
+
|
| 13 |
+
In the meantime, below is an example of what you can do with just a few lines of code:
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
|
| 17 |
+
num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
|
| 18 |
+
|
| 19 |
+
indices = np.linspace(0, 1, num_points)
|
| 20 |
+
theta = 2 * np.pi * num_turns * indices
|
| 21 |
+
radius = indices
|
| 22 |
+
|
| 23 |
+
x = radius * np.cos(theta)
|
| 24 |
+
y = radius * np.sin(theta)
|
| 25 |
+
|
| 26 |
+
df = pd.DataFrame({
|
| 27 |
+
"x": x,
|
| 28 |
+
"y": y,
|
| 29 |
+
"idx": indices,
|
| 30 |
+
"rand": np.random.randn(num_points),
|
| 31 |
+
})
|
| 32 |
+
|
| 33 |
+
st.altair_chart(alt.Chart(df, height=700, width=700)
|
| 34 |
+
.mark_point(filled=True)
|
| 35 |
+
.encode(
|
| 36 |
+
x=alt.X("x", axis=None),
|
| 37 |
+
y=alt.Y("y", axis=None),
|
| 38 |
+
color=alt.Color("idx", legend=None, scale=alt.Scale()),
|
| 39 |
+
size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
|
| 40 |
+
))
|
src/trends.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Precompute small aggregates for the dashboard charts.
|
| 2 |
+
|
| 3 |
+
Long format -> one tiny trends.parquet the app loads for line/bar/donut charts:
|
| 4 |
+
kind ∈ {daily, hourly, vehicle, dow, vtype}
|
| 5 |
+
label the x-axis label
|
| 6 |
+
value the count
|
| 7 |
+
order sort key
|
| 8 |
+
"""
|
| 9 |
+
from collections import Counter
|
| 10 |
+
|
| 11 |
+
import pandas as pd
|
| 12 |
+
|
| 13 |
+
from src import config
|
| 14 |
+
|
| 15 |
+
DOW = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def build_trends(df):
|
| 19 |
+
rows = []
|
| 20 |
+
|
| 21 |
+
# daily time series (for the line chart)
|
| 22 |
+
daily = df.groupby("date").size().reset_index(name="value").sort_values("date")
|
| 23 |
+
for i, (d, v) in enumerate(zip(daily["date"], daily["value"])):
|
| 24 |
+
rows.append(("daily", str(d), int(v), i))
|
| 25 |
+
|
| 26 |
+
# hourly profile (bar)
|
| 27 |
+
hourly = df.groupby("hour").size()
|
| 28 |
+
for h in range(24):
|
| 29 |
+
rows.append(("hourly", f"{h:02d}", int(hourly.get(h, 0)), h))
|
| 30 |
+
|
| 31 |
+
# by vehicle type (top 10 bar)
|
| 32 |
+
for i, (k, v) in enumerate(df["vehicle_type"].value_counts().head(10).items()):
|
| 33 |
+
rows.append(("vehicle", str(k), int(v), i))
|
| 34 |
+
|
| 35 |
+
# by day-of-week (bar)
|
| 36 |
+
dow = df.groupby("dow").size()
|
| 37 |
+
for i in range(7):
|
| 38 |
+
rows.append(("dow", DOW[i], int(dow.get(i, 0)), i))
|
| 39 |
+
|
| 40 |
+
# by violation type (donut) — only parking-relevant labels
|
| 41 |
+
vt = Counter()
|
| 42 |
+
for lst in df["violations"]:
|
| 43 |
+
for v in lst:
|
| 44 |
+
if v in config.PARKING_SEVERITY:
|
| 45 |
+
vt[v] += 1
|
| 46 |
+
for i, (k, c) in enumerate(vt.most_common(8)):
|
| 47 |
+
rows.append(("vtype", str(k).title(), int(c), i))
|
| 48 |
+
|
| 49 |
+
return pd.DataFrame(rows, columns=["kind", "label", "value", "order"])
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def build_byday(df):
|
| 53 |
+
"""Per-day aggregates so charts can animate cumulatively during replay.
|
| 54 |
+
|
| 55 |
+
Long format: date, dim ∈ {hour, vehicle}, key, value.
|
| 56 |
+
"""
|
| 57 |
+
rows = []
|
| 58 |
+
h = df.groupby(["date", "hour"]).size().reset_index(name="value")
|
| 59 |
+
for _, r in h.iterrows():
|
| 60 |
+
rows.append((str(r["date"]), "hour", f'{int(r["hour"]):02d}', int(r["value"])))
|
| 61 |
+
v = df.groupby(["date", "vehicle_type"]).size().reset_index(name="value")
|
| 62 |
+
for _, r in v.iterrows():
|
| 63 |
+
rows.append((str(r["date"]), "vehicle", str(r["vehicle_type"]), int(r["value"])))
|
| 64 |
+
return pd.DataFrame(rows, columns=["date", "dim", "key", "value"])
|