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 @@

Response

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 @@

Response

"total_cost": 65.98, "sales_tax": 8.58, "fuel_type": "petrol", + "fuel_distance": null, "is_full_tank": true, "is_missed": false, "station": "Shell", @@ -271,7 +276,7 @@

Response

"offset": 0 } -

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.

@@ -302,6 +307,8 @@

Request Body

price_per_unitnumberNoPrice per liter total_costnumberNoTotal cost (auto-calculated if not provided) sales_taxnumberNoSales tax included in the total cost + fuel_typestringNoFuel this fill-up put in; defaults to the vehicle's own fuel type + fuel_distancenumberNoDistance run on this fuel since the previous fill-up of it, in the vehicle's unit (dual-fuel vehicles only) is_full_tankbooleanNoWas tank filled completely? (default: true) is_missedbooleanNoMark as missed fill-up (default: false) stationstringNoGas station name diff --git a/app/templates/fuel/form.html b/app/templates/fuel/form.html index e4a5ec2..f8abff5 100644 --- a/app/templates/fuel/form.html +++ b/app/templates/fuel/form.html @@ -30,7 +30,8 @@

{% if log %}{{ data-uses-tessie="{{ 'true' if vehicle.uses_tessie_odometer() else 'false' }}" data-tessie-odometer="{% if vehicle.tessie_last_odometer %}{% if vehicle.get_effective_odometer_unit() == 'mi' %}{{ (vehicle.tessie_last_odometer * 0.621371)|round|int }}{% else %}{{ vehicle.tessie_last_odometer|round|int }}{% endif %}{% endif %}" data-primary-fuel="{{ vehicle.fuel_type }}" - data-secondary-fuel="{{ vehicle.secondary_fuel_type or '' }}"> + data-secondary-fuel="{{ vehicle.secondary_fuel_type or '' }}" + data-bi-fuel="{{ 'true' if vehicle.declares_second_fuel() else 'false' }}"> {{ vehicle.name }} {% endfor %} @@ -137,6 +138,16 @@

{% if log %}{{ + +
{% if log %}{{ function updateFuelTypeSelector(selectedOption) { const fuelTypeField = document.getElementById('fuel-type-field'); const fuelTypeSelect = document.getElementById('fuel_type'); + // Dual-fuel vehicles need the distance run on each fuel (#221) + const fuelDistanceField = document.getElementById('fuel-distance-field'); + const fuelDistanceUnit = document.getElementById('fuel-distance-unit'); const primaryFuel = selectedOption.getAttribute('data-primary-fuel') || ''; const secondaryFuel = selectedOption.getAttribute('data-secondary-fuel') || ''; const fuelLabels = {{ dict(fuel_types)|tojson }}; @@ -263,6 +277,14 @@

{% if log %}{{ }; let choices = []; + + // Only a vehicle that burns two fuels needs the distance split out. An + // AdBlue tank doesn't count — it propels nothing (#319) — so the server + // decides and says so on the option. + const biFuel = selectedOption.getAttribute('data-bi-fuel') === 'true'; + fuelDistanceField.classList.toggle('hidden', !biFuel); + fuelDistanceUnit.textContent = selectedOption.getAttribute('data-odometer-unit') || ''; + if (secondaryFuel) { choices = [primaryFuel, secondaryFuel]; } else if (propulsionFuels[primaryFuel]) { diff --git a/app/templates/vehicles/view.html b/app/templates/vehicles/view.html index ab2117d..330eab2 100644 --- a/app/templates/vehicles/view.html +++ b/app/templates/vehicles/view.html @@ -164,16 +164,22 @@

{{ vehicle.name }}<
{{ vehicle.get_reading_unit() }}

{% if vehicle.uses_fuel() %} + {# One card per fuel: on a dual-fuel vehicle petrol and LPG each get their + own average, since a combined figure would be meaningless (#221). #} + {% for entry in stats.consumption_by_fuel %}
-
{{ _('Avg. Consumption') }}
- {% if stats.avg_consumption %} -
{{ "%.1f"|format(stats.avg_consumption) }}
+
+ {{ _('Avg. Consumption') }}{% if stats.consumption_by_fuel|length > 1 %} · {{ entry.fuel_type|fuel_type_label }}{% endif %} +
+ {% if entry.value %} +
{{ "%.1f"|format(entry.value) }}
{{ vehicle.get_consumption_unit() or current_user.consumption_unit }}
{% else %} - {% set reason = vehicle.get_consumption_unavailable_reason() %} - {% if reason == 'missed_fill_up' %} + {% if entry.reason == 'missed_fill_up' %} {% set consumption_hint = _('A missed fill-up in the range means we can’t calculate this honestly.') %} - {% elif reason == 'insufficient_data' %} + {% elif entry.reason == 'needs_distance_attribution' %} + {% set consumption_hint = _('Consumption can’t be worked out until the distance run on each fuel is recorded — the odometer can’t tell petrol from LPG. Add it to your fill-ups.') %} + {% elif entry.reason == 'insufficient_data' %} {% set consumption_hint = _('Not enough distance or fuel data yet to calculate this.') %} {% else %} {% set consumption_hint = _('Needs at least two full-tank fill-ups.') %} @@ -182,6 +188,7 @@

{{ vehicle.name }}<
{{ consumption_hint }}
{% endif %}

+ {% endfor %} {% endif %} {% if vehicle.uses_charging() %}
diff --git a/app/translations/messages.pot b/app/translations/messages.pot index b32749a..30d86e3 100644 --- a/app/translations/messages.pot +++ b/app/translations/messages.pot @@ -2843,6 +2843,16 @@ msgstr "" msgid "Discount per" msgstr "" +#: app/templates/fuel/form.html:138 +msgid "Distance on this fuel" +msgstr "" + +#: app/templates/fuel/form.html:143 +msgid "" +"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." +msgstr "" + #: app/templates/fuel/form.html:84 msgid "Optional loyalty discount, subtracted from the price per unit." msgstr "" @@ -4215,6 +4225,13 @@ msgstr "" msgid "A missed fill-up in the range means we can’t calculate this honestly." msgstr "" +#: app/templates/vehicles/view.html:181 +msgid "" +"Consumption can’t be worked out until the distance run on each fuel is " +"recorded — the odometer can’t tell petrol from LPG. Add it to your fill-" +"ups." +msgstr "" + #: app/templates/vehicles/view.html:120 msgid "Not enough distance or fuel data yet to calculate this." msgstr "" diff --git a/config.py b/config.py index 82e8989..2a7b545 100644 --- a/config.py +++ b/config.py @@ -11,7 +11,7 @@ load_dotenv(basedir / '.env') -APP_VERSION = '0.40.0' +APP_VERSION = '0.41.0' RELEASE_CHANNEL = os.environ.get('RELEASE_CHANNEL', 'stable') GIT_SHA = os.environ.get('GIT_SHA', '')[:7] # Short SHA GITHUB_REPO = 'dannymcc/may' diff --git a/migrations/versions/d0e1f2a3b4c5_add_fuel_distance_to_fuel_logs.py b/migrations/versions/d0e1f2a3b4c5_add_fuel_distance_to_fuel_logs.py new file mode 100644 index 0000000..7c415d4 --- /dev/null +++ b/migrations/versions/d0e1f2a3b4c5_add_fuel_distance_to_fuel_logs.py @@ -0,0 +1,31 @@ +"""add fuel_distance to fuel_logs + +Revision ID: d0e1f2a3b4c5 +Revises: f2a3b4c5d6e7 +Create Date: 2026-08-23 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd0e1f2a3b4c5' +down_revision = 'f2a3b4c5d6e7' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + + columns = [c['name'] for c in inspector.get_columns('fuel_logs')] + if 'fuel_distance' not in columns: + with op.batch_alter_table('fuel_logs', schema=None) as batch_op: + batch_op.add_column(sa.Column('fuel_distance', sa.Float(), nullable=True)) + + +def downgrade(): + with op.batch_alter_table('fuel_logs', schema=None) as batch_op: + batch_op.drop_column('fuel_distance') diff --git a/tests/test_backup_restore.py b/tests/test_backup_restore.py index b58b769..7222177 100644 --- a/tests/test_backup_restore.py +++ b/tests/test_backup_restore.py @@ -51,7 +51,8 @@ def make_backup_data(): 'is_missed': False, 'station': 'Shell', 'notes': None, 'created_at': '2024-01-15T18:00:00'}, {'id': 2, 'date': '2024-02-15', 'odometer': 10500.0, 'volume': 42.0, - 'price_per_unit': 1.6, 'total_cost': 67.2, 'is_full_tank': True, + 'price_per_unit': 1.6, 'total_cost': 67.2, + 'fuel_type': 'lpg', 'fuel_distance': 320.0, 'is_full_tank': True, 'is_missed': False, 'station': 'BP', 'notes': None, 'created_at': '2024-02-15T18:00:00'}, ], @@ -219,6 +220,11 @@ def test_restores_vehicle_and_records(self, auth_client, test_user): assert vehicle.specs.count() == 1 assert vehicle.fuel_logs.count() == 2 + # #221 — a dual-fuel history has to survive the round trip, which means + # both which fuel went in and the distance run on it. + lpg_log = vehicle.fuel_logs.order_by(FuelLog.date).all()[1] + assert lpg_log.fuel_type == 'lpg' + assert lpg_log.fuel_distance == 320.0 log = vehicle.fuel_logs.order_by(FuelLog.date).first() assert log.odometer == 10000.0 assert log.station == 'Shell' diff --git a/tests/test_exports.py b/tests/test_exports.py index 80ddd88..e8410b6 100644 --- a/tests/test_exports.py +++ b/tests/test_exports.py @@ -67,6 +67,21 @@ def test_export_csv_includes_sales_tax(self, auth_client, sample_vehicle, sample assert 'sales_tax' in lines[0] assert '7.8' in lines[1] + def test_export_csv_keeps_which_fuel_went_in(self, auth_client, sample_vehicle, + sample_fuel_log): + """#221 — the fuel type and its attributed distance belong in the CSV + too, or a dual-fuel history is lost on export.""" + sample_fuel_log.fuel_type = 'lpg' + sample_fuel_log.fuel_distance = 320 + db.session.commit() + + resp = auth_client.get('/api/export/csv') + with zipfile.ZipFile(io.BytesIO(resp.data)) as zf: + fuel_csv = zf.read('fuel_logs.csv').decode('utf-8') + header, row = fuel_csv.splitlines()[0], fuel_csv.splitlines()[1] + assert 'fuel_type' in header and 'fuel_distance' in header + assert 'lpg' in row and '320' in row + def test_export_csv_includes_odometer_unit(self, auth_client, sample_vehicle, sample_fuel_log, sample_expense): """#173 — odometer values must be self-describing about units.""" resp = auth_client.get('/api/export/csv') @@ -120,6 +135,19 @@ def test_export_json_with_vehicle(self, auth_client, sample_vehicle): assert len(data['vehicles']) == 1 assert data['vehicles'][0]['name'] == 'Test Car' + def test_export_json_keeps_which_fuel_went_in(self, auth_client, sample_vehicle, + sample_fuel_log): + """#221 — without the fuel type and its attributed distance a dual-fuel + history cannot be reconstructed from an export.""" + sample_fuel_log.fuel_type = 'lpg' + sample_fuel_log.fuel_distance = 320 + db.session.commit() + + resp = auth_client.get('/api/export/json') + log = resp.get_json()['vehicles'][0]['fuel_logs'][0] + assert log['fuel_type'] == 'lpg' + assert log['fuel_distance'] == 320 + def test_export_json_with_fuel_log(self, auth_client, sample_vehicle, sample_fuel_log): resp = auth_client.get('/api/export/json') assert resp.status_code == 200 diff --git a/tests/test_fuel.py b/tests/test_fuel.py index 349d741..8321ad5 100644 --- a/tests/test_fuel.py +++ b/tests/test_fuel.py @@ -1238,3 +1238,224 @@ def test_vehicle_page_charts_each_fuel_type_separately( # The trend chart builds one dataset per fuel type rather than one # flat consumption series. assert 'typeLabels' in html + +class TestDualFuelConsumption: + """#221 — petrol and LPG are averaged separately, on attributed distance.""" + + @pytest.fixture + def bifuel_vehicle(self, app, test_user): + vehicle = Vehicle(owner_id=test_user.id, name='LPG Car', vehicle_type='car', + make='Dacia', model='Duster', fuel_type='petrol', + secondary_fuel_type='lpg', odometer_unit='km') + db.session.add(vehicle) + db.session.commit() + return vehicle + + def _log(self, user, vehicle, odometer, volume, fuel_type, + fuel_distance=None, is_full_tank=True): + log = FuelLog(vehicle_id=vehicle.id, user_id=user.id, date=date(2024, 1, 1), + odometer=odometer, volume=volume, fuel_type=fuel_type, + fuel_distance=fuel_distance, is_full_tank=is_full_tank) + db.session.add(log) + db.session.commit() + return log + + def _attributed_history(self, user, vehicle): + """Two fill-ups of each fuel, with the distance split by the driver.""" + return { + 'lpg': [self._log(user, vehicle, 10000, 40, 'lpg'), + self._log(user, vehicle, 10600, 60, 'lpg', fuel_distance=500)], + 'petrol': [self._log(user, vehicle, 10200, 30, 'petrol'), + self._log(user, vehicle, 10800, 20, 'petrol', fuel_distance=200)], + } + + def test_logged_fuel_types_lists_primary_first(self, bifuel_vehicle, test_user): + self._attributed_history(test_user, bifuel_vehicle) + assert bifuel_vehicle.get_propulsion_fuel_types() == ['petrol', 'lpg'] + assert bifuel_vehicle.runs_on_two_fuels() is True + + def test_single_fuel_vehicle_is_not_treated_as_dual(self, sample_vehicle, test_user): + """Logging only one fuel leaves the odometer-based average alone.""" + self._log(test_user, sample_vehicle, 10000, 40, None) + self._log(test_user, sample_vehicle, 10500, 40, None) + assert sample_vehicle.runs_on_two_fuels() is False + avg = sample_vehicle.get_average_consumption() + assert abs(avg - 8.0) < 0.01 + + def test_declared_bifuel_with_one_fuel_logged_keeps_odometer_maths( + self, bifuel_vehicle, test_user): + """Declaring LPG but only ever filling with petrol changes nothing: + there is no second fuel in the history to disentangle.""" + self._log(test_user, bifuel_vehicle, 10000, 40, 'petrol') + self._log(test_user, bifuel_vehicle, 10500, 40, 'petrol') + + assert bifuel_vehicle.runs_on_two_fuels() is False + assert abs(bifuel_vehicle.get_average_consumption() - 8.0) < 0.01 + + def test_hybrid_untyped_and_petrol_logs_are_one_fuel(self, app, test_user): + """A plain hybrid is not bi-fuel. Its older fill-ups predate the fuel + type selector and carry no type, its newer ones say 'petrol'; that is + one fuel, and the hybrid must keep its ordinary average (#268).""" + vehicle = Vehicle(owner_id=test_user.id, name='Hybrid', vehicle_type='car', + make='Toyota', model='Yaris', fuel_type='hybrid', + odometer_unit='km') + db.session.add(vehicle) + db.session.commit() + self._log(test_user, vehicle, 10000, 40, None) + self._log(test_user, vehicle, 10500, 40, 'petrol') + + assert vehicle.get_propulsion_fuel_types() == ['petrol'] + assert vehicle.runs_on_two_fuels() is False + assert abs(vehicle.get_average_consumption() - 8.0) < 0.01 + + def test_adblue_is_not_a_second_propulsion_fuel(self, adblue_vehicle, test_user): + """AdBlue is an auxiliary fluid (#319): a diesel that tracks it is not + bi-fuel and must never be asked to attribute distance to it.""" + self._log(test_user, adblue_vehicle, 10000, 50, 'diesel') + self._log(test_user, adblue_vehicle, 10500, 30, 'adblue', is_full_tank=False) + self._log(test_user, adblue_vehicle, 11000, 40, 'diesel') + + assert adblue_vehicle.declares_second_fuel() is False + assert adblue_vehicle.runs_on_two_fuels() is False + assert abs(adblue_vehicle.get_average_consumption() - 4.0) < 0.01 + + def test_fuels_are_not_blended_into_one_average(self, bifuel_vehicle, test_user): + """The reported defect: petrol litres and LPG litres over one odometer + span produced a single meaningless figure.""" + self._attributed_history(test_user, bifuel_vehicle) + + petrol = bifuel_vehicle.get_average_consumption(fuel_type='petrol') + lpg = bifuel_vehicle.get_average_consumption(fuel_type='lpg') + # 20 L over 200 km, and 60 L over 500 km — each on its own fuel. + assert abs(petrol - 10.0) < 0.01 + assert abs(lpg - 12.0) < 0.01 + assert bifuel_vehicle.get_consumption_unavailable_reason('lpg') is None + + def test_default_fuel_is_the_vehicle_primary(self, bifuel_vehicle, test_user): + self._attributed_history(test_user, bifuel_vehicle) + assert bifuel_vehicle.get_average_consumption() == \ + bifuel_vehicle.get_average_consumption(fuel_type='petrol') + + def test_by_fuel_breakdown_covers_every_logged_fuel(self, bifuel_vehicle, test_user): + self._attributed_history(test_user, bifuel_vehicle) + breakdown = bifuel_vehicle.get_average_consumption_by_fuel() + assert [entry['fuel_type'] for entry in breakdown] == ['petrol', 'lpg'] + assert all(entry['reason'] is None for entry in breakdown) + + def test_by_fuel_breakdown_without_logs_keeps_one_entry(self, bifuel_vehicle): + """No fill-ups yet still yields the usual single empty state.""" + breakdown = bifuel_vehicle.get_average_consumption_by_fuel() + assert len(breakdown) == 1 + assert breakdown[0]['value'] is None + assert breakdown[0]['reason'] == 'insufficient_full_tanks' + + def test_unattributed_distance_is_reported_not_guessed( + self, bifuel_vehicle, test_user): + """Without a distance per fuel we say so rather than inventing one.""" + self._log(test_user, bifuel_vehicle, 10000, 40, 'lpg') + self._log(test_user, bifuel_vehicle, 10200, 30, 'petrol') + self._log(test_user, bifuel_vehicle, 10600, 60, 'lpg') + + assert bifuel_vehicle.get_average_consumption(fuel_type='lpg') is None + assert bifuel_vehicle.get_consumption_unavailable_reason('lpg') == \ + 'needs_distance_attribution' + + def test_single_fuel_stretch_keeps_its_odometer_figure(self, bifuel_vehicle, test_user): + """A car converted to LPG keeps the ordinary maths over the stretch it + ran on petrol alone: no LPG fill-up falls in that span, so the + odometer distance is unambiguous and nothing needs attributing.""" + self._log(test_user, bifuel_vehicle, 10000, 40, 'petrol') + self._log(test_user, bifuel_vehicle, 10500, 40, 'petrol') + # The conversion, and the first LPG fill-ups, come later. + self._log(test_user, bifuel_vehicle, 11000, 45, 'lpg') + self._log(test_user, bifuel_vehicle, 11400, 50, 'lpg', fuel_distance=400) + + assert bifuel_vehicle.runs_on_two_fuels() is True + # 40 L over the 500 km between the two petrol fills, untouched. + assert abs(bifuel_vehicle.get_average_consumption(fuel_type='petrol') - 8.0) < 0.01 + assert bifuel_vehicle.get_consumption_unavailable_reason('petrol') is None + + def test_per_fill_up_figure_follows_its_own_fuel(self, bifuel_vehicle, test_user): + logs = self._attributed_history(test_user, bifuel_vehicle) + # 60 L over the 500 km the driver ran on LPG, ignoring the petrol + # fill-up that sits between the two LPG odometer readings. + assert abs(logs['lpg'][1].get_consumption() - 12.0) < 0.01 + assert abs(logs['petrol'][1].get_consumption() - 10.0) < 0.01 + + def test_per_fill_up_figure_needs_attribution(self, bifuel_vehicle, test_user): + self._log(test_user, bifuel_vehicle, 10000, 40, 'lpg') + self._log(test_user, bifuel_vehicle, 10200, 30, 'petrol') + latest = self._log(test_user, bifuel_vehicle, 10600, 60, 'lpg') + assert latest.get_consumption() is None + + def test_new_log_stores_attributed_distance(self, auth_client, bifuel_vehicle): + auth_client.post('/fuel/new', data={ + 'vehicle_id': str(bifuel_vehicle.id), + 'date': '2024-03-01', + 'odometer': '20000', + 'volume': '45.0', + 'price_per_unit': '0.80', + 'total_cost': '36.0', + 'fuel_type': 'lpg', + 'fuel_distance': '420', + 'is_full_tank': 'on', + }, follow_redirects=True) + + log = FuelLog.query.filter_by(vehicle_id=bifuel_vehicle.id).one() + assert log.fuel_type == 'lpg' + assert log.fuel_distance == 420 + + def test_bad_attributed_distance_is_rejected_not_crashed(self, auth_client, + bifuel_vehicle): + """A negative distance must come back as a flashed error. There is no + fuel/new.html to render, so the failure path has to redirect.""" + resp = auth_client.post('/fuel/new', data={ + 'vehicle_id': str(bifuel_vehicle.id), + 'date': '2024-03-01', + 'odometer': '20000', + 'volume': '45.0', + 'price_per_unit': '0.80', + 'total_cost': '36.0', + 'fuel_type': 'lpg', + 'fuel_distance': '-5', + 'is_full_tank': 'on', + }, follow_redirects=True) + + assert resp.status_code == 200 + assert FuelLog.query.filter_by(vehicle_id=bifuel_vehicle.id).count() == 0 + + def test_edit_updates_attributed_distance(self, auth_client, bifuel_vehicle, test_user): + log = self._log(test_user, bifuel_vehicle, 20000, 45, 'lpg', fuel_distance=420) + auth_client.post(f'/fuel/{log.id}/edit', data={ + 'vehicle_id': str(bifuel_vehicle.id), + 'date': '2024-03-01', + 'odometer': '20000', + 'volume': '45.0', + 'fuel_type': 'lpg', + 'fuel_distance': '380', + 'is_full_tank': 'on', + }, follow_redirects=True) + + db.session.refresh(log) + assert log.fuel_distance == 380 + + def test_vehicle_page_shows_a_figure_per_fuel( + self, auth_client, bifuel_vehicle, test_user): + self._attributed_history(test_user, bifuel_vehicle) + resp = auth_client.get(f'/vehicles/{bifuel_vehicle.id}') + html = resp.get_data(as_text=True) + assert resp.status_code == 200 + assert 'LPG' in html + assert 'Petrol/Gasoline' in html + + def test_vehicle_page_asks_for_attribution( + self, auth_client, bifuel_vehicle, test_user): + self._log(test_user, bifuel_vehicle, 10000, 40, 'lpg') + self._log(test_user, bifuel_vehicle, 10200, 30, 'petrol') + self._log(test_user, bifuel_vehicle, 10600, 60, 'lpg') + + resp = auth_client.get(f'/vehicles/{bifuel_vehicle.id}') + html = resp.get_data(as_text=True) + # Says why the figure has gone, not merely what to do about it. + assert 'Consumption can' in html + assert 'distance run on each fuel' in html