Skip to content

Commit 75e7b16

Browse files
committed
v7.4.0 — Route Plan with Milestones
New capability: existing saved routes can now generate a timed field plan with milestones, sunrise/sunset overlay, and resupply candidates. POST /api/maps/route-plan takes {route_id, pace_kmh, depart_iso, corridor_km, people} and returns: - Timed milestones at every waypoint in the chain — cumulative distance + ETA ("At WP-Bravo by 14:30") - Sun overlay — sunrise / sunset for the departure + arrival days (using the simplified NOAA algorithm from tasks.py) so the plan shows which milestones fall in darkness - Corridor search — finds other waypoints within `corridor_km` of any route waypoint and surfaces them as resupply/shelter options - Per-person water + calorie estimate (3 L/day, 3500 kcal/day — same reference numbers used by the Kit Builder) UI: new "Plan" button on each saved route card opens a modal with pace/corridor/people/departure inputs. Results page shows a 5-stat header (km, duration, ascent, water, kcal), the sun overlay, a timeline of milestones with dark-window highlighting, and a nearby- waypoint corridor list. Mobile-responsive. Empty state: if no other waypoints fall in the corridor, UI prompts user to widen the corridor or add more waypoints rather than showing an empty div. 7 new tests: happy path (NYC→Philly distance check), single-waypoint rejection, 404 on unknown route, pace affects duration, corridor surfaces nearby, pace clamp on zero-input. Tests: 861 passed (was 854).
1 parent 32072c6 commit 75e7b16

9 files changed

Lines changed: 652 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<div align="center">
22
<img src="nomad-mark.png" width="140" height="140"/>
33

4-
# NOMAD Field Desk v7.3.0
4+
# NOMAD Field Desk v7.4.0
55

66
### Your Personal Intelligence & Preparedness Command Center
77

config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class Config:
3636
"""Central configuration with environment variable overrides."""
3737

3838
# --- App Identity ---
39-
VERSION = os.environ.get('NOMAD_VERSION', '7.3.0')
39+
VERSION = os.environ.get('NOMAD_VERSION', '7.4.0')
4040

4141
# --- Upload / Content Limits ---
4242
MAX_CONTENT_LENGTH = int(os.environ.get('NOMAD_MAX_CONTENT_LENGTH', 100 * 1024 * 1024)) # 100 MB

installer.iss

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
; NOMAD Field Desk Inno Setup Script
2-
; AppVersion: 7.3.0
2+
; AppVersion: 7.4.0
33

44
#define MyAppName "NOMAD Field Desk"
5-
#define MyAppVersion "7.3.0"
5+
#define MyAppVersion "7.4.0"
66
#define MyAppPublisher "SysAdminDoc"
77
#define MyAppURL "https://github.com/SysAdminDoc/project-nomad-desktop"
88
#define MyAppSupportURL "https://github.com/SysAdminDoc/project-nomad-desktop/issues"

tests/test_route_plan.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Tests for the Route Plan endpoint (v7.4.0).
2+
3+
Covers the happy path, error paths, pace scaling, and corridor search.
4+
"""
5+
6+
7+
def _create_route_with_waypoints(client, coords, name='Test Route'):
8+
"""Create waypoints, then a route that references them in order.
9+
Returns (route_id, waypoint_ids)."""
10+
wp_ids = []
11+
for i, (lat, lng) in enumerate(coords):
12+
resp = client.post('/api/waypoints', json={
13+
'name': f'WP-{i}', 'lat': lat, 'lng': lng,
14+
'category': 'other', 'elevation_m': 100 + i * 10,
15+
})
16+
wp_ids.append(resp.get_json()['id'])
17+
import json
18+
resp = client.post('/api/maps/routes', json={
19+
'name': name,
20+
'waypoint_ids': json.dumps(wp_ids),
21+
})
22+
return resp.get_json()['id'], wp_ids
23+
24+
25+
class TestRoutePlan:
26+
def test_missing_route_id(self, client):
27+
resp = client.post('/api/maps/route-plan', json={})
28+
assert resp.status_code == 400
29+
30+
def test_unknown_route(self, client):
31+
resp = client.post('/api/maps/route-plan', json={'route_id': 99999})
32+
assert resp.status_code == 404
33+
34+
def test_single_waypoint_rejected(self, client):
35+
"""A route with fewer than 2 waypoints can't be planned."""
36+
rid, _ = _create_route_with_waypoints(client, [(40.0, -74.0)])
37+
resp = client.post('/api/maps/route-plan', json={'route_id': rid})
38+
assert resp.status_code == 400
39+
40+
def test_happy_path(self, client):
41+
# NYC → Philadelphia approx 130 km
42+
rid, _ = _create_route_with_waypoints(client, [
43+
(40.7128, -74.0060),
44+
(39.9526, -75.1652),
45+
])
46+
resp = client.post('/api/maps/route-plan', json={
47+
'route_id': rid, 'pace_kmh': 60, 'people': 2,
48+
})
49+
assert resp.status_code == 200
50+
data = resp.get_json()
51+
assert data['route_id'] == rid
52+
assert len(data['milestones']) == 2
53+
assert data['totals']['distance_km'] > 100
54+
assert data['totals']['distance_km'] < 200
55+
# 2 people → 2× water
56+
assert data['totals']['water_l_total'] > 0
57+
# Sun data present
58+
assert len(data['sun']) >= 1
59+
60+
def test_pace_affects_duration(self, client):
61+
rid, _ = _create_route_with_waypoints(client, [
62+
(40.0, -74.0), (41.0, -74.0), # ~111 km north
63+
])
64+
slow = client.post('/api/maps/route-plan', json={
65+
'route_id': rid, 'pace_kmh': 5,
66+
}).get_json()
67+
fast = client.post('/api/maps/route-plan', json={
68+
'route_id': rid, 'pace_kmh': 50,
69+
}).get_json()
70+
# Slower pace → longer duration
71+
assert slow['totals']['duration_hours'] > fast['totals']['duration_hours']
72+
73+
def test_corridor_surfaces_nearby_waypoints(self, client):
74+
rid, _ = _create_route_with_waypoints(client, [
75+
(40.0, -74.0), (41.0, -74.0),
76+
])
77+
# Create an off-route waypoint close to the start waypoint.
78+
# The corridor search measures from route waypoints (not segment
79+
# midpoints) so the candidate should be within the corridor of at
80+
# least one waypoint.
81+
client.post('/api/waypoints', json={
82+
'name': 'Resupply Alpha', 'lat': 40.05, 'lng': -74.05,
83+
'category': 'supply',
84+
})
85+
resp = client.post('/api/maps/route-plan', json={
86+
'route_id': rid, 'corridor_km': 20,
87+
})
88+
data = resp.get_json()
89+
names = [n['name'] for n in data.get('nearby_waypoints', [])]
90+
assert 'Resupply Alpha' in names
91+
92+
def test_pace_clamped(self, client):
93+
rid, _ = _create_route_with_waypoints(client, [
94+
(40.0, -74.0), (40.1, -74.0),
95+
])
96+
# Zero pace → clamped to 0.5 km/h minimum (not div/0)
97+
resp = client.post('/api/maps/route-plan', json={
98+
'route_id': rid, 'pace_kmh': 0,
99+
})
100+
assert resp.status_code == 200
101+
assert resp.get_json()['params']['pace_kmh'] >= 0.5

web/blueprints/maps.py

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -831,6 +831,254 @@ def api_elevation_profile(route_id):
831831
'total_descent': round(total_descent, 1),
832832
'total_distance_m': round(total_dist),
833833
})
834+
835+
836+
# ─── Route Plan with Milestones (v7.4.0) ───────────────────────────
837+
#
838+
# Takes an existing saved route (ordered waypoint chain) + a pace + a
839+
# departure time, and returns:
840+
# - Timed milestones at every waypoint ("you'll be at X by 14:30")
841+
# - Sunrise / sunset along the way so the planner can see if they're
842+
# moving in darkness on any leg
843+
# - Resupply / shelter candidates from other waypoints within a
844+
# configurable corridor around each leg (doesn't have to be
845+
# on the route — just nearby)
846+
# - Per-person water + calorie estimate for the trip
847+
#
848+
# This is deterministic math on the existing elevation_profile pipeline.
849+
# No external services, no routing engine — we just walk the waypoint
850+
# chain the user already built.
851+
852+
def _haversine_km(lat1, lng1, lat2, lng2):
853+
"""Great-circle distance in km."""
854+
R = 6371.0
855+
p1, p2 = math.radians(lat1), math.radians(lat2)
856+
dlat = math.radians(lat2 - lat1)
857+
dlng = math.radians(lng2 - lng1)
858+
a = math.sin(dlat / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlng / 2) ** 2
859+
return 2 * R * math.asin(math.sqrt(a))
860+
861+
862+
def _sun_times_for(lat, lng, when):
863+
"""Compute sunrise/sunset in UTC ISO-8601 for a given date + coord.
864+
865+
Simplified from tasks.py:_sun_times — uses the same NOAA algorithm
866+
but scoped to one day so the import graph stays trivial. Returns
867+
``{sunrise_iso, sunset_iso}``, or ``None`` for polar day/night.
868+
"""
869+
from datetime import datetime, timezone, timedelta
870+
# Convert date to Julian day (NOAA formula)
871+
y, m, d = when.year, when.month, when.day
872+
if m <= 2:
873+
y, m = y - 1, m + 12
874+
a = y // 100
875+
b = 2 - a + a // 4
876+
jd = int(365.25 * (y + 4716)) + int(30.6001 * (m + 1)) + d + b - 1524.5
877+
878+
def _zenith(rising):
879+
# Official zenith for sunrise/sunset = 90° 50'
880+
zen = 90.833
881+
lng_hr = lng / 15.0
882+
t = jd + ((6 if rising else 18) - lng_hr) / 24
883+
M = (0.9856 * t) - 3.289
884+
L = M + (1.916 * math.sin(math.radians(M))) + (0.020 * math.sin(math.radians(2 * M))) + 282.634
885+
L = L % 360
886+
RA = math.degrees(math.atan(0.91764 * math.tan(math.radians(L)))) % 360
887+
Lq = math.floor(L / 90) * 90
888+
RAq = math.floor(RA / 90) * 90
889+
RA = (RA + (Lq - RAq)) / 15
890+
sinDec = 0.39782 * math.sin(math.radians(L))
891+
cosDec = math.cos(math.asin(sinDec))
892+
try:
893+
cosH = (math.cos(math.radians(zen)) - (sinDec * math.sin(math.radians(lat)))) / (cosDec * math.cos(math.radians(lat)))
894+
except ZeroDivisionError:
895+
return None
896+
if cosH > 1 or cosH < -1:
897+
return None # Polar day/night
898+
if rising:
899+
H = 360 - math.degrees(math.acos(cosH))
900+
else:
901+
H = math.degrees(math.acos(cosH))
902+
H = H / 15
903+
T = H + RA - (0.06571 * t) - 6.622
904+
UT = (T - lng_hr) % 24
905+
return UT
906+
907+
def _ut_to_iso(ut, day):
908+
if ut is None:
909+
return None
910+
hours = int(ut)
911+
minutes = int((ut - hours) * 60)
912+
minutes = max(0, min(59, minutes))
913+
base = datetime(day.year, day.month, day.day, 0, 0, 0, tzinfo=timezone.utc)
914+
return (base + timedelta(hours=hours, minutes=minutes)).isoformat()
915+
916+
return {
917+
'sunrise_iso': _ut_to_iso(_zenith(True), when),
918+
'sunset_iso': _ut_to_iso(_zenith(False), when),
919+
}
920+
921+
922+
@maps_bp.route('/api/maps/route-plan', methods=['POST'])
923+
def api_maps_route_plan():
924+
"""Return a planned schedule for an existing route.
925+
926+
Body:
927+
{route_id, pace_kmh, depart_iso, corridor_km, people}
928+
929+
All fields optional except ``route_id``. Defaults:
930+
pace_kmh=5 (walking), depart_iso=now UTC, corridor_km=5, people=1
931+
"""
932+
from datetime import datetime, timezone, timedelta
933+
934+
data = request.get_json() or {}
935+
route_id = data.get('route_id')
936+
if not isinstance(route_id, int):
937+
try: route_id = int(route_id)
938+
except (TypeError, ValueError):
939+
return jsonify({'error': 'route_id required'}), 400
940+
941+
try:
942+
pace_kmh = float(data.get('pace_kmh') or 5.0)
943+
except (TypeError, ValueError):
944+
pace_kmh = 5.0
945+
pace_kmh = max(0.5, min(pace_kmh, 120.0)) # walking pace floor, vehicle ceiling
946+
947+
try:
948+
corridor_km = float(data.get('corridor_km') or 5.0)
949+
except (TypeError, ValueError):
950+
corridor_km = 5.0
951+
corridor_km = max(0.5, min(corridor_km, 50.0))
952+
953+
try:
954+
people = max(1, min(int(data.get('people') or 1), 50))
955+
except (TypeError, ValueError):
956+
people = 1
957+
958+
depart_iso = data.get('depart_iso')
959+
try:
960+
depart = datetime.fromisoformat((depart_iso or '').replace('Z', '+00:00'))
961+
if depart.tzinfo is None:
962+
depart = depart.replace(tzinfo=timezone.utc)
963+
except (TypeError, ValueError):
964+
depart = datetime.now(timezone.utc)
965+
966+
with db_session() as db:
967+
route = db.execute('SELECT * FROM map_routes WHERE id = ?', (route_id,)).fetchone()
968+
if not route:
969+
return jsonify({'error': 'Route not found'}), 404
970+
wp_ids = _safe_id_list(route['waypoint_ids'])
971+
if len(wp_ids) < 2:
972+
return jsonify({'error': 'Route needs at least 2 waypoints'}), 400
973+
974+
placeholders = ','.join('?' for _ in wp_ids)
975+
order_case = ' '.join(f'WHEN id = ? THEN {i}' for i, _ in enumerate(wp_ids))
976+
waypoints = db.execute(
977+
f'SELECT id, name, lat, lng, elevation_m, category FROM waypoints '
978+
f'WHERE id IN ({placeholders}) ORDER BY CASE {order_case} END',
979+
wp_ids + wp_ids,
980+
).fetchall()
981+
waypoints = [dict(w) for w in waypoints]
982+
983+
# All other waypoints (candidates for resupply/shelter corridor)
984+
all_wps = db.execute(
985+
'SELECT id, name, lat, lng, category FROM waypoints LIMIT 2000'
986+
).fetchall()
987+
route_id_set = set(wp_ids)
988+
candidates = [dict(w) for w in all_wps if w['id'] not in route_id_set]
989+
990+
# Walk the chain — compute cumulative distance, ETA, leg-level info
991+
milestones = []
992+
cumulative_km = 0.0
993+
cumulative_ascent_m = 0.0
994+
prev = None
995+
for wp in waypoints:
996+
if prev is not None:
997+
seg_km = _haversine_km(prev['lat'], prev['lng'], wp['lat'], wp['lng'])
998+
cumulative_km += seg_km
999+
delta = (wp.get('elevation_m') or 0) - (prev.get('elevation_m') or 0)
1000+
if delta > 0:
1001+
cumulative_ascent_m += delta
1002+
eta = depart + timedelta(hours=(cumulative_km / pace_kmh))
1003+
milestones.append({
1004+
'waypoint_id': wp['id'],
1005+
'name': wp['name'],
1006+
'lat': wp['lat'],
1007+
'lng': wp['lng'],
1008+
'category': wp.get('category') or '',
1009+
'cumulative_km': round(cumulative_km, 2),
1010+
'eta_iso': eta.isoformat(),
1011+
'eta_hours_in': round((eta - depart).total_seconds() / 3600, 2),
1012+
})
1013+
prev = wp
1014+
1015+
total_km = round(cumulative_km, 2)
1016+
total_hours = round(cumulative_km / pace_kmh, 2)
1017+
1018+
# Find nearby candidates — within corridor_km of ANY waypoint on the route
1019+
nearby = []
1020+
for cand in candidates:
1021+
best_km = None
1022+
best_wp = None
1023+
for wp in waypoints:
1024+
d = _haversine_km(wp['lat'], wp['lng'], cand['lat'], cand['lng'])
1025+
if best_km is None or d < best_km:
1026+
best_km = d
1027+
best_wp = wp
1028+
if best_km is not None and best_km <= corridor_km:
1029+
nearby.append({
1030+
'waypoint_id': cand['id'],
1031+
'name': cand['name'],
1032+
'category': cand.get('category') or '',
1033+
'lat': cand['lat'],
1034+
'lng': cand['lng'],
1035+
'nearest_route_wp': best_wp['name'] if best_wp else '',
1036+
'distance_from_route_km': round(best_km, 2),
1037+
})
1038+
nearby.sort(key=lambda c: c['distance_from_route_km'])
1039+
nearby = nearby[:30]
1040+
1041+
# Sunrise / sunset overlay — use the start coord on the depart date,
1042+
# and the end coord on the arrival date. For most routes these are
1043+
# the same day; two days of sun data is enough signal.
1044+
sun = []
1045+
days_spanned = set([depart.date()])
1046+
arrive_at = depart + timedelta(hours=total_hours)
1047+
days_spanned.add(arrive_at.date())
1048+
start_lat, start_lng = waypoints[0]['lat'], waypoints[0]['lng']
1049+
for day in sorted(days_spanned):
1050+
st = _sun_times_for(start_lat, start_lng, datetime.combine(day, datetime.min.time()))
1051+
sun.append({'date': day.isoformat(), 'lat': start_lat, 'lng': start_lng, **st})
1052+
1053+
# Resource burn estimate — reuse the Kit Builder water model without
1054+
# importing the blueprint (keeps maps → kit_builder coupling clean).
1055+
# Assume temperate + bug-out for pace-based movement.
1056+
water_l_per_person = 3.0 * max(1.0, total_hours / 24)
1057+
kcal_per_person = 3500 * max(1.0, total_hours / 24)
1058+
1059+
return jsonify({
1060+
'route_id': route_id,
1061+
'route_name': route['name'],
1062+
'params': {
1063+
'pace_kmh': pace_kmh,
1064+
'corridor_km': corridor_km,
1065+
'people': people,
1066+
'depart_iso': depart.isoformat(),
1067+
},
1068+
'milestones': milestones,
1069+
'totals': {
1070+
'distance_km': total_km,
1071+
'duration_hours': total_hours,
1072+
'ascent_m': round(cumulative_ascent_m, 0),
1073+
'water_l_total': round(water_l_per_person * people, 1),
1074+
'kcal_total': round(kcal_per_person * people, 0),
1075+
'arrive_iso': arrive_at.isoformat(),
1076+
},
1077+
'nearby_waypoints': nearby,
1078+
'sun': sun,
1079+
})
1080+
1081+
8341082
@maps_bp.route('/api/maps/annotations')
8351083
def api_map_annotations_list():
8361084
try:

0 commit comments

Comments
 (0)