File size: 7,337 Bytes
2d30bad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# backend/src/database.py

import sqlite3
from pathlib import Path
from datetime import datetime, timezone

DB_PATH = Path(__file__).resolve().parent.parent / "parking.db"


def get_connection() -> sqlite3.Connection:
    conn = sqlite3.connect(DB_PATH, timeout=30, check_same_thread=False)
    conn.row_factory = sqlite3.Row
    # WAL mode: allows concurrent reads alongside writes (avoids "database is locked")
    conn.execute("PRAGMA journal_mode=WAL")
    # Wait up to 10 s for any lock before raising an error
    conn.execute("PRAGMA busy_timeout=10000")
    return conn


def init_db() -> None:
    """Create tables if they do not already exist."""
    conn = get_connection()
    cur = conn.cursor()

    cur.executescript("""

        CREATE TABLE IF NOT EXISTS occupancy_log (

            id          INTEGER PRIMARY KEY AUTOINCREMENT,

            slot_id     TEXT    NOT NULL,

            status      TEXT    NOT NULL CHECK(status IN ('occupied', 'empty')),

            confidence  REAL    NOT NULL,

            logged_at   TEXT    NOT NULL

        );



        CREATE TABLE IF NOT EXISTS predictions (

            id              INTEGER PRIMARY KEY AUTOINCREMENT,

            slot_id         TEXT    NOT NULL,

            horizon_minutes INTEGER NOT NULL,

            vacancy_prob    REAL    NOT NULL,

            predicted_at    TEXT    NOT NULL

        );



        CREATE INDEX IF NOT EXISTS idx_occupancy_slot

            ON occupancy_log(slot_id, logged_at);



        CREATE INDEX IF NOT EXISTS idx_predictions_slot

            ON predictions(slot_id, predicted_at);

    """)

    conn.commit()
    conn.close()


def log_occupancy(records: list[dict]) -> None:
    """

    Insert a batch of occupancy readings.



    Each record must have:

        slot_id    : str

        status     : 'occupied' | 'empty'

        confidence : float

    """
    now = datetime.now(timezone.utc).isoformat()
    rows = [(r["slot_id"], r["status"], r["confidence"], now) for r in records]

    conn = get_connection()
    conn.executemany(
        "INSERT INTO occupancy_log (slot_id, status, confidence, logged_at) "
        "VALUES (?, ?, ?, ?)",
        rows,
    )
    conn.commit()
    conn.close()


def get_latest_occupancy() -> list[dict]:
    """

    Return the most recent status for every slot.

    Uses a subquery to pick the MAX logged_at per slot_id.

    """
    conn = get_connection()
    rows = conn.execute("""

        SELECT ol.slot_id, ol.status, ol.confidence, ol.logged_at

        FROM   occupancy_log ol

        INNER JOIN (

            SELECT slot_id, MAX(logged_at) AS max_ts

            FROM   occupancy_log

            GROUP  BY slot_id

        ) latest ON ol.slot_id = latest.slot_id

                 AND ol.logged_at = latest.max_ts

        ORDER BY ol.slot_id

    """).fetchall()
    conn.close()
    return [dict(r) for r in rows]


def get_full_history(limit: int = 500, offset: int = 0) -> list[dict]:
    """Return a paginated slice of occupancy_log, ordered by time."""
    conn = get_connection()
    rows = conn.execute(
        "SELECT slot_id, status, confidence, logged_at "
        "FROM   occupancy_log "
        "ORDER  BY logged_at DESC "
        "LIMIT  ? OFFSET ?",
        (limit, offset),
    ).fetchall()
    conn.close()
    return [dict(r) for r in rows]


def save_predictions(records: list[dict]) -> None:
    """

    Insert Prophet forecast results.



    Each record must have:

        slot_id         : str

        horizon_minutes : int

        vacancy_prob    : float

    """
    now = datetime.now(timezone.utc).isoformat()
    rows = [
        (r["slot_id"], r["horizon_minutes"], r["vacancy_prob"], now)
        for r in records
    ]
    conn = get_connection()
    conn.executemany(
        "INSERT INTO predictions (slot_id, horizon_minutes, vacancy_prob, predicted_at) "
        "VALUES (?, ?, ?, ?)",
        rows,
    )
    conn.commit()
    conn.close()


def get_latest_predictions(horizon_minutes: int) -> list[dict]:
    """Return the most recent forecast for each slot at the requested horizon."""
    conn = get_connection()
    rows = conn.execute("""

        SELECT p.slot_id, p.vacancy_prob, p.predicted_at

        FROM   predictions p

        INNER JOIN (

            SELECT slot_id, MAX(predicted_at) AS max_ts

            FROM   predictions

            WHERE  horizon_minutes = ?

            GROUP  BY slot_id

        ) latest ON p.slot_id   = latest.slot_id

                 AND p.predicted_at = latest.max_ts

        WHERE p.horizon_minutes = ?

        ORDER BY p.slot_id

    """, (horizon_minutes, horizon_minutes)).fetchall()
    conn.close()
    return [dict(r) for r in rows]


def get_analytics_summary() -> dict:
    """

    Return aggregated analytics without sending raw rows.

    Computes:

      - total_readings: total rows in occupancy_log

      - avg_occupancy_pct: overall average occupancy percentage

      - peak_hour: 0-23 hour with highest average occupancy

      - busiest_slot: slot_id with most occupied readings

      - hourly_trend: list of {hour, occupied, empty} for last 48 buckets

    """
    conn = get_connection()

    # Total readings
    total = conn.execute("SELECT COUNT(*) AS cnt FROM occupancy_log").fetchone()["cnt"]

    # Average occupancy %
    avg_row = conn.execute("""

        SELECT ROUND(100.0 * SUM(CASE WHEN status = 'occupied' THEN 1 ELSE 0 END)

               / NULLIF(COUNT(*), 0), 1) AS avg_pct

        FROM occupancy_log

    """).fetchone()
    avg_pct = float(avg_row["avg_pct"]) if avg_row["avg_pct"] is not None else 0.0

    # Peak hour (0-23) — SQLite strftime extracts UTC hour
    peak_row = conn.execute("""

        SELECT CAST(strftime('%H', logged_at) AS INTEGER) AS hr,

               SUM(CASE WHEN status = 'occupied' THEN 1 ELSE 0 END) AS occ_count

        FROM   occupancy_log

        GROUP  BY hr

        ORDER  BY occ_count DESC

        LIMIT  1

    """).fetchone()
    peak_hour = int(peak_row["hr"]) if peak_row else 0

    # Busiest slot (most occupied readings)
    busy_row = conn.execute("""

        SELECT slot_id, COUNT(*) AS cnt

        FROM   occupancy_log

        WHERE  status = 'occupied'

        GROUP  BY slot_id

        ORDER  BY cnt DESC

        LIMIT  1

    """).fetchone()
    busiest_slot = busy_row["slot_id"] if busy_row else None

    # Hourly trend: last 48 distinct hours bucketed by strftime('%Y-%m-%dT%H:00', logged_at)
    trend_rows = conn.execute("""

        SELECT strftime('%Y-%m-%dT%H:00', logged_at) AS hour,

               SUM(CASE WHEN status = 'occupied' THEN 1 ELSE 0 END) AS occupied,

               SUM(CASE WHEN status = 'empty'    THEN 1 ELSE 0 END) AS empty

        FROM   occupancy_log

        GROUP  BY hour

        ORDER  BY hour DESC

        LIMIT  48

    """).fetchall()
    # Reverse so chronological order (oldest first)
    hourly_trend = list(reversed([dict(r) for r in trend_rows]))

    conn.close()

    return {
        "total_readings":   total,
        "avg_occupancy_pct": avg_pct,
        "peak_hour":         peak_hour,
        "busiest_slot":      busiest_slot,
        "hourly_trend":      hourly_trend,
    }