Skip to content

Analytics and Advanced Features

Features layered on top of the basic regulator: weather-aware charging, alternator lifetime modeling, motion and comfort analytics from the inertial sensor, and the local sensor-history buffer that feeds the optional cloud sync. Each feature is independent — disabling any one of them does not affect the others or the core charging loop.

For how the charging decision itself is made, see Charging Control. For the sensors that feed these features, see Sensors. For how results reach the dashboard, see Telemetry Pipeline.


Defer to Solar — Solar Forecast Charging Holiday

When the solar forecast indicates the panels will cover the next few days, the XREG-010 skips alternator charging entirely, saving engine fuel and alternator wear.

The switch is Setup ▸ Solar ▸ Defer to Solar, off by default. It is independent of the Cloud Features toggle: the forecast comes straight from Open-Meteo rather than through our servers, so it keeps working with Cloud Features off — and that request carries the boat's latitude and longitude to a third party. In Access Point mode the forecast block on the Solar tab (#solar-forecast-block) is grayed out by the .gate-ap rule and an offline note appears above it (.ap-only-row), since no forecast can be fetched without internet; nothing on the tab is hidden — manual position, its clear button, and the array settings stay reachable, because a stale manual position has no other UI escape.

Where the forecast comes from

The fetch worker (executeFetchWeatherData(), run on the networking core) queries Open-Meteo, a free public forecast API that needs no key. It requests the daily shortwave-radiation total for today, tomorrow, and the day after at the boat's current coordinates.

Each day's radiation figure is converted into an expected solar harvest in kilowatt-hours (pKwHrToday, pKwHrTomorrow, pKwHr2days) using two settings: the installed panel nameplate wattage (SolarWatts) and a real-world derating factor (performanceRatio) covering wiring, angle, shading and temperature losses. The ratio can be typed, but by default the regulator learns it from what the panels actually deliver — see Closing the loop below. A change to either setting re-derives the three displayed predictions immediately (weatherRecomputePredicted()) rather than waiting for the next fetch.

The dashboard prints the age of the last successful fetch under the forecast table, fed by TS_WeatherFetch in the timestamp stream. It earns its place because the scheduled refresh only runs with the field off: on a long motoring day the table can be hours stale with nothing else on screen to say so.

The decision — analyzeWeatherMode()

A day counts as a "good solar day" if its expected harvest clears the bar. The bar is the user's threshold (UVThresholdHigh) — or, with Size Threshold from Consumption on and consumption history in the ledger, the consumption predicted for this boat plus a margin (solarConsMarginPct), so "enough sun" means enough for the boat that is actually being run rather than a number typed from a guess. Until history exists the typed threshold stands in, and the Solar tab states in a sentence which bar is in force (sledNeedKwh, sledNeedSource). If at least two of the three forecast days clear the bar, the pause activates (currentWeatherMode = 1). The two-of-three vote prevents a single optimistic forecast day from suppressing charging ahead of what turns out to be a cloudy stretch.

When the pause is active and the feature is enabled (weatherModeEnabled), the per-tick control snapshot (buildTickSnapshot()) clears the charging-enabled flag and the field stays off, with everything downstream (staging, safeties, telemetry) seeing a normal "not charging" state. The weather override is applied last in that snapshot — after the ignition, on/off, BMS and idle-stage gates — so tick.solarForecastPause means a sunny forecast is the only thing holding charging off. That distinction drives a dedicated off-reason (REASON_SOLAR_PAUSE): the dashboard reads resting — solar forecast is strong (Defer to Solar) rather than the generic charging disabled, and classifies it as an idle rest, not a fault.

Releasing a stale forecast

A forecast that can no longer be refreshed must not hold the field down forever. currentWeatherMode is written only in analyzeWeatherMode(), which used to run inside a client-mode-plus-WiFi gate, so losing the network with the pause active latched the alternator off with nothing left to re-evaluate it.

analyzeWeatherMode() now releases the pause once the forecast ages past WeatherUpdateInterval + WeatherStaleGraceMs — 30 minutes of grace on top of the refresh interval, so a merely-due forecast does not drop the pause in the same tick that queues its refetch (which would blip the field on once per interval). The release clears weatherDataValid, prints a console line, and charging resumes normally until the next successful fetch. A forecast never fetched this power cycle (weatherLastUpdate == 0) counts as stale from the start: the predicted-harvest figures are not persisted across a reboot.

Refresh gating — updateWeatherMode()

The refresh logic runs from the main loop on every pass, in every network mode — the decision and the stale release above are local arithmetic that must keep running with no internet. Only the fetch is gated:

  1. If the feature is disabled, the pause is forced off and nothing else happens.
  2. The decision re-runs on the cached forecast, which is also where a too-old forecast is released.
  3. If the forecast is still fresh (within WeatherUpdateInterval), nothing further happens.
  4. Everything past this point reaches the internet, so the function returns immediately unless the device is in client (station) mode with WiFi connected.
  5. Network fetches are deferred until the field has been off and settled for a while (fieldOffSettled()) — the regulator never does discretionary network work while actively charging.
  6. When a refresh is due, a fetch request is queued to the background HTTPS worker; if the queue is full it retries shortly.

There is also a manual Update Weather Now button. It checks for a GPS fix and a reasonably strong WiFi signal, printing a plain-language reason to the console on each failure, but it deliberately does not require the field to be off. The scheduled fetch waits for fieldOffSettled(); a user pressing the button has asked for the forecast now and owns the consequence of a TLS handshake landing mid-charge.

Where the coordinates come from

Position for the forecast follows the firmware's general GPS source hierarchy, resolved every loop pass by resolveSources():

  • Sticky manual override first. If the user has typed coordinates into Setup ▸ Solar (gpsManualActive), those win over every live source and stay in force until explicitly cleared.
  • Otherwise the user-selected position source mode (gpsPositionSource) applies: automatic mode prefers boat instruments (NMEA2000) and falls back to the phone's GPS when the boat feed goes stale; forced-NMEA and forced-phone modes honor the user's choice even when stale. Position and the clock are separate settings — the clock's own source mode (timeSourceMode) has no say over the coordinates used here.

If no position is available at all, the fetch is skipped and an error is reported rather than requesting a forecast for latitude zero, longitude zero.

Closing the loop — the solar ledger

Every forecast is a claim. The ledger checks each one against what the panels did, and checks the boat's consumption against what its own recent history predicted. Two things come out of it: a performance ratio that fits the array on this boat, and a pause bar sized from what the boat really uses. Both are switches on Setup ▸ Solar, on by default; the record itself is on Live Data ▸ Integrations under Solar Energy Ledger. Nothing in the ledger leaves the boat.

One entry per day. At local midnight the day just finished is booked: the harvest the forecast promised for it, the harvest the panels delivered, the consumption that was predicted, and the consumption the boat actually drew. The promised figure is the one that was on the table the evening before — the number the overnight pause decision was acting on — not any of the refreshed forecasts that arrived during the day. Delivered harvest is the Victron charger's panel power integrated over the day, so it needs the VE.Direct link. Consumption is house-load energy: everything the sources put in (alternator, solar) plus what the battery gave up, minus what went back into the battery — which needs the battery shunt. A quantity that cannot be measured on an installation is recorded as unknown, never as a low number.

Learning the performance ratio. A complete day with a forecast and a live VE.Direct link yields one measurement: delivered harvest divided by the forecast at a ratio of 1.0. That day's ratio is blended slowly into the working ratio, so one cloudy day or one unusually bright one barely moves it, while a real change — new shade from a bimini, a cleaned array, a panel that has failed — shows up within a couple of weeks. The learning rate is a setting. Typing a ratio on the Solar tab restarts learning from that value.

Predicting consumption. The prediction for a day is the median of the newest seven complete days within the last month, or the mean while fewer than seven exist. A median ignores the one night the inverter ran until dawn; a mean would carry it for a week.

Days that do not count. A day the regulator was awake for less than about twenty hours is kept on the chart but feeds neither the learned ratio nor the consumption prediction — a partial day would understate both.

Implementation details

Read on only for the internals; the behavior above is the whole story.

  • Each day is a SolarLedgerRec in a 90-day ring in PSRAM (sledRing), persisted to /solarledger.bin; the day in progress is sledLive, persisted to /solarlive.bin so a reboot resumes the day with its midnight baselines instead of losing it. Day boundaries use the same local-midnight rule as the usage analytics (the app's reported UTC offset, usageTzOffsetS).
  • Which forecast slot named a day is resolved from the fetch stamp (weatherFetchEpoch): a fetch made yesterday called it "tomorrow", a fetch the day before called it "day 2"; anything older is no forecast. A forecast that only arrives after midnight (a late boot) fills the day in once, flagged as same-day (SLED_F_SAMEDAY).
  • The record stores the forecast both at the ratio then in force (predHarvKwh, what the chart shows) and at ratio 1.0 (predIrrKwh, irradiance times array watts) — the learning basis, so a ratio that changes after the capture cannot distort the day's measurement. Learning needs a forecast of at least 0.3 kWh at ratio 1.0; the day ratio and the working ratio are both clamped to 0.15–1.20, and the per-day blend weight is capped at one half.
  • Consumption is measurable only with the battery shunt configured and either a VE.Direct link or no solar array entered — an array with no link makes solar an invisible source, and a house-load figure that ignored it would be wrong rather than approximate.
  • solarLedgerService() runs from the main loop once a second under its own timer (ft_solarLedger, visible in the Function Timing table). A normal pass is one clock compare; the midnight roll is a handful of float operations. Every flash write — the ring, the live-day state, and the learned ratio's key-value store entry — waits for the field gate to be physically cut (fieldCutSettled()), and the live-day state is written at most every thirty minutes. The shutdown sequence and the maintenance restart flush explicitly.
  • /solarledger.csv streams the closed days oldest-first plus the day in progress, straight from PSRAM; the dashboard charts are drawn from it. /get?ResetSolarLedger=1 clears the ring and reopens the current day.
  • Today's figures ride the slow status stream (sledPredHarvToday, sledActHarvToday, sledPredConsToday, sledActConsToday, kWh ×100 with −1 for unknown), with the bar in force, the count of complete days and the minutes awake today.

What you see

  • Setup ▸ SolarLearn Performance Ratio, Learning Rate, Size Threshold from Consumption, Consumption Margin, and a sentence under the forecast table naming the bar the vote is using and why.
  • Live Data ▸ Integrations ▸ Solar Energy Ledger — a Today card (predicted and so-far harvest, predicted and so-far consumption, the working ratio, complete days on record, hours awake today), then two day-by-day charts, harvest and consumption: bars are what happened, the line with points is what was predicted the evening before. A 30-day or 90-day window, window totals as a sentence, and Clear Ledger.

Alternator Lifetime Modeling — calculateThermalStress()

Three independent damage accumulators track the three things that actually wear out in an alternator: winding insulation, bearing grease, and brushes. The model runs every ten seconds (THERMAL_UPDATE_INTERVAL) whenever the alternator temperature reading (TempToUse) and RPM pass sanity checks — implausible or missing readings are skipped so a bad sensor can never accumulate phantom damage.

The three wear models

  • Insulation follows the classic chemistry-rate-versus-temperature relationship (Arrhenius equation): insulation life roughly halves for every 18 °F (10 °C) the winding runs above its rating point. The predicted life is clamped to a ceiling so a cold start does not show an implausibly long lifetime.
  • Grease uses the bearing-industry rule of thumb — the same halve-per-18 °F (10 °C) step above a reference baseline — multiplied by a speed factor, since higher shaft RPM churns the grease harder. Lifetimes are normalized to a reference alternator speed, with a floor on RPM to avoid divide-by-zero at standstill.
  • Brushes are dominated by mechanical wear (proportional to RPM) with a mild temperature factor layered on, floored so an implausibly cold reading can't flip the math.

Because only one probe measures the alternator itself, the winding, bearing, and brush temperatures are all currently estimated as the sensed temperature plus a single user-settable offset (WindingTempOffset). This is a deliberate placeholder until real-world data justifies separate per-component offsets.

Damage accumulation and outputs

Each pass converts elapsed time into a fraction of each component's predicted life and adds it to a running total (CumulativeInsulationDamage, CumulativeGreaseDamage, CumulativeBrushDamage, each clamped to the 0–1 range). From these come the dashboard values:

  • Remaining-life percentages (InsulationLifePercent, GreaseLifePercent, BrushLifePercent).
  • PredictedLifeHours — the lifetime of whichever component is wearing fastest at current operating conditions. A hot, fast steady state therefore shows a much shorter predicted life than a cool idle; the number is an instantaneous rate, not a countdown clock.
  • LifeIndicatorColor — a simple green/yellow/red bucket of the predicted-life figure for at-a-glance display.

Persistence

The cumulative damage values persist in flash key-value storage (NVS), written through the change-detecting batch saver (saveNVSDataFull()) and restored at boot by loadNVSData(). The saver only writes values that changed, and fires at field-off edges and shutdown rather than continuously, so flash wear stays negligible. The accumulators are monotonic by design — damage doesn't undo.

This per-component wear model is complementary to the empirical Charging-System Health system, which compares measured output against the alternator's own best-ever performance.


IMU Analytics — Motion, Comfort, and Sea State

updateAccelMetrics() runs every loop pass and processes the sample ring filled by the inertial sensor's FIFO drain (see Sensors → LSM6DSOX). It turns raw acceleration and rotation into boat-level metrics: heel angle, sea-state severity, and estimated seasickness risk.

Real-time outputs

Fused tilt angles (imu_heel_deg, imu_pitch_deg) come from a complementary filter blending accelerometer and gyro. Yaw rate (imu_yaw_rate_dps) is read directly from the gyro, and per-sample vertical and total acceleration magnitudes (imu_vertical_accel_g, imu_total_accel_g) feed the impact detection below.

Rolling-window metrics

Sliding windows summarize recent motion: one-minute range and mean-deviation figures for heel and pitch (imu_heel_change_60s, imu_heel_deviation_60s, and pitch equivalents), plus two-minute peak-deviation versions and a compass-based heading swing (imu_heading_swing_120s) that reports peak-to-peak yaw wander — useful for judging anchor sailing.

Comfort and seasickness estimates

  • imu_msi_score — a motion-sickness index derived from frequency-weighted vertical-acceleration RMS, following the published Lawther & Griffin seasickness research. The weighting emphasizes the low-frequency band where the human inner ear is most sensitive. Zero is flat calm; one hundred is severe.
  • imu_vomit_pct — a power-law estimate of what fraction of people would be sick after a couple of hours at the current motion level, from the same research lineage.
  • imu_anchorage_comfort — a 0–100 heuristic blending roll deviation, the sickness index, and slam events into a single anchorage-comfort score.

Impact and capsize counters

Vertical-acceleration spikes above a user-settable threshold (SLAM_THRESHOLD_G) count as slams — tracked per session (imu_slam_count) and for the lifetime of the device (imu_slam_count_lifetime). Extreme heel or pitch beyond their thresholds (CAPSIZE_THRESHOLD_DEG, PITCHPOLE_THRESHOLD_DEG) increment lifetime capsize and pitchpole counters; these trigger an immediate synchronous flash save (saveNVSDataFull()), because a boat that has just capsized may be about to lose power.

The detection thresholds are user-configurable settings persisted in flash key-value storage (NVS) via settingWrite() like any other setting; the counters themselves persist through the batch saver.

Sea-state binning

Time is bucketed into gentle / moderate / rough / extreme sea-state minutes based on the motion-sickness index, kept separately for moving versus stationary (imu_min_moving_gentle through imu_min_stat_extreme). These minute counters persist across reboots and build a long-term picture of what conditions the boat actually lives in; they are reported on the dashboard through the diagnostics channel (CSV2). The sailing-performance system measures sea state on its own, from pitch standard deviation (see Sailing Performance), and does not read these bins.


Sensor History — Local Ring Buffer and Optional Cloud Sync

The firmware keeps about a month of fixed-cadence sensor history on the device (the long-term plot ring: 4,320 ten-minute records, durable across power-down), independent of any internet connection. With cloud features enabled, the device periodically uploads that history to the project's servers so the owner can see long-term trends from anywhere.

Window aggregation

A window accumulator (SensorWindow, instance currentWindow) is updated every loop pass by updateSensorWindow(): running minimum / maximum / average for voltages, currents, temperatures, and RPM, plus IMU, GPS, and weather summaries. At a fixed cadence (firmware constant SENSOR_UPLOAD_INTERVAL), uploadSensorHistory() closes the window, and resetSensorWindow() re-seeds the extrema sentinels for the next one.

The PSRAM ring — sensorRing[]

Each closed window becomes a snapshot (SensorSnapshot) pushed into a large ring buffer in external RAM (PSRAM) by pushSensorSnapshot() — several days of history with no flash wear at all, following the project-wide rule that big buffers live in PSRAM (see Memory and Persistence). Ring index updates are guarded by a critical section because the upload worker on the other core pops entries concurrently, and an in-flight marker (sensorRingInFlightIndex) prevents a snapshot from being overwritten while it is being transmitted.

Upload path

When cloud features are enabled (CloudFeatures), uploadBufferedRecords() periodically drains the ring: it serializes a batch of snapshots to JSON and hands it to the background HTTPS worker. Uploads are gated the same way as all discretionary network traffic — only after the field has been off and settled for 70 seconds (fieldOffSettled()), and throttled to a modest cadence — except when the user presses the dashboard's Upload Now button (cloudUploadNowBtn), which sets a bypass flag (forceCloudFlushPending) and drains the ring back-to-back. Successful uploads pop entries from the ring tail; failures leave them in place for retry, so nothing is lost to a flaky connection.

Separately from sensor data, a configuration snapshot (buildConfigPayload()) uploads the device's persisted configuration under the same gating — once a day (CONFIG_SNAPSHOT_INTERVAL), and on request after a boot, a vessel-info save, or registration (configSnapshotRequested) — so the cloud holds the device's current configuration. It captures more than the user-editable settings — runtime overrides, test modes, commissioning progress and other lifecycle state ride along as well, so the snapshot doubles as a remote debugging record. Those extra keys are export-only: they appear in the snapshot and in /exportConfig, but a config import never adopts them.

With cloud features disabled, the window accumulator and ring still run — history accumulates locally and is served to the dashboard only.

Surviving shutdown

During the staged ignition-off shutdown, if the post-charging cloud-drain window expires with records still in the ring, dumpSensorRingToLittleFS() writes the remaining snapshots to a flash file (a magic-numbered, versioned binary), and restoreSensorRingFromLittleFS() reloads them on the next boot. A blocking flash write is acceptable at that point because the field is already off and the device is about to sleep. The result: unsent history survives a complete power-down as long as the shutdown sequence reached its final phase.


Cross-references