Skip to content

Commit ae4d8f4

Browse files
authored
Merge pull request #253 from dannymcc/dev
v0.24.3 — average consumption survives missed fill-ups
2 parents 947e68c + 2b830bc commit ae4d8f4

3 files changed

Lines changed: 80 additions & 31 deletions

File tree

app/models.py

Lines changed: 49 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -299,30 +299,54 @@ def get_total_distance(self, distance_unit=None):
299299
return _distance_in(raw_distance, self.get_effective_odometer_unit(), distance_unit)
300300
return raw_distance
301301

302-
def get_average_consumption(self, consumption_unit=None, volume_unit='L'):
303-
"""Calculate average fuel consumption between the first and last
304-
full-tank fill-ups.
305-
306-
Sums every litre poured between those two anchors so partial fills
307-
in the middle are counted (issue #169). Returns None when any log
308-
in the range is flagged ``is_missed`` — we have no way to make the
309-
figure honest in that case.
302+
def _valid_consumption_segments(self):
303+
"""Collect (distance, fuel) spans usable for the consumption average.
304+
305+
Each span runs between consecutive full-tank fill-ups, counting every
306+
litre poured within it so partial fills are included (issue #169).
307+
A span containing a log flagged ``is_missed`` is discarded — there is
308+
no way to make that span honest — but spans either side of it remain
309+
usable, so one missed fill-up doesn't invalidate the whole history
310+
(issue #251).
311+
312+
Returns ``None`` when there are fewer than two full-tank anchors,
313+
otherwise a (possibly empty) list of ``(distance, fuel)`` tuples.
310314
"""
311315
full_logs = self.fuel_logs.filter_by(is_full_tank=True).order_by(FuelLog.odometer).all()
312316
if len(full_logs) < 2:
313317
return None
314318

315-
first_odo = full_logs[0].odometer
316-
last_odo = full_logs[-1].odometer
317319
range_logs = self.fuel_logs.filter(
318-
FuelLog.odometer > first_odo,
319-
FuelLog.odometer <= last_odo,
320-
).all()
321-
if any(log.is_missed for log in range_logs):
320+
FuelLog.odometer > full_logs[0].odometer,
321+
FuelLog.odometer <= full_logs[-1].odometer,
322+
).order_by(FuelLog.odometer).all()
323+
324+
segments = []
325+
for start, end in zip(full_logs, full_logs[1:]):
326+
span_logs = [log for log in range_logs
327+
if start.odometer < log.odometer <= end.odometer]
328+
if any(log.is_missed for log in span_logs):
329+
continue
330+
fuel = sum(log.volume for log in span_logs if log.volume)
331+
distance = end.odometer - start.odometer
332+
if distance > 0 and fuel > 0:
333+
segments.append((distance, fuel))
334+
return segments
335+
336+
def get_average_consumption(self, consumption_unit=None, volume_unit='L'):
337+
"""Calculate average fuel consumption across full-tank fill-up spans.
338+
339+
Spans contaminated by a missed fill-up are excluded rather than
340+
poisoning the whole figure (issue #251); the average covers every
341+
remaining span, partial fills included (issue #169). Returns None
342+
when no honest span exists.
343+
"""
344+
segments = self._valid_consumption_segments()
345+
if not segments:
322346
return None
323347

324-
total_fuel = sum(log.volume for log in range_logs if log.volume)
325-
total_distance = last_odo - first_odo
348+
total_distance = sum(distance for distance, _ in segments)
349+
total_fuel = sum(fuel for _, fuel in segments)
326350

327351
if total_distance > 0 and total_fuel > 0:
328352
odometer_unit = self.get_effective_odometer_unit()
@@ -348,28 +372,23 @@ def get_consumption_unavailable_reason(self):
348372
helpful empty state instead of a bare dash (issue #214):
349373
350374
- ``'insufficient_full_tanks'`` — fewer than two full-tank fill-ups
351-
- ``'missed_fill_up'`` — a fill-up in the range is flagged missed
375+
- ``'missed_fill_up'`` — every span is invalidated by a missed fill-up
352376
- ``'insufficient_data'`` — not enough distance/volume to calculate
353377
"""
354-
full_logs = self.fuel_logs.filter_by(is_full_tank=True).order_by(FuelLog.odometer).all()
355-
if len(full_logs) < 2:
378+
segments = self._valid_consumption_segments()
379+
if segments is None:
356380
return 'insufficient_full_tanks'
381+
if segments:
382+
return None
357383

358-
first_odo = full_logs[0].odometer
359-
last_odo = full_logs[-1].odometer
384+
full_logs = self.fuel_logs.filter_by(is_full_tank=True).order_by(FuelLog.odometer).all()
360385
range_logs = self.fuel_logs.filter(
361-
FuelLog.odometer > first_odo,
362-
FuelLog.odometer <= last_odo,
386+
FuelLog.odometer > full_logs[0].odometer,
387+
FuelLog.odometer <= full_logs[-1].odometer,
363388
).all()
364389
if any(log.is_missed for log in range_logs):
365390
return 'missed_fill_up'
366-
367-
total_fuel = sum(log.volume for log in range_logs if log.volume)
368-
total_distance = last_odo - first_odo
369-
if total_distance <= 0 or total_fuel <= 0:
370-
return 'insufficient_data'
371-
372-
return None
391+
return 'insufficient_data'
373392

374393
def uses_tessie_odometer(self):
375394
"""Check if this vehicle uses Tessie for odometer tracking"""

config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
basedir = Path(__file__).parent.absolute()
55

66

7-
APP_VERSION = '0.24.2'
7+
APP_VERSION = '0.24.3'
88
RELEASE_CHANNEL = os.environ.get('RELEASE_CHANNEL', 'stable')
99
GIT_SHA = os.environ.get('GIT_SHA', '')[:7] # Short SHA
1010
GITHUB_REPO = 'dannymcc/may'

tests/test_fuel.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,36 @@ def test_reason_missed_fill_up(self, app, test_user, sample_vehicle):
584584
assert sample_vehicle.get_average_consumption() is None
585585
assert sample_vehicle.get_consumption_unavailable_reason() == 'missed_fill_up'
586586

587+
def test_missed_fill_up_only_invalidates_its_own_span(
588+
self, app, test_user, sample_vehicle):
589+
"""#251 — one missed fill-up must not kill the whole average.
590+
591+
Spans between full tanks that don't contain the missed log stay
592+
usable; only the contaminated span is excluded.
593+
"""
594+
logs = [
595+
FuelLog(vehicle_id=sample_vehicle.id, user_id=test_user.id,
596+
date=date(2024, 1, 1), odometer=10000, volume=40, is_full_tank=True),
597+
FuelLog(vehicle_id=sample_vehicle.id, user_id=test_user.id,
598+
date=date(2024, 1, 10), odometer=10500, volume=40, is_full_tank=True),
599+
# Contaminated span: missed fill-up between the next two anchors.
600+
FuelLog(vehicle_id=sample_vehicle.id, user_id=test_user.id,
601+
date=date(2024, 1, 15), odometer=10700, volume=20,
602+
is_full_tank=False, is_missed=True),
603+
FuelLog(vehicle_id=sample_vehicle.id, user_id=test_user.id,
604+
date=date(2024, 1, 20), odometer=11000, volume=45, is_full_tank=True),
605+
FuelLog(vehicle_id=sample_vehicle.id, user_id=test_user.id,
606+
date=date(2024, 1, 30), odometer=11500, volume=40, is_full_tank=True),
607+
]
608+
db.session.add_all(logs)
609+
db.session.commit()
610+
# Valid spans: 10000->10500 (40 L / 500) and 11000->11500 (40 L / 500).
611+
# The 10500->11000 span is excluded, so: 80 L / 1000 km = 8.0 L/100km.
612+
avg = sample_vehicle.get_average_consumption()
613+
assert avg is not None
614+
assert abs(avg - 8.0) < 0.01
615+
assert sample_vehicle.get_consumption_unavailable_reason() is None
616+
587617
def test_reason_none_when_available(self, app, test_user, sample_vehicle):
588618
log1 = FuelLog(vehicle_id=sample_vehicle.id, user_id=test_user.id,
589619
date=date(2024, 1, 1), odometer=10000, volume=40, is_full_tank=True)

0 commit comments

Comments
 (0)