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:
- If
!weatherModeEnabled, forcecurrentWeatherMode = 0and return. - If existing data is fresh (
now - weatherLastUpdate < WeatherUpdateInterval), just re-runanalyzeWeatherMode()and return. - If
!fieldOffSettled(15000)(field hasn't been off for 75 s), skip the fetch but still analyze whatever stale data exists. - If
now >= nextWeatherUpdate, enqueue anHTTPS_FETCH_WEATHERrequest 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¶
- Every
SENSOR_UPLOAD_INTERVALms:pushSensorSnapshot()adds the closed window to the PSRAM ring.bufferedRecordCountincrements. - Every
BUFFER_UPLOAD_INTERVALms (13 s): gated byfieldOffSettled(10 s)→ 70 s total settle.uploadBufferedRecords()builds JSON ofbufferedRecordCountrecords and enqueuesHTTPS_UPLOAD_PAYLOADonhttpsQueue. Core 0'sexecuteUploadPayload()POSTs to Supabase. On success, the records are popped from the ring (popTailSnapshot()); on failure they stay for retry. - "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_THRESHforcvConsecutiveReadsconsecutive 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, butuploadBufferedRecords()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¶
- OTA pipeline, signature verification, partition rollback → Network & Web System → OTA Update Pipeline
- Cloud upload throttle gates,
httpsQueuesemantics → Network & Web System → HTTPS Background Task - Override-mode entry/exit,
sysIDRunninginteraction with the control loop → Field Control → Override Modes - IMU FIFO drain, error handling, auto-disable threshold → Sensor Systems → LSM6DSOX
- Thermal stress sensor source (
TempToUse), staleness gate → Safeties → T5 Temperature Stale