91dc7729f9
Lets organizers automatically populate a shared Google Sheet (the same kind they previously built by hand) with an Overview tab and one tab per group, instead of printing dicts. Group tabs are named after all their members (not a single host) and show each group's route, course times, and guests with allergies. The Overview tab also gets configurable Meal Times, Support Contacts, and Info sections, passed through as plain data from tatami_masterplan.py. Export is fully opt-in via GOOGLE_SHEETS_CREDENTIALS_FILE and GOOGLE_SHEETS_SPREADSHEET_ID; without them, behavior is unchanged.
321 lines
15 KiB
Markdown
321 lines
15 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`.
|
||
|
||
Optionally, also set `GOOGLE_SHEETS_CREDENTIALS_FILE` and
|
||
`GOOGLE_SHEETS_SPREADSHEET_ID` in `.env` 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 **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.
|
||
|
||
## Sharing the plan with participants
|
||
|
||
`get_masterplan`'s dicts are great for code, but participants need something
|
||
readable. `compute_masterplan_groups` (the same computation, returning live
|
||
`Group`/`Participant` objects instead of dicts) 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**, and set
|
||
`GOOGLE_SHEETS_SPREADSHEET_ID` to that sheet's ID (in `.env`).
|
||
|
||
With both set, running `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, passed straight
|
||
through to the sheet with no logic in between — edit these 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)
|
||
```
|
||
|
||
`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 neither variable is set, this step is skipped entirely and Tatami just
|
||
prints the plan, as before.
|
||
|
||
## 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:251` (`__main__`) | a Karlsruhe address | Where everyone ends the night; also influences host ranking. |
|
||
| **Travel mode** | `tatami_masterplan.py:254` (`mode="BICYCLE"`) | `BICYCLE` | Any Routes API `travelMode`: `BICYCLE`, `DRIVE`, `WALK`, `TWO_WHEELER`, `TRANSIT`. |
|
||
| **Course start times** | `tatami_masterplan.py` (`COURSE_TIMES`) | `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: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
|
||
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 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`).
|