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 io import StringIO 10from pathlib import Path 11 12from haversine import haversine 13from haversine import Unit 14 15from ssscoring.constants import BREAKOFF_ALTITUDE 16from ssscoring.constants import DEG_IN_RADIANS 17from ssscoring.constants import EXIT_SPEED 18from ssscoring.constants import FLYSIGHT_FILE_ENCODING 19from ssscoring.constants import FT_IN_M 20from ssscoring.constants import KMH_AS_MS 21from ssscoring.constants import LAST_TIME_TRANCHE 22from ssscoring.constants import MAX_ALTITUDE_METERS 23from ssscoring.constants import MAX_VALID_ELEVATION 24from ssscoring.constants import MPS_2_KMH 25from ssscoring.constants import PERFORMANCE_WINDOW_LENGTH 26from ssscoring.constants import SCORING_INTERVAL 27from ssscoring.constants import SPEED_ACCURACY_THRESHOLD 28from ssscoring.constants import TABLE_INTERVAL 29from ssscoring.constants import VALIDATION_WINDOW_LENGTH 30from ssscoring.datatypes import JumpResults 31from ssscoring.datatypes import JumpStatus 32from ssscoring.datatypes import PerformanceWindow 33from ssscoring.errors import SSScoringError 34from ssscoring.flysight import FlySightVersion 35from ssscoring.flysight import detectFlySightFileVersionOf 36 37import math 38 39import numpy as np 40import pandas as pd 41 42 43# +++ functions +++ 44 45def isValidMinimumAltitude(altitude: float) -> bool: 46 """ 47 Reports whether an `altitude` is below the IPC and USPA valid parameters, 48 or within `BREAKOFF_ALTITUDE` and `PERFORMACE_WINDOW_LENGTH`. In invalid 49 altitude doesn't invalidate a FlySight data file. This function can be used 50 for generating warnings. The stock FlySightViewer scores a speed jump even 51 if the exit was below the minimum altitude. 52 53 See: FAI Competition Rules Speed Skydiving section 5.3 for details. 54 55 Arguments 56 --------- 57 altitude 58 An altitude in meters, calculated as data.hMSL - DZ altitude. 59 60 Returns 61 ------- 62 `True` if the altitude is valid. 63 """ 64 if not isinstance(altitude, float): 65 altitude = float(altitude) 66 minAltitude = BREAKOFF_ALTITUDE+PERFORMANCE_WINDOW_LENGTH 67 return altitude >= minAltitude 68 69 70def isValidMaximumAltitude(altitude: float) -> bool: 71 """ 72 Reports whether an `altitude` is above the maximum altitude allowed by the 73 rules. 74 75 See: FAI Competition Rules Speed Skydiving section 5.3 for details. 76 77 Arguments 78 --------- 79 altitude 80 An altitude in meters, calculated as data.hMSL - DZ altitude. 81 82 Returns 83 ------- 84 `True` if the altitude is valid. 85 86 See 87 --- 88 `ssscoring.constants.MAX_ALTITUDE_FT` 89 `ssscoring.constants.MAX_ALTITUDE_METERS` 90 """ 91 if not isinstance(altitude, float): 92 altitude = float(altitude) 93 return altitude <= MAX_ALTITUDE_METERS 94 95 96def isValidJumpISC(data: pd.DataFrame, 97 window: PerformanceWindow) -> bool: 98 """ 99 Validates the jump according to ISC/FAI/USPA competition rules. A jump is 100 valid when the speed accuracy parameter is less than 3 m/s for the whole 101 validation window duration. 102 103 Arguments 104 --------- 105 data : pd.DataFramce 106 Jumnp data in SSScoring format 107 window : ssscoring.PerformanceWindow 108 Performance window start, end values in named tuple format 109 110 Returns 111 ------- 112 `True` if the jump is valid according to ISC/FAI/USPA rules. 113 """ 114 if len(data) > 0: 115 accuracy = data[data.altitudeAGL <= window.validationStart].speedAccuracyISC.max() 116 return accuracy < SPEED_ACCURACY_THRESHOLD 117 else: 118 return False 119 120 121def calculateDistance(start: tuple, end: tuple) -> float: 122 """ 123 Calculate the distance between two terrestrial coordinates points. 124 125 Arguments 126 --------- 127 start 128 A latitude, longitude tuple of floating point numbers. 129 130 end 131 A latitude, longitude tuple of floating point numbers. 132 133 Returns 134 ------- 135 The distance, in meters, between both points. 136 """ 137 return haversine(start, end, unit = Unit.METERS) 138 139 140def convertFlySight2SSScoring(rawData: pd.DataFrame, 141 altitudeDZMeters = 0.0, 142 altitudeDZFt = 0.0): 143 """ 144 Converts a raw dataframe initialized from a FlySight CSV file into the 145 SSScoring file format. The SSScoring format uses more descriptive column 146 headers, adds the altitude in feet, and uses UNIX time instead of an ISO 147 string. 148 149 If both `altitudeDZMeters` and `altitudeDZFt` are zero then hMSL is used. 150 Otherwise, this function adjusts the effective altitude with the value. If 151 both meters and feet values are set this throws an error. 152 153 Arguments 154 --------- 155 rawData : pd.DataFrame 156 FlySight CSV input as a dataframe 157 158 altitudeDZMeters : float 159 Drop zone height above MSL 160 161 altitudeDZFt 162 Drop zone altitudde above MSL 163 164 Returns 165 ------- 166 A dataframe in SSScoring format, featuring these columns: 167 168 - timeUnix 169 - altitudeMSL 170 - altitudeAGL 171 - altitudeMSLFt 172 - altitudeAGLFt 173 - hMetersPerSecond 174 - hKMh (km/h) 175 - vMetersPerSecond 176 - vKMh (km/h) 177 - angle 178 - speedAccuracy (ignore; see ISC documentation) 179 - hMetersPerSecond 180 - hKMh 181 - latitude 182 - longitude 183 - verticalAccuracy 184 - speedAccuracyISC 185 186 Errors 187 ------ 188 `SSScoringError` if the DZ altitude is set in both meters and feet. 189 """ 190 if not isinstance(rawData, pd.DataFrame): 191 raise SSScoringError('convertFlySight2SSScoring input must be a FlySight CSV dataframe') 192 193 if altitudeDZMeters and altitudeDZFt: 194 raise SSScoringError('Cannot set altitude in meters and feet; pick one') 195 196 if altitudeDZMeters: 197 altitudeDZFt = FT_IN_M*altitudeDZMeters 198 if altitudeDZFt: 199 altitudeDZMeters = altitudeDZFt/FT_IN_M 200 201 data = rawData.copy() 202 203 data['altitudeMSLFt'] = data['hMSL'].apply(lambda h: FT_IN_M*h) 204 data['altitudeAGL'] = data.hMSL-altitudeDZMeters 205 data['altitudeAGLFt'] = data.altitudeMSLFt-altitudeDZFt 206 data['timeUnix'] = np.round(data['time'].apply(lambda t: pd.Timestamp(t).timestamp()), decimals = 2) 207 data['hMetersPerSecond'] = (data.velE**2.0+data.velN**2.0)**0.5 208 speedAngle = data['hMetersPerSecond']/data['velD'] 209 speedAngle = np.round(90.0-speedAngle.apply(math.atan)/DEG_IN_RADIANS, decimals = 2) 210 speedAccuracyISC = np.round(data.vAcc.apply(lambda a: (2.0**0.5)*a/3.0), decimals = 2) 211 212 data = pd.DataFrame(data = { 213 'timeUnix': data.timeUnix, 214 'altitudeMSL': data.hMSL, 215 'altitudeAGL': data.altitudeAGL, 216 'altitudeMSLFt': data.altitudeMSLFt, 217 'altitudeAGLFt': data.altitudeAGLFt, 218 'vMetersPerSecond': data.velD, 219 'vKMh': 3.6*data.velD, 220 'speedAngle': speedAngle, 221 'speedAccuracy': data.sAcc, 222 'hMetersPerSecond': data.hMetersPerSecond, 223 'hKMh': 3.6*data.hMetersPerSecond, 224 'latitude': data.lat, 225 'longitude': data.lon, 226 'verticalAccuracy': data.vAcc, 227 'speedAccuracyISC': speedAccuracyISC, 228 }) 229 230 return data 231 232 233def _dataGroups(data): 234 data_ = data.copy() 235 data_['positive'] = (data_.vMetersPerSecond > 0) 236 data_['group'] = (data_.positive != data_.positive.shift(1)).astype(int).cumsum()-1 237 238 return data_ 239 240 241def getSpeedSkydiveFrom(data: pd.DataFrame) -> tuple: 242 """ 243 Take the skydive dataframe and get the speed skydiving data: 244 245 - Exit 246 - Speed skydiving window 247 - Drops data before exit and below breakoff altitude 248 249 Arguments 250 --------- 251 data : pd.DataFrame 252 Jump data in SSScoring format 253 254 Returns 255 ------- 256 A tuple of two elements: 257 258 - A named tuple with performance and validation window data 259 - A dataframe featuring only speed skydiving data 260 261 Warm up FlySight files and non-speed skydiving files may return invalid 262 values: 263 264 - `None` for the `PerformanceWindow` instance 265 - `data`, most likely empty 266 """ 267 if len(data): 268 data = _dataGroups(data) 269 groups = data.group.max()+1 270 271 freeFallGroup = -1 272 MIN_DATA_POINTS = 100 # heuristic 273 MIN_MAX_SPEED = 200 # km/h, heuristic; slower ::= no free fall 274 for group in range(groups): 275 subset = data[data.group == group] 276 if len(subset) >= MIN_DATA_POINTS and subset.vKMh.max() >= MIN_MAX_SPEED: 277 freeFallGroup = group 278 279 data = data[data.group == freeFallGroup] 280 data = data.drop('group', axis = 1).drop('positive', axis = 1) 281 282 data = data[data.altitudeAGL <= MAX_VALID_ELEVATION] 283 if len(data) > 0: 284 exitTime = data[data.vMetersPerSecond > EXIT_SPEED].head(1).timeUnix.iat[0] 285 data = data[data.timeUnix >= exitTime] 286 data = data[data.altitudeAGL >= BREAKOFF_ALTITUDE] 287 288 if len(data): 289 windowStart = data.iloc[0].altitudeAGL 290 windowEnd = windowStart-PERFORMANCE_WINDOW_LENGTH 291 if windowEnd < BREAKOFF_ALTITUDE: 292 windowEnd = BREAKOFF_ALTITUDE 293 294 validationWindowStart = windowEnd+VALIDATION_WINDOW_LENGTH 295 data = data[data.altitudeAGL >= windowEnd] 296 performanceWindow = PerformanceWindow(windowStart, windowEnd, validationWindowStart) 297 else: 298 performanceWindow = None 299 300 return performanceWindow, data 301 else: 302 return None, data 303 304 305def _verticalAcceleration(vKMh: pd.Series, time: pd.Series, interval=TABLE_INTERVAL) -> pd.Series: 306 vAcc = ((vKMh/KMH_AS_MS).diff()/time.diff()).fillna(vKMh/KMH_AS_MS/interval) 307 return vAcc 308 309 310def jumpAnalysisTable(data: pd.DataFrame) -> pd.DataFrame: 311 """ 312 Generates the HCD jump analysis table, with speed data at 5-second intervals 313 after exit. 314 315 Arguments 316 --------- 317 data : pd.DataFrame 318 Jump data in SSScoring format 319 320 Returns 321 ------- 322 A tuple with a pd.DataFrame and the max speed recorded for the jump: 323 324 - A table dataframe with time and speed 325 - a floating point number 326 """ 327 table = None 328 distanceStart = (data.iloc[0].latitude, data.iloc[0].longitude) 329 for column in pd.Series([ 5.0, 10.0, 15.0, 20.0, 25.0, ]): 330 for interval in range(int(column)*10, 10*(int(column)+1)): 331 # Use the next 0.1 sec interval if the current interval tranche has 332 # NaN values. 333 columnRef = interval/10.0 334 timeOffset = data.iloc[0].timeUnix+columnRef 335 tranche = data.query('timeUnix == %f' % timeOffset).copy() 336 tranche['time'] = [ column, ] 337 currentPosition = (tranche.iloc[0].latitude, tranche.iloc[0].longitude) 338 tranche['distanceFromExit'] = [ round(calculateDistance(distanceStart, currentPosition), 2), ] 339 if not tranche.isnull().any().any(): 340 break 341 342 if pd.isna(tranche.iloc[-1].vKMh): 343 tranche = data.tail(1).copy() 344 currentPosition = (tranche.iloc[0].latitude, tranche.iloc[0].longitude) 345 tranche['time'] = tranche.timeUnix-data.iloc[0].timeUnix 346 tranche['distanceFromExit'] = [ round(calculateDistance(distanceStart, currentPosition), 2), ] 347 348 if table is not None: 349 table = pd.concat([ table, tranche, ]) 350 else: 351 table = tranche 352 table = pd.DataFrame({ 353 'time': table.time, 354 'vKMh': table.vKMh, 355 'deltaV': table.vKMh.diff().fillna(table.vKMh), 356 'vAccel m/s²': _verticalAcceleration(table.vKMh, table.time), 357 'speedAngle': table.speedAngle, 358 'angularVel º/s': (table.speedAngle.diff()/table.time.diff()).fillna(table.speedAngle/TABLE_INTERVAL), 359 'deltaAngle': table.speedAngle.diff().fillna(table.speedAngle), 360 'hKMh': table.hKMh, 361 'distanceFromExit (m)': table.distanceFromExit, 362 'altitude (ft)': table.altitudeAGLFt, 363 }) 364 return table 365 366 367def dropNonSkydiveDataFrom(data: pd.DataFrame) -> pd.DataFrame: 368 """ 369 Discards all data rows before maximum altitude, and all "negative" altitude 370 rows because we don't skydive underground (FlySight bug?). 371 372 This is a more accurate mean velocity calculation from a physics point of 373 view, but it differs from the ISC definition using in scoring - which, if we 374 get technical, is the one that counts. 375 376 Arguments 377 --------- 378 data : pd.DataFrame 379 Jump data in SSScoring format (headers differ from FlySight format) 380 381 Returns 382 ------- 383 The jump data for the skydive 384 """ 385 timeMaxAlt = data[data.altitudeAGL == data.altitudeAGL.max()].timeUnix.iloc[0] 386 data = data[data.timeUnix > timeMaxAlt] 387 388 data = data[data.altitudeAGL > 0] 389 390 return data 391 392 393def calcScoreMeanVelocity(data: pd.DataFrame) -> tuple: 394 """ 395 Calculates the speeds over a 3-second interval as the mean of all the speeds 396 recorded within that 3-second window and resolves the maximum speed. 397 398 Arguments 399 --------- 400 data 401 A `pd.dataframe` with speed run data. 402 403 Returns 404 ------- 405 A `tuple` with the best score throughout the speed run, and a dicitionary 406 of the meanVSpeed:spotInTime used in determining the exact scoring speed 407 at every datat point during the speed run. 408 409 Notes 410 ----- 411 This implementation uses iteration instead of binning/factorization because 412 some implementers may be unfamiliar with data manipulation in dataframes 413 and this is a critical function that may be under heavy review. Future 414 versions may revert to dataframe and series/np.array factorization. 415 """ 416 scores = dict() 417 for spot in data.plotTime[::1]: 418 subset = data[(data.plotTime <= spot) & (data.plotTime >= (spot-SCORING_INTERVAL))] 419 scores[np.round(subset.vKMh.mean(), decimals = 2)] = spot 420 return (max(scores), scores) 421 422 423def calcScoreISC(data: pd.DataFrame) -> tuple: 424 """ 425 Calculates the speeds over a 3-second interval as the ds/dt and dt is the 426 is a 3-second sliding interval from exit. The window slider moves along the 427 `plotTime` axis in the dataframe. 428 429 Arguments 430 --------- 431 data 432 A `pd.dataframe` with speed run data. 433 434 Returns 435 ------- 436 A `tuple` with the best score throughout the speed run, and a dicitionary 437 of the meanVSpeed:spotInTime used in determining the exact scoring speed 438 at every datat point during the speed run. 439 """ 440 scores = dict() 441 step = data.plotTime.diff().dropna().mode().iloc[0] 442 end = data.plotTime[-1:].iloc[0]-SCORING_INTERVAL 443 for spot in np.arange(0.0, end, step): 444 intervalStart = np.round(spot, decimals = 2) 445 intervalEnd = np.round(intervalStart+SCORING_INTERVAL, decimals = 2) 446 try: 447 h1 = data[data.plotTime == intervalStart].altitudeAGL.iloc[0] 448 h2 = data[data.plotTime == intervalEnd].altitudeAGL.iloc[0] 449 except IndexError: 450 # TODO: Decide whether to log the missing FlySight samples. 451 continue 452 intervalScore = np.round(MPS_2_KMH*abs(h1-h2)/SCORING_INTERVAL, decimals = 2) 453 scores[intervalScore] = intervalStart 454 return (max(scores), scores) 455 456 457def processJump(data: pd.DataFrame) -> JumpResults: 458 """ 459 Take a dataframe in SSScoring format and process it for display. It 460 serializes all the steps that would be taken from the ssscoring module, but 461 includes some text/HTML data in the output. 462 463 Arguments 464 --------- 465 data: pd.DataFrame 466 A dataframe in SSScoring format 467 468 Returns 469 ------- 470 A `JumpResults` named tuple with these items: 471 472 - `score` speed score 473 - `maxSpeed` maximum speed during the jump 474 - `scores` a Series of every 3-second window scores from exit to breakoff 475 - `data` an updated SSScoring dataframe `plotTime`, where 0 = exit, used 476 for plotting 477 - `window` a named tuple with the exit, breakoff, and validation window 478 altitudes 479 - `table` a dataframe featuring the speeds and altitudes at 5-sec intervals 480 - `color` a string that defines the color for the jump result; possible 481 values are _green_ for valid jump, _red_ for invalid jump, per ISC rules 482 - `result` a string with the legend of _valid_ or _invalid_ jump 483 """ 484 workData = data.copy() 485 workData = dropNonSkydiveDataFrom(workData) 486 window, workData = getSpeedSkydiveFrom(workData) 487 if workData.empty and not window: 488 workData = None 489 maxSpeed = -1.0 490 score = -1.0 491 scores = None 492 table = None 493 window = None 494 jumpStatus = JumpStatus.WARM_UP_FILE 495 else: 496 validJump = isValidJumpISC(workData, window) 497 jumpStatus = JumpStatus.OK 498 score = None 499 scores = None 500 table = None 501 if validJump: 502 table = None 503 table = jumpAnalysisTable(workData) 504 maxSpeed = data.vKMh.max() 505 baseTime = workData.iloc[0].timeUnix 506 workData['plotTime'] = round(workData.timeUnix-baseTime, 2) 507 score, scores = calcScoreISC(workData) 508 else: 509 maxSpeed = -1 510 if len(workData): 511 jumpStatus = JumpStatus.SPEED_ACCURACY_EXCEEDS_LIMIT 512 else: 513 jumpStatus = JumpStatus.INVALID_SPEED_FILE 514 return JumpResults(workData, maxSpeed, score, scores, table, window, jumpStatus) 515 516 517def _readVersion1CSV(fileThing: str) -> pd.DataFrame: 518 return pd.read_csv(fileThing, skiprows = (1, 1), index_col = False) 519 520 521def _tagVersion1From(fileThing: str) -> str: 522 return fileThing.replace('.CSV', '').replace('.csv', '').replace('/data', '').replace('/', ' ').strip()+':v1' 523 524 525def _tagVersion2From(fileThing: str) -> str: 526 if '/' in fileThing: 527 return fileThing.split('/')[-2]+':v2' 528 else: 529 return fileThing.replace('.CSV', '').replace('.csv', '')+':v2' 530 531 532 533def _readVersion2CSV(jumpFile: str) -> pd.DataFrame: 534 from ssscoring.constants import FLYSIGHT_2_HEADER 535 from ssscoring.flysight import skipOverFS2MetadataRowsIn 536 537 rawData = pd.read_csv(jumpFile, names = FLYSIGHT_2_HEADER, skiprows = 6, index_col = False) 538 rawData = skipOverFS2MetadataRowsIn(rawData) 539 rawData.drop('GNSS', inplace = True, axis = 1) 540 return rawData 541 542 543def getFlySightDataFromCSVBuffer(buffer:bytes, bufferName:str) -> tuple: 544 """ 545 Ingress a buffer with known FlySight or SkyTrax file data for SSScoring 546 processing. 547 548 Arguments 549 --------- 550 buffer 551 A binary data buffer, bag of bytes, containing a known FlySight track file. 552 553 bufferName 554 An arbitrary name for the buffer of type `str`. It's used for constructing 555 the full buffer tag value for human identification. 556 557 Returns 558 ------- 559 A `tuple` with two items: 560 - `rawData` - a dataframe representation of the CSV with the original 561 headers but without the data type header 562 - `tag` - a string with an identifying tag derived from the path name 563 and file version in the form `some name:vX`. It uses the current 564 path as metadata to infer the name. There's no semantics enforcement. 565 566 Raises 567 ------ 568 `SSScoringError` if the CSV file is invalid in any way. 569 """ 570 if not isinstance(buffer, bytes): 571 raise SSScoringError('buffer must be an instance of bytes, a bytes buffer') 572 try: 573 stringIO = StringIO(buffer.decode(FLYSIGHT_FILE_ENCODING)) 574 except Exception as e: 575 raise SSScoringError('invalid buffer endcoding - %s' % str(e)) 576 version = detectFlySightFileVersionOf(buffer) 577 if version == FlySightVersion.V1: 578 rawData = _readVersion1CSV(stringIO) 579 tag = _tagVersion1From(bufferName) 580 elif version == FlySightVersion.V2: 581 rawData = _readVersion2CSV(stringIO) 582 tag = _tagVersion2From(bufferName) 583 return (rawData, tag) 584 585 586def getFlySightDataFromCSVFileName(jumpFile) -> tuple: 587 """ 588 Ingress a known FlySight or SkyTrax file into memory for SSScoring 589 processing. 590 591 Arguments 592 --------- 593 jumpFile 594 A string or `pathlib.Path` object; can be a relative or an asbolute path. 595 596 Returns 597 ------- 598 A `tuple` with two items: 599 - `rawData` - a dataframe representation of the CSV with the original 600 headers but without the data type header 601 - `tag` - a string with an identifying tag derived from the path name 602 and file version in the form `some name:vX`. It uses the current 603 path as metadata to infer the name. There's no semantics enforcement. 604 605 Raises 606 ------ 607 `SSScoringError` if the CSV file is invalid in any way. 608 """ 609 from ssscoring.flysight import validFlySightHeaderIn 610 611 if isinstance(jumpFile, Path): 612 jumpFile = jumpFile.as_posix() 613 elif isinstance(jumpFile, str): 614 pass 615 else: 616 raise SSScoringError('jumpFile must be a string or a Path object') 617 if not validFlySightHeaderIn(jumpFile): 618 raise SSScoringError('%s is an invalid speed skydiving file') 619 version = detectFlySightFileVersionOf(jumpFile) 620 if version == FlySightVersion.V1: 621 rawData = _readVersion1CSV(jumpFile) 622 tag = _tagVersion1From(jumpFile) 623 elif version == FlySightVersion.V2: 624 rawData = _readVersion2CSV(jumpFile) 625 tag = _tagVersion2From(jumpFile) 626 return (rawData, tag) 627 628 629def processAllJumpFiles(jumpFiles: list, altitudeDZMeters = 0.0) -> dict: 630 """ 631 Process all jump files in a list of valid FlySight files. Returns a 632 dictionary of jump results with a human-readable version of the file name. 633 The `jumpFiles` list can be generated by hand or the output of the 634 `ssscoring.fs1.getAllSpeedJumpFilesFrom` called to operate on a data lake. 635 636 Arguments 637 --------- 638 jumpFiles 639 A list of file things that could represent one of these: 640 - file things relative or absolute path names to individual FlySight CSV 641 files. 642 - A specialization of BytesIO, such as the bags of bytes that Streamlit.io 643 generates after uploading and reading a file into the Streamlit 644 environment 645 646 altitudeDZMeters : float 647 Drop zone height above MSL 648 649 Returns 650 ------- 651 dict 652 A dictionary of jump results. The key is a human-readable version of a 653 `jumpFile` name with the extension, path, and extraneous spaces eliminated 654 or replaced by appropriate characters. File names use Unicode, so accents 655 and non-ANSI characters are allowed in file names. 656 657 Raises 658 ------ 659 `SSScoringError` if the jumpFiles object is empty, or if the individual 660 objects in the list aren't `BytesIO`, file name strings, or `Path` 661 instances. 662 """ 663 jumpResults = dict() 664 if not len(jumpFiles): 665 raise SSScoringError('jumpFiles must have at least one element') 666 if not isinstance(jumpFiles, dict) and not isinstance(jumpFiles, list): 667 raise SSScoringError('dict with jump file names and FS versions or list of byte bags expected') 668 if isinstance(jumpFiles, dict): 669 objectsList = sorted(list(jumpFiles.keys())) 670 elif isinstance(jumpFiles, list): 671 objectsList = jumpFiles 672 obj = objectsList[0] 673 if not isinstance(obj, Path) and not isinstance(obj, str) and not isinstance(obj, BytesIO): 674 raise SSScoringError('jumpFiles must contain file-like things or BytesIO objects') 675 for jumpFile in objectsList: 676 if isinstance(jumpFile, BytesIO): 677 rawData, tag = getFlySightDataFromCSVBuffer(jumpFile.getvalue(), jumpFile.name) 678 else: 679 rawData, tag = getFlySightDataFromCSVFileName(jumpFile) 680 jumpResult = processJump(convertFlySight2SSScoring(rawData, altitudeDZMeters = altitudeDZMeters)) 681 if JumpStatus.OK == jumpResult.status: 682 jumpResults[tag] = jumpResult 683 return jumpResults 684 685 686def aggregateResults(jumpResults: dict) -> pd.DataFrame: 687 """ 688 Aggregate all the results in a table fashioned after Marco Hepp's and Nklas 689 Daniel's score tracking data. 690 691 Arguments 692 --------- 693 jumpResults: dict 694 A dictionary of jump results, in which each result corresponds to a FlySight 695 file name. See `ssscoring.processAllJumpFiles` for details. 696 697 Returns 698 ------- 699 A dataframe featuring these columns: 700 701 - Score 702 - Speeds at 5, 10, 15, 20, and 25 second tranches 703 - Final time contemplated in the analysis 704 - Max speed 705 706 The dataframe rows are identified by the human readable jump file name. 707 """ 708 speeds = pd.DataFrame() 709 for jumpResultIndex in sorted(list(jumpResults.keys())): 710 jumpResult = jumpResults[jumpResultIndex] 711 # TODO: if jumpResult.score > 0.0: 712 if jumpResult.status == JumpStatus.OK: 713 t = jumpResult.table 714 finalTime = t.iloc[-1].time 715 t.iloc[-1].time = LAST_TIME_TRANCHE 716 t = pd.pivot_table(t, columns = t.time) 717 t.drop(['altitude (ft)'], inplace = True) 718 d = pd.DataFrame([ jumpResult.score, ], index = [ jumpResultIndex, ], columns = [ 'score', ], dtype = object) 719 for column in t.columns: 720 # d[column] = t[column].iloc[2] 721 d[column] = t[column].vKMh 722 d['finalTime'] = [ finalTime, ] 723 d['maxSpeed'] = jumpResult.maxSpeed 724 725 if speeds.empty: 726 speeds = d.copy() 727 else: 728 speeds = pd.concat([ speeds, d, ]) 729 speeds = speeds.replace(np.nan, 0.0) 730 return speeds.sort_index() 731 732 733def roundedAggregateResults(aggregate: pd.DataFrame) -> pd.DataFrame: 734 """ 735 Aggregate all the results in a table fashioned after Marco Hepp's and Nklas 736 Daniel's score tracking data. All speed results are rounded at `n > x.5` 737 for any value. 738 739 Arguments 740 --------- 741 aggregate: pd.DataFrame 742 A dataframe output of `ssscoring.fs1.aggregateResults`. 743 744 Returns 745 ------- 746 A dataframe featuring the **rounded values** for these columns: 747 748 - Score 749 - Speeds at 5, 10, 15, 20, and 25 second tranches 750 - Max speed 751 752 The `finalTime` column is ignored. 753 754 The dataframe rows are identified by the human readable jump file name. 755 756 This is a less precise version of the `ssscoring.aggregateResults` 757 dataframe, useful during training to keep rounded results available for 758 review. 759 """ 760 for column in [col for col in aggregate.columns if 'Time' not in str(col)]: 761 aggregate[column] = aggregate[column].apply(round) 762 763 return aggregate 764 765 766def totalResultsFrom(aggregate: pd.DataFrame) -> pd.DataFrame: 767 """ 768 Calculates the total and mean speeds for an aggregation of speed jumps. 769 770 Arguments 771 --------- 772 aggregate: pd.DataFrame 773 The aggregate results dataframe resulting from calling `ssscoring.aggregateResults` 774 with valid results. 775 776 Returns 777 ------- 778 A dataframe with one row and two columns: 779 780 - totalSpeed ::= the sum of all speeds in the aggregated results 781 - meanSpeed ::= the mean of all speeds 782 - maxSpeed := the absolute max speed over the speed runs set 783 - meanSpeedSTD := scored speeds standar deviation 784 - maxScore ::= the max score among all the speed scores 785 - maxScoreSTD := the max scores standard deviation 786 787 Raises 788 ------ 789 `AttributeError` if aggregate is an empty dataframe or `None`, or if the 790 `aggregate` dataframe doesn't conform to the output of `ssscoring.aggregateResults`. 791 """ 792 if aggregate is None: 793 raise AttributeError('aggregate dataframe is empty') 794 elif isinstance(aggregate, pd.DataFrame) and not len(aggregate): 795 raise AttributeError('aggregate dataframe is empty') 796 797 totals = pd.DataFrame({ 798 'totalScore': [ round(aggregate.score.sum(), 2), ], 799 'mean': [ round(aggregate.score.mean(), 2), ], 800 'deviation': [ round(aggregate.score.std(), 2), ], 801 'maxScore': [ round(aggregate.score.max(), 2), ], 802 'absMaxSpeed': [ round(aggregate.maxSpeed.max(), 2), ], }, index = [ 'totalSpeed'],) 803 return totals
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
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.
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
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
97def isValidJumpISC(data: pd.DataFrame, 98 window: PerformanceWindow) -> bool: 99 """ 100 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 if len(data) > 0: 116 accuracy = data[data.altitudeAGL <= window.validationStart].speedAccuracyISC.max() 117 return accuracy < SPEED_ACCURACY_THRESHOLD 118 else: 119 return False
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.
122def calculateDistance(start: tuple, end: tuple) -> float: 123 """ 124 Calculate the distance between two terrestrial coordinates points. 125 126 Arguments 127 --------- 128 start 129 A latitude, longitude tuple of floating point numbers. 130 131 end 132 A latitude, longitude tuple of floating point numbers. 133 134 Returns 135 ------- 136 The distance, in meters, between both points. 137 """ 138 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.
141def convertFlySight2SSScoring(rawData: pd.DataFrame, 142 altitudeDZMeters = 0.0, 143 altitudeDZFt = 0.0): 144 """ 145 Converts a raw dataframe initialized from a FlySight CSV file into the 146 SSScoring file format. The SSScoring format uses more descriptive column 147 headers, adds the altitude in feet, and uses UNIX time instead of an ISO 148 string. 149 150 If both `altitudeDZMeters` and `altitudeDZFt` are zero then hMSL is used. 151 Otherwise, this function adjusts the effective altitude with the value. If 152 both meters and feet values are set this throws an error. 153 154 Arguments 155 --------- 156 rawData : pd.DataFrame 157 FlySight CSV input as a dataframe 158 159 altitudeDZMeters : float 160 Drop zone height above MSL 161 162 altitudeDZFt 163 Drop zone altitudde above MSL 164 165 Returns 166 ------- 167 A dataframe in SSScoring format, featuring these columns: 168 169 - timeUnix 170 - altitudeMSL 171 - altitudeAGL 172 - altitudeMSLFt 173 - altitudeAGLFt 174 - hMetersPerSecond 175 - hKMh (km/h) 176 - vMetersPerSecond 177 - vKMh (km/h) 178 - angle 179 - speedAccuracy (ignore; see ISC documentation) 180 - hMetersPerSecond 181 - hKMh 182 - latitude 183 - longitude 184 - verticalAccuracy 185 - speedAccuracyISC 186 187 Errors 188 ------ 189 `SSScoringError` if the DZ altitude is set in both meters and feet. 190 """ 191 if not isinstance(rawData, pd.DataFrame): 192 raise SSScoringError('convertFlySight2SSScoring input must be a FlySight CSV dataframe') 193 194 if altitudeDZMeters and altitudeDZFt: 195 raise SSScoringError('Cannot set altitude in meters and feet; pick one') 196 197 if altitudeDZMeters: 198 altitudeDZFt = FT_IN_M*altitudeDZMeters 199 if altitudeDZFt: 200 altitudeDZMeters = altitudeDZFt/FT_IN_M 201 202 data = rawData.copy() 203 204 data['altitudeMSLFt'] = data['hMSL'].apply(lambda h: FT_IN_M*h) 205 data['altitudeAGL'] = data.hMSL-altitudeDZMeters 206 data['altitudeAGLFt'] = data.altitudeMSLFt-altitudeDZFt 207 data['timeUnix'] = np.round(data['time'].apply(lambda t: pd.Timestamp(t).timestamp()), decimals = 2) 208 data['hMetersPerSecond'] = (data.velE**2.0+data.velN**2.0)**0.5 209 speedAngle = data['hMetersPerSecond']/data['velD'] 210 speedAngle = np.round(90.0-speedAngle.apply(math.atan)/DEG_IN_RADIANS, decimals = 2) 211 speedAccuracyISC = np.round(data.vAcc.apply(lambda a: (2.0**0.5)*a/3.0), decimals = 2) 212 213 data = pd.DataFrame(data = { 214 'timeUnix': data.timeUnix, 215 'altitudeMSL': data.hMSL, 216 'altitudeAGL': data.altitudeAGL, 217 'altitudeMSLFt': data.altitudeMSLFt, 218 'altitudeAGLFt': data.altitudeAGLFt, 219 'vMetersPerSecond': data.velD, 220 'vKMh': 3.6*data.velD, 221 'speedAngle': speedAngle, 222 'speedAccuracy': data.sAcc, 223 'hMetersPerSecond': data.hMetersPerSecond, 224 'hKMh': 3.6*data.hMetersPerSecond, 225 'latitude': data.lat, 226 'longitude': data.lon, 227 'verticalAccuracy': data.vAcc, 228 'speedAccuracyISC': speedAccuracyISC, 229 }) 230 231 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
- hMetersPerSecond
- hKMh (km/h)
- vMetersPerSecond
- vKMh (km/h)
- angle
- speedAccuracy (ignore; see ISC documentation)
- hMetersPerSecond
- hKMh
- latitude
- longitude
- verticalAccuracy
- speedAccuracyISC
Errors
SSScoringError
if the DZ altitude is set in both meters and feet.
242def getSpeedSkydiveFrom(data: pd.DataFrame) -> tuple: 243 """ 244 Take the skydive dataframe and get the speed skydiving data: 245 246 - Exit 247 - Speed skydiving window 248 - Drops data before exit and below breakoff altitude 249 250 Arguments 251 --------- 252 data : pd.DataFrame 253 Jump data in SSScoring format 254 255 Returns 256 ------- 257 A tuple of two elements: 258 259 - A named tuple with performance and validation window data 260 - A dataframe featuring only speed skydiving data 261 262 Warm up FlySight files and non-speed skydiving files may return invalid 263 values: 264 265 - `None` for the `PerformanceWindow` instance 266 - `data`, most likely empty 267 """ 268 if len(data): 269 data = _dataGroups(data) 270 groups = data.group.max()+1 271 272 freeFallGroup = -1 273 MIN_DATA_POINTS = 100 # heuristic 274 MIN_MAX_SPEED = 200 # km/h, heuristic; slower ::= no free fall 275 for group in range(groups): 276 subset = data[data.group == group] 277 if len(subset) >= MIN_DATA_POINTS and subset.vKMh.max() >= MIN_MAX_SPEED: 278 freeFallGroup = group 279 280 data = data[data.group == freeFallGroup] 281 data = data.drop('group', axis = 1).drop('positive', axis = 1) 282 283 data = data[data.altitudeAGL <= MAX_VALID_ELEVATION] 284 if len(data) > 0: 285 exitTime = data[data.vMetersPerSecond > EXIT_SPEED].head(1).timeUnix.iat[0] 286 data = data[data.timeUnix >= exitTime] 287 data = data[data.altitudeAGL >= BREAKOFF_ALTITUDE] 288 289 if len(data): 290 windowStart = data.iloc[0].altitudeAGL 291 windowEnd = windowStart-PERFORMANCE_WINDOW_LENGTH 292 if windowEnd < BREAKOFF_ALTITUDE: 293 windowEnd = BREAKOFF_ALTITUDE 294 295 validationWindowStart = windowEnd+VALIDATION_WINDOW_LENGTH 296 data = data[data.altitudeAGL >= windowEnd] 297 performanceWindow = PerformanceWindow(windowStart, windowEnd, validationWindowStart) 298 else: 299 performanceWindow = None 300 301 return performanceWindow, data 302 else: 303 return None, data
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:
None
for thePerformanceWindow
instancedata
, most likely empty
311def jumpAnalysisTable(data: pd.DataFrame) -> pd.DataFrame: 312 """ 313 Generates the HCD jump analysis table, with speed data at 5-second intervals 314 after exit. 315 316 Arguments 317 --------- 318 data : pd.DataFrame 319 Jump data in SSScoring format 320 321 Returns 322 ------- 323 A tuple with a pd.DataFrame and the max speed recorded for the jump: 324 325 - A table dataframe with time and speed 326 - a floating point number 327 """ 328 table = None 329 distanceStart = (data.iloc[0].latitude, data.iloc[0].longitude) 330 for column in pd.Series([ 5.0, 10.0, 15.0, 20.0, 25.0, ]): 331 for interval in range(int(column)*10, 10*(int(column)+1)): 332 # Use the next 0.1 sec interval if the current interval tranche has 333 # NaN values. 334 columnRef = interval/10.0 335 timeOffset = data.iloc[0].timeUnix+columnRef 336 tranche = data.query('timeUnix == %f' % timeOffset).copy() 337 tranche['time'] = [ column, ] 338 currentPosition = (tranche.iloc[0].latitude, tranche.iloc[0].longitude) 339 tranche['distanceFromExit'] = [ round(calculateDistance(distanceStart, currentPosition), 2), ] 340 if not tranche.isnull().any().any(): 341 break 342 343 if pd.isna(tranche.iloc[-1].vKMh): 344 tranche = data.tail(1).copy() 345 currentPosition = (tranche.iloc[0].latitude, tranche.iloc[0].longitude) 346 tranche['time'] = tranche.timeUnix-data.iloc[0].timeUnix 347 tranche['distanceFromExit'] = [ round(calculateDistance(distanceStart, currentPosition), 2), ] 348 349 if table is not None: 350 table = pd.concat([ table, tranche, ]) 351 else: 352 table = tranche 353 table = pd.DataFrame({ 354 'time': table.time, 355 'vKMh': table.vKMh, 356 'deltaV': table.vKMh.diff().fillna(table.vKMh), 357 'vAccel m/s²': _verticalAcceleration(table.vKMh, table.time), 358 'speedAngle': table.speedAngle, 359 'angularVel º/s': (table.speedAngle.diff()/table.time.diff()).fillna(table.speedAngle/TABLE_INTERVAL), 360 'deltaAngle': table.speedAngle.diff().fillna(table.speedAngle), 361 'hKMh': table.hKMh, 362 'distanceFromExit (m)': table.distanceFromExit, 363 'altitude (ft)': table.altitudeAGLFt, 364 }) 365 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
368def dropNonSkydiveDataFrom(data: pd.DataFrame) -> pd.DataFrame: 369 """ 370 Discards all data rows before maximum altitude, and all "negative" altitude 371 rows because we don't skydive underground (FlySight bug?). 372 373 This is a more accurate mean velocity calculation from a physics point of 374 view, but it differs from the ISC definition using in scoring - which, if we 375 get technical, is the one that counts. 376 377 Arguments 378 --------- 379 data : pd.DataFrame 380 Jump data in SSScoring format (headers differ from FlySight format) 381 382 Returns 383 ------- 384 The jump data for the skydive 385 """ 386 timeMaxAlt = data[data.altitudeAGL == data.altitudeAGL.max()].timeUnix.iloc[0] 387 data = data[data.timeUnix > timeMaxAlt] 388 389 data = data[data.altitudeAGL > 0] 390 391 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
394def calcScoreMeanVelocity(data: pd.DataFrame) -> tuple: 395 """ 396 Calculates the speeds over a 3-second interval as the mean of all the speeds 397 recorded within that 3-second window and resolves the maximum speed. 398 399 Arguments 400 --------- 401 data 402 A `pd.dataframe` with speed run data. 403 404 Returns 405 ------- 406 A `tuple` with the best score throughout the speed run, and a dicitionary 407 of the meanVSpeed:spotInTime used in determining the exact scoring speed 408 at every datat point during the speed run. 409 410 Notes 411 ----- 412 This implementation uses iteration instead of binning/factorization because 413 some implementers may be unfamiliar with data manipulation in dataframes 414 and this is a critical function that may be under heavy review. Future 415 versions may revert to dataframe and series/np.array factorization. 416 """ 417 scores = dict() 418 for spot in data.plotTime[::1]: 419 subset = data[(data.plotTime <= spot) & (data.plotTime >= (spot-SCORING_INTERVAL))] 420 scores[np.round(subset.vKMh.mean(), decimals = 2)] = spot 421 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.
424def calcScoreISC(data: pd.DataFrame) -> tuple: 425 """ 426 Calculates the speeds over a 3-second interval as the ds/dt and dt is the 427 is a 3-second sliding interval from exit. The window slider moves along the 428 `plotTime` axis in the dataframe. 429 430 Arguments 431 --------- 432 data 433 A `pd.dataframe` with speed run data. 434 435 Returns 436 ------- 437 A `tuple` with the best score throughout the speed run, and a dicitionary 438 of the meanVSpeed:spotInTime used in determining the exact scoring speed 439 at every datat point during the speed run. 440 """ 441 scores = dict() 442 step = data.plotTime.diff().dropna().mode().iloc[0] 443 end = data.plotTime[-1:].iloc[0]-SCORING_INTERVAL 444 for spot in np.arange(0.0, end, step): 445 intervalStart = np.round(spot, decimals = 2) 446 intervalEnd = np.round(intervalStart+SCORING_INTERVAL, decimals = 2) 447 try: 448 h1 = data[data.plotTime == intervalStart].altitudeAGL.iloc[0] 449 h2 = data[data.plotTime == intervalEnd].altitudeAGL.iloc[0] 450 except IndexError: 451 # TODO: Decide whether to log the missing FlySight samples. 452 continue 453 intervalScore = np.round(MPS_2_KMH*abs(h1-h2)/SCORING_INTERVAL, decimals = 2) 454 scores[intervalScore] = intervalStart 455 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.
458def processJump(data: pd.DataFrame) -> JumpResults: 459 """ 460 Take a dataframe in SSScoring format and process it for display. It 461 serializes all the steps that would be taken from the ssscoring module, but 462 includes some text/HTML data in the output. 463 464 Arguments 465 --------- 466 data: pd.DataFrame 467 A dataframe in SSScoring format 468 469 Returns 470 ------- 471 A `JumpResults` named tuple with these items: 472 473 - `score` speed score 474 - `maxSpeed` maximum speed during the jump 475 - `scores` a Series of every 3-second window scores from exit to breakoff 476 - `data` an updated SSScoring dataframe `plotTime`, where 0 = exit, used 477 for plotting 478 - `window` a named tuple with the exit, breakoff, and validation window 479 altitudes 480 - `table` a dataframe featuring the speeds and altitudes at 5-sec intervals 481 - `color` a string that defines the color for the jump result; possible 482 values are _green_ for valid jump, _red_ for invalid jump, per ISC rules 483 - `result` a string with the legend of _valid_ or _invalid_ jump 484 """ 485 workData = data.copy() 486 workData = dropNonSkydiveDataFrom(workData) 487 window, workData = getSpeedSkydiveFrom(workData) 488 if workData.empty and not window: 489 workData = None 490 maxSpeed = -1.0 491 score = -1.0 492 scores = None 493 table = None 494 window = None 495 jumpStatus = JumpStatus.WARM_UP_FILE 496 else: 497 validJump = isValidJumpISC(workData, window) 498 jumpStatus = JumpStatus.OK 499 score = None 500 scores = None 501 table = None 502 if validJump: 503 table = None 504 table = jumpAnalysisTable(workData) 505 maxSpeed = data.vKMh.max() 506 baseTime = workData.iloc[0].timeUnix 507 workData['plotTime'] = round(workData.timeUnix-baseTime, 2) 508 score, scores = calcScoreISC(workData) 509 else: 510 maxSpeed = -1 511 if len(workData): 512 jumpStatus = JumpStatus.SPEED_ACCURACY_EXCEEDS_LIMIT 513 else: 514 jumpStatus = JumpStatus.INVALID_SPEED_FILE 515 return JumpResults(workData, maxSpeed, score, scores, table, window, jumpStatus)
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:
score
speed scoremaxSpeed
maximum speed during the jumpscores
a Series of every 3-second window scores from exit to breakoffdata
an updated SSScoring dataframeplotTime
, where 0 = exit, used for plottingwindow
a named tuple with the exit, breakoff, and validation window altitudestable
a dataframe featuring the speeds and altitudes at 5-sec intervalscolor
a string that defines the color for the jump result; possible values are _green_ for valid jump, _red_ for invalid jump, per ISC rulesresult
a string with the legend of _valid_ or _invalid_ jump
544def getFlySightDataFromCSVBuffer(buffer:bytes, bufferName:str) -> tuple: 545 """ 546 Ingress a buffer with known FlySight or SkyTrax file data for SSScoring 547 processing. 548 549 Arguments 550 --------- 551 buffer 552 A binary data buffer, bag of bytes, containing a known FlySight track file. 553 554 bufferName 555 An arbitrary name for the buffer of type `str`. It's used for constructing 556 the full buffer tag value for human identification. 557 558 Returns 559 ------- 560 A `tuple` with two items: 561 - `rawData` - a dataframe representation of the CSV with the original 562 headers but without the data type header 563 - `tag` - a string with an identifying tag derived from the path name 564 and file version in the form `some name:vX`. It uses the current 565 path as metadata to infer the name. There's no semantics enforcement. 566 567 Raises 568 ------ 569 `SSScoringError` if the CSV file is invalid in any way. 570 """ 571 if not isinstance(buffer, bytes): 572 raise SSScoringError('buffer must be an instance of bytes, a bytes buffer') 573 try: 574 stringIO = StringIO(buffer.decode(FLYSIGHT_FILE_ENCODING)) 575 except Exception as e: 576 raise SSScoringError('invalid buffer endcoding - %s' % str(e)) 577 version = detectFlySightFileVersionOf(buffer) 578 if version == FlySightVersion.V1: 579 rawData = _readVersion1CSV(stringIO) 580 tag = _tagVersion1From(bufferName) 581 elif version == FlySightVersion.V2: 582 rawData = _readVersion2CSV(stringIO) 583 tag = _tagVersion2From(bufferName) 584 return (rawData, tag)
Ingress a buffer with known FlySight or SkyTrax file data for SSScoring processing.
Arguments
buffer
A binary data buffer, bag of bytes, containing a known FlySight track file.
bufferName
An arbitrary name for the buffer of type str
. It's used for constructing
the full buffer tag value for human identification.
Returns
A tuple
with two items:
- rawData
- a dataframe representation of the CSV with the original
headers but without the data type header
- tag
- a string with an identifying tag derived from the path name
and file version in the form some name:vX
. It uses the current
path as metadata to infer the name. There's no semantics enforcement.
Raises
SSScoringError
if the CSV file is invalid in any way.
587def getFlySightDataFromCSVFileName(jumpFile) -> tuple: 588 """ 589 Ingress a known FlySight or SkyTrax file into memory for SSScoring 590 processing. 591 592 Arguments 593 --------- 594 jumpFile 595 A string or `pathlib.Path` object; can be a relative or an asbolute path. 596 597 Returns 598 ------- 599 A `tuple` with two items: 600 - `rawData` - a dataframe representation of the CSV with the original 601 headers but without the data type header 602 - `tag` - a string with an identifying tag derived from the path name 603 and file version in the form `some name:vX`. It uses the current 604 path as metadata to infer the name. There's no semantics enforcement. 605 606 Raises 607 ------ 608 `SSScoringError` if the CSV file is invalid in any way. 609 """ 610 from ssscoring.flysight import validFlySightHeaderIn 611 612 if isinstance(jumpFile, Path): 613 jumpFile = jumpFile.as_posix() 614 elif isinstance(jumpFile, str): 615 pass 616 else: 617 raise SSScoringError('jumpFile must be a string or a Path object') 618 if not validFlySightHeaderIn(jumpFile): 619 raise SSScoringError('%s is an invalid speed skydiving file') 620 version = detectFlySightFileVersionOf(jumpFile) 621 if version == FlySightVersion.V1: 622 rawData = _readVersion1CSV(jumpFile) 623 tag = _tagVersion1From(jumpFile) 624 elif version == FlySightVersion.V2: 625 rawData = _readVersion2CSV(jumpFile) 626 tag = _tagVersion2From(jumpFile) 627 return (rawData, tag)
Ingress a known FlySight or SkyTrax file into memory for SSScoring processing.
Arguments
jumpFile
A string or pathlib.Path
object; can be a relative or an asbolute path.
Returns
A tuple
with two items:
- rawData
- a dataframe representation of the CSV with the original
headers but without the data type header
- tag
- a string with an identifying tag derived from the path name
and file version in the form some name:vX
. It uses the current
path as metadata to infer the name. There's no semantics enforcement.
Raises
SSScoringError
if the CSV file is invalid in any way.
630def processAllJumpFiles(jumpFiles: list, altitudeDZMeters = 0.0) -> dict: 631 """ 632 Process all jump files in a list of valid FlySight files. Returns a 633 dictionary of jump results with a human-readable version of the file name. 634 The `jumpFiles` list can be generated by hand or the output of the 635 `ssscoring.fs1.getAllSpeedJumpFilesFrom` called to operate on a data lake. 636 637 Arguments 638 --------- 639 jumpFiles 640 A list of file things that could represent one of these: 641 - file things relative or absolute path names to individual FlySight CSV 642 files. 643 - A specialization of BytesIO, such as the bags of bytes that Streamlit.io 644 generates after uploading and reading a file into the Streamlit 645 environment 646 647 altitudeDZMeters : float 648 Drop zone height above MSL 649 650 Returns 651 ------- 652 dict 653 A dictionary of jump results. The key is a human-readable version of a 654 `jumpFile` name with the extension, path, and extraneous spaces eliminated 655 or replaced by appropriate characters. File names use Unicode, so accents 656 and non-ANSI characters are allowed in file names. 657 658 Raises 659 ------ 660 `SSScoringError` if the jumpFiles object is empty, or if the individual 661 objects in the list aren't `BytesIO`, file name strings, or `Path` 662 instances. 663 """ 664 jumpResults = dict() 665 if not len(jumpFiles): 666 raise SSScoringError('jumpFiles must have at least one element') 667 if not isinstance(jumpFiles, dict) and not isinstance(jumpFiles, list): 668 raise SSScoringError('dict with jump file names and FS versions or list of byte bags expected') 669 if isinstance(jumpFiles, dict): 670 objectsList = sorted(list(jumpFiles.keys())) 671 elif isinstance(jumpFiles, list): 672 objectsList = jumpFiles 673 obj = objectsList[0] 674 if not isinstance(obj, Path) and not isinstance(obj, str) and not isinstance(obj, BytesIO): 675 raise SSScoringError('jumpFiles must contain file-like things or BytesIO objects') 676 for jumpFile in objectsList: 677 if isinstance(jumpFile, BytesIO): 678 rawData, tag = getFlySightDataFromCSVBuffer(jumpFile.getvalue(), jumpFile.name) 679 else: 680 rawData, tag = getFlySightDataFromCSVFileName(jumpFile) 681 jumpResult = processJump(convertFlySight2SSScoring(rawData, altitudeDZMeters = altitudeDZMeters)) 682 if JumpStatus.OK == jumpResult.status: 683 jumpResults[tag] = jumpResult 684 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.
687def aggregateResults(jumpResults: dict) -> pd.DataFrame: 688 """ 689 Aggregate all the results in a table fashioned after Marco Hepp's and Nklas 690 Daniel's score tracking data. 691 692 Arguments 693 --------- 694 jumpResults: dict 695 A dictionary of jump results, in which each result corresponds to a FlySight 696 file name. See `ssscoring.processAllJumpFiles` for details. 697 698 Returns 699 ------- 700 A dataframe featuring these columns: 701 702 - Score 703 - Speeds at 5, 10, 15, 20, and 25 second tranches 704 - Final time contemplated in the analysis 705 - Max speed 706 707 The dataframe rows are identified by the human readable jump file name. 708 """ 709 speeds = pd.DataFrame() 710 for jumpResultIndex in sorted(list(jumpResults.keys())): 711 jumpResult = jumpResults[jumpResultIndex] 712 # TODO: if jumpResult.score > 0.0: 713 if jumpResult.status == JumpStatus.OK: 714 t = jumpResult.table 715 finalTime = t.iloc[-1].time 716 t.iloc[-1].time = LAST_TIME_TRANCHE 717 t = pd.pivot_table(t, columns = t.time) 718 t.drop(['altitude (ft)'], inplace = True) 719 d = pd.DataFrame([ jumpResult.score, ], index = [ jumpResultIndex, ], columns = [ 'score', ], dtype = object) 720 for column in t.columns: 721 # d[column] = t[column].iloc[2] 722 d[column] = t[column].vKMh 723 d['finalTime'] = [ finalTime, ] 724 d['maxSpeed'] = jumpResult.maxSpeed 725 726 if speeds.empty: 727 speeds = d.copy() 728 else: 729 speeds = pd.concat([ speeds, d, ]) 730 speeds = speeds.replace(np.nan, 0.0) 731 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.
734def roundedAggregateResults(aggregate: pd.DataFrame) -> pd.DataFrame: 735 """ 736 Aggregate all the results in a table fashioned after Marco Hepp's and Nklas 737 Daniel's score tracking data. All speed results are rounded at `n > x.5` 738 for any value. 739 740 Arguments 741 --------- 742 aggregate: pd.DataFrame 743 A dataframe output of `ssscoring.fs1.aggregateResults`. 744 745 Returns 746 ------- 747 A dataframe featuring the **rounded values** for these columns: 748 749 - Score 750 - Speeds at 5, 10, 15, 20, and 25 second tranches 751 - Max speed 752 753 The `finalTime` column is ignored. 754 755 The dataframe rows are identified by the human readable jump file name. 756 757 This is a less precise version of the `ssscoring.aggregateResults` 758 dataframe, useful during training to keep rounded results available for 759 review. 760 """ 761 for column in [col for col in aggregate.columns if 'Time' not in str(col)]: 762 aggregate[column] = aggregate[column].apply(round) 763 764 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.
767def totalResultsFrom(aggregate: pd.DataFrame) -> pd.DataFrame: 768 """ 769 Calculates the total and mean speeds for an aggregation of speed jumps. 770 771 Arguments 772 --------- 773 aggregate: pd.DataFrame 774 The aggregate results dataframe resulting from calling `ssscoring.aggregateResults` 775 with valid results. 776 777 Returns 778 ------- 779 A dataframe with one row and two columns: 780 781 - totalSpeed ::= the sum of all speeds in the aggregated results 782 - meanSpeed ::= the mean of all speeds 783 - maxSpeed := the absolute max speed over the speed runs set 784 - meanSpeedSTD := scored speeds standar deviation 785 - maxScore ::= the max score among all the speed scores 786 - maxScoreSTD := the max scores standard deviation 787 788 Raises 789 ------ 790 `AttributeError` if aggregate is an empty dataframe or `None`, or if the 791 `aggregate` dataframe doesn't conform to the output of `ssscoring.aggregateResults`. 792 """ 793 if aggregate is None: 794 raise AttributeError('aggregate dataframe is empty') 795 elif isinstance(aggregate, pd.DataFrame) and not len(aggregate): 796 raise AttributeError('aggregate dataframe is empty') 797 798 totals = pd.DataFrame({ 799 'totalScore': [ round(aggregate.score.sum(), 2), ], 800 'mean': [ round(aggregate.score.mean(), 2), ], 801 'deviation': [ round(aggregate.score.std(), 2), ], 802 'maxScore': [ round(aggregate.score.max(), 2), ], 803 'absMaxSpeed': [ round(aggregate.maxSpeed.max(), 2), ], }, index = [ 'totalSpeed'],) 804 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
.