diff --git a/README.md b/README.md index 9998390..29f1db5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,266 @@ # Tatami -Tool for Arranging Tasty Appointments, Meetings & Invitations + +**T**ool for **A**rranging **T**asty **A**ppointments, **M**eetings & **I**nvitations. + +Tatami generates a **running dinner** masterplan. Given a list of participants with +home addresses, it forms small hosting groups, decides which groups cook which +course and who visits whom, and orders the whole evening to minimize travel time +(by bike, using the Google Maps Routes API), finishing at a shared after‑party. + +- [What is a running dinner?](#what-is-a-running-dinner) +- [How Tatami builds the plan](#how-tatami-builds-the-plan) +- [Requirements](#requirements) +- [Setup](#setup) +- [Input: the participant CSV](#input-the-participant-csv) +- [Running it](#running-it) +- [Output format](#output-format) +- [What you can tweak](#what-you-can-tweak) +- [How the algorithm works](#how-the-algorithm-works) +- [Testing](#testing) +- [Limitations & scaling](#limitations--scaling) +- [Project layout](#project-layout) +- [Development](#development) + +--- + +## What is a running dinner? + +A running dinner is a social dinner event spread across many homes. Participants +are split into **hosting groups**. The evening has three courses — **starter, +main, dessert** — and each group cooks **exactly one** course in their own home. +For the other two courses they travel to other groups' homes as guests. The +tables are mixed for every course, so people meet many others over the night. +Everyone converges on a common **after‑party** location at the end. + +Tatami arranges all of this and tries to keep the total cycling time low. + +## How Tatami builds the plan + +The pipeline (see the `__main__` block of `src/tatami/tatami_masterplan.py`): + +1. **Load participants** from a tab‑separated CSV into `Participant` objects. +2. **Fetch travel times** — a full pairwise duration matrix between every + participant address plus the after‑party address, via the Google Routes API. +3. **Build the masterplan**: + - Rank participants by how convenient they are as hosts (kitchen‑size penalty + plus distance to the after‑party), then split into **hosts** (one per group) + and **semi‑hosts** (distributed into the host groups). + - Reduce the full matrix to a host‑to‑host matrix and bake in each host's + kitchen‑size penalty. + - Run **simulated annealing** to find a low‑travel‑time assignment of groups + to the dinner rotation. + - Assign each group a course and compute who hosts whom for each course. +4. **Return** two lists of plain dicts (groups and participants) ready for + serialization. + +## Requirements + +- **Python ≥ 3.13** +- **[uv](https://docs.astral.sh/uv/)** for dependency management +- A **Google Maps API key** with the **Routes API** enabled + (the new `routes.googleapis.com` `computeRouteMatrix` endpoint — *not* the + legacy Distance Matrix API). + +## Setup + +```bash +# 1. Install dependencies (creates the virtualenv from uv.lock) +uv sync + +# 2. Provide your API key +cp .env.example .env +# then edit .env and set GOOGLE_MAPS_API_KEY=... +``` + +The key is read from `.env` automatically (via `python-dotenv`). `.env` is +gitignored, so your secret never gets committed. An already‑exported +`GOOGLE_MAPS_API_KEY` environment variable takes precedence over the file. + +> The package raises at import time if no key is found, so `GOOGLE_MAPS_API_KEY` +> must be set (even to a dummy value) just to import `tatami.traveltimes`. + +## Input: the participant CSV + +A **tab‑separated** file (the default working file is `test-config.csv` in the +directory you run from) with these columns: + +| Column | Type | Meaning | +|----------------|--------|----------------------------------------------------------------------| +| `name` | string | Participant / household name. | +| `address` | string | Full postal address — this is what the Routes API geocodes. | +| `phone` | string | Contact number (carried through to the output, not used in routing). | +| `kitchen_size` | number | `0–10`; a *suitability‑to‑host* proxy. Bigger = better kitchen. | +| `allergies` | string | Free text (carried through, not used in routing). | + +Example (columns separated by **tabs**): + +``` +name address phone kitchen_size allergies +Alice Römerstr. 12, 76189 Karlsruhe 555-1234 8 none +Bob Gottesauerstr. 30, 76131 Karlsruhe 555-5678 7 peanuts +Charlie Hermann-Hesse-Str 50, 76189 Karlsruhe 555-8765 9 none +``` + +## Running it + +```bash +uv run python -m tatami.tatami_masterplan +``` + +This reads `test-config.csv` from the current directory, calls the Routes API, +and prints the generated masterplan. The after‑party address and travel mode are +currently set in the `__main__` block of `tatami_masterplan.py` (see +[What you can tweak](#what-you-can-tweak)). + +To use Tatami from your own code: + +```python +from tatami.tatami_masterplan import ( + get_after_party_group, get_masterplan, load_csv_to_participants, +) +from tatami.traveltimes import get_participant_distance_matrix + +participants = load_csv_to_participants("my-participants.csv") +after_party = get_after_party_group("Some Street 1, 12345 City") + +distance_matrix = get_participant_distance_matrix( + [*participants, after_party.main_member], mode="BICYCLE" +) +groups, participants_out = get_masterplan(participants, distance_matrix, after_party) +``` + +## Output format + +`get_masterplan` returns a tuple `(group_dicts, participant_dicts)`. + +Each **group** dict: + +```python +{ + "uuid": "Group_…", # group id + "members": ["…", "…"], # participant uuids in this group + "main_member": "…", # the participant whose home is used for routing + "course": "starter", # "starter" | "main" | "dessert" + "hosts": ["…", "…", "…"], # the three groups this group eats with, + # ordered starter → main → dessert + # (includes this group itself, for its own course) +} +``` + +Each **participant** dict mirrors the CSV columns plus a `uuid`. Everything is +keyed by UUID, so resolve names/addresses by looking participants up by `uuid`. + +> Note: only a group's `main_member` address is used for all travel calculations; +> other members are assumed to join at the main member's home. + +## What you can tweak + +All knobs currently live in the source. The most useful ones: + +| What | Where | Default | Effect | +|------|-------|---------|--------| +| **After‑party address** | `tatami_masterplan.py:230` (`__main__`) | a Karlsruhe address | Where everyone ends the night; also influences host ranking. | +| **Travel mode** | `tatami_masterplan.py:233` (`mode="BICYCLE"`) | `BICYCLE` | Any Routes API `travelMode`: `BICYCLE`, `DRIVE`, `WALK`, `TWO_WHEELER`, `TRANSIT`. | +| **Group sizing** | `tatami_masterplan.py:24` (`len(participants) / 6`) | 1 group per ~6 people | The divisor sets how many participants form one "course‑triple". Larger → fewer, bigger groups. | +| **Kitchen‑size penalty** | `classes.py:19` (`minutes=3 * (10 - kitchen_size)`) | 3 min per point | Travel‑time‑equivalent penalty for small kitchens. Raise the `3` to push hosting toward big kitchens. | +| **Annealing schedule** | `tatami_masterplan.py:48–50` | `T=1000`, `cooling=0.99`, `iters=10000` | Optimization quality vs. runtime. More iterations / slower cooling → better routes, slower. | +| **Course names** | `tatami_masterplan.py:31` | `["starter", "main", "dessert"]` | The three courses. The 3‑course rotation is baked into the topology — changing the *count* needs more work (see below). | +| **Rotation topology** | `tatami_masterplan.py:172–173` (offsets `+1`, `-4`) | — | Defines who hosts whom. Changing these changes who meets whom; keep the invariant that each group's three hosts cover all three courses. | +| **Distance vs. duration** | `traveltimes.py` `get_distance_matrix(value=…)` | `"duration"` | Optimize on travel **time** (`"duration"`) or **distance** (`"distanceMeters"`). | + +After changing routing‑relevant knobs, run the test suite (`uv run pytest`) — the +topology and cost invariants are covered there. + +## How the algorithm works + +### Group building + +`groups_per_course = floor(n / 6)` groups are created **per course**, for +`3 × groups_per_course` groups total. Participants are sorted by +`get_after_party_time` (kitchen penalty + distance to after‑party); the best +become the one **host** of each group, and the rest are shuffled in round‑robin +as **semi‑hosts**. A group's `main_member` (the host) is the only address used +for that group in all distance lookups. + +### Rotation topology (`get_courses`) + +Each group occupies a **slot** `0 … n‑1`. The course a slot cooks is `slot % 3` +(`0 → starter`, `1 → main`, `2 → dessert`). For slot `i`, the three groups it +dines with are slots `i` (itself, for its own course), `i + 1`, and `i − 4` +(mod `n`). These offsets are chosen so that: + +- a group's three hosts always cover **all three** courses, +- **every** host serves **exactly three** groups (itself + two guests) for its + course, +- guests are mixed differently at each course. + +These invariants are verified in `tests/test_routing.py`. + +### Route optimization (simulated annealing) + +The decision variable is *which physical group sits in which slot*. For a given +assignment, `fast_total_time` sums every group's route +(starter‑host → main‑host → dessert‑host → after‑party) using the reduced, +penalty‑baked matrix. `simulated_annealing` starts from a random assignment and +repeatedly proposes swapping two slots, accepting worse solutions with Boltzmann +probability `exp(-Δ / T)` while the temperature `T` cools, and returns the best +assignment it finds. It is a **heuristic** — good, not provably optimal — though +on small instances it reliably reaches the true optimum. + +## Testing + +```bash +uv run pytest # full offline suite (no API calls, HTTP is mocked) +uv run pytest -m e2e # opt-in live test that calls the real Routes API +``` + +- The default suite (`tests/`) covers the domain model, group building, the + rotation topology, the route cost/optimization, and the Routes API wrapper + (with the HTTP layer mocked) — **no network, no API quota used**. +- `tests/test_e2e_api.py` is marked `e2e` and **excluded by default**. Run it + explicitly with `-m e2e`. It makes a single minimal request (two addresses → + a 2×2, 4‑element matrix) and **skips itself** if only a placeholder/dummy key + is available, so it never spuriously fails. + +## Limitations & scaling + +- **Participant count.** The Routes API `computeRouteMatrix` caps at **625 + elements** (a 25×25 matrix), so Tatami currently handles up to ~**24 + participants** plus the after‑party in one shot. Bigger events need batching. +- **Group counts** are multiples of 3, and you need **≥ 6 participants** before + any groups are formed at all. +- **Heuristic routing.** Simulated annealing does not guarantee the global + optimum on large instances; tune the schedule if results look poor. +- **One address per group.** Only the host's (`main_member`'s) address is used + for routing; guests are assumed to gather there. + +## Project layout + +``` +src/tatami/ + classes.py # Participant and Group domain model + traveltimes.py # Google Routes API wrapper + matrix helpers + tatami_masterplan.py # pipeline: load → fetch → group → optimize → assign +tests/ # pytest suite (offline + opt-in live e2e) +running_dinner/ # legacy standalone prototype — NOT used by the package +``` + +> `running_dinner/running_dinner.py` is a pre‑package prototype kept for +> reference only. It uses German field names, a brute‑force search, and the +> **legacy** Distance Matrix API. Don't assume its conventions apply to +> `src/tatami/`. + +## Development + +This project uses `uv` and enforces quality with `ruff` and `mypy`. + +```bash +uv sync # install (incl. dev tools) +uv run ruff check # lint +uv run ruff format # format +uv run mypy --allow-redefinition src/ # type-check +uv run pre-commit install # enable the pre-commit hooks +``` + +Pre‑commit runs `ruff check`, `ruff format`, and `mypy` automatically (see +`.pre-commit-config.yaml`).