Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,40 @@ This file starts at 0.28.0. Notes for earlier releases are on the

## [Unreleased]

## [0.41.0] - 2026-08-25

### Added

- Each fill-up of a dual-fuel vehicle can record the **distance run on this
fuel** since the last fill-up of it, entered in the vehicle's own unit. The
odometer alone cannot say which miles were run on which fuel, so this is the
only way to work out a consumption figure for a stretch of history in which
both fuels were used. The field appears only for vehicles that genuinely burn
two fuels.
- The fuel type and the distance attributed to it are included in the CSV and
JSON exports and in backups, so a dual-fuel history survives a restore, and
both `fuel_type` and `fuel_distance` can now be set on a fill-up over the REST
API as well as read. ([#221](https://github.com/dannymcc/may/issues/221))

### Fixed

- A dual-fuel vehicle — a petrol car converted to run on LPG, say — now gets a
separate average consumption for each fuel on its vehicle page, instead of
one figure that mixed petrol litres and LPG litres over the same odometer
span and so described neither.

This is a visible change for dual-fuel vehicles: a stretch of history where
both fuels were used will explain that the figure cannot be worked out,
rather than showing an average derived from the wrong distance. To restore
the figure, edit those fill-ups and enter the distance run on that fuel
since the previous fill-up of it. Only stretches that actually mix the two
fuels are affected — a car converted to LPG last year keeps its ordinary
figures for the years it ran on petrol alone — and everything else on the
page (totals, spend, price history and the per-fill-up records) is
unchanged. Vehicles running a single fuel are unaffected, as are diesels
tracking AdBlue, which propels nothing.
([#221](https://github.com/dannymcc/may/issues/221))

## [0.40.0] - 2026-08-25

### Changed
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,29 @@ Track every fill-up with:

Saving a fuel log returns you to the fuel log list, unless you started from a vehicle page, in which case you go back to that vehicle. Deleting one does the same: from the fuel log list you stay on the list, from a vehicle page you return to that vehicle. Notes behave the same way.

#### Dual-fuel vehicles (petrol + LPG)
Give the vehicle a secondary fuel type and each fill-up gets a **Fuel Type**
selector. May then keeps the two fuels apart: each fuel gets its own average on
the vehicle page, built only from fill-ups of that fuel, because a combined
figure would describe neither.

The odometer cannot say which kilometres were run on LPG and which on petrol,
so a fill-up of a dual-fuel vehicle also asks for **Distance on this fuel** —
how far the car ran on that fuel since your last fill-up of it. This is only
needed where it is genuinely ambiguous: a stretch in which you filled with one
fuel only is worked out from the odometer as usual, so a car converted to LPG
keeps its earlier petrol figures. Where the two fuels are mixed and that
distance is missing, the page says the figure cannot be worked out without it
rather than showing one derived from the wrong distance.

The distance is entered in the vehicle's own unit, the one its odometer reads
in. Both the fuel type and the distance attributed to it appear in the CSV and
JSON exports, in backups and over the REST API, where they can be set on a
fill-up as well as read. The `average_consumption` figure in the API's vehicle
stats covers the vehicle's primary fuel; the per-fuel figures are on the vehicle
page. A diesel tracking AdBlue is not a dual-fuel vehicle for this purpose —
AdBlue propels nothing — so it is never asked for the distance.

### EV Charging
Log charging sessions for electric and plug-in hybrid vehicles:
- Date, optional start and end times, and the odometer reading
Expand Down
149 changes: 147 additions & 2 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,74 @@ def get_total_distance(self, distance_unit=None):
return _distance_in(raw_distance, self.get_effective_odometer_unit(), distance_unit)
return raw_distance

def get_primary_fuel_type(self):
"""The fuel this vehicle burns by default, propulsion resolved (#221)."""
return resolve_price_fuel_type(None, self.fuel_type)

def get_propulsion_fuel_types(self):
"""Distinct fuels the fill-ups say this vehicle actually burns (#221).

A log without its own fuel type counts as the vehicle's primary fuel,
and propulsion labels are resolved to the fuel they burn, so a
hybrid's untyped rows and its explicit petrol rows are one fuel, not
two (#268). Auxiliary fluids are left out: AdBlue propels nothing
(#319). Ordered primary fuel first so bi-fuel vehicles read naturally.
"""
types = {_propulsion_fuel_type(log.fuel_type or self.fuel_type)
for log in self.fuel_logs.all()}
types.discard(None)
primary = self.get_primary_fuel_type()
return sorted(types, key=lambda ft: (ft != primary, ft))

def declares_second_fuel(self):
"""True when the owner has declared a second fuel this vehicle burns.

Distinct from :meth:`runs_on_two_fuels`, which also wants fill-ups of
both to exist: the fuel form has to offer the distance field before
the first such fill-up, or the attribution could never be entered.
"""
secondary = _propulsion_fuel_type(self.secondary_fuel_type)
return bool(secondary) and secondary != self.get_primary_fuel_type()

def runs_on_two_fuels(self):
"""True when this vehicle burns two different fuels (#221).

An LPG conversion is the usual case: the owner declares a
``secondary_fuel_type`` and fills up on both. That declaration is
what makes the odometer ambiguous — only the owner knows the car can
run on either — so it, not the mix of fuel types happening to appear
in the logs, is the gate. A plain hybrid whose older fill-ups predate
the fuel type selector must not be mistaken for a bi-fuel car (#268).

Both halves have to hold. Until fill-ups of both fuels exist there is
nothing to disentangle, so a declared bi-fuel car that has only ever
logged petrol keeps the ordinary odometer maths.
"""
return self.declares_second_fuel() and len(self.get_propulsion_fuel_types()) > 1

def _other_fuel_odometers(self, fuel_type=None):
"""Odometer readings of fill-ups of this vehicle's *other* fuel (#221).

A span containing one of these covers ground run on both fuels, so
its odometer difference says nothing about either and the driver's
own attribution is needed. A span with none of them is unambiguous:
a car converted to LPG last year keeps the ordinary odometer maths
over the years it ran on petrol alone.

Empty unless the vehicle actually runs on two fuels.
"""
if not self.runs_on_two_fuels():
return []
target = fuel_type or self.get_primary_fuel_type()
return [log.odometer for log in self.fuel_logs.all()
if _propulsion_fuel_type(log.fuel_type or self.fuel_type) not in (None, target)]

@staticmethod
def _span_runs_on_both_fuels(other_odometers, start_odometer, end_odometer):
"""True when a fill-up of the other fuel falls inside this span (#221)."""
return any(start_odometer < odometer <= end_odometer
for odometer in other_odometers)

def _valid_consumption_segments(self, fuel_type=None):
"""Collect (distance, fuel) spans usable for the consumption average.

Expand All @@ -507,6 +575,11 @@ def _valid_consumption_segments(self, fuel_type=None):
apart: AdBlue is an auxiliary fluid, not propulsion, so pouring it
in must never move the diesel figure (issue #319).

Where a span covers ground run on both fuels the odometer cannot say
which miles went on which, so its distance is the one the driver
attributed to this fuel, and a span missing that attribution is
dropped rather than guessed at (issue #221).

Returns ``None`` when there are fewer than two full-tank anchors,
otherwise a (possibly empty) list of ``(distance, fuel)`` tuples.
The span is expressed in the vehicle's own ``tracking_unit`` — km or
Expand All @@ -527,14 +600,22 @@ def _valid_consumption_segments(self, fuel_type=None):
same_fuel,
).order_by(FuelLog.odometer).all()

other_fuel_odometers = self._other_fuel_odometers(fuel_type)
segments = []
for start, end in zip(full_logs, full_logs[1:]):
span_logs = [log for log in range_logs
if start.odometer < log.odometer <= end.odometer]
if any(log.is_missed for log in span_logs):
continue
fuel = sum(log.volume for log in span_logs if log.volume)
distance = end.odometer - start.odometer
if self._span_runs_on_both_fuels(other_fuel_odometers,
start.odometer, end.odometer):
distances = [log.fuel_distance for log in span_logs]
if any(distance is None for distance in distances):
continue
distance = sum(distances)
else:
distance = end.odometer - start.odometer
if distance > 0 and fuel > 0:
segments.append((distance, fuel))
return segments
Expand All @@ -551,6 +632,9 @@ def get_average_consumption(self, consumption_unit=None, volume_unit='L', fuel_t
An hours-tracked vehicle is averaged in litres per engine hour and
``consumption_unit`` is ignored: mpg, km/L and L/100km are all named
for a distance this vehicle never records (issue #323).

On a bi-fuel vehicle the figure covers one fuel at a time and needs
the distance the driver attributed to that fuel (issue #221).
"""
segments = self._valid_consumption_segments(fuel_type)
if not segments:
Expand Down Expand Up @@ -588,6 +672,8 @@ def get_consumption_unavailable_reason(self, fuel_type=None):

- ``'insufficient_full_tanks'`` — fewer than two full-tank fill-ups
- ``'missed_fill_up'`` — every span is invalidated by a missed fill-up
- ``'needs_distance_attribution'`` — bi-fuel vehicle whose fill-ups
don't say how far the car ran on this fuel (issue #221)
- ``'insufficient_data'`` — not enough distance/volume to calculate
"""
segments = self._valid_consumption_segments(fuel_type)
Expand All @@ -608,8 +694,31 @@ def get_consumption_unavailable_reason(self, fuel_type=None):
).all()
if any(log.is_missed for log in range_logs):
return 'missed_fill_up'
other_fuel_odometers = self._other_fuel_odometers(fuel_type)
if (self._span_runs_on_both_fuels(other_fuel_odometers, full_logs[0].odometer,
full_logs[-1].odometer)
and any(log.fuel_distance is None for log in range_logs)):
return 'needs_distance_attribution'
return 'insufficient_data'

def get_average_consumption_by_fuel(self, consumption_unit=None, volume_unit='L'):
"""Average consumption per fuel for a bi-fuel vehicle (#221).

Returns one entry per fuel type logged, each with the figure (or
``None``) and the reason it is missing, so the UI can show both
fuels side by side instead of one meaningless combined number.
A vehicle with no fill-ups yet still gets a single entry for its
primary fuel, so the UI keeps its usual empty state.
"""
return [
{
'fuel_type': fuel_type,
'value': self.get_average_consumption(consumption_unit, volume_unit, fuel_type),
'reason': self.get_consumption_unavailable_reason(fuel_type),
}
for fuel_type in self.get_propulsion_fuel_types() or [self.get_primary_fuel_type()]
]

def uses_tessie_odometer(self):
"""Check if this vehicle uses Tessie for odometer tracking"""
from app.services.tessie import TessieService
Expand Down Expand Up @@ -913,6 +1022,9 @@ class FuelLog(db.Model):
sales_tax = db.Column(db.Float) # sales tax paid, included in total_cost (issue #225)

fuel_type = db.Column(db.String(20), nullable=True) # overrides vehicle primary; set when vehicle has secondary fuel type
# Distance run on this fuel since the previous fill-up of the same fuel,
# in the vehicle's odometer unit. Only bi-fuel vehicles need it (#221).
fuel_distance = db.Column(db.Float, nullable=True)
is_full_tank = db.Column(db.Boolean, default=True)
is_missed = db.Column(db.Boolean, default=False) # missed fill-up flag

Expand Down Expand Up @@ -973,6 +1085,11 @@ def get_consumption(self, consumption_unit=None, volume_unit='L'):
two readings means. For an hours-tracked vehicle it is engine hours,
so the figure is litres per hour and ``consumption_unit`` is ignored
(issue #323).

Where the span covers ground run on both of a bi-fuel vehicle's
fuels, the distance is the one the driver attributed to this fuel
rather than the odometer difference — the odometer cannot say which
miles were run on LPG and which on petrol (issue #221).
"""
if not self.volume or not self.is_full_tank:
return None
Expand All @@ -989,7 +1106,6 @@ def get_consumption(self, consumption_unit=None, volume_unit='L'):
).order_by(FuelLog.odometer.desc()).first()
if not prev_full:
return None
distance = self.odometer - prev_full.odometer
between = FuelLog.query.filter(
FuelLog.vehicle_id == self.vehicle_id,
FuelLog.odometer > prev_full.odometer,
Expand All @@ -998,6 +1114,15 @@ def get_consumption(self, consumption_unit=None, volume_unit='L'):
).all()
if any(log.is_missed for log in between):
return None
other_fuel_odometers = (self.vehicle._other_fuel_odometers(self.effective_fuel_type)
if self.vehicle else [])
if Vehicle._span_runs_on_both_fuels(other_fuel_odometers,
prev_full.odometer, self.odometer):
if any(log.fuel_distance is None for log in between):
return None
distance = sum(log.fuel_distance for log in between)
else:
distance = self.odometer - prev_full.odometer
volume_native = sum(log.volume for log in between if log.volume)

if distance > 0 and volume_native > 0:
Expand Down Expand Up @@ -1033,6 +1158,7 @@ def to_dict(self, consumption_unit=None, volume_unit='L'):
'total_cost': self.total_cost,
'sales_tax': self.sales_tax,
'fuel_type': self.effective_fuel_type,
'fuel_distance': self.fuel_distance,
'is_full_tank': self.is_full_tank,
'is_missed': self.is_missed,
'station': self.station,
Expand Down Expand Up @@ -1330,6 +1456,25 @@ def resolve_price_fuel_type(log_fuel_type, vehicle_fuel_type):
return PROPULSION_TO_FUEL.get(fuel_type, fuel_type) or 'petrol'


# Fluids a vehicle carries alongside its fuel without burning them for
# propulsion. They are logged and costed, but they never earn a consumption
# figure of their own (#319) and never split a bi-fuel vehicle's distance
# (#221): an AdBlue top-up says nothing about how far the car ran on diesel.
AUXILIARY_FLUID_TYPES = {'adblue'}


def _propulsion_fuel_type(fuel_type):
"""The fuel a stored type actually burns, or None if it burns nothing.

Propulsion labels resolve to the fuel behind them, so 'hybrid' and
'petrol' are one fuel rather than two (#268), and auxiliary fluids
resolve to None (#319).
"""
if not fuel_type or fuel_type in AUXILIARY_FLUID_TYPES:
return None
return resolve_price_fuel_type(fuel_type, fuel_type)


def fuel_type_label(fuel_type):
"""Display label for a stored fuel type slug, translated where known."""
return dict(FUEL_TYPES).get(fuel_type) or (fuel_type or '').replace('_', ' ').title()
Expand Down
20 changes: 16 additions & 4 deletions app/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,8 +528,8 @@ def api_create_fuel_log(vehicle_id):
Create a fuel log

Required fields: date, odometer
Optional fields: volume, price_per_unit, total_cost, sales_tax, is_full_tank, is_missed,
station, notes
Optional fields: volume, price_per_unit, total_cost, sales_tax, fuel_type,
fuel_distance, is_full_tank, is_missed, station, notes
"""
user = get_api_user()
vehicle = db.get_or_404(Vehicle, vehicle_id)
Expand Down Expand Up @@ -561,6 +561,8 @@ def api_create_fuel_log(vehicle_id):
price_per_unit=parse_decimal(data['price_per_unit']) if data.get('price_per_unit') else None,
total_cost=parse_decimal(data['total_cost']) if data.get('total_cost') else None,
sales_tax=parse_decimal(data['sales_tax']) if data.get('sales_tax') else None,
fuel_type=data.get('fuel_type') or None,
fuel_distance=parse_decimal(data['fuel_distance']) if data.get('fuel_distance') else None,
is_full_tank=data.get('is_full_tank', True),
is_missed=data.get('is_missed', False),
station=data.get('station'),
Expand Down Expand Up @@ -620,6 +622,10 @@ def api_update_fuel_log(log_id):
log.total_cost = parse_decimal(data['total_cost']) if data['total_cost'] else None
if 'sales_tax' in data:
log.sales_tax = parse_decimal(data['sales_tax']) if data['sales_tax'] else None
if 'fuel_type' in data:
log.fuel_type = data['fuel_type'] or None
if 'fuel_distance' in data:
log.fuel_distance = parse_decimal(data['fuel_distance']) if data['fuel_distance'] else None
if 'is_full_tank' in data:
log.is_full_tank = data['is_full_tank']
if 'is_missed' in data:
Expand Down Expand Up @@ -1625,8 +1631,9 @@ def export_csv():
writer = csv.writer(fuel_csv)
writer.writerow([
'id', 'vehicle_id', 'vehicle_name', 'date', 'odometer', 'odometer_unit',
'volume', 'price_per_unit', 'total_cost', 'sales_tax', 'is_full_tank',
'is_missed', 'station', 'notes', 'created_at'
'volume', 'price_per_unit', 'total_cost', 'sales_tax', 'fuel_type',
'fuel_distance', 'is_full_tank', 'is_missed', 'station', 'notes',
'created_at'
])
for vehicle in current_user.get_all_vehicles():
# Each row states the unit its own vehicle meters in, so a
Expand All @@ -1638,6 +1645,7 @@ def export_csv():
log.id, vehicle.id, vehicle.name, log.date.isoformat(),
log.odometer, odometer_unit,
log.volume, log.price_per_unit, log.total_cost, log.sales_tax,
log.fuel_type, log.fuel_distance,
log.is_full_tank, log.is_missed, log.station, log.notes,
log.created_at.isoformat() if log.created_at else ''
])
Expand Down Expand Up @@ -1946,6 +1954,8 @@ def export_json():
'price_per_unit': log.price_per_unit,
'total_cost': log.total_cost,
'sales_tax': log.sales_tax,
'fuel_type': log.fuel_type,
'fuel_distance': log.fuel_distance,
'is_full_tank': log.is_full_tank,
'is_missed': log.is_missed,
'station': log.station,
Expand Down Expand Up @@ -2248,6 +2258,8 @@ def export_full_backup():
'price_per_unit': log.price_per_unit,
'total_cost': log.total_cost,
'sales_tax': log.sales_tax,
'fuel_type': log.fuel_type,
'fuel_distance': log.fuel_distance,
'is_full_tank': log.is_full_tank,
'is_missed': log.is_missed,
'station': log.station,
Expand Down
Loading