export const FLEET_POSITIONS_CURRENT = Object.freeze({ id: "fleet.positions.current.v1", version: "1.0.0", delivery: "snapshot+patch", semanticTypes: Object.freeze(["map.moving_object", "geo.position"]), }); export function toFleetPositionsSnapshot(rows, { tenantId, connectionId, generatedAt = new Date(), staleAfterMs }) { return { schemaVersion: "nodedc.data-product.snapshot.v1", dataProduct: FLEET_POSITIONS_CURRENT, scope: { tenantId, connectionId }, generatedAt: iso(generatedAt), entities: rows.map((row) => toFleetPositionEntity(row, { generatedAt, staleAfterMs })), }; } export function toFleetPositionPatch(row, { generatedAt = new Date(), staleAfterMs }) { return { schemaVersion: "nodedc.data-product.patch.v1", dataProductId: FLEET_POSITIONS_CURRENT.id, operation: "upsert", emittedAt: iso(generatedAt), entity: toFleetPositionEntity(row, { generatedAt, staleAfterMs }), }; } export function toFleetPositionEntity(row, { generatedAt, staleAfterMs }) { const observedAt = date(row.observedAt); const receivedAt = date(row.receivedAt); const latitude = finiteNumber(row.latitude); const longitude = finiteNumber(row.longitude); const hasPosition = latitude !== null && longitude !== null; const ageMs = observedAt ? Math.max(0, generatedAt.getTime() - observedAt.getTime()) : null; const status = !hasPosition ? "no-position" : ageMs !== null && ageMs > staleAfterMs ? "stale" : "active"; return { subjectId: String(row.subjectId), semanticType: "map.moving_object", label: String(row.name || row.subjectId), status, observedAt: observedAt ? observedAt.toISOString() : null, receivedAt: receivedAt ? receivedAt.toISOString() : null, position: hasPosition ? { latitude, longitude } : null, motion: { speed: finiteNumber(row.speed), course: finiteNumber(row.course), }, quality: { gpsValid: booleanOrNull(row.gpsValid), satellites: finiteNumber(row.satellites), hdop: finiteNumber(row.hdop), accuracyM: finiteNumber(row.accuracyM), }, }; } function iso(value) { const parsed = date(value); if (!parsed) throw new Error("data_product_timestamp_invalid"); return parsed.toISOString(); } function date(value) { if (value instanceof Date && !Number.isNaN(value.getTime())) return value; const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? null : parsed; } function finiteNumber(value) { if (value === null || value === undefined || value === "") return null; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } function booleanOrNull(value) { if (value === true || value === 1 || value === "1" || value === "true") return true; if (value === false || value === 0 || value === "0" || value === "false") return false; return null; }