From 9f531efb1f1b3def111323af1fb12747cbc0853b Mon Sep 17 00:00:00 2001
From: Daniel Wysocki <daniel.wysocki@ligo.org>
Date: Wed, 24 Apr 2024 15:13:29 +0000
Subject: [PATCH] Splitting O4 into O4a/O4b, adding ER16

---
 config/settings/base.py              |  3 +-
 docs/user_docs/source/queries.rst    | 93 ++++++++++++++++++++++++++++
 gracedb/search/constants.py          | 39 ++++++++++--
 gracedb/search/query/events.py       |  9 ++-
 gracedb/search/query/superevents.py  |  8 +--
 gracedb/search/tests/test_queries.py | 24 ++++---
 gracedb/search/utils.py              | 18 ++++++
 gracedb/static/css/override-new.css  | 18 ++++++
 gracedb/superevents/views.py         | 13 ++--
 gracedb/templates/navbar_frag.html   | 33 ++++++++--
 10 files changed, 226 insertions(+), 32 deletions(-)

diff --git a/config/settings/base.py b/config/settings/base.py
index b8539933e..6522bdae2 100644
--- a/config/settings/base.py
+++ b/config/settings/base.py
@@ -712,7 +712,8 @@ EVENT_SUPEREVENT_WINDOW_BEFORE = 100
 EVENT_SUPEREVENT_WINDOW_AFTER = 100
 
 # Define which observation periods to show on the public events page:
-PUBLIC_PAGE_RUNS = ['O4', 'ER15', 'O3']
+# TODO: Group O4b and O4a under O4, once implemented.
+PUBLIC_PAGE_RUNS = ['O4', 'O4b', 'O4a', 'ER16', 'ER15', 'O3']
 
 # Define how long to cache the public page:
 PUBLIC_PAGE_CACHING = int(get_from_env('DJANGO_PUBLIC_PAGE_CACHING',
diff --git a/docs/user_docs/source/queries.rst b/docs/user_docs/source/queries.rst
index f48046157..03b503b9b 100644
--- a/docs/user_docs/source/queries.rst
+++ b/docs/user_docs/source/queries.rst
@@ -136,6 +136,95 @@ Examples:
 - ``is_preferred_event: True``
 - ``is_preferred_event: False``
 
+By run identifier
+-----------------
+Events (and superevents) can be queried by Observation/Engineering/Science run identifier,
+which is based on a preset ``gpstime`` range. The ``runid:`` keyword is optional.
+Examples and available options are below: 
+
+- ``runid: O4``
+- ``O3``
+- ``O1 O2``
+
+.. list-table:: GraceDB Queryable Run ID's
+   :widths: 25 25
+   :header-rows: 1
+
+   * - runid
+     - gpstime/t_0 range
+
+   * - ``O4``
+     - (1368975618, 1389456018), (1396796418, 1423238418)
+
+   * - ``O4b``
+     - (1396796418, 1423238418)
+
+   * - ``O4a``
+     - (1368975618, 1389456018)
+
+   * - ``ER16``
+     - (1394982018, 1396796418)
+
+   * - ``ER15``
+     - (1366556418, 1368975618)
+
+   * - ``O3``
+     - (1238166018, 1269363618)
+
+   * - ``ER14``
+     - (1235750418, 1238166018)
+
+   * - ``ER13``
+     - (1228838418, 1229176818)
+
+   * - ``O2``
+     - (1164556817, 1187733618)
+
+   * - ``O1``
+     - (1126623617, 1136649617)
+
+   * - ``ER8``
+     - (1123858817, 1126623617)
+
+   * - ``ER7``
+     - (1117400416, 1118329216)
+
+   * - ``ER6``
+     - (1102089616, 1102863616)
+
+   * - ``ER5``
+     - (1073822416, 1078876816)
+
+   * - ``ER4``
+     - (1057881616, 1061856016)
+
+   * - ``ER3``
+     - (1044136816, 1045785616)
+
+   * - ``ER2``
+     - (1026666016, 1028480416)
+
+   * - ``ER1``
+     - (1011601640, 1013299215)
+
+   * - ``ER1test``
+     - (1010944815, 1011601640)
+
+   * - ``S6``
+     - (931035296, 971622087)
+
+   * - ``S6A``
+     - (931035296, 935798487)
+
+   * - ``S6B``
+     - (937800015, 947260815)
+
+   * - ``S6C``
+     - (949449543, 961545687)
+
+   * - ``S6D``
+     - (956707143, 971622087)
+
 
 Superevent queries
 ==================
@@ -219,6 +308,10 @@ By label
 --------
 Same as for events.
 
+By run identifier
+-----------------
+Same as for events.
+
 By public status
 ----------------
 Use the ``is_public`` or ``is_exposed`` keywords.
diff --git a/gracedb/search/constants.py b/gracedb/search/constants.py
index 33c84a1e5..be9544835 100644
--- a/gracedb/search/constants.py
+++ b/gracedb/search/constants.py
@@ -17,11 +17,21 @@ ExpressionOperator.setParseAction(lambda toks: EXPR_OPERATORS[toks[0]])
 
 # Dict of LIGO run names (keys) and GPS time range tuples (values)
 RUN_MAP = {
-    # O4 Start May 24, 2023...1500UTC? 18 months later...Nov. 24, 2024.
-    # FIXME: change the end date in a future release:
-    "O4": (1368975618, 1416495618),
-    # ER15 start Apr 26, 2023 1600 UTC
-    "ER15": (1366560018, 1368975618), 
+    "O4": {
+            # https://observing.docs.ligo.org/plan/
+            # The LIGO Hanford (LHO), LIGO Livingston (LLO), and Virgo detectors transitioned
+            # to the regular observing run O4b at 15:00 UTC on 10 April 2024. O4b will run
+            # until February 2025 (FIXME specific date TBD), with no further planned breaks
+            # in observing.
+            "O4b": (1396796418, 1423238418),
+            # O4a started May 24, 2023 1500UTC and ended Jan 16, 2024 1600UTC
+            "O4a": (1368975618, 1389456018),
+          },
+    # ER16 started March 20, 2024 1500UTC, ended when O4b started
+    # https://dcc.ligo.org/DocDB/0191/M2300233/002/ER16_O4b_Start.pdf
+    "ER16": (1394982018, 1396796418),
+    # ER15 start Apr 26, 2023 1500 UTC
+    "ER15": (1366556418, 1368975618),
     # O3 suspended early due to COVID-19:
     # https://www.ligo.caltech.edu/news/ligo20200326
     # 01 Apr 2019 15:00:00 UTC - 27 Mar 2020 16:00:00 UTC
@@ -59,3 +69,22 @@ RUN_MAP = {
     "S6D" : (956707143, 971622087),
 }
 
+# Flattens run map, creating a consistently typed dict mapping
+# run/segment names to a list of (start, stop) gpstimes.
+def flatten_run_map(dictionary):
+    flattened = {}
+    for key, value in dictionary.items():
+        if isinstance(value, dict):
+            # Insert the full run
+            flattened[key] = list(value.values())
+            # Insert the individual segments
+            for segment_name, segment_times in value.items():
+                flattened[segment_name] = [segment_times]
+        else:
+            # Insert a full run that has no segments
+            flattened[key] = [value]
+
+    return flattened
+
+# A flat RUN_MAP list to use in queries:
+RUN_MAP_FLAT = flatten_run_map(RUN_MAP)
diff --git a/gracedb/search/query/events.py b/gracedb/search/query/events.py
index 94a244c53..623695cb9 100644
--- a/gracedb/search/query/events.py
+++ b/gracedb/search/query/events.py
@@ -30,8 +30,8 @@ nltime = nltime_.setParseAction(lambda toks: toks["calculatedTime"])
 from events.models import Group, Pipeline, Search
 from .labels import getLabelQ
 from .superevents import parse_superevent_id, superevent_expr
-from ..constants import RUN_MAP, ExpressionOperator
-from ..utils import maybeRange
+from ..constants import RUN_MAP, RUN_MAP_FLAT, ExpressionOperator
+from ..utils import maybeRange, run_map_search_filter
 
 
 # hasfar flag
@@ -46,11 +46,10 @@ gpsQ = Optional(Suppress(Keyword("gpstime:"))) + (gpstime^gpstimeRange)
 gpsQ = gpsQ.setParseAction(maybeRange("gpstime"))
 
 # run ids
-runid = Or(list(map(CaselessLiteral, list(RUN_MAP)))).setName("run id")
+runid = Or(list(map(CaselessLiteral, list(RUN_MAP_FLAT)))).setName("run id")
 #runidList = OneOrMore(runid).setName("run id list")
 runQ = (Optional(Suppress(Keyword("runid:"))) + runid)
-runQ = runQ.setParseAction(lambda toks: ("gpstime", Q(gpstime__range=
-                                                        RUN_MAP[toks[0]])))
+runQ = runQ.setParseAction(lambda toks: ("gpstime", run_map_search_filter(toks[0], 'gpstime')))
 
 # Gracedb ID
 gid = Suppress(Word("gG", exact=1)) + Word("0123456789")
diff --git a/gracedb/search/query/superevents.py b/gracedb/search/query/superevents.py
index bd9ef3497..b7e29cbe2 100644
--- a/gracedb/search/query/superevents.py
+++ b/gracedb/search/query/superevents.py
@@ -16,8 +16,8 @@ from core.utils import letters_to_int, int_to_letters
 from events.nltime import nlTimeExpression as nltime_
 from superevents.models import Superevent
 from .labels import getLabelQ
-from ..constants import RUN_MAP, ExpressionOperator
-from ..utils import maybeRange
+from ..constants import RUN_MAP, RUN_MAP_FLAT, ExpressionOperator
+from ..utils import maybeRange, run_map_search_filter
 
 from pyparsing import Word, nums, Literal, CaselessLiteral, delimitedList, \
     Suppress, QuotedString, Keyword, Combine, Or, Optional, OneOrMore, \
@@ -96,10 +96,10 @@ parameter_dicts = {
     'runid': {
         'keyword': 'runid',
         'keywordOptional': True,
-        'value': Or(list(map(CaselessLiteral, list(RUN_MAP)))).setName(
+        'value': Or(list(map(CaselessLiteral, RUN_MAP_FLAT))).setName(
             "run id"),
         'doRange': False,
-        'parseAction': lambda toks: ("t_0", Q(t_0__range=RUN_MAP[toks[0]])),
+        'parseAction': lambda toks: ("t_0", run_map_search_filter(toks[0], 't_0')),
     },
     # t_0: 123456.0987 OR gpstime: 123456.0987
     't_0': {
diff --git a/gracedb/search/tests/test_queries.py b/gracedb/search/tests/test_queries.py
index a3087f9eb..31ea1ed1f 100644
--- a/gracedb/search/tests/test_queries.py
+++ b/gracedb/search/tests/test_queries.py
@@ -10,7 +10,8 @@ from django.conf import settings
 from django.db.models import Q
 
 from superevents.models import Superevent
-from search.constants import RUN_MAP
+from search.constants import RUN_MAP, RUN_MAP_FLAT
+from search.utils import run_map_search_filter
 from search.query.events import parseQuery
 from search.query.superevents import parseSupereventQuery
 
@@ -142,8 +143,8 @@ SUPEREVENT_QUERY_TEST_DATA = [
         Q(events__id__range=[123, 129])),
 ]
 # Add tests based on run IDs
-RUNID_QUERY_DATA = [(k, Q(t_0__range=v) & (DEFAULT_Q))
-    for k,v in RUN_MAP.items()]
+RUNID_QUERY_DATA = [(k, run_map_search_filter(k, 't_0') & (DEFAULT_Q))
+    for k in RUN_MAP_FLAT]
 SUPEREVENT_QUERY_TEST_DATA.extend(RUNID_QUERY_DATA)
 
 @pytest.mark.parametrize("query,expected_Q_result", SUPEREVENT_QUERY_TEST_DATA)
@@ -296,11 +297,20 @@ EVENT_QUERY_TEST_DATA = [
     ("is_preferred_event: False", Q(superevent_preferred_for__isnull=True) &
         DEFAULT_EVENT_Q),
     # By run name
-    ("runid: O1", Q(gpstime__range=RUN_MAP["O1"]) & DEFAULT_EVENT_Q),
-    ("O1", Q(gpstime__range=RUN_MAP["O1"]) & DEFAULT_EVENT_Q),
+    ("runid: O1", run_map_search_filter("O1", "gpstime") & DEFAULT_EVENT_Q),
+    ("O1", run_map_search_filter("O1", "gpstime") & DEFAULT_EVENT_Q),
     ("O1 O2",
-        ( Q(gpstime__range=RUN_MAP["O1"])
-        | Q(gpstime__range=RUN_MAP["O2"])) & DEFAULT_EVENT_Q),
+        ( run_map_search_filter("O1", "gpstime")
+        | run_map_search_filter("O2", "gpstime")) & DEFAULT_EVENT_Q),
+    # try out the O4 filters:
+    ("O4", run_map_search_filter("O4", "gpstime") & DEFAULT_EVENT_Q),
+    # Manually test O4:
+    ("O4", (Q(gpstime__range=RUN_MAP_FLAT["O4"][0]) |
+            Q(gpstime__range=RUN_MAP_FLAT["O4"][1])) & DEFAULT_EVENT_Q),
+    # test one of O4's subranges:
+    ("O4a", run_map_search_filter("O4a", "gpstime") & DEFAULT_EVENT_Q),
+    # and manually:
+    ("O4b", Q(gpstime__range=RUN_MAP_FLAT["O4b"][0]) & DEFAULT_EVENT_Q),
     # 'nevents'
     ("nevents: 5", Q(nevents="5") & DEFAULT_EVENT_Q),
 ]
diff --git a/gracedb/search/utils.py b/gracedb/search/utils.py
index 4b5c2b00b..ec24c442e 100644
--- a/gracedb/search/utils.py
+++ b/gracedb/search/utils.py
@@ -3,6 +3,7 @@ from pyparsing import Keyword, CaselessKeyword, oneOf, Literal, Or, \
     OneOrMore, ZeroOrMore, Optional, Suppress
 
 from django.db.models import Q, QuerySet
+from .constants import RUN_MAP_FLAT
 
 # Set up logger
 logger = logging.getLogger(__name__)
@@ -74,3 +75,20 @@ def handle_binary_ops(toks, op="or"):
         new_toks = toks
 
     return new_toks, updated
+
+
+# Given a string representation of an observing/engineering period (like 'O4'),
+# and a time range parameter (t_0 for superevents, gpstime for events), then 
+# return a Q filter over that range, or period of ranges.
+#--------------------------------------------------------------------------
+def run_map_search_filter(obsrun, param):
+    # start with a blank filter:
+    q_filt = Q()
+
+    if obsrun not in RUN_MAP_FLAT:
+        raise ValueError('observation period not found in RUN_MAP_FLAT')
+
+    for tup in RUN_MAP_FLAT[obsrun]:
+        q_filt |= Q(**{f'{param}__range': tup})
+
+    return q_filt
diff --git a/gracedb/static/css/override-new.css b/gracedb/static/css/override-new.css
index 17348dadc..492e34271 100644
--- a/gracedb/static/css/override-new.css
+++ b/gracedb/static/css/override-new.css
@@ -333,3 +333,21 @@ table.dataTable.no-footer {
 }
 
 /* end dataTable table css */
+
+/* Add dropdown multilevel menu stuff */
+.dropdown-menu li {
+position: relative;
+}
+.dropdown-menu .dropdown-submenu {
+display: none;
+position: absolute;
+left: 100%;
+top: -7px;
+}
+.dropdown-menu .dropdown-submenu-left {
+right: 100%;
+left: auto;
+}
+.dropdown-menu > li:hover > .dropdown-submenu {
+display: block;
+}
diff --git a/gracedb/superevents/views.py b/gracedb/superevents/views.py
index f380ea017..666f5ae0f 100644
--- a/gracedb/superevents/views.py
+++ b/gracedb/superevents/views.py
@@ -29,6 +29,7 @@ from .mixins import ExposeHideMixin, OperatorSignoffMixin, \
     RRTViewMixin
 from .models import Superevent, VOEvent, Log
 from search.constants import RUN_MAP
+from search.utils import run_map_search_filter
 from .utils import get_superevent_by_date_id_or_404, \
     get_superevent_by_sid_or_gwid_or_404
 
@@ -284,10 +285,12 @@ class SupereventPublic(DisplayFarMixin, ListView):
         if self.obsrun not in settings.PUBLIC_PAGE_RUNS:
             raise Http404
 
+        # The base query for exposed production superevents:
         qs = Superevent.objects.filter(is_exposed=True,
-            category=Superevent.SUPEREVENT_CATEGORY_PRODUCTION,
-            t_0__range=RUN_MAP[self.obsrun])
-        return qs
+            category=Superevent.SUPEREVENT_CATEGORY_PRODUCTION)
+         
+        # return the events in the date range:
+        return qs.filter(run_map_search_filter(self.obsrun, 't_0'))
 
 
     # Define significance per run:
@@ -296,7 +299,7 @@ class SupereventPublic(DisplayFarMixin, ListView):
         # https://git.ligo.org/computing/gracedb/server/-/issues/303#note_725082
         # So use Q filters for these:
         significant_filter = Q()
-        if self.obsrun in ['ER15', 'O4']:
+        if self.obsrun in ['ER15', 'ER16', 'O4a', 'O4b', 'O4']:
            significant_filter = Q(labels__name='ADVREQ') | \
                                 Q(labels__name='ADVOK') | \
                                 Q(labels__name='ADVNO')
@@ -314,7 +317,7 @@ class SupereventPublic(DisplayFarMixin, ListView):
     # Note: this value is also used as a trigger to show the significance
     # button and bullet.
     def insignificant_docs(self, run):
-        if run in ['ER15', 'O4']:
+        if run in ['ER15', 'ER16', 'O4a', 'O4b', 'O4']:
             return 'https://emfollow.docs.ligo.org/userguide/content.html#significance'
         else:
             return None
diff --git a/gracedb/templates/navbar_frag.html b/gracedb/templates/navbar_frag.html
index 1e0410f2a..57bc15d9d 100644
--- a/gracedb/templates/navbar_frag.html
+++ b/gracedb/templates/navbar_frag.html
@@ -1,5 +1,23 @@
 {% load static %}
 
+<script src="https://cdnjs.cloudflare.com/ajax/libs/tether/1.4.0/js/tether.min.js" integrity="sha384-DztdAPBWPRXSA/3eYEEUWrWCy7G5KFbe8fFjk5JAIxUYHKkDx6Qin1DkWx51bBrb" crossorigin="anonymous"></script>
+<script>
+$( document ).ready( function () {
+    $( '.navbar a.dropdown-toggle' ).on( 'click', function ( e ) {
+        var $el = $( this );
+        var $parent = $( this ).offsetParent( ".dropdown-menu" );
+        $( this ).parent( "li" ).toggleClass( 'show' );
+
+        if ( !$parent.parent().hasClass( 'navbar-nav' ) ) {
+            $el.next().css( { "top": $el[0].offsetTop, "left": $parent.outerWidth() - 4 } );
+        }
+        $( '.navbar-nav li.show' ).not( $( this ).parents( "li" ) ).removeClass( "show" );
+        return false;
+    } );
+} );
+
+</script>
+
 <div class="fixed-top" style="margin-top: -75px; padding-top: 75px; box-shadow: 0 0 0.2rem rgba(0,0,0,.1), 0 0.2rem 0.4rem rgba(0,0,0,.2);">
   <nav class="navbar navbar-expand-md navbar-gracedb navbar-dark">
     <a class="navbar-brand text-white" href="/">
@@ -23,11 +41,16 @@
   <button type="button" class="btn btn-small shadow-none btn-split dropdown-toggle dropdown-toggle-split" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
     <span class="sr-only">Toggle Dropdown</span>
   </button>
-  <div class="dropdown-menu">
-    <a class="dropdown-item" href="{% url "superevents:public-alerts" "O4" %}">O4</a>
-    <a class="dropdown-item" href="{% url "superevents:public-alerts" "ER15"%}">ER15</a>
-    <a class="dropdown-item" href="{% url "superevents:public-alerts" "O3" %}">O3</a>
-  </div>
+  <ul class="dropdown-menu">
+    <li> <div class="dropdown-item dropdown-toggle"><a href="{% url "superevents:public-alerts" "O4" %}" style="color: black; text-decoration: none;">O4</a></div>
+    <ul class="dropdown-menu dropdown-submenu">
+      <li> <a class="dropdown-item" href="{% url "superevents:public-alerts" "O4b" %}">O4b</a> </li>
+      <li> <a class="dropdown-item" href="{% url "superevents:public-alerts" "O4a" %}">O4a</a> </li>
+    </ul></li>
+    <li> <a class="dropdown-item" href="{% url "superevents:public-alerts" "ER16"%}">ER16</a> </li>
+    <li> <a class="dropdown-item" href="{% url "superevents:public-alerts" "ER15"%}">ER15</a> </li>
+    <li> <a class="dropdown-item" href="{% url "superevents:public-alerts" "O3" %}">O3</a> </li>
+  </ul>
 </div>
       </li>
 
-- 
GitLab