Skip to content

Advanced Features

Features layered on top of the basic regulator: weather-aware charging, alternator lifetime modeling, motion / comfort analytics, plant-delay characterization (System ID), online tuning scoring, and the cloud-history sync pipeline. Each is independent — disabling any one of them does not affect the others.

OTA updates and the secure firmware bundle pipeline are documented under Network & Web System → OTA Update Pipeline. Cloud upload mechanics (HTTPS queue, throttle gates) are in the same page.


Weather Mode — Open-Meteo Solar Forecast

When sufficient solar generation is predicted, charging is suspended to save engine fuel.

Source

executeFetchWeatherData() runs on Core 0 (HTTPS_FETCH_WEATHER). Hits Open-Meteo:

https://api.open-meteo.com/v1/forecast?latitude=<lat>&longitude=<lon>&daily=shortwave_radiation_sum&timezone=auto

Open-Meteo is free, no API key, global, and returns shortwave radiation in MJ/m²/day for 3 days (today, tomorrow, day-after).

Conversion to expected solar kWh

pKwHrToday    = mjToday    * MJ_TO_KWH_CONVERSION / STC_IRRADIANCE * SolarWatts * performanceRatio;
pKwHrTomorrow = mjTomorrow * MJ_TO_KWH_CONVERSION / STC_IRRADIANCE * SolarWatts * performanceRatio;
pKwHr2days    = mjDay2     * MJ_TO_KWH_CONVERSION / STC_IRRADIANCE * SolarWatts * performanceRatio;

User settings: SolarWatts (panel nameplate W), performanceRatio (typically 0.75–0.85 for real-world losses), UVThresholdHigh (kWh threshold for "good day").

Mode decision — analyzeWeatherMode()

int highUVDays = (pKwHrToday    >= UVThresholdHigh)
               + (pKwHrTomorrow >= UVThresholdHigh)
               + (pKwHr2days    >= UVThresholdHigh);
currentWeatherMode = (highUVDays >= 2) ? 1 : 0;  // 2-of-3 vote

currentWeatherMode == 1 makes buildTickSnapshot() set chargingEnabled = false → alternator off. Two-of-three voting prevents a single optimistic forecast day from suppressing charging on what turns out to be cloudy weather.

Gating — updateWeatherMode()

Called from loop() when WiFi.status() == WL_CONNECTED. Logic:

  1. If !weatherModeEnabled, force currentWeatherMode = 0 and return.
  2. If existing data is fresh (now - weatherLastUpdate < WeatherUpdateInterval), just re-run analyzeWeatherMode() and return.
  3. If !fieldOffSettled(15000) (field hasn't been off for 75 s), skip the fetch but still analyze whatever stale data exists.
  4. If now >= nextWeatherUpdate, enqueue an HTTPS_FETCH_WEATHER request and set the next fetch time.

triggerWeatherUpdate() is the manual-button version: same checks plus an RSSI guard (WiFi.RSSI() >= -76) and MODE_CLIENT check. Default interval is 1 hour; failures back off 2 s.

GPS coordinates come from NMEA2K. With LatitudeNMEA == 0.0 && LongitudeNMEA == 0.0 (no GPS lock), weatherLastError is set and the fetch is skipped.


Alternator Lifetime Modeling — calculateThermalStress()

Three independent damage accumulators tracking the three things that wear out: insulation, bearing grease, and brushes. Runs every THERMAL_UPDATE_INTERVAL (typically 2 s) when TempToUse is valid.

Insulation life — Arrhenius

T_winding_K = (T_winding_F  32) · 5/9 + 273.15;
L_insul = L_REF_INSUL · exp(EA_INSULATION / BOLTZMANN_K · (1/T_winding_K  1/T_REF_K));

Classic Arrhenius equation: insulation life halves for every ~10 °C above the rating point. T_REF_K is the reference temperature; EA_INSULATION is the activation energy; L_REF_INSUL is the rated lifetime at T_REF. The exponential is clamped to ≤ 10,000 h so a cold-start moment doesn't run the predicted life off the dashboard.

Grease life — temperature halves life per 10 °F + RPM factor

L_grease_base = L_REF_GREASE · pow(0.5, (T_bearing_F  158) / 18);
L_grease = L_grease_base · (6000 / max(Alt_RPM, 100));

The base reference is 158 °F (the SKF / NLGI baseline). Higher RPM ages the grease faster — the 6000 / RPM factor normalizes lifetime to a 6000-alt-RPM reference. The 100 RPM floor protects against divide-by-zero.

Brush life — temperature factor + RPM

temp_factor = 1 + 0.0025 · (T_brush_F  150);
L_brush = (L_REF_BRUSH · 6000 / max(Alt_RPM, 100)) / max(temp_factor, 0.1);

Brushes are dominated by RPM (wear) with a mild temperature dependency. The temp_factor floor of 0.1 prevents the divisor going to zero or negative if the brush "temperature" reading is implausibly cold.

Damage accumulation

Each tick:

elapsedHours = elapsedSeconds / 3600;
CumulativeInsulationDamage += elapsedHours / L_insul;
CumulativeGreaseDamage     += elapsedHours / L_grease;
CumulativeBrushDamage      += elapsedHours / L_brush;
// constrain to 0..1

InsulationLifePercent = (1 − damage) × 100, etc. PredictedLifeHours = 1 / max(insul_damage_rate, grease_damage_rate, brush_damage_rate) — the minimum predicted lifetime among the three components, expressed in hours at current operating conditions. So a hot, fast steady-state shows a much lower predicted life than a cool idle.

LifeIndicatorColor: 0 (green) > 5000 h, 1 (yellow) > 1000 h, 2 (red) ≤ 1000 h.

Component-temperature offsets

T_winding_F = T_bearing_F = T_brush_F = TempToUse + WindingTempOffset currently. The model places case temperature, winding temperature, brush temperature, and bearing temperature at the same value as the sensor + a single offset — placeholder until empirical data on the three components diverges. WindingTempOffset is user-settable; future versions will likely add separate offsets for bearing and brush.

Persistence

All three Cumulative*Damage floats are NVS-persisted (storage namespace). The accumulator is monotonic — it never decreases. Resetting requires the factory-reset path. This is intentional: damage doesn't undo.


IMU Analytics — Motion Comfort and Tipping

Driven by updateAccelMetrics() (runs from loop() when accelEnabled == 1). Operates on the imuRingBuffer populated by drainIMUFifo() — see Sensor Systems → LSM6DSOX.

Real-time outputs

Variable Meaning
imu_heel_deg, imu_pitch_deg Complementary-filtered angles (accel + gyro fusion)
imu_yaw_rate_dps Direct from gyro Z
imu_vertical_accel_g, imu_total_accel_g Per-sample magnitudes

Rolling-window metrics

Variable Window Definition
imu_heel_change_60s, imu_pitch_change_60s 60 s Max − min
imu_heel_deviation_60s, imu_pitch_deviation_60s 60 s Mean-absolute deviation from window mean
imu_heel_deviation_120s, imu_pitch_deviation_120s, imu_heading_swing_120s 120 s Peak deviation; heading swing is peak-to-peak from compass

Higher-level analytic outputs

Variable Source Description
imu_msi_score Lawther & Griffin 1987 frequency-weighted vertical-acceleration RMS Motion Sickness Index. 0 = calm, 100 = severe. Uses the 0.5–5 Hz weighting curve where the human vestibular system is most sensitive
imu_vomit_pct L&G power-law approximation Estimated % of population vomiting after 2 h at the current MSI
imu_anchorage_comfort Heuristic — roll deviation + MSI + slam-count weighted 0–100 where 100 = perfectly calm anchorage
imu_slam_count (session) / imu_slam_count_lifetime (NVS-persisted) imu_vertical_accel_g > SLAM_THRESHOLD_G (2.5) events Count of impacts
imu_slam_peak_max Max of imu_vertical_accel_g during a slam event Peak g recorded since reset
imu_capsize_count (NVS) |heel| > CAPSIZE_THRESHOLD_DEG (120) One-shot lifetime counter — survives every reset
imu_pitchpole_count (NVS) |pitch| > PITCHPOLE_THRESHOLD_DEG (70) Same

imu_capsize_count and imu_pitchpole_count are intentionally never user-resettable — they're permanent records of the event so future buyers see the full history.

Sea-state binning

imu_min_moving_gentle / moderate / rough / extreme and imu_min_stat_* track minutes spent in each sea-state bucket per category (moving vs stationary). Bins are defined by imu_msi_score thresholds. Persisted across sessions.

When accelEnabled flips on

On the rising edge, accel_tail and gyro_tail are slammed to their respective _head indices to flush whatever stale samples accumulated in the FIFO while IMU was disabled. Otherwise the first call to drainIMUFifo() would burn through ~2000 stale samples before reaching live data — pointless and CPU-expensive.


Cloud Sensor History

A two-tier buffer that captures 5-min sensor snapshots locally, then uploads them in batches when network conditions are favorable.

Local PSRAM ring — sensorRing[]

SENSOR_RING_SIZE = 1000 entries × ~700 B = ~700 KB PSRAM. At a 5-min cadence (SENSOR_UPLOAD_INTERVAL), that's ~83 hours of local history.

Each SensorSnapshot captures a wide range of values at that instant: voltage/current min/max/avg over the window, RPM histogram, temperature peaks, IMU summary, GPS, weather, etc. pushSensorSnapshot(collectionTime) is called from uploadSensorHistory() at the end of each 5-min window.

Window aggregation — currentWindow / SensorWindow

The 5-min window accumulator. updateSensorWindow() runs every tick from loop() and updates running min/max/avg/peak fields. resetSensorWindow() zeroes the accumulator and re-seeds extrema to their sentinels (-999999 / 999999).

Upload pipeline

  1. Every SENSOR_UPLOAD_INTERVAL ms: pushSensorSnapshot() adds the closed window to the PSRAM ring. bufferedRecordCount increments.
  2. Every BUFFER_UPLOAD_INTERVAL ms (13 s): gated by fieldOffSettled(10 s) → 70 s total settle. uploadBufferedRecords() builds JSON of bufferedRecordCount records and enqueues HTTPS_UPLOAD_PAYLOAD on httpsQueue. Core 0's executeUploadPayload() POSTs to Supabase. On success, the records are popped from the ring (popTailSnapshot()); on failure they stay for retry.
  3. "Upload Cloud Now" button: sets forceCloudFlushPending = true → bypasses both the field-off settle and the 13 s throttle. Records drain back-to-back.

Shutdown phase 4 — disk dump

At ignition-off shutdown phase 4 (30 min after field cut), if the ring is still non-empty: dumpSensorRingToLittleFS() writes everything to /sensor_ring_backup.bin. restoreSensorRingFromLittleFS() on the next boot reloads it. Survives a complete power loss as long as the device had reached phase 4.

Format: magic + version header, then a packed array of SensorSnapshot structs. SENSOR_RING_BACKUP_MAGIC = 0x53524258 ("SRBX"); SENSOR_RING_BACKUP_VER = 1.

Config snapshots — distinct from sensor data

Every CONFIG_SNAPSHOT_INTERVAL (40 min), buildConfigPayload() constructs a JSON dump of every user-settable parameter and pushes it through HTTPS_UPLOAD_CONFIG. Gives the cloud side a way to detect drift between dashboard-shown settings and what the device is actually using. Same gating (fieldOffSettled(10 s), RSSI ≥ -76, isRegistered).


System ID — Plant-Delay Step Test

User-initiated automated test that measures the alternator's response time to a step change in field excitation. Goal: produce numbers (SystemIDDeadTime, SystemIDRiseTime, SystemIDSlope) for tuning the inner current PID.

Phase machine — systemID_tick() in 7_functions.ino

SYSID_OFF (0) → SYSID_STABILIZE (1) → SYSID_BASELINE (2) → SYSID_STEP_UP (3) → SYSID_HOLD (4) → SYSID_STEP_DOWN (5) → SYSID_DECAY (6) → done
Phase What
STABILIZE Closed-loop proportional control toward SYSID_STABILIZE_AMPS = 10 A. 5-sample (5-s) rolling average must be within ±3 A of target. SYSID_STABILIZE_TIMEOUT_MS = 30 s aborts if it can't converge
BASELINE Record pre-step duty / current for InputFilterTC × N ms — establishes the "before"
STEP_UP Apply SystemIDStepAmplitude duty delta. Sample current at high cadence (every CH1 sample). Detect first crossing of half-step, full-step thresholds → SystemIDRiseTime, SystemIDSlope, SystemIDDeadTime
HOLD Wait holdMs for current to settle
STEP_DOWN Reverse the step. Re-capture rise/decay metrics
DECAY Settle before exit

While systemIDActive != 0, the main control loop forces govMode = GOV_BYPASS_SLEW, puts the inner PID in MANUAL, and pins pidOutput = sysIDDutyOut. On exit, the pre-test state (mode, setpoint, cv_I, voltage-control-active) is restored via the g_sysIDResume snapshot — see Field Control → SystemID.

Abort triggers

Any safety-protection event during the test aborts it cleanly: applyImmediateCut() checks systemIDActive and sets systemIDAbortRequested = true + systemIDAbortReason/systemIDAbortPhase. The phase loop sees the flag, logs an "ABORTED — protection fired" message, returns to normal control. The test results from the aborted run are discarded — they can't be trusted if a safety fired mid-step.

What the user sees

A "Plant Delay Step Test" button on the Tuning tab. Below it, three dashboard fields fill in: dead time (ms), rise time (ms), and dV/dt slope. The dashboard also shows the current phase (1..6) as a progress indicator, updating from CSV2 (which fires every 500 ms during sysID, not 5 s, exactly so this stays responsive).


Online Tuning Score Systems

Three independent scoring systems, all keyed to the same square-wave-injection pattern. Each one drops a record into a 50-entry PSRAM ring after the user clicks "Commit." See Field Control → Override Modes for activation logic; this section covers the scoring math.

Inner PID Tuning (TuningMode == 1)

Square-wave between 5 A and 5 + waveAmplitude A on uTargetAmps. Scoring window opens once the slew rate drops below 1 A/s after a toggle, closes 5 s later. ISE/T accumulator:

errorAccum   += pidError² · actualDtSec;
activeTimeSec += actualDtSec;
score = errorAccum / activeTimeSec;

Lower is better. worstErrorA tracks the single-tick worst.

CV Tuning (CVTuningMode == 1)

Square-wave between cvBaseTarget (LOW) and cvBaseTarget + cvWaveAmplitudeV (HIGH) on ChargingVoltageTarget. Asymmetric scoring:

  • HIGH phase: overshoot above target weighted by cvKOvershoot; undershoot during the rise weighted ×1. First 25 mV of overshoot is free (CV_HIGH_DEADBAND_V).
  • LOW phase: one-sided overshoot scored normally; undershoot ramps from 0 → 1 weight over 10 s after a 1 s grace, capped at 1× (CV_LOW_GRACE_SEC, CV_LOW_RAMP_SEC).
  • Settling time accumulates when |IBV − target| ≤ CV_SETTLE_V_THRESH for cvConsecutiveReads consecutive ticks. Half-period penalty if never settled.

Records also include fastOvFires, iExcessFires, loadDumpFires, hardOcFires — protection events that fired during the phase. A "great" score with high fastOvFires is a red flag.

Thermal Tuning

Two-phase test: warmup ramp (controlled current rise) and cooldown decay. Captures the time-to-reach-target-temp, peak overshoot, and settling characteristics for tuning TempPIDKp / TempPIDKi / ThermalLookaheadSec. Implementation in thermalScore_init() / related helpers.

Score-log persistence

All three logs (tuningLog, cvTuningLog, thermalTuningLog) live in PSRAM and persist to LittleFS via saveTuningLog() / saveCVTuningLog() / saveThermalTuningLog(). Loaded at boot. Reset buttons on each tab clear the corresponding log and re-save.

liveScoreBuckets / cvLiveScoreBuckets / thermalLiveScoreBuckets are 4-window-by-60-bucket × 3-set rings showing live score evolution over the last few minutes — the tuning UI's running "scoreboard" between commits.


Cloud Features Master Switch

CloudFeatures == 1 is the master enable for everything that touches the network. When 0:

  • uploadSensorHistory() still saves windows locally to the ring, but uploadBufferedRecords() never runs.
  • buildConfigPayload() / config snapshot uploads don't run.
  • checkTimeSync() doesn't fire.
  • Forced-OTA check doesn't fire at boot.
  • Weather fetches don't run.
  • SSE to local browser is unaffected.
  • Local control loop is unaffected.

CloudFeatures is automatically set to 0 by setupWiFi() when MODE_AP is selected — operational AP mode has no internet, so cloud features are nonsensical there.


Cross-references