Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://www.lmsal.com/hek/VOEvent_Spec.html>`_

Expand Down Expand Up @@ -106,4 +113,43 @@ Example: Same request but with the new flat shape
...
},
...
]
]

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.
113 changes: 113 additions & 0 deletions src/Event/EventTree.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php declare(strict_types=1);

namespace Helioviewer\Api\Event;

use Helioviewer\Api\Sentry\Sentry;
use Helioviewer\Api\Sentry\ClientInterface as SentryClientInterface;

/**
* Buckets a flat list of events (the shape returned by
* EventsApi::getEventsForSource()) into a "SOURCE>>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<string, list<array<string, mixed>>> bucket name => events */
private array $buckets;

private function __construct(array $buckets)
{
$this->buckets = $buckets;
}

/**
* @param list<array<string, mixed>> $events Flat events from EventsApi
* @param list<string> $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<string, list<array<string, mixed>>>
*/
public function export(): array
{
return $this->buckets;
}
}
38 changes: 29 additions & 9 deletions src/Module/SolarEvents.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -192,11 +193,16 @@ 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' 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 ($format !== 'flat') {
if (!in_array($format, ['tree', 'flat', 'simpletree'], true)) {
$format = 'tree';
}

Expand All @@ -210,15 +216,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) {
Expand All @@ -227,6 +242,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);
}
Expand Down Expand Up @@ -283,7 +303,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:
Expand Down
Loading
Loading