From 814a59fdf572ae1559c444d57772853f63591a10 Mon Sep 17 00:00:00 2001 From: Theodore Kruczek Date: Thu, 2 Jul 2026 06:22:59 -0400 Subject: [PATCH 001/131] feat(satellite): :sparkles: add SGP4-propagated OrbitalSatellite with Doppler and slant range --- src/equipment/antenna/antenna-core.ts | 6 +- src/equipment/satellite/orbital-satellite.ts | 187 ++++++++++++++++++ src/equipment/satellite/satellite.ts | 23 ++- src/simulation/sim-time.ts | 27 +++ .../satellite/orbital-satellite.test.ts | 173 ++++++++++++++++ 5 files changed, 409 insertions(+), 7 deletions(-) create mode 100644 src/equipment/satellite/orbital-satellite.ts create mode 100644 src/simulation/sim-time.ts create mode 100644 test/equipment/satellite/orbital-satellite.test.ts diff --git a/src/equipment/antenna/antenna-core.ts b/src/equipment/antenna/antenna-core.ts index 61ea7843..e2377b3d 100644 --- a/src/equipment/antenna/antenna-core.ts +++ b/src/equipment/antenna/antenna-core.ts @@ -1882,8 +1882,10 @@ export abstract class AntennaCore extends BaseEquipment { const deltaEl = satellite.el - this.state.elevation; const offAxis_deg = Math.hypot(deltaAz, deltaEl); - // Calculate free-space path loss (downlink from GEO satellite to ground) - const fspl = this.calculateFreeSpacePathLoss_(signal.frequency, GEO_SATELLITE_DISTANCE_KM); + // Calculate free-space path loss (downlink from satellite to ground). + // Orbital satellites report true slant range; legacy fixed-telemetry + // satellites fall back to the nominal GEO slant range. + const fspl = this.calculateFreeSpacePathLoss_(signal.frequency, satellite.rangeKm ?? GEO_SATELLITE_DISTANCE_KM); // Calculate atmospheric loss using realistic model const atmosphericLoss = this.calculateAtmosphericLoss_(signal.frequency, elev_deg); diff --git a/src/equipment/satellite/orbital-satellite.ts b/src/equipment/satellite/orbital-satellite.ts new file mode 100644 index 00000000..8f5b9655 --- /dev/null +++ b/src/equipment/satellite/orbital-satellite.ts @@ -0,0 +1,187 @@ +/** + * @file OrbitalSatellite - SGP4-propagated satellite (Campaign 2+) + * @description Extends the legacy fixed-telemetry Satellite with real orbital + * mechanics from ootk. Position (ECI + ground-station-relative az/el/range) is + * propagated from a TLE against the simulated scenario clock, so LEO passes, + * slant-range path loss, and Doppler shift are physically realistic. + * + * Campaign 1 scenarios never instantiate this class, so all legacy behavior + * (fixed GEO az/el, figure-8 geosync, constant GEO slant range) is preserved. + */ + +import { getSimulatedNowMs } from '@app/simulation/sim-time'; +import { Hertz, RfFrequency, RfSignal } from '@app/types'; +import { + Degrees, + EciVec3, + GroundObject, + Kilometers, + KilometersPerSecond, + LlaVec3, + Satellite as OotkSatellite, + TleLine1, + TleLine2, + Vec3, +} from 'ootk'; +import { Satellite, SatelliteState } from './satellite'; + +/** Geodetic location of the ground station observing this satellite. */ +export interface OrbitalObserver { + name?: string; + lat: Degrees; + lon: Degrees; + /** Altitude above the WGS-84 ellipsoid in km */ + alt: Kilometers; +} + +/** Configuration for an SGP4-propagated satellite. */ +export interface OrbitalSatelliteConfig { + /** TLE line 1 */ + tle1: TleLine1; + /** TLE line 2 */ + tle2: TleLine2; + /** Ground station the relative az/el/range telemetry is computed against */ + observer: OrbitalObserver; + /** + * Apply Doppler shift to downlink signal frequencies based on range rate. + * Default: true. Uplink Doppler is not modeled. + */ + isDopplerEnabled?: boolean; + /** Elevation below which the satellite's signals are not receivable. Default: 0 deg */ + minElevation?: Degrees; +} + +/** + * A satellite whose position comes from real SGP4 propagation of a TLE. + * + * On every throttled position update the satellite: + * - propagates to the current simulated time (scenario clock), + * - refreshes `az`/`el` (all existing consumers keep working unchanged), + * - refreshes `rangeKm` so the antenna computes true slant-range FSPL, + * - caches ECI/LLA state for dashboards and mission planning, + * - computes the Doppler factor applied to downlink signals. + * + * Below `minElevation` the satellite transmits nothing (LOS behavior). + */ +export class OrbitalSatellite extends Satellite { + private readonly ootkSat_: OotkSatellite; + private readonly observer_: GroundObject; + private readonly isDopplerEnabled_: boolean; + private readonly minElevation_: Degrees; + + /** Current ECI position (km), null until first successful propagation */ + eciPosition: EciVec3 | null = null; + /** Current ECI velocity (km/s), null until first successful propagation */ + eciVelocity: Vec3 | null = null; + /** Current geodetic position, null until first successful propagation */ + lla: LlaVec3 | null = null; + /** Current Doppler factor (observed = transmitted * factor), 1 when unavailable */ + dopplerFactor: number = 1; + + constructor( + name: string, + norad: number, + rxSignal: RfSignal[], + beaconSignal: RfSignal[], + orbitalConfig: OrbitalSatelliteConfig, + satelliteState: Partial = {}, + ) { + super(name, norad, rxSignal, beaconSignal, { + az: 0 as Degrees, + el: 0 as Degrees, + frequencyOffset: 2.225e9 as Hertz, + ...satelliteState, + orbitType: 'leo', + }); + + this.ootkSat_ = new OotkSatellite({ + name, + tle1: orbitalConfig.tle1, + tle2: orbitalConfig.tle2, + }); + this.observer_ = new GroundObject({ + name: orbitalConfig.observer.name ?? 'Ground Station', + lat: orbitalConfig.observer.lat, + lon: orbitalConfig.observer.lon, + alt: orbitalConfig.observer.alt, + }); + this.isDopplerEnabled_ = orbitalConfig.isDopplerEnabled ?? true; + this.minElevation_ = orbitalConfig.minElevation ?? (0 as Degrees); + + // Seed telemetry at the TLE epoch so az/el/range are sane before the + // scenario clock starts driving updates. + this.propagateTo_(this.ootkSat_.toTle().epoch.toDateTime().getTime()); + } + + /** The underlying ootk satellite (for pass planning and dashboards). */ + get ootkSatellite(): OotkSatellite { + return this.ootkSat_; + } + + /** The ground station observer used for relative telemetry. */ + get groundObserver(): GroundObject { + return this.observer_; + } + + /** True when the satellite is above the minimum receivable elevation. */ + get isAboveHorizon(): boolean { + return this.el > this.minElevation_; + } + + /** + * Propagate to the current simulated time (throttled to the base class + * update interval). Replaces the legacy fixed/figure-8 position models. + */ + protected updatePosition_(): void { + const nowMs = getSimulatedNowMs(); + // abs() so restoring a checkpoint (time jumping backwards) still updates + if (Math.abs(nowMs - this.lastPositionUpdateTime_) < Satellite.POSITION_UPDATE_INTERVAL_MS) { + return; + } + this.lastPositionUpdateTime_ = nowMs; + this.propagateTo_(nowMs); + } + + /** + * Update signals like the base class, then apply orbital effects: + * suppress all transmissions below the horizon and Doppler-shift downlinks. + */ + update(): void { + super.update(); + + if (!this.isAboveHorizon) { + this.txSignal = []; + return; + } + + if (this.isDopplerEnabled_ && this.dopplerFactor !== 1) { + this.txSignal = this.txSignal.map((sig) => ({ + ...sig, + frequency: (sig.frequency * this.dopplerFactor) as RfFrequency, + })); + } + } + + private propagateTo_(timeMs: number): void { + const date = new Date(timeMs); + const rae = this.ootkSat_.rae(this.observer_, date); + + if (!rae) { + // Propagation failed (e.g., time far outside TLE validity) - keep last state + return; + } + + this.az = ((rae.az % 360) + 360) % 360 as Degrees; + this.el = rae.el; + this.rangeKm = rae.rng; + + const posVel = this.ootkSat_.eci(date); + this.eciPosition = posVel?.position ?? null; + this.eciVelocity = posVel?.velocity ?? null; + this.lla = this.ootkSat_.lla(date); + + if (this.isDopplerEnabled_) { + this.dopplerFactor = this.ootkSat_.dopplerFactor(this.observer_, date) ?? 1; + } + } +} diff --git a/src/equipment/satellite/satellite.ts b/src/equipment/satellite/satellite.ts index 3f6fbc80..e9c1f011 100644 --- a/src/equipment/satellite/satellite.ts +++ b/src/equipment/satellite/satellite.ts @@ -89,8 +89,13 @@ export interface SignalDegradationConfig { interferencePower: dBm; } -/** Satellite orbit type determining position behavior */ -export type OrbitType = 'geostationary' | 'geosynchronous'; +/** + * Satellite orbit type determining position behavior. + * - 'geostationary': fixed az/el (default) + * - 'geosynchronous': parametric figure-8 (analemma) pattern + * - 'leo': real SGP4-propagated orbit; only valid on OrbitalSatellite subclass + */ +export type OrbitType = 'geostationary' | 'geosynchronous' | 'leo'; /** * Configuration for geosynchronous (inclined) orbit figure-8 pattern. @@ -190,10 +195,18 @@ export class Satellite { private phase_: number = 0; /** Timestamp of last position update (ms) - for throttling */ - private lastPositionUpdateTime_: number = 0; + protected lastPositionUpdateTime_: number = 0; /** Position update interval (ms) */ - private static readonly POSITION_UPDATE_INTERVAL_MS = 1000; + protected static readonly POSITION_UPDATE_INTERVAL_MS = 1000; + + /** + * Slant range from the ground station to the satellite (km). + * Null for legacy fixed-telemetry satellites, in which case the antenna + * falls back to the nominal GEO slant range for path-loss calculations. + * Populated each position update by OrbitalSatellite. + */ + rangeKm: number | null = null; /** Rate of position change: 0.1 degrees per 30 seconds (peak velocity) */ private static readonly POSITION_RATE_DEG_PER_MS = 0.1 / 30000; @@ -346,7 +359,7 @@ export class Satellite { * Traces a figure-8 (analemma) pattern using parametric equations. * Throttled to 1 second intervals to reduce computation. */ - private updatePosition_(): void { + protected updatePosition_(): void { if (this.orbitType !== 'geosynchronous' || !this.geosyncConfig_) { return; } diff --git a/src/simulation/sim-time.ts b/src/simulation/sim-time.ts new file mode 100644 index 00000000..441e0e42 --- /dev/null +++ b/src/simulation/sim-time.ts @@ -0,0 +1,27 @@ +/** + * @file sim-time - Simulated clock accessors + * @description Provides the current simulated time for physics that must follow + * the scenario clock (e.g., SGP4 orbit propagation). Falls back to wall-clock + * time when no scenario clock is running (unit tests, menus). + */ + +import { OpsLogManager } from '@app/ops-log/ops-log-manager'; + +/** + * Get the current simulated time as a Unix timestamp in milliseconds. + * Uses the scenario clock (OpsLogManager) when initialized, otherwise Date.now(). + */ +export function getSimulatedNowMs(): number { + if (OpsLogManager.isInitialized()) { + return OpsLogManager.getInstance().getCurrentTimestampMs(); + } + + return Date.now(); +} + +/** + * Get the current simulated time as a Date. + */ +export function getSimulatedNow(): Date { + return new Date(getSimulatedNowMs()); +} diff --git a/test/equipment/satellite/orbital-satellite.test.ts b/test/equipment/satellite/orbital-satellite.test.ts new file mode 100644 index 00000000..5456c19d --- /dev/null +++ b/test/equipment/satellite/orbital-satellite.test.ts @@ -0,0 +1,173 @@ +import { OrbitalSatellite } from '@app/equipment/satellite/orbital-satellite'; +import { Satellite } from '@app/equipment/satellite/satellite'; +import { SignalOrigin } from '@app/signal-origin'; +import type { dBi, dBm, FECType, Hertz, ModulationType, RfFrequency } from '@app/types'; +import type { Degrees, Kilometers, TleLine1, TleLine2 } from 'ootk'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +/** Scenario clock start used to author the test TLE: 2027-03-15 14:00:00 UTC */ +const SCENARIO_START_MS = Date.UTC(2027, 2, 15, 14, 0, 0); + +/** + * Authored TLE: sun-synchronous LEO bird making a near-zenith pass over the + * Galway observer: AOS T+2 min, max el 88.3 deg at T+8 min, LOS T+14.5 min. + */ +const TLE1 = '1 61701U 27015A 27074.58333333 .00001000 00000-0 10000-3 0 9996' as TleLine1; +const TLE2 = '2 61701 97.6000 26.0000 0010000 90.0000 294.0000 14.90000000123451' as TleLine2; + +let simNowMs = SCENARIO_START_MS; + +vi.mock('@app/simulation/sim-time', () => ({ + getSimulatedNowMs: () => simNowMs, + getSimulatedNow: () => new Date(simNowMs), +})); + +const KU_BEACON_FREQUENCY = 11699e6 as RfFrequency; + +function createOrbitalSatellite(): OrbitalSatellite { + return new OrbitalSatellite( + 'TEST-LEO-1', + 61701, + [], + [], + { + tle1: TLE1, + tle2: TLE2, + observer: { lat: 53.27 as Degrees, lon: -9.05 as Degrees, alt: 0.02 as Kilometers }, + }, + { + ephemerisErrorAz: 0.1 as Degrees, + ephemerisErrorEl: 0.05 as Degrees, + // Disable random effects for deterministic assertions + degradationConfig: { + atmosphericEffects: false, + randomDropout: false, + powerVariation: false, + interference: false, + }, + transponderConfigs: [ + { + id: 'TP-1', + uplinkCenterFrequency: 14005e6 as RfFrequency, + bandwidth: 36e6 as Hertz, + frequencyOffset: 2.255e9 as Hertz, + polarization: 'H', + beacon: { + frequency: KU_BEACON_FREQUENCY, + signalId: 'TEST-LEO-1-Beacon', + serverId: 1, + noradId: 61701, + power: 0 as dBm, + bandwidth: 1e3 as Hertz, + modulation: 'CW' as ModulationType, + fec: 'null' as FECType, + polarization: 'V', + feed: '', + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + }, + }, + ], + }, + ); +} + +describe('OrbitalSatellite', () => { + beforeEach(() => { + simNowMs = SCENARIO_START_MS; + }); + + it('propagates az/el/range from the TLE at the simulated time', () => { + const sat = createOrbitalSatellite(); + + // Max elevation point of the authored pass + simNowMs = SCENARIO_START_MS + 8 * 60 * 1000; + sat.update(); + + expect(sat.el).toBeGreaterThan(80); + expect(sat.rangeKm).not.toBeNull(); + expect(sat.rangeKm!).toBeGreaterThan(500); + expect(sat.rangeKm!).toBeLessThan(800); + expect(sat.az).toBeGreaterThanOrEqual(0); + expect(sat.az).toBeLessThan(360); + expect(sat.orbitType).toBe('leo'); + }); + + it('moves between position updates as simulated time advances', () => { + const sat = createOrbitalSatellite(); + + simNowMs = SCENARIO_START_MS + 4 * 60 * 1000; + sat.update(); + const el1 = sat.el as number; + + simNowMs = SCENARIO_START_MS + 6 * 60 * 1000; + sat.update(); + const el2 = sat.el as number; + + // Rising leg of the pass: elevation increases significantly + expect(el2).toBeGreaterThan(el1 + 5); + }); + + it('transmits nothing while below the horizon', () => { + const sat = createOrbitalSatellite(); + + // T+60 min: between passes, satellite well below horizon + simNowMs = SCENARIO_START_MS + 60 * 60 * 1000; + sat.update(); + + expect(sat.isAboveHorizon).toBe(false); + expect(sat.el).toBeLessThan(0); + expect(sat.txSignal).toHaveLength(0); + expect(sat.getTransmittedSignals()).toHaveLength(0); + }); + + it('applies Doppler shift to downlink signals (higher frequency while approaching)', () => { + const sat = createOrbitalSatellite(); + + // T+4 min: rising leg, satellite approaching the station + simNowMs = SCENARIO_START_MS + 4 * 60 * 1000; + sat.update(); + + expect(sat.isAboveHorizon).toBe(true); + const beacon = sat.txSignal.find((sig) => sig.signalId === 'TEST-LEO-1-Beacon'); + expect(beacon).toBeDefined(); + + const shiftHz = (beacon!.frequency as number) - (KU_BEACON_FREQUENCY as number); + // Approaching at ~7 km/s: expect a positive shift in the hundreds of kHz + expect(shiftHz).toBeGreaterThan(50e3); + expect(shiftHz).toBeLessThan(400e3); + }); + + it('applies ephemeris error to predicted position', () => { + const sat = createOrbitalSatellite(); + + simNowMs = SCENARIO_START_MS + 8 * 60 * 1000; + sat.update(); + + expect(sat.predictedAz - sat.az).toBeCloseTo(0.1, 5); + expect(sat.predictedEl - sat.el).toBeCloseTo(0.05, 5); + }); + + it('does not affect legacy fixed-telemetry satellites', () => { + const geoSat = new Satellite('GEO-TEST', 99999, [], [], { + az: 161.8 as Degrees, + el: 34.2 as Degrees, + frequencyOffset: 2.225e9 as Hertz, + degradationConfig: { + atmosphericEffects: false, + randomDropout: false, + powerVariation: false, + interference: false, + }, + }); + + geoSat.update(); + + expect(geoSat.az).toBe(161.8); + expect(geoSat.el).toBe(34.2); + expect(geoSat.rangeKm).toBeNull(); + expect(geoSat.orbitType).toBe('geostationary'); + }); +}); From adb484ac0f2ecd2844f022042115b804773e4db8 Mon Sep 17 00:00:00 2001 From: Theodore Kruczek Date: Thu, 2 Jul 2026 06:23:13 -0400 Subject: [PATCH 002/131] feat(antenna): :sparkles: add Ku-band LEO tracker config and opt-in antennaConfigKey --- .../ground-station/ground-station-state.ts | 7 ++++ src/equipment/antenna/antenna-config-keys.ts | 1 + src/equipment/antenna/antenna-configs.ts | 33 +++++++++++++++++++ src/pages/mission-control/ground-station.ts | 10 ++++-- 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/assets/ground-station/ground-station-state.ts b/src/assets/ground-station/ground-station-state.ts index b03a9e39..a89391d0 100644 --- a/src/assets/ground-station/ground-station-state.ts +++ b/src/assets/ground-station/ground-station-state.ts @@ -1,4 +1,5 @@ import type { AntennaState } from "@app/equipment/antenna"; +import type { ANTENNA_CONFIG_KEYS } from "@app/equipment/antenna/antenna-config-keys"; import type { RealTimeSpectrumAnalyzerState } from "@app/equipment/real-time-spectrum-analyzer/real-time-spectrum-analyzer"; import type { ReceiverState } from "@app/equipment/receiver/receiver"; import type { RFFrontEndState } from "@app/equipment/rf-front-end/rf-front-end-core"; @@ -45,6 +46,12 @@ export interface GroundStationConfig { isOperational?: boolean; location: GroundStationLocation; antennas: string[]; // Antenna config IDs + /** + * Antenna hardware config actually instantiated by mission control. + * Opt-in: when omitted the factory default is used, preserving legacy + * campaign behavior (the `antennas` array above is not applied there). + */ + antennaConfigKey?: ANTENNA_CONFIG_KEYS; antennasState?: Partial[]; // Initial antenna states (parallel to antennas array) rfFrontEnds: Partial[]; // RF front-end configs spectrumAnalyzers?: Partial[]; // Spectrum analyzer configs (optional) diff --git a/src/equipment/antenna/antenna-config-keys.ts b/src/equipment/antenna/antenna-config-keys.ts index 400a89ab..b9a970f8 100644 --- a/src/equipment/antenna/antenna-config-keys.ts +++ b/src/equipment/antenna/antenna-config-keys.ts @@ -5,6 +5,7 @@ export enum ANTENNA_CONFIG_KEYS { X_BAND_3M_ANTESTAR_RS = "X_BAND_3M_ANTESTAR_RS", C_BAND_3M_ANTESTAR = "C_BAND_3M_ANTESTAR", KU_BAND_3M_ANTESTAR = "KU_BAND_3M_ANTESTAR", + KU_BAND_4M_LEO_TRACKER = "KU_BAND_4M_LEO_TRACKER", C_BAND_2M4_ANTESTAR = "C_BAND_2M4_ANTESTAR", KU_BAND_2M4_ANTESTAR = "KU_BAND_2M4_ANTESTAR", KU_BAND_1M8_OFFSET = "KU_BAND_1M8_OFFSET", diff --git a/src/equipment/antenna/antenna-configs.ts b/src/equipment/antenna/antenna-configs.ts index 8de18e36..e7868dd8 100644 --- a/src/equipment/antenna/antenna-configs.ts +++ b/src/equipment/antenna/antenna-configs.ts @@ -210,6 +210,39 @@ export const ANTENNA_CONFIGS: Record = { rxPhysTemp_K: 290, }, + // ───────────────────────────────── Ku-Band LEO (4 m) ───────────────────────────── + // Fast-pedestal Ku tracking antenna for LEO downlink stations (NATS Europe). + // Modeled on X-band remote-sensing pedestals but with Ku feed: full-hemisphere + // coverage, continuous azimuth, and slew rates sized for high-elevation passes. + KU_BAND_4M_LEO_TRACKER: { + name: 'Leonis 4.0m Ku-Band LEO Tracker', + diameter: 4.0, + efficiency: 0.63, + band: 'Ku', + minRxFrequency: 10.7e9 as Hertz, + maxRxFrequency: 12.75e9 as Hertz, + minTxFrequency: 13.75e9 as Hertz, + maxTxFrequency: 14.5e9 as Hertz, + feedLoss: 0.3, + + surfaceRms_m: 0.0004, + blockageFraction: 0.06, + xpd_dB: 32, + polType: 'linear', + feedLossModel: { a: 0.25, b: 0.1, c: 0.01 }, + + kBeamConst: 70, + patternModel: 'ITU465', + pointingSigma_deg: 0.05, // servo position rms during rate tracking + elRange_deg: [0, 90], + azContinuous: true, + maxRate_deg_s: 5.0, // fast pedestal for near-zenith LEO passes + + lnaNF_dB: 1.0, + rxChainLoss_dB: 0.6, + rxPhysTemp_K: 290, + }, + KU_BAND_3M_ANTESTAR: { name: 'Antestar 3.0m Ku-Band VSAT', diameter: 3.0, diff --git a/src/pages/mission-control/ground-station.ts b/src/pages/mission-control/ground-station.ts index 289fe3be..b1b90ed1 100644 --- a/src/pages/mission-control/ground-station.ts +++ b/src/pages/mission-control/ground-station.ts @@ -36,7 +36,7 @@ export class GroundStation { equipment: {} }; - this.createEquipment_(); + this.createEquipment_(config); this.wireEquipment_(); this.registerWithEventBus_(); } @@ -47,8 +47,12 @@ export class GroundStation { `; - private createEquipment_(): void { - const modernAntenna = createAntenna(this.id, 'modern') as AntennaUIModern; + private createEquipment_(config: GroundStationConfig): void { + // antennaConfigKey is opt-in (Campaign 2+); omitting it keeps the factory + // default so legacy campaigns are unaffected + const modernAntenna = (config.antennaConfigKey + ? createAntenna(this.id, 'modern', config.antennaConfigKey) + : createAntenna(this.id, 'modern')) as AntennaUIModern; // Create one of each for now, mirroring `sandbox/equipment.ts` this.antennas.push(modernAntenna); this.rfFrontEnds.push(createRFFrontEnd(this.id)); From 847b7cbb125a19863b0c5fadb7439fe943f09e42 Mon Sep 17 00:00:00 2001 From: Theodore Kruczek Date: Thu, 2 Jul 2026 06:23:26 -0400 Subject: [PATCH 003/131] feat(mission-planning): :sparkles: add pass planner service and Pass Schedule tab --- src/pages/mission-control/tabbed-canvas.ts | 34 +++- .../tabs/pass-schedule-tab.css | 33 ++++ .../mission-control/tabs/pass-schedule-tab.ts | 180 ++++++++++++++++++ src/services/pass-planner-service.ts | 174 +++++++++++++++++ test/services/pass-planner-service.test.ts | 87 +++++++++ 5 files changed, 507 insertions(+), 1 deletion(-) create mode 100644 src/pages/mission-control/tabs/pass-schedule-tab.css create mode 100644 src/pages/mission-control/tabs/pass-schedule-tab.ts create mode 100644 src/services/pass-planner-service.ts create mode 100644 test/services/pass-planner-service.test.ts diff --git a/src/pages/mission-control/tabbed-canvas.ts b/src/pages/mission-control/tabbed-canvas.ts index 5442da41..1293fb9f 100644 --- a/src/pages/mission-control/tabbed-canvas.ts +++ b/src/pages/mission-control/tabbed-canvas.ts @@ -4,6 +4,7 @@ import dashboardPng from '@app/assets/icons/dashboard.png'; import gpsPng from '@app/assets/icons/gps.png'; import radarPng from '@app/assets/icons/radar.png'; import satellitePng from '@app/assets/icons/satellite.png'; +import stopwatchPng from '@app/assets/icons/stopwatch.png'; import { BaseElement } from "@app/components/base-element"; import { html } from "@app/engine/utils/development/formatter"; import { qs } from "@app/engine/utils/query-selector"; @@ -15,6 +16,7 @@ import { ACUControlTab } from '@app/pages/mission-control/tabs/acu-control-tab'; import { DashboardTab } from '@app/pages/mission-control/tabs/dashboard-tab'; import { GPSTimingTab } from '@app/pages/mission-control/tabs/gps-timing-tab'; import { MissionOverviewTab } from '@app/pages/mission-control/tabs/mission-overview-tab'; +import { PassScheduleTab } from '@app/pages/mission-control/tabs/pass-schedule-tab'; import { RxAnalysisTab } from '@app/pages/mission-control/tabs/rx-analysis-tab'; import { SatelliteDashboardTab } from '@app/pages/mission-control/tabs/satellite-dashboard-tab'; import { TxChainTab } from '@app/pages/mission-control/tabs/tx-chain-tab'; @@ -32,7 +34,7 @@ export class TabbedCanvas extends BaseElement { private activeTab_: string = 'mission-overview'; private selectedAssetId_: string | null = null; - private readonly tabInstances_: Map = new Map(); + private readonly tabInstances_: Map = new Map(); protected html_ = html`
@@ -168,6 +170,11 @@ export class TabbedCanvas extends BaseElement { { id: 'gps-timing', label: 'GPS Timing', icon: gpsPng, isDisabled: groundStation.state.isOperational === false }, ); + // Pass Schedule only exists for scenarios with orbital (SGP4) satellites + if (SimulationManager.getInstance().satellites.some((sat) => sat.orbitType === 'leo')) { + tabs.push({ id: 'pass-schedule', label: 'Pass Schedule', icon: stopwatchPng, isDisabled: groundStation.state.isOperational === false }); + } + this.renderTabs_(tabs); this.switchTab_('dashboard'); } @@ -310,6 +317,10 @@ export class TabbedCanvas extends BaseElement { this.renderGPSTimingTab_(content); break; + case 'pass-schedule': + this.renderPassScheduleTab_(); + break; + default: content.innerHTML = html`
@@ -517,6 +528,27 @@ export class TabbedCanvas extends BaseElement { gpsTab.activate(); } + /** + * Render Pass Schedule tab (Campaign 2+ orbital satellites) + */ + private renderPassScheduleTab_(): void { + const tabKey = 'pass-schedule'; + let passTab = this.tabInstances_.get(tabKey) as PassScheduleTab; + + if (passTab && !document.contains(passTab.dom)) { + passTab.dispose(); + this.tabInstances_.delete(tabKey); + passTab = null!; + } + + if (!passTab) { + passTab = new PassScheduleTab('canvas-content'); + this.tabInstances_.set(tabKey, passTab); + } + + passTab.activate(); + } + /** * Cleanup */ diff --git a/src/pages/mission-control/tabs/pass-schedule-tab.css b/src/pages/mission-control/tabs/pass-schedule-tab.css new file mode 100644 index 00000000..742d7edf --- /dev/null +++ b/src/pages/mission-control/tabs/pass-schedule-tab.css @@ -0,0 +1,33 @@ +/** + * Pass Schedule Tab - multi-contact mission planning (Campaign 2+) + */ + +.pass-schedule-tab { + padding: 1rem; +} + +.pass-schedule-title { + color: var(--mc-text-primary); + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 0; +} + +.pass-schedule-table th { + color: var(--mc-text-tertiary); + font-size: 0.7rem; + letter-spacing: 0.05em; +} + +.pass-row-active { + background-color: var(--mc-surface-2); +} + +.pass-status-active { + color: var(--color-success); + font-weight: 600; +} + +.pass-status-upcoming { + color: var(--mc-text-secondary); +} diff --git a/src/pages/mission-control/tabs/pass-schedule-tab.ts b/src/pages/mission-control/tabs/pass-schedule-tab.ts new file mode 100644 index 00000000..37b7bd33 --- /dev/null +++ b/src/pages/mission-control/tabs/pass-schedule-tab.ts @@ -0,0 +1,180 @@ +import { BaseElement } from "@app/components/base-element"; +import { html } from "@app/engine/utils/development/formatter"; +import { qs } from "@app/engine/utils/query-selector"; +import { OrbitalSatellite } from "@app/equipment/satellite/orbital-satellite"; +import { EventBus } from "@app/events/event-bus"; +import { Events, SimulatedTimeTickData } from "@app/events/events"; +import { PassPlannerService, SatellitePass } from "@app/services/pass-planner-service"; +import { getSimulatedNowMs } from "@app/simulation/sim-time"; +import { SimulationManager } from "@app/simulation/simulation-manager"; +import './pass-schedule-tab.css'; + +/** + * PassScheduleTab - Multi-contact mission planning (Campaign 2+) + * + * Displays predicted contact windows (AOS/LOS/max elevation) for all + * SGP4-propagated satellites in the scenario, with a live countdown to the + * next contact. Only registered when the scenario contains orbital + * satellites, so legacy campaigns never see it. + */ +export class PassScheduleTab extends BaseElement { + /** Recompute the schedule at most this often (simulated ms) */ + private static readonly RECOMPUTE_INTERVAL_MS = 60_000; + + private readonly satellites_: OrbitalSatellite[]; + private readonly passPlanner_ = new PassPlannerService(); + private readonly boundTimeTickHandler_: (data: SimulatedTimeTickData) => void; + + private passes_: SatellitePass[] = []; + private lastComputeMs_: number = 0; + + constructor(containerId: string) { + super(); + this.satellites_ = SimulationManager.getInstance().satellites + .filter((sat): sat is OrbitalSatellite => sat instanceof OrbitalSatellite); + this.init_(containerId, 'replace'); + this.dom_ = qs('.pass-schedule-tab'); + + this.boundTimeTickHandler_ = this.handleTimeTick_.bind(this); + EventBus.getInstance().on(Events.SIMULATED_TIME_TICK, this.boundTimeTickHandler_); + + this.refreshSchedule_(getSimulatedNowMs()); + this.renderRows_(getSimulatedNowMs()); + } + + protected get html_(): string { + return html` +
+
+
+
+

Pass Schedule

+ Next 12 hours · all times UTC +
+
+
+
+
+ + + + + + + + + + + + + +
SATELLITEAOSLOSDURATIONMAX ELAOS AZSTATUS
+
+
+
+
+
+ `; + } + + protected addEventListeners_(): void { + // Listeners registered in constructor (need bound reference for dispose) + } + + private handleTimeTick_(data: SimulatedTimeTickData): void { + if (Math.abs(data.timestampMs - this.lastComputeMs_) >= PassScheduleTab.RECOMPUTE_INTERVAL_MS) { + this.refreshSchedule_(data.timestampMs); + } + this.renderRows_(data.timestampMs); + } + + private refreshSchedule_(nowMs: number): void { + this.lastComputeMs_ = nowMs; + // Include a pass that is already in progress by searching from 20 min ago + this.passes_ = this.passPlanner_.getContactSchedule(this.satellites_, nowMs - 20 * 60 * 1000) + .filter((pass) => pass.losMs > nowMs); + } + + private renderRows_(nowMs: number): void { + const tbody = this.dom_?.querySelector('#pass-schedule-rows'); + if (!tbody) { + return; + } + + if (this.satellites_.length === 0) { + tbody.innerHTML = html`No orbital satellites in this scenario.`; + return; + } + + const upcoming = this.passes_.filter((pass) => pass.losMs > nowMs); + + if (upcoming.length === 0) { + tbody.innerHTML = html`No contacts predicted in the planning horizon.`; + return; + } + + tbody.innerHTML = upcoming.map((pass) => this.renderRow_(pass, nowMs)).join(''); + } + + private renderRow_(pass: SatellitePass, nowMs: number): string { + const isActive = nowMs >= pass.aosMs && nowMs <= pass.losMs; + let status: string; + let statusClass: string; + + if (isActive) { + status = `IN CONTACT (LOS -${PassScheduleTab.formatCountdown_(pass.losMs - nowMs)})`; + statusClass = 'pass-status-active'; + } else { + status = `AOS -${PassScheduleTab.formatCountdown_(pass.aosMs - nowMs)}`; + statusClass = 'pass-status-upcoming'; + } + + return html` + + ${pass.satelliteName} + ${PassScheduleTab.formatTime_(pass.aosMs)} + ${PassScheduleTab.formatTime_(pass.losMs)} + ${PassScheduleTab.formatCountdown_(pass.durationS * 1000)} + ${pass.maxEl.toFixed(1)}° + ${pass.aosAz.toFixed(0)}° + ${status} + + `; + } + + private static formatTime_(timestampMs: number): string { + const d = new Date(timestampMs); + const hh = String(d.getUTCHours()).padStart(2, '0'); + const mm = String(d.getUTCMinutes()).padStart(2, '0'); + const ss = String(d.getUTCSeconds()).padStart(2, '0'); + return `${hh}:${mm}:${ss}`; + } + + private static formatCountdown_(deltaMs: number): string { + const totalS = Math.max(0, Math.floor(deltaMs / 1000)); + const h = Math.floor(totalS / 3600); + const m = Math.floor((totalS % 3600) / 60); + const s = totalS % 60; + if (h > 0) { + return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; + } + return `${m}:${String(s).padStart(2, '0')}`; + } + + public activate(): void { + if (this.dom_) { + this.dom_.style.display = 'block'; + } + } + + public deactivate(): void { + if (this.dom_) { + this.dom_.style.display = 'none'; + } + } + + public dispose(): void { + EventBus.getInstance().off(Events.SIMULATED_TIME_TICK, this.boundTimeTickHandler_); + this.dom_?.remove(); + } +} diff --git a/src/services/pass-planner-service.ts b/src/services/pass-planner-service.ts new file mode 100644 index 00000000..d30b0974 --- /dev/null +++ b/src/services/pass-planner-service.ts @@ -0,0 +1,174 @@ +/** + * @file PassPlannerService - LEO contact window prediction + * @description Computes upcoming passes (AOS/LOS/max-elevation) for + * SGP4-propagated satellites over their ground station observer. Used by the + * mission planning UI for multi-contact scheduling in Campaign 2+. + */ + +import { OrbitalSatellite } from '@app/equipment/satellite/orbital-satellite'; +import { Degrees, Kilometers } from 'ootk'; + +/** A single predicted contact window between a ground station and a satellite. */ +export interface SatellitePass { + /** NORAD catalog id of the satellite */ + noradId: number; + /** Satellite display name */ + satelliteName: string; + /** Acquisition of signal (satellite rises above min elevation), Unix ms */ + aosMs: number; + /** Loss of signal (satellite sets below min elevation), Unix ms */ + losMs: number; + /** Time of maximum elevation, Unix ms */ + maxElMs: number; + /** Maximum elevation reached during the pass */ + maxEl: Degrees; + /** Azimuth at AOS */ + aosAz: Degrees; + /** Azimuth at LOS */ + losAz: Degrees; + /** Slant range at maximum elevation */ + maxElRangeKm: Kilometers; + /** Pass duration in seconds */ + durationS: number; +} + +export interface PassPlannerOptions { + /** How far ahead to search, in hours. Default: 12 */ + horizonHours?: number; + /** Coarse sampling step in seconds. Default: 30 */ + stepS?: number; + /** Elevation defining AOS/LOS. Default: 0 deg */ + minElevation?: Degrees; + /** Maximum number of passes to return per satellite. Default: 10 */ + maxPasses?: number; +} + +/** Bisection refinement iterations for AOS/LOS edges (~30s / 2^6 < 1s accuracy) */ +const REFINE_ITERATIONS = 6; + +/** + * Predicts contact windows for orbital satellites. + * Stateless: every call samples the orbit fresh from the requested start time, + * so it works with the simulated scenario clock and after checkpoint restores. + */ +export class PassPlannerService { + /** + * Compute upcoming passes for one satellite starting at `startMs`. + * A pass already in progress at `startMs` is included (aosMs = startMs). + */ + getPasses(satellite: OrbitalSatellite, startMs: number, options: PassPlannerOptions = {}): SatellitePass[] { + const horizonHours = options.horizonHours ?? 12; + const stepS = options.stepS ?? 30; + const minEl = options.minElevation ?? (0 as Degrees); + const maxPasses = options.maxPasses ?? 10; + + const ootkSat = satellite.ootkSatellite; + const observer = satellite.groundObserver; + const endMs = startMs + horizonHours * 3600 * 1000; + + const elAt = (timeMs: number): number => { + const rae = ootkSat.rae(observer, new Date(timeMs)); + return rae ? rae.el : -90; + }; + + const passes: SatellitePass[] = []; + let prevMs = startMs; + let prevUp = elAt(startMs) > minEl; + let aosMs: number | null = prevUp ? startMs : null; + + for (let t = startMs + stepS * 1000; t <= endMs && passes.length < maxPasses; t += stepS * 1000) { + const up = elAt(t) > minEl; + + if (up && !prevUp) { + aosMs = this.refineCrossing_(elAt, prevMs, t, minEl, true); + } else if (!up && prevUp && aosMs !== null) { + const losMs = this.refineCrossing_(elAt, prevMs, t, minEl, false); + const pass = this.buildPass_(satellite, aosMs, losMs, stepS); + if (pass) { + passes.push(pass); + } + aosMs = null; + } + + prevUp = up; + prevMs = t; + } + + return passes; + } + + /** + * Compute upcoming passes for several satellites, merged and sorted by AOS. + */ + getContactSchedule(satellites: OrbitalSatellite[], startMs: number, options: PassPlannerOptions = {}): SatellitePass[] { + return satellites + .flatMap((sat) => this.getPasses(sat, startMs, options)) + .sort((a, b) => a.aosMs - b.aosMs); + } + + /** + * Bisect the horizon crossing between two sample times. + * @param rising true when refining an AOS (el crosses upward) + */ + private refineCrossing_( + elAt: (timeMs: number) => number, + belowMs: number, + aboveMs: number, + minEl: number, + rising: boolean, + ): number { + let lo = belowMs; + let hi = aboveMs; + + for (let i = 0; i < REFINE_ITERATIONS; i++) { + const mid = (lo + hi) / 2; + const isUp = elAt(mid) > minEl; + if (isUp === rising) { + hi = mid; + } else { + lo = mid; + } + } + + return Math.round((lo + hi) / 2); + } + + /** Sample within [aos, los] for max elevation and edge azimuths. */ + private buildPass_(satellite: OrbitalSatellite, aosMs: number, losMs: number, stepS: number): SatellitePass | null { + const ootkSat = satellite.ootkSatellite; + const observer = satellite.groundObserver; + + const aosRae = ootkSat.rae(observer, new Date(aosMs)); + const losRae = ootkSat.rae(observer, new Date(losMs)); + if (!aosRae || !losRae) { + return null; + } + + let maxEl = -90; + let maxElMs = aosMs; + let maxElRangeKm = aosRae.rng; + const fineStepMs = Math.max(1000, (stepS * 1000) / 6); + + for (let t = aosMs; t <= losMs; t += fineStepMs) { + const rae = ootkSat.rae(observer, new Date(t)); + if (rae && rae.el > maxEl) { + maxEl = rae.el; + maxElMs = t; + maxElRangeKm = rae.rng; + } + } + + return { + noradId: satellite.noradId, + satelliteName: satellite.name, + aosMs, + losMs, + maxElMs, + maxEl: maxEl as Degrees, + aosAz: aosRae.az, + losAz: losRae.az, + maxElRangeKm, + durationS: Math.round((losMs - aosMs) / 1000), + }; + } +} diff --git a/test/services/pass-planner-service.test.ts b/test/services/pass-planner-service.test.ts new file mode 100644 index 00000000..bf8feecb --- /dev/null +++ b/test/services/pass-planner-service.test.ts @@ -0,0 +1,87 @@ +import { OrbitalSatellite } from '@app/equipment/satellite/orbital-satellite'; +import { PassPlannerService } from '@app/services/pass-planner-service'; +import type { Degrees, Kilometers, TleLine1, TleLine2 } from 'ootk'; +import { describe, expect, it } from 'vitest'; + +/** Scenario clock start used to author the test TLEs: 2027-03-15 14:00:00 UTC */ +const SCENARIO_START_MS = Date.UTC(2027, 2, 15, 14, 0, 0); + +const OBSERVER = { lat: 53.27 as Degrees, lon: -9.05 as Degrees, alt: 0.02 as Kilometers }; + +/** AOS T+2.0 min, max el 88.3 deg at T+8, LOS T+14.5 min; next pass ~T+99.5 min */ +const SAT_A = new OrbitalSatellite('TEST-LEO-A', 61701, [], [], { + tle1: '1 61701U 27015A 27074.58333333 .00001000 00000-0 10000-3 0 9996' as TleLine1, + tle2: '2 61701 97.6000 26.0000 0010000 90.0000 294.0000 14.90000000123451' as TleLine2, + observer: OBSERVER, +}); + +/** AOS T+17.5 min, max el 83.9 deg at T+24, LOS T+31.5 min */ +const SAT_B = new OrbitalSatellite('TEST-LEO-B', 61702, [], [], { + tle1: '1 61702U 27015A 27074.58333333 .00001000 00000-0 10000-3 0 9997' as TleLine1, + tle2: '2 61702 98.1000 30.0000 0010000 90.0000 236.0000 14.60000000123456' as TleLine2, + observer: OBSERVER, +}); + +const MINUTE_MS = 60 * 1000; + +describe('PassPlannerService', () => { + const planner = new PassPlannerService(); + + it('finds the authored pass with accurate AOS/LOS/max elevation', () => { + const passes = planner.getPasses(SAT_A, SCENARIO_START_MS, { horizonHours: 1 }); + + expect(passes).toHaveLength(1); + const pass = passes[0]; + + // AOS ~T+2.0 min, LOS ~T+14.5 min (30s coarse sampling, bisected to <1s) + expect(pass.aosMs).toBeGreaterThan(SCENARIO_START_MS + 1 * MINUTE_MS); + expect(pass.aosMs).toBeLessThan(SCENARIO_START_MS + 3 * MINUTE_MS); + expect(pass.losMs).toBeGreaterThan(SCENARIO_START_MS + 13.5 * MINUTE_MS); + expect(pass.losMs).toBeLessThan(SCENARIO_START_MS + 15.5 * MINUTE_MS); + expect(pass.maxEl).toBeGreaterThan(80); + expect(pass.durationS).toBeGreaterThan(10 * 60); + expect(pass.durationS).toBeLessThan(16 * 60); + expect(pass.noradId).toBe(61701); + expect(pass.satelliteName).toBe('TEST-LEO-A'); + }); + + it('finds multiple passes over a longer horizon', () => { + const passes = planner.getPasses(SAT_A, SCENARIO_START_MS, { horizonHours: 12 }); + + expect(passes.length).toBeGreaterThanOrEqual(2); + // Second pass ~T+99.5 min + expect(passes[1].aosMs).toBeGreaterThan(SCENARIO_START_MS + 90 * MINUTE_MS); + expect(passes[1].aosMs).toBeLessThan(SCENARIO_START_MS + 110 * MINUTE_MS); + // Passes are chronological and non-overlapping + expect(passes[0].losMs).toBeLessThan(passes[1].aosMs); + }); + + it('includes a pass already in progress at the search start', () => { + // Start searching mid-pass (T+8 min) + const startMs = SCENARIO_START_MS + 8 * MINUTE_MS; + const passes = planner.getPasses(SAT_A, startMs, { horizonHours: 1 }); + + expect(passes).toHaveLength(1); + expect(passes[0].aosMs).toBe(startMs); + expect(passes[0].losMs).toBeGreaterThan(startMs); + }); + + it('merges multi-satellite schedules sorted by AOS', () => { + const schedule = planner.getContactSchedule([SAT_B, SAT_A], SCENARIO_START_MS, { horizonHours: 1 }); + + expect(schedule.length).toBeGreaterThanOrEqual(2); + expect(schedule[0].noradId).toBe(61701); // SAT_A rises first (T+2) + expect(schedule[1].noradId).toBe(61702); // SAT_B follows (T+17.5) + for (let i = 1; i < schedule.length; i++) { + expect(schedule[i].aosMs).toBeGreaterThanOrEqual(schedule[i - 1].aosMs); + } + }); + + it('returns no passes when the satellite never rises in the horizon window', () => { + // 40-60 min after start both authored passes are over and the next is >30 min away + const startMs = SCENARIO_START_MS + 40 * MINUTE_MS; + const passes = planner.getPasses(SAT_A, startMs, { horizonHours: 0.5 }); + + expect(passes).toHaveLength(0); + }); +}); From 072911c718138ef5736ee86e06f99f6611fdfbb0 Mon Sep 17 00:00:00 2001 From: Theodore Kruczek Date: Thu, 2 Jul 2026 06:23:36 -0400 Subject: [PATCH 004/131] feat(nats-eu): :sparkles: add NATS Europe campaign with Galway Ku station and LEO scenario --- src/campaigns/nats-eu/ground-stations.ts | 229 +++++++++++++++++++++++ src/campaigns/nats-eu/satellites.ts | 157 ++++++++++++++++ src/campaigns/nats-eu/scenario1.ts | 147 +++++++++++++++ src/campaigns/nats/campaign-data.ts | 6 +- src/scenario-manager.ts | 2 + 5 files changed, 538 insertions(+), 3 deletions(-) create mode 100644 src/campaigns/nats-eu/ground-stations.ts create mode 100644 src/campaigns/nats-eu/satellites.ts create mode 100644 src/campaigns/nats-eu/scenario1.ts diff --git a/src/campaigns/nats-eu/ground-stations.ts b/src/campaigns/nats-eu/ground-stations.ts new file mode 100644 index 00000000..9fb73789 --- /dev/null +++ b/src/campaigns/nats-eu/ground-stations.ts @@ -0,0 +1,229 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { FrequencyBand } from '@app/constants'; +import { type AntennaState } from '@app/equipment/antenna'; +import { ANTENNA_CONFIG_KEYS } from '@app/equipment/antenna/antenna-config-keys'; +import { type CouplerState } from '@app/equipment/rf-front-end/coupler-module/coupler-module'; +import { TapPoint } from '@app/equipment/rf-front-end/coupler-module/tap-points'; +import { SignalOrigin } from '@app/signal-origin'; +import type { dB, dBi, dBm, FECType, Hertz, IfFrequency, MHz, ModulationType } from '@app/types'; +import type { Degrees } from 'ootk'; + +/** + * NATS Europe - Galway Ground Station (GW-01) + * + * Ku-band LEO downlink station on the Irish west coast. Frequency plan: + * - LNB LO 13100 MHz (high-side): 11686 MHz video -> 1414 MHz IF, + * 11699 MHz beacon -> 1401 MHz IF (both inside the 950-2150 MHz L-band IF) + * - BUC LO 12600 MHz (low-side): 1405 MHz IF -> 14005 MHz Ku uplink + * - Fast 4m pedestal (KU_BAND_4M_LEO_TRACKER) sized for near-zenith passes + */ +export const galwayGroundStation = { + id: 'GW-01', + name: 'Galway Ground Station', + location: { + latitude: 53.27, + longitude: -9.05, + elevation: 20, + }, + antennas: [ANTENNA_CONFIG_KEYS.KU_BAND_4M_LEO_TRACKER], + antennaConfigKey: ANTENNA_CONFIG_KEYS.KU_BAND_4M_LEO_TRACKER, + antennasState: [ + { + // Powered and parked near the MERIDIAN-SAR-1 AOS azimuth; the operator + // selects the target and enables program-track for the pass + isPowered: true, + azimuth: 165 as Degrees, + elevation: 3 as Degrees, + polarization: 0 as Degrees, + trackingMode: 'manual', + targetAzimuth: 165 as Degrees, + targetElevation: 3 as Degrees, + targetPolarization: 0 as Degrees, + slewing: false, + beaconFrequencyHz: 11699e6 as Hertz, // MERIDIAN-SAR-1 telemetry beacon + beaconSearchBwHz: 1e6, // Wide enough for +/-273 kHz LEO Doppler + beaconTrackingBwHz: 1e3, + isLocked: false, + } as Partial, + ], + rfFrontEnds: [{ + omt: { + isPowered: true, + txPolarization: 'H', + rxPolarization: 'V', + effectiveTxPol: 'H', + effectiveRxPol: 'V', + crossPolIsolation: 28.5 as dB, + isFaulted: false, + insertionLoss: 0.5 as dB, + }, + buc: { + isPowered: true, + isMuted: false, + isLoopback: false, + temperature: 25, + currentDraw: 0, + loFrequency: 12600 as MHz, // Ku-band low-side LO + filterHighHz: FrequencyBand.ku.upHigh, + filterLowHz: FrequencyBand.ku.upLow, + filterRejectionDb: 40 as dB, + isExtRefLocked: true, + frequencyError: 0, + phaseLockRange: 10000, + gain: 23 as dB, + outputPower: -10 as dBm, + saturationPower: 15 as dBm, + gainFlatness: 0.5 as dB, + groupDelay: 3, + phaseNoise: -100, + spuriousOutputs: [], + noiseFloor: -140, + }, + hpa: { + isPowered: true, + backOff: 10, + outputPower: 50 as dBm, + isOverdriven: false, + imdLevel: -30, + temperature: 45, + isHpaEnabled: false, + isHpaSwitchEnabled: false, + noiseFloor: -140, + gain: 44 as dB, + }, + filter: { + isPowered: true, + bandwidthIndex: 13, + bandwidth: 40 as MHz, + insertionLoss: 2.0, + noiseFloor: -101, + }, + lnb: { + isPowered: true, + loFrequency: 13100 as MHz, // Ku-band high-side LO (IF = LO - RF) + gain: 65 as dB, + lnaNoiseFigure: 0.8, + mixerNoiseFigure: 16.0, + noiseTemperature: 60, // K - stable + noiseTemperatureStabilizationTime: 0, + isExtRefLocked: true, + noiseFloor: -140, + frequencyError: 0, + temperature: 28, + thermalStabilizationTime: 0, + }, + agc: { + isPowered: true, + isBypassed: false, + targetLevel: -30 as dBm, + currentGain: 10 as dB, + inputPower: -80 as dBm, + outputPower: -30 as dBm, + attackTime: 10, + releaseTime: 100, + maxGain: 10 as dB, + minGain: -100 as dB, + }, + coupler: { + isEngineeringMode: false, + tapPointA: TapPoint.TX_IF, + tapPointB: TapPoint.RX_IF, + availableTapPointsA: [TapPoint.TX_IF, TapPoint.RX_IF], + availableTapPointsB: [TapPoint.TX_IF, TapPoint.RX_IF], + couplingFactorA: -40, + couplingFactorB: -39, + isEnabledA: false, + isEnabledB: true, + isActiveA: false, + isActiveB: true, + } as CouplerState, + gpsdo: { + isPowered: true, + isLocked: true, + warmupTimeRemaining: 0, + temperature: 70, + gnssSignalPresent: true, + isGnssSwitchUp: true, + isGnssAcquiringLock: false, + satelliteCount: 9, + utcAccuracy: 50, + constellation: 'GPS', + lockDuration: 7200, + frequencyAccuracy: 2e-11, + allanDeviation: 1e-11, + phaseNoise: -110, + isInHoldover: false, + holdoverDuration: 0, + holdoverError: 0, + active10MHzOutputs: 3, + max10MHzOutputs: 5, + output10MHzLevel: 7, + ppsOutputsEnabled: true, + operatingHours: 4380, + selfTestPassed: true, + agingRate: 1e-10, + }, + }], + spectrumAnalyzers: [ + { + referenceLevel: -60 as dBm, + centerFrequency: 1414e6 as Hertz, // MERIDIAN-SAR-1 video downlink IF + span: 60e6 as Hertz, + rbw: 1e6 as Hertz, + minAmplitude: -110 as dBm, + maxAmplitude: -50 as dBm, + scaleDbPerDiv: 10 as dB, + screenMode: 'both', + inputUnit: 'MHz', + inputValue: '', + traces: [ + { isVisible: true, isUpdating: true, mode: 'clearwrite' }, + { isVisible: false, isUpdating: false, mode: 'clearwrite' }, + { isVisible: false, isUpdating: false, mode: 'clearwrite' }, + ], + selectedTrace: 1, + }, + ], + transmitters: [{ + activeModem: 1, + modems: [{ + isPowered: true, + antenna_id: 1, + modem_number: 1, + isFaulted: false, + isTransmitting: false, + isTransmittingSwitchUp: false, + isFaultSwitchUp: false, + id: 1, + isLoopback: false, + ifSignal: { + signalId: 'GW-01-MERIDIAN-CMD', + serverId: 1, + noradId: 61701, + polarization: 'V', + feed: '', + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + frequency: 1405e6 as IfFrequency, // -> 14005 MHz Ku telecommand uplink + power: -7 as dBm, + bandwidth: 36e6 as Hertz, + modulation: 'QPSK' as ModulationType, + fec: '3/4' as FECType, + }, + }], + }], + receivers: [{ + activeModem: 1, + modems: [{ + modemNumber: 1, + isPowered: true, + frequency: 1414 as MHz, // MERIDIAN-SAR-1 video downlink IF + bandwidth: 36 as MHz, + modulation: 'QPSK', + fec: '3/4', + antenna_id: 1, + }], + }], +} as GroundStationConfig; diff --git a/src/campaigns/nats-eu/satellites.ts b/src/campaigns/nats-eu/satellites.ts new file mode 100644 index 00000000..a4a96abf --- /dev/null +++ b/src/campaigns/nats-eu/satellites.ts @@ -0,0 +1,157 @@ +import { OrbitalObserver, OrbitalSatellite } from '@app/equipment/satellite/orbital-satellite'; +import { TransponderConfig } from '@app/equipment/satellite/satellite'; +import { SignalOrigin } from '@app/signal-origin'; +import type { FECType, Hertz, ModulationType, RfFrequency, dBi, dBm } from '@app/types'; +import type { Degrees, Kilometers, TleLine1, TleLine2 } from 'ootk'; + +/** + * NATS Europe satellite roster. + * + * Unlike the GEO TIDEMARK fleet, these are SGP4-propagated LEO satellites: + * az/el/range are computed from the TLE against the simulated scenario clock + * relative to the Galway ground station, with Doppler applied to downlinks. + * + * TLE epochs are authored for the 2027-03-15 14:00:00 UTC scenario start: + * - MERIDIAN-SAR-1: AOS T+2.0 min, max el 88.3 deg at T+8, LOS T+14.5 min + * - MERIDIAN-SAR-2: AOS T+17.5 min, max el 83.9 deg at T+24, LOS T+31.5 min + * Both make follow-up passes ~97 min later for multi-contact planning. + */ + +/** Galway Ground Station (GW-01) observer used for relative telemetry */ +export const galwayObserver: OrbitalObserver = { + name: 'Galway Ground Station', + lat: 53.27 as Degrees, + lon: -9.05 as Degrees, + alt: 0.02 as Kilometers, +}; + +export const meridianSar1Satellite = new OrbitalSatellite( + 'MERIDIAN-SAR-1', + 61701, + [], // No external uplink traffic - downlinks are transmitted directly + [], // Beacons defined in transponderConfigs + { + tle1: '1 61701U 27015A 27074.58333333 .00001000 00000-0 10000-3 0 9996' as TleLine1, + tle2: '2 61701 97.6000 26.0000 0010000 90.0000 294.0000 14.90000000123451' as TleLine2, + observer: galwayObserver, + }, + { + rotation: 0 as Degrees, + ephemerisErrorAz: 0.08 as Degrees, + ephemerisErrorEl: 0.05 as Degrees, + transponderConfigs: [ + { + id: 'TP-CMD', + uplinkCenterFrequency: 14005e6 as RfFrequency, // Ku telecommand uplink + bandwidth: 36e6 as Hertz, + frequencyOffset: 2.255e9 as Hertz, // Transponded downlink: 11750 MHz + polarization: 'H', + beacon: { + frequency: 11699e6 as RfFrequency, // Ku telemetry beacon (CW) + signalId: 'MERIDIAN-SAR-1-Beacon', + serverId: 1, + noradId: 61701, + power: 0 as dBm, + bandwidth: 1e3 as Hertz, + modulation: 'CW' as ModulationType, + fec: 'null' as FECType, + polarization: 'V', + feed: '', + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + }, + } as TransponderConfig, + { + id: 'TP-PAYLOAD', + uplinkCenterFrequency: 14100e6 as RfFrequency, // Unused payload uplink slot + bandwidth: 40e6 as Hertz, + frequencyOffset: 2.4e9 as Hertz, + polarization: 'H', + // Direct-transmit SAR imagery downlink (modeled as a transponder beacon) + beacon: { + frequency: 11686e6 as RfFrequency, + signalId: 'MERIDIAN-SAR-1-VIDEO', + serverId: 1, + noradId: 61701, + power: 22 as dBm, + bandwidth: 36e6 as Hertz, + modulation: 'QPSK' as ModulationType, + fec: '3/4' as FECType, + polarization: 'V', + feed: 'blue-1.mp4', + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + }, + } as TransponderConfig, + ], + }, +); + +export const meridianSar2Satellite = new OrbitalSatellite( + 'MERIDIAN-SAR-2', + 61702, + [], + [], + { + tle1: '1 61702U 27015A 27074.58333333 .00001000 00000-0 10000-3 0 9997' as TleLine1, + tle2: '2 61702 98.1000 30.0000 0010000 90.0000 236.0000 14.60000000123456' as TleLine2, + observer: galwayObserver, + }, + { + rotation: 0 as Degrees, + ephemerisErrorAz: 0.06 as Degrees, + ephemerisErrorEl: 0.07 as Degrees, + transponderConfigs: [ + { + id: 'TP-CMD', + uplinkCenterFrequency: 14035e6 as RfFrequency, + bandwidth: 36e6 as Hertz, + frequencyOffset: 2.255e9 as Hertz, // Transponded downlink: 11780 MHz + polarization: 'H', + beacon: { + frequency: 11703e6 as RfFrequency, + signalId: 'MERIDIAN-SAR-2-Beacon', + serverId: 1, + noradId: 61702, + power: 0 as dBm, + bandwidth: 1e3 as Hertz, + modulation: 'CW' as ModulationType, + fec: 'null' as FECType, + polarization: 'V', + feed: '', + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + }, + } as TransponderConfig, + { + id: 'TP-PAYLOAD', + uplinkCenterFrequency: 14130e6 as RfFrequency, + bandwidth: 40e6 as Hertz, + frequencyOffset: 2.4e9 as Hertz, + polarization: 'H', + beacon: { + frequency: 11730e6 as RfFrequency, + signalId: 'MERIDIAN-SAR-2-VIDEO', + serverId: 1, + noradId: 61702, + power: 22 as dBm, + bandwidth: 36e6 as Hertz, + modulation: 'QPSK' as ModulationType, + fec: '3/4' as FECType, + polarization: 'V', + feed: 'blue-2.mp4', + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + }, + } as TransponderConfig, + ], + }, +); diff --git a/src/campaigns/nats-eu/scenario1.ts b/src/campaigns/nats-eu/scenario1.ts new file mode 100644 index 00000000..2f5b085d --- /dev/null +++ b/src/campaigns/nats-eu/scenario1.ts @@ -0,0 +1,147 @@ +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm } from '@app/types'; +import { galwayGroundStation } from './ground-stations'; +import { meridianSar1Satellite, meridianSar2Satellite } from './satellites'; + +/** + * NATS Europe - Scenario 1: "First Light Over Galway" + * + * Charlie's first shift at the Galway LEO downlink station. Validates the + * Campaign 2 feature set end to end: + * - SGP4-propagated LEO satellites (real az/el/range from TLEs) + * - Pass Schedule tab (multi-contact mission planning) + * - Program-track of a fast-moving LEO pass on the Ku-band 4m tracker + * - Ku-band RF chain (13.1 GHz LNB LO, 12.6 GHz BUC LO) + * - Video feed decoding of the SAR imagery downlink during the pass + * + * Pass timeline (scenario clock starts 2027-03-15 14:00:00 UTC): + * - MERIDIAN-SAR-1: AOS T+2.0 min, max el 88 deg T+8, LOS T+14.5 min + * - MERIDIAN-SAR-2: AOS T+17.5 min, max el 84 deg T+24, LOS T+31.5 min + */ +export const natsEuScenario1Data: ScenarioData = { + id: 'nats-eu-scenario1', + url: 'nats-eu/scenarios/nats-eu-scenario1', + imageUrl: 'nats/8/card.png', + number: 1, + isDisabled: false, + difficulty: 'intermediate', + title: 'First Light Over Galway', + subtitle: 'LEO Pass Operations', + duration: '35 min', + missionType: 'LEO Downlink', + description: `Welcome to Galway, Ireland - NATS Europe's newest LEO downlink site. Charlie Brooks has transferred from Vermont to stand up GW-01, a Ku-band station tasked with capturing SAR imagery from the MERIDIAN constellation. +

Unlike the geostationary TIDEMARK fleet you trained on, MERIDIAN satellites scream overhead in minutes. Check the pass schedule, configure the tracker before AOS, ride the pass with program-track, and decode the imagery downlink before the bird drops below the horizon. A second contact follows shortly after - plan accordingly.`, + equipment: [ + '4-meter Ku-band LEO Tracking Antenna', + 'Ku-band RF Front End (13.1 GHz LNB / 12.6 GHz BUC)', + 'Pass Schedule Planner', + 'Spectrum Analyzer', + 'RX/TX Modems with Video Decoder', + ], + settings: { + isSync: true, + groundStations: [galwayGroundStation], + satellites: [meridianSar1Satellite, meridianSar2Satellite], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-03-15', + scenarioStartWallTime: '14:00:00', + }, + objectives: [ + { + id: 'review-pass-schedule', + title: 'Review the Contact Schedule', + description: 'Open the Pass Schedule tab and review the upcoming MERIDIAN contacts. Note the AOS time and azimuth for MERIDIAN-SAR-1.', + groundStation: 'GW-01', + conditions: [ + { + type: 'tab-active', + description: 'Pass Schedule Tab Open', + params: { tab: 'pass-schedule' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'track-meridian-1', + title: 'Track MERIDIAN-SAR-1', + description: 'Set the antenna to program-track so it follows MERIDIAN-SAR-1 through the pass. The 4m pedestal slews at up to 5 deg/s - it will need it near zenith.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-pass-schedule'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-1 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-1-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'decode-sar-video', + title: 'Decode the SAR Imagery Downlink', + description: 'With the receiver tuned to 1414 MHz (11686 MHz RF), lock the QPSK 3/4 downlink and confirm the imagery feed decodes on the monitor before LOS.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['track-meridian-1'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'RX Modem Locked on Downlink', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'second-contact', + title: 'Capture the Second Contact', + description: 'MERIDIAN-SAR-2 rises at T+17.5 min. Retarget the tracker, retune the receiver to 1370 MHz (11730 MHz RF), and decode its downlink.', + groundStation: 'GW-01', + isOptional: true, + prerequisiteObjectiveIds: ['decode-sar-video'], + conditions: [ + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-2 Downlink Detected', + params: { + signalId: 'MERIDIAN-SAR-2-VIDEO', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + { + type: 'receiver-signal-locked', + description: 'RX Modem Locked on SAR-2', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + ], +}; diff --git a/src/campaigns/nats/campaign-data.ts b/src/campaigns/nats/campaign-data.ts index db7927e4..c9c94da0 100644 --- a/src/campaigns/nats/campaign-data.ts +++ b/src/campaigns/nats/campaign-data.ts @@ -1,4 +1,5 @@ import type { CampaignData } from '@app/campaigns/campaign-types'; +import { natsEuScenario1Data } from '@app/campaigns/nats-eu/scenario1'; import { sandboxData } from './sandbox'; import { scenario1Data } from './scenario1'; import { scenario2Data } from './scenario2'; @@ -83,10 +84,9 @@ export const natsEuCampaignData: CampaignData = { totalDuration: '160-220 min', campaignType: 'LEO Commercial Communications', scenarios: [ - + natsEuScenario1Data, ], - isLocked: true, - lockedText: 'Under Development', + isLocked: false, isDisabled: false, }; diff --git a/src/scenario-manager.ts b/src/scenario-manager.ts index ddd80c4e..573bb638 100644 --- a/src/scenario-manager.ts +++ b/src/scenario-manager.ts @@ -25,6 +25,7 @@ import { scenario22Data } from '@app/campaigns/nats/scenario22'; import { scenario23Data } from '@app/campaigns/nats/scenario23'; import { scenario24Data } from '@app/campaigns/nats/scenario24'; import { sandboxData as natsSandboxData } from '@app/campaigns/nats/sandbox'; +import { natsEuScenario1Data } from '@app/campaigns/nats-eu/scenario1'; import { AntennaState } from '@app/equipment/antenna'; import { ANTENNA_CONFIG_KEYS } from "@app/equipment/antenna/antenna-config-keys"; import { defaultSpectrumAnalyzerState } from '@app/equipment/real-time-spectrum-analyzer/defaultSpectrumAnalyzerState'; @@ -214,6 +215,7 @@ export const SCENARIOS: ScenarioData[] = [ scenario22Data, scenario23Data, scenario24Data, + natsEuScenario1Data, ]; export function isScenarioLocked(scenario: ScenarioData, completedScenarioIds: string[]): boolean { From 5db8878acf12458313cc705e0734d986f763bb52 Mon Sep 17 00:00:00 2001 From: Theodore Kruczek Date: Thu, 2 Jul 2026 06:23:46 -0400 Subject: [PATCH 005/131] feat(theme): :sparkles: per-campaign body class with NATS Europe blue accent theme --- src/dev-menu/dev-menu.css | 2 +- .../analyzer-control.css | 2 +- src/index.css | 36 ++++---- src/modal/dialog-manager.css | 8 +- src/modal/hint-modal.css | 6 +- src/modal/modal-manager.css | 10 +-- src/objectives/objectives-manager.css | 2 +- src/pages/layout/header/header.css | 4 +- .../mission-control/mission-control-page.css | 12 +-- .../mission-control/tabs/gps-timing-tab.css | 7 +- .../mission-control/tabs/rx-analysis-tab.css | 12 +-- .../mission-control/tabs/rx-analysis-tab.ts | 2 +- src/pages/scenario-selection.css | 6 +- src/router.ts | 16 ++++ src/tabler-overrides.css | 84 +++++++++++++++---- 15 files changed, 144 insertions(+), 65 deletions(-) diff --git a/src/dev-menu/dev-menu.css b/src/dev-menu/dev-menu.css index 890f1f09..7ed7c0d2 100644 --- a/src/dev-menu/dev-menu.css +++ b/src/dev-menu/dev-menu.css @@ -36,7 +36,7 @@ .dev-menu__section .form-control:focus { background-color: var(--mc-surface-2); border-color: var(--mc-accent-red); - box-shadow: 0 0 0 0.2rem rgba(186, 22, 12, 0.25); + box-shadow: 0 0 0 0.2rem rgba(var(--mc-accent-red-rgb, 186, 22, 12), 0.25); } /* Hide number input spinners */ diff --git a/src/equipment/real-time-spectrum-analyzer/analyzer-control.css b/src/equipment/real-time-spectrum-analyzer/analyzer-control.css index dfdf0d28..6d6a8d42 100644 --- a/src/equipment/real-time-spectrum-analyzer/analyzer-control.css +++ b/src/equipment/real-time-spectrum-analyzer/analyzer-control.css @@ -238,7 +238,7 @@ /* Bottom Row: Power Button */ .power-button { - background: #ba160c !important; + background: var(--mc-accent-red, #ba160c) !important; color: #fff; font-size: 0.8em; width: 60px; diff --git a/src/index.css b/src/index.css index f49c2fca..f84fb1d3 100644 --- a/src/index.css +++ b/src/index.css @@ -7,29 +7,33 @@ body { } :root { - /* Legacy variables - kept for backward compatibility */ - --colorTertiary: #4a4a4a !important; - --color-dark-background: #1f1f1f !important; - --color-dark-border: #3a3a3a !important; - --color-dark-text-accent: #dd382f !important; - --color-dark-text-muted: #6b6b6b !important; + /* Legacy variables - kept for backward compatibility. + NOTE: this file loads AFTER tabler-overrides.css, so these declarations + win the cascade. They must reference the --mc-* theme variables (with the + historical literals as fallbacks) or per-campaign themes cannot re-skin + legacy consumers. */ + --colorTertiary: var(--mc-surface-3, #4a4a4a) !important; + --color-dark-background: var(--mc-surface-0, #1f1f1f) !important; + --color-dark-border: var(--mc-border, #3a3a3a) !important; + --color-dark-text-accent: var(--mc-accent-text-dark, #dd382f) !important; + --color-dark-text-muted: var(--mc-text-muted, #6b6b6b) !important; /* International Orange Engineering - Primary Colors */ - --color-primary: #ba160c !important; - --color-text-accent: #ff2827; - --color-primary-light: #e01a0e !important; - --color-primary-dark: #860f08 !important; + --color-primary: var(--mc-accent-red, #ba160c) !important; + --color-text-accent: var(--mc-accent-red-bright, #ff2827); + --color-primary-light: var(--mc-accent-red-light, #e01a0e) !important; + --color-primary-dark: var(--mc-accent-red-dark, #860f08) !important; /* Surface Colors - Dark Theme Layers */ - --color-surface-0: #1f1f1f; + --color-surface-0: var(--mc-surface-0, #1f1f1f); /* Darkest - main background */ - --color-surface-1: #292929; + --color-surface-1: var(--mc-surface-1, #292929); /* Panel background */ - --color-surface-2: #3a3a3a; + --color-surface-2: var(--mc-surface-2, #3a3a3a); /* Hover/active states */ - --color-surface-3: #4a4a4a; + --color-surface-3: var(--mc-surface-3, #4a4a4a); /* Scrollbar thumb */ - --color-surface-4: #5a5a5a; + --color-surface-4: var(--mc-surface-4, #5a5a5a); /* Scrollbar thumb hover */ /* Text Colors */ @@ -41,7 +45,7 @@ body { /* Muted text */ /* Border Colors */ - --color-border: #3a3a3a; + --color-border: var(--mc-border, #3a3a3a); /* Status Colors */ --color-success: #22c55e; diff --git a/src/modal/dialog-manager.css b/src/modal/dialog-manager.css index 51c853e1..75ad463b 100644 --- a/src/modal/dialog-manager.css +++ b/src/modal/dialog-manager.css @@ -85,7 +85,7 @@ } .dialog-character-title { - color: #ff3535; + color: var(--mc-accent-red-bright, #ff3535); font-size: 1rem; font-weight: 500; letter-spacing: 0.3px; @@ -113,12 +113,12 @@ } .dialog-text-container::-webkit-scrollbar-thumb { - background: #ff3535; + background: var(--mc-accent-red-bright, #ff3535); border-radius: 4px; } .dialog-text-container::-webkit-scrollbar-thumb:hover { - background: #ff2827; + background: var(--mc-accent-red-bright, #ff2827); } /* Dialog Text Content */ @@ -176,7 +176,7 @@ .dialog-skip-progress-fill { fill: none; - stroke: #ff3535; + stroke: var(--mc-accent-red-bright, #ff3535); stroke-width: 2; stroke-linecap: round; stroke-dasharray: 100.53; diff --git a/src/modal/hint-modal.css b/src/modal/hint-modal.css index ea80d2a3..fbcd2f48 100644 --- a/src/modal/hint-modal.css +++ b/src/modal/hint-modal.css @@ -90,12 +90,12 @@ } .hint-btn-confirm { - background-color: var(--mc-accent-primary, #ba160c); + background-color: var(--mc-accent-red, #ba160c); color: white; } .hint-btn-confirm:hover { - background-color: var(--mc-accent-hover, #cb1d14); + background-color: var(--mc-accent-red-light, #cb1d14); } /* Hint display styles */ @@ -114,7 +114,7 @@ background-color: var(--mc-bg-secondary, #343a40); padding: 1rem; border-radius: 4px; - border-left: 3px solid var(--mc-accent-primary, #ba160c); + border-left: 3px solid var(--mc-accent-red, #ba160c); text-align: left; margin: 0; } \ No newline at end of file diff --git a/src/modal/modal-manager.css b/src/modal/modal-manager.css index e30dd85a..5ef7b541 100644 --- a/src/modal/modal-manager.css +++ b/src/modal/modal-manager.css @@ -15,10 +15,10 @@ --hm-bg-end: #1f1f1f; --hm-text: #eaeaea; --hm-muted: #bfc3c6; - --hm-accent: #ff3535; - --hm-accent-2: #ff2827; + --hm-accent: var(--mc-accent-red-bright, #ff3535); + --hm-accent-2: var(--mc-accent-red-bright, #ff2827); /* subtle application theme color for accents */ - --hm-theme: #ba160c; + --hm-theme: var(--mc-accent-red, #ba160c); --hm-border: #444; --hm-radius: 10px; --hm-max-width: 1000px; @@ -207,13 +207,13 @@ color: var(--hm-text); font-size: 0.8rem; font-weight: 700; - border: 1px solid rgba(186, 22, 12, 0.14); + border: 1px solid rgba(var(--mc-accent-red-rgb, 186, 22, 12), 0.14); vertical-align: middle; } strong.red { background: var(--hm-theme); - border-color: rgba(255, 40, 39, 0.14); + border-color: rgba(var(--mc-accent-red-bright-rgb, 255, 40, 39), 0.14); } strong.green { diff --git a/src/objectives/objectives-manager.css b/src/objectives/objectives-manager.css index 80a8706f..02c0230c 100644 --- a/src/objectives/objectives-manager.css +++ b/src/objectives/objectives-manager.css @@ -168,7 +168,7 @@ /* Hint button for conditions with available hints */ .condition-hint-btn { - background: var(--mc-status-warning, #ba160c); + background: var(--mc-status-warning, var(--mc-accent-red, #ba160c)); border: none; border-radius: 50%; width: 22px; diff --git a/src/pages/layout/header/header.css b/src/pages/layout/header/header.css index 5343bfcf..68f6b818 100644 --- a/src/pages/layout/header/header.css +++ b/src/pages/layout/header/header.css @@ -71,7 +71,7 @@ .header-logo-section img:hover { transform: scale(1.08) rotate(-2deg); - box-shadow: 0 4px 16px rgba(186, 22, 12, 0.15); + box-shadow: 0 4px 16px rgba(var(--mc-accent-red-rgb, 186, 22, 12), 0.15); } .header-title-section { @@ -118,7 +118,7 @@ .header-icon-button:hover { /* subtle hover using a low-opacity primary color */ - background-color: rgba(186, 22, 12, 0.06); + background-color: rgba(var(--mc-accent-red-rgb, 186, 22, 12), 0.06); } .header-icon-button .icon { diff --git a/src/pages/mission-control/mission-control-page.css b/src/pages/mission-control/mission-control-page.css index 2819720c..28b8fabb 100644 --- a/src/pages/mission-control/mission-control-page.css +++ b/src/pages/mission-control/mission-control-page.css @@ -60,7 +60,7 @@ .command-bar-left i { font-size: 20px; - color: #ba160c; + color: var(--mc-accent-red, #ba160c); } /* ===== Static Alarm Bar ===== */ @@ -89,12 +89,13 @@ } .command-bar-alarm-bar.alarm { - background-color: rgba(186, 22, 12, 0.2); - border-top: 1px solid rgba(186, 22, 12, 0.3); + /* Semantic alarm state - stays red regardless of campaign accent */ + background-color: rgba(var(--mc-danger-rgb, 186, 22, 12), 0.2); + border-top: 1px solid rgba(var(--mc-danger-rgb, 186, 22, 12), 0.3); } .command-bar-left .text-blue-500 { - color: #ba160c; + color: var(--mc-accent-red, #ba160c); } /* Alarm Count Badges */ @@ -116,7 +117,8 @@ } .alarm-count.error { - background: rgba(186, 22, 12, 0.3); + /* Semantic error count - stays red regardless of campaign accent */ + background: rgba(var(--mc-danger-rgb, 186, 22, 12), 0.3); color: #fca5a5; } diff --git a/src/pages/mission-control/tabs/gps-timing-tab.css b/src/pages/mission-control/tabs/gps-timing-tab.css index b5e2dcd9..a6937f0d 100644 --- a/src/pages/mission-control/tabs/gps-timing-tab.css +++ b/src/pages/mission-control/tabs/gps-timing-tab.css @@ -103,9 +103,10 @@ } .status-badge-red { - background-color: rgba(186, 22, 12, 0.15); - color: var(--mc-accent-red-bright, #ff2827); - border: 1px solid rgba(186, 22, 12, 0.3); + /* Semantic fault indicator - stays red regardless of campaign accent */ + background-color: rgba(var(--mc-danger-rgb, 186, 22, 12), 0.15); + color: var(--mc-danger-bright, #ff2827); + border: 1px solid rgba(var(--mc-danger-rgb, 186, 22, 12), 0.3); } .status-badge-amber { diff --git a/src/pages/mission-control/tabs/rx-analysis-tab.css b/src/pages/mission-control/tabs/rx-analysis-tab.css index e7a14a89..bf771b46 100644 --- a/src/pages/mission-control/tabs/rx-analysis-tab.css +++ b/src/pages/mission-control/tabs/rx-analysis-tab.css @@ -57,7 +57,7 @@ /* Spectrum Analyzer Engineering Controls */ .sa-engineering-controls { padding: 0.75rem; - background: rgba(186, 22, 12, 0.08); + background: rgba(var(--mc-accent-red-rgb, 186, 22, 12), 0.08); border-radius: 4px; border: 2px solid var(--mc-accent-red, #ba160c); } @@ -72,7 +72,7 @@ margin-bottom: 0.75rem; letter-spacing: 0.15em; padding-bottom: 0.5rem; - border-bottom: 1px solid rgba(186, 22, 12, 0.3); + border-bottom: 1px solid rgba(var(--mc-accent-red-rgb, 186, 22, 12), 0.3); } /* Engineering controls - improved label contrast */ @@ -92,11 +92,11 @@ .sa-engineering-controls .form-control:focus, .sa-engineering-controls .form-select:focus { border-color: var(--mc-accent-red-bright, #ff4136); - box-shadow: 0 0 0 0.2rem rgba(186, 22, 12, 0.25); + box-shadow: 0 0 0 0.2rem rgba(var(--mc-accent-red-rgb, 186, 22, 12), 0.25); } .sa-engineering-controls .input-group-text { - background-color: rgba(186, 22, 12, 0.2); + background-color: rgba(var(--mc-accent-red-rgb, 186, 22, 12), 0.2); border-color: var(--mc-accent-red, #ba160c); color: var(--mc-accent-red-bright, #ff4136); } @@ -129,7 +129,7 @@ background-color: rgba(0, 0, 0, 0.5); border: 1px solid var(--mc-accent-red, #ba160c); border-radius: 4px; - text-shadow: 0 0 4px rgba(255, 65, 54, 0.5); + text-shadow: 0 0 4px rgba(var(--mc-accent-red-bright-rgb, 255, 65, 54), 0.5); } /* Display Mode & Actions - 2-element grid */ @@ -455,7 +455,7 @@ font-size: 1.125rem; font-weight: 700; color: var(--mc-accent-red-bright, #ff2827); - text-shadow: 0 0 8px rgba(255, 40, 39, 0.4); + text-shadow: 0 0 8px rgba(var(--mc-accent-red-bright-rgb, 255, 40, 39), 0.4); outline: none; } diff --git a/src/pages/mission-control/tabs/rx-analysis-tab.ts b/src/pages/mission-control/tabs/rx-analysis-tab.ts index c4203b49..facbdc58 100644 --- a/src/pages/mission-control/tabs/rx-analysis-tab.ts +++ b/src/pages/mission-control/tabs/rx-analysis-tab.ts @@ -368,7 +368,7 @@ export class RxAnalysisTab extends BaseElement {
-
diff --git a/src/pages/scenario-selection.css b/src/pages/scenario-selection.css index d1163dfb..9dd63212 100644 --- a/src/pages/scenario-selection.css +++ b/src/pages/scenario-selection.css @@ -224,7 +224,7 @@ .scenario-card.selected { border-color: var(--color-primary); box-shadow: - 0 6px 20px rgba(186, 22, 12, 0.4), + 0 6px 20px rgba(var(--mc-accent-red-rgb, 186, 22, 12), 0.4), inset 0 1px 1px rgba(255, 255, 255, 0.15); } @@ -498,7 +498,7 @@ .btn-start:hover { background: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-primary) 100%); - box-shadow: 0 4px 10px rgba(186, 22, 12, 0.8); + box-shadow: 0 4px 10px rgba(var(--mc-accent-red-rgb, 186, 22, 12), 0.8); } .btn-start-fresh { @@ -563,7 +563,7 @@ .scenario-card.selected .status-dot { background-color: var(--color-primary); - box-shadow: 0 0 8px rgba(186, 22, 12, 0.8); + box-shadow: 0 0 8px rgba(var(--mc-accent-red-rgb, 186, 22, 12), 0.8); } /* Action Buttons */ diff --git a/src/router.ts b/src/router.ts index 5d9a6171..df0d7f4a 100644 --- a/src/router.ts +++ b/src/router.ts @@ -73,6 +73,10 @@ export class Router { const path = globalThis.location.pathname; this.currentPath = path; + // Tag with the active campaign so per-campaign themes + // (e.g. .campaign-nats-eu) can scope CSS variable overrides + this.updateCampaignBodyClass_(/^\/campaigns\/([^/]+)/.exec(path)?.[1]); + // Hide all pages this.hideAll(); @@ -119,6 +123,18 @@ export class Router { EventBus.getInstance().emit(Events.ROUTE_CHANGED, { path }); } + private updateCampaignBodyClass_(campaignId?: string): void { + const body = document.body; + for (const cls of Array.from(body.classList)) { + if (cls.startsWith('campaign-')) { + body.classList.remove(cls); + } + } + if (campaignId) { + body.classList.add(`campaign-${campaignId}`); + } + } + private hideAll(): void { CampaignSelectionPage.getInstance().hide(); ScenarioSelectionPage.getInstance().hide(); diff --git a/src/tabler-overrides.css b/src/tabler-overrides.css index 12ee2781..31b5798a 100644 --- a/src/tabler-overrides.css +++ b/src/tabler-overrides.css @@ -35,8 +35,8 @@ body { --tblr-primary: #ba160c !important; --tblr-primary-rgb: 186, 22, 12 !important; --tblr-primary-text-emphasis: #ff2827 !important; - --tblr-primary-bg-subtle: rgba(186, 22, 12, 0.15) !important; - --tblr-primary-border-subtle: rgba(186, 22, 12, 0.3) !important; + --tblr-primary-bg-subtle: rgba(var(--mc-accent-red-rgb), 0.15) !important; + --tblr-primary-border-subtle: rgba(var(--mc-accent-red-rgb), 0.3) !important; /* Dark Theme Background Colors */ --tblr-body-bg: #1f1f1f !important; @@ -84,12 +84,25 @@ body { --mc-accent-red-bright: #ff2827; --mc-accent-red-light: #e01a0e; --mc-accent-red-dark: #860f08; + /* Comma-separated RGB triplets for rgba() tints/glows of the accent */ + --mc-accent-red-rgb: 186, 22, 12; + --mc-accent-red-bright-rgb: 255, 40, 39; + /* Darker accent used for dark-surface text highlights */ + --mc-accent-text-dark: #dd382f; + + /* Semantic danger/fault colors - campaign themes must NOT override these: + alarms stay red even when the brand accent changes */ + --mc-danger: #ba160c; + --mc-danger-bright: #ff2827; + --mc-danger-rgb: 186, 22, 12; /* Backward Compatibility - Map old variables to new system */ --color-primary: var(--mc-accent-red); + --color-primary-light: var(--mc-accent-red-light); + --color-primary-dark: var(--mc-accent-red-dark); --color-dark-background: var(--mc-surface-0); --color-dark-border: var(--mc-border); - --color-dark-text-accent: #dd382f; + --color-dark-text-accent: var(--mc-accent-text-dark); --color-dark-text-muted: var(--mc-text-muted); --color-text-accent: var(--mc-accent-red-bright); @@ -104,6 +117,49 @@ body { --color-text-tertiary: var(--mc-text-tertiary); } +/* ========================================================================== + Per-Campaign Theme: NATS Europe (North Atlantic blue) + Applied via body.campaign- class set by the Router. Same layout and + components as the base theme; only the accent shifts from Mission Control + red to a maritime blue, with subtly cooler surfaces, so the EU station + feels like a different facility without disorienting the user. + ========================================================================== */ + +body.campaign-nats-eu { + /* Primary accent: North Atlantic blue */ + --tblr-primary: #0f62ac !important; + --tblr-primary-rgb: 15, 98, 172 !important; + --tblr-primary-text-emphasis: #3aa0ff !important; + --tblr-primary-bg-subtle: rgba(15, 98, 172, 0.15) !important; + --tblr-primary-border-subtle: rgba(15, 98, 172, 0.3) !important; + + --tblr-link-color: #1878d4 !important; + --tblr-link-hover-color: #3aa0ff !important; + + /* The --mc-accent-red-* names are kept (they are the semantic "accent" + slots consumed everywhere); only their values change per campaign */ + --mc-accent-red: #0f62ac; + --mc-accent-red-bright: #3aa0ff; + --mc-accent-red-light: #1878d4; + --mc-accent-red-dark: #0a4478; + --mc-accent-red-rgb: 15, 98, 172; + --mc-accent-red-bright-rgb: 58, 160, 255; + --mc-accent-text-dark: #4b93d4; + + /* Slightly cooler surfaces for the EU facility */ + --mc-surface-0: #1d2023; + --mc-surface-1: #26292e; + --mc-surface-2: #363a40; + --mc-surface-3: #464b52; + --mc-surface-4: #565c64; + --mc-border: #363a40; + + --tblr-body-bg: #1d2023 !important; + --tblr-secondary-bg: #26292e !important; + --tblr-tertiary-bg: #363a40 !important; + --tblr-border-color: #363a40 !important; +} + /* ========================================================================== Card Component Overrides ========================================================================== */ @@ -171,7 +227,7 @@ body { .form-check-input:focus { border-color: var(--mc-accent-red-light) !important; - box-shadow: 0 0 0 0.25rem rgba(186, 22, 12, 0.25) !important; + box-shadow: 0 0 0 0.25rem rgba(var(--mc-accent-red-rgb), 0.25) !important; } /* Form labels */ @@ -197,7 +253,7 @@ body { background-color: var(--mc-surface-2); border-color: var(--mc-accent-red); color: var(--mc-text-primary); - box-shadow: 0 0 0 0.25rem rgba(186, 22, 12, 0.25); + box-shadow: 0 0 0 0.25rem rgba(var(--mc-accent-red-rgb), 0.25); } /* ========================================================================== @@ -373,7 +429,7 @@ body { .btn-primary:focus { background-color: var(--mc-accent-red-dark); border-color: var(--mc-accent-red-dark); - box-shadow: 0 0 0 0.25rem rgba(186, 22, 12, 0.5); + box-shadow: 0 0 0 0.25rem rgba(var(--mc-accent-red-rgb), 0.5); } /* ========================================================================== @@ -522,11 +578,11 @@ body { background-color: var(--mc-accent-red); border-color: var(--mc-accent-red); color: #ffffff; - box-shadow: 0 0 8px rgba(186, 22, 12, 0.4); + box-shadow: 0 0 8px rgba(var(--mc-accent-red-rgb), 0.4); } .btn.btn-tracking:focus { - box-shadow: 0 0 0 0.25rem rgba(186, 22, 12, 0.25); + box-shadow: 0 0 0 0.25rem rgba(var(--mc-accent-red-rgb), 0.25); } /* Apply Button - Red accent for primary actions */ @@ -553,7 +609,7 @@ body { } .btn.btn-apply:focus { - box-shadow: 0 0 0 0.25rem rgba(186, 22, 12, 0.25); + box-shadow: 0 0 0 0.25rem rgba(var(--mc-accent-red-rgb), 0.25); } /* ========================================================================== @@ -634,8 +690,8 @@ body { ========================================================================== */ .alert-primary { - background-color: var(--mc-primary-bg-subtle, rgba(186, 22, 12, 0.15)); - border-color: var(--mc-primary-border-subtle, rgba(186, 22, 12, 0.3)); + background-color: var(--mc-primary-bg-subtle, rgba(var(--mc-accent-red-rgb), 0.15)); + border-color: var(--mc-primary-border-subtle, rgba(var(--mc-accent-red-rgb), 0.3)); color: var(--mc-accent-red-bright); } @@ -658,9 +714,9 @@ body { } .alert-danger { - background-color: rgba(186, 22, 12, 0.15); - border-color: rgba(186, 22, 12, 0.3); - color: var(--mc-accent-red-bright); + background-color: rgba(var(--mc-danger-rgb), 0.15); + border-color: rgba(var(--mc-danger-rgb), 0.3); + color: var(--mc-danger-bright); } /* ========================================================================== From 1de917cb7561f480114cd4fa3f8feb2710b36914 Mon Sep 17 00:00:00 2001 From: Theodore Kruczek Date: Thu, 2 Jul 2026 06:23:55 -0400 Subject: [PATCH 006/131] docs: :memo: add campaign 2 orbital foundations plan and retrospective --- ...se-1-campaign2-orbital-foundations-plan.md | 110 ++++++++++++++++++ ...e-1-campaign2-orbital-foundations-retro.md | 24 ++++ 2 files changed, 134 insertions(+) create mode 100644 plans/phase-1-campaign2-orbital-foundations-plan.md create mode 100644 retrospectives/phase-1-campaign2-orbital-foundations-retro.md diff --git a/plans/phase-1-campaign2-orbital-foundations-plan.md b/plans/phase-1-campaign2-orbital-foundations-plan.md new file mode 100644 index 00000000..c2bafe02 --- /dev/null +++ b/plans/phase-1-campaign2-orbital-foundations-plan.md @@ -0,0 +1,110 @@ +# Phase 1 — Campaign 2 (NATS Europe) Orbital Foundations Plan + +## Goal + +Enable Campaign 2 ("NATS Europe", Charlie transfers to Europe) with: + +1. **Realistic satellites** — SGP4-propagated from TLEs via `ootk`, producing real ECI position and ground-station-relative range/az/el. +2. **LEO satellite tracking** — antenna program-track of a fast-moving pass, AOS/LOS behavior. +3. **Multi-contact mission planning** — pass prediction (AOS, LOS, max elevation, duration) surfaced in a Mission Control tab. +4. **Video feed decoding** — LEO downlink carries a video payload rendered by the receiver when locked. +5. **Ku-band operations** — Ku ground station (LNB/BUC LOs, passbands, beacon, antenna config). +6. **Cosmetic re-theme** — NATS Europe feels like a different station/company without disorienting the user. + +**Hard constraint:** 100% backwards compatibility with Campaign 1 (nats). All new physics are opt-in via a new satellite subclass and optional config fields; defaults preserve current behavior. + +## Current-state findings (deep dive summary) + +- `ootk@5.1.1` is already a dependency (currently used for branded types only). Its `Satellite` class provides `rae(observer, date)`, `eci(date)`, `rng()`, `applyDoppler()`; `GroundObject` models the station. +- `src/equipment/satellite/satellite.ts` — sim `Satellite` stores **static az/el** (GEO) or a parametric figure-8 (`orbitType: 'geosynchronous'`). `predictedAz/El` = truth + ephemeris error (step-track gameplay). Position updates throttled to 1 Hz inside `update()` on `Events.UPDATE`. +- **Every consumer reads `sat.az` / `sat.el` / `sat.predictedAz/El`** (antenna program-track, lock checks, link budget off-axis, traffic manager, dashboards). So a subclass that keeps those fields fresh from SGP4 gets full downstream compatibility for free. +- Slant range is **not** modeled: `antenna-core.ts` uses `GEO_SATELLITE_DISTANCE_KM = 38000` for FSPL. Doppler is not modeled anywhere. +- Simulated clock exists in `OpsLogManager` (`currentTimestampMs`, seeded from `scenarioStartDate`/`scenarioStartWallTime`, advances by dt, pausable) but nothing feeds it into satellite position yet. +- Campaign registry supports multiple campaigns; **`natsEuCampaignData` stub already exists** (id `nats-eu`, locked "Under Development") and is registered in `router.ts`. Scenarios must also be added to the flat `SCENARIOS` array in `scenario-manager.ts`. +- Ku-band already exists in the data model: `FrequencyBand.ku` in `constants.ts`, several `KU_BAND_*` antenna configs. Gaps: LNB/BUC LO + passband defaults are C-band-hardcoded; `GroundStation.createEquipment_()` ignores the configured antenna key (always factory default). +- Video feed rendering already exists: `BaseSignal.feed` URL → receiver `