using TheLivingWorld.Core.Ecs; using TheLivingWorld.Core.Simulation; namespace TheLivingWorld.Tests; public sealed class WeatherModelTests { private const double Warsaw = 52.23; private const double Sydney = -33.87; private static readonly DateTime JanuaryNoon = new(2012, 1, 15, 12, 0, 0, DateTimeKind.Unspecified); private static readonly DateTime JulyNoon = new(2012, 7, 15, 12, 0, 0, DateTimeKind.Unspecified); [Fact] public void Seasons_run_the_other_way_below_the_equator() { Assert.True(WeatherModel.SeasonPhase(JulyNoon, Warsaw) > 0.8f); Assert.True(WeatherModel.SeasonPhase(JanuaryNoon, Warsaw) < -0.8f); Assert.True(WeatherModel.SeasonPhase(JulyNoon, Sydney) < -0.8f); Assert.True(WeatherModel.SeasonPhase(JanuaryNoon, Sydney) > 0.8f); } [Fact] public void The_day_peaks_in_the_afternoon_and_bottoms_out_before_dawn() { var afternoon = new DateTime(2012, 6, 1, 15, 0, 0, DateTimeKind.Unspecified); var beforeDawn = new DateTime(2012, 6, 1, 3, 0, 0, DateTimeKind.Unspecified); Assert.Equal(1f, WeatherModel.DiurnalPhase(afternoon), 3); Assert.Equal(-1f, WeatherModel.DiurnalPhase(beforeDawn), 3); } [Fact] public void Siberia_is_brutal_in_January_and_pleasant_in_July() { var climate = ClimateCatalog.Siberian; var winter = Calm(climate, 62.03, JanuaryNoon); var summer = Calm(climate, 62.03, JulyNoon); Assert.InRange(winter.TemperatureC, -45f, -20f); Assert.InRange(summer.TemperatureC, 8f, 30f); } [Fact] public void The_equator_barely_notices_the_calendar() { var climate = ClimateCatalog.Equatorial; var january = Calm(climate, 1.35, JanuaryNoon); var july = Calm(climate, 1.35, JulyNoon); Assert.InRange(MathF.Abs(january.TemperatureC - july.TemperatureC), 0f, 3f); Assert.InRange(january.TemperatureC, 20f, 36f); } [Fact] public void A_year_of_a_climate_averages_out_to_its_stated_mean() { foreach (var climate in ClimateCatalog.All) { var total = 0.0; var samples = 0; // Every six hours through a year, so both the seasonal and the daily curve are covered evenly. for (var hours = 0; hours < 365 * 24; hours += 6) { var moment = new DateTime(2012, 1, 1, 0, 0, 0, DateTimeKind.Unspecified).AddHours(hours); total += Calm(climate, Warsaw, moment).TemperatureC; samples++; } var mean = total / samples; Assert.True( Math.Abs(mean - climate.MeanTemperatureC) < 1.5, $"{climate.Label} averaged {mean:F1} °C against a stated mean of {climate.MeanTemperatureC} °C."); } } [Fact] public void A_deep_low_clouds_over_and_rains_while_a_high_stays_clear() { var climate = ClimateCatalog.CentralEuropean; var low = WeatherModel.Sample(climate, Warsaw, JulyNoon, anomalyHpa: -22f, 0f, 0f); var high = WeatherModel.Sample(climate, Warsaw, JulyNoon, anomalyHpa: 14f, 0f, 0f); Assert.True(low.CloudCover > high.CloudCover); Assert.True(low.PrecipitationMmH > 0f); Assert.Equal(0f, high.PrecipitationMmH); Assert.True(low.PressureHpa < high.PressureHpa); } [Fact] public void Precipitation_falls_as_snow_once_it_is_freezing() { var winterNight = new DateTime(2012, 1, 15, 2, 0, 0, DateTimeKind.Unspecified); var sample = WeatherModel.Sample(ClimateCatalog.Siberian, 62.03, winterNight, -25f, 0f, 0f); Assert.True(sample.TemperatureC < 0f); Assert.Contains( sample.Condition, new[] { WeatherCondition.Snow, WeatherCondition.HeavySnow, WeatherCondition.Blizzard }); } [Fact] public void A_dry_climate_under_a_gale_raises_a_sandstorm_rather_than_rain() { // A steep gradient with no depth to it: lots of wind, not enough convergence to cloud over. var sample = WeatherModel.Sample(ClimateCatalog.HotDesert, 30.05, JulyNoon, 2f, 30f, 0f); Assert.True(sample.WindSpeedMs > 11f); Assert.Equal(0f, sample.PrecipitationMmH); Assert.Equal(WeatherCondition.Sandstorm, sample.Condition); } [Fact] public void Wind_runs_along_the_isobars_and_the_hemisphere_decides_which_way() { // Pressure rising to the north. The along-isobar component flips between hemispheres, so the wind // arrives from the east in the north and from the west in the south. (They are not exactly opposite: // the friction term drags both towards the low regardless of hemisphere.) var north = WeatherModel.Sample(ClimateCatalog.Oceanic, Warsaw, JulyNoon, 0f, 0f, 20f); var south = WeatherModel.Sample(ClimateCatalog.Oceanic, Sydney, JulyNoon, 0f, 0f, 20f); Assert.InRange(north.WindDirectionDeg, 0f, 180f); Assert.InRange(south.WindDirectionDeg, 180f, 360f); } [Fact] public void A_steeper_gradient_means_a_stronger_wind() { var calm = WeatherModel.Sample(ClimateCatalog.Oceanic, Warsaw, JulyNoon, 0f, 0f, 0f); var breezy = WeatherModel.Sample(ClimateCatalog.Oceanic, Warsaw, JulyNoon, 0f, 0f, 10f); var gale = WeatherModel.Sample(ClimateCatalog.Oceanic, Warsaw, JulyNoon, 0f, 0f, 40f); Assert.Equal(ClimateCatalog.Oceanic.WindSpeedMs, calm.WindSpeedMs, 3); Assert.True(gale.WindSpeedMs > breezy.WindSpeedMs); Assert.True(breezy.WindSpeedMs > calm.WindSpeedMs); } [Fact] public void Wind_chill_bites_in_the_cold_and_humidity_bites_in_the_heat() { var freezing = WeatherModel.Sample(ClimateCatalog.Tundra, 68, JanuaryNoon, -10f, 15f, 0f); Assert.True(freezing.FeelsLikeC < freezing.TemperatureC); var muggy = WeatherModel.Sample(ClimateCatalog.Equatorial, 1.35, JulyNoon, -12f, 0f, 0f); Assert.True(muggy.TemperatureC > 26f); Assert.True(muggy.FeelsLikeC > muggy.TemperatureC); } [Fact] public void An_overcast_sky_flattens_the_daily_temperature_swing() { var climate = ClimateCatalog.ColdSteppe; var afternoon = new DateTime(2012, 7, 15, 15, 0, 0, DateTimeKind.Unspecified); var beforeDawn = new DateTime(2012, 7, 15, 3, 0, 0, DateTimeKind.Unspecified); var clearSwing = Calm(climate, Warsaw, afternoon).TemperatureC - Calm(climate, Warsaw, beforeDawn).TemperatureC; var cloudyDay = WeatherModel.Sample(climate, Warsaw, afternoon, -20f, 0f, 0f).TemperatureC; var cloudyNight = WeatherModel.Sample(climate, Warsaw, beforeDawn, -20f, 0f, 0f).TemperatureC; Assert.True(clearSwing > cloudyDay - cloudyNight); } [Fact] public void The_wet_season_sits_where_the_preset_says_it_does() { // The monsoon peaks just after midsummer; the Mediterranean does its raining in winter. var monsoonSummer = WeatherModel.WetSeasonFactor(ClimateCatalog.TropicalMonsoon, JulyNoon, 19.08); var monsoonWinter = WeatherModel.WetSeasonFactor(ClimateCatalog.TropicalMonsoon, JanuaryNoon, 19.08); Assert.True(monsoonSummer > monsoonWinter); var medSummer = WeatherModel.WetSeasonFactor(ClimateCatalog.Mediterranean, JulyNoon, 41.39); var medWinter = WeatherModel.WetSeasonFactor(ClimateCatalog.Mediterranean, JanuaryNoon, 41.39); Assert.True(medWinter > medSummer); } [Fact] public void A_pressure_system_pulls_the_field_towards_itself_and_fades_at_the_edges() { PressureSystem[] systems = [ new(X: 0.5f, Y: 0.5f, VelocityX: 0f, VelocityY: 0f, IntensityHpa: -20f, Radius: 0.3f, AgeHours: 10f, LifetimeHours: 40f), ]; var centre = WeatherModel.SampleField(systems, 0.5f, 0.5f); var edge = WeatherModel.SampleField(systems, 1.5f, 0.5f); Assert.InRange(centre.Anomaly, -21f, -19f); Assert.InRange(edge.Anomaly, -0.5f, 0f); // Pressure climbs as you leave the low, so the gradient east of centre points east. var offCentre = WeatherModel.SampleField(systems, 0.65f, 0.5f); Assert.True(offCentre.GradientX > 0f); } [Fact] public void Systems_fade_in_and_out_instead_of_popping() { var born = new PressureSystem(0.5f, 0.5f, 0f, 0f, -20f, 0.3f, AgeHours: 0f, LifetimeHours: 40f); var grown = born with { AgeHours = 20f }; var dying = born with { AgeHours = 40f }; Assert.Equal(0f, WeatherModel.Envelope(born), 3); Assert.Equal(1f, WeatherModel.Envelope(grown), 3); Assert.Equal(0f, WeatherModel.Envelope(dying), 3); } [Fact] public void Snow_piles_up_below_freezing_and_melts_above_it() { var afterAnHour = WeatherModel.UpdateSnowDepth(0f, temperatureC: -4f, precipitationMmH: 2f, 1f); Assert.Equal(20f, afterAnHour, 1); // Rain at the same rate leaves nothing lying. Assert.Equal(0f, WeatherModel.UpdateSnowDepth(0f, 6f, 2f, 1f), 1); var thawed = WeatherModel.UpdateSnowDepth(afterAnHour, temperatureC: 8f, precipitationMmH: 0f, 2f); Assert.True(thawed < afterAnHour); } [Fact] public void Snow_depth_never_leaves_its_bounds() { Assert.Equal(0f, WeatherModel.UpdateSnowDepth(5f, temperatureC: 30f, 0f, elapsedHours: 100f)); Assert.Equal( WeatherModel.MaxSnowDepthMm, WeatherModel.UpdateSnowDepth(0f, temperatureC: -20f, precipitationMmH: 40f, elapsedHours: 100f)); // A zero-length step still normalises a value that arrived out of range from storage. Assert.Equal(0f, WeatherModel.UpdateSnowDepth(-5f, -10f, 0f, 0f)); } [Fact] public void A_world_opened_in_deep_winter_already_has_snow_on_the_ground() { var siberianWinter = WeatherModel.SeasonalSnowDepth(ClimateCatalog.Siberian, 62.03, JanuaryNoon); var siberianSummer = WeatherModel.SeasonalSnowDepth(ClimateCatalog.Siberian, 62.03, JulyNoon); Assert.True(siberianWinter > WeatherModel.FullCoverDepthMm); Assert.Equal(0f, siberianSummer); // Nowhere warm ever starts under snow, whatever the month. Assert.Equal(0f, WeatherModel.SeasonalSnowDepth(ClimateCatalog.Equatorial, 1.35, JanuaryNoon)); Assert.Equal(0f, WeatherModel.SeasonalSnowDepth(ClimateCatalog.HotDesert, 30.05, JanuaryNoon)); } [Fact] public void The_seasonal_snow_line_follows_the_hemisphere() { // Same climate, opposite hemispheres: the snow is on the ground in opposite months. var north = WeatherModel.SeasonalSnowDepth(ClimateCatalog.Tundra, 68, JanuaryNoon); var south = WeatherModel.SeasonalSnowDepth(ClimateCatalog.Tundra, -68, JanuaryNoon); Assert.True(north > 0f); Assert.True(north > south); } private static WeatherSample Calm(ClimatePreset climate, double latitude, DateTime moment) => WeatherModel.Sample(climate, latitude, moment, anomalyHpa: 0f, gradientX: 0f, gradientY: 0f); }