diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e9337d..7c28b69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 9f83e44..99cddc8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/models.py b/app/models.py index 4212935..6788865 100644 --- a/app/models.py +++ b/app/models.py @@ -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. @@ -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 @@ -527,6 +600,7 @@ 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 @@ -534,7 +608,14 @@ def _valid_consumption_segments(self, fuel_type=None): 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 @@ -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: @@ -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) @@ -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 @@ -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 @@ -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 @@ -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, @@ -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: @@ -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, @@ -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() diff --git a/app/routes/api.py b/app/routes/api.py index caa3b2c..284715b 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -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) @@ -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'), @@ -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: @@ -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 @@ -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 '' ]) @@ -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, @@ -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, diff --git a/app/routes/fuel.py b/app/routes/fuel.py index a224b12..18a390b 100644 --- a/app/routes/fuel.py +++ b/app/routes/fuel.py @@ -121,6 +121,15 @@ def new(): flash(err, 'error') return redirect(url_for('fuel.new', vehicle_id=vehicle_id)) + # Distance run on this fuel — only bi-fuel vehicles need it (#221) + fuel_distance = None + if request.form.get('fuel_distance'): + fuel_distance, err = validate_positive_number( + request.form.get('fuel_distance'), 'Distance on this fuel', max_value=9999999) + if err: + flash(err, 'error') + return redirect(url_for('fuel.new', vehicle_id=vehicle_id)) + log = FuelLog( vehicle_id=vehicle_id, user_id=current_user.id, @@ -132,6 +141,7 @@ def new(): total_cost=total_cost, sales_tax=sales_tax, fuel_type=request.form.get('fuel_type') or None, + fuel_distance=fuel_distance, is_full_tank=request.form.get('is_full_tank') == 'on', is_missed=request.form.get('is_missed') == 'on', station=request.form.get('station'), @@ -235,6 +245,7 @@ def edit(log_id): log.total_cost = parse_decimal(request.form.get('total_cost')) if request.form.get('total_cost') else None log.sales_tax = parse_decimal(request.form.get('sales_tax')) if request.form.get('sales_tax') else None log.fuel_type = request.form.get('fuel_type') or None + log.fuel_distance = parse_decimal(request.form.get('fuel_distance')) if request.form.get('fuel_distance') else None log.is_full_tank = request.form.get('is_full_tank') == 'on' log.is_missed = request.form.get('is_missed') == 'on' log.station = request.form.get('station') diff --git a/app/routes/vehicles.py b/app/routes/vehicles.py index dbb629a..4f344ce 100644 --- a/app/routes/vehicles.py +++ b/app/routes/vehicles.py @@ -184,6 +184,10 @@ def view(vehicle_id): 'net_cost': vehicle.get_net_cost(), 'total_distance': vehicle.get_total_distance(vehicle.get_effective_odometer_unit()), 'avg_consumption': vehicle.get_average_consumption(current_user.consumption_unit, current_user.volume_unit), + # Dual-fuel vehicles get a figure per fuel rather than one blended + # number that describes neither fuel (#221). + 'consumption_by_fuel': vehicle.get_average_consumption_by_fuel( + current_user.consumption_unit, current_user.volume_unit), 'cost_per_distance': vehicle.get_cost_per_distance(), 'total_fuel_volume': vehicle.get_total_fuel_volume(), 'total_co2_kg': vehicle.get_total_co2_kg(current_user.volume_unit), diff --git a/app/services/backup_restore.py b/app/services/backup_restore.py index 8cc74d8..197b574 100644 --- a/app/services/backup_restore.py +++ b/app/services/backup_restore.py @@ -201,7 +201,7 @@ def key_of_row(self, row): {'date': 'date', 'odometer': 'float', 'volume': 'float', 'price_per_unit': 'float', 'discount_per_unit': 'float', 'total_cost': 'float', 'sales_tax': 'float', - 'fuel_type': 'str', 'is_full_tank': 'bool', + 'fuel_type': 'str', 'fuel_distance': 'float', 'is_full_tank': 'bool', 'is_missed': 'bool', 'station': 'str', 'notes': 'str', 'created_at': 'datetime'}, key_fields=('date', 'odometer', 'volume')), diff --git a/app/templates/api/docs.html b/app/templates/api/docs.html index 443065e..198e849 100644 --- a/app/templates/api/docs.html +++ b/app/templates/api/docs.html @@ -118,6 +118,10 @@
average_consumption in litres per hour. The tracking unit is
set on the vehicle page and is not currently exposed or settable over the
API; vehicles created here are tracked by distance.
+ On a vehicle with a secondary_fuel_type that it actually burns,
+ such as a petrol car converted to LPG, average_consumption
+ covers the primary fuel only — each fuel is averaged on its own, and the
+ per-fuel figures are shown on the vehicle page.
@@ -257,6 +261,7 @@ fuel_type is the fuel the fill-up actually put in; logs recorded without one report the vehicle's own fuel type. It is read-only over the API — set it in the web interface if a vehicle takes more than one fuel.
fuel_type is the fuel the fill-up actually put in; logs recorded without one report the vehicle's own fuel type. fuel_distance is the distance run on that fuel since the previous fill-up of it, in the vehicle's own unit, and is only needed on a vehicle that burns two fuels — the odometer cannot say which of the two propelled which part of the distance. Both can be set when creating or updating a fill-up.
price_per_unittotal_costsales_taxfuel_typefuel_distanceis_full_tankis_missedstation{{ _('Distance run on this fuel since your last fill-up of it. The odometer can’t tell the fuels apart, so consumption for a dual-fuel vehicle needs this.') }}
+