Skip to content

Commit b520c7e

Browse files
malysclaude
andcommitted
feat(arrival): forecast the charge on arrival from the car's own route
CP-041 asked for an arrival state of charge. The obvious construction — pack capacity, an energy model, a distance — cannot run on this vehicle: CP-003 settled that battery power is declared and never published, so no kWh can be integrated. State of charge per kilometre needs no energy unit and both of its inputs read, so that is what the screen computes. The remaining distance comes from the head unit's own guidance through the synchronous getters CP-040 found. The screen never registers a listener: the callback fan-out belongs to the navigation app and a stable build stays out of it. Everything — the binder calls, the trip file, the arithmetic — runs on a single worker thread on a five second cadence, because a route shortens by metres a second and this is not a speedometer. The rate prefers the driver's own recorded trips and falls back to the vehicle's range estimate, which is what makes a fresh install useful: trips recorded before the 3.6× speed fix are 3.6 times too long and the estimator refuses them, so history is empty until new trips accumulate. A forecast wider than fifteen points is refused and says so, rather than printing a band half the pack wide and letting the driver read the midpoint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ed6bb53 commit b520c7e

8 files changed

Lines changed: 340 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@ All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.
1313

1414
### Added
1515

16+
- **The dashboard forecasts the charge you will arrive with.** The screen the whole trip
17+
companion was supposed to lead to could not be built the obvious way: CP-003 proved this
18+
firmware declares battery power and never publishes it, so nothing can integrate kWh. Charge
19+
per kilometre needs no energy unit, and both its inputs read. The new "Arrival charge" screen
20+
takes the remaining distance from the car's own guidance — read through synchronous getters,
21+
never by registering on the adapter's callback fan-out — and spends it at a rate taken from
22+
your recorded trips, or from the vehicle's own range estimate until three trips exist.
23+
It refuses to show a figure whose band is wider than fifteen points, because a forecast that
24+
says "somewhere between 20 and 60" is not a forecast. Trips recorded before the 3.6× speed fix
25+
are excluded by the estimator rather than silently averaged in.
26+
1627
- **The bundle answers the speed-unit question by itself.** A trip distance is integrated from
1728
speed, so a wrong speed unit scales it by the same factor and nothing in the number says so —
1829
a 2.4 km drive recorded as more than 7 km was the only symptom, and it needed somebody to

‎app/src/main/AndroidManifest.xml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@
7171
android:exported="false"
7272
android:screenOrientation="landscape" />
7373

74+
<activity
75+
android:name=".ArrivalForecastActivity"
76+
android:exported="false"
77+
android:screenOrientation="landscape" />
78+
7479
<activity
7580
android:name=".EnergyBreakdownActivity"
7681
android:exported="false"
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
package com.evsuite.chargepilot
2+
3+
import android.content.ComponentName
4+
import android.content.Context
5+
import android.content.Intent
6+
import android.content.ServiceConnection
7+
import android.os.Bundle
8+
import android.os.IBinder
9+
import android.view.View
10+
import androidx.appcompat.app.AppCompatActivity
11+
import com.evsuite.chargepilot.databinding.ActivityArrivalForecastBinding
12+
import com.evsuite.hardware.FirmwareInfo
13+
import com.evsuite.hardware.saic.SaicNavGuidance
14+
import com.evsuite.hardware.telemetry.ArrivalSocForecast
15+
import com.evsuite.hardware.telemetry.EnergySnapshot
16+
import com.evsuite.hardware.telemetry.EnergyTripHistoryStore
17+
import com.evsuite.hardware.telemetry.Provenanced
18+
import com.evsuite.hardware.telemetry.SocRate
19+
import com.evsuite.hardware.telemetry.SocRateEstimator
20+
import java.io.File
21+
import java.util.Locale
22+
import java.util.concurrent.Executors
23+
import java.util.concurrent.ScheduledFuture
24+
import java.util.concurrent.TimeUnit
25+
26+
/**
27+
* What the charge will read on arrival, if the head unit knows where the driver is going.
28+
*
29+
* The obvious way to build this — the energy model, a distance, a pack capacity — cannot work
30+
* on this vehicle: CP-003 proved battery power is declared and never published, so nothing can
31+
* integrate kWh. State of charge per kilometre needs no energy unit and both its inputs read,
32+
* which is what this screen shows.
33+
*
34+
* **It reads the navigation, it never registers with it.** `SaicNavGuidance.readNow` answers
35+
* from synchronous getters; the listener registration that CP-040 used to discover them stays
36+
* in the unstable channel, so a stable build never joins the adapter's callback fan-out.
37+
*
38+
* Everything happens on [worker]: the vehicle binder calls, the trip file, and the arithmetic.
39+
* The dashboard's own sampling must not wait on any of it.
40+
*/
41+
class ArrivalForecastActivity : AppCompatActivity() {
42+
43+
private lateinit var binding: ActivityArrivalForecastBinding
44+
private lateinit var provenance: ProvenanceText
45+
46+
private val worker = Executors.newSingleThreadScheduledExecutor { runnable ->
47+
Thread(runnable, "chargepilot-arrival")
48+
}
49+
50+
@Volatile
51+
private var snapshot: EnergySnapshot? = null
52+
53+
/** Read once: the trip file does not change while this screen is open. */
54+
@Volatile
55+
private var rate: SocRate? = null
56+
57+
@Volatile
58+
private var ratePending = true
59+
60+
private var recorder: TripRecordingService? = null
61+
private var bound = false
62+
private var refresh: ScheduledFuture<*>? = null
63+
64+
private val connection = object : ServiceConnection {
65+
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
66+
val value = (service as? TripRecordingService.LocalBinder)?.service ?: return
67+
recorder = value
68+
snapshot = value.latest
69+
value.setListener(this@ArrivalForecastActivity) { latest -> snapshot = latest }
70+
}
71+
72+
override fun onServiceDisconnected(name: ComponentName?) {
73+
recorder = null
74+
snapshot = null
75+
}
76+
}
77+
78+
override fun onCreate(savedInstanceState: Bundle?) {
79+
super.onCreate(savedInstanceState)
80+
binding = ActivityArrivalForecastBinding.inflate(layoutInflater)
81+
setContentView(binding.root)
82+
provenance = ProvenanceText(this)
83+
binding.backAction.setOnClickListener { finish() }
84+
worker.execute {
85+
SaicNavGuidance.connect(applicationContext)
86+
loadRate()
87+
}
88+
}
89+
90+
override fun onStart() {
91+
super.onStart()
92+
bound = bindService(
93+
Intent(this, TripRecordingService::class.java),
94+
connection,
95+
Context.BIND_AUTO_CREATE,
96+
)
97+
refresh = worker.scheduleWithFixedDelay(::recompute, 0L, REFRESH_SECONDS, TimeUnit.SECONDS)
98+
}
99+
100+
override fun onStop() {
101+
refresh?.cancel(false)
102+
refresh = null
103+
recorder?.clearListener(this)
104+
recorder = null
105+
if (bound) unbindService(connection)
106+
bound = false
107+
snapshot = null
108+
super.onStop()
109+
}
110+
111+
override fun onDestroy() {
112+
worker.shutdownNow()
113+
super.onDestroy()
114+
}
115+
116+
/**
117+
* The rate this driver has actually spent, when enough trips can be vouched for.
118+
*
119+
* Trips recorded before the speed conversion was fixed are 3.6 times too long and are
120+
* refused by the estimator, so a fresh install has no history and falls back to the
121+
* vehicle's own range — which is the point of having two sources.
122+
*/
123+
private fun loadRate() {
124+
val generation = FirmwareInfo.getGeneration()
125+
val trips = runCatching {
126+
EnergyTripHistoryStore(File(filesDir, HISTORY_FILE)).readSummaries()
127+
}.getOrDefault(emptyList())
128+
rate = SocRateEstimator.fromTrips(trips, generation)
129+
ratePending = false
130+
}
131+
132+
private fun recompute() {
133+
val latest = snapshot
134+
val generation = FirmwareInfo.getGeneration()
135+
val guidance = runCatching { SaicNavGuidance.readNow() }.getOrNull()
136+
val remainingKm = guidance?.remainingDistanceKm(generation)
137+
val socPercent = latest?.socPercent?.toDouble()
138+
val effective = rate ?: SocRateEstimator.fromVehicleRange(
139+
socPercent,
140+
latest?.rangeKm?.toDouble(),
141+
)
142+
val arrival = ArrivalSocForecast.of(socPercent, remainingKm, effective)
143+
val reach = ArrivalSocForecast.rangeAtRateKm(socPercent, effective)
144+
val state = ViewState(
145+
arrival = arrival,
146+
reachKm = reach,
147+
remainingKm = remainingKm,
148+
minutes = guidance?.remainingMinutes,
149+
road = guidance?.road,
150+
rate = effective,
151+
routeKnown = remainingKm != null,
152+
ratePending = ratePending,
153+
)
154+
runOnUiThread { if (!isFinishing && !isDestroyed) render(state) }
155+
}
156+
157+
private fun render(state: ViewState) {
158+
val arrival = provenance.render(state.arrival, "%.0f %%")
159+
binding.arrivalValue.text = arrival
160+
binding.arrivalValue.contentDescription =
161+
provenance.describe(getString(R.string.arrival_label), state.arrival, arrival)
162+
163+
binding.arrivalStatus.text = when {
164+
!state.routeKnown -> getString(R.string.arrival_no_route)
165+
state.ratePending -> getString(R.string.arrival_rate_loading)
166+
!state.arrival.isAvailable -> getString(R.string.arrival_refused)
167+
else -> getString(
168+
R.string.arrival_route,
169+
String.format(Locale.getDefault(), "%.1f", state.remainingKm ?: 0.0),
170+
state.minutes ?: 0,
171+
state.road.orEmpty(),
172+
)
173+
}
174+
175+
binding.arrivalDetail.visibility = if (state.rate == null) View.GONE else View.VISIBLE
176+
state.rate?.let { rate ->
177+
binding.arrivalDetail.text = getString(
178+
R.string.arrival_detail,
179+
getString(
180+
when (rate.source) {
181+
SocRate.Source.TRIP_HISTORY -> R.string.arrival_source_trips
182+
SocRate.Source.VEHICLE_RANGE -> R.string.arrival_source_range
183+
}
184+
),
185+
rate.sampleCount,
186+
String.format(Locale.getDefault(), "%.3f", rate.percentPerKm),
187+
provenance.render(state.reachKm, "%.0f km"),
188+
)
189+
}
190+
}
191+
192+
private data class ViewState(
193+
val arrival: Provenanced<Double>,
194+
val reachKm: Provenanced<Double>,
195+
val remainingKm: Double?,
196+
val minutes: Int?,
197+
val road: String?,
198+
val rate: SocRate?,
199+
val routeKnown: Boolean,
200+
val ratePending: Boolean,
201+
)
202+
203+
private companion object {
204+
const val HISTORY_FILE = "trips.json"
205+
206+
/** Slow on purpose: a route shortens by metres a second and this is not a speedometer. */
207+
const val REFRESH_SECONDS = 5L
208+
}
209+
}

‎app/src/main/java/com/evsuite/chargepilot/MainActivity.kt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ class MainActivity : AppCompatActivity() {
100100
binding.diagnosticsAction.setOnClickListener {
101101
showDiagnostics()
102102
}
103+
binding.arrivalForecastAction.setOnClickListener {
104+
startActivity(Intent(this, ArrivalForecastActivity::class.java))
105+
}
103106
renderUnavailable()
104107
requestVehiclePermissions()
105108
if (TripRecordingService.isAutomaticDetectionEnabled(this)) {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
THESIS: one banded arrival figure, refused outright rather than shown too wide to act on.
4+
OWN-WORLD: EVSuite tonal panes, one outline, one radius, Roboto readouts, no shadow or motion.
5+
STORY: read the route the car already knows, then the charge it leaves you with, then why.
6+
FIRST VIEWPORT: fixed top context and Back action; the arrival figure owns the centre.
7+
FORM: parked evidence sheet extending the speed comparison; seed key cp041-20260904.
8+
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md.
9+
-->
10+
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
11+
android:layout_width="match_parent"
12+
android:layout_height="match_parent"
13+
android:orientation="vertical">
14+
15+
<LinearLayout
16+
android:layout_width="match_parent"
17+
android:layout_height="88dp"
18+
android:background="@drawable/bg_topbar"
19+
android:gravity="center_vertical"
20+
android:orientation="horizontal"
21+
android:paddingStart="@dimen/spacing_lg"
22+
android:paddingEnd="@dimen/spacing_lg">
23+
24+
<TextView
25+
android:layout_width="0dp"
26+
android:layout_height="wrap_content"
27+
android:layout_weight="1"
28+
android:text="@string/arrival_title"
29+
android:textAppearance="@style/Text.EV.Title" />
30+
31+
<com.google.android.material.button.MaterialButton
32+
android:id="@+id/backAction"
33+
style="@style/Widget.EV.Button.Outlined"
34+
android:layout_width="180dp"
35+
android:layout_height="@dimen/touch_target"
36+
android:text="@string/action_back" />
37+
</LinearLayout>
38+
39+
<ScrollView
40+
android:layout_width="match_parent"
41+
android:layout_height="0dp"
42+
android:layout_weight="1"
43+
android:fillViewport="true">
44+
45+
<LinearLayout
46+
android:layout_width="match_parent"
47+
android:layout_height="wrap_content"
48+
android:gravity="center_horizontal"
49+
android:orientation="vertical"
50+
android:padding="@dimen/spacing_lg">
51+
52+
<TextView
53+
android:id="@+id/arrivalStatus"
54+
android:layout_width="1400dp"
55+
android:layout_height="wrap_content"
56+
android:gravity="center"
57+
android:textAppearance="@style/Text.EV.Body"
58+
android:accessibilityLiveRegion="polite" />
59+
60+
<TextView
61+
android:id="@+id/arrivalValue"
62+
android:layout_width="1400dp"
63+
android:layout_height="wrap_content"
64+
android:layout_marginTop="@dimen/spacing_lg"
65+
android:background="@drawable/bg_card"
66+
android:gravity="center"
67+
android:padding="@dimen/spacing_lg"
68+
android:textAppearance="@style/Text.EV.Title" />
69+
70+
<TextView
71+
android:id="@+id/arrivalDetail"
72+
android:layout_width="1400dp"
73+
android:layout_height="wrap_content"
74+
android:layout_marginTop="@dimen/spacing_lg"
75+
android:background="@drawable/bg_card"
76+
android:lineSpacingExtra="6dp"
77+
android:padding="@dimen/spacing_lg"
78+
android:textAppearance="@style/Text.EV.Body"
79+
android:visibility="gone" />
80+
</LinearLayout>
81+
</ScrollView>
82+
</LinearLayout>

‎app/src/main/res/layout/activity_main.xml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,14 @@
429429
android:layout_weight="1"
430430
android:text="@string/action_trip_history" />
431431
</LinearLayout>
432+
433+
<com.google.android.material.button.MaterialButton
434+
android:id="@+id/arrivalForecastAction"
435+
style="@style/Widget.EV.Button.Outlined"
436+
android:layout_width="match_parent"
437+
android:layout_height="@dimen/touch_target"
438+
android:layout_marginTop="@dimen/spacing_sm"
439+
android:text="@string/action_arrival_forecast" />
432440
</LinearLayout>
433441
</LinearLayout>
434442
</LinearLayout>

‎app/src/main/res/values-fr/strings.xml‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,4 +203,15 @@
203203
<string name="trip_speed_not_recorded">Aucun intervalle exploitable de vitesse n’a été enregistré pour ce trajet.</string>
204204
<string name="trip_value_missing_description">%1$s : indisponible. %2$s</string>
205205
<string name="trip_value_description">%1$s : %2$s</string>
206+
<string name="action_arrival_forecast">Charge à l’arrivée</string>
207+
<string name="arrival_title">Charge à l’arrivée</string>
208+
<string name="arrival_label">Charge à l’arrivée</string>
209+
<string name="arrival_no_route">Aucun itinéraire. Lancez un guidage dans la navigation du véhicule et ceci se remplira.</string>
210+
<string name="arrival_rate_loading">Lecture des trajets enregistrés…</string>
211+
<string name="arrival_refused">Pas assez de preuves pour un chiffre exploitable. Rien n’est affiché plutôt qu’une fourchette large comme la moitié de la batterie.</string>
212+
<string name="arrival_route">%1$s km restants · %2$d min · %3$s</string>
213+
<string name="arrival_detail">Taux depuis %1$s (%2$d trajets) · %3$s %% par km
214+
Cette charge atteint environ %4$s à ce rythme</string>
215+
<string name="arrival_source_trips">vos trajets enregistrés</string>
216+
<string name="arrival_source_range">l’autonomie du véhicule</string>
206217
</resources>

‎app/src/main/res/values/strings.xml‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,4 +207,15 @@
207207
<string name="trip_speed_not_recorded">No usable speed interval was recorded for the trip.</string>
208208
<string name="trip_value_missing_description">%1$s: unavailable. %2$s</string>
209209
<string name="trip_value_description">%1$s: %2$s</string>
210+
<string name="action_arrival_forecast">Arrival charge</string>
211+
<string name="arrival_title">Charge on arrival</string>
212+
<string name="arrival_label">Charge on arrival</string>
213+
<string name="arrival_no_route">No route. Start a guidance in the car’s own navigation and this fills in.</string>
214+
<string name="arrival_rate_loading">Reading the trip record…</string>
215+
<string name="arrival_refused">Not enough evidence for a figure worth acting on. Nothing is shown rather than a band half the pack wide.</string>
216+
<string name="arrival_route">%1$s km remaining · %2$d min · %3$s</string>
217+
<string name="arrival_detail">Rate from %1$s (%2$d trips) · %3$s %% per km
218+
This charge reaches about %4$s at that rate</string>
219+
<string name="arrival_source_trips">your recorded trips</string>
220+
<string name="arrival_source_range">the vehicle’s own range</string>
210221
</resources>

0 commit comments

Comments
 (0)