File size: 10,714 Bytes
1f0fbfd e52b02b 1f0fbfd e52b02b 1f0fbfd e52b02b 1f0fbfd e52b02b 1f0fbfd e52b02b 1f0fbfd e52b02b 1f0fbfd e52b02b 1f0fbfd 8000e3d 1f0fbfd e52b02b 1f0fbfd e52b02b 1f0fbfd | 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 | # Birdie data contract
This file is the merge contract between the two backend repos. Anything that produces or consumes seat data must use
the models below (they live in `birdie/models.py`). Map your code onto the public functions listed per module; private
helpers (leading underscore) are free to change.
Rules:
- `birdie/` never imports Gradio. UI code lives only in `app.py` and `birdie/ui/`.
- Every timestamp is a timezone-aware `datetime` in Doha time (UTC+3, `birdie.clock.DOHA_TZ`).
- Everything time-based takes a `clock` (`RealClock` in the app, `FakeClock` in tests). Nothing sleeps in tests.
- Timers and thresholds come from `birdie.config.TIMERS` (demo or production, behind `DEMO_MODE`).
- Seat polygons are normalised `(x, y)` corners in `0..1`, so they work at any camera resolution.
- The app does not persist source frames. Hosted Space logging and retention must be checked separately. The app stores seat states and aggregate counts.
## Models (`birdie/models.py`)
| Model | Fields |
|---|---|
| `Venue` | `venue_id: str`, `name: str`, `area: str`, `type: VenueType` (`cafe`, `library`, `cowork`, `study_room`), `lat: float`, `lon: float`, `zones: list[str]`, `source: VenueSource` (`live`, `recorded`, `simulated`), optional `floor_plan` and display metadata |
| `Seat` | `seat_id: str`, `venue_id: str`, `zone: str`, optional `table_id`, optional customer-map coordinates, `seat_polygon` and `workspace_polygon` (normalised camera regions), `has_outlet`, `has_window` |
| `StudyTable` | `table_id`, `table_type` (`shared`, `private`, `single_person_desk`), `capacity`, `seat_ids`, normalised `table_polygon`, `is_shareable`, `is_available_for_studying` |
| `SeatSnapshot` | `venue_id`, `seat_id`, `zone`, `has_outlet`, `state: SeatState` (`UNKNOWN`, `FREE`, `OCCUPIED`, `HELD`, `REVIEW_REQUIRED`, `UNAVAILABLE`), timestamps, detector evidence, grace-period countdown, review and override flags |
| `SeatEvent` | `ts: datetime`, `venue_id`, `seat_id`, `old_state: SeatState \| None`, `new_state: SeatState` |
| `Query` | `area: str \| None`, `party_size: int`, `needs_outlet: bool`, `quiet: bool`, `zone: str \| None`, `venue_type: VenueType \| None`, `arrival_minutes: int` |
| `VenueConfig` | `venue`, `seats`, `tables`, `unmapped_seat_ids`, private mirror-exclusion regions, legacy `camera_table_regions` fallback, and optional `recording_sha256` tying calibration to one clip |
With an explicit `tables` layout, each configured seat must link to exactly one table and appear in that table's `seat_ids`. Known IDs awaiting calibration live in `unmapped_seat_ids`; they are excluded from capacity and availability. Camera coordinates stay private and are not returned by the public venue API.
The customer API groups detailed backend states into three availability statuses: `free`, `taken` (`OCCUPIED` or `UNAVAILABLE`), and `held` (`HELD` or `REVIEW_REQUIRED`). `UNKNOWN` is returned as `checking`, a camera-evidence indicator rather than an availability state. `counts.free`, `counts.taken`, `counts.held_or_review`, and `counts.checking` partition configured seats; legacy detailed state counters and the raw `state`/`seat_state` fields remain available for diagnostics. A seat is a verified study space only when its state is `FREE`, its configured chair is detected above the detector confidence threshold, and its assigned workspace is clear. A missing chair, uncertain/shared item, missing polygon, unknown seat state, or disabled table is never counted as usable. Shared tables are evaluated seat by seat. Private or non-shareable tables are one group space and require every seat to be verified free. A belongings timeout only raises staff review; it never frees the blocked workspace. `counts.usable_study_spaces` is the customer-facing total.
Zone ids used by the demo venues: `quiet`, `open`, `group`, `family`, `women`, `terrace`, `indoor`
(labels in `birdie.venues.ZONE_LABELS`).
## Storage (`birdie/storage.py`)
SQLite at `data/birdie.db` (gitignored). `Repository(path)`; use `Repository(":memory:")` in tests.
| Function | Purpose |
|---|---|
| `add_seat_event(event, synthetic=False)`, `add_seat_events(events, synthetic=False)` | Append to the seat event log. `synthetic=True` marks back-filled history for simulated venues |
| `seat_events(venue_id=None, since=None, until=None, include_synthetic=True) -> list[SeatEvent]` | Read the event log in time order |
| `record_occupancy(venue_id, ts, free, occupied, held)`, `occupancy_buckets(venue_id, since=None)` | Live occupancy in 15-minute buckets |
| `add_alert(...)`, `alerts(venue_id=None, unresolved_only=False)`, `resolve_alerts(venue_id, seat_id, kind=None)` | Staff alerts |
| `save_seat_snapshots(snapshots)`, `seat_snapshots(venue_id)` | Persist current state and unattended timer timestamps across process restarts |
## Venues (`birdie/venues.py`)
`load_all() -> dict[str, VenueConfig]`, `load_venue(venue_id)`, `save_venue(config)`, `zone_label(zone)`.
## Vision (`birdie/vision/`)
| Function | Purpose |
|---|---|
| `detector.detect(frame, threshold=0.4) -> list[{label, score, box}]` | RT-DETR r18 (`PekingU/rtdetr_r18vd`). Keeps COCO classes `chair`, `person`, `backpack, handbag, suitcase, laptop, cup, bottle, book, cell phone`. `box = [x1, y1, x2, y2]` in pixels of the frame passed in. Resizes to long side <= 640 internally |
| `detector.resize_long_side(frame, 640) -> PIL.Image`, `detector.load()` | Helpers. `_infer` is the pure (frame in, detections out) function wrapped with `@spaces.GPU` when `spaces` is importable |
| `seats.assign_detections(detections, seats, (w, h), min_overlap=0.2, table_regions=None) -> {seat_id: [det]}` | Assign person/item evidence to configured seat/workspace regions; ambiguous shared-table items remain unknown |
| `seats.observe_evidence(assigned)` | Confidence-aware `PERSON`, `ITEMS`, `EMPTY`, or conservative `UNKNOWN` evidence |
| `seats.workspace_evidence(detections, seats, (w, h))` | Verify a visible chair and clear/blocked/uncertain desk workspace for each calibrated seat |
| `state.SeatStateMachine(venue_id, seat_id, clock, k=3, hold_seconds=...)` | Smooths evidence; belongings become `HELD`, then `REVIEW_REQUIRED` after the real-time grace period |
| `state.VenueTracker(venue_id, seats, clock, on_event)` | `.process({seat_id: Observation}) -> [SeatEvent]` (missing seats = UNKNOWN), `.tick()` (hold timers and stale-feed expiry), `.snapshots()`, `.counts()`, `.force(states)`, `.set_seats(seats)` |
| `render.annotate(frame, detections)` | RGB frame with detector boxes only; no static seat regions and no blurring |
| `render.to_data_uri(frame)` | In-memory JPEG data URL (frames never touch disk) |
| `clip.iter_clip_frames(path, fps=1)` | Sample a recorded clip at 1 fps, in memory |
| `demo.replay_recording(svc, venue_id, path, stop)` | Repeatedly process a configured clip and invalidate seat states on failure |
## Forecast (`birdie/forecast/`)
| Function | Purpose |
|---|---|
| `calendar.is_weekend(dt)` (Friday + Saturday), `day_type(dt)` (`weekday`/`friday`/`saturday`), `is_friday_prayer_quiet(dt)`, `is_ramadan(dt)`, `is_exam_season(dt)`, `is_terrace_season(dt)`, `slot_of(dt)`, `features(dt)` | Qatar calendar. `RAMADAN_RANGES`, `EXAM_RANGES` are editable |
| `synthetic.profile(venue_type, ts)`, `synthetic.generate_history(venue, n_seats, end, weeks=4) -> [{ts, occupied, free, total, synthetic}]` | Synthetic history, always labelled synthetic |
| `model.BaselineForecaster(venue_type).fit(history).predict_ratio(ts)` | Default forecaster (occupancy ratio 0..1) |
| `model.ChronosForecaster().predict(contexts, steps)`, `model.load_chronos()` | Optional `amazon/chronos-bolt-small`; raises `ForecasterUnavailable` / returns None if it can't load |
| `model.ForecastEngine(configs, clock, repo).ratio_at(venue_id, ts)` | What the app uses (`BIRDIE_USE_CHRONOS=1` to try Chronos) |
## Decision (`birdie/decision.py`)
Functions take a `LiveData` object: anything with `now()`, `venues()`, `venue(id)`, `seats(id)`, `snapshots(id)`,
and `forecast_ratio(id, ts)`. `BirdieService` implements it.
| Function | Purpose |
|---|---|
| `availability_at(data, venue_id, arrival_minutes) -> Availability` | `expected_free`, `low`, `high`, `live_free`, `forecast_free`, `live_weight`; `.summary()` |
| `blend_availability(...)` | The pure blending rule (live weight `0.5 ** (arrival / 20 min)`) |
| `focus_score(data, venue_id, arrival_minutes) -> FocusScore` | `score` 0..100 and `parts` (free 40, noise 25, outlets 20, trend 15) |
| `recommend(data, query, top=3) -> [Recommendation]` | Filters by the `Query`, ranks by focus score; each has `reasons` |
## Simulator (`birdie/simulator.py`)
`Simulator(configs, trackers, ratio_at, clock)`: `.step()` feeds observations to each simulated venue's
`VenueTracker.process` (the camera's code path); `.backfill(repo, venue_id, hours=24)` writes a synthetic event log.
## Analytics (`birdie/analytics.py`)
`venue_dashboard(repo, venue_id, seat_ids, now, hours=24, observed_since=None) -> Dashboard` with
`occupancy_by_hour`, `peak_hours`, `held_minutes_total`, `held_minutes_by_seat`. Building blocks: `seat_intervals`,
`occupancy_by_hour`, `held_minutes`.
## Service (`birdie/service.py`)
`BirdieService(repo=None, clock=None, venues=None, timers=TIMERS, detector=None)` is the one object the UI talks to:
`process_frame(venue_id, frame)` associates detections and updates seat state; `snapshots(venue_id)`, `zone_counts(venue_id)`, `upsert_seat(seat)`,
`delete_seat(venue_id, seat_id)`, `study_availability(venue_id)`, `set_table_available_for_studying(...)`, `availability_at(venue_id, minutes)`,
`forecast` (ForecastEngine), `simulator`, `tick()`, `start()` (back-fill + 2 s scheduler), `stop()`.
`GET /api/venues/{venue_id}` returns table-level and seat-level `usable_for_studying`, `workspace_clear`, and `availability_reason`. Staff may disable/re-enable a seat or table through the bearer-token protected maintenance endpoints. Venue geometry changes are stored in `venues/<venue_id>.json` and require a fresh camera sample before availability returns.
## Assistant (`birdie/assistant/`, stub)
`answer(data, question) -> AssistantAnswer` = `parse_query(question) -> Query` (Fanar, JSON) ->
`recommend()` in Python -> `phrase_reply(...)` (Fanar, user's language). No tool calling.
## Mapping a second repo onto this contract
1. Convert your seat and venue objects to the models above (`Model.model_validate(dict)`).
2. Anything that observes seats should end in `VenueTracker.process({seat_id: Observation})`, so smoothing,
hold timers and SeatEvents stay identical.
3. Anything that reads availability should go through `LiveData` + `birdie.decision`.
|