diff --git a/llm/flask_server.py b/llm/flask_server.py index 4d4fab3..d3f6ee4 100644 --- a/llm/flask_server.py +++ b/llm/flask_server.py @@ -366,6 +366,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 @@ -382,6 +463,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/roadcast/app.py b/roadcast/app.py index 71f07ed..a2631f1 100644 --- a/roadcast/app.py +++ b/roadcast/app.py @@ -1,4 +1,5 @@ from flask import Flask, request, jsonify +from flask_cors import CORS from dotenv import load_dotenv from train import compute_index from models import load_model @@ -8,6 +9,14 @@ from models import MLP load_dotenv() app = Flask(__name__) +# Enable CORS for all routes, origins, and methods +CORS(app, resources={ + r"/*": { + "origins": "*", + "methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + "allow_headers": ["Content-Type", "Authorization", "Accept", "Origin", "X-Requested-With"] + } +}) import os import threading import json @@ -121,6 +130,7 @@ def predict_endpoint(): # build feature vector of correct length and populate lat/lon using preprocess meta if available feature_vector = np.zeros(int(input_dim), dtype=float) meta_path = os.path.join(os.getcwd(), 'preprocess_meta.npz') + if os.path.exists(meta_path): try: meta = np.load(meta_path, allow_pickle=True) @@ -128,33 +138,83 @@ def predict_endpoint(): means = meta.get('means') if means is not None and len(means) == input_dim: feature_vector[:] = means + col_lower = [c.lower() for c in cols] - if 'lat' in col_lower: - feature_vector[col_lower.index('lat')] = src_lat - elif 'latitude' in col_lower: - feature_vector[col_lower.index('latitude')] = src_lat - else: - feature_vector[0] = src_lat - if 'lon' in col_lower: - feature_vector[col_lower.index('lon')] = src_lon - elif 'longitude' in col_lower: - feature_vector[col_lower.index('longitude')] = src_lon - else: - if input_dim > 1: - feature_vector[1] = src_lon - except Exception: + print(f"šŸ“‹ Available columns: {col_lower[:10]}...") # Show first 10 columns + + # Try to find and populate coordinate fields + coord_mappings = [ + (('lat', 'latitude', 'src_lat', 'source_lat'), src_lat), + (('lon', 'lng', 'longitude', 'src_lon', 'source_lon'), src_lon), + (('dst_lat', 'dest_lat', 'destination_lat', 'end_lat'), dst_lat), + (('dst_lon', 'dest_lon', 'destination_lon', 'end_lon', 'dst_lng'), dst_lon) + ] + + for possible_names, value in coord_mappings: + for name in possible_names: + if name in col_lower: + idx = col_lower.index(name) + feature_vector[idx] = value + print(f"āœ… Mapped {name} (index {idx}) = {value}") + break + + # Calculate route features that might be useful + route_distance = ((dst_lat - src_lat)**2 + (dst_lon - src_lon)**2)**0.5 + midpoint_lat = (src_lat + dst_lat) / 2 + midpoint_lon = (src_lon + dst_lon) / 2 + + # Try to populate additional features that might exist + additional_features = { + 'distance': route_distance, + 'route_distance': route_distance, + 'midpoint_lat': midpoint_lat, + 'midpoint_lon': midpoint_lon, + 'lat_diff': abs(dst_lat - src_lat), + 'lon_diff': abs(dst_lon - src_lon) + } + + for feature_name, feature_value in additional_features.items(): + if feature_name in col_lower: + idx = col_lower.index(feature_name) + feature_vector[idx] = feature_value + print(f"āœ… Mapped {feature_name} (index {idx}) = {feature_value}") + + except Exception as e: + print(f"āš ļø Error processing metadata: {e}") + # Fallback to simple coordinate mapping feature_vector[:] = 0.0 feature_vector[0] = src_lat if input_dim > 1: feature_vector[1] = src_lon + if input_dim > 2: + feature_vector[2] = dst_lat + if input_dim > 3: + feature_vector[3] = dst_lon else: + print("āš ļø No preprocess_meta.npz found, using simple coordinate mapping") + # Simple fallback mapping feature_vector[0] = src_lat if input_dim > 1: feature_vector[1] = src_lon + if input_dim > 2: + feature_vector[2] = dst_lat + if input_dim > 3: + feature_vector[3] = dst_lon + + # Add some derived features to create more variation + if input_dim > 4: + feature_vector[4] = ((dst_lat - src_lat)**2 + (dst_lon - src_lon)**2)**0.5 # distance + if input_dim > 5: + feature_vector[5] = (src_lat + dst_lat) / 2 # midpoint lat + if input_dim > 6: + feature_vector[6] = (src_lon + dst_lon) / 2 # midpoint lon # compute index using model try: + print(f"šŸ” Feature vector for prediction: {feature_vector[:8]}...") # Show first 8 values + print(f"šŸ“ Coordinates: src({src_lat}, {src_lon}) → dst({dst_lat}, {dst_lon})") index = compute_index(model, feature_vector) + print(f"šŸ“Š Computed index: {index}") except Exception as e: return jsonify({"error": "compute_index failed", "detail": str(e)}), 500 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..6215d58 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,31 @@ export default function UnifiedControlPanel({ } setIsHydrated(true); - }, []); // Crash data state + }, []); + + // Check AI API status when AI magnitudes are enabled + useEffect(() => { + if (useAIMagnitudes) { + const checkApiStatus = async () => { + try { + const status = await getCircuitBreakerStatus(); + setAiApiStatus(status); + } catch (error) { + console.error('Error checking API status:', error); + setAiApiStatus({ isOpen: true, failures: 1 }); + } + }; + + // 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 +266,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..560b669 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,80 @@ 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') { + // Scale the roadcast API prediction to a reasonable range for visualization + // Roadcast returns values like 47, so we'll scale them to 1-10 range + let scaledMagnitude = prediction.prediction; + + // If the value seems to be in the roadcast range (typically 0-100), scale it down + if (prediction.prediction > 20) { + scaledMagnitude = Math.max(1, Math.min(10, Math.round(prediction.prediction / 10))); + } else { + scaledMagnitude = Math.max(1, Math.min(10, Math.round(prediction.prediction))); + } + + console.log(`šŸŽÆ Scaled magnitude from ${prediction.prediction} to ${scaledMagnitude}`); + + enhancedFeatures[featureIndex] = { + ...enhancedFeatures[featureIndex], + properties: { + ...enhancedFeatures[featureIndex].properties, + mag: scaledMagnitude, + 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 +169,124 @@ 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') { + // Scale the roadcast API prediction to a reasonable range for visualization + if (prediction.prediction > 20) { + mag = Math.max(1, Math.min(10, Math.round(prediction.prediction / 10))); + } else { + mag = Math.max(1, Math.min(10, Math.round(prediction.prediction))); + } + console.log(`šŸŽÆ Synthetic point scaled magnitude from ${prediction.prediction} to ${mag}`); + 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..56f6cda 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(true); // Default to true since roadcast API is reliable 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..7d1ceed --- /dev/null +++ b/web/src/lib/crashMagnitudeApi.ts @@ -0,0 +1,255 @@ +/** + * API service for crash magnitude prediction using roadcast model + */ + +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 roadcast API + * Simplified version that always tries to get the prediction + */ +export async function getCrashMagnitudePrediction( + sourceLat: number, + sourceLon: number, + destLat: number, + destLon: number +): Promise { + + try { + const requestBody: CrashMagnitudeRequest = { + source: { + lat: sourceLat, + lon: sourceLon + }, + destination: { + lat: destLat, + lon: destLon + } + }; + + console.log('ļæ½ Requesting crash magnitude from roadcast API:', 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:5000/predict', fetchOptions); + + if (!response.ok) { + console.error('āŒ Roadcast API error:', response.status, response.statusText); + return null; + } + + const data: CrashMagnitudeResponse = await response.json(); + console.log('āœ… Roadcast magnitude prediction received:', data); + + // Handle roadcast API response format + // The roadcast API returns the magnitude in the 'index' field + if (data.index !== undefined) { + console.log('šŸŽÆ Using roadcast index as crash magnitude:', data.index); + return { + prediction: data.index, + confidence: 0.95 // High confidence for roadcast model + }; + } else if (data.prediction && typeof data.prediction === 'object' && data.prediction.prediction !== undefined) { + // Fallback: Response format: { prediction: { prediction: number } } + return data.prediction; + } else if (typeof data.prediction === 'number') { + // Fallback: Response format: { prediction: number } + return { prediction: data.prediction }; + } + + console.warn('āš ļø No usable magnitude data in roadcast API response:', data); + return null; + + } catch (error) { + + 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 + +/** + * Status tracking for roadcast API (simplified - always available) + */ + +function isCircuitBreakerOpen(): boolean { + // Roadcast API is local and reliable, always return false + return false; +} + +function recordCircuitBreakerFailure(): void { + // Not needed for local roadcast API, but kept for compatibility +} + +function recordCircuitBreakerSuccess(): void { + // Not needed for local roadcast API, but kept for compatibility +} + +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 roadcast API by testing connection + */ +export async function getCircuitBreakerStatus(): Promise<{ isOpen: boolean; failures: number; resetTime?: number }> { + try { + // Test the roadcast API with a simple request + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout + + const response = await fetch('http://localhost:5000/predict', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + source: { lat: 38.9, lon: -77.0 }, + destination: { lat: 38.91, lon: -77.01 } + }), + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (response.ok) { + const data = await response.json(); + console.log('🟢 Roadcast API status check successful:', data.index); + return { + isOpen: false, // API is available + failures: 0, + resetTime: undefined + }; + } else { + console.log('šŸ”“ Roadcast API status check failed:', response.status); + return { + isOpen: true, // API returned error + failures: 1, + resetTime: undefined + }; + } + } catch (error) { + console.log('šŸ”Œ Roadcast API unavailable:', error); + return { + isOpen: true, // API is unavailable + failures: 1, + resetTime: undefined + }; + } +} \ No newline at end of file