In general I think it would be nice to add, but what you're describing seems to be over-engineered. It sounds like something that an AI would come up with.
I understand, but it's not complex, Anton. I did some research on Google and also with AI, about how large companies use idle time monitoring. Today we have a well-implemented summary report and we just need to add the calculation. We already have ignition time, duration, motion; we just need to calculate the difference. We can use the Key files for REPORT_IDLE_MIN_DURATION, REPORT_IDLE_MAX_GA, and REPORT_IDLE_RPM_THRESHOLD, or we could consider having a configuration per device, in case the user wants to have different times for different devices.
private long calculateIdleTime(long deviceId, Date from, Date to) throws StorageException {
// Get configurable thresholds
final long minIdleDuration = config.getLong(Keys.REPORT_IDLE_MIN_DURATION);
final long maxGapDuration = config.getLong(Keys.REPORT_IDLE_MAX_GAP);
final double idleRpmThreshold = config.getDouble(Keys.REPORT_IDLE_RPM_THRESHOLD);
long idleTime = 0;
var positions = PositionUtil.getPositions(storage, deviceId, from, to);
if (positions.isEmpty()) {
return 0;
}
Position previousPosition = null;
boolean wasIdle = false;
long idleStartTime = 0;
for (Position position : positions) {
boolean isIdle = false;
// Multi-sensor detection approach (industry standard)
Boolean ignition = position.getBoolean(Position.KEY_IGNITION);
Boolean motion = position.getBoolean(Position.KEY_MOTION);
Double rpm = position.getDouble(Position.KEY_RPM);
// Enhanced validation: engine must be running (RPM > threshold or ignition=true)
boolean engineRunning = false;
if (rpm != null && rpm > idleRpmThreshold) {
engineRunning = true;
} else if (ignition != null && ignition) {
engineRunning = true;
}
// Idle criteria: engine running + not moving
if (engineRunning && motion != null && !motion) {
isIdle = true;
}
if (previousPosition != null) {
long currentTime = position.getFixTime().getTime();
long duration = currentTime - previousPosition.getFixTime().getTime();
if (wasIdle && isIdle) {
// Continue idle period - only count if gap is reasonable
if (duration > 0 && duration < maxGapDuration) {
idleTime += duration;
}
} else if (wasIdle && !isIdle) {
// End of idle period - validate total duration meets minimum threshold
long totalIdleDuration = currentTime - idleStartTime;
if (totalIdleDuration < minIdleDuration) {
// Remove idle time that doesn't meet minimum threshold
idleTime = Math.max(0, idleTime - totalIdleDuration);
}
} else if (!wasIdle && isIdle) {
// Start new idle period
idleStartTime = currentTime;
}
}
previousPosition = position;
wasIdle = isIdle;
}
// Check if we ended during an idle period
if (wasIdle && previousPosition != null) {
long totalIdleDuration = previousPosition.getFixTime().getTime() - idleStartTime;
if (totalIdleDuration < minIdleDuration) {
// Remove idle time that doesn't meet minimum threshold
idleTime = Math.max(0, idleTime - totalIdleDuration);
}
}
// Ensure idle time is never negative
return Math.max(0, idleTime);
}
private Collection<SummaryReportItem> calculateDeviceResult(
Device device, Date from, Date to, boolean fast) throws StorageException {
SummaryReportItem result = new SummaryReportItem();
result.setDeviceId(device.getId());
result.setDeviceName(device.getName());
Position first = null;
Position last = null;
long idleTime = 0;
if (fast) {
first = PositionUtil.getEdgePosition(storage, device.getId(), from, to, false);
last = PositionUtil.getEdgePosition(storage, device.getId(), from, to, true);
} else {
var positions = PositionUtil.getPositions(storage, device.getId(), from, to);
for (Position position : positions) {
if (first == null) {
first = position;
}
if (position.getSpeed() > result.getMaxSpeed()) {
result.setMaxSpeed(position.getSpeed());
}
last = position;
}
}
if (first != null && last != null) {
TripsConfig tripsConfig = new TripsConfig(
new AttributeUtil.StorageProvider(config, storage, permissionsService, device));
boolean ignoreOdometer = tripsConfig.getIgnoreOdometer();
result.setDistance(PositionUtil.calculateDistance(first, last, !ignoreOdometer));
result.setSpentFuel(reportUtils.calculateFuel(first, last, device));
if (first.hasAttribute(Position.KEY_HOURS) && last.hasAttribute(Position.KEY_HOURS)) {
result.setStartHours(first.getLong(Position.KEY_HOURS));
result.setEndHours(last.getLong(Position.KEY_HOURS));
long engineHours = result.getEngineHours();
if (engineHours > 0) {
result.setAverageSpeed(UnitsConverter.knotsFromMps(result.getDistance() * 1000 / engineHours));
}
}
if (!ignoreOdometer
&& first.getDouble(Position.KEY_ODOMETER) != 0 && last.getDouble(Position.KEY_ODOMETER) != 0) {
result.setStartOdometer(first.getDouble(Position.KEY_ODOMETER));
result.setEndOdometer(last.getDouble(Position.KEY_ODOMETER));
} else {
result.setStartOdometer(first.getDouble(Position.KEY_TOTAL_DISTANCE));
result.setEndOdometer(last.getDouble(Position.KEY_TOTAL_DISTANCE));
}
result.setStartTime(first.getFixTime());
result.setEndTime(last.getFixTime());
// Calculate idle time using centralized method (not in fast mode to avoid performance impact)
if (!fast) {
idleTime = calculateIdleTime(device.getId(), from, to);
}
result.setIdleTime(idleTime);
return List.of(result);
}
return List.of();
}
private Collection<SummaryReportItem> calculateDeviceResults(
Device device, ZonedDateTime from, ZonedDateTime to, boolean daily) throws StorageException {
boolean fast = Duration.between(from, to).toSeconds() > config.getLong(Keys.REPORT_FAST_THRESHOLD);
var results = new ArrayList<SummaryReportItem>();
if (daily) {
while (from.truncatedTo(ChronoUnit.DAYS).isBefore(to.truncatedTo(ChronoUnit.DAYS))) {
ZonedDateTime fromDay = from.truncatedTo(ChronoUnit.DAYS);
ZonedDateTime nextDay = fromDay.plusDays(1);
results.addAll(calculateDeviceResult(
device, Date.from(from.toInstant()), Date.from(nextDay.toInstant()), fast));
from = nextDay;
}
}
results.addAll(calculateDeviceResult(device, Date.from(from.toInstant()), Date.from(to.toInstant()), fast));
return results;
}
public Collection<SummaryReportItem> getObjects(
long userId, Collection<Long> deviceIds, Collection<Long> groupIds,
Date from, Date to, boolean daily) throws StorageException {
reportUtils.checkPeriodLimit(from, to);
var tz = UserUtil.getTimezone(permissionsService.getServer(), permissionsService.getUser(userId)).toZoneId();
ArrayList<SummaryReportItem> result = new ArrayList<>();
for (Device device: DeviceUtil.getAccessibleDevices(storage, userId, deviceIds, groupIds)) {
var deviceResults = calculateDeviceResults(
device, from.toInstant().atZone(tz), to.toInstant().atZone(tz), daily);
for (SummaryReportItem summaryReport : deviceResults) {
if (summaryReport.getStartTime() != null && summaryReport.getEndTime() != null) {
result.add(summaryReport);
}
}
}
return result;
}
public void getExcel(OutputStream outputStream,
long userId, Collection<Long> deviceIds, Collection<Long> groupIds,
Date from, Date to, boolean daily) throws StorageException, IOException {
Collection<SummaryReportItem> summaries = getObjects(userId, deviceIds, groupIds, from, to, daily);
File file = Paths.get(config.getString(Keys.TEMPLATES_ROOT), "export", "summary.xlsx").toFile();
try (InputStream inputStream = new FileInputStream(file)) {
var context = reportUtils.initializeContext(userId);
context.putVar("summaries", summaries);
context.putVar("from", from);
context.putVar("to", to);
JxlsHelper.getInstance().setUseFastFormulaProcessor(false)
.processTemplate(inputStream, outputStream, context);
}
}
ReportUtils.java
private long calculateIdleTime(long deviceId, Date from, Date to) throws StorageException {
// Get configurable thresholds
final long minIdleDuration = config.getLong(Keys.REPORT_IDLE_MIN_DURATION);
final long maxGapDuration = config.getLong(Keys.REPORT_IDLE_MAX_GAP);
final double idleRpmThreshold = config.getDouble(Keys.REPORT_IDLE_RPM_THRESHOLD);
long idleTime = 0;
var positions = PositionUtil.getPositions(storage, deviceId, from, to);
if (positions.isEmpty()) {
return 0;
}
Position previousPosition = null;
boolean wasIdle = false;
long idleStartTime = 0;
for (Position position : positions) {
boolean isIdle = false;
// Multi-sensor detection approach (industry standard)
Boolean ignition = position.getBoolean(Position.KEY_IGNITION);
Boolean motion = position.getBoolean(Position.KEY_MOTION);
Double rpm = position.getDouble(Position.KEY_RPM);
// Enhanced validation: engine must be running (RPM > threshold or ignition=true)
boolean engineRunning = false;
if (rpm != null && rpm > idleRpmThreshold) {
engineRunning = true;
} else if (ignition != null && ignition) {
engineRunning = true;
}
// Idle criteria: engine running + not moving
if (engineRunning && motion != null && !motion) {
isIdle = true;
}
if (previousPosition != null) {
long currentTime = position.getFixTime().getTime();
long duration = currentTime - previousPosition.getFixTime().getTime();
if (wasIdle && isIdle) {
// Continue idle period - only count if gap is reasonable
if (duration > 0 && duration < maxGapDuration) {
idleTime += duration;
}
} else if (wasIdle && !isIdle) {
// End of idle period - validate total duration meets minimum threshold
long totalIdleDuration = currentTime - idleStartTime;
if (totalIdleDuration < minIdleDuration) {
// Remove idle time that doesn't meet minimum threshold
idleTime = Math.max(0, idleTime - totalIdleDuration);
}
} else if (!wasIdle && isIdle) {
// Start new idle period
idleStartTime = currentTime;
}
}
previousPosition = position;
wasIdle = isIdle;
}
// Check if we ended during an idle period
if (wasIdle && previousPosition != null) {
long totalIdleDuration = previousPosition.getFixTime().getTime() - idleStartTime;
if (totalIdleDuration < minIdleDuration) {
// Remove idle time that doesn't meet minimum threshold
idleTime = Math.max(0, idleTime - totalIdleDuration);
}
}
// Ensure idle time is never negative
return Math.max(0, idleTime);
}
private TripReportItem calculateTrip(
Device device, Position startTrip, Position endTrip, double maxSpeed,
boolean ignoreOdometer) throws StorageException {
TripReportItem trip = new TripReportItem();
long tripDuration = endTrip.getFixTime().getTime() - startTrip.getFixTime().getTime();
long deviceId = startTrip.getDeviceId();
trip.setDeviceId(deviceId);
trip.setDeviceName(device.getName());
trip.setStartPositionId(startTrip.getId());
trip.setStartLat(startTrip.getLatitude());
trip.setStartLon(startTrip.getLongitude());
trip.setStartTime(startTrip.getFixTime());
String startAddress = startTrip.getAddress();
if (startAddress == null && geocoder != null && config.getBoolean(Keys.GEOCODER_ON_REQUEST)) {
startAddress = geocoder.getAddress(startTrip.getLatitude(), startTrip.getLongitude(), null);
}
trip.setStartAddress(startAddress);
trip.setEndPositionId(endTrip.getId());
trip.setEndLat(endTrip.getLatitude());
trip.setEndLon(endTrip.getLongitude());
trip.setEndTime(endTrip.getFixTime());
String endAddress = endTrip.getAddress();
if (endAddress == null && geocoder != null && config.getBoolean(Keys.GEOCODER_ON_REQUEST)) {
endAddress = geocoder.getAddress(endTrip.getLatitude(), endTrip.getLongitude(), null);
}
trip.setEndAddress(endAddress);
trip.setDistance(PositionUtil.calculateDistance(startTrip, endTrip, !ignoreOdometer));
trip.setDuration(tripDuration);
if (tripDuration > 0) {
trip.setAverageSpeed(UnitsConverter.knotsFromMps(trip.getDistance() * 1000 / tripDuration));
}
trip.setMaxSpeed(maxSpeed);
trip.setSpentFuel(calculateFuel(startTrip, endTrip, device));
trip.setDriverUniqueId(findDriver(startTrip, endTrip));
trip.setDriverName(findDriverName(trip.getDriverUniqueId()));
// Calculate idle time using centralized method
long idleTime = calculateIdleTime(deviceId, startTrip.getFixTime(), endTrip.getFixTime());
trip.setIdleTime(idleTime);
if (!ignoreOdometer
&& startTrip.getDouble(Position.KEY_ODOMETER) != 0
&& endTrip.getDouble(Position.KEY_ODOMETER) != 0) {
trip.setStartOdometer(startTrip.getDouble(Position.KEY_ODOMETER));
trip.setEndOdometer(endTrip.getDouble(Position.KEY_ODOMETER));
} else {
trip.setStartOdometer(startTrip.getDouble(Position.KEY_TOTAL_DISTANCE));
trip.setEndOdometer(endTrip.getDouble(Position.KEY_TOTAL_DISTANCE));
}
return trip;
}
private StopReportItem calculateStop(
Device device, Position startStop, Position endStop, boolean ignoreOdometer) {
StopReportItem stop = new StopReportItem();
long deviceId = startStop.getDeviceId();
stop.setDeviceId(deviceId);
stop.setDeviceName(device.getName());
stop.setPositionId(startStop.getId());
stop.setLatitude(startStop.getLatitude());
stop.setLongitude(startStop.getLongitude());
stop.setStartTime(startStop.getFixTime());
String address = startStop.getAddress();
if (address == null && geocoder != null && config.getBoolean(Keys.GEOCODER_ON_REQUEST)) {
address = geocoder.getAddress(stop.getLatitude(), stop.getLongitude(), null);
}
stop.setAddress(address);
stop.setEndTime(endStop.getFixTime());
long stopDuration = endStop.getFixTime().getTime() - startStop.getFixTime().getTime();
stop.setDuration(stopDuration);
stop.setSpentFuel(calculateFuel(startStop, endStop, device));
if (startStop.hasAttribute(Position.KEY_HOURS) && endStop.hasAttribute(Position.KEY_HOURS)) {
stop.setEngineHours(endStop.getLong(Position.KEY_HOURS) - startStop.getLong(Position.KEY_HOURS));
}
if (!ignoreOdometer
&& startStop.getDouble(Position.KEY_ODOMETER) != 0
&& endStop.getDouble(Position.KEY_ODOMETER) != 0) {
stop.setStartOdometer(startStop.getDouble(Position.KEY_ODOMETER));
stop.setEndOdometer(endStop.getDouble(Position.KEY_ODOMETER));
} else {
stop.setStartOdometer(startStop.getDouble(Position.KEY_TOTAL_DISTANCE));
stop.setEndOdometer(endStop.getDouble(Position.KEY_TOTAL_DISTANCE));
}
return stop;
}
@SuppressWarnings("unchecked")
private <T extends BaseReportItem> T calculateTripOrStop(
Device device, Position startPosition, Position endPosition, double maxSpeed,
boolean ignoreOdometer, Class<T> reportClass) throws StorageException {
if (reportClass.equals(TripReportItem.class)) {
return (T) calculateTrip(device, startPosition, endPosition, maxSpeed, ignoreOdometer);
} else {
return (T) calculateStop(device, startPosition, endPosition, ignoreOdometer);
}
}
public <T extends BaseReportItem> List<T> detectTripsAndStops(
Device device, Date from, Date to, Class<T> reportClass) throws StorageException {
long threshold = config.getLong(Keys.REPORT_FAST_THRESHOLD);
if (Duration.between(from.toInstant(), to.toInstant()).toSeconds() > threshold) {
return fastTripsAndStops(device, from, to, reportClass);
} else {
return slowTripsAndStops(device, from, to, reportClass);
}
}
public <T extends BaseReportItem> List<T> slowTripsAndStops(
Device device, Date from, Date to, Class<T> reportClass) throws StorageException {
List<T> result = new ArrayList<>();
var attributeProvider = new AttributeUtil.StorageProvider(config, storage, permissionsService, device);
TripsConfig tripsConfig = new TripsConfig(attributeProvider);
boolean ignoreOdometer = tripsConfig.getIgnoreOdometer();
boolean trips = reportClass.equals(TripReportItem.class);
boolean useNewLogic = config.getBoolean(Keys.REPORT_TRIP_NEW_LOGIC);
List<Event> events = new ArrayList<>();
Map<Long, Position> positionMap = new HashMap<>();
Position startPosition = null;
double maxSpeed = 0;
var positions = PositionUtil.getPositions(storage, device.getId(), from, to);
if (!positions.isEmpty()) {
boolean initialValue = positions.get(0).getBoolean(Position.KEY_MOTION);
if (initialValue == trips) {
startPosition = positions.get(0);
maxSpeed = startPosition.getSpeed();
}
if (useNewLogic) {
double minDistance = AttributeUtil.lookup(attributeProvider, Keys.REPORT_TRIP_MIN_DISTANCE);
long minDuration = AttributeUtil.lookup(attributeProvider, Keys.REPORT_TRIP_MIN_DURATION) * 1000;
long stopGap = AttributeUtil.lookup(attributeProvider, Keys.REPORT_TRIP_STOP_GAP) * 1000;
Deque<Position> motionPositions = new ArrayDeque<>();
NewMotionState motionState = new NewMotionState();
motionState.setPositions(motionPositions);
motionState.setMotionStreak(initialValue);
motionState.setEventPosition(positions.get(0));
for (Position position : positions) {
maxSpeed = Math.max(maxSpeed, position.getSpeed());
positionMap.put(position.getId(), position);
NewMotionProcessor.updateState(motionState, position, minDistance, minDuration, stopGap);
if (!motionState.getEvents().isEmpty()) {
for (Event event : motionState.getEvents()) {
event.set("maxSpeed", maxSpeed);
events.add(event);
}
maxSpeed = 0;
}
motionPositions.add(position);
while (motionPositions.size() > 1) {
var iterator = motionPositions.iterator();
iterator.next();
Position second = iterator.next();
Position last = motionPositions.peekLast();
if (last.getFixTime().getTime() - second.getFixTime().getTime() >= minDuration) {
motionPositions.poll();
} else {
break;
}
}
}
} else {
MotionState motionState = new MotionState();
motionState.setMotionStreak(initialValue);
motionState.setMotionState(initialValue);
for (int i = 0; i < positions.size(); i++) {
Position last = i > 0 ? positions.get(i - 1) : null;
Position position = positions.get(i);
maxSpeed = Math.max(maxSpeed, position.getSpeed());
positionMap.put(position.getId(), position);
boolean motion = position.getBoolean(Position.KEY_MOTION);
MotionProcessor.updateState(motionState, last, positions.get(i), motion, tripsConfig);
if (motionState.getEvent() != null) {
motionState.getEvent().set("maxSpeed", maxSpeed);
events.add(motionState.getEvent());
maxSpeed = 0;
}
}
}
}
for (Event event : events) {
boolean motion = event.getType().equals(Event.TYPE_DEVICE_MOVING);
if (motion == trips) {
startPosition = positionMap.get(event.getPositionId());
} else if (startPosition != null) {
Position endPosition = positionMap.get(event.getPositionId());
if (endPosition != null) {
result.add(calculateTripOrStop(
device, startPosition, endPosition,
event.getDouble("maxSpeed"), ignoreOdometer, reportClass));
}
startPosition = null;
}
}
if (startPosition != null) {
Position endPosition = positions.get(positions.size() - 1);
result.add(calculateTripOrStop(
device, startPosition, endPosition, maxSpeed, ignoreOdometer, reportClass));
}
return result;
}
public <T extends BaseReportItem> List<T> fastTripsAndStops(
Device device, Date from, Date to, Class<T> reportClass) throws StorageException {
List<T> result = new ArrayList<>();
TripsConfig tripsConfig = new TripsConfig(
new AttributeUtil.StorageProvider(config, storage, permissionsService, device));
boolean ignoreOdometer = tripsConfig.getIgnoreOdometer();
boolean trips = reportClass.equals(TripReportItem.class);
var events = storage.getObjects(Event.class, new Request(
new Columns.All(),
Condition.merge(List.of(
new Condition.Equals("deviceId", device.getId()),
new Condition.Between("eventTime", from, to),
new Condition.Or(
new Condition.Equals("type", Event.TYPE_DEVICE_MOVING),
new Condition.Equals("type", Event.TYPE_DEVICE_STOPPED)))),
new Order("eventTime")));
Position startPosition = PositionUtil.getEdgePosition(storage, device.getId(), from, to, false);
if (startPosition != null && !startPosition.getBoolean(Position.KEY_MOTION)) {
startPosition = null;
}
for (Event event : events) {
boolean motion = event.getType().equals(Event.TYPE_DEVICE_MOVING);
if (motion == trips) {
startPosition = storage.getObject(Position.class, new Request(
new Columns.All(), new Condition.Equals("id", event.getPositionId())));
} else if (startPosition != null) {
Position endPosition = storage.getObject(Position.class, new Request(
new Columns.All(), new Condition.Equals("id", event.getPositionId())));
if (endPosition != null) {
result.add(calculateTripOrStop(
device, startPosition, endPosition, 0, ignoreOdometer, reportClass));
}
startPosition = null;
}
}
if (startPosition != null) {
Position endPosition = PositionUtil.getEdgePosition(storage, device.getId(), from, to, true);
result.add(calculateTripOrStop(
device, startPosition, endPosition, 0, ignoreOdometer, reportClass));
}
return result;
}
Keys.java
/**
* Minimum duration in milliseconds to consider as idle time. Default 60 seconds.
* Periods shorter than this are ignored (e.g., traffic lights, brief stops).
*/
public static final ConfigKey<Long> REPORT_IDLE_MIN_DURATION = new LongConfigKey(
"report.idle.minDuration",
List.of(KeyType.CONFIG, KeyType.DEVICE),
60000L);
/**
* Maximum gap in milliseconds between positions to consider for idle time calculation.
* Gaps larger than this are ignored to avoid unrealistic calculations. Default 1 hour.
*/
public static final ConfigKey<Long> REPORT_IDLE_MAX_GAP = new LongConfigKey(
"report.idle.maxGap",
List.of(KeyType.CONFIG, KeyType.DEVICE),
3600000L);
/**
* Minimum RPM threshold to consider engine as running for idle time calculation.
* Default 0 RPM (engine running at any RPM above 0).
*/
public static final ConfigKey<Double> REPORT_IDLE_RPM_THRESHOLD = new DoubleConfigKey(
"report.idle.rpmThreshold",
List.of(KeyType.CONFIG, KeyType.DEVICE),
0.0);
Posting large chunks of code here is completely pointless. If you want, you should send a proper PR.
Completed PR.
Hi everyone,
I'd like to know what you think of this idea of including idle time in reports.
I'm bringing this up to further exchange ideas, and if we reach a consensus with Anton, then we can submit a PR.
/src/main/java/org/traccar/model/Position.java
Added constant KEY_IDLE = "idle" for standardization
/src/main/java/org/traccar/reports/model/SummaryReportItem.java
Added idleTime field (long) with getters/setters
Comment: time in milliseconds
/src/main/java/org/traccar/reports/model/TripReportItem.java
Added idleTime field (long) with getters/setters
Comment: time in milliseconds
2. Report Providers
/src/main/java/org/traccar/reports/SummaryReportProvider.java
Implemented idle time calculation logic in calculateDeviceResult() method
Multi-sensor validation (RPM + ignition + motion)
60 seconds minimum threshold
Protection against negative values
/src/main/java/org/traccar/reports/common/ReportUtils.java
Implemented idle time calculation logic in calculateTrip() method
Precise detection of idle periods
Start/End control of each period
Multiple sensor validation
Calculation Logic
Idle Time Detection Criteria
// Conditions to consider vehicle idle:
Implemented Algorithm
Position Scanning: Analyzes all positions in the period
Multi-sensor Detection: Validates engine by RPM or ignition
Period Control: Tracks start/end of each idle period
Threshold Validation: Only counts periods >= 60 seconds
Anti-negative Protection: Ensures result >= 0
Calculation Formula
Configurations
Implemented Constants
// Minimum threshold to consider idle
final long MIN_IDLE_DURATION = 60000; // 60 seconds
// Threshold to filter unrealistic gaps
final long MAX_GAP_DURATION = 3600000; // 1 hour
// Minimum RPM to consider engine running
final double IDLE_RPM_THRESHOLD = 0.0;
// For each pair of consecutive positions:
if (engineRunning && !inMotion && duration >= 60s) {
idleTime += duration;
}