1#!/usr/bin/env python3
  2
  3"""Generic source-ID for PUS based missions.
  4
  5MTG
  6JCS
  7"""
  8
  9import logging
 10from collections import namedtuple
 11from collections import defaultdict
 12from datetime import timedelta
 13from datetime import datetime
 14from enum import Enum
 15from typing import Union
 16from typing import Callable
 17
 18from chart.common.xml import SubElement
 19from chart.plots.sampling import Sampling
 20from chart.common.xml import parsechildstr
 21from chart.sids.exceptions import NoSuchSID
 22from chart.sids import SIDBase
 23from chart.common.decorators import memoized
 24from chart.common.decorators import memoized2
 25from chart.common.xml import XMLElement
 26from chart.project import settings
 27from chart.common.path import Path
 28
 29logger = logging.getLogger()
 30
 31ELEM_SATELLITES = 'satellites'
 32ELEM_SATELLITE = 'satellite'
 33ELEM_NAME = 'name'
 34ELEM_DESCRIPTION = 'description'
 35ELEM_LAUNCH_DATE = 'launch-date'
 36ELEM_DECOMMISION_DATE = 'decommision-date'
 37ELEM_ORBIT_DURATION = 'orbit-duration'
 38ELEM_SIDS = 'sids'
 39ELEM_SID = 'sid'
 40ELEM_SID_NUM = 'sid-num'
 41ELEM_LONG_NAME = 'long-name'
 42ELEM_COLOUR = 'colour'
 43ELEM_VISIBLE = 'visible'
 44ELEM_OPERATIONAL = 'operational'
 45ELEM_GROUP = 'group'
 46ELEM_DB_PATH = 'db-path'
 47ELEM_ORBITER = 'orbiter'
 48ELEM_GOOD_FRAME_SPID = 'good-frame-spid'
 49ELEM_BAD_FRAME_SPID = 'bad-frame-spid'
 50ELEM_UNKNOWN_PACKET_SPID = 'unknown-packet-spid'
 51ELEM_BAD_PACKET_SPID = 'bad-packet-spid'
 52ELEM_IDLE_PACKET_SPID = 'idle-packet-spid'
 53ELEM_IDLE_FRAME_SPID = 'idle-frame-spid'
 54ELEM_TIME_COUPLES_SPID = 'time-couples-spid'
 55ELEM_SPECIAL_PACKETS = 'special-packets'
 56ELEM_DYNAMIC_PACKETS = 'dynamic-udc-spid'
 57
 58
 59ELEM_HIGH_RATE_PACKETS = 'high-rate-packets'
 60ELEM_HR_TIME_LOC = 'high-rate-time-loc'
 61ELEM_HR_SERVICE_IDS = 'high-rate-service'
 62ELEM_HR_SUBSERVICE_IDS = 'high-rate-subservice'
 63
 64ELEM_SCOS_EV_PACKETS = 'scos-events-packets'
 65ELEM_FIXED_SCOS_PACKETS = 'fixed-scos-events-packet'
 66ELEM_VARIABLE_SCOS_PACKETS = 'variable-scos-events-packet'
 67ELEM_OOL_TYPES = 'ool-event-types'
 68ELEM_OOL_HEADER_ID = 'ool-header-id'
 69ELEM_SCC_PACKET_ID = 'scc-packet-id'
 70ELEM_SCC_PACKET_DEF_ID = 'scc-packet-def-id'
 71
 72ELEM_PACKET = 'packet'
 73ELEM_ID = 'id'
 74
 75ELEM_SAMPLING_OPTIONS = 'sampling-options'
 76ELEM_SAMPLING = 'sampling'
 77ELEM_AUTO_SAMPLING_OPTIONS = 'auto-sampling-options'
 78
 79# Select the type of dropdown box(s) used to select a SID in the plot and event viewers
 80ELEM_SELECTOR = 'selector'
 81
 82
 83class Satellite:
 84    def __init__(self,
 85                 name,
 86                 description=None,
 87                 launch_date=None,
 88                 decommision_date=None,
 89                 orbit_duration=None,
 90                 ground_test=None):
 91        self.name = name
 92        self.description = description
 93        self.launch_date = launch_date
 94        self.decommision_date = decommision_date
 95        self.orbit_duration = orbit_duration
 96        self.ground_test = ground_test
 97        # the below is for backward-compatibility with SID_EPS implementation
 98        # derived logic, as in line 1195 of chart/chart/plotviewer/retrieval.py:
 99        self.ground_test = orbit_duration is not None 
100
101@memoized
102def all_satellites(satellites_elem):
103    """Parse <satellites> from sattings.SOURCES and return a list of Satellites."""
104    result = []
105    for sat_elem in satellites_elem.findall(ELEM_SATELLITE):
106        result.append(Satellite(
107            name=sat_elem.parse_str(ELEM_NAME),
108            description=sat_elem.parse_str(ELEM_DESCRIPTION, None),
109            launch_date=sat_elem.parse_datetime(ELEM_LAUNCH_DATE, None),
110            decommision_date=sat_elem.parse_datetime(ELEM_DECOMMISION_DATE, None),
111            orbit_duration=sat_elem.parse_timedelta(ELEM_ORBIT_DURATION, None)))
112
113    return result
114
115@memoized
116def all_sids():
117    """Parse <satellites> from settings.SOURCES and return a list of SIDs."""
118    SID_PUS.construction_started = True
119    # print('all_sids')
120    root_elem = XMLElement(filename=settings.SOURCES)
121    sids_elem = root_elem.find(ELEM_SIDS)
122    satellites_elem = root_elem.find(ELEM_SATELLITES)
123    result = []
124    for sid_elem in sids_elem.findall(ELEM_SID):
125        name = sid_elem.parse_str(ELEM_NAME)
126        satellite_s = sid_elem.parse_str(ELEM_SATELLITE)
127        satellite = None
128        for s in all_satellites(satellites_elem):
129            if s.name == satellite_s:
130                satellite = s
131
132        if satellite is None:
133            raise ValueError('SID {n} has no satellite found'.format(n=name))
134
135        result.append(SID_PUS(
136            sid_num=sid_elem.parse_int(ELEM_SID_NUM),
137            name=name,
138            long_name=sid_elem.parse_str(ELEM_LONG_NAME, None),
139            description=sid_elem.parse_str(ELEM_DESCRIPTION, None),
140            satellite=satellite,
141            colour=sid_elem.parse_str(ELEM_COLOUR, None),
142            operational=sid_elem.parse_bool(ELEM_OPERATIONAL, True),
143            visible=sid_elem.parse_bool(ELEM_VISIBLE, True),
144            group=sid_elem.parse_str(ELEM_GROUP, None),
145            db_path=sid_elem.parse_str(ELEM_DB_PATH, None) if sid_elem.parse_str(ELEM_DB_PATH, None) else name,
146            orbit=sid_elem.parse_str(ELEM_ORBITER, None)))
147
148    # now fix the orbit members to point to an instance of the orbit<>timestamp converter class
149    for sid in result:
150        # check for sids that use another sid's orbiter
151        for s in result:
152            if sid.orbit == s.name:
153                sid.orbit = s.orbit
154
155        orbiter = s.orbit
156
157        # otherwise the SID should use it's named class as the orbit determiner
158        # i'm being lazy import the class properly here
159        if sid.orbit == 'chart.common.orbits_geo_events.OrbitDeterminer':
160            from chart.products.fdf.orbits_geo_events import OrbitDeterminer
161            sid.orbit = OrbitDeterminer(sid)
162        if sid.orbit == 'chart.common.orbits_epssg_events.OrbitDeterminer':
163            from chart.products.fdf.orbits_epssg_events import OrbitDeterminer
164            sid.orbit = OrbitDeterminer(sid)
165        if sid.orbit == 'chart.products.fdf.orbit_s3.Orbit':
166            from chart.products.fdf.orbit_s3 import Orbit
167            sid.orbit = Orbit(sid)
168
169    # read special packets definitions
170    SID_PUS.special_packets = SpecialPackets(root_elem.find(ELEM_SPECIAL_PACKETS))
171
172    # read special HR packets definitions
173    special_packets_elem = root_elem.find(ELEM_HIGH_RATE_PACKETS)
174    if special_packets_elem is not None:
175        SID_PUS.special_HR_packets = HR_SpecialPackets(special_packets_elem)
176
177    else:
178        # logger.warn('No special HR packets found')
179        SID_PUS.special_HR_packets = None
180
181    SCOS_EV_packets_elem = root_elem.find(ELEM_SCOS_EV_PACKETS)
182    if SCOS_EV_packets_elem is not None:
183        SID_PUS.SCOS_EV_packets = SCOS_EV_Packets(SCOS_EV_packets_elem)
184
185    else:
186        SID_PUS.SCOS_EV_packets = None
187
188    SID_PUS.construction_allowed = False
189
190    # Used to represent a report which is has no source, usually a system
191    # job like purge or a system digest report
192    # uses wildcard to switch off the validity check
193    # SID_PUS.SIDLESS = SID_PUS(name='SYS', sidless=True)
194    result.append(SID_PUS(sidless=True, visible=False))
195
196    from chart.plots.sampling import Sampling
197    SID_PUS.sampling_options = []
198    samplings_elem = root_elem.find(ELEM_SAMPLING_OPTIONS)
199    if samplings_elem is None:
200        logger.warn('Sources XML file has no samplings element')
201
202    else:
203        for s in samplings_elem.parse_strs(ELEM_SAMPLING):
204            SID_PUS.sampling_options.append(Sampling[s])
205
206    SID_PUS.global_auto_sampling_options = []
207    auto_samplings_elem = root_elem.find(ELEM_AUTO_SAMPLING_OPTIONS)
208    for s in auto_samplings_elem.parse_strs(ELEM_SAMPLING):
209        SID_PUS.global_auto_sampling_options.append(Sampling[s])
210
211    SID_PUS.selector = root_elem.parse_str(ELEM_SELECTOR)
212
213    SID_PUS.default_sid = {'name': result[0].name}
214
215    return result
216
217class SID_USER:
218    sid=None
219
220class SID_PUSMeta(type):
221    """Cached constructor.
222
223    During application startup, SID_PUS.construction_allowed is set and new instances
224    of SID_PUS can be freely created and added to the SID_PUS.all_sids list.
225
226    Later, `construction_allowed` is cleared and no new SIDs can be created, only retrieved
227    from the existing list.
228    """
229
230    def __call__(cls, name=None, sid_num=None, sidless=False, *args, **kwargs):
231        # print('metasid',cls,'name',name,'sid_num',sid_num,'args',args,'kwargs',kwargs)
232
233        if not SID_PUS.construction_started:
234            all_sids()
235
236        if SID_PUS.construction_allowed or sidless:
237            # allow sids from file and sidless to be actualy constructed
238            res = super(SID_PUSMeta, cls).__call__(name=name, sid_num=sid_num, *args, **kwargs)
239            return res
240
241        # SID_PUS.construction_allowed = False
242        for sid in all_sids():
243            # print('testing ',sid_num,name,' against ',sid)
244            if sid.sid_num == sid_num or (
245                    sid.name is not None and name is not None and sid.name.upper() == name.upper()) or (
246                    sid.db_path is not None and name is not None and sid.db_path.upper() == name.upper()
247                    ):
248                SID_USER.sid = sid
249                return sid
250
251        # print('none found')
252
253
254class SpecialPackets:
255    """Mission-specific PUS RAPIDFILE SPID values that have special meanings in decoding code."""
256
257    def __init__(self, elem):
258        self.good_frame_spid = elem.parse_int(ELEM_GOOD_FRAME_SPID)
259        self.bad_frame_spid = elem.parse_int(ELEM_BAD_FRAME_SPID)
260        self.unknown_packet_spid = elem.parse_int(ELEM_UNKNOWN_PACKET_SPID)
261        self.bad_packet_spid = elem.parse_int(ELEM_BAD_PACKET_SPID)
262        self.idle_packet_spid = elem.parse_int(ELEM_IDLE_PACKET_SPID)
263        self.idle_frame_spid = elem.parse_int(ELEM_IDLE_FRAME_SPID)
264        self.time_couples_spid = elem.parse_int(ELEM_TIME_COUPLES_SPID, None)
265        self.dynamic_packet_spid = elem.parse_int(ELEM_DYNAMIC_PACKETS, None)
266
267
268class HR_SpecialPackets:
269    """Mission-specific High Rate Packets which use COLLECTION_DATA reference time,
270    contained within the packet, rather than packet sensing time.
271    """
272
273    def __init__(self, elem):
274        self.hr_service = []
275        hr_service_elem = elem.find(ELEM_HR_SERVICE_IDS)
276        for id_elem in hr_service_elem.findall(ELEM_PACKET):
277            self.hr_service.append(id_elem.parse_int(ELEM_ID))
278
279        self.hr_subservice = []
280        hr_subservice_elem = elem.find(ELEM_HR_SUBSERVICE_IDS)
281        for id_elem in hr_subservice_elem.findall(ELEM_PACKET):
282            self.hr_subservice.append(id_elem.parse_int(ELEM_ID))
283
284        self.hr_time_location = elem.parse_int(ELEM_HR_TIME_LOC, None)
285
286
287class SpecialPacketDefinition:
288    """Representation of a SCOS special packet.
289
290    For some reason we store their definitionss in sources.xml instead of in the SRDB directory."""
291    def __init__(self, elem):
292        self.spid = elem.parse_int(ELEM_ID)
293        self.name = elem.parse_str(ELEM_NAME)
294        self.description = elem.parse_str(ELEM_DESCRIPTION, None)
295
296
297class SCOS_EV_Packets:
298    """Mission-specific SCOS Event Packets which defined manually, as are not defined in MIB."""
299
300    def __init__(self, elem):
301        self.fixed_scos_events = []
302        for packet_elem in elem.find(ELEM_FIXED_SCOS_PACKETS).findall(ELEM_PACKET):
303            self.fixed_scos_events.append(SpecialPacketDefinition(packet_elem))
304
305        self.variable_scos_events = []
306        for packet_elem in elem.find(ELEM_VARIABLE_SCOS_PACKETS).findall(ELEM_PACKET):
307            self.variable_scos_events.append(SpecialPacketDefinition(packet_elem))
308
309        self.ool_event_types = []
310        for packet_elem in elem.find(ELEM_OOL_TYPES).findall(ELEM_PACKET):
311            self.ool_event_types.append(SpecialPacketDefinition(packet_elem))
312
313        self.ool_hdr_id = elem.parse_int(ELEM_OOL_HEADER_ID)
314        self.scc_packet_id = elem.parse_int(ELEM_SCC_PACKET_ID)
315        self.scc_packet_def_id = elem.parse_int(ELEM_SCC_PACKET_DEF_ID)
316
317    def is_variable_spid(self, spid):
318        """Test if `spid` is the SPID of a variable packet."""
319        return spid in (p.spid for p in self.variable_scos_events)
320
321    def is_fixed_spid(self, spid):
322        """Test if `spid` is the SPID of a fixed packet."""
323        return spid in (p.spid for p in self.fixed_scos_events)
324
325
326class SID_PUS(SIDBase, metaclass=SID_PUSMeta):  # , metaclass=sid_mtg_meta):
327    """MTG Source ID object. To become general PUS or non-PUS sid_num based source.
328
329    For any SID identified by a simple name i.e. only SID_MSG is not suitable for folding
330    into this object."""
331
332    selector = None  # 'mtg'
333    default_sid = None  # {'sid': 'MTGi1'}
334    # all_sids = []
335
336    # For some crazy reason when you try to print CCSDS packets from a binary file
337    # the all() function doesn't get called so we put this in as a hack to get chartmtg packet
338    # display working. This is quite bad and very likely to cause bad results in chartjcs
339    special_HR_packets = None
340
341    # If exists and not null, this is the label for the aux events check box
342    # ("ECL" for eclipse FDF events in CHART-EPS, "OOL" for out out limits events in
343    # PUS projects)
344    plot_aux_events_label = 'OOL'
345
346    # these objects are only created during startup when settings.SOURCES is parsed. Afterward
347    # clients can only create SIDs via the metaclass cached lookup
348    construction_allowed = True
349    construction_started = False
350
351    global_auto_sampling_options = None
352    sampling_options = None
353
354    special_packets = None
355    # class SpecialPackets(Enum):
356        # GOOD_FRAME_SPID = 90010
357        # BAD_FRAME_SPID = 90020
358        # UNKNOWN_PACKET_SPID = 90030
359        # BAD_PACKET_SPID = 90040
360        # IDLE_PACKET_SPID = 90050
361        # IDLE_FRAME_SPID = 90060
362
363    # After reading the SPID from a RapidFile block, we determine it's a CADU block
364    # if it matches any of these values
365    # CADU_RECORDS = [
366        # SpecialPackets.GOOD_FRAME_SPID,
367        # SpecialPackets.BAD_FRAME_SPID,
368        # SpecialPackets.UNKNOWN_PACKET_SPID,
369        # SpecialPackets.BAD_PACKET_SPID,
370        # SpecialPackets.IDLE_PACKET_SPID,
371        # SpecialPackets.IDLE_FRAME_SPID,
372    # ]
373
374    # With NIS header, i.e. Frames
375    # WITH_NIS_HEADER = [
376    #    SpecialPackets.GOOD_FRAME_SPID,
377    #    SpecialPackets.BAD_FRAME_SPID,
378    #    SpecialPackets.IDLE_FRAME_SPID,
379    # ]
380
381    # Without NIS Header, i.e. Packets
382    # NO_NIS_HEADER = [
383    #    SpecialPackets.UNKNOWN_PACKET_SPID,
384    #    SpecialPackets.BAD_PACKET_SPID,
385    #    SpecialPackets.IDLE_PACKET_SPID,
386    # ]
387
388
389    def __init__(self,
390                 sid_num: int=None,
391                 name: str=None,
392                 long_name: str=None,
393                 description: str=None,
394                 satellite: Satellite=None,
395                 colour: str=None,
396                 operational: bool=True,
397                 visible: bool=True,
398                 default: bool=False,
399                 orbit: Union[str, Callable]=None,
400                 group=None,
401                 db_path: str=None):
402        """Args:
403
404            `sid_num`: Unique SID_NUM entry for database storage
405            `name`: Normal short unique name for this source
406            `long_name`: Longer and more descriptive name for example for graph titles
407            `description`: Very long name. Not currently used.
408            `satellite`: The Satellite object this SID refers to. Multiple SIDs can refer
409                to the same satellite.
410            `colour`: Suggested rendering colour for this SID used in the report viewer
411            `operational`: The SID is currently potentially in-flight and generating data.
412                Used to determine if regular algorithms and reports should be generated.
413            `visible`: Allow this SID to be hidden from the user interface, for internal testing
414                SIDs
415            `default`: True if this is the normal SID the user interface selects by default
416            `orbiter`: A callable (fn or class) used to map orbit numbers to times
417            `group`: Allow related SIDs to be grouped together in a user interface
418            `db_path`: contains spacecraft packet definations path i.e SGA or SGB
419        """
420        # print('sid init ' + str(name))
421        self.sid_num = sid_num
422        self.name = name
423        self.long_name = long_name
424        self.description = description
425        self.satellite = satellite
426        self.colour = colour
427        self.operational = operational
428        self.visible = visible
429        self.default = default
430        self.orbit = orbit
431        self.group = group
432        self.db_path = db_path
433
434    def __str__(self):
435        """Used for plotviewer titles and report viewer calendar."""
436        if self.name is None:
437            return 'Non satellite specific'
438
439        else:
440            return self.name
441
442    def __repr__(self):
443        return self.__str__()
444
445    def get_db_path():
446        logging.info("SID DB_path:{p}".format(p=SID_USER.sid.db_path))
447        return SID_USER.sid.db_path
448    @staticmethod
449    def ddl_id_fields():
450        """SID fields implicitly added to the start of all TS tables."""
451        from chart.db.model.table import FieldInfo
452        return [
453            FieldInfo(name='SID_NUM',
454                      description='Source ID number',
455                      datatype=int,
456                      length=8),
457        ]
458
459
460    @staticmethod
461    def ddl_cal_clause(sids):
462        """Return an SQL clauses suitable for a CASE statement which will recognise
463        any of `sids`."""
464        if len(sids) == 1:
465            if sids[0].name == '*':
466                return '1=1'
467
468            else:
469                return 'SID_NUM={n}'.format(n=sids[0].sid_num)
470
471        else:
472            return 'SID_NUM in ({members})'.format(
473                members=','.join('{n}'.format(n=s.sid_num) for s in sids))
474
475
476    @staticmethod
477    def all(operational=None, visible=None, include_sys=False):
478        """Yield all satellites matching parameters."""
479        for s in all_sids():
480            if (operational is None or operational == s.operational) and\
481               (visible is None or visible == s.visible) and\
482               (include_sys or s.name is not None):
483                yield s
484
485    @staticmethod
486    def sql_sys_select(table_name):  # (unused arg) pylint: disable=W0613
487        """Craft SQL fragment to retrieve the fields of a SID from a system table.
488
489        SID is constructed later from_sys_select()."""
490
491        return ['SID_NUM']
492
493    def to_xml(self, elem):
494        """Annotate parent `elem` with ourselves, creating child nodes."""
495        if self.name is not None:
496            SubElement(elem, ELEM_SID).text = self.name
497
498    @staticmethod
499    def from_xml(elem, wildcard=False):
500        """Create a SID from childs of `elem`, or None if not found.."""
501        name = parsechildstr(elem, ELEM_SID, None, expand_settings=True)
502        if name is None:
503            return None
504
505        return SID_PUS(name=name)
506
507    @staticmethod
508    def django_all():
509        """Return SID information sent to the web client."""
510        return [{'initial_year': s.satellite.launch_date.year if s.satellite is not None and
511                 s.satellite.launch_date is not None else None,
512                'time_offset': 0, # self.satellite.time_offset.total_seconds()
513                        # if self.satellite is not None and self.satellite.time_offset is not None
514                        # else 0,
515                'title': s.name,  # for plot viewer title
516                 'menu_value': {'sid': s.name},
517                } for s in SID_PUS.all(operational=None)]
518
519    @staticmethod
520    def ts_indexes():
521        """Return list of TS AP table field names which should be indexed."""
522        return ['SID_NUM', ]
523
524    # @staticmethod
525    # def ts_fields():
526        # """Return a list of field names needed to store our instances in timeseries tables."""
527        # return ('SID_NUM', )
528
529    # def ts_values(self):
530        # """Return a list of values to store this specific instance in a timeseries table."""
531        # return [self.sid_num]
532
533    insert_fields = ['SID_NUM']
534
535    def bind_insert(self):
536        return [self.sid_num]
537
538    @staticmethod
539    def from_django_request(request):
540        """Extract SID(s) from `query`, either a Django request GET object
541        or a dictionary or extracted pairs from a plot URL datapoint (dict)."""
542        sid = request.get('sid')
543        if sid is None:
544            return None
545
546        if sid == 'SYS':
547            result = SID_PUS()
548            # return SID_PUS.SIDLESS
549
550        result = SID_PUS(name=sid)
551        assert result.sid_num is not None
552        return result
553        # a=result.name
554        # b=result.sid_num
555        # print(result)
556        # 1/0
557        # return SID_PUS(name=sid)
558
559    @staticmethod
560    def from_django_params(params, remove=False):
561        """Extract a SID from `query` (URL parameter string)."""
562        if 'sid' in params:
563            res = SID_PUS(params['sid'])
564            if remove:
565                del params['sid']
566
567            return res
568
569        else:
570            return None
571
572    def as_url_query(self, base=None):
573        """Return a dictionary representation of ourselves suitable for passing into
574        the urllib.urlencode() function to create/extend a URL query fragment."""
575        if base is None:
576            if self.name is None:
577                return {'sid': 'SYS'}
578
579            else:
580                return {'sid': self.name}
581
582        else:
583            if self.name is None:
584                base['sid'] = 'SYS'
585
586            else:
587                base['sid'] = self.name
588
589            return base
590
591    @staticmethod
592    def from_string(st):
593        for sid in SID_PUS.all():
594            if sid.name is not None and st.lower() == sid.name.lower():
595                return sid
596
597        raise ValueError('Unknown source {s}'.format(s=st))
598
599    def expand(self):
600        """Wildcard support, probably not needed but this still gets called."""
601        return [self]
602
603    @staticmethod
604    def get_reportname_part(sid):
605        """Return the SID fragment of a report name e.g. the M02 in
606        SVM_DHSA_REPORT_M02_20130901.zip.
607        Also used as subdirectory name in the reports archive.
608        Also used as part of the statefiles name.
609        """
610        if sid is None or sid.name is None:
611            return 'SYS'
612
613        else:
614            return sid.name
615
616    # subsampling options to be considered when auto-selecting best stats subsampling
617    # should be sorted from longest to shortest
618    @memoized2
619    def auto_sampling_options(self):
620        """Return the list of stats subsampling options available for this sid.
621        """
622        return SID_PUS.global_auto_sampling_options
623
624    def sql_where(self, table_name=None, match_on_none=False, prefix=None):
625        """Return an SQL clause that will test for ts rows containing us.
626
627        If `table_name` is set it will be the name (not Tableinfo) of the table to be written
628        to, which may be a system table.
629
630        If `match_on_none` is set then we will match table rows contains null values in the SID
631        column.
632
633        `prefix` can be used to prefix column names for queries that join tables.
634
635        Avoids bind variables."""
636        # if self.sid_num is None:
637            # we are the wildcard SID that matches anything
638            # return '1=1'
639
640        if match_on_none:
641            return '(SID_NUM is null or SID_NUM={sidnum})'.format(sidnum=self.sid_num)
642
643        elif self.sid_num is None:
644            raise ValueError('Cannot query for null SID')
645
646        return 'SID_NUM={sidnum}'.format(sidnum=self.sid_num)
647
648    @staticmethod
649    def from_reportname_part(fragment):
650        """Decode a report name fragment."""
651        if fragment == 'SYS':  # for system reports like digest
652            return SID_PUS()
653
654        else:
655            return SID_PUS(fragment)
656
657    @staticmethod
658    def all_report_sids():
659        """Scan REPORTs table and return a dictionary of report name against a set of
660        SIDs for which we have reports of that activity."""
661        # only used by reportviewer. can be removed
662        from chart.db.connection import db_connect
663        res = defaultdict(list)
664        db_conn = db_connect('REPORTS')
665        bindvars = {}
666        project_clause = ''
667        if settings.DATABASE_PROJECT_ID is not None:
668            project_clause = 'WHERE project=:project '
669            bindvars['project'] = settings.DATABASE_PROJECT_ID
670
671        for activity, sid_num in db_conn.query((
672                'SELECT DISTINCT activity, sid_num '
673                'FROM reports {project_clause}'
674                'ORDER BY sid_num').format(project_clause=project_clause), **bindvars):
675            res[activity].append(SID_PUS(sid_num=sid_num) if sid_num is not None else SID_PUS())
676
677        return res
678
679    @staticmethod
680    def ddl_sys_fields(table_name=None):  # (unused arg) pylint: disable=W0613
681        """Events, jobs, reports, subscriptions tables using the {{sid}} expansion."""
682        from chart.db.model.table import FieldInfo
683        return [
684            FieldInfo(name='SID_NUM',
685                      datatype=int,
686                      length=4,
687                      allow_null=True,
688                      description='Spacecraft ID'),
689            ]
690
691    def cal_dirname(self):
692        """Compute a subdirectory to write named calibration files to."""
693        return self.short_name
694
695    def cal_name(self):
696        """Compute a suitable plSQL function name suffix."""
697        return self.satellite.name
698
699    @property
700    def short_name(self):
701        return self.name
702
703    @staticmethod
704    def sql_sys_where(table_name, sid):  # (unused arg) pylint: disable=W0613
705        """Return an SQL clause which will test for us in the JOBS or REPORTS or EVENTS table.
706        If `sid` is None then don't apply any filtering."""
707        # should be removed and reported with all bind variables
708        if sid is None:
709            return '1=1'
710
711        else:
712            return "SID_NUM={sidnum}".format(sidnum=sid.sid_num)
713
714    @staticmethod
715    def sql_sys_where_bind(table_name):  # (unused arg) pylint: disable=W0613
716        """SQL fragment to use in WHERE clauses filtering for us, using bind variables."""
717        # Should be renamed to something better like sys_where_bind()
718        # and used exclusively with sql_sys_where() completely avoided due to potential
719        # security issue
720        return 'SID_NUM=:sid_num'
721
722    def bind_sys_where(self, table_name):  # (unused arg) pylint: disable=W0613
723        """Convert ourselves into a bindvars dict for use with the sql emitted by
724        sql_sys_where_bind()."""
725        return {'sid_num': self.sid_num}
726
727    def db_dir(self, subdirectory:Path) -> Path:
728        """Return our configuration directory for `subdirectory`."""
729        # This is the bit that should be self.satellite.db_path instead so multiple SIDs
730        # can refer to the same satellite configuration
731        return settings.SID_SPECIFIC_DIR.joinpath(self.db_path, subdirectory)
732
733    @staticmethod
734    def sql_sys_insert(table_name):
735        """Return 2 SQL fragments for an insert statement:
736        - Fields
737        - Bind variables
738
739        E.g.:
740                sid_vars, sid_binds = SID.sql_sys_insert('JOBS')
741        insert_job.ins_cur = db_conn.prepared_cursor(
742            'INSERT INTO JOBS (CATEGORY, ACTIVITY, FILENAME, DIRNAME, {sidfield}ORBIT, '
743            'SENSING_START, SENSING_STOP, TABLENAME, EARLIEST_EXECUTION_TIME '
744            'VALUES '
745            '(:category, :activity, :filename, :dirname, {sidbind}:orbit, :sensing_start, '
746            ':sensing_stop, :tablename, earliest_execution_time'.format(
747                sidfield=sid_fields, sidbind=sid_binds))
748        .
749        """
750        return (',SID_NUM', ',:sidnum')
751
752    @staticmethod
753    def sql_sys_update(table_name):  # (unused arg) pylint:disable=W0613
754        """SQL fragment, using bind variables, to update the SID part of a system table."""
755        return ',sid_num=:sidnum'
756
757    @staticmethod
758    def bind_sys_insert(table_name, sid):
759        """Convert ourselves into a dict to be inserted into an insert statement variable
760        list.
761        usage:
762    db_conn.query('INSERT INTO PRODUCTS (activity, filename, result{sidfields}, sensing_start) '
763             'VALUES (:activity, :filename, :result{sidbinds}, :sensing_start)'.format(
764                 sidfields=sid_fields, sidbinds=sid_binds),
765                  activity=activity,
766                  filename=filename,
767                  result=result,
768                  sensing_start=sensing_start,
769                  **SID.bind_sys_insert('PRODUCTS', sid))
770        .
771        """
772        if sid is None:
773            return {'sidnum': None}
774
775        else:
776            return {'sidnum': sid.sid_num}
777
778    @staticmethod
779    def from_sys_select(table_name, args):  # (unused arg) pylint: disable=W0613
780        """Construct a SID from the fields requested by sql_sys_select()."""
781        if args[0] is None:
782            return None
783
784        else:
785            return SID_PUS(sid_num=args[0])
786
787    @staticmethod
788    def report_sids(activity):
789        """Only used by reportviewer. can be removed."""
790        from chart.db.connection import db_connect
791        db_conn = db_connect('REPORTS')
792        res = []
793        for activity, sid_num in db_conn.query(
794                'SELECT DISTINCT activity, sid_num FROM reports WHERE activity=:reportname',
795                reportname=activity.name):
796            res.append(SID_PUS(sid_num=sid_num))
797
798        return res
799
800    @memoized2
801    def all_tables(self):
802        """Yield all timeseries tables."""
803        from chart.db.model.table import TableInfo
804        return list(TableInfo.all())