899fb76b45
Document the running-dinner concept, setup (uv + .env API key), CSV input schema, how to run it (CLI and library), output format, a "what to tweak" table mapping each knob to its file:line, the algorithm (group building, rotation topology, annealing), testing (offline + opt-in e2e), limitations, and development commands. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
267 lines
12 KiB
Markdown
267 lines
12 KiB
Markdown
# Tatami
|
||
|
||
**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`).
|