Skip to content
Draft
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
320 changes: 320 additions & 0 deletions doc/telemetry-hardening-report.md

Large diffs are not rendered by default.

107 changes: 93 additions & 14 deletions onair/data_handling/parser_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
# See "NOSA GSC-19165-1 OnAIR.pdf"

from .tlm_json_parser import parseTlmConfJson, str2lst
from onair.src.util.print_io import print_msg
import datetime
import math


def extract_meta_data_handle_ss_breakdown(meta_data_file, ss_breakdown):
Expand Down Expand Up @@ -47,30 +49,107 @@ def extract_meta_data(meta_data_file):


def floatify_input(_input, remove_str=False):
"""Convert each item of ``_input`` to a finite float, preserving length.

Parsing policy (hardened):

- Values that ``float()`` parses to a FINITE float are appended as-is
(finite floats pass through unchanged).
- Values that ``convert_str_to_timestamp`` parses (a ``%Y-%j-%H:%M:%S.%f``
telemetry timestamp, or a strict ``MM:SS`` pair with minute and second
each in 0..59) are appended as their epoch float.
- Everything else -- ``"-"``, ``None``, junk text, and non-finite
spellings like ``"nan"``/``"inf"``/``"Infinity"`` (whose ``float()``
result is not finite) -- is either dropped entirely when ``remove_str``
is True, or substituted with ``math.nan`` plus a WARNING via
``print_msg`` when ``remove_str`` is False. A ``0.0`` fallback is
never used: masking junk as a real measurement value hides telemetry
faults.
"""
floatified = []
for i in _input:
try:
x = float(i)
floatified.append(x)
except ValueError:
try:
x = convert_str_to_timestamp(i)
if math.isfinite(x):
floatified.append(x)
except:
if remove_str == False:
floatified.append(0.0)
else:
continue
continue
except (ValueError, TypeError):
pass
# non-finite float() results (nan/inf strings and floats) fall through
# to the timestamp attempt, which rejects them -> junk policy below
try:
x = convert_str_to_timestamp(i)
floatified.append(x)
continue
except Exception:
pass
if remove_str:
continue
print_msg(
f"floatify_input: could not parse {i!r} to a finite float; substituting math.nan",
["WARNING"],
)
floatified.append(math.nan)
return floatified


def convert_str_to_timestamp(time_str):
"""Convert a telemetry time string to an epoch float.

Contract: ``time_str`` must be a ``str``; any other type raises
``ValueError`` immediately at the function boundary.

Accepted formats:

- ``%Y-%j-%H:%M:%S.%f`` (e.g. ``"2020-001-12:34:56.789"``), parsed with
``datetime.strptime``; or
- a strict ``MM:SS`` pair (e.g. ``"12:34"``) with exactly two colon
separated parts, both integer-parseable via ``int()``, minute and
second each in 0..59 (single-digit components such as ``"1:2"`` are
accepted, matching ``int()`` semantics). The date is fixed to 1am
on Jan 1st, 2000.

Anything else -- non-strings (``None``, numbers, objects), ``"12:34:56"``,
``"99:99"``, ``"-"``, empty strings -- raises ``ValueError`` instead of
silently truncating extra parts or leaking ``IndexError`` /
``AttributeError``.

Only ``ValueError`` from ``strptime`` falls through to the ``MM:SS``
fallback; any other unexpected error propagates loudly instead of
being masked as a format retry.

Note: the ``MM:SS`` branch builds a naive ``datetime`` and calls
``.timestamp()``, which interprets it in the host's LOCAL timezone.
This is the pre-existing upstream semantics (identical to the original
implementation) and is intentionally preserved here: values are
consistent within any single machine, so residuals and relative
comparisons are unaffected.
"""
if not isinstance(time_str, str):
raise ValueError(
f"convert_str_to_timestamp: expected a time string, got {type(time_str).__name__}: {time_str!r}"
)
try:
t = datetime.datetime.strptime(time_str, "%Y-%j-%H:%M:%S.%f")
return t.timestamp()
except:
min_sec = time_str.split(":")
# Use 1 am on Jan 1st, 2000 as the date if only minutes and seconds are specified
t = datetime.datetime(2000, 1, 1, 1, int(min_sec[0]), int(min_sec[1]), 0)
return t.timestamp()
except ValueError:
pass
min_sec = time_str.split(":")
if len(min_sec) != 2:
raise ValueError(
f"convert_str_to_timestamp: expected MM:SS, got {time_str!r}"
)
try:
minute = int(min_sec[0])
second = int(min_sec[1])
except ValueError:
raise ValueError(
f"convert_str_to_timestamp: expected MM:SS with integer minute and second, got {time_str!r}"
)
if not (0 <= minute <= 59 and 0 <= second <= 59):
raise ValueError(
f"convert_str_to_timestamp: minute and second must be in 0..59, got {time_str!r}"
)
# Use 1 am on Jan 1st, 2000 as the date if only minutes and seconds are specified
t = datetime.datetime(2000, 1, 1, 1, minute, second, 0)
return t.timestamp()
20 changes: 20 additions & 0 deletions plugins/kalman/kalman_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
The module is part of NASA's On-Board Artificial Intelligence Research (OnAIR) Platform.
"""

import math

import simdkalman
import numpy as np
from onair.data_handling.parser_util import floatify_input
Expand Down Expand Up @@ -94,6 +96,16 @@ def update(self, low_level_data=None, _high_level_data=None):
corresponding frames, and maintains the window size by removing older
data points if necessary.

Hardened ingestion policy:

- ``low_level_data`` of ``None`` triggers a FAIL message and no store.
- Frames whose length does not match the header length are REJECTED
with a WARNING (both lengths reported) and no data is stored; this
prevents frame/window desynchronization.
- After floatification, non-finite values (``math.nan`` substituted by
``floatify_input`` for ``"-"``/junk, or genuine NaN/Inf readings)
are skipped, so windows only ever contain finite floats.

Parameters
----------
low_level_data : list
Expand All @@ -107,10 +119,18 @@ def update(self, low_level_data=None, _high_level_data=None):
print_msg(
"Kalman plugin requires low_level_data but received None.", ["FAIL"]
)
elif len(low_level_data) != len(self.headers):
print_msg(
f"Kalman plugin update: frame length {len(low_level_data)} does not match header length {len(self.headers)}; frame rejected.",
["WARNING"],
)
return
else:
frame = floatify_input(low_level_data)

for i, value in enumerate(frame):
if not math.isfinite(value):
continue # windows hold only finite floats
self.frames[i].append(value)
if len(self.frames[i]) > self.window_size:
self.frames[i].pop(0)
Expand Down
Loading