From 0e5fc7ac32eaa1d7dc606c48f1edf67add9cb45c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 08:23:02 +0000 Subject: [PATCH 1/3] Initial plan From 57b73b4f35d586e4b4d493ef79ac393c0ad999b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 08:32:46 +0000 Subject: [PATCH 2/3] refactor: extract shared haversine utility, fix error suppression, improve tests Co-authored-by: fallofpheonix <160165035+fallofpheonix@users.noreply.github.com> --- engine-backend/src/modules/common/geo.util.ts | 28 +++ .../src/modules/risk/risk.service.ts | 14 +- .../route-options/route-options.service.ts | 23 +-- .../modules/routing/pathfinding.service.ts | 16 +- .../src/modules/routing/routing.controller.ts | 20 +- udie_backend_py/app/services.py | 18 +- udie_backend_py/app/storage.py | 11 +- udie_mobile/test/widget_test.dart | 178 +++++++++++++++++- 8 files changed, 222 insertions(+), 86 deletions(-) create mode 100644 engine-backend/src/modules/common/geo.util.ts diff --git a/engine-backend/src/modules/common/geo.util.ts b/engine-backend/src/modules/common/geo.util.ts new file mode 100644 index 0000000..89fc652 --- /dev/null +++ b/engine-backend/src/modules/common/geo.util.ts @@ -0,0 +1,28 @@ +/** + * Shared geospatial utility functions. + * Single source of truth for Haversine distance calculations + * across all backend modules. + */ + +const EARTH_RADIUS_KM = 6371; + +/** + * Computes the great-circle distance in kilometres between two + * latitude/longitude coordinates using the Haversine formula. + */ +export function haversineKm( + lat1: number, + lng1: number, + lat2: number, + lng2: number, +): number { + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLng = ((lng2 - lng1) * Math.PI) / 180; + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos((lat1 * Math.PI) / 180) * + Math.cos((lat2 * Math.PI) / 180) * + Math.sin(dLng / 2) * + Math.sin(dLng / 2); + return EARTH_RADIUS_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} diff --git a/engine-backend/src/modules/risk/risk.service.ts b/engine-backend/src/modules/risk/risk.service.ts index b7db248..3718c75 100644 --- a/engine-backend/src/modules/risk/risk.service.ts +++ b/engine-backend/src/modules/risk/risk.service.ts @@ -7,6 +7,7 @@ import { resolveRouteRegion } from '../common/region-resolver.util'; import { ObservabilityService } from '../common/observability.service'; import { classifyRiskLevel } from './risk.algorithm'; import { RiskSurfaceCacheService } from './risk-surface-cache.service'; +import { haversineKm } from '../common/geo.util'; @Injectable() export class RiskService { @@ -287,7 +288,7 @@ export class RiskService { private calculatePathDistance(coords: { lat: number, lng: number }[]): number { let dist = 0; for (let i = 0; i < coords.length - 1; i++) { - dist += this.haversine(coords[i], coords[i + 1]); + dist += haversineKm(coords[i].lat, coords[i].lng, coords[i + 1].lat, coords[i + 1].lng); } return dist; } @@ -306,15 +307,4 @@ export class RiskService { } return nearest; } - - private haversine(c1: { lat: number, lng: number }, c2: { lat: number, lng: number }): number { - const R = 6371; // km - const dLat = (c2.lat - c1.lat) * Math.PI / 180; - const dLng = (c2.lng - c1.lng) * Math.PI / 180; - const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos(c1.lat * Math.PI / 180) * Math.cos(c2.lat * Math.PI / 180) * - Math.sin(dLng / 2) * Math.sin(dLng / 2); - const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - return R * c; - } } diff --git a/engine-backend/src/modules/route-options/route-options.service.ts b/engine-backend/src/modules/route-options/route-options.service.ts index 26db2df..13678d9 100644 --- a/engine-backend/src/modules/route-options/route-options.service.ts +++ b/engine-backend/src/modules/route-options/route-options.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { performance } from 'perf_hooks'; import { QueryResultRow } from 'pg'; import { DatabaseService } from '../../database/database.service'; +import { haversineKm } from '../common/geo.util'; import { SpatialService } from '../common/spatial.service'; import { EtaQueryDto, @@ -357,7 +358,7 @@ export class RouteOptionsService { }; } - const distanceKm = this.haversineKm(dto.origin, dto.destination); + const distanceKm = haversineKm(dto.origin.lat, dto.origin.lng, dto.destination.lat, dto.destination.lng); const startNode = await this.findNearestNode(dto.origin, dto.city_id); const endNode = await this.findNearestNode(dto.destination, dto.city_id); if (!startNode || !endNode) { @@ -460,7 +461,7 @@ export class RouteOptionsService { } private async loadGraph(origin: LatLngDto, destination: LatLngDto, cityId?: string): Promise { - const distanceKm = this.haversineKm(origin, destination); + const distanceKm = haversineKm(origin.lat, origin.lng, destination.lat, destination.lng); const margin = Math.max(0.05, distanceKm / 80); const minLat = Math.min(origin.lat, destination.lat) - margin; const maxLat = Math.max(origin.lat, destination.lat) + margin; @@ -698,10 +699,7 @@ export class RouteOptionsService { if (!node) { return 0; } - const distanceKm = this.haversineKm( - { lat: node.lat, lng: node.lng }, - { lat: end.lat, lng: end.lng }, - ); + const distanceKm = haversineKm(node.lat, node.lng, end.lat, end.lng); return (distanceKm * weights.distance) + ((distanceKm / 70) * 3600 * weights.travelTime); } @@ -932,19 +930,6 @@ export class RouteOptionsService { return { from, to }; } - private haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }) { - const R = 6371; - const dLat = ((b.lat - a.lat) * Math.PI) / 180; - const dLng = ((b.lng - a.lng) * Math.PI) / 180; - const aa = - Math.sin(dLat / 2) ** 2 + - Math.cos((a.lat * Math.PI) / 180) * - Math.cos((b.lat * Math.PI) / 180) * - Math.sin(dLng / 2) ** 2; - const c = 2 * Math.atan2(Math.sqrt(aa), Math.sqrt(1 - aa)); - return R * c; - } - private bearing(a: GraphNode, b: GraphNode) { const lat1 = (a.lat * Math.PI) / 180; const lat2 = (b.lat * Math.PI) / 180; diff --git a/engine-backend/src/modules/routing/pathfinding.service.ts b/engine-backend/src/modules/routing/pathfinding.service.ts index 29bb323..42bbe2d 100644 --- a/engine-backend/src/modules/routing/pathfinding.service.ts +++ b/engine-backend/src/modules/routing/pathfinding.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; +import { haversineKm } from '../common/geo.util'; import { GraphAdjacency, RoadEdge, RoadNode } from './road-graph.service'; export interface RouteCostWeights { @@ -216,7 +217,7 @@ export class PathfindingService { * A* heuristic: straight-line distance converted to approximate cost (Prompt 6). */ private heuristic(from: RoadNode, to: RoadNode, weights: RouteCostWeights): number { - const distKm = this.haversineKm(from.lat, from.lng, to.lat, to.lng); + const distKm = haversineKm(from.lat, from.lng, to.lat, to.lng); const estimatedTimeS = (distKm / 50) * 3600; // assume 50 km/h return weights.timeWeight * estimatedTimeS; } @@ -386,17 +387,4 @@ export class PathfindingService { private pathKey(path: PathResult): string { return path.nodes.map(n => n.id).join(','); } - - private haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number { - const R = 6371; - const dLat = ((lat2 - lat1) * Math.PI) / 180; - const dLng = ((lng2 - lng1) * Math.PI) / 180; - const a = - Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos((lat1 * Math.PI) / 180) * - Math.cos((lat2 * Math.PI) / 180) * - Math.sin(dLng / 2) * - Math.sin(dLng / 2); - return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - } } diff --git a/engine-backend/src/modules/routing/routing.controller.ts b/engine-backend/src/modules/routing/routing.controller.ts index a87e59b..b1a4782 100644 --- a/engine-backend/src/modules/routing/routing.controller.ts +++ b/engine-backend/src/modules/routing/routing.controller.ts @@ -9,6 +9,7 @@ import { } from '@nestjs/common'; import * as h3 from 'h3-js'; import { DatabaseService } from '../../database/database.service'; +import { haversineKm } from '../common/geo.util'; import { RoadGraphService } from './road-graph.service'; import { PathfindingService } from './pathfinding.service'; import { TrafficService } from './traffic.service'; @@ -118,7 +119,9 @@ export class RoutingController { hit_count = route_cache.hit_count + 1, expires_at = EXCLUDED.expires_at`, [originH3, destH3, hour, JSON.stringify(result)], - ).catch(() => { /* non-critical */ }); + ).catch((err: unknown) => { + this.logger.warn(`[ROUTE_CACHE] DB write failed (non-critical): ${err instanceof Error ? err.message : String(err)}`); + }); return result; } @@ -197,7 +200,7 @@ export class RoutingController { if (!originNode || !destNode) { // Fallback: simple haversine estimate - const distKm = this.haversineKm(olat, olng, dlat, dlng); + const distKm = haversineKm(olat, olng, dlat, dlng); const travelTimeS = (distKm / 40) * 3600; return { travelTimeS: Math.round(travelTimeS), @@ -230,7 +233,7 @@ export class RoutingController { // Fallback route when graph is empty or disconnected private fallbackRoute(dto: RouteRequestDto) { - const distKm = this.haversineKm( + const distKm = haversineKm( dto.origin.lat, dto.origin.lng, dto.destination.lat, dto.destination.lng, ); @@ -257,15 +260,4 @@ export class RoutingController { fallback: true, }; } - - private haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number { - const R = 6371; - const dLat = ((lat2 - lat1) * Math.PI) / 180; - const dLng = ((lng2 - lng1) * Math.PI) / 180; - const a = - Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * - Math.sin(dLng / 2) * Math.sin(dLng / 2); - return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - } } diff --git a/udie_backend_py/app/services.py b/udie_backend_py/app/services.py index 132d0d4..2cbacec 100644 --- a/udie_backend_py/app/services.py +++ b/udie_backend_py/app/services.py @@ -3,7 +3,7 @@ import asyncio import time from dataclasses import dataclass -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from math import exp import httpx @@ -19,6 +19,7 @@ RoutePoint, SourceStatus, TrafficForecastResponse, + _haversine_km, now_utc, ) from app.sources.base import GovernmentSource, SourceResult @@ -204,8 +205,6 @@ def traffic_forecast_for_point(lat: float, lng: float, horizon_minutes: int = 15 Short-term traffic forecast using exponential smoothing over stored risk cells (Prompt 18). Uses the in-memory risk store as a proxy for congestion intensity. """ - from math import exp - risk_cells = store.get_area_risk_cells(lat=lat, lng=lng, radius_km=1.0) avg_risk = sum(r for _, r in risk_cells) / max(1, len(risk_cells)) if risk_cells else 0.0 @@ -249,16 +248,7 @@ def compute_navigation_route( Compute a simple navigation route with risk scoring (Prompt 30). Uses straight-line polyline with risk-aware ETA. """ - from math import atan2, cos, radians, sin, sqrt - - def haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float: - r = 6371.0 - d_lat = radians(lat2 - lat1) - d_lng = radians(lng2 - lng1) - a = sin(d_lat / 2.0) ** 2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(d_lng / 2.0) ** 2 - return r * 2.0 * atan2(sqrt(a), sqrt(1.0 - a)) - - dist_km = haversine_km(origin.lat, origin.lng, destination.lat, destination.lng) + dist_km = _haversine_km(origin.lat, origin.lng, destination.lat, destination.lng) # Speed by mode speed_map = {"fastest": 60.0, "shortest": 40.0, "safest": 35.0, "balanced": 45.0} @@ -271,8 +261,6 @@ def haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float: risk_penalty = 1.0 + risk_score * 0.5 travel_time_s = (dist_km / speed_kmh) * 3600.0 * risk_penalty - from datetime import timedelta - arrival = now_utc() + timedelta(seconds=travel_time_s) polyline = [[origin.lng, origin.lat], [destination.lng, destination.lat]] diff --git a/udie_backend_py/app/storage.py b/udie_backend_py/app/storage.py index ee1f5b2..ea0e548 100644 --- a/udie_backend_py/app/storage.py +++ b/udie_backend_py/app/storage.py @@ -10,7 +10,7 @@ from pathlib import Path from app.config import settings -from app.models import AreaNewsItem, BoundingBox, DisruptionEvent, RoutePoint, now_utc +from app.models import AreaNewsItem, BoundingBox, DisruptionEvent, RoutePoint, _haversine_km, now_utc def _resolve_db_path(raw: str) -> Path: @@ -528,13 +528,4 @@ def _route_to_cells(points: list[RoutePoint]) -> list[RouteCell]: return cells -def _haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float: - r = 6371.0 - d_lat = math.radians(lat2 - lat1) - d_lng = math.radians(lng2 - lng1) - a = math.sin(d_lat / 2.0) ** 2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(d_lng / 2.0) ** 2 - c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a)) - return r * c - - store = UdieStore() diff --git a/udie_mobile/test/widget_test.dart b/udie_mobile/test/widget_test.dart index 629059d..c2d0ae9 100644 --- a/udie_mobile/test/widget_test.dart +++ b/udie_mobile/test/widget_test.dart @@ -1,7 +1,181 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:udie_mobile/src/models.dart'; +import 'package:udie_mobile/src/api_client.dart'; + void main() { - test('sanity', () { - expect(2 + 2, 4); + // ────────────────────────────────────────────────────────── + // DisruptionEvent model tests + // ────────────────────────────────────────────────────────── + group('DisruptionEvent.fromJson', () { + test('parses a complete JSON object', () { + final json = { + 'id': 'evt-001', + 'source': 'ndma', + 'category': 'flood', + 'title': 'Flood alert', + 'lat': 28.6139, + 'lng': 77.2090, + 'severity': 0.8, + 'updated_at': '2024-01-15T10:30:00.000Z', + }; + + final event = DisruptionEvent.fromJson(json); + + expect(event.id, 'evt-001'); + expect(event.source, 'ndma'); + expect(event.category, 'flood'); + expect(event.title, 'Flood alert'); + expect(event.lat, 28.6139); + expect(event.lng, 77.2090); + expect(event.severity, 0.8); + expect(event.updatedAt.isAfter(DateTime(2024)), isTrue); + }); + + test('handles missing optional fields with safe defaults', () { + final json = {}; + final event = DisruptionEvent.fromJson(json); + + expect(event.id, ''); + expect(event.lat, 0.0); + expect(event.lng, 0.0); + expect(event.severity, 0.0); + expect(event.updatedAt, DateTime.fromMillisecondsSinceEpoch(0)); + }); + + test('point getter returns correct LatLng', () { + final json = { + 'id': 'x', + 'source': 's', + 'category': 'c', + 'title': 't', + 'lat': 12.9716, + 'lng': 77.5946, + 'severity': 0.5, + 'updated_at': '', + }; + final event = DisruptionEvent.fromJson(json); + expect(event.point.latitude, 12.9716); + expect(event.point.longitude, 77.5946); + }); + }); + + // ────────────────────────────────────────────────────────── + // AreaNewsItem model tests + // ────────────────────────────────────────────────────────── + group('AreaNewsItem.fromJson', () { + test('parses a complete news item', () { + final json = { + 'id': 'news-1', + 'source': 'govt', + 'category': 'weather', + 'title': 'Heavy rain expected', + 'summary': 'IMD issues red alert', + 'url': 'https://example.com', + 'published_at': '2024-03-01T08:00:00.000Z', + 'lat': 19.076, + 'lng': 72.8777, + }; + + final item = AreaNewsItem.fromJson(json); + + expect(item.id, 'news-1'); + expect(item.category, 'weather'); + expect(item.lat, 19.076); + expect(item.lng, 72.8777); + }); + + test('nullable lat/lng defaults to null when absent', () { + final json = { + 'id': 'news-2', + 'source': 'src', + 'category': 'cat', + 'title': 'T', + 'summary': 'S', + 'url': 'https://example.com', + 'published_at': '2024-01-01T00:00:00Z', + }; + final item = AreaNewsItem.fromJson(json); + expect(item.lat, isNull); + expect(item.lng, isNull); + }); + }); + + // ────────────────────────────────────────────────────────── + // SourceStatus model tests + // ────────────────────────────────────────────────────────── + group('SourceStatus.fromJson', () { + test('parses event and news counts', () { + final json = { + 'name': 'ndma-sachet', + 'category': 'government', + 'endpoint': 'https://sachet.ndma.gov.in', + 'event_count': 42, + 'news_count': 7, + 'last_error': null, + }; + final status = SourceStatus.fromJson(json); + expect(status.eventCount, 42); + expect(status.newsCount, 7); + expect(status.lastError, isNull); + }); + + test('captures last_error string', () { + final json = { + 'name': 'src', + 'category': 'cat', + 'endpoint': 'https://example.com', + 'event_count': 0, + 'news_count': 0, + 'last_error': 'Connection timed out', + }; + final status = SourceStatus.fromJson(json); + expect(status.lastError, 'Connection timed out'); + }); + }); + + // ────────────────────────────────────────────────────────── + // RiskResult model tests + // ────────────────────────────────────────────────────────── + group('RiskResult.fromJson', () { + test('parses a DANGER risk response', () { + final json = { + 'riskScore': 0.85, + 'classification': 'DANGER', + 'riskDensity': 4.2, + 'contributingEvents': 5, + 'evalLatencyMs': 3, + }; + final result = RiskResult.fromJson(json); + expect(result.riskScore, 0.85); + expect(result.classification, 'DANGER'); + expect(result.contributingEvents, 5); + expect(result.evalLatencyMs, 3); + }); + + test('defaults to zero values on empty JSON', () { + final result = RiskResult.fromJson({}); + expect(result.riskScore, 0.0); + expect(result.classification, ''); + expect(result.contributingEvents, 0); + }); + }); + + // ────────────────────────────────────────────────────────── + // GeoArea model tests + // ────────────────────────────────────────────────────────── + group('GeoArea.toQuery', () { + test('produces correct query map keys', () { + final area = GeoArea( + city: 'Mumbai', + center: const LatLngCoordinate(19.076, 72.8777).toLatLng(), + radiusKm: 10, + ); + final query = area.toQuery(); + expect(query['city'], 'Mumbai'); + expect(query.containsKey('lat'), isTrue); + expect(query.containsKey('lng'), isTrue); + expect(query.containsKey('radiusKm'), isTrue); + }); }); } From bde1cfbe253357ac49655f96e81e808a082c84f3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 08:35:23 +0000 Subject: [PATCH 3/3] fix: remove unused import in widget test, wrap long haversine call sites Co-authored-by: fallofpheonix <160165035+fallofpheonix@users.noreply.github.com> --- .../src/modules/route-options/route-options.service.ts | 10 ++++++++-- udie_mobile/test/widget_test.dart | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/engine-backend/src/modules/route-options/route-options.service.ts b/engine-backend/src/modules/route-options/route-options.service.ts index 13678d9..44ce101 100644 --- a/engine-backend/src/modules/route-options/route-options.service.ts +++ b/engine-backend/src/modules/route-options/route-options.service.ts @@ -358,7 +358,10 @@ export class RouteOptionsService { }; } - const distanceKm = haversineKm(dto.origin.lat, dto.origin.lng, dto.destination.lat, dto.destination.lng); + const distanceKm = haversineKm( + dto.origin.lat, dto.origin.lng, + dto.destination.lat, dto.destination.lng, + ); const startNode = await this.findNearestNode(dto.origin, dto.city_id); const endNode = await this.findNearestNode(dto.destination, dto.city_id); if (!startNode || !endNode) { @@ -461,7 +464,10 @@ export class RouteOptionsService { } private async loadGraph(origin: LatLngDto, destination: LatLngDto, cityId?: string): Promise { - const distanceKm = haversineKm(origin.lat, origin.lng, destination.lat, destination.lng); + const distanceKm = haversineKm( + origin.lat, origin.lng, + destination.lat, destination.lng, + ); const margin = Math.max(0.05, distanceKm / 80); const minLat = Math.min(origin.lat, destination.lat) - margin; const maxLat = Math.max(origin.lat, destination.lat) + margin; diff --git a/udie_mobile/test/widget_test.dart b/udie_mobile/test/widget_test.dart index c2d0ae9..effc698 100644 --- a/udie_mobile/test/widget_test.dart +++ b/udie_mobile/test/widget_test.dart @@ -1,7 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; import 'package:udie_mobile/src/models.dart'; -import 'package:udie_mobile/src/api_client.dart'; void main() { // ──────────────────────────────────────────────────────────