Energía solar para nodos IoT autónomos: paneles, controladores de carga, baterías y dimensionamiento
Energía solar para nodos IoT autónomos: paneles, controladores de carga, baterías y dimensionamiento
En el capítulo 16 armamos el ecosistema completo de Elixir sobre hardware: Nerves para construir firmware que arranca la BEAM directamente sobre Linux, Elixir Circuits para hablar con GPIO, I2C y SPI, Soleil y los BEAM bots para mover motores, y GRiSP para correr Erlang sin sistema operativo. Todo eso da por supuesto algo que hasta ahora nunca discutimos: que el nodo tiene electricidad. Mientras el proyecto vive sobre el escritorio con un cargador USB enchufado, la energía es invisible. En el momento en que ese mismo nodo tiene que quedarse seis meses en un potrero, en un techo, en una boya o en un poste a doscientos metros del tablero más cercano, la energía deja de ser un detalle y pasa a ser la restricción que define el diseño completo: cuánto puede medir, cada cuánto puede transmitir, qué radio puede usar y si puede o no permitirse un Raspberry Pi.
Este capítulo recorre la cadena entera —del fotón que llega al panel hasta el miliamperio que consume el microcontrolador cuando duerme—, explica qué hace realmente cada componente en el medio y termina con un método reproducible de dimensionamiento que puedes aplicar con números propios. La parte de software no es decorativa: vamos a medir el consumo real con un sensor de corriente leído desde Nerves, y a escribir una política de energía en Elixir que degrade el comportamiento del nodo cuando la batería baja, en lugar de dejarlo morir de golpe.
Por qué la energía define la arquitectura del nodo
Un error frecuente al empezar es diseñar primero la funcionalidad y después “resolver” la alimentación. En un sistema off-grid el orden es el inverso. La energía disponible por día es un techo duro, y ese techo se reparte entre todo lo que el nodo quiera hacer.
Piensa en la diferencia entre dos nodos que hacen exactamente la misma tarea —medir humedad de suelo y reportarla— con dos arquitecturas distintas:
- Un Raspberry Pi Zero 2 W con Nerves, encendido las 24 horas, consumiendo alrededor de 0,6 W promedio. Son unos 14,4 Wh al día.
- Un ESP32 que duerme casi todo el tiempo y despierta un instante cada hora. Son unos 0,06 Wh al día.
La diferencia es de más de doscientos a uno. Traducida a hardware: el primero necesita un panel de unos 10 Wp y una batería de plomo de 7 Ah; el segundo funciona con un panel de 1 Wp y una celda de litio de las que trae cualquier power bank. El primero cuesta un orden de magnitud más, pesa varios kilos, necesita caja intemperie con ventilación y anclaje mecánico. El segundo cabe en una caja de derivación.
Cada opción resuelve cosas distintas: el Pi da un sistema operativo completo, sistema de archivos, actualizaciones OTA de Nerves y la BEAM con supervisión real; el ESP32 da autonomía. Lo relevante es que la decisión se toma con el presupuesto energético sobre la mesa, no después.
flowchart TD
A["Requisito funcional<br/>(qué mide, cada cuánto, por qué radio)"] --> B["Presupuesto energético diario<br/>Wh/día"]
B --> C{"¿Cabe en el presupuesto<br/>disponible?"}
C -->|No| E["Reducir: menos frecuencia, dormir más,<br/>radio de menor consumo, MCU en vez de SBC"]
E --> B
C -->|Sí| F["Dimensionar panel, batería y controlador<br/>por el mes de peor irradiación"]
F --> G["Verificar con medición real<br/>del consumo del prototipo"]
G --> H{"¿Coincide con lo calculado?"}
H -->|No| I["Buscar consumos parásitos: LED, reguladores,<br/>pull-ups, quiescente del controlador"]
I --> G
H -->|Sí| J["Instalar"]
El efecto fotovoltaico: de dónde sale la corriente
Un panel solar no almacena nada ni tiene partes móviles. Convierte luz en corriente eléctrica de forma directa, y el mecanismo es el mismo en cualquier panel de silicio.
Una celda fotovoltaica es una unión P-N de silicio, es decir, un diodo grande y plano. En la zona de unión existe un campo eléctrico interno permanente producido por el dopado de las dos capas. Cuando un fotón con suficiente energía atraviesa la celda y es absorbido, cede su energía a un electrón de la banda de valencia y lo promueve a la banda de conducción, dejando atrás un hueco. El campo eléctrico de la unión separa ese par electrón-hueco antes de que se recombinen: empuja los electrones hacia un lado y los huecos hacia el otro. Si los dos lados están conectados por un circuito externo, los electrones circulan por él para volver a recombinarse. Eso es la corriente.
Tres consecuencias prácticas se desprenden de ese mecanismo, y explican casi todo el comportamiento raro de los paneles:
- La corriente es proporcional a la cantidad de fotones, es decir, a la irradiancia. Media sombra significa media corriente.
- El voltaje depende de la física de la unión, no de la luz. Un panel iluminado débilmente sigue entregando casi el mismo voltaje de circuito abierto; lo que cae es la corriente. Por eso un multímetro en modo voltaje “miente”: un panel puede marcar 21 V en un día nublado y no ser capaz de entregar ni 100 mA.
- El voltaje cae cuando la celda se calienta. El silicio caliente tiene más agitación térmica, la barrera de la unión se reduce y el voltaje generado baja. Es el efecto contraintuitivo más importante del diseño solar: un panel rinde menos en un mediodía de enero a 60 °C de temperatura de celda que en una mañana fría y despejada de invierno con la misma irradiancia.
Un panel comercial no es una celda: es un conjunto de celdas en serie. Cada celda de silicio cristalino entrega alrededor de 0,5 a 0,6 V en circuito abierto, sin importar su tamaño. El tamaño determina la corriente. Un panel “de 12 V” tiene típicamente 36 celdas en serie (alrededor de 21 V en vacío), y un panel residencial moderno tiene 60, 72 o 144 medias celdas.
La curva I-V y por qué existe el punto de máxima potencia
Si conectas un panel iluminado a una resistencia variable y barres desde cortocircuito hasta circuito abierto anotando corriente y voltaje, obtienes la curva característica del panel. Sus puntos notables aparecen impresos en la etiqueta trasera de cualquier módulo:
| Parámetro | Símbolo | Qué significa | Ejemplo (panel 100 Wp de 36 celdas) |
|---|---|---|---|
| Voltaje de circuito abierto | Voc | Voltaje sin carga conectada | 21,6 V |
| Corriente de cortocircuito | Isc | Corriente con los bornes unidos | 6,1 A |
| Voltaje en máxima potencia | Vmp | Voltaje del punto óptimo | 17,8 V |
| Corriente en máxima potencia | Imp | Corriente del punto óptimo | 5,62 A |
| Potencia pico | Pmax | Vmp × Imp | 100 W |
| Coeficiente de temperatura de Voc | β | Cuánto cae Voc por grado | -0,30 %/°C |
| Coeficiente de temperatura de Pmax | γ | Cuánta potencia se pierde por grado | -0,38 %/°C |
Todos esos valores están medidos en STC (Standard Test Conditions): irradiancia de 1000 W/m², temperatura de celda de 25 °C y masa de aire AM1.5. Son condiciones de laboratorio que en terreno casi nunca ocurren simultáneamente: cuando hay 1000 W/m² de irradiancia, la celda está muchísimo más caliente que 25 °C. Por eso los fabricantes también publican NOCT (Nominal Operating Cell Temperature), medido a 800 W/m², 20 °C de ambiente y 1 m/s de viento, que refleja mejor la realidad y siempre da una potencia menor.
El punto clave: la potencia entregada es el producto V × I, y ese producto es cero en los dos extremos de la curva (en cortocircuito el voltaje es cero, en circuito abierto la corriente es cero). Entre medio hay un máximo, el punto de máxima potencia o MPP, que ocurre en el codo de la curva. La única forma de que el panel entregue su potencia nominal es que la carga conectada lo obligue a trabajar exactamente en ese punto.
Una batería no hace eso. Una batería de plomo de 12 V descargada impone unos 12,2 V al panel. Si el MPP del panel está en 17,8 V, el panel se ve forzado a trabajar en 12,2 V, entregando aproximadamente los mismos 5,6 A pero a menos voltaje: 68 W en lugar de 100 W. Se pierde casi un tercio, y esa pérdida no se disipa en ningún lado —simplemente no se genera. Ese es el problema exacto que resuelve un controlador MPPT.
Efecto de la temperatura, con números
Supongamos el panel de la tabla en un día de verano con la celda a 60 °C, o sea 35 °C sobre STC:
- Caída de Voc: 21,6 V × (-0,30 %/°C × 35 °C) = 21,6 × (-0,105) = -2,27 V, quedando 19,3 V.
- Caída de potencia: 100 W × (-0,38 %/°C × 35 °C) = -13,3 W, quedando 86,7 W aun con irradiancia plena.
Y al revés, en una mañana de invierno con la celda a -5 °C, o sea 30 °C bajo STC, el Voc sube a 21,6 × 1,09 = 23,5 V. Este caso importa por seguridad: el voltaje máximo de entrada de un controlador de carga debe soportar el Voc en el día más frío del año, no el Voc de la etiqueta.
Sombreado parcial y diodos de bypass
Las celdas de un panel están en serie, así que la corriente que circula por todas es la misma: la de la celda peor iluminada. Una hoja, una deposición de guano o la sombra de un poste sobre una sola celda estrangula la corriente del panel completo. Peor: la celda sombreada, atravesada por la corriente que generan las demás, se comporta como una resistencia y disipa energía en forma de calor, formando un punto caliente que puede dañar el encapsulado.
La solución de fábrica son los diodos de bypass, alojados en la caja de conexiones trasera. Cada uno puentea un subgrupo de celdas (típicamente uno cada 20 celdas, tres por panel). Si un subgrupo se sombrea, el diodo entra en conducción y deja pasar la corriente de los subgrupos sanos por fuera del grupo afectado, sacrificando ese tercio del panel en vez del panel entero. De ahí una regla práctica de instalación: si va a haber sombra parcial, conviene orientar el panel de modo que la sombra corra a lo largo y afecte a un subgrupo completo, en vez de cruzar transversalmente los tres.
Tipos de panel y cuál usar en un nodo IoT
| Tecnología | Eficiencia típica | Comportamiento con luz difusa | Coeficiente de temperatura | Uso recomendado |
|---|---|---|---|---|
| Monocristalino | 19 a 23 % | Bueno | -0,29 a -0,35 %/°C | Opción por defecto para nodos y sistemas pequeños; mejor W por cm² |
| Monocristalino PERC | 20 a 23 % | Bueno | -0,26 a -0,34 %/°C | Igual que el anterior, con algo mejor rendimiento a alta temperatura |
| Policristalino | 15 a 18 % | Bueno | -0,38 a -0,45 %/°C | Ya casi descontinuado; solo si el precio por vatio compensa el área extra |
| Película delgada (CIGS, a-Si) | 10 a 14 % | Muy bueno | -0,20 a -0,25 %/°C | Ambientes con nubosidad constante o difusa; flexible, buen aliado en superficies curvas |
| Bifacial | 19 a 22 % (+5 a 20 % trasero) | Bueno | -0,30 %/°C | Instalaciones sobre suelo claro o techo blanco, montaje elevado |
Para un nodo IoT de pocos vatios hay además una categoría aparte que conviene conocer: los paneles de celdas de interior (a-Si o fotovoltaica orgánica optimizada para luz artificial), que rinden razonablemente con 200 a 1000 lux y sirven para alimentar sensores dentro de edificios. No dan potencia útil bajo el sol pleno comparados con silicio cristalino, pero convierten iluminación de oficina, que es exactamente donde el silicio cristalino falla.
Sobre la nubosidad, conviene ser preciso porque circula mucha mitología: un panel sigue generando con nubes porque la radiación difusa atraviesa la capa nubosa. Con nubosidad ligera se obtiene del orden del 50 al 70 % de la producción despejada; con nubosidad densa de invierno, entre 10 y 40 %; con una tormenta cerrada puede bajar de 10 %. Esto no es un detalle: el dimensionamiento se hace con el peor mes, no con el promedio anual, y en la práctica esos días malos son los que definen el tamaño de la batería.
Irradiancia y horas sol pico
Para dimensionar necesitamos convertir “cuánto sol hay en este lugar” en un número que se pueda multiplicar. Ese número es la hora sol pico (HSP).
La HSP es la energía diaria recibida por metro cuadrado, expresada en unidades de 1000 W/m². Si un sitio recibe 5 kWh/m² al día, decimos que tiene 5 HSP: equivale a que el sol hubiera brillado con exactamente 1000 W/m² durante cinco horas y el resto del día nada. Es una ficción contable, pero es la ficción correcta, porque los paneles están caracterizados justamente a 1000 W/m². Multiplicar los vatios pico del panel por las HSP del sitio da directamente los Wh diarios que el panel podría generar en condiciones ideales.
Valores orientativos de HSP diaria para Chile, con panel fijo e inclinación optimizada anual:
| Zona | HSP promedio anual | HSP peor mes (junio) | HSP mejor mes (enero) |
|---|---|---|---|
| Arica y Parinacota / Antofagasta (Desierto de Atacama) | 6,5 a 7,5 | 5,0 a 5,8 | 7,5 a 8,3 |
| Atacama / Coquimbo | 5,8 a 6,6 | 3,8 a 4,5 | 7,3 a 8,0 |
| Valparaíso / Metropolitana | 5,0 a 5,5 | 2,3 a 2,8 | 7,2 a 7,8 |
| Maule / Ñuble | 4,5 a 5,0 | 1,8 a 2,3 | 7,0 a 7,5 |
| Biobío / Araucanía | 3,8 a 4,4 | 1,4 a 1,9 | 6,5 a 7,0 |
| Los Ríos / Los Lagos | 3,2 a 3,8 | 1,0 a 1,5 | 6,0 a 6,6 |
| Aysén / Magallanes | 2,6 a 3,2 | 0,5 a 1,0 | 5,8 a 6,5 |
Observa la columna del medio: en Puerto Montt en junio, un panel produce en un día lo que en Calama produce en poco más de una hora. Un sistema dimensionado con el promedio anual en el sur de Chile se queda sin energía todos los inviernos, exactamente cuando además las noches son más largas.
Para obtener valores precisos de un sitio concreto existen bases de datos públicas de irradiación, entre ellas el Explorador Solar del Ministerio de Energía de Chile y las bases globales de NASA POWER y PVGIS de la Comisión Europea. Todas entregan irradiación mensual media por coordenadas geográficas, que es exactamente el dato de entrada de la fórmula que veremos más abajo.
Inclinación y orientación
Dos reglas simples que cubren la mayoría de los casos en el hemisferio sur:
- Orientación: el panel mira al norte geográfico (no magnético; en Chile la declinación magnética es significativa, del orden de varios grados al este, así que apuntar con una brújula sin corregir introduce un error apreciable).
- Inclinación: para maximizar el promedio anual, inclinación ≈ latitud del lugar. Para maximizar el peor mes de invierno, que es lo que suele interesar en off-grid, inclinación ≈ latitud + 15°. En Santiago (33,5° S) eso significa unos 48° respecto de la horizontal.
Una inclinación mínima de 10° a 15° es obligatoria aunque estés en el trópico, por una razón que no tiene que ver con el sol: es lo que permite que la lluvia lave el polvo. Un panel horizontal acumula suciedad y en zonas áridas puede perder 20 % o más de producción en pocos meses.
Controladores de carga: PWM contra MPPT
Entre el panel y la batería nunca va un cable directo. Hace falta un controlador de carga, que cumple tres funciones distintas:
- Regular la carga en etapas, para no destruir la batería (bulk, absorción, flotación en plomo; corriente constante y voltaje constante en litio).
- Impedir la descarga inversa de noche, cuando el panel se convierte en una carga que consume de la batería.
- Proteger de sobrevoltaje y sobrecorriente, y en muchos modelos, desconectar las cargas cuando la batería llega al límite inferior (LVD, low voltage disconnect).
Hay dos familias.
Un controlador PWM conecta el panel a la batería a través de un interruptor de estado sólido que abre y cierra rápidamente, regulando la carga mediante el ciclo de trabajo. Cuando el interruptor está cerrado, panel y batería quedan al mismo voltaje: el panel es arrastrado al voltaje de la batería. Es simple, barato y confiable, pero desperdicia la diferencia entre Vmp y el voltaje de batería.
Un controlador MPPT es un convertidor DC-DC (típicamente reductor) que desacopla el lado del panel del lado de la batería. Trabaja el panel en su Vmp, toma esa potencia y la convierte a la corriente y voltaje que la batería necesita. Como la potencia se conserva (menos las pérdidas de conversión, del orden de 3 a 6 %), bajar el voltaje sube la corriente.
Con números, usando el panel de 100 Wp de más arriba cargando una batería de plomo a 12,4 V:
| PWM | MPPT | |
|---|---|---|
| Voltaje impuesto al panel | 12,4 V (el de la batería) | 17,8 V (el Vmp) |
| Corriente del panel | ~5,8 A | 5,62 A |
| Potencia extraída del panel | 71,9 W | 100 W |
| Potencia entregada a la batería | 71,9 W | ~96 W (con 96 % de eficiencia) |
| Corriente de carga a la batería | 5,8 A | 7,7 A |
La ganancia es de aproximadamente 33 % en este escenario, y crece cuando la batería está profundamente descargada o cuando hace frío (Vmp más alto). Cuando la batería está casi llena y el panel caliente, la diferencia se achica bastante.
| Criterio | PWM | MPPT |
|---|---|---|
| Costo | Bajo | 3 a 6 veces mayor |
| Ganancia energética | Referencia | +10 a +30 % típico, más en frío o batería descargada |
| Voltaje de panel admitido | Debe coincidir con el nominal de la batería (panel 12 V con batería 12 V) | Puede aceptar paneles de voltaje mucho mayor |
| Consumo propio (quiescente) | 4 a 10 mA | 10 a 35 mA |
| Complejidad y modos de falla | Baja | Mayor: electrónica de conmutación, firmware |
| Cuándo conviene | Sistemas menores a ~150 W, presupuesto ajustado, panel del mismo voltaje nominal | Sistemas medianos y grandes, climas fríos o nublados, paneles de 60/72 celdas con baterías de 12 o 24 V |
Un detalle contraintuitivo para nodos muy pequeños: el consumo propio del controlador puede ser mayor que el consumo del nodo. Un MPPT que consume 25 mA de forma continua gasta 600 mAh al día, mientras que el ESP32 durmiente del ejemplo inicial gastaba 5 mAh al día. En esa escala el controlador comercial se vuelve absurdo y conviene usar un circuito de carga integrado de pocos microamperios de quiescente, o directamente un cargador solar diseñado para carga baja.
Cómo funciona por dentro un MPPT: perturbar y observar
El MPP no está en un voltaje fijo: se mueve con la irradiancia y con la temperatura, minuto a minuto. El controlador no puede tener el valor tabulado; tiene que buscarlo constantemente. El algoritmo más común es perturbar y observar (P&O), y su lógica es sencilla de seguir:
flowchart TD
A["Medir V y I del panel"] --> B["Calcular P = V × I"]
B --> C{"¿P > P_anterior?"}
C -->|Sí| D{"¿El último cambio<br/>subió el voltaje?"}
C -->|No| E{"¿El último cambio<br/>subió el voltaje?"}
D -->|Sí| F["Seguir subiendo V<br/>(mismo sentido)"]
D -->|No| G["Seguir bajando V<br/>(mismo sentido)"]
E -->|Sí| H["Invertir: bajar V"]
E -->|No| I["Invertir: subir V"]
F --> J["Guardar P como P_anterior<br/>y aplicar nuevo ciclo de trabajo"]
G --> J
H --> J
I --> J
J --> K["Esperar el intervalo<br/>de muestreo"]
K --> A
En palabras: el controlador mueve un poco el punto de operación en alguna dirección y compara la potencia resultante con la anterior. Si mejoró, sigue moviéndose en el mismo sentido; si empeoró, se devuelve. El resultado es que el punto de operación oscila permanentemente en torno al MPP, con una amplitud pequeña. Esa oscilación es una pérdida inherente al método, y de ahí vienen los refinamientos comerciales: paso variable (grande cuando está lejos del máximo, fino cuando está cerca), conductancia incremental, y barridos completos periódicos de la curva para escapar de máximos locales causados por sombreado parcial.
Ese último punto explica una conducta que se observa en controladores buenos: cada cierto rato hacen un barrido completo desde Voc hasta casi cortocircuito. Cuando hay sombra parcial, los diodos de bypass crean varios picos locales de potencia en la curva, y P&O puro se queda pegado en el primero que encuentra. El barrido completo encuentra el pico global.
Baterías: la parte que se desgasta
El panel dura veinticinco años. La batería no, y es la que define el costo de operación del sistema.
Cuatro parámetros gobiernan la elección:
- Capacidad, en Ah a un voltaje nominal, o en Wh (que es lo comparable entre químicas distintas). Ojo con la letra chica: la capacidad de las baterías de plomo se especifica a una tasa de descarga determinada, normalmente C20 (descarga en 20 horas). Si descargas más rápido, obtienes menos capacidad; es el efecto Peukert. Una batería de plomo de 100 Ah C20 entrega bastante menos de 100 Ah si la descargas en 5 horas.
- Profundidad de descarga útil (DoD), el porcentaje que puedes extraer sin destruirla prematuramente. Es la diferencia más grande entre químicas y la que más se subestima.
- Ciclos de vida a esa DoD, y vida calendario (una batería envejece aunque no se use).
- Comportamiento con la temperatura, que en instalaciones a la intemperie es determinante.
| Química | DoD útil recomendada | Ciclos a esa DoD | Vida calendario | Costo relativo por Wh | Carga bajo 0 °C | Notas |
|---|---|---|---|---|---|---|
| Plomo-ácido abierto (inundada) | 50 % | 500 a 1200 | 3 a 5 años | $ | Reducida | Requiere reposición de agua destilada y ventilación por hidrógeno |
| AGM | 50 a 60 % | 400 a 900 | 4 a 6 años | $$ | Reducida | Sellada, sin mantenimiento, tolera montaje en cualquier posición |
| Gel | 55 a 60 % | 700 a 1500 | 5 a 8 años | $$ | Reducida | Tolera mejor la descarga profunda ocasional que AGM |
| OPzS / OPzV (placa tubular) | 70 % | 2000 a 4500 | 12 a 20 años | $$$ | Reducida | Formato estacionario grande, para instalaciones fijas |
| Litio LiFePO₄ | 80 a 100 % | 3000 a 8000 | 10 a 15 años | $$$$ | Prohibida sin calefacción | Requiere BMS; muy estable térmicamente |
| Li-ion NMC (18650, LiPo) | 80 % | 500 a 1500 | 3 a 6 años | $$$ | Prohibida | Alta densidad de energía; exige BMS y trato cuidadoso |
Una comparación que se entiende mejor con dinero y con vatios-hora reales: una batería AGM de 12 V y 100 Ah tiene 1200 Wh nominales, pero solo unos 600 Wh utilizables, y aguantará del orden de 600 ciclos. Una LiFePO₄ de 12 V y 50 Ah tiene 640 Wh nominales y unos 550 Wh utilizables —prácticamente lo mismo— con 4000 ciclos, la mitad del peso y un tercio del volumen. El costo inicial de la de litio es mayor, pero el costo por ciclo útil suele ser considerablemente menor.
Las dos trampas térmicas
Frío. Ninguna batería de litio debe cargarse por debajo de 0 °C. Hacerlo produce deposición metálica de litio sobre el ánodo (lithium plating), que es irreversible, degrada la capacidad y puede crear dendritas que terminen en un cortocircuito interno. Un BMS decente bloquea la carga bajo cero, pero si estás integrando celdas sueltas tú mismo, esa protección tienes que ponerla. Para instalaciones en la zona sur y austral de Chile este punto no es teórico: en invierno hay heladas frecuentes, y una LiFePO₄ sin protección en una caja a la intemperie se degrada en el primer invierno. Las químicas de plomo sí aceptan carga bajo cero, aunque con capacidad reducida, lo que las vuelve una opción razonable justamente donde el litio sufre.
Calor. Una regla empírica muy citada para plomo: la vida útil se reduce a la mitad por cada 10 °C sobre 25 °C de temperatura de operación. Una caja negra cerrada al sol en el norte de Chile puede llegar a 60 °C interiores, lo que reduce la vida de una AGM de cinco años a menos de uno. La batería va a la sombra, ventilada, o enterrada si el proyecto lo permite. Además, el voltaje de carga correcto depende de la temperatura, por lo que los controladores serios traen un sensor externo para compensarlo.
Voltaje contra estado de carga
Estimar el estado de carga (SoC) a partir del voltaje es el método más barato y el que usaremos en el código. Funciona razonablemente en plomo y muy mal en LiFePO₄, y conviene saber por qué.
| SoC | Plomo 12 V en reposo | LiFePO₄ 4 celdas (12,8 V) en reposo |
|---|---|---|
| 100 % | 12,70 V | 13,4 V |
| 75 % | 12,45 V | 13,2 V |
| 50 % | 12,20 V | 13,1 V |
| 25 % | 11,95 V | 13,0 V |
| 0 % | 11,70 V | 10,0 V (caída abrupta) |
La curva del plomo es una rampa suave de un volt de recorrido: el voltaje es un indicador decente. La de LiFePO₄ es una meseta plana; entre 90 % y 20 % de carga apenas hay 200 mV de diferencia, dentro del error de un ADC barato con divisor resistivo. Para litio, si necesitas SoC confiable, hay que contar coulombs (integrar la corriente en el tiempo con un medidor tipo culombímetro) o leer el SoC que reporta el BMS.
Dos condiciones más para que la lectura sirva: hay que medir en reposo, sin consumo significativo ni carga en curso, porque la resistencia interna hace caer el voltaje bajo carga; y hay que dejar pasar un tiempo de relajación tras desconectar, del orden de minutos en litio y de bastante más en plomo.
La cadena de energía completa
Antes de calcular, conviene tener el mapa de por dónde pasa cada julio y dónde se pierde.
flowchart LR
SOL(("Sol")) -->|"fotones<br/>W/m2"| P["Panel FV<br/>Vmp 17,8 V"]
P -->|"CC variable"| DB["Diodo de bloqueo<br/>(evita descarga nocturna)"]
DB --> F1["Fusible lado panel"]
F1 --> CC["Controlador de carga<br/>MPPT o PWM"]
CC -->|"carga regulada"| BMS["BMS / protección<br/>sobre y sub voltaje"]
BMS --> BAT[("Batería<br/>12,8 V")]
BAT --> F2["Fusible lado batería"]
F2 --> REG["Regulador buck<br/>12 V a 3,3 V"]
REG --> MCU["MCU / SBC<br/>Nerves o AtomVM"]
REG --> RAD["Radio<br/>LoRa / WiFi"]
REG --> SEN["Sensores"]
MCU -.->|"I2C"| MON["INA219<br/>medidor V e I"]
MON -.->|"telemetría"| MCU
MCU -.->|"GPIO: corta cargas<br/>no esenciales"| SW["MOSFET de carga"]
SW --> SEN
classDef perdida fill:#8c2f2f,stroke:#e57373,color:#fff
class CC,REG perdida
Los dos bloques marcados en rojo son donde se pierde energía de forma sistemática y evitable en parte:
- El controlador pierde entre 3 y 6 % en conversión si es MPPT, más su consumo quiescente permanente.
- El regulador de 12 V a 3,3 V pierde según su topología. Un regulador lineal (LDO) disipa la diferencia de voltaje como calor: bajando de 12 V a 3,3 V, su eficiencia máxima teórica es 3,3/12 = 27,5 %. Tres cuartas partes de la energía se van en calor. Un convertidor reductor conmutado (buck) alcanza 85 a 95 %. En un sistema solar, usar un LDO para una caída grande es multiplicar por tres el tamaño del panel.
Hay un matiz que suele confundir: el LDO no es siempre peor. Cuando el nodo duerme, lo que importa no es la eficiencia de conversión sino el consumo quiescente del propio regulador. Muchos buck genéricos consumen de 1 a 10 mA solo por estar encendidos, lo que aniquila cualquier estrategia de sueño profundo; algunos LDO de bajo consumo se quedan en 1 a 2 µA. La regla práctica: buck para las fases activas de alta corriente, LDO de muy bajo quiescente para el riel que alimenta al MCU dormido, o un buck moderno con modo de ahorro a carga ligera (PFM), que combina ambos comportamientos.
Medir el consumo real antes de calcular nada
Toda la aritmética de dimensionamiento se apoya en un número: cuántos Wh al día consume el nodo. Ese número no se saca de la hoja de datos del microcontrolador. Se mide, porque en la práctica el consumo lo dominan cosas que no aparecen en el diagrama: un LED de encendido de 5 mA, un regulador con quiescente alto, una resistencia de pull-up mal dimensionada, un módulo que nunca entra en su modo de bajo consumo porque el firmware no lo configura.
El instrumento adecuado y barato es un INA219: un medidor de corriente y voltaje de bus por I2C, que mide la caída sobre una resistencia shunt en el lado alto del circuito. Su dirección I2C por defecto es 0x40 y expone registros de configuración, voltaje de shunt, voltaje de bus, potencia y corriente. Los últimos dos requieren escribir previamente el registro de calibración; los dos primeros funcionan sin calibrar, y con ellos ya se puede calcular todo. Vamos a hacer exactamente eso desde Nerves, apoyándonos en Circuits.I2C del capítulo 16.
defmodule SolarNode.INA219 do
@moduledoc """
Lectura de voltaje de bus y corriente de un INA219 por I2C.
Se usan solo los registros de voltaje de bus y de voltaje de shunt, sin
registro de calibración: la corriente se calcula en software dividiendo la
caída de shunt por el valor de la resistencia. Es menos eficiente que dejar
que el chip lo haga, pero elimina una fuente clásica de errores (calibración
mal calculada) y no depende de suposiciones sobre el rango del PGA.
"""
alias Circuits.I2C
@default_address 0x40
# Registros del INA219
@reg_config 0x00
@reg_shunt_voltage 0x01
@reg_bus_voltage 0x02
# LSB del registro de voltaje de shunt: 10 uV
@shunt_lsb_volts 0.000_010
# LSB del registro de voltaje de bus: 4 mV, valor en los bits 15..3
@bus_lsb_volts 0.004
# Configuración: rango de bus 32 V, PGA /8 (+-320 mV),
# 12 bits con 8 muestras promediadas en ambos ADC, modo continuo shunt+bus.
@config_value 0x3DDF
defstruct [:bus, :address, :shunt_ohms]
@doc """
Abre el bus I2C y configura el chip.
`shunt_ohms` es el valor de la resistencia shunt soldada en la placa.
Las placas comunes traen 0.1 ohm, que permite medir hasta ~3,2 A.
"""
def open(bus_name \\ "i2c-1", opts \\ []) do
address = Keyword.get(opts, :address, @default_address)
shunt_ohms = Keyword.get(opts, :shunt_ohms, 0.1)
with {:ok, bus} <- I2C.open(bus_name),
:ok <- write_register(bus, address, @reg_config, @config_value) do
{:ok, %__MODULE__{bus: bus, address: address, shunt_ohms: shunt_ohms}}
end
end
@doc "Cierra el bus I2C."
def close(%__MODULE__{bus: bus}), do: I2C.close(bus)
@doc "Devuelve voltaje de bus en volts, corriente en amperes y potencia en watts."
def read(%__MODULE__{} = dev) do
with {:ok, volts} <- bus_voltage(dev),
{:ok, amps} <- current(dev) do
{:ok, %{volts: volts, amps: amps, watts: volts * amps}}
end
end
@doc "Voltaje del bus medido, en volts."
def bus_voltage(%__MODULE__{} = dev) do
case read_register(dev, @reg_bus_voltage) do
# Los tres bits bajos son banderas de estado, no valor.
{:ok, raw} -> {:ok, Bitwise.bsr(raw, 3) * @bus_lsb_volts}
error -> error
end
end
@doc "Corriente calculada a partir de la caída sobre el shunt, en amperes."
def current(%__MODULE__{shunt_ohms: shunt_ohms} = dev) do
case read_register(dev, @reg_shunt_voltage) do
{:ok, raw} -> {:ok, to_signed_16(raw) * @shunt_lsb_volts / shunt_ohms}
error -> error
end
end
defp read_register(%__MODULE__{bus: bus, address: address}, register) do
case I2C.write_read(bus, address, <<register>>, 2) do
{:ok, <<value::unsigned-integer-size(16)>>} -> {:ok, value}
{:error, reason} -> {:error, reason}
end
end
defp write_register(bus, address, register, value) do
I2C.write(bus, address, <<register, value::unsigned-integer-size(16)>>)
end
defp to_signed_16(value) when value >= 0x8000, do: value - 0x10000
defp to_signed_16(value), do: value
end
Con eso podemos hacer lo importante: integrar el consumo en el tiempo. Una sola lectura instantánea no sirve, porque el consumo de un nodo es un tren de pulsos: microamperios durante casi todo el ciclo y un pico de cien o doscientos miliamperios cuando transmite. El promedio es lo que interesa, y el promedio se obtiene acumulando.
defmodule SolarNode.Consumo do
@moduledoc """
Acumulador de consumo: muestrea el INA219 a intervalo fijo e integra
corriente y energía para obtener el consumo medio real del nodo.
"""
use GenServer
alias SolarNode.INA219
defstruct [:dev, :intervalo_ms, :inicio_ms, muestras: 0, amp_s: 0.0, joules: 0.0]
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
end
@doc "Devuelve el resumen acumulado desde el inicio."
def resumen(server \\ __MODULE__), do: GenServer.call(server, :resumen)
@impl true
def init(opts) do
intervalo_ms = Keyword.get(opts, :intervalo_ms, 50)
case INA219.open(Keyword.get(opts, :bus, "i2c-1"), opts) do
{:ok, dev} ->
agendar(intervalo_ms)
{:ok,
%__MODULE__{
dev: dev,
intervalo_ms: intervalo_ms,
inicio_ms: System.monotonic_time(:millisecond)
}}
{:error, razon} ->
{:stop, {:ina219_no_disponible, razon}}
end
end
@impl true
def handle_info(:muestrear, %__MODULE__{} = estado) do
dt_s = estado.intervalo_ms / 1000
estado =
case INA219.read(estado.dev) do
{:ok, %{amps: a, watts: w}} ->
%{estado | muestras: estado.muestras + 1, amp_s: estado.amp_s + a * dt_s,
joules: estado.joules + w * dt_s}
{:error, _razon} ->
estado
end
agendar(estado.intervalo_ms)
{:noreply, estado}
end
@impl true
def handle_call(:resumen, _from, %__MODULE__{} = estado) do
transcurrido_s = (System.monotonic_time(:millisecond) - estado.inicio_ms) / 1000
ma = media(estado.amp_s * 1000, transcurrido_s)
mw = media(estado.joules * 1000, transcurrido_s)
# Las proyecciones a 24 h suponen comportamiento estacionario del nodo.
resumen = %{
transcurrido_s: Float.round(transcurrido_s, 1),
muestras: estado.muestras,
corriente_media_ma: ma,
potencia_media_mw: mw,
consumo_diario_mah: ma * 24,
consumo_diario_wh: mw * 24 / 1000
}
{:reply, resumen, estado}
end
defp media(_total, transcurrido_s) when transcurrido_s <= 0, do: 0.0
defp media(total, transcurrido_s), do: total / transcurrido_s
defp agendar(intervalo_ms), do: Process.send_after(self(), :muestrear, intervalo_ms)
end
Una sesión típica de medición en el shell de Nerves, dejando correr al menos un ciclo completo del nodo:
iex> {:ok, _pid} = SolarNode.Consumo.start_link(intervalo_ms: 20, shunt_ohms: 0.1)
{:ok, #PID<0.1123.0>}
# ... se deja correr una hora completa de operación normal ...
iex> SolarNode.Consumo.resumen()
%{
transcurrido_s: 3601.2,
muestras: 180060,
corriente_media_ma: 1.42,
potencia_media_mw: 5.25,
consumo_diario_mah: 34.08,
consumo_diario_wh: 0.126
}
Ese 1.42 mA promedio es el número real. Si el cálculo de escritorio decía 0,22 mA, la diferencia son consumos parásitos, y encontrarlos antes de comprar el panel importa: con ese número el panel tendría que ser seis veces más grande de lo previsto, cuando probablemente sea más barato rediseñar la alimentación.
Una advertencia sobre el instrumento: el propio INA219 consume alrededor de 1 mA en modo continuo, así que para medir un nodo cuyo consumo dormido son decenas de microamperios, el medidor es más grande que lo medido. En ese régimen se usa solo durante la caracterización en banco, se lo pone en modo de disparo único entre lecturas, o se pasa a un instrumento de mayor rango dinámico.
El método de dimensionamiento, paso a paso
Ahora sí, la aritmética. Son cinco pasos y todos son multiplicaciones y divisiones; no hay nada más complicado que eso. Lo difícil es elegir bien los factores.
Paso 1. Consumo diario de energía. Suma de potencia por tiempo de cada carga, medido o estimado:
E_dia [Wh/día] = Σ (P_i [W] × t_i [h/día])
Es cómodo trabajar en mWh cuando el nodo es pequeño. Si tienes corriente en vez de potencia, E = V × I × t.
Paso 2. Consumo corregido por las pérdidas del camino. La energía que sale del panel no llega íntegra a la carga. Se pierde en el controlador, en el ciclo de carga y descarga de la batería (eficiencia coulómbica y de voltaje: 80 a 85 % en plomo, 92 a 97 % en litio), en el cableado y en la conversión DC-DC.
E_generar [Wh/día] = E_dia / (η_controlador × η_batería × η_cableado × η_regulador)
Valores razonables: η_controlador 0,96 (MPPT) o 0,75 a 0,80 efectivo (PWM, considerando la pérdida por trabajar fuera del MPP); η_batería 0,85 (plomo) o 0,95 (litio); η_cableado 0,97; η_regulador 0,90 (buck) o el cociente de voltajes (LDO).
Paso 3. Potencia pico del panel. Se divide por las horas sol pico del peor mes y por un factor de derating que agrupa suciedad, envejecimiento, temperatura, desviación de orientación y tolerancia de fábrica. Un derating de 0,75 a 0,80 es realista para instalaciones fijas sin limpieza frecuente.
P_panel [Wp] = E_generar / (HSP_peor_mes × derating)
Paso 4. Capacidad de la batería. Se dimensiona por días de autonomía, es decir, cuántos días seguidos sin sol debe sobrevivir el nodo. Para un nodo de telemetría no crítico, 3 días. Para algo que no puede caerse, 5 a 7 días en climas nublados.
C_util [Wh] = E_dia × días_autonomía
C_nominal [Wh] = C_util / DoD
C_nominal [Ah] = C_nominal [Wh] / V_nominal_batería
Paso 5. Verificación de la corriente de carga. La corriente que el panel puede inyectar debe estar dentro de lo que la batería acepta, y no ser tan baja que nunca complete la carga. Para plomo, la corriente de carga recomendada está entre C/20 y C/5 (entre 5 A y 20 A para una batería de 100 Ah); una corriente crónicamente menor a C/20 favorece la sulfatación porque la batería nunca alcanza la fase de absorción. Para LiFePO₄, hasta 0,5C es habitual y no hay problema con corrientes bajas.
Codifiquemos el método completo, porque es exactamente el tipo de cálculo que conviene tener versionado y con pruebas en vez de en una planilla suelta.
defmodule SolarNode.Dimensionamiento do
@moduledoc """
Dimensionamiento de un sistema fotovoltaico aislado para un nodo IoT.
Todas las funciones son puras: reciben mapas de entrada y devuelven mapas
de resultado, lo que permite probarlas y barrer escenarios sin hardware.
"""
# Una carga es %{nombre: String.t(), watts: float(), horas_dia: float()}
@eficiencias %{
controlador_mppt: 0.96,
controlador_pwm: 0.78,
bateria_plomo: 0.85,
bateria_litio: 0.95,
cableado: 0.97,
regulador_buck: 0.90
}
@dod %{
plomo_abierta: 0.50,
agm: 0.55,
gel: 0.60,
opzv: 0.70,
lifepo4: 0.80,
nmc: 0.80
}
@doc """
Energía diaria consumida por el conjunto de cargas, en Wh/día.
iex> SolarNode.Dimensionamiento.consumo_diario([
...> %{nombre: "MCU activo", watts: 0.40, horas_dia: 0.04},
...> %{nombre: "MCU dormido", watts: 0.000066, horas_dia: 23.96}
...> ])
0.017581
"""
def consumo_diario(cargas) do
cargas
|> Enum.map(fn %{watts: w, horas_dia: h} -> w * h end)
|> Enum.sum()
|> Float.round(6)
end
@doc """
Calcula el sistema completo a partir del consumo y las condiciones del sitio.
Opciones: `:hsp` (horas sol pico del peor mes, obligatorio), `:controlador`
(`:mppt` o `:pwm`), `:quimica` (`:agm`, `:gel`, `:plomo_abierta`, `:opzv`,
`:lifepo4`, `:nmc`), `:autonomia` en días, `:derating` y `:v_bateria`.
"""
def calcular(consumo_wh_dia, opts) do
hsp = Keyword.fetch!(opts, :hsp)
controlador = Keyword.get(opts, :controlador, :mppt)
quimica = Keyword.get(opts, :quimica, :agm)
autonomia = Keyword.get(opts, :autonomia, 3)
derating = Keyword.get(opts, :derating, 0.78)
v_bateria = Keyword.get(opts, :v_bateria, 12.0)
eta_controlador = eficiencia_controlador(controlador)
eta_bateria = eficiencia_bateria(quimica)
eta_cadena = eta_controlador * eta_bateria * @eficiencias.cableado
energia_a_generar = consumo_wh_dia / eta_cadena
panel_wp = energia_a_generar / (hsp * derating)
dod = Map.fetch!(@dod, quimica)
bateria_util_wh = consumo_wh_dia * autonomia
bateria_nominal_wh = bateria_util_wh / dod
bateria_ah = bateria_nominal_wh / v_bateria
corriente_carga_a = panel_wp / v_bateria
%{
consumo_wh_dia: redondear(consumo_wh_dia),
eficiencia_cadena: redondear(eta_cadena),
energia_a_generar_wh_dia: redondear(energia_a_generar),
panel_wp_minimo: redondear(panel_wp),
panel_wp_recomendado: redondear(panel_wp * 1.3),
bateria_util_wh: redondear(bateria_util_wh),
bateria_nominal_wh: redondear(bateria_nominal_wh),
bateria_ah: redondear(bateria_ah),
corriente_carga_estimada_a: redondear(corriente_carga_a),
tasa_c_carga: redondear(corriente_carga_a / max(bateria_ah, 0.001)),
autonomia_dias: autonomia,
quimica: quimica,
controlador: controlador
}
end
@doc "Días reales de autonomía de un banco, considerando la DoD de su química."
def autonomia_real(bateria_wh_nominal, consumo_wh_dia, quimica) do
dod = Map.fetch!(@dod, quimica)
redondear(bateria_wh_nominal * dod / consumo_wh_dia)
end
@doc "Balance diario para unas HSP dadas; negativo significa que el banco se descarga."
def balance_diario(panel_wp, hsp, consumo_wh_dia, opts \\ []) do
derating = Keyword.get(opts, :derating, 0.78)
controlador = Keyword.get(opts, :controlador, :mppt)
quimica = Keyword.get(opts, :quimica, :agm)
eta = eficiencia_controlador(controlador) * eficiencia_bateria(quimica) * @eficiencias.cableado
generado = panel_wp * hsp * derating * eta
%{
generado_wh: redondear(generado),
consumido_wh: redondear(consumo_wh_dia),
balance_wh: redondear(generado - consumo_wh_dia),
razon_generacion_consumo: redondear(generado / consumo_wh_dia)
}
end
defp eficiencia_controlador(:mppt), do: @eficiencias.controlador_mppt
defp eficiencia_controlador(:pwm), do: @eficiencias.controlador_pwm
defp eficiencia_bateria(quimica) when quimica in [:lifepo4, :nmc],
do: @eficiencias.bateria_litio
defp eficiencia_bateria(_plomo), do: @eficiencias.bateria_plomo
defp redondear(valor) when is_float(valor), do: Float.round(valor, 4)
defp redondear(valor), do: valor
end
Ejemplo cerrado 1: nodo ESP32 con LoRa en Santiago
Especificación: un sensor de humedad de suelo que despierta cada hora, mide, transmite por LoRa y vuelve a dormir. Instalado en la Región Metropolitana, debe funcionar todo el año.
Consumos medidos en banco con el INA219:
| Estado | Corriente a 3,3 V | Duración por ciclo | Ciclos/día | Horas/día |
|---|---|---|---|---|
| Sueño profundo | 20 µA | 3594 s | 24 | 23,96 |
| Arranque y lectura de sensores | 45 mA | 2,0 s | 24 | 0,0133 |
| Transmisión LoRa (SF9, 20 dBm) | 130 mA | 0,4 s | 24 | 0,00267 |
| Escucha de ventana de recepción | 12 mA | 1,0 s | 24 | 0,00667 |
| Quiescente del regulador y divisor | 350 µA | permanente | — | 24 |
iex> cargas = [
...> %{nombre: "sueño profundo", watts: 0.000_020 * 3.3, horas_dia: 23.96},
...> %{nombre: "arranque y lectura", watts: 0.045 * 3.3, horas_dia: 0.0133},
...> %{nombre: "TX LoRa", watts: 0.130 * 3.3, horas_dia: 0.00267},
...> %{nombre: "RX ventana", watts: 0.012 * 3.3, horas_dia: 0.00667},
...> %{nombre: "quiescente regulador", watts: 0.000_350 * 3.3, horas_dia: 24.0}
...> ]
iex> consumo = SolarNode.Dimensionamiento.consumo_diario(cargas)
0.032686
iex> SolarNode.Dimensionamiento.calcular(consumo,
...> hsp: 2.5,
...> controlador: :pwm,
...> quimica: :lifepo4,
...> autonomia: 5,
...> v_bateria: 3.7,
...> derating: 0.75
...> )
%{
autonomia_dias: 5,
bateria_ah: 0.0552,
bateria_nominal_wh: 0.2043,
bateria_util_wh: 0.1634,
consumo_wh_dia: 0.0327,
controlador: :pwm,
corriente_carga_estimada_a: 0.0066,
eficiencia_cadena: 0.7188,
energia_a_generar_wh_dia: 0.0455,
panel_wp_minimo: 0.0243,
panel_wp_recomendado: 0.0315,
quimica: :lifepo4,
tasa_c_carga: 0.1187
}
Lectura de los resultados: en teoría bastan 24 mWp de panel y una celda de 55 mAh. En la práctica no se compra nada tan pequeño ni conviene: el panel más chico razonable es de 0,5 a 1 Wp, y la celda 18650 de 2600 mAh cuesta prácticamente lo mismo que una de 500 mAh. Con una 18650 de 2600 mAh (9,6 Wh) la autonomía real es enorme:
iex> SolarNode.Dimensionamiento.autonomia_real(9.6, 0.032686, :lifepo4)
234.9631
Casi doscientos treinta y cinco días sin sol. Ese sobredimensionamiento no es despilfarro: cubre el envejecimiento de la celda, la pérdida de capacidad por frío invernal y evita ciclar profundamente, lo que multiplica los ciclos de vida. Sobredimensionar la batería en un nodo pequeño es la decisión de bajo costo que más años agrega.
Hay además un detalle revelador en la tabla de consumos: el quiescente del regulador (350 µA constantes) representa 27,7 mWh al día, más del 80 % del total, mientras que la transmisión LoRa —lo que instintivamente uno cree caro— aporta apenas 1,1 mWh. En nodos que duermen, domina lo que nunca se apaga.
Ejemplo cerrado 2: gateway con Raspberry Pi y Nerves en Valdivia
Especificación: un gateway LoRaWAN sobre Raspberry Pi Zero 2 W con Nerves, encendido permanentemente, más el concentrador de radio, instalado en Los Ríos.
| Carga | Potencia media | Horas/día | Wh/día |
|---|---|---|---|
| Raspberry Pi Zero 2 W con Nerves, WiFi activo | 0,85 W | 24 | 20,4 |
| Concentrador LoRa (recepción continua) | 0,45 W | 24 | 10,8 |
| Sensores auxiliares y LED de estado | 0,10 W | 24 | 2,4 |
| Total | 33,6 |
iex> SolarNode.Dimensionamiento.calcular(33.6,
...> hsp: 1.2,
...> controlador: :mppt,
...> quimica: :agm,
...> autonomia: 4,
...> v_bateria: 12.0,
...> derating: 0.75
...> )
%{
autonomia_dias: 4,
bateria_ah: 20.3636,
bateria_nominal_wh: 244.3636,
bateria_util_wh: 134.4,
consumo_wh_dia: 33.6,
controlador: :mppt,
corriente_carga_estimada_a: 3.9306,
eficiencia_cadena: 0.7915,
energia_a_generar_wh_dia: 42.45,
panel_wp_minimo: 47.1666,
panel_wp_recomendado: 61.3166,
quimica: :agm,
tasa_c_carga: 0.193
}
Con 1,2 HSP en el peor mes de Valdivia, el panel mínimo es de 47 Wp y el recomendado supera los 60 Wp; una batería AGM de 12 V y 26 Ah cubre los cuatro días de autonomía con margen. Compara con el mismo gateway instalado en Calama, donde el peor mes ronda 5,2 HSP:
iex> 33.6
...> |> SolarNode.Dimensionamiento.calcular(hsp: 5.2, quimica: :agm, autonomia: 4)
...> |> Map.take([:panel_wp_minimo, :panel_wp_recomendado, :bateria_ah])
%{bateria_ah: 20.3636, panel_wp_minimo: 10.466, panel_wp_recomendado: 13.6058}
El mismo nodo necesita un panel cuatro veces y media más chico. La batería, en cambio, es idéntica: la batería la fija el consumo y los días de autonomía, no la irradiación. Esa asimetría es útil de recordar cuando hay que recortar presupuesto.
Verifiquemos si el sistema de Valdivia sobrevive un día realmente malo, de esos de 0,4 HSP:
iex> SolarNode.Dimensionamiento.balance_diario(60.0, 0.4, 33.6, quimica: :agm)
%{
balance_wh: -18.7827,
consumido_wh: 33.6,
generado_wh: 14.8173,
razon_generacion_consumo: 0.441
}
Ese día el banco pierde 18,8 Wh, alrededor del 14 % de su capacidad útil. Con una semana entera así, se agota. Es precisamente el escenario que justifica un respaldo (una segunda fuente, o un corte programado de funciones no esenciales), y es lo que vamos a implementar en software a continuación.
Una política de energía en Elixir
El sistema físico ya está dimensionado. Falta la parte que ningún panel resuelve: qué hace el nodo cuando, pese a todo, la batería empieza a bajar. Un nodo bien diseñado no se apaga de golpe; degrada. Reduce la frecuencia de muestreo, deja de transmitir imágenes, apaga sensores caros, y en el último escalón se limita a mantener un latido mínimo hasta que vuelva el sol.
stateDiagram-v2
[*] --> Nominal
Nominal: Nominal (SoC > 70%)
Nominal: Muestreo cada 5 min, todos los sensores
Nominal: Transmite cada muestra
Ahorro: Ahorro (SoC 40-70%)
Ahorro: Muestreo cada 15 min, sensores caros apagados
Ahorro: Transmite agregados
Critico: Crítico (SoC 20-40%)
Critico: Muestreo cada 60 min, solo sensores esenciales
Critico: Radio en potencia reducida
Supervivencia: Supervivencia (SoC < 20%)
Supervivencia: Latido cada 6 h, sin escucha de bajada
Supervivencia: Cargas externas cortadas
Nominal --> Ahorro: SoC baja de 70%
Ahorro --> Nominal: SoC sube sobre 78%
Ahorro --> Critico: SoC baja de 40%
Critico --> Ahorro: SoC sube sobre 48%
Critico --> Supervivencia: SoC baja de 20%
Supervivencia --> Critico: SoC sube sobre 30%
Supervivencia --> [*]: LVD del controlador<br/>corta la carga
Nota los umbrales de subida y de bajada: nunca son iguales. Esa histéresis es indispensable, porque el voltaje de la batería sube al aliviar la carga y baja al aplicarla. Sin histéresis, el nodo entraría y saldría del modo de ahorro varias veces por minuto, y el propio cambio de modo consumiría energía.
defmodule SolarNode.PoliticaEnergia do
@moduledoc """
Máquina de estados que ajusta el comportamiento del nodo según el estado
de carga de la batería, con histéresis para evitar oscilación entre modos.
Publica cada transición por `Phoenix.PubSub` o, en su ausencia, por un
simple `:telemetry`, para que el resto de la aplicación reaccione sin
acoplarse a este módulo.
"""
use GenServer
require Logger
@modos [:nominal, :ahorro, :critico, :supervivencia]
# Umbrales de SoC: {entrar_al_bajar, salir_al_subir}
@umbrales %{
ahorro: {0.70, 0.78},
critico: {0.40, 0.48},
supervivencia: {0.20, 0.30}
}
@intervalos_ms %{
nominal: 5 * 60 * 1000,
ahorro: 15 * 60 * 1000,
critico: 60 * 60 * 1000,
supervivencia: 6 * 60 * 60 * 1000
}
defstruct modo: :nominal, soc: 1.0, ultima_transicion: nil, transiciones: 0
# API pública
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc "Informa una nueva lectura de estado de carga (0.0 a 1.0)."
def actualizar_soc(soc) when soc >= 0.0 and soc <= 1.0 do
GenServer.cast(__MODULE__, {:soc, soc})
end
@doc "Modo actual del nodo."
def modo, do: GenServer.call(__MODULE__, :modo)
@doc "Intervalo de muestreo vigente, en milisegundos."
def intervalo_ms, do: Map.fetch!(@intervalos_ms, modo())
@doc """
Resuelve el modo que corresponde a un SoC dado partiendo del modo actual,
aplicando histéresis. Función pura: se prueba sin levantar el proceso.
iex> SolarNode.PoliticaEnergia.resolver_modo(:nominal, 0.69)
:ahorro
iex> SolarNode.PoliticaEnergia.resolver_modo(:ahorro, 0.75)
:ahorro
"""
def resolver_modo(modo_actual, soc) do
cond do
soc < elem(@umbrales.supervivencia, 0) -> :supervivencia
soc < elem(@umbrales.critico, 0) -> :critico
soc < elem(@umbrales.ahorro, 0) -> :ahorro
soc >= elem(@umbrales.ahorro, 1) -> :nominal
soc >= elem(@umbrales.critico, 1) -> subir_hasta(modo_actual, :ahorro)
soc >= elem(@umbrales.supervivencia, 1) -> subir_hasta(modo_actual, :critico)
true -> modo_actual
end
end
# Si el modo actual ya es más permisivo que el destino, no lo degrada:
# la histéresis solo autoriza a subir hasta cierto punto.
defp subir_hasta(modo_actual, destino) do
if indice(modo_actual) < indice(destino), do: modo_actual, else: destino
end
defp indice(modo), do: Enum.find_index(@modos, &(&1 == modo))
# Callbacks
@impl true
def init(_opts) do
{:ok, %__MODULE__{ultima_transicion: DateTime.utc_now()}}
end
@impl true
def handle_cast({:soc, soc}, %__MODULE__{} = estado) do
nuevo_modo = resolver_modo(estado.modo, soc)
estado =
if nuevo_modo == estado.modo do
%{estado | soc: soc}
else
aplicar_transicion(estado.modo, nuevo_modo, soc)
%{
estado
| modo: nuevo_modo,
soc: soc,
ultima_transicion: DateTime.utc_now(),
transiciones: estado.transiciones + 1
}
end
{:noreply, estado}
end
@impl true
def handle_call(:modo, _from, estado), do: {:reply, estado.modo, estado}
defp aplicar_transicion(anterior, nuevo, soc) do
Logger.info("energía: #{anterior} -> #{nuevo} (SoC #{round(soc * 100)}%)")
:telemetry.execute(
[:solar_node, :energia, :modo],
%{soc: soc},
%{modo_anterior: anterior, modo_nuevo: nuevo}
)
SolarNode.Cargas.aplicar_modo(nuevo)
end
end
El módulo que corta físicamente las cargas usa Circuits.GPIO para gobernar transistores MOSFET del lado alto, que es la forma habitual de apagar un periférico completo en vez de dejarlo en su modo de bajo consumo, que casi nunca es cero.
defmodule SolarNode.Cargas do
@moduledoc """
Control físico de cargas conmutables mediante MOSFET gobernados por GPIO.
En `:supervivencia` se apagan todas; en `:nominal` se encienden todas.
"""
use GenServer
alias Circuits.GPIO
# Nombre lógico de la carga -> especificación de línea GPIO.
# En Circuits.GPIO v2 la línea se identifica por nombre ("GPIO17") o por
# tupla {controlador, offset}; en v1 se usaba el número del pin.
@lineas %{
camara: "GPIO17",
luminosidad: "GPIO27",
humedad_suelo: "GPIO22",
calefactor_bateria: "GPIO23"
}
@conmutables_por_modo %{
nominal: [:camara, :luminosidad, :humedad_suelo],
ahorro: [:luminosidad, :humedad_suelo],
critico: [:humedad_suelo],
supervivencia: []
}
def start_link(opts \\ []), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
@doc "Enciende exactamente las cargas que corresponden al modo indicado."
def aplicar_modo(modo), do: GenServer.call(__MODULE__, {:aplicar_modo, modo})
@impl true
def init(_opts) do
handles =
Map.new(@lineas, fn {carga, linea} ->
{:ok, gpio} = GPIO.open(linea, :output, initial_value: 0)
{carga, gpio}
end)
{:ok, %{handles: handles, valores: Map.new(@lineas, fn {c, _} -> {c, 0} end)}}
end
@impl true
def handle_call({:aplicar_modo, modo}, _from, estado) do
encendidas = Map.fetch!(@conmutables_por_modo, modo)
valores =
Map.new(estado.handles, fn {carga, gpio} ->
valor = if carga in encendidas, do: 1, else: 0
:ok = GPIO.write(gpio, valor)
{carga, valor}
end)
{:reply, :ok, %{estado | valores: valores}}
end
end
Y el módulo que traduce voltaje a estado de carga, con la tabla de la sección de baterías e interpolación lineal entre puntos:
defmodule SolarNode.EstadoCarga do
@moduledoc """
Estimación del estado de carga (SoC) a partir del voltaje en reposo.
Precisión aceptable en plomo-ácido; en LiFePO4 la curva es tan plana que
el resultado solo sirve para detectar los extremos. Para litio conviene
contar coulombs o leer el SoC que informa el BMS.
"""
# Curvas: lista de {voltaje_por_celda_o_banco, soc} ordenada de menor a mayor.
@curvas %{
plomo_12v: [{11.70, 0.00}, {11.95, 0.25}, {12.20, 0.50}, {12.45, 0.75}, {12.70, 1.00}],
lifepo4_4s: [
{10.00, 0.00}, {12.80, 0.10}, {13.00, 0.25},
{13.10, 0.50}, {13.20, 0.75}, {13.40, 1.00}
]
}
@doc """
Devuelve el SoC estimado (0.0 a 1.0) para un voltaje y una curva.
iex> SolarNode.EstadoCarga.desde_voltaje(12.20, :plomo_12v)
0.5
iex> SolarNode.EstadoCarga.desde_voltaje(12.325, :plomo_12v)
0.625
iex> SolarNode.EstadoCarga.desde_voltaje(9.0, :plomo_12v)
0.0
"""
def desde_voltaje(voltaje, curva_nombre \\ :plomo_12v) do
curva = Map.fetch!(@curvas, curva_nombre)
{v_min, soc_min} = List.first(curva)
{v_max, soc_max} = List.last(curva)
cond do
voltaje <= v_min -> soc_min
voltaje >= v_max -> soc_max
true -> interpolar(curva, voltaje)
end
end
defp interpolar(curva, voltaje) do
curva
|> Enum.chunk_every(2, 1, :discard)
|> Enum.find_value(fn [{v1, s1}, {v2, s2}] ->
if voltaje >= v1 and voltaje <= v2 do
s1 + (voltaje - v1) / (v2 - v1) * (s2 - s1)
end
end)
end
end
El pegamento entre las tres piezas es un proceso que muestrea el voltaje y alimenta la política:
defmodule SolarNode.Vigilancia do
@moduledoc """
Muestrea el voltaje de la batería cada minuto, promedia una ventana para
filtrar transitorios de carga y alimenta la política de energía.
"""
use GenServer
alias SolarNode.{EstadoCarga, INA219, PoliticaEnergia}
@intervalo_ms 60_000
@ventana 10
def start_link(opts \\ []), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
@impl true
def init(opts) do
case INA219.open(Keyword.get(opts, :bus, "i2c-1"), opts) do
{:ok, dev} ->
Process.send_after(self(), :medir, @intervalo_ms)
{:ok, %{dev: dev, ventana: [], curva: Keyword.get(opts, :curva, :plomo_12v)}}
{:error, razon} ->
{:stop, {:ina219_no_disponible, razon}}
end
end
@impl true
def handle_info(:medir, estado) do
estado =
case INA219.bus_voltage(estado.dev) do
{:ok, volts} -> %{estado | ventana: Enum.take([volts | estado.ventana], @ventana)}
{:error, _razon} -> estado
end
# Solo se decide con la ventana completa.
if length(estado.ventana) == @ventana do
(Enum.sum(estado.ventana) / @ventana)
|> EstadoCarga.desde_voltaje(estado.curva)
|> PoliticaEnergia.actualizar_soc()
end
Process.send_after(self(), :medir, @intervalo_ms)
{:noreply, estado}
end
end
Y el árbol de supervisión que los junta, en el estilo habitual de una aplicación Nerves:
defmodule SolarNode.Application do
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = [SolarNode.Cargas, SolarNode.PoliticaEnergia, SolarNode.Vigilancia]
Supervisor.start_link(children, strategy: :one_for_one, name: SolarNode.Supervisor)
end
end
El orden importa: Cargas arranca primero porque PoliticaEnergia le hace llamadas al aplicar una transición, y Vigilancia va al final porque es quien dispara el flujo. Con :one_for_one, si el proceso de vigilancia muere por un error de I2C transitorio se reinicia solo, reabre el bus y sigue, sin tumbar el control de cargas.
El ciclo completo, en el tiempo
sequenceDiagram
participant P as Panel FV
participant C as Controlador
participant B as Batería
participant V as Vigilancia
participant PE as PolíticaEnergia
participant L as Cargas (GPIO)
participant R as Radio
Note over P,B: Mediodía despejado
P->>C: 17,8 V a 5,6 A (MPP)
C->>B: Carga en fase bulk, 7,7 A
V->>B: Lee 13,2 V por INA219
V->>PE: actualizar_soc(0.95)
PE-->>PE: resolver_modo(:ahorro, 0.95) = :nominal
PE->>L: aplicar_modo(:nominal): enciende todas las cargas
PE->>R: Transmite cada 5 min
Note over P,B: Tercer día nublado seguido
P->>C: 15 V a 0,6 A
C->>B: Carga marginal, no alcanza absorción
V->>PE: Lee 12,05 V, actualizar_soc(0.35)
PE-->>PE: resolver_modo(:ahorro, 0.35) = :critico
PE->>L: aplicar_modo(:critico): apaga cámara y luminosidad
PE->>R: Transmite cada 60 min, potencia reducida
Note over P,B: Vuelve el sol
P->>C: 18,1 V a 5,9 A
V->>PE: Lee 12,35 V, actualizar_soc(0.60)
PE-->>PE: 0.60 > 0.48, sube a :ahorro (no salta a :nominal)
PE->>L: aplicar_modo(:ahorro)
Ese último paso ilustra la histéresis escalonada: al recuperarse, el nodo sube un peldaño por vez y no salta al modo más permisivo, evitando que un repunte momentáneo de voltaje —muy común al aliviar carga— dispare la restauración completa y vuelva a hundir la batería.
Seguridad e instalación
Un sistema solar pequeño maneja voltajes bajos, pero corrientes que sí pueden iniciar un incendio. Una batería de plomo de 100 Ah en cortocircuito entrega centenares de amperes durante los segundos que tarda en fundir lo que sea que la esté cortocircuitando.
- Fusibles en ambos lados. Uno entre panel y controlador, dimensionado a aproximadamente 1,25 veces la Isc del arreglo. Otro entre batería y controlador, y otro entre batería y cargas, dimensionados según la corriente máxima del ramal. El fusible protege el cable, no el equipo: se elige por la capacidad del conductor.
- Diodo de bloqueo entre panel y controlador si el controlador no lo trae, para que la batería no se descargue a través del panel de noche. Muchos controladores modernos ya lo implementan con un MOSFET, que disipa menos que un diodo Schottky.
- Orden de conexión al montar: primero la batería al controlador, después el panel, y las cargas al final; para desconectar, el orden inverso exacto. La mayoría de los controladores detectan el voltaje de la batería al arrancar para decidir si el sistema es de 12 o 24 V, y conectar el panel primero puede hacer fallar esa detección.
- Ventilación en plomo abierto. La carga genera hidrógeno, que es explosivo en concentración. Nada de cajas herméticas ni de contactos que hagan chispa en el mismo recinto. Las AGM y las de gel son de recombinación y liberan mucho menos, pero también tienen válvula de alivio.
- Protección contra polaridad inversa. Es el error de cableado más frecuente y el que destruye controladores. Un fusible más un diodo de by-pass hacia la línea de tierra, o un MOSFET de canal P en serie, cuestan casi nada.
- Caída de tensión en el cable. En 12 V, una caída de 0,5 V es más del 4 % del sistema. La resistencia de un conductor es
R = ρ × L / A, y la caída esΔV = 2 × I × R(ida y vuelta). Para 10 A en 5 metros de cable de cobre de 2,5 mm², la caída ronda 0,7 V: conviene subir a 4 o 6 mm², o mover el controlador cerca del panel. - Puesta a tierra y descargas atmosféricas. Un panel en un poste en campo abierto es un pararrayos involuntario. La estructura metálica va a tierra, y en zonas con tormentas eléctricas conviene un descargador de sobretensión en la entrada del controlador.
Sobre normativa en Chile: mientras el sistema sea aislado y no se conecte a la red pública, el escenario regulatorio es simple. En cuanto haya intención de inyectar excedentes a la red, aplica el régimen de generación distribuida conocido como net billing (Ley 20.571, modificada por la Ley 21.118), que permite a los clientes regulados autoabastecerse e inyectar excedentes recibiendo una compensación en la boleta. Ese trámite requiere que la instalación la ejecute un instalador o empresa autorizada por la Superintendencia de Electricidad y Combustibles y que el proyecto se declare formalmente. Para un nodo IoT de pocos vatios, aislado y sin conexión a la red, nada de eso entra en juego.
Errores comunes
| Error | Causa | Solución |
|---|---|---|
| El panel entrega 21 V con el multímetro pero el sistema no carga | Se está midiendo Voc sin carga; el panel puede tener voltaje y casi nada de corriente por sombra, suciedad o luz difusa | Medir Isc con el multímetro en modo corriente (cortocircuitando el panel a través del instrumento) o medir la corriente real de carga en el controlador |
| El nodo consume mucho más de lo calculado | Consumos parásitos que no se modelaron: LED de encendido, quiescente del regulador, pull-ups siempre activas, periféricos que nunca entran en su modo de bajo consumo | Medir con INA219 o instrumento de rango dinámico alto durante un ciclo completo; cortar por GPIO todo lo que no sea esencial |
| La batería de litio pierde capacidad tras el primer invierno | Se cargó por debajo de 0 °C, produciendo deposición metálica irreversible sobre el ánodo | Usar un BMS que bloquee la carga bajo 0 °C, agregar calefacción de batería alimentada solo cuando hay excedente solar, o cambiar a plomo en climas fríos |
| La batería de plomo dura menos de dos años | Descargas más profundas que el 50 % de DoD, o temperatura de operación alta y sostenida en la caja | Sobredimensionar el banco para que el ciclado diario sea menor al 20 %; instalar la batería a la sombra y ventilada |
| El sistema funciona en verano y colapsa cada invierno | Se dimensionó con las HSP promedio anuales en vez del peor mes | Recalcular con la irradiación del mes más desfavorable del sitio y aumentar la inclinación del panel a latitud + 15° |
| La generación cae abruptamente con una sombra pequeña | Las celdas están en serie: la corriente del panel completo la fija la celda peor iluminada | Reubicar el panel; orientar de modo que la sombra corra a lo largo y active un solo diodo de bypass; en arreglos, evitar mezclar paneles con sombreado distinto en la misma cadena |
| El controlador MPPT se apaga o entra en falla en las mañanas frías | El Voc del panel sube con el frío y supera el voltaje máximo de entrada del controlador | Elegir el controlador con el Voc corregido a la temperatura mínima histórica del sitio, no con el Voc de la etiqueta |
| El nodo entra y sale del modo de ahorro cada pocos segundos | Umbrales de entrada y salida iguales, sin histéresis; el voltaje sube al aliviar carga y baja al aplicarla | Separar los umbrales de bajada y subida, y promediar el voltaje en una ventana de varios minutos antes de decidir |
| El estado de carga informado por el firmware no se parece a la realidad en una batería LiFePO₄ | La curva de descarga del LiFePO₄ es plana: entre 20 % y 90 % de carga hay apenas décimas de volt | Contar coulombs integrando la corriente, o leer el SoC directamente del BMS por su interfaz de comunicación |
| El controlador de carga consume más que el nodo | En sistemas de pocos miliamperios, el quiescente del controlador comercial (10 a 35 mA) domina el balance | Usar un circuito de carga integrado de bajo quiescente pensado para sistemas pequeños, en vez de un controlador de instalación |
| La corriente de carga es siempre muy baja y la batería de plomo se sulfata | Panel subdimensionado: la corriente nunca llega a C/20 y la batería jamás completa la fase de absorción | Aumentar la potencia del panel para alcanzar al menos C/20, idealmente entre C/10 y C/5 |
Ejercicios propuestos
-
Presupuesto energético de tu propio nodo. Toma el proyecto que hayas armado en capítulos anteriores, lista todas sus cargas con potencia y horas diarias, y calcula
E_diaconSolarNode.Dimensionamiento.consumo_diario/1. Compara ese número con el que obtienes midiendo una hora real conSolarNode.Consumo. Documenta a qué se debe la diferencia. -
Barrido geográfico. Escribe una función que reciba una lista de sitios con sus HSP del peor mes y devuelva, para un mismo consumo, el panel recomendado en cada uno. Grafica o tabula el resultado para las siete zonas de la tabla de HSP de este capítulo y explica por qué la batería no cambia entre sitios pero el panel sí.
-
Simulador de balance multidía. Implementa
SolarNode.Simulador.correr/3que reciba una serie de HSP diarias (por ejemplo, treinta días con una racha de cinco días nublados en el medio), la capacidad de la batería y el consumo diario, y devuelva la evolución del estado de carga día a día, marcando en qué día se cruza cada umbral de la política de energía. Usabalance_diario/4como base. -
Corrección por temperatura. Extiende
Dimensionamientocon una función que calcule el Voc corregido a una temperatura dada, a partir del Voc en STC y del coeficiente β. Úsala para verificar si un controlador con 50 V de entrada máxima admite tres paneles de 21,6 V de Voc en serie en un sitio donde la mínima histórica es -8 °C. -
Pruebas de la máquina de estados. Escribe pruebas con ExUnit para
PoliticaEnergia.resolver_modo/2que cubran todas las transiciones del diagrama, incluida la verificación explícita de que un SoC de 0,45 no lleve de:supervivenciadirectamente a:nominal. Agrega una prueba basada en propiedades que compruebe que el modo nunca mejora más de un peldaño en una sola actualización. -
Contador de coulombs. Modifica
SolarNode.Consumopara que, además de acumular, mantenga un estado de carga por integración de corriente: sumando cuando entra energía y restando cuando sale, partiendo de una capacidad nominal conocida. Compara sus resultados con los deEstadoCarga.desde_voltaje/2sobre una batería LiFePO₄ y explica cuál de los dos métodos usarías en producción y por qué.
Lo que viene
Con este capítulo cerramos la parte de infraestructura: ya sabemos qué componentes existen, cómo se comunican, cómo se programan desde Elixir y ahora cómo se alimentan de forma autónoma. Todas las piezas están sobre la mesa, y desde aquí en adelante el trabajo es de integración.
En el capítulo 18 bajamos a la práctica con el ESP32: proyectos completos, de principio a fin, que combinan sensores, actuadores, conectividad y —cuando corresponde— el sistema solar que acabamos de dimensionar. Vamos a construir cosas que funcionan y quedan funcionando. Si quieres revisar cualquier concepto previo, el recorrido completo está en el índice del curso.