ssscoring.calc
Functions and logic for analyzing and manipulating FlySight dataframes.
1# See: https://github.com/pr3d4t0r/SSScoring/blob/master/LICENSE.txt 2 3""" 4Functions and logic for analyzing and manipulating FlySight dataframes. 5""" 6 7 8from io import BytesIO 9from pathlib import Path 10 11from haversine import haversine 12from haversine import Unit 13 14from ssscoring.constants import BREAKOFF_ALTITUDE 15from ssscoring.constants import DEG_IN_RADIANS 16from ssscoring.constants import EXIT_SPEED 17from ssscoring.constants import FT_IN_M 18from ssscoring.constants import JUMP_RUN_SAMPLES 19from ssscoring.constants import KMH_AS_MS 20from ssscoring.constants import LAST_TIME_TRANCHE 21from ssscoring.constants import MAX_ALTITUDE_METERS 22from ssscoring.constants import MAX_VALID_ELEVATION 23from ssscoring.constants import MPS_2_KMH 24from ssscoring.constants import PERFORMANCE_WINDOW_LENGTH 25from ssscoring.constants import SCORING_INTERVAL 26from ssscoring.constants import SPEED_ACCURACY_THRESHOLD 27from ssscoring.constants import TABLE_INTERVAL 28from ssscoring.constants import VALIDATION_WINDOW_LENGTH 29from ssscoring.datatypes import JumpResults 30from ssscoring.datatypes import JumpStatus 31from ssscoring.datatypes import PerformanceWindow 32from ssscoring.errors import SSScoringError 33from ssscoring.flysight import getFlySightDataFromCSVBuffer 34from ssscoring.flysight import getFlySightDataFromCSVFileName 35 36import math 37import re 38import warnings 39 40import numpy as np 41import pandas as pd 42 43 44# +++ functions +++ 45 46def isValidMinimumAltitude(altitude: float) -> bool: 47 """ 48 Reports whether an `altitude` is below the IPC and USPA valid parameters, 49 or within `BREAKOFF_ALTITUDE` and `PERFORMACE_WINDOW_LENGTH`. In invalid 50 altitude doesn't invalidate a FlySight data file. This function can be used 51 for generating warnings. The stock FlySightViewer scores a speed jump even 52 if the exit was below the minimum altitude. 53 54 See: FAI Competition Rules Speed Skydiving section 5.3 for details. 55 56 Arguments 57 --------- 58 altitude 59 An altitude in meters, calculated as data.hMSL - DZ altitude. 60 61 Returns 62 ------- 63 `True` if the altitude is valid. 64 """ 65 if not isinstance(altitude, float): 66 altitude = float(altitude) 67 minAltitude = BREAKOFF_ALTITUDE+PERFORMANCE_WINDOW_LENGTH 68 return altitude >= minAltitude 69 70 71def isValidMaximumAltitude(altitude: float) -> bool: 72 """ 73 Reports whether an `altitude` is above the maximum altitude allowed by the 74 rules. 75 76 See: FAI Competition Rules Speed Skydiving section 5.3 for details. 77 78 Arguments 79 --------- 80 altitude 81 An altitude in meters, calculated as data.hMSL - DZ altitude. 82 83 Returns 84 ------- 85 `True` if the altitude is valid. 86 87 See 88 --- 89 `ssscoring.constants.MAX_ALTITUDE_FT` 90 `ssscoring.constants.MAX_ALTITUDE_METERS` 91 """ 92 if not isinstance(altitude, float): 93 altitude = float(altitude) 94 return altitude <= MAX_ALTITUDE_METERS 95 96 97def isValidJumpISC(data: pd.DataFrame, 98 window: PerformanceWindow) -> bool: 99 """ 100 **DEPRECATED** - Validates the jump according to ISC/FAI/USPA competition rules. A jump is 101 valid when the speed accuracy parameter is less than 3 m/s for the whole 102 validation window duration. 103 104 Arguments 105 --------- 106 data : pd.DataFramce 107 Jumnp data in SSScoring format 108 window : ssscoring.PerformanceWindow 109 Performance window start, end values in named tuple format 110 111 Returns 112 ------- 113 `True` if the jump is valid according to ISC/FAI/USPA rules. 114 """ 115 warnings.warn('This function is DEPRECATED as of version 2.4.0', UserWarning) 116 if len(data) > 0: 117 accuracy = data[data.altitudeAGL <= window.validationStart].speedAccuracyISC.max() 118 return accuracy < SPEED_ACCURACY_THRESHOLD 119 else: 120 return False 121 122 123def validateJumpISC(data: pd.DataFrame, 124 window: PerformanceWindow) -> JumpStatus: 125 """ 126 Validates the jump according to ISC/FAI/USPA competition rules. A jump is 127 valid when the speed accuracy parameter is less than 3 m/s for the whole 128 validation window duration. 129 130 Arguments 131 --------- 132 data : pd.DataFramce 133 Jumnp data in SSScoring format 134 window : ssscoring.PerformanceWindow 135 Performance window start, end values in named tuple format 136 137 Returns 138 ------- 139 - `JumpStatus.OK` if `data` reflects a valid jump according to ISC rules, 140 where all speed accuracy values < 'SPEED_ACCURACY_THRESHOLD'. 141 - `JumpStatus.SPEED_ACCURACY_EXCEEDS_LIMIT' if `data` has one or more values 142 within the validation window with a value >= 'SPEED_ACCURACY_THRESHOLD'. 143 144 Raises 145 ------ 146 `SSScoringError' if `data` has a length of zero or it's not initialized. 147 """ 148 if len(data) > 0: 149 accuracy = data[data.altitudeAGL <= window.validationStart].speedAccuracyISC.max() 150 return JumpStatus.OK if accuracy < SPEED_ACCURACY_THRESHOLD else JumpStatus.SPEED_ACCURACY_EXCEEDS_LIMIT 151 else: 152 raise SSScoringError('data length of zero or invalid') 153 154 155def calculateDistance(start: tuple, end: tuple) -> float: 156 """ 157 Calculate the distance between two terrestrial coordinates points. 158 159 Arguments 160 --------- 161 start 162 A latitude, longitude tuple of floating point numbers. 163 164 end 165 A latitude, longitude tuple of floating point numbers. 166 167 Returns 168 ------- 169 The distance, in meters, between both points. 170 """ 171 return haversine(start, end, unit = Unit.METERS) 172 173 174def convertFlySight2SSScoring(rawData: pd.DataFrame, 175 altitudeDZMeters = 0.0, 176 altitudeDZFt = 0.0): 177 """ 178 Converts a raw dataframe initialized from a FlySight CSV file into the 179 SSScoring file format. The SSScoring format uses more descriptive column 180 headers, adds the altitude in feet, and uses UNIX time instead of an ISO 181 string. 182 183 If both `altitudeDZMeters` and `altitudeDZFt` are zero then hMSL is used. 184 Otherwise, this function adjusts the effective altitude with the value. If 185 both meters and feet values are set this throws an error. 186 187 Arguments 188 --------- 189 rawData : pd.DataFrame 190 FlySight CSV input as a dataframe 191 192 altitudeDZMeters : float 193 Drop zone height above MSL 194 195 altitudeDZFt 196 Drop zone altitudde above MSL 197 198 Returns 199 ------- 200 A dataframe in SSScoring format, featuring these columns: 201 202 - timeUnix 203 - altitudeMSL 204 - altitudeAGL 205 - altitudeMSLFt 206 - altitudeAGLFt 207 - vMetersPerSecond 208 - vKMh (km/h) 209 - vAccelMS2 (m/s²) 210 - speedAccuracy (ignore; see ISC documentation) 211 - hMetersPerSecond 212 - hKMh (km/h) 213 - latitude 214 - longitude 215 - verticalAccuracy 216 - speedAccuracyISC 217 218 Errors 219 ------ 220 `SSScoringError` if the DZ altitude is set in both meters and feet. 221 """ 222 if not isinstance(rawData, pd.DataFrame): 223 raise SSScoringError('convertFlySight2SSScoring input must be a FlySight CSV dataframe') 224 225 if altitudeDZMeters and altitudeDZFt: 226 raise SSScoringError('Cannot set altitude in meters and feet; pick one') 227 228 if altitudeDZMeters: 229 altitudeDZFt = FT_IN_M*altitudeDZMeters 230 if altitudeDZFt: 231 altitudeDZMeters = altitudeDZFt/FT_IN_M 232 233 data = rawData.copy() 234 235 data['altitudeMSLFt'] = data['hMSL'].apply(lambda h: FT_IN_M*h) 236 data['altitudeAGL'] = data.hMSL-altitudeDZMeters 237 data['altitudeAGLFt'] = data.altitudeMSLFt-altitudeDZFt 238 data['timeUnix'] = np.round(data['time'].apply(lambda t: pd.Timestamp(t).timestamp()), decimals = 2) 239 data['hMetersPerSecond'] = (data.velE**2.0+data.velN**2.0)**0.5 240 speedAngle = data['hMetersPerSecond']/data['velD'] 241 speedAngle = np.round(90.0-speedAngle.apply(math.atan)/DEG_IN_RADIANS, decimals = 2) 242 speedAccuracyISC = np.round(data.vAcc.apply(lambda a: (2.0**0.5)*a/3.0), decimals = 2) 243 244 data = pd.DataFrame(data = { 245 'timeUnix': data.timeUnix, 246 'altitudeMSL': data.hMSL, 247 'altitudeAGL': data.altitudeAGL, 248 'altitudeMSLFt': data.altitudeMSLFt, 249 'altitudeAGLFt': data.altitudeAGLFt, 250 'vMetersPerSecond': data.velD, 251 'vKMh': 3.6*data.velD, 252 'speedAngle': speedAngle, 253 'speedAccuracy': data.sAcc, 254 'vAccelMS2': data.velD.diff()/data.timeUnix.diff(), 255 'hMetersPerSecond': data.hMetersPerSecond, 256 'hKMh': 3.6*data.hMetersPerSecond, 257 'latitude': data.lat, 258 'longitude': data.lon, 259 'verticalAccuracy': data.vAcc, 260 'speedAccuracyISC': speedAccuracyISC, 261 'velocityNorth': data.velN, 262 'velocityEast': data.velE, 263 }) 264 265 return data 266 267 268def _dataGroups(data): 269 data_ = data.copy() 270 data_['positive'] = (data_.vMetersPerSecond > 0) 271 data_['group'] = (data_.positive != data_.positive.shift(1)).fillna(True).astype(int).cumsum()-1 272 273 return data_ 274 275 276def getSpeedSkydiveFrom(data: pd.DataFrame) -> tuple: 277 """ 278 Take the skydive dataframe and get the speed skydiving data: 279 280 - Exit 281 - Speed skydiving window 282 - Drops data before exit and below breakoff altitude 283 284 Arguments 285 --------- 286 data : pd.DataFrame 287 Jump data in SSScoring format 288 289 Returns 290 ------- 291 A tuple of two elements: 292 293 - A named tuple with performance and validation window data 294 - A dataframe featuring only speed skydiving data 295 296 Warm up FlySight files and non-speed skydiving files may return invalid 297 values: 298 299 - `None` for the `PerformanceWindow` instance 300 - `data`, most likely empty 301 """ 302 if len(data): 303 data = _dataGroups(data) 304 groups = data.group.max()+1 305 306 freeFallGroup = -1 307 MIN_DATA_POINTS = 100 # heuristic 308 MIN_MAX_SPEED = 200 # km/h, heuristic; slower ::= no free fall 309 for group in range(groups): 310 subset = data[data.group == group] 311 if len(subset) >= MIN_DATA_POINTS and subset.vKMh.max() >= MIN_MAX_SPEED: 312 freeFallGroup = group 313 314 data = data[data.group == freeFallGroup] 315 data = data.drop('group', axis = 1).drop('positive', axis = 1) 316 317 data = data[data.altitudeAGL <= MAX_VALID_ELEVATION] 318 if len(data) > 0: 319 exitTime = data[data.vMetersPerSecond > EXIT_SPEED].head(1).timeUnix.iat[0] 320 data = data[data.timeUnix >= exitTime] 321 data = data[data.altitudeAGL >= BREAKOFF_ALTITUDE] 322 323 if len(data): 324 windowStart = data.iloc[0].altitudeAGL 325 windowEnd = windowStart-PERFORMANCE_WINDOW_LENGTH 326 if windowEnd < BREAKOFF_ALTITUDE: 327 windowEnd = BREAKOFF_ALTITUDE 328 329 validationWindowStart = windowEnd+VALIDATION_WINDOW_LENGTH 330 data = data[data.altitudeAGL >= windowEnd] 331 refN = float(data.velocityNorth.iloc[0]) 332 refE = float(data.velocityEast.iloc[0]) 333 refMag = (refN**2.0 + refE**2.0)**0.5 334 unitN, unitE = (refN/refMag, refE/refMag) if refMag > 0.0 else (1.0, 0.0) 335 signedHMPS = (data.velocityNorth*unitN + data.velocityEast*unitE).to_numpy(dtype=float, na_value=0.0) 336 vMS = data.vMetersPerSecond.to_numpy(dtype=float, na_value=0.0) 337 data = data.copy() 338 data['speedAngle'] = np.round( 339 np.where(signedHMPS == 0.0, 90.0, np.degrees(np.arctan(vMS/signedHMPS))), 340 decimals=2, 341 ) 342 data = data.drop(['velocityNorth', 'velocityEast',], axis=1) 343 performanceWindow = PerformanceWindow(windowStart, windowEnd, validationWindowStart) 344 else: 345 data = data.drop(['velocityNorth', 'velocityEast',], axis=1) 346 performanceWindow = None 347 348 return performanceWindow, data 349 else: 350 return None, data.drop(['velocityNorth', 'velocityEast',], axis=1) 351 352 353def _verticalAcceleration(vKMh: pd.Series, time: pd.Series, interval=TABLE_INTERVAL) -> pd.Series: 354 vAcc = ((vKMh/KMH_AS_MS).diff()/time.diff()).fillna(vKMh/KMH_AS_MS/interval) 355 return vAcc 356 357 358def jumpAnalysisTable(data: pd.DataFrame) -> pd.DataFrame: 359 """ 360 Generates the HCD jump analysis table, with speed data at 5-second intervals 361 after exit. 362 363 Arguments 364 --------- 365 data : pd.DataFrame 366 Jump data in SSScoring format 367 368 Returns 369 ------- 370 A tuple with a pd.DataFrame and the max speed recorded for the jump: 371 372 - A table dataframe with time and speed 373 - a floating point number 374 """ 375 table = None 376 distanceStart = (data.iloc[0].latitude, data.iloc[0].longitude) 377 for column in pd.Series([ 5.0, 10.0, 15.0, 20.0, 25.0, ]): 378 for interval in range(int(column)*10, 10*(int(column)+1)): 379 # Use the next 0.1 sec interval if the current interval tranche has 380 # NaN values. 381 columnRef = interval/10.0 382 timeOffset = data.iloc[0].timeUnix+columnRef 383 tranche = data.query('timeUnix == %f' % timeOffset).copy() 384 tranche['time'] = [ column, ] 385 currentPosition = (tranche.iloc[0].latitude, tranche.iloc[0].longitude) 386 tranche['distanceFromExit'] = [ round(calculateDistance(distanceStart, currentPosition), 2), ] 387 if not tranche.isnull().any().any(): 388 break 389 390 if pd.isna(tranche.iloc[-1].vKMh): 391 tranche = data.tail(1).copy() 392 currentPosition = (tranche.iloc[0].latitude, tranche.iloc[0].longitude) 393 tranche['time'] = tranche.timeUnix-data.iloc[0].timeUnix 394 tranche['distanceFromExit'] = [ round(calculateDistance(distanceStart, currentPosition), 2), ] 395 396 if table is not None: 397 table = pd.concat([ table, tranche, ]) 398 else: 399 table = tranche 400 table = pd.DataFrame({ 401 'time': table.time, 402 'vKMh': table.vKMh, 403 'deltaV': table.vKMh.diff().fillna(table.vKMh), 404 'vAccel m/s²': _verticalAcceleration(table.vKMh, table.time), 405 'speedAngle': table.speedAngle, 406 'angularVel º/s': (table.speedAngle.diff()/table.time.diff()).fillna(table.speedAngle/TABLE_INTERVAL), 407 'deltaAngle': table.speedAngle.diff().fillna(table.speedAngle), 408 'hKMh': table.hKMh, 409 'distanceFromExit (m)': table.distanceFromExit, 410 'altitude (ft)': table.altitudeAGLFt, 411 }) 412 return table 413 414 415def dropNonSkydiveDataFrom(data: pd.DataFrame) -> pd.DataFrame: 416 """ 417 Discards all data rows before maximum altitude, and all "negative" altitude 418 rows because we don't skydive underground (FlySight bug?). 419 420 This is a more accurate mean velocity calculation from a physics point of 421 view, but it differs from the ISC definition using in scoring - which, if we 422 get technical, is the one that counts. 423 424 Arguments 425 --------- 426 data : pd.DataFrame 427 Jump data in SSScoring format (headers differ from FlySight format) 428 429 Returns 430 ------- 431 The jump data for the skydive 432 """ 433 timeMaxAlt = data[data.altitudeAGL == data.altitudeAGL.max()].timeUnix.iloc[0] 434 data = data[data.timeUnix > timeMaxAlt] 435 436 data = data[data.altitudeAGL > 0] 437 438 return data 439 440 441def calcScoreMeanVelocity(data: pd.DataFrame) -> tuple: 442 """ 443 Calculates the speeds over a 3-second interval as the mean of all the speeds 444 recorded within that 3-second window and resolves the maximum speed. 445 446 Arguments 447 --------- 448 data 449 A `pd.dataframe` with speed run data. 450 451 Returns 452 ------- 453 A `tuple` with the best score throughout the speed run, and a dicitionary 454 of the meanVSpeed:spotInTime used in determining the exact scoring speed 455 at every datat point during the speed run. 456 457 Notes 458 ----- 459 This implementation uses iteration instead of binning/factorization because 460 some implementers may be unfamiliar with data manipulation in dataframes 461 and this is a critical function that may be under heavy review. Future 462 versions may revert to dataframe and series/np.array factorization. 463 """ 464 scores = dict() 465 for spot in data.plotTime[::1]: 466 subset = data[(data.plotTime <= spot) & (data.plotTime >= (spot-SCORING_INTERVAL))] 467 scores[np.round(subset.vKMh.mean(), decimals = 2)] = spot 468 return (max(scores), scores) 469 470 471def calcScoreISC(data: pd.DataFrame) -> tuple: 472 """ 473 Calculates the speeds over a 3-second interval as the ds/dt and dt is the 474 is a 3-second sliding interval from exit. The window slider moves along the 475 `plotTime` axis in the dataframe. 476 477 Arguments 478 --------- 479 data 480 A `pd.dataframe` with speed run data. 481 482 Returns 483 ------- 484 A `tuple` with the best score throughout the speed run, and a dicitionary 485 of the meanVSpeed:spotInTime used in determining the exact scoring speed 486 at every datat point during the speed run. 487 """ 488 scores = dict() 489 step = data.plotTime.diff().dropna().mode().iloc[0] 490 end = data.plotTime[-1:].iloc[0]-SCORING_INTERVAL 491 for spot in np.arange(0.0, end, step): 492 intervalStart = np.round(spot, decimals = 2) 493 intervalEnd = np.round(intervalStart+SCORING_INTERVAL, decimals = 2) 494 try: 495 h1 = data[data.plotTime == intervalStart].altitudeAGL.iloc[0] 496 h2 = data[data.plotTime == intervalEnd].altitudeAGL.iloc[0] 497 except IndexError: 498 # TODO: Decide whether to log the missing FlySight samples. 499 continue 500 intervalScore = np.round(MPS_2_KMH*abs(h1-h2)/SCORING_INTERVAL, decimals = 2) 501 scores[intervalScore] = intervalStart 502 return (max(scores), scores) 503 504 505def jumpRunBearing(jumpData: pd.DataFrame, nSamples: int = JUMP_RUN_SAMPLES) -> float: 506 """ 507 Estimates the jump run bearing from the first `nSamples` rows of the 508 performance window using the aircraft's residual forward throw (ISC §5.1.3). 509 510 Arguments 511 --------- 512 jumpData : pd.DataFrame 513 Performance-window data in SSScoring format with `plotTime >= 0`. 514 515 nSamples : int 516 Number of post-exit samples to average. Default: `JUMP_RUN_SAMPLES` (15, 517 i.e. 3 seconds at 5 Hz). 518 519 Returns 520 ------- 521 Mean bearing in degrees [0, 360). 522 """ 523 samples = jumpData.head(nSamples) 524 lats = samples.latitude.to_numpy(dtype=float) 525 lons = samples.longitude.to_numpy(dtype=float) 526 sinSum = 0.0 527 cosSum = 0.0 528 count = 0 529 for i in range(len(lats) - 1): 530 lat1, lon1, lat2, lon2 = map(math.radians, [lats[i], lons[i], lats[i + 1], lons[i + 1]]) 531 dLon = lon2 - lon1 532 x = math.sin(dLon) * math.cos(lat2) 533 y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dLon) 534 if x != 0.0 or y != 0.0: 535 b = math.atan2(x, y) 536 sinSum += math.sin(b) 537 cosSum += math.cos(b) 538 count += 1 539 if count == 0: 540 return 0.0 541 return (math.degrees(math.atan2(sinSum / count, cosSum / count)) + 360.0) % 360.0 542 543 544def forwardLateralDisplacement( 545 jumpData: pd.DataFrame, 546 exitLat: float, 547 exitLon: float, 548 bearing: float, 549) -> pd.DataFrame: 550 """ 551 Adds `forwardM` and `lateralM` columns to `jumpData`: signed displacement 552 in metres along and perpendicular to the jump run axis from the exit point. 553 Positive `forwardM` = moving away from exit; negative = reversed. 554 Positive `lateralM` = right of jump run; negative = left. 555 556 Arguments 557 --------- 558 jumpData : pd.DataFrame 559 Performance-window data in SSScoring format. 560 561 exitLat, exitLon : float 562 Exit point coordinates (latitude, longitude). 563 564 bearing : float 565 Jump run bearing in degrees [0, 360), typically from `jumpRunBearing()`. 566 567 Returns 568 ------- 569 Copy of `jumpData` with `forwardM` and `lateralM` columns appended. 570 """ 571 bearingRad = math.radians(bearing) 572 lats = jumpData.latitude.to_numpy(dtype=float) 573 lons = jumpData.longitude.to_numpy(dtype=float) 574 distances = np.array([ 575 calculateDistance((exitLat, exitLon), (lat, lon)) 576 for lat, lon in zip(lats, lons) 577 ]) 578 lat1Rad = math.radians(exitLat) 579 lon1Rad = math.radians(exitLon) 580 lat2Rad = np.radians(lats) 581 dLon = np.radians(lons) - lon1Rad 582 x = np.sin(dLon) * np.cos(lat2Rad) 583 y = math.cos(lat1Rad) * np.sin(lat2Rad) - math.sin(lat1Rad) * np.cos(lat2Rad) * np.cos(dLon) 584 ptBearings = np.arctan2(x, y) 585 deltas = ptBearings - bearingRad 586 result = jumpData.copy() 587 result['forwardM'] = np.round(distances * np.cos(deltas), decimals=2) 588 result['lateralM'] = np.round(distances * np.sin(deltas), decimals=2) 589 return result 590 591 592def detectBackFall(jumpData: pd.DataFrame) -> dict: 593 """ 594 Detects whether the skydiver fell to their back during the performance 595 window and quantifies the severity using GPS ground track geometry. 596 597 A back-fall produces a reversal in the skydiver's displacement along the 598 jump run axis (forward reversal) or perpendicular to it (lateral reversal). 599 Both axes are checked; either non-zero reversal depth flags a back-fall. 600 601 Arguments 602 --------- 603 jumpData : pd.DataFrame 604 Performance-window data in SSScoring format, with `plotTime` column set 605 (i.e., called after `processJump` sets `plotTime`). 606 607 Returns 608 ------- 609 dict with keys: 610 611 - `backFall` : bool — `True` if any reversal detected 612 - `onsetTime` : float | None — `plotTime` at peak forward displacement; 613 `None` if no back-fall detected 614 - `forwardReversalM` : float — metres reversed along jump run axis (≥ 0) 615 - `lateralReversalM` : float — metres reversed on lateral axis (≥ 0) 616 """ 617 exitLat = float(jumpData.latitude.iloc[0]) 618 exitLon = float(jumpData.longitude.iloc[0]) 619 bearing = jumpRunBearing(jumpData) 620 df = forwardLateralDisplacement(jumpData, exitLat, exitLon, bearing) 621 forwardMax = float(df.forwardM.max()) 622 onsetIdx = df.forwardM.idxmax() 623 onsetTime = float(df.loc[onsetIdx].plotTime) 624 forwardReversalM = round(max(0.0, forwardMax - float(df.forwardM.iloc[-1])), 2) 625 lateralAbsMax = float(df.lateralM.abs().max()) 626 lateralReversalM = round(max(0.0, lateralAbsMax - float(df.lateralM.abs().iloc[-1])), 2) 627 backFall = forwardReversalM > 0.0 or lateralReversalM > 0.0 628 return { 629 'backFall': backFall, 630 'onsetTime': onsetTime if backFall else None, 631 'forwardReversalM': forwardReversalM, 632 'lateralReversalM': lateralReversalM, 633 } 634 635 636def processJump(data: pd.DataFrame) -> JumpResults: 637 """ 638 Take a dataframe in SSScoring format and process it for display. It 639 serializes all the steps that would be taken from the ssscoring module, but 640 includes some text/HTML data in the output. 641 642 Arguments 643 --------- 644 data: pd.DataFrame 645 A dataframe in SSScoring format 646 647 Returns 648 ------- 649 A `JumpResults` named tuple with these items: 650 651 - `score` speed score 652 - `maxSpeed` maximum speed during the jump 653 - `scores` a Series of every 3-second window scores from exit to breakoff 654 - `data` an updated SSScoring dataframe `plotTime`, where 0 = exit, used 655 for plotting 656 - `window` a named tuple with the exit, breakoff, and validation window 657 altitudes 658 - `table` a dataframe featuring the speeds and altitudes at 5-sec intervals 659 - `color` a string that defines the color for the jump result; possible 660 values are _green_ for valid jump, _red_ for invalid jump, per ISC rules 661 - `result` a string with the legend of _valid_ or _invalid_ jump 662 """ 663 workData = data.copy() 664 workData = dropNonSkydiveDataFrom(workData) 665 window, workData = getSpeedSkydiveFrom(workData) 666 backFall = False 667 backFallOnset = None 668 forwardReversalM = 0.0 669 lateralReversalM = 0.0 670 if workData.empty and not window: 671 workData = None 672 maxSpeed = -1.0 673 score = -1.0 674 scores = None 675 table = None 676 window = None 677 jumpStatus = JumpStatus.WARM_UP_FILE 678 else: 679 jumpStatus = validateJumpISC(workData, window) 680 score = None 681 scores = None 682 table = None 683 baseTime = workData.iloc[0].timeUnix 684 workData['plotTime'] = round(workData.timeUnix-baseTime, 2) 685 if jumpStatus == JumpStatus.OK: 686 table = jumpAnalysisTable(workData) 687 maxSpeed = data.vKMh.max() 688 score, scores = calcScoreISC(workData) 689 backFallResult = detectBackFall(workData) 690 backFall = backFallResult['backFall'] 691 backFallOnset = backFallResult['onsetTime'] 692 forwardReversalM = backFallResult['forwardReversalM'] 693 lateralReversalM = backFallResult['lateralReversalM'] 694 else: 695 maxSpeed = -1 696 if not len(workData): 697 jumpStatus = JumpStatus.INVALID_SPEED_FILE 698 return JumpResults(workData, maxSpeed, score, scores, table, window, jumpStatus, backFall, backFallOnset, forwardReversalM, lateralReversalM) 699 700 701def processAllJumpFiles(jumpFiles: list, altitudeDZMeters = 0.0) -> dict: 702 """ 703 Process all jump files in a list of valid FlySight files. Returns a 704 dictionary of jump results with a human-readable version of the file name. 705 The `jumpFiles` list can be generated by hand or the output of the 706 `ssscoring.fs1.getAllSpeedJumpFilesFrom` called to operate on a data lake. 707 708 Arguments 709 --------- 710 jumpFiles 711 A list of file things that could represent one of these: 712 - file things relative or absolute path names to individual FlySight CSV 713 files. 714 - A specialization of BytesIO, such as the bags of bytes that Streamlit.io 715 generates after uploading and reading a file into the Streamlit 716 environment 717 718 altitudeDZMeters : float 719 Drop zone height above MSL 720 721 Returns 722 ------- 723 dict 724 A dictionary of jump results. The key is a human-readable version of a 725 `jumpFile` name with the extension, path, and extraneous spaces eliminated 726 or replaced by appropriate characters. File names use Unicode, so accents 727 and non-ANSI characters are allowed in file names. 728 729 Raises 730 ------ 731 `SSScoringError` if the jumpFiles object is empty, or if the individual 732 objects in the list aren't `BytesIO`, file name strings, or `Path` 733 instances. 734 """ 735 jumpResults = dict() 736 if not len(jumpFiles): 737 raise SSScoringError('jumpFiles must have at least one element') 738 if not isinstance(jumpFiles, dict) and not isinstance(jumpFiles, list): 739 raise SSScoringError('dict with jump file names and FS versions or list of byte bags expected') 740 if isinstance(jumpFiles, dict): 741 objectsList = sorted(list(jumpFiles.keys()), key=str) 742 elif isinstance(jumpFiles, list): 743 objectsList = jumpFiles 744 obj = objectsList[0] 745 if not isinstance(obj, Path) and not isinstance(obj, str) and not isinstance(obj, BytesIO): 746 raise SSScoringError('jumpFiles must contain file-like things or BytesIO objects') 747 _v1Pattern = re.compile(r'^\d{2}-\d{2}-\d{2}\.CSV$', re.IGNORECASE) 748 for jumpFile in objectsList: 749 if isinstance(jumpFile, BytesIO): 750 fileName = jumpFile.name 751 if not _v1Pattern.match(fileName) and not fileName.upper().endswith('TRACK.CSV'): 752 continue 753 rawData, tag = getFlySightDataFromCSVBuffer(jumpFile.getvalue(), jumpFile.name) 754 elif isinstance(jumpFiles, dict) and isinstance(jumpFiles[jumpFile], pd.DataFrame): 755 rawData = jumpFiles[jumpFile] 756 tag = jumpFile 757 else: 758 rawData, tag = getFlySightDataFromCSVFileName(jumpFile) 759 try: 760 jumpResult = processJump(convertFlySight2SSScoring(rawData, altitudeDZMeters = altitudeDZMeters)) 761 except Exception: 762 jumpResult = JumpResults(None, 0.0, 0.0, None, None, None, JumpStatus.INVALID_SPEED_FILE) 763 jumpResults[tag] = jumpResult 764 return jumpResults 765 766 767def aggregateResults(jumpResults: dict) -> pd.DataFrame: 768 """ 769 Aggregate all the results in a table fashioned after Marco Hepp's and Nklas 770 Daniel's score tracking data. 771 772 Arguments 773 --------- 774 jumpResults: dict 775 A dictionary of jump results, in which each result corresponds to a FlySight 776 file name. See `ssscoring.processAllJumpFiles` for details. 777 778 Returns 779 ------- 780 A dataframe featuring these columns: 781 782 - Score 783 - Speeds at 5, 10, 15, 20, and 25 second tranches 784 - Final time contemplated in the analysis 785 - Max speed 786 787 The dataframe rows are identified by the human readable jump file name. 788 789 Raises 790 ------ 791 `SSScoringError` if the `jumpResults` object is empty. 792 """ 793 if not len(jumpResults): 794 raise SSScoringError('jumpResults is empty - impossible to collate angles') 795 796 speeds = pd.DataFrame() 797 for jumpResultIndex in sorted(list(jumpResults.keys())): 798 jumpResult = jumpResults[jumpResultIndex] 799 if jumpResult.status == JumpStatus.OK: 800 t = jumpResult.table.copy() 801 finalTime = t.iloc[-1].time 802 803 if finalTime > 20.1: 804 finalSpeed = t.iloc[-1].vKMh 805 t.iloc[-1].time = LAST_TIME_TRANCHE # keep LAST_TIME_TRANCHE for pivoting 806 else: 807 finalSpeed = None 808 809 t = pd.pivot_table(t, columns=t.time) 810 t.columns = [str(c) for c in t.columns] 811 d = pd.DataFrame([jumpResult.score], index=[jumpResultIndex], columns=['score']) 812 813 for column in t.columns: 814 d[column] = t[column].vKMh 815 816 d['finalTime'] = [finalTime] 817 d['maxSpeed'] = jumpResult.maxSpeed 818 819 if finalSpeed is not None: 820 d['finalSpeed'] = [finalSpeed] 821 else: 822 d['finalSpeed'] = [d.get('25.0', [0.0])[0]] 823 824 if speeds.empty: 825 speeds = d.copy() 826 else: 827 speeds = pd.concat([speeds, d]) 828 829 cols = ['score', '5.0', '10.0', '15.0', '20.0', 'finalSpeed', 'finalTime', 'maxSpeed'] 830 speeds = speeds[[c for c in cols if c in speeds.columns]] 831 speeds = speeds.replace(np.nan, 0.0) 832 return speeds.sort_index() 833 834 835def roundedAggregateResults(aggregate: pd.DataFrame) -> pd.DataFrame: 836 """ 837 Aggregate all the results in a table fashioned after Marco Hepp's and Nklas 838 Daniel's score tracking data. All speed results are rounded at `n > x.5` 839 for any value. 840 841 Arguments 842 --------- 843 aggregate: pd.DataFrame 844 A dataframe output of `ssscoring.fs1.aggregateResults`. 845 846 Returns 847 ------- 848 A dataframe featuring the **rounded values** for these columns: 849 850 - Score 851 - Speeds at 5, 10, 15, 20, and 25 second tranches 852 - Max speed 853 854 The `finalTime` column is ignored. 855 856 The dataframe rows are identified by the human readable jump file name. 857 858 This is a less precise version of the `ssscoring.aggregateResults` 859 dataframe, useful during training to keep rounded results available for 860 review. 861 862 Raises 863 ------ 864 `SSSCoringError` if the `jumpResults` object is empty. 865 """ 866 for column in [col for col in aggregate.columns if 'Time' not in str(col)]: 867 aggregate[column] = aggregate[column].apply(round) 868 869 return aggregate 870 871 872def collateAnglesByTimeFromExit(jumpResults: dict) -> pd.DataFrame: 873 """ 874 Collate all the angles by time from the `jumpResults` into a dataframe that 875 features the jump tag as index, the time tranches and the angles at each 876 time tranche. 877 878 Arguments 879 --------- 880 jumpResults: dict 881 A dictionary of jump results, in which each result corresponds to a FlySight 882 file name. See `ssscoring.processAllJumpFiles` for details. 883 884 Returns 885 ------- 886 A dataframe featuring these columns: 887 888 - Score 889 - Angles at 5, 10, 15, 20, and 25 second tranches 890 - Final time contemplated in the analysis 891 892 Raises 893 ------ 894 `SSSCoringError` if the `jumpResults` object is empty. 895 """ 896 if not len(jumpResults): 897 raise SSScoringError('jumpResults is empty - impossible to collate angles') 898 899 angles = pd.DataFrame() 900 for jumpResultIndex in sorted(list(jumpResults.keys())): 901 jumpResult = jumpResults[jumpResultIndex] 902 if jumpResult.status == JumpStatus.OK: 903 t = jumpResult.table.copy() # ← critical: avoid mutation 904 905 finalTime = t.iloc[-1].time 906 finalAngle = t.iloc[-1].speedAngle 907 908 t.iloc[-1].time = LAST_TIME_TRANCHE 909 910 t = pd.pivot_table(t, columns=t.time) 911 t.columns = [str(c) for c in t.columns] 912 d = pd.DataFrame([jumpResult.score], index=[jumpResultIndex], columns=['score']) 913 914 for column in t.columns: 915 d[column] = t[column].speedAngle 916 917 d['finalTime'] = [finalTime] 918 d['finalAngle'] = [finalAngle] # ← new clean column 919 920 if angles.empty: 921 angles = d.copy() 922 else: 923 angles = pd.concat([angles, d]) 924 925 cols = ['score', '5.0', '10.0', '15.0', '20.0', 'finalAngle', 'finalTime'] 926 angles = angles[[c for c in cols if c in angles.columns]] 927 angles = angles.replace(np.nan, 0.0) 928 929 return angles.sort_index() 930 931 932def totalResultsFrom(aggregate: pd.DataFrame) -> pd.DataFrame: 933 """ 934 Calculates the total and mean speeds for an aggregation of speed jumps. 935 936 Arguments 937 --------- 938 aggregate: pd.DataFrame 939 The aggregate results dataframe resulting from calling `ssscoring.aggregateResults` 940 with valid results. 941 942 Returns 943 ------- 944 A dataframe with one row and two columns: 945 946 - totalSpeed ::= the sum of all speeds in the aggregated results 947 - meanSpeed ::= the mean of all speeds 948 - maxSpeed := the absolute max speed over the speed runs set 949 - meanSpeedSTD := scored speeds standar deviation 950 - maxScore ::= the max score among all the speed scores 951 - maxScoreSTD := the max scores standard deviation 952 953 Raises 954 ------ 955 `AttributeError` if aggregate is an empty dataframe or `None`, or if the 956 `aggregate` dataframe doesn't conform to the output of `ssscoring.aggregateResults`. 957 """ 958 if aggregate is None: 959 raise AttributeError('aggregate dataframe is empty') 960 elif isinstance(aggregate, pd.DataFrame) and not len(aggregate): 961 raise AttributeError('aggregate dataframe is empty') 962 963 totals = pd.DataFrame({ 964 'totalScore': [ round(aggregate.score.sum(), 2), ], 965 'mean': [ round(aggregate.score.mean(), 2), ], 966 'deviation': [ round(aggregate.score.std(), 2), ], 967 'maxScore': [ round(aggregate.score.max(), 2), ], 968 'absMaxSpeed': [ round(aggregate.maxSpeed.max(), 2), ], }, index = [ 'totalSpeed'],) 969 return totals
47def isValidMinimumAltitude(altitude: float) -> bool: 48 """ 49 Reports whether an `altitude` is below the IPC and USPA valid parameters, 50 or within `BREAKOFF_ALTITUDE` and `PERFORMACE_WINDOW_LENGTH`. In invalid 51 altitude doesn't invalidate a FlySight data file. This function can be used 52 for generating warnings. The stock FlySightViewer scores a speed jump even 53 if the exit was below the minimum altitude. 54 55 See: FAI Competition Rules Speed Skydiving section 5.3 for details. 56 57 Arguments 58 --------- 59 altitude 60 An altitude in meters, calculated as data.hMSL - DZ altitude. 61 62 Returns 63 ------- 64 `True` if the altitude is valid. 65 """ 66 if not isinstance(altitude, float): 67 altitude = float(altitude) 68 minAltitude = BREAKOFF_ALTITUDE+PERFORMANCE_WINDOW_LENGTH 69 return altitude >= minAltitude
Reports whether an altitude is below the IPC and USPA valid parameters,
or within BREAKOFF_ALTITUDE and PERFORMACE_WINDOW_LENGTH. In invalid
altitude doesn't invalidate a FlySight data file. This function can be used
for generating warnings. The stock FlySightViewer scores a speed jump even
if the exit was below the minimum altitude.
See: FAI Competition Rules Speed Skydiving section 5.3 for details.
Arguments
altitude
An altitude in meters, calculated as data.hMSL - DZ altitude.
Returns
True if the altitude is valid.
72def isValidMaximumAltitude(altitude: float) -> bool: 73 """ 74 Reports whether an `altitude` is above the maximum altitude allowed by the 75 rules. 76 77 See: FAI Competition Rules Speed Skydiving section 5.3 for details. 78 79 Arguments 80 --------- 81 altitude 82 An altitude in meters, calculated as data.hMSL - DZ altitude. 83 84 Returns 85 ------- 86 `True` if the altitude is valid. 87 88 See 89 --- 90 `ssscoring.constants.MAX_ALTITUDE_FT` 91 `ssscoring.constants.MAX_ALTITUDE_METERS` 92 """ 93 if not isinstance(altitude, float): 94 altitude = float(altitude) 95 return altitude <= MAX_ALTITUDE_METERS
Reports whether an altitude is above the maximum altitude allowed by the
rules.
See: FAI Competition Rules Speed Skydiving section 5.3 for details.
Arguments
altitude
An altitude in meters, calculated as data.hMSL - DZ altitude.
Returns
True if the altitude is valid.
See
ssscoring.constants.MAX_ALTITUDE_FT
ssscoring.constants.MAX_ALTITUDE_METERS
98def isValidJumpISC(data: pd.DataFrame, 99 window: PerformanceWindow) -> bool: 100 """ 101 **DEPRECATED** - Validates the jump according to ISC/FAI/USPA competition rules. A jump is 102 valid when the speed accuracy parameter is less than 3 m/s for the whole 103 validation window duration. 104 105 Arguments 106 --------- 107 data : pd.DataFramce 108 Jumnp data in SSScoring format 109 window : ssscoring.PerformanceWindow 110 Performance window start, end values in named tuple format 111 112 Returns 113 ------- 114 `True` if the jump is valid according to ISC/FAI/USPA rules. 115 """ 116 warnings.warn('This function is DEPRECATED as of version 2.4.0', UserWarning) 117 if len(data) > 0: 118 accuracy = data[data.altitudeAGL <= window.validationStart].speedAccuracyISC.max() 119 return accuracy < SPEED_ACCURACY_THRESHOLD 120 else: 121 return False
DEPRECATED - Validates the jump according to ISC/FAI/USPA competition rules. A jump is valid when the speed accuracy parameter is less than 3 m/s for the whole validation window duration.
Arguments
data : pd.DataFramce
Jumnp data in SSScoring format window : ssscoring.PerformanceWindow Performance window start, end values in named tuple format
Returns
True if the jump is valid according to ISC/FAI/USPA rules.
124def validateJumpISC(data: pd.DataFrame, 125 window: PerformanceWindow) -> JumpStatus: 126 """ 127 Validates the jump according to ISC/FAI/USPA competition rules. A jump is 128 valid when the speed accuracy parameter is less than 3 m/s for the whole 129 validation window duration. 130 131 Arguments 132 --------- 133 data : pd.DataFramce 134 Jumnp data in SSScoring format 135 window : ssscoring.PerformanceWindow 136 Performance window start, end values in named tuple format 137 138 Returns 139 ------- 140 - `JumpStatus.OK` if `data` reflects a valid jump according to ISC rules, 141 where all speed accuracy values < 'SPEED_ACCURACY_THRESHOLD'. 142 - `JumpStatus.SPEED_ACCURACY_EXCEEDS_LIMIT' if `data` has one or more values 143 within the validation window with a value >= 'SPEED_ACCURACY_THRESHOLD'. 144 145 Raises 146 ------ 147 `SSScoringError' if `data` has a length of zero or it's not initialized. 148 """ 149 if len(data) > 0: 150 accuracy = data[data.altitudeAGL <= window.validationStart].speedAccuracyISC.max() 151 return JumpStatus.OK if accuracy < SPEED_ACCURACY_THRESHOLD else JumpStatus.SPEED_ACCURACY_EXCEEDS_LIMIT 152 else: 153 raise SSScoringError('data length of zero or invalid')
Validates the jump according to ISC/FAI/USPA competition rules. A jump is valid when the speed accuracy parameter is less than 3 m/s for the whole validation window duration.
Arguments
data : pd.DataFramce
Jumnp data in SSScoring format window : ssscoring.PerformanceWindow Performance window start, end values in named tuple format
Returns
JumpStatus.OKifdatareflects a valid jump according to ISC rules, where all speed accuracy values < 'SPEED_ACCURACY_THRESHOLD'.JumpStatus.SPEED_ACCURACY_EXCEEDS_LIMIT' ifdata` has one or more values within the validation window with a value >= 'SPEED_ACCURACY_THRESHOLD'.
Raises
SSScoringError' ifdata` has a length of zero or it's not initialized.
156def calculateDistance(start: tuple, end: tuple) -> float: 157 """ 158 Calculate the distance between two terrestrial coordinates points. 159 160 Arguments 161 --------- 162 start 163 A latitude, longitude tuple of floating point numbers. 164 165 end 166 A latitude, longitude tuple of floating point numbers. 167 168 Returns 169 ------- 170 The distance, in meters, between both points. 171 """ 172 return haversine(start, end, unit = Unit.METERS)
Calculate the distance between two terrestrial coordinates points.
Arguments
start
A latitude, longitude tuple of floating point numbers.
end
A latitude, longitude tuple of floating point numbers.
Returns
The distance, in meters, between both points.
175def convertFlySight2SSScoring(rawData: pd.DataFrame, 176 altitudeDZMeters = 0.0, 177 altitudeDZFt = 0.0): 178 """ 179 Converts a raw dataframe initialized from a FlySight CSV file into the 180 SSScoring file format. The SSScoring format uses more descriptive column 181 headers, adds the altitude in feet, and uses UNIX time instead of an ISO 182 string. 183 184 If both `altitudeDZMeters` and `altitudeDZFt` are zero then hMSL is used. 185 Otherwise, this function adjusts the effective altitude with the value. If 186 both meters and feet values are set this throws an error. 187 188 Arguments 189 --------- 190 rawData : pd.DataFrame 191 FlySight CSV input as a dataframe 192 193 altitudeDZMeters : float 194 Drop zone height above MSL 195 196 altitudeDZFt 197 Drop zone altitudde above MSL 198 199 Returns 200 ------- 201 A dataframe in SSScoring format, featuring these columns: 202 203 - timeUnix 204 - altitudeMSL 205 - altitudeAGL 206 - altitudeMSLFt 207 - altitudeAGLFt 208 - vMetersPerSecond 209 - vKMh (km/h) 210 - vAccelMS2 (m/s²) 211 - speedAccuracy (ignore; see ISC documentation) 212 - hMetersPerSecond 213 - hKMh (km/h) 214 - latitude 215 - longitude 216 - verticalAccuracy 217 - speedAccuracyISC 218 219 Errors 220 ------ 221 `SSScoringError` if the DZ altitude is set in both meters and feet. 222 """ 223 if not isinstance(rawData, pd.DataFrame): 224 raise SSScoringError('convertFlySight2SSScoring input must be a FlySight CSV dataframe') 225 226 if altitudeDZMeters and altitudeDZFt: 227 raise SSScoringError('Cannot set altitude in meters and feet; pick one') 228 229 if altitudeDZMeters: 230 altitudeDZFt = FT_IN_M*altitudeDZMeters 231 if altitudeDZFt: 232 altitudeDZMeters = altitudeDZFt/FT_IN_M 233 234 data = rawData.copy() 235 236 data['altitudeMSLFt'] = data['hMSL'].apply(lambda h: FT_IN_M*h) 237 data['altitudeAGL'] = data.hMSL-altitudeDZMeters 238 data['altitudeAGLFt'] = data.altitudeMSLFt-altitudeDZFt 239 data['timeUnix'] = np.round(data['time'].apply(lambda t: pd.Timestamp(t).timestamp()), decimals = 2) 240 data['hMetersPerSecond'] = (data.velE**2.0+data.velN**2.0)**0.5 241 speedAngle = data['hMetersPerSecond']/data['velD'] 242 speedAngle = np.round(90.0-speedAngle.apply(math.atan)/DEG_IN_RADIANS, decimals = 2) 243 speedAccuracyISC = np.round(data.vAcc.apply(lambda a: (2.0**0.5)*a/3.0), decimals = 2) 244 245 data = pd.DataFrame(data = { 246 'timeUnix': data.timeUnix, 247 'altitudeMSL': data.hMSL, 248 'altitudeAGL': data.altitudeAGL, 249 'altitudeMSLFt': data.altitudeMSLFt, 250 'altitudeAGLFt': data.altitudeAGLFt, 251 'vMetersPerSecond': data.velD, 252 'vKMh': 3.6*data.velD, 253 'speedAngle': speedAngle, 254 'speedAccuracy': data.sAcc, 255 'vAccelMS2': data.velD.diff()/data.timeUnix.diff(), 256 'hMetersPerSecond': data.hMetersPerSecond, 257 'hKMh': 3.6*data.hMetersPerSecond, 258 'latitude': data.lat, 259 'longitude': data.lon, 260 'verticalAccuracy': data.vAcc, 261 'speedAccuracyISC': speedAccuracyISC, 262 'velocityNorth': data.velN, 263 'velocityEast': data.velE, 264 }) 265 266 return data
Converts a raw dataframe initialized from a FlySight CSV file into the SSScoring file format. The SSScoring format uses more descriptive column headers, adds the altitude in feet, and uses UNIX time instead of an ISO string.
If both altitudeDZMeters and altitudeDZFt are zero then hMSL is used.
Otherwise, this function adjusts the effective altitude with the value. If
both meters and feet values are set this throws an error.
Arguments
rawData : pd.DataFrame
FlySight CSV input as a dataframe
altitudeDZMeters : float
Drop zone height above MSL
altitudeDZFt
Drop zone altitudde above MSL
Returns
A dataframe in SSScoring format, featuring these columns:
- timeUnix
- altitudeMSL
- altitudeAGL
- altitudeMSLFt
- altitudeAGLFt
- vMetersPerSecond
- vKMh (km/h)
- vAccelMS2 (m/s²)
- speedAccuracy (ignore; see ISC documentation)
- hMetersPerSecond
- hKMh (km/h)
- latitude
- longitude
- verticalAccuracy
- speedAccuracyISC
Errors
SSScoringError if the DZ altitude is set in both meters and feet.
277def getSpeedSkydiveFrom(data: pd.DataFrame) -> tuple: 278 """ 279 Take the skydive dataframe and get the speed skydiving data: 280 281 - Exit 282 - Speed skydiving window 283 - Drops data before exit and below breakoff altitude 284 285 Arguments 286 --------- 287 data : pd.DataFrame 288 Jump data in SSScoring format 289 290 Returns 291 ------- 292 A tuple of two elements: 293 294 - A named tuple with performance and validation window data 295 - A dataframe featuring only speed skydiving data 296 297 Warm up FlySight files and non-speed skydiving files may return invalid 298 values: 299 300 - `None` for the `PerformanceWindow` instance 301 - `data`, most likely empty 302 """ 303 if len(data): 304 data = _dataGroups(data) 305 groups = data.group.max()+1 306 307 freeFallGroup = -1 308 MIN_DATA_POINTS = 100 # heuristic 309 MIN_MAX_SPEED = 200 # km/h, heuristic; slower ::= no free fall 310 for group in range(groups): 311 subset = data[data.group == group] 312 if len(subset) >= MIN_DATA_POINTS and subset.vKMh.max() >= MIN_MAX_SPEED: 313 freeFallGroup = group 314 315 data = data[data.group == freeFallGroup] 316 data = data.drop('group', axis = 1).drop('positive', axis = 1) 317 318 data = data[data.altitudeAGL <= MAX_VALID_ELEVATION] 319 if len(data) > 0: 320 exitTime = data[data.vMetersPerSecond > EXIT_SPEED].head(1).timeUnix.iat[0] 321 data = data[data.timeUnix >= exitTime] 322 data = data[data.altitudeAGL >= BREAKOFF_ALTITUDE] 323 324 if len(data): 325 windowStart = data.iloc[0].altitudeAGL 326 windowEnd = windowStart-PERFORMANCE_WINDOW_LENGTH 327 if windowEnd < BREAKOFF_ALTITUDE: 328 windowEnd = BREAKOFF_ALTITUDE 329 330 validationWindowStart = windowEnd+VALIDATION_WINDOW_LENGTH 331 data = data[data.altitudeAGL >= windowEnd] 332 refN = float(data.velocityNorth.iloc[0]) 333 refE = float(data.velocityEast.iloc[0]) 334 refMag = (refN**2.0 + refE**2.0)**0.5 335 unitN, unitE = (refN/refMag, refE/refMag) if refMag > 0.0 else (1.0, 0.0) 336 signedHMPS = (data.velocityNorth*unitN + data.velocityEast*unitE).to_numpy(dtype=float, na_value=0.0) 337 vMS = data.vMetersPerSecond.to_numpy(dtype=float, na_value=0.0) 338 data = data.copy() 339 data['speedAngle'] = np.round( 340 np.where(signedHMPS == 0.0, 90.0, np.degrees(np.arctan(vMS/signedHMPS))), 341 decimals=2, 342 ) 343 data = data.drop(['velocityNorth', 'velocityEast',], axis=1) 344 performanceWindow = PerformanceWindow(windowStart, windowEnd, validationWindowStart) 345 else: 346 data = data.drop(['velocityNorth', 'velocityEast',], axis=1) 347 performanceWindow = None 348 349 return performanceWindow, data 350 else: 351 return None, data.drop(['velocityNorth', 'velocityEast',], axis=1)
Take the skydive dataframe and get the speed skydiving data:
- Exit
- Speed skydiving window
- Drops data before exit and below breakoff altitude
Arguments
data : pd.DataFrame
Jump data in SSScoring format
Returns
A tuple of two elements:
- A named tuple with performance and validation window data
- A dataframe featuring only speed skydiving data
Warm up FlySight files and non-speed skydiving files may return invalid values:
Nonefor thePerformanceWindowinstancedata, most likely empty
359def jumpAnalysisTable(data: pd.DataFrame) -> pd.DataFrame: 360 """ 361 Generates the HCD jump analysis table, with speed data at 5-second intervals 362 after exit. 363 364 Arguments 365 --------- 366 data : pd.DataFrame 367 Jump data in SSScoring format 368 369 Returns 370 ------- 371 A tuple with a pd.DataFrame and the max speed recorded for the jump: 372 373 - A table dataframe with time and speed 374 - a floating point number 375 """ 376 table = None 377 distanceStart = (data.iloc[0].latitude, data.iloc[0].longitude) 378 for column in pd.Series([ 5.0, 10.0, 15.0, 20.0, 25.0, ]): 379 for interval in range(int(column)*10, 10*(int(column)+1)): 380 # Use the next 0.1 sec interval if the current interval tranche has 381 # NaN values. 382 columnRef = interval/10.0 383 timeOffset = data.iloc[0].timeUnix+columnRef 384 tranche = data.query('timeUnix == %f' % timeOffset).copy() 385 tranche['time'] = [ column, ] 386 currentPosition = (tranche.iloc[0].latitude, tranche.iloc[0].longitude) 387 tranche['distanceFromExit'] = [ round(calculateDistance(distanceStart, currentPosition), 2), ] 388 if not tranche.isnull().any().any(): 389 break 390 391 if pd.isna(tranche.iloc[-1].vKMh): 392 tranche = data.tail(1).copy() 393 currentPosition = (tranche.iloc[0].latitude, tranche.iloc[0].longitude) 394 tranche['time'] = tranche.timeUnix-data.iloc[0].timeUnix 395 tranche['distanceFromExit'] = [ round(calculateDistance(distanceStart, currentPosition), 2), ] 396 397 if table is not None: 398 table = pd.concat([ table, tranche, ]) 399 else: 400 table = tranche 401 table = pd.DataFrame({ 402 'time': table.time, 403 'vKMh': table.vKMh, 404 'deltaV': table.vKMh.diff().fillna(table.vKMh), 405 'vAccel m/s²': _verticalAcceleration(table.vKMh, table.time), 406 'speedAngle': table.speedAngle, 407 'angularVel º/s': (table.speedAngle.diff()/table.time.diff()).fillna(table.speedAngle/TABLE_INTERVAL), 408 'deltaAngle': table.speedAngle.diff().fillna(table.speedAngle), 409 'hKMh': table.hKMh, 410 'distanceFromExit (m)': table.distanceFromExit, 411 'altitude (ft)': table.altitudeAGLFt, 412 }) 413 return table
Generates the HCD jump analysis table, with speed data at 5-second intervals after exit.
Arguments
data : pd.DataFrame
Jump data in SSScoring format
Returns
A tuple with a pd.DataFrame and the max speed recorded for the jump:
- A table dataframe with time and speed
- a floating point number
416def dropNonSkydiveDataFrom(data: pd.DataFrame) -> pd.DataFrame: 417 """ 418 Discards all data rows before maximum altitude, and all "negative" altitude 419 rows because we don't skydive underground (FlySight bug?). 420 421 This is a more accurate mean velocity calculation from a physics point of 422 view, but it differs from the ISC definition using in scoring - which, if we 423 get technical, is the one that counts. 424 425 Arguments 426 --------- 427 data : pd.DataFrame 428 Jump data in SSScoring format (headers differ from FlySight format) 429 430 Returns 431 ------- 432 The jump data for the skydive 433 """ 434 timeMaxAlt = data[data.altitudeAGL == data.altitudeAGL.max()].timeUnix.iloc[0] 435 data = data[data.timeUnix > timeMaxAlt] 436 437 data = data[data.altitudeAGL > 0] 438 439 return data
Discards all data rows before maximum altitude, and all "negative" altitude rows because we don't skydive underground (FlySight bug?).
This is a more accurate mean velocity calculation from a physics point of view, but it differs from the ISC definition using in scoring - which, if we get technical, is the one that counts.
Arguments
data : pd.DataFrame
Jump data in SSScoring format (headers differ from FlySight format)
Returns
The jump data for the skydive
442def calcScoreMeanVelocity(data: pd.DataFrame) -> tuple: 443 """ 444 Calculates the speeds over a 3-second interval as the mean of all the speeds 445 recorded within that 3-second window and resolves the maximum speed. 446 447 Arguments 448 --------- 449 data 450 A `pd.dataframe` with speed run data. 451 452 Returns 453 ------- 454 A `tuple` with the best score throughout the speed run, and a dicitionary 455 of the meanVSpeed:spotInTime used in determining the exact scoring speed 456 at every datat point during the speed run. 457 458 Notes 459 ----- 460 This implementation uses iteration instead of binning/factorization because 461 some implementers may be unfamiliar with data manipulation in dataframes 462 and this is a critical function that may be under heavy review. Future 463 versions may revert to dataframe and series/np.array factorization. 464 """ 465 scores = dict() 466 for spot in data.plotTime[::1]: 467 subset = data[(data.plotTime <= spot) & (data.plotTime >= (spot-SCORING_INTERVAL))] 468 scores[np.round(subset.vKMh.mean(), decimals = 2)] = spot 469 return (max(scores), scores)
Calculates the speeds over a 3-second interval as the mean of all the speeds recorded within that 3-second window and resolves the maximum speed.
Arguments
data
A pd.dataframe with speed run data.
Returns
A tuple with the best score throughout the speed run, and a dicitionary
of the meanVSpeed:spotInTime used in determining the exact scoring speed
at every datat point during the speed run.
Notes
This implementation uses iteration instead of binning/factorization because some implementers may be unfamiliar with data manipulation in dataframes and this is a critical function that may be under heavy review. Future versions may revert to dataframe and series/np.array factorization.
472def calcScoreISC(data: pd.DataFrame) -> tuple: 473 """ 474 Calculates the speeds over a 3-second interval as the ds/dt and dt is the 475 is a 3-second sliding interval from exit. The window slider moves along the 476 `plotTime` axis in the dataframe. 477 478 Arguments 479 --------- 480 data 481 A `pd.dataframe` with speed run data. 482 483 Returns 484 ------- 485 A `tuple` with the best score throughout the speed run, and a dicitionary 486 of the meanVSpeed:spotInTime used in determining the exact scoring speed 487 at every datat point during the speed run. 488 """ 489 scores = dict() 490 step = data.plotTime.diff().dropna().mode().iloc[0] 491 end = data.plotTime[-1:].iloc[0]-SCORING_INTERVAL 492 for spot in np.arange(0.0, end, step): 493 intervalStart = np.round(spot, decimals = 2) 494 intervalEnd = np.round(intervalStart+SCORING_INTERVAL, decimals = 2) 495 try: 496 h1 = data[data.plotTime == intervalStart].altitudeAGL.iloc[0] 497 h2 = data[data.plotTime == intervalEnd].altitudeAGL.iloc[0] 498 except IndexError: 499 # TODO: Decide whether to log the missing FlySight samples. 500 continue 501 intervalScore = np.round(MPS_2_KMH*abs(h1-h2)/SCORING_INTERVAL, decimals = 2) 502 scores[intervalScore] = intervalStart 503 return (max(scores), scores)
Calculates the speeds over a 3-second interval as the ds/dt and dt is the
is a 3-second sliding interval from exit. The window slider moves along the
plotTime axis in the dataframe.
Arguments
data
A pd.dataframe with speed run data.
Returns
A tuple with the best score throughout the speed run, and a dicitionary
of the meanVSpeed:spotInTime used in determining the exact scoring speed
at every datat point during the speed run.
506def jumpRunBearing(jumpData: pd.DataFrame, nSamples: int = JUMP_RUN_SAMPLES) -> float: 507 """ 508 Estimates the jump run bearing from the first `nSamples` rows of the 509 performance window using the aircraft's residual forward throw (ISC §5.1.3). 510 511 Arguments 512 --------- 513 jumpData : pd.DataFrame 514 Performance-window data in SSScoring format with `plotTime >= 0`. 515 516 nSamples : int 517 Number of post-exit samples to average. Default: `JUMP_RUN_SAMPLES` (15, 518 i.e. 3 seconds at 5 Hz). 519 520 Returns 521 ------- 522 Mean bearing in degrees [0, 360). 523 """ 524 samples = jumpData.head(nSamples) 525 lats = samples.latitude.to_numpy(dtype=float) 526 lons = samples.longitude.to_numpy(dtype=float) 527 sinSum = 0.0 528 cosSum = 0.0 529 count = 0 530 for i in range(len(lats) - 1): 531 lat1, lon1, lat2, lon2 = map(math.radians, [lats[i], lons[i], lats[i + 1], lons[i + 1]]) 532 dLon = lon2 - lon1 533 x = math.sin(dLon) * math.cos(lat2) 534 y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dLon) 535 if x != 0.0 or y != 0.0: 536 b = math.atan2(x, y) 537 sinSum += math.sin(b) 538 cosSum += math.cos(b) 539 count += 1 540 if count == 0: 541 return 0.0 542 return (math.degrees(math.atan2(sinSum / count, cosSum / count)) + 360.0) % 360.0
Estimates the jump run bearing from the first nSamples rows of the
performance window using the aircraft's residual forward throw (ISC §5.1.3).
Arguments
jumpData : pd.DataFrame
Performance-window data in SSScoring format with plotTime >= 0.
nSamples : int
Number of post-exit samples to average. Default: JUMP_RUN_SAMPLES (15,
i.e. 3 seconds at 5 Hz).
Returns
Mean bearing in degrees [0, 360).
545def forwardLateralDisplacement( 546 jumpData: pd.DataFrame, 547 exitLat: float, 548 exitLon: float, 549 bearing: float, 550) -> pd.DataFrame: 551 """ 552 Adds `forwardM` and `lateralM` columns to `jumpData`: signed displacement 553 in metres along and perpendicular to the jump run axis from the exit point. 554 Positive `forwardM` = moving away from exit; negative = reversed. 555 Positive `lateralM` = right of jump run; negative = left. 556 557 Arguments 558 --------- 559 jumpData : pd.DataFrame 560 Performance-window data in SSScoring format. 561 562 exitLat, exitLon : float 563 Exit point coordinates (latitude, longitude). 564 565 bearing : float 566 Jump run bearing in degrees [0, 360), typically from `jumpRunBearing()`. 567 568 Returns 569 ------- 570 Copy of `jumpData` with `forwardM` and `lateralM` columns appended. 571 """ 572 bearingRad = math.radians(bearing) 573 lats = jumpData.latitude.to_numpy(dtype=float) 574 lons = jumpData.longitude.to_numpy(dtype=float) 575 distances = np.array([ 576 calculateDistance((exitLat, exitLon), (lat, lon)) 577 for lat, lon in zip(lats, lons) 578 ]) 579 lat1Rad = math.radians(exitLat) 580 lon1Rad = math.radians(exitLon) 581 lat2Rad = np.radians(lats) 582 dLon = np.radians(lons) - lon1Rad 583 x = np.sin(dLon) * np.cos(lat2Rad) 584 y = math.cos(lat1Rad) * np.sin(lat2Rad) - math.sin(lat1Rad) * np.cos(lat2Rad) * np.cos(dLon) 585 ptBearings = np.arctan2(x, y) 586 deltas = ptBearings - bearingRad 587 result = jumpData.copy() 588 result['forwardM'] = np.round(distances * np.cos(deltas), decimals=2) 589 result['lateralM'] = np.round(distances * np.sin(deltas), decimals=2) 590 return result
Adds forwardM and lateralM columns to jumpData: signed displacement
in metres along and perpendicular to the jump run axis from the exit point.
Positive forwardM = moving away from exit; negative = reversed.
Positive lateralM = right of jump run; negative = left.
Arguments
jumpData : pd.DataFrame
Performance-window data in SSScoring format.
exitLat, exitLon : float
Exit point coordinates (latitude, longitude).
bearing : float
Jump run bearing in degrees [0, 360), typically from jumpRunBearing().
Returns
Copy of jumpData with forwardM and lateralM columns appended.
593def detectBackFall(jumpData: pd.DataFrame) -> dict: 594 """ 595 Detects whether the skydiver fell to their back during the performance 596 window and quantifies the severity using GPS ground track geometry. 597 598 A back-fall produces a reversal in the skydiver's displacement along the 599 jump run axis (forward reversal) or perpendicular to it (lateral reversal). 600 Both axes are checked; either non-zero reversal depth flags a back-fall. 601 602 Arguments 603 --------- 604 jumpData : pd.DataFrame 605 Performance-window data in SSScoring format, with `plotTime` column set 606 (i.e., called after `processJump` sets `plotTime`). 607 608 Returns 609 ------- 610 dict with keys: 611 612 - `backFall` : bool — `True` if any reversal detected 613 - `onsetTime` : float | None — `plotTime` at peak forward displacement; 614 `None` if no back-fall detected 615 - `forwardReversalM` : float — metres reversed along jump run axis (≥ 0) 616 - `lateralReversalM` : float — metres reversed on lateral axis (≥ 0) 617 """ 618 exitLat = float(jumpData.latitude.iloc[0]) 619 exitLon = float(jumpData.longitude.iloc[0]) 620 bearing = jumpRunBearing(jumpData) 621 df = forwardLateralDisplacement(jumpData, exitLat, exitLon, bearing) 622 forwardMax = float(df.forwardM.max()) 623 onsetIdx = df.forwardM.idxmax() 624 onsetTime = float(df.loc[onsetIdx].plotTime) 625 forwardReversalM = round(max(0.0, forwardMax - float(df.forwardM.iloc[-1])), 2) 626 lateralAbsMax = float(df.lateralM.abs().max()) 627 lateralReversalM = round(max(0.0, lateralAbsMax - float(df.lateralM.abs().iloc[-1])), 2) 628 backFall = forwardReversalM > 0.0 or lateralReversalM > 0.0 629 return { 630 'backFall': backFall, 631 'onsetTime': onsetTime if backFall else None, 632 'forwardReversalM': forwardReversalM, 633 'lateralReversalM': lateralReversalM, 634 }
Detects whether the skydiver fell to their back during the performance window and quantifies the severity using GPS ground track geometry.
A back-fall produces a reversal in the skydiver's displacement along the jump run axis (forward reversal) or perpendicular to it (lateral reversal). Both axes are checked; either non-zero reversal depth flags a back-fall.
Arguments
jumpData : pd.DataFrame
Performance-window data in SSScoring format, with plotTime column set
(i.e., called after processJump sets plotTime).
Returns
dict with keys:
backFall: bool —Trueif any reversal detectedonsetTime: float | None —plotTimeat peak forward displacement;Noneif no back-fall detectedforwardReversalM: float — metres reversed along jump run axis (≥ 0)lateralReversalM: float — metres reversed on lateral axis (≥ 0)
637def processJump(data: pd.DataFrame) -> JumpResults: 638 """ 639 Take a dataframe in SSScoring format and process it for display. It 640 serializes all the steps that would be taken from the ssscoring module, but 641 includes some text/HTML data in the output. 642 643 Arguments 644 --------- 645 data: pd.DataFrame 646 A dataframe in SSScoring format 647 648 Returns 649 ------- 650 A `JumpResults` named tuple with these items: 651 652 - `score` speed score 653 - `maxSpeed` maximum speed during the jump 654 - `scores` a Series of every 3-second window scores from exit to breakoff 655 - `data` an updated SSScoring dataframe `plotTime`, where 0 = exit, used 656 for plotting 657 - `window` a named tuple with the exit, breakoff, and validation window 658 altitudes 659 - `table` a dataframe featuring the speeds and altitudes at 5-sec intervals 660 - `color` a string that defines the color for the jump result; possible 661 values are _green_ for valid jump, _red_ for invalid jump, per ISC rules 662 - `result` a string with the legend of _valid_ or _invalid_ jump 663 """ 664 workData = data.copy() 665 workData = dropNonSkydiveDataFrom(workData) 666 window, workData = getSpeedSkydiveFrom(workData) 667 backFall = False 668 backFallOnset = None 669 forwardReversalM = 0.0 670 lateralReversalM = 0.0 671 if workData.empty and not window: 672 workData = None 673 maxSpeed = -1.0 674 score = -1.0 675 scores = None 676 table = None 677 window = None 678 jumpStatus = JumpStatus.WARM_UP_FILE 679 else: 680 jumpStatus = validateJumpISC(workData, window) 681 score = None 682 scores = None 683 table = None 684 baseTime = workData.iloc[0].timeUnix 685 workData['plotTime'] = round(workData.timeUnix-baseTime, 2) 686 if jumpStatus == JumpStatus.OK: 687 table = jumpAnalysisTable(workData) 688 maxSpeed = data.vKMh.max() 689 score, scores = calcScoreISC(workData) 690 backFallResult = detectBackFall(workData) 691 backFall = backFallResult['backFall'] 692 backFallOnset = backFallResult['onsetTime'] 693 forwardReversalM = backFallResult['forwardReversalM'] 694 lateralReversalM = backFallResult['lateralReversalM'] 695 else: 696 maxSpeed = -1 697 if not len(workData): 698 jumpStatus = JumpStatus.INVALID_SPEED_FILE 699 return JumpResults(workData, maxSpeed, score, scores, table, window, jumpStatus, backFall, backFallOnset, forwardReversalM, lateralReversalM)
Take a dataframe in SSScoring format and process it for display. It serializes all the steps that would be taken from the ssscoring module, but includes some text/HTML data in the output.
Arguments
data: pd.DataFrame
A dataframe in SSScoring format
Returns
A JumpResults named tuple with these items:
scorespeed scoremaxSpeedmaximum speed during the jumpscoresa Series of every 3-second window scores from exit to breakoffdataan updated SSScoring dataframeplotTime, where 0 = exit, used for plottingwindowa named tuple with the exit, breakoff, and validation window altitudestablea dataframe featuring the speeds and altitudes at 5-sec intervalscolora string that defines the color for the jump result; possible values are _green_ for valid jump, _red_ for invalid jump, per ISC rulesresulta string with the legend of _valid_ or _invalid_ jump
702def processAllJumpFiles(jumpFiles: list, altitudeDZMeters = 0.0) -> dict: 703 """ 704 Process all jump files in a list of valid FlySight files. Returns a 705 dictionary of jump results with a human-readable version of the file name. 706 The `jumpFiles` list can be generated by hand or the output of the 707 `ssscoring.fs1.getAllSpeedJumpFilesFrom` called to operate on a data lake. 708 709 Arguments 710 --------- 711 jumpFiles 712 A list of file things that could represent one of these: 713 - file things relative or absolute path names to individual FlySight CSV 714 files. 715 - A specialization of BytesIO, such as the bags of bytes that Streamlit.io 716 generates after uploading and reading a file into the Streamlit 717 environment 718 719 altitudeDZMeters : float 720 Drop zone height above MSL 721 722 Returns 723 ------- 724 dict 725 A dictionary of jump results. The key is a human-readable version of a 726 `jumpFile` name with the extension, path, and extraneous spaces eliminated 727 or replaced by appropriate characters. File names use Unicode, so accents 728 and non-ANSI characters are allowed in file names. 729 730 Raises 731 ------ 732 `SSScoringError` if the jumpFiles object is empty, or if the individual 733 objects in the list aren't `BytesIO`, file name strings, or `Path` 734 instances. 735 """ 736 jumpResults = dict() 737 if not len(jumpFiles): 738 raise SSScoringError('jumpFiles must have at least one element') 739 if not isinstance(jumpFiles, dict) and not isinstance(jumpFiles, list): 740 raise SSScoringError('dict with jump file names and FS versions or list of byte bags expected') 741 if isinstance(jumpFiles, dict): 742 objectsList = sorted(list(jumpFiles.keys()), key=str) 743 elif isinstance(jumpFiles, list): 744 objectsList = jumpFiles 745 obj = objectsList[0] 746 if not isinstance(obj, Path) and not isinstance(obj, str) and not isinstance(obj, BytesIO): 747 raise SSScoringError('jumpFiles must contain file-like things or BytesIO objects') 748 _v1Pattern = re.compile(r'^\d{2}-\d{2}-\d{2}\.CSV$', re.IGNORECASE) 749 for jumpFile in objectsList: 750 if isinstance(jumpFile, BytesIO): 751 fileName = jumpFile.name 752 if not _v1Pattern.match(fileName) and not fileName.upper().endswith('TRACK.CSV'): 753 continue 754 rawData, tag = getFlySightDataFromCSVBuffer(jumpFile.getvalue(), jumpFile.name) 755 elif isinstance(jumpFiles, dict) and isinstance(jumpFiles[jumpFile], pd.DataFrame): 756 rawData = jumpFiles[jumpFile] 757 tag = jumpFile 758 else: 759 rawData, tag = getFlySightDataFromCSVFileName(jumpFile) 760 try: 761 jumpResult = processJump(convertFlySight2SSScoring(rawData, altitudeDZMeters = altitudeDZMeters)) 762 except Exception: 763 jumpResult = JumpResults(None, 0.0, 0.0, None, None, None, JumpStatus.INVALID_SPEED_FILE) 764 jumpResults[tag] = jumpResult 765 return jumpResults
Process all jump files in a list of valid FlySight files. Returns a
dictionary of jump results with a human-readable version of the file name.
The jumpFiles list can be generated by hand or the output of the
ssscoring.fs1.getAllSpeedJumpFilesFrom called to operate on a data lake.
Arguments
jumpFiles
A list of file things that could represent one of these:
- file things relative or absolute path names to individual FlySight CSV files.
- A specialization of BytesIO, such as the bags of bytes that Streamlit.io generates after uploading and reading a file into the Streamlit environment
altitudeDZMeters : float
Drop zone height above MSL
Returns
dict
A dictionary of jump results. The key is a human-readable version of a
jumpFile name with the extension, path, and extraneous spaces eliminated
or replaced by appropriate characters. File names use Unicode, so accents
and non-ANSI characters are allowed in file names.
Raises
SSScoringError if the jumpFiles object is empty, or if the individual
objects in the list aren't BytesIO, file name strings, or Path
instances.
768def aggregateResults(jumpResults: dict) -> pd.DataFrame: 769 """ 770 Aggregate all the results in a table fashioned after Marco Hepp's and Nklas 771 Daniel's score tracking data. 772 773 Arguments 774 --------- 775 jumpResults: dict 776 A dictionary of jump results, in which each result corresponds to a FlySight 777 file name. See `ssscoring.processAllJumpFiles` for details. 778 779 Returns 780 ------- 781 A dataframe featuring these columns: 782 783 - Score 784 - Speeds at 5, 10, 15, 20, and 25 second tranches 785 - Final time contemplated in the analysis 786 - Max speed 787 788 The dataframe rows are identified by the human readable jump file name. 789 790 Raises 791 ------ 792 `SSScoringError` if the `jumpResults` object is empty. 793 """ 794 if not len(jumpResults): 795 raise SSScoringError('jumpResults is empty - impossible to collate angles') 796 797 speeds = pd.DataFrame() 798 for jumpResultIndex in sorted(list(jumpResults.keys())): 799 jumpResult = jumpResults[jumpResultIndex] 800 if jumpResult.status == JumpStatus.OK: 801 t = jumpResult.table.copy() 802 finalTime = t.iloc[-1].time 803 804 if finalTime > 20.1: 805 finalSpeed = t.iloc[-1].vKMh 806 t.iloc[-1].time = LAST_TIME_TRANCHE # keep LAST_TIME_TRANCHE for pivoting 807 else: 808 finalSpeed = None 809 810 t = pd.pivot_table(t, columns=t.time) 811 t.columns = [str(c) for c in t.columns] 812 d = pd.DataFrame([jumpResult.score], index=[jumpResultIndex], columns=['score']) 813 814 for column in t.columns: 815 d[column] = t[column].vKMh 816 817 d['finalTime'] = [finalTime] 818 d['maxSpeed'] = jumpResult.maxSpeed 819 820 if finalSpeed is not None: 821 d['finalSpeed'] = [finalSpeed] 822 else: 823 d['finalSpeed'] = [d.get('25.0', [0.0])[0]] 824 825 if speeds.empty: 826 speeds = d.copy() 827 else: 828 speeds = pd.concat([speeds, d]) 829 830 cols = ['score', '5.0', '10.0', '15.0', '20.0', 'finalSpeed', 'finalTime', 'maxSpeed'] 831 speeds = speeds[[c for c in cols if c in speeds.columns]] 832 speeds = speeds.replace(np.nan, 0.0) 833 return speeds.sort_index()
Aggregate all the results in a table fashioned after Marco Hepp's and Nklas Daniel's score tracking data.
Arguments
jumpResults: dict
A dictionary of jump results, in which each result corresponds to a FlySight
file name. See ssscoring.processAllJumpFiles for details.
Returns
A dataframe featuring these columns:
- Score
- Speeds at 5, 10, 15, 20, and 25 second tranches
- Final time contemplated in the analysis
- Max speed
The dataframe rows are identified by the human readable jump file name.
Raises
SSScoringError if the jumpResults object is empty.
836def roundedAggregateResults(aggregate: pd.DataFrame) -> pd.DataFrame: 837 """ 838 Aggregate all the results in a table fashioned after Marco Hepp's and Nklas 839 Daniel's score tracking data. All speed results are rounded at `n > x.5` 840 for any value. 841 842 Arguments 843 --------- 844 aggregate: pd.DataFrame 845 A dataframe output of `ssscoring.fs1.aggregateResults`. 846 847 Returns 848 ------- 849 A dataframe featuring the **rounded values** for these columns: 850 851 - Score 852 - Speeds at 5, 10, 15, 20, and 25 second tranches 853 - Max speed 854 855 The `finalTime` column is ignored. 856 857 The dataframe rows are identified by the human readable jump file name. 858 859 This is a less precise version of the `ssscoring.aggregateResults` 860 dataframe, useful during training to keep rounded results available for 861 review. 862 863 Raises 864 ------ 865 `SSSCoringError` if the `jumpResults` object is empty. 866 """ 867 for column in [col for col in aggregate.columns if 'Time' not in str(col)]: 868 aggregate[column] = aggregate[column].apply(round) 869 870 return aggregate
Aggregate all the results in a table fashioned after Marco Hepp's and Nklas
Daniel's score tracking data. All speed results are rounded at n > x.5
for any value.
Arguments
aggregate: pd.DataFrame
A dataframe output of ssscoring.fs1.aggregateResults.
Returns
A dataframe featuring the rounded values for these columns:
- Score
- Speeds at 5, 10, 15, 20, and 25 second tranches
- Max speed
The finalTime column is ignored.
The dataframe rows are identified by the human readable jump file name.
This is a less precise version of the ssscoring.aggregateResults
dataframe, useful during training to keep rounded results available for
review.
Raises
SSSCoringError if the jumpResults object is empty.
873def collateAnglesByTimeFromExit(jumpResults: dict) -> pd.DataFrame: 874 """ 875 Collate all the angles by time from the `jumpResults` into a dataframe that 876 features the jump tag as index, the time tranches and the angles at each 877 time tranche. 878 879 Arguments 880 --------- 881 jumpResults: dict 882 A dictionary of jump results, in which each result corresponds to a FlySight 883 file name. See `ssscoring.processAllJumpFiles` for details. 884 885 Returns 886 ------- 887 A dataframe featuring these columns: 888 889 - Score 890 - Angles at 5, 10, 15, 20, and 25 second tranches 891 - Final time contemplated in the analysis 892 893 Raises 894 ------ 895 `SSSCoringError` if the `jumpResults` object is empty. 896 """ 897 if not len(jumpResults): 898 raise SSScoringError('jumpResults is empty - impossible to collate angles') 899 900 angles = pd.DataFrame() 901 for jumpResultIndex in sorted(list(jumpResults.keys())): 902 jumpResult = jumpResults[jumpResultIndex] 903 if jumpResult.status == JumpStatus.OK: 904 t = jumpResult.table.copy() # ← critical: avoid mutation 905 906 finalTime = t.iloc[-1].time 907 finalAngle = t.iloc[-1].speedAngle 908 909 t.iloc[-1].time = LAST_TIME_TRANCHE 910 911 t = pd.pivot_table(t, columns=t.time) 912 t.columns = [str(c) for c in t.columns] 913 d = pd.DataFrame([jumpResult.score], index=[jumpResultIndex], columns=['score']) 914 915 for column in t.columns: 916 d[column] = t[column].speedAngle 917 918 d['finalTime'] = [finalTime] 919 d['finalAngle'] = [finalAngle] # ← new clean column 920 921 if angles.empty: 922 angles = d.copy() 923 else: 924 angles = pd.concat([angles, d]) 925 926 cols = ['score', '5.0', '10.0', '15.0', '20.0', 'finalAngle', 'finalTime'] 927 angles = angles[[c for c in cols if c in angles.columns]] 928 angles = angles.replace(np.nan, 0.0) 929 930 return angles.sort_index()
Collate all the angles by time from the jumpResults into a dataframe that
features the jump tag as index, the time tranches and the angles at each
time tranche.
Arguments
jumpResults: dict
A dictionary of jump results, in which each result corresponds to a FlySight
file name. See ssscoring.processAllJumpFiles for details.
Returns
A dataframe featuring these columns:
- Score
- Angles at 5, 10, 15, 20, and 25 second tranches
- Final time contemplated in the analysis
Raises
SSSCoringError if the jumpResults object is empty.
933def totalResultsFrom(aggregate: pd.DataFrame) -> pd.DataFrame: 934 """ 935 Calculates the total and mean speeds for an aggregation of speed jumps. 936 937 Arguments 938 --------- 939 aggregate: pd.DataFrame 940 The aggregate results dataframe resulting from calling `ssscoring.aggregateResults` 941 with valid results. 942 943 Returns 944 ------- 945 A dataframe with one row and two columns: 946 947 - totalSpeed ::= the sum of all speeds in the aggregated results 948 - meanSpeed ::= the mean of all speeds 949 - maxSpeed := the absolute max speed over the speed runs set 950 - meanSpeedSTD := scored speeds standar deviation 951 - maxScore ::= the max score among all the speed scores 952 - maxScoreSTD := the max scores standard deviation 953 954 Raises 955 ------ 956 `AttributeError` if aggregate is an empty dataframe or `None`, or if the 957 `aggregate` dataframe doesn't conform to the output of `ssscoring.aggregateResults`. 958 """ 959 if aggregate is None: 960 raise AttributeError('aggregate dataframe is empty') 961 elif isinstance(aggregate, pd.DataFrame) and not len(aggregate): 962 raise AttributeError('aggregate dataframe is empty') 963 964 totals = pd.DataFrame({ 965 'totalScore': [ round(aggregate.score.sum(), 2), ], 966 'mean': [ round(aggregate.score.mean(), 2), ], 967 'deviation': [ round(aggregate.score.std(), 2), ], 968 'maxScore': [ round(aggregate.score.max(), 2), ], 969 'absMaxSpeed': [ round(aggregate.maxSpeed.max(), 2), ], }, index = [ 'totalSpeed'],) 970 return totals
Calculates the total and mean speeds for an aggregation of speed jumps.
Arguments
aggregate: pd.DataFrame
The aggregate results dataframe resulting from calling ssscoring.aggregateResults
with valid results.
Returns
A dataframe with one row and two columns:
- totalSpeed ::= the sum of all speeds in the aggregated results
- meanSpeed ::= the mean of all speeds
- maxSpeed := the absolute max speed over the speed runs set
- meanSpeedSTD := scored speeds standar deviation
- maxScore ::= the max score among all the speed scores
- maxScoreSTD := the max scores standard deviation
Raises
AttributeError if aggregate is an empty dataframe or None, or if the
aggregate dataframe doesn't conform to the output of ssscoring.aggregateResults.