Build Lake Eufaula recommendation vertical slice

This commit is contained in:
reaper
2026-08-29 20:35:42 -05:00
parent 3eb459a307
commit ad4089bb77
8 changed files with 116 additions and 12 deletions
+36
View File
@@ -0,0 +1,36 @@
from dataclasses import asdict, dataclass
from datetime import datetime
@dataclass(frozen=True)
class Conditions:
air_temperature_f: int
wind_mph: int
wind_direction: str
pressure_inhg: float
pressure_trend: str
cloud_cover_percent: int
water_temperature_f: int
sunrise: str
def demo_conditions(starts_at: datetime) -> Conditions:
"""Stable demo conditions until a live provider adapter is connected."""
seasonal_water = {12: 52, 1: 49, 2: 53, 3: 61, 4: 69, 5: 76}.get(starts_at.month, 78)
return Conditions(64 if starts_at.month in (2, 3, 11) else 72, 9, "SW", 29.91, "falling", 58, seasonal_water, "6:42 AM")
def build_bass_plan(starts_at: datetime) -> dict:
conditions = demo_conditions(starts_at)
score = min(82 + (4 if conditions.pressure_trend == "falling" else 0) + (3 if 6 <= conditions.wind_mph <= 14 else 0) + (2 if conditions.cloud_cover_percent >= 50 else 0), 95)
return {
"bite_score": score,
"confidence": 0.86,
"summary": "Start on wind-blown secondary points at first light, then move to shaded wood and dock cover as the sun rises.",
"conditions": asdict(conditions),
"primary_pattern": {"name": "Wind-blown points", "depth": "612 ft", "structure": "Secondary points with rock, brush, or nearby creek-channel access", "presentation": "Slow-roll across the wind, then pause beside cover"},
"tackle": {"lure": "3/8 oz chartreuse-white double willow spinnerbait", "rod": "7' medium-heavy, fast action", "reel": "7.1:1 baitcaster", "line": "15 lb fluorocarbon", "backup": "Green-pumpkin Texas-rigged creature bait for protected cover"},
"evidence": ["A falling pressure trend favors active, roaming bass.", "A 9 mph southwest wind concentrates bait on exposed banks and points.", "Cloud cover extends the moving-bait window after sunrise.", f"Water near {conditions.water_temperature_f}°F supports a moderate retrieve."],
"professor_finn": "Id begin on the southwest-facing secondary points and let the wind choose the first three stops. Keep the spinnerbait moving until you contact fish. If the wind fades or the sun becomes direct, slow down with the Texas rig around shaded wood and the darkest dock edges.",
"data_status": "Demo conditions — live weather provider not connected yet",
}
+22
View File
@@ -0,0 +1,22 @@
from datetime import datetime, timezone
from django.test import SimpleTestCase
from rest_framework.test import APIClient
from .recommendations import build_bass_plan
class RecommendationEngineTests(SimpleTestCase):
def test_plan_is_explainable_and_actionable(self):
plan = build_bass_plan(datetime(2026, 9, 5, 11, tzinfo=timezone.utc))
self.assertGreaterEqual(plan["bite_score"], 80)
self.assertEqual(plan["primary_pattern"]["name"], "Wind-blown points")
self.assertEqual(len(plan["evidence"]), 4)
self.assertIn("spinnerbait", plan["tackle"]["lure"])
class DemoPlanApiTests(SimpleTestCase):
def setUp(self): self.client = APIClient()
def test_demo_plan_requires_no_account(self):
response = self.client.post("/api/demo/plan/", {"starts_at": "2026-09-05T06:30:00-05:00"}, format="json")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["trip"]["water_body"], "Lake Eufaula")
self.assertIn("professor_finn", response.data["plan"])
def test_invalid_date_is_clear(self):
self.assertEqual(self.client.post("/api/demo/plan/", {"starts_at": "Saturday morning"}, format="json").status_code, 400)
+2 -3
View File
@@ -1,11 +1,10 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import RecommendationViewSet, SpeciesViewSet, TripViewSet, WaterBodyViewSet, health
from .views import RecommendationViewSet, SpeciesViewSet, TripViewSet, WaterBodyViewSet, demo_plan, health
router = DefaultRouter()
router.register("species", SpeciesViewSet)
router.register("water-bodies", WaterBodyViewSet)
router.register("trips", TripViewSet, basename="trip")
router.register("recommendations", RecommendationViewSet, basename="recommendation")
urlpatterns = [path("health/", health), path("", include(router.urls))]
urlpatterns = [path("health/", health), path("demo/plan/", demo_plan), path("", include(router.urls))]
+19 -2
View File
@@ -1,11 +1,29 @@
from datetime import timedelta
from django.http import JsonResponse
from rest_framework import permissions, viewsets
from django.utils import timezone
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from .models import Recommendation, Species, Trip, WaterBody
from .recommendations import build_bass_plan
from .serializers import RecommendationSerializer, SpeciesSerializer, TripSerializer, WaterBodySerializer
def health(_request):
return JsonResponse({"status": "ok", "service": "fishiq-api"})
@api_view(["GET", "POST"])
@permission_classes([permissions.AllowAny])
def demo_plan(request):
starts_at = timezone.now() + timedelta(days=3)
supplied_start = request.data.get("starts_at") if request.method == "POST" else request.query_params.get("starts_at")
if supplied_start:
try:
starts_at = timezone.datetime.fromisoformat(supplied_start.replace("Z", "+00:00"))
if timezone.is_naive(starts_at): starts_at = timezone.make_aware(starts_at)
except ValueError:
return Response({"starts_at": ["Use an ISO-8601 date and time."]}, status=status.HTTP_400_BAD_REQUEST)
return Response({"trip": {"water_body": "Lake Eufaula", "region": "Alabama / Georgia", "target_species": "Largemouth bass", "starts_at": starts_at.isoformat(), "duration_hours": 5}, "plan": build_bass_plan(starts_at)})
class ReadOnlyCatalogViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = [permissions.AllowAny]
@@ -27,4 +45,3 @@ class RecommendationViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = RecommendationSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self): return Recommendation.objects.filter(trip__owner=self.request.user)