Solar Array String-Level Performance Dashboard

This reference application is here to give you ideas and help you get started quickly. It's built on specific hardware that may not match yours, so focus on the sections that fit your use case. If you'd like to talk through your project and whether it's a good fit for Blues, reach out.
Building with an AI assistant? Point it at this accelerator's firmware in firmware/solar_string_monitor/ so it can learn how the device works and help you adapt it to your hardware.
This project is an asset performance optimization reference design that keeps continuous watch on every individual string in a solar array. It catches soiling, shading, and bad modules wherever the array happens to be. You don't need WiFi or anything from site IT, and the existing inverter stays untouched.
1. Project Overview
The problem. Utility-scale and large commercial-and-industrial (C&I) solar installations routinely lose 3–8% of annual production to three mundane, entirely preventable causes. Dust, bird droppings, and pollen soil the panels. A branch grows six inches over winter, or a new HVAC unit goes in on a flat roof, and part of the array ends up shaded. Or a single module in a series string degrades or fails. Each of these leaves a distinctive electrical signature in the per-string DC current and voltage data, so none of the losses are invisible. The trouble is that almost nobody reads that data in real time.
A modern string inverter or combiner box already knows, down to the register, how much DC current and voltage each string is producing. It just has no network path off the array and into the asset manager's dashboard. To get the data, a maintenance tech has to show up on-site with a laptop and a Modbus cable, and that doesn't happen until something breaks badly enough to open a service ticket. Meanwhile, months of partial production loss pile up, and neither the owner nor the O&M (operations and maintenance) contractor sees them until the quarterly energy report shows a yield gap.
This project adds the missing network path. Every five minutes it reads per-string DC voltage and current from a Modbus RTU source, usually a multi-MPPT string inverter where each MPPT input tracks one string. It also reads irradiance from a pyranometer (a calibrated instrument that measures incident solar radiation in watts per square meter, W/m²) and panel temperature from a backsheet probe. From those, it computes a Performance Ratio (PR) for each string on its own. PR is the ratio of a string's actual power to the power you'd expect at the current irradiance and temperature.
Strings that fall below a configurable PR threshold get an immediate alert with a root-cause hypothesis (shading, soiling, or string fault). That hypothesis depends on an independent operating voltage reading for each string, which multi-MPPT inverters implementing the SunSpec Model 160 Multiple MPPT register model provide. §11 covers hardware compatibility and what changes when the monitored device only reports per-string current. When a string's PR recovers above the threshold, the device rearms the alert locally. It clears the internal underperformance flag so a later drop fires a fresh event, but it doesn't send a recovery Note to the Blues Notehub cloud service. Every hour, a summary Note records the per-string window means, plus the array-level irradiance and module temperature that all the strings share as a reference, for trend analysis.
Why Notecard. Solar arrays sit on rooftops, in open fields, and under parking canopies, which are about the worst places for WiFi range and the hardest places to get site IT involved. Nobody is going to install a permanent AP on the ballast tray of a strip-mall rooftop system. A ground mount in a rural field has no building nearby. And at a carport canopy over a retail parking lot, the network belongs to the tenant's coffee franchise, not the solar O&M contractor. Cellular gets around all of that. Notecard Cell+WiFi ships with a prepaid global SIM and registers on the cellular network by itself, so there's no SIM activation form, no per-site IT discussion, and no router credentials to manage.
The WiFi radio is there in the hardware, but this build doesn't use it. A metal
NEMA 4X enclosure blocks much of the WiFi signal, and the firmware explicitly
clears any stored WiFi credentials at first boot with card.wifi. That
guarantees cellular-only operation even on a reused or previously provisioned
Notecard, instead of assuming anything about the device's prior state. For a
portfolio O&M operator managing dozens of customer sites, the biggest win with
cellular-first IoT is that the same firmware and SKU deploy the same way on
site one and site forty-three, with no site-specific network configuration at
all.
New to Blues?
Learn what we do in this 3-minute video that walks you through how we simplify wireless connectivity.
Learn what we do in this 3-minute video that walks you through how we simplify wireless connectivity.
Deployment scenario. A metal NEMA 4X enclosure mounts on or near the combiner box or string inverter and takes power from the array's AC output through a compact AC/DC converter. Three cables leave the enclosure: a shielded RS-485 pair to the combiner's Modbus port, a pair for the pyranometer, and the DS18B20 backsheet temperature probe cable, which runs to a representative panel in the array. You don't modify the inverter, and the plant network never comes into it.
Before You Start — Critical Constraints
Per-string independent voltage required. This firmware reads a [V, I]
register pair per string and classifies root causes by comparing each string's
operating voltage and current to the fleet mean. Multi-MPPT inverters that
implement SunSpec Model 160 Multiple
MPPT expose per-MPPT
voltage and current, and they work perfectly. Traditional string combiners put
multiple strings on a shared DC bus and only expose per-string current, with no
per-string voltage. Against a combiner-only source, the shading and
string_fault root-cause classifications will never fire, because both need a
low per-string voltage. See §11
Limitations for details.
Maximum 4 strings supported. The compile-time constant MAX_STRINGS = 4
fits the STM32L433's 64 KB SRAM budget. Going beyond 4 is untested and means
revalidating the static memory footprint.
Modbus register map is a demo. The firmware reads contiguous
holding-register pairs [V, I, V, I, …] with fixed scaling. Real inverters (SMA
SunnyBoy, Fronius Symo, Huawei SUN2000, and others) each have their own
register maps, addressing conventions, and scaling. In production you'll need a
vendor-specific firmware build or careful manual register validation (see
Modbus first-light in §9).
2. System Architecture
Device-side responsibilities. Every five minutes, the Notecard's ATTN pin
wakes the Notecarrier CX's STM32L433 host from STOP2, and the host reads its
three inputs in turn: per-string DC voltage and current over Modbus RTU,
irradiance from the pyranometer's analog output, and module temperature from
the 1-Wire probe. With those readings it computes a temperature-derated
expected power for each string and turns that into a Performance Ratio it can
check against the alert threshold locally. Either a fresh summary Note goes
into the queue or, if a string has fallen below the threshold, an immediate
alert fires. Then the host goes back to STOP2 (a few µA) until the next ATTN
wake. All of it moves over I²C to the Notecard in the carrier's M.2 slot, so
the host never touches modem AT commands, session management, or raw sockets.
Notecard responsibilities. The Notecard handles everything after that. It
queues Notes in on-device flash, brings up
cellular sessions on the
hub.set outbound
cadence (60 minutes by default), and wakes the radio right away for anything
marked sync:true. Environment
variables
travel the other direction on the same channel, so an O&M operator can retune
thresholds, register addresses, and scaling factors across a whole portfolio of
devices from Notehub without anyone touching firmware.
Notehub responsibilities. The data lands in Notehub. Events arrive over the Internet, Notehub stores every one, and project-level routes forward them to wherever the operator's downstream system needs them. Summaries and alerts go into separate Notefiles, so each stream can go to its own destination at its own urgency without any filter logic. Smart Fleets let a multi-site O&M operator slice the portfolio, with one fleet per customer or one per panel technology (monocrystalline vs bifacial, for example). Each fleet carries its own environment-variable overrides, and the firmware never has to fork.
Routing to the cloud (high level only). Notehub can send data to HTTP, MQTT, AWS, Azure, GCP, Snowflake, and several other destinations. Route setup is specific to each project, and this one doesn't include a downstream endpoint. The Notehub routing docs cover the details.
3. Technical Summary
What you'll have when you're done: A cellular-connected device that reads per-string DC voltage and current from your Modbus inverter or combiner every 5 minutes and computes each string's performance ratio (actual power / temperature-derated expected power). When any string drops below 80% of expected power, it fires an immediate alert to Notehub with a root-cause hypothesis (shading, soiling, or string fault). It also sends an hourly summary Note for trend analysis.
Fastest path to first event (30 minutes):
- Assemble the hardware (Notecarrier CX, Notecard Cell+WiFi, RS-485
transceiver, and sensors) and fit the
ATTN→D6sleep jumper (see §5 Wiring and Assembly). - Set up your project in Notehub and claim the Notecard (§6 steps 1–2).
- Copy the firmware directory and edit
solar_string_monitor.ino, replacingPRODUCT_UIDwith your Notehub project UID. - Build and flash (see Build Instructions below).
- Power up the assembly. On first boot, the Notecard registers with your project on its own.
- Check Notehub. A
solar_alert.qoevent shows up within a minute or so of any sample that trips an alert, and the firstsolar_summary.qoarrives after the first hourly report window closes (12 samples by default).
Build Instructions:
arduino-cli core install STMicroelectronics:stm32
arduino-cli lib install "Blues Wireless Notecard" "STM32duino Low Power" "STM32duino RTC" ModbusMaster OneWire DallasTemperature
arduino-cli compile --fqbn STMicroelectronics:stm32:Blues:pnum=CYGNET,usb=none firmware/solar_string_monitor/solar_string_monitor.ino
arduino-cli upload -p /dev/ttyACM0 --fqbn STMicroelectronics:stm32:Blues:pnum=CYGNET,usb=none firmware/solar_string_monitor/solar_string_monitor.inoThe usb=none board option turns off the USB CDC stack, and it has to be off
for the host to sleep (see §7). firmware/solar_string_monitor/sketch.yaml
sets the same board options, so arduino-cli compile run from that directory
without --fqbn picks them up too.
Here's a sample Note from this device:
{
"file": "solar_alert.qo",
"body": {
"string_id": 3,
"reason": "shading",
"perf_ratio": 0.63,
"actual_w": 2989.8,
"expected_w": 4766.5,
"string_v": 402.4,
"string_a": 7.43,
"irradiance_wm2": 882.1,
"mod_temp_c": 53.4
}
}4. Hardware Requirements
| Part | Qty | Rationale |
|---|---|---|
| Notecarrier CX | 1 | Integrated carrier with an onboard STM32L433 host. It exposes the UART TX/RX and A0–A5 analog inputs this project needs, so you don't need an external MCU. The host sleeps in STOP2 between samples, and the Notecard's ATTN pin wakes it. |
| Notecard Cell+WiFi (MBGLW) (datasheet) | 1 | Cellular connectivity removes any dependence on per-site WiFi or IT. Prepaid SIM included. |
| Taoglas GA.111.101111 LTE Magnetic Mount Antenna, 3 dBi, 1 m SMA | 1 | Omnidirectional LTE/3G/2G antenna covering 698–2700 MHz. Its strong neodymium magnet base mounts directly on the metal NEMA 4X enclosure lid with no extra hardware, and the 1 m RG-174 cable ends in an SMA male connector. The antenna has to sit outside the metal enclosure for reliable LTE Cat-1 bis reception, because internal antennas next to inverter metalwork won't work reliably. |
| SparkFun WRL-18568 SMA to U.FL Cable, 150 mm | 1 | Pigtail that connects the Notecarrier CX U.FL cellular antenna port to the GA.111's SMA male connector inside the enclosure. The GA.111 cable comes in through a weatherproof cable gland, and 150 mm of pigtail is enough to reach from the M.2 slot area to the gland. |
| Blues Mojo | 1 | Coulomb counter on the power rail for checking the sleep/wake power profile on the bench. |
| SparkFun RS-485 Transceiver Breakout (BOB-10124) | 1 | Converts the host UART to a half-duplex differential RS-485 signal. The SP3485 chip runs on a single 3.3V supply and tolerates 5V logic. It connects UART TX/RX and a direction-control GPIO (D9) to the RS-485 A/B bus. |
| Apogee SP-110-SS Self-Powered Pyranometer | 1 | ISO 9060 Class C silicon-cell pyranometer with a 0–400 mV analog output (0.2 mV per W/m²). It's self-powered, so it needs no excitation voltage, and it's designed for solar resource monitoring and PV system performance analysis. |
| Adafruit Waterproof DS18B20 Temperature Sensor (Product 381) | 1 | 1-Wire digital probe in a stainless-steel housing, accurate to ±0.5 °C from −10 °C to +85 °C. It's rated to 125 °C, which covers panel backsheet temperatures in hot climates. Attach it to a representative panel backsheet with thermal adhesive tape. |
| 4.7 kΩ resistor | 1 | 1-Wire pull-up from DS18B20 data line to 3V3. |
| 120 Ω resistor | 2 | RS-485 bus termination at each end of the cable (one at the Notecarrier CX breakout, one at the combiner/inverter). |
| Shielded twisted pair, 22 AWG, RS-485-rated | 1 run | A → A, B → B wire pairs, with the shield bonded to chassis earth at the combiner end only (single-ended bond). If the inverter needs a dedicated COM/signal-ground conductor, add a third wire alongside the A/B pair (see Wiring and Assembly). Length depends on where the enclosure goes. |
| AC/DC supply, 5V/2A (e.g. MeanWell IRM-10-5) | 1 | Derives 5V DC from the array's local AC supply. The IRM-10-5 is encapsulated, rated for wide-range AC input (85–264 VAC), and delivers up to 2A (10W). That leaves enough headroom for the Notecard Cell+WiFi's cellular burst current, the STM32 host, and the RS-485 transceiver all running at once. |
| Metal NEMA 4X enclosure, ~6×4×3″ | 1 | Weather-rated metal housing for outdoor installation near the combiner or on a rooftop equipment pad. The magnetic-base antenna mount (Taoglas GA.111) needs a metal lid. A non-metal enclosure would need a different antenna setup, such as an external bracket or mast mount. |
All Blues hardware ships with a prepaid SIM that includes 500 MB of data and 10 years of service, with no activation fees and no monthly commitment.
5. Wiring and Assembly
⚠️ Electrical safety requires qualified personnel. Solar PV arrays and their combiner/inverter equipment carry hazardous DC voltages (typically 300–1000 V on the string bus) and AC mains. All installation, wiring, and commissioning work must be done by a licensed electrician or qualified PV installer, following:
- Applicable national and local electrical codes (NEC Article 690, IEC 62548, or your jurisdiction's equivalent)
- The inverter and combiner manufacturers' installation instructions
- Lockout/tagout (LOTO) procedures before working near or on energized PV conductors
Don't work on wiring connected to the PV array, combiner, or AC supply until isolation is confirmed.
Inside the NEMA 4X enclosure, everything ties back to the Notecarrier
CX and its dual 16-pin
header. The Notecard Cell+WiFi seats in the M.2 slot and talks to the STM32
host over the onboard I²C bus, so you don't run any data wiring between them.
The one jumper you do add is ATTN→D6, which lets the Notecard wake the host
from sleep. During bench validation, the Mojo sits inline between the 5V supply
and the Notecarrier's +VBAT pad as a power monitor. A Qwiic cable runs from one
of the Mojo's Qwiic ports to the Notecarrier CX Qwiic connector, which gives
the Notecard a data path to read Mojo's coulomb counter. The Mojo subsection
below and §9 walk through the bench readout
procedure.
RS-485 transceiver (SparkFun BOB-10124 → Modbus combiner):
- TX (Notecarrier CX) → DI (BOB-10124): UART transmit from the host to the RS-485 driver input.
- RX (Notecarrier CX) → RO (BOB-10124): RS-485 receiver output back to the host.
- D9 (Notecarrier CX) → DE and /RE tied together (BOB-10124): Direction control, where HIGH = transmit and LOW = receive (half-duplex). Tie DE and /RE to the same net.
- +3V3 (Notecarrier CX) → VCC (BOB-10124): The SP3485 runs on 3.3V, so don't connect it to 5V.
- GND → GND (BOB-10124).
- A (BOB-10124) → combiner/inverter A (+) terminal.
- B (BOB-10124) → combiner/inverter B (−) terminal.
- Cable shield → combiner/inverter chassis earth at the combiner end only. A single-ended shield bond prevents ground loops, so do not ground the shield at both ends.
- RS-485 signal ground (COM). Some inverters need a dedicated third conductor, often labeled C, COM, or GND on the RS-485 port, on top of the cable shield bond. It gives the two RS-485 nodes a common voltage reference. Check the inverter's Modbus wiring diagram. If it shows a COM terminal, run a third wire from the BOB-10124 GND pin to that terminal alongside the A/B pair. Without it, the bus can work over short distances but tends to pick up noise errors on longer cable runs.
- Put a 120 Ω resistor across A/B at the BOB-10124 end of the cable and a second 120 Ω resistor at the combiner's RS-485 port to terminate the RS-485 bus.
Pyranometer (Apogee SP-110-SS → A0):
- SP-110-SS positive (white) wire → A0 (Notecarrier CX).
- SP-110-SS negative (black) wire → GND.
- Mount the pyranometer co-planar with the panels (same tilt and azimuth) within 2–3 meters of the array, away from anything that could shade it and out of the panels' shadow.
Module temperature probe (Adafruit DS18B20 → D5):
- DS18B20 data (yellow) → D5 (Notecarrier CX).
- DS18B20 power (red) → +3V3 (Notecarrier CX). The DS18B20 can run on parasitic power from the data line, but an external 3V3 supply removes the timing sensitivity.
- DS18B20 ground (black) → GND.
- Put a 4.7 kΩ resistor from D5 to +3V3. This 1-Wire pull-up is mandatory.
- Attach the probe tip to the backsheet of a representative module with thermal adhesive tape, mid-string, away from frame edges and bypass diodes.
Cellular antenna (Taoglas GA.111 + SparkFun WRL-18568):
- Seat the WRL-18568 U.FL end onto the Notecarrier CX U.FL cellular antenna port.
- Pass the GA.111's 1 m cable through a weatherproof cable gland in the enclosure wall, then screw the GA.111 SMA male connector onto the WRL-18568 SMA female end inside the enclosure.
- Set the GA.111 magnetic base on the outside of the metal enclosure lid, away from the inverter's switching power supply and transformer. Aim for at least 30 cm of separation. The neodymium magnet holds the antenna securely on the metal surface with no extra hardware.
- Don't run the antenna coax parallel to AC power cables inside the enclosure. If they have to share a path, cross the AC conductors at 90°.
ATTN-to-D6 jumper (host sleep):
Fit a short jumper between the ATTN and D6 pins on the Notecarrier CX
header, which is the same 16-pin header that holds D5 and D9. D5 is the
usual choice for this jumper, but here it carries the DS18B20 and D9 carries
the RS-485 direction pin, so the firmware uses D6 through CX_ATTN_PIN. The
Notecard pulls ATTN low while the host sleeps and raises it when the next
sample is due, and the rising edge on D6 wakes the host from STOP2. Leave the
Notecarrier CX EN pin unconnected. On the CX it enables the shared 3.3 V
VIO rail (host, Qwiic, 3V3_OUT, and Notecard I/O), so driving it from
ATTN would brown out the whole board instead of putting the host to sleep.
Putting a Host to Sleep Between Sensor
Readings
covers the full pattern.
Power chain:
- AC mains → MeanWell IRM-10-5 (5V/2A output) → Mojo BAT input → Mojo LOAD output → Notecarrier CX +VBAT pad.
Mojo data connection (bench validation only):
- The power path above already puts Mojo inline on the 5V rail, so you don't need any extra power wiring.
- Mojo Qwiic port → Notecarrier CX Qwiic connector (standard 100 mm
Qwiic cable). The Notecard reads Mojo's coulomb counter / power monitor over
this Qwiic connection and exposes live power-consumption data through
card.power. You'll use this data path to validate the sleep/wake power profile on the bench. - Reading Mojo data. Power the assembly only through +VBAT, with the USB
cable to the Notecarrier disconnected (§9 explains why USB has to be absent).
Then open the In-Browser Terminal and send
{"req":"card.power"}to query Mojo live. To turn on periodic energy logging to Notehub, send{"req":"card.power","minutes":60}. The Notecard then writes a power-consumption snapshot to_log.qoat that interval, and you can export those from Notehub as CSV for full-session energy analysis. The application firmware doesn't callcard.poweron each sample cycle. Mojo measurements are bench instrumentation only (see §11 Limitations). - Before field deployment: remove the Qwiic cable. Mojo itself stays on the bench, so the only power path in the enclosure is IRM-10-5 → Notecarrier CX +VBAT.
6. Notehub Setup
-
Create a project. Sign up at notehub.io and create a project. Copy the ProductUID and paste it into
firmware/solar_string_monitor/solar_string_monitor.inoasPRODUCT_UID. -
Claim the Notecard. Power up the unit, and the Notecard joins your project on its first cellular session.
-
Create a Fleet per site. Fleets group devices for shared configuration and routing. One fleet per installation makes sense here, since every device on a site reads the same inverter model with the same register map and the same string rating. Smart Fleets can move a device into a different fleet automatically (a replacement unit with a different panel model, for example) without re-flashing.
-
Set environment variables. Every variable below is optional, and the firmware default applies to anything you don't set. A value set in Notehub overrides the compile-time default on the device's next inbound sync, so operators can retune thresholds and register addresses across a whole fleet without touching the firmware.
Variable Default Purpose sample_interval_sec300Seconds between Modbus + sensor samples. report_interval_min60Minutes between summary Notes. modbus_slave_id1Modbus server (slave) address of the string combiner or inverter. modbus_baud9600RS-485 bus baud rate. Must match the inverter configuration. Common values are 9600 and 19200. Use modbus_parityandmodbus_stop_bitsfor the rest of the serial framing.modbus_parity"none"RS-485 framing parity: "none","even", or"odd". Must match the inverter's Modbus port configuration. The default"none"gives 8N1 or 8N2, depending onmodbus_stop_bits. Changes take effect on the next device wake, with no firmware reflash.modbus_stop_bits1RS-485 stop bits per character: 1or2. Most Modbus RTU devices use 1 stop bit, but some older meters and energy analyzers need 2. Changes take effect on the next device wake.n_strings4Number of strings to poll (1–4). A single-string deployment ( n_strings = 1) still fires PR-threshold alerts, but the root-cause hypothesis always comes out asdegraded, because shading/soiling/string_fault classification needs at least two peer strings to compare.reg_base1000-based holding-register address of string 1's voltage register. The firmware reads 2×n_stringscontiguous registers: [V1, I1, V2, I2, …].string_v_scale_x10010Voltage scaling factor × 100. Raw register value × (this / 100) = volts. Default 10 → 0.10 V/count. string_a_scale_x100010Current scaling factor × 1000. Raw register value × (this / 1000) = amps. Default 10 → 0.010 A/count. string_stc_w6000Rated power per string at STC (W), used to compute expected power. The 6000 W default represents a 15-module × 400 W C&I string (~600 V, ~10 A at STC). Update it to match the actual installed string rating before or right after commissioning. perf_thresh_pct80Alert when PR < this value / 100. Default 80 → alert when PR < 0.80. irradiance_min_wm2100Minimum irradiance (W/m²) before performance gets evaluated. It suppresses false alerts at dawn, at dusk, and under overcast skies, when noise dominates. temp_coeff_per10000-35Module power temperature coefficient × 10 000. Default −35 → −0.0035 /°C (typical for mono-PERC silicon). Adjust it to the value on the actual module datasheet. alert_cooldown_sec1800Minimum seconds between repeat alerts for the same string (default 30 minutes). The firmware converts it to a sample count at runtime, so the wall-clock window stays the same even if sample_interval_secchanges.pyranometer_mv_per_wm2_x1000200Pyranometer sensitivity × 1000 in mV/(W/m²). Default 200 → 0.200 mV/(W/m²) (Apogee SP-110-SS nominal). Replace it with the value from the unit's calibration certificate to correct for unit-to-unit variation. Register-map Note. The
reg_baseand scaling defaults are only examples for a demo register layout. The firmware reads contiguous pairs[V1, I1, V2, I2, …], one voltage and one current register per string, and it needs the monitored device to provide an independent operating voltage for each string. Multi-MPPT inverters implementing SunSpec Model 160 Multiple MPPT expose per-MPPT voltage (DCV) and current (DCA) in this form, but traditional string combiners usually don't. For production, look up the actual register addresses, addressing convention (0-based wire-level vs. 1-based / Modicon "40001" notation), scaling, and signedness in the inverter's Modbus communication manual. See Limitations.Timing constraint. For each summary window to cover exactly the configured report period,
report_interval_min × 60should be an integer multiple ofsample_interval_sec. The defaults work: 60 minutes and 300 seconds give 3600 / 300 = 12 exactly. If the numbers don't divide evenly, the firmware uses ceiling division for the window length, so the window runs one sample long and covers at least the full configured period instead of falling short. Then_samplesfield in each summary Note shows the actual sample count. With a non-divisible configuration, it'll be⌈report_interval_min × 60 / sample_interval_sec⌉instead of the exact quotient. -
Configure routes. Add one route for
solar_alert.qo(real-time delivery to an O&M on-call system or CMMS) and one forsolar_summary.qo(batched delivery to a long-term analytics store). With the two Notefiles split at the source, each can go to its own destination at its own urgency, and the routes don't need filtering logic.
7. Firmware Design
The firmware is split between a main sketch,
firmware/solar_string_monitor/solar_string_monitor.ino,
and a pair of helper files for the sensor and Notecard data path,
firmware/solar_string_monitor/solar_string_monitor_helpers.cpp
and
firmware/solar_string_monitor/solar_string_monitor_helpers.h.
There's also
firmware/solar_string_monitor/cx_sleep.h,
which holds the host-sleep mechanics (card.attn request, STOP2 entry, ATTN
wake). Splitting things up keeps the orchestration in .ino readable and puts
the Modbus, sensor, template, and sleep logic in their own files.
Dependencies (install them from the Arduino Library Manager or with
arduino-cli lib install):
- Arduino core for STM32 (
stm32duino/Arduino_Core_STM32), which supports the Notecarrier CX's STM32L433 host. - Blues Wireless Notecard
(
note-arduino), the Notecard library for Arduino. - ModbusMaster by 4-20ma, a
portable Modbus RTU client that works with any Arduino
Stream-compatible serial port, includingSerial1on the STM32L433. - OneWire and DallasTemperature, which together drive the DS18B20 over 1-Wire.
- STM32duino Low Power (and
its dependency STM32duino RTC), which provides the STOP2 entry and
ATTN-pin wake used in
cx_sleep.h.
Build settings: The Notecarrier CX's host is the same STM32L433 as the
Blues Cygnet, so select Blues boards → Cygnet (pnum=CYGNET) and set USB
support (if available) to None (usb=none). If the USB CDC stack is
enabled and no USB host is attached (the Notecarrier CX USB-C switch in NC,
or no cable), the STM32's USB wakeup interrupt fires continuously and the host
never stays in STOP2. Debug output goes to the LPUART on the Notecarrier CX
debug jack instead, which an ST-LINK V3 exposes as a virtual COM port (see
Serial Logging With STLINK).
Modules
| Responsibility | Where |
|---|---|
Notecard configuration (hub.set, accelerometer off, template definitions) | setup() in .ino → defineTemplates() in helpers |
| Environment-variable fetch and clamp | fetchEnvVars() in helpers |
| RS-485 direction control | preTransmission() / postTransmission() callbacks in .ino |
| Modbus serial framing (parity / stop-bits) | serialConfigFromEnv() in helpers → Serial1.begin() in loop() |
| Pyranometer read | readIrradiance() in helpers |
| DS18B20 module temperature read | readModuleTemp() in helpers |
| Modbus RTU string poll | readStrings() in helpers |
| Temperature-derated expected power | computeExpected() in helpers |
| Per-string running accumulators | accumulateWindow() in helpers |
| PR evaluation, hypothesis, and alert emission | evaluateAndAlert() in helpers |
| Hourly summary Note | sendSummary() in helpers |
Host sleep (STOP2 until the Notecard raises ATTN on D6) | goToSleep() in .ino → cx_sleep.h: cxSleepBegin, cxSleepUntilAttn |
| Application state (held in RAM across sleep cycles) | AppState (g_state) |
Sensor reading strategy
Pyranometer (Apogee SP-110-SS). The sensor puts 0.2 mV per W/m² straight onto A0. The firmware averages 8 samples to cut ADC noise, converts counts to millivolts at the host's 3.3V / 12-bit reference, and divides by 0.2 to get W/m². At 1000 W/m² (STC) the sensor outputs about 200 mV, which lands at roughly 248 ADC counts. That's enough resolution for threshold comparisons. A 10× non-inverting op-amp amplifier between the sensor and A0 would raise the effective resolution to about 11 bits, and it's recommended for production deployments where precise PR calculation depends on good irradiance measurements.
DS18B20 module temperature. The firmware calls 1-Wire
requestTemperatures() + getTempCByIndex(0) at 11-bit resolution (375
milliseconds conversion time). If the probe is disconnected
(DEVICE_DISCONNECTED_C) or the value is out of range (< −40 °C or > 110 °C),
the firmware returns a −9999 sentinel and emits a rate-limited
temp_probe_fault alert Note (sync:true, at most once per report window).
The DS18B20's 85 °C "power-on sentinel" is not part of the validity checks.
requestTemperatures() blocks for the full conversion time, so the scratchpad
always holds a real measurement before the read. Treating 85 °C as a fault
would wrongly reject genuine backsheet readings near 85 °C, and that's a
realistic value for a hot rooftop array in summer.
For any sample cycle where the probe returns the −9999 sentinel, the firmware
skips PR evaluation and per-string window accumulation, so no made-up
expected-power values reach the accumulators or Notehub. In the summary,
mod_temp_c emits −9999 for any window with no valid temperature readings,
which lets downstream consumers tell a sensor failure apart from a real
near-zero temperature measurement.
Modbus string reads. One readHoldingRegisters(base, 2×n) call pulls every
string's voltage and current registers in a single Modbus transaction, which
uses the bus roughly 8× more efficiently than reading each register separately.
The firmware expects the registers in contiguous pairs [V1, I1, V2, I2, …]
starting at reg_base. Real inverters vary, as
Limitations explains.
Event payload design
The device uses two template-backed Notefiles. Templates store Notes as fixed-length binary records instead of free-form JSON, which cuts per-Note wire size by 3–5×. Over a multi-year deployment on a shared SIM, at 24 summary Notes/day per device, that saving matters.
Alert Note (solar_alert.qo, sync:true, fires when a Performance Ratio
threshold trips):
{
"file": "solar_alert.qo",
"body": {
"string_id": 3,
"reason": "shading",
"perf_ratio": 0.63,
"actual_w": 2989.8,
"expected_w": 4766.5,
"string_v": 402.4,
"string_a": 7.43,
"irradiance_wm2": 882.1,
"mod_temp_c": 53.4
},
"sync": true
}Field reference:
string_id: 1–4 for the string that underperformed, or 0 for a Modbus bus failurereason:"shading"(low V, normal I),"soiling"(normal V, low I),"string_fault"(both low),"degraded"(unclear pattern or single-string mode),"temp_probe_fault","modbus_fail"perf_ratio: actual power / temperature-derated expected poweractual_w: measured string power at that momentexpected_w: the derated STC expectation, calculated as STC power × (irradiance / 1000) × (1 + temp_coeff × (temp − 25))- Rate limiting: repeat alerts for the same string are suppressed for 30
minutes (configurable with
alert_cooldown_sec)
Summary Note (solar_summary.qo, sent hourly):
{
"file": "solar_summary.qo",
"body": {
"irradiance_wm2": 867.2,
"mod_temp_c": 52.1,
"s1_v": 596.4, "s1_a": 8.03, "s1_w": 4789.1, "s1_ew": 4710.8, "s1_pr": 1.02,
"s2_v": 598.1, "s2_a": 8.01, "s2_w": 4790.8, "s2_ew": 4710.8, "s2_pr": 1.02,
"s3_v": 401.3, "s3_a": 7.41, "s3_w": 2973.6, "s3_ew": 4710.8, "s3_pr": 0.63,
"s4_v": 597.8, "s4_a": 8.02, "s4_w": 4794.4, "s4_ew": 4710.8, "s4_pr": 1.02,
"alert_flags": 4,
"n_samples": 12
}
}Field reference:
irradiance_wm2: window mean across all sample cycles (including Modbus failures), so the environmental context is always completemod_temp_c: window mean over the cycles where the DS18B20 returned a valid reading, or−9999if there were nonesN_v,sN_a: voltage and current means over the samples where both the Modbus read and the temperature probe were valid, or−9999if there were none (or the string isn't configured)sN_w: actual power mean (V × I)sN_ew: temperature-derated expected power meansN_pr: the window's performance ratio (sN_w / sN_ew)alert_flags: bitmask (bit 0 = string 1, bit 1 = string 2, and so on). For example,4is binary 0100, meaning string 3 is currently flaggedn_samples: the number of sample cycles in the window, 12 by default (60 minutes ÷ 5 minutes). Ifreport_interval_min × 60isn't evenly divisible bysample_interval_sec, the window runs one sample longer (ceiling division)
Low-power strategy
The host wakes up and runs one complete sample cycle, which usually takes 2–5
seconds of active computation and I²C traffic. Then it calls goToSleep().
That function uses cxSleepUntilAttn() (in cx_sleep.h) to send a
card.attn sleep
request for g_sample_interval_sec seconds, wait for the Notecard to pull
ATTN low, and put the STM32L433 into STOP2, where it draws a few microamps
and keeps its RAM. When the interval is up, the Notecard raises ATTN. The
rising edge on D6 wakes the host, execution picks up right where it slept,
and loop() starts its next cycle. Since the host resumes in place instead of
cold-booting, g_state just stays in RAM. Nothing gets serialized to the Notecard, and setup() runs
only once per power-up. If the sleep request fails or ATTN never goes low
(because the jumper is missing), loop() waits out the interval awake and
tries again on the next cycle, so the sample cadence holds either way.
The Notecard itself idles at ~8–18 µA between cellular syncs. The +3V3 rail
stays up while the host sleeps, though, so the RS-485 transceiver's quiescent
draw sits on top of that floor. If a lower idle floor matters, gate the
transceiver's VCC from a spare GPIO. Sampling and transmission run on separate
cadences on purpose. The device samples every 5 minutes but only connects to
Notehub once an hour, and alerts are the only thing that skip the hourly
transmit timer.
Sync strategy
The Notecard runs in
hub.set periodic mode,
so it wakes the radio on a schedule instead of holding a cellular connection
open. outbound is set to g_report_interval_min (60 minutes by default), and
queued summary Notes go to Notehub roughly once an hour. inbound is fixed at
120 minutes, which means the Notecard checks Notehub for fleet-level
environment
variable
updates every two hours. A threshold or register-map change you publish in
Notehub can take up to two hours to reach a deployed device, so plan for that
lag when you tune a running installation. If you change report_interval_min
through a Notehub environment variable, the firmware re-issues hub.set on the
next wake cycle. That keeps the Notecard's outbound sync cadence aligned with
the new summary period without a firmware reflash.
Alert Notes carry sync:true, which skips the scheduled outbound window
entirely. The Notecard wakes the radio as soon as it gets a sync:true Note,
whatever the outbound timer says. Alerts reach Notehub in roughly 15–60
seconds, and the bulk of the traffic still goes out in the efficient hourly
batch. Connecting every 5 minutes for each summary sample would burn far more
energy than collecting 12 samples and sending them in one session.
Retry and error handling
- The first Notecard transaction in
setup()is the cold-boothub.set. If it fails, it returnsfalse, the firmware logs the error, andlast_hub_outboundstays at 0. The cadence-mismatch check at the top ofloop()(which usesrequestAndResponseand inspects the Notecarderrfield) then re-issueshub.seton the first pass and on every wake until it succeeds, so no device gets silently left unconfigured. readStrings()tries the Modbus read up to 3 times before giving up. On a complete failure it emits amodbus_failalert Note (rate-limited to once per report window) instead of quietly dropping the sample, so the O&M operator can tell "the array is down" apart from "the monitoring device lost Modbus".fetchEnvVars()clamps environment variable values from Notehub to physically reasonable ranges, so a typo in the Notehub UI can't pushg_perf_thresholdto a value that fires on every sample or never fires at all.- Alert de-duplication uses a configurable
alert_cooldown_secwindow (1800 seconds, or 30 minutes, by default). The firmware computes the matching sample count at runtime as⌈alert_cooldown_sec / sample_interval_sec⌉, so the 30-minute wall-clock window holds even ifsample_interval_secchanges through a Notehub env var.
Key code snippet 1: Performance Ratio and root-cause hypothesis
The firmware computes PR for each string, then compares the operating voltage
and current at the inverter/combiner measurement point against the fleet mean
to pick a hypothesis. These are live operating-point readings under load, not
open-circuit or short-circuit values. Low operating voltage with near-normal
operating current points to partial shading. Near-normal voltage with low
operating current points to soiling. When both are low, it's likely a more
serious string fault. A string at or above g_perf_threshold (0.80 by default)
just has its alert_active flag cleared, which rearms its alert.
Comparing strings takes at least two of them. With n_strings = 1 there's no
fleet mean to compare against (the mean is just the single string's own
values), so the firmware skips the hypothesis block and every PR alert fires as
degraded. §11 Limitations has more on
single-string and mixed-geometry deployments.
for (uint8_t i = 0; i < n; i++)
pr[i] = (exp_w > 0.1f) ? (v[i] * a[i]) / exp_w : 0.0f;
for (uint8_t i = 0; i < n; i++) {
if (pr[i] >= g_perf_threshold) { g_state.alert_active[i] = false; continue; }
// Default hypothesis: "degraded" (PR below threshold, cause unclear).
// Comparative shading/soiling/string_fault classification requires at
// least two peer strings; with n == 1 both ratios equal 1.0 and the
// block below is skipped, keeping the reason as "degraded".
const char *reason = "degraded";
if (n >= 2) {
float vr = (v_mean > 0.1f) ? v[i] / v_mean : 1.0f;
float ar = (a_mean > 0.1f) ? a[i] / a_mean : 1.0f;
if (vr < 0.85f && ar >= 0.90f) reason = "shading";
else if (ar < 0.85f && vr >= 0.90f) reason = "soiling";
else if (vr < 0.85f && ar < 0.85f) reason = "string_fault";
}
// ...
}Key code snippet 2: temperature-derated expected power model
The expected power accounts for the gap between the current module temperature and the STC reference of 25 °C. A mono-PERC silicon module at 65 °C (common on a rooftop in summer) loses roughly 14% of its STC rating from temperature alone, and the model corrects for that before comparing actual power to expected. The firmware also floors the temperature factor at 0.40 as a guard.
float temp_factor = 1.0f + g_temp_coeff * (mod_temp_c - 25.0f);
if (temp_factor < 0.40f) temp_factor = 0.40f;
return g_string_stc_w * (irr_wm2 / 1000.0f) * temp_factor;Key code snippet 3: immediate-sync alert
sync:true skips the periodic outbound window. The Notecard wakes the radio
right away, and an alert typically reaches Notehub 15–60 seconds after the
event. sendAlert() returns false if the Notecard doesn't answer or returns
an error, and evaluateAndAlert() only starts the string's cooldown when it
returns true, so a failed alert is tried again on the next sample.
bool sendAlert(uint8_t str_id, const char *reason,
float pr, float v, float a, float irr, float mod_temp) {
J *req = notecard.newRequest("note.add");
JAddStringToObject(req, "file", ALERT_NOTEFILE);
JAddBoolToObject(req, "sync", true);
J *b = JAddObjectToObject(req, "body");
JAddNumberToObject(b, "string_id", str_id);
JAddStringToObject(b, "reason", reason);
JAddNumberToObject(b, "perf_ratio", pr);
JAddNumberToObject(b, "actual_w", v * a);
JAddNumberToObject(b, "expected_w", computeExpected(irr, mod_temp));
// ...
J *rsp = notecard.requestAndResponse(req);
if (!rsp) {
debugSerial.println(F("[app] Alert: no response from Notecard"));
return false;
}
const char *err = JGetString(rsp, "err");
if (err && *err) {
// ...
return false;
}
notecard.deleteResponse(rsp);
// ...
return true;
}Key code snippet 4: sleeping until the Notecard raises ATTN
cxSleepUntilAttn() asks the Notecard to hold ATTN low for
g_sample_interval_sec seconds, confirms the pin has gone low, and puts the
STM32 into STOP2. Because the host stays alive, the card.attn call is a
request whose result gets checked, not a fire-and-forget command. Everything in
g_state (sample count, accumulators, and alert state) stays in RAM, right
where loop() left it when the host resumes. The DS18B20 has D5 and the
RS-485 direction pin has D9, so the sketch defines CX_ATTN_PIN as D6
before including cx_sleep.h.
// D5 is the DS18B20 data line and D9 the RS-485 DE/RE pin, so the ATTN
// jumper lands on D6.
#define CX_ATTN_PIN D6
#include "cx_sleep.h"
// ...
static void goToSleep(uint32_t seconds) {
if (!cxSleepUntilAttn(notecard, seconds, NULL, &debugSerial)) {
// The Notecard didn't take the sleep request, or ATTN never went low
// (check the ATTN -> D6 jumper). Keep the sample cadence and retry.
debugSerial.println(F("[app] ATTN sleep failed — waiting out the interval awake"));
delay(seconds * 1000UL);
}
}
// ...
void setup() {
// ...
// Wake from STOP2 on the ATTN rising edge (ATTN jumpered to D6).
cxSleepBegin();
}
// ...
void loop() {
// ...
// Sleep until the Notecard raises ATTN g_sample_interval_sec seconds from
// now. Execution resumes at the top of loop(); g_state stays in RAM.
goToSleep(g_sample_interval_sec);
}8. Data Flow
Collected every sample_interval_sec (default 5 minutes): DC voltage and
current for each configured string (from Modbus), irradiance in W/m² (from the
pyranometer ADC), and module backsheet temperature in °C (from the DS18B20).
Computed locally: expected power per string (irradiance-normalized and temperature-derated), performance ratio per string, and a root-cause hypothesis for any string below the threshold.
Transmitted:
solar_summary.qo: once perreport_interval_min(24 Notes/day by default), template-encoded, then queued and shipped on the Notecard's outbound cellular sync.irradiance_wm2is the window mean across all sample cycles,mod_temp_cis the mean over cycles with a valid probe reading, and the per-string fields are means over the window's samples where both Modbus and the probe were valid. A string with no such samples in the window emits−9999. You can tunesample_interval_secandreport_interval_minindependently, but for the window to cover exactly the configured report period,report_interval_min × 60has to be an integer multiple ofsample_interval_sec. If it isn't, the firmware rounds the window length up (see the Timing constraint Note in §6).solar_alert.qo: sent immediately withsync:truewhen a threshold trips, with a per-string de-duplication window (30 minutes by default, tunable withalert_cooldown_sec). Amodbus_failalert (string_id=0) fires when the RS-485 bus is unreachable and is rate-limited to once per report window. Thealert_flagsbitmask insolar_summary.qoreflects the last known active state of each string. The firmware clears the flags unconditionally whenever irradiance drops belowirradiance_min_wm2. That happens at the loop level, regardless of whether Modbus or the probe succeeded, so overnight and low-light summaries always report 0 instead of carrying stale daytime fault states forward, even if telemetry happened to drop out at sunset. When irradiance is above the threshold, flags only update on sample cycles where Modbus polling succeeds and the temperature probe is valid. If telemetry drops out for several cycles within a daytime window, the flags carry forward the state from the most recent successful poll.
Routed. Both Notefiles reach Notehub, and the project's routes send them on
to whatever downstream systems they specify. An O&M operator usually wants
solar_alert.qo on their existing on-call channel (SMS, CMMS ticket, Slack)
and solar_summary.qo in a long-term store for energy yield analysis.
Alert triggers:
Single-string Note. When n_strings = 1, comparative root-cause
classification (shading, soiling, string_fault) isn't available, because
there are no peer strings to compare against. Every PR-threshold trip fires as
degraded. That's the correct, documented behavior (see §11
Limitations).
shading: string PR below the threshold, with operating voltage well below the fleet mean but operating current near normal. That's the classic partial-shade signature. A shadow across one or more cells pulls down the operating string voltage, while current in the bypass-diode conduction path is less affected. Requiresn_strings ≥ 2.soiling: string PR below the threshold, with operating current well below the fleet mean but voltage near normal. Uniform soiling (dust, pollen, bird droppings) cuts operating current proportionally across every cell in the string, and voltage drops only slightly. Requiresn_strings ≥ 2.string_fault: both voltage and current well below the fleet mean. This points to a serious fault, such as a broken cell, a failed bypass diode, high series resistance, or a physical disconnection. Requiresn_strings ≥ 2.degraded: PR below the threshold, but the V/I signature doesn't clearly fit any of the patterns above. It's the catch-all for mixed or unclear signatures, and the only hypothesis emitted whenn_strings = 1.temp_probe_fault: the DS18B20 backsheet probe returned an invalid reading (disconnected or out-of-range, i.e. < −40 °C or > 110 °C). It's rate-limited to once per report window. PR evaluation is skipped for the affected sample cycle, andmod_temp_cemits−9999in any summary window with no valid temperature readings.modbus_fail: the Modbus bus was unreachable after 3 attempts, so suspect the whole combiner or the RS-485 cable.
9. Validation and Testing
Expected cadence. On a sunny day in steady state, a healthy array produces
one solar_summary.qo event every report_interval_min minutes (one per hour
by default) and no solar_alert.qo events. If you turn report_interval_min
down in Notehub, the firmware re-issues hub.set on the next wake cycle so the
Notecard's outbound sync cadence keeps up with the new summary interval.
Whenever you change report_interval_min or sample_interval_sec, make sure
report_interval_min × 60 is still an integer multiple of
sample_interval_sec. If it isn't, the firmware rounds the window length up so
the summary window covers at least the full configured period (see the Timing
constraint Note in §6). At night or under heavy overcast, irradiance falls
below irradiance_min_wm2 and PR evaluation is suppressed, but the summary
Note still goes out with the actual (near-zero) measured values.
Modbus first-light. Before you connect to the real inverter, run the
firmware against a Modbus simulator (Modbus Mechanic, ModRSsim2, or any slave
simulator) through a USB-RS-485 adapter, and check that the register reads
match the expected values. Confirm that modbus_baud, modbus_slave_id,
modbus_parity, and modbus_stop_bits match the inverter configuration. You
can set all four through Notehub environment variables
without re-flashing. The default framing is 8N1 (no parity, 1 stop bit), which
matches most Modbus RTU devices. When field commissioning goes wrong, it's
usually a single mismatched parameter.
Alert simulation. The easiest way to force alerts without climbing onto the
roof is to set string_stc_w in the Fleet environment variables to at least
four times the actual string's STC power rating (for example, change the
default 6000 to 24000). The inflated expected-power denominator drives
every configured string's computed PR well below the 80% default threshold.
Once the next inbound sync delivers the change (which can take up to 120
minutes, as the Sync strategy section in §7 explains), all strings fire
degraded alerts on their next sample. Confirm that solar_alert.qo events
show up in Notehub for each configured string, then set string_stc_w back to
the correct rated value.
Don't use perf_thresh_pct values above 100 for this test. The firmware
clamps that variable to a maximum of 100 (effective threshold: PR < 1.00), so
setting 120 does nothing more than 100 would.
Using Mojo to validate the power profile. The table below separates Notecard-only figures from the published datasheet from the whole-assembly behavior you'll see at the +VBAT pad. A +VBAT measurement includes the Notecarrier regulator, the transceiver, and any peripheral quiescent draw on top of the Notecard itself, so it won't match the bare-Notecard datasheet values. Use the Mojo to characterize the real idle and burst figures for your own stack.
| State | Notecard MBGLW (datasheet) | Whole-assembly at +VBAT |
|---|---|---|
| Idle: host in STOP2, Notecard between syncs | ~8–18 µA @ 5V, the MBGLW datasheet idle figure (the range reflects variation across MBGLW hardware revisions) | Notecard-dominated during sleep, with the STM32L433 in STOP2 adding ~1–2 µA. The +3V3 rail stays up, so the RS-485 transceiver's quiescent current (and the DS18B20's ~1 µA standby) sit on top of that, along with the Notecarrier regulator overhead. Measure your own stack with the Mojo to get the actual baseline. |
| Host awake + sampling (~2–5 s burst, every 5 min) | Radio off. The Notecard is busy servicing I²C traffic from the host during this phase. There's no published datasheet figure for this transient state, so measure whole-system current at +VBAT with the Mojo. | The STM32 core, RS-485 driver, pyranometer ADC, and 1-Wire transceiver all draw current during this burst. The whole-assembly +VBAT burst current is host-dominated and will be far above the bare-Notecard sleep floor. Measure it with the Mojo to characterize the actual burst current for your stack. |
| Cellular sync (hourly, ~15–60 s per session) | 100–300 mA average, with peak bursts during radio warm-up, network registration, and the TLS handshake. The MBGLW datasheet and the low-power firmware design guide have characterized per-session energy figures. | Notecard-dominated, since the host is in STOP2 during the sync window |
Wire Mojo inline in the power path (MeanWell 5V
output → Mojo BAT, Mojo LOAD → Notecarrier CX +VBAT), and run a Qwiic cable
from Mojo to the Notecarrier CX Qwiic port so the Notecard can read the coulomb
counter. Disconnect USB from the Notecarrier, which is mandatory for reasons
covered below. Then open the In-Browser Terminal and send
{"req":"card.power"} to query Mojo live. For periodic energy logging, send
{"req":"card.power","minutes":60}. The Notecard writes a power-consumption
snapshot to _log.qo at that interval, and you can export those from Notehub
as CSV for full-session energy analysis.
With the default cadence (5-minute samples, hourly sync), a healthy trace shows
three distinct current levels. The lowest is the idle floor between sample
cycles. It's Notecard-dominated, but the +VBAT reading will sit above the
bare-Notecard ~8–18 µA figure because of the host's ~1–2 µA in STOP2, the
RS-485 transceiver's quiescent draw on the always-on +3V3 rail, and the
Notecarrier regulator overhead, so measure your own stack to establish the
actual baseline. Every five minutes there's a brief 2–5 s active burst while
the host wakes, reads sensors, computes PR, hands off to the Notecard, and goes
back to sleep. Once an hour there's a ~15–60 s cellular excursion for the
outbound sync, in the range the MBGLW
datasheet characterizes. Alert
Notes (sync:true) add an out-of-schedule cellular burst whenever a PR
threshold trips.
Measurement conditions for sleep-current validation. To get close to the
low-idle regime in the MBGLW
datasheet, you have to measure
with USB/VUSB absent. If VUSB is present on the Notecarrier, it holds the
Notecard out of its lowest-power state no matter what the firmware does. Power
the assembly only through the Mojo on the +VBAT pad, with no USB cable attached
to the Notecarrier. The measured +VBAT idle floor will still sit above the
bare-Notecard datasheet figure by the Notecarrier regulator overhead, the
host's ~1–2 µA in STOP2, and the RS-485 transceiver's quiescent draw, and
that's normal. If the idle floor stays above a few mA after you remove USB, the
host isn't entering STOP2. Check the ATTN→D6 jumper and confirm the
firmware was built with USB support set to None (usb=none). The debug console
on the ST-LINK port prints [app] ATTN sleep failed when ATTN never reaches
D6. As a secondary check, make sure the Notecarrier CX DIP switch is set to
HST rather than NC. HST is the correct operating position, and it removes
one possible source of spurious UART activity on the Notecard's diagnostic port
that can get in the way of diagnosing sleep-path issues.
10. Troubleshooting
| Symptom | Likely Cause | Solution |
|---|---|---|
| Notecard doesn't connect to cellular on first power-up. | No ProductUID set, or invalid UID. | Make sure PRODUCT_UID in the sketch is your real Notehub project UID, not the placeholder. Flash again and watch the debug console (the LPUART on the Notecarrier CX debug jack, read through an ST-LINK as described in §7) for error messages. |
No solar_summary.qo events appear in Notehub. | Device isn't claiming to the project, or the Notecard is in the wrong project. | Open the In-Browser Terminal and send {"req":"hub.status"} to check which project the Notecard has claimed. If it's wrong, try {"req":"hub.set","product":"your-real-product-uid"} to re-claim. |
solar_summary.qo events appear, but all strings report −9999 (no data). | Modbus read failing, or the DS18B20 probe is faulted. Per-string fields only accumulate on samples where both are valid. | Look for a modbus_fail or temp_probe_fault alert in Notehub. For Modbus, check that modbus_baud, modbus_slave_id, modbus_parity, and modbus_stop_bits match the inverter configuration, and bench-test with a Modbus simulator (see "Modbus first-light" in §9). |
| Alerts fire constantly, even on a healthy array. | string_stc_w or perf_thresh_pct is set too high. | Check that string_stc_w matches your actual string rating. The default 6000 W is for a 15×400 W example, so adjust it. To test the alert path, simulate with an inflated STC value (§9). |
| No alerts even when strings are visibly shaded or soiled. | perf_thresh_pct is too low, or irradiance is below irradiance_min_wm2. | Check whether irradiance_wm2 in the summary is below your threshold (100 W/m² by default). In low light, PR evaluation is suppressed on purpose to avoid noise-driven false positives. Raise perf_thresh_pct in Notehub to increase sensitivity (e.g., from 80 to 85), and allow up to 120 minutes for the environment variable to sync. |
| Idle current is several mA or higher (should be tens of µA). | USB/VUSB is connected to the Notecarrier, the ATTN→D6 jumper is missing, or the firmware was built with USB CDC enabled. | Disconnect USB from the Notecarrier and power through +VBAT only. Confirm the DIP switch is set to HST (not NC). Check the jumper between ATTN and D6 (§5), and make sure the build used USB support: None (usb=none, §7). With the USB CDC stack enabled and no USB host attached, the host leaves STOP2 within a millisecond. |
temp_probe_fault alerts fire. | DS18B20 disconnected, out of range (< −40 °C or > 110 °C), or probe is shorted. | Check that the DS18B20 data wire goes to D5 with a 4.7 kΩ pull-up to 3V3. Look at the probe tip for corrosion or water damage, and confirm it's attached to a representative panel backsheet. |
| Pyranometer reads seem consistently low or high. | Pyranometer is shaded, or unit calibration differs from nominal. | Make sure the sensor is in full sun, co-planar with the array, and away from anything that could shade it. Every sensor has a calibration tolerance, and the default pyranometer_mv_per_wm2_x1000 = 200 (0.200 mV/W/m²) is the Apogee SP-110-SS nominal value. If you have your unit's calibration certificate, update it with that value. |
11. Limitations and Next Steps
(The rest of the page points here for the details on each constraint. For commissioning steps, see §9 Validation and Testing above.)
This reference design is for the moment a portfolio operator wants real per-string visibility on an array, with soiling, shading, and bad-module signals showing up on an off-site dashboard within a single sample interval. A handful of details were kept simple so you can get from BOM to first event in one afternoon. Each one is listed below with the production hardening it needs.
Simplified for this reference design
Some of these items simplify the signal model, and the rest simplify the hardware.
Per-string voltage is required for full root-cause classification. The
firmware reads a [V, I] register pair for each string and uses the
per-string voltage signature to classify root causes. That signal model comes
from multi-MPPT string inverters where each MPPT input tracks one string, such
as those that implement the SunSpec Model 160 Multiple
MPPT register model. That
model exposes per-MPPT voltage (DCV) and current (DCA) over Modbus RTU.
Traditional string combiner boxes put multiple strings on a shared DC bus and
usually expose only per-string current at the combiner's Modbus port, so
independent per-string voltages are not available in that topology.
Against a combiner-only source, the shading root-cause hypothesis will never
fire. It classifies by operating voltage well below the fleet mean, and every
string reads the same shared bus voltage, so all the per-string voltage ratios
equal 1.0. string_fault also needs a low voltage ratio, so it can't fire
either. In that configuration every PR-threshold trip resolves as soiling or
degraded, depending on the current magnitude. If the monitored device only
provides per-string current and a single bus voltage, document this constraint
for O&M personnel, so they understand that a soiling or degraded alert can
also mean undetected shading or a string fault.
Register-map demo only. The firmware reads N contiguous 16-bit
holding-register pairs [V, I, V, I, …] with fixed scaling from reg_base.
Real string combiners and inverters from SMA, Fronius, Huawei, SolarEdge, and
ABB each publish their own Modbus register maps. Those maps come with
vendor-specific addressing conventions, scaling factors, signed/unsigned
handling, and sometimes 32-bit register pairs for accumulated energy, so
production deployments need a vendor-specific firmware build that implements
the correct map. The environment-variable scaling factors
(string_v_scale_x100, string_a_scale_x1000, reg_base) let one firmware
binary cover multiple sites with the same vendor, but they are not a
substitute for correct implementation of the register map.
All monitored strings must be electrically comparable. A single device
should only monitor peer strings on the same array geometry. That means equal
module count, equal STC rating, and the same orientation, tilt, azimuth, and
irradiance/temperature reference (same MPPT group and microclimate). The PR
model uses string_stc_w as one reference power for every monitored string,
and the V/I root-cause hypothesis (shading/soiling/string_fault) uses the fleet
mean of all monitored strings as its reference. If one device monitors strings
with different orientations, different MPPT groups, or unequal lengths,
normal production differences between groups will be misclassified as
faults. For mixed-geometry arrays, deploy one device per homogeneous string
group.
Maximum 4 strings. MAX_STRINGS = 4 is a compile-time constant, but
utility-scale installations can have 20–30 strings per combiner. Raising
MAX_STRINGS grows sizeof(AppState), the state struct held in RAM across
sleep cycles, and expands the note.template payload definition sent on first
boot. The real limit is the STM32L433's 64 KB SRAM. AppState, the
per-Notefile template JSON construction buffers, and the Modbus response buffer
all have to fit together in static and stack memory. The implementation uses
no dynamic allocation, so the risk is a growing static footprint that leaves
too little headroom for library stack frames, rather than heap fragmentation.
Scaling MAX_STRINGS beyond 4 means revalidating that the combined static
footprint fits in the 64 KB SRAM budget. Nobody has measured a ceiling for this
implementation yet, so validate at each increment before field deployment.
RS-485 interface is POC-grade only. The SparkFun BOB-10124 (SP3485) is a non-isolated breakout board with no surge hardening, fine for bench and prototype use. Solar PV combiner and inverter RS-485 ports are exposed to the outdoor environment and share a ground reference with high-voltage DC bus equipment, so lightning-induced transients and ground-fault currents are a realistic field hazard. Production hardware should replace the BOB-10124 with an isolated RS-485 transceiver (e.g. Analog Devices ADM2587E or similar) that provides galvanic isolation between the host UART and the RS-485 bus. It should also add surge protection on the A/B lines (TVS diodes rated for the bus, IEC 61000-4-5 Class 4 or better). The cable shield must stay single-ended bonded at the combiner/inverter chassis, and outdoor PV installations need proper bonding and grounding per IEC 62548 or NEC Article 690.
Analog pyranometer resolution is good enough for coarse threshold comparisons but limits PR accuracy. Wired straight to A0, the Apogee SP-110-SS gives about 4 W/m² per ADC count at the host's 12-bit / 3.3V reference. A single-supply rail-to-rail op-amp in a 10× non-inverting configuration between the sensor and A0 brings that to about 0.4 W/m² per count, and it's recommended for production deployments. Another option is a pyranometer with Modbus RTU output, such as the IMT Si-RS485TC-T-MB, which also has a Pt1000 module temperature channel. It can replace both the SP-110-SS and the DS18B20 with a single RS-485 device on the same bus.
Single-axis temperature coefficient. The firmware uses one temp_coeff
value for the whole array, but bifacial, thin-film, and heterojunction (HJT)
modules all have noticeably different temperature coefficients from standard
mono-PERC. The default (−0.0035 /°C) is typical for mono-PERC, so set
temp_coeff_per10000 to the value on your actual module datasheet. A
per-string coefficient would need a more complex state structure.
No irradiance-sensor validation. The firmware uses the pyranometer reading as-is, with no cross-check. A bird dropping on the lens or a shadow from a nearby object makes the firmware compute an artificially low expected power, which makes the PR look high and ends up suppressing real underperformance alerts. A second reference pyranometer or a model-based irradiance estimate (from a local weather API through a Notehub route) would catch this.
Heuristic root-cause hypothesis. The shading/soiling/string-fault classification uses simple thresholds on the ratio of each string's V and I to the fleet mean. It will misclassify strings in some multi-fault scenarios (one string with both shading and partial soiling, for example), and the fleet-mean reference breaks down when most of the strings are underperforming at the same time. Treat the hypothesis as a first-pass maintenance triage signal, not a definitive diagnosis.
Single-string deployments support PR alerts only. When n_strings = 1,
the comparative root-cause hypotheses (shading, soiling, string_fault)
aren't available. With no peer strings to compare against, every PR-threshold
trip comes out as degraded. The device still detects when the string's PR
drops below perf_thresh_pct and fires an immediate alert, but the maintenance
team has to work out the root cause from a site inspection or other data. If a
single-string installation needs root-cause classification, a Modbus-output
pyranometer with a reference string (even one extra string of the same
geometry) provides the peer comparison.
Mojo is bench-validation equipment only. During commissioning, the Mojo is
spliced into the 5V supply rail and connected to the Notecarrier CX with a
Qwiic cable so you can check the sleep/wake current profile on the bench. The
Notecard reads Mojo's coulomb counter / power monitor over that Qwiic
connection and exposes the data through card.power, but the application
firmware doesn't call card.power on each sample cycle. The Mojo and its Qwiic
cable are not deployed to the field, so remove both before you close up the
assembly. If fleet-level energy-consumption telemetry would be useful, adding a
runtime mAh field to the summary Note is a straightforward extension: call
card.power in firmware and include the result in solar_summary.qo.
Production Next Steps
Once the basic dashboard is live, these extensions move it toward a portfolio-grade monitoring product.
Vendor-specific Modbus register-map implementations for the major inverter/combiner families (SMA SunnyBoy, Fronius Symo, SolarEdge SetApp, Huawei SUN2000) would use SunSpec Models 101 and 103 (single-phase and three-phase inverters) and Model 160 (Multiple MPPT) as the standard reference.
Increase MAX_STRINGS to at least 12 and confirm that total static memory
(AppState + template JSON buffers + library stack frames) still fits in the
STM32L433's 64 KB SRAM budget.
A Modbus-output pyranometer+temperature combo sensor (e.g. IMT Si-RS485TC-T-MB) folds three separate interfaces into one RS-485 bus.
A shadow-mask calendar would suppress shading alerts during known shadow times, which you can calculate from GPS coordinates and sun-position math, so seasonal early-morning or late-afternoon shadows don't flood the alert channel.
Field-upgradeable firmware through Notecard Outboard Firmware Update lets you push register-map recipe updates to the fleet without a site visit.
Per-device commissioning would record a 7-day baseline PR for each string right after installation, so the alert threshold adapts to site-specific shading and soiling patterns instead of relying on a generic default.
12. Summary
The asset manager used to find out about a soiled or shaded string only when the quarterly energy report showed a yield gap. Now, every five minutes, the device reads what the combiner already knows, puts it in context with the current irradiance and module temperature, and decides whether each string is performing within bounds. It sends a compact summary every hour, plus an alert with a root-cause hypothesis (shading, soiling, or string fault) within about one sample interval (5 minutes by default) of any string falling below the threshold.
The whole footprint is a Notecarrier CX with a Cell+WiFi Notecard, an RS-485 transceiver, an analog pyranometer, and a DS18B20 backsheet probe. None of it touches the existing inverter or asks anything of site IT. Because the cellular-first deployment uses one SKU and one SIM with no per-site configuration, it stays economical across a portfolio of dozens or hundreds of arrays. Instead of a quarterly look-back, the operator gets continuous visibility into the 3–8% of annual production that used to leak away unnoticed.
On This Page
- 1. Project Overview
- Before You Start — Critical Constraints
- 2. System Architecture
- 3. Technical Summary
- 4. Hardware Requirements
- 5. Wiring and Assembly
- 6. Notehub Setup
- 7. Firmware Design
- Modules
- Sensor reading strategy
- Event payload design
- Low-power strategy
- Sync strategy
- Retry and error handling
- Key code snippet 1: Performance Ratio and root-cause hypothesis
- Key code snippet 2: temperature-derated expected power model
- Key code snippet 3: immediate-sync alert
- Key code snippet 4: sleeping until the Notecard raises ATTN
- 8. Data Flow
- 9. Validation and Testing
- 10. Troubleshooting
- 11. Limitations and Next Steps
- 12. Summary