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:
@@ -79,9 +79,9 @@ gitignored, so your secret never gets committed. An already‑exported
|
||||
> 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 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)).
|
||||
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 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)).
|
||||
|
||||
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 |
|
||||
|------|-------|---------|--------|
|
||||
| **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. |
|
||||
| **Saved plan path** | `PLAN_FILE` env var | `masterplan.json` | Where the computed/edited `Plan` is saved to and (on the next run) loaded from. |
|
||||
| **After‑party 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 "course‑triple". Larger → fewer, bigger groups. |
|
||||
| **Kitchen‑size penalty** | `classes.py` (`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` (`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 3‑course 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 routing‑relevant 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
|
||||
|
||||
Reference in New Issue
Block a user