/** * QUIXZOOM Urban Change Detection Engine * * Svarar på frågor: * - Vad har förändrats sedan senaste observationen? * - Är förändringen normal eller avvikande? * - Kräver den verifiering? * - Ska ett nytt uppdrag skapas? * - Hur påverkar förändringen objektets hälsostatus och prioritet? * * Gör UKG till ett levande system som kontinuerligt övervakar * och uppdaterar stadens digitala tvilling. */ class ChangeDetectionEngine { constructor(options = {}) { // Trösklar för avvikelser this.thresholds = { critical: options.criticalThreshold || 0.9, // Kritiskt — omedelbar åtgärd warning: options.warningThreshold || 0.7, // Varning — planera åtgärd normal: options.normalThreshold || 0.3, // Normal — ingen åtgärd }; // Förändringstyper och deras prioritet this.changeTypes = { 'structural': { priority: 'critical', description: 'Strukturell skada' }, 'safety': { priority: 'critical', description: 'Säkerhetsrisk' }, 'degradation': { priority: 'warning', description: 'Nedbrytning' }, 'maintenance': { priority: 'normal', description: 'Underhållsbehov' }, 'cosmetic': { priority: 'low', description: 'Kosmetisk' }, 'expected': { priority: 'low', description: 'Förväntad förändring' }, }; // Historik för trendanalys this.changeHistory = new Map(); // objectId -> [changes] } /** * ============================================================ * HUVUDMETOD: Detektera förändringar * ============================================================ */ detectChanges(objectId, newObservation, previousObservation) { console.log(`[CHANGE] Detecting changes for ${objectId}`); if (!previousObservation) { console.log(`[CHANGE] No previous observation — first sighting`); return { objectId, changes: [], severity: 'none', action: 'none', reason: 'First observation — no baseline', }; } // 1. Identifiera förändringar const changes = this.identifyChanges(newObservation, previousObservation); if (changes.length === 0) { console.log(`[CHANGE] No changes detected`); return { objectId, changes: [], severity: 'none', action: 'none', reason: 'No changes detected', }; } // 2. Klassificera förändringar const classifiedChanges = changes.map(change => this.classifyChange(change)); // 3. Beräkna allvarlighetsgrad const severity = this.calculateSeverity(classifiedChanges); // 4. Bedöm om förändringen är normal eller avvikande const assessment = this.assessChange(objectId, classifiedChanges, severity); // 5. Bestäm åtgärd const action = this.determineAction(objectId, assessment); // 6. Uppdatera historik this.updateHistory(objectId, classifiedChanges); return { objectId, changes: classifiedChanges, severity, assessment, action, timestamp: Date.now(), }; } /** * ============================================================ * 1. IDENTIFIERA FÖRÄNDRINGAR * ============================================================ */ identifyChanges(newObs, prevObs) { const changes = []; // Jämför attribut const newAttrs = newObs.attributes || {}; const prevAttrs = prevObs.attributes || {}; const allKeys = new Set([...Object.keys(newAttrs), ...Object.keys(prevAttrs)]); for (const key of allKeys) { if (newAttrs[key] !== prevAttrs[key]) { changes.push({ type: 'attribute', attribute: key, oldValue: prevAttrs[key], newValue: newAttrs[key], description: `${key}: ${prevAttrs[key]} → ${newAttrs[key]}`, }); } } // Jämför plats if (newObs.location && prevObs.location) { const distance = this.calculateDistance(newObs.location, prevObs.location); if (distance > 1) { // Mer än 1 meter changes.push({ type: 'location', attribute: 'position', oldValue: prevObs.location, newValue: newObs.location, description: `Position shifted by ${distance.toFixed(2)}m`, distance, }); } } // Jämför dimensioner if (newObs.dimensions && prevObs.dimensions) { const dimChanges = this.compareDimensions(newObs.dimensions, prevObs.dimensions); if (dimChanges.length > 0) { changes.push(...dimChanges); } } // Jämför skador/defekter if (newObs.damageSignature && prevObs.damageSignature) { const damageDiff = this.compareDamageSignatures(newObs.damageSignature, prevObs.damageSignature); if (damageDiff.score > 0.3) { changes.push({ type: 'damage', attribute: 'damage', oldValue: 'previous', newValue: 'current', description: `Damage signature changed (${(damageDiff.score * 100).toFixed(0)}% difference)`, severity: damageDiff.severity, }); } } return changes; } /** * ============================================================ * 2. KLASSIFICERA FÖRÄNDRING * ============================================================ */ classifyChange(change) { const { type, attribute, oldValue, newValue } = change; // Klassificera baserat på attribut och förändring const classification = this.getClassification(attribute, oldValue, newValue); return { ...change, classification: classification.type, priority: classification.priority, severity: classification.severity, normal: classification.normal, }; } getClassification(attribute, oldValue, newValue) { // Strukturella förändringar if (['lean', 'crack', 'deformation'].includes(attribute)) { return { type: 'structural', priority: 'critical', severity: 0.9, normal: false, }; } // Säkerhetsrisker if (['light', 'signal', 'barrier'].includes(attribute)) { if (newValue === 'broken' || newValue === 'missing') { return { type: 'safety', priority: 'critical', severity: 0.95, normal: false, }; } } // Nedbrytning if (attribute === 'rust') { return { type: 'degradation', priority: 'warning', severity: 0.7, normal: true, // Rost är normalt över tid }; } if (attribute === 'paint') { return { type: 'cosmetic', priority: 'low', severity: 0.3, normal: true, }; } // Underhåll if (['cleanliness', 'vegetation'].includes(attribute)) { return { type: 'maintenance', priority: 'normal', severity: 0.5, normal: true, }; } // Förväntade förändringar if (attribute === 'height' && typeof newValue === 'number' && typeof oldValue === 'number') { if (newValue > oldValue) { return { type: 'expected', priority: 'low', severity: 0.2, normal: true, // Träd växer }; } } // Default return { type: 'unknown', priority: 'normal', severity: 0.5, normal: true, }; } /** * ============================================================ * 3. BERÄKNA ALLVARLIGHETSGRAD * ============================================================ */ calculateSeverity(changes) { if (changes.length === 0) return 'none'; // Hitta högsta severity const maxSeverity = Math.max(...changes.map(c => c.severity || 0)); if (maxSeverity >= this.thresholds.critical) return 'critical'; if (maxSeverity >= this.thresholds.warning) return 'warning'; if (maxSeverity >= this.thresholds.normal) return 'normal'; return 'low'; } /** * ============================================================ * 4. BEDÖM FÖRÄNDRING * ============================================================ */ assessChange(objectId, changes, severity) { const history = this.changeHistory.get(objectId) || []; // Trendanalys const trend = this.analyzeTrend(objectId, changes, history); // Är förändringen accelererande? const accelerating = this.isAccelerating(objectId, changes, history); // Är förändringen inom förväntade parametrar? const expected = changes.every(c => c.normal); // Påverkan på hälsostatus const healthImpact = this.calculateHealthImpact(changes); return { severity, expected, accelerating, trend, healthImpact, requiresVerification: severity === 'critical' || severity === 'warning', requiresMission: severity === 'critical' || (severity === 'warning' && accelerating), }; } analyzeTrend(objectId, currentChanges, history) { if (history.length < 2) return 'insufficient_data'; // Jämför med tidigare förändringar const recentChanges = history.slice(-5); // Kolla om samma attribut förändras upprepade gånger const attributeCounts = {}; for (const entry of recentChanges) { for (const change of entry.changes) { attributeCounts[change.attribute] = (attributeCounts[change.attribute] || 0) + 1; } } const repeatedAttributes = Object.entries(attributeCounts) .filter(([attr, count]) => count >= 3) .map(([attr]) => attr); if (repeatedAttributes.length > 0) { return { direction: 'degrading', repeatedAttributes, stability: 'unstable', }; } return { direction: 'stable', repeatedAttributes: [], stability: 'stable', }; } isAccelerating(objectId, changes, history) { if (history.length < 3) return false; // Kolla om förändringarna blir större över tid const recent = history.slice(-3); const severities = recent.map(entry => Math.max(...entry.changes.map(c => c.severity || 0)) ); return severities[2] > severities[1] && severities[1] > severities[0]; } calculateHealthImpact(changes) { // Beräkna ny hälsostatus baserat på förändringar let health = 1.0; for (const change of changes) { if (change.severity) { health -= change.severity * 0.1; } } return Math.max(0, health); } /** * ============================================================ * 5. BESTÄM ÅTGÄRD * ============================================================ */ determineAction(objectId, assessment) { if (assessment.severity === 'critical') { return { type: 'immediate_action', description: 'Critical change detected — immediate inspection required', priority: 'critical', createMission: true, missionType: 'verify', notify: ['admin', 'maintenance'], }; } if (assessment.severity === 'warning' && assessment.accelerating) { return { type: 'schedule_inspection', description: 'Accelerating degradation — schedule inspection within 1 week', priority: 'high', createMission: true, missionType: 'verify', notify: ['maintenance'], }; } if (assessment.severity === 'warning') { return { type: 'monitor', description: 'Monitor for further changes', priority: 'medium', createMission: false, notify: [], }; } return { type: 'none', description: 'No action required', priority: 'low', createMission: false, notify: [], }; } /** * ============================================================ * 6. UPPDATERA HISTORIK * ============================================================ */ updateHistory(objectId, changes) { if (!this.changeHistory.has(objectId)) { this.changeHistory.set(objectId, []); } this.changeHistory.get(objectId).push({ timestamp: Date.now(), changes, }); // Behåll endast senaste 50 entries const history = this.changeHistory.get(objectId); if (history.length > 50) { history.shift(); } } /** * ============================================================ * HJÄLPMETODER * ============================================================ */ calculateDistance(a, b) { const R = 6371e3; const φ1 = a.lat * Math.PI / 180; const φ2 = b.lat * Math.PI / 180; const Δφ = (b.lat - a.lat) * Math.PI / 180; const Δλ = (b.lng - a.lng) * Math.PI / 180; const x = Math.sin(Δφ/2) * Math.sin(Δφ/2) + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ/2) * Math.sin(Δλ/2); const c = 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1-x)); return R * c; } compareDimensions(newDims, prevDims) { const changes = []; const keys = ['width', 'height', 'depth']; for (const key of keys) { if (newDims[key] && prevDims[key]) { const diff = Math.abs(newDims[key] - prevDims[key]); const ratio = diff / prevDims[key]; if (ratio > 0.1) { // Mer än 10% förändring changes.push({ type: 'dimension', attribute: key, oldValue: prevDims[key], newValue: newDims[key], description: `${key}: ${prevDims[key]} → ${newDims[key]} (${(ratio * 100).toFixed(0)}% change)`, }); } } } return changes; } compareDamageSignatures(newSig, prevSig) { // Förenklad jämförelse const diff = Math.abs(newSig.score - prevSig.score); return { score: diff, severity: diff > 0.5 ? 'high' : diff > 0.2 ? 'medium' : 'low', }; } } // Exportera module.exports = ChangeDetectionEngine; // Demo if (require.main === module) { const engine = new ChangeDetectionEngine(); console.log('=== URBAN CHANGE DETECTION ENGINE DEMO ===\n'); // Baseline-observation const baseline = { id: 'obs_001', timestamp: Date.now(), location: { lat: 13.725, lng: 100.555 }, attributes: { material: 'steel', height: 7.8, paint: 'grey', rust: false, lean: 0, light: 'working', }, }; // Scenario 1: Ingen förändring console.log('Scenario 1: No change'); const result1 = engine.detectChanges('street_lamp_001', baseline, baseline); console.log(` Severity: ${result1.severity}`); console.log(` Action: ${result1.action.type}`); console.log(); // Scenario 2: Liten förändring (rost) const obs2 = { ...baseline, attributes: { ...baseline.attributes, rust: true, }, }; console.log('Scenario 2: Rust detected'); const result2 = engine.detectChanges('street_lamp_001', obs2, baseline); console.log(` Changes: ${result2.changes.length}`); console.log(` Severity: ${result2.severity}`); console.log(` Expected: ${result2.assessment.expected}`); console.log(` Action: ${result2.action.type}`); console.log(); // Scenario 3: Allvarlig förändring (lutning + trasig lampa) const obs3 = { ...baseline, attributes: { ...baseline.attributes, lean: 5, light: 'broken', }, }; console.log('Scenario 3: Tilt + broken light'); const result3 = engine.detectChanges('street_lamp_001', obs3, baseline); console.log(` Changes: ${result3.changes.length}`); for (const change of result3.changes) { console.log(` - ${change.description} (${change.classification})`); } console.log(` Severity: ${result3.severity}`); console.log(` Health Impact: ${(result3.assessment.healthImpact * 100).toFixed(0)}%`); console.log(` Requires Verification: ${result3.assessment.requiresVerification}`); console.log(` Requires Mission: ${result3.assessment.requiresMission}`); console.log(` Action: ${result3.action.type}`); console.log(` Mission Type: ${result3.action.missionType}`); console.log(); // Scenario 4: Accelererande nedbrytning const obs4a = { ...baseline, attributes: { ...baseline.attributes, lean: 1 }, }; const obs4b = { ...baseline, attributes: { ...baseline.attributes, lean: 3 }, }; const obs4c = { ...baseline, attributes: { ...baseline.attributes, lean: 5 }, }; console.log('Scenario 4: Accelerating tilt'); engine.detectChanges('street_lamp_002', obs4a, baseline); engine.detectChanges('street_lamp_002', obs4b, obs4a); const result4c = engine.detectChanges('street_lamp_002', obs4c, obs4b); console.log(` Severity: ${result4c.severity}`); console.log(` Accelerating: ${result4c.assessment.accelerating}`); console.log(` Action: ${result4c.action.type}`); console.log(); console.log('=== STATISTICS ==='); console.log(`Tracked objects: ${engine.changeHistory.size}`); for (const [id, history] of engine.changeHistory) { console.log(` ${id}: ${history.length} change records`); } }