dcs-retribution/game/weather.py
Dan Albert 73e0cbe182
Use weibull distribution for wind generation.
Wind speeds should not be uniformly distributed. This switches to a
Weibull distribution which allegedly (see the bug) is good enough.
Experimentally that seems true as well, though I know nothing about how
wind works irl. This at least looks like it'll generate reasonable
variation in missions while keeping the 1st through 3rd quartile
behaviors from getting out of hand.

I'm very uncertain about the scaling factor aspect of this. Naively the
wind speeds at different altitudes ought to be somewhat correlated, but
I'm not sure how much, and whether this kind of scaling is at all the
right way to do it. As before, meh, close enough?

Fixes https://github.com/dcs-liberation/dcs_liberation/issues/2861.
2023-05-18 13:29:02 +02:00

486 lines
15 KiB
Python

from __future__ import annotations
import datetime
import logging
import math
import random
from dataclasses import dataclass, field
from enum import Enum
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.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
class NightMissions(Enum):
DayAndNight = "nightmissions_nightandday"
OnlyDay = "nightmissions_onlyday"
OnlyNight = "nightmissions_onlynight"
@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 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(
weibull_shape: float,
weibull_scale_speed: Speed,
at_2000m_factor_range: tuple[float, float],
at_8000m_factor_range: tuple[float, float],
) -> 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(
weibull_scale_speed.meters_per_second, weibull_shape
)
at_2000m_factor = random.uniform(*at_2000m_factor_range)
at_8000m_factor = random.uniform(*at_8000m_factor_range)
# 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, msl * at_2000m_factor),
),
at_8000m=Wind(
wind_direction_8000m.degrees,
min(max_supported_wind_speed, msl * at_8000m_factor),
),
)
@staticmethod
def random_cloud_base() -> int:
return random.randint(1000, 5000)
@staticmethod
def random_cloud_thickness() -> int:
# values lower than 400m can generate clear skies in some cases
return random.randint(400, 2000)
@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(1.5, knots(5), (1, 1.8), (1.5, 2.5))
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(1.6, knots(6.5), (1, 1.8), (1.5, 2.5))
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(2.6, knots(12), (1, 1.7), (2.2, 2.8))
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(6, knots(20), (1, 2), (2.5, 3.5))
@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_day_missions
)
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_day_missions: NightMissions,
) -> datetime.datetime:
from game.theater import DaytimeMap
if night_day_missions == NightMissions.OnlyDay:
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)
elif night_day_missions == NightMissions.OnlyNight:
logging.info("Skip Day mission due to user settings")
time_range = DaytimeMap(
dawn=(datetime.time(hour=0), datetime.time(hour=3)),
day=(datetime.time(hour=3), datetime.time(hour=6)),
dusk=(datetime.time(hour=21), datetime.time(hour=22)),
night=(datetime.time(hour=22), datetime.time(hour=23)),
).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)