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.
This commit is contained in:
2026-06-19 16:07:47 +02:00
parent 91dc7729f9
commit 6b0fca0100
12 changed files with 538 additions and 157 deletions
+67 -37
View File
@@ -79,9 +79,9 @@ gitignored, so your secret never gets committed. An alreadyexported
> 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).
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
@@ -111,18 +111,26 @@ Charlie Hermann-Hesse-Str 50, 76189 Karlsruhe 555-8765 9 none
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 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)).
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, get_masterplan, load_csv_to_participants,
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")
@@ -130,14 +138,22 @@ 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)
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
`get_masterplan` returns a tuple `(group_dicts, participant_dicts)`.
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.
Each **group** dict:
`get_masterplan` (a thin convenience wrapper around `compute_masterplan_groups`)
returns a tuple `(group_dicts, participant_dicts)`. Each **group** dict:
```python
{
@@ -157,13 +173,20 @@ 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
`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.
`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:
@@ -172,12 +195,15 @@ Set up once:
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`).
`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 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
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
@@ -187,9 +213,9 @@ 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`:
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 = {
@@ -199,11 +225,13 @@ ORGANIZER_CONTACTS = [("Lars (Organizer)", "0151-23456789")] # -> "Support Cont
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.
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 neither variable is set, this step is skipped entirely and Tatami just
prints the plan, as before.
If `spreadsheet_id` is unset, this step is skipped entirely and Tatami just
prints a reminder to fill it in.
## What you can tweak
@@ -211,14 +239,15 @@ All knobs currently live in the source. The most useful ones:
| What | Where | Default | Effect |
|------|-------|---------|--------|
| **Afterparty 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 "coursetriple". Larger → fewer, bigger groups. |
| **Kitchensize penalty** | `classes.py:19` (`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:4850` | `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 3course rotation is baked into the topology — changing the *count* needs more work (see below). |
| **Rotation topology** | `tatami_masterplan.py:172173` (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. |
| **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
@@ -291,7 +320,8 @@ uv run pytest -m e2e # opt-in live test that calls the real Routes API
```
src/tatami/
classes.py # Participant and Group domain model
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