feat: Implement AI crash magnitude prediction API and integrate with map components

This commit is contained in:
2025-09-28 04:54:41 -04:00
parent 4da975110b
commit 694a0ad426
6 changed files with 619 additions and 87 deletions

View File

@@ -368,6 +368,87 @@ def get_single_route_endpoint():
'error': str(e) 'error': str(e)
}), 500 }), 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) @app.errorhandler(404)
def not_found(error): def not_found(error):
return jsonify({'success': False, 'error': 'Endpoint not found'}), 404 return jsonify({'success': False, 'error': 'Endpoint not found'}), 404
@@ -384,6 +465,7 @@ if __name__ == '__main__':
print(" - POST /api/analyze-crashes") print(" - POST /api/analyze-crashes")
print(" - POST /api/find-safe-route") print(" - POST /api/find-safe-route")
print(" - POST /api/get-single-route") print(" - POST /api/get-single-route")
print(" - POST /predict (AI crash magnitude prediction)")
print("\n🌐 Server running on http://localhost:5001") print("\n🌐 Server running on http://localhost:5001")
app.run(debug=True, host='0.0.0.0', port=5001) app.run(debug=True, host='0.0.0.0', port=5001)

View File

@@ -5,7 +5,7 @@ import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css'; import 'mapbox-gl/dist/mapbox-gl.css';
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder'; import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css'; 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 { useCrashData, UseCrashDataResult } from '../hooks/useCrashData';
import { CrashData } from '../api/crashes/route'; import { CrashData } from '../api/crashes/route';
import { WeatherData, CrashAnalysisData } from '../../lib/flaskApi'; import { WeatherData, CrashAnalysisData } from '../../lib/flaskApi';
@@ -49,6 +49,7 @@ interface MapViewProps {
crashData?: CrashData[]; // external crash data to use crashData?: CrashData[]; // external crash data to use
crashDataHook?: UseCrashDataResult; // the crash data hook from main page crashDataHook?: UseCrashDataResult; // the crash data hook from main page
isMapPickingMode?: boolean; // whether map is in picking mode (prevents popups) isMapPickingMode?: boolean; // whether map is in picking mode (prevents popups)
useAIMagnitudes?: boolean; // whether to use AI-predicted crash magnitudes
} }
export default function MapView({ export default function MapView({
@@ -63,7 +64,8 @@ export default function MapView({
useRealCrashData = true, useRealCrashData = true,
crashData = [], crashData = [],
crashDataHook, crashDataHook,
isMapPickingMode = false isMapPickingMode = false,
useAIMagnitudes = true // Default to true to use AI predictions
}: MapViewProps) { }: MapViewProps) {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const mapContainerRef = useRef<HTMLDivElement | null>(null); const mapContainerRef = useRef<HTMLDivElement | null>(null);
@@ -71,6 +73,7 @@ export default function MapView({
const styleChoiceRef = useRef<'dark' | 'streets'>(mapStyleChoice); const styleChoiceRef = useRef<'dark' | 'streets'>(mapStyleChoice);
const isMapPickingModeRef = useRef<boolean>(isMapPickingMode); const isMapPickingModeRef = useRef<boolean>(isMapPickingMode);
const [size, setSize] = useState({ width: 0, height: 0 }); const [size, setSize] = useState({ width: 0, height: 0 });
const [isLoadingAIPredictions, setIsLoadingAIPredictions] = useState(false);
const dcDataRef = useRef<GeoJSON.FeatureCollection | null>(null); const dcDataRef = useRef<GeoJSON.FeatureCollection | null>(null);
const internalCrashDataHook = useCrashData({ autoLoad: false, limit: 10000 }); // Don't auto-load if external data provided 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 currentCrashDataHook = crashDataHook || internalCrashDataHook;
const activeData = crashData.length > 0 ? crashData : currentCrashDataHook.data; const activeData = crashData.length > 0 ? crashData : currentCrashDataHook.data;
console.log('MapView useEffect: crashData.length =', crashData.length, 'crashDataHook.data.length =', currentCrashDataHook.data.length); console.log('MapView useEffect: crashData.length =', crashData.length, 'crashDataHook.data.length =', currentCrashDataHook.data.length);
if (useRealCrashData && activeData.length > 0) { if (useRealCrashData && activeData.length > 0) {
console.log('Converting crash data to GeoJSON...'); console.log('Converting crash data to GeoJSON...');
dcDataRef.current = convertCrashDataToGeoJSON(activeData);
// Update the map source if map is ready const processData = async () => {
const map = mapRef.current; setIsLoadingAIPredictions(useAIMagnitudes);
if (map && map.isStyleLoaded()) {
console.log('Updating map source with new data...'); let geoJSONData: GeoJSON.FeatureCollection;
if (map.getSource('dc-quakes')) { if (useAIMagnitudes) {
(map.getSource('dc-quakes') as mapboxgl.GeoJSONSource).setData(dcDataRef.current); console.log('🤖 Using AI-enhanced crash data conversion...');
geoJSONData = await convertCrashDataToGeoJSONWithAI(activeData);
} else { } else {
console.log('Source not found, calling addDataAndLayers'); console.log('📊 Using standard crash data conversion...');
// Call the inner function manually - we need to recreate it here geoJSONData = convertCrashDataToGeoJSON(activeData);
if (dcDataRef.current) { }
console.log('Adding data and layers, data has', dcDataRef.current.features.length, 'features');
if (!map.getSource('dc-quakes')) { dcDataRef.current = geoJSONData;
console.log('Creating new source'); setIsLoadingAIPredictions(false);
map.addSource('dc-quakes', { type: 'geojson', data: dcDataRef.current });
} // Update the map source if map is ready
// Add layers if they don't exist const map = mapRef.current;
if (!map.getLayer('dc-heat')) { if (map && map.isStyleLoaded()) {
map.addLayer({ console.log('Updating map source with new data...');
id: 'dc-heat', type: 'heatmap', source: 'dc-quakes', if (map.getSource('dc-quakes')) {
paint: { (map.getSource('dc-quakes') as mapboxgl.GeoJSONSource).setData(dcDataRef.current);
'heatmap-weight': ['interpolate', ['linear'], ['get', 'mag'], 0, 0, 6, 1], } else {
'heatmap-intensity': heatIntensity, console.log('Source not found, will be added when map loads');
'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');
}
} }
} }
} 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(() => { useEffect(() => {
const el = containerRef.current; const el = containerRef.current;
@@ -236,16 +199,35 @@ export default function MapView({
const currentCrashDataHook = crashDataHook || internalCrashDataHook; const currentCrashDataHook = crashDataHook || internalCrashDataHook;
const activeData = crashData.length > 0 ? crashData : currentCrashDataHook.data; const activeData = crashData.length > 0 ? crashData : currentCrashDataHook.data;
console.log('Initializing map data, activeData length:', activeData.length); console.log('Initializing map data, activeData length:', activeData.length);
if (useRealCrashData && activeData.length > 0) {
console.log('Using real crash data'); const initializeData = async () => {
dcDataRef.current = convertCrashDataToGeoJSON(activeData); if (useRealCrashData && activeData.length > 0) {
} else if (!useRealCrashData) { console.log('Using real crash data');
console.log('Using synthetic data'); if (useAIMagnitudes) {
dcDataRef.current = generateDCPoints(900); setIsLoadingAIPredictions(true);
} else { console.log('🤖 Using AI-enhanced real crash data...');
console.log('No data available yet, using empty data'); dcDataRef.current = await convertCrashDataToGeoJSONWithAI(activeData);
dcDataRef.current = { type: 'FeatureCollection' as const, features: [] }; 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) => { const computeNearbyStats = async (center: [number, number], radiusMeters = 300) => {
try { try {

View File

@@ -2,6 +2,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { UseCrashDataResult } from '../hooks/useCrashData'; import { UseCrashDataResult } from '../hooks/useCrashData';
import { getCircuitBreakerStatus } from '../../lib/crashMagnitudeApi';
interface UnifiedControlPanelProps { interface UnifiedControlPanelProps {
// Map controls props // Map controls props
@@ -17,6 +18,8 @@ interface UnifiedControlPanelProps {
onChangeIntensity: (v: number) => void; onChangeIntensity: (v: number) => void;
gradientRoutes: boolean; gradientRoutes: boolean;
onToggleGradientRoutes: (v: boolean) => void; onToggleGradientRoutes: (v: boolean) => void;
useAIMagnitudes: boolean;
onToggleAIMagnitudes: (v: boolean) => void;
// Crash data controls props // Crash data controls props
crashDataHook: UseCrashDataResult; crashDataHook: UseCrashDataResult;
@@ -36,6 +39,8 @@ export default function UnifiedControlPanel({
onChangeIntensity, onChangeIntensity,
gradientRoutes, gradientRoutes,
onToggleGradientRoutes, onToggleGradientRoutes,
useAIMagnitudes,
onToggleAIMagnitudes,
crashDataHook, crashDataHook,
onDataLoaded onDataLoaded
}: UnifiedControlPanelProps) { }: UnifiedControlPanelProps) {
@@ -59,6 +64,7 @@ export default function UnifiedControlPanel({
const [isMapControlsSectionOpen, setIsMapControlsSectionOpen] = useState(getInitialMapControlsState); const [isMapControlsSectionOpen, setIsMapControlsSectionOpen] = useState(getInitialMapControlsState);
const [isCrashDataSectionOpen, setIsCrashDataSectionOpen] = useState(getInitialCrashDataState); const [isCrashDataSectionOpen, setIsCrashDataSectionOpen] = useState(getInitialCrashDataState);
const [isHydrated, setIsHydrated] = useState(false); const [isHydrated, setIsHydrated] = useState(false);
const [aiApiStatus, setAiApiStatus] = useState<{ isOpen: boolean; failures: number }>({ isOpen: false, failures: 0 });
// Load localStorage values after hydration // Load localStorage values after hydration
useEffect(() => { useEffect(() => {
@@ -77,7 +83,26 @@ export default function UnifiedControlPanel({
} }
setIsHydrated(true); 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 { data, loading, error, pagination, loadMore, refresh, yearFilter, setYearFilter } = crashDataHook;
const [currentYear, setCurrentYear] = useState('2024'); // Default to prevent hydration mismatch const [currentYear, setCurrentYear] = useState('2024'); // Default to prevent hydration mismatch
const [selectedYear, setSelectedYear] = useState<string>('2024'); // Default value const [selectedYear, setSelectedYear] = useState<string>('2024'); // Default value
@@ -236,6 +261,29 @@ export default function UnifiedControlPanel({
<input type="checkbox" checked={gradientRoutes} onChange={(e) => onToggleGradientRoutes(e.target.checked)} /> <input type="checkbox" checked={gradientRoutes} onChange={(e) => onToggleGradientRoutes(e.target.checked)} />
</div> </div>
<div className="mc-row">
<label className="mc-label">
AI Magnitudes 🤖
<span style={{
fontSize: 8,
padding: '2px 6px',
borderRadius: 4,
marginLeft: 8,
backgroundColor: aiApiStatus.isOpen ? '#d4edda' : '#f8d7da',
color: aiApiStatus.isOpen ? '#155724' : '#721c24'
}}>
{aiApiStatus.isOpen ? 'Available' : `Unavailable (${aiApiStatus.failures} failures)`}
</span>
</label>
<input type="checkbox" checked={useAIMagnitudes} onChange={(e) => onToggleAIMagnitudes(e.target.checked)} />
</div>
{useAIMagnitudes && (
<div style={{ fontSize: 10, color: 'var(--text-secondary)', marginTop: -4, marginBottom: 8, lineHeight: 1.3 }}>
Uses AI to predict crash severity. Falls back to traditional calculation if API unavailable.
</div>
)}
<div style={{ marginBottom: 6 }}> <div style={{ marginBottom: 6 }}>
<label style={{ display: 'block', fontSize: 12 }}>Radius: {heatRadius}</label> <label style={{ display: 'block', fontSize: 12 }}>Radius: {heatRadius}</label>
<input className="mc-range" type="range" min={5} max={100} value={heatRadius} onChange={(e) => onChangeRadius(Number(e.target.value))} style={{ width: '100%' }} /> <input className="mc-range" type="range" min={5} max={100} value={heatRadius} onChange={(e) => onChangeRadius(Number(e.target.value))} style={{ width: '100%' }} />

View File

@@ -1,6 +1,7 @@
import { CrashData } from '../api/crashes/route'; import { CrashData } from '../api/crashes/route';
import { getCachedCrashMagnitude, CrashMagnitudePrediction } from '../../lib/crashMagnitudeApi';
export type PointFeature = GeoJSON.Feature<GeoJSON.Point, { mag: number; crashData: CrashData }>; export type PointFeature = GeoJSON.Feature<GeoJSON.Point, { mag: number; crashData: CrashData; aiPredicted?: boolean }>;
export const haversine = (a: [number, number], b: [number, number]) => { export const haversine = (a: [number, number], b: [number, number]) => {
const toRad = (v: number) => v * Math.PI / 180; 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]); console.log('Sample crash data:', crashes[0]);
const features: PointFeature[] = crashes.map((crash) => { const features: PointFeature[] = crashes.map((crash) => {
// Calculate severity score based on fatalities and major injuries // Calculate fallback severity score based on fatalities and major injuries
const severityScore = Math.max(1, const fallbackSeverityScore = Math.max(1,
(crash.fatalDriver + crash.fatalPedestrian + crash.fatalBicyclist) * 3 + (crash.fatalDriver + crash.fatalPedestrian + crash.fatalBicyclist) * 3 +
(crash.majorInjuriesDriver + crash.majorInjuriesPedestrian + crash.majorInjuriesBicyclist) * 2 + (crash.majorInjuriesDriver + crash.majorInjuriesPedestrian + crash.majorInjuriesBicyclist) * 2 +
(crash.totalVehicles + crash.totalPedestrians + crash.totalBicycles) (crash.totalVehicles + crash.totalPedestrians + crash.totalBicycles)
@@ -35,8 +36,9 @@ export const convertCrashDataToGeoJSON = (crashes: CrashData[]): GeoJSON.Feature
coordinates: [crash.longitude, crash.latitude] coordinates: [crash.longitude, crash.latitude]
}, },
properties: { properties: {
mag: Math.min(6, severityScore), // Cap at 6 for consistent visualization mag: Math.min(6, fallbackSeverityScore), // Cap at 6 for consistent visualization
crashData: crash 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; return geoJSON;
}; };
/**
* Enhanced version that fetches AI predictions for crash magnitudes
*/
export const convertCrashDataToGeoJSONWithAI = async (crashes: CrashData[]): Promise<GeoJSON.FeatureCollection> => {
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) => { export const generateDCPoints = (count = 500) => {
const center = { lon: -77.0369, lat: 38.9072 }; const center = { lon: -77.0369, lat: 38.9072 };
const features: PointFeature[] = []; const features: PointFeature[] = [];
@@ -93,13 +159,118 @@ export const generateDCPoints = (count = 500) => {
features.push({ features.push({
type: 'Feature', type: 'Feature',
geometry: { type: 'Point', coordinates: [lon, lat] }, geometry: { type: 'Point', coordinates: [lon, lat] },
properties: { mag, crashData: syntheticCrash } properties: { mag, crashData: syntheticCrash, aiPredicted: false }
}); });
} }
return { type: 'FeatureCollection', features } as GeoJSON.FeatureCollection<GeoJSON.Geometry>; return { type: 'FeatureCollection', features } as GeoJSON.FeatureCollection<GeoJSON.Geometry>;
}; };
/**
* 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<GeoJSON.Geometry>;
};
// Calculate crash density along a route path // Calculate crash density along a route path
export const calculateRouteCrashDensity = ( export const calculateRouteCrashDensity = (
routeCoordinates: [number, number][], routeCoordinates: [number, number][],

View File

@@ -18,6 +18,7 @@ export default function Home() {
const [heatRadius, setHeatRadius] = useState(16); const [heatRadius, setHeatRadius] = useState(16);
const [heatIntensity, setHeatIntensity] = useState(1); const [heatIntensity, setHeatIntensity] = useState(1);
const [gradientRoutes, setGradientRoutes] = useState(true); const [gradientRoutes, setGradientRoutes] = useState(true);
const [useAIMagnitudes, setUseAIMagnitudes] = useState(false); // Default to false to avoid API issues
const [popup, setPopup] = useState<PopupData>(null); const [popup, setPopup] = useState<PopupData>(null);
const [popupVisible, setPopupVisible] = useState(false); const [popupVisible, setPopupVisible] = useState(false);
@@ -66,6 +67,8 @@ export default function Home() {
onChangeIntensity={(v) => setHeatIntensity(v)} onChangeIntensity={(v) => setHeatIntensity(v)}
gradientRoutes={gradientRoutes} gradientRoutes={gradientRoutes}
onToggleGradientRoutes={(v) => setGradientRoutes(v)} onToggleGradientRoutes={(v) => setGradientRoutes(v)}
useAIMagnitudes={useAIMagnitudes}
onToggleAIMagnitudes={(v) => setUseAIMagnitudes(v)}
crashDataHook={crashDataHook} crashDataHook={crashDataHook}
/> />
@@ -79,6 +82,7 @@ export default function Home() {
crashData={crashDataHook.data} crashData={crashDataHook.data}
crashDataHook={crashDataHook} crashDataHook={crashDataHook}
isMapPickingMode={isMapPickingMode} isMapPickingMode={isMapPickingMode}
useAIMagnitudes={useAIMagnitudes}
onMapReady={(m) => { mapRef.current = m; }} onMapReady={(m) => { mapRef.current = m; }}
onPopupCreate={(p) => { setPopupVisible(false); setPopup(p); requestAnimationFrame(() => setPopupVisible(true)); }} onPopupCreate={(p) => { setPopupVisible(false); setPopup(p); requestAnimationFrame(() => setPopupVisible(true)); }}
/> />

View File

@@ -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<CrashMagnitudePrediction | null> {
// 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<CrashMagnitudePrediction | null> {
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<Array<{ prediction: CrashMagnitudePrediction | null; id?: string }>> {
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<string, { prediction: CrashMagnitudePrediction; timestamp: number }>();
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<CrashMagnitudePrediction | null> {
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
};
}