ssscoring.appcommon

Common functions, classes, and objects to all Streamlit apps in the SSScoring package.

  1# See: https://github.com/pr3d4t0r/SSScoring/blob/master/LICENSE.txt
  2
  3"""
  4Common functions, classes, and objects to all Streamlit apps in the SSScoring
  5package.
  6"""
  7
  8from importlib_resources import files
  9from io import StringIO
 10
 11import base64
 12
 13from ssscoring import __VERSION__
 14from ssscoring.calc import isValidMaximumAltitude
 15from ssscoring.calc import isValidMinimumAltitude
 16from ssscoring.constants import DEFAULT_PLOT_INCREMENT
 17from ssscoring.constants import DEFAULT_PLOT_MAX_V_SCALE
 18from ssscoring.constants import DZ_DIRECTORY
 19from ssscoring.constants import FLYSIGHT_FILE_ENCODING
 20from ssscoring.constants import M_2_FT
 21from ssscoring.constants import RESOURCES
 22from ssscoring.constants import SSSCORE_DOWNLOAD_PNG
 23from ssscoring.constants import SSSCORE_INSTRUCTIONS_MD
 24from ssscoring.datatypes import JumpResults
 25from ssscoring.datatypes import JumpStatus
 26from ssscoring.errors import SSScoringError
 27from ssscoring.notebook import SPEED_COLORS
 28from ssscoring.notebook import graphAcceleration
 29from ssscoring.notebook import graphAltitude
 30from ssscoring.notebook import graphAngle
 31from ssscoring.notebook import graphJumpResult
 32from ssscoring.notebook import initializeExtraYRanges
 33from ssscoring.notebook import initializePlot
 34# TODO: Remove this if present after 20260531
 35# from streamlit_bokeh import streamlit_bokeh
 36
 37import os
 38
 39# TODO: Remove this if present after 20260531
 40# import bokeh.models as bm
 41import pandas as pd
 42import pydeck as pdk
 43import streamlit as st
 44
 45
 46# *** constants ***
 47
 48DEFAULT_DATA_LAKE = './data'
 49"""
 50Default data lake directory when reading files from the local file system.
 51"""
 52
 53STREAMLIT_SIG_KEY = 'HOSTNAME'
 54"""
 55Environment key used by the Streamlig.app environment when running an
 56application.
 57"""
 58
 59STREAMLIT_SIG_VALUE = 'streamlit'
 60"""
 61Expected value associate with the environment variable `STREAMLIT_SIG_KEY` when
 62running in a Streamlit.app environment.
 63"""
 64
 65
 66# *** implementation ***
 67
 68def isStreamlitHostedApp() -> bool:
 69    """
 70    Detect if the hosting environment is a native Python system or a Streamlit
 71    app environment hosted by streamlit.io or Snowflake.
 72
 73    Returns
 74    -------
 75    `True` if the app is running in the Streamlit app or Snoflake app
 76    environment, otherwise `False`.
 77    """
 78    keys = tuple(os.environ.keys())
 79    if STREAMLIT_SIG_KEY not in keys:
 80        return False
 81    if os.environ[STREAMLIT_SIG_KEY] == STREAMLIT_SIG_VALUE:
 82        return True
 83    return False
 84
 85
 86@st.cache_data
 87def fetchResource(resourceName: str) -> StringIO:
 88    """
 89    Fetch a file-like resource from the `PYTHONPATH` and `resources` module
 90    included in the SSScore package.  Common resources include the drop zones
 91    list CSV and in-line documentation Markdown text.
 92
 93    Arguments
 94    ---------
 95        resourceName
 96    A string representing the resource file name, usually a CSV file.
 97
 98    Returns
 99    -------
100    An instance of `StringIO` ready to be process as a text stream by the
101    caller.
102
103    Raises
104    ------
105    `SSScoringError` if the resource dataframe isn't the global drop zones
106    directory or the file is invalid in any way.
107    """
108    try:
109        return StringIO(files(RESOURCES).joinpath(resourceName).read_bytes().decode(FLYSIGHT_FILE_ENCODING))
110    except Exception as e:
111        raise SSScoringError('Invalid resource - %s' % str(e))
112
113
114_SSSCORE_ICON_PLACEHOLDER = '{{SSSCORE_ICON_128}}'
115_SSSCORE_ICON_LINK = 'https://github.com/pr3d4t0r/SSScoring/releases'
116_SSSCORE_ICON_HTML = (
117    '<div style="text-align: left; margin: 0.75em 0 1em 0;">'
118    '<a href="%s" target="_blank">'
119    '<img src="data:image/png;base64,{b64}" width="128" height="128"'
120    ' style="border-radius: 16px; box-shadow: 0 4px 12px rgba(0,0,0,0.25);"'
121    ' alt="Download SSScore"/>'
122    '</a>'
123    '</div>'
124) % _SSSCORE_ICON_LINK
125
126
127@st.cache_data
128def fetchInstructionsHTML() -> str:
129    """
130    Load `instructions.md` and substitute the `{{SSSCORE_ICON_128}}`
131    placeholder with an inline base64 PNG icon linked to the GitHub releases
132    page.  The PNG is read from the package resource, so the result is
133    self-contained and works offline and from within a Python wheel.
134
135    Returns
136    -------
137    The instructions Markdown/HTML string, ready for `st.write(...,
138    unsafe_allow_html=True)`.
139    """
140    text = fetchResource(SSSCORE_INSTRUCTIONS_MD).read()
141    iconBytes = files(RESOURCES).joinpath(SSSCORE_DOWNLOAD_PNG).read_bytes()
142    b64 = base64.b64encode(iconBytes).decode('ascii')
143    iconHTML = _SSSCORE_ICON_HTML.format(b64=b64)
144    return text.replace(_SSSCORE_ICON_PLACEHOLDER, iconHTML)
145
146
147def initDropZonesFromResource() -> pd.DataFrame:
148    """
149    Get the DZs directory from a CSV enclosed in the distribution package as a
150    resource.  The resources package is fixed to `ssscoring.resources`, the
151    default resource file is defined by `DZ_DIRECTORY` but can be anything.
152
153    Returns
154    -------
155    The global drop zones directory as a dataframe.
156
157    Raises
158    ------
159    `SSScoringError` if the resource dataframe isn't the global drop zones
160    directory or the file is invalid in any way.
161    """
162    try:
163        buffer = fetchResource(DZ_DIRECTORY)
164        dropZones = pd.read_csv(buffer, sep=',')
165    except Exception as e:
166        raise SSScoringError('Invalid resource - %s' % str(e))
167
168    if 'dropZone' not in dropZones.columns:
169        raise SSScoringError('dropZone object is a dataframe but not the drop zones directory')
170
171    return dropZones
172
173
174def displayJumpDataIn(resultsTable: pd.DataFrame):
175    """
176    Display the individual results, as a table.
177
178    Arguments
179    ---------
180        resultsTable: pd.DataFrame
181    The results from a speed skydiving jump.
182
183    See
184    ---
185    `ssscoring.datatypes.JumpResults`
186    """
187    if resultsTable is None:
188        return
189
190    table = resultsTable.copy()
191
192    for col in ['vKMh', 'deltaV', 'vAccel m/s²', 'speedAngle',
193                'angularVel º/s', 'deltaAngle', 'hKMh', 'distanceFromExit (m)']:
194        if col in table.columns:
195            table[col] = table[col].round(2)
196
197    if 'altitude (ft)' in table.columns:
198        table['altitude (ft)'] = table['altitude (ft)'].round(1)
199
200    table.index = [''] * len(table)
201
202    st.dataframe(
203        table,
204        hide_index=True,
205        column_config={
206            col: st.column_config.NumberColumn(format="%.2f")
207            for col in table.columns
208            if table[col].dtype.kind in 'fi' and col != 'altitude (ft)'
209        }
210    )
211
212
213def interpretJumpResult(tag: str,
214                        jumpResult: JumpResults,
215                        processBadJump: bool):
216    """
217    Interpret the jump results and generate the corresponding labels and
218    warnings if a jump is invalid, or only "somewhat valid" according to ISC
219    rules.  The caller turns results display on/off depending on training vs
220    in-competition settins.  Because heuristics are a beautiful thing.
221
222    Arguments
223    ---------
224        tag
225    A string that identifies a specific jump and the FlySight version that
226    generated the corresponding track file.  Often in the form: `HH-mm-ss:vX`
227    where `X` is the FlySight hardware version.
228
229        jumpResult
230    An instance of `ssscoring.datatypes.JumpResults` with jump data.
231
232        processBadJump
233    If `True`, generate end-user warnings as part of its results processing if
234    the jump is invalid because of ISC rules, but set the status to OK so that
235    the jump may be displayed.
236
237    Returns
238    -------
239    A `tuple` of these objects:
240
241    - `jumpStatusInfo` - the jump status, in human-readable form
242    - `scoringInfo` - Max speed, scoring window, etc.
243    - `badJumpLegend` - A warning or error if the jump is invalid according to
244      ISC rules
245    - `jumpStatus` - An instance of `JumpStatus` that may have been overriden to
246      `OK` if `processBadJump` was set to `True` and the jump was invalid.  Used
247      only for display.  Use `jumpResult.status` to determine the actual result
248      of the jump from a strict scoring perspective.  `jumpStatus` is  used
249      for display override purposes only.
250    """
251    maxSpeed = jumpResult.maxSpeed
252    window = jumpResult.window
253    jumpStatus = jumpResult.status
254    jumpStatusInfo = ''
255    match jumpResult.status:
256        case JumpStatus.WARM_UP_FILE:
257            badJumpLegend = '<span style="color: red">Warm up file or SMD ran out of battery - nothing to do<br>'
258            scoringInfo = ''
259        case JumpStatus.SPEED_ACCURACY_EXCEEDS_LIMIT:
260            badJumpLegend = '<span style="color: red">%s - RE-JUMP: speed accuracy exceeds ISC threshold<br>' % tag
261            scoringInfo = ''
262        case JumpStatus.INVALID_SPEED_FILE:
263            badJumpLegend = '<span style="color: red">Invalid or corrupted FlySight file - it\'s neither version 1 nor version 2<br>'
264            scoringInfo = ''
265        case JumpStatus.UNSUPPORTED_PLD_FORMAT:
266            badJumpLegend = '<span style="color: red">Unsupported file format — not a FlySight v1, v2, or Deep &amp; Steep Insight device<br>'
267            scoringInfo = ''
268        case _:
269            scoringInfo = 'Max speed = {0:,.0f}; '.format(maxSpeed)+('exit at %d m (%d ft)<br>Validation window starts at %d m (%d ft)<br>End scoring window at %d m (%d ft)<br>' % \
270                            (window.start, M_2_FT*window.start, window.validationStart, M_2_FT*window.validationStart, window.end, M_2_FT*window.end))
271    showJumpData = False
272    match jumpStatus:
273        case JumpStatus.OK:
274            showJumpData = True
275        case JumpStatus.WARM_UP_FILE | JumpStatus.SPEED_ACCURACY_EXCEEDS_LIMIT | JumpStatus.INVALID_SPEED_FILE | JumpStatus.UNSUPPORTED_PLD_FORMAT:
276            pass
277        case _ if processBadJump:
278            showJumpData = True
279    if showJumpData:
280        jumpStatusInfo = '<span style="color: %s">%s jump - %s - %.02f km/h</span><br>' % ('green', tag, 'VALID', jumpResult.score)
281        belowMaxAltitude = isValidMaximumAltitude(jumpResult.data.altitudeAGL.max())
282        badJumpLegend = None
283        if not isValidMinimumAltitude(jumpResult.data.altitudeAGL.max()):
284            badJumpLegend = '<span style="color: yellow"><span style="font-weight: bold">Warning:</span> exit altitude AGL was lower than the minimum scoring altitude<br>'
285            jumpStatus = JumpStatus.ALTITUDE_EXCEEDS_MINIMUM
286        if not belowMaxAltitude:
287            jumpStatusInfo = '<span style="color: %s">%s jump - %s - %.02f km/h</span><br>' % ('red', tag, 'INVALID', jumpResult.score)
288            badJumpLegend = '<span style="color: red"><span style="font-weight: bold">RE-JUMP:</span> exit altitude AGL exceeds the maximum altitude<br>'
289            jumpStatus = JumpStatus.ALTITUDE_EXCEEDS_MAXIMUM
290    return jumpStatusInfo, scoringInfo, badJumpLegend, jumpStatus
291
292
293def plotJumpResult(tag: str, jumpResult: JumpResults):
294    if jumpResult.data is not None:
295        try:
296            yMax = DEFAULT_PLOT_MAX_V_SCALE if jumpResult.score <= DEFAULT_PLOT_MAX_V_SCALE else jumpResult.score + DEFAULT_PLOT_INCREMENT
297        except TypeError:
298            yMax = DEFAULT_PLOT_MAX_V_SCALE
299        fig = initializePlot(tag, backgroundColorName='#2c2c2c', yMax=yMax)
300        fig = initializeExtraYRanges(fig, startY=min(jumpResult.data.altitudeAGLFt)-500.0, endY=max(jumpResult.data.altitudeAGLFt)+500.0)
301        graphAltitude(fig, jumpResult)
302        graphAngle(fig, jumpResult)
303        graphAcceleration(fig, jumpResult)
304        graphJumpResult(fig, jumpResult, lineColor=SPEED_COLORS[0])
305        st.plotly_chart(fig, width='stretch')
306
307
308def initFileUploaderState(filesObject:str, uploaderKey:str ='uploaderKey'):
309    """
310    Initialize the session state for the Streamlit app uploader so that
311    selections can be cleared in callbacks later.
312
313    **Important**: `initFileUploaderState()` __must__ be called after setting the
314    page configuration (per Streamlit architecture rules) and before adding any
315    widgets to the sidebars, containers, or main application.
316
317    Argument
318    --------
319        filesObject
320    A `str` name that is either `'trackFile'` for the single track file process
321    or `'trackFiles'` for the app page that handles more than one track file at
322    a time.
323
324        uploaderKey
325    A unique identifier for the uploader key component, usually set to
326    `'uploaderKey'` but can be any arbitrary name.  This value must match the
327    `file_uploader(..., key=uploaderKey,...)` value.
328
329    """
330    if filesObject not in st.session_state:
331        st.session_state[filesObject] = None
332    if uploaderKey not in st.session_state:
333        st.session_state[uploaderKey] = 0
334
335
336def displayTrackOnMap(deck: pdk.Deck,
337                      displayScore=True,
338                      showJumpRunLegend=False):
339    """
340    Displays a track map drawn using PyDeck.
341
342    Arguments
343    ---------
344        deck
345    A PyDeck initialized with map layers.
346
347        displayScore
348    If `True`, display the max score point; else display the max speed point.
349
350        showJumpRunLegend
351    If `True`, render a colour legend for the speed run and jump run lines.
352    Only meaningful for single-jump views where the jump run PathLayer is present.
353    """
354    if deck is not None:
355        label = 'score' if displayScore else 'speed'
356        st.write('Brightest point shows the max **%s** point.  Exit at orange point.  Each track dot is 4 m in diameter.' % label)
357        st.pydeck_chart(deck)
358        if showJumpRunLegend:
359            st.markdown(
360                '<div style="display:flex; gap:1.5em; align-items:center; margin-top:0.4em;">'
361                '<span><span style="display:inline-block; width:28px; height:4px;'
362                ' background:#6495ed; vertical-align:middle; margin-right:5px;"></span>Speed run</span>'
363                '<span><span style="display:inline-block; width:28px; height:4px;'
364                ' background:rgba(200,200,200,0.9); vertical-align:middle; margin-right:5px;"></span>Jump run</span>'
365                '</div>',
366                unsafe_allow_html=True,
367            )
368
369
370@st.dialog('DZ Coordinates')
371def displayDZCoordinates():
372    """
373    Display the DZ coordinates in a dialog if one is selected in the drop zones
374    selection box.  The selection is stored in the  `st.session_state.currentDropZone`
375    variable.  If a valid name is available, the latitude and longitude are
376    displayed.  If `None` or invalid, a notification dialog is displayed.
377
378    The corresponding UI button is only enabled if the user selected a valid DZ,
379    otherwise the button is disabled.
380    """
381    dropZones = initDropZonesFromResource()
382    currentDZ = dropZones[dropZones.dropZone == st.session_state.currentDropZone ].iloc[0]
383    lat = float(currentDZ.lat)
384    lon = float(currentDZ.lon)
385    st.write(st.session_state.currentDropZone)
386    st.write('%.4f, %.4f - elevation: %.2f m, %.2f ft MSL' % (lat, lon, currentDZ.elevation, currentDZ.elevation*M_2_FT))
387    if st.button('OK'):
388        st.rerun()
389
390
391def setSideBarAndMain(icon: str, singleTrack: bool, selectDZState):
392    """
393    Set all the interactive and navigational components for the app's side bar.
394
395    Arguments
396    ---------
397        icon
398    A meaningful Emoji associated with the the side bar's title.
399
400        singleTrack
401    A flag for allowing selection of a single or multiple track files in the
402    corresponding selector component.  Determines whether the side bar is used
403    for the single- or multiple selection application.
404
405        selectDZState
406    A callback for the drop zone selector selection box, affected by events in
407    the main application.
408
409    Notes
410    -----
411    All the aplication level values associated with the components and
412    selections from the side bar are stored in `st.session_state` and visible
413    across the whole application.
414
415    **Do not** cache calls to `ssscoring.appcommon.setSideBarAndMain()` because
416    this can result in unpredictable behavior since the cache may never be
417    cleared until application reload.
418    """
419    dropZones = initDropZonesFromResource()
420    st.session_state.currentDropZone = None
421    elevation = None
422    st.sidebar.title('%s SSScore %s' % (icon, __VERSION__))
423    st.session_state.processBadJump = st.sidebar.checkbox('Process bad jumps', value=True, help='Display results from invalid jumps')
424    st.session_state.currentDropZone = st.sidebar.selectbox('Select the drop zone:', dropZones.dropZone, index=None, on_change=selectDZState, disabled=(elevation != None and elevation != 0.0))
425    elevation = st.sidebar.number_input('...or enter the DZ elevation in meters:', min_value=0.0, max_value=4000.0, value='min', format='%.2f', disabled=(st.session_state.currentDropZone != None), on_change=selectDZState)
426    if st.session_state.currentDropZone:
427        st.session_state.elevation = dropZones[dropZones.dropZone == st.session_state.currentDropZone ].iloc[0].elevation
428    elif elevation != None and elevation != 0.0:
429        st.session_state.elevation= elevation
430    else:
431        st.session_state.elevation = None
432        st.session_state.trackFiles = None
433    st.sidebar.metric('Elevation', value='%.1f m' % (0.0 if st.session_state.elevation == None else st.session_state.elevation))
434    if singleTrack:
435        trackFile = st.sidebar.file_uploader('Track file', type=[ 'csv', ], disabled=st.session_state.elevation == None, key = st.session_state.uploaderKey)
436        if trackFile:
437            st.session_state.trackFile = trackFile
438    else:
439        trackFiles = st.sidebar.file_uploader(
440            'Track files',
441            type=[ 'csv', ],
442            disabled=st.session_state.elevation == None,
443            accept_multiple_files=True,
444            key = st.session_state.uploaderKey
445        )
446        if trackFiles:
447            st.session_state.trackFiles = trackFiles
448    st.sidebar.button('Clear', on_click=selectDZState)
449    st.sidebar.button('Show DZ coordinates', on_click=displayDZCoordinates, disabled=(st.session_state.currentDropZone == None))
450    st.sidebar.link_button('Report missing DZ', 'https://github.com/pr3d4t0r/SSScoring/issues/new?template=report-missing-dz.md', icon=':material/breaking_news_alt_1:')
451    st.sidebar.link_button('Feature request or bug report', 'https://github.com/pr3d4t0r/SSScoring/issues/new?template=Blank+issue', icon=':material/breaking_news_alt_1:')
452
453
454def setSideBarDeprecated(icon: str):
455    """
456    Set a disabled version of the sidebar to maintain UX compatibility with the
457    actual app, used when the main screen is configured to display an end-user
458    message like "we moved to a new domain."
459
460    Arguments
461    ---------
462        icon
463    A meaningful Emoji associated with the the side bar's title.
464    """
465    dropZones = initDropZonesFromResource()
466    st.session_state.currentDropZone = None
467    elevation = None
468    st.sidebar.title('%s SSScore %s' % (icon, __VERSION__))
469    st.session_state.processBadJump = st.sidebar.checkbox('Process bad jumps', value=True, help='Display results from invalid jumps', disabled=True)
470    st.session_state.currentDropZone = st.sidebar.selectbox('Select the drop zone:', dropZones.dropZone, index=None, disabled=True)
471    elevation = st.sidebar.number_input('...or enter the DZ elevation in meters:', min_value=0.0, max_value=4000.0, value='min', format='%.2f', disabled=True)
472    if st.session_state.currentDropZone:
473        st.session_state.elevation = dropZones[dropZones.dropZone == st.session_state.currentDropZone ].iloc[0].elevation
474    elif elevation != None and elevation != 0.0:
475        st.session_state.elevation= elevation
476    else:
477        st.session_state.elevation = None
478        st.session_state.trackFiles = None
479    st.sidebar.metric('Elevation', value='%.1f m' % (0.0 if st.session_state.elevation == None else st.session_state.elevation))
480    st.sidebar.file_uploader('Track file', type=[ 'csv', ], disabled=True)
481    st.sidebar.button('Clear', disabled=True)
482    st.sidebar.button('Display DZ coordinates', disabled=True)
DEFAULT_DATA_LAKE = './data'

Default data lake directory when reading files from the local file system.

STREAMLIT_SIG_KEY = 'HOSTNAME'

Environment key used by the Streamlig.app environment when running an application.

STREAMLIT_SIG_VALUE = 'streamlit'

Expected value associate with the environment variable STREAMLIT_SIG_KEY when running in a Streamlit.app environment.

def isStreamlitHostedApp() -> bool:
69def isStreamlitHostedApp() -> bool:
70    """
71    Detect if the hosting environment is a native Python system or a Streamlit
72    app environment hosted by streamlit.io or Snowflake.
73
74    Returns
75    -------
76    `True` if the app is running in the Streamlit app or Snoflake app
77    environment, otherwise `False`.
78    """
79    keys = tuple(os.environ.keys())
80    if STREAMLIT_SIG_KEY not in keys:
81        return False
82    if os.environ[STREAMLIT_SIG_KEY] == STREAMLIT_SIG_VALUE:
83        return True
84    return False

Detect if the hosting environment is a native Python system or a Streamlit app environment hosted by streamlit.io or Snowflake.

Returns

True if the app is running in the Streamlit app or Snoflake app environment, otherwise False.

@st.cache_data
def fetchResource(resourceName: str) -> _io.StringIO:
 87@st.cache_data
 88def fetchResource(resourceName: str) -> StringIO:
 89    """
 90    Fetch a file-like resource from the `PYTHONPATH` and `resources` module
 91    included in the SSScore package.  Common resources include the drop zones
 92    list CSV and in-line documentation Markdown text.
 93
 94    Arguments
 95    ---------
 96        resourceName
 97    A string representing the resource file name, usually a CSV file.
 98
 99    Returns
100    -------
101    An instance of `StringIO` ready to be process as a text stream by the
102    caller.
103
104    Raises
105    ------
106    `SSScoringError` if the resource dataframe isn't the global drop zones
107    directory or the file is invalid in any way.
108    """
109    try:
110        return StringIO(files(RESOURCES).joinpath(resourceName).read_bytes().decode(FLYSIGHT_FILE_ENCODING))
111    except Exception as e:
112        raise SSScoringError('Invalid resource - %s' % str(e))

Fetch a file-like resource from the PYTHONPATH and resources module included in the SSScore package. Common resources include the drop zones list CSV and in-line documentation Markdown text.

Arguments

resourceName

A string representing the resource file name, usually a CSV file.

Returns

An instance of StringIO ready to be process as a text stream by the caller.

Raises

SSScoringError if the resource dataframe isn't the global drop zones directory or the file is invalid in any way.

@st.cache_data
def fetchInstructionsHTML() -> str:
128@st.cache_data
129def fetchInstructionsHTML() -> str:
130    """
131    Load `instructions.md` and substitute the `{{SSSCORE_ICON_128}}`
132    placeholder with an inline base64 PNG icon linked to the GitHub releases
133    page.  The PNG is read from the package resource, so the result is
134    self-contained and works offline and from within a Python wheel.
135
136    Returns
137    -------
138    The instructions Markdown/HTML string, ready for `st.write(...,
139    unsafe_allow_html=True)`.
140    """
141    text = fetchResource(SSSCORE_INSTRUCTIONS_MD).read()
142    iconBytes = files(RESOURCES).joinpath(SSSCORE_DOWNLOAD_PNG).read_bytes()
143    b64 = base64.b64encode(iconBytes).decode('ascii')
144    iconHTML = _SSSCORE_ICON_HTML.format(b64=b64)
145    return text.replace(_SSSCORE_ICON_PLACEHOLDER, iconHTML)

Load instructions.md and substitute the {{SSSCORE_ICON_128}} placeholder with an inline base64 PNG icon linked to the GitHub releases page. The PNG is read from the package resource, so the result is self-contained and works offline and from within a Python wheel.

Returns

The instructions Markdown/HTML string, ready for st.write(..., unsafe_allow_html=True).

def initDropZonesFromResource() -> pandas.DataFrame:
148def initDropZonesFromResource() -> pd.DataFrame:
149    """
150    Get the DZs directory from a CSV enclosed in the distribution package as a
151    resource.  The resources package is fixed to `ssscoring.resources`, the
152    default resource file is defined by `DZ_DIRECTORY` but can be anything.
153
154    Returns
155    -------
156    The global drop zones directory as a dataframe.
157
158    Raises
159    ------
160    `SSScoringError` if the resource dataframe isn't the global drop zones
161    directory or the file is invalid in any way.
162    """
163    try:
164        buffer = fetchResource(DZ_DIRECTORY)
165        dropZones = pd.read_csv(buffer, sep=',')
166    except Exception as e:
167        raise SSScoringError('Invalid resource - %s' % str(e))
168
169    if 'dropZone' not in dropZones.columns:
170        raise SSScoringError('dropZone object is a dataframe but not the drop zones directory')
171
172    return dropZones

Get the DZs directory from a CSV enclosed in the distribution package as a resource. The resources package is fixed to ssscoring.resources, the default resource file is defined by DZ_DIRECTORY but can be anything.

Returns

The global drop zones directory as a dataframe.

Raises

SSScoringError if the resource dataframe isn't the global drop zones directory or the file is invalid in any way.

def displayJumpDataIn(resultsTable: pandas.DataFrame):
175def displayJumpDataIn(resultsTable: pd.DataFrame):
176    """
177    Display the individual results, as a table.
178
179    Arguments
180    ---------
181        resultsTable: pd.DataFrame
182    The results from a speed skydiving jump.
183
184    See
185    ---
186    `ssscoring.datatypes.JumpResults`
187    """
188    if resultsTable is None:
189        return
190
191    table = resultsTable.copy()
192
193    for col in ['vKMh', 'deltaV', 'vAccel m/s²', 'speedAngle',
194                'angularVel º/s', 'deltaAngle', 'hKMh', 'distanceFromExit (m)']:
195        if col in table.columns:
196            table[col] = table[col].round(2)
197
198    if 'altitude (ft)' in table.columns:
199        table['altitude (ft)'] = table['altitude (ft)'].round(1)
200
201    table.index = [''] * len(table)
202
203    st.dataframe(
204        table,
205        hide_index=True,
206        column_config={
207            col: st.column_config.NumberColumn(format="%.2f")
208            for col in table.columns
209            if table[col].dtype.kind in 'fi' and col != 'altitude (ft)'
210        }
211    )

Display the individual results, as a table.

Arguments

resultsTable: pd.DataFrame

The results from a speed skydiving jump.

See

ssscoring.datatypes.JumpResults

def interpretJumpResult( tag: str, jumpResult: ssscoring.datatypes.JumpResults, processBadJump: bool):
214def interpretJumpResult(tag: str,
215                        jumpResult: JumpResults,
216                        processBadJump: bool):
217    """
218    Interpret the jump results and generate the corresponding labels and
219    warnings if a jump is invalid, or only "somewhat valid" according to ISC
220    rules.  The caller turns results display on/off depending on training vs
221    in-competition settins.  Because heuristics are a beautiful thing.
222
223    Arguments
224    ---------
225        tag
226    A string that identifies a specific jump and the FlySight version that
227    generated the corresponding track file.  Often in the form: `HH-mm-ss:vX`
228    where `X` is the FlySight hardware version.
229
230        jumpResult
231    An instance of `ssscoring.datatypes.JumpResults` with jump data.
232
233        processBadJump
234    If `True`, generate end-user warnings as part of its results processing if
235    the jump is invalid because of ISC rules, but set the status to OK so that
236    the jump may be displayed.
237
238    Returns
239    -------
240    A `tuple` of these objects:
241
242    - `jumpStatusInfo` - the jump status, in human-readable form
243    - `scoringInfo` - Max speed, scoring window, etc.
244    - `badJumpLegend` - A warning or error if the jump is invalid according to
245      ISC rules
246    - `jumpStatus` - An instance of `JumpStatus` that may have been overriden to
247      `OK` if `processBadJump` was set to `True` and the jump was invalid.  Used
248      only for display.  Use `jumpResult.status` to determine the actual result
249      of the jump from a strict scoring perspective.  `jumpStatus` is  used
250      for display override purposes only.
251    """
252    maxSpeed = jumpResult.maxSpeed
253    window = jumpResult.window
254    jumpStatus = jumpResult.status
255    jumpStatusInfo = ''
256    match jumpResult.status:
257        case JumpStatus.WARM_UP_FILE:
258            badJumpLegend = '<span style="color: red">Warm up file or SMD ran out of battery - nothing to do<br>'
259            scoringInfo = ''
260        case JumpStatus.SPEED_ACCURACY_EXCEEDS_LIMIT:
261            badJumpLegend = '<span style="color: red">%s - RE-JUMP: speed accuracy exceeds ISC threshold<br>' % tag
262            scoringInfo = ''
263        case JumpStatus.INVALID_SPEED_FILE:
264            badJumpLegend = '<span style="color: red">Invalid or corrupted FlySight file - it\'s neither version 1 nor version 2<br>'
265            scoringInfo = ''
266        case JumpStatus.UNSUPPORTED_PLD_FORMAT:
267            badJumpLegend = '<span style="color: red">Unsupported file format — not a FlySight v1, v2, or Deep &amp; Steep Insight device<br>'
268            scoringInfo = ''
269        case _:
270            scoringInfo = 'Max speed = {0:,.0f}; '.format(maxSpeed)+('exit at %d m (%d ft)<br>Validation window starts at %d m (%d ft)<br>End scoring window at %d m (%d ft)<br>' % \
271                            (window.start, M_2_FT*window.start, window.validationStart, M_2_FT*window.validationStart, window.end, M_2_FT*window.end))
272    showJumpData = False
273    match jumpStatus:
274        case JumpStatus.OK:
275            showJumpData = True
276        case JumpStatus.WARM_UP_FILE | JumpStatus.SPEED_ACCURACY_EXCEEDS_LIMIT | JumpStatus.INVALID_SPEED_FILE | JumpStatus.UNSUPPORTED_PLD_FORMAT:
277            pass
278        case _ if processBadJump:
279            showJumpData = True
280    if showJumpData:
281        jumpStatusInfo = '<span style="color: %s">%s jump - %s - %.02f km/h</span><br>' % ('green', tag, 'VALID', jumpResult.score)
282        belowMaxAltitude = isValidMaximumAltitude(jumpResult.data.altitudeAGL.max())
283        badJumpLegend = None
284        if not isValidMinimumAltitude(jumpResult.data.altitudeAGL.max()):
285            badJumpLegend = '<span style="color: yellow"><span style="font-weight: bold">Warning:</span> exit altitude AGL was lower than the minimum scoring altitude<br>'
286            jumpStatus = JumpStatus.ALTITUDE_EXCEEDS_MINIMUM
287        if not belowMaxAltitude:
288            jumpStatusInfo = '<span style="color: %s">%s jump - %s - %.02f km/h</span><br>' % ('red', tag, 'INVALID', jumpResult.score)
289            badJumpLegend = '<span style="color: red"><span style="font-weight: bold">RE-JUMP:</span> exit altitude AGL exceeds the maximum altitude<br>'
290            jumpStatus = JumpStatus.ALTITUDE_EXCEEDS_MAXIMUM
291    return jumpStatusInfo, scoringInfo, badJumpLegend, jumpStatus

Interpret the jump results and generate the corresponding labels and warnings if a jump is invalid, or only "somewhat valid" according to ISC rules. The caller turns results display on/off depending on training vs in-competition settins. Because heuristics are a beautiful thing.

Arguments

tag

A string that identifies a specific jump and the FlySight version that generated the corresponding track file. Often in the form: HH-mm-ss:vX where X is the FlySight hardware version.

jumpResult

An instance of ssscoring.datatypes.JumpResults with jump data.

processBadJump

If True, generate end-user warnings as part of its results processing if the jump is invalid because of ISC rules, but set the status to OK so that the jump may be displayed.

Returns

A tuple of these objects:

  • jumpStatusInfo - the jump status, in human-readable form
  • scoringInfo - Max speed, scoring window, etc.
  • badJumpLegend - A warning or error if the jump is invalid according to ISC rules
  • jumpStatus - An instance of JumpStatus that may have been overriden to OK if processBadJump was set to True and the jump was invalid. Used only for display. Use jumpResult.status to determine the actual result of the jump from a strict scoring perspective. jumpStatus is used for display override purposes only.
def plotJumpResult(tag: str, jumpResult: ssscoring.datatypes.JumpResults):
294def plotJumpResult(tag: str, jumpResult: JumpResults):
295    if jumpResult.data is not None:
296        try:
297            yMax = DEFAULT_PLOT_MAX_V_SCALE if jumpResult.score <= DEFAULT_PLOT_MAX_V_SCALE else jumpResult.score + DEFAULT_PLOT_INCREMENT
298        except TypeError:
299            yMax = DEFAULT_PLOT_MAX_V_SCALE
300        fig = initializePlot(tag, backgroundColorName='#2c2c2c', yMax=yMax)
301        fig = initializeExtraYRanges(fig, startY=min(jumpResult.data.altitudeAGLFt)-500.0, endY=max(jumpResult.data.altitudeAGLFt)+500.0)
302        graphAltitude(fig, jumpResult)
303        graphAngle(fig, jumpResult)
304        graphAcceleration(fig, jumpResult)
305        graphJumpResult(fig, jumpResult, lineColor=SPEED_COLORS[0])
306        st.plotly_chart(fig, width='stretch')
def initFileUploaderState(filesObject: str, uploaderKey: str = 'uploaderKey'):
309def initFileUploaderState(filesObject:str, uploaderKey:str ='uploaderKey'):
310    """
311    Initialize the session state for the Streamlit app uploader so that
312    selections can be cleared in callbacks later.
313
314    **Important**: `initFileUploaderState()` __must__ be called after setting the
315    page configuration (per Streamlit architecture rules) and before adding any
316    widgets to the sidebars, containers, or main application.
317
318    Argument
319    --------
320        filesObject
321    A `str` name that is either `'trackFile'` for the single track file process
322    or `'trackFiles'` for the app page that handles more than one track file at
323    a time.
324
325        uploaderKey
326    A unique identifier for the uploader key component, usually set to
327    `'uploaderKey'` but can be any arbitrary name.  This value must match the
328    `file_uploader(..., key=uploaderKey,...)` value.
329
330    """
331    if filesObject not in st.session_state:
332        st.session_state[filesObject] = None
333    if uploaderKey not in st.session_state:
334        st.session_state[uploaderKey] = 0

Initialize the session state for the Streamlit app uploader so that selections can be cleared in callbacks later.

Important: initFileUploaderState() __must__ be called after setting the page configuration (per Streamlit architecture rules) and before adding any widgets to the sidebars, containers, or main application.

Argument

filesObject

A str name that is either 'trackFile' for the single track file process or 'trackFiles' for the app page that handles more than one track file at a time.

uploaderKey

A unique identifier for the uploader key component, usually set to 'uploaderKey' but can be any arbitrary name. This value must match the file_uploader(..., key=uploaderKey,...) value.

def displayTrackOnMap( deck: pydeck.bindings.deck.Deck, displayScore=True, showJumpRunLegend=False):
337def displayTrackOnMap(deck: pdk.Deck,
338                      displayScore=True,
339                      showJumpRunLegend=False):
340    """
341    Displays a track map drawn using PyDeck.
342
343    Arguments
344    ---------
345        deck
346    A PyDeck initialized with map layers.
347
348        displayScore
349    If `True`, display the max score point; else display the max speed point.
350
351        showJumpRunLegend
352    If `True`, render a colour legend for the speed run and jump run lines.
353    Only meaningful for single-jump views where the jump run PathLayer is present.
354    """
355    if deck is not None:
356        label = 'score' if displayScore else 'speed'
357        st.write('Brightest point shows the max **%s** point.  Exit at orange point.  Each track dot is 4 m in diameter.' % label)
358        st.pydeck_chart(deck)
359        if showJumpRunLegend:
360            st.markdown(
361                '<div style="display:flex; gap:1.5em; align-items:center; margin-top:0.4em;">'
362                '<span><span style="display:inline-block; width:28px; height:4px;'
363                ' background:#6495ed; vertical-align:middle; margin-right:5px;"></span>Speed run</span>'
364                '<span><span style="display:inline-block; width:28px; height:4px;'
365                ' background:rgba(200,200,200,0.9); vertical-align:middle; margin-right:5px;"></span>Jump run</span>'
366                '</div>',
367                unsafe_allow_html=True,
368            )

Displays a track map drawn using PyDeck.

Arguments

deck

A PyDeck initialized with map layers.

displayScore

If True, display the max score point; else display the max speed point.

showJumpRunLegend

If True, render a colour legend for the speed run and jump run lines. Only meaningful for single-jump views where the jump run PathLayer is present.

@st.dialog('DZ Coordinates')
def displayDZCoordinates():
371@st.dialog('DZ Coordinates')
372def displayDZCoordinates():
373    """
374    Display the DZ coordinates in a dialog if one is selected in the drop zones
375    selection box.  The selection is stored in the  `st.session_state.currentDropZone`
376    variable.  If a valid name is available, the latitude and longitude are
377    displayed.  If `None` or invalid, a notification dialog is displayed.
378
379    The corresponding UI button is only enabled if the user selected a valid DZ,
380    otherwise the button is disabled.
381    """
382    dropZones = initDropZonesFromResource()
383    currentDZ = dropZones[dropZones.dropZone == st.session_state.currentDropZone ].iloc[0]
384    lat = float(currentDZ.lat)
385    lon = float(currentDZ.lon)
386    st.write(st.session_state.currentDropZone)
387    st.write('%.4f, %.4f - elevation: %.2f m, %.2f ft MSL' % (lat, lon, currentDZ.elevation, currentDZ.elevation*M_2_FT))
388    if st.button('OK'):
389        st.rerun()

Display the DZ coordinates in a dialog if one is selected in the drop zones selection box. The selection is stored in the st.session_state.currentDropZone variable. If a valid name is available, the latitude and longitude are displayed. If None or invalid, a notification dialog is displayed.

The corresponding UI button is only enabled if the user selected a valid DZ, otherwise the button is disabled.

def setSideBarAndMain(icon: str, singleTrack: bool, selectDZState):
392def setSideBarAndMain(icon: str, singleTrack: bool, selectDZState):
393    """
394    Set all the interactive and navigational components for the app's side bar.
395
396    Arguments
397    ---------
398        icon
399    A meaningful Emoji associated with the the side bar's title.
400
401        singleTrack
402    A flag for allowing selection of a single or multiple track files in the
403    corresponding selector component.  Determines whether the side bar is used
404    for the single- or multiple selection application.
405
406        selectDZState
407    A callback for the drop zone selector selection box, affected by events in
408    the main application.
409
410    Notes
411    -----
412    All the aplication level values associated with the components and
413    selections from the side bar are stored in `st.session_state` and visible
414    across the whole application.
415
416    **Do not** cache calls to `ssscoring.appcommon.setSideBarAndMain()` because
417    this can result in unpredictable behavior since the cache may never be
418    cleared until application reload.
419    """
420    dropZones = initDropZonesFromResource()
421    st.session_state.currentDropZone = None
422    elevation = None
423    st.sidebar.title('%s SSScore %s' % (icon, __VERSION__))
424    st.session_state.processBadJump = st.sidebar.checkbox('Process bad jumps', value=True, help='Display results from invalid jumps')
425    st.session_state.currentDropZone = st.sidebar.selectbox('Select the drop zone:', dropZones.dropZone, index=None, on_change=selectDZState, disabled=(elevation != None and elevation != 0.0))
426    elevation = st.sidebar.number_input('...or enter the DZ elevation in meters:', min_value=0.0, max_value=4000.0, value='min', format='%.2f', disabled=(st.session_state.currentDropZone != None), on_change=selectDZState)
427    if st.session_state.currentDropZone:
428        st.session_state.elevation = dropZones[dropZones.dropZone == st.session_state.currentDropZone ].iloc[0].elevation
429    elif elevation != None and elevation != 0.0:
430        st.session_state.elevation= elevation
431    else:
432        st.session_state.elevation = None
433        st.session_state.trackFiles = None
434    st.sidebar.metric('Elevation', value='%.1f m' % (0.0 if st.session_state.elevation == None else st.session_state.elevation))
435    if singleTrack:
436        trackFile = st.sidebar.file_uploader('Track file', type=[ 'csv', ], disabled=st.session_state.elevation == None, key = st.session_state.uploaderKey)
437        if trackFile:
438            st.session_state.trackFile = trackFile
439    else:
440        trackFiles = st.sidebar.file_uploader(
441            'Track files',
442            type=[ 'csv', ],
443            disabled=st.session_state.elevation == None,
444            accept_multiple_files=True,
445            key = st.session_state.uploaderKey
446        )
447        if trackFiles:
448            st.session_state.trackFiles = trackFiles
449    st.sidebar.button('Clear', on_click=selectDZState)
450    st.sidebar.button('Show DZ coordinates', on_click=displayDZCoordinates, disabled=(st.session_state.currentDropZone == None))
451    st.sidebar.link_button('Report missing DZ', 'https://github.com/pr3d4t0r/SSScoring/issues/new?template=report-missing-dz.md', icon=':material/breaking_news_alt_1:')
452    st.sidebar.link_button('Feature request or bug report', 'https://github.com/pr3d4t0r/SSScoring/issues/new?template=Blank+issue', icon=':material/breaking_news_alt_1:')

Set all the interactive and navigational components for the app's side bar.

Arguments

icon

A meaningful Emoji associated with the the side bar's title.

singleTrack

A flag for allowing selection of a single or multiple track files in the corresponding selector component. Determines whether the side bar is used for the single- or multiple selection application.

selectDZState

A callback for the drop zone selector selection box, affected by events in the main application.

Notes

All the aplication level values associated with the components and selections from the side bar are stored in st.session_state and visible across the whole application.

Do not cache calls to ssscoring.appcommon.setSideBarAndMain() because this can result in unpredictable behavior since the cache may never be cleared until application reload.

def setSideBarDeprecated(icon: str):
455def setSideBarDeprecated(icon: str):
456    """
457    Set a disabled version of the sidebar to maintain UX compatibility with the
458    actual app, used when the main screen is configured to display an end-user
459    message like "we moved to a new domain."
460
461    Arguments
462    ---------
463        icon
464    A meaningful Emoji associated with the the side bar's title.
465    """
466    dropZones = initDropZonesFromResource()
467    st.session_state.currentDropZone = None
468    elevation = None
469    st.sidebar.title('%s SSScore %s' % (icon, __VERSION__))
470    st.session_state.processBadJump = st.sidebar.checkbox('Process bad jumps', value=True, help='Display results from invalid jumps', disabled=True)
471    st.session_state.currentDropZone = st.sidebar.selectbox('Select the drop zone:', dropZones.dropZone, index=None, disabled=True)
472    elevation = st.sidebar.number_input('...or enter the DZ elevation in meters:', min_value=0.0, max_value=4000.0, value='min', format='%.2f', disabled=True)
473    if st.session_state.currentDropZone:
474        st.session_state.elevation = dropZones[dropZones.dropZone == st.session_state.currentDropZone ].iloc[0].elevation
475    elif elevation != None and elevation != 0.0:
476        st.session_state.elevation= elevation
477    else:
478        st.session_state.elevation = None
479        st.session_state.trackFiles = None
480    st.sidebar.metric('Elevation', value='%.1f m' % (0.0 if st.session_state.elevation == None else st.session_state.elevation))
481    st.sidebar.file_uploader('Track file', type=[ 'csv', ], disabled=True)
482    st.sidebar.button('Clear', disabled=True)
483    st.sidebar.button('Display DZ coordinates', disabled=True)

Set a disabled version of the sidebar to maintain UX compatibility with the actual app, used when the main screen is configured to display an end-user message like "we moved to a new domain."

Arguments

icon

A meaningful Emoji associated with the the side bar's title.