Files
boc/quixzoom-capture-pipeline/change-detection/engine.js
T
Bernt bae705aa97 ARCHITECTURE: NFC roadmap, edge AI, audit logging
- 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
2026-06-29 16:24:48 +00:00

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`);
}
}