Files
swarm-house/prototype/src/live.ts
T
eSlider 1e5854ee0a
CI & Release / Verify simulator (push) Skipped
CI & Release / Trivy scan (push) Skipped
CI & Release / Semantic Release (push) Skipped
CI & Release / Verify simulator (pull_request) Failing after 5s
CI & Release / Trivy scan (pull_request) Skipped
CI & Release / Semantic Release (pull_request) Skipped
feat: prototype perimeter patrol, 3D view, and swarm algorithms
Add optional Three.js scene, base-pad lifecycle, mycelial swarms,
UFO transit steering, and algorithm selector (boids, APF, ACO, hypha).
2026-07-12 12:51:25 +01:00

142 lines
4.2 KiB
TypeScript

// Live mode: real drone poses from the explorer's read-only SQL API.
// The same gated /api/query endpoint a peer drone (or an engineer on the
// bench) would use — the prototype is just another read-only consumer.
import { AREA, LINK_RANGE, POSE_RATE_BYTES, type LinkStats, type World } from "./sim";
export const DEFAULT_EXPLORER_URL =
(import.meta.env.VITE_EXPLORER_URL as string | undefined) ?? "http://localhost:30088";
export interface LivePose {
id: string;
x: number; // meters, mission frame
y: number;
yawDeg: number;
battery: number | null;
tsNs: number;
}
// Latest pose per drone from its own 'sent' state rows, joined with the
// latest battery reading from telemetry. arg_max keeps it a single scan.
const LIVE_SQL = `
WITH pose AS (
SELECT drone,
arg_max(pos_x, ts_ns) AS x,
arg_max(pos_y, ts_ns) AS y,
arg_max(yaw, ts_ns) AS yaw,
max(ts_ns) AS ts_ns
FROM state
WHERE direction = 'sent'
GROUP BY drone
),
batt AS (
SELECT drone, arg_max(level_pct, ts_ns) AS battery
FROM telemetry
WHERE sensor = 'battery'
GROUP BY drone
)
SELECT p.drone, p.x, p.y, p.yaw, p.ts_ns, b.battery
FROM pose p LEFT JOIN batt b USING (drone)
ORDER BY p.drone`;
interface QueryResponse {
columns?: string[];
rows?: (string | number | null)[][];
error?: string;
}
export async function fetchLivePoses(baseUrl: string): Promise<LivePose[]> {
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/api/query`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sql: LIVE_SQL }),
});
if (!res.ok) throw new Error(`explorer responded ${res.status}`);
const data = (await res.json()) as QueryResponse;
if (data.error) throw new Error(data.error);
return (data.rows ?? []).map((r) => ({
id: String(r[0]),
x: Number(r[1]),
y: Number(r[2]),
yawDeg: Number(r[3]),
tsNs: Number(r[4]),
battery: r[5] === null ? null : Number(r[5]),
}));
}
// Scale mission-frame meters into the prototype's square view. The virtual
// drones patrol a smaller square than the sim world, so fit the observed
// extent (with padding) instead of assuming a size.
function fitScale(poses: LivePose[]): number {
const extent = Math.max(400, ...poses.map((p) => Math.max(p.x, p.y))) * 1.15;
return AREA / extent;
}
function linkKey(a: number, b: number): string {
return a < b ? `${a}-${b}` : `${b}-${a}`;
}
/** Convert live poses into the World shape the renderer already speaks. */
export function toLiveWorld(poses: LivePose[], prev: World | null, dtS: number): World {
const k = fitScale(poses);
const drones = poses.map((p, i) => {
const t = (prev?.t ?? 0) + dtS;
const band = 16 + (i % 6) * 4.5;
const alt =
band +
Math.sin(t * 0.62 + i * 0.91) * 4.5 +
Math.sin(p.x * 0.011 + t * 0.45) * 2;
const altVel = Math.cos(t * 0.62 + i * 0.91) * 2.8;
return {
id: i,
name: p.id,
pos: { x: p.x * k, y: p.y * k },
vel: { x: 0, y: 0 },
heading: (p.yawDeg * Math.PI) / 180,
waypoint: 0,
battery: p.battery ?? 100,
channel: 0,
alt,
altVel,
};
});
// Every in-range pair exchanges pose broadcasts; accumulate totals so the
// panel keeps its meaning between polls.
const links = new Map<string, LinkStats>();
let totalBytes = prev?.totalBytes ?? 0;
for (let i = 0; i < drones.length; i++) {
for (let j = i + 1; j < drones.length; j++) {
const dist = Math.hypot(
drones[i].pos.x - drones[j].pos.x,
drones[i].pos.y - drones[j].pos.y,
(drones[i].alt ?? 0) - (drones[j].alt ?? 0),
);
if (dist > LINK_RANGE) continue;
const key = linkKey(i, j);
const old = prev?.links.get(key);
const bytes = POSE_RATE_BYTES * 2 * dtS;
totalBytes += bytes;
links.set(key, {
key,
a: i,
b: j,
totalUp: (old?.totalUp ?? 0) + bytes / 2,
totalDown: (old?.totalDown ?? 0) + bytes / 2,
rate: POSE_RATE_BYTES * 2,
flash: 0.6,
bulk: 0,
});
}
}
return {
t: (prev?.t ?? 0) + dtS,
drones,
obstacles: [],
links,
totalBytes,
broadcasts: (prev?.broadcasts ?? 0) + links.size * 2,
};
}