Files
Tatami/README.md
T
lars 91dc7729f9 Add optional Google Sheets export for the masterplan
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.
2026-06-19 15:17:37 +02:00

15 KiB
Raw Blame History

Tatami

Tool for Arranging Tasty Appointments, Meetings & Invitations.

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?

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 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

# 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 and GOOGLE_SHEETS_SPREADSHEET_ID in .env to export the plan to a shared Google Sheet — see 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

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).

To use Tatami from your own code:

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:

{
    "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:

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
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.
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

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
  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.

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).