<?php declare(strict_types=1); namespace GpxStat; # development version has suffix 'new' if (!defined('PmWiki')) exit(); /* GpxStat + This recipe processes a .gpx (XML) file to calculate a number of measurements including * start time, end time, duration, moving time * min and max elevation * metres ascended and descended * distance, max speed, average speed, average moving speed .gpx files can have multiple tracks. Directive parameters are gpx="groupname.pagename/filename" this parameter is mandatory, the groupname and pagename are optional display="layout" this parameter is optional. Layout values are table (of all available values) tramp (i.e. walk) ski drive train bus, boat, fly; in case these are differentiated in the future analyse (to assist with understanding the .gpx file) if not supplied all available values are displayed in a column stoppedthreshold="speed" this parameter is optional. Set thespeed in km/h below which points count as stopped, overrides default and config value if specified timezone="timezone/name" this parameter is optional. Timezones recognised are those from the IANA / Olson timezone database. A conversion from timezone abbreviations is also attempted. You can also try the keyword 'detect' to calculate the timezone from the first point on the track, YMMV. startname="location name" this parameter is optional. Replaces the text "Start:" endname="location name" this parameter is optional. Replaces the text "End:" banner="on", "off", or "version" this parameter is optional. Enables (default), disables, or shows banner with version Written with research from Micrososft CoPilot. The contents of a GPX file are similar to <gpx> <trk> <name>PaekÄkÄriki escarpment</name> <trkseg> <trkpt lat="-40.988689" lon="174.951949"> <ele>9.20</ele> <time>2022-04-23T21:52:26Z</time> <speed>1.1</speed> </trkpt> </trkseg> </trk> </gpx> Speed can also be recorded as an extension <extensions> <gte:gps speed="2.5"/> </extensions> Speed is calculated if it is not available from either of the two tags in the file. See https://www.pmwiki.org/wiki/Cookbook/GpxStat and https://kiwiwiki.nz/pmwiki/pmwiki.php/Cookbook/GpxStat + Copyright 2024-present Simon Davis This software is written for PmWiki; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version. See pmwiki.php for full details and lack of warranty. + Revision History <reverse chronological order please # 2024-05-05 update for PHP 8 warnings # 2024-03-00 add train # 2024-02-06 add avg dsc speed # 2024-01-16 add parameters (startname, endname, stoppedthreshold, banner), update analyse, handle no timestamps, correct display of duration # 2024-01-01 initial version + */ //================================================================================================= # // Initialise constants const NL = "\n"; const BR = '<br/>' . NL; const GPXSTATNAME = 'GpxStat'; $GpxStatNew = ''; # initialise if (substr(__NAMESPACE__, -3) === 'new') { $GpxStatNew = 'new'; # empty string for production version, 'new' for development version } const EARTH_RADIUS = 6371000; # metres const AVGWINDOW = 8; # number of points averaged to omit outliers # constants # The stdDev value for outlier detection. See https://en.wikipedia.org/wiki/Standard_deviation#Rules_for_normally_distributed_data # 1 sd ~= 68%; 2 sd ~= 95%; 2.5 sd ~= 99%; 3 sd ~= 99.7% const NRSTDDEV = 2.5; # 2.5 std devs ~= 99% of data, empirically this works well const NRSTDDEVELEV = 2; # 2 std devs ~= 95% of data, empirically this works well for elevation const KM_TO_M = 1000; const HOUR_TO_S = 3600; const VAL = 'val'; const TXT = 'txt'; # values const GPXS = 'gpxstat'; const TDAT = 'trkDate'; const TMZN = 'timeZone'; const TZDB = 'geoTimeZone'; # IANA/Olson const TRTZ = 'trkDateTimezone'; # track official timezones const TZCL = 'tzCalc'; const TNAM = 'trkName'; const TDES = 'trkDscr'; const DURN = 'duration'; const ELPS = 'elapsedDuration'; const DIST = 'distance'; const ASCT = 'ascent'; const DSCT = 'descent'; const ASCTRAW = 'ascentRaw'; const DSCTRAW = 'descentRaw'; const ASTM = 'ascTime'; const DSTM = 'dscTime'; const STRT = 'startTime'; const ENDT = 'endTime'; const MNEL = 'minElev'; const MXEL = 'maxElev'; const CHEL = 'chgElev'; const STEL = 'startElev'; const ENEL = 'endElev'; const MXSP = 'maxSpeed'; const THSP = 'thresholdSpeed'; const DURM = 'durMov'; const DURS = 'durStp'; const AVSP = 'avgSpd'; const ASSP = 'ascSpd'; const DSSP = 'dscSpd'; const SPCL = 'spCalc'; const AVMS = 'avgMovSpd'; const NRPT = 'nrPoints'; const NRSG = 'nrSegments'; const NRTK = 'nrTracks'; const FLDS = 'fileDesc'; const FNAM = 'fileName'; const SNAM = 'startName'; const ENAM = 'endName'; # HTML const TBLS = '<table class="gpxstat">'; const TBLE = '</table>'; const TBLR = '<tr>'; const TBLD = '<td>'; const TBLDL = '<td class="tdl">'; const TBLDR = '<td class="tdr">'; const TBLDB = '<td class="tdb">'; const TBLD2 = '<td class="tdb" colspan="2">'; const SS = '<span style="display:inline-block; min-width:11em;">'; const SE = '</span>'; const GPXSTXT = '<i>GpxStat</i> '; # literals const MTR = ' m '; const KM = ' km '; const KMH = ' km/h '; const HOUR = ' h '; # # set debug flag \SDV($GpxStatDebug, false); # set default debug setting if not defined in a configuration file $gpxstat_debugon = boolval ($GpxStatDebug); # if on writes input and output to web page # Version date $RecipeInfo[GPXSTATNAME] ['Version'] = '2024-05-05' . $GpxStatNew; # PmWiki version numbering is done by date # recipe version page variable $FmtPV['$GpxStatVersion'] = "'" . __NAMESPACE__ . " version {$RecipeInfo[GPXSTATNAME] ['Version']}'"; // return version as a custom page variable # set default formatting for recipe classes \SDV($HTMLStylesFmt[__NAMESPACE__], NL . '.gpxstat {font-size: smaller; font-family: monospace;}' . NL . '.gpxstat table, .gpxstat tbody, .gpxstat tr, .gpxstat td, .gpxstat th {vertical-align: top; border-collapse: collapse;}' . NL . '.gpxstat .tdr {border: dotted lightgrey; border-width:1px 1px 1px 0px;}' . NL . '.gpxstat .tdl {border: dotted lightgrey; border-width:1px 0px 1px 1px;}' . NL . '.gpxstat .tdb {border: dotted lightgrey; border-width:1px 1px 1px 1px;}' . NL ); # if (!function_exists('dmsg')) { if (!isset ($MessagesFmt[GPXSTATNAME])) $MessagesFmt[GPXSTATNAME] = []; function dmsg (string $smsgprefix, $smsgdata) { # local instance global $MessagesFmt; $MessagesFmt[GPXSTATNAME] [] = $smsgprefix . ': ' . \PHSC (implode(NL . $smsgprefix . ': ', [$smsgdata])); } } # if ($gpxstat_debugon) dmsg('<hr>' . __FILE__, $RecipeInfo[GPXSTATNAME]['Version']); # declare $gpxstat for (:if enabled gpxstat:) recipe installation check $gpxstat = true; # enabled # acquire other configuration variables from config.php if they exist \SDV($GpxStatTimeFmt, 'H:i'); # e.g. 'Y-m-d T H:i:s' $timeFmt = strval($GpxStatTimeFmt); \SDV($GpxStatDateFmt, 'Y-m-d'); # e.g. 'Y-m-d T H:i:s' $dateFmt = strval ($GpxStatDateFmt); \SDV($GpxStatThresholdSpeed, 0.8); # set threshold moving speed in km/hour $thresholdSpeedKMH = floatval ($GpxStatThresholdSpeed); \SDV ($GpxStatBanner, 'on'); # change display of banner $bannerFlag = mb_strtolower(strval ($GpxStatBanner)); \SDV ($GpxStatBrief, 'true'); # make descriptors brief $briefFlag = boolval ($GpxStatBrief); \SDV ($GpxStatTimezone, null); # parameter is checked below # ## Add a PmWiki custom markup $vnamepagefile = "(?:[$PageNameChars]+(?:\.|\/)){0,2}" # optional group/page name and separator (/ or .) repeated . "[$UploadNameChars]+" . '.gpx'; # file name and .gpx # (:GpxStat Optional parameters:) $gpxstat_markup_pattern = '/\\(:' . mb_strtolower(GPXSTATNAME) . '(.*)?:\\)/i'; # when has to be at least fulltext \Markup(GPXSTATNAME . $GpxStatNew, #name 'fulltext', # when, e.g. fulltext, directives $gpxstat_markup_pattern, # pattern __NAMESPACE__ . '\GpxStat_Directive' ); # if ($gpxstat_debugon) dmsg('GpxStat markup', $gpxstat_markup_pattern); // return; # completed PmWiki Info recipe setup /*-----------------------------------------------------------------------------------------------------------*/ # /** GPX file statistics * /param * gpx=groupname.pagename/filename.gpx * display=layout * timezone=tzname * /return The PmWiki-formatted information as HTML. */ # function GpxStat_Directive (array $m):string { # global $gpxstat_debugon, $RecipeInfo;# import variables global $timeFmt, $dateFmt, $thresholdSpeedKMH, $vnamepagefile, $bannerFlag, $briefFlag; global $GpxStatTimezone; global $retVal, $showVals; # for debugging $args = \ParseArgs($m[1]); # contains all text within directive $retVal = '<:block>' . NL; # break out of paragraph; $diffVals = []; $dispArray = ['default']; # defaults to default $calcVals = []; # initialise $calcVals [SNAM] = ''; $calcVals [ENAM] = ''; // if (array_key_exists ('debug', $args)) { $debugopt = $args['debug'] === 'true'; if ($debugopt) $gpxstat_debugon = true; # set on inside directive } if ($gpxstat_debugon) { # display inputs and outputs to wiki page #dmsg ('<hr>', GPXSTATNAME); #dmsg ('m[]', $m); #dmsg ('args[]', $args); } # end gpxstat_debugon if (array_key_exists ('display', $args)) { $dispArray = explode(',', $args ['display']); $dispArray = array_map('strtolower', $dispArray); // Convert all values to lowercase } if (array_key_exists ('startname', $args)) { $calcVals [SNAM] = strval ($args['startname']); } if (array_key_exists ('endname', $args)) { $calcVals [ENAM] = strval ($args['endname']); } if (array_key_exists ('banner', $args)) { $bannerFlag = mb_strtolower(strval ($args['banner'])); } if (array_key_exists ('brief', $args)) { $briefFlag = boolval ($args['brief']); } if (array_key_exists ('stoppedthreshold', $args)){ $thresholdSpeedKMH = floatval ($args['stoppedthreshold']); # override config or default values } # switch (true) { case (array_key_exists ('gpx', $args)): $fileName = urldecode (strval($args['gpx'])); if (empty ($fileName)) { return GPXSTATNAME . ': gpx filename is missing ' . strval($fileName); } $pmatch = preg_match ('/^' . $vnamepagefile . '/i', $fileName); if ($pmatch === false) return GPXSTATNAME . ': gpx does not match a filename pattern "' . $fileName . '" failed (' . $vnamepagefile . ')'; if (1 == $pmatch) { # pattern matches subject list ($fileexists, $wikifilefqdn) = CheckWikiFile ($fileName); //if ($gpxstat_debugon) dmsg ('gpxfile', '"' . $args['gpx'] . '", "' . $wikifilefqdn . '"'); if ($fileexists) break; # exit switch, we have a file to process return $wikifilefqdn; # return file upload code to PmWiki (don't use Keep) } # pattern does not match subject return GPXSTATNAME . ': "' . $fileName . '" not a valid filename'; default: return GPXSTATNAME . ': parameter "gpx=filename.gpx" is required'; } # end switch $thresholdSpeedMS = $thresholdSpeedKMH * (KM_TO_M / HOUR_TO_S); # convert to m/s // Load the XML file libxml_use_internal_errors(true); $gpxXml = simplexml_load_file($wikifilefqdn); # returns class object or null # if ($gpxXml === false) { // Retrieve an array of errors $errors = libxml_get_errors(); // Iterate over each error foreach ($errors as $error) { $retVal .= display_xml_error($error, $gpxXml); } // Clear the libxml error buffer libxml_clear_errors(); // Handle the errors return 'File "' . $wikifilefqdn . '" ' . BR . $retVal; } if (! ($gpxXml->getName() === 'gpx')) return 'File is not a gpx file' . BR; // Get the namespace of the gpx data $trknamespaces = $gpxXml->getNamespaces(true); # $calcVals [TZCL] = ''; # global message list ($calcVals [TMZN], $calcVals [TZCL]) = checkTimezone ($GpxStatTimezone, $gpxXml); # requires gpx file # if (array_key_exists ('timezone', $args)) { list ($calcVals [TMZN], $calcVals [TZCL]) = checkTimezone (strval($args['timezone']), $gpxXml); # requires gpx file } // Initialise variables $calcVals [TNAM] = []; # multiple values are possible $calcVals [TDES] = []; # multiple values are possible $calcVals [NRSG] = 0; # count track segments $calcVals [NRTK] = 0; # count number of tracks $calcVals [ASCTRAW] = 0; $calcVals [DSCTRAW] = 0; $calcVals [STRT] = null; $calcVals [ENDT] = null; $calcVals [ASTM] = 0; $calcVals [DSTM] = 0; $calcVals [ASSP] = 0; $calcVals [DSSP] = 0; $calcVals [STEL] = null; $calcVals [DURM] = 0; $calcVals [DURN] = 0; $calcVals [DURS] = 0; $calcVals [TDAT] = null; $calcVals [SPCL] = ''; $timeVals = []; # time values in seconds $distVals = []; # distance values in metres $speedVals = []; # speed values in metres per second $elevVals = []; # elevation values in metres $pointsNumber = -1; # point number starts from zero $showVals = true or $gpxstat_debugon or boolval(in_array('showvals', $dispArray)); // Extract values from the gpx root if (isset($gpxXml->name)) { # some gpx files have a name here $calcVals [TNAM][] = strval($gpxXml->name); } if (isset($gpxXml->desc)) { # some gpx files have a desc here $calcVals [TDES][] = strval($gpxXml->desc); } // Extract values from the metadata if (isset($gpxXml->metadata->time)) { $calcVals [TDAT] = date_create(strval($gpxXml->metadata->time)); } if (isset($gpxXml->metadata->name)) { # some gpx file have a name here $calcVals [TNAM][] = strval($gpxXml->metadata->name); } if (isset($gpxXml->metadata->desc)) { # some gpx file have a desc here $calcVals [TDES][] = strval($gpxXml->metadata->desc); } // Loop through each "trk" element in the XML foreach ($gpxXml->trk as $trk) { // treat each track as separate (to avoid values from jumps from one track to the next) $previousTrkpt = null; $previousTime = null; $previousElev = null; $calcVals [NRTK]++; if (isset($trk->name)) $calcVals [TNAM][] = strval($trk->name); if (isset($trk->desc)) $calcVals [TDES][] = strval($trk->desc); // Loop through each "trkseg" element foreach ($trk->trkseg as $trkSeg) { $calcVals [NRSG]++; // Loop through each "trkpt" element foreach ($trkSeg->trkpt as $trkPnt) { // Increment the count of points for each "trkpt" element $pointsNumber++; # note these arrays start from 0 $timeVals [$pointsNumber] = $pointTime = (isset($trkPnt->time)) ? strtotime(strval($trkPnt->time)) : 0; # UNIX timestamp $elevVals [$pointsNumber] = $pointElev = (isset($trkPnt->ele)) ? floatval($trkPnt->ele) : 0; if (empty($calcVals [STEL])) { $calcVals [STEL] = $pointElev; # save first elevation in track } // Calculate distance using the Haversine formula if (isset($previousTrkpt)) { $distPoint = haversineGreatCircleDistance(floatval($previousTrkpt['lat']), floatval($previousTrkpt['lon']), floatval($trkPnt['lat']), floatval($trkPnt['lon'])); # metres $distVals [$pointsNumber] = $distPoint; # calculated // Calculate speed if not available switch (true) { case (isset($trkPnt->speed)): break; # speed tag exists and is not empty case (isset($trknamespaces['gte'])): // Register the namespace with the SimpleXMLElement $trkPnt->registerXPathNamespace('gte', $trknamespaces['gte']); // Check if the extensions and gps nodes exist if (isset($trkPnt->extensions) && isset($trkPnt->extensions->children($trknamespaces['gte'])->gps)) { // Extract the speed $speed = $trkPnt->extensions->children($trknamespaces['gte'])->gps->attributes()->speed; } else {$speed = null;} if (isset($speed) && !empty($speed)) { $trkPnt->speed = strval($speed); $calcVals [SPCL] = 'speed from extension'; break; # speed exists in extension } # fall through if speed not set in extension default: // Calculate speed if not available $timeDelta = abs($previousTime - $pointTime); $speed = ($timeDelta > 0) ? $distPoint / $timeDelta : 0; $trkPnt->speed = strval($speed); $calcVals [SPCL] = 'speeds calculated'; } # end switch } else { $distVals [$pointsNumber] = 0; # distance of zero if no previous point } # end if $speedVals [$pointsNumber] = $pointSpeed = (isset($trkPnt->speed)) ? floatval($trkPnt->speed) : 0; # defaults to zero // Calculate metres climbed and descended if (isset($previousTrkpt)) { $pointElevChange = $pointElev - $previousElev; if ($pointElevChange > 0) { $calcVals [ASCTRAW] += $pointElevChange; } else { $calcVals [DSCTRAW] += abs($pointElevChange); } } // Set start time if (empty($calcVals [STRT])) { $calcVals [STRT] = $pointTime; } if (empty($calcVals [TDAT])) { $calcVals [TDAT] = $pointTime; } $calcVals [ENDT] = $pointTime; // Calculate moving times if (isset($previousTrkpt)) { if ($previousTime !== null) { $intervalTime = abs($pointTime - $previousTime); $calcVals [DURN] += $intervalTime; # add to track duration if ($pointSpeed > $thresholdSpeedMS) { # moving $calcVals [DURM] += $intervalTime; if ($previousElev !== null) { if ($pointElev > $previousElev) { $calcVals [ASTM] += $intervalTime; // Add to ascending time } elseif ($pointElev < $previousElev) { $calcVals [DSTM] += $intervalTime; // Add to descending time } } } else { # not moving $calcVals [DURS] += $intervalTime; # stopped } } } $previousTime = $pointTime; $previousElev = $pointElev; $previousTrkpt = $trkPnt; } # end foreach trkseg } # end foreach trk } # end foreach gpxxml $calcVals [ENEL] = $pointElev; $calcVals [NRPT] = $pointsNumber + 1; list ($speedSmooth, $speedOutliers, $diffVals[MXSP]) = removeOutliers('Speed', $speedVals, NRSTDDEV, AVGWINDOW); list ($elevSmooth, $elevOutliers, $diffVals[MXEL]) = removeOutliers('Elevation', $elevVals, NRSTDDEVELEV, AVGWINDOW); list ($distSmooth, $distOutliers, $diffVals[DIST]) = removeOutliers('Distance', $distVals, NRSTDDEV, AVGWINDOW); list ($calcVals [ASCT], $calcVals [DSCT]) = calcChange ($elevSmooth, $speedSmooth, $thresholdSpeedKMH); # calculate ascend and descent from smoothed values list ($calcVals [ASSP], $calcVals [DSSP]) = calcAvgSpeed ($elevSmooth, $speedSmooth, $distSmooth, $timeVals, $thresholdSpeedKMH); # calculate average speed from smoothed values list ($minV, $maxV) = calcMinMax ($speedSmooth); $calcVals [MXSP] = $maxV; # calculate from smoothed values $calcVals [DIST] = array_sum ($distSmooth); # $outVals[TZDB][TXT] = $outVals[TZDB][VAL] = ''; if ((!empty($calcVals [TZCL])) or (! empty ($calcVals [TMZN]))) { $outVals[TZDB][TXT] = 'Time zone: '; $outVals[TZDB][VAL] = strval($calcVals [TMZN]); } # switch ($bannerFlag) { case 'off' : $outVals[GPXS][TXT] = ''; $outVals[GPXS][VAL] = ''; break; case 'on' : $outVals[GPXS][TXT] = makeabbr (GPXSTXT, 'Version ' . $RecipeInfo[GPXSTATNAME] ['Version']); $outVals[GPXS][VAL] = ''; break; default: $retVal = 'GpxStat: Banner parameter must have values "on", "off", or "version"'; # falls through case 'version' : $outVals[GPXS][TXT] = GPXSTXT; $outVals[GPXS][VAL] = 'Version ' . $RecipeInfo[GPXSTATNAME] ['Version']; } # end switch $outVals[FLDS][TXT] = 'File: '; $outVals[FLDS][VAL] = '"' . urldecode ($wikifilefqdn) . '"'; $outVals[FNAM][TXT] = 'Filename: '; $outVals[FNAM][VAL] = urldecode(basename ($wikifilefqdn, '.gpx')); $outVals[TRTZ][TXT] = 'Track date time zone: '; $outVals[TRTZ][VAL] = date('T', $calcVals [TDAT]); $outVals[TNAM][TXT] = 'Track name: '; $outVals[TNAM][VAL] = implode (BR, $calcVals [TNAM]); $outVals[TDES][TXT] = 'Track desc: '; $outVals[TDES][VAL] = implode (BR, $calcVals [TDES]); $outVals[TDAT][TXT] = 'Track date ('. $outVals[TRTZ][VAL] . '): '; $outVals[TDAT][VAL] = date($dateFmt, $calcVals [TDAT]); $outVals[DURN][TXT] = makeabbr('Duration: ', $outVals[TDAT][TXT] . date($dateFmt, $calcVals [TDAT])); $outVals[DURN][VAL] = displaySecsAsHoursMins($calcVals [DURN]) . HOUR; $calcVals [ELPS] = $calcVals [ENDT] - $calcVals [STRT]; $outVals[ELPS][TXT] = 'Elapsed: '; $outVals[ELPS][VAL] = displaySecsAsHoursMins($calcVals [ELPS]) . HOUR; $outVals[DIST][TXT] = makeabbr('Distance: ', $outVals[FNAM][VAL]); $outVals[MXSP][TXT] = 'Max speed: '; $outVals[ASCT][TXT] = 'Ascent: '; $outVals[ASCT][VAL] = number_format($calcVals [ASCT]) . MTR; $outVals[DSCT][TXT] = 'Descent: '; $outVals[DSCT][VAL] = number_format($calcVals [DSCT]) . MTR; $outVals[STRT][TXT] = (empty($calcVals [SNAM])) ? 'Start: ' : makeabbr ($calcVals [SNAM], 'Start'); $outVals[STRT][VAL] = makeabbr((date('Y-m-d', $calcVals [STRT]) == date('Y-m-d', $calcVals [ENDT])) ? date($timeFmt, $calcVals [STRT]) : date($dateFmt, $calcVals [STRT]) . ' ' . date($timeFmt, $calcVals [STRT]), $calcVals [TMZN]); $tzid = (empty ($calcVals [TMZN])) ? '' : get_timezone_abbreviation($calcVals [TMZN]); $outVals[ENDT][TXT] = (empty($calcVals [ENAM])) ? 'End: ' : makeabbr ($calcVals [ENAM], 'End'); $outVals[ENDT][VAL] = makeabbr((date('Y-m-d', $calcVals [STRT]) == date('Y-m-d', $calcVals [ENDT])) ? date($timeFmt, $calcVals [ENDT]) : date($dateFmt, $calcVals [ENDT]) . ' ' . date($timeFmt, $calcVals [ENDT]), $tzid); $outVals[ASTM][TXT] = 'Duration ascent: '; $outVals[ASTM][VAL] = displaySecsAsHoursMins($calcVals [ASTM]) . HOUR; $outVals[DSTM][TXT] = 'Duration descent: '; $outVals[DSTM][VAL] = displaySecsAsHoursMins($calcVals [DSTM]) . HOUR; $outVals[STEL][TXT] = ($briefFlag) ? 'Start elev: ' : 'Start elevation: '; $outVals[STEL][VAL] = number_format($calcVals [STEL]) . MTR; $outVals[ENEL][TXT] = ($briefFlag) ? 'End elev: ' : 'End elevation: '; $outVals[ENEL][VAL] = number_format($calcVals [ENEL]) . MTR; list ($minV, $maxV) = calcMinMax ($elevSmooth); $outVals[MNEL][TXT] = ($briefFlag) ? 'Min elev: ' : 'Min elevation: '; $outVals[MNEL][VAL] = number_format($minV) . MTR; $outVals[MXEL][TXT] = ($briefFlag) ? 'Max elev: ' : 'Max elevation: '; $outVals[MXEL][VAL] = number_format($maxV) . MTR; $outVals[CHEL][TXT] = (($briefFlag) ? 'Elev ' : 'Elevation ') . (($calcVals [ENEL] >= $calcVals [STEL]) ? 'gain' : 'loss') . ': '; $outVals[CHEL][VAL] = number_format($calcVals [ENEL] - $calcVals [STEL]) . MTR; $outVals[DURM][TXT] = 'Duration moving: '; $outVals[DURM][VAL] = displaySecsAsHoursMins($calcVals [DURM]) . HOUR; $outVals[DURS][TXT] = 'Duration stopped: '; $outVals[DURS][VAL] = displaySecsAsHoursMins($calcVals [DURS]) . HOUR; $calcVals [AVSP] = ($calcVals [DURN] > 0) ? $calcVals [DIST] / $calcVals [DURN] : 0; $outVals[AVSP][TXT] = 'Avg speed: '; $decPlaces = ($calcVals [AVSP] < 10) ? 1 : 0; $outVals[AVSP][VAL] = number_format($calcVals [AVSP] * 3.6, $decPlaces) . KMH; $outVals[AVMS][TXT] = 'Avg moving speed: '; $calcVals [AVMS] = ($calcVals [DURM] > 0) ? $calcVals [DIST] / $calcVals [DURM] : 0; $decPlaces = ($calcVals [AVMS] < 10) ? 1 : 0; $outVals[AVMS][VAL] = number_format($calcVals [AVMS] * 3.6, $decPlaces) . KMH; $outVals[ASSP][TXT] = 'Avg asc speed: '; $decPlaces = ($calcVals [ASSP] < 10) ? 1 : 0; $outVals[ASSP][VAL] = number_format($calcVals [ASSP], $decPlaces) . KMH; $outVals[DSSP][TXT] = 'Avg dsc speed: '; $decPlaces = ($calcVals [DSSP] < 10) ? 1 : 0; $outVals[DSSP][VAL] = number_format($calcVals [DSSP], $decPlaces) . KMH; $outVals[THSP][TXT] = 'Threshold speed: '; $outVals[THSP][VAL] = number_format($thresholdSpeedKMH, 1) . KMH; $outVals[NRPT][TXT] = '# Points: '; $outVals[NRPT][VAL] = number_format($calcVals [NRPT]); $outVals[NRSG][TXT] = '# Segments: '; $outVals[NRSG][VAL] = number_format($calcVals [NRSG]); $outVals[NRTK][TXT] = '# Tracks: '; $outVals[NRTK][VAL] = number_format($calcVals [NRTK]); // Select number of decimal places $decPlaces = ($calcVals [DIST] < 100000) ? 1 : 0; # metres $outVals[DIST][VAL] = number_format($calcVals [DIST] / 1000, $decPlaces) . KM; // Format to 1 decimal place $decPlaces = ($calcVals [MXSP] < 10) ? 1 : 0; $outVals[MXSP][VAL] = number_format($calcVals [MXSP], $decPlaces) . KMH; # km/h // Display the information if (empty (array_Filter($timeVals))) { // GPX file contained no time values, e.g. if it was converted from a KML file $retVal .= TBLS . NL; $retVal .= TBLDL . $outVals[DIST][TXT] . TBLDR . $outVals[DIST][VAL] . NL; $retVal .= TBLDL . $outVals[GPXS][TXT] . TBLDR . $outVals[GPXS][VAL] . NL; $retVal .= TBLE . NL; } else { foreach ($dispArray as $layout) { switch ($layout) { case 'table': $retVal .= TBLS . NL; $retVal .= TBLR . TBLDL . $outVals[TNAM][TXT] . '<td class="tdr" colspan="7">' . $outVals[TNAM][VAL]. '</td></tr>' . NL; $retVal .= TBLR . TBLDL . $outVals[TDES][TXT] . '<td class="tdr" colspan="7">' . $outVals[TDES][VAL] . '</td></tr>' . NL; $retVal .= TBLR; $retVal .= TBLDL . $outVals[DIST][TXT] . TBLDR . $outVals[DIST][VAL] . NL; $retVal .= TBLDL . $outVals[AVSP][TXT] . TBLDR . $outVals[AVSP][VAL] . NL; $retVal .= TBLD2 . $calcVals [SPCL] . NL; $retVal .= TBLDL . $outVals[STEL][TXT] . TBLDR . $outVals[STEL][VAL] . NL; $retVal .= TBLR; $retVal .= TBLDL . $outVals[ASCT][TXT] . TBLDR . $outVals[ASCT][VAL] . NL; $retVal .= TBLDL . $outVals[AVMS][TXT] . TBLDR . $outVals[AVMS][VAL] . NL; $retVal .= TBLDL . $outVals[MNEL][TXT] . TBLDR . $outVals[MNEL][VAL] . NL; $retVal .= TBLDL . $outVals[ENEL][TXT] . TBLDR . $outVals[ENEL][VAL] . NL; $retVal .= TBLR; $retVal .= TBLDL . $outVals[DSCT][TXT] . TBLDR . $outVals[DSCT][VAL] . NL; $retVal .= TBLDL . $outVals[MXSP][TXT] . TBLDR . $outVals[MXSP][VAL] . NL; $retVal .= TBLDL . $outVals[MXEL][TXT] . TBLDR . $outVals[MXEL][VAL] . NL; $retVal .= TBLDL . $outVals[CHEL][TXT] . TBLDR . $outVals[CHEL][VAL] . NL; $retVal .= TBLR; $retVal .= TBLDL . $outVals[DURN][TXT] . TBLDR . $outVals[DURN][VAL] . NL; $retVal .= TBLDL . $outVals[DURM][TXT] . TBLDR . $outVals[DURM][VAL] . NL; $retVal .= TBLDL . $outVals[DURS][TXT] . TBLDR . $outVals[DURS][VAL] . NL; $retVal .= TBLDL . $outVals[GPXS][TXT] . TBLDR . $outVals[GPXS][VAL] . NL; $retVal .= TBLR; $retVal .= TBLDL . $outVals[STRT][TXT] . TBLDR . $outVals[STRT][VAL] . NL; $retVal .= TBLDL . $outVals[ENDT][TXT] . TBLDR . $outVals[ENDT][VAL] . NL; $retVal .= TBLDL . $outVals[TDAT][TXT] . TBLDR . $outVals[TDAT][VAL] . NL; $retVal .= TBLDL . $outVals[TZDB][TXT] . TBLDR . $outVals[TZDB][VAL] . ' ' . $calcVals [TZCL] . NL; $retVal .= TBLR; $retVal .= TBLDL . $outVals[NRPT][TXT] . TBLDR . $outVals[NRPT][VAL] . NL; if ($calcVals [NRSG] > 1) { $retVal .= TBLDL . $outVals[NRSG][TXT] . TBLDR . $outVals[NRSG][VAL] . NL; } else {$retVal .= TBLD2 . NL; } if ($calcVals [NRTK] > 1) { $retVal .= TBLDL . $outVals[NRTK][TXT] . TBLDR . $outVals[NRTK][VAL] . NL; } else {$retVal .= TBLD2 . NL; } $retVal .= TBLDL . $outVals[THSP][TXT] . TBLDR . $outVals[THSP][VAL] . NL; $retVal .= TBLR . TBLDL . $outVals[FLDS][TXT] . '<td class="tdr" colspan="7">' . $outVals[FLDS][VAL] . '</td></tr>' . NL; $retVal .= TBLE . NL; break; case 'ski': $retVal .= TBLS . NL; $retVal .= TBLR; $retVal .= TBLDL . $outVals[STRT][TXT] . TBLDR . $outVals[STRT][VAL] . NL; $retVal .= TBLDL . $outVals[DIST][TXT] . TBLDR . $outVals[DIST][VAL] . NL; $retVal .= TBLDL . $outVals[MXEL][TXT] . TBLDR . $outVals[MXEL][VAL] . NL; $retVal .= TBLDL . $outVals[DURM][TXT] . TBLDR . $outVals[DURM][VAL] . NL; $retVal .= TBLR; $retVal .= TBLDL . $outVals[ENDT][TXT] . TBLDR . $outVals[ENDT][VAL] . NL; $retVal .= TBLDL . $outVals[MXSP][TXT] . TBLDR . $outVals[MXSP][VAL] . NL; $retVal .= TBLDL . $outVals[ASCT][TXT] . TBLDR . $outVals[ASCT][VAL] . NL; $retVal .= TBLDL . $outVals[ASTM][TXT] . TBLDR . $outVals[ASTM][VAL] . NL; $retVal .= TBLR; $retVal .= TBLDL . $outVals[DURN][TXT] . TBLDR . $outVals[DURN][VAL] . NL; $retVal .= TBLDL . $outVals[DSSP][TXT] . TBLDR . $outVals[DSSP][VAL] . NL; $retVal .= TBLDL . $outVals[DSCT][TXT] . TBLDR . $outVals[DSCT][VAL] . NL; $retVal .= TBLDL . $outVals[DSTM][TXT] . TBLDR . $outVals[DSTM][VAL] . NL; $retVal .= TBLE . NL; break; case 'walk': $retVal .= TBLS . NL; $retVal .= TBLR; $retVal .= TBLDL . $outVals[DIST][TXT] . TBLDR . $outVals[DIST][VAL] . NL; $retVal .= TBLDL . $outVals[ASCT][TXT] . TBLDR . $outVals[ASCT][VAL] . NL; $retVal .= TBLDL . $outVals[DSCT][TXT] . TBLDR . $outVals[DSCT][VAL] . NL; $retVal .= TBLR; $retVal .= TBLDL . $outVals[MXEL][TXT] . TBLDR . $outVals[MXEL][VAL] . NL; $retVal .= TBLDL . $outVals[AVMS][TXT] . TBLDR . $outVals[AVMS][VAL] . NL; $retVal .= TBLDL . $outVals[DURM][TXT] . TBLDR . $outVals[DURM][VAL] . NL; $retVal .= TBLR; $retVal .= TBLDL . $outVals[STRT][TXT] . TBLDR . $outVals[STRT][VAL] . NL; $retVal .= TBLDL . $outVals[ENDT][TXT] . TBLDR . $outVals[ENDT][VAL] . NL; $retVal .= TBLDL . $outVals[DURN][TXT] . TBLDR . $outVals[DURN][VAL] . NL; $retVal .= TBLE . NL; break; case 'tramp': $retVal .= TBLS . NL; $retVal .= TBLR; $retVal .= TBLDL . $outVals[STRT][TXT] . TBLDR . $outVals[STRT][VAL] . NL; $retVal .= TBLDL . $outVals[STEL][TXT] . TBLDR . $outVals[STEL][VAL] . NL; $retVal .= TBLDL . $outVals[MXEL][TXT] . TBLDR . $outVals[MXEL][VAL] . NL; $retVal .= TBLDL . $outVals[DIST][TXT] . TBLDR . $outVals[DIST][VAL] . NL; $retVal .= TBLR; $retVal .= TBLDL . $outVals[ENDT][TXT] . TBLDR . $outVals[ENDT][VAL] . NL; $retVal .= TBLDL . $outVals[ENEL][TXT] . TBLDR . $outVals[ENEL][VAL] . NL; $retVal .= TBLDL . $outVals[ASCT][TXT] . TBLDR . $outVals[ASCT][VAL] . NL; $retVal .= TBLDL . $outVals[AVMS][TXT] . TBLDR . $outVals[AVMS][VAL] . NL; $retVal .= TBLR; $retVal .= TBLDL . $outVals[DURN][TXT] . TBLDR . $outVals[DURN][VAL] . NL; $retVal .= TBLDL . $outVals[CHEL][TXT] . TBLDR . $outVals[CHEL][VAL] . NL; $retVal .= TBLDL . $outVals[DSCT][TXT] . TBLDR . $outVals[DSCT][VAL] . NL; $retVal .= TBLDL . $outVals[DURM][TXT] . TBLDR . $outVals[DURM][VAL] . NL; $retVal .= TBLE . NL; break; case 'train': case 'fly': # add in case we want to differentiate these later case 'boat': case 'bus': case 'drive': $retVal .= TBLS . NL; $retVal .= TBLR; $retVal .= TBLDL . $outVals[DIST][TXT] . TBLDR . $outVals[DIST][VAL] . NL; $retVal .= TBLDL . $outVals[DURN][TXT] . TBLDR . $outVals[DURN][VAL] . NL; $retVal .= TBLDL . $outVals[DURM][TXT] . TBLDR . $outVals[DURM][VAL] . NL; $retVal .= TBLR; $retVal .= TBLDL . $outVals[AVMS][TXT] . TBLDR . $outVals[AVMS][VAL] . NL; if (in_array('train', $dispArray)) { $retVal .= TBLDL . $outVals[MXSP][TXT] . TBLDR . $outVals[MXSP][VAL] . NL; } else { $retVal .= TBLDL . $outVals[GPXS][TXT] . TBLDR . $outVals[GPXS][VAL] . NL; } $retVal .= TBLDL . $outVals[MXEL][TXT] . TBLDR . $outVals[MXEL][VAL] . NL; $retVal .= TBLR; $retVal .= TBLDL . $outVals[STRT][TXT] . TBLDR . $outVals[STRT][VAL] . NL; $retVal .= TBLDL . $outVals[ENDT][TXT] . TBLDR . $outVals[ENDT][VAL] . NL; $retVal .= TBLDL . $outVals[MNEL][TXT] . TBLDR . $outVals[MNEL][VAL] . NL; $retVal .= TBLE . NL; break; case 'default': # show everything by default in a list $retVal .= '<div class="gpxstat">' . NL; $retVal .= SS . GPXSTXT . SE . $RecipeInfo[GPXSTATNAME] ['Version'] . BR; $retVal .= SS . $outVals[DIST][TXT] . SE . $outVals[DIST][VAL] . BR; $retVal .= SS . $outVals[MXSP][TXT] . SE . $outVals[MXSP][VAL] . ' ' . $calcVals [SPCL] . BR; $retVal .= SS . $outVals[DSSP][TXT] . SE . $outVals[DSSP][VAL] . BR; $retVal .= SS . $outVals[ASSP][TXT] . SE . $outVals[ASSP][VAL] . BR; $retVal .= SS . $outVals[AVMS][TXT] . SE . $outVals[AVMS][VAL] . BR; $retVal .= SS . $outVals[AVSP][TXT] . SE . $outVals[AVSP][VAL] . BR; $retVal .= SS . $outVals[MNEL][TXT] . SE . $outVals[MNEL][VAL] . BR; $retVal .= SS . $outVals[MXEL][TXT] . SE . $outVals[MXEL][VAL] . BR; $retVal .= SS . $outVals[STEL][TXT] . SE . $outVals[STEL][VAL] . BR; $retVal .= SS . $outVals[ENEL][TXT] . SE . $outVals[ENEL][VAL] . BR; $retVal .= SS . $outVals[CHEL][TXT] . SE . $outVals[CHEL][VAL] . BR; $retVal .= SS . $outVals[ASCT][TXT] . SE . $outVals[ASCT][VAL] . BR; $retVal .= SS . $outVals[DSCT][TXT] . SE . $outVals[DSCT][VAL] . BR; $retVal .= SS . $outVals[STRT][TXT] . SE . $outVals[STRT][VAL] . BR; $retVal .= SS . $outVals[ENDT][TXT] . SE . $outVals[ENDT][VAL] . BR; $retVal .= SS . $outVals[ELPS][TXT] . SE . $outVals[ELPS][VAL] . BR; $retVal .= SS . $outVals[DURN][TXT] . SE . $outVals[DURN][VAL] . BR; $retVal .= SS . $outVals[DURM][TXT] . SE . $outVals[DURM][VAL] . BR; $retVal .= SS . $outVals[ASTM][TXT] . SE . $outVals[ASTM][VAL] . BR; $retVal .= SS . $outVals[DSTM][TXT] . SE . $outVals[DSTM][VAL] . BR; $retVal .= SS . $outVals[DURS][TXT] . SE . $outVals[DURS][VAL] . BR; $retVal .= SS . $outVals[NRPT][TXT] . SE . $outVals[NRPT][VAL] . BR; if ($calcVals [NRSG] > 1) { $retVal .= SS . $outVals[NRSG][TXT] . SE . $outVals[NRSG][VAL] . BR; } if ($calcVals [NRTK] > 1) { $retVal .= SS . $outVals[NRTK][TXT] . SE . $outVals[NRTK][VAL] . BR; } $retVal .= SS . $outVals[TZDB][TXT] . SE . $outVals[TZDB][VAL] . ' ' . $calcVals [TZCL] . BR; $retVal .= SS . $outVals[TDAT][TXT] . SE . $outVals[TDAT][VAL] . BR; $retVal .= SS . $outVals[TNAM][TXT] . SE . $outVals[TNAM][VAL] . BR; $retVal .= SS . $outVals[TDES][TXT] . SE . $outVals[TDES][VAL] . BR; $retVal .= SS . $outVals[FLDS][TXT] . SE . $outVals[FLDS][VAL] . SE . NL; $retVal .= '</div>' . NL; break; case 'analyse': # displayed after all other display options break; case 'showvals' : # changes analyse output break; default: $retVal .= GPXSTATNAME . ': Unknown display option: "' . implode (', ', $dispArray) . '"' . BR; } # end switch } # end foreach } # end if // Provide analysis of GPX file if (in_array('analyse', $dispArray)) { $retVal .= '<hr>' . TBLS . NL; $retVal .= TBLR . TBLDL . '<i>Analyse</i>' . TBLDR . $calcVals [SPCL] . NL; $retVal .= TBLDL . $outVals[TZDB][TXT] . TBLDR . $outVals[TZDB][VAL] . ' ' . $calcVals [TZCL] . NL; $retVal .= TBLDL . $outVals[THSP][TXT] . TBLDR . $outVals[THSP][VAL] . NL; $retVal .= TBLR . TBLD . 'Stopped durations: '; // analyse the effect of a variance in stopped threshold speed $colCount = 1; $analyseTHSpeed = 0.2; # km/h $thSpeed = 0.1; # km/h for ($indx = 1; $indx <= 17; $indx++) { if ($colCount++ > 5) { $colCount = 1; $retVal .= NL . TBLR . NL; } list ($stoppedDuration, $stoppedCount) = calcStoppedDuration ($timeVals, $speedVals, $thSpeed); $retVal .= TBLDB . displaySecsAsHoursMins($stoppedDuration) . HOUR . number_format ($thSpeed, 1) . KMH . ' (#' . number_format ($stoppedCount) . ')' . NL; $thSpeed += $analyseTHSpeed; } #end for // analyse the distribution of the data foreach ($diffVals as $diffKey => $diffVal) { $retVal .= TBLR . TBLDL . 'Variances: ' . TBLDR . $outVals[$diffKey][TXT] . ' ' . $outVals[$diffKey][VAL] . NL . $diffVal . NL; } $retVal .= TBLR; $retVal .= TBLDL . $outVals[NRPT][TXT] . TBLDR . $outVals[NRPT][VAL] . NL; if ($calcVals [NRSG] > 1) { $retVal .= TBLDL . $outVals[NRSG][TXT] . TBLDR . $outVals[NRSG][VAL] . NL; } else {$retVal .= TBLD2 . NL; } if ($calcVals [NRTK] > 1) { $retVal .= TBLDL . $outVals[NRTK][TXT] . TBLDR . $outVals[NRTK][VAL] . NL; } else {$retVal .= TBLD2 . NL; } $retVal .= TBLR . TBLDL . $outVals[TNAM][TXT] . '<td class="tdr" colspan="5">' . $outVals[TNAM][VAL] . '</td></tr>' . NL; $retVal .= TBLR . TBLDL . $outVals[TDES][TXT] . '<td class="tdr" colspan="5">' . $outVals[TDES][VAL] . '</td></tr>' . NL; $retVal .= TBLR . TBLDL . $outVals[TDAT][TXT] . '<td class="tdr" colspan="5">' . $outVals[TDAT][VAL] . NL; $retVal .= TBLE . NL; $retVal .= '<span class="gpxstat">' . $outVals[FLDS][TXT] . $outVals[FLDS][VAL] . '</span>' . BR; $retVal .= '<details><summary class="gpxstat">Max, Min, Outliers</summary>' . NL . TBLS . TBLR . NL; // Get the maximum speeds for analysis $retVal .= TBLDL; arsort($speedVals, SORT_NUMERIC); // Sort speeds in descending order while maintaining index association $maxSpeeds = array_slice($speedVals, 0, AVGWINDOW, true); $retVal .= 'Max Speeds' . BR . 'Record#: Speed' . BR; foreach ($maxSpeeds as $recordNumber => $maxspeed) { $retVal .= '#' . number_format($recordNumber) . ': ' . number_format($maxspeed, 1) . KMH . BR; } # end foreach $retVal .= 'Avg Max speed: ' . number_format (array_sum($maxSpeeds) / count ($maxSpeeds), 1) . KMH . BR; // Get the maximum elevations for analysis $retVal .= TBLDL; arsort($elevVals, SORT_NUMERIC); // Sort elevation in descending order while maintaining index association $maxElevs = array_slice($elevVals, 0, AVGWINDOW); $retVal .= 'Max Elevations' . BR . 'Record#: Elevation' . BR; foreach ($maxElevs as $recordNumber => $maxelev) { $retVal .= '#' . number_format($recordNumber) . ': ' . number_format($maxelev, 1) . MTR . BR; } # end foreach $retVal .= 'Avg Max Elev: ' . number_format (array_sum($maxElevs) / count ($maxElevs), 1) . MTR . BR; // Get the minimum elevations for analysis $retVal .= TBLDL; $minElevs = array_slice($elevVals, -intval(AVGWINDOW)); $retVal .= 'Min Elevations' . BR . 'Record#: Elevation' . BR; foreach ($minElevs as $recordNumber => $minelev) { $retVal .= '#' . number_format($recordNumber) . ': ' . number_format($minelev, 1) . MTR . BR; } # end foreach $retVal .= 'Avg Min Elev: ' . number_format (array_sum($minElevs) / count ($minElevs), 1) . MTR . BR; // Get the maximum distances for analysis $retVal .= TBLDL; arsort($distVals, SORT_NUMERIC); $maxDists = array_slice($distVals, 0, AVGWINDOW, true); $retVal .= 'Max Distances' . BR . 'Record#: Distance' . BR; foreach ($maxDists as $recordNumber => $maxdist) { $retVal .= '#' . number_format($recordNumber) . ': ' . number_format($maxdist, 1) . MTR . BR; } # end foreach $retVal .= 'Avg Max dist: ' . number_format (array_sum($maxDists) / count ($maxDists), 1) . KMH . BR; // Display speed outliers $retVal .= TBLDL; $retVal .= 'Speed outliers (' . number_format(count($speedOutliers)) . ')' . BR . 'Record#: Speed' . BR; foreach ($speedOutliers as $speedOutlier) { $retVal .= '#' . number_format($speedOutlier['position']) . ': ' . number_format($speedOutlier['value'], 1) . KMH . BR; } # end foreach // Display elevation outliers $retVal .= TBLDL; $retVal .= 'Elevation outliers (' . number_format(count($elevOutliers)) . ')' . BR . 'Record#: Elevation' . BR; foreach ($elevOutliers as $elevOutlier) { $retVal .= (isset($elevOutlier)) ? '#' . number_format($elevOutlier['position']) . ': ' . number_format($elevOutlier['value'], 1) . MTR . BR : 'Elev outlier missing ' . BR; } # end foreach // Display distance outliers $retVal .= TBLDL; $retVal .= 'Distance outliers (' . number_format(count($distOutliers)) . ')' . BR . 'Record#: Distance' . BR; foreach ($distOutliers as $distOutlier) { $retVal .= '#' . number_format($distOutlier['position']) . ': ' . number_format($distOutlier['value'], 1) . MTR . BR; } # end foreach $retVal .= TBLE . NL . '</details>' . NL; } # end if return Keep ($retVal); } # # function removeOutliers(string $dataName, array $dataIn, $stdDev, int $windowSize = AVGWINDOW) { /* * This function removes outliers from a dataset using a simple outlier detection algorithm. * * @param array $dataIn The input data array. * @param float $stdDev The stdDev value for outlier detection. See https://en.wikipedia.org/wiki/Standard_deviation#Rules_for_normally_distributed_data 1 sd ~= 68%; 2 sd ~= 95%; 2.5 sd ~= 99%; 3 sd ~= 99.7% * @param int $windowSize The number of points to consider for the average delta vector. * @local float $blendingFactor The blending factor for combining the extrapolated point and the actual data point. * * @return associative array with * data array with outliers removed * array of outliers along with their original positions in the input data array. * Each outlier is represented as an associative array with `value` and `position` keys, where `value` is the outlier value and `position` is its original position in the input data array. * * The blending factor determines how much weight is given to the actual data point versus the extrapolated point. * A common starting point is 0.5, which gives equal weight to both. To give more weight to the actual data points, increase $P (e.g., to 0.6 or 0.7). * To give more weight to the extrapolated points (i.e., smooth the data more), decrease $P (e.g., to 0.4 or 0.3). */ global $showVals; # for use with analyse $outlierVals = ''; # log outlier values $blendingFactor = 1.0; # set to ignore extrapolated point if within stdDev (don't change this for this gpx application) $smoothData = []; // Initialise an empty array to store the smoothed result. $outliers = []; // Initialise an empty array to store the outliers. $sizeData = count($dataIn); // Get the size of the input data array. list ($diffAvg, $diffMax, $diffStdDev) = calcDifference($dataIn); # find average, maximum, and std deviation of the differences between points # $threshold = $diffStdDev * $stdDev;; # default threshold set to value $outlierVals .= TBLDR . ' Diff avg: ' . number_format($diffAvg, 2) . TBLDR . ' Diff std dev: ' . number_format($diffStdDev, 2) . TBLDR . ' Diff max: ' . number_format($diffMax, 2) . TBLDR . ' Calc Thrh: ' . number_format($threshold, 2); if ($showVals) { $outlierVals .= TBLR . TBLDL . $dataName . ' diff vals: ' . '<td class="tdr" colspan="3">' . TBLDR . '#points: ' . number_format($sizeData) . TBLDR . '#std dev: ' . $stdDev . NL ; $outlierVals .= TBLR . '<td colspan=6>' . '<details><summary class="gpxstat">' . $dataName . ' details</summary>' . NL .TBLS . NL; } // Loop over the data array starting from the x-th element. for ($indx = 1; $indx < $sizeData; $indx++) { // Compute the average delta vector over the last windowSize points # start from 1 as we are calclating the difference from the previous point $slidingWindow = array_slice($dataIn, $indx - $windowSize, $windowSize); list ($wndwDelta, , ) = calcDifference($slidingWindow); // Extrapolate a new point by adding the average delta vector to the last point. $extrapolatedPoint = floatval ($dataIn[$indx - 1] + $wndwDelta); $pointVariance = floatval ($dataIn[$indx] - $dataIn[$indx - 1]); // If the variance between the actual data point and the previous point is less than the threshold... switch (true) { case (abs($pointVariance) <= $threshold): // ...consider it a good data point and blend it with the extrapolated point. $smoothData[] = ($blendingFactor * $dataIn[$indx]) + ((1 - $blendingFactor) * $extrapolatedPoint); break; default: // If the variance exceeds the threshold, consider it an outlier and use just the extrapolated point. $smoothData[] = $extrapolatedPoint; // Add the outlier and its original position to the outliers array. $outliers[] = ['value' => $dataIn[$indx], 'position' => intval($indx)]; if ($showVals) { $outlierVals .= TBLR . TBLDL . '#: ' . number_format($indx - 1) . TBLDL . ' Wndw delta: ' . number_format($wndwDelta, 2) . TBLDL . ' pt:' . ((isset ($dataIn[$indx-1])) ? number_format($dataIn[$indx-1], 1) : 'null') . TBLDL . '>pt+: ' . number_format($dataIn[$indx], 1) . TBLDL . ' (expt:' . number_format($extrapolatedPoint, 1) . ')' . TBLDB . ' var:' . number_format($pointVariance, 1) . NL; } } # end switch } # end for if ($showVals) { $outlierVals .= TBLE . '</details>'; } // Return the resulting data array and the outliers. return [$smoothData, $outliers, $outlierVals]; } # end removeOutliers # function calcDifference (array $dataIn) { /* Calculate the average difference between all consecutive values in an array. * $dataIn array The input array of numeric values. * @return array The average difference, max diff, and standard deviation if the array has at least two elements, otherwise zero. */ $pointDifferences = []; # initialise $squares = []; $nrItems = count($dataIn); if($nrItems <= 1) { return [0, 0, 0]; } // calculate the difference between every consecutive point for($indx = 0; $indx < $nrItems - 1; $indx++) { $pointDifferences [] = $dataIn[$indx + 1] - $dataIn[$indx]; } $diffMax = max(array_map('abs', $pointDifferences)); # absolute value $totalDiff = array_sum ($pointDifferences); $diffAvg = floatval ($totalDiff / ($nrItems - 1)); $squares = []; foreach ($pointDifferences as $pointVal) { $squares[] = pow($pointVal - $diffAvg, 2); } $stdDev = floatval (sqrt(array_sum($squares) / count($squares))); return [$diffAvg, $diffMax, $stdDev]; } # end calcDifference # function calcStoppedDuration (array $timeVals, array $speedVals, float $thSpeedKMH) { // Calculate stopped times; note parameter arrays start from 1 $nrSpeedVals = count ($speedVals); if (count ($speedVals) !== count ($timeVals)) return [$nrSpeedVals, count ($timeVals)]; # should never happen $thresholdSpeedMS = $thSpeedKMH * (KM_TO_M / HOUR_TO_S); # convert to metres per second $stoppedDuration = 0; $stoppedCount = 0; for ($indx = 1; $indx < $nrSpeedVals; $indx++) { if ($speedVals [$indx] <= $thresholdSpeedMS) { # stopped $intervalDuration = abs($timeVals [$indx] - $timeVals [$indx - 1]); # this is a proxy for detecting separate tracks # if the interval is greater than 4 hours ignore it for analysis purposes if ($intervalDuration < 4 * HOUR_TO_S) { # four hours in seconds $stoppedDuration += $intervalDuration; $stoppedCount++; } } } return [$stoppedDuration, $stoppedCount]; # unix timestamp } # end CalcStoppedTime # function calcChange (array $dataIn, array $speedVals, float $thSpeedKMH) { // Calculate values increasing and descreasing when moving $nrItems = count($dataIn); if ($nrItems !== count ($speedVals)) return [$nrItems, count ($speedVals)]; # should never happen $thresholdSpeedMS = $thSpeedKMH * (KM_TO_M / HOUR_TO_S); # convert to metres per second $Increase = 0; $Decrease = 0; for ($indx = 2; $indx < $nrItems; $indx++) { if ($speedVals [$indx] > $thresholdSpeedMS) { # moving $pointChange = $dataIn [$indx] - $dataIn [$indx - 1]; if ($pointChange < 0) { # decreasing $Decrease += abs($pointChange); } else { $Increase += $pointChange; } # if } } # for return [$Increase, $Decrease]; # unix timestamp } # end calcChange # function calcAvgSpeed (array $elevVals, array $speedVals, array $distVals, array $timeVals, float $thSpeedKMH) { // Calculate speeds ascending and descending when moving $nrElevVals = count ($elevVals); if ($nrElevVals !== count ($speedVals)) return [$nrElevVals, count ($speedVals)]; # should never happen $thresholdSpeedMS = $thSpeedKMH * (KM_TO_M / HOUR_TO_S); # convert to metres per second $ascDist = 0; # sum of ascending distance in metres $ascTime = 0; # sum of time ascending in seconds $dscDist = 0; # sum or descending distance in metres $dscTime = 0; # sum of time descending in seconds for ($indx = 2; $indx < $nrElevVals; $indx++) { if ($speedVals [$indx] > $thresholdSpeedMS) { # moving $elevChange = $elevVals [$indx] - $elevVals [$indx - 1]; if ($elevChange == 0) continue; # next # $distChange = $distVals [$indx]; # metres $timeChange = $timeVals [$indx] - $timeVals [$indx - 1]; # seconds if ($elevChange < 0) { # descending $dscDist += $distChange; $dscTime += $timeChange; } else { # ascending $ascDist += $distChange; $ascTime += $timeChange; } # if } } # for $ascSpeed = calculateSpeedKMH ($ascDist, $ascTime); $dscSpeed = calculateSpeedKMH ($dscDist, $dscTime); return [$ascSpeed, $dscSpeed]; # speed KMH } # end calcAvgSpeed # function calcMinMax (array $calcVals) : array { arsort($calcVals, SORT_NUMERIC); // Sort vals in descending order while maintaining index association $minVals = array_slice($calcVals, -intval(AVGWINDOW)); $minVal = array_sum($minVals) / count ($minVals); $maxVals = array_slice($calcVals, 0, intval(AVGWINDOW), true); $maxVal = array_sum($maxVals) / count ($maxVals); return [$minVal, $maxVal]; } # function calculateSpeedKMH (float $distanceInMeters, float $durationInSeconds): float { $speedInKMH = ($durationInSeconds == 0) ? 0 : $distanceInMeters / 1000 / ($durationInSeconds / HOUR_TO_S); return $speedInKMH; } # // check wiki file exists when passed groupname.pagename/filename.ext function CheckWikiFile (string $wikifilename):array { global $UploadDir, $FmtV, $gpxstat_debugon, $pagename; # check if wiki file exists # if it does exist return FQDN # if it doesn't exist return markup to allow file to be uploaded $wikifilefqdn = \DownloadUrl ($pagename, $wikifilename); #PmWiki function, returns the public URL of an attached file or false if it doesn't exist //if ($gpxstat_debugon) tpmsg ('wikifile', '"' . $wikifilename . '", "' . strval ($wikifilefqdn) . '", "' . $FmtV['$LinkUpload'] . '"'); if (! $wikifilefqdn === false) { # file exists return [true, $wikifilefqdn]; } $wikimarkup = 'Upload: [[Attach:' . $wikifilename . ' | ' . $wikifilename . ']]' . BR; # # note that the markup [[>>]] already seems to have been processed by the time we return this return [false, $wikimarkup]; } # end CheckWikiFile # function checkTimezone ($gpxTimezone, $gpxXml) { # seach IANA / Olson and official abbreviations to validate input global $retVal; $tzCalc = ''; // Check if the parameter is a SimpleXMLElement object $gpxXml->registerXPathNamespace('gpx', 'https://www.topografix.com/GPX/1/1'); if (empty($gpxTimezone)) return [null, $tzCalc]; # no timezone to check $gpxstatTimezone = strval ($gpxTimezone); if (strtolower($gpxstatTimezone) == 'detect') { # try to calculate timezone for position list ($cur_lat, $cur_long) = get_first_lat_long_from_gpx($gpxXml); $timezone = get_nearest_timezone($cur_lat, $cur_long, $country_code = null); if (empty($timezone)) return [null, 'TZ not detected']; return [$timezone, 'TZ detected']; } // search IANA / Olson timezone database if (in_array($gpxstatTimezone, timezone_identifiers_list())) { $timezone = $gpxstatTimezone; date_default_timezone_set($timezone); # side effect return [$timezone, $tzCalc]; } // search official timezone abbreviations $timezone = timezone_name_from_abbr($gpxstatTimezone); if (! empty($timezone)) { if (in_array(strval($timezone), timezone_identifiers_list())) { date_default_timezone_set($timezone); # side effect return [$timezone, $tzCalc]; } } $tzCalc = 'Unrecognised: "' . $gpxstatTimezone . '" timezone'; $retVal .= GPXSTATNAME . ': ' . $tzCalc; return [null, $tzCalc]; } # end checkTimezone # function get_nearest_timezone($cur_lat, $cur_long, $country_code = null) { // Function to calculate the nearest timezone based on the given latitude and longitude // This method might not be 100% accurate for countries with multiple timezones. // see https://stackoverflow.com/questions/3126878/get-php-timezone-name-from-latitude-and-longitude // Get all timezone identifiers, if country code is provided then get timezone identifiers of that country $timezone_ids = (empty($country_code)) # country_code not supplied ? \DateTimeZone::listIdentifiers() : \DateTimeZone::listIdentifiers(\DateTimeZone::PER_COUNTRY, $country_code); // Check if timezone identifiers exist if($timezone_ids && is_array($timezone_ids) && isset($timezone_ids[0])) { // If only one identifier exists, set it as the timezone if(count($timezone_ids) == 1) { return $timezone_ids[0]; } // Loop through all timezone identifiers $tz_distance = PHP_INT_MAX; $time_zone = null; foreach($timezone_ids as $timezone_id) { // Create a new DateTimeZone object $timezone = new \DateTimeZone($timezone_id); // Get the location of the timezone $location = $timezone->getLocation(); $tz_lat = $location['latitude']; $tz_long = $location['longitude']; // Calculate the distance between the given location and the timezone location $theta = floatval($cur_long) - $tz_long; $distance = (sin(deg2rad(floatval($cur_lat))) * sin(deg2rad($tz_lat))) + (cos(deg2rad(floatval($cur_lat))) * cos(deg2rad($tz_lat)) * cos(deg2rad($theta))); $distance = acos($distance); $distance = abs(rad2deg($distance)); // If no timezone has been set or the calculated distance is less than the previous distance // then set the current timezone as the nearest timezone if($tz_distance > $distance) { $time_zone = $timezone_id; $tz_distance = $distance; } } // Return the nearest timezone return $time_zone; } // If no timezone identifiers exist, return message return 'get_nearest_timezone: no timezone ids' . BR; } # function get_first_lat_long_from_gpx($xml) { /** * Function to get the first latitude and longitude from GPX data. * * @param string $gpx_contents The GPX data as a string. * @return array withth 'lat' and 'lon' , or null. */ global $retVal; $xml->registerXPathNamespace('gpx', 'https://www.topografix.com/GPX/1/1'); // Find the first track point $trackpoints = $xml->xpath('//gpx:trkpt'); if ($trackpoints === false) return null; if (empty($trackpoints)) return null; // Track points are found and the first one exists // Get the first track point $first_point = $trackpoints[0]; // Get the latitude and longitude attributes as strings $lat = strval($first_point['lat']); $lon = strval($first_point['lon']); // Return the latitude and longitude as an associative array return [$lat, $lon]; } # function haversineGreatCircleDistance (float $latitudeFrom, float $longitudeFrom, float $latitudeTo, float $longitudeTo): float { // calculate distance in metres between two points on the earth's surface // convert from degrees to radians ## see https://community.esri.com/t5/coordinate-reference-systems-blog/distance-on-a-sphere-the-haversine-formula/ba-p/902128 $latFrom = deg2rad($latitudeFrom); $lonFrom = deg2rad($longitudeFrom); $latTo = deg2rad($latitudeTo); $lonTo = deg2rad($longitudeTo); $latDelta = $latTo - $latFrom; $lonDelta = $lonTo - $lonFrom; $angle = 2 * asin(sqrt(pow(sin($latDelta / 2), 2) + cos($latFrom) * cos($latTo) * pow(sin($lonDelta / 2), 2))); return $angle * EARTH_RADIUS; # return in units of metres } # end haversineGreatCircleDistance # function get_timezone_abbreviation($timezone_id) { $abb_list = timezone_abbreviations_list(); foreach ($abb_list as $abb_key => $abb_val) { $key = array_search($timezone_id, array_column($abb_val, 'timezone_id')); if ($key !== false) { return strtoupper($abb_key); } } return false; } # function displaySecsAsHoursMins(int $duration, string $format = '%02d:%02d'): string { # convert a duration in seconds to a display as hours and minutes $hours = intval (floor($duration / HOUR_TO_S)); $minutes = intval (($duration / 60)) % 60; return sprintf($format, $hours, $minutes); } # function makeabbr (string $literal, $title): string { # https://stackoverflow.com/questions/5362628/how-to-get-the-names-and-abbreviations-of-a-time-zone-in-php if (empty($title)) return $literal; return '<abbr title="' . $title . '">' . $literal . '</abbr>'; } function display_xml_error($error, $gpxXml) { # This function takes a LibXMLError object and the XML data as input, # and returns a string that represents the error. $return = GPXSTATNAME . ': "' . $gpxXml[$error->line - 1] . '"' . BR; $return .= str_repeat('-', $error->column) . "^\n"; // Determine the error level and add the appropriate message to the return string switch ($error->level) { case LIBXML_ERR_WARNING: $return .= "Warning $error->code: "; break; case LIBXML_ERR_ERROR: $return .= "Error $error->code: "; break; case LIBXML_ERR_FATAL: $return .= "Fatal Error $error->code: "; break; } // Add the error message, line, and column to the return string $return .= trim($error->message) . BR . "Line: $error->line" . BR . "Column: $error->column"; // If a file is associated with the error, add it to the return string if ($error->file) { $return .= BR . "File: $error->file"; } return $return; } # end display_xml_error