Files
OpenTracks/src/main/java/de/dennisguse/opentracks/stats/SegmentStatisticUpdater.java
T
2025-11-17 22:22:44 +01:00

326 lines
11 KiB
Java

/*
* Copyright 2010 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package de.dennisguse.opentracks.stats;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import java.time.Duration;
import java.time.Instant;
import de.dennisguse.opentracks.data.models.Altitude;
import de.dennisguse.opentracks.data.models.AltitudeExtremities;
import de.dennisguse.opentracks.data.models.AltitudeGainLoss;
import de.dennisguse.opentracks.data.models.Distance;
import de.dennisguse.opentracks.data.models.HeartRate;
import de.dennisguse.opentracks.data.models.Power;
import de.dennisguse.opentracks.data.models.Speed;
import de.dennisguse.opentracks.data.models.Statistics;
import de.dennisguse.opentracks.data.models.Track;
import de.dennisguse.opentracks.data.models.TrackPoint;
/**
* Statistical data about a {@link Track}.
* The data in this class should be filled out by {@link TrackStatisticsUpdater}.
*
* @author Rodrigo Damazio
*/
//TODO Check that data ranges are valid (not less than zero etc.)
public class SegmentStatisticUpdater {
// The min and max altitude (meters) seen on this track.
private final ExtremityMonitor altitudeExtremities = new ExtremityMonitor();
// The track start time.
private Instant startTime; //TODO Should never be null!
// The track stop time.
private Instant stopTime; //TODO Should never be null!
private Distance totalDistance;
/**
* Updated when new points are received, may be stale.
* This statistic is only updated when a new point is added to the statistics, so it may be off.
* If you need to calculate the proper totalTime, use startTime with the current time.
*/
private Duration totalTime;
// Based on when we believe the user is traveling.
private Duration movingTime;
// The maximum speed (meters/second) that we believe is valid.
private Speed maxSpeed;
private Float totalAltitudeGain_m = null;
private Float totalAltitudeLoss_m = null;
// The average heart rate seen on this track
private HeartRate avgHeartRate = null;
private Power avgPower = null;
private boolean isIdle;
public SegmentStatisticUpdater() {
reset();
}
/**
* Copy constructor.
*
* @param other another statistics data object to copy from
*/
public SegmentStatisticUpdater(SegmentStatisticUpdater other) {
this(other.getStatistics());
this.isIdle = other.isIdle();
}
public SegmentStatisticUpdater(Statistics statistics) {
startTime = statistics.startTime();
stopTime = statistics.stopTime();
totalDistance = statistics.totalDistance();
totalTime = statistics.totalTime();
movingTime = statistics.movingTime();
maxSpeed = statistics.maxSpeed();
if (statistics.altitudeExtremities() != null) {
altitudeExtremities.set(statistics.altitudeExtremities().min_m(), statistics.altitudeExtremities().max_m());
}
if (statistics.altitudeGainLoss() != null) {
totalAltitudeGain_m = statistics.altitudeGainLoss().gain_m();
totalAltitudeLoss_m = statistics.altitudeGainLoss().loss_m();
}
avgHeartRate = statistics.avgHeartRate();
avgPower = statistics.avgPower();
}
/**
* Combines these statistics with those from another object.
* This assumes that the time periods covered by each do not intersect.
*
* @param other another statistics data object
*/
//TODO Should be refactored to append only [mainly due to isIdle]
public void merge(SegmentStatisticUpdater other) {
if (startTime == null) {
startTime = other.startTime;
} else {
startTime = startTime.isBefore(other.startTime) ? startTime : other.startTime;
}
if (stopTime == null) {
stopTime = other.stopTime;
} else {
stopTime = stopTime.isAfter(other.stopTime) ? stopTime : other.stopTime;
}
isIdle = other.isIdle; //TODO This implicitly assumes append mode.
if (avgHeartRate == null) {
avgHeartRate = other.avgHeartRate;
} else {
if (other.avgHeartRate != null) {
// Using total time as weights for the averaging.
// Important to do this before total time is updated
avgHeartRate = HeartRate.of(
(totalTime.getSeconds() * avgHeartRate.getBPM() + other.totalTime.getSeconds() * other.avgHeartRate.getBPM())
/ (totalTime.getSeconds() + other.totalTime.getSeconds())
);
}
}
if (avgPower == null) {
avgPower = other.avgPower;
} else {
if (other.avgPower != null) {
// Using total time as weights for the averaging.
// Important to do this before total time is updated
avgPower = Power.of(
(totalTime.getSeconds() * avgPower.getW() + other.totalTime.getSeconds() * other.avgPower.getW())
/ (totalTime.getSeconds() + other.totalTime.getSeconds())
);
}
}
totalDistance = totalDistance.plus(other.totalDistance);
totalTime = totalTime.plus(other.totalTime);
movingTime = movingTime.plus(other.movingTime);
maxSpeed = Speed.max(maxSpeed, other.maxSpeed);
if (other.altitudeExtremities.hasData()) {
altitudeExtremities.update(other.altitudeExtremities.getMin());
altitudeExtremities.update(other.altitudeExtremities.getMax());
}
if (totalAltitudeGain_m == null) {
if (other.totalAltitudeGain_m != null) {
totalAltitudeGain_m = other.totalAltitudeGain_m;
}
} else {
if (other.totalAltitudeGain_m != null) {
totalAltitudeGain_m += other.totalAltitudeGain_m;
}
}
if (totalAltitudeLoss_m == null) {
if (other.totalAltitudeLoss_m != null) {
totalAltitudeLoss_m = other.totalAltitudeLoss_m;
}
} else {
if (other.totalAltitudeLoss_m != null) {
totalAltitudeLoss_m += other.totalAltitudeLoss_m;
}
}
}
public boolean isInitialized() {
return startTime != null;
}
public void reset() {
startTime = null;
stopTime = null;
totalTime = Duration.ZERO;
movingTime = Duration.ZERO;
totalDistance = Distance.ZERO;
maxSpeed = Speed.ZERO;
totalAltitudeGain_m = null;
totalAltitudeLoss_m = null;
isIdle = false;
}
public void reset(Instant startTime) {
reset();
setStartTime(startTime);
}
public Statistics getStatistics() {
// Times may not be live (i.e., updated automatically).
return new Statistics(
startTime,
stopTime,
totalTime,
movingTime,
totalDistance,
getMaxSpeed(),
// This is calculated from the smoothed altitude, so this can actually be less than the current altitude.
altitudeExtremities.hasData() ? new AltitudeExtremities(altitudeExtremities.getMin(), altitudeExtremities.getMax()) : null,
totalAltitudeGain_m != null && totalAltitudeLoss_m != null ? new AltitudeGainLoss(totalAltitudeGain_m, totalAltitudeLoss_m) : null,
avgHeartRate,
avgPower
);
}
public Instant getStartTime() {
return startTime;
}
/**
* Should only be called on start.
*/
public void setStartTime(Instant startTime) {
this.startTime = startTime;
setStopTime(startTime);
}
public void setStopTime(Instant stopTime) {
if (stopTime.isBefore(startTime)) {
// Time must be monotonically increasing, but we might have events at the same point in time (BLE and GPS)
throw new RuntimeException("stopTime cannot be less than startTime: " + startTime + " " + stopTime);
}
this.stopTime = stopTime;
}
@VisibleForTesting
public void setTotalDistance(Distance totalDistance_m) {
this.totalDistance = totalDistance_m;
}
public void addTotalDistance(Distance distance_m) {
totalDistance = totalDistance.plus(distance_m);
}
@VisibleForTesting
public void setTotalTime(Duration totalTime) {
this.totalTime = totalTime;
}
public void addMovingTime(TrackPoint trackPoint, TrackPoint lastTrackPoint) {
Duration movingDuration = Duration.between(lastTrackPoint.getTime(), trackPoint.getTime());
if (movingDuration.isNegative()) {
throw new RuntimeException("Moving time cannot be negative");
}
movingTime = movingTime.plus(movingDuration);
}
public boolean isIdle() {
return isIdle;
}
public void setIdle(boolean idle) {
isIdle = idle;
}
private Speed getAverageMovingSpeed() {
return Speed.of(totalDistance, movingTime);
}
public Speed getMaxSpeed() {
return Speed.max(maxSpeed, getAverageMovingSpeed());
}
public void setMaxSpeed(Speed maxSpeed) {
this.maxSpeed = maxSpeed;
}
public void updateAltitudeExtremities(Altitude altitude) {
if (altitude != null) {
altitudeExtremities.update(altitude.toM());
}
}
public void setAverageHeartRate(HeartRate heartRate) {
if (heartRate != null) {
avgHeartRate = heartRate;
}
}
public void setAveragePower(Power power) {
if (power != null) {
avgPower = power;
}
}
@VisibleForTesting
@Deprecated
public void setTotalAltitudeGain(Float totalAltitudeGain_m) {
this.totalAltitudeGain_m = totalAltitudeGain_m;
}
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public void addTotalAltitudeGainLoss(AltitudeGainLoss altitudeGainLoss) {
if (totalAltitudeGain_m == null) {
totalAltitudeGain_m = 0f;
}
totalAltitudeGain_m += altitudeGainLoss.gain_m();
if (totalAltitudeLoss_m == null) {
totalAltitudeLoss_m = 0f;
}
totalAltitudeLoss_m += altitudeGainLoss.loss_m();
}
@NonNull
@Override
public String toString() {
return "SegmentStatisticUpdater{" + getStatistics() + "}";
}
}