diff --git a/data/planes.csv b/data/planes.csv new file mode 100644 index 0000000..49d97e5 --- /dev/null +++ b/data/planes.csv @@ -0,0 +1,23 @@ +Name,Manufacturer,Range (nm),Avg. Cruise Spt (kt),Capacity (people) +737 MAX 8,Boeing,3550,449,178 +737-800,Boeing,2935,449,162 +A320neo,Airbus,3500,447,180 +A321neo,Airbus,4000,454,206 +A319,Airbus,3750,447,124 +777-200ER,Boeing,7700,488,314 +777-300ER,Boeing,7370,488,396 +787-8,Boeing,7305,488,242 +787-9,Boeing,7635,488,296 +787-10,Boeing,6430,488,336 +747-400,Boeing,7260,493,416 +747-8I,Boeing,7730,493,467 +A330-200,Airbus,7200,470,247 +A330-300,Airbus,6350,470,277 +A330-900neo,Airbus,6550,470,287 +A350-900,Airbus,8100,488,325 +A350-1000,Airbus,8000,488,369 +A380-800,Airbus,8000,490,555 +E175,Embraer,2200,447,78 +E195-E2,Embraer,2600,447,132 +CRJ900,Bombardier,1670,447,76 +CRJ700,Bombardier,1378,447,66 diff --git a/requirements.txt b/requirements.txt index b62ee3c..b2b530b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,67 @@ +altgraph==0.17.4 +annotated-types==0.7.0 +anyio==4.11.0 +APScheduler==3.11.0 asgiref==3.9.2 astroid==3.3.11 +bottle==0.13.4 +certifi==2025.8.3 +contourpy==1.3.3 +cycler==0.12.1 dill==0.4.0 +distro==1.9.0 Django==5.2.6 +django-apscheduler==0.7.0 django-stubs==5.2.5 django-stubs-ext==5.2.5 +djangorestframework==3.16.1 dotenv==0.9.9 +fonttools==4.60.1 +geopandas==1.1.1 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +idna==3.10 isort==6.0.1 +jiter==0.11.0 +kiwisolver==1.4.9 +matplotlib==3.10.6 mccabe==0.7.0 mypy==1.18.2 mypy_extensions==1.1.0 +networkx==3.5 +numpy==2.3.3 +openai==2.0.0 +packaging==25.0 +pandas==2.3.3 pathspec==0.12.1 +pillow==11.3.0 platformdirs==4.4.0 +proxy_tools==0.1.0 +psycopg2-binary==2.9.10 +pydantic==2.11.9 +pydantic_core==2.33.2 +pyinstaller==6.16.0 +pyinstaller-hooks-contrib==2025.9 pylint==3.3.8 +pyogrio==0.11.1 +pyparsing==3.2.5 +pyproj==3.7.2 +python-dateutil==2.9.0.post0 python-dotenv==1.1.1 +pytz==2025.2 +pywebview==6.0 +setuptools==80.9.0 +shapely==2.1.2 +six==1.17.0 +sniffio==1.3.1 sqlparse==0.5.3 tomlkit==0.13.3 +tqdm==4.67.1 types-PyYAML==6.0.12.20250915 types-requests==2.32.4.20250913 +typing-inspection==0.4.2 typing_extensions==4.15.0 +tzdata==2025.2 +tzlocal==5.3.1 urllib3==2.5.0 diff --git a/simulator/models/aircraft.py b/simulator/models/aircraft.py index 3f3c67b..e40bf89 100644 --- a/simulator/models/aircraft.py +++ b/simulator/models/aircraft.py @@ -1,5 +1,8 @@ from django.db import models +# from .flight import Flight +from django.utils import timezone + class AircraftBase(models.Model): name = models.CharField(max_length=20) @@ -22,12 +25,12 @@ class Equipment(models.Model): "Aerodrome", on_delete=models.PROTECT, related_name="based_equipment", - help_text="Home base for this equipment", + help_text="Starting location for this equipment", null=True, blank=True, ) cycles = models.PositiveIntegerField( - default=0, help_text="Total number of flight cycles (takeoff/landing pairs)" + default=0, help_text="Total number of flight cycles" ) air_time_hours = models.DecimalField( max_digits=10, @@ -42,8 +45,6 @@ class Equipment(models.Model): @property def current_location(self): """Get the current location of this equipment based on flight history.""" - from .flight import Flight - from django.utils import timezone now = timezone.now() @@ -59,5 +60,4 @@ class Equipment(models.Model): elif last_flight.arrival_time <= now: return last_flight.destination - # No flights yet or no flights have departed, equipment is at base return self.base_location diff --git a/simulator/models/flight.py b/simulator/models/flight.py index 980aa0b..4d004ba 100644 --- a/simulator/models/flight.py +++ b/simulator/models/flight.py @@ -141,3 +141,11 @@ class Flight(models.Model): class Route(models.Model): """regulary scheduled flights""" + + carrier = models.ForeignKey( + Carrier, on_delete=models.CASCADE, related_name="routes" + ) + name = models.CharField() + days_of_week = models.JSONField( + default=list, help_text="[0,1,2,3,4] for weekdays, [5,6] for weekends, etc" + ) diff --git a/simulator/models/flight.py.save b/simulator/models/flight.py.save new file mode 100644 index 0000000..4d004ba --- /dev/null +++ b/simulator/models/flight.py.save @@ -0,0 +1,151 @@ +from django.db import models +from .carrier import Carrier +from .aircraft import Equipment +from .aerodrome import Aerodrome +from django.core.validators import MinValueValidator, MaxValueValidator +from simulator.utils import haversine_nm +from datetime import timedelta +from django.core.exceptions import ValidationError +from django.utils import timezone +from decimal import Decimal + + +class Flight(models.Model): + carrier = models.ForeignKey( + Carrier, on_delete=models.CASCADE, related_name="flights" + ) + equipment = models.ForeignKey( + Equipment, on_delete=models.CASCADE, related_name="flights" + ) + origin = models.ForeignKey( + Aerodrome, on_delete=models.CASCADE, related_name="departures" + ) + destination = models.ForeignKey( + Aerodrome, on_delete=models.CASCADE, related_name="arrivals" + ) + + flight_number = models.PositiveIntegerField( + validators=[MinValueValidator(1), MaxValueValidator(9999)] + ) + departure_time = models.DateTimeField() + canceled = models.BooleanField(default=False) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["carrier", "flight_number"], name="unique_flight_per_carrier" + ) + ] + + def __str__(self): + return f"{self.carrier.icao}{self.flight_number} {self.origin.icao} > {self.destination.icao}" + + @property + def distance_nm(self) -> float: + return haversine_nm( + self.origin.latitude, + self.origin.longitude, + self.destination.latitude, + self.destination.longitude, + ) + + @property + def duration(self) -> timedelta: + speed = self.equipment.model.cruise_speed_kt + hours = self.distance_nm / speed + return timedelta(hours=hours) + + @property + def arrival_time(self): + return self.departure_time + self.duration + + def clean(self): + if self.origin == self.destination: + raise ValidationError("Origin and destination airports cannot be the same.") + + if self.equipment.owner != self.carrier: + raise ValidationError( + f"{self.equipment} is owned by {self.equipment.owner}, " + f"not {self.carrier}. Cannot assign equipment to this flight." + ) + + if self.distance_nm > self.equipment.model.range_nm: + raise ValidationError( + f"Flight distance ({self.distance_nm:.0f} nm) exceeds {self.equipment.model} " + f"maximum range ({self.equipment.model.range_nm} nm)." + ) + + previous_flights = Flight.objects.filter( + equipment=self.equipment, + departure_time__lt=self.departure_time, + canceled=False, + ).exclude(pk=self.pk) + + previous_flight = None + latest_arrival = None + for flight in previous_flights: + if latest_arrival is None or flight.arrival_time > latest_arrival: + latest_arrival = flight.arrival_time + previous_flight = flight + + if previous_flight: + if previous_flight.destination != self.origin: + raise ValidationError( + f"{self.equipment} will be at {previous_flight.destination.icao} " + f"after flight {previous_flight.carrier.icao}{previous_flight.flight_number}. " + f"Cannot depart from {self.origin.icao}." + ) + + if previous_flight.arrival_time > self.departure_time: + raise ValidationError( + f"{self.equipment} arrives at {self.origin.icao} at {previous_flight.arrival_time}. " + f"Cannot depart before arrival." + ) + else: + if ( + self.equipment.base_location + and self.equipment.base_location != self.origin + ): + raise ValidationError( + f"{self.equipment} is based at {self.equipment.base_location.icao}. " + f"First flight must depart from base location, not {self.origin.icao}." + ) + + arrival = self.arrival_time + + @property + def status(self): + if self.canceled: + return "Canceled" + now = timezone.now() + if now < self.departure_time: + return "Scheduled" + elif self.departure_time <= now < self.arrival_time: + return "In-Flight" + else: + return "Arrived" + + def save(self, *args, **kwargs): + """Override save to update equipment cycles and air time when flight completes.""" + is_new = self.pk is None + super().save(*args, **kwargs) + + if not is_new and self.status == "Arrived": + if not hasattr(self, "_stats_updated"): + flight_hours = Decimal(str(self.duration.total_seconds() / 3600)) + self.equipment.cycles += 1 + self.equipment.air_time_hours += flight_hours + self.equipment.save(update_fields=["cycles", "air_time_hours"]) + self._stats_updated = True + + +class Route(models.Model): + """regulary scheduled flights""" + + carrier = models.ForeignKey( + Carrier, on_delete=models.CASCADE, related_name="routes" + ) + name = models.CharField() + days_of_week = models.JSONField( + default=list, help_text="[0,1,2,3,4] for weekdays, [5,6] for weekends, etc" + )