File size: 20,283 Bytes
ef55b57
 
 
cd439ab
 
2ca89fb
cd439ab
ef55b57
 
 
 
 
5086de6
 
2ca89fb
5086de6
2ca89fb
 
ef55b57
 
 
08b99b2
2ca89fb
 
 
ef55b57
 
5086de6
 
 
 
2ca89fb
 
08b99b2
 
2ca89fb
74460ff
 
bc3a44a
2ca89fb
 
 
 
 
 
 
 
 
5086de6
 
 
 
2ca89fb
c485991
2ca89fb
08b99b2
 
74460ff
 
 
 
 
2ca89fb
 
 
 
 
 
 
 
5086de6
1857b6b
ef55b57
5086de6
ef55b57
5086de6
ef55b57
08b99b2
 
 
 
 
2ca89fb
08b99b2
 
 
2ca89fb
08b99b2
 
 
2ca89fb
08b99b2
 
2ca89fb
 
 
5086de6
08b99b2
 
 
 
 
2ca89fb
ef55b57
 
 
2ca89fb
5086de6
 
08b99b2
 
 
 
 
 
 
 
 
 
 
 
2ca89fb
08b99b2
 
 
2ca89fb
ef55b57
 
 
74460ff
 
ef55b57
2ca89fb
ef55b57
 
 
2ca89fb
74460ff
bc3a44a
74460ff
2ca89fb
 
 
 
 
 
 
74460ff
 
2ca89fb
 
 
ef55b57
2ca89fb
bc3a44a
 
 
 
2ca89fb
 
 
 
 
 
 
 
bc3a44a
2ca89fb
 
 
 
 
5caf589
74460ff
 
2ca89fb
 
 
 
 
 
 
ef55b57
5caf589
 
2ca89fb
 
 
 
 
 
 
 
 
 
5caf589
 
 
 
 
2ca89fb
 
 
 
 
 
 
 
5caf589
 
2ca89fb
 
 
 
 
 
 
 
5caf589
 
ef55b57
 
 
 
 
 
2ca89fb
 
 
5caf589
2ca89fb
 
5caf589
2ca89fb
 
 
 
 
 
 
 
 
 
5caf589
ef55b57
 
 
 
 
 
2ca89fb
ef55b57
2ca89fb
5caf589
 
 
2ca89fb
5caf589
5086de6
5caf589
 
 
2ca89fb
97ef31f
 
2ca89fb
 
 
97ef31f
2ca89fb
 
 
 
 
 
 
 
 
 
 
ef55b57
 
cd439ab
 
ef55b57
 
 
5086de6
 
 
2ca89fb
 
 
 
 
 
 
5086de6
 
 
2ca89fb
 
 
 
 
 
 
 
 
 
5086de6
ef55b57
2ca89fb
5086de6
 
2ca89fb
08b99b2
5086de6
08b99b2
 
5086de6
cd439ab
2ca89fb
 
 
 
 
 
 
 
 
 
 
08b99b2
2ca89fb
 
 
 
 
 
 
 
 
ef55b57
cd439ab
2ca89fb
 
 
 
 
 
 
 
bc3a44a
2ca89fb
 
 
bc3a44a
 
2ca89fb
 
 
bc3a44a
 
 
2ca89fb
bc3a44a
 
 
 
2ca89fb
bc3a44a
 
 
 
 
2ca89fb
bc3a44a
 
 
 
2ca89fb
 
 
 
 
 
 
 
 
 
 
bc3a44a
 
 
 
2ca89fb
 
 
bc3a44a
 
 
 
e1e9687
2ca89fb
bc3a44a
2ca89fb
bc3a44a
 
 
 
 
 
2ca89fb
bc3a44a
2ca89fb
 
 
 
 
 
 
 
 
 
bc3a44a
 
ef55b57
 
 
2ca89fb
5086de6
08b99b2
5086de6
 
08b99b2
2ca89fb
08b99b2
 
 
 
 
 
 
 
2ca89fb
08b99b2
 
2ca89fb
 
 
 
08b99b2
2ca89fb
08b99b2
2ca89fb
08b99b2
2ca89fb
 
 
 
 
 
 
 
08b99b2
ef55b57
 
 
 
cd439ab
2ca89fb
5086de6
 
2ca89fb
bc3a44a
 
 
 
 
 
 
 
 
2ca89fb
 
 
 
bc3a44a
2ca89fb
bc3a44a
 
 
 
 
 
 
 
 
 
 
 
 
2ca89fb
bc3a44a
 
 
 
 
2ca89fb
 
 
 
 
 
 
bc3a44a
2ca89fb
bc3a44a
2ca89fb
 
 
 
 
bc3a44a
2ca89fb
bc3a44a
 
 
 
2ca89fb
bc3a44a
 
ef55b57
 
 
08b99b2
ef55b57
 
 
2ca89fb
 
 
bc3a44a
5086de6
2ca89fb
cd439ab
2ca89fb
 
ef55b57
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
import time
import asyncio
import math
from .aircraft import Aircraft


class SimulationEngine:
    def __init__(self, time_step: float = 0.1):
        self.aircrafts: dict[str, Aircraft] = {}
        self.simulation_time = 0.0
        self.is_active = False
        self.is_terminal = False
        self.time_scale = 1.0
        self.tick_rate = time_step

        # Current loaded configuration
        self.config = None  # Will be of type AirportConfig

        # Weather State
        self.wind_heading = 90.0
        self.wind_speed = 10.0
        self.active_runways: list[str] = []
        self.runway_status: dict[str, dict] = {}  # {id: {occupied_by: Optional[str]}}
        self.runway_cooldowns: dict[str, float] = {}  # {id: timestamp_until_free}

        self.event_buffer = []

    def load_airport(self, config):
        """Load a new airport configuration and reset the simulation"""
        self.reset_environment()
        self.config = config
        self.time_scale = config.time_scale if hasattr(config, "time_scale") else 1.0

        # Determine initial active runways based on default wind
        self.update_weather(self.wind_heading, self.wind_speed)

        # Initialize runway status
        self.runway_status = {r.id: {"occupied_by": None} for r in self.config.runways}
        self.runway_cooldowns = {r.id: 0.0 for r in self.config.runways}

        self.event_buffer.append(
            {
                "type": "AIRPORT_LOADED",
                "code": self.config.airport_code,
                "timestamp": time.time(),
                "msg": f"Airport {config.name} loaded. Active RWYs: {', '.join(self.active_runways)}",
            }
        )

    def update_config(self, config):
        """Update the configuration without resetting the simulation state"""
        self.config = config
        if hasattr(config, "time_scale"):
            self.time_scale = config.time_scale

        # Re-evaluate weather logic to ensure active runways are still valid
        self.update_weather(self.wind_heading, self.wind_speed)

        # Sync runway status
        for r in self.config.runways:
            if r.id not in self.runway_status:
                self.runway_status[r.id] = {"occupied_by": None}

        self.event_buffer.append(
            {
                "type": "INFO",
                "msg": f"Config updated. Active RWYs: {', '.join(self.active_runways)}",
                "timestamp": time.time(),
            }
        )

    async def run(self, on_step=None):
        self.is_active = True
        print("[Sim Engine] Simulation loop started.")
        last_time = time.perf_counter()
        tick_count = 0
        while self.is_active:
            try:
                current_time = time.perf_counter()
                actual_dt = current_time - last_time
                last_time = current_time
                dt = actual_dt * self.time_scale

                # Step only if NOT terminal
                if not self.is_terminal and self.config:
                    self.step(dt)

                # ALWAYS broadcast if on_step is provided, even if terminal
                if on_step:
                    await on_step(self.get_full_state())

                tick_count += 1
                if tick_count % 100 == 0:
                    print(
                        f"[Sim Engine] Heartbeat @ t={self.simulation_time:.1f}s, Scale={self.time_scale}x"
                    )

                execution_time = time.perf_counter() - current_time
                sleep_time = max(0.01, self.tick_rate - execution_time)
                await asyncio.sleep(sleep_time)
            except Exception as e:
                print(f"[Sim Engine Critical Error] {e}")
                await asyncio.sleep(0.5)  # Throttle on error

    def step(self, dt: float):
        self.simulation_time += dt

        # Build STAR context from config
        stars = {}
        if self.config and self.active_runways:
            # Resolve stars for ALL active runways
            for gate_id, runway_star_map in self.config.stars.items():
                gate_routes = []
                for active_rw_id in self.active_runways:
                    if active_rw_id in runway_star_map:
                        wp_ids = runway_star_map[active_rw_id]
                        route = []
                        for wp_id in wp_ids:
                            wp_cfg = self.config.waypoints.get(wp_id)
                            if wp_cfg:
                                route.append(wp_cfg.model_dump())

                        gate_routes.append({"runway": active_rw_id, "waypoints": route})
                if gate_routes:
                    stars[gate_id] = gate_routes

        context = {
            "wind_heading": self.wind_heading,
            "wind_speed": self.wind_speed,
            "stars": stars,
            "runway_status": self.runway_status,
        }

        to_delete = []
        for callsign, aircraft in list(self.aircrafts.items()):
            aircraft.update(dt, context)

            # Use assigned runway's threshold for landing detection
            # SUCCESSFUL LANDING Detection
            if aircraft.state == "LANDING" and aircraft.speed < 10:
                self.event_buffer.append(
                    {
                        "type": "SUCCESSFUL_LANDING",
                        "callsign": callsign,
                        "timestamp": time.time(),
                    }
                )
                if aircraft.target_runway_id in self.runway_status:
                    self.runway_status[aircraft.target_runway_id]["occupied_by"] = None
                    self.runway_cooldowns[aircraft.target_runway_id] = (
                        self.simulation_time + 60.0
                    )
                to_delete.append(callsign)

            # SUCCESSFUL DEPARTURE Detection (45km Boundary)
            if aircraft.state == "CLIMB_OUT":
                dist_from_center = math.sqrt(aircraft.x**2 + aircraft.y**2)
                if dist_from_center > 45.0:
                    self.event_buffer.append(
                        {
                            "type": "SUCCESSFUL_DEPARTURE",
                            "callsign": callsign,
                            "reward": 100,
                            "timestamp": time.time(),
                        }
                    )
                    to_delete.append(callsign)

            if (
                aircraft.state == "CRASHED"
                or aircraft.state == "CRASHED_RUNWAY_INCURSION"
            ):
                self.trigger_crash(callsign, aircraft.state)
                # Release lock if they crash while on runway
                if aircraft.target_runway_id in self.runway_status:
                    if (
                        self.runway_status[aircraft.target_runway_id]["occupied_by"]
                        == callsign
                    ):
                        self.runway_status[aircraft.target_runway_id]["occupied_by"] = (
                            None
                        )

            # 1. CFIT Detection (Controlled Flight Into Terrain)
            # Below 50ft when not in a ground/landing state
            if aircraft.altitude < 50 and aircraft.state not in [
                "ON_GATE",
                "TAXIING",
                "HOLDING_SHORT",
                "LINE_UP",
                "TAKEOFF_ROLL",
                "LANDING",
                "APPROACH",
                "CLIMB_OUT",
            ]:
                self.trigger_crash(callsign, "CFIT")

            # 2. Runway Overshoot (Overrun) Detection
            # If in LANDING state and past the runway boundary at high speed
            if aircraft.state == "LANDING" and aircraft.target_runway_id:
                rw_cfg = next(
                    (
                        r
                        for r in self.config.runways
                        if r.id == aircraft.target_runway_id
                    ),
                    None,
                )
                if rw_cfg:
                    # Check distance from threshold vs runway length
                    dist_from_threshold = math.sqrt(
                        (aircraft.x - rw_cfg.start.x) ** 2
                        + (aircraft.y - rw_cfg.start.y) ** 2
                    )
                    if (
                        dist_from_threshold > (rw_cfg.length_km + 0.1)
                        and aircraft.speed > 20
                    ):
                        self.trigger_crash(callsign, "OVERRUN")

        for callsign in to_delete:
            if callsign in self.aircrafts:
                del self.aircrafts[callsign]

        self.check_separation_violations()

    def trigger_crash(
        self, callsign: str, subtype: str, participants: list[str] = None
    ):
        """Centralized crash handler"""
        if self.is_terminal:
            return
        self.is_terminal = True
        self.event_buffer.append(
            {
                "type": "CRASH",
                "subtype": subtype,
                "callsign": callsign,
                "participants": participants or [callsign],
                "timestamp": time.time(),
                "msg": f"CRASH: {subtype} involving {', '.join(participants or [callsign])}",
            }
        )

    def check_separation_violations(self):
        callsigns = list(self.aircrafts.keys())
        for i in range(len(callsigns)):
            for j in range(i + 1, len(callsigns)):
                a1 = self.aircrafts[callsigns[i]]
                a2 = self.aircrafts[callsigns[j]]
                dist = math.sqrt((a1.x - a2.x) ** 2 + (a1.y - a2.y) ** 2)
                alt_diff = abs(a1.altitude - a2.altitude)

                # COLLISION (MAC): < 0.3km (300m) AND < 300ft
                if dist < 0.3 and alt_diff < 300:
                    self.trigger_crash(a1.callsign, "MAC", [a1.callsign, a2.callsign])

                # SEPARATION VIOLATION: < 5km AND < 1000ft
                elif dist < 5.0 and alt_diff < 1000:
                    # Skip violation if they are on ground close to each other
                    if a1.altitude < 100 and a2.altitude < 100:
                        continue

                    # Push metrics to individual aircraft
                    a1.separation_warnings += 1
                    a1.closest_proximity_km = min(
                        a1.closest_proximity_km, round(dist, 2)
                    )
                    a2.separation_warnings += 1
                    a2.closest_proximity_km = min(
                        a2.closest_proximity_km, round(dist, 2)
                    )

                    self.event_buffer.append(
                        {
                            "type": "SEPARATION_VIOLATION",
                            "participants": [a1.callsign, a2.callsign],
                            "timestamp": time.time(),
                        }
                    )

    def reset_environment(self):
        self.aircrafts = {}
        self.simulation_time = 0.0
        self.is_terminal = False
        self.event_buffer = []

    def remove_aircraft(self, callsign: str):
        if callsign in self.aircrafts:
            del self.aircrafts[callsign]
            self.event_buffer.append(
                {
                    "type": "AIRCRAFT_REMOVED",
                    "callsign": callsign,
                    "timestamp": time.time(),
                }
            )
            return True
        return False

    def add_aircraft(
        self,
        callsign,
        ac_type,
        weight_class,
        gate,
        altitude=10000,
        heading=None,
        speed=250,
    ):
        if not self.config or gate not in self.config.gates:
            return False

        gate_pos = self.config.gates[gate]
        pos = (gate_pos.x, gate_pos.y)

        # Default heading: points toward center (0,0)
        if heading is None:
            dx = 0 - pos[0]
            dy = 0 - pos[1]
            heading = (90 - math.degrees(math.atan2(dy, dx))) % 360

        new_ac = Aircraft(
            callsign.upper(),
            ac_type,
            weight_class,
            pos,
            altitude,
            heading,
            speed,
            active_star=gate,
            gate=gate,
        )
        self.aircrafts[callsign.upper()] = new_ac
        self.event_buffer.append(
            {
                "type": "SPAWN",
                "callsign": callsign.upper(),
                "ac_type": ac_type,
                "weight_class": weight_class,
                "timestamp": time.time(),
            }
        )
        return True

    def spawn_departure(
        self,
        callsign: str,
        ac_type: str,
        runway_id: str,
        gate_id: str,
        terminal_gate_id: str = None,
    ):
        """Spawns an aircraft on the ground for a SID departure."""
        if not self.config:
            return None

        # Find runway config
        rw_cfg = next((r for r in self.config.runways if r.id == runway_id), None)
        if not rw_cfg:
            return None

        # Determine spawn point and state
        start_p = rw_cfg.start
        initial_state = "HOLDING_SHORT"

        if terminal_gate_id and terminal_gate_id in self.config.terminal_gates:
            gate_p = self.config.terminal_gates[terminal_gate_id]
            start_p = gate_p
            initial_state = "ON_GATE"

        # Build SID route: Runway -> DP -> Gate
        sid_wp_ids = self.config.sids.get(runway_id, {}).get(gate_id, [])
        route = []
        for wp_id in sid_wp_ids:
            wp = self.config.waypoints.get(wp_id)
            if wp:
                route.append(wp.model_dump())
            elif wp_id in self.config.gates:
                # Boundary gate is a "virtual waypoint"
                gate_p = self.config.gates[wp_id]
                route.append(
                    {
                        "id": wp_id,
                        "name": wp_id,
                        "x": gate_p.x,
                        "y": gate_p.y,
                        "target_alt": 6000,
                        "target_speed": 250,
                    }
                )

        # Create Aircraft
        ac = Aircraft(
            callsign=callsign.upper(),
            type=ac_type,
            weight_class="Heavy"
            if any(x in ac_type for x in ["74", "77", "78", "380"])
            else "Medium",
            position=(start_p.x, start_p.y),
            altitude=0,
            heading=rw_cfg.heading,
            speed=0,
            state=initial_state,
            gate=gate_id,
        )

        # Initialize departure state
        ac.target_runway_id = runway_id
        ac.runway_threshold = {"x": rw_cfg.start.x, "y": rw_cfg.start.y}
        ac.runway_heading = rw_cfg.heading
        ac.active_route = route
        ac.route_index = 0

        self.aircrafts[callsign.upper()] = ac

        self.event_buffer.append(
            {
                "type": "SPAWN",
                "subtype": "DEPARTURE",
                "callsign": callsign.upper(),
                "runway": runway_id,
                "timestamp": time.time(),
            }
        )
        return ac

    def update_weather(self, heading: float, speed: float):
        self.wind_heading = heading
        self.wind_speed = speed

        if not self.config or not self.config.runways:
            self.active_runways = []
            return

        old_runways = list(self.active_runways)

        # 1. Selection logic: Select all runways with ANY headwind component (< 90 deg diff)
        # diff = (r.heading - wind_heading + 180) % 360 - 180
        # abs(diff) < 90 means it has a headwind component.
        new_active = []
        for r in self.config.runways:
            diff = (r.heading - heading + 180) % 360 - 180
            if abs(diff) < 90:
                new_active.append(r.id)

        # 2. Safety Fallback: If no runway has a headwind component, pick the single best one (least tailwind)
        if not new_active and self.config.runways:
            best_rw = min(
                self.config.runways,
                key=lambda r: abs((r.heading - heading + 180) % 360 - 180),
            )
            new_active = [best_rw.id]

        self.active_runways = new_active

        if set(self.active_runways) != set(old_runways):
            self.event_buffer.append(
                {
                    "type": "RUNWAY_CHANGE",
                    "from": old_runways,
                    "to": self.active_runways,
                    "timestamp": time.time(),
                }
            )
            # Transition existing enroute aircraft to HOLDING so they can be re-cleared for new active runways
            for ac in self.aircrafts.values():
                if ac.active_star:
                    ac.active_star = None
                    ac.state = "HOLDING"

    def get_full_state(self, clear_events: bool = True):
        config_data = self.config.model_dump() if self.config else None
        anchor = config_data["anchor"] if config_data else None

        # Calculate dynamic runway status for the UI
        # 1. Start with base occupied_by from engine locks
        display_runway_status = {}
        for r_id, status in self.runway_status.items():
            effective_status = {
                "id": r_id,
                "status": "CLEAR",
                "occupied_by": status["occupied_by"],
                "reserved_by": None,
                "cooldown_remaining": max(
                    0,
                    round(self.runway_cooldowns.get(r_id, 0) - self.simulation_time, 1),
                ),
            }

            # Priority 1: Physical Occupancy (Lock)
            if status["occupied_by"]:
                effective_status["status"] = "OCCUPIED"
            # Priority 2: Cooldown
            elif effective_status["cooldown_remaining"] > 0:
                effective_status["status"] = "COOLDOWN"
            # Priority 3: Reservations (Assignments)
            else:
                # Check for any aircraft that is actively using or approaching this runway
                for ac in self.aircrafts.values():
                    # 1. Skip if still at terminal (don't block runway yet)
                    if ac.state == "ON_GATE":
                        continue

                    is_reserving = False
                    # 2. Check if this is their target runway and they are in an active phase
                    if ac.target_runway_id == r_id:
                        # Arrivals in ENROUTE/HOLDING don't reserve yet
                        # Departures in TAXIING/HOLDING_SHORT do reserve
                        if ac.state in [
                            "TAXIING",
                            "HOLDING_SHORT",
                            "APPROACH",
                            "LANDING",
                            "GO_AROUND",
                        ]:
                            is_reserving = True

                    # 3. Check for queued landings (explicitly cleared)
                    if (
                        not is_reserving
                        and ac.queued_landing
                        and ac.queued_landing.get("runway_id") == r_id
                    ):
                        is_reserving = True

                    if is_reserving:
                        effective_status["status"] = "RESERVED"
                        effective_status["reserved_by"] = ac.callsign
                        break

            display_runway_status[r_id] = effective_status

        state = {
            "simulation_time": round(self.simulation_time, 2),
            "is_terminal": self.is_terminal,
            "active_runways": self.active_runways,
            "wind_heading": self.wind_heading,
            "wind_speed": self.wind_speed,
            "time_scale": self.time_scale,
            "aircrafts": {
                c: a.get_state(anchor=anchor) for c, a in self.aircrafts.items()
            },
            "runway_status": display_runway_status,
            "events": list(self.event_buffer),
            "config": config_data,
        }
        if clear_events:
            self.event_buffer = []
        return state