bae705aa97
- Add NFC ePassport roadmap (ICAO 9303, eIDAS) - Add TensorFlow.js edge face detection (BlazeFace) - Add structured audit logger (GDPR-compliant) - Risk scoring support Part of KYC Apple Native UX v1.1.0
590 lines
17 KiB
JavaScript
590 lines
17 KiB
JavaScript
/**
|
|
* 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`);
|
|
}
|
|
}
|