From 0e139b86406040dfd9fb92bec2608f75d66dabbc Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 16 May 2023 00:05:29 -0700 Subject: [PATCH] Make wind speed moddable. These should probably be overridable per theater and per season, but even with that we'll want some defaults. https://github.com/dcs-liberation/dcs_liberation/issues/2862 --- changelog.md | 5 + docs/modding/index.rst | 3 +- docs/modding/weather.rst | 76 +++++++++++++ game/weather/conditions.py | 4 +- game/weather/weather.py | 107 +++++------------- game/weather/weatherarchetype.py | 58 ++++++++++ game/weather/windspeedgenerators.py | 95 ++++++++++++++++ resources/weather/archetypes/clear.yaml | 14 +++ resources/weather/archetypes/cloudy.yaml | 14 +++ resources/weather/archetypes/raining.yaml | 14 +++ .../weather/archetypes/thunderstorm.yaml | 14 +++ 11 files changed, 321 insertions(+), 83 deletions(-) create mode 100644 docs/modding/weather.rst create mode 100644 game/weather/weatherarchetype.py create mode 100644 game/weather/windspeedgenerators.py create mode 100644 resources/weather/archetypes/clear.yaml create mode 100644 resources/weather/archetypes/cloudy.yaml create mode 100644 resources/weather/archetypes/raining.yaml create mode 100644 resources/weather/archetypes/thunderstorm.yaml diff --git a/changelog.md b/changelog.md index 2c515112..dec424de 100644 --- a/changelog.md +++ b/changelog.md @@ -140,6 +140,11 @@ Saves from 6.x are not compatible with 7.0. * **[Modding]** Add support for VSN F-4B and F-4C mod. * **[Modding]** Aircraft task capabilities and preferred aircraft for each task are now moddable in the aircraft unit yaml files. Each aircraft has a weight per task. Higher weights are given higher preference. * **[Modding]** The `mission_types` field in squadron files has been removed. Squadron task capability is now determined by airframe, and the auto-assignable list has always been overridden by the campaign settings. +* **[Modding]** Wind speed generation inputs are now moddable. See https://dcs-liberation.rtfd.io/en/latest/modding/weather.html. +* **[New Game Wizard]** Choices for some options will be remembered for the next new game. Not all settings will be preserved, as many are campaign dependent. +* **[New Game Wizard]** Lua plugins can now be set while creating a new game. +* **[New Game Wizard]** Squadrons can be directly replaced with a preset during air wing configuration rather than needing to remove and create a new squadron. +* **[New Game Wizard]** Squadron liveries can now be selected during air wing configuration. * **[Squadrons]** Squadron-specific mission capability lists no longer restrict players from assigning missions outside the squadron's preferences. * **[New Game Wizard]** Squadrons can be directly replaced with a preset during air wing configuration rather than needing to remove and create a new squadron. * **[UI]** The orientation of objects like SAMs, EWRs, garrisons, and ships can now be manually adjusted. diff --git a/docs/modding/index.rst b/docs/modding/index.rst index 81e41a0a..645f2c06 100644 --- a/docs/modding/index.rst +++ b/docs/modding/index.rst @@ -6,4 +6,5 @@ Modding guide :caption: Contents: fuel-consumption-measurement.md - layouts.rst \ No newline at end of file + layouts.rst + weather.rst diff --git a/docs/modding/weather.rst b/docs/modding/weather.rst new file mode 100644 index 00000000..cb11a1af --- /dev/null +++ b/docs/modding/weather.rst @@ -0,0 +1,76 @@ +####### +Weather +####### + +Weather conditions in DCS Liberation are randomly generated at the start of each +turn. Some of the inputs to that generator (more to come) can be controlled via +the config files in ``resources/weather``. + +********** +Archetypes +********** + +A weather archetype defines the the conditions for a style of weather, such as +"clear", or "raining". There are currently four archetypes: + +1. clear +2. cloudy +3. raining +4. thunderstorm + +The odds of each archetype appearing in each season are defined in the theater +yaml files (``resources/theaters/*/info.yaml``). + +.. literalinclude:: ../../resources/weather/archetypes/clear.yaml + :language: yaml + :linenos: + :caption: resources/weather/archetypes/clear.yaml + +Wind speeds +=========== + +DCS missions define wind with a speed and heading at each of three altitudes: + +1. MSL +2. 2000 meters +3. 8000 meters + +Blending between each altitude band is done in a manner defined by DCS. + +Liberation randomly generates a direction for the wind at MSL, and each other +altitude band will have wind within +/- 90 degrees of that heading. + +Wind speeds can be modded by altering the ``speed`` dict in the archetype yaml. +The only random distribution currently supported is the Weibull distribution, so +all archetypes currently use: + +.. code:: yaml + + speed: + weibull: + ... + +The Weibull distribution has two parameters: a shape and a scale. + +The scale is simplest to understand. 63.2% of all outcomes of the distribution +are below the scale parameter. + +The shape controls where the peak of the distribution is. See the examples in +the links below for illustrations and guidelines, but generally speaking low +values (between 1 and 2.6) will cause low speeds to be more common, medium +values (around 3) will be fairly evenly distributed around the median, and high +values (greater than 3.7) will cause high speeds to be more common. As wind +speeds tend to be higher at higher altitudes and fairly slow close to the +ground, you typically want a low value for MSL, a medium value for 2000m, and a +high value for 8000m. + +For examples, see https://statisticsbyjim.com/probability/weibull-distribution/. +To experiment with different inputs, use Wolfram Alpha, e.g. +https://www.wolframalpha.com/input?i=weibull+distribution+1.5+5. + +When generating wind speeds, each subsequent altitude band will have the lower +band's speed added to its scale parameter. That is, for the example above, the +actual scale parameter of ``at_2000m`` will be ``20 + wind speed at MSL``, and +the scale parameter of ``at_8000m`` will be ``20 + wind speed at 2000m``. This +is to ensure that a generally windy day (high wind speed at MSL) will create +similarly high winds at higher altitudes and vice versa. diff --git a/game/weather/conditions.py b/game/weather/conditions.py index 9eeeccc2..2737d60c 100644 --- a/game/weather/conditions.py +++ b/game/weather/conditions.py @@ -79,7 +79,9 @@ class Conditions: season = determine_season(day) logging.debug("Weather: Season {}".format(season)) weather_chances = seasonal_conditions.weather_type_chances[season] - chances = { + chances: dict[ + type[ClearSkies] | type[Cloudy] | type[Raining] | type[Thunderstorm], float + ] = { Thunderstorm: weather_chances.thunderstorm, Raining: weather_chances.raining, Cloudy: weather_chances.cloudy, diff --git a/game/weather/weather.py b/game/weather/weather.py index 2df076ba..5e66f66a 100644 --- a/game/weather/weather.py +++ b/game/weather/weather.py @@ -4,25 +4,23 @@ import datetime import logging import math import random -from dataclasses import dataclass from enum import Enum +from abc import ABC, abstractmethod from typing import Optional, TYPE_CHECKING -from dcs.weather import Weather as PydcsWeather, Wind +from dcs.weather import Weather as PydcsWeather from game.timeofday import TimeOfDay from game.utils import ( - Heading, Pressure, inches_hg, interpolate, - knots, meters, - Speed, ) from game.weather.atmosphericconditions import AtmosphericConditions from game.weather.clouds import Clouds from game.weather.fog import Fog +from game.weather.weatherarchetype import WeatherArchetype, WeatherArchetypes from game.weather.wind import WindConditions if TYPE_CHECKING: @@ -35,13 +33,7 @@ class NightMissions(Enum): OnlyNight = "nightmissions_onlynight" -@dataclass(frozen=True) -class WeibullWindSpeedParameters: - shape: float - scale: Speed - - -class Weather: +class Weather(ABC): def __init__( self, seasonal_conditions: SeasonalConditions, @@ -114,6 +106,11 @@ class Weather: ) return conditions + @property + @abstractmethod + def archetype(self) -> WeatherArchetype: + ... + @property def pressure_adjustment(self) -> float: raise NotImplementedError @@ -138,47 +135,7 @@ class Weather: ) 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), - ), - ) + return self.archetype.wind_parameters.speed.random_wind() @staticmethod def random_cloud_base() -> int: @@ -257,6 +214,10 @@ class Weather: class ClearSkies(Weather): + @property + def archetype(self) -> WeatherArchetype: + return WeatherArchetypes.with_id("clear") + @property def pressure_adjustment(self) -> float: return 0.22 @@ -275,15 +236,12 @@ class ClearSkies(Weather): 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 archetype(self) -> WeatherArchetype: + return WeatherArchetypes.with_id("cloudy") + @property def pressure_adjustment(self) -> float: return 0.0 @@ -303,15 +261,12 @@ class Cloudy(Weather): # 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 archetype(self) -> WeatherArchetype: + return WeatherArchetypes.with_id("raining") + @property def pressure_adjustment(self) -> float: return -0.22 @@ -331,15 +286,12 @@ class Raining(Weather): # 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 archetype(self) -> WeatherArchetype: + return WeatherArchetypes.with_id("thunderstorm") + @property def pressure_adjustment(self) -> float: return 0.1 @@ -359,10 +311,3 @@ class Thunderstorm(Weather): 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)), - ) diff --git a/game/weather/weatherarchetype.py b/game/weather/weatherarchetype.py new file mode 100644 index 00000000..ebc36953 --- /dev/null +++ b/game/weather/weatherarchetype.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +from .windspeedgenerators import WindSpeedGenerator + + +@dataclass(frozen=True) +class WindParameters: + speed: WindSpeedGenerator + + @staticmethod + def from_data(data: dict[str, Any]) -> WindParameters: + return WindParameters(speed=WindSpeedGenerator.from_data(data["speed"])) + + +@dataclass(frozen=True) +class WeatherArchetype: + id: str + wind_parameters: WindParameters + + @staticmethod + def from_data(data: dict[str, Any]) -> WeatherArchetype: + return WeatherArchetype( + id=data["id"], wind_parameters=WindParameters.from_data(data["wind"]) + ) + + @staticmethod + def from_yaml(path: Path) -> WeatherArchetype: + with path.open(encoding="utf-8") as yaml_file: + data = yaml.safe_load(yaml_file) + return WeatherArchetype.from_data(data) + + +class WeatherArchetypes: + _by_id: dict[str, WeatherArchetype] | None = None + + @classmethod + def with_id(cls, ident: str) -> WeatherArchetype: + if cls._by_id is None: + cls._by_id = cls.load() + return cls._by_id[ident] + + @staticmethod + def load() -> dict[str, WeatherArchetype]: + by_id = {} + for path in Path("resources/weather/archetypes").glob("*.yaml"): + archetype = WeatherArchetype.from_yaml(path) + if archetype.id in by_id: + raise RuntimeError( + f"Found duplicate weather archetype ID: {archetype.id}" + ) + by_id[archetype.id] = archetype + return by_id diff --git a/game/weather/windspeedgenerators.py b/game/weather/windspeedgenerators.py new file mode 100644 index 00000000..2afce3d6 --- /dev/null +++ b/game/weather/windspeedgenerators.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import random +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any + +from dcs.weather import Wind + +from game.utils import Speed, knots, Heading +from .wind import WindConditions + + +@dataclass(frozen=True) +class WeibullWindSpeedParameters: + shape: float + scale: Speed + + @staticmethod + def from_data(data: dict[str, Any]) -> WeibullWindSpeedParameters: + return WeibullWindSpeedParameters( + shape=data["shape"], scale=knots(data["scale_kts"]) + ) + + +class WindSpeedGenerator(ABC): + @abstractmethod + def random_wind(self) -> WindConditions: + ... + + @staticmethod + def from_data(data: dict[str, Any]) -> WindSpeedGenerator: + if len(data) != 1: + raise ValueError( + f"Wind speed dict has wrong number of keys ({len(data)}). Expected 1." + ) + name = list(data.keys())[0] + match name: + case "weibull": + return WeibullWindSpeedGenerator.from_data(data["weibull"]) + raise KeyError(f"Unknown wind speed generator type: {name}") + + +class WeibullWindSpeedGenerator(WindSpeedGenerator): + def __init__( + self, + at_msl: WeibullWindSpeedParameters, + at_2000m: WeibullWindSpeedParameters, + at_8000m: WeibullWindSpeedParameters, + ) -> None: + self.at_msl = at_msl + self.at_2000m = at_2000m + self.at_8000m = at_8000m + + def random_wind(self) -> WindConditions: + 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( + self.at_msl.scale.meters_per_second, self.at_msl.shape + ) + at_2000m = random.weibullvariate( + msl + self.at_2000m.scale.meters_per_second, self.at_2000m.shape + ) + at_8000m = random.weibullvariate( + at_2000m + self.at_8000m.scale.meters_per_second, self.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 from_data(data: dict[str, Any]) -> WindSpeedGenerator: + return WeibullWindSpeedGenerator( + at_msl=WeibullWindSpeedParameters.from_data(data["at_msl"]), + at_2000m=WeibullWindSpeedParameters.from_data(data["at_2000m"]), + at_8000m=WeibullWindSpeedParameters.from_data(data["at_8000m"]), + ) diff --git a/resources/weather/archetypes/clear.yaml b/resources/weather/archetypes/clear.yaml new file mode 100644 index 00000000..d7d809bc --- /dev/null +++ b/resources/weather/archetypes/clear.yaml @@ -0,0 +1,14 @@ +--- +id: clear +wind: + speed: + weibull: + at_msl: + shape: 1.5 + scale_kts: 5 + at_2000m: + shape: 3.5 + scale_kts: 20 + at_8000m: + shape: 6.4 + scale_kts: 20 diff --git a/resources/weather/archetypes/cloudy.yaml b/resources/weather/archetypes/cloudy.yaml new file mode 100644 index 00000000..9c7ea6c9 --- /dev/null +++ b/resources/weather/archetypes/cloudy.yaml @@ -0,0 +1,14 @@ +--- +id: cloudy +wind: + speed: + weibull: + at_msl: + shape: 1.6 + scale_kts: 6.5 + at_2000m: + shape: 3.5 + scale_kts: 22 + at_8000m: + shape: 6.4 + scale_kts: 18 diff --git a/resources/weather/archetypes/raining.yaml b/resources/weather/archetypes/raining.yaml new file mode 100644 index 00000000..7f6de023 --- /dev/null +++ b/resources/weather/archetypes/raining.yaml @@ -0,0 +1,14 @@ +--- +id: raining +wind: + speed: + weibull: + at_msl: + shape: 2.6 + scale_kts: 8 + at_2000m: + shape: 4.2 + scale_kts: 20 + at_8000m: + shape: 6.4 + scale_kts: 20 diff --git a/resources/weather/archetypes/thunderstorm.yaml b/resources/weather/archetypes/thunderstorm.yaml new file mode 100644 index 00000000..fbccb672 --- /dev/null +++ b/resources/weather/archetypes/thunderstorm.yaml @@ -0,0 +1,14 @@ +--- +id: thunderstorm +wind: + speed: + weibull: + at_msl: + shape: 6 + scale_kts: 20 + at_2000m: + shape: 6.2 + scale_kts: 20 + at_8000m: + shape: 6.4 + scale_kts: 20