Files
lars 6b0fca0100 Move Participant/Group to pydantic and add a saveable/editable Plan model
Participant and Group are now pydantic BaseModels. Group.hosts can form
cycles between groups, so it's kept as a private, non-persisted live list
(set via set_hosts()/add_host()) backed by a serializable host_uuids field,
re-linked via resolve_hosts() after a reload.

A new Plan model (src/tatami/plan.py) bundles groups, the after-party group,
and the event config (course_times, organizer_contacts, info_text,
spreadsheet_id) and supports save()/load() to/from JSON. tatami_masterplan's
__main__ now saves to masterplan.json (PLAN_FILE env var to override) on
first run and loads it on later runs instead of recomputing, so the plan can
be hand-edited (move a member between groups, change a course, fill in
spreadsheet_id) and picked up on rerun without hitting the Routes API again.
spreadsheet_id moves out of .env (GOOGLE_SHEETS_SPREADSHEET_ID) onto the plan
itself, since it's part of the plan rather than a secret.
2026-06-19 16:07:47 +02:00

351 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 afterparty.
- [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 **afterparty** 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 tabseparated CSV into `Participant` objects.
2. **Fetch travel times** — a full pairwise duration matrix between every
participant address plus the afterparty address, via the Google Routes API.
3. **Build the masterplan**:
- Rank participants by how convenient they are as hosts (kitchensize penalty
plus distance to the afterparty), then split into **hosts** (one per group)
and **semihosts** (distributed into the host groups).
- Reduce the full matrix to a hosttohost matrix and bake in each host's
kitchensize penalty.
- Run **simulated annealing** to find a lowtraveltime 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 alreadyexported
`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`.
Optionally, also set `GOOGLE_SHEETS_CREDENTIALS_FILE` in `.env` and a
`spreadsheet_id` in the saved `masterplan.json` to export the plan to a
shared Google Sheet — see [Sharing the plan with participants](#sharing-the-plan-with-participants).
## Input: the participant CSV
A **tabseparated** 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 | `010`; a *suitabilitytohost* 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
```
The first run reads `test-config.csv` from the current directory, calls the
Routes API, computes the masterplan, and **saves it** to `masterplan.json`
(override the path with the `PLAN_FILE` env var). The afterparty address and
travel mode are currently set in the `__main__` block of
`tatami_masterplan.py` (see [What you can tweak](#what-you-can-tweak)).
Every subsequent run **loads `masterplan.json` instead of recomputing**, so
you can hand-edit that file — move a participant between groups, change a
course, fix an address, set `spreadsheet_id` — and rerun to pick up the edit
without calling the Routes API again. Delete (or move) the file to force a
fresh computation.
To use Tatami from your own code:
```python
from tatami.tatami_masterplan import (
get_after_party_group, compute_masterplan_groups, load_csv_to_participants,
)
from tatami.traveltimes import get_participant_distance_matrix
from tatami.plan import Plan
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 = compute_masterplan_groups(
participants, distance_matrix, after_party
)
plan = Plan(groups=groups, after_party_group=after_party)
plan.save("masterplan.json")
```
## Output format
The domain model (`Participant`, `Group`, `Plan` in `src/tatami/`) is built on
[pydantic](https://docs.pydantic.dev/), so everything supports
`model_dump()`/`model_dump_json()` plus `Plan.save(path)`/`Plan.load(path)`
for the full plan.
`get_masterplan` (a thin convenience wrapper around `compute_masterplan_groups`)
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.
A `Group`'s `hosts` are *other* `Group`s, and these references can form
cycles (a group's hosts can also host that group back), so a `Plan`'s JSON
persists them as plain `host_uuids` id lists rather than embedding the full
objects — `Plan.load()` re-links the live `group.hosts` list from those ids
after loading, so `group.hosts[i].course` etc. works exactly as it does right
after computing the plan.
## Sharing the plan with participants
`Plan` (`src/tatami/plan.py`) bundles the computed `groups`, the
`after_party_group`, and the event-wide configuration below into one
object that feeds an optional Google Sheets export — the same kind of shared
spreadsheet organizers have used in previous years, just generated
automatically instead of by hand.
Set up once:
1. Create a Google Cloud service account and enable the **Google Sheets API**
for its project.
2. Download the service account's JSON key and point
`GOOGLE_SHEETS_CREDENTIALS_FILE` at it (in `.env`).
3. Create a blank Google Sheet, share it with the service account's
`client_email` (from the JSON key) as **Editor**, copy its sheet ID, and
set `spreadsheet_id` to that ID **in the saved `masterplan.json`** (not
`.env` — the spreadsheet to export to is part of the plan itself, so it
round-trips with everything else).
With `GOOGLE_SHEETS_CREDENTIALS_FILE` set and `spreadsheet_id` filled in,
rerunning `uv run python -m tatami.tatami_masterplan` populates that
spreadsheet with an **Overview** tab (every participant, their group, course,
address, phone, allergies — followed by a Meal Times table, a Support
Contacts table, and a free-text Info block, see below) and one tab per group
(their own course, route with addresses and fixed course times, and the
guest list — with allergies — for the course they host). Reruns are
idempotent: tabs are cleared and rewritten, and stale tabs from a previous
run are deleted.
Tatami never contacts participants directly — sharing the sheet's link is
still up to the organizer, exactly as before.
The three extra Overview sections are plain configuration, carried on the
`Plan` and passed straight through to the sheet with no logic in between —
set them when first building the plan, in `tatami_masterplan.py`:
```python
COURSE_TIMES = {
"starter": "18:30", "main": "20:00", "dessert": "22:00", "after_party": "23:30",
} # -> "Meal Times" table
ORGANIZER_CONTACTS = [("Lars (Organizer)", "0151-23456789")] # -> "Support Contacts" table
INFO_TEXT = "Welcome to the running dinner! ..." # -> "Info" block (one row per line)
```
or by editing `course_times` / `organizer_contacts` / `info_text` directly in
the saved `masterplan.json` afterwards. `organizer_contacts` and `info_text`
are optional (`None`/empty skips that section); `course_times` is also reused
for each group's own route table.
If `spreadsheet_id` is unset, this step is skipped entirely and Tatami just
prints a reminder to fill it in.
## What you can tweak
All knobs currently live in the source. The most useful ones:
| What | Where | Default | Effect |
|------|-------|---------|--------|
| **Saved plan path** | `PLAN_FILE` env var | `masterplan.json` | Where the computed/edited `Plan` is saved to and (on the next run) loaded from. |
| **Afterparty address** | `tatami_masterplan.py` (`__main__`) | a Karlsruhe address | Where everyone ends the night; also influences host ranking. Only used the first time a plan is computed. |
| **Travel mode** | `tatami_masterplan.py` (`mode="BICYCLE"`) | `BICYCLE` | Any Routes API `travelMode`: `BICYCLE`, `DRIVE`, `WALK`, `TWO_WHEELER`, `TRANSIT`. |
| **Course start times** | `tatami_masterplan.py` (`COURSE_TIMES`), or `course_times` in the saved plan | `18:30` / `20:00` / `22:00` / `23:30` | Fixed slot times written into the Google Sheet export; the dinner runs on a synchronized schedule, not travel-derived timing. |
| **Group sizing** | `tatami_masterplan.py:54` (`len(participants) / 6`) | 1 group per ~6 people | The divisor sets how many participants form one "coursetriple". Larger → fewer, bigger groups. |
| **Kitchensize penalty** | `classes.py` (`minutes=3 * (10 - kitchen_size)`) | 3 min per point | Traveltimeequivalent penalty for small kitchens. Raise the `3` to push hosting toward big kitchens. |
| **Annealing schedule** | `tatami_masterplan.py` (`run_simulated_annealing` call) | `T=1000`, `cooling=0.99`, `iters=10000` | Optimization quality vs. runtime. More iterations / slower cooling → better routes, slower. |
| **Course names** | `tatami_masterplan.py` (`courses = [...]`) | `["starter", "main", "dessert"]` | The three courses. The 3course rotation is baked into the topology — changing the *count* needs more work (see below). |
| **Rotation topology** | `tatami_masterplan.py` `get_courses` (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 routingrelevant 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 afterparty); the best
become the one **host** of each group, and the rest are shuffled in roundrobin
as **semihosts**. 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 … n1`. 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
(starterhost → mainhost → desserthost → afterparty) using the reduced,
penaltybaked 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, 4element 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 afterparty 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 (pydantic)
plan.py # Plan: bundles groups + config, save()/load() to/from JSON
traveltimes.py # Google Routes API wrapper + matrix helpers
sheets_export.py # optional Google Sheets export for participants
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 prepackage prototype kept for
> reference only. It uses German field names, a bruteforce 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
```
Precommit runs `ruff check`, `ruff format`, and `mypy` automatically (see
`.pre-commit-config.yaml`).