// Copyright Pacificao. All Rights Reserved. #include "AgrarianWeatherProviderSubsystem.h" #include "AgrarianGameState.h" #include "AgrarianPerformanceStats.h" #include "Dom/JsonObject.h" #include "Engine/World.h" #include "HttpModule.h" #include "Interfaces/IHttpResponse.h" #include "Kismet/GameplayStatics.h" #include "ProfilingDebugging/CpuProfilerTrace.h" #include "Serialization/JsonReader.h" #include "Serialization/JsonSerializer.h" bool UAgrarianWeatherProviderSubsystem::RequestWeatherForActiveGameState() { UWorld* World = GetWorld(); AAgrarianGameState* GameState = World ? World->GetGameState() : nullptr; if (!GameState || !GameState->HasAuthority()) { return false; } return RequestWeatherForTile(GameState->ActiveSolarTileId, GameState->ActiveTileLatitude, GameState->ActiveTileLongitude); } bool UAgrarianWeatherProviderSubsystem::RequestWeatherForTile(FName TileId, float Latitude, float Longitude) { SCOPE_CYCLE_COUNTER(STAT_AgrarianWeatherProviderRequest); TRACE_CPUPROFILER_EVENT_SCOPE(AgrarianWeatherProviderRequest); if (TileId == NAME_None) { return false; } UWorld* World = GetWorld(); if (!World || !World->GetAuthGameMode()) { return false; } AAgrarianGameState* GameState = World->GetGameState(); if (TryApplyCachedSnapshot(TileId, TEXT("open-meteo"), GameState)) { return true; } if (!bEnableLiveWeatherRequests) { return ApplyDeterministicFallbackWeather(TileId, Latitude, Longitude, GameState); } const FString Url = BuildOpenMeteoForecastUrl(TileId, Latitude, Longitude); TSharedRef Request = FHttpModule::Get().CreateRequest(); Request->SetURL(Url); Request->SetVerb(TEXT("GET")); Request->SetHeader(TEXT("Accept"), TEXT("application/json")); Request->SetHeader(TEXT("User-Agent"), TEXT("AgrarianGameMVP/0.1")); Request->OnProcessRequestComplete().BindUObject(this, &UAgrarianWeatherProviderSubsystem::OnOpenMeteoResponse, TileId, Latitude, Longitude); return Request->ProcessRequest(); } FString UAgrarianWeatherProviderSubsystem::BuildOpenMeteoForecastUrl(FName TileId, float Latitude, float Longitude) const { const float ClampedLatitude = FMath::Clamp(Latitude, -90.0f, 90.0f); const float ClampedLongitude = FMath::Clamp(Longitude, -180.0f, 180.0f); return FString::Printf( TEXT("%s?latitude=%.6f&longitude=%.6f¤t=temperature_2m,relative_humidity_2m,precipitation,rain,showers,snowfall,weather_code,cloud_cover,pressure_msl,wind_speed_10m&daily=temperature_2m_max,temperature_2m_min,weather_code,precipitation_sum,wind_speed_10m_max&forecast_days=1&timezone=auto"), *OpenMeteoForecastEndpoint, ClampedLatitude, ClampedLongitude); } bool UAgrarianWeatherProviderSubsystem::RequestNoaaNwsFallbackForTile(FName TileId, float Latitude, float Longitude) { if (!bEnableNoaaNwsFallbackForUSTiles || TileId == NAME_None || !IsNoaaNwsEligibleCoordinate(Latitude, Longitude)) { return false; } UWorld* World = GetWorld(); if (!World || !World->GetAuthGameMode()) { return false; } AAgrarianGameState* GameState = World->GetGameState(); if (TryApplyCachedSnapshot(TileId, TEXT("noaa-nws"), GameState)) { return true; } TSharedRef Request = FHttpModule::Get().CreateRequest(); Request->SetURL(BuildNoaaNwsPointsUrl(Latitude, Longitude)); Request->SetVerb(TEXT("GET")); Request->SetHeader(TEXT("Accept"), TEXT("application/geo+json")); Request->SetHeader(TEXT("User-Agent"), NoaaNwsUserAgent); Request->OnProcessRequestComplete().BindUObject(this, &UAgrarianWeatherProviderSubsystem::OnNoaaNwsPointsResponse, TileId, Latitude, Longitude); return Request->ProcessRequest(); } bool UAgrarianWeatherProviderSubsystem::IsNoaaNwsEligibleCoordinate(float Latitude, float Longitude) const { return Latitude >= 18.0f && Latitude <= 72.0f && Longitude >= -180.0f && Longitude <= -64.0f; } FString UAgrarianWeatherProviderSubsystem::BuildNoaaNwsPointsUrl(float Latitude, float Longitude) const { return FString::Printf( TEXT("%s/%.4f,%.4f"), *NoaaNwsPointsEndpoint, FMath::Clamp(Latitude, -90.0f, 90.0f), FMath::Clamp(Longitude, -180.0f, 180.0f)); } bool UAgrarianWeatherProviderSubsystem::ApplySnapshotToGameState(const FAgrarianWeatherProviderSnapshot& Snapshot, AAgrarianGameState* GameState) const { if (!Snapshot.bIsValid || !GameState || !GameState->HasAuthority()) { return false; } GameState->ApplyMappedWeatherInputs(MapSnapshotToAgrarianWeatherInputs(Snapshot)); return true; } FAgrarianMappedWeatherInputs UAgrarianWeatherProviderSubsystem::MapSnapshotToAgrarianWeatherInputs(const FAgrarianWeatherProviderSnapshot& Snapshot) const { FAgrarianMappedWeatherInputs MappedInputs; MappedInputs.TileId = Snapshot.TileId; MappedInputs.Latitude = Snapshot.Latitude; MappedInputs.Longitude = Snapshot.Longitude; MappedInputs.TemperatureC = Snapshot.CurrentTemperatureC; MappedInputs.DailyLowTemperatureC = Snapshot.DailyLowTemperatureC; MappedInputs.DailyHighTemperatureC = Snapshot.DailyHighTemperatureC; MappedInputs.PrecipitationMm = Snapshot.PrecipitationMm; MappedInputs.WindSpeedKmh = Snapshot.WindSpeedKmh; MappedInputs.CloudCoverPercent = Snapshot.CloudCoverPercent; MappedInputs.RelativeHumidityPercent = Snapshot.RelativeHumidityPercent; MappedInputs.PressureMslHpa = Snapshot.PressureMslHpa > 0.0f ? Snapshot.PressureMslHpa : 1013.25f; MappedInputs.VisibilityMeters = Snapshot.VisibilityMeters; MappedInputs.ProviderWeatherCode = Snapshot.WeatherCode; MappedInputs.MappedWeather = Snapshot.MappedWeather; MappedInputs.Provider = Snapshot.Provider; MappedInputs.ProviderTimestamp = Snapshot.ProviderTimestamp; MappedInputs.bHasProviderData = Snapshot.bIsValid; return MappedInputs; } bool UAgrarianWeatherProviderSubsystem::TryApplyCachedSnapshot(FName TileId, const FString& Provider, AAgrarianGameState* GameState) { if (!GameState || !GameState->HasAuthority()) { return false; } const FAgrarianCachedWeatherSnapshot* CachedSnapshot = ServerWeatherSnapshotCache.Find(MakeCacheKey(TileId, Provider)); if (!CachedSnapshot || !CachedSnapshot->IsFresh(FDateTime::UtcNow())) { return false; } LastSnapshot = CachedSnapshot->Snapshot; return ApplySnapshotToGameState(CachedSnapshot->Snapshot, GameState); } bool UAgrarianWeatherProviderSubsystem::HasFreshCachedSnapshot(FName TileId, const FString& Provider) const { const FAgrarianCachedWeatherSnapshot* CachedSnapshot = ServerWeatherSnapshotCache.Find(MakeCacheKey(TileId, Provider)); return CachedSnapshot && CachedSnapshot->IsFresh(FDateTime::UtcNow()); } void UAgrarianWeatherProviderSubsystem::ClearWeatherSnapshotCache() { ServerWeatherSnapshotCache.Empty(); } bool UAgrarianWeatherProviderSubsystem::ApplyDeterministicFallbackWeather(FName TileId, float Latitude, float Longitude, AAgrarianGameState* GameState) { SCOPE_CYCLE_COUNTER(STAT_AgrarianWeatherProviderParse); TRACE_CPUPROFILER_EVENT_SCOPE(AgrarianWeatherProviderFallback); if (!bEnableDeterministicFallbackWeather || !GameState || !GameState->HasAuthority() || TileId == NAME_None) { return false; } const FAgrarianWeatherProviderSnapshot Snapshot = BuildDeterministicFallbackSnapshot( TileId, Latitude, Longitude, GameState->ActiveDayOfYear, GameState->WorldHours); LastSnapshot = Snapshot; CacheSnapshot(Snapshot, WeatherSnapshotCacheTtlSeconds); return ApplySnapshotToGameState(Snapshot, GameState); } FAgrarianWeatherProviderSnapshot UAgrarianWeatherProviderSubsystem::BuildDeterministicFallbackSnapshot(FName TileId, float Latitude, float Longitude, int32 DayOfYear, float HourOfDay) const { const int32 SafeDay = FMath::Clamp(DayOfYear, 1, 366); const float SeasonalRadians = ((static_cast(SafeDay) - 172.0f) / 366.0f) * 2.0f * PI; const float LatitudeSeasonScale = FMath::Clamp(FMath::Abs(Latitude) / 60.0f, 0.0f, 1.0f); const float SeasonalTemperatureC = 12.0f + (FMath::Cos(SeasonalRadians) * 10.0f * LatitudeSeasonScale); const float DailySwingC = FMath::Lerp(4.0f, 10.0f, LatitudeSeasonScale); const float Noise = GetDeterministicWeatherNoise(TileId, SafeDay, 11); const float StormNoise = GetDeterministicWeatherNoise(TileId, SafeDay, 23); const float CloudNoise = GetDeterministicWeatherNoise(TileId, SafeDay, 37); const float WindNoise = GetDeterministicWeatherNoise(TileId, SafeDay, 53); FAgrarianWeatherProviderSnapshot Snapshot; Snapshot.TileId = TileId; Snapshot.Latitude = FMath::Clamp(Latitude, -90.0f, 90.0f); Snapshot.Longitude = FMath::Clamp(Longitude, -180.0f, 180.0f); Snapshot.Provider = TEXT("deterministic-fallback"); Snapshot.ProviderTimestamp = FString::Printf(TEXT("day-%03d-hour-%02d"), SafeDay, FMath::FloorToInt(FMath::Clamp(HourOfDay, 0.0f, 24.0f))); Snapshot.DailyLowTemperatureC = SeasonalTemperatureC - DailySwingC + FMath::Lerp(-2.0f, 2.0f, Noise); Snapshot.DailyHighTemperatureC = SeasonalTemperatureC + DailySwingC + FMath::Lerp(-2.0f, 2.0f, Noise); Snapshot.CurrentTemperatureC = (Snapshot.DailyLowTemperatureC + Snapshot.DailyHighTemperatureC) * 0.5f; Snapshot.CloudCoverPercent = FMath::Clamp(CloudNoise * 100.0f, 0.0f, 100.0f); Snapshot.RelativeHumidityPercent = FMath::Clamp(45.0f + (Snapshot.CloudCoverPercent * 0.35f) + FMath::Lerp(-10.0f, 10.0f, Noise), 10.0f, 100.0f); Snapshot.WindSpeedKmh = FMath::Clamp(5.0f + (WindNoise * 35.0f), 0.0f, 75.0f); Snapshot.PressureMslHpa = FMath::Clamp(1018.0f - (Snapshot.CloudCoverPercent * 0.12f) - (Snapshot.WindSpeedKmh * 0.08f), 960.0f, 1040.0f); Snapshot.PrecipitationMm = StormNoise > 0.72f ? FMath::Lerp(0.1f, 8.0f, StormNoise) : 0.0f; Snapshot.VisibilityMeters = FMath::Clamp(10000.0f - (Snapshot.CloudCoverPercent * 30.0f) - (Snapshot.PrecipitationMm * 300.0f), 250.0f, 10000.0f); Snapshot.WeatherCode = Snapshot.PrecipitationMm > 0.0f ? 61 : 0; Snapshot.MappedWeather = MapOpenMeteoWeatherCode(Snapshot.WeatherCode, Snapshot.PrecipitationMm, Snapshot.WindSpeedKmh); Snapshot.bIsValid = true; return Snapshot; } EAgrarianWeatherType UAgrarianWeatherProviderSubsystem::MapOpenMeteoWeatherCode(int32 WeatherCode, float PrecipitationMm, float WindSpeedKmh) { if (WeatherCode >= 95 || WindSpeedKmh >= 55.0f) { return EAgrarianWeatherType::Storm; } if (WeatherCode == 71 || WeatherCode == 73 || WeatherCode == 75 || WeatherCode == 77 || WeatherCode == 85 || WeatherCode == 86) { return EAgrarianWeatherType::ColdWind; } if ((WeatherCode >= 51 && WeatherCode <= 67) || (WeatherCode >= 80 && WeatherCode <= 82) || PrecipitationMm > 0.05f) { return EAgrarianWeatherType::Rain; } if (WindSpeedKmh >= 35.0f) { return EAgrarianWeatherType::ColdWind; } return EAgrarianWeatherType::Clear; } float UAgrarianWeatherProviderSubsystem::GetDeterministicWeatherNoise(FName TileId, int32 DayOfYear, int32 Salt) const { const FString SeedString = FString::Printf(TEXT("%s:%d:%d"), *TileId.ToString(), DayOfYear, Salt); return static_cast(GetTypeHash(SeedString) % 10000) / 9999.0f; } FString UAgrarianWeatherProviderSubsystem::MakeCacheKey(FName TileId, const FString& Provider) const { return FString::Printf(TEXT("%s:%s"), *Provider.ToLower(), *TileId.ToString()); } void UAgrarianWeatherProviderSubsystem::CacheSnapshot(const FAgrarianWeatherProviderSnapshot& Snapshot, float TimeToLiveSeconds) { if (!Snapshot.bIsValid || Snapshot.TileId == NAME_None || Snapshot.Provider.IsEmpty()) { return; } FAgrarianCachedWeatherSnapshot CachedSnapshot; CachedSnapshot.Snapshot = Snapshot; CachedSnapshot.CachedAtUtc = FDateTime::UtcNow(); CachedSnapshot.TimeToLiveSeconds = FMath::Max(60.0f, TimeToLiveSeconds); ServerWeatherSnapshotCache.Add(MakeCacheKey(Snapshot.TileId, Snapshot.Provider), CachedSnapshot); } void UAgrarianWeatherProviderSubsystem::OnOpenMeteoResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful, FName TileId, float Latitude, float Longitude) { if (!bWasSuccessful || !Response.IsValid() || Response->GetResponseCode() < 200 || Response->GetResponseCode() >= 300) { UWorld* World = GetWorld(); ApplyDeterministicFallbackWeather(TileId, Latitude, Longitude, World ? World->GetGameState() : nullptr); return; } FAgrarianWeatherProviderSnapshot Snapshot; if (!ParseOpenMeteoForecast(Response->GetContentAsString(), TileId, Latitude, Longitude, Snapshot)) { UWorld* World = GetWorld(); ApplyDeterministicFallbackWeather(TileId, Latitude, Longitude, World ? World->GetGameState() : nullptr); return; } LastSnapshot = Snapshot; CacheSnapshot(Snapshot, WeatherSnapshotCacheTtlSeconds); UWorld* World = GetWorld(); AAgrarianGameState* GameState = World ? World->GetGameState() : nullptr; ApplySnapshotToGameState(Snapshot, GameState); } void UAgrarianWeatherProviderSubsystem::OnNoaaNwsPointsResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful, FName TileId, float Latitude, float Longitude) { if (!bWasSuccessful || !Response.IsValid() || Response->GetResponseCode() < 200 || Response->GetResponseCode() >= 300) { return; } TSharedPtr RootObject; const TSharedRef> Reader = TJsonReaderFactory<>::Create(Response->GetContentAsString()); if (!FJsonSerializer::Deserialize(Reader, RootObject) || !RootObject.IsValid()) { return; } const TSharedPtr* PropertiesObject = nullptr; if (!RootObject->TryGetObjectField(TEXT("properties"), PropertiesObject) || !PropertiesObject || !PropertiesObject->IsValid()) { return; } FString ForecastGridDataUrl; if (!(*PropertiesObject)->TryGetStringField(TEXT("forecastGridData"), ForecastGridDataUrl) || ForecastGridDataUrl.IsEmpty()) { return; } LastNoaaNwsForecastGridDataUrl = ForecastGridDataUrl; TSharedRef GridRequest = FHttpModule::Get().CreateRequest(); GridRequest->SetURL(ForecastGridDataUrl); GridRequest->SetVerb(TEXT("GET")); GridRequest->SetHeader(TEXT("Accept"), TEXT("application/geo+json")); GridRequest->SetHeader(TEXT("User-Agent"), NoaaNwsUserAgent); GridRequest->OnProcessRequestComplete().BindUObject(this, &UAgrarianWeatherProviderSubsystem::OnNoaaNwsGridDataResponse, TileId, Latitude, Longitude); GridRequest->ProcessRequest(); } void UAgrarianWeatherProviderSubsystem::OnNoaaNwsGridDataResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful, FName TileId, float Latitude, float Longitude) { if (!bWasSuccessful || !Response.IsValid() || Response->GetResponseCode() < 200 || Response->GetResponseCode() >= 300) { UWorld* World = GetWorld(); ApplyDeterministicFallbackWeather(TileId, Latitude, Longitude, World ? World->GetGameState() : nullptr); return; } FAgrarianWeatherProviderSnapshot Snapshot; if (!ParseNoaaNwsGridData(Response->GetContentAsString(), TileId, Latitude, Longitude, Snapshot)) { UWorld* World = GetWorld(); ApplyDeterministicFallbackWeather(TileId, Latitude, Longitude, World ? World->GetGameState() : nullptr); return; } LastSnapshot = Snapshot; CacheSnapshot(Snapshot, WeatherSnapshotCacheTtlSeconds); UWorld* World = GetWorld(); AAgrarianGameState* GameState = World ? World->GetGameState() : nullptr; ApplySnapshotToGameState(Snapshot, GameState); } bool UAgrarianWeatherProviderSubsystem::ParseOpenMeteoForecast(const FString& ResponseContent, FName TileId, float Latitude, float Longitude, FAgrarianWeatherProviderSnapshot& OutSnapshot) const { SCOPE_CYCLE_COUNTER(STAT_AgrarianWeatherProviderParse); TRACE_CPUPROFILER_EVENT_SCOPE(AgrarianWeatherProviderParseOpenMeteo); TSharedPtr RootObject; const TSharedRef> Reader = TJsonReaderFactory<>::Create(ResponseContent); if (!FJsonSerializer::Deserialize(Reader, RootObject) || !RootObject.IsValid()) { return false; } const TSharedPtr* CurrentObject = nullptr; if (!RootObject->TryGetObjectField(TEXT("current"), CurrentObject) || !CurrentObject || !CurrentObject->IsValid()) { return false; } const TSharedPtr* DailyObject = nullptr; if (!RootObject->TryGetObjectField(TEXT("daily"), DailyObject) || !DailyObject || !DailyObject->IsValid()) { return false; } const TArray>* DailyLowValues = nullptr; const TArray>* DailyHighValues = nullptr; if (!(*DailyObject)->TryGetArrayField(TEXT("temperature_2m_min"), DailyLowValues) || !(*DailyObject)->TryGetArrayField(TEXT("temperature_2m_max"), DailyHighValues) || !DailyLowValues || !DailyHighValues || DailyLowValues->Num() == 0 || DailyHighValues->Num() == 0) { return false; } double CurrentTemperature = 0.0; double RelativeHumidity = 0.0; double Precipitation = 0.0; double Rain = 0.0; double Showers = 0.0; double Snowfall = 0.0; double WeatherCode = 0.0; double CloudCover = 0.0; double Pressure = 0.0; double WindSpeed = 0.0; (*CurrentObject)->TryGetNumberField(TEXT("temperature_2m"), CurrentTemperature); (*CurrentObject)->TryGetNumberField(TEXT("relative_humidity_2m"), RelativeHumidity); (*CurrentObject)->TryGetNumberField(TEXT("precipitation"), Precipitation); (*CurrentObject)->TryGetNumberField(TEXT("rain"), Rain); (*CurrentObject)->TryGetNumberField(TEXT("showers"), Showers); (*CurrentObject)->TryGetNumberField(TEXT("snowfall"), Snowfall); (*CurrentObject)->TryGetNumberField(TEXT("weather_code"), WeatherCode); (*CurrentObject)->TryGetNumberField(TEXT("cloud_cover"), CloudCover); (*CurrentObject)->TryGetNumberField(TEXT("pressure_msl"), Pressure); (*CurrentObject)->TryGetNumberField(TEXT("wind_speed_10m"), WindSpeed); OutSnapshot.TileId = TileId; OutSnapshot.Latitude = FMath::Clamp(Latitude, -90.0f, 90.0f); OutSnapshot.Longitude = FMath::Clamp(Longitude, -180.0f, 180.0f); OutSnapshot.Provider = TEXT("open-meteo"); (*CurrentObject)->TryGetStringField(TEXT("time"), OutSnapshot.ProviderTimestamp); OutSnapshot.CurrentTemperatureC = static_cast(CurrentTemperature); OutSnapshot.DailyLowTemperatureC = static_cast((*DailyLowValues)[0]->AsNumber()); OutSnapshot.DailyHighTemperatureC = static_cast((*DailyHighValues)[0]->AsNumber()); OutSnapshot.PrecipitationMm = static_cast(FMath::Max(Precipitation, FMath::Max(Rain + Showers, Snowfall))); OutSnapshot.WindSpeedKmh = static_cast(WindSpeed); OutSnapshot.CloudCoverPercent = static_cast(CloudCover); OutSnapshot.RelativeHumidityPercent = static_cast(RelativeHumidity); OutSnapshot.PressureMslHpa = static_cast(Pressure); OutSnapshot.VisibilityMeters = FMath::Clamp(10000.0f - (OutSnapshot.CloudCoverPercent * 35.0f) - (OutSnapshot.PrecipitationMm * 250.0f), 250.0f, 10000.0f); OutSnapshot.WeatherCode = FMath::RoundToInt(WeatherCode); OutSnapshot.MappedWeather = MapOpenMeteoWeatherCode(OutSnapshot.WeatherCode, OutSnapshot.PrecipitationMm, OutSnapshot.WindSpeedKmh); OutSnapshot.bIsValid = true; return true; } bool UAgrarianWeatherProviderSubsystem::ParseNoaaNwsGridData(const FString& ResponseContent, FName TileId, float Latitude, float Longitude, FAgrarianWeatherProviderSnapshot& OutSnapshot) const { SCOPE_CYCLE_COUNTER(STAT_AgrarianWeatherProviderParse); TRACE_CPUPROFILER_EVENT_SCOPE(AgrarianWeatherProviderParseNoaaNws); TSharedPtr RootObject; const TSharedRef> Reader = TJsonReaderFactory<>::Create(ResponseContent); if (!FJsonSerializer::Deserialize(Reader, RootObject) || !RootObject.IsValid()) { return false; } const TSharedPtr* PropertiesObject = nullptr; if (!RootObject->TryGetObjectField(TEXT("properties"), PropertiesObject) || !PropertiesObject || !PropertiesObject->IsValid()) { return false; } auto ReadFirstGridValue = [](const TSharedPtr& Properties, const TCHAR* FieldName, double& OutValue) -> bool { const TSharedPtr* FieldObject = nullptr; if (!Properties->TryGetObjectField(FieldName, FieldObject) || !FieldObject || !FieldObject->IsValid()) { return false; } const TArray>* Values = nullptr; if (!(*FieldObject)->TryGetArrayField(TEXT("values"), Values) || !Values || Values->Num() == 0) { return false; } const TSharedPtr FirstValue = (*Values)[0]->AsObject(); return FirstValue.IsValid() && FirstValue->TryGetNumberField(TEXT("value"), OutValue); }; double TemperatureC = 0.0; if (!ReadFirstGridValue(*PropertiesObject, TEXT("temperature"), TemperatureC)) { return false; } double PrecipitationPercent = 0.0; double WindSpeedKmh = 0.0; double RelativeHumidity = 0.0; double SkyCover = 0.0; double Pressure = 0.0; double Visibility = 0.0; ReadFirstGridValue(*PropertiesObject, TEXT("probabilityOfPrecipitation"), PrecipitationPercent); ReadFirstGridValue(*PropertiesObject, TEXT("windSpeed"), WindSpeedKmh); ReadFirstGridValue(*PropertiesObject, TEXT("relativeHumidity"), RelativeHumidity); ReadFirstGridValue(*PropertiesObject, TEXT("skyCover"), SkyCover); ReadFirstGridValue(*PropertiesObject, TEXT("pressure"), Pressure); ReadFirstGridValue(*PropertiesObject, TEXT("visibility"), Visibility); OutSnapshot.TileId = TileId; OutSnapshot.Latitude = FMath::Clamp(Latitude, -90.0f, 90.0f); OutSnapshot.Longitude = FMath::Clamp(Longitude, -180.0f, 180.0f); OutSnapshot.Provider = TEXT("noaa-nws"); OutSnapshot.ProviderTimestamp = FDateTime::UtcNow().ToIso8601(); OutSnapshot.CurrentTemperatureC = static_cast(TemperatureC); OutSnapshot.DailyLowTemperatureC = static_cast(TemperatureC - 4.0); OutSnapshot.DailyHighTemperatureC = static_cast(TemperatureC + 4.0); OutSnapshot.PrecipitationMm = PrecipitationPercent > 30.0 ? 0.1f : 0.0f; OutSnapshot.WindSpeedKmh = static_cast(WindSpeedKmh); OutSnapshot.CloudCoverPercent = static_cast(SkyCover); OutSnapshot.RelativeHumidityPercent = static_cast(RelativeHumidity); OutSnapshot.PressureMslHpa = Pressure > 0.0 ? static_cast(Pressure / 100.0) : 1013.25f; OutSnapshot.VisibilityMeters = Visibility > 0.0 ? static_cast(Visibility) : FMath::Clamp(10000.0f - (OutSnapshot.CloudCoverPercent * 35.0f), 250.0f, 10000.0f); OutSnapshot.WeatherCode = PrecipitationPercent > 30.0 ? 61 : 0; OutSnapshot.MappedWeather = MapOpenMeteoWeatherCode(OutSnapshot.WeatherCode, OutSnapshot.PrecipitationMm, OutSnapshot.WindSpeedKmh); OutSnapshot.bIsValid = true; return true; }