From 4abf1af64752386980bb1fe5097c88c1bb0c5442 Mon Sep 17 00:00:00 2001 From: Ermilov Aleksei Date: Wed, 5 Aug 2026 15:49:10 +0500 Subject: [PATCH 01/10] Integrations: add WSA-ENLIL client and animation functionality --- .gitignore | 3 + Directory.Build.props | 3 +- Directory.Packages.props | 1 + samples/NoaaClientSample/Program.cs | 38 ++++++++++ src/NoaaClient/NoaaClient.csproj | 1 + src/NoaaClient/ServiceCollectionExtensions.cs | 2 + src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs | 17 +++++ .../Responses/WsaEnlilManifestEntry.cs | 11 +++ src/NoaaClient/WsaEnlil/WsaEnlilClient.cs | 72 +++++++++++++++++++ 9 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs create mode 100644 src/NoaaClient/WsaEnlil/Responses/WsaEnlilManifestEntry.cs create mode 100644 src/NoaaClient/WsaEnlil/WsaEnlilClient.cs diff --git a/.gitignore b/.gitignore index 4a6afa6..f776852 100644 --- a/.gitignore +++ b/.gitignore @@ -354,3 +354,6 @@ MigrationBackup/ # JetBrains IDEA files .idea/ + +# Serena MCP files +.serena/ diff --git a/Directory.Build.props b/Directory.Build.props index e92a1c5..7f7c68f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -10,6 +10,7 @@ 9999 True True + high $(NoWarn) @@ -28,7 +29,7 @@ - 1.1.1 + 1.2.0 diff --git a/Directory.Packages.props b/Directory.Packages.props index 676b3ee..33c8d77 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,6 +19,7 @@ + diff --git a/samples/NoaaClientSample/Program.cs b/samples/NoaaClientSample/Program.cs index b698f3c..955667b 100644 --- a/samples/NoaaClientSample/Program.cs +++ b/samples/NoaaClientSample/Program.cs @@ -2,6 +2,7 @@ using AuroraScienceHub.Integrations.NoaaClient; using AuroraScienceHub.Integrations.NoaaClient.Ace; +using AuroraScienceHub.Integrations.NoaaClient.WsaEnlil; using AuroraScienceHub.Integrations.NoaaClient.KpIndex; using AuroraScienceHub.Integrations.NoaaClient.Rtsw; using AuroraScienceHub.Integrations.Samples.NoaaClientSample; @@ -20,6 +21,7 @@ var aceClient = host.Services.GetRequiredService(); var kpIndexClient = host.Services.GetRequiredService(); var rtswClient = host.Services.GetRequiredService(); +var wsaEnlilClient = host.Services.GetRequiredService(); // Display header AnsiConsole.Write(new FigletText("NOAA Client").Color(Color.Blue)); @@ -40,6 +42,8 @@ "KP Index 27-Day Forecast", "KP Index 3-Day Forecast", "KP Index Nowcast", + "WSA-ENLIL Animation", + "WSA-ENLIL Animation (small, 320px)", new string('-', 30), "Execute All Requests", "Exit")); @@ -99,6 +103,18 @@ await AnsiConsole.Status() var data = await kpIndexClient.GetKpIndexNowcastAsync(CancellationToken.None); OutputFormatter.DisplayKpNowcast(data, 10); }), + "WSA-ENLIL Animation" => FetchAndDisplay(async () => + { + var gifBytes = await wsaEnlilClient.GetEnlilAnimationAsync( + maxWidth: 480, cancellationToken: CancellationToken.None); + await SaveAnimationAsync(gifBytes, "enlil_animation.gif"); + }), + "WSA-ENLIL Animation (small, 320px)" => FetchAndDisplay(async () => + { + var gifBytes = await wsaEnlilClient.GetEnlilAnimationAsync( + maxWidth: 320, cancellationToken: CancellationToken.None); + await SaveAnimationAsync(gifBytes, "enlil_animation_small.gif"); + }), "Execute All Requests" => ExecuteAllRequests(), _ => Task.CompletedTask }); @@ -148,6 +164,12 @@ async Task ExecuteAllRequests() { var data = await kpIndexClient.GetKpIndexNowcastAsync(CancellationToken.None); OutputFormatter.DisplayKpNowcast(data, 5); + }), + ("WSA-ENLIL Animation", async () => + { + var gifBytes = await wsaEnlilClient.GetEnlilAnimationAsync( + maxWidth: 480, cancellationToken: CancellationToken.None); + await SaveAnimationAsync(gifBytes, "enlil_animation.gif"); }) }; @@ -185,4 +207,20 @@ await AnsiConsole.Progress() _ => "Extreme" }; +// Saves GIF animation bytes to a temp file and displays the result +static async Task SaveAnimationAsync(byte[] gifBytes, string fileName) +{ + if (gifBytes.Length == 0) + { + AnsiConsole.MarkupLine("[red]No animation data returned.[/]"); + return; + } + + var outputPath = Path.Combine(Path.GetTempPath(), fileName); + await File.WriteAllBytesAsync(outputPath, gifBytes, CancellationToken.None); + + AnsiConsole.MarkupLine($"[green]Animation saved:[/] {outputPath}"); + AnsiConsole.MarkupLine($"[dim]Size: {gifBytes.Length / 1024} KB | Format: GIF[/]"); +} + diff --git a/src/NoaaClient/NoaaClient.csproj b/src/NoaaClient/NoaaClient.csproj index ed48fd3..dfa4824 100644 --- a/src/NoaaClient/NoaaClient.csproj +++ b/src/NoaaClient/NoaaClient.csproj @@ -10,6 +10,7 @@ + diff --git a/src/NoaaClient/ServiceCollectionExtensions.cs b/src/NoaaClient/ServiceCollectionExtensions.cs index 127aac5..bab307a 100644 --- a/src/NoaaClient/ServiceCollectionExtensions.cs +++ b/src/NoaaClient/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using AuroraScienceHub.Integrations.NoaaClient.Ace; +using AuroraScienceHub.Integrations.NoaaClient.WsaEnlil; using AuroraScienceHub.Integrations.NoaaClient.KpIndex; using AuroraScienceHub.Integrations.NoaaClient.Rtsw; using Microsoft.Extensions.DependencyInjection; @@ -26,6 +27,7 @@ public static IServiceCollection AddNoaaClients(this IServiceCollection services #pragma warning restore CS0618 services.AddHttpClient(); services.AddHttpClient(); + services.AddHttpClient(); return services; } diff --git a/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs b/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs new file mode 100644 index 0000000..faba5f7 --- /dev/null +++ b/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs @@ -0,0 +1,17 @@ +namespace AuroraScienceHub.Integrations.NoaaClient.WsaEnlil; + +/// +/// Client for creating WSA-ENLIL solar wind forecast animated GIFs from NOAA SWPC imagery. +/// +public interface IWsaEnlilClient +{ + /// + /// Downloads the WSA-ENLIL animation manifest and all frames, assembling them into an optimized animated GIF. + /// + /// Maximum output width in pixels. Frames are resized proportionally. Default 480. + /// Cancellation token. + /// GIF file bytes. + Task GetEnlilAnimationAsync( + int maxWidth = 480, + CancellationToken cancellationToken = default); +} diff --git a/src/NoaaClient/WsaEnlil/Responses/WsaEnlilManifestEntry.cs b/src/NoaaClient/WsaEnlil/Responses/WsaEnlilManifestEntry.cs new file mode 100644 index 0000000..0b68114 --- /dev/null +++ b/src/NoaaClient/WsaEnlil/Responses/WsaEnlilManifestEntry.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace AuroraScienceHub.Integrations.NoaaClient.WsaEnlil.Responses; + +/// +/// A single frame entry in the WSA-ENLIL animation manifest. +/// +/// Relative frame URL, e.g. "/images/animations/enlil/enlil_com2_58426_20250118T120000.jpg". +public sealed record WsaEnlilManifestEntry( + [property: JsonPropertyName("url")] string Url +); diff --git a/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs b/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs new file mode 100644 index 0000000..d1943de --- /dev/null +++ b/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs @@ -0,0 +1,72 @@ +using AuroraScienceHub.Framework.Http; +using AuroraScienceHub.Integrations.NoaaClient.WsaEnlil.Responses; +using ImageMagick; +using Microsoft.Extensions.Options; + +namespace AuroraScienceHub.Integrations.NoaaClient.WsaEnlil; + +internal sealed class WsaEnlilClient : IWsaEnlilClient +{ + private const string ManifestPath = "products/animations/enlil.json"; + private const int FrameDelayMs = 50; + private const int DefaultColorCount = 256; + + private readonly HttpClient _httpClient; + private readonly Uri _baseUrl; + + public WsaEnlilClient( + HttpClient httpClient, + IOptions options) + { + _httpClient = httpClient; + _baseUrl = options.Value.RequiredServerUrl; + } + + public async Task GetEnlilAnimationAsync( + int maxWidth = 480, + CancellationToken cancellationToken = default) + { + var manifestUrl = new Uri(_baseUrl, ManifestPath); + var manifest = await _httpClient + .GetFromJsonOrDefaultAsync>(manifestUrl, cancellationToken) + .ConfigureAwait(false); + + if (manifest is null || manifest.Count == 0) + { + return []; + } + + using var collection = new MagickImageCollection(); + + foreach (var entry in manifest) + { + cancellationToken.ThrowIfCancellationRequested(); + + var frameUrl = new Uri(_baseUrl, entry.Url).ToString(); + var frameBytes = await _httpClient + .GetByteArrayAsync(frameUrl, cancellationToken) + .ConfigureAwait(false); + + var image = new MagickImage(frameBytes); + + if (image.Width > maxWidth) + { + var geometry = new MagickGeometry((uint)maxWidth, 0) + { + IgnoreAspectRatio = false + }; + image.Resize(geometry); + } + + // Magick.NET AnimationDelay is in centiseconds + image.AnimationDelay = (uint)(FrameDelayMs / 10); + image.GifDisposeMethod = GifDisposeMethod.Background; + + collection.Add(image); + } + + collection.Quantize(new QuantizeSettings { Colors = DefaultColorCount }); + + return collection.ToByteArray(MagickFormat.Gif); + } +} From 559da808f65b9198cb3b532d555a8b872b31d3c6 Mon Sep 17 00:00:00 2001 From: Ermilov Aleksei Date: Wed, 5 Aug 2026 15:58:30 +0500 Subject: [PATCH 02/10] - --- src/NoaaClient/WsaEnlil/WsaEnlilClient.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs b/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs index d1943de..5e0c569 100644 --- a/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs +++ b/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs @@ -43,11 +43,11 @@ public async Task GetEnlilAnimationAsync( cancellationToken.ThrowIfCancellationRequested(); var frameUrl = new Uri(_baseUrl, entry.Url).ToString(); - var frameBytes = await _httpClient - .GetByteArrayAsync(frameUrl, cancellationToken) + await using var stream = await _httpClient + .GetStreamAsync(frameUrl, cancellationToken) .ConfigureAwait(false); - var image = new MagickImage(frameBytes); + var image = new MagickImage(stream); if (image.Width > maxWidth) { @@ -56,6 +56,7 @@ public async Task GetEnlilAnimationAsync( IgnoreAspectRatio = false }; image.Resize(geometry); + image.Strip(); // Remove metadata to reduce file size } // Magick.NET AnimationDelay is in centiseconds From 693dae4f326021991f8d9eebe9b9a16d1631a9df Mon Sep 17 00:00:00 2001 From: Ermilov Aleksei Date: Wed, 5 Aug 2026 16:04:27 +0500 Subject: [PATCH 03/10] - --- src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs | 6 +++--- src/NoaaClient/WsaEnlil/WsaEnlilClient.cs | 9 +++------ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs b/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs index faba5f7..f503336 100644 --- a/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs +++ b/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs @@ -1,16 +1,16 @@ namespace AuroraScienceHub.Integrations.NoaaClient.WsaEnlil; /// -/// Client for creating WSA-ENLIL solar wind forecast animated GIFs from NOAA SWPC imagery. +/// Client for creating WSA-ENLIL solar wind forecast animations from NOAA SWPC imagery. /// public interface IWsaEnlilClient { /// - /// Downloads the WSA-ENLIL animation manifest and all frames, assembling them into an optimized animated GIF. + /// Downloads the WSA-ENLIL animation manifest and all frames, assembling them into an optimized animated WebP. /// /// Maximum output width in pixels. Frames are resized proportionally. Default 480. /// Cancellation token. - /// GIF file bytes. + /// WebP file bytes. Task GetEnlilAnimationAsync( int maxWidth = 480, CancellationToken cancellationToken = default); diff --git a/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs b/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs index 5e0c569..f9a8e17 100644 --- a/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs +++ b/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs @@ -9,7 +9,7 @@ internal sealed class WsaEnlilClient : IWsaEnlilClient { private const string ManifestPath = "products/animations/enlil.json"; private const int FrameDelayMs = 50; - private const int DefaultColorCount = 256; + private const int WebPQuality = 75; private readonly HttpClient _httpClient; private readonly Uri _baseUrl; @@ -59,15 +59,12 @@ public async Task GetEnlilAnimationAsync( image.Strip(); // Remove metadata to reduce file size } - // Magick.NET AnimationDelay is in centiseconds image.AnimationDelay = (uint)(FrameDelayMs / 10); - image.GifDisposeMethod = GifDisposeMethod.Background; + image.Quality = WebPQuality; collection.Add(image); } - collection.Quantize(new QuantizeSettings { Colors = DefaultColorCount }); - - return collection.ToByteArray(MagickFormat.Gif); + return collection.ToByteArray(MagickFormat.WebP); } } From 9a0f1cf2d855b01132f77fc847e72f30e905b8f6 Mon Sep 17 00:00:00 2001 From: Ermilov Aleksei Date: Thu, 6 Aug 2026 09:04:49 +0500 Subject: [PATCH 04/10] Integrations: update WSA-ENLIL client to return WebP animation stream --- samples/NoaaClientSample/Program.cs | 23 +++++++++++----------- src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs | 8 ++++++-- src/NoaaClient/WsaEnlil/WsaEnlilClient.cs | 12 +++++++---- 3 files changed, 26 insertions(+), 17 deletions(-) diff --git a/samples/NoaaClientSample/Program.cs b/samples/NoaaClientSample/Program.cs index 955667b..0496814 100644 --- a/samples/NoaaClientSample/Program.cs +++ b/samples/NoaaClientSample/Program.cs @@ -105,15 +105,15 @@ await AnsiConsole.Status() }), "WSA-ENLIL Animation" => FetchAndDisplay(async () => { - var gifBytes = await wsaEnlilClient.GetEnlilAnimationAsync( + await using var stream = await wsaEnlilClient.GetEnlilAnimationAsync( maxWidth: 480, cancellationToken: CancellationToken.None); - await SaveAnimationAsync(gifBytes, "enlil_animation.gif"); + await SaveAnimationAsync(stream, "enlil_animation.webp"); }), "WSA-ENLIL Animation (small, 320px)" => FetchAndDisplay(async () => { - var gifBytes = await wsaEnlilClient.GetEnlilAnimationAsync( + await using var stream = await wsaEnlilClient.GetEnlilAnimationAsync( maxWidth: 320, cancellationToken: CancellationToken.None); - await SaveAnimationAsync(gifBytes, "enlil_animation_small.gif"); + await SaveAnimationAsync(stream, "enlil_animation_small.webp"); }), "Execute All Requests" => ExecuteAllRequests(), _ => Task.CompletedTask @@ -167,9 +167,9 @@ async Task ExecuteAllRequests() }), ("WSA-ENLIL Animation", async () => { - var gifBytes = await wsaEnlilClient.GetEnlilAnimationAsync( + await using var stream = await wsaEnlilClient.GetEnlilAnimationAsync( maxWidth: 480, cancellationToken: CancellationToken.None); - await SaveAnimationAsync(gifBytes, "enlil_animation.gif"); + await SaveAnimationAsync(stream, "enlil_animation.webp"); }) }; @@ -207,20 +207,21 @@ await AnsiConsole.Progress() _ => "Extreme" }; -// Saves GIF animation bytes to a temp file and displays the result -static async Task SaveAnimationAsync(byte[] gifBytes, string fileName) +// Saves WebP animation stream to a temp file and displays the result +static async Task SaveAnimationAsync(Stream stream, string fileName) { - if (gifBytes.Length == 0) + if (stream.Length == 0) { AnsiConsole.MarkupLine("[red]No animation data returned.[/]"); return; } var outputPath = Path.Combine(Path.GetTempPath(), fileName); - await File.WriteAllBytesAsync(outputPath, gifBytes, CancellationToken.None); + await using var fileStream = File.Create(outputPath); + await stream.CopyToAsync(fileStream); AnsiConsole.MarkupLine($"[green]Animation saved:[/] {outputPath}"); - AnsiConsole.MarkupLine($"[dim]Size: {gifBytes.Length / 1024} KB | Format: GIF[/]"); + AnsiConsole.MarkupLine($"[dim]Size: {stream.Length / 1024} KB | Format: WebP[/]"); } diff --git a/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs b/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs index f503336..23e8afa 100644 --- a/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs +++ b/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs @@ -10,8 +10,12 @@ public interface IWsaEnlilClient /// /// Maximum output width in pixels. Frames are resized proportionally. Default 480. /// Cancellation token. - /// WebP file bytes. - Task GetEnlilAnimationAsync( + /// A stream containing the WebP animation data. The caller is responsible for disposing this stream. + /// + /// The returned stream is a MemoryStream containing the complete WebP data. + /// Typical usage: await using var stream = await GetEnlilAnimationAsync(cancellationToken); + /// + Task GetEnlilAnimationAsync( int maxWidth = 480, CancellationToken cancellationToken = default); } diff --git a/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs b/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs index f9a8e17..f62cca1 100644 --- a/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs +++ b/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs @@ -22,7 +22,7 @@ public WsaEnlilClient( _baseUrl = options.Value.RequiredServerUrl; } - public async Task GetEnlilAnimationAsync( + public async Task GetEnlilAnimationAsync( int maxWidth = 480, CancellationToken cancellationToken = default) { @@ -33,7 +33,7 @@ public async Task GetEnlilAnimationAsync( if (manifest is null || manifest.Count == 0) { - return []; + return new MemoryStream(); } using var collection = new MagickImageCollection(); @@ -42,7 +42,7 @@ public async Task GetEnlilAnimationAsync( { cancellationToken.ThrowIfCancellationRequested(); - var frameUrl = new Uri(_baseUrl, entry.Url).ToString(); + var frameUrl = new Uri(_baseUrl, entry.Url); await using var stream = await _httpClient .GetStreamAsync(frameUrl, cancellationToken) .ConfigureAwait(false); @@ -65,6 +65,10 @@ public async Task GetEnlilAnimationAsync( collection.Add(image); } - return collection.ToByteArray(MagickFormat.WebP); + var memoryStream = new MemoryStream(); + await collection.WriteAsync(memoryStream, MagickFormat.WebP, cancellationToken); + memoryStream.Position = 0; + + return memoryStream; } } From 1022a06ff4a35a5440880943be93c7d0ae78c1ea Mon Sep 17 00:00:00 2001 From: Ermilov Aleksei Date: Thu, 6 Aug 2026 09:09:51 +0500 Subject: [PATCH 05/10] - --- src/NoaaClient/WsaEnlil/WsaEnlilClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs b/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs index f62cca1..4248f78 100644 --- a/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs +++ b/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs @@ -28,7 +28,7 @@ public async Task GetEnlilAnimationAsync( { var manifestUrl = new Uri(_baseUrl, ManifestPath); var manifest = await _httpClient - .GetFromJsonOrDefaultAsync>(manifestUrl, cancellationToken) + .GetFromJsonOrDefaultAsync>(manifestUrl, cancellationToken) .ConfigureAwait(false); if (manifest is null || manifest.Count == 0) From 8f47dbb50c49ba58ccb17d12d5ee7e9ab4eda526 Mon Sep 17 00:00:00 2001 From: Ermilov Aleksei Date: Thu, 6 Aug 2026 16:00:19 +0500 Subject: [PATCH 06/10] Integrations: update WSA-ENLIL client to generate MP4 animations and update dependencies --- Directory.Packages.props | 3 +- README.md | 31 ++++ samples/NoaaClientSample/Program.cs | 8 +- src/NoaaClient/NoaaClient.csproj | 2 +- src/NoaaClient/README.md | 22 +++ src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs | 10 +- src/NoaaClient/WsaEnlil/WsaEnlilClient.cs | 86 ++++++--- .../WsaEnlil/WsaEnlilClientTests.cs | 171 ++++++++++++++++++ 8 files changed, 301 insertions(+), 32 deletions(-) create mode 100644 tests/UnitTests/NoaaClient/WsaEnlil/WsaEnlilClientTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 33c8d77..ccf0233 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,7 +19,8 @@ - + + diff --git a/README.md b/README.md index b56be9e..bd748d5 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ Each package provides HTTP clients for accessing external data sources with stro |---------------------|-----------------------------------------------------------------------------------------------------------------------------| | **RTSW Client** | NOAA real-time solar wind feeds — 1-minute magnetometer and plasma JSON data with `active` and `source` metadata | | **KP-Index Client** | Geomagnetic activity indices — nowcast and forecast data (3-day and 27-day) | +| **WSA-ENLIL Client** | Solar wind forecast animation — downloads WSA-ENLIL model frames and encodes them as MP4 (H.264) via FFmpeg | | **ACE Client** | Legacy text feeds (deprecated; use RTSW instead — see [Integrations #3](https://github.com/Aurora-Science-Hub/Integrations/issues/3)) | See [detailed documentation](src/NoaaClient/README.md) for usage examples and API reference. @@ -107,6 +108,7 @@ This repository provides production-ready HTTP clients for external space weathe - [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) or later - IDE: [Visual Studio 2025+](https://visualstudio.microsoft.com/), [Rider 2025+](https://www.jetbrains.com/rider/), or [VS Code](https://code.visualstudio.com/) +- **FFmpeg** — required for WSA-ENLIL animation encoding (see [installation instructions](#ffmpeg-installation)) ### Building from Source @@ -125,6 +127,35 @@ dotnet build dotnet test ``` +### FFmpeg Installation + +The **WSA-ENLIL animation** feature requires FFmpeg to encode forecast frames into MP4 video. Install it for your platform: + +**Windows:** +```powershell +winget install ffmpeg +``` + +**macOS:** +```bash +brew install ffmpeg +``` + +**Linux (Ubuntu/Debian):** +```bash +sudo apt-get install ffmpeg +``` + +**Linux (Fedora/RHEL):** +```bash +sudo dnf install ffmpeg-free +``` + +Verify the installation: +```bash +ffmpeg -version +``` + ## Code Style The solution uses [EditorConfig](.editorconfig) based on [Azure SDK .NET](https://github.com/Azure/azure-sdk-for-net/blob/main/.editorconfig) to maintain consistent code style across all packages. diff --git a/samples/NoaaClientSample/Program.cs b/samples/NoaaClientSample/Program.cs index 0496814..e2b7f0c 100644 --- a/samples/NoaaClientSample/Program.cs +++ b/samples/NoaaClientSample/Program.cs @@ -107,13 +107,13 @@ await AnsiConsole.Status() { await using var stream = await wsaEnlilClient.GetEnlilAnimationAsync( maxWidth: 480, cancellationToken: CancellationToken.None); - await SaveAnimationAsync(stream, "enlil_animation.webp"); + await SaveAnimationAsync(stream, "enlil_animation.mp4"); }), "WSA-ENLIL Animation (small, 320px)" => FetchAndDisplay(async () => { await using var stream = await wsaEnlilClient.GetEnlilAnimationAsync( maxWidth: 320, cancellationToken: CancellationToken.None); - await SaveAnimationAsync(stream, "enlil_animation_small.webp"); + await SaveAnimationAsync(stream, "enlil_animation_small.mp4"); }), "Execute All Requests" => ExecuteAllRequests(), _ => Task.CompletedTask @@ -169,7 +169,7 @@ async Task ExecuteAllRequests() { await using var stream = await wsaEnlilClient.GetEnlilAnimationAsync( maxWidth: 480, cancellationToken: CancellationToken.None); - await SaveAnimationAsync(stream, "enlil_animation.webp"); + await SaveAnimationAsync(stream, "enlil_animation.mp4"); }) }; @@ -221,7 +221,7 @@ static async Task SaveAnimationAsync(Stream stream, string fileName) await stream.CopyToAsync(fileStream); AnsiConsole.MarkupLine($"[green]Animation saved:[/] {outputPath}"); - AnsiConsole.MarkupLine($"[dim]Size: {stream.Length / 1024} KB | Format: WebP[/]"); + AnsiConsole.MarkupLine($"[dim]Size: {stream.Length / 1024} KB | Format: MP4[/]"); } diff --git a/src/NoaaClient/NoaaClient.csproj b/src/NoaaClient/NoaaClient.csproj index dfa4824..2f3462d 100644 --- a/src/NoaaClient/NoaaClient.csproj +++ b/src/NoaaClient/NoaaClient.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/NoaaClient/README.md b/src/NoaaClient/README.md index 423c5b6..1789b96 100644 --- a/src/NoaaClient/README.md +++ b/src/NoaaClient/README.md @@ -49,6 +49,28 @@ var forecast3Day = await kpIndexClient.GetKpIndex3DayForecastAsync(cancellationT var forecast27Day = await kpIndexClient.GetKpIndex27DayForecastAsync(cancellationToken); ``` +## WSA-ENLIL Client + +Generates MP4 (H.264) animations from WSA-ENLIL solar wind forecast model frames provided by NOAA SWPC. + +**Prerequisites:** FFmpeg must be installed on the system. See [FFmpeg Installation](../../README.md#ffmpeg-installation) for platform-specific instructions. + +```csharp +// Download and encode WSA-ENLIL animation frames as MP4 video +await using var stream = await wsaEnlilClient.GetEnlilAnimationAsync( + maxWidth: 480, + cancellationToken: cancellationToken); + +// Save to file +await using var fileStream = File.Create("enlil_animation.mp4"); +await stream.CopyToAsync(fileStream); +``` + +- **maxWidth** — maximum output width in pixels; frames are scaled proportionally (default: 480) +- **Return value** — `MemoryStream` containing the complete MP4 video; caller is responsible for disposal +- Frame rate: 20 fps (50 ms per frame) +- Encoder: H.264 (libx264), CRF 23, pixel format yuv420p + ## Deprecated API `IAceClient` is obsolete and scheduled for removal ([#3](https://github.com/Aurora-Science-Hub/Integrations/issues/3)). Use `IRtswClient` for magnetometer and solar wind plasma data. diff --git a/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs b/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs index 23e8afa..fc618f3 100644 --- a/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs +++ b/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs @@ -3,16 +3,20 @@ namespace AuroraScienceHub.Integrations.NoaaClient.WsaEnlil; /// /// Client for creating WSA-ENLIL solar wind forecast animations from NOAA SWPC imagery. /// +/// +/// Requires FFmpeg to be installed on the system and available in PATH. +/// See README for platform-specific installation instructions. +/// public interface IWsaEnlilClient { /// - /// Downloads the WSA-ENLIL animation manifest and all frames, assembling them into an optimized animated WebP. + /// Downloads the WSA-ENLIL animation manifest and all frames, assembling them into an optimized MP4 (H.264) video. /// /// Maximum output width in pixels. Frames are resized proportionally. Default 480. /// Cancellation token. - /// A stream containing the WebP animation data. The caller is responsible for disposing this stream. + /// A stream containing the MP4 video data. The caller is responsible for disposing this stream. /// - /// The returned stream is a MemoryStream containing the complete WebP data. + /// The returned stream is a MemoryStream containing the complete MP4 data. /// Typical usage: await using var stream = await GetEnlilAnimationAsync(cancellationToken); /// Task GetEnlilAnimationAsync( diff --git a/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs b/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs index 4248f78..d74c1c9 100644 --- a/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs +++ b/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs @@ -1,6 +1,7 @@ using AuroraScienceHub.Framework.Http; using AuroraScienceHub.Integrations.NoaaClient.WsaEnlil.Responses; -using ImageMagick; +using FFMpegCore; +using FFMpegCore.Pipes; using Microsoft.Extensions.Options; namespace AuroraScienceHub.Integrations.NoaaClient.WsaEnlil; @@ -8,8 +9,12 @@ namespace AuroraScienceHub.Integrations.NoaaClient.WsaEnlil; internal sealed class WsaEnlilClient : IWsaEnlilClient { private const string ManifestPath = "products/animations/enlil.json"; - private const int FrameDelayMs = 50; - private const int WebPQuality = 75; + private const int Fps = 20; // 1000 / FrameDelayMs + private const int Crf = 23; // H.264 quality (0 = lossless, 51 = worst) + private const string TempDirPrefix = "enlil_"; + private const string FrameFileFormat = "frame_{0:D4}.jpg"; + private const string FrameSearchPattern = "frame_%04d.jpg"; + private const int OutputStreamCapacity = 5 * 1024 * 1024; // 5 MB initial buffer private readonly HttpClient _httpClient; private readonly Uri _baseUrl; @@ -36,39 +41,74 @@ public async Task GetEnlilAnimationAsync( return new MemoryStream(); } - using var collection = new MagickImageCollection(); + string? tempDir = null; + try + { + tempDir = Directory.CreateTempSubdirectory(TempDirPrefix).FullName; + await DownloadFramesAsync(manifest, tempDir, cancellationToken).ConfigureAwait(false); + return await EncodeVideoAsync(tempDir, maxWidth, cancellationToken).ConfigureAwait(false); + } + finally + { + if (tempDir is not null) + { + try { Directory.Delete(tempDir, recursive: true); } + catch { /* best-effort cleanup */ } + } + } + } + private async Task DownloadFramesAsync( + IReadOnlyCollection manifest, + string tempDir, + CancellationToken cancellationToken) + { + var index = 0; foreach (var entry in manifest) { cancellationToken.ThrowIfCancellationRequested(); var frameUrl = new Uri(_baseUrl, entry.Url); - await using var stream = await _httpClient + await using var sourceStream = await _httpClient .GetStreamAsync(frameUrl, cancellationToken) .ConfigureAwait(false); - var image = new MagickImage(stream); + var framePath = Path.Combine(tempDir, string.Format(FrameFileFormat, index)); + await using var fileStream = File.Create(framePath); + await sourceStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); - if (image.Width > maxWidth) - { - var geometry = new MagickGeometry((uint)maxWidth, 0) - { - IgnoreAspectRatio = false - }; - image.Resize(geometry); - image.Strip(); // Remove metadata to reduce file size - } + index++; + } + } - image.AnimationDelay = (uint)(FrameDelayMs / 10); - image.Quality = WebPQuality; + private static async Task EncodeVideoAsync( + string tempDir, + int maxWidth, + CancellationToken cancellationToken) + { + // Guard against known FFMpegCore issue #468: already-cancelled token may be ignored + cancellationToken.ThrowIfCancellationRequested(); - collection.Add(image); - } + var outputStream = new MemoryStream(OutputStreamCapacity); + var inputPattern = Path.Combine(tempDir, FrameSearchPattern); - var memoryStream = new MemoryStream(); - await collection.WriteAsync(memoryStream, MagickFormat.WebP, cancellationToken); - memoryStream.Position = 0; + await FFMpegArguments + .FromFileInput(inputPattern, verifyExists: false, + inputOptions => inputOptions + .WithCustomArgument($"-framerate {Fps}")) + .OutputToPipe(new StreamPipeSink(outputStream), + outputOptions => outputOptions + .WithCustomArgument($"-vf scale={maxWidth}:-1") + .WithVideoCodec("libx264") + .WithCustomArgument($"-crf {Crf}") + .WithCustomArgument("-pix_fmt yuv420p") + .WithCustomArgument("-movflags +frag_keyframe+empty_moov") + .ForceFormat("mp4")) + .CancellableThrough(cancellationToken) + .ProcessAsynchronously() + .ConfigureAwait(false); - return memoryStream; + outputStream.Position = 0; + return outputStream; } } diff --git a/tests/UnitTests/NoaaClient/WsaEnlil/WsaEnlilClientTests.cs b/tests/UnitTests/NoaaClient/WsaEnlil/WsaEnlilClientTests.cs new file mode 100644 index 0000000..e512a07 --- /dev/null +++ b/tests/UnitTests/NoaaClient/WsaEnlil/WsaEnlilClientTests.cs @@ -0,0 +1,171 @@ +using System.Net; +using AuroraScienceHub.Integrations.NoaaClient; +using AuroraScienceHub.Integrations.NoaaClient.WsaEnlil; +using Microsoft.Extensions.Options; +using Shouldly; + +namespace AuroraScienceHub.Integrations.UnitTests.NoaaClient.WsaEnlil; + +/// +/// Unit tests for . +/// +/// +/// Tests cover the HTTP/download pipeline. ffmpeg encoding is an integration concern +/// and is not covered by unit tests. +/// +public sealed class WsaEnlilClientTests +{ + private static readonly Uri BaseUrl = new("https://services.swpc.noaa.gov"); + + [Fact(DisplayName = "Returns empty stream when NOAA manifest is an empty JSON array")] + public async Task GetEnlilAnimationAsync_WhenManifestIsEmpty_ReturnsEmptyStream() + { + // Arrange + var sut = CreateSut("[]"); + + // Act + await using var result = await sut.GetEnlilAnimationAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + result.Length.ShouldBe(0); + } + + [Fact(DisplayName = "Returns empty stream when NOAA manifest deserializes to null")] + public async Task GetEnlilAnimationAsync_WhenManifestIsNull_ReturnsEmptyStream() + { + // Arrange + var sut = CreateSut("null"); + + // Act + await using var result = await sut.GetEnlilAnimationAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + result.Length.ShouldBe(0); + } + + [Fact(DisplayName = "Throws OperationCanceledException when token is pre-cancelled")] + public async Task GetEnlilAnimationAsync_WhenTokenIsCancelled_ThrowsOperationCanceledException() + { + // Arrange + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + var sut = CreateSut("[]"); + + // Act & Assert + await Should.ThrowAsync( + async () => await sut.GetEnlilAnimationAsync(cancellationToken: cts.Token)); + } + + [Fact(DisplayName = "Throws when token is cancelled mid-download")] + public async Task GetEnlilAnimationAsync_WhenCancelledDuringDownload_ThrowsOperationCanceledException() + { + // Arrange + using var cts = new CancellationTokenSource(); + + var manifestJson = """ + [ + {"url":"/images/animations/enlil/frame1.jpg"}, + {"url":"/images/animations/enlil/frame2.jpg"}, + {"url":"/images/animations/enlil/frame3.jpg"} + ] + """; + + var handler = new CancellingHandler(manifestJson, cancelAfterRequests: 2, cts); + using var httpClient = new HttpClient(handler); + var options = Options.Create(new NoaaClientOptions { ServerUrl = BaseUrl }); + var sut = new WsaEnlilClient(httpClient, options); + + // Act & Assert + await Should.ThrowAsync( + async () => await sut.GetEnlilAnimationAsync(cancellationToken: cts.Token)); + } + + private static WsaEnlilClient CreateSut(string manifestJson) + { + var handler = new TestHttpMessageHandler(CreateJsonResponse(manifestJson)); + var httpClient = new HttpClient(handler); + var options = Options.Create(new NoaaClientOptions { ServerUrl = BaseUrl }); + return new WsaEnlilClient(httpClient, options); + } + + private static HttpResponseMessage CreateJsonResponse(string json) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json") + }; + } + + /// + /// Simple test double that returns pre-configured responses. + /// + private sealed class TestHttpMessageHandler : HttpMessageHandler + { + private readonly Queue _responses; + + public TestHttpMessageHandler(params HttpResponseMessage[] responses) + { + _responses = new Queue(responses); + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(_responses.Count > 0 + ? _responses.Dequeue() + : new HttpResponseMessage(HttpStatusCode.NotFound)); + } + } + + /// + /// Handler that cancels the token after a configured number of requests. + /// + private sealed class CancellingHandler : HttpMessageHandler + { + private readonly string _manifestJson; + private readonly int _cancelAfterRequests; + private readonly CancellationTokenSource _cts; + private int _requestCount; + + public CancellingHandler(string manifestJson, int cancelAfterRequests, CancellationTokenSource cts) + { + _manifestJson = manifestJson; + _cancelAfterRequests = cancelAfterRequests; + _cts = cts; + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var count = Interlocked.Increment(ref _requestCount); + if (count > _cancelAfterRequests) + { + await _cts.CancelAsync(); + // Give the cancellation a moment to propagate + await Task.Delay(50, CancellationToken.None); + cancellationToken.ThrowIfCancellationRequested(); + } + + // Simulate network latency so cancellation can be observed + await Task.Delay(100, CancellationToken.None); + + return count == 1 + ? new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(_manifestJson, System.Text.Encoding.UTF8, "application/json") + } + : new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent("fake-jpeg-data"u8.ToArray()) + }; + } + } +} From 0c60e842158edba02b7bcd306b7b9b88ba32ab11 Mon Sep 17 00:00:00 2001 From: Ermilov Aleksei Date: Thu, 6 Aug 2026 16:28:19 +0500 Subject: [PATCH 07/10] Integrations: update WSA-ENLIL client to validate maxWidth parameter and adjust scaling argument --- src/NoaaClient/WsaEnlil/WsaEnlilClient.cs | 16 +++++++++------- .../NoaaClient/WsaEnlil/WsaEnlilClientTests.cs | 6 +++--- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs b/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs index d74c1c9..f68cc6e 100644 --- a/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs +++ b/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs @@ -9,7 +9,7 @@ namespace AuroraScienceHub.Integrations.NoaaClient.WsaEnlil; internal sealed class WsaEnlilClient : IWsaEnlilClient { private const string ManifestPath = "products/animations/enlil.json"; - private const int Fps = 20; // 1000 / FrameDelayMs + private const int Fps = 20; private const int Crf = 23; // H.264 quality (0 = lossless, 51 = worst) private const string TempDirPrefix = "enlil_"; private const string FrameFileFormat = "frame_{0:D4}.jpg"; @@ -28,9 +28,14 @@ public WsaEnlilClient( } public async Task GetEnlilAnimationAsync( - int maxWidth = 480, - CancellationToken cancellationToken = default) + int maxWidth, + CancellationToken cancellationToken) { + if (maxWidth <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maxWidth), "Max width must be greater than zero."); + } + var manifestUrl = new Uri(_baseUrl, ManifestPath); var manifest = await _httpClient .GetFromJsonOrDefaultAsync>(manifestUrl, cancellationToken) @@ -86,9 +91,6 @@ private static async Task EncodeVideoAsync( int maxWidth, CancellationToken cancellationToken) { - // Guard against known FFMpegCore issue #468: already-cancelled token may be ignored - cancellationToken.ThrowIfCancellationRequested(); - var outputStream = new MemoryStream(OutputStreamCapacity); var inputPattern = Path.Combine(tempDir, FrameSearchPattern); @@ -98,7 +100,7 @@ await FFMpegArguments .WithCustomArgument($"-framerate {Fps}")) .OutputToPipe(new StreamPipeSink(outputStream), outputOptions => outputOptions - .WithCustomArgument($"-vf scale={maxWidth}:-1") + .WithCustomArgument($"-vf scale={maxWidth}:-2") .WithVideoCodec("libx264") .WithCustomArgument($"-crf {Crf}") .WithCustomArgument("-pix_fmt yuv420p") diff --git a/tests/UnitTests/NoaaClient/WsaEnlil/WsaEnlilClientTests.cs b/tests/UnitTests/NoaaClient/WsaEnlil/WsaEnlilClientTests.cs index e512a07..d73a865 100644 --- a/tests/UnitTests/NoaaClient/WsaEnlil/WsaEnlilClientTests.cs +++ b/tests/UnitTests/NoaaClient/WsaEnlil/WsaEnlilClientTests.cs @@ -15,7 +15,7 @@ namespace AuroraScienceHub.Integrations.UnitTests.NoaaClient.WsaEnlil; /// public sealed class WsaEnlilClientTests { - private static readonly Uri BaseUrl = new("https://services.swpc.noaa.gov"); + private static readonly Uri BaseUrl = new("https://noaa.test"); [Fact(DisplayName = "Returns empty stream when NOAA manifest is an empty JSON array")] public async Task GetEnlilAnimationAsync_WhenManifestIsEmpty_ReturnsEmptyStream() @@ -80,10 +80,10 @@ public async Task GetEnlilAnimationAsync_WhenCancelledDuringDownload_ThrowsOpera // Act & Assert await Should.ThrowAsync( - async () => await sut.GetEnlilAnimationAsync(cancellationToken: cts.Token)); + async () => await sut.GetEnlilAnimationAsync(maxWidth: 420, cancellationToken: cts.Token)); } - private static WsaEnlilClient CreateSut(string manifestJson) + private static IWsaEnlilClient CreateSut(string manifestJson) { var handler = new TestHttpMessageHandler(CreateJsonResponse(manifestJson)); var httpClient = new HttpClient(handler); From 02d4aa8d2c9b54ac4dbc8f3b159b9f1a2d606763 Mon Sep 17 00:00:00 2001 From: Aleksei Ermilov <39774874+alex1ozr@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:33:47 +0500 Subject: [PATCH 08/10] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Directory.Packages.props | 1 - samples/NoaaClientSample/Program.cs | 2 +- src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index ccf0233..fff288d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,7 +20,6 @@ - diff --git a/samples/NoaaClientSample/Program.cs b/samples/NoaaClientSample/Program.cs index e2b7f0c..71d7c3e 100644 --- a/samples/NoaaClientSample/Program.cs +++ b/samples/NoaaClientSample/Program.cs @@ -207,7 +207,7 @@ await AnsiConsole.Progress() _ => "Extreme" }; -// Saves WebP animation stream to a temp file and displays the result +// Saves MP4 animation stream to a temp file and displays the result static async Task SaveAnimationAsync(Stream stream, string fileName) { if (stream.Length == 0) diff --git a/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs b/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs index fc618f3..a470c8c 100644 --- a/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs +++ b/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs @@ -17,7 +17,7 @@ public interface IWsaEnlilClient /// A stream containing the MP4 video data. The caller is responsible for disposing this stream. /// /// The returned stream is a MemoryStream containing the complete MP4 data. - /// Typical usage: await using var stream = await GetEnlilAnimationAsync(cancellationToken); + /// Typical usage: await using var stream = await GetEnlilAnimationAsync(cancellationToken: cancellationToken); /// Task GetEnlilAnimationAsync( int maxWidth = 480, From 141b3f92901fcb9a96e3f278a3f4eaebddd88625 Mon Sep 17 00:00:00 2001 From: Ermilov Aleksei Date: Thu, 6 Aug 2026 16:35:09 +0500 Subject: [PATCH 09/10] Integrations: add unit test for GetEnlilAnimationAsync to validate maxWidth parameter --- .../NoaaClient/WsaEnlil/WsaEnlilClientTests.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/UnitTests/NoaaClient/WsaEnlil/WsaEnlilClientTests.cs b/tests/UnitTests/NoaaClient/WsaEnlil/WsaEnlilClientTests.cs index d73a865..a437b23 100644 --- a/tests/UnitTests/NoaaClient/WsaEnlil/WsaEnlilClientTests.cs +++ b/tests/UnitTests/NoaaClient/WsaEnlil/WsaEnlilClientTests.cs @@ -45,6 +45,21 @@ public async Task GetEnlilAnimationAsync_WhenManifestIsNull_ReturnsEmptyStream() result.Length.ShouldBe(0); } + [Theory(DisplayName = "Throws ArgumentOutOfRangeException when maxWidth is zero or negative")] + [InlineData(0)] + [InlineData(-1)] + public async Task GetEnlilAnimationAsync_WhenMaxWidthIsInvalid_ThrowsArgumentOutOfRangeException(int maxWidth) + { + // Arrange + var sut = CreateSut("[]"); + + // Act & Assert + await Should.ThrowAsync( + async () => await sut.GetEnlilAnimationAsync( + maxWidth: maxWidth, + cancellationToken: TestContext.Current.CancellationToken)); + } + [Fact(DisplayName = "Throws OperationCanceledException when token is pre-cancelled")] public async Task GetEnlilAnimationAsync_WhenTokenIsCancelled_ThrowsOperationCanceledException() { From 3d420b0731e0c1bc13e67b662d3016ba50974c2e Mon Sep 17 00:00:00 2001 From: Ermilov Aleksei Date: Thu, 6 Aug 2026 16:50:45 +0500 Subject: [PATCH 10/10] Integrations: add GetLastFrameTimeAsync method to WSA-ENLIL client and corresponding tests --- samples/NoaaClientSample/Program.cs | 11 +++ src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs | 13 ++++ src/NoaaClient/WsaEnlil/WsaEnlilClient.cs | 44 ++++++++++-- .../WsaEnlil/WsaEnlilClientTests.cs | 68 +++++++++++++++++++ 4 files changed, 130 insertions(+), 6 deletions(-) diff --git a/samples/NoaaClientSample/Program.cs b/samples/NoaaClientSample/Program.cs index 71d7c3e..86629ae 100644 --- a/samples/NoaaClientSample/Program.cs +++ b/samples/NoaaClientSample/Program.cs @@ -44,6 +44,7 @@ "KP Index Nowcast", "WSA-ENLIL Animation", "WSA-ENLIL Animation (small, 320px)", + "WSA-ENLIL Last Frame Time", new string('-', 30), "Execute All Requests", "Exit")); @@ -115,6 +116,16 @@ await AnsiConsole.Status() maxWidth: 320, cancellationToken: CancellationToken.None); await SaveAnimationAsync(stream, "enlil_animation_small.mp4"); }), + "WSA-ENLIL Last Frame Time" => FetchAndDisplay(async () => + { + var lastFrameTime = await wsaEnlilClient.GetLastFrameTimeAsync( + CancellationToken.None); + if (lastFrameTime is null) + AnsiConsole.MarkupLine("[yellow]No data available.[/]"); + else + AnsiConsole.MarkupLine( + $"[green]Last frame time:[/] {lastFrameTime:yyyy-MM-dd HH:mm:ss} UTC"); + }), "Execute All Requests" => ExecuteAllRequests(), _ => Task.CompletedTask }); diff --git a/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs b/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs index a470c8c..93d96a2 100644 --- a/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs +++ b/src/NoaaClient/WsaEnlil/IWsaEnlilClient.cs @@ -22,4 +22,17 @@ public interface IWsaEnlilClient Task GetEnlilAnimationAsync( int maxWidth = 480, CancellationToken cancellationToken = default); + + /// + /// Returns the timestamp of the last frame in the WSA-ENLIL animation, without downloading frames or encoding video. + /// + /// Cancellation token. + /// + /// The timestamp extracted from the last frame's URL, or if the manifest is empty or unavailable. + /// + /// + /// Fetches only the manifest JSON (~1 KB). Use this to check whether the animation has been updated + /// before calling . + /// + Task GetLastFrameTimeAsync(CancellationToken cancellationToken = default); } diff --git a/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs b/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs index f68cc6e..cf3e249 100644 --- a/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs +++ b/src/NoaaClient/WsaEnlil/WsaEnlilClient.cs @@ -1,3 +1,5 @@ +using System.Globalization; +using System.Text.RegularExpressions; using AuroraScienceHub.Framework.Http; using AuroraScienceHub.Integrations.NoaaClient.WsaEnlil.Responses; using FFMpegCore; @@ -6,7 +8,7 @@ namespace AuroraScienceHub.Integrations.NoaaClient.WsaEnlil; -internal sealed class WsaEnlilClient : IWsaEnlilClient +internal sealed partial class WsaEnlilClient : IWsaEnlilClient { private const string ManifestPath = "products/animations/enlil.json"; private const int Fps = 20; @@ -19,6 +21,9 @@ internal sealed class WsaEnlilClient : IWsaEnlilClient private readonly HttpClient _httpClient; private readonly Uri _baseUrl; + [GeneratedRegex(@"(\d{8}T\d{6})\.jpg$")] + private static partial Regex FrameTimestampRegex(); + public WsaEnlilClient( HttpClient httpClient, IOptions options) @@ -36,11 +41,7 @@ public async Task GetEnlilAnimationAsync( throw new ArgumentOutOfRangeException(nameof(maxWidth), "Max width must be greater than zero."); } - var manifestUrl = new Uri(_baseUrl, ManifestPath); - var manifest = await _httpClient - .GetFromJsonOrDefaultAsync>(manifestUrl, cancellationToken) - .ConfigureAwait(false); - + var manifest = await FetchManifestAsync(cancellationToken).ConfigureAwait(false); if (manifest is null || manifest.Count == 0) { return new MemoryStream(); @@ -63,6 +64,37 @@ public async Task GetEnlilAnimationAsync( } } + public async Task GetLastFrameTimeAsync(CancellationToken cancellationToken) + { + var manifest = await FetchManifestAsync(cancellationToken).ConfigureAwait(false); + if (manifest is null || manifest.Count == 0) + { + return null; + } + + var lastUrl = manifest.Last().Url; + var match = FrameTimestampRegex().Match(lastUrl); + if (!match.Success) + { + return null; + } + + return DateTime.ParseExact( + match.Groups[1].Value, + "yyyyMMddTHHmmss", + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal); + } + + private async Task?> FetchManifestAsync( + CancellationToken cancellationToken) + { + var manifestUrl = new Uri(_baseUrl, ManifestPath); + return await _httpClient + .GetFromJsonOrDefaultAsync>(manifestUrl, cancellationToken) + .ConfigureAwait(false); + } + private async Task DownloadFramesAsync( IReadOnlyCollection manifest, string tempDir, diff --git a/tests/UnitTests/NoaaClient/WsaEnlil/WsaEnlilClientTests.cs b/tests/UnitTests/NoaaClient/WsaEnlil/WsaEnlilClientTests.cs index a437b23..7735106 100644 --- a/tests/UnitTests/NoaaClient/WsaEnlil/WsaEnlilClientTests.cs +++ b/tests/UnitTests/NoaaClient/WsaEnlil/WsaEnlilClientTests.cs @@ -60,6 +60,74 @@ await Should.ThrowAsync( cancellationToken: TestContext.Current.CancellationToken)); } + [Fact(DisplayName = "GetLastFrameTime returns null when manifest is empty")] + public async Task GetLastFrameTimeAsync_WhenManifestIsEmpty_ReturnsNull() + { + // Arrange + var sut = CreateSut("[]"); + + // Act + var result = await sut.GetLastFrameTimeAsync( + TestContext.Current.CancellationToken); + + // Assert + result.ShouldBeNull(); + } + + [Fact(DisplayName = "GetLastFrameTime returns null when manifest is null")] + public async Task GetLastFrameTimeAsync_WhenManifestIsNull_ReturnsNull() + { + // Arrange + var sut = CreateSut("null"); + + // Act + var result = await sut.GetLastFrameTimeAsync( + TestContext.Current.CancellationToken); + + // Assert + result.ShouldBeNull(); + } + + [Fact(DisplayName = "GetLastFrameTime extracts timestamp from last frame URL")] + public async Task GetLastFrameTimeAsync_ReturnsTimestampFromLastFrameUrl() + { + // Arrange + var manifestJson = """ + [ + {"url":"/images/animations/enlil/frame_20250117T120000.jpg"}, + {"url":"/images/animations/enlil/frame_20250118T060000.jpg"}, + {"url":"/images/animations/enlil/frame_20250118T120000.jpg"} + ] + """; + var sut = CreateSut(manifestJson); + + // Act + var result = await sut.GetLastFrameTimeAsync( + TestContext.Current.CancellationToken); + + // Assert + result.ShouldBe(new DateTime(2025, 1, 18, 12, 0, 0, DateTimeKind.Utc)); + } + + [Fact(DisplayName = "GetLastFrameTime returns null when URL has no timestamp")] + public async Task GetLastFrameTimeAsync_WhenUrlHasNoTimestamp_ReturnsNull() + { + // Arrange + var manifestJson = """ + [ + {"url":"/images/animations/enlil/no_timestamp_here.jpg"} + ] + """; + var sut = CreateSut(manifestJson); + + // Act + var result = await sut.GetLastFrameTimeAsync( + TestContext.Current.CancellationToken); + + // Assert + result.ShouldBeNull(); + } + [Fact(DisplayName = "Throws OperationCanceledException when token is pre-cancelled")] public async Task GetEnlilAnimationAsync_WhenTokenIsCancelled_ThrowsOperationCanceledException() {