4da624bfcf
Route building / optimization (tatami_masterplan.py): - fast_total_time was permutation-invariant: it applied get_courses to group *values* instead of slots and ignored the permutation, so every ordering scored identically and the annealing optimized nothing. It now maps each rotation slot to its assigned group via the permutation. - Replaced the broken next_permutation/simulated_annealing (enumerated n! orderings per iteration, fed unnormalized Boltzmann weights to np.random.choice -> ValueError, and returned the last random sample) with a standard neighbor-swap annealer that tracks and returns the best solution and handles <2 slots. - Convert the reduced Timedelta matrix to float seconds before annealing (np.exp can't operate on Timedelta). Group building (tatami_masterplan.py): - assign_courses set each group's hosts (sorted by course) before all courses were assigned, so hosts whose course was still None got mis-ordered. Assign all courses first, then wire up hosts. - get_masterplan no longer mutates the caller's participant list. classes.py: - Narrow casts on distance-matrix lookups to satisfy the mypy gate (pre-existing failures). Tests: - Add pytest suite (74 tests) covering the rotation topology, route cost and optimization, the domain model, the masterplan pipeline, and the Routes API wrapper (HTTP mocked). The cost cross-check caught the assign_courses ordering bug above.
122 lines
4.0 KiB
Python
122 lines
4.0 KiB
Python
"""Tests for the Google Routes API wrapper (HTTP layer mocked)."""
|
|
|
|
import datetime as dt
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
import tatami.traveltimes as tt
|
|
from tatami.classes import Group
|
|
from tatami.traveltimes import (
|
|
get_distance_matrix,
|
|
get_participant_distance_matrix,
|
|
reduce_distance_matrix,
|
|
)
|
|
from conftest import make_participants, make_timedelta_matrix
|
|
|
|
|
|
class FakeResponse:
|
|
def __init__(self, status_code, json_data=None, text=""):
|
|
self.status_code = status_code
|
|
self._json = json_data
|
|
self.text = text
|
|
|
|
def json(self):
|
|
return self._json
|
|
|
|
|
|
def route_matrix_payload(durations_seconds):
|
|
"""Build a computeRouteMatrix-style flat list of elements from an n x n array."""
|
|
payload = []
|
|
n = len(durations_seconds)
|
|
for i in range(n):
|
|
for j in range(n):
|
|
payload.append(
|
|
{
|
|
"originIndex": i,
|
|
"destinationIndex": j,
|
|
"duration": f"{int(durations_seconds[i][j])}s",
|
|
"distanceMeters": int(durations_seconds[i][j]) * 4,
|
|
}
|
|
)
|
|
return payload
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_post(monkeypatch):
|
|
"""Patch requests.post; returns a setter for the desired response."""
|
|
state = {}
|
|
|
|
def fake_post(url, json=None, headers=None):
|
|
return state["response"]
|
|
|
|
monkeypatch.setattr(tt.requests, "post", fake_post)
|
|
|
|
def set_response(response):
|
|
state["response"] = response
|
|
|
|
return set_response
|
|
|
|
|
|
class TestGetDistanceMatrix:
|
|
def test_builds_duration_matrix(self, mock_post):
|
|
durations = np.array([[0, 120], [300, 0]])
|
|
mock_post(FakeResponse(200, route_matrix_payload(durations)))
|
|
matrix = get_distance_matrix(["a", "b"])
|
|
assert matrix.shape == (2, 2)
|
|
assert matrix.loc[0, 1] == pd.to_timedelta("120s")
|
|
assert matrix.loc[1, 0] == pd.to_timedelta("300s")
|
|
|
|
def test_distance_meters_value(self, mock_post):
|
|
durations = np.array([[0, 120], [300, 0]])
|
|
mock_post(FakeResponse(200, route_matrix_payload(durations)))
|
|
matrix = get_distance_matrix(["a", "b"], value="distanceMeters")
|
|
assert matrix.loc[0, 1] == 480.0
|
|
|
|
def test_empty_addresses_raises(self):
|
|
with pytest.raises(ValueError):
|
|
get_distance_matrix([])
|
|
|
|
def test_non_200_raises(self, mock_post):
|
|
mock_post(FakeResponse(500, text="boom"))
|
|
with pytest.raises(Exception, match="boom"):
|
|
get_distance_matrix(["a", "b"])
|
|
|
|
def test_invalid_value_raises(self, mock_post):
|
|
durations = np.array([[0, 120], [300, 0]])
|
|
mock_post(FakeResponse(200, route_matrix_payload(durations)))
|
|
with pytest.raises(ValueError):
|
|
get_distance_matrix(["a", "b"], value="bogus")
|
|
|
|
|
|
class TestGetParticipantDistanceMatrix:
|
|
def test_labels_axes_with_uuids(self, mock_post):
|
|
participants = make_participants(2)
|
|
durations = np.array([[0, 120], [300, 0]])
|
|
mock_post(FakeResponse(200, route_matrix_payload(durations)))
|
|
matrix = get_participant_distance_matrix(participants)
|
|
uuids = [p.uuid for p in participants]
|
|
assert list(matrix.index) == uuids
|
|
assert list(matrix.columns) == uuids
|
|
|
|
|
|
class TestReduceDistanceMatrix:
|
|
def test_selects_main_members_and_adds_penalty(self):
|
|
# group 0 main member has kitchen_size 7 -> 9 minute (540s) penalty.
|
|
people = make_participants(2, kitchen_sizes=[7.0, 10.0])
|
|
seconds = np.array([[0, 100], [200, 0]])
|
|
matrix = make_timedelta_matrix(people, seconds)
|
|
groups = [Group(members=[people[0]]), Group(members=[people[1]])]
|
|
|
|
reduced = reduce_distance_matrix(matrix, groups)
|
|
|
|
u0, u1 = people[0].uuid, people[1].uuid
|
|
assert list(reduced.index) == [u0, u1]
|
|
# Penalty added to every entry of group 0's row.
|
|
assert reduced.loc[u0, u1] == dt.timedelta(seconds=100) + dt.timedelta(
|
|
minutes=9
|
|
)
|
|
# Group 1 has no penalty (kitchen_size 10).
|
|
assert reduced.loc[u1, u0] == dt.timedelta(seconds=200)
|