mirror of
https://github.com/dcs-liberation/dcs_liberation.git
synced 2025-11-10 14:22:26 +00:00
The previous method of using a uniform scalar of the MSL wind speed for higher altitudes didn't offer enough control. In particular, the shape needs to be quite different to skew low, mid, high. This patch reworks that system so the parameters of each distribution are configured per-altitude level. To keep some continuity between altitudes (on a windy day, all levels should have higher wind speeds on average), the wind speed of the lower altitude will be added to the scale value of the higher altitude. Since it wasn't practical to approximate the previous behavior with the new system, this also handles the tuning of each. The low altitude speeds remain mostly unchanged (typically around 5 knots expect for thunderstorms), but the average speeds for other altitudes went up to more closely match the previous intent but without the massive overshoot. At 2000m wind speeds are typically in the 20-25 knot range now, and 8000m 30-50 knots. https://www.quora.com/What-is-the-average-wind-speed-at-different-altitudes has some of the source data, and Quora is the most authoritative source there is. It claims that cruise altitude winds can get "as high as 150 knots", but doesn't claim anything about the average. I had a surprisingly difficult time finding good data for cruise altitude air speeds for non-jet stream paths (though many of our maps are in jet streams), so I just eyeballed it from https://turbli.com/wind-during-flights/. https://github.com/dcs-liberation/dcs_liberation/issues/2861 Fixes https://github.com/dcs-liberation/dcs_liberation/issues/2863.
495 lines
15 KiB
Python
495 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import datetime
|
|
import logging
|
|
import math
|
|
import random
|
|
from dataclasses import dataclass, field
|
|
from typing import Optional, TYPE_CHECKING
|
|
|
|
from dcs.cloud_presets import Clouds as PydcsClouds
|
|
from dcs.weather import CloudPreset, Weather as PydcsWeather, Wind
|
|
|
|
from game.theater.daytimemap import DaytimeMap
|
|
from game.theater.seasonalconditions import determine_season
|
|
from game.timeofday import TimeOfDay
|
|
from game.utils import (
|
|
Distance,
|
|
Heading,
|
|
Pressure,
|
|
inches_hg,
|
|
interpolate,
|
|
knots,
|
|
meters,
|
|
Speed,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from game.settings import Settings
|
|
from game.theater import ConflictTheater
|
|
from game.theater.seasonalconditions import SeasonalConditions
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AtmosphericConditions:
|
|
#: Pressure at sea level.
|
|
qnh: Pressure
|
|
|
|
#: Temperature at sea level in Celcius.
|
|
temperature_celsius: float
|
|
|
|
#: Turbulence per 10 cm.
|
|
turbulence_per_10cm: float
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WindConditions:
|
|
at_0m: Wind
|
|
at_2000m: Wind
|
|
at_8000m: Wind
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WeibullWindSpeedParameters:
|
|
shape: float
|
|
scale: Speed
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Clouds:
|
|
base: int
|
|
density: int
|
|
thickness: int
|
|
precipitation: PydcsWeather.Preceptions
|
|
preset: Optional[CloudPreset] = field(default=None)
|
|
|
|
@classmethod
|
|
def random_preset(cls, rain: bool) -> Clouds:
|
|
clouds = (p.value for p in PydcsClouds)
|
|
if rain:
|
|
presets = [p for p in clouds if "Rain" in p.name]
|
|
else:
|
|
presets = [p for p in clouds if "Rain" not in p.name]
|
|
preset = random.choice(presets)
|
|
return Clouds(
|
|
base=random.randint(preset.min_base, preset.max_base),
|
|
density=0,
|
|
thickness=0,
|
|
precipitation=PydcsWeather.Preceptions.None_,
|
|
preset=preset,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Fog:
|
|
visibility: Distance
|
|
thickness: int
|
|
|
|
|
|
class Weather:
|
|
def __init__(
|
|
self,
|
|
seasonal_conditions: SeasonalConditions,
|
|
day: datetime.date,
|
|
time_of_day: TimeOfDay,
|
|
) -> None:
|
|
# Future improvement: Use theater, day and time of day
|
|
# to get a more realistic conditions
|
|
self.atmospheric = self.generate_atmospheric(
|
|
seasonal_conditions, day, time_of_day
|
|
)
|
|
self.clouds = self.generate_clouds()
|
|
self.fog = self.generate_fog()
|
|
self.wind = self.generate_wind()
|
|
|
|
def generate_atmospheric(
|
|
self,
|
|
seasonal_conditions: SeasonalConditions,
|
|
day: datetime.date,
|
|
time_of_day: TimeOfDay,
|
|
) -> AtmosphericConditions:
|
|
pressure = self.interpolate_summer_winter(
|
|
seasonal_conditions.summer_avg_pressure,
|
|
seasonal_conditions.winter_avg_pressure,
|
|
day,
|
|
)
|
|
temperature = self.interpolate_summer_winter(
|
|
seasonal_conditions.summer_avg_temperature,
|
|
seasonal_conditions.winter_avg_temperature,
|
|
day,
|
|
)
|
|
|
|
seasonal_turbulence = self.interpolate_seasonal_turbulence(
|
|
seasonal_conditions.high_avg_yearly_turbulence_per_10cm,
|
|
seasonal_conditions.low_avg_yearly_turbulence_per_10cm,
|
|
day,
|
|
)
|
|
|
|
day_turbulence = seasonal_conditions.solar_noon_turbulence_per_10cm
|
|
night_turbulence = seasonal_conditions.midnight_turbulence_per_10cm
|
|
time_of_day_turbulence = self.interpolate_solar_activity(
|
|
time_of_day, day_turbulence, night_turbulence
|
|
)
|
|
|
|
random_turbulence = random.normalvariate(mu=0, sigma=0.5)
|
|
|
|
turbulence = abs(
|
|
seasonal_turbulence + time_of_day_turbulence + random_turbulence
|
|
)
|
|
|
|
if time_of_day == TimeOfDay.Day:
|
|
temperature += seasonal_conditions.temperature_day_night_difference / 2
|
|
if time_of_day == TimeOfDay.Night:
|
|
temperature -= seasonal_conditions.temperature_day_night_difference / 2
|
|
pressure += self.pressure_adjustment
|
|
temperature += self.temperature_adjustment
|
|
turbulence += self.turbulence_adjustment
|
|
logging.debug(
|
|
"Weather: Before random: temp {} press {}".format(temperature, pressure)
|
|
)
|
|
conditions = AtmosphericConditions(
|
|
qnh=self.random_pressure(pressure),
|
|
temperature_celsius=self.random_temperature(temperature),
|
|
turbulence_per_10cm=turbulence,
|
|
)
|
|
logging.debug(
|
|
"Weather: After random: temp {} press {}".format(
|
|
conditions.temperature_celsius, conditions.qnh.pressure_in_inches_hg
|
|
)
|
|
)
|
|
return conditions
|
|
|
|
@property
|
|
def pressure_adjustment(self) -> float:
|
|
raise NotImplementedError
|
|
|
|
@property
|
|
def temperature_adjustment(self) -> float:
|
|
raise NotImplementedError
|
|
|
|
@property
|
|
def turbulence_adjustment(self) -> float:
|
|
raise NotImplementedError
|
|
|
|
def generate_clouds(self) -> Optional[Clouds]:
|
|
raise NotImplementedError
|
|
|
|
def generate_fog(self) -> Optional[Fog]:
|
|
if random.randrange(5) != 0:
|
|
return None
|
|
return Fog(
|
|
visibility=meters(random.randint(2500, 5000)),
|
|
thickness=random.randint(100, 500),
|
|
)
|
|
|
|
def generate_wind(self) -> WindConditions:
|
|
raise NotImplementedError
|
|
|
|
@staticmethod
|
|
def random_wind(
|
|
params_at_msl: WeibullWindSpeedParameters,
|
|
params_at_2000m: WeibullWindSpeedParameters,
|
|
params_at_8000m: WeibullWindSpeedParameters,
|
|
) -> WindConditions:
|
|
"""Generates random wind."""
|
|
wind_direction = Heading.random()
|
|
wind_direction_2000m = wind_direction + Heading.random(-90, 90)
|
|
wind_direction_8000m = wind_direction + Heading.random(-90, 90)
|
|
|
|
# The first parameter is the scale. 63.2% of all results will fall below that
|
|
# value.
|
|
# https://www.itl.nist.gov/div898/handbook/eda/section3/weibplot.htm
|
|
msl = random.weibullvariate(
|
|
params_at_msl.scale.meters_per_second, params_at_msl.shape
|
|
)
|
|
at_2000m = random.weibullvariate(
|
|
msl + params_at_2000m.scale.meters_per_second, params_at_2000m.shape
|
|
)
|
|
at_8000m = random.weibullvariate(
|
|
at_2000m + params_at_8000m.scale.meters_per_second, params_at_8000m.shape
|
|
)
|
|
|
|
# DCS is limited to 97 knots wind speed.
|
|
max_supported_wind_speed = knots(97).meters_per_second
|
|
|
|
return WindConditions(
|
|
# Always some wind to make the smoke move a bit.
|
|
at_0m=Wind(wind_direction.degrees, max(1.0, msl)),
|
|
at_2000m=Wind(
|
|
wind_direction_2000m.degrees,
|
|
min(max_supported_wind_speed, at_2000m),
|
|
),
|
|
at_8000m=Wind(
|
|
wind_direction_8000m.degrees,
|
|
min(max_supported_wind_speed, at_8000m),
|
|
),
|
|
)
|
|
|
|
@staticmethod
|
|
def random_cloud_base() -> int:
|
|
return random.randint(2000, 3000)
|
|
|
|
@staticmethod
|
|
def random_cloud_thickness() -> int:
|
|
return random.randint(100, 400)
|
|
|
|
@staticmethod
|
|
def random_pressure(average_pressure: float) -> Pressure:
|
|
# "Safe" constants based roughly on ME and viper altimeter.
|
|
# Units are inches of mercury.
|
|
SAFE_MIN = 28.4
|
|
SAFE_MAX = 30.9
|
|
# Use normalvariate to get normal distribution, more realistic than uniform
|
|
pressure = random.normalvariate(average_pressure, 0.1)
|
|
return inches_hg(max(SAFE_MIN, min(SAFE_MAX, pressure)))
|
|
|
|
@staticmethod
|
|
def random_temperature(average_temperature: float) -> float:
|
|
# "Safe" constants based roughly on ME.
|
|
# Temperatures are in Celcius.
|
|
SAFE_MIN = -12
|
|
SAFE_MAX = 49
|
|
# Use normalvariate to get normal distribution, more realistic than uniform
|
|
temperature = random.normalvariate(average_temperature, 2)
|
|
temperature = round(temperature)
|
|
return max(SAFE_MIN, min(SAFE_MAX, temperature))
|
|
|
|
@staticmethod
|
|
def interpolate_summer_winter(
|
|
summer_value: float, winter_value: float, day: datetime.date
|
|
) -> float:
|
|
day_of_year = day.timetuple().tm_yday
|
|
day_of_year_peak_summer = 183
|
|
distance_from_peak_summer = abs(-day_of_year_peak_summer + day_of_year)
|
|
winter_factor = distance_from_peak_summer / day_of_year_peak_summer
|
|
return interpolate(summer_value, winter_value, winter_factor, clamp=True)
|
|
|
|
@staticmethod
|
|
def interpolate_seasonal_turbulence(
|
|
high_value: float, low_value: float, day: datetime.date
|
|
) -> float:
|
|
day_of_year = day.timetuple().tm_yday
|
|
day_of_year_peak_summer = 183
|
|
distance_from_peak_summer = -day_of_year_peak_summer + day_of_year
|
|
|
|
amplitude = 0.5 * (high_value - low_value)
|
|
offset = amplitude + low_value
|
|
|
|
# A high peak in summer and winter, between high_value and low_value.
|
|
return (
|
|
amplitude * math.cos(4 * math.pi * distance_from_peak_summer / 365.25)
|
|
+ offset
|
|
)
|
|
|
|
@staticmethod
|
|
def interpolate_solar_activity(
|
|
time_of_day: TimeOfDay, high: float, low: float
|
|
) -> float:
|
|
|
|
scale: float = 0
|
|
|
|
match time_of_day:
|
|
case TimeOfDay.Dawn:
|
|
scale = 0.4
|
|
case TimeOfDay.Day:
|
|
scale = 1
|
|
case TimeOfDay.Dusk:
|
|
scale = 0.6
|
|
case TimeOfDay.Night:
|
|
scale = 0
|
|
|
|
return interpolate(value1=low, value2=high, factor=scale, clamp=True)
|
|
|
|
|
|
class ClearSkies(Weather):
|
|
@property
|
|
def pressure_adjustment(self) -> float:
|
|
return 0.22
|
|
|
|
@property
|
|
def temperature_adjustment(self) -> float:
|
|
return 3.0
|
|
|
|
@property
|
|
def turbulence_adjustment(self) -> float:
|
|
return 0.0
|
|
|
|
def generate_clouds(self) -> Optional[Clouds]:
|
|
return None
|
|
|
|
def generate_fog(self) -> Optional[Fog]:
|
|
return None
|
|
|
|
def generate_wind(self) -> WindConditions:
|
|
return self.random_wind(
|
|
WeibullWindSpeedParameters(1.5, knots(5)),
|
|
WeibullWindSpeedParameters(3.5, knots(20)),
|
|
WeibullWindSpeedParameters(6.4, knots(20)),
|
|
)
|
|
|
|
|
|
class Cloudy(Weather):
|
|
@property
|
|
def pressure_adjustment(self) -> float:
|
|
return 0.0
|
|
|
|
@property
|
|
def temperature_adjustment(self) -> float:
|
|
return 0.0
|
|
|
|
@property
|
|
def turbulence_adjustment(self) -> float:
|
|
return 0.75
|
|
|
|
def generate_clouds(self) -> Optional[Clouds]:
|
|
return Clouds.random_preset(rain=False)
|
|
|
|
def generate_fog(self) -> Optional[Fog]:
|
|
# DCS 2.7 says to not use fog with the cloud presets.
|
|
return None
|
|
|
|
def generate_wind(self) -> WindConditions:
|
|
return self.random_wind(
|
|
WeibullWindSpeedParameters(1.6, knots(6.5)),
|
|
WeibullWindSpeedParameters(3.5, knots(22)),
|
|
WeibullWindSpeedParameters(6.4, knots(18)),
|
|
)
|
|
|
|
|
|
class Raining(Weather):
|
|
@property
|
|
def pressure_adjustment(self) -> float:
|
|
return -0.22
|
|
|
|
@property
|
|
def temperature_adjustment(self) -> float:
|
|
return -3.0
|
|
|
|
@property
|
|
def turbulence_adjustment(self) -> float:
|
|
return 1.5
|
|
|
|
def generate_clouds(self) -> Optional[Clouds]:
|
|
return Clouds.random_preset(rain=True)
|
|
|
|
def generate_fog(self) -> Optional[Fog]:
|
|
# DCS 2.7 says to not use fog with the cloud presets.
|
|
return None
|
|
|
|
def generate_wind(self) -> WindConditions:
|
|
return self.random_wind(
|
|
WeibullWindSpeedParameters(2.6, knots(8)),
|
|
WeibullWindSpeedParameters(4.2, knots(20)),
|
|
WeibullWindSpeedParameters(6.4, knots(20)),
|
|
)
|
|
|
|
|
|
class Thunderstorm(Weather):
|
|
@property
|
|
def pressure_adjustment(self) -> float:
|
|
return 0.1
|
|
|
|
@property
|
|
def temperature_adjustment(self) -> float:
|
|
return -3.0
|
|
|
|
@property
|
|
def turbulence_adjustment(self) -> float:
|
|
return 3.0
|
|
|
|
def generate_clouds(self) -> Optional[Clouds]:
|
|
return Clouds(
|
|
base=self.random_cloud_base(),
|
|
density=random.randint(9, 10),
|
|
thickness=self.random_cloud_thickness(),
|
|
precipitation=PydcsWeather.Preceptions.Thunderstorm,
|
|
)
|
|
|
|
def generate_wind(self) -> WindConditions:
|
|
return self.random_wind(
|
|
WeibullWindSpeedParameters(6, knots(20)),
|
|
WeibullWindSpeedParameters(6.2, knots(20)),
|
|
WeibullWindSpeedParameters(6.4, knots(20)),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Conditions:
|
|
time_of_day: TimeOfDay
|
|
start_time: datetime.datetime
|
|
weather: Weather
|
|
|
|
@classmethod
|
|
def generate(
|
|
cls,
|
|
theater: ConflictTheater,
|
|
day: datetime.date,
|
|
time_of_day: TimeOfDay,
|
|
settings: Settings,
|
|
forced_time: datetime.time | None = None,
|
|
) -> Conditions:
|
|
# The time might be forced by the campaign for the first turn.
|
|
if forced_time is not None:
|
|
_start_time = datetime.datetime.combine(day, forced_time)
|
|
else:
|
|
_start_time = cls.generate_start_time(
|
|
theater, day, time_of_day, settings.night_disabled
|
|
)
|
|
|
|
return cls(
|
|
time_of_day=time_of_day,
|
|
start_time=_start_time,
|
|
weather=cls.generate_weather(theater.seasonal_conditions, day, time_of_day),
|
|
)
|
|
|
|
@classmethod
|
|
def generate_start_time(
|
|
cls,
|
|
theater: ConflictTheater,
|
|
day: datetime.date,
|
|
time_of_day: TimeOfDay,
|
|
night_disabled: bool,
|
|
) -> datetime.datetime:
|
|
if night_disabled:
|
|
logging.info("Skip Night mission due to user settings")
|
|
time_range = DaytimeMap(
|
|
dawn=(datetime.time(hour=8), datetime.time(hour=9)),
|
|
day=(datetime.time(hour=10), datetime.time(hour=12)),
|
|
dusk=(datetime.time(hour=12), datetime.time(hour=14)),
|
|
night=(datetime.time(hour=14), datetime.time(hour=17)),
|
|
).range_of(time_of_day)
|
|
else:
|
|
time_range = theater.daytime_map.range_of(time_of_day)
|
|
|
|
# Starting missions on the hour is a nice gameplay property, so keep the random
|
|
# time constrained to that. DaytimeMap enforces that we have only whole hour
|
|
# ranges for now, so we don't need to worry about accidentally changing the time
|
|
# of day by truncating sub-hours.
|
|
time = datetime.time(
|
|
hour=random.randint(time_range[0].hour, time_range[1].hour)
|
|
)
|
|
return datetime.datetime.combine(day, time)
|
|
|
|
@classmethod
|
|
def generate_weather(
|
|
cls,
|
|
seasonal_conditions: SeasonalConditions,
|
|
day: datetime.date,
|
|
time_of_day: TimeOfDay,
|
|
) -> Weather:
|
|
season = determine_season(day)
|
|
logging.debug("Weather: Season {}".format(season))
|
|
weather_chances = seasonal_conditions.weather_type_chances[season]
|
|
chances = {
|
|
Thunderstorm: weather_chances.thunderstorm,
|
|
Raining: weather_chances.raining,
|
|
Cloudy: weather_chances.cloudy,
|
|
ClearSkies: weather_chances.clear_skies,
|
|
}
|
|
logging.debug("Weather: Chances {}".format(weather_chances))
|
|
weather_type = random.choices(
|
|
list(chances.keys()), weights=list(chances.values())
|
|
)[0]
|
|
logging.debug("Weather: Type {}".format(weather_type))
|
|
return weather_type(seasonal_conditions, day, time_of_day)
|