diff --git a/llm/flask_server.py b/llm/flask_server.py index 9b2e373..2657f2a 100644 --- a/llm/flask_server.py +++ b/llm/flask_server.py @@ -368,6 +368,87 @@ def get_single_route_endpoint(): 'error': str(e) }), 500 +@app.route('/predict', methods=['POST', 'OPTIONS']) +def predict_crash_magnitude(): + """ + Predict crash magnitude for a route using AI model. + Expected request body: + { + "source": {"lat": float, "lon": float}, + "destination": {"lat": float, "lon": float} + } + """ + # Handle preflight CORS request + if request.method == 'OPTIONS': + response = jsonify({'status': 'ok'}) + response.headers.add('Access-Control-Allow-Origin', '*') + response.headers.add('Access-Control-Allow-Headers', 'Content-Type') + response.headers.add('Access-Control-Allow-Methods', 'POST, OPTIONS') + return response + + try: + data = request.get_json() + if not data: + return jsonify({'error': 'No JSON data provided'}), 400 + + # Validate required fields + if 'source' not in data or 'destination' not in data: + return jsonify({'error': 'Missing source or destination coordinates'}), 400 + + source = data['source'] + destination = data['destination'] + + # Validate coordinate format + required_fields = ['lat', 'lon'] + for coord_set, name in [(source, 'source'), (destination, 'destination')]: + for field in required_fields: + if field not in coord_set: + return jsonify({'error': f'Missing {field} in {name} coordinates'}), 400 + try: + float(coord_set[field]) + except (TypeError, ValueError): + return jsonify({'error': f'Invalid {field} value in {name} coordinates'}), 400 + + # For now, return a mock prediction based on distance + # In a real implementation, this would call your AI model + import math + + lat1, lon1 = float(source['lat']), float(source['lon']) + lat2, lon2 = float(destination['lat']), float(destination['lon']) + + # Calculate distance (rough approximation) + distance = math.sqrt((lat2 - lat1)**2 + (lon2 - lon1)**2) + + # Mock prediction: longer routes might have higher crash magnitude + # This is just placeholder logic until you integrate your actual AI model + base_magnitude = min(distance * 50, 1.0) # Cap at 1.0 + confidence = 0.85 # Mock confidence + + response_data = { + 'prediction': { + 'prediction': base_magnitude, + 'confidence': confidence + }, + 'called_with': f"Route from ({lat1}, {lon1}) to ({lat2}, {lon2})", + 'diagnostics': { + 'input_dim': 4 # lat1, lon1, lat2, lon2 + } + } + + print(f"šŸ”® Crash magnitude prediction request: {data}") + print(f"šŸ“Š Returning prediction: {response_data['prediction']['prediction']:.3f}") + + response = jsonify(response_data) + response.headers.add('Access-Control-Allow-Origin', '*') + return response + + except Exception as e: + print(f"āŒ Error in crash magnitude prediction: {e}") + traceback.print_exc() + error_response = jsonify({'error': str(e)}) + error_response.headers.add('Access-Control-Allow-Origin', '*') + return error_response, 500 + @app.errorhandler(404) def not_found(error): return jsonify({'success': False, 'error': 'Endpoint not found'}), 404 @@ -384,6 +465,7 @@ if __name__ == '__main__': print(" - POST /api/analyze-crashes") print(" - POST /api/find-safe-route") print(" - POST /api/get-single-route") + print(" - POST /predict (AI crash magnitude prediction)") print("\n🌐 Server running on http://localhost:5001") app.run(debug=True, host='0.0.0.0', port=5001) \ No newline at end of file diff --git a/web/src/app/components/MapView.tsx b/web/src/app/components/MapView.tsx index 6ae73a9..d554bb8 100644 --- a/web/src/app/components/MapView.tsx +++ b/web/src/app/components/MapView.tsx @@ -5,7 +5,7 @@ import mapboxgl from 'mapbox-gl'; import 'mapbox-gl/dist/mapbox-gl.css'; import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder'; import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css'; -import { generateDCPoints, haversine, PointFeature, convertCrashDataToGeoJSON } from '../lib/mapUtils'; +import { generateDCPoints, generateDCPointsWithAI, haversine, PointFeature, convertCrashDataToGeoJSON, convertCrashDataToGeoJSONWithAI } from '../lib/mapUtils'; import { useCrashData, UseCrashDataResult } from '../hooks/useCrashData'; import { CrashData } from '../api/crashes/route'; import { WeatherData, CrashAnalysisData } from '../../lib/flaskApi'; @@ -49,6 +49,7 @@ interface MapViewProps { crashData?: CrashData[]; // external crash data to use crashDataHook?: UseCrashDataResult; // the crash data hook from main page isMapPickingMode?: boolean; // whether map is in picking mode (prevents popups) + useAIMagnitudes?: boolean; // whether to use AI-predicted crash magnitudes } export default function MapView({ @@ -63,7 +64,8 @@ export default function MapView({ useRealCrashData = true, crashData = [], crashDataHook, - isMapPickingMode = false + isMapPickingMode = false, + useAIMagnitudes = true // Default to true to use AI predictions }: MapViewProps) { const containerRef = useRef(null); const mapContainerRef = useRef(null); @@ -71,6 +73,7 @@ export default function MapView({ const styleChoiceRef = useRef<'dark' | 'streets'>(mapStyleChoice); const isMapPickingModeRef = useRef(isMapPickingMode); const [size, setSize] = useState({ width: 0, height: 0 }); + const [isLoadingAIPredictions, setIsLoadingAIPredictions] = useState(false); const dcDataRef = useRef(null); const internalCrashDataHook = useCrashData({ autoLoad: false, limit: 10000 }); // Don't auto-load if external data provided @@ -84,80 +87,40 @@ export default function MapView({ const currentCrashDataHook = crashDataHook || internalCrashDataHook; const activeData = crashData.length > 0 ? crashData : currentCrashDataHook.data; console.log('MapView useEffect: crashData.length =', crashData.length, 'crashDataHook.data.length =', currentCrashDataHook.data.length); + if (useRealCrashData && activeData.length > 0) { console.log('Converting crash data to GeoJSON...'); - dcDataRef.current = convertCrashDataToGeoJSON(activeData); - // Update the map source if map is ready - const map = mapRef.current; - if (map && map.isStyleLoaded()) { - console.log('Updating map source with new data...'); - if (map.getSource('dc-quakes')) { - (map.getSource('dc-quakes') as mapboxgl.GeoJSONSource).setData(dcDataRef.current); + + const processData = async () => { + setIsLoadingAIPredictions(useAIMagnitudes); + + let geoJSONData: GeoJSON.FeatureCollection; + if (useAIMagnitudes) { + console.log('šŸ¤– Using AI-enhanced crash data conversion...'); + geoJSONData = await convertCrashDataToGeoJSONWithAI(activeData); } else { - console.log('Source not found, calling addDataAndLayers'); - // Call the inner function manually - we need to recreate it here - if (dcDataRef.current) { - console.log('Adding data and layers, data has', dcDataRef.current.features.length, 'features'); - if (!map.getSource('dc-quakes')) { - console.log('Creating new source'); - map.addSource('dc-quakes', { type: 'geojson', data: dcDataRef.current }); - } - // Add layers if they don't exist - if (!map.getLayer('dc-heat')) { - map.addLayer({ - id: 'dc-heat', type: 'heatmap', source: 'dc-quakes', - paint: { - 'heatmap-weight': ['interpolate', ['linear'], ['get', 'mag'], 0, 0, 6, 1], - 'heatmap-intensity': heatIntensity, - 'heatmap-color': [ - 'interpolate', - ['linear'], - ['heatmap-density'], - 0, 'rgba(0,0,0,0)', - 0.2, 'rgba(255,255,0,0.7)', - 0.4, 'rgba(255,165,0,0.8)', - 0.6, 'rgba(255,69,0,0.9)', - 0.8, 'rgba(255,0,0,0.95)', - 1, 'rgba(139,0,0,1)' - ], - 'heatmap-radius': heatRadius, - 'heatmap-opacity': ['interpolate', ['linear'], ['zoom'], 7, 1, 12, 0.8] - } - }); - } - if (!map.getLayer('dc-point')) { - map.addLayer({ - id: 'dc-point', type: 'circle', source: 'dc-quakes', minzoom: 12, - paint: { - 'circle-radius': ['interpolate', ['linear'], ['get', 'mag'], 1, 3, 6, 10], - 'circle-color': [ - 'interpolate', - ['linear'], - ['get', 'mag'], - 1, styleChoiceRef.current === 'dark' ? '#ffff99' : '#ffa500', - 3, styleChoiceRef.current === 'dark' ? '#ff6666' : '#ff4500', - 6, styleChoiceRef.current === 'dark' ? '#ff0000' : '#8b0000' - ] as any, - 'circle-opacity': ['interpolate', ['linear'], ['zoom'], 12, 0.7, 14, 0.9], - 'circle-stroke-width': 1, - 'circle-stroke-color': styleChoiceRef.current === 'dark' ? '#ffffff' : '#000000' - } - }); - } - // Update layer visibility - if (map.getLayer('dc-heat')) { - map.setLayoutProperty('dc-heat', 'visibility', heatVisible ? 'visible' : 'none'); - } - if (map.getLayer('dc-point')) { - map.setLayoutProperty('dc-point', 'visibility', pointsVisible ? 'visible' : 'none'); - } + console.log('šŸ“Š Using standard crash data conversion...'); + geoJSONData = convertCrashDataToGeoJSON(activeData); + } + + dcDataRef.current = geoJSONData; + setIsLoadingAIPredictions(false); + + // Update the map source if map is ready + const map = mapRef.current; + if (map && map.isStyleLoaded()) { + console.log('Updating map source with new data...'); + if (map.getSource('dc-quakes')) { + (map.getSource('dc-quakes') as mapboxgl.GeoJSONSource).setData(dcDataRef.current); + } else { + console.log('Source not found, will be added when map loads'); } } - } else { - console.log('Map style not loaded yet'); - } + }; + + processData().catch(console.error); } - }, [useRealCrashData, crashDataHook?.data, crashData, heatRadius, heatIntensity, heatVisible, pointsVisible]); + }, [crashData, crashDataHook, useRealCrashData, useAIMagnitudes]); useEffect(() => { const el = containerRef.current; @@ -236,16 +199,35 @@ export default function MapView({ const currentCrashDataHook = crashDataHook || internalCrashDataHook; const activeData = crashData.length > 0 ? crashData : currentCrashDataHook.data; console.log('Initializing map data, activeData length:', activeData.length); - if (useRealCrashData && activeData.length > 0) { - console.log('Using real crash data'); - dcDataRef.current = convertCrashDataToGeoJSON(activeData); - } else if (!useRealCrashData) { - console.log('Using synthetic data'); - dcDataRef.current = generateDCPoints(900); - } else { - console.log('No data available yet, using empty data'); - dcDataRef.current = { type: 'FeatureCollection' as const, features: [] }; - } + + const initializeData = async () => { + if (useRealCrashData && activeData.length > 0) { + console.log('Using real crash data'); + if (useAIMagnitudes) { + setIsLoadingAIPredictions(true); + console.log('šŸ¤– Using AI-enhanced real crash data...'); + dcDataRef.current = await convertCrashDataToGeoJSONWithAI(activeData); + setIsLoadingAIPredictions(false); + } else { + dcDataRef.current = convertCrashDataToGeoJSON(activeData); + } + } else if (!useRealCrashData) { + console.log('Using synthetic data'); + if (useAIMagnitudes) { + setIsLoadingAIPredictions(true); + console.log('šŸ¤– Using AI-enhanced synthetic data...'); + dcDataRef.current = await generateDCPointsWithAI(900); + setIsLoadingAIPredictions(false); + } else { + dcDataRef.current = generateDCPoints(900); + } + } else { + console.log('No data available yet, using empty data'); + dcDataRef.current = { type: 'FeatureCollection' as const, features: [] }; + } + }; + + initializeData().catch(console.error); const computeNearbyStats = async (center: [number, number], radiusMeters = 300) => { try { diff --git a/web/src/app/components/UnifiedControlPanel.tsx b/web/src/app/components/UnifiedControlPanel.tsx index ccc3b35..5f4f12a 100644 --- a/web/src/app/components/UnifiedControlPanel.tsx +++ b/web/src/app/components/UnifiedControlPanel.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { UseCrashDataResult } from '../hooks/useCrashData'; +import { getCircuitBreakerStatus } from '../../lib/crashMagnitudeApi'; interface UnifiedControlPanelProps { // Map controls props @@ -17,6 +18,8 @@ interface UnifiedControlPanelProps { onChangeIntensity: (v: number) => void; gradientRoutes: boolean; onToggleGradientRoutes: (v: boolean) => void; + useAIMagnitudes: boolean; + onToggleAIMagnitudes: (v: boolean) => void; // Crash data controls props crashDataHook: UseCrashDataResult; @@ -36,6 +39,8 @@ export default function UnifiedControlPanel({ onChangeIntensity, gradientRoutes, onToggleGradientRoutes, + useAIMagnitudes, + onToggleAIMagnitudes, crashDataHook, onDataLoaded }: UnifiedControlPanelProps) { @@ -59,6 +64,7 @@ export default function UnifiedControlPanel({ const [isMapControlsSectionOpen, setIsMapControlsSectionOpen] = useState(getInitialMapControlsState); const [isCrashDataSectionOpen, setIsCrashDataSectionOpen] = useState(getInitialCrashDataState); const [isHydrated, setIsHydrated] = useState(false); + const [aiApiStatus, setAiApiStatus] = useState<{ isOpen: boolean; failures: number }>({ isOpen: false, failures: 0 }); // Load localStorage values after hydration useEffect(() => { @@ -77,7 +83,26 @@ export default function UnifiedControlPanel({ } setIsHydrated(true); - }, []); // Crash data state + }, []); + + // Check AI API status when AI magnitudes are enabled + useEffect(() => { + if (useAIMagnitudes) { + const checkApiStatus = () => { + const status = getCircuitBreakerStatus(); + setAiApiStatus(status); + }; + + // Check immediately + checkApiStatus(); + + // Check every 30 seconds + const interval = setInterval(checkApiStatus, 30000); + return () => clearInterval(interval); + } + }, [useAIMagnitudes]); + + // Crash data state const { data, loading, error, pagination, loadMore, refresh, yearFilter, setYearFilter } = crashDataHook; const [currentYear, setCurrentYear] = useState('2024'); // Default to prevent hydration mismatch const [selectedYear, setSelectedYear] = useState('2024'); // Default value @@ -236,6 +261,29 @@ export default function UnifiedControlPanel({ onToggleGradientRoutes(e.target.checked)} /> +
+ + onToggleAIMagnitudes(e.target.checked)} /> +
+ + {useAIMagnitudes && ( +
+ Uses AI to predict crash severity. Falls back to traditional calculation if API unavailable. +
+ )} +
onChangeRadius(Number(e.target.value))} style={{ width: '100%' }} /> diff --git a/web/src/app/lib/mapUtils.ts b/web/src/app/lib/mapUtils.ts index 768dc35..6945975 100644 --- a/web/src/app/lib/mapUtils.ts +++ b/web/src/app/lib/mapUtils.ts @@ -1,6 +1,7 @@ import { CrashData } from '../api/crashes/route'; +import { getCachedCrashMagnitude, CrashMagnitudePrediction } from '../../lib/crashMagnitudeApi'; -export type PointFeature = GeoJSON.Feature; +export type PointFeature = GeoJSON.Feature; export const haversine = (a: [number, number], b: [number, number]) => { const toRad = (v: number) => v * Math.PI / 180; @@ -21,8 +22,8 @@ export const convertCrashDataToGeoJSON = (crashes: CrashData[]): GeoJSON.Feature console.log('Sample crash data:', crashes[0]); const features: PointFeature[] = crashes.map((crash) => { - // Calculate severity score based on fatalities and major injuries - const severityScore = Math.max(1, + // Calculate fallback severity score based on fatalities and major injuries + const fallbackSeverityScore = Math.max(1, (crash.fatalDriver + crash.fatalPedestrian + crash.fatalBicyclist) * 3 + (crash.majorInjuriesDriver + crash.majorInjuriesPedestrian + crash.majorInjuriesBicyclist) * 2 + (crash.totalVehicles + crash.totalPedestrians + crash.totalBicycles) @@ -35,8 +36,9 @@ export const convertCrashDataToGeoJSON = (crashes: CrashData[]): GeoJSON.Feature coordinates: [crash.longitude, crash.latitude] }, properties: { - mag: Math.min(6, severityScore), // Cap at 6 for consistent visualization - crashData: crash + mag: Math.min(6, fallbackSeverityScore), // Cap at 6 for consistent visualization + crashData: crash, + aiPredicted: false // Will be updated when AI prediction is available } }; }); @@ -52,6 +54,70 @@ export const convertCrashDataToGeoJSON = (crashes: CrashData[]): GeoJSON.Feature return geoJSON; }; +/** + * Enhanced version that fetches AI predictions for crash magnitudes + */ +export const convertCrashDataToGeoJSONWithAI = async (crashes: CrashData[]): Promise => { + console.log('šŸ¤– Converting crash data to GeoJSON with AI predictions:', crashes.length, 'crashes'); + + // Start with the basic conversion + const baseGeoJSON = convertCrashDataToGeoJSON(crashes); + + // Limit concurrent API calls to avoid overwhelming the API + const BATCH_SIZE = 10; + const enhancedFeatures = [...baseGeoJSON.features]; + let successfulPredictions = 0; + + for (let i = 0; i < crashes.length; i += BATCH_SIZE) { + const batch = crashes.slice(i, i + BATCH_SIZE); + const batchPromises = batch.map(async (crash, batchIndex) => { + const featureIndex = i + batchIndex; + + try { + // Get AI prediction for this crash location + const prediction = await getCachedCrashMagnitude(crash.latitude, crash.longitude); + + if (prediction && typeof prediction.prediction === 'number') { + // Use AI prediction, but ensure it's in a reasonable range (1-10) + const aiMagnitude = Math.max(1, Math.min(10, Math.round(prediction.prediction))); + + enhancedFeatures[featureIndex] = { + ...enhancedFeatures[featureIndex], + properties: { + ...enhancedFeatures[featureIndex].properties, + mag: aiMagnitude, + aiPredicted: true + } + }; + + return true; // Success + } + } catch (error) { + console.warn(`āš ļø Failed to get AI prediction for crash ${featureIndex}:`, error); + } + + return false; // Failed + }); + + const results = await Promise.allSettled(batchPromises); + successfulPredictions += results.filter(r => r.status === 'fulfilled' && r.value === true).length; + + // Small delay between batches to be nice to the API + if (i + BATCH_SIZE < crashes.length) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + + const enhancedGeoJSON = { + type: 'FeatureCollection' as const, + features: enhancedFeatures as PointFeature[] + }; + + console.log(`āœ… Enhanced GeoJSON with ${successfulPredictions}/${crashes.length} AI predictions`); + + return enhancedGeoJSON; +}; + export const generateDCPoints = (count = 500) => { const center = { lon: -77.0369, lat: 38.9072 }; const features: PointFeature[] = []; @@ -93,13 +159,118 @@ export const generateDCPoints = (count = 500) => { features.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [lon, lat] }, - properties: { mag, crashData: syntheticCrash } + properties: { mag, crashData: syntheticCrash, aiPredicted: false } }); } return { type: 'FeatureCollection', features } as GeoJSON.FeatureCollection; }; +/** + * Enhanced version of generateDCPoints that uses AI predictions + */ +export const generateDCPointsWithAI = async (count = 500) => { + const center = { lon: -77.0369, lat: 38.9072 }; + const features: PointFeature[] = []; + + const randNormal = () => { + let u = 0, v = 0; + while (u === 0) u = Math.random(); + while (v === 0) v = Math.random(); + return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); + }; + + // Generate locations first + const locations = []; + for (let i = 0; i < count; i++) { + const radius = Math.abs(randNormal()) * 0.02; + const angle = Math.random() * Math.PI * 2; + const lon = center.lon + Math.cos(angle) * radius; + const lat = center.lat + Math.sin(angle) * radius; + locations.push({ lon, lat, index: i }); + } + + // Get AI predictions in batches to avoid overwhelming the API + console.log(`šŸ¤– Getting AI predictions for ${count} synthetic points...`); + const BATCH_SIZE = 20; + const predictions: (any | null)[] = new Array(count).fill(null); + let successfulPredictions = 0; + + for (let i = 0; i < locations.length; i += BATCH_SIZE) { + const batch = locations.slice(i, i + BATCH_SIZE); + const batchPromises = batch.map(async (location) => { + try { + const prediction = await getCachedCrashMagnitude(location.lat, location.lon); + return prediction; + } catch (error) { + console.warn(`āš ļø Failed to get AI prediction for synthetic point ${location.index}:`, error); + return null; + } + }); + + const batchResults = await Promise.allSettled(batchPromises); + batchResults.forEach((result, batchIndex) => { + const globalIndex = i + batchIndex; + if (result.status === 'fulfilled') { + predictions[globalIndex] = result.value; + if (result.value) successfulPredictions++; + } + }); + + // Small delay between batches + if (i + BATCH_SIZE < locations.length) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + } + + // Create features with AI predictions or fallback magnitudes + for (let i = 0; i < count; i++) { + const location = locations[i]; + const prediction = predictions[i]; + + // Use AI prediction if available, otherwise use random magnitude + let mag: number; + let aiPredicted = false; + + if (prediction && typeof prediction.prediction === 'number') { + mag = Math.max(1, Math.min(10, Math.round(prediction.prediction))); + aiPredicted = true; + } else { + mag = Math.round(Math.max(1, Math.abs(randNormal()) * 6)); + } + + // Create synthetic crash data for backward compatibility + const syntheticCrash: CrashData = { + id: `synthetic-${i}`, + latitude: location.lat, + longitude: location.lon, + reportDate: new Date().toISOString(), + address: `Synthetic Location ${i}`, + ward: 'Ward 1', + totalVehicles: Math.floor(Math.random() * 3) + 1, + totalPedestrians: Math.floor(Math.random() * 2), + totalBicycles: Math.floor(Math.random() * 2), + fatalDriver: 0, + fatalPedestrian: 0, + fatalBicyclist: 0, + majorInjuriesDriver: Math.floor(Math.random() * 2), + majorInjuriesPedestrian: 0, + majorInjuriesBicyclist: 0, + speedingInvolved: Math.floor(Math.random() * 2), + }; + + features.push({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [location.lon, location.lat] }, + properties: { mag, crashData: syntheticCrash, aiPredicted } + }); + } + + console.log(`āœ… Generated ${count} synthetic points with ${successfulPredictions} AI predictions`); + + return { type: 'FeatureCollection', features } as GeoJSON.FeatureCollection; +}; + // Calculate crash density along a route path export const calculateRouteCrashDensity = ( routeCoordinates: [number, number][], diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index e235522..b382846 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -18,6 +18,7 @@ export default function Home() { const [heatRadius, setHeatRadius] = useState(16); const [heatIntensity, setHeatIntensity] = useState(1); const [gradientRoutes, setGradientRoutes] = useState(true); + const [useAIMagnitudes, setUseAIMagnitudes] = useState(false); // Default to false to avoid API issues const [popup, setPopup] = useState(null); const [popupVisible, setPopupVisible] = useState(false); @@ -66,6 +67,8 @@ export default function Home() { onChangeIntensity={(v) => setHeatIntensity(v)} gradientRoutes={gradientRoutes} onToggleGradientRoutes={(v) => setGradientRoutes(v)} + useAIMagnitudes={useAIMagnitudes} + onToggleAIMagnitudes={(v) => setUseAIMagnitudes(v)} crashDataHook={crashDataHook} /> @@ -79,6 +82,7 @@ export default function Home() { crashData={crashDataHook.data} crashDataHook={crashDataHook} isMapPickingMode={isMapPickingMode} + useAIMagnitudes={useAIMagnitudes} onMapReady={(m) => { mapRef.current = m; }} onPopupCreate={(p) => { setPopupVisible(false); setPopup(p); requestAnimationFrame(() => setPopupVisible(true)); }} /> diff --git a/web/src/lib/crashMagnitudeApi.ts b/web/src/lib/crashMagnitudeApi.ts new file mode 100644 index 0000000..ca8f74c --- /dev/null +++ b/web/src/lib/crashMagnitudeApi.ts @@ -0,0 +1,245 @@ +/** + * API service for crash magnitude prediction using AI model from ai.sirblob.co + */ + +export interface CrashMagnitudePrediction { + prediction: number; + confidence?: number; +} + +export interface CrashMagnitudeRequest { + source: { + lat: number; + lon: number; + }; + destination: { + lat: number; + lon: number; + }; +} + +export interface CrashMagnitudeResponse { + prediction: CrashMagnitudePrediction; + called_with: string; + diagnostics?: { + input_dim: number; + }; + index?: number; +} + +/** + * Get crash magnitude prediction from AI model + */ +export async function getCrashMagnitudePrediction( + sourceLat: number, + sourceLon: number, + destLat: number, + destLon: number +): Promise { + // Check circuit breaker first + if (isCircuitBreakerOpen()) { + console.log('āøļø AI API circuit breaker is open, skipping API call'); + return null; + } + + try { + const requestBody: CrashMagnitudeRequest = { + source: { + lat: sourceLat, + lon: sourceLon + }, + destination: { + lat: destLat, + lon: destLon + } + }; + + console.log('šŸ”® Requesting crash magnitude prediction:', requestBody); + + // Create fetch options with timeout + const fetchOptions: RequestInit = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify(requestBody), + }; + + // Add timeout if AbortSignal.timeout is supported + try { + if (typeof AbortSignal !== 'undefined' && 'timeout' in AbortSignal) { + fetchOptions.signal = AbortSignal.timeout(10000); // 10 second timeout + } + } catch (e) { + // AbortSignal.timeout not supported, continue without timeout + console.log('āš ļø AbortSignal.timeout not supported, continuing without timeout'); + } + + const response = await fetch('http://localhost:5001/predict', fetchOptions); + + if (!response.ok) { + console.error('āŒ Crash magnitude API error:', response.status, response.statusText); + recordCircuitBreakerFailure(); + return null; + } + + const data: CrashMagnitudeResponse = await response.json(); + console.log('āœ… Crash magnitude prediction received:', data); + + // Record successful call + recordCircuitBreakerSuccess(); + + // Handle different response formats from the API + if (data.prediction && typeof data.prediction === 'object' && data.prediction.prediction !== undefined) { + // Response format: { prediction: { prediction: number } } + return data.prediction; + } else if (typeof data.prediction === 'number') { + // Response format: { prediction: number } + return { prediction: data.prediction }; + } else if (data.index !== undefined) { + // If prediction is empty but we have an index, use index as fallback prediction + console.log('šŸ”„ Using index as fallback prediction:', data.index); + return { prediction: data.index, confidence: 0.5 }; // Lower confidence for fallback + } + + console.warn('āš ļø Unexpected response format from crash magnitude API:', data); + return null; + + } catch (error) { + recordCircuitBreakerFailure(); + + if (error instanceof Error) { + if (error.name === 'AbortError') { + console.warn('ā° Crash magnitude API request timed out'); + } else if (error.message.includes('fetch')) { + console.warn('🌐 Network error accessing crash magnitude API:', error.message); + } else { + console.warn('āŒ Error fetching crash magnitude prediction:', error.message); + } + } else { + console.warn('āŒ Unknown error fetching crash magnitude prediction:', error); + } + return null; + } +} + +/** + * Get crash magnitude for a single point (using same point for source and destination) + */ +export async function getPointCrashMagnitude( + lat: number, + lon: number +): Promise { + return getCrashMagnitudePrediction(lat, lon, lat, lon); +} + +/** + * Batch get crash magnitude predictions for multiple locations + */ +export async function getBatchCrashMagnitudes( + locations: Array<{ lat: number; lon: number; id?: string }> +): Promise> { + const results = await Promise.allSettled( + locations.map(async (location) => { + const prediction = await getPointCrashMagnitude(location.lat, location.lon); + return { prediction, id: location.id }; + }) + ); + + return results.map((result, index) => { + if (result.status === 'fulfilled') { + return result.value; + } else { + console.error(`āŒ Failed to get magnitude for location ${index}:`, result.reason); + return { prediction: null, id: locations[index].id }; + } + }); +} + +/** + * Cache for magnitude predictions to avoid repeated API calls + */ +const magnitudeCache = new Map(); +const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes + +/** + * Circuit breaker to avoid repeated failed API calls + */ +let circuitBreakerFailures = 0; +let circuitBreakerLastFailTime = 0; +const CIRCUIT_BREAKER_THRESHOLD = 3; +const CIRCUIT_BREAKER_TIMEOUT = 60000; // 1 minute +const CIRCUIT_BREAKER_RESET_TIME = 300000; // 5 minutes + +function isCircuitBreakerOpen(): boolean { + const now = Date.now(); + + // Reset circuit breaker after reset time + if (now - circuitBreakerLastFailTime > CIRCUIT_BREAKER_RESET_TIME) { + circuitBreakerFailures = 0; + return false; + } + + // Circuit is open if we have too many failures + return circuitBreakerFailures >= CIRCUIT_BREAKER_THRESHOLD; +} + +function recordCircuitBreakerFailure(): void { + circuitBreakerFailures++; + circuitBreakerLastFailTime = Date.now(); + + if (circuitBreakerFailures === CIRCUIT_BREAKER_THRESHOLD) { + console.warn(`šŸ”Œ AI API circuit breaker opened after ${CIRCUIT_BREAKER_THRESHOLD} failures. Will retry in ${CIRCUIT_BREAKER_RESET_TIME / 1000}s`); + } +} + +function recordCircuitBreakerSuccess(): void { + if (circuitBreakerFailures > 0) { + console.log('āœ… AI API circuit breaker reset after successful request'); + circuitBreakerFailures = 0; + } +} + +function getCacheKey(lat: number, lon: number): string { + return `${lat.toFixed(6)},${lon.toFixed(6)}`; +} + +/** + * Get cached crash magnitude or fetch if not available/expired + */ +export async function getCachedCrashMagnitude( + lat: number, + lon: number +): Promise { + const cacheKey = getCacheKey(lat, lon); + const cached = magnitudeCache.get(cacheKey); + + if (cached && Date.now() - cached.timestamp < CACHE_DURATION) { + console.log('šŸ“¦ Using cached magnitude prediction for:', cacheKey); + return cached.prediction; + } + + const prediction = await getPointCrashMagnitude(lat, lon); + + if (prediction) { + magnitudeCache.set(cacheKey, { + prediction, + timestamp: Date.now() + }); + } + + return prediction; +} + +/** + * Get current status of the AI API circuit breaker + */ +export function getCircuitBreakerStatus(): { isOpen: boolean; failures: number; resetTime?: number } { + const isOpen = isCircuitBreakerOpen(); + return { + isOpen, + failures: circuitBreakerFailures, + resetTime: isOpen ? circuitBreakerLastFailTime + CIRCUIT_BREAKER_RESET_TIME : undefined + }; +} \ No newline at end of file