diff --git a/data/camera-settings.json b/data/camera-settings.json
new file mode 100644
index 0000000..b424610
--- /dev/null
+++ b/data/camera-settings.json
@@ -0,0 +1,22 @@
+{
+ "shutterUs": 0,
+ "gain": 0,
+ "ev": -0.04,
+ "exposureMode": "long",
+ "brightness": 0,
+ "contrast": 1,
+ "saturation": 1,
+ "sharpness": 1,
+ "awb": "fluorescent",
+ "awbGains": null,
+ "denoise": "off",
+ "autofocusMode": "manual",
+ "autofocusRange": "macro",
+ "autofocusSpeed": "normal",
+ "lensPosition": 4.8,
+ "hflip": false,
+ "vflip": false,
+ "rotation": 0,
+ "quality": 93,
+ "roi": null
+}
\ No newline at end of file
diff --git a/data/captures/photos/photo_20261022T003029Z.jpg b/data/captures/photos/photo_20261022T003029Z.jpg
new file mode 100644
index 0000000..cecff2d
Binary files /dev/null and b/data/captures/photos/photo_20261022T003029Z.jpg differ
diff --git a/data/captures/photos/photo_20261022T003029Z.json b/data/captures/photos/photo_20261022T003029Z.json
new file mode 100644
index 0000000..de8f809
--- /dev/null
+++ b/data/captures/photos/photo_20261022T003029Z.json
@@ -0,0 +1,86 @@
+{
+ "type": "photo",
+ "name": "photo_20261022T003029Z.jpg",
+ "url": "/media/photos/photo_20261022T003029Z.jpg",
+ "raw": null,
+ "bytes": 2766941,
+ "durationMs": 4318,
+ "takenAt": "2026-10-22T00:30:33.571Z",
+ "settings": {
+ "shutterUs": 0,
+ "gain": 0,
+ "ev": 0,
+ "exposureMode": "normal",
+ "brightness": 0,
+ "contrast": 1,
+ "saturation": 1,
+ "sharpness": 1,
+ "awb": "auto",
+ "awbGains": null,
+ "denoise": "auto",
+ "autofocusMode": "manual",
+ "autofocusRange": "normal",
+ "autofocusSpeed": "normal",
+ "lensPosition": 0,
+ "hflip": false,
+ "vflip": false,
+ "rotation": 0,
+ "quality": 93,
+ "roi": null,
+ "iso": 0
+ },
+ "meta": {
+ "telemetry": {
+ "time": {
+ "iso": "2026-10-22T00:30:29.252Z",
+ "epochMs": 1792629029252
+ },
+ "sensor": {
+ "state": "backoff",
+ "sampleAgeMs": null,
+ "restarts": 6,
+ "lastError": "exited (code=1, signal=null)"
+ },
+ "orientation": null,
+ "battery": {
+ "state": "ok",
+ "available": true,
+ "source": "pisugar-server",
+ "socketPath": "/tmp/pisugar-server.sock",
+ "voltageV": 3.874,
+ "percent": 85,
+ "percentEstimate": 85,
+ "percentIsEstimate": false,
+ "powerPlugged": true,
+ "chargingAllowed": true,
+ "charging": true,
+ "updatedAt": "2026-10-22T00:30:25.646Z",
+ "error": null
+ },
+ "gps": {
+ "state": "no_fix",
+ "position": null,
+ "satellites": {
+ "used": 0,
+ "seen": 26
+ },
+ "dop": {
+ "hdop": null,
+ "vdop": null,
+ "pdop": null
+ },
+ "speedMps": null,
+ "clockSkewMs": -609,
+ "errorM": {
+ "x": null,
+ "y": null,
+ "v": null
+ }
+ }
+ },
+ "pointing": {
+ "available": false,
+ "error": "no_orientation"
+ }
+ }
+}
\ No newline at end of file
diff --git a/data/captures/photos/photo_20261022T005834Z.jpg b/data/captures/photos/photo_20261022T005834Z.jpg
new file mode 100644
index 0000000..0ccc5c3
Binary files /dev/null and b/data/captures/photos/photo_20261022T005834Z.jpg differ
diff --git a/data/captures/photos/photo_20261022T005834Z.json b/data/captures/photos/photo_20261022T005834Z.json
new file mode 100644
index 0000000..13662df
--- /dev/null
+++ b/data/captures/photos/photo_20261022T005834Z.json
@@ -0,0 +1,86 @@
+{
+ "type": "photo",
+ "name": "photo_20261022T005834Z.jpg",
+ "url": "/media/photos/photo_20261022T005834Z.jpg",
+ "raw": null,
+ "bytes": 4131974,
+ "durationMs": 3962,
+ "takenAt": "2026-10-22T00:58:38.129Z",
+ "settings": {
+ "shutterUs": 0,
+ "gain": 5,
+ "ev": 0,
+ "exposureMode": "long",
+ "brightness": 0,
+ "contrast": 1,
+ "saturation": 1,
+ "sharpness": 1,
+ "awb": "indoor",
+ "awbGains": null,
+ "denoise": "off",
+ "autofocusMode": "continuous",
+ "autofocusRange": "macro",
+ "autofocusSpeed": "normal",
+ "lensPosition": 0.4,
+ "hflip": false,
+ "vflip": false,
+ "rotation": 0,
+ "quality": 93,
+ "roi": null,
+ "iso": 500
+ },
+ "meta": {
+ "telemetry": {
+ "time": {
+ "iso": "2026-10-22T00:58:34.160Z",
+ "epochMs": 1792630714160
+ },
+ "sensor": {
+ "state": "backoff",
+ "sampleAgeMs": null,
+ "restarts": 11,
+ "lastError": "exited (code=1, signal=null)"
+ },
+ "orientation": null,
+ "battery": {
+ "state": "ok",
+ "available": true,
+ "source": "pisugar-server",
+ "socketPath": "/tmp/pisugar-server.sock",
+ "voltageV": 3.893,
+ "percent": 87,
+ "percentEstimate": 87,
+ "percentIsEstimate": false,
+ "powerPlugged": true,
+ "chargingAllowed": true,
+ "charging": true,
+ "updatedAt": "2026-10-22T00:58:31.250Z",
+ "error": null
+ },
+ "gps": {
+ "state": "no_fix",
+ "position": null,
+ "satellites": {
+ "used": 0,
+ "seen": 20
+ },
+ "dop": {
+ "hdop": null,
+ "vdop": null,
+ "pdop": null
+ },
+ "speedMps": null,
+ "clockSkewMs": -80,
+ "errorM": {
+ "x": null,
+ "y": null,
+ "v": null
+ }
+ }
+ },
+ "pointing": {
+ "available": false,
+ "error": "no_orientation"
+ }
+ }
+}
\ No newline at end of file
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..ed1bb95
--- /dev/null
+++ b/index.html
@@ -0,0 +1,206 @@
+
+
+
+MRS Telescope
+
+
+
![live view]()
+
+
+
+
+
+
+
+
+
+
Target
+
+
+
+
Mount & focuser hardware not connected
+
+
+
Focus–
+
+ Focus step
+
+
+
+
+
+
Capture
+
+
+
+
+
+
+
diff --git a/server.mjs b/server.mjs
index c84ab26..ae3632d 100644
--- a/server.mjs
+++ b/server.mjs
@@ -1,15 +1,20 @@
import { createServer } from 'node:http';
+import fs from 'node:fs';
+import fsp from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { TelemetryService } from './services/telemetry.mjs';
import { CameraService } from './services/camera.mjs';
import { CelestialService } from './services/celestial.mjs';
+import { SkyService } from './services/sky.mjs';
const ROOT = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(ROOT, 'data'));
const PORT = Number(process.env.PORT || 8080);
const HOST = process.env.HOST || '0.0.0.0';
+// UI lives in the project root. Override with UI_FILE if you keep it elsewhere (e.g. ui/index.html).
+const UI_FILE = path.resolve(process.env.UI_FILE || path.join(ROOT, 'index.html'));
const log = Object.fromEntries(['debug', 'info', 'warn', 'error'].map((level) => [
level,
@@ -43,9 +48,9 @@ const cameraConfig = {
vidBin: process.env.RPICAM_VID || 'rpicam-vid',
stillBin: process.env.RPICAM_STILL || 'rpicam-still',
ffmpegBin: process.env.FFMPEG || 'ffmpeg',
- width: Number(process.env.W || 1280),
+ width: Number(process.env.W || 720),
height: Number(process.env.H || 720),
- fps: Number(process.env.FPS || 30),
+ fps: Number(process.env.FPS || 60),
streamQuality: 85,
streamMaxShutterUs: 1_000_000,
streamIdleStopMs: 5000,
@@ -81,6 +86,7 @@ if (process.env.DECLINATION_DEG !== undefined) {
const telemetry = new TelemetryService(telemetryConfig, { log });
const celestial = new CelestialService(celestialConfig, { telemetry, log, dataDir: DATA_DIR });
+const sky = new SkyService({ celestial, telemetry });
const camera = new CameraService(cameraConfig, {
log,
dataDir: DATA_DIR,
@@ -106,6 +112,38 @@ function sendJson(res, status, data) {
res.end(JSON.stringify(data));
}
+const MIME = {
+ '.html': 'text/html; charset=utf-8',
+ '.js': 'text/javascript; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.png': 'image/png',
+ '.mp4': 'video/mp4',
+ '.h264': 'video/h264',
+ '.dng': 'image/x-adobe-dng',
+};
+
+async function serveFile(res, file, cache = 'no-cache') {
+ let st;
+ try {
+ st = await fsp.stat(file);
+ if (!st.isFile()) throw new Error('not a file');
+ } catch {
+ return sendJson(res, 404, { error: 'not_found' });
+ }
+
+ res.writeHead(200, {
+ 'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream',
+ 'Content-Length': st.size,
+ 'Cache-Control': cache,
+ });
+ fs.createReadStream(file)
+ .on('error', (err) => res.destroy(err))
+ .pipe(res);
+}
+
function readJson(req) {
return new Promise((resolve, reject) => {
let body = '';
@@ -134,8 +172,35 @@ function readJson(req) {
async function handle(req, res) {
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
+ const is = (method, pathname) => req.method === method && url.pathname === pathname;
- if (req.method === 'GET' && url.pathname === '/') {
+ // ───────────── UI + static ─────────────
+
+ if (is('GET', '/')) {
+ res.writeHead(302, { Location: '/index.html', 'Cache-Control': 'no-store' });
+ return res.end();
+ }
+
+ if (is('GET', '/index.html')) {
+ return serveFile(res, UI_FILE);
+ }
+
+ if (req.method === 'GET' && url.pathname.startsWith('/media/')) {
+ let rel;
+ try {
+ rel = decodeURIComponent(url.pathname.slice('/media/'.length));
+ } catch {
+ return sendJson(res, 400, { error: 'bad_path' });
+ }
+ const base = path.resolve(camera.mediaRoot);
+ const file = path.resolve(base, rel);
+ if (!file.startsWith(base + path.sep)) return sendJson(res, 403, { error: 'forbidden' });
+ return serveFile(res, file, 'public, max-age=3600');
+ }
+
+ // ───────────── API index ─────────────
+
+ if (is('GET', '/api')) {
return sendJson(res, 200, {
name: 'MRS',
status: 'online',
@@ -144,34 +209,29 @@ async function handle(req, res) {
'/api/status',
'/api/telemetry/battery',
'/api/camera/settings',
+ '/api/camera/files',
+ '/api/celestial/predict?target=',
+ '/api/celestial/delta?target=',
+ '/api/celestial/targets',
+ '/api/celestial/alignment',
+ '/api/sky/info?target=',
+ '/api/sky/recommend',
+ '/api/sky/objects',
],
});
}
- if (req.method === 'GET' && url.pathname === '/stream') {
+ // ───────────── stream / telemetry ─────────────
+
+ if (is('GET', '/stream')) {
return camera.addStreamClient(req, res);
}
- if (req.method === 'GET' && url.pathname === '/api/telemetry/battery') {
+ if (is('GET', '/api/telemetry/battery')) {
return sendJson(res, 200, { battery: telemetry.getBattery() });
}
- if (req.method === 'GET' && url.pathname === '/api/camera/settings') {
- return sendJson(res, 200, { settings: camera.getSettings() });
- }
-
- if (req.method === 'PATCH' && url.pathname === '/api/camera/settings') {
- const body = await readJson(req);
- const settings = await camera.applySettings(body);
- return sendJson(res, 200, { ok: true, settings });
- }
-
- if (req.method === 'POST' && url.pathname === '/api/camera/settings/reset') {
- const settings = await camera.resetSettings();
- return sendJson(res, 200, { ok: true, settings });
- }
-
- if (req.method === 'GET' && url.pathname === '/api/status') {
+ if (is('GET', '/api/status')) {
let celestialStatus;
try {
@@ -189,7 +249,53 @@ async function handle(req, res) {
});
}
- if (req.method === 'GET' && url.pathname === '/api/celestial/predict') {
+ // ───────────── camera settings ─────────────
+
+ if (is('GET', '/api/camera/settings')) {
+ return sendJson(res, 200, { settings: camera.getSettings() });
+ }
+
+ if (is('PATCH', '/api/camera/settings')) {
+ const body = await readJson(req);
+ const settings = await camera.applySettings(body);
+ return sendJson(res, 200, { ok: true, settings });
+ }
+
+ if (is('POST', '/api/camera/settings/reset')) {
+ const settings = await camera.resetSettings();
+ return sendJson(res, 200, { ok: true, settings });
+ }
+
+ // ───────────── camera capture jobs ─────────────
+
+ if (is('POST', '/api/camera/photo')) {
+ return sendJson(res, 200, await camera.takePhoto(await readJson(req)));
+ }
+
+ if (is('POST', '/api/camera/video/start')) {
+ return sendJson(res, 200, { ok: true, job: await camera.startVideo(await readJson(req)) });
+ }
+
+ if (is('POST', '/api/camera/video/stop')) {
+ return sendJson(res, 200, await camera.stopVideo());
+ }
+
+ if (is('POST', '/api/camera/timelapse/start')) {
+ return sendJson(res, 200, { ok: true, job: await camera.startTimelapse(await readJson(req)) });
+ }
+
+ if (is('POST', '/api/camera/timelapse/stop')) {
+ return sendJson(res, 200, await camera.stopTimelapse());
+ }
+
+ if (is('GET', '/api/camera/files')) {
+ const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit') || 50)));
+ return sendJson(res, 200, await camera.listFiles({ limit }));
+ }
+
+ // ───────────── celestial ─────────────
+
+ if (is('GET', '/api/celestial/predict')) {
const target = url.searchParams.get('target');
if (!target) {
return sendJson(res, 400, {
@@ -200,7 +306,7 @@ async function handle(req, res) {
return sendJson(res, 200, celestial.predict(target));
}
- if (req.method === 'GET' && url.pathname === '/api/celestial/delta') {
+ if (is('GET', '/api/celestial/delta')) {
const target = url.searchParams.get('target');
if (!target) {
return sendJson(res, 400, {
@@ -211,18 +317,18 @@ async function handle(req, res) {
return sendJson(res, 200, celestial.delta(target));
}
- if (req.method === 'GET' && url.pathname === '/api/celestial/targets') {
+ if (is('GET', '/api/celestial/targets')) {
const kind = url.searchParams.get('kind') || undefined;
const q = url.searchParams.get('q') || undefined;
const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit') || 100)));
return sendJson(res, 200, { targets: celestial.listTargets({ kind, q, limit }) });
}
- if (req.method === 'GET' && url.pathname === '/api/celestial/alignment') {
+ if (is('GET', '/api/celestial/alignment')) {
return sendJson(res, 200, celestial.getAlignment());
}
- if (req.method === 'POST' && url.pathname === '/api/celestial/zero') {
+ if (is('POST', '/api/celestial/zero')) {
const body = await readJson(req);
if (!body.target) {
return sendJson(res, 400, {
@@ -233,15 +339,38 @@ async function handle(req, res) {
return sendJson(res, 200, await celestial.zero(body.target));
}
- if (req.method === 'DELETE' && url.pathname === '/api/celestial/zero') {
+ if (is('DELETE', '/api/celestial/zero')) {
return sendJson(res, 200, await celestial.clearZero());
}
- if (req.method === 'POST' && url.pathname === '/api/celestial/tle/refresh') {
+ if (is('POST', '/api/celestial/tle/refresh')) {
const body = await readJson(req);
return sendJson(res, 200, { results: await celestial.refreshTLEs({ force: body.force !== false }) });
}
+ // ───────────── sky (info / recommendations / overlay) ─────────────
+
+ if (is('GET', '/api/sky/info')) {
+ const target = url.searchParams.get('target');
+ if (!target) {
+ return sendJson(res, 400, {
+ error: 'missing_target',
+ message: 'Pass ?target=moon, saturn, vega, iss, radec:5.9,7.4, etc.',
+ });
+ }
+ return sendJson(res, 200, sky.info(target));
+ }
+
+ if (is('GET', '/api/sky/recommend')) {
+ const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') || 8)));
+ const minAlt = Number(url.searchParams.get('minAlt') || 15);
+ return sendJson(res, 200, { recommendations: sky.recommend({ limit, minAlt }) });
+ }
+
+ if (is('GET', '/api/sky/objects')) {
+ return sendJson(res, 200, sky.objects());
+ }
+
return sendJson(res, 404, { error: 'not_found', path: url.pathname });
}
diff --git a/services/sky.mjs b/services/sky.mjs
new file mode 100644
index 0000000..259df5e
--- /dev/null
+++ b/services/sky.mjs
@@ -0,0 +1,131 @@
+// Sky service: target info, "what should I look at" recommendations, and overlay objects.
+// Positions come from CelestialService.predict() (astronomy-engine, fully offline for Sun/Moon/planets/stars).
+import * as Astronomy from 'astronomy-engine';
+import { STARS } from '../lib/stars.mjs';
+import { angularSeparation } from '../lib/geo.mjs';
+
+// Bundled deep-sky showpieces (J2000 RA hours, Dec deg, visual magnitude). Swap for OpenNGC later.
+const DSO = [
+ ['m31', 'M31 Andromeda Galaxy', 'Galaxy', 0.712, 41.27, 3.4],
+ ['m42', 'M42 Orion Nebula', 'Nebula', 5.588, -5.39, 4.0],
+ ['m45', 'M45 Pleiades', 'Open Cluster', 3.79, 24.12, 1.6],
+ ['m44', 'M44 Beehive', 'Open Cluster', 8.667, 19.67, 3.7],
+ ['m13', 'M13 Hercules Cluster', 'Globular', 16.695, 36.46, 5.8],
+ ['m8', 'M8 Lagoon Nebula', 'Nebula', 18.063, -24.38, 6.0],
+ ['m27', 'M27 Dumbbell Nebula', 'Planetary Nebula', 19.994, 22.72, 7.4],
+ ['m57', 'M57 Ring Nebula', 'Planetary Nebula', 18.893, 33.03, 8.8],
+ ['m81', 'M81 Bode\'s Galaxy', 'Galaxy', 9.926, 69.07, 6.9],
+ ['m51', 'M51 Whirlpool Galaxy', 'Galaxy', 13.498, 47.2, 8.4],
+].map(([id, label, type, ra, dec, mag]) => ({ id, label, type, ra, dec, mag, spec: `radec:${ra},${dec}` }));
+
+const PLANETS = ['mercury', 'venus', 'mars', 'jupiter', 'saturn', 'uranus', 'neptune'];
+const OUTER = ['Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune'];
+const r = (v, d = 1) => (v == null ? v : Math.round(v * 10 ** d) / 10 ** d);
+const iso = (t) => t?.date?.toISOString?.() ?? null;
+const cap = (s) => s[0].toUpperCase() + s.slice(1);
+
+export class SkyService {
+ #c;
+ #t;
+
+ constructor({ celestial, telemetry }) {
+ this.#c = celestial;
+ this.#t = telemetry;
+ }
+
+ #candidates() {
+ return [
+ { id: 'body:moon', label: 'Moon', type: 'Moon', spec: 'moon' },
+ ...PLANETS.map((p) => ({ id: `body:${p}`, label: cap(p), type: 'Planet', spec: `body:${p}` })),
+ ...Object.entries(STARS).map(([k, s]) => ({ id: `star:${k}`, label: s.label, type: 'Star', mag: s.mag ?? 2, spec: `star:${k}` })),
+ ...DSO.map((d) => ({ ...d, id: `dso:${d.id}` })),
+ ];
+ }
+
+ /** Detail for one target: position + (for solar-system bodies) phase, magnitude, elongation, opposition, rise/transit/set. */
+ info(spec) {
+ const date = this.#t.now();
+ const p = this.#c.predict(spec, { date });
+ const out = { ...p };
+ if (p.target.kind !== 'body') return out;
+
+ const name = p.target.label;
+ const o = new Astronomy.Observer(p.observer.lat, p.observer.lon, p.observer.altM);
+ const ill = Astronomy.Illumination(name, date);
+ out.magnitude = r(ill.mag);
+ out.phasePct = r(ill.phase_fraction * 100, 0);
+ out.distanceKm = Math.round(p.distanceAu * 149_597_870.7);
+ out.elongationDeg = name === 'Sun' ? 0 : r(Astronomy.AngleFromSun(name, date));
+ out.events = {
+ rise: iso(Astronomy.SearchRiseSet(name, o, +1, date, 2)),
+ transit: iso(Astronomy.SearchHourAngle(name, o, 0, date).time),
+ set: iso(Astronomy.SearchRiseSet(name, o, -1, date, 2)),
+ };
+ if (OUTER.includes(name)) {
+ out.oppositionPct = r((out.elongationDeg / 180) * 100, 0); // 100% = exactly at opposition
+ out.nextOpposition = iso(Astronomy.SearchRelativeLongitude(name, 180, date));
+ }
+ return out;
+ }
+
+ /** Ranked list of what's worth observing right now. */
+ recommend({ limit = 8, minAlt = 15 } = {}) {
+ const date = this.#t.now();
+ const moon = this.#c.predict('moon', { date });
+ const moonIllum = (1 - Math.cos((Astronomy.MoonPhase(date) * Math.PI) / 180)) / 2;
+ const rows = [];
+
+ for (const c of this.#candidates()) {
+ let p;
+ try {
+ p = this.#c.predict(c.spec, { date });
+ } catch {
+ continue;
+ }
+ if (p.alt < minAlt) continue;
+
+ const row = { id: c.id, spec: c.spec, label: c.label, type: c.type, az: p.az, alt: p.alt, why: [] };
+ let mag = c.mag;
+ let score = (Math.min(p.alt, 70) / 70) * 40;
+
+ if (c.type === 'Planet' || c.type === 'Moon') {
+ const ill = Astronomy.Illumination(c.label, date);
+ mag = ill.mag;
+ row.phasePct = r(ill.phase_fraction * 100, 0);
+ const el = Astronomy.AngleFromSun(c.label, date);
+ if (OUTER.includes(c.label)) {
+ row.oppositionPct = r((el / 180) * 100, 0);
+ if (el > 150) { score += 15; row.why.push('near opposition'); }
+ }
+ if (el < 20 && c.type === 'Planet') { score -= 30; row.why.push('close to the Sun'); }
+ if (c.type === 'Planet') score += 10;
+ }
+ const moonSep = angularSeparation(p.az, p.alt, moon.az, moon.alt);
+ if (c.type !== 'Moon' && mag > 4 && moonSep < 40) {
+ score -= (40 - moonSep) * 0.6 * moonIllum;
+ row.why.push('washed out by Moon');
+ }
+ score += Math.max(0, 10 - (mag ?? 10)) * 3;
+ if (p.alt > 50) row.why.push('high in sky');
+
+ row.mag = r(mag);
+ row.moonSepDeg = r(moonSep, 0);
+ row.score = r(score, 0);
+ rows.push(row);
+ }
+ return rows.sort((a, b) => b.score - a.score).slice(0, limit);
+ }
+
+ /** Everything above (roughly) the horizon, for the sky-map / live overlay. */
+ objects() {
+ const date = this.#t.now();
+ const out = [];
+ for (const c of this.#candidates()) {
+ try {
+ const p = this.#c.predict(c.spec, { date });
+ if (p.alt > -5) out.push({ id: c.id, spec: c.spec, label: c.label, type: c.type, mag: c.mag ?? null, az: p.az, alt: p.alt });
+ } catch { /* skip */ }
+ }
+ return { time: date.toISOString(), objects: out };
+ }
+}