From 272d4fc0ad1006e17972491986aab558f83f4476 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Wed, 13 May 2026 18:19:29 +0000 Subject: [PATCH 01/15] add logging per chunk, and put chunk size to 50 in config --- settings/Config.Example.ini | 5 +++ src/Config.php | 7 ++-- src/Event/Api/EventsApi.php | 23 +++++++++++-- src/Event/Api/EventsApiInterface.php | 4 ++- src/Movie/HelioviewerMovie.php | 34 ++++++++++++++++--- tests/autoload.php | 8 +++++ .../events/api/GetEventsBatchTest.php | 4 +-- 7 files changed, 72 insertions(+), 13 deletions(-) diff --git a/settings/Config.Example.ini b/settings/Config.Example.ini index d9f907838..64fd3ce96 100644 --- a/settings/Config.Example.ini +++ b/settings/Config.Example.ini @@ -91,6 +91,11 @@ events_api_url = "https://events.helioviewer.org" ; Timeout in seconds for Events API requests events_api_timeout = 10 +; Number of timestamps per Events API batch request when building a movie. +; Smaller = faster individual requests, more requests total; larger = fewer +; round-trips but slower per request. 50 is a balanced default. +event_api_events_per_frame_chunksize = 50 + [movie_params] ; FFmpeg location ffmpeg = ffmpeg diff --git a/src/Config.php b/src/Config.php index abc9b6215..d48df1226 100644 --- a/src/Config.php +++ b/src/Config.php @@ -18,7 +18,8 @@ class Config { private $_bools = array('disable_cache', 'enable_statistics_collection', 'db_events','sentry_enabled'); private $_ints = array('build_num', 'ffmpeg_max_threads', - 'max_jpx_frames', 'max_movie_frames'); + 'max_jpx_frames', 'max_movie_frames', + 'event_api_events_per_frame_chunksize'); private $_floats = array('events_api_timeout'); private $config; @@ -86,7 +87,9 @@ private function _fixTypes() { // integers foreach ($this->_ints as $int) { - $this->config[$int] = (int)$this->config[$int]; + if (isset($this->config[$int])) { + $this->config[$int] = (int)$this->config[$int]; + } } // floats diff --git a/src/Event/Api/EventsApi.php b/src/Event/Api/EventsApi.php index ae50b2f4c..fc085c228 100644 --- a/src/Event/Api/EventsApi.php +++ b/src/Event/Api/EventsApi.php @@ -156,7 +156,7 @@ public function getDistributions(string $size, int $fromTimestamp, int $toTimest } /** {@inheritdoc} */ - public function getEventsBatch(array $timestamps, array $sources): array + public function getEventsBatch(array $timestamps, array $sources, int $chunkSize = 50, string $logLabel = ''): array { // Only allow known sources $validSources = self::filterSources($sources); @@ -166,9 +166,12 @@ public function getEventsBatch(array $timestamps, array $sources): array if (empty($timestamps)) { return []; } + if ($chunkSize < 1) { + $chunkSize = 50; + } $sourcesParam = implode('::', $validSources); - $chunks = array_chunk($timestamps, 150); + $chunks = array_chunk($timestamps, $chunkSize); $url = "/helioviewer/events/{$sourcesParam}/observations"; // Closure to fetch a single chunk of timestamps @@ -193,12 +196,26 @@ public function getEventsBatch(array $timestamps, array $sources): array } }; + $logChunk = function (int $i, int $total, int $size, int $elapsedMs) use ($logLabel) { + if ($logLabel === '') { + return; + } + error_log(sprintf( + "[%s] EventsApi chunk %d/%d (%d timestamps) took %dms", + $logLabel, $i + 1, $total, $size, $elapsedMs + )); + }; + // First chunk returns full response (event_types + events + observations) + $start = microtime(true); $merged = $fetchChunk($chunks[0]); + $logChunk(0, count($chunks), count($chunks[0]), (int) round((microtime(true) - $start) * 1000)); // Subsequent chunks only add new observations (event_types and events are the same) for ($i = 1; $i < count($chunks); $i++) { + $start = microtime(true); $chunk = $fetchChunk($chunks[$i]); + $logChunk($i, count($chunks), count($chunks[$i]), (int) round((microtime(true) - $start) * 1000)); $merged['observations'] += $chunk['observations']; } @@ -216,7 +233,7 @@ public function getEventsBatch(array $timestamps, array $sources): array */ private function parseResponse($response): array { - $body = (string)$response->getBody(); + $body = (string)$response->getBody(); $data = json_decode($body, true); if (json_last_error() !== JSON_ERROR_NONE) { diff --git a/src/Event/Api/EventsApiInterface.php b/src/Event/Api/EventsApiInterface.php index c5d096c3d..a4adcd6fa 100644 --- a/src/Event/Api/EventsApiInterface.php +++ b/src/Event/Api/EventsApiInterface.php @@ -45,8 +45,10 @@ public function getDistributions(string $size, int $fromTimestamp, int $toTimest * * @param string[] $timestamps Array of observation datetime strings * @param string[] $sources Array of source names (e.g. ['HEK', 'CCMC', 'RHESSI']) + * @param int $chunkSize Max timestamps per upstream POST request + * @param string $logLabel Optional label prepended to per-chunk error_log lines (e.g. "Movie:Xp66n") * @return array Keyed by timestamp, each value is legacy-format event categories * @throws EventsApiException on API errors or unexpected responses */ - public function getEventsBatch(array $timestamps, array $sources): array; + public function getEventsBatch(array $timestamps, array $sources, int $chunkSize = 50, string $logLabel = ''): array; } diff --git a/src/Movie/HelioviewerMovie.php b/src/Movie/HelioviewerMovie.php index 557f0ff82..899e8c9e4 100644 --- a/src/Movie/HelioviewerMovie.php +++ b/src/Movie/HelioviewerMovie.php @@ -520,21 +520,45 @@ private function _buildMovieFrames($watermark) { 'switchSources' => $this->switchSources ); - // Preload events for all frames in 1-2 batch requests + // Preload events for all frames. EventsApi handles chunking internally + // using the configured chunk size and labels per-chunk logs with the movie ID. $timestamps = $this->_getTimeStamps(); $eventsApi = new EventsApi(); $batchResponse = []; $sources = $this->_eventsManager->getSources(); + $movieId = $this->publicId; + + error_log(sprintf( + "[Movie:%s] Starting movie build, frames=%d, sources=%s", + $movieId, + count($timestamps), + $sources ? implode(',', $sources) : '(none)' + )); if ($this->_eventsManager->hasEvents()) { + $chunkSize = defined('HV_EVENT_API_EVENTS_PER_FRAME_CHUNKSIZE') + ? HV_EVENT_API_EVENTS_PER_FRAME_CHUNKSIZE + : 50; + + $totalStart = microtime(true); try { - $batchResponse = $eventsApi->getEventsBatch($timestamps, $sources); + $batchResponse = $eventsApi->getEventsBatch( + $timestamps, + $sources, + $chunkSize, + "Movie:{$movieId}" + ); } catch (EventsApiException $e) { - error_log("[Movie:{$this->publicId}] Batch events failed: " . $e->getMessage()); - } catch (\Exception $e) { - error_log("[Movie:{$this->publicId}] Unexpected error fetching events: " . $e->getMessage()); + error_log("[Movie:{$movieId}] Batch events failed: " . $e->getMessage()); + } catch (\Throwable $e) { + error_log("[Movie:{$movieId}] Unexpected error fetching events: " . $e->getMessage()); Sentry::capture($e); } + $totalMs = (int) round((microtime(true) - $totalStart) * 1000); + error_log(sprintf( + "[Movie:%s] all event chunks done in %dms (%d frames)", + $movieId, $totalMs, count($timestamps) + )); } $options['batchEventResponse'] = $batchResponse; diff --git a/tests/autoload.php b/tests/autoload.php index e4d5c0395..32e7130ea 100644 --- a/tests/autoload.php +++ b/tests/autoload.php @@ -9,6 +9,14 @@ * included in every main php source file...). */ +// Redirect PHP's error_log destination off stderr. PHPUnit's +// @runInSeparateProcess tests use stderr as their IPC channel with the child +// process, so any stray error_log() call in production code corrupts the IPC +// and surfaces as a PHPUnit\Framework\Exception. Sending to a temp file keeps +// the messages around for inspection without breaking the tests. +ini_set('log_errors', '1'); +ini_set('error_log', sys_get_temp_dir() . '/helioviewer-test.log'); + // Load Helioviewer Configuration. This defines all the HV_* variables // seen throughout the project require_once __DIR__ . '/../src/Config.php'; diff --git a/tests/unit_tests/events/api/GetEventsBatchTest.php b/tests/unit_tests/events/api/GetEventsBatchTest.php index c28ef9a2e..a871248ae 100644 --- a/tests/unit_tests/events/api/GetEventsBatchTest.php +++ b/tests/unit_tests/events/api/GetEventsBatchTest.php @@ -88,7 +88,7 @@ public function testItShouldPaginateTimestampsAt150(): void $this->mockLegacyEvents->method('convertAll')->willReturn([]); - $this->eventsApi->getEventsBatch($timestamps, ['HEK']); + $this->eventsApi->getEventsBatch($timestamps, ['HEK'], 150); } public function testItShouldThrowAndCaptureSentryOnHttpError(): void @@ -156,6 +156,6 @@ public function testItShouldMergeObservationsAcrossChunksAndPassToConverter(): v })) ->willReturn([]); - $this->eventsApi->getEventsBatch($timestamps, ['HEK']); + $this->eventsApi->getEventsBatch($timestamps, ['HEK'], 150); } } From 3dd18b4efd7776d5a0b363affd445cd0d64a1239 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Wed, 13 May 2026 18:33:22 +0000 Subject: [PATCH 02/15] fix typo comment --- src/Event/Api/EventsApi.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Event/Api/EventsApi.php b/src/Event/Api/EventsApi.php index fc085c228..04431c549 100644 --- a/src/Event/Api/EventsApi.php +++ b/src/Event/Api/EventsApi.php @@ -233,7 +233,7 @@ public function getEventsBatch(array $timestamps, array $sources, int $chunkSize */ private function parseResponse($response): array { - $body = (string)$response->getBody(); + $body = (string)$response->getBody(); $data = json_decode($body, true); if (json_last_error() !== JSON_ERROR_NONE) { From 0760d48888e40311589aa7d98766c1a41aafb314 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Wed, 13 May 2026 19:05:35 +0000 Subject: [PATCH 03/15] fix early status check of movie, craete frames directory early --- src/Movie/HelioviewerMovie.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Movie/HelioviewerMovie.php b/src/Movie/HelioviewerMovie.php index 899e8c9e4..4ffd48d90 100644 --- a/src/Movie/HelioviewerMovie.php +++ b/src/Movie/HelioviewerMovie.php @@ -423,9 +423,11 @@ public function getCurrentFrame() { } } - // Do not call closedir boolean if we can not open directory + // The frames directory may not exist yet when getMovieStatus is polled + // very early in PROCESSING -- before _buildMovieFrames has created it. + // Treat that as "0 frames written so far" instead of throwing. if (false === $handle) { - throw new \Exception("Could not find requested movie frames"); + return 0; } @closedir($handle); @@ -502,6 +504,14 @@ private function _buildMovieFrames($watermark) { $this->_dbSetup(); + // Pre-create the frames directory so getMovieStatus polls that arrive + // while the events prefetch is still running don't see a missing dir + // (status flips to PROCESSING the moment build() is invoked). + $framesDir = $this->directory . 'frames'; + if (!@file_exists($framesDir)) { + @mkdir($framesDir, 0775, true); + } + $frameNum = 0; // Movie frame parameters From bf7f7f9ac3b0219b95499a437079343cc7b2bed6 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Wed, 13 May 2026 19:12:03 +0000 Subject: [PATCH 04/15] rename new chunksize configuration name, align with old config names --- settings/Config.Example.ini | 2 +- src/Config.php | 2 +- src/Movie/HelioviewerMovie.php | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/settings/Config.Example.ini b/settings/Config.Example.ini index 64fd3ce96..5cd878c86 100644 --- a/settings/Config.Example.ini +++ b/settings/Config.Example.ini @@ -94,7 +94,7 @@ events_api_timeout = 10 ; Number of timestamps per Events API batch request when building a movie. ; Smaller = faster individual requests, more requests total; larger = fewer ; round-trips but slower per request. 50 is a balanced default. -event_api_events_per_frame_chunksize = 50 +events_api_events_per_frame_chunksize = 50 [movie_params] ; FFmpeg location diff --git a/src/Config.php b/src/Config.php index d48df1226..d7d29fabc 100644 --- a/src/Config.php +++ b/src/Config.php @@ -19,7 +19,7 @@ class Config { private $_bools = array('disable_cache', 'enable_statistics_collection', 'db_events','sentry_enabled'); private $_ints = array('build_num', 'ffmpeg_max_threads', 'max_jpx_frames', 'max_movie_frames', - 'event_api_events_per_frame_chunksize'); + 'events_api_events_per_frame_chunksize'); private $_floats = array('events_api_timeout'); private $config; diff --git a/src/Movie/HelioviewerMovie.php b/src/Movie/HelioviewerMovie.php index 4ffd48d90..9cc7dedbf 100644 --- a/src/Movie/HelioviewerMovie.php +++ b/src/Movie/HelioviewerMovie.php @@ -546,8 +546,8 @@ private function _buildMovieFrames($watermark) { )); if ($this->_eventsManager->hasEvents()) { - $chunkSize = defined('HV_EVENT_API_EVENTS_PER_FRAME_CHUNKSIZE') - ? HV_EVENT_API_EVENTS_PER_FRAME_CHUNKSIZE + $chunkSize = defined('HV_EVENTS_API_EVENTS_PER_FRAME_CHUNKSIZE') + ? HV_EVENTS_API_EVENTS_PER_FRAME_CHUNKSIZE : 50; $totalStart = microtime(true); From 70700a66267bd0039ec0c34adeea7d387ed68150 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Wed, 13 May 2026 19:24:27 +0000 Subject: [PATCH 05/15] add log if chunk requests failing, movie maker starts to make requests per frame again --- src/Image/Composite/HelioviewerCompositeImage.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Image/Composite/HelioviewerCompositeImage.php b/src/Image/Composite/HelioviewerCompositeImage.php index 2f6379af5..ea1e44866 100644 --- a/src/Image/Composite/HelioviewerCompositeImage.php +++ b/src/Image/Composite/HelioviewerCompositeImage.php @@ -646,6 +646,7 @@ private function _addEventLayer($imagickImage) { // Fetch events via batch (movies have pre-fetched, screenshots fetch for single timestamp) if (empty($this->batchEventResponse)) { + error_log("[date={$this->date}] batchEventResponse empty, fetching single timestamp"); try { $this->batchEventResponse = $this->eventsApi->getEventsBatch( [$this->date], From 46ae577df1e0b85497fa72aa65f0d26762d93db0 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Wed, 13 May 2026 20:14:20 +0000 Subject: [PATCH 06/15] add more logging for edge cases of event selections --- src/Movie/HelioviewerMovie.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Movie/HelioviewerMovie.php b/src/Movie/HelioviewerMovie.php index 9cc7dedbf..0b7cecf00 100644 --- a/src/Movie/HelioviewerMovie.php +++ b/src/Movie/HelioviewerMovie.php @@ -539,10 +539,11 @@ private function _buildMovieFrames($watermark) { $movieId = $this->publicId; error_log(sprintf( - "[Movie:%s] Starting movie build, frames=%d, sources=%s", + "[Movie:%s] Starting movie build, frames=%d, sources=%s, hasEvents=%s", $movieId, count($timestamps), - $sources ? implode(',', $sources) : '(none)' + $sources ? implode(',', $sources) : '(none)', + $this->_eventsManager->hasEvents() ? 'true' : 'false' )); if ($this->_eventsManager->hasEvents()) { @@ -569,6 +570,8 @@ private function _buildMovieFrames($watermark) { "[Movie:%s] all event chunks done in %dms (%d frames)", $movieId, $totalMs, count($timestamps) )); + } else { + error_log("[Movie:{$movieId}] No event types selected, skipping EventsApi request"); } $options['batchEventResponse'] = $batchResponse; From ca0b103f096eb36b41e8823bafd84ac6f3a5add8 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Mon, 18 May 2026 17:40:49 +0000 Subject: [PATCH 07/15] fix small warning about empty or non-existing array --- src/Event/EventsStateManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Event/EventsStateManager.php b/src/Event/EventsStateManager.php index 750ab224a..7129c5039 100644 --- a/src/Event/EventsStateManager.php +++ b/src/Event/EventsStateManager.php @@ -42,7 +42,7 @@ private function __construct(array $events_state) } - foreach($eventHelioGroupState['layers'] as $eventHelioGroupLayer) { + foreach(($eventHelioGroupState['layers'] ?? []) as $eventHelioGroupLayer) { $layer_event_type = $eventHelioGroupLayer['event_type']; From 87c0309a00f0bd0ab808891c9195e4d412429d27 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Mon, 18 May 2026 17:41:47 +0000 Subject: [PATCH 08/15] bring chunksize max size and chunksize through configuration --- src/Event/Api/EventsApi.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Event/Api/EventsApi.php b/src/Event/Api/EventsApi.php index 04431c549..aa13f1dd2 100644 --- a/src/Event/Api/EventsApi.php +++ b/src/Event/Api/EventsApi.php @@ -23,6 +23,12 @@ class EventsApi implements EventsApiInterface { /** Known event sources */ public const VALID_SOURCES = ['HEK', 'CCMC', 'RHESSI']; + /** Upstream-imposed cap on timestamps per batch request */ + public const MAX_CHUNK_SIZE = 150; + + /** Upstream-imposed cap on selections per frames_with_selections request */ + public const MAX_SELECTIONS = 200; + private ClientInterface $client; private SentryClientInterface $sentry; private LegacyEventsInterface $legacyEvents; @@ -167,7 +173,10 @@ public function getEventsBatch(array $timestamps, array $sources, int $chunkSize return []; } if ($chunkSize < 1) { - $chunkSize = 50; + $chunkSize = defined('HV_EVENTS_API_EVENTS_PER_FRAME_CHUNKSIZE') ? (int) HV_EVENTS_API_EVENTS_PER_FRAME_CHUNKSIZE : 50; + } + if ($chunkSize > self::MAX_CHUNK_SIZE) { + $chunkSize = self::MAX_CHUNK_SIZE; } $sourcesParam = implode('::', $validSources); From d2183a114fc366f14954ed16cf712cc36212c864 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Mon, 18 May 2026 20:29:14 +0000 Subject: [PATCH 09/15] implement new function for events to event_state_manaager internal data to current event_selections code --- src/Event/EventsStateManager.php | 221 ++++++++++++++++-- .../GetSelectionsTest.php | 48 ++++ .../selections/deduplication.php | 16 ++ .../selections/empty_muted.php | 26 +++ .../selections/layers_v2_shortcut.php | 54 +++++ .../selections/level_1_all_wildcard.php | 52 +++++ .../selections/level_2_explicit_frms.php | 79 +++++++ .../selections/level_3_event_instances.php | 74 ++++++ .../selections/markers_visible_coercion.php | 77 ++++++ .../selections/multi_source.php | 84 +++++++ .../selections/unknown_malformed.php | 64 +++++ 11 files changed, 781 insertions(+), 14 deletions(-) create mode 100644 tests/unit_tests/events/events_state_manager/GetSelectionsTest.php create mode 100644 tests/unit_tests/events/events_state_manager/selections/deduplication.php create mode 100644 tests/unit_tests/events/events_state_manager/selections/empty_muted.php create mode 100644 tests/unit_tests/events/events_state_manager/selections/layers_v2_shortcut.php create mode 100644 tests/unit_tests/events/events_state_manager/selections/level_1_all_wildcard.php create mode 100644 tests/unit_tests/events/events_state_manager/selections/level_2_explicit_frms.php create mode 100644 tests/unit_tests/events/events_state_manager/selections/level_3_event_instances.php create mode 100644 tests/unit_tests/events/events_state_manager/selections/markers_visible_coercion.php create mode 100644 tests/unit_tests/events/events_state_manager/selections/multi_source.php create mode 100644 tests/unit_tests/events/events_state_manager/selections/unknown_malformed.php diff --git a/src/Event/EventsStateManager.php b/src/Event/EventsStateManager.php index 7129c5039..62b146734 100644 --- a/src/Event/EventsStateManager.php +++ b/src/Event/EventsStateManager.php @@ -12,6 +12,8 @@ namespace Helioviewer\Api\Event; +use Helioviewer\Api\Event\Api\EventsApi; + class EventsStateManager { // internal events state original @@ -35,8 +37,9 @@ private function __construct(array $events_state) $this->events_tree_label_visibility = []; foreach($events_state as $eventHelioGroupName => $eventHelioGroupState) { // CCMC or HEK state - - // If we don't have visible markers for CCMC or HEK then no need to handle them + + // Skip only when markers_visible is explicitly set to a non-truthy + // value. Missing key is treated as "on" by historical convention. if (array_key_exists('markers_visible', $eventHelioGroupState) && $eventHelioGroupState['markers_visible'] != true) { continue; } @@ -44,36 +47,45 @@ private function __construct(array $events_state) foreach(($eventHelioGroupState['layers'] ?? []) as $eventHelioGroupLayer) { - $layer_event_type = $eventHelioGroupLayer['event_type']; - + $layer_event_type = $eventHelioGroupLayer['event_type'] ?? null; + if ($layer_event_type === null) { + continue; + } + if (!array_key_exists($layer_event_type, $this->events_tree)) { $this->events_tree[$layer_event_type] = []; - $this->events_tree_label_visibility[$layer_event_type] = $eventHelioGroupState['labels_visible']; + $this->events_tree_label_visibility[$layer_event_type] = $eventHelioGroupState['labels_visible'] ?? false; } + $layerFrms = $eventHelioGroupLayer['frms'] ?? []; + // This damn all fix - if (in_array("all",$eventHelioGroupLayer['frms'])) { + if (in_array("all", $layerFrms)) { $this->events_tree[$layer_event_type] = "all_frms"; } else { - foreach($eventHelioGroupLayer['frms'] as $eventLayerFrm) { + foreach($layerFrms as $eventLayerFrm) { $event_layer_frm = str_replace('\\', '', $eventLayerFrm); if (!array_key_exists($event_layer_frm, $this->events_tree[$layer_event_type])) { $this->events_tree[$layer_event_type][$event_layer_frm] = 'all_event_instances'; } } - foreach($eventHelioGroupLayer['event_instances'] as $eventLayerEventInstance) { + foreach(($eventHelioGroupLayer['event_instances'] ?? []) as $eventLayerEventInstance) { $event_instance_frm_pieces = explode('--',$eventLayerEventInstance); - $event_instance_frm = $event_instance_frm_pieces[1]; + $event_instance_frm = $event_instance_frm_pieces[1] ?? null; + if ($event_instance_frm === null) { + // Malformed instance id (missing "----" segment); skip + continue; + } $event_instance_frm = str_replace('\\', '', $event_instance_frm); - // if we have frms all included like "frm1" and in event instance "flare--frm1--event1" + // if we have frms all included like "frm1" and in event instance "flare--frm1--event1" // we just ignore those since they are all included into the tree with frm1 anyways - // this is also indicates, eventsState is invalid somehow - if (in_array($event_instance_frm, $eventHelioGroupLayer['frms'])) { + // this is also indicates, eventsState is invalid somehow + if (in_array($event_instance_frm, $layerFrms)) { continue; } @@ -301,14 +313,195 @@ public function isEventTypeLabelVisible(string $event_category_pin): bool } + /** + * Build path-prefix selection strings for the events API + * frames_with_selections endpoint. + * + * ─── End-to-end example ─────────────────────────────────────────────── + * + * INPUT $this->events_state: + * [ + * 'tree_HEK' => [ + * 'markers_visible' => true, + * 'layers' => [ + * ['event_type' => 'AR', 'frms' => ['all'], 'event_instances' => []], + * ['event_type' => 'FL', 'frms' => ['NOAA_SWPC'], 'event_instances' => ['flare--SDO HMI--evt-123']], + * ], + * ], + * 'tree_CCMC' => [ + * 'markers_visible' => true, + * 'layers_v2' => ['CCMC>>DONKI>>CME'], + * ], + * 'tree_RHESSI' => [ + * 'markers_visible' => false, // <- muted, skipped entirely + * 'layers' => [...], + * ], + * ] + * + * OUTPUT (after dedup): + * [ + * 'HEK>>Active Region', // AR with frms=['all'] -> level 1 + * 'HEK>>Flare>>NOAA_SWPC', // FL + 'NOAA_SWPC' + * 'HEK>>Flare>>NOAA SWPC', // variant: underscores -> spaces + * 'HEK>>Flare>>SDO HMI', // FRM parsed out of event_instance + * 'HEK>>Flare>>SDO_HMI', // variant: spaces -> underscores + * 'CCMC>>DONKI>>CME', // layers_v2 passed straight through + * ] + * + * Why three variants for FRM names? + * FRM strings come from user/frontend input and the upstream events API + * has historically been inconsistent about whether spaces or underscores + * are canonical. Emitting raw + both transforms means whichever form the + * upstream stores, at least one of our paths will prefix-match it. + * + * @return string[] deduplicated list of path-prefix selection strings + */ + public function getSelections(): array + { + $selections = []; + + // Drive the loop off the canonical source list (EventsApi::VALID_SOURCES) + // rather than whatever happens to be in events_state. Typo'd entries + // like "tree_HEKL" are silently ignored; iteration order is stable. + foreach (EventsApi::VALID_SOURCES as $source) { + + $treeKey = 'tree_' . $source; + if (!isset($this->events_state[$treeKey])) { + continue; + } + $sourceState = $this->events_state[$treeKey]; + + // Muted source contributes nothing. Mirrors the constructor's + // historical convention: only skip when markers_visible is + // EXPLICITLY non-truthy; absent key falls through. + // e.g. tree_RHESSI with markers_visible=false => skip + if (array_key_exists('markers_visible', $sourceState) && $sourceState['markers_visible'] != true) { + continue; + } + + // ─── Shortcut: layers_v2 ──────────────────────────────────────── + // If the frontend has already produced canonical selection paths + // (e.g. ['CCMC>>DONKI>>CME']), pass them through verbatim. No pin + // lookup, no FRM variants — the frontend owns that. + if (!empty($sourceState['layers_v2'])) { + foreach ($sourceState['layers_v2'] as $path) { + $selections[] = $path; + } + continue; + } + + // ─── Walk layers (legacy / v1 path) ───────────────────────────── + // Each layer entry describes one event_type within this source. + // layer = ['event_type'=>'AR', 'frms'=>['all'], 'event_instances'=>[]] + foreach ($sourceState['layers'] ?? [] as $layer) { + + $pin = $layer['event_type'] ?? null; + if ($pin === null) { + // Malformed layer: no event_type field. Log and skip. + error_log(sprintf( + "[getSelections] layer missing 'event_type' in source=%s layer=%s", + $source, json_encode($layer) + )); + continue; + } + + // Translate the 2-3 letter pin into the upstream API's + // human-readable label. + // ('HEK', 'AR') -> 'Active Region' + // ('HEK', 'FL') -> 'Flare' + // ('CCMC', 'FP') -> 'Solar Flare Predictions' + // Unknown pin -> null -> skip (matches EventSelections behavior) + $label = EventSelections::$event_types_map[$source][$pin] ?? null; + if ($label === null) { + // Pin isn't in the map - usually means a typo or a new + // event type we haven't registered yet. Visible signal, + // except for 'UNK' which is intentionally out of the map + // (it's the frontend's "unknown / fallback" sentinel). + if ($pin !== 'UNK') { + error_log(sprintf( + "[getSelections] unknown pin '%s' for source=%s (not in EventSelections::\$event_types_map)", + $pin, $source + )); + } + continue; + } + + $frms = $layer['frms'] ?? []; // e.g. ['NOAA_SWPC'] or ['all'] + $eventInstances = $layer['event_instances'] ?? []; // e.g. ['flare--SDO HMI--evt-123'] + + // ─── Level 1: 'all' wildcard ──────────────────────────────── + // frms=['all'] means "include every FRM under this event_type". + // We can emit a single 2-part path; the upstream prefix matcher + // will catch every event regardless of FRM. + // ('HEK', 'Active Region') => "HEK>>Active Region" + if (in_array('all', $frms, true)) { + $selections[] = "{$source}>>{$label}"; + continue; + } + + // Helper: emit a FRM-deep path plus two whitespace variants. + // FRM strings come from user input, so we hedge against + // upstream naming drift (space vs underscore). + // $frm = "NOAA_SWPC" => + // "HEK>>Flare>>NOAA_SWPC" (raw) + // "HEK>>Flare>>NOAA_SWPC" (spaces->underscores: no change) + // "HEK>>Flare>>NOAA SWPC" (underscores->spaces) + $pushFrmVariants = function (string $frm) use (&$selections, $source, $label) { + $selections[] = "{$source}>>{$label}>>{$frm}"; + $selections[] = "{$source}>>{$label}>>" . str_replace(' ', '_', $frm); + $selections[] = "{$source}>>{$label}>>" . str_replace('_', ' ', $frm); + }; + + // ─── Level 2: explicit FRM list ───────────────────────────── + // frms=['NOAA_SWPC','SPoCA'] => one path-variants-set per FRM + foreach ($frms as $frm) { + $pushFrmVariants($frm); + } + + // ─── Level 3: FRMs only referenced via event_instances ────── + // The frontend can target a single event by listing an entry + // in event_instances without including that event's FRM in + // the `frms` array. We still need to fetch at the FRM level + // (upstream matches by path prefix, not by event id) and + // filter to the specific instance later in the renderer. + // + // event_instance = "flare--SDO HMI--evt-123" + // parts = ['flare', 'SDO HMI', 'evt-123'] + // frm = 'SDO HMI' (parts[1]) + // -> only push if SDO HMI isn't already in $frms (no dup work) + foreach ($eventInstances as $ei) { + $parts = explode('--', $ei); + $frm = $parts[1] ?? null; + if ($frm === null) { + // event_instance string didn't carry a FRM segment. + // Expected shape: "----". + error_log(sprintf( + "[getSelections] malformed event_instance '%s' (no FRM segment) source=%s pin=%s", + $ei, $source, $pin + )); + continue; + } + if (!in_array($frm, $frms, true)) { + $pushFrmVariants($frm); + } + } + } + } + + // Dedup: same FRM in multiple layers, or variant collisions where the + // raw FRM already has the canonical form (e.g. "NOAA_SWPC" produces + // an identical raw and spaces->underscores result). + return array_values(array_unique($selections)); + } + /** * Makes event id from given event and its belonging event_type and frm_name * @param string event_category_pin , given event_type * @param string frm_name , given frm_name - * @param array event , given event to check + * @param array event , given event to check * @return string */ - public static function makeEventId(string $event_category_pin, string $frm_name, array $event): string + public static function makeEventId(string $event_category_pin, string $frm_name, array $event): string { $event_id_pieces = [ $event_category_pin, diff --git a/tests/unit_tests/events/events_state_manager/GetSelectionsTest.php b/tests/unit_tests/events/events_state_manager/GetSelectionsTest.php new file mode 100644 index 000000000..9439dfe38 --- /dev/null +++ b/tests/unit_tests/events/events_state_manager/GetSelectionsTest.php @@ -0,0 +1,48 @@ +>LABEL[>>FRM]") for the events API + * frames_with_selections endpoint. + * + * Fixtures live in ./selections/, one PHP file per behaviour group. Each + * fixture file returns an array of cases keyed by description. The provider + * concatenates them in the order listed below; PHPUnit prints the case key + * verbatim on failure. + */ +class GetSelectionsTest extends TestCase +{ + /** Ordered list of fixture files under ./selections/ */ + private const FIXTURES = [ + 'empty_muted.php', + 'markers_visible_coercion.php', + 'level_1_all_wildcard.php', + 'level_2_explicit_frms.php', + 'level_3_event_instances.php', + 'layers_v2_shortcut.php', + 'unknown_malformed.php', + 'deduplication.php', + 'multi_source.php', + ]; + + public static function selectionsProvider(): array + { + $cases = []; + foreach (self::FIXTURES as $file) { + $cases = array_merge($cases, require __DIR__ . '/selections/' . $file); + } + return $cases; + } + + /** + * @dataProvider selectionsProvider + */ + public function testItShouldReturnExpectedSelections(array $state, array $expected): void + { + $manager = EventsStateManager::buildFromEventsState($state); + $this->assertEquals($expected, $manager->getSelections()); + } +} diff --git a/tests/unit_tests/events/events_state_manager/selections/deduplication.php b/tests/unit_tests/events/events_state_manager/selections/deduplication.php new file mode 100644 index 000000000..d16c4f95a --- /dev/null +++ b/tests/unit_tests/events/events_state_manager/selections/deduplication.php @@ -0,0 +1,16 @@ + [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [ + ['event_type' => 'FL', 'frms' => ['NOAA_SWPC'], 'event_instances' => []], + ['event_type' => 'FL', 'frms' => ['NOAA_SWPC'], 'event_instances' => []], + ], + ], + ], + 'expected' => ['HEK>>Flare>>NOAA_SWPC', 'HEK>>Flare>>NOAA SWPC'], +], +]; diff --git a/tests/unit_tests/events/events_state_manager/selections/empty_muted.php b/tests/unit_tests/events/events_state_manager/selections/empty_muted.php new file mode 100644 index 000000000..99e7a5cfb --- /dev/null +++ b/tests/unit_tests/events/events_state_manager/selections/empty_muted.php @@ -0,0 +1,26 @@ + [ + 'state' => [], + 'expected' => [], +], +'source with markers_visible=false produces nothing' => [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => false, 'labels_visible' => false, + 'layers' => [['event_type' => 'AR', 'frms' => ['all'], 'event_instances' => []]], + ], + ], + 'expected' => [], +], +'source with markers_visible key absent still processes (historical convention)' => [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', + 'layers' => [['event_type' => 'AR', 'frms' => ['all'], 'event_instances' => []]], + ], + ], + 'expected' => ['HEK>>Active Region'], +], +]; diff --git a/tests/unit_tests/events/events_state_manager/selections/layers_v2_shortcut.php b/tests/unit_tests/events/events_state_manager/selections/layers_v2_shortcut.php new file mode 100644 index 000000000..9378db53a --- /dev/null +++ b/tests/unit_tests/events/events_state_manager/selections/layers_v2_shortcut.php @@ -0,0 +1,54 @@ + [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'AR', 'frms' => ['all'], 'event_instances' => []]], + 'layers_v2' => ['HEK>>Active Region>>SPoCA', 'HEK>>Flare'], + ], + ], + 'expected' => ['HEK>>Active Region>>SPoCA', 'HEK>>Flare'], +], +'layers_v2 empty: falls through to legacy layers' => [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'AR', 'frms' => ['all'], 'event_instances' => []]], + 'layers_v2' => [], + ], + ], + 'expected' => ['HEK>>Active Region'], +], +'layers_v2 shortcut bypasses pin lookup even for unknown pins' => [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'ZZ', 'frms' => ['all'], 'event_instances' => []]], + 'layers_v2' => ['HEK>>Anything>>Goes'], + ], + ], + 'expected' => ['HEK>>Anything>>Goes'], +], +'layers_v2 shortcut works for CCMC source' => [ + 'state' => [ + 'tree_CCMC' => [ + 'id' => 'CCMC', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'C3', 'frms' => ['all'], 'event_instances' => []]], + 'layers_v2' => ['CCMC>>DONKI>>CME', 'CCMC>>Solar Flare Predictions'], + ], + ], + 'expected' => ['CCMC>>DONKI>>CME', 'CCMC>>Solar Flare Predictions'], +], +'layers_v2 shortcut works for RHESSI source' => [ + 'state' => [ + 'tree_RHESSI' => [ + 'id' => 'RHESSI', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [], + 'layers_v2' => ['RHESSI>>Solar Flares>>Clean'], + ], + ], + 'expected' => ['RHESSI>>Solar Flares>>Clean'], +], +]; diff --git a/tests/unit_tests/events/events_state_manager/selections/level_1_all_wildcard.php b/tests/unit_tests/events/events_state_manager/selections/level_1_all_wildcard.php new file mode 100644 index 000000000..199bfebf0 --- /dev/null +++ b/tests/unit_tests/events/events_state_manager/selections/level_1_all_wildcard.php @@ -0,0 +1,52 @@ +>LABEL' => [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'AR', 'frms' => ['all'], 'event_instances' => []]], + ], + ], + 'expected' => ['HEK>>Active Region'], +], +'level 1: multiple all-layers emit one path each' => [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [ + ['event_type' => 'AR', 'frms' => ['all'], 'event_instances' => []], + ['event_type' => 'FL', 'frms' => ['all'], 'event_instances' => []], + ], + ], + ], + 'expected' => ['HEK>>Active Region', 'HEK>>Flare'], +], +'level 1: CCMC C3 all -> CCMC>>DONKI' => [ + 'state' => [ + 'tree_CCMC' => [ + 'id' => 'CCMC', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'C3', 'frms' => ['all'], 'event_instances' => []]], + ], + ], + 'expected' => ['CCMC>>DONKI'], +], +'level 1: CCMC FP all -> CCMC>>Solar Flare Predictions' => [ + 'state' => [ + 'tree_CCMC' => [ + 'id' => 'CCMC', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'FP', 'frms' => ['all'], 'event_instances' => []]], + ], + ], + 'expected' => ['CCMC>>Solar Flare Predictions'], +], +'level 1: RHESSI F2 all -> RHESSI>>Solar Flares' => [ + 'state' => [ + 'tree_RHESSI' => [ + 'id' => 'RHESSI', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'F2', 'frms' => ['all'], 'event_instances' => []]], + ], + ], + 'expected' => ['RHESSI>>Solar Flares'], +], +]; diff --git a/tests/unit_tests/events/events_state_manager/selections/level_2_explicit_frms.php b/tests/unit_tests/events/events_state_manager/selections/level_2_explicit_frms.php new file mode 100644 index 000000000..baba37544 --- /dev/null +++ b/tests/unit_tests/events/events_state_manager/selections/level_2_explicit_frms.php @@ -0,0 +1,79 @@ + [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'FL', 'frms' => ['Foo Bar_Baz'], 'event_instances' => []]], + ], + ], + 'expected' => [ + 'HEK>>Flare>>Foo Bar_Baz', + 'HEK>>Flare>>Foo_Bar_Baz', + 'HEK>>Flare>>Foo Bar Baz', + ], +], +'level 2: FRM with only underscores collapses to 2 variants' => [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'FL', 'frms' => ['NOAA_SWPC'], 'event_instances' => []]], + ], + ], + 'expected' => ['HEK>>Flare>>NOAA_SWPC', 'HEK>>Flare>>NOAA SWPC'], +], +'level 2: FRM with only spaces collapses to 2 variants' => [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'FL', 'frms' => ['NOAA SWPC'], 'event_instances' => []]], + ], + ], + 'expected' => ['HEK>>Flare>>NOAA SWPC', 'HEK>>Flare>>NOAA_SWPC'], +], +'level 2: FRM with neither space nor underscore collapses to 1 path' => [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'FL', 'frms' => ['NOAA-SWPC'], 'event_instances' => []]], + ], + ], + 'expected' => ['HEK>>Flare>>NOAA-SWPC'], +], +'level 2: multiple FRMs each produce their own variant set' => [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'FL', 'frms' => ['NOAA_SWPC', 'SPoCA'], 'event_instances' => []]], + ], + ], + 'expected' => [ + 'HEK>>Flare>>NOAA_SWPC', + 'HEK>>Flare>>NOAA SWPC', + 'HEK>>Flare>>SPoCA', + ], +], +'level 2: CCMC C3 with named FRM (mixed space and underscore)' => [ + 'state' => [ + 'tree_CCMC' => [ + 'id' => 'CCMC', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'C3', 'frms' => ['Foo Bar_Baz'], 'event_instances' => []]], + ], + ], + 'expected' => [ + 'CCMC>>DONKI>>Foo Bar_Baz', + 'CCMC>>DONKI>>Foo_Bar_Baz', + 'CCMC>>DONKI>>Foo Bar Baz', + ], +], +'level 2: RHESSI F2 with named FRM' => [ + 'state' => [ + 'tree_RHESSI' => [ + 'id' => 'RHESSI', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'F2', 'frms' => ['Clean_60s'], 'event_instances' => []]], + ], + ], + 'expected' => ['RHESSI>>Solar Flares>>Clean_60s', 'RHESSI>>Solar Flares>>Clean 60s'], +], +]; diff --git a/tests/unit_tests/events/events_state_manager/selections/level_3_event_instances.php b/tests/unit_tests/events/events_state_manager/selections/level_3_event_instances.php new file mode 100644 index 000000000..5b0157388 --- /dev/null +++ b/tests/unit_tests/events/events_state_manager/selections/level_3_event_instances.php @@ -0,0 +1,74 @@ + [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [ + [ + 'event_type' => 'FL', + 'frms' => ['NOAA_SWPC'], + 'event_instances' => ['flare--SDO_HMI--evt-123'], + ], + ], + ], + ], + 'expected' => [ + 'HEK>>Flare>>NOAA_SWPC', + 'HEK>>Flare>>NOAA SWPC', + 'HEK>>Flare>>SDO_HMI', + 'HEK>>Flare>>SDO HMI', + ], +], +'level 3: event_instance with FRM already in frms list contributes nothing extra' => [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [ + [ + 'event_type' => 'FL', + 'frms' => ['NOAA_SWPC'], + 'event_instances' => ['flare--NOAA_SWPC--evt-123'], + ], + ], + ], + ], + 'expected' => ['HEK>>Flare>>NOAA_SWPC', 'HEK>>Flare>>NOAA SWPC'], +], +'level 3: malformed event_instance (no -- separators) is silently skipped' => [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [ + [ + 'event_type' => 'FL', + 'frms' => ['NOAA_SWPC'], + 'event_instances' => ['no-double-dashes'], + ], + ], + ], + ], + 'expected' => ['HEK>>Flare>>NOAA_SWPC', 'HEK>>Flare>>NOAA SWPC'], +], +'level 3: CCMC event_instance picks up FRM' => [ + 'state' => [ + 'tree_CCMC' => [ + 'id' => 'CCMC', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [ + [ + 'event_type' => 'C3', + 'frms' => ['CCMC_DONKI'], + 'event_instances' => ['donki--Alt_Source--evt-77'], + ], + ], + ], + ], + 'expected' => [ + 'CCMC>>DONKI>>CCMC_DONKI', + 'CCMC>>DONKI>>CCMC DONKI', + 'CCMC>>DONKI>>Alt_Source', + 'CCMC>>DONKI>>Alt Source', + ], +], +]; diff --git a/tests/unit_tests/events/events_state_manager/selections/markers_visible_coercion.php b/tests/unit_tests/events/events_state_manager/selections/markers_visible_coercion.php new file mode 100644 index 000000000..19c0dd55d --- /dev/null +++ b/tests/unit_tests/events/events_state_manager/selections/markers_visible_coercion.php @@ -0,0 +1,77 @@ + [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => 'true', 'labels_visible' => true, + 'layers' => [['event_type' => 'AR', 'frms' => ['all'], 'event_instances' => []]], + ], + ], + 'expected' => ['HEK>>Active Region'], + ], + "markers_visible='false' (string) is ALSO truthy (non-empty string): processes -- GOTCHA" => [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => 'false', 'labels_visible' => true, + 'layers' => [['event_type' => 'AR', 'frms' => ['all'], 'event_instances' => []]], + ], + ], + 'expected' => ['HEK>>Active Region'], + ], + "markers_visible='' (empty string) is falsy: skipped" => [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => '', 'labels_visible' => true, + 'layers' => [['event_type' => 'AR', 'frms' => ['all'], 'event_instances' => []]], + ], + ], + 'expected' => [], + ], + "markers_visible='nonsense-for-bool' is truthy: processes" => [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => 'nonsense-for-bool', 'labels_visible' => true, + 'layers' => [['event_type' => 'AR', 'frms' => ['all'], 'event_instances' => []]], + ], + ], + 'expected' => ['HEK>>Active Region'], + ], + "markers_visible=0 (int) is falsy: skipped" => [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => 0, 'labels_visible' => true, + 'layers' => [['event_type' => 'AR', 'frms' => ['all'], 'event_instances' => []]], + ], + ], + 'expected' => [], + ], + "markers_visible=1 (int) is truthy: processes" => [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => 1, 'labels_visible' => true, + 'layers' => [['event_type' => 'AR', 'frms' => ['all'], 'event_instances' => []]], + ], + ], + 'expected' => ['HEK>>Active Region'], + ], + "markers_visible=null is falsy: skipped" => [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => null, 'labels_visible' => true, + 'layers' => [['event_type' => 'AR', 'frms' => ['all'], 'event_instances' => []]], + ], + ], + 'expected' => [], + ], +]; diff --git a/tests/unit_tests/events/events_state_manager/selections/multi_source.php b/tests/unit_tests/events/events_state_manager/selections/multi_source.php new file mode 100644 index 000000000..f4460c661 --- /dev/null +++ b/tests/unit_tests/events/events_state_manager/selections/multi_source.php @@ -0,0 +1,84 @@ + [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'AR', 'frms' => ['all'], 'event_instances' => []]], + ], + 'tree_CCMC' => [ + 'id' => 'CCMC', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'FP', 'frms' => ['all'], 'event_instances' => []]], + ], + 'tree_RHESSI' => [ + 'id' => 'RHESSI', 'markers_visible' => false, 'labels_visible' => false, + 'layers' => [['event_type' => 'F2', 'frms' => ['all'], 'event_instances' => []]], + ], + ], + 'expected' => ['HEK>>Active Region', 'CCMC>>Solar Flare Predictions'], +], +'multi-source: all three visible, each contributing a Level 1 path' => [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'AR', 'frms' => ['all'], 'event_instances' => []]], + ], + 'tree_CCMC' => [ + 'id' => 'CCMC', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'C3', 'frms' => ['all'], 'event_instances' => []]], + ], + 'tree_RHESSI' => [ + 'id' => 'RHESSI', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'F2', 'frms' => ['all'], 'event_instances' => []]], + ], + ], + 'expected' => [ + 'HEK>>Active Region', + 'CCMC>>DONKI', + 'RHESSI>>Solar Flares', + ], +], +'multi-source: each source at a different level' => [ + 'state' => [ + // HEK: level 1 (all) + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'AR', 'frms' => ['all'], 'event_instances' => []]], + ], + // CCMC: level 2 (named FRM) + 'tree_CCMC' => [ + 'id' => 'CCMC', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'C3', 'frms' => ['CCMC-DONKI'], 'event_instances' => []]], + ], + // RHESSI: layers_v2 shortcut + 'tree_RHESSI' => [ + 'id' => 'RHESSI', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'F2', 'frms' => ['all'], 'event_instances' => []]], + 'layers_v2' => ['RHESSI>>Solar Flares>>Custom'], + ], + ], + 'expected' => [ + 'HEK>>Active Region', + 'CCMC>>DONKI>>CCMC-DONKI', + 'RHESSI>>Solar Flares>>Custom', + ], +], +'multi-source: CCMC muted, HEK + RHESSI visible' => [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'AR', 'frms' => ['all'], 'event_instances' => []]], + ], + 'tree_CCMC' => [ + 'id' => 'CCMC', 'markers_visible' => false, 'labels_visible' => false, + 'layers' => [['event_type' => 'C3', 'frms' => ['all'], 'event_instances' => []]], + ], + 'tree_RHESSI' => [ + 'id' => 'RHESSI', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [['event_type' => 'F2', 'frms' => ['all'], 'event_instances' => []]], + ], + ], + 'expected' => ['HEK>>Active Region', 'RHESSI>>Solar Flares'], +], +]; diff --git a/tests/unit_tests/events/events_state_manager/selections/unknown_malformed.php b/tests/unit_tests/events/events_state_manager/selections/unknown_malformed.php new file mode 100644 index 000000000..6bb03b502 --- /dev/null +++ b/tests/unit_tests/events/events_state_manager/selections/unknown_malformed.php @@ -0,0 +1,64 @@ + [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [ + ['event_type' => 'AR', 'frms' => ['all'], 'event_instances' => []], + ['event_type' => 'ZZ', 'frms' => ['all'], 'event_instances' => []], + ], + ], + ], + 'expected' => ['HEK>>Active Region'], +], +'UNK sentinel pin is silently skipped (no unknown-pin log fired)' => [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [ + ['event_type' => 'UNK', 'frms' => ['all'], 'event_instances' => []], + ['event_type' => 'AR', 'frms' => ['all'], 'event_instances' => []], + ], + ], + ], + 'expected' => ['HEK>>Active Region'], +], +'layer missing event_type key is skipped' => [ + 'state' => [ + 'tree_HEK' => [ + 'id' => 'HEK', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [ + ['frms' => ['all'], 'event_instances' => []], + ['event_type' => 'AR', 'frms' => ['all'], 'event_instances' => []], + ], + ], + ], + 'expected' => ['HEK>>Active Region'], +], +'unknown pin in CCMC source is skipped' => [ + 'state' => [ + 'tree_CCMC' => [ + 'id' => 'CCMC', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [ + ['event_type' => 'C3', 'frms' => ['all'], 'event_instances' => []], + ['event_type' => 'XY', 'frms' => ['all'], 'event_instances' => []], // not in CCMC map + ], + ], + ], + 'expected' => ['CCMC>>DONKI'], +], +'unknown pin in RHESSI source is skipped' => [ + 'state' => [ + 'tree_RHESSI' => [ + 'id' => 'RHESSI', 'markers_visible' => true, 'labels_visible' => true, + 'layers' => [ + ['event_type' => 'F2', 'frms' => ['all'], 'event_instances' => []], + ['event_type' => 'ZZ', 'frms' => ['all'], 'event_instances' => []], // not in RHESSI map + ], + ], + ], + 'expected' => ['RHESSI>>Solar Flares'], +], +]; From dbf8a1e1e56b3a32a70bb06e52bb274c0a323718 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Mon, 18 May 2026 20:39:52 +0000 Subject: [PATCH 10/15] new endpoint to use selections when filtering for movie frames --- src/Event/Api/EventsApi.php | 77 ++++++ src/Event/Api/EventsApiInterface.php | 27 +++ .../GetEventsForFramesWithSelectionsTest.php | 227 ++++++++++++++++++ 3 files changed, 331 insertions(+) create mode 100644 tests/unit_tests/events/api/GetEventsForFramesWithSelectionsTest.php diff --git a/src/Event/Api/EventsApi.php b/src/Event/Api/EventsApi.php index aa13f1dd2..8584ed4d6 100644 --- a/src/Event/Api/EventsApi.php +++ b/src/Event/Api/EventsApi.php @@ -232,6 +232,83 @@ public function getEventsBatch(array $timestamps, array $sources, int $chunkSize return $this->legacyEvents->convertAll($merged); } + /** {@inheritdoc} */ + public function getEventsForFramesWithSelections( + array $timestamps, + array $selections, + int $chunkSize = 50, + string $logLabel = '' + ): array { + if (empty($selections)) { + throw new EventsApiException("No selections given. At least one path-prefix selection is required."); + } + if (count($selections) > self::MAX_SELECTIONS) { + throw new EventsApiException("Too many selections: " . count($selections) . ". Upstream limit is " . self::MAX_SELECTIONS . "."); + } + if (empty($timestamps)) { + return []; + } + if ($chunkSize < 1) { + $chunkSize = defined('HV_EVENTS_API_EVENTS_PER_FRAME_CHUNKSIZE') ? (int) HV_EVENTS_API_EVENTS_PER_FRAME_CHUNKSIZE : 50; + } + if ($chunkSize > self::MAX_CHUNK_SIZE) { + $chunkSize = self::MAX_CHUNK_SIZE; + } + + $url = "/helioviewer/events/frames_with_selections"; + $chunks = array_chunk($timestamps, $chunkSize); + + $fetchChunk = function (array $chunkTimestamps) use ($url, $selections) { + $this->sentry->setContext('EventsApi', [ + 'endpoint' => $url, + 'timestamp_count' => count($chunkTimestamps), + 'selection_count' => count($selections), + ]); + + try { + $response = $this->client->request('POST', $url, [ + 'json' => [ + 'timestamps' => $chunkTimestamps, + 'selections' => $selections, + ], + ]); + return $this->parseResponse($response); + } catch (\Throwable $e) { + $this->sentry->setContext('EventsApi', [ + 'error' => $e->getMessage(), + ]); + $exception = new EventsApiException("Failed to fetch frames_with_selections: " . $e->getMessage(), 0, $e); + $this->sentry->capture($exception); + throw $exception; + } + }; + + $logChunk = function (int $i, int $total, int $size, int $elapsedMs) use ($logLabel) { + if ($logLabel === '') { + return; + } + error_log(sprintf( + "[%s] EventsApi frames_with_selections chunk %d/%d (%d timestamps) took %dms", + $logLabel, $i + 1, $total, $size, $elapsedMs + )); + }; + + $start = microtime(true); + $merged = $fetchChunk($chunks[0]); + $logChunk(0, count($chunks), count($chunks[0]), (int) round((microtime(true) - $start) * 1000)); + + for ($i = 1; $i < count($chunks); $i++) { + $start = microtime(true); + $chunk = $fetchChunk($chunks[$i]); + $logChunk($i, count($chunks), count($chunks[$i]), (int) round((microtime(true) - $start) * 1000)); + // events keyed by uuid; timestamps keyed by datetime string. + does first-wins union. + $merged['events'] = ($merged['events'] ?? []) + ($chunk['events'] ?? []); + $merged['timestamps'] = ($merged['timestamps'] ?? []) + ($chunk['timestamps'] ?? []); + } + + return $merged; + } + /** * Parse the HTTP response body as JSON. * Validates that the response is valid JSON and returns an array. diff --git a/src/Event/Api/EventsApiInterface.php b/src/Event/Api/EventsApiInterface.php index a4adcd6fa..3664caa80 100644 --- a/src/Event/Api/EventsApiInterface.php +++ b/src/Event/Api/EventsApiInterface.php @@ -51,4 +51,31 @@ public function getDistributions(string $size, int $fromTimestamp, int $toTimest * @throws EventsApiException on API errors or unexpected responses */ public function getEventsBatch(array $timestamps, array $sources, int $chunkSize = 50, string $logLabel = ''): array; + + /** + * Fetch events for multiple observation timestamps filtered by a list of + * path-prefix selections. Posts to /helioviewer/events/frames_with_selections. + * + * Returns the RAW merged response (no legacy conversion): + * [ + * 'events' => [ => {path, label, start, end, hv_hpc_x, hv_hpc_y, footprint, type, pin} ], + * 'timestamps' => [ => { => {hv_hpc_x, hv_hpc_y} } ], + * ] + * + * Timestamps are paginated by $chunkSize (capped at the upstream limit of + * 150). Selections are sent verbatim with every request (limit: 200). + * + * @param string[] $timestamps Array of observation datetime strings + * @param string[] $selections Path-prefix strings (e.g. ['HEK>>Flare', 'CCMC>>DONKI>>CME']); max 200 + * @param int $chunkSize Max timestamps per upstream POST request (capped at 150) + * @param string $logLabel Optional label prepended to per-chunk error_log lines + * @return array Merged raw response with 'events' and 'timestamps' keys + * @throws EventsApiException on API errors, empty selections, or selections > 200 + */ + public function getEventsForFramesWithSelections( + array $timestamps, + array $selections, + int $chunkSize = 50, + string $logLabel = '' + ): array; } diff --git a/tests/unit_tests/events/api/GetEventsForFramesWithSelectionsTest.php b/tests/unit_tests/events/api/GetEventsForFramesWithSelectionsTest.php new file mode 100644 index 000000000..e019b4011 --- /dev/null +++ b/tests/unit_tests/events/api/GetEventsForFramesWithSelectionsTest.php @@ -0,0 +1,227 @@ + + */ + +use PHPUnit\Framework\TestCase; +use GuzzleHttp\ClientInterface; +use GuzzleHttp\Psr7\Response; +use Helioviewer\Api\Event\Api\EventsApi; +use Helioviewer\Api\Event\Api\EventsApiException; +use Helioviewer\Api\Event\Api\LegacyEventsInterface; +use Helioviewer\Api\Sentry\ClientInterface as SentryClientInterface; + +final class GetEventsForFramesWithSelectionsTest extends TestCase +{ + private $mockClient; + private $mockSentry; + private $mockLegacyEvents; + private $eventsApi; + + protected function setUp(): void + { + $this->mockClient = $this->createMock(ClientInterface::class); + $this->mockSentry = $this->createMock(SentryClientInterface::class); + $this->mockLegacyEvents = $this->createMock(LegacyEventsInterface::class); + $this->eventsApi = new EventsApi($this->mockClient, $this->mockSentry, $this->mockLegacyEvents); + } + + public function testItShouldThrowForEmptySelections(): void + { + $this->mockClient->expects($this->never())->method('request'); + + $this->expectException(EventsApiException::class); + $this->expectExceptionMessage('No selections given'); + + $this->eventsApi->getEventsForFramesWithSelections(['2024-01-15 12:00:00'], []); + } + + public function testItShouldThrowWhenSelectionsExceedUpstreamLimit(): void + { + $tooMany = array_fill(0, EventsApi::MAX_SELECTIONS + 1, 'HEK>>Flare'); + + $this->mockClient->expects($this->never())->method('request'); + + $this->expectException(EventsApiException::class); + $this->expectExceptionMessage('Too many selections'); + + $this->eventsApi->getEventsForFramesWithSelections(['2024-01-15 12:00:00'], $tooMany); + } + + public function testItShouldReturnEmptyForEmptyTimestamps(): void + { + $this->mockClient->expects($this->never())->method('request'); + + $result = $this->eventsApi->getEventsForFramesWithSelections([], ['HEK>>Flare']); + $this->assertEquals([], $result); + } + + public function testItShouldFallBackToConfiguredChunkSizeWhenCallerPassesLessThanOne(): void + { + // 60 timestamps with chunkSize=0 -> defined config or fallback 50 -> 2 chunks (50 + 10) + $timestamps = []; + for ($i = 0; $i < 60; $i++) { + $timestamps[] = "2024-01-15 " . sprintf('%02d:%02d:00', intdiv($i, 60), $i % 60); + } + + $emptyResponse = ['events' => [], 'timestamps' => []]; + + $this->mockClient->expects($this->exactly(2)) + ->method('request') + ->withConsecutive( + ['POST', '/helioviewer/events/frames_with_selections', $this->callback(function ($options) { + return count($options['json']['timestamps']) === 50; + })], + ['POST', '/helioviewer/events/frames_with_selections', $this->callback(function ($options) { + return count($options['json']['timestamps']) === 10; + })] + ) + ->willReturn(new Response(200, [], json_encode($emptyResponse))); + + $this->eventsApi->getEventsForFramesWithSelections($timestamps, ['HEK>>Flare'], 0); + } + + public function testItShouldCapChunkSizeAtUpstreamLimit(): void + { + // Asking for chunkSize 999 with 200 timestamps -> capped to 150 -> 2 chunks (150 + 50) + $timestamps = []; + for ($i = 0; $i < 200; $i++) { + $timestamps[] = "2024-01-15 " . sprintf('%02d:%02d:00', intdiv($i, 60), $i % 60); + } + + $emptyResponse = ['events' => [], 'timestamps' => []]; + + $this->mockClient->expects($this->exactly(2)) + ->method('request') + ->withConsecutive( + ['POST', '/helioviewer/events/frames_with_selections', $this->callback(function ($options) { + return count($options['json']['timestamps']) === 150; + })], + ['POST', '/helioviewer/events/frames_with_selections', $this->callback(function ($options) { + return count($options['json']['timestamps']) === 50; + })] + ) + ->willReturn(new Response(200, [], json_encode($emptyResponse))); + + $this->eventsApi->getEventsForFramesWithSelections($timestamps, ['HEK>>Flare'], 999); + } + + public function testItShouldPaginateTimestampsAtTheGivenChunkSize(): void + { + // 7 timestamps, chunkSize=3 -> 3 chunks of (3, 3, 1) + $timestamps = [ + '2024-01-15 12:00:00', '2024-01-15 12:01:00', '2024-01-15 12:02:00', + '2024-01-15 12:03:00', '2024-01-15 12:04:00', '2024-01-15 12:05:00', + '2024-01-15 12:06:00', + ]; + + $emptyResponse = ['events' => [], 'timestamps' => []]; + + $this->mockClient->expects($this->exactly(3)) + ->method('request') + ->withConsecutive( + ['POST', '/helioviewer/events/frames_with_selections', $this->callback(function ($options) { + return count($options['json']['timestamps']) === 3 + && $options['json']['selections'] === ['HEK>>Flare', 'CCMC>>DONKI>>CME']; + })], + ['POST', '/helioviewer/events/frames_with_selections', $this->callback(function ($options) { + return count($options['json']['timestamps']) === 3 + && $options['json']['selections'] === ['HEK>>Flare', 'CCMC>>DONKI>>CME']; + })], + ['POST', '/helioviewer/events/frames_with_selections', $this->callback(function ($options) { + return count($options['json']['timestamps']) === 1 + && $options['json']['selections'] === ['HEK>>Flare', 'CCMC>>DONKI>>CME']; + })] + ) + ->willReturn(new Response(200, [], json_encode($emptyResponse))); + + $this->eventsApi->getEventsForFramesWithSelections( + $timestamps, + ['HEK>>Flare', 'CCMC>>DONKI>>CME'], + 3 + ); + } + + public function testItShouldThrowAndCaptureSentryOnHttpError(): void + { + $this->mockClient->method('request') + ->willThrowException(new \RuntimeException('connection refused')); + + $this->mockSentry->expects($this->atLeastOnce()) + ->method('setContext') + ->with('EventsApi', $this->callback(function ($params) { + return array_key_exists('error', $params) + || array_key_exists('endpoint', $params) + || array_key_exists('api_url', $params); + })); + + $this->mockSentry->expects($this->once()) + ->method('capture') + ->with($this->isInstanceOf(EventsApiException::class)); + + $this->expectException(EventsApiException::class); + $this->expectExceptionMessage('Failed to fetch frames_with_selections: connection refused'); + + $this->eventsApi->getEventsForFramesWithSelections( + ['2024-01-15 12:00:00'], + ['HEK>>Flare'] + ); + } + + public function testItShouldMergeEventsAndTimestampsAcrossChunks(): void + { + // 4 timestamps, chunkSize=2 -> 2 chunks + $timestamps = [ + '2024-01-15 12:00:00', '2024-01-15 12:01:00', + '2024-01-15 12:02:00', '2024-01-15 12:03:00', + ]; + + $chunk1 = [ + 'events' => [ + 'evt-FOO' => ['path' => 'HEK>>Flare', 'label' => 'FOO event', 'hv_hpc_x' => 1.0, 'hv_hpc_y' => 2.0], + 'evt-BAR' => ['path' => 'HEK>>Flare', 'label' => 'BAR event', 'hv_hpc_x' => 3.0, 'hv_hpc_y' => 4.0], + ], + 'timestamps' => [ + '2024-01-15 12:00:00' => ['evt-FOO' => ['hv_hpc_x' => 1.1, 'hv_hpc_y' => 2.1]], + '2024-01-15 12:01:00' => ['evt-FOO' => ['hv_hpc_x' => 1.2, 'hv_hpc_y' => 2.2]], + ], + ]; + + $chunk2 = [ + 'events' => [ + 'evt-BAR' => ['path' => 'HEK>>Flare', 'label' => 'BAR event', 'hv_hpc_x' => 3.0, 'hv_hpc_y' => 4.0], + 'evt-BAZ' => ['path' => 'HEK>>Flare', 'label' => 'BAZ event', 'hv_hpc_x' => 5.0, 'hv_hpc_y' => 6.0], + ], + 'timestamps' => [ + '2024-01-15 12:02:00' => ['evt-BAR' => ['hv_hpc_x' => 3.5, 'hv_hpc_y' => 4.5]], + '2024-01-15 12:03:00' => ['evt-BAZ' => ['hv_hpc_x' => 5.5, 'hv_hpc_y' => 6.5]], + ], + ]; + + $this->mockClient->expects($this->exactly(2)) + ->method('request') + ->willReturnOnConsecutiveCalls( + new Response(200, [], json_encode($chunk1)), + new Response(200, [], json_encode($chunk2)) + ); + + $merged = $this->eventsApi->getEventsForFramesWithSelections( + $timestamps, + ['HEK>>Flare'], + 2 + ); + + // Union of events across both chunks + $this->assertCount(3, $merged['events']); + $this->assertArrayHasKey('evt-FOO', $merged['events']); + $this->assertArrayHasKey('evt-BAR', $merged['events']); + $this->assertArrayHasKey('evt-BAZ', $merged['events']); + + // Union of timestamps across both chunks (4 unique) + $this->assertCount(4, $merged['timestamps']); + $this->assertEquals(1.1, $merged['timestamps']['2024-01-15 12:00:00']['evt-FOO']['hv_hpc_x']); + $this->assertEquals(3.5, $merged['timestamps']['2024-01-15 12:02:00']['evt-BAR']['hv_hpc_x']); + $this->assertEquals(5.5, $merged['timestamps']['2024-01-15 12:03:00']['evt-BAZ']['hv_hpc_x']); + } +} From aad25ddd8b69e1b51d835cc20d879b83f0f637d0 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Wed, 20 May 2026 16:02:31 +0000 Subject: [PATCH 11/15] increase sentry coverage for invalid event_strings, tracking them in sentry to further refine our conversion algorithm --- src/Event/EventsStateManager.php | 66 +++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/src/Event/EventsStateManager.php b/src/Event/EventsStateManager.php index 62b146734..249c64d51 100644 --- a/src/Event/EventsStateManager.php +++ b/src/Event/EventsStateManager.php @@ -13,6 +13,7 @@ namespace Helioviewer\Api\Event; use Helioviewer\Api\Event\Api\EventsApi; +use Helioviewer\Api\Sentry\Sentry; class EventsStateManager { @@ -397,11 +398,13 @@ public function getSelections(): array $pin = $layer['event_type'] ?? null; if ($pin === null) { - // Malformed layer: no event_type field. Log and skip. - error_log(sprintf( - "[getSelections] layer missing 'event_type' in source=%s layer=%s", - $source, json_encode($layer) - )); + // Malformed layer: no event_type field. + Sentry::setContext('getSelections', [ + 'source' => $source, + 'layer' => $layer, + 'events_state' => $this->events_state, // original payload, for debugging + ]); + Sentry::message("getSelections: layer missing 'event_type'"); continue; } @@ -418,10 +421,12 @@ public function getSelections(): array // except for 'UNK' which is intentionally out of the map // (it's the frontend's "unknown / fallback" sentinel). if ($pin !== 'UNK') { - error_log(sprintf( - "[getSelections] unknown pin '%s' for source=%s (not in EventSelections::\$event_types_map)", - $pin, $source - )); + Sentry::setContext('getSelections', [ + 'source' => $source, + 'pin' => $pin, + 'events_state' => $this->events_state, // original payload, for debugging + ]); + Sentry::message("getSelections: unknown pin '{$pin}' for source '{$source}'"); } continue; } @@ -475,10 +480,13 @@ public function getSelections(): array if ($frm === null) { // event_instance string didn't carry a FRM segment. // Expected shape: "----". - error_log(sprintf( - "[getSelections] malformed event_instance '%s' (no FRM segment) source=%s pin=%s", - $ei, $source, $pin - )); + Sentry::setContext('getSelections', [ + 'source' => $source, + 'pin' => $pin, + 'event_instance' => $ei, + 'events_state' => $this->events_state, // original payload, for debugging + ]); + Sentry::message("getSelections: malformed event_instance (no FRM segment)"); continue; } if (!in_array($frm, $frms, true)) { @@ -494,6 +502,38 @@ public function getSelections(): array return array_values(array_unique($selections)); } + /** + * Build per-source visibility config for use by EventContext / renderer. + * + * Returns one entry per source in EventsApi::VALID_SOURCES (so callers + * always get a complete map). Missing source in events_state OR missing + * 'labels_visible' key both default to TRUE (labels are visible by + * default; the frontend must explicitly opt out). + * + * Shape: + * [ + * 'HEK' => ['label_visibility' => true], + * 'CCMC' => ['label_visibility' => true], + * 'RHESSI' => ['label_visibility' => true], + * ] + * + * Nested dict (one key today, room for more like marker_visibility + * later without breaking callers). + * + * @return array + */ + public function getVisibilitySelections(): array + { + $result = []; + foreach (EventsApi::VALID_SOURCES as $source) { + $treeKey = 'tree_' . $source; + // Default to true: labels are visible unless the frontend explicitly opts out. + $labelVisible = $this->events_state[$treeKey]['labels_visible'] ?? true; + $result[$source] = ['label_visibility' => (bool) $labelVisible]; + } + return $result; + } + /** * Makes event id from given event and its belonging event_type and frm_name * @param string event_category_pin , given event_type From 6e65cb55e4aa5b0eec791c15b379dc7698448b62 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Fri, 22 May 2026 19:00:25 +0000 Subject: [PATCH 12/15] Bring eventcontext which utilizes selections timestamps for communication of getting events from eventsapi, change our composite images maker to use just to know the events to draw , further simplifying our system --- src/Event/EventContext.php | 199 +++++++++++++++ .../Composite/HelioviewerCompositeImage.php | 108 +------- src/Module/WebClient.php | 29 ++- src/Movie/HelioviewerMovie.php | 62 ++--- tests/unit_tests/events/EventContextTest.php | 232 ++++++++++++++++++ 5 files changed, 481 insertions(+), 149 deletions(-) create mode 100644 src/Event/EventContext.php create mode 100644 tests/unit_tests/events/EventContextTest.php diff --git a/src/Event/EventContext.php b/src/Event/EventContext.php new file mode 100644 index 000000000..c0e3a0208 --- /dev/null +++ b/src/Event/EventContext.php @@ -0,0 +1,199 @@ + '019c3d8f-...', + * 'label' => 'AR 13700', // empty string '' when labels are hidden for this source + * 'type' => 'AR', + * 'pin' => 'AR', + * 'hv_hpc_x' => -119.0, // rotated for this frame + * 'hv_hpc_y' => 570.2, + * 'footprint' => [{x,y}, ...], // already shifted by (dx,dy) + * ] + * + * NOTE: there is no separate label_visibility flag. The renderer treats an + * empty 'label' as "do not draw any label text", so we apply the visibility + * decision at shape time by zeroing out hidden labels. + */ +class EventContext +{ + private static ?self $emptyInstance = null; + + /** + * @param array>> $eventsByDate + * date => list of event dicts. + * Invariant: every timestamp passed to build() appears as a key here, + * even if its list is empty. We rely on this to detect the + * "caller asks for an unrequested date" programming-bug case. + */ + private function __construct(private array $eventsByDate) + { + } + + /** + * Memoized empty context. Use this when you need an EventContext-shaped + * placeholder without an actual fetch (e.g. a renderer fallback when the + * caller forgot to inject one). + */ + public static function empty(): self + { + return self::$emptyInstance ??= new self([]); + } + + /** + * Build the context by fetching events for $timestamps filtered by $selections. + * + * Empty $timestamps OR empty $selections -> no HTTP call, empty context. + * HTTP failure -> Sentry capture, returns an empty context. + * + * @param string[] $timestamps dates to fetch events for + * @param string[] $selections path-prefix selections (from EventsStateManager::getSelections()) + * @param array $visibilitySelections per-source visibility map (from EventsStateManager::getVisibilitySelections()) + * @param EventsApiInterface $api + * @param int $chunkSize forwarded to the API client + * @param string $logLabel forwarded to the API client (per-chunk error_log tag) + */ + public static function build( + array $timestamps, + array $selections, + array $visibilitySelections, + EventsApiInterface $api, + int $chunkSize = 50, + string $logLabel = '' + ): self { + // Short-circuit when there's nothing to fetch. Pre-populate with empty + // lists per requested timestamp so the post-build invariant holds: + // every requested date appears as a key in $eventsByDate. + if (empty($timestamps) || empty($selections)) { + return new self(array_fill_keys($timestamps, [])); + } + + try { + $raw = $api->getEventsForFramesWithSelections( + $timestamps, $selections, $chunkSize, $logLabel + ); + } catch (\Throwable $e) { + Sentry::setContext('EventContext', [ + 'timestamps_count' => count($timestamps), + 'selections' => $selections, + 'log_label' => $logLabel, + ]); + Sentry::capture($e); + return new self(array_fill_keys($timestamps, [])); + } + + // Shape the raw frames_with_selections response into per-date + // draw-ready arrays. Footprints get rotation-shifted by (dx,dy). + // For hidden-label sources we set 'label' to '' so the renderer + // (which already treats empty label as "skip label drawing") needs + // no extra logic. + $events = $raw['events'] ?? []; + $observations = $raw['timestamps'] ?? []; + $eventsByDate = []; + + foreach ($observations as $ts => $obs) { + $list = []; + foreach ($obs as $eventId => $coords) { + $event = $events[$eventId] ?? null; + if (!$event) { + continue; + } + + // Determine source by parsing the event's canonical path. + // path is e.g. "HEK>>Active Region>>SPoCA" + $path = $event['path'] ?? ''; + $source = explode('>>', $path)[0] ?? ''; + // Default to true: when the source isn't in the visibility map, + // assume labels are on (matches getVisibilitySelections()'s default). + $labelVisible = (bool) ($visibilitySelections[$source]['label_visibility'] ?? true); + + // Rotation delta: how much did this frame's rotated coords + // drift from the event's "canonical" coords? + $dx = $coords['hv_hpc_x'] - ($event['hv_hpc_x'] ?? 0.0); + $dy = $coords['hv_hpc_y'] - ($event['hv_hpc_y'] ?? 0.0); + + $footprint = []; + if (!empty($event['footprint'])) { + $footprint = array_map( + fn($p) => ['x' => $p['x'] + $dx, 'y' => $p['y'] + $dy], + $event['footprint'] + ); + } + + $type = $event['type'] ?? 'UNK'; + $list[] = [ + 'id' => $eventId, + // Empty label = "do not draw a label". This is how we + // encode "labels hidden for this source" downstream. + 'label' => $labelVisible ? ($event['label'] ?? '') : '', + 'type' => $type, + 'pin' => $event['pin'] ?? $type, + 'hv_hpc_x' => $coords['hv_hpc_x'], + 'hv_hpc_y' => $coords['hv_hpc_y'], + 'footprint' => $footprint, + ]; + } + $eventsByDate[$ts] = $list; + } + + return new self($eventsByDate); + } + + /** + * Returns the draw-ready event list for $date, or [] if none. + * + * The upstream guarantees that every timestamp passed to build() ends up + * as a key in the response (even when its list is empty), so a missing + * key here when the context has some dates means the caller is asking + * for a date that was never part of the build() request. That's a + * programming bug; we Sentry-log and return [] without blowing up the + * render. + * + * If $this->eventsByDate is itself empty (e.g. the EventContext::empty() + * singleton, or a build() that short-circuited), the "missing key" case + * is expected — no Sentry signal. + */ + public function getEventsForDate(string $date): array + { + if (!array_key_exists($date, $this->eventsByDate)) { + if (!empty($this->eventsByDate)) { + Sentry::setContext('EventContext', [ + 'requested_date' => $date, + 'available_dates' => array_keys($this->eventsByDate), + 'events_by_date' => $this->eventsByDate, + ]); + Sentry::message("EventContext::getEventsForDate called with a date that wasn't part of build()"); + } + return []; + } + return $this->eventsByDate[$date]; + } + + /** + * Whether any timestamp produced at least one event. Convenience for + * callers that want to short-circuit downstream work. + */ + public function hasEvents(): bool + { + foreach ($this->eventsByDate as $list) { + if (!empty($list)) { + return true; + } + } + return false; + } +} diff --git a/src/Image/Composite/HelioviewerCompositeImage.php b/src/Image/Composite/HelioviewerCompositeImage.php index ea1e44866..03941f5f9 100644 --- a/src/Image/Composite/HelioviewerCompositeImage.php +++ b/src/Image/Composite/HelioviewerCompositeImage.php @@ -20,8 +20,7 @@ require_once HV_ROOT_DIR.'/../src/Module/SolarBodies.php'; use Helioviewer\Api\Sentry\Sentry; -use Helioviewer\Api\Event\Api\EventsApi; -use Helioviewer\Api\Event\Api\EventsApiException; +use Helioviewer\Api\Event\EventContext; class Image_Composite_HelioviewerCompositeImage { @@ -116,8 +115,7 @@ public static function resolveMarkerPath(string $baseDir, string $type): string protected $switchSources; protected $celestialBodiesLabels; protected $celestialBodiesTrajectories; - protected $eventsApi; - protected array $batchEventResponse; + protected EventContext $eventContext; /** * Creates a new HelioviewerCompositeImage instance @@ -154,8 +152,7 @@ public function __construct($layers, $eventsManager, $movieIcons, $celestialBodi 'grayscale' => false, 'eclipse' => false, 'moon' => false, - 'eventsApi' => null, - 'batchEventResponse' => [] + 'eventContext' => null, ); $options = array_replace($defaults, $options); @@ -165,8 +162,7 @@ public function __construct($layers, $eventsManager, $movieIcons, $celestialBodi $this->imageScale = $roi->imageScale(); $this->db = $options['database'] ? $options['database'] : new Database_ImgIndex(); - $this->eventsApi = $options['eventsApi'] ?? new EventsApi(); - $this->batchEventResponse = $options['batchEventResponse']; + $this->eventContext = $options['eventContext'] ?? EventContext::empty(); $this->layers = $layers; $this->eventsManager = $eventsManager; $this->movieIcons = $movieIcons; @@ -479,9 +475,7 @@ private function _buildCompositeImage() { $image = $this->_imageLayers[0]->getIMagickImage(); } - if ( $this->eventsManager->hasEvents() && $this->date != '2999-01-01T00:00:00.000Z') { - $this->_addEventLayer($image); - } + $this->_addEventLayer($image); if ( $this->movieIcons) { @@ -644,94 +638,8 @@ private function _addEventLayer($imagickImage) { $markerPinPixelOffsetX = 12; $markerPinPixelOffsetY = 38; - // Fetch events via batch (movies have pre-fetched, screenshots fetch for single timestamp) - if (empty($this->batchEventResponse)) { - error_log("[date={$this->date}] batchEventResponse empty, fetching single timestamp"); - try { - $this->batchEventResponse = $this->eventsApi->getEventsBatch( - [$this->date], - EventsApi::VALID_SOURCES - ); - } catch (EventsApiException $e) { - // Already captured to Sentry by EventsApi - } catch (\Throwable $e) { - Sentry::capture($e); - } - } - - $event_categories = $this->batchEventResponse[$this->date] ?? []; - if (empty($event_categories)) return; - - // Lay down all relevant event REGIONS first - $events_to_render = []; - $events_manager = $this->eventsManager; - $add_label_visibility_and_concept = function($events_data, $event_cat_pin, $event_group_name) use ($events_manager) { - return array_map(function($ed) use ($events_manager, $event_cat_pin, $event_group_name) { - $ed['concept'] = $event_group_name; - $ed['label_visibility'] = $events_manager->isEventTypeLabelVisible($event_cat_pin) ? true : false; - return $ed; - }, $events_data); - }; - - - foreach($event_categories as $event_cat) { - - $event_cat_pin = $event_cat['pin']; - - // if we dont have any configuration for this event_type - if (!$this->eventsManager->hasEventsForEventType($event_cat_pin)) { - continue; - } - - // Are we going to go for all children of this event type - if ($this->eventsManager->appliesAllEventsForEventType($event_cat_pin)) { - - foreach($event_cat['groups'] as $ecg) { - $events_to_render = array_merge( - $events_to_render, - $add_label_visibility_and_concept($ecg['data'], $event_cat_pin, $ecg['name']) - ); - } - - continue; - - } - - // Check each group now - foreach($event_cat['groups'] as $event_cat_group) { - - // Applies for event type - if($this->eventsManager->appliesFrmForEventType($event_cat_pin, $event_cat_group['name'])) { - - // applies all events for this group - if($this->eventsManager->appliesAllEventInstancesForFrm($event_cat_pin, $event_cat_group['name'])) { - $events_to_render = array_merge( - $events_to_render, - $add_label_visibility_and_concept($event_cat_group['data'], $event_cat_pin, $event_cat_group['name']) - ); - } else { - - // applies some events for this group - $events_filtered_for_event_instances = []; - - foreach($event_cat_group['data'] as $ev) { - - if ($this->eventsManager->appliesEventInstance($event_cat_pin, $event_cat_group['name'], $ev)) { - $events_filtered_for_event_instances[] = $ev; - } - - } - - $events_to_render = array_merge( - $events_to_render, - $add_label_visibility_and_concept($events_filtered_for_event_instances, $event_cat_pin, $event_cat_group['name']) - ); - - } - } - - } - } + $events_to_render = $this->eventContext->getEventsForDate($this->date); + if (empty($events_to_render)) return; // Draw event footprint polygons onto the composite image. // Footprint is an array of {x, y} points in HPC arcseconds (already rotated by Events API). @@ -791,7 +699,7 @@ private function _addEventLayer($imagickImage) { $y = $y - $this->_timeOffsetY; $imagickImage->compositeImage($marker, IMagick::COMPOSITE_DISSOLVE, $x - $markerPinPixelOffsetX, $y - $markerPinPixelOffsetY); - if ($event['label_visibility']) { + if ($event['label'] !== '') { $x = $x + 11; $y = $y - 24; diff --git a/src/Module/WebClient.php b/src/Module/WebClient.php index b9fb11091..0e750657c 100644 --- a/src/Module/WebClient.php +++ b/src/Module/WebClient.php @@ -19,6 +19,7 @@ use Helioviewer\Api\Module\BaseModule; use Helioviewer\Api\Module\ModuleInterface; use Helioviewer\Api\Event\EventsStateManager; +use Helioviewer\Api\Event\EventContext; use Helioviewer\Api\Event\Timeline\Timeline as EventTimeline; use Helioviewer\Api\Event\Api\EventsApiException; use Helioviewer\Api\Sentry\Sentry; @@ -505,6 +506,13 @@ public function postScreenshot() $events_manager = EventsStateManager::buildFromEventsState($json_params['eventsState']); + $eventContext = EventContext::build( + timestamps: [$json_params['date']], + selections: $events_manager->getSelections(), + visibilitySelections: $events_manager->getVisibilitySelections(), + api: $this->eventsApi(), + ); + Sentry::setContext('Screenshot Request Variables',[ 'layers' => $layers, 'events_manager' => $events_manager, @@ -530,7 +538,7 @@ public function postScreenshot() $scaleY, $json_params['date'], $roi, - array_merge($json_params, ['eventsApi' => $this->eventsApi()]) + array_merge($json_params, ['eventContext' => $eventContext]) ); // Display screenshot @@ -615,6 +623,13 @@ public function takeScreenshot() { // Events manager built from old logic $events_manager = EventsStateManager::buildFromLegacyEventStrings($events_legacy_string, $event_labels); + $eventContext = EventContext::build( + timestamps: [$this->_params['date']], + selections: $events_manager->getSelections(), + visibilitySelections: $events_manager->getVisibilitySelections(), + api: $this->eventsApi(), + ); + // Create the screenshot $screenshot = new Image_Composite_HelioviewerScreenshot( $layers, @@ -627,7 +642,7 @@ public function takeScreenshot() { $scaleY, $this->_params['date'], $roi, - array_merge($this->_options, ['eventsApi' => $this->eventsApi()]) + array_merge($this->_options, ['eventContext' => $eventContext]) ); // Display screenshot @@ -722,6 +737,12 @@ public function reTakeScreenshot($screenshotId) { $events_manager = EventsStateManager::buildFromLegacyEventStrings($metaData['eventSourceString'], (bool)$metaData['eventsLabels']); } + $eventContext = EventContext::build( + timestamps: [$metaData['observationDate']], + selections: $events_manager->getSelections(), + visibilitySelections: $events_manager->getVisibilitySelections(), + api: $this->eventsApi(), + ); $celestialBodies = array( "labels" => $metaData['celestialBodiesLabels'], "trajectories" => $metaData['celestialBodiesTrajectories']); @@ -738,7 +759,7 @@ public function reTakeScreenshot($screenshotId) { $metaData['scaleY'], $metaData['observationDate'], $roi, - array_merge($options, ['eventsApi' => $this->eventsApi()]) + array_merge($options, ['eventContext' => $eventContext]) ); } @@ -1352,7 +1373,7 @@ public function getEclipseImage() { 'grayscale' => true, 'eclipse' => true, 'moon' => $this->_options['moon'], - 'eventsApi' => $this->eventsApi() + 'eventContext' => EventContext::empty() ] ); $screenshot->display(); diff --git a/src/Movie/HelioviewerMovie.php b/src/Movie/HelioviewerMovie.php index 0b7cecf00..0c791317b 100644 --- a/src/Movie/HelioviewerMovie.php +++ b/src/Movie/HelioviewerMovie.php @@ -36,8 +36,8 @@ require_once HV_ROOT_DIR . '/../src/Helper/Serialize.php'; use Helioviewer\Api\Event\EventsStateManager; +use Helioviewer\Api\Event\EventContext; use Helioviewer\Api\Event\Api\EventsApi; -use Helioviewer\Api\Event\Api\EventsApiException; use Helioviewer\Api\Sentry\Sentry; /** @@ -530,52 +530,24 @@ private function _buildMovieFrames($watermark) { 'switchSources' => $this->switchSources ); - // Preload events for all frames. EventsApi handles chunking internally - // using the configured chunk size and labels per-chunk logs with the movie ID. + // Preload events for all frames via EventContext. Chunking + per-chunk + // logging is handled inside EventsApi::getEventsForFramesWithSelections. + // Empty selections short-circuit internally with no HTTP call. $timestamps = $this->_getTimeStamps(); - $eventsApi = new EventsApi(); - $batchResponse = []; - $sources = $this->_eventsManager->getSources(); - $movieId = $this->publicId; - - error_log(sprintf( - "[Movie:%s] Starting movie build, frames=%d, sources=%s, hasEvents=%s", - $movieId, - count($timestamps), - $sources ? implode(',', $sources) : '(none)', - $this->_eventsManager->hasEvents() ? 'true' : 'false' - )); - - if ($this->_eventsManager->hasEvents()) { - $chunkSize = defined('HV_EVENTS_API_EVENTS_PER_FRAME_CHUNKSIZE') - ? HV_EVENTS_API_EVENTS_PER_FRAME_CHUNKSIZE - : 50; - - $totalStart = microtime(true); - try { - $batchResponse = $eventsApi->getEventsBatch( - $timestamps, - $sources, - $chunkSize, - "Movie:{$movieId}" - ); - } catch (EventsApiException $e) { - error_log("[Movie:{$movieId}] Batch events failed: " . $e->getMessage()); - } catch (\Throwable $e) { - error_log("[Movie:{$movieId}] Unexpected error fetching events: " . $e->getMessage()); - Sentry::capture($e); - } - $totalMs = (int) round((microtime(true) - $totalStart) * 1000); - error_log(sprintf( - "[Movie:%s] all event chunks done in %dms (%d frames)", - $movieId, $totalMs, count($timestamps) - )); - } else { - error_log("[Movie:{$movieId}] No event types selected, skipping EventsApi request"); - } + $movieId = $this->publicId; + $chunkSize = defined('HV_EVENTS_API_EVENTS_PER_FRAME_CHUNKSIZE') + ? (int) HV_EVENTS_API_EVENTS_PER_FRAME_CHUNKSIZE + : 50; + + $eventContext = EventContext::build( + timestamps: $timestamps, + selections: $this->_eventsManager->getSelections(), + visibilitySelections: $this->_eventsManager->getVisibilitySelections(), + api: new EventsApi(), + chunkSize: $chunkSize, + ); - $options['batchEventResponse'] = $batchResponse; - $options['eventsApi'] = $eventsApi; + $options['eventContext'] = $eventContext; // Index of preview frame $previewIndex = floor($this->numFrames/2); diff --git a/tests/unit_tests/events/EventContextTest.php b/tests/unit_tests/events/EventContextTest.php new file mode 100644 index 000000000..0974d28f5 --- /dev/null +++ b/tests/unit_tests/events/EventContextTest.php @@ -0,0 +1,232 @@ + + */ + +use PHPUnit\Framework\TestCase; +use Helioviewer\Api\Event\EventContext; +use Helioviewer\Api\Event\Api\EventsApiInterface; +use Helioviewer\Api\Event\Api\EventsApiException; +use Helioviewer\Api\Sentry\Sentry; +use Helioviewer\Api\Sentry\ClientInterface as SentryClientInterface; + +final class EventContextTest extends TestCase +{ + private $mockApi; + private $mockSentry; + + protected function setUp(): void + { + $this->mockApi = $this->createMock(EventsApiInterface::class); + $this->mockSentry = $this->createMock(SentryClientInterface::class); + // Swap the static Sentry facade's client for our mock so we can assert + // on capture/message/setContext calls. + Sentry::init(['enabled' => true, 'client' => $this->mockSentry]); + } + + public function testEmptyTimestampsShortCircuitsWithNoApiCall(): void + { + $this->mockApi->expects($this->never())->method('getEventsForFramesWithSelections'); + $this->mockSentry->expects($this->never())->method('capture'); + $this->mockSentry->expects($this->never())->method('message'); + + $context = EventContext::build([], ['HEK>>Active Region>>SPoCA'], [], $this->mockApi); + + $this->assertFalse($context->hasEvents()); + // Asking for any date on an empty context is silent (eventsByDate is empty, + // so missing-key is expected, not a programming bug). + $this->assertSame([], $context->getEventsForDate('2024-01-01T00:00:00.000Z')); + } + + public function testEmptySelectionsShortCircuitsAndPopulatesRequestedTimestamps(): void + { + $this->mockApi->expects($this->never())->method('getEventsForFramesWithSelections'); + $this->mockSentry->expects($this->never())->method('message'); + + $context = EventContext::build(['2024-01-01T00:00:00.000Z'], [], [], $this->mockApi); + + $this->assertFalse($context->hasEvents()); + // Date IS in the map (filled via array_fill_keys) -> returns [] silently. + $this->assertSame([], $context->getEventsForDate('2024-01-01T00:00:00.000Z')); + } + + public function testHttpFailureIsCapturedAndYieldsEmptyContext(): void + { + $this->mockApi->method('getEventsForFramesWithSelections') + ->willThrowException(new EventsApiException('boom')); + $this->mockSentry->expects($this->once())->method('capture'); + + $context = EventContext::build( + ['2024-01-01T00:00:00.000Z'], + ['HEK>>Active Region>>SPoCA'], + [], + $this->mockApi, + ); + + $this->assertFalse($context->hasEvents()); + // Requested timestamps still get populated as empty lists in the failure path. + $this->assertSame([], $context->getEventsForDate('2024-01-01T00:00:00.000Z')); + } + + public function testHappyPathShiftsFootprintByRotationDelta(): void + { + $eventId = 'event-uuid'; + $ts = '2024-01-01T00:00:00.000Z'; + $this->mockApi->method('getEventsForFramesWithSelections')->willReturn([ + 'events' => [ + $eventId => [ + 'label' => 'AR 13700', + 'type' => 'AR', + 'pin' => 'AR', + 'path' => 'HEK>>Active Region>>SPoCA', + 'hv_hpc_x' => 100.0, + 'hv_hpc_y' => 200.0, + 'footprint' => [['x' => 110.0, 'y' => 210.0]], + ], + ], + 'timestamps' => [ + $ts => [ + $eventId => ['hv_hpc_x' => 120.0, 'hv_hpc_y' => 230.0], + ], + ], + ]); + + $context = EventContext::build([$ts], ['HEK>>Active Region>>SPoCA'], [], $this->mockApi); + + $events = $context->getEventsForDate($ts); + $this->assertCount(1, $events); + // dx = 120 - 100 = 20, dy = 230 - 200 = 30. Footprint point (110, 210) -> (130, 240). + $this->assertSame(130.0, $events[0]['footprint'][0]['x']); + $this->assertSame(240.0, $events[0]['footprint'][0]['y']); + $this->assertSame(120.0, $events[0]['hv_hpc_x']); + $this->assertSame(230.0, $events[0]['hv_hpc_y']); + $this->assertTrue($context->hasEvents()); + } + + public function testHiddenLabelIsEncodedAsEmptyString(): void + { + $eventId = 'event-uuid'; + $ts = '2024-01-01T00:00:00.000Z'; + $this->mockApi->method('getEventsForFramesWithSelections')->willReturn([ + 'events' => [ + $eventId => [ + 'label' => 'AR 13700', + 'type' => 'AR', + 'pin' => 'AR', + 'path' => 'HEK>>Active Region>>SPoCA', + 'hv_hpc_x' => 0.0, + 'hv_hpc_y' => 0.0, + ], + ], + 'timestamps' => [ + $ts => [ + $eventId => ['hv_hpc_x' => 0.0, 'hv_hpc_y' => 0.0], + ], + ], + ]); + + $context = EventContext::build( + [$ts], + ['HEK>>Active Region>>SPoCA'], + ['HEK' => ['label_visibility' => false]], + $this->mockApi, + ); + + $events = $context->getEventsForDate($ts); + $this->assertSame('', $events[0]['label']); + } + + public function testVisibleLabelDefaultsToTrueWhenSourceMissingFromVisibilityMap(): void + { + $eventId = 'event-uuid'; + $ts = '2024-01-01T00:00:00.000Z'; + $this->mockApi->method('getEventsForFramesWithSelections')->willReturn([ + 'events' => [ + $eventId => [ + 'label' => 'AR 13700', + 'type' => 'AR', + 'pin' => 'AR', + 'path' => 'HEK>>Active Region>>SPoCA', + 'hv_hpc_x' => 0.0, + 'hv_hpc_y' => 0.0, + ], + ], + 'timestamps' => [ + $ts => [$eventId => ['hv_hpc_x' => 0.0, 'hv_hpc_y' => 0.0]], + ], + ]); + + // Visibility map empty -> default to true -> label preserved. + $context = EventContext::build([$ts], ['HEK>>Active Region>>SPoCA'], [], $this->mockApi); + + $events = $context->getEventsForDate($ts); + $this->assertSame('AR 13700', $events[0]['label']); + } + + public function testGetEventsForDateOnUnknownDateLogsToSentryWhenContextNonEmpty(): void + { + $this->mockApi->method('getEventsForFramesWithSelections')->willReturn([ + 'events' => [], + 'timestamps' => ['2024-01-01T00:00:00.000Z' => []], + ]); + $this->mockSentry->expects($this->once()) + ->method('setContext') + ->with('EventContext', $this->callback(function ($params) { + return array_key_exists('requested_date', $params) + && array_key_exists('available_dates', $params) + && array_key_exists('events_by_date', $params); + })); + $this->mockSentry->expects($this->once())->method('message'); + + $context = EventContext::build( + ['2024-01-01T00:00:00.000Z'], + ['HEK>>Active Region>>SPoCA'], + [], + $this->mockApi, + ); + + $this->assertSame([], $context->getEventsForDate('2099-12-31T00:00:00.000Z')); + } + + public function testHasEventsReturnsTrueWhenAtLeastOneDateHasEvents(): void + { + $eventId = 'event-uuid'; + $ts1 = '2024-01-01T00:00:00.000Z'; + $ts2 = '2024-01-02T00:00:00.000Z'; + $this->mockApi->method('getEventsForFramesWithSelections')->willReturn([ + 'events' => [ + $eventId => [ + 'label' => 'AR 13700', + 'type' => 'AR', + 'pin' => 'AR', + 'path' => 'HEK>>Active Region>>SPoCA', + 'hv_hpc_x' => 0.0, + 'hv_hpc_y' => 0.0, + ], + ], + 'timestamps' => [ + $ts1 => [$eventId => ['hv_hpc_x' => 0.0, 'hv_hpc_y' => 0.0]], + $ts2 => [], + ], + ]); + + $context = EventContext::build([$ts1, $ts2], ['HEK>>Active Region>>SPoCA'], [], $this->mockApi); + + $this->assertTrue($context->hasEvents()); + } + + public function testEmptySingletonReturnsSameInstance(): void + { + $this->assertSame(EventContext::empty(), EventContext::empty()); + } + + public function testEmptySingletonHasNoEventsAndIsSilentOnAnyDate(): void + { + $this->mockSentry->expects($this->never())->method('message'); + + $empty = EventContext::empty(); + $this->assertFalse($empty->hasEvents()); + $this->assertSame([], $empty->getEventsForDate('2099-12-31T00:00:00.000Z')); + } +} From febf29d5fa863260eae4c59f62b4c541a934734d Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Fri, 22 May 2026 19:06:27 +0000 Subject: [PATCH 13/15] increse logging --- src/Job/MovieBuilder.php | 17 ++++++++---- src/Module/Movies.php | 17 ++++++++++++ src/Module/WebClient.php | 37 +++++++++++++++++++++++-- src/Movie/HelioviewerMovie.php | 50 ++++++++++++++++++++++++++++++++-- 4 files changed, 111 insertions(+), 10 deletions(-) diff --git a/src/Job/MovieBuilder.php b/src/Job/MovieBuilder.php index 4a914d4a3..02b1bde75 100644 --- a/src/Job/MovieBuilder.php +++ b/src/Job/MovieBuilder.php @@ -34,17 +34,23 @@ class Job_MovieBuilder public function perform() { - printf("Starting movie %s\n", $this->args['movieId']); + $movieId = $this->args['movieId']; + $format = $this->args['format'] ?? 'mp4'; + error_log(sprintf("[Movie:%s] Worker picked up, format=%s", $movieId, $format)); // Build movie + $startMs = microtime(true); try { - $movie = new Movie_HelioviewerMovie($this->args['movieId']); + $movie = new Movie_HelioviewerMovie($movieId); $movie->build(); } catch (Exception $e) { Sentry::capture($e); - // Handle any errors encountered - printf("Error processing movie %s\n", $this->args['movieId']); + $elapsed = (int) round((microtime(true) - $startMs) * 1000); + error_log(sprintf( + "[Movie:%s] Worker error after %dms: %s", + $movieId, $elapsed, $e->getMessage() + )); logException($e, "Resque_"); // If counter was increased at queue time, decrement @@ -53,7 +59,8 @@ public function perform() throw $e; } - printf("Finished movie %s\n", $this->args['movieId']); + $elapsed = (int) round((microtime(true) - $startMs) * 1000); + error_log(sprintf("[Movie:%s] Worker finished in %dms", $movieId, $elapsed)); $this->_updateCounter(); // If the queue is empty and no jobs are being processed, set estimated diff --git a/src/Module/Movies.php b/src/Module/Movies.php index 10555037d..29f5cfb43 100644 --- a/src/Module/Movies.php +++ b/src/Module/Movies.php @@ -204,6 +204,11 @@ public function postMovie() { $token = Resque::enqueue(HV_MOVIE_QUEUE, 'Job_MovieBuilder', $args, true); + error_log(sprintf( + "[Movie:%s] enqueued via postMovie, format=%s, eta=%ds, queueSize=%d, token=%s", + $publicId, $options['format'], (int)$estBuildTime, $queueSize, $token + )); + // Print response $response = array( 'id' => $publicId, @@ -393,6 +398,11 @@ public function queueMovie() { $token = Resque::enqueue(HV_MOVIE_QUEUE, 'Job_MovieBuilder', $args, true); + error_log(sprintf( + "[Movie:%s] enqueued via queueMovie, format=%s, eta=%ds, queueSize=%d, token=%s", + $publicId, $options['format'], (int)$estBuildTime, $queueSize, $token + )); + // Print response $response = array( 'id' => $publicId, @@ -570,6 +580,13 @@ public function reQueueMovie($silent=false) { ); $token = Resque::enqueue(HV_MOVIE_QUEUE, 'Job_MovieBuilder', $args, true); + error_log(sprintf( + "[Movie:%s] enqueued via reQueueMovie (force=%s), format=%s, eta=%ds, queueSize=%d, token=%s", + $publicId, + $options['force'] ? 'true' : 'false', + $options['format'], (int)$estBuildTime, $queueSize, $token + )); + // Create entries for each version of the movie in the movieFormats // table diff --git a/src/Module/WebClient.php b/src/Module/WebClient.php index 0e750657c..db58a8601 100644 --- a/src/Module/WebClient.php +++ b/src/Module/WebClient.php @@ -506,12 +506,22 @@ public function postScreenshot() $events_manager = EventsStateManager::buildFromEventsState($json_params['eventsState']); + $screenshotDate = $json_params['date']; + $totalStart = microtime(true); $eventContext = EventContext::build( - timestamps: [$json_params['date']], + timestamps: [$screenshotDate], selections: $events_manager->getSelections(), visibilitySelections: $events_manager->getVisibilitySelections(), api: $this->eventsApi(), + logLabel: "Screenshot:{$screenshotDate}", ); + $totalMs = (int) round((microtime(true) - $totalStart) * 1000); + error_log(sprintf( + "[Screenshot:%s] postScreenshot, hasEvents=%s, event context built in %dms", + $screenshotDate, + $eventContext->hasEvents() ? 'true' : 'false', + $totalMs + )); Sentry::setContext('Screenshot Request Variables',[ 'layers' => $layers, @@ -623,12 +633,22 @@ public function takeScreenshot() { // Events manager built from old logic $events_manager = EventsStateManager::buildFromLegacyEventStrings($events_legacy_string, $event_labels); + $screenshotDate = $this->_params['date']; + $totalStart = microtime(true); $eventContext = EventContext::build( - timestamps: [$this->_params['date']], + timestamps: [$screenshotDate], selections: $events_manager->getSelections(), visibilitySelections: $events_manager->getVisibilitySelections(), api: $this->eventsApi(), + logLabel: "Screenshot:{$screenshotDate}", ); + $totalMs = (int) round((microtime(true) - $totalStart) * 1000); + error_log(sprintf( + "[Screenshot:%s] takeScreenshot, hasEvents=%s, event context built in %dms", + $screenshotDate, + $eventContext->hasEvents() ? 'true' : 'false', + $totalMs + )); // Create the screenshot $screenshot = new Image_Composite_HelioviewerScreenshot( @@ -737,12 +757,23 @@ public function reTakeScreenshot($screenshotId) { $events_manager = EventsStateManager::buildFromLegacyEventStrings($metaData['eventSourceString'], (bool)$metaData['eventsLabels']); } + $screenshotDate = $metaData['observationDate']; + $totalStart = microtime(true); $eventContext = EventContext::build( - timestamps: [$metaData['observationDate']], + timestamps: [$screenshotDate], selections: $events_manager->getSelections(), visibilitySelections: $events_manager->getVisibilitySelections(), api: $this->eventsApi(), + logLabel: "Screenshot:{$screenshotDate}", ); + $totalMs = (int) round((microtime(true) - $totalStart) * 1000); + error_log(sprintf( + "[Screenshot:%s] reTakeScreenshot id=%d, hasEvents=%s, event context built in %dms", + $screenshotDate, + $screenshotId, + $eventContext->hasEvents() ? 'true' : 'false', + $totalMs + )); $celestialBodies = array( "labels" => $metaData['celestialBodiesLabels'], "trajectories" => $metaData['celestialBodiesTrajectories']); diff --git a/src/Movie/HelioviewerMovie.php b/src/Movie/HelioviewerMovie.php index 0c791317b..b8dd212bf 100644 --- a/src/Movie/HelioviewerMovie.php +++ b/src/Movie/HelioviewerMovie.php @@ -531,21 +531,32 @@ private function _buildMovieFrames($watermark) { ); // Preload events for all frames via EventContext. Chunking + per-chunk - // logging is handled inside EventsApi::getEventsForFramesWithSelections. - // Empty selections short-circuit internally with no HTTP call. + // logging is handled inside EventsApi::getEventsForFramesWithSelections, + // labelled with the movie ID. Empty selections short-circuit internally. $timestamps = $this->_getTimeStamps(); $movieId = $this->publicId; $chunkSize = defined('HV_EVENTS_API_EVENTS_PER_FRAME_CHUNKSIZE') ? (int) HV_EVENTS_API_EVENTS_PER_FRAME_CHUNKSIZE : 50; + $contextStart = microtime(true); $eventContext = EventContext::build( timestamps: $timestamps, selections: $this->_eventsManager->getSelections(), visibilitySelections: $this->_eventsManager->getVisibilitySelections(), api: new EventsApi(), chunkSize: $chunkSize, + logLabel: "Movie:{$movieId}", ); + $contextMs = (int) round((microtime(true) - $contextStart) * 1000); + + error_log(sprintf( + "[Movie:%s] Starting movie build, frames=%d, hasEvents=%s, event context built in %dms", + $movieId, + count($timestamps), + $eventContext->hasEvents() ? 'true' : 'false', + $contextMs + )); $options['eventContext'] = $eventContext; @@ -555,17 +566,25 @@ private function _buildMovieFrames($watermark) { // Add tolerance for single-frame failures $numFailures = 0; + // Frame-loop progress tracking + $totalFrames = count($timestamps); + $frameLoopStart = microtime(true); + $frameTimingsMs = []; + // Compile frames foreach ($this->_getTimeStamps() as $time) { $filepath = sprintf('%sframes/frame%d.bmp', $this->directory, $frameNum); try { + $frameStart = microtime(true); $screenshot = new Image_Composite_HelioviewerMovieFrame( $filepath, $this->_layers, $this->_eventsManager, $this->movieIcons, $this->celestialBodies, $this->scale, $this->scaleType, $this->scaleX, $this->scaleY, $time, $this->_roi, $options); + $frameMs = (int) round((microtime(true) - $frameStart) * 1000); + $frameTimingsMs[] = $frameMs; if ( $frameNum == $previewIndex ) { // Make a copy of frame to be used for preview images @@ -574,11 +593,22 @@ private function _buildMovieFrames($watermark) { $frameNum++; array_push($this->_frames, $filepath); + + $pct = $totalFrames > 0 ? (int) round(($frameNum / $totalFrames) * 100) : 0; + error_log(sprintf( + "[Movie:%s] frame %d/%d (%d%%) in %dms", + $movieId, $frameNum, $totalFrames, $pct, $frameMs + )); } catch (Exception $e) { Sentry::capture($e); $numFailures += 1; + error_log(sprintf( + "[Movie:%s] frame %d/%d FAILED (failure %d/3): %s", + $movieId, $frameNum + 1, $totalFrames, $numFailures, $e->getMessage() + )); + if ($numFailures <= 3) { // Recover if failure occurs on a single frame $this->numFrames--; @@ -591,6 +621,22 @@ private function _buildMovieFrames($watermark) { } } + // Frame-loop summary + $frameLoopMs = (int) round((microtime(true) - $frameLoopStart) * 1000); + $framesBuilt = count($frameTimingsMs); + $avgFrameMs = $framesBuilt > 0 ? (int) round(array_sum($frameTimingsMs) / $framesBuilt) : 0; + $totalBuildMs = $contextMs + $frameLoopMs; + $contextShare = $totalBuildMs > 0 ? (int) round(($contextMs / $totalBuildMs) * 100) : 0; + error_log(sprintf( + "[Movie:%s] Build complete: %d/%d frames in %dms total (event context: %dms / %d%%, frame loop: %dms, avg %dms/frame, failures: %d)", + $movieId, + $framesBuilt, $totalFrames, + $totalBuildMs, + $contextMs, $contextShare, + $frameLoopMs, $avgFrameMs, + $numFailures + )); + $this->_createPreviewImages($previewImage); } From 3b3ea971b4aabbf5e1ccc50434c34defa909e5d2 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Tue, 26 May 2026 17:28:56 +0000 Subject: [PATCH 14/15] put max events api limits to configuration --- settings/Config.Example.ini | 8 +++++++ src/Config.php | 4 +++- src/Event/Api/EventsApi.php | 43 ++++++++++++++++++++++++++++--------- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/settings/Config.Example.ini b/settings/Config.Example.ini index 5cd878c86..4958a5af6 100644 --- a/settings/Config.Example.ini +++ b/settings/Config.Example.ini @@ -96,6 +96,14 @@ events_api_timeout = 10 ; round-trips but slower per request. 50 is a balanced default. events_api_events_per_frame_chunksize = 50 +; Upstream-imposed cap on timestamps per Events API batch request. +; Any chunk size larger than this gets clamped down to this value. +events_api_events_per_frame_max_chunk_size = 150 + +; Upstream-imposed cap on path-prefix selections per frames_with_selections +; request. Exceeding this raises EventsApiException before any HTTP call. +events_api_events_per_frame_max_selections = 200 + [movie_params] ; FFmpeg location ffmpeg = ffmpeg diff --git a/src/Config.php b/src/Config.php index d7d29fabc..0af77c89a 100644 --- a/src/Config.php +++ b/src/Config.php @@ -19,7 +19,9 @@ class Config { private $_bools = array('disable_cache', 'enable_statistics_collection', 'db_events','sentry_enabled'); private $_ints = array('build_num', 'ffmpeg_max_threads', 'max_jpx_frames', 'max_movie_frames', - 'events_api_events_per_frame_chunksize'); + 'events_api_events_per_frame_chunksize', + 'events_api_events_per_frame_max_chunk_size', + 'events_api_events_per_frame_max_selections'); private $_floats = array('events_api_timeout'); private $config; diff --git a/src/Event/Api/EventsApi.php b/src/Event/Api/EventsApi.php index 8584ed4d6..782652463 100644 --- a/src/Event/Api/EventsApi.php +++ b/src/Event/Api/EventsApi.php @@ -23,11 +23,31 @@ class EventsApi implements EventsApiInterface { /** Known event sources */ public const VALID_SOURCES = ['HEK', 'CCMC', 'RHESSI']; - /** Upstream-imposed cap on timestamps per batch request */ - public const MAX_CHUNK_SIZE = 150; + /** Fallback used when the HV_* config constant is not defined. */ + private const DEFAULT_MAX_CHUNK_SIZE = 150; + private const DEFAULT_MAX_SELECTIONS = 200; - /** Upstream-imposed cap on selections per frames_with_selections request */ - public const MAX_SELECTIONS = 200; + /** + * Upstream-imposed cap on timestamps per batch request. Driven by + * HV_EVENTS_API_EVENTS_PER_FRAME_MAX_CHUNK_SIZE in Config.ini. + */ + public static function maxChunkSize(): int + { + return defined('HV_EVENTS_API_EVENTS_PER_FRAME_MAX_CHUNK_SIZE') + ? (int) HV_EVENTS_API_EVENTS_PER_FRAME_MAX_CHUNK_SIZE + : self::DEFAULT_MAX_CHUNK_SIZE; + } + + /** + * Upstream-imposed cap on selections per frames_with_selections request. + * Driven by HV_EVENTS_API_EVENTS_PER_FRAME_MAX_SELECTIONS in Config.ini. + */ + public static function maxSelections(): int + { + return defined('HV_EVENTS_API_EVENTS_PER_FRAME_MAX_SELECTIONS') + ? (int) HV_EVENTS_API_EVENTS_PER_FRAME_MAX_SELECTIONS + : self::DEFAULT_MAX_SELECTIONS; + } private ClientInterface $client; private SentryClientInterface $sentry; @@ -175,8 +195,9 @@ public function getEventsBatch(array $timestamps, array $sources, int $chunkSize if ($chunkSize < 1) { $chunkSize = defined('HV_EVENTS_API_EVENTS_PER_FRAME_CHUNKSIZE') ? (int) HV_EVENTS_API_EVENTS_PER_FRAME_CHUNKSIZE : 50; } - if ($chunkSize > self::MAX_CHUNK_SIZE) { - $chunkSize = self::MAX_CHUNK_SIZE; + $maxChunk = self::maxChunkSize(); + if ($chunkSize > $maxChunk) { + $chunkSize = $maxChunk; } $sourcesParam = implode('::', $validSources); @@ -242,8 +263,9 @@ public function getEventsForFramesWithSelections( if (empty($selections)) { throw new EventsApiException("No selections given. At least one path-prefix selection is required."); } - if (count($selections) > self::MAX_SELECTIONS) { - throw new EventsApiException("Too many selections: " . count($selections) . ". Upstream limit is " . self::MAX_SELECTIONS . "."); + $maxSelections = self::maxSelections(); + if (count($selections) > $maxSelections) { + throw new EventsApiException("Too many selections: " . count($selections) . ". Upstream limit is " . $maxSelections . "."); } if (empty($timestamps)) { return []; @@ -251,8 +273,9 @@ public function getEventsForFramesWithSelections( if ($chunkSize < 1) { $chunkSize = defined('HV_EVENTS_API_EVENTS_PER_FRAME_CHUNKSIZE') ? (int) HV_EVENTS_API_EVENTS_PER_FRAME_CHUNKSIZE : 50; } - if ($chunkSize > self::MAX_CHUNK_SIZE) { - $chunkSize = self::MAX_CHUNK_SIZE; + $maxChunk = self::maxChunkSize(); + if ($chunkSize > $maxChunk) { + $chunkSize = $maxChunk; } $url = "/helioviewer/events/frames_with_selections"; From 0cdc39453de4abb5c8834dc64a135993b528de92 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Tue, 26 May 2026 17:31:09 +0000 Subject: [PATCH 15/15] use empty event context for default value --- src/Image/Composite/HelioviewerCompositeImage.php | 6 +++--- src/Module/WebClient.php | 1 - .../events/api/GetEventsForFramesWithSelectionsTest.php | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Image/Composite/HelioviewerCompositeImage.php b/src/Image/Composite/HelioviewerCompositeImage.php index 03941f5f9..e0e80f3c0 100644 --- a/src/Image/Composite/HelioviewerCompositeImage.php +++ b/src/Image/Composite/HelioviewerCompositeImage.php @@ -152,9 +152,9 @@ public function __construct($layers, $eventsManager, $movieIcons, $celestialBodi 'grayscale' => false, 'eclipse' => false, 'moon' => false, - 'eventContext' => null, + 'eventContext' => EventContext::empty(), ); - + $options = array_replace($defaults, $options); $this->width = $roi->getPixelWidth(); @@ -162,7 +162,7 @@ public function __construct($layers, $eventsManager, $movieIcons, $celestialBodi $this->imageScale = $roi->imageScale(); $this->db = $options['database'] ? $options['database'] : new Database_ImgIndex(); - $this->eventContext = $options['eventContext'] ?? EventContext::empty(); + $this->eventContext = $options['eventContext']; $this->layers = $layers; $this->eventsManager = $eventsManager; $this->movieIcons = $movieIcons; diff --git a/src/Module/WebClient.php b/src/Module/WebClient.php index db58a8601..94ef3b2ef 100644 --- a/src/Module/WebClient.php +++ b/src/Module/WebClient.php @@ -1404,7 +1404,6 @@ public function getEclipseImage() { 'grayscale' => true, 'eclipse' => true, 'moon' => $this->_options['moon'], - 'eventContext' => EventContext::empty() ] ); $screenshot->display(); diff --git a/tests/unit_tests/events/api/GetEventsForFramesWithSelectionsTest.php b/tests/unit_tests/events/api/GetEventsForFramesWithSelectionsTest.php index e019b4011..53bc802df 100644 --- a/tests/unit_tests/events/api/GetEventsForFramesWithSelectionsTest.php +++ b/tests/unit_tests/events/api/GetEventsForFramesWithSelectionsTest.php @@ -39,7 +39,7 @@ public function testItShouldThrowForEmptySelections(): void public function testItShouldThrowWhenSelectionsExceedUpstreamLimit(): void { - $tooMany = array_fill(0, EventsApi::MAX_SELECTIONS + 1, 'HEK>>Flare'); + $tooMany = array_fill(0, EventsApi::maxSelections() + 1, 'HEK>>Flare'); $this->mockClient->expects($this->never())->method('request');