ssscoring.calc

Functions and logic for analyzing and manipulating FlySight dataframes.

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

Reports whether an altitude is below the IPC and USPA valid parameters, or within BREAKOFF_ALTITUDE and PERFORMACE_WINDOW_LENGTH. In invalid altitude doesn't invalidate a FlySight data file. This function can be used for generating warnings. The stock FlySightViewer scores a speed jump even if the exit was below the minimum altitude.

See: FAI Competition Rules Speed Skydiving section 5.3 for details.

Arguments

altitude

An altitude in meters, calculated as data.hMSL - DZ altitude.

Returns

True if the altitude is valid.

def isValidMaximumAltitude(altitude: float) -> bool:
72def isValidMaximumAltitude(altitude: float) -> bool:
73    """
74    Reports whether an `altitude` is above the maximum altitude allowed by the
75    rules.
76
77    See:  FAI Competition Rules Speed Skydiving section 5.3 for details.
78
79    Arguments
80    ---------
81        altitude
82    An altitude in meters, calculated as data.hMSL - DZ altitude.
83
84    Returns
85    -------
86    `True` if the altitude is valid.
87
88    See
89    ---
90    `ssscoring.constants.MAX_ALTITUDE_FT`
91    `ssscoring.constants.MAX_ALTITUDE_METERS`
92    """
93    if not isinstance(altitude, float):
94        altitude = float(altitude)
95    return altitude <= MAX_ALTITUDE_METERS

Reports whether an altitude is above the maximum altitude allowed by the rules.

See: FAI Competition Rules Speed Skydiving section 5.3 for details.

Arguments

altitude

An altitude in meters, calculated as data.hMSL - DZ altitude.

Returns

True if the altitude is valid.

See

ssscoring.constants.MAX_ALTITUDE_FT ssscoring.constants.MAX_ALTITUDE_METERS

def isValidJumpISC( data: pandas.DataFrame, window: ssscoring.datatypes.PerformanceWindow) -> bool:
 98def isValidJumpISC(data: pd.DataFrame,
 99                window: PerformanceWindow) -> bool:
100    """
101    **DEPRECATED** - Validates the jump according to ISC/FAI/USPA competition rules.  A jump is
102    valid when the speed accuracy parameter is less than 3 m/s for the whole
103    validation window duration.
104
105    Arguments
106    ---------
107        data : pd.DataFramce
108    Jumnp data in SSScoring format
109        window : ssscoring.PerformanceWindow
110    Performance window start, end values in named tuple format
111
112    Returns
113    -------
114    `True` if the jump is valid according to ISC/FAI/USPA rules.
115    """
116    warnings.warn('This function is DEPRECATED as of version 2.4.0', UserWarning)
117    if len(data) > 0:
118        accuracy = data[data.altitudeAGL <= window.validationStart].speedAccuracyISC.max()
119        return accuracy < SPEED_ACCURACY_THRESHOLD
120    else:
121        return False

DEPRECATED - Validates the jump according to ISC/FAI/USPA competition rules. A jump is valid when the speed accuracy parameter is less than 3 m/s for the whole validation window duration.

Arguments

data : pd.DataFramce

Jumnp data in SSScoring format window : ssscoring.PerformanceWindow Performance window start, end values in named tuple format

Returns

True if the jump is valid according to ISC/FAI/USPA rules.

def validateJumpISC( data: pandas.DataFrame, window: ssscoring.datatypes.PerformanceWindow) -> ssscoring.datatypes.JumpStatus:
124def validateJumpISC(data: pd.DataFrame,
125                window: PerformanceWindow) -> JumpStatus:
126    """
127    Validates the jump according to ISC/FAI/USPA competition rules.  A jump is
128    valid when the speed accuracy parameter is less than 3 m/s for the whole
129    validation window duration.
130
131    Arguments
132    ---------
133        data : pd.DataFramce
134    Jumnp data in SSScoring format
135        window : ssscoring.PerformanceWindow
136    Performance window start, end values in named tuple format
137
138    Returns
139    -------
140    - `JumpStatus.OK` if `data` reflects a valid jump according to ISC rules,
141    where all speed accuracy values < 'SPEED_ACCURACY_THRESHOLD'.
142    - `JumpStatus.SPEED_ACCURACY_EXCEEDS_LIMIT' if `data` has one or more values
143    within the validation window with a value >= 'SPEED_ACCURACY_THRESHOLD'.
144
145    Raises
146    ------
147    `SSScoringError' if `data` has a length of zero or it's not initialized.
148    """
149    if len(data) > 0:
150        accuracy = data[data.altitudeAGL <= window.validationStart].speedAccuracyISC.max()
151        return JumpStatus.OK if accuracy < SPEED_ACCURACY_THRESHOLD else JumpStatus.SPEED_ACCURACY_EXCEEDS_LIMIT
152    else:
153        raise SSScoringError('data length of zero or invalid')

Validates the jump according to ISC/FAI/USPA competition rules. A jump is valid when the speed accuracy parameter is less than 3 m/s for the whole validation window duration.

Arguments

data : pd.DataFramce

Jumnp data in SSScoring format window : ssscoring.PerformanceWindow Performance window start, end values in named tuple format

Returns

  • JumpStatus.OK if data reflects a valid jump according to ISC rules, where all speed accuracy values < 'SPEED_ACCURACY_THRESHOLD'.
  • JumpStatus.SPEED_ACCURACY_EXCEEDS_LIMIT' ifdata` has one or more values within the validation window with a value >= 'SPEED_ACCURACY_THRESHOLD'.

Raises

SSScoringError' ifdata` has a length of zero or it's not initialized.

def calculateDistance(start: tuple, end: tuple) -> float:
156def calculateDistance(start: tuple, end: tuple) -> float:
157    """
158    Calculate the distance between two terrestrial coordinates points.
159
160    Arguments
161    ---------
162        start
163    A latitude, longitude tuple of floating point numbers.
164
165        end
166    A latitude, longitude tuple of floating point numbers.
167
168    Returns
169    -------
170    The distance, in meters, between both points.
171    """
172    return haversine(start, end, unit = Unit.METERS)

Calculate the distance between two terrestrial coordinates points.

Arguments

start

A latitude, longitude tuple of floating point numbers.

end

A latitude, longitude tuple of floating point numbers.

Returns

The distance, in meters, between both points.

def convertFlySight2SSScoring(rawData: pandas.DataFrame, altitudeDZMeters=0.0, altitudeDZFt=0.0):
175def convertFlySight2SSScoring(rawData: pd.DataFrame,
176                              altitudeDZMeters = 0.0,
177                              altitudeDZFt = 0.0):
178    """
179    Converts a raw dataframe initialized from a FlySight CSV file into the
180    SSScoring file format.  The SSScoring format uses more descriptive column
181    headers, adds the altitude in feet, and uses UNIX time instead of an ISO
182    string.
183
184    If both `altitudeDZMeters` and `altitudeDZFt` are zero then hMSL is used.
185    Otherwise, this function adjusts the effective altitude with the value.  If
186    both meters and feet values are set this throws an error.
187
188    Arguments
189    ---------
190        rawData : pd.DataFrame
191    FlySight CSV input as a dataframe
192
193        altitudeDZMeters : float
194    Drop zone height above MSL
195
196        altitudeDZFt
197    Drop zone altitudde above MSL
198
199    Returns
200    -------
201    A dataframe in SSScoring format, featuring these columns:
202
203    - timeUnix
204    - altitudeMSL
205    - altitudeAGL
206    - altitudeMSLFt
207    - altitudeAGLFt
208    - vMetersPerSecond
209    - vKMh (km/h)
210    - vAccelMS2 (m/s²)
211    - speedAccuracy (ignore; see ISC documentation)
212    - hMetersPerSecond
213    - hKMh (km/h)
214    - latitude
215    - longitude
216    - verticalAccuracy
217    - speedAccuracyISC
218
219    Errors
220    ------
221    `SSScoringError` if the DZ altitude is set in both meters and feet.
222    """
223    if not isinstance(rawData, pd.DataFrame):
224        raise SSScoringError('convertFlySight2SSScoring input must be a FlySight CSV dataframe')
225
226    if altitudeDZMeters and altitudeDZFt:
227        raise SSScoringError('Cannot set altitude in meters and feet; pick one')
228
229    if altitudeDZMeters:
230        altitudeDZFt = FT_IN_M*altitudeDZMeters
231    if altitudeDZFt:
232        altitudeDZMeters = altitudeDZFt/FT_IN_M
233
234    data = rawData.copy()
235
236    data['altitudeMSLFt'] = data['hMSL'].apply(lambda h: FT_IN_M*h)
237    data['altitudeAGL'] = data.hMSL-altitudeDZMeters
238    data['altitudeAGLFt'] = data.altitudeMSLFt-altitudeDZFt
239    data['timeUnix'] = np.round(data['time'].apply(lambda t: pd.Timestamp(t).timestamp()), decimals = 2)
240    data['hMetersPerSecond'] = (data.velE**2.0+data.velN**2.0)**0.5
241    speedAngle = data['hMetersPerSecond']/data['velD']
242    speedAngle = np.round(90.0-speedAngle.apply(math.atan)/DEG_IN_RADIANS, decimals = 2)
243    speedAccuracyISC = np.round(data.vAcc.apply(lambda a: (2.0**0.5)*a/3.0), decimals = 2)
244
245    data = pd.DataFrame(data = {
246        'timeUnix': data.timeUnix,
247        'altitudeMSL': data.hMSL,
248        'altitudeAGL': data.altitudeAGL,
249        'altitudeMSLFt': data.altitudeMSLFt,
250        'altitudeAGLFt': data.altitudeAGLFt,
251        'vMetersPerSecond': data.velD,
252        'vKMh': 3.6*data.velD,
253        'speedAngle': speedAngle,
254        'speedAccuracy': data.sAcc,
255        'vAccelMS2': data.velD.diff()/data.timeUnix.diff(),
256        'hMetersPerSecond': data.hMetersPerSecond,
257        'hKMh': 3.6*data.hMetersPerSecond,
258        'latitude': data.lat,
259        'longitude': data.lon,
260        'verticalAccuracy': data.vAcc,
261        'speedAccuracyISC': speedAccuracyISC,
262        'velocityNorth': data.velN,
263        'velocityEast': data.velE,
264    })
265
266    return data

Converts a raw dataframe initialized from a FlySight CSV file into the SSScoring file format. The SSScoring format uses more descriptive column headers, adds the altitude in feet, and uses UNIX time instead of an ISO string.

If both altitudeDZMeters and altitudeDZFt are zero then hMSL is used. Otherwise, this function adjusts the effective altitude with the value. If both meters and feet values are set this throws an error.

Arguments

rawData : pd.DataFrame

FlySight CSV input as a dataframe

altitudeDZMeters : float

Drop zone height above MSL

altitudeDZFt

Drop zone altitudde above MSL

Returns

A dataframe in SSScoring format, featuring these columns:

  • timeUnix
  • altitudeMSL
  • altitudeAGL
  • altitudeMSLFt
  • altitudeAGLFt
  • vMetersPerSecond
  • vKMh (km/h)
  • vAccelMS2 (m/s²)
  • speedAccuracy (ignore; see ISC documentation)
  • hMetersPerSecond
  • hKMh (km/h)
  • latitude
  • longitude
  • verticalAccuracy
  • speedAccuracyISC

Errors

SSScoringError if the DZ altitude is set in both meters and feet.

def getSpeedSkydiveFrom(data: pandas.DataFrame) -> tuple:
277def getSpeedSkydiveFrom(data: pd.DataFrame) -> tuple:
278    """
279    Take the skydive dataframe and get the speed skydiving data:
280
281    - Exit
282    - Speed skydiving window
283    - Drops data before exit and below breakoff altitude
284
285    Arguments
286    ---------
287        data : pd.DataFrame
288    Jump data in SSScoring format
289
290    Returns
291    -------
292    A tuple of two elements:
293
294    - A named tuple with performance and validation window data
295    - A dataframe featuring only speed skydiving data
296
297    Warm up FlySight files and non-speed skydiving files may return invalid
298    values:
299
300    - `None` for the `PerformanceWindow` instance
301    - `data`, most likely empty
302    """
303    if len(data):
304        data = _dataGroups(data)
305        groups = data.group.max()+1
306
307        freeFallGroup = -1
308        MIN_DATA_POINTS = 100 # heuristic
309        MIN_MAX_SPEED = 200 # km/h, heuristic; slower ::= no free fall
310        for group in range(groups):
311            subset = data[data.group == group]
312            if len(subset) >= MIN_DATA_POINTS and subset.vKMh.max() >= MIN_MAX_SPEED:
313                freeFallGroup = group
314
315        data = data[data.group == freeFallGroup]
316        data = data.drop('group', axis = 1).drop('positive', axis = 1)
317
318    data = data[data.altitudeAGL <= MAX_VALID_ELEVATION]
319    if len(data) > 0:
320        exitTime = data[data.vMetersPerSecond > EXIT_SPEED].head(1).timeUnix.iat[0]
321        data = data[data.timeUnix >= exitTime]
322        data = data[data.altitudeAGL >= BREAKOFF_ALTITUDE]
323
324        if len(data):
325            windowStart = data.iloc[0].altitudeAGL
326            windowEnd = windowStart-PERFORMANCE_WINDOW_LENGTH
327            if windowEnd < BREAKOFF_ALTITUDE:
328                windowEnd = BREAKOFF_ALTITUDE
329
330            validationWindowStart = windowEnd+VALIDATION_WINDOW_LENGTH
331            data = data[data.altitudeAGL >= windowEnd]
332            refN = float(data.velocityNorth.iloc[0])
333            refE = float(data.velocityEast.iloc[0])
334            refMag = (refN**2.0 + refE**2.0)**0.5
335            unitN, unitE = (refN/refMag, refE/refMag) if refMag > 0.0 else (1.0, 0.0)
336            signedHMPS = (data.velocityNorth*unitN + data.velocityEast*unitE).to_numpy(dtype=float, na_value=0.0)
337            vMS = data.vMetersPerSecond.to_numpy(dtype=float, na_value=0.0)
338            data = data.copy()
339            data['speedAngle'] = np.round(
340                np.where(signedHMPS == 0.0, 90.0, np.degrees(np.arctan(vMS/signedHMPS))),
341                decimals=2,
342            )
343            data = data.drop(['velocityNorth', 'velocityEast',], axis=1)
344            performanceWindow = PerformanceWindow(windowStart, windowEnd, validationWindowStart)
345        else:
346            data = data.drop(['velocityNorth', 'velocityEast',], axis=1)
347            performanceWindow = None
348
349        return performanceWindow, data
350    else:
351        return None, data.drop(['velocityNorth', 'velocityEast',], axis=1)

Take the skydive dataframe and get the speed skydiving data:

  • Exit
  • Speed skydiving window
  • Drops data before exit and below breakoff altitude

Arguments

data : pd.DataFrame

Jump data in SSScoring format

Returns

A tuple of two elements:

  • A named tuple with performance and validation window data
  • A dataframe featuring only speed skydiving data

Warm up FlySight files and non-speed skydiving files may return invalid values:

  • None for the PerformanceWindow instance
  • data, most likely empty
def jumpAnalysisTable(data: pandas.DataFrame) -> pandas.DataFrame:
359def jumpAnalysisTable(data: pd.DataFrame) -> pd.DataFrame:
360    """
361    Generates the HCD jump analysis table, with speed data at 5-second intervals
362    after exit.
363
364    Arguments
365    ---------
366        data : pd.DataFrame
367    Jump data in SSScoring format
368
369    Returns
370    -------
371    A tuple with a pd.DataFrame and the max speed recorded for the jump:
372
373    - A table dataframe with time and speed
374    - a floating point number
375    """
376    table = None
377    distanceStart = (data.iloc[0].latitude, data.iloc[0].longitude)
378    for column in pd.Series([ 5.0, 10.0, 15.0, 20.0, 25.0, ]):
379        for interval in range(int(column)*10, 10*(int(column)+1)):
380            # Use the next 0.1 sec interval if the current interval tranche has
381            # NaN values.
382            columnRef = interval/10.0
383            timeOffset = data.iloc[0].timeUnix+columnRef
384            tranche = data.query('timeUnix == %f' % timeOffset).copy()
385            tranche['time'] = [ column, ]
386            currentPosition = (tranche.iloc[0].latitude, tranche.iloc[0].longitude)
387            tranche['distanceFromExit'] = [ round(calculateDistance(distanceStart, currentPosition), 2), ]
388            if not tranche.isnull().any().any():
389                break
390
391        if pd.isna(tranche.iloc[-1].vKMh):
392            tranche = data.tail(1).copy()
393            currentPosition = (tranche.iloc[0].latitude, tranche.iloc[0].longitude)
394            tranche['time'] = tranche.timeUnix-data.iloc[0].timeUnix
395            tranche['distanceFromExit'] = [ round(calculateDistance(distanceStart, currentPosition), 2), ]
396
397        if table is not None:
398            table = pd.concat([ table, tranche, ])
399        else:
400            table = tranche
401    table = pd.DataFrame({
402                'time': table.time,
403                'vKMh': table.vKMh,
404                'deltaV': table.vKMh.diff().fillna(table.vKMh),
405                'vAccel m/s²': _verticalAcceleration(table.vKMh, table.time),
406                'speedAngle': table.speedAngle,
407                'angularVel º/s': (table.speedAngle.diff()/table.time.diff()).fillna(table.speedAngle/TABLE_INTERVAL),
408                'deltaAngle': table.speedAngle.diff().fillna(table.speedAngle),
409                'hKMh': table.hKMh,
410                'distanceFromExit (m)': table.distanceFromExit,
411                'altitude (ft)': table.altitudeAGLFt,
412            })
413    return table

Generates the HCD jump analysis table, with speed data at 5-second intervals after exit.

Arguments

data : pd.DataFrame

Jump data in SSScoring format

Returns

A tuple with a pd.DataFrame and the max speed recorded for the jump:

  • A table dataframe with time and speed
  • a floating point number
def dropNonSkydiveDataFrom(data: pandas.DataFrame) -> pandas.DataFrame:
416def dropNonSkydiveDataFrom(data: pd.DataFrame) -> pd.DataFrame:
417    """
418    Discards all data rows before maximum altitude, and all "negative" altitude
419    rows because we don't skydive underground (FlySight bug?).
420
421    This is a more accurate mean velocity calculation from a physics point of
422    view, but it differs from the ISC definition using in scoring - which, if we
423    get technical, is the one that counts.
424
425    Arguments
426    ---------
427        data : pd.DataFrame
428    Jump data in SSScoring format (headers differ from FlySight format)
429
430    Returns
431    -------
432    The jump data for the skydive
433    """
434    timeMaxAlt = data[data.altitudeAGL == data.altitudeAGL.max()].timeUnix.iloc[0]
435    data = data[data.timeUnix > timeMaxAlt]
436
437    data = data[data.altitudeAGL > 0]
438
439    return data

Discards all data rows before maximum altitude, and all "negative" altitude rows because we don't skydive underground (FlySight bug?).

This is a more accurate mean velocity calculation from a physics point of view, but it differs from the ISC definition using in scoring - which, if we get technical, is the one that counts.

Arguments

data : pd.DataFrame

Jump data in SSScoring format (headers differ from FlySight format)

Returns

The jump data for the skydive

def calcScoreMeanVelocity(data: pandas.DataFrame) -> tuple:
442def calcScoreMeanVelocity(data: pd.DataFrame) -> tuple:
443    """
444    Calculates the speeds over a 3-second interval as the mean of all the speeds
445    recorded within that 3-second window and resolves the maximum speed.
446
447    Arguments
448    ---------
449        data
450    A `pd.dataframe` with speed run data.
451
452    Returns
453    -------
454    A `tuple` with the best score throughout the speed run, and a dicitionary
455    of the meanVSpeed:spotInTime used in determining the exact scoring speed
456    at every datat point during the speed run.
457
458    Notes
459    -----
460    This implementation uses iteration instead of binning/factorization because
461    some implementers may be unfamiliar with data manipulation in dataframes
462    and this is a critical function that may be under heavy review.  Future
463    versions may revert to dataframe and series/np.array factorization.
464    """
465    scores = dict()
466    for spot in data.plotTime[::1]:
467        subset = data[(data.plotTime <= spot) & (data.plotTime >= (spot-SCORING_INTERVAL))]
468        scores[np.round(subset.vKMh.mean(), decimals = 2)] = spot
469    return (max(scores), scores)

Calculates the speeds over a 3-second interval as the mean of all the speeds recorded within that 3-second window and resolves the maximum speed.

Arguments

data

A pd.dataframe with speed run data.

Returns

A tuple with the best score throughout the speed run, and a dicitionary of the meanVSpeed:spotInTime used in determining the exact scoring speed at every datat point during the speed run.

Notes

This implementation uses iteration instead of binning/factorization because some implementers may be unfamiliar with data manipulation in dataframes and this is a critical function that may be under heavy review. Future versions may revert to dataframe and series/np.array factorization.

def calcScoreISC(data: pandas.DataFrame) -> tuple:
472def calcScoreISC(data: pd.DataFrame) -> tuple:
473    """
474    Calculates the speeds over a 3-second interval as the ds/dt and dt is the
475    is a 3-second sliding interval from exit.  The window slider moves along the
476    `plotTime` axis in the dataframe.
477
478    Arguments
479    ---------
480        data
481    A `pd.dataframe` with speed run data.
482
483    Returns
484    -------
485    A `tuple` with the best score throughout the speed run, and a dicitionary
486    of the meanVSpeed:spotInTime used in determining the exact scoring speed
487    at every datat point during the speed run.
488    """
489    scores = dict()
490    step = data.plotTime.diff().dropna().mode().iloc[0]
491    end = data.plotTime[-1:].iloc[0]-SCORING_INTERVAL
492    for spot in np.arange(0.0, end, step):
493        intervalStart = np.round(spot, decimals = 2)
494        intervalEnd = np.round(intervalStart+SCORING_INTERVAL, decimals = 2)
495        try:
496            h1 = data[data.plotTime == intervalStart].altitudeAGL.iloc[0]
497            h2 = data[data.plotTime == intervalEnd].altitudeAGL.iloc[0]
498        except IndexError:
499            # TODO: Decide whether to log the missing FlySight samples.
500            continue
501        intervalScore = np.round(MPS_2_KMH*abs(h1-h2)/SCORING_INTERVAL, decimals = 2)
502        scores[intervalScore] = intervalStart
503    return (max(scores), scores)

Calculates the speeds over a 3-second interval as the ds/dt and dt is the is a 3-second sliding interval from exit. The window slider moves along the plotTime axis in the dataframe.

Arguments

data

A pd.dataframe with speed run data.

Returns

A tuple with the best score throughout the speed run, and a dicitionary of the meanVSpeed:spotInTime used in determining the exact scoring speed at every datat point during the speed run.

def jumpRunBearing(jumpData: pandas.DataFrame, nSamples: int = 15) -> float:
506def jumpRunBearing(jumpData: pd.DataFrame, nSamples: int = JUMP_RUN_SAMPLES) -> float:
507    """
508    Estimates the jump run bearing from the first `nSamples` rows of the
509    performance window using the aircraft's residual forward throw (ISC §5.1.3).
510
511    Arguments
512    ---------
513        jumpData : pd.DataFrame
514    Performance-window data in SSScoring format with `plotTime >= 0`.
515
516        nSamples : int
517    Number of post-exit samples to average.  Default: `JUMP_RUN_SAMPLES` (15,
518    i.e. 3 seconds at 5 Hz).
519
520    Returns
521    -------
522    Mean bearing in degrees [0, 360).
523    """
524    samples = jumpData.head(nSamples)
525    lats = samples.latitude.to_numpy(dtype=float)
526    lons = samples.longitude.to_numpy(dtype=float)
527    sinSum = 0.0
528    cosSum = 0.0
529    count = 0
530    for i in range(len(lats) - 1):
531        lat1, lon1, lat2, lon2 = map(math.radians, [lats[i], lons[i], lats[i + 1], lons[i + 1]])
532        dLon = lon2 - lon1
533        x = math.sin(dLon) * math.cos(lat2)
534        y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dLon)
535        if x != 0.0 or y != 0.0:
536            b = math.atan2(x, y)
537            sinSum += math.sin(b)
538            cosSum += math.cos(b)
539            count += 1
540    if count == 0:
541        return 0.0
542    return (math.degrees(math.atan2(sinSum / count, cosSum / count)) + 360.0) % 360.0

Estimates the jump run bearing from the first nSamples rows of the performance window using the aircraft's residual forward throw (ISC §5.1.3).

Arguments

jumpData : pd.DataFrame

Performance-window data in SSScoring format with plotTime >= 0.

nSamples : int

Number of post-exit samples to average. Default: JUMP_RUN_SAMPLES (15, i.e. 3 seconds at 5 Hz).

Returns

Mean bearing in degrees [0, 360).

def forwardLateralDisplacement( jumpData: pandas.DataFrame, exitLat: float, exitLon: float, bearing: float) -> pandas.DataFrame:
545def forwardLateralDisplacement(
546    jumpData: pd.DataFrame,
547    exitLat: float,
548    exitLon: float,
549    bearing: float,
550) -> pd.DataFrame:
551    """
552    Adds `forwardM` and `lateralM` columns to `jumpData`: signed displacement
553    in metres along and perpendicular to the jump run axis from the exit point.
554    Positive `forwardM` = moving away from exit; negative = reversed.
555    Positive `lateralM` = right of jump run; negative = left.
556
557    Arguments
558    ---------
559        jumpData : pd.DataFrame
560    Performance-window data in SSScoring format.
561
562        exitLat, exitLon : float
563    Exit point coordinates (latitude, longitude).
564
565        bearing : float
566    Jump run bearing in degrees [0, 360), typically from `jumpRunBearing()`.
567
568    Returns
569    -------
570    Copy of `jumpData` with `forwardM` and `lateralM` columns appended.
571    """
572    bearingRad = math.radians(bearing)
573    lats = jumpData.latitude.to_numpy(dtype=float)
574    lons = jumpData.longitude.to_numpy(dtype=float)
575    distances = np.array([
576        calculateDistance((exitLat, exitLon), (lat, lon))
577        for lat, lon in zip(lats, lons)
578    ])
579    lat1Rad = math.radians(exitLat)
580    lon1Rad = math.radians(exitLon)
581    lat2Rad = np.radians(lats)
582    dLon = np.radians(lons) - lon1Rad
583    x = np.sin(dLon) * np.cos(lat2Rad)
584    y = math.cos(lat1Rad) * np.sin(lat2Rad) - math.sin(lat1Rad) * np.cos(lat2Rad) * np.cos(dLon)
585    ptBearings = np.arctan2(x, y)
586    deltas = ptBearings - bearingRad
587    result = jumpData.copy()
588    result['forwardM'] = np.round(distances * np.cos(deltas), decimals=2)
589    result['lateralM'] = np.round(distances * np.sin(deltas), decimals=2)
590    return result

Adds forwardM and lateralM columns to jumpData: signed displacement in metres along and perpendicular to the jump run axis from the exit point. Positive forwardM = moving away from exit; negative = reversed. Positive lateralM = right of jump run; negative = left.

Arguments

jumpData : pd.DataFrame

Performance-window data in SSScoring format.

exitLat, exitLon : float

Exit point coordinates (latitude, longitude).

bearing : float

Jump run bearing in degrees [0, 360), typically from jumpRunBearing().

Returns

Copy of jumpData with forwardM and lateralM columns appended.

def detectBackFall(jumpData: pandas.DataFrame) -> dict:
593def detectBackFall(jumpData: pd.DataFrame) -> dict:
594    """
595    Detects whether the skydiver fell to their back during the performance
596    window and quantifies the severity using GPS ground track geometry.
597
598    A back-fall produces a reversal in the skydiver's displacement along the
599    jump run axis (forward reversal) or perpendicular to it (lateral reversal).
600    Both axes are checked; either non-zero reversal depth flags a back-fall.
601
602    Arguments
603    ---------
604        jumpData : pd.DataFrame
605    Performance-window data in SSScoring format, with `plotTime` column set
606    (i.e., called after `processJump` sets `plotTime`).
607
608    Returns
609    -------
610    dict with keys:
611
612    - `backFall` : bool — `True` if any reversal detected
613    - `onsetTime` : float | None — `plotTime` at peak forward displacement;
614      `None` if no back-fall detected
615    - `forwardReversalM` : float — metres reversed along jump run axis (≥ 0)
616    - `lateralReversalM` : float — metres reversed on lateral axis (≥ 0)
617    """
618    exitLat = float(jumpData.latitude.iloc[0])
619    exitLon = float(jumpData.longitude.iloc[0])
620    bearing = jumpRunBearing(jumpData)
621    df = forwardLateralDisplacement(jumpData, exitLat, exitLon, bearing)
622    forwardMax = float(df.forwardM.max())
623    onsetIdx = df.forwardM.idxmax()
624    onsetTime = float(df.loc[onsetIdx].plotTime)
625    forwardReversalM = round(max(0.0, forwardMax - float(df.forwardM.iloc[-1])), 2)
626    lateralAbsMax = float(df.lateralM.abs().max())
627    lateralReversalM = round(max(0.0, lateralAbsMax - float(df.lateralM.abs().iloc[-1])), 2)
628    backFall = forwardReversalM > 0.0 or lateralReversalM > 0.0
629    return {
630        'backFall': backFall,
631        'onsetTime': onsetTime if backFall else None,
632        'forwardReversalM': forwardReversalM,
633        'lateralReversalM': lateralReversalM,
634    }

Detects whether the skydiver fell to their back during the performance window and quantifies the severity using GPS ground track geometry.

A back-fall produces a reversal in the skydiver's displacement along the jump run axis (forward reversal) or perpendicular to it (lateral reversal). Both axes are checked; either non-zero reversal depth flags a back-fall.

Arguments

jumpData : pd.DataFrame

Performance-window data in SSScoring format, with plotTime column set (i.e., called after processJump sets plotTime).

Returns

dict with keys:

  • backFall : bool — True if any reversal detected
  • onsetTime : float | None — plotTime at peak forward displacement; None if no back-fall detected
  • forwardReversalM : float — metres reversed along jump run axis (≥ 0)
  • lateralReversalM : float — metres reversed on lateral axis (≥ 0)
def processJump(data: pandas.DataFrame) -> ssscoring.datatypes.JumpResults:
637def processJump(data: pd.DataFrame) -> JumpResults:
638    """
639    Take a dataframe in SSScoring format and process it for display.  It
640    serializes all the steps that would be taken from the ssscoring module, but
641    includes some text/HTML data in the output.
642
643    Arguments
644    ---------
645        data: pd.DataFrame
646    A dataframe in SSScoring format
647
648    Returns
649    -------
650    A `JumpResults` named tuple with these items:
651
652    - `score` speed score
653    - `maxSpeed` maximum speed during the jump
654    - `scores` a Series of every 3-second window scores from exit to breakoff
655    - `data` an updated SSScoring dataframe `plotTime`, where 0 = exit, used
656      for plotting
657    - `window` a named tuple with the exit, breakoff, and validation window
658      altitudes
659    - `table` a dataframe featuring the speeds and altitudes at 5-sec intervals
660    - `color` a string that defines the color for the jump result; possible
661      values are _green_ for valid jump, _red_ for invalid jump, per ISC rules
662    - `result` a string with the legend of _valid_ or _invalid_ jump
663    """
664    workData = data.copy()
665    workData = dropNonSkydiveDataFrom(workData)
666    window, workData = getSpeedSkydiveFrom(workData)
667    backFall = False
668    backFallOnset = None
669    forwardReversalM = 0.0
670    lateralReversalM = 0.0
671    if workData.empty and not window:
672        workData = None
673        maxSpeed = -1.0
674        score = -1.0
675        scores = None
676        table = None
677        window = None
678        jumpStatus = JumpStatus.WARM_UP_FILE
679    else:
680        jumpStatus = validateJumpISC(workData, window)
681        score = None
682        scores = None
683        table = None
684        baseTime = workData.iloc[0].timeUnix
685        workData['plotTime'] = round(workData.timeUnix-baseTime, 2)
686        if jumpStatus == JumpStatus.OK:
687            table = jumpAnalysisTable(workData)
688            maxSpeed = data.vKMh.max()
689            score, scores = calcScoreISC(workData)
690            backFallResult = detectBackFall(workData)
691            backFall = backFallResult['backFall']
692            backFallOnset = backFallResult['onsetTime']
693            forwardReversalM = backFallResult['forwardReversalM']
694            lateralReversalM = backFallResult['lateralReversalM']
695        else:
696            maxSpeed = -1
697            if not len(workData):
698                jumpStatus = JumpStatus.INVALID_SPEED_FILE
699    return JumpResults(workData, maxSpeed, score, scores, table, window, jumpStatus, backFall, backFallOnset, forwardReversalM, lateralReversalM)

Take a dataframe in SSScoring format and process it for display. It serializes all the steps that would be taken from the ssscoring module, but includes some text/HTML data in the output.

Arguments

data: pd.DataFrame

A dataframe in SSScoring format

Returns

A JumpResults named tuple with these items:

  • 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 processAllJumpFiles(jumpFiles: list, altitudeDZMeters=0.0) -> dict:
702def processAllJumpFiles(jumpFiles: list, altitudeDZMeters = 0.0) -> dict:
703    """
704    Process all jump files in a list of valid FlySight files.  Returns a
705    dictionary of jump results with a human-readable version of the file name.
706    The `jumpFiles` list can be generated by hand or the output of the
707    `ssscoring.fs1.getAllSpeedJumpFilesFrom` called to operate on a data lake.
708
709    Arguments
710    ---------
711        jumpFiles
712    A list of file things that could represent one of these:
713    - file things relative or absolute path names to individual FlySight CSV
714      files.
715    - A specialization of BytesIO, such as the bags of bytes that Streamlit.io
716      generates after uploading and reading a file into the Streamlit
717      environment
718
719        altitudeDZMeters : float
720    Drop zone height above MSL
721
722    Returns
723    -------
724        dict
725    A dictionary of jump results.  The key is a human-readable version of a
726    `jumpFile` name with the extension, path, and extraneous spaces eliminated
727    or replaced by appropriate characters.  File names use Unicode, so accents
728    and non-ANSI characters are allowed in file names.
729
730    Raises
731    ------
732    `SSScoringError` if the jumpFiles object is empty, or if the individual
733    objects in the list aren't `BytesIO`, file name strings, or `Path`
734    instances.
735    """
736    jumpResults = dict()
737    if not len(jumpFiles):
738        raise SSScoringError('jumpFiles must have at least one element')
739    if not isinstance(jumpFiles, dict) and not isinstance(jumpFiles, list):
740        raise SSScoringError('dict with jump file names and FS versions or list of byte bags expected')
741    if isinstance(jumpFiles, dict):
742        objectsList = sorted(list(jumpFiles.keys()), key=str)
743    elif isinstance(jumpFiles, list):
744        objectsList = jumpFiles
745    obj = objectsList[0]
746    if not isinstance(obj, Path) and not isinstance(obj, str) and not isinstance(obj, BytesIO):
747        raise SSScoringError('jumpFiles must contain file-like things or BytesIO objects')
748    _v1Pattern = re.compile(r'^\d{2}-\d{2}-\d{2}\.CSV$', re.IGNORECASE)
749    for jumpFile in objectsList:
750        if isinstance(jumpFile, BytesIO):
751            fileName = jumpFile.name
752            if not _v1Pattern.match(fileName) and not fileName.upper().endswith('TRACK.CSV'):
753                continue
754            rawData, tag = getFlySightDataFromCSVBuffer(jumpFile.getvalue(), jumpFile.name)
755        elif isinstance(jumpFiles, dict) and isinstance(jumpFiles[jumpFile], pd.DataFrame):
756            rawData = jumpFiles[jumpFile]
757            tag = jumpFile
758        else:
759            rawData, tag = getFlySightDataFromCSVFileName(jumpFile)
760        try:
761            jumpResult = processJump(convertFlySight2SSScoring(rawData, altitudeDZMeters = altitudeDZMeters))
762        except Exception:
763            jumpResult = JumpResults(None, 0.0, 0.0, None, None, None, JumpStatus.INVALID_SPEED_FILE)
764        jumpResults[tag] = jumpResult
765    return jumpResults

Process all jump files in a list of valid FlySight files. Returns a dictionary of jump results with a human-readable version of the file name. The jumpFiles list can be generated by hand or the output of the ssscoring.fs1.getAllSpeedJumpFilesFrom called to operate on a data lake.

Arguments

jumpFiles

A list of file things that could represent one of these:

  • file things relative or absolute path names to individual FlySight CSV files.
  • A specialization of BytesIO, such as the bags of bytes that Streamlit.io generates after uploading and reading a file into the Streamlit environment
altitudeDZMeters : float

Drop zone height above MSL

Returns

dict

A dictionary of jump results. The key is a human-readable version of a jumpFile name with the extension, path, and extraneous spaces eliminated or replaced by appropriate characters. File names use Unicode, so accents and non-ANSI characters are allowed in file names.

Raises

SSScoringError if the jumpFiles object is empty, or if the individual objects in the list aren't BytesIO, file name strings, or Path instances.

def aggregateResults(jumpResults: dict) -> pandas.DataFrame:
768def aggregateResults(jumpResults: dict) -> pd.DataFrame:
769    """
770    Aggregate all the results in a table fashioned after Marco Hepp's and Nklas
771    Daniel's score tracking data.
772
773    Arguments
774    ---------
775        jumpResults: dict
776    A dictionary of jump results, in which each result corresponds to a FlySight
777    file name.  See `ssscoring.processAllJumpFiles` for details.
778
779    Returns
780    -------
781    A dataframe featuring these columns:
782
783    - Score
784    - Speeds at 5, 10, 15, 20, and 25 second tranches
785    - Final time contemplated in the analysis
786    - Max speed
787
788    The dataframe rows are identified by the human readable jump file name.
789
790    Raises
791    ------
792    `SSScoringError` if the `jumpResults` object is empty.
793    """
794    if not len(jumpResults):
795        raise SSScoringError('jumpResults is empty - impossible to collate angles')
796
797    speeds = pd.DataFrame()
798    for jumpResultIndex in sorted(list(jumpResults.keys())):
799        jumpResult = jumpResults[jumpResultIndex]
800        if jumpResult.status == JumpStatus.OK:
801            t = jumpResult.table.copy()
802            finalTime = t.iloc[-1].time
803
804            if finalTime > 20.1:
805                finalSpeed = t.iloc[-1].vKMh
806                t.iloc[-1].time = LAST_TIME_TRANCHE   # keep LAST_TIME_TRANCHE for pivoting
807            else:
808                finalSpeed = None
809
810            t = pd.pivot_table(t, columns=t.time)
811            t.columns = [str(c) for c in t.columns]
812            d = pd.DataFrame([jumpResult.score], index=[jumpResultIndex], columns=['score'])
813
814            for column in t.columns:
815                d[column] = t[column].vKMh
816
817            d['finalTime'] = [finalTime]
818            d['maxSpeed'] = jumpResult.maxSpeed
819
820            if finalSpeed is not None:
821                d['finalSpeed'] = [finalSpeed]
822            else:
823                d['finalSpeed'] = [d.get('25.0', [0.0])[0]]
824
825            if speeds.empty:
826                speeds = d.copy()
827            else:
828                speeds = pd.concat([speeds, d])
829
830    cols = ['score', '5.0', '10.0', '15.0', '20.0', 'finalSpeed', 'finalTime', 'maxSpeed']
831    speeds = speeds[[c for c in cols if c in speeds.columns]]
832    speeds = speeds.replace(np.nan, 0.0)
833    return speeds.sort_index()

Aggregate all the results in a table fashioned after Marco Hepp's and Nklas Daniel's score tracking data.

Arguments

jumpResults: dict

A dictionary of jump results, in which each result corresponds to a FlySight file name. See ssscoring.processAllJumpFiles for details.

Returns

A dataframe featuring these columns:

  • Score
  • Speeds at 5, 10, 15, 20, and 25 second tranches
  • Final time contemplated in the analysis
  • Max speed

The dataframe rows are identified by the human readable jump file name.

Raises

SSScoringError if the jumpResults object is empty.

def roundedAggregateResults(aggregate: pandas.DataFrame) -> pandas.DataFrame:
836def roundedAggregateResults(aggregate: pd.DataFrame) -> pd.DataFrame:
837    """
838    Aggregate all the results in a table fashioned after Marco Hepp's and Nklas
839    Daniel's score tracking data.  All speed results are rounded at `n > x.5`
840    for any value.
841
842    Arguments
843    ---------
844        aggregate: pd.DataFrame
845    A dataframe output of `ssscoring.fs1.aggregateResults`.
846
847    Returns
848    -------
849    A dataframe featuring the **rounded values** for these columns:
850
851    - Score
852    - Speeds at 5, 10, 15, 20, and 25 second tranches
853    - Max speed
854
855    The `finalTime` column is ignored.
856
857    The dataframe rows are identified by the human readable jump file name.
858
859    This is a less precise version of the `ssscoring.aggregateResults`
860    dataframe, useful during training to keep rounded results available for
861    review.
862
863    Raises
864    ------
865    `SSSCoringError` if the `jumpResults` object is empty.
866    """
867    for column in [col for col in aggregate.columns if 'Time' not in str(col)]:
868        aggregate[column] = aggregate[column].apply(round)
869
870    return aggregate

Aggregate all the results in a table fashioned after Marco Hepp's and Nklas Daniel's score tracking data. All speed results are rounded at n > x.5 for any value.

Arguments

aggregate: pd.DataFrame

A dataframe output of ssscoring.fs1.aggregateResults.

Returns

A dataframe featuring the rounded values for these columns:

  • Score
  • Speeds at 5, 10, 15, 20, and 25 second tranches
  • Max speed

The finalTime column is ignored.

The dataframe rows are identified by the human readable jump file name.

This is a less precise version of the ssscoring.aggregateResults dataframe, useful during training to keep rounded results available for review.

Raises

SSSCoringError if the jumpResults object is empty.

def collateAnglesByTimeFromExit(jumpResults: dict) -> pandas.DataFrame:
873def collateAnglesByTimeFromExit(jumpResults: dict) -> pd.DataFrame:
874    """
875    Collate all the angles by time from the `jumpResults` into a dataframe that
876    features the jump tag as index, the time tranches and the angles at each
877    time tranche.
878
879    Arguments
880    ---------
881        jumpResults: dict
882    A dictionary of jump results, in which each result corresponds to a FlySight
883    file name.  See `ssscoring.processAllJumpFiles` for details.
884
885    Returns
886    -------
887    A dataframe featuring these columns:
888
889    - Score
890    - Angles at 5, 10, 15, 20, and 25 second tranches
891    - Final time contemplated in the analysis
892
893    Raises
894    ------
895    `SSSCoringError` if the `jumpResults` object is empty.
896    """
897    if not len(jumpResults):
898        raise SSScoringError('jumpResults is empty - impossible to collate angles')
899
900    angles = pd.DataFrame()
901    for jumpResultIndex in sorted(list(jumpResults.keys())):
902        jumpResult = jumpResults[jumpResultIndex]
903        if jumpResult.status == JumpStatus.OK:
904            t = jumpResult.table.copy()                    # ← critical: avoid mutation
905
906            finalTime = t.iloc[-1].time
907            finalAngle = t.iloc[-1].speedAngle
908
909            t.iloc[-1].time = LAST_TIME_TRANCHE
910
911            t = pd.pivot_table(t, columns=t.time)
912            t.columns = [str(c) for c in t.columns]
913            d = pd.DataFrame([jumpResult.score], index=[jumpResultIndex], columns=['score'])
914
915            for column in t.columns:
916                d[column] = t[column].speedAngle
917
918            d['finalTime'] = [finalTime]
919            d['finalAngle'] = [finalAngle]                 # ← new clean column
920
921            if angles.empty:
922                angles = d.copy()
923            else:
924                angles = pd.concat([angles, d])
925
926    cols = ['score', '5.0', '10.0', '15.0', '20.0', 'finalAngle', 'finalTime']
927    angles = angles[[c for c in cols if c in angles.columns]]
928    angles = angles.replace(np.nan, 0.0)
929
930    return angles.sort_index()

Collate all the angles by time from the jumpResults into a dataframe that features the jump tag as index, the time tranches and the angles at each time tranche.

Arguments

jumpResults: dict

A dictionary of jump results, in which each result corresponds to a FlySight file name. See ssscoring.processAllJumpFiles for details.

Returns

A dataframe featuring these columns:

  • Score
  • Angles at 5, 10, 15, 20, and 25 second tranches
  • Final time contemplated in the analysis

Raises

SSSCoringError if the jumpResults object is empty.

def totalResultsFrom(aggregate: pandas.DataFrame) -> pandas.DataFrame:
933def totalResultsFrom(aggregate: pd.DataFrame) -> pd.DataFrame:
934    """
935    Calculates the total and mean speeds for an aggregation of speed jumps.
936
937    Arguments
938    ---------
939        aggregate: pd.DataFrame
940    The aggregate results dataframe resulting from calling `ssscoring.aggregateResults`
941    with valid results.
942
943    Returns
944    -------
945    A dataframe with one row and two columns:
946
947    - totalSpeed ::= the sum of all speeds in the aggregated results
948    - meanSpeed ::= the mean of all speeds
949    - maxSpeed := the absolute max speed over the speed runs set
950    - meanSpeedSTD := scored speeds standar deviation
951    - maxScore ::= the max score among all the speed scores
952    - maxScoreSTD := the max scores standard deviation
953
954    Raises
955    ------
956    `AttributeError` if aggregate is an empty dataframe or `None`, or if the
957    `aggregate` dataframe doesn't conform to the output of `ssscoring.aggregateResults`.
958    """
959    if aggregate is None:
960        raise AttributeError('aggregate dataframe is empty')
961    elif isinstance(aggregate, pd.DataFrame) and not len(aggregate):
962        raise AttributeError('aggregate dataframe is empty')
963
964    totals = pd.DataFrame({
965        'totalScore': [ round(aggregate.score.sum(), 2), ],
966        'mean': [ round(aggregate.score.mean(), 2), ],
967        'deviation': [ round(aggregate.score.std(), 2), ],
968        'maxScore': [ round(aggregate.score.max(), 2), ],
969        'absMaxSpeed': [ round(aggregate.maxSpeed.max(), 2), ], }, index = [ 'totalSpeed'],)
970    return totals

Calculates the total and mean speeds for an aggregation of speed jumps.

Arguments

aggregate: pd.DataFrame

The aggregate results dataframe resulting from calling ssscoring.aggregateResults with valid results.

Returns

A dataframe with one row and two columns:

  • totalSpeed ::= the sum of all speeds in the aggregated results
  • meanSpeed ::= the mean of all speeds
  • maxSpeed := the absolute max speed over the speed runs set
  • meanSpeedSTD := scored speeds standar deviation
  • maxScore ::= the max score among all the speed scores
  • maxScoreSTD := the max scores standard deviation

Raises

AttributeError if aggregate is an empty dataframe or None, or if the aggregate dataframe doesn't conform to the output of ssscoring.aggregateResults.