From 3b485a0d6df200d0f6d9a4a8d3a51ac90e137e83 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Thu, 25 Jun 2026 22:10:11 +0000 Subject: [PATCH 1/2] implement simpletree mode for event reading --- src/Event/EventTree.php | 113 +++++++++++ src/Module/SolarEvents.php | 35 +++- tests/unit_tests/events/EventTreeTest.php | 218 ++++++++++++++++++++++ 3 files changed, 357 insertions(+), 9 deletions(-) create mode 100644 src/Event/EventTree.php create mode 100644 tests/unit_tests/events/EventTreeTest.php diff --git a/src/Event/EventTree.php b/src/Event/EventTree.php new file mode 100644 index 00000000..f1458409 --- /dev/null +++ b/src/Event/EventTree.php @@ -0,0 +1,113 @@ +>Label" tree, using + * EventSelections::$event_types_map as the canonical catalogue. + * + * Usage: + * $tree = EventTree::make($flatEvents, $requestedSources); + * $payload = $tree->export(); // -> ['HEK>>Active Region' => [...], ...] + * + * make() also pings Sentry once per call if it saw: + * - paths that didn't match any pre-seeded "SOURCE>>Label" bucket + * (likely a new HEK/CCMC/RHESSI concept the map doesn't know yet -- + * these events still get bucketed under a dynamically-created key); + * - malformed/empty paths -- these events are dropped from the output. + */ +class EventTree +{ + /** @var array>> bucket name => events */ + private array $buckets; + + private function __construct(array $buckets) + { + $this->buckets = $buckets; + } + + /** + * @param list> $events Flat events from EventsApi + * @param list $sources Sources to seed buckets for; + * only these contribute the + * "SOURCE>>Label" pre-seeded keys. + * @param SentryClientInterface|null $sentry Optional client override for + * tests. Falls back to the + * static Sentry::$client. + */ + public static function make(array $events, array $sources, ?SentryClientInterface $sentry = null): self + { + $sentry = $sentry ?? Sentry::$client; + $buckets = []; + + // Pre-seed every known bucket for the requested sources. + foreach ($sources as $source) { + if (!isset(EventSelections::$event_types_map[$source])) { + continue; + } + foreach (EventSelections::$event_types_map[$source] as $code => $label) { + $buckets[$source . '>>' . $label] = []; + } + } + + $unknownPaths = []; + $invalidPaths = []; + + foreach ($events as $event) { + $path = $event['path'] ?? ''; + $parts = explode('>>', $path); + + // Drop events whose path can't yield a SOURCE>>Label bucket. + if (count($parts) < 2 || $parts[0] === '' || $parts[1] === '') { + $invalidPaths[] = $path; + continue; + } + + $matched = false; + foreach (array_keys($buckets) as $bucket) { + if (str_starts_with($path, $bucket . '>>')) { + $buckets[$bucket][] = $event; + $matched = true; + break; + } + } + + if (!$matched) { + $bucket = $parts[0] . '>>' . $parts[1]; + $unknownPaths[] = $path; + if (!isset($buckets[$bucket])) { + $buckets[$bucket] = []; + } + $buckets[$bucket][] = $event; + } + } + + if (!empty($unknownPaths) || !empty($invalidPaths)) { + $sentry->setContext('SimpleTreeUnknownPaths', [ + 'unknown_paths' => array_values(array_unique($unknownPaths)), + 'invalid_paths' => array_values(array_unique($invalidPaths)), + 'requested_sources' => $sources, + ]); + $sentry->message( + 'simpletree: encountered paths not in EventSelections::$event_types_map' + ); + } + + return new self($buckets); + } + + /** + * Returns the bucketed structure ready to be JSON-encoded as the + * HTTP response body. + * + * @return array>> + */ + public function export(): array + { + return $this->buckets; + } +} diff --git a/src/Module/SolarEvents.php b/src/Module/SolarEvents.php index 91647da5..2b3c2dcf 100644 --- a/src/Module/SolarEvents.php +++ b/src/Module/SolarEvents.php @@ -17,6 +17,7 @@ use Helioviewer\Api\Sentry\Sentry; use Helioviewer\Api\Event\Api\EventsApi; use Helioviewer\Api\Event\Api\EventsApiException; +use Helioviewer\Api\Event\EventTree; class Module_SolarEvents extends BaseModule implements ModuleInterface { @@ -192,11 +193,13 @@ public function importEvents() { public function events() { $observationTime = new DateTimeImmutable($this->_params['startTime']); - // Output format selector: 'tree' (legacy nested categories, default) - // or 'flat' (new v1 per-source endpoint). Anything else falls back to - // 'tree' for backwards compatibility. + // Output format selector: + // 'tree' legacy nested categories (default) + // 'flat' new v1 per-source endpoint + // 'simpletree' same flat fetch, dumped via pre() for now + // Anything else falls back to 'tree' for backwards compatibility. $format = $this->_options['format'] ?? 'tree'; - if ($format !== 'flat') { + if (!in_array($format, ['tree', 'flat', 'simpletree'], true)) { $format = 'tree'; } @@ -210,15 +213,24 @@ public function events() { $sources = $allSources; } - // Fetch events from each source via EventsApi + // Fetch events from each source via EventsApi. $data = []; foreach ($sources as $source) { try { - $sourceData = ($format === 'flat') - ? $this->eventsApi()->getEventsForSource($observationTime, $source) - : $this->eventsApi()->getEventsForSourceLegacy($observationTime, $source); + + $sourceData = []; + + if ($format === 'tree') { + $sourceData = $this->eventsApi()->getEventsForSourceLegacy($observationTime, $source); + } + + if (in_array($format, ['flat', 'simpletree'], true)) { + $sourceData = $this->eventsApi()->getEventsForSource($observationTime, $source); + } + $data = array_merge($data, $sourceData); + } catch (EventsApiException $e) { return $this->_sendResponse(500, 'Internal Server Error', 'Failed to fetch events from ' . $source); } catch (\Throwable $e) { @@ -227,6 +239,11 @@ public function events() { } } + // simpletree: re-shape the flat payload into a SOURCE>>Label tree. + if ($format === 'simpletree') { + $data = EventTree::make($data, $sources)->export(); + } + header("Content-Type: application/json"); echo json_encode($data); } @@ -283,7 +300,7 @@ public function getValidationRules(): array { 'bools' => array('cacheOnly','force','ar_filter'), 'dates' => array('startTime'), 'alphanumlist' => array('eventType', 'sources'), - 'choices' => array('format' => ['tree', 'flat']), + 'choices' => array('format' => ['tree', 'flat', 'simpletree']), ); break; default: diff --git a/tests/unit_tests/events/EventTreeTest.php b/tests/unit_tests/events/EventTreeTest.php new file mode 100644 index 00000000..0887879f --- /dev/null +++ b/tests/unit_tests/events/EventTreeTest.php @@ -0,0 +1,218 @@ + + */ + +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\MockObject\MockObject; +use Helioviewer\Api\Event\EventTree; +use Helioviewer\Api\Sentry\Sentry; +use Helioviewer\Api\Sentry\ClientInterface as SentryClientInterface; + +final class EventTreeTest extends TestCase +{ + /** @var SentryClientInterface&MockObject */ + private $sentry; + + protected function setUp(): void + { + $this->sentry = $this->createMock(SentryClientInterface::class); + } + + public function testItPreseedsBucketsForRequestedSources(): void + { + $this->sentry->expects($this->never())->method('message'); + $this->sentry->expects($this->never())->method('setContext'); + + $payload = EventTree::make([], ['HEK'], $this->sentry)->export(); + + $this->assertArrayHasKey('HEK>>Active Region', $payload); + $this->assertArrayHasKey('HEK>>Flare', $payload); + $this->assertSame([], $payload['HEK>>Active Region']); + + // CCMC/RHESSI weren't requested -> no buckets for them. + $this->assertArrayNotHasKey('CCMC>>DONKI', $payload); + $this->assertArrayNotHasKey('RHESSI>>Solar Flares', $payload); + } + + public function testItSkipsUnknownSourcesSilently(): void + { + $this->sentry->expects($this->never())->method('message'); + $this->sentry->expects($this->never())->method('setContext'); + + $payload = EventTree::make([], ['NONEXISTENT'], $this->sentry)->export(); + + $this->assertSame([], $payload); + } + + public function testItBucketsEventByPathPrefix(): void + { + $this->sentry->expects($this->never())->method('message'); + + $event = ['path' => 'HEK>>Active Region>>SPoCA', 'id' => 'a1']; + $payload = EventTree::make([$event], ['HEK'], $this->sentry)->export(); + + $this->assertSame([$event], $payload['HEK>>Active Region']); + $this->assertSame([], $payload['HEK>>CME']); + $this->assertSame([], $payload['HEK>>Flare']); + } + + public function testItRoutesMultipleEventsToCorrectBuckets(): void + { + $this->sentry->expects($this->never())->method('message'); + + $events = [ + ['path' => 'HEK>>Active Region>>SPoCA', 'id' => 'ar1'], + ['path' => 'HEK>>Active Region>>NOAA', 'id' => 'ar2'], + ['path' => 'HEK>>Flare>>SSW_Flare', 'id' => 'fl1'], + ['path' => 'CCMC>>DONKI>>CME', 'id' => 'cme1'], + ]; + + $payload = EventTree::make($events, ['HEK', 'CCMC'], $this->sentry)->export(); + + $this->assertCount(2, $payload['HEK>>Active Region']); + $this->assertCount(1, $payload['HEK>>Flare']); + $this->assertCount(1, $payload['CCMC>>DONKI']); + $this->assertSame([], $payload['CCMC>>Solar Flare Predictions']); + } + + public function testItCreatesDynamicBucketAndReportsUnknownPath(): void + { + $this->sentry->expects($this->once()) + ->method('setContext') + ->with( + $this->equalTo('SimpleTreeUnknownPaths'), + $this->callback(function (array $params): bool { + return $params['unknown_paths'] === ['HEK>>NewConcept>>SomeFRM'] + && $params['invalid_paths'] === [] + && $params['requested_sources'] === ['HEK']; + }) + ); + + $this->sentry->expects($this->once()) + ->method('message') + ->with($this->equalTo( + 'simpletree: encountered paths not in EventSelections::$event_types_map' + )); + + $event = ['path' => 'HEK>>NewConcept>>SomeFRM', 'id' => 'x']; + $payload = EventTree::make([$event], ['HEK'], $this->sentry)->export(); + + $this->assertArrayHasKey('HEK>>NewConcept', $payload); + $this->assertSame([$event], $payload['HEK>>NewConcept']); + } + + public function testItReportsOnlyTheFirstOccurrenceOfEachNewConcept(): void + { + // Once a dynamic bucket is created (HEK>>NewConcept from event 1), + // subsequent events whose path falls under that bucket are matched + // by the regular str_starts_with loop and never re-reported. So + // unknown_paths captures one path per *new concept*, not per event. + $this->sentry->expects($this->once()) + ->method('setContext') + ->with( + 'SimpleTreeUnknownPaths', + $this->callback(function (array $params): bool { + $reported = $params['unknown_paths']; + return count($reported) === 2 + && in_array('HEK>>NewConcept>>FrmA', $reported, true) + && in_array('HEK>>AnotherNew>>FrmA', $reported, true); + }) + ); + + $this->sentry->expects($this->once())->method('message'); + + $events = [ + ['path' => 'HEK>>NewConcept>>FrmA'], // 1st: bucket created, recorded + ['path' => 'HEK>>NewConcept>>FrmA'], // 2nd: matched silently + ['path' => 'HEK>>NewConcept>>FrmB'], // 3rd: matched silently + ['path' => 'HEK>>AnotherNew>>FrmA'], // 4th: new concept, recorded + ]; + + EventTree::make($events, ['HEK'], $this->sentry); + } + + public function testItDropsMalformedPathsAndReportsThem(): void + { + $this->sentry->expects($this->once()) + ->method('setContext') + ->with( + 'SimpleTreeUnknownPaths', + $this->callback(function (array $params): bool { + $invalid = $params['invalid_paths']; + return $params['unknown_paths'] === [] + && in_array('', $invalid, true) + && in_array('HEK', $invalid, true) + && in_array('>>', $invalid, true) + && in_array('HEK>>', $invalid, true) + && in_array('>>Active Region', $invalid, true); + }) + ); + + $this->sentry->expects($this->once())->method('message'); + + $events = [ + ['path' => ''], // empty + ['path' => 'HEK'], // single segment + ['path' => '>>'], // both segments empty + ['path' => 'HEK>>'], // second empty + ['path' => '>>Active Region'], // first empty + [], // missing 'path' key (treated as '') + ]; + + $payload = EventTree::make($events, ['HEK'], $this->sentry)->export(); + + // None of the malformed events should reach any bucket. + foreach ($payload as $bucket => $items) { + $this->assertSame([], $items, "Bucket $bucket should have stayed empty"); + } + } + + public function testItStaysSilentWhenEverythingMatches(): void + { + $this->sentry->expects($this->never())->method('message'); + $this->sentry->expects($this->never())->method('setContext'); + + $events = [ + ['path' => 'HEK>>Active Region>>SPoCA'], + ['path' => 'HEK>>Flare>>SSW_Flare'], + ['path' => 'CCMC>>DONKI>>CME'], + ]; + + EventTree::make($events, ['HEK', 'CCMC'], $this->sentry); + } + + public function testItReportsBothUnknownAndInvalidPathsInSameRequest(): void + { + $this->sentry->expects($this->once()) + ->method('setContext') + ->with( + 'SimpleTreeUnknownPaths', + $this->callback(function (array $params): bool { + return $params['unknown_paths'] === ['HEK>>NewConcept>>X'] + && $params['invalid_paths'] === ['']; + }) + ); + + $this->sentry->expects($this->once())->method('message'); + + $events = [ + ['path' => 'HEK>>NewConcept>>X'], // unknown + ['path' => ''], // invalid + ]; + + EventTree::make($events, ['HEK'], $this->sentry); + } + + public function testItFallsBackToStaticSentryClientWhenNoneInjected(): void + { + $this->sentry->expects($this->once())->method('message'); + $this->sentry->expects($this->once())->method('setContext'); + + // Bind the mock as Sentry::$client; make() with no 3rd arg should pick it up. + Sentry::init(['enabled' => true, 'client' => $this->sentry]); + + EventTree::make([['path' => 'HEK>>NewConcept>>X']], ['HEK']); + } +} From cb57ceec694246f4bb9ab75dae5874df9271bb35 Mon Sep 17 00:00:00 2001 From: Kasim Necdet Percinel Date: Thu, 25 Jun 2026 22:23:10 +0000 Subject: [PATCH 2/2] fix more documentation --- .../solar_features_and_events/events.rst | 60 ++++++++++++++++--- src/Module/SolarEvents.php | 5 +- 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/docs/src/source/api/api_groups/solar_features_and_events/events.rst b/docs/src/source/api/api_groups/solar_features_and_events/events.rst index f44e31b1..0aa7cdfc 100644 --- a/docs/src/source/api/api_groups/solar_features_and_events/events.rst +++ b/docs/src/source/api/api_groups/solar_features_and_events/events.rst @@ -15,17 +15,24 @@ Returns a list of HEK events in the :ref:`helioviewer-event-format`. | | | | | | If not provided, all sources will be queried | | | | | | | Allowed values: HEK, CCMC, RHESSI. | +-----------+----------+--------+----------------------+------------------------------------------------------------+ - | format | Optional | string | flat | | Output shape. ``tree`` (default) returns the legacy | - | | | | | | nested category/group structure described in | - | | | | | | :ref:`helioviewer-event-format`. ``flat`` returns the | - | | | | | | new v1 per-source response (one object per event with | - | | | | | | no category nesting). Allowed values: ``tree``, | - | | | | | | ``flat``. | + | format | Optional | string | | tree | | Output shape. ``tree`` (default) returns the legacy | + | | | | | simpletree | | nested category/group structure described in | + | | | | | flat | | :ref:`helioviewer-event-format`. ``simpletree`` buckets | + | | | | | | the flat response into a one-level ``SOURCE>>Label`` map | + | | | | | | keyed by event type. ``flat`` returns the new v1 | + | | | | | | per-source response (one object per event with no | + | | | | | | category nesting). Allowed values: ``tree``, | + | | | | | | ``simpletree``, ``flat``. | +-----------+----------+--------+----------------------+------------------------------------------------------------+ See :ref:`helioviewer-event-format` for the response format when ``format=tree`` (the default). When ``format=flat`` is requested, the response is the raw v1 events payload for each source, concatenated -- no nesting, no group keys. +When ``format=simpletree`` is requested, that flat payload is re-shaped into +a single-level map whose keys are ``SOURCE>>Label`` strings (one per known +event type for the requested sources) and whose values are arrays of the +matching events. Empty buckets are kept so the client can render the full +catalogue regardless of what's in the response. Event specific data conforms to the `HEK Event Specification `_ @@ -106,4 +113,43 @@ Example: Same request but with the new flat shape ... }, ... - ] \ No newline at end of file + ] + +Example: Same request bucketed into a SOURCE>>Label map + +.. code-block:: + :caption: Example Query (format=simpletree) + + https://api.helioviewer.org/v2/events/?startTime=2023-03-30T00:00:00Z&sources=HEK&format=simpletree + +.. code-block:: + :caption: Example Response (format=simpletree) + + { + "HEK>>Active Region": [ + { + "concept": "Active Region", + "frm_name": "NOAA SWPC Observer", + "path": "HEK>>Active Region>>NOAA SWPC Observer", + "pin": "AR", + ... + }, + ... + ], + "HEK>>CME": [], + "HEK>>Coronal Hole": [ + { + "concept": "Coronal Hole", + "frm_name": "SPoCA", + "path": "HEK>>Coronal Hole>>SPoCA", + ... + } + ], + "HEK>>Flare": [], + "HEK>>Sunspot": [], + ... + } + +Every key in the response object is a ``SOURCE>>Label`` string covering the +event-type catalogue for the requested sources. Empty buckets are kept so +the client can know the full catalogue regardless of what's in the response. \ No newline at end of file diff --git a/src/Module/SolarEvents.php b/src/Module/SolarEvents.php index 2b3c2dcf..99cbf8f4 100644 --- a/src/Module/SolarEvents.php +++ b/src/Module/SolarEvents.php @@ -196,7 +196,10 @@ public function events() { // Output format selector: // 'tree' legacy nested categories (default) // 'flat' new v1 per-source endpoint - // 'simpletree' same flat fetch, dumped via pre() for now + // 'simpletree' flat events bucketed into a "SOURCE>>Label" tree + // (see EventTree) -- one key per known event type for + // the requested sources, empty arrays for types with + // no events in the response. // Anything else falls back to 'tree' for backwards compatibility. $format = $this->_options['format'] ?? 'tree'; if (!in_array($format, ['tree', 'flat', 'simpletree'], true)) {