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
def isValidMinimumAltitude(altitude: float) -> bool:
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.

def isValidMaximumAltitude(altitude: float) -> bool:
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

def isValidJumpISC( data: pandas.core.frame.DataFrame, window: ssscoring.datatypes.PerformanceWindow) -> bool:
 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.

def calculateDistance(start: tuple, end: tuple) -> float:
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.

def convertFlySight2SSScoring( rawData: pandas.core.frame.DataFrame, altitudeDZMeters=0.0, altitudeDZFt=0.0):
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.

def getSpeedSkydiveFrom(data: pandas.core.frame.DataFrame) -> tuple:
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 the PerformanceWindow instance
  • data, most likely empty
def jumpAnalysisTable(data: pandas.core.frame.DataFrame) -> pandas.core.frame.DataFrame:
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
def dropNonSkydiveDataFrom(data: pandas.core.frame.DataFrame) -> pandas.core.frame.DataFrame:
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

def calcScoreMeanVelocity(data: pandas.core.frame.DataFrame) -> tuple:
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.

def calcScoreISC(data: pandas.core.frame.DataFrame) -> tuple:
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.

def processJump(data: pandas.core.frame.DataFrame) -> ssscoring.datatypes.JumpResults:
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 score
  • maxSpeed maximum speed during the jump
  • scores a Series of every 3-second window scores from exit to breakoff
  • data an updated SSScoring dataframe plotTime, where 0 = exit, used for plotting
  • window a named tuple with the exit, breakoff, and validation window altitudes
  • table a dataframe featuring the speeds and altitudes at 5-sec intervals
  • color a string that defines the color for the jump result; possible values are _green_ for valid jump, _red_ for invalid jump, per ISC rules
  • result a string with the legend of _valid_ or _invalid_ jump
def getFlySightDataFromCSVBuffer(buffer: bytes, bufferName: str) -> tuple:
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.

def getFlySightDataFromCSVFileName(jumpFile) -> tuple:
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.

def processAllJumpFiles(jumpFiles: list, altitudeDZMeters=0.0) -> dict:
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.

def aggregateResults(jumpResults: dict) -> pandas.core.frame.DataFrame:
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.

def roundedAggregateResults(aggregate: pandas.core.frame.DataFrame) -> pandas.core.frame.DataFrame:
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.

def totalResultsFrom(aggregate: pandas.core.frame.DataFrame) -> pandas.core.frame.DataFrame:
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.