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 & 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 directory when reading files from the local file system.
Environment key used by the Streamlig.app environment when running an application.
Expected value associate with the environment variable STREAMLIT_SIG_KEY when
running in a Streamlit.app environment.
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.
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.
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).
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.
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
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 & 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 formscoringInfo- Max speed, scoring window, etc.badJumpLegend- A warning or error if the jump is invalid according to ISC rulesjumpStatus- An instance ofJumpStatusthat may have been overriden toOKifprocessBadJumpwas set toTrueand the jump was invalid. Used only for display. UsejumpResult.statusto determine the actual result of the jump from a strict scoring perspective.jumpStatusis used for display override purposes only.
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')
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.
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.
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.
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.
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.