Files
boc/quixzoom-capture-pipeline/urban-knowledge-graph/ukg.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

798 lines
21 KiB
JavaScript

/**
* QUIXZOOM Urban Knowledge Graph (UKG)
*
* Centralt datalager — den verkliga "hjärnan" i QUIXZOOM.
*
* Tre lager:
* 1. Observation Layer — Rådata → observationer
* 2. Evidence Layer — Bevis för varje objekt
* 3. Temporal Layer — Förändring över tid
*
* Varje objekt i staden får:
* - Globalt objekt-ID
* - Geografisk position
* - Typ
* - Attribut
* - Observationer
* - Historik
* - Evidens
* - Verifieringsstatus
* - Osäkerhet
* - Relationer till andra objekt
*/
const crypto = require('crypto');
class UrbanKnowledgeGraph {
constructor() {
// Objekt-index
this.objects = new Map(); // objectId -> UrbanObject
// Spatialt index (för snabb geografisk sökning)
this.spatialIndex = new Map(); // gridKey -> Set(objectIds)
// Typ-index
this.typeIndex = new Map(); // type -> Set(objectIds)
// Observationer
this.observations = new Map(); // observationId -> Observation
// Evidens
this.evidence = new Map(); // evidenceId -> Evidence
// Temporal
this.temporal = new Map(); // objectId -> [TemporalRecord]
// Relationer
this.relations = new Map(); // objectId -> [Relation]
// Grid-storlek för spatialt index (m)
this.gridSize = 10;
}
// ============================================================
// OBJEKT-HANTERING
// ============================================================
createObject(config) {
const objectId = config.id || this.generateObjectId(config);
const obj = {
id: objectId,
type: config.type, // street_lamp, pothole, crosswalk, etc.
subtype: config.subtype,
// Geografi
location: {
lat: config.location.lat,
lng: config.location.lng,
accuracy: config.location.accuracy || 5,
altitude: config.location.altitude,
},
// Attribut (kan ändras över tid)
attributes: config.attributes || {},
// Metadata
createdAt: Date.now(),
updatedAt: Date.now(),
createdBy: config.createdBy,
// Status
status: 'active', // active, removed, modified
verificationStatus: config.verificationStatus || 'unverified',
// Osäkerhet
confidence: config.confidence || 0,
uncertainty: config.uncertainty || 1.0,
// Referenser
observationIds: [],
evidenceIds: [],
temporalIds: [],
relationIds: [],
};
this.objects.set(objectId, obj);
// Uppdatera index
this.addToSpatialIndex(objectId, obj.location);
this.addToTypeIndex(objectId, obj.type);
console.log(`[UKG] Object created: ${objectId} (${obj.type})`);
return obj;
}
getObject(objectId) {
return this.objects.get(objectId);
}
updateObject(objectId, updates) {
const obj = this.objects.get(objectId);
if (!obj) return null;
// Spara gammalt tillstånd i temporal layer
this.addTemporalRecord(objectId, {
timestamp: Date.now(),
attributes: { ...obj.attributes },
verificationStatus: obj.verificationStatus,
confidence: obj.confidence,
});
// Uppdatera objekt
if (updates.attributes) {
obj.attributes = { ...obj.attributes, ...updates.attributes };
}
if (updates.location) {
// Ta bort från gammalt spatialt index
this.removeFromSpatialIndex(objectId, obj.location);
// Uppdatera plats
obj.location = { ...obj.location, ...updates.location };
// Lägg till i nytt spatialt index
this.addToSpatialIndex(objectId, obj.location);
}
if (updates.verificationStatus) {
obj.verificationStatus = updates.verificationStatus;
}
if (updates.confidence !== undefined) {
obj.confidence = updates.confidence;
}
obj.updatedAt = Date.now();
console.log(`[UKG] Object updated: ${objectId}`);
return obj;
}
// ============================================================
// OBSERVATION LAYER
// ============================================================
addObservation(config) {
const observationId = config.id || this.generateId('obs');
const observation = {
id: observationId,
timestamp: config.timestamp || Date.now(),
// Källa
source: {
type: config.sourceType, // quixzoom, youtube_cc, inspector, municipality
id: config.sourceId,
operator: config.operator,
},
// Media
media: {
type: config.mediaType, // video, image
url: config.mediaUrl,
frameNumber: config.frameNumber,
timestamp: config.mediaTimestamp,
},
// Vad som observerades
observedObjects: config.observedObjects || [],
// Plats
location: config.location,
// Kvalitet
quality: config.quality || {},
// AI-analys
aiAnalysis: config.aiAnalysis || {},
// Metadata
metadata: config.metadata || {},
};
this.observations.set(observationId, observation);
// Koppla till objekt
for (const observed of observation.observedObjects) {
if (observed.objectId) {
const obj = this.objects.get(observed.objectId);
if (obj) {
obj.observationIds.push(observationId);
}
}
}
console.log(`[UKG] Observation added: ${observationId}`);
return observation;
}
getObservation(observationId) {
return this.observations.get(observationId);
}
// ============================================================
// EVIDENCE LAYER
// ============================================================
addEvidence(config) {
const evidenceId = config.id || this.generateId('ev');
const evidence = {
id: evidenceId,
objectId: config.objectId,
observationId: config.observationId,
// Bevis
proof: {
type: config.proofType, // image, video, lidar, manual_inspection
url: config.proofUrl,
frameNumber: config.frameNumber,
timestamp: config.proofTimestamp,
},
// Vem som lade till beviset
source: {
type: config.sourceType, // quixzoom, inspector, municipality, ai
id: config.sourceId,
operator: config.operator,
},
// Kvalitet
quality: config.quality || {},
reliability: config.reliability || 0,
// Metadata
timestamp: config.timestamp || Date.now(),
metadata: config.metadata || {},
};
this.evidence.set(evidenceId, evidence);
// Koppla till objekt
const obj = this.objects.get(config.objectId);
if (obj) {
obj.evidenceIds.push(evidenceId);
}
console.log(`[UKG] Evidence added: ${evidenceId} for object ${config.objectId}`);
return evidence;
}
getEvidenceForObject(objectId) {
const obj = this.objects.get(objectId);
if (!obj) return [];
return obj.evidenceIds.map(id => this.evidence.get(id)).filter(Boolean);
}
// ============================================================
// TEMPORAL LAYER
// ============================================================
addTemporalRecord(objectId, record) {
const temporalId = this.generateId('tmp');
const temporalRecord = {
id: temporalId,
objectId,
timestamp: record.timestamp || Date.now(),
// Tillstånd vid denna tidpunkt
state: {
attributes: record.attributes,
verificationStatus: record.verificationStatus,
confidence: record.confidence,
status: record.status,
},
// Vad som ändrades
changes: record.changes || [],
// Orsak till ändring
reason: record.reason,
source: record.source,
};
if (!this.temporal.has(objectId)) {
this.temporal.set(objectId, []);
}
this.temporal.get(objectId).push(temporalRecord);
// Koppla till objekt
const obj = this.objects.get(objectId);
if (obj) {
obj.temporalIds.push(temporalId);
}
return temporalRecord;
}
getTemporalHistory(objectId) {
return this.temporal.get(objectId) || [];
}
getObjectAtTime(objectId, timestamp) {
const history = this.getTemporalHistory(objectId);
if (history.length === 0) return null;
// Hitta senaste record före timestamp
let closest = null;
for (const record of history) {
if (record.timestamp <= timestamp) {
closest = record;
}
}
return closest;
}
// ============================================================
// RELATIONER
// ============================================================
addRelation(config) {
const relationId = this.generateId('rel');
const relation = {
id: relationId,
fromObjectId: config.fromObjectId,
toObjectId: config.toObjectId,
type: config.relationType, // connected_to, part_of, near, supports, etc.
attributes: config.attributes || {},
confidence: config.confidence || 0,
timestamp: Date.now(),
};
if (!this.relations.has(config.fromObjectId)) {
this.relations.set(config.fromObjectId, []);
}
this.relations.get(config.fromObjectId).push(relation);
// Koppla till objekt
const fromObj = this.objects.get(config.fromObjectId);
if (fromObj) {
fromObj.relationIds.push(relationId);
}
console.log(`[UKG] Relation added: ${relationId} (${config.fromObjectId} -> ${config.toObjectId})`);
return relation;
}
getRelations(objectId) {
return this.relations.get(objectId) || [];
}
// ============================================================
// SPATIAL SÖKNING
// ============================================================
findNearbyObjects(location, radius = 50) {
const nearby = new Set();
const gridKeys = this.getGridKeysInRadius(location, radius);
for (const key of gridKeys) {
const objectIds = this.spatialIndex.get(key);
if (objectIds) {
for (const objectId of objectIds) {
const obj = this.objects.get(objectId);
if (obj) {
const distance = this.calculateDistance(location, obj.location);
if (distance <= radius) {
nearby.add(obj);
}
}
}
}
}
return Array.from(nearby);
}
findObjectsByType(type, area = null) {
const objectIds = this.typeIndex.get(type);
if (!objectIds) return [];
const objects = Array.from(objectIds)
.map(id => this.objects.get(id))
.filter(Boolean);
if (area) {
return objects.filter(obj => this.isInArea(obj.location, area));
}
return objects;
}
// ============================================================
// MERGE-OBJEKT (hundra videor → ett objekt)
// ============================================================
mergeObjects(targetId, sourceIds) {
const target = this.objects.get(targetId);
if (!target) return null;
for (const sourceId of sourceIds) {
const source = this.objects.get(sourceId);
if (!source || source.id === targetId) continue;
// Flytta observationer
for (const obsId of source.observationIds) {
if (!target.observationIds.includes(obsId)) {
target.observationIds.push(obsId);
}
}
// Flytta evidens
for (const evId of source.evidenceIds) {
if (!target.evidenceIds.includes(evId)) {
target.evidenceIds.push(evId);
}
// Uppdatera evidens-referens
const ev = this.evidence.get(evId);
if (ev) {
ev.objectId = targetId;
}
}
// Flytta temporal
for (const tmpId of source.temporalIds) {
if (!target.temporalIds.includes(tmpId)) {
target.temporalIds.push(tmpId);
}
}
// Ta bort källa
this.objects.delete(sourceId);
this.removeFromSpatialIndex(sourceId, source.location);
this.removeFromTypeIndex(sourceId, source.type);
console.log(`[UKG] Merged ${sourceId} into ${targetId}`);
}
// Uppdatera konfidens
target.confidence = Math.min(1.0, target.confidence + 0.1 * sourceIds.length);
target.verificationStatus = 'verified';
return target;
}
// ============================================================
// STATISTIK OCH ANALYS
// ============================================================
getStatistics() {
return {
totalObjects: this.objects.size,
totalObservations: this.observations.size,
totalEvidence: this.evidence.size,
totalTemporalRecords: Array.from(this.temporal.values()).reduce((sum, arr) => sum + arr.length, 0),
totalRelations: Array.from(this.relations.values()).reduce((sum, arr) => sum + arr.length, 0),
byType: this.getTypeStatistics(),
byVerification: this.getVerificationStatistics(),
};
}
getTypeStatistics() {
const stats = new Map();
for (const obj of this.objects.values()) {
const count = stats.get(obj.type) || 0;
stats.set(obj.type, count + 1);
}
return Object.fromEntries(stats);
}
getVerificationStatistics() {
const stats = { verified: 0, unverified: 0, disputed: 0 };
for (const obj of this.objects.values()) {
stats[obj.verificationStatus] = (stats[obj.verificationStatus] || 0) + 1;
}
return stats;
}
// ============================================================
// EXPORT
// ============================================================
exportObject(objectId) {
const obj = this.objects.get(objectId);
if (!obj) return null;
return {
...obj,
observations: obj.observationIds.map(id => this.observations.get(id)).filter(Boolean),
evidence: obj.evidenceIds.map(id => this.evidence.get(id)).filter(Boolean),
temporal: this.getTemporalHistory(objectId),
relations: this.getRelations(objectId),
};
}
exportArea(area) {
const objects = [];
for (const obj of this.objects.values()) {
if (this.isInArea(obj.location, area)) {
objects.push(this.exportObject(obj.id));
}
}
return {
area,
statistics: this.getStatistics(),
objects,
};
}
// ============================================================
// HJÄLPMETODER
// ============================================================
generateObjectId(config) {
// Deterministiskt ID baserat på plats och typ
const lat = config.location.lat.toFixed(6);
const lng = config.location.lng.toFixed(6);
const type = config.type;
const hash = crypto.createHash('md5')
.update(`${type}_${lat}_${lng}`)
.digest('hex')
.substring(0, 12);
return `${type}_${hash}`;
}
generateId(prefix) {
return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
getGridKey(location) {
const x = Math.floor(location.lng * 1000 / this.gridSize);
const y = Math.floor(location.lat * 1000 / this.gridSize);
return `${x},${y}`;
}
getGridKeysInRadius(location, radius) {
const keys = [];
const radiusInGrid = Math.ceil(radius / this.gridSize);
const centerX = Math.floor(location.lng * 1000 / this.gridSize);
const centerY = Math.floor(location.lat * 1000 / this.gridSize);
for (let dx = -radiusInGrid; dx <= radiusInGrid; dx++) {
for (let dy = -radiusInGrid; dy <= radiusInGrid; dy++) {
keys.push(`${centerX + dx},${centerY + dy}`);
}
}
return keys;
}
addToSpatialIndex(objectId, location) {
const key = this.getGridKey(location);
if (!this.spatialIndex.has(key)) {
this.spatialIndex.set(key, new Set());
}
this.spatialIndex.get(key).add(objectId);
}
removeFromSpatialIndex(objectId, location) {
const key = this.getGridKey(location);
const set = this.spatialIndex.get(key);
if (set) {
set.delete(objectId);
}
}
addToTypeIndex(objectId, type) {
if (!this.typeIndex.has(type)) {
this.typeIndex.set(type, new Set());
}
this.typeIndex.get(type).add(objectId);
}
removeFromTypeIndex(objectId, type) {
const set = this.typeIndex.get(type);
if (set) {
set.delete(objectId);
}
}
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;
}
isInArea(location, area) {
return location.lat >= area.minLat &&
location.lat <= area.maxLat &&
location.lng >= area.minLng &&
location.lng <= area.maxLng;
}
}
// Exportera
module.exports = UrbanKnowledgeGraph;
// Demo
if (require.main === module) {
const ukg = new UrbanKnowledgeGraph();
console.log('=== URBAN KNOWLEDGE GRAPH DEMO ===\n');
// Skapa objekt: Street Lamp #1293
const lamp = ukg.createObject({
type: 'street_lamp',
location: { lat: 13.725, lng: 100.555, accuracy: 2 },
attributes: {
material: 'steel',
height: 7.8,
paint: 'grey',
rust: false,
lean: 0,
light: 'working',
},
confidence: 0.94,
createdBy: 'quixzoom_system',
});
console.log(`Created object: ${lamp.id}`);
console.log(`Attributes:`, lamp.attributes);
console.log();
// Lägg till observation
const obs1 = ukg.addObservation({
sourceType: 'quixzoom',
sourceId: 'zoomer_001',
mediaType: 'video',
mediaUrl: 'https://r2.landvex.com/videos/bangkok_001.mp4',
frameNumber: 342,
observedObjects: [{
objectId: lamp.id,
confidence: 0.94,
bbox: [100, 200, 150, 300],
}],
location: { lat: 13.725, lng: 100.555 },
quality: { resolution: 1080, sharpness: 0.9 },
aiAnalysis: { objectDetection: 0.94, condition: 'good' },
});
console.log(`Added observation: ${obs1.id}`);
console.log();
// Lägg till evidens från flera källor
const ev1 = ukg.addEvidence({
objectId: lamp.id,
observationId: obs1.id,
proofType: 'video',
proofUrl: 'https://r2.landvex.com/videos/bangkok_001.mp4#frame=342',
frameNumber: 342,
sourceType: 'quixzoom',
sourceId: 'zoomer_001',
reliability: 0.85,
});
const ev2 = ukg.addEvidence({
objectId: lamp.id,
proofType: 'video',
proofUrl: 'https://r2.landvex.com/videos/bangkok_002.mp4#frame=91',
frameNumber: 91,
sourceType: 'quixzoom',
sourceId: 'zoomer_002',
reliability: 0.80,
});
const ev3 = ukg.addEvidence({
objectId: lamp.id,
proofType: 'manual_inspection',
sourceType: 'inspector',
sourceId: 'inspector_001',
reliability: 0.95,
});
console.log(`Added ${lamp.evidenceIds.length} evidence items`);
console.log();
// Temporal: Förändring över tid
console.log('=== TEMPORAL HISTORY ===');
ukg.addTemporalRecord(lamp.id, {
timestamp: new Date('2026-06-01').getTime(),
attributes: { ...lamp.attributes, rust: false, lean: 0, light: 'working' },
verificationStatus: 'verified',
confidence: 0.95,
reason: 'Initial observation',
});
ukg.addTemporalRecord(lamp.id, {
timestamp: new Date('2026-08-12').getTime(),
attributes: { ...lamp.attributes, rust: false, lean: 2, light: 'working' },
verificationStatus: 'verified',
confidence: 0.90,
reason: 'Tilt detected',
changes: ['lean: 0 -> 2'],
});
ukg.addTemporalRecord(lamp.id, {
timestamp: new Date('2026-09-20').getTime(),
attributes: { ...lamp.attributes, rust: true, lean: 4, light: 'flickering' },
verificationStatus: 'verified',
confidence: 0.85,
reason: 'Corrosion increasing',
changes: ['rust: false -> true', 'lean: 2 -> 4', 'light: working -> flickering'],
});
ukg.addTemporalRecord(lamp.id, {
timestamp: new Date('2026-10-11').getTime(),
attributes: { ...lamp.attributes, rust: true, lean: 4, light: 'broken' },
verificationStatus: 'verified',
confidence: 0.95,
reason: 'Light broken',
changes: ['light: flickering -> broken'],
});
ukg.addTemporalRecord(lamp.id, {
timestamp: new Date('2026-11-02').getTime(),
attributes: { ...lamp.attributes, rust: false, lean: 0, light: 'working' },
verificationStatus: 'verified',
confidence: 0.98,
reason: 'Replaced',
changes: ['status: repaired'],
});
const history = ukg.getTemporalHistory(lamp.id);
for (const record of history) {
const date = new Date(record.timestamp).toISOString().split('T')[0];
console.log(` ${date}: ${record.reason}`);
if (record.changes.length > 0) {
console.log(` Changes: ${record.changes.join(', ')}`);
}
}
console.log();
// Visa evidens
console.log('=== EVIDENCE ===');
const evidence = ukg.getEvidenceForObject(lamp.id);
for (const ev of evidence) {
console.log(` ${ev.source.type}: ${ev.proofType} (reliability: ${ev.reliability})`);
}
console.log();
// Statistik
console.log('=== STATISTICS ===');
const stats = ukg.getStatistics();
console.log(`Total objects: ${stats.totalObjects}`);
console.log(`Total observations: ${stats.totalObservations}`);
console.log(`Total evidence: ${stats.totalEvidence}`);
console.log(`Total temporal records: ${stats.totalTemporalRecords}`);
console.log();
// Exportera objekt
console.log('=== EXPORTED OBJECT ===');
const exported = ukg.exportObject(lamp.id);
console.log(`ID: ${exported.id}`);
console.log(`Type: ${exported.type}`);
console.log(`Observations: ${exported.observations.length}`);
console.log(`Evidence: ${exported.evidence.length}`);
console.log(`Temporal records: ${exported.temporal.length}`);
}