Metadata-Version: 2.4
Name: solar-forecast-langgraph
Version: 0.1.10
Summary: LangGraph workflow for solar forecasting with OpenMeteo, historical data, and inverter-control integration
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: cryptography==50.0.2; sys_platform != "darwin" or platform_machine != "x86_64"
Requires-Dist: langgraph>=0.2.0
Requires-Dist: langchain-core>=0.3.0
Requires-Dist: langchain-openai>=0.2.0
Requires-Dist: requests>=2.31
Requires-Dist: httpx==0.28.1
Requires-Dist: httpcore==1.0.9
Requires-Dist: paho-mqtt>=2.0
Requires-Dist: pydantic>=2.8
Requires-Dist: pydantic-settings>=2.3
Requires-Dist: numpy>=1.26
Requires-Dist: pandas>=2.2
Requires-Dist: scikit-learn>=1.5
Requires-Dist: tenacity>=8.3
Requires-Dist: python-dotenv>=1.0
Requires-Dist: aiofiles>=25.1.0
Provides-Extra: dev
Requires-Dist: ruff>=0.5.0; extra == "dev"
Requires-Dist: pytest>=8.3; extra == "dev"
Requires-Dist: pytest-cov>=5.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: pandas-stubs>=3.0.5.260730; extra == "dev"
Requires-Dist: types-aiofiles>=25.1.0.20260518; extra == "dev"
Requires-Dist: scikit-learn-stubs>=0.0.3; extra == "dev"
Requires-Dist: types-atheris>=3.1.0.20260630; extra == "dev"
Dynamic: license-file

# Solar Forecast LangGraph

[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/15459/badge)](https://www.bestpractices.dev/projects/15459)

[![Fuzzing: RC only](https://img.shields.io/badge/Fuzzing-RC%20only-blue)](docs/fuzzing.md)

[![CI](https://github.com/4alvit/solar-forecast-langgraph/actions/workflows/quality-gate.yml/badge.svg)](https://github.com/4alvit/solar-forecast-langgraph/actions/workflows/quality-gate.yml)
[![Coverage](https://codecov.io/gh/4alvit/solar-forecast-langgraph/branch/main/graph/badge.svg)](https://codecov.io/gh/4alvit/solar-forecast-langgraph)
[![PyPI](https://img.shields.io/pypi/v/solar-forecast-langgraph.svg)](https://pypi.org/project/solar-forecast-langgraph/)
[![Python](https://img.shields.io/pypi/pyversions/solar-forecast-langgraph.svg)](https://pypi.org/project/solar-forecast-langgraph/)
[![License](https://img.shields.io/github/license/4alvit/solar-forecast-langgraph.svg)](LICENSE)

LangGraph workflow for solar forecasting with:
- **Weather data agent** — OpenMeteo API (free, no key required)
- **Historical generation loader** — From inverter-monitoring webhook
- **Panel configuration schema** — Azimuth, tilt, capacity, losses
- **Forecast model** — Physical (clear-sky + clouds) + Statistical (ML) + LLM reasoning
- **Inverter-control integration** — Pre-charge battery before cloudy periods
- **Forecast validation** — Evaluation requirements and current limitations ([guide](docs/forecast-validation.md))

---

<!-- ci-release-process:start -->
## Release process

See the [release strategy](RELEASING.md) for validation, nightly, beta, RC and stable promotion rules, and the [operator runbook](docs/release-workflow.md) for local commands.
<!-- ci-release-process:end -->

## Architecture

```mermaid
graph TD
    A[User Request] --> B[LangGraph Workflow]
    
    B --> C[Fetch Weather<br/>OpenMeteo API]
    B --> D[Fetch History<br/>inverter-monitoring]
    
    C --> E[Train Statistical Model]
    D --> E
    
    E --> F[Generate Forecast<br/>Physical + Statistical Ensemble]
    F --> G[Optional LLM Enhancement<br/>Base forecast on failure or when disabled]
    G --> H[Inverter-Control Hook<br/>Pre-charge decision]
    H --> I[Final Forecast Output]
    
    I -.-> J[Planned: accuracy tracking]
    J -.-> K[Planned: retraining scheduler]
    
    subgraph "Data Sources"
        C
        D
    end
    
    subgraph "Models"
        E
        F
        G
    end
    
    subgraph "Integrations"
        H
    end
```

---

## Installation

### From the stable release

As of 12 October 2026, [PyPI](https://pypi.org/project/solar-forecast-langgraph/0.1.4/)
and [GitHub Releases](https://github.com/4alvit/solar-forecast-langgraph/releases/tag/v0.1.4)
both provide `0.1.4`. The [verified publication](https://github.com/4alvit/solar-forecast-langgraph/actions/runs/38299579517)
uploaded the existing approved wheel and sdist without rebuilding them.

```bash
python -m venv .venv
source .venv/bin/activate  # Windows: .venv\Scripts\activate
python -m pip install solar-forecast-langgraph==0.1.4
```

The same wheel is also available directly from GitHub:

```bash
python -m pip install "https://github.com/4alvit/solar-forecast-langgraph/releases/download/v0.1.4/solar_forecast_langgraph-0.1.4-py3-none-any.whl"
```

Select an explicit stable version and verify its `SHA256SUMS` and
`release-manifest.json` when updating. Nightly, beta and RC tags are prereleases.
PyPI publication remains a separate manual promotion of verified assets;
creating a GitHub release does not publish it automatically. See
[distribution channels](docs/distribution-channels.md) for the verified hashes
and promotion procedure.

After installing, check the CLI is available:

```bash
solar-forecast --help
```

### From source (for development)

```bash
git clone git@github.com:4alvit/solar-forecast-langgraph.git
cd solar-forecast-langgraph
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
pre-commit install
```

---

## Quick Start

```bash
# Copy example config and customize
cp site_config.example.py site_config.local.py
# Edit site_config.local.py with your panel details

# Run forecast (48h horizon, 30 days history)
solar-forecast --config site_config.local.py --horizon 48 --output forecast.json
```

Output example:
```
Forecast Summary:
  Site: my-solar-site
  Panel: south-roof
  Method: ensemble
  Horizon: 48 hours
  Total Energy: 42500 Wh
  Generated: 2024-01-15T10:30:00+00:00

Next 12 hours:
  2024-01-15 11:00: 1200W (1200Wh) [960-1440]
  2024-01-15 12:00: 2800W (2800Wh) [2240-3360]
  2024-01-15 13:00: 3500W (3500Wh) [2800-4200]
```

---

## Configuration

### Panel Configuration

```python
from solar_forecast.config import PanelConfig, SiteConfig

panel = PanelConfig(
    name="South Roof",
    panel_id="south-roof",
    azimuth=180,  # 0=N, 90=E, 180=S, 270=W
    tilt=35,  # Degrees from horizontal
    capacity_kw=5.0,  # DC capacity
    module_count=14,
    latitude=52.37,
    longitude=4.90,
    # Optional losses
    shading_loss=0.0,
    soiling_loss=0.02,
    wiring_loss=0.01,
    module_efficiency=0.20,
    temperature_coefficient=-0.0035,
    inverter_efficiency=0.96,
    dc_ac_ratio=1.2,
)

site = SiteConfig(
    site_name="my-site",
    latitude=52.37,
    longitude=4.90,
    timezone="Europe/Amsterdam",
    panels=[panel],
)
```

See [site_config.example.py](site_config.example.py) for full example.

---

## Forecast Methods

| Method | Description | Use Case |
|--------|-------------|----------|
| `physical` | Isotropic transposition of weather-adjusted radiation | No historical data |
| `statistical` | ML model on historical data | Sufficient history (30+ days) |
| `ensemble` | Weighted: 60% physical + 40% statistical | Default; comparative accuracy is not yet established |
| `llm_enhanced` | Optional LLM analysis of weather patterns | Requires API credentials; field accuracy is not yet established |

The physical fallback takes the three horizontal components from
[Open-Meteo](https://open-meteo.com/en/docs): global (`shortwave_radiation`),
direct (`direct_radiation`) and diffuse (`diffuse_radiation`), all in W/m².
Direct horizontal radiation differs from direct normal irradiance (DNI).
For sun above the horizon, the model projects the direct component onto the
panel and adds isotropic sky diffuse and ground reflection (albedo 0.2):

```text
POA = direct_horizontal × max(0, cos(incidence)) / cos(zenith)
    + diffuse_horizontal × (1 + cos(tilt)) / 2
    + global_horizontal × 0.2 × (1 - cos(tilt)) / 2
```

Thus a horizontal panel preserves global irradiance when direct + diffuse =
global, and a panel facing away from the sun still receives diffuse radiation.
The previous calculation incorrectly used the beam projection ratio to remove
diffuse light. The replacement follows the
[isotropic transposition model](https://pvlib-python.readthedocs.io/en/v0.13.1/reference/generated/pvlib.irradiance.get_total_irradiance.html),
not an anisotropic Hay-Davies approximation. Eight offline reference cases in
`tests/fixtures/isotropic-pvlib-0.13.1.json` were calculated using pvlib 0.13.1
`get_total_irradiance(..., albedo=0.2, model="isotropic")`; pvlib is not a runtime
dependency. For reproduction, convert each fixture's horizontal direct input
to DNI with `direct_horizontal / cos(radians(zenith))`.

New Python callers should pass `direct_horizontal=`. The historical `dni=`
argument (or third positional argument) still means **horizontal** direct
radiation; passing both names raises `TypeError`. The statistical feature named
`dni` retains the same historical meaning to preserve fitted model compatibility.
No extra cloud derating is applied. The solar position remains approximate;
hourly weather averages are treated at the forecast timestamp. At/below the
horizon output is zero. Near-horizon direct projection is sensitive to input
consistency; these numerical checks do not establish real-site forecast accuracy.

---

## Inverter-Control Integration

The workflow publishes forecast data and pre-charge requests to the Cerbo GX MQTT broker (Venus OS `N/` namespace):

```mermaid
sequenceDiagram
    participant WF as LangGraph Workflow
    participant FC as Forecast
    participant CB as Cerbo GX MQTT
    participant IC as Inverter-Control

    WF->>FC: Generate 48h forecast
    FC->>WF: Forecast points with confidence
    WF->>CB: Publish solar_forecast/{site}/forecast_json (retain=True)
    WF->>WF: After 00:01 local, compare today's calendar-day kWh to threshold
    alt Low generation (< threshold for today)
        WF->>CB: Publish solar_forecast/{site}/pre_charge_request (retain=False)
        Note over CB,IC: Delivery adapter and controller safety policy must be configured separately
    end
```

Topics published:

The workflow suppresses pre-charge when there are no points for the panel's
current local calendar day; it still publishes the available daily summary.
A present zero-generation forecast remains distinct from missing data. MQTT
publication alone does not prove controller delivery or physical charging.
Repeated workflow runs can publish repeated requests: the integration must
establish deduplication and an actuator-side safety gate before enabling
automatic charging. This workflow does not provide that end-to-end guarantee.

| Topic | retain | Payload |
|-------|--------|---------|
| `solar_forecast/{site_id}/forecast_json` | True | Daily kWh summary (`today_kwh`, `tomorrow_kwh`) |
| `solar_forecast/{site_id}/pre_charge_request` | False | Pre-charge trigger for low **today** forecast (`horizon_hours=24`, `day=today`) |

Environment variables:
```bash
INVERTER_CONTROL_ENABLED=true          # Enable the hook
MQTT_BROKER=localhost                # Cerbo GX MQTT broker host
MQTT_PORT=1883                       # MQTT port
SITE_ID=default                      # Site identifier used in topic paths
PRE_CHARGE_THRESHOLD_WH=6000         # Pre-charge if today's calendar-day forecast < this (Wh)
TOU_EXPENSIVE_START_HOUR=22          # Optional: suppress pre-charge during expensive grid window
TOU_EXPENSIVE_END_HOUR=6
```

The expensive tariff window uses the selected panel's `timezone`, including its
daylight-saving rules. The container's `TZ` does not change this decision. Start
hours are inclusive and end hours are exclusive; `22` to `6` spans midnight, and
an end hour of `24` ends at midnight. For example, a panel in `Europe/Amsterdam`
suppresses pre-charge from 22:00 to 06:00 Amsterdam time even when the container
runs in UTC. Forecast publication continues while pre-charge is suppressed.

---

## Forecast Validation

The implementation trains from historical generation and produces forecasts.
It does not yet persist forecast-versus-actual pairs, calculate operational
MAE/RMSE/MAPE/bias, or automatically retrain based on measured errors. Unit tests
use synthetic weather/generation and check behavior; they are not evidence of
accuracy on a real installation.

See [forecast validation](docs/forecast-validation.md) for the required public
data, chronological holdout, baselines, and metrics before claiming that one
method is more accurate or using forecasts for operational decisions.

---

## Development

### Run Tests

```bash
pytest tests/ -v --cov=solar_forecast
```

### Lint & Format

```bash
ruff check .
ruff format .
```

### Type Check

```bash
mypy solar_forecast/
```

---

## Project Structure

```
solar-forecast-langgraph/
├── solar_forecast/
│   ├── __init__.py          # Public exports
│   ├── config.py            # Panel/Site configuration schemas
│   ├── weather.py           # OpenMeteo client
│   ├── history.py           # Historical data loaders
│   ├── model.py             # Physical/Statistical/LLM models
│   ├── workflow.py          # LangGraph workflow
│   └── main.py              # CLI entry point
├── tests/
│   ├── test_config.py
│   ├── test_weather.py
│   ├── test_model.py
│   └── test_workflow.py
├── .github/workflows/ci-checks.yml # Reusable CI checks
├── pyproject.toml           # Package config
├── site_config.example.py   # Example configuration
└── README.md
```

---

## Roadmap

- [ ] Measure optional LLM forecast adjustments against held-out field data
- [ ] Automated model retraining scheduler
- [ ] Prometheus metrics export
- [ ] Grafana dashboard template
- [ ] Multi-site support in single workflow
- [ ] Battery SoC optimization (not just pre-charge)
- [ ] Shadow modeling from 3D terrain

---

## Docker

The release pipeline builds `linux/amd64` and `linux/arm64` OCI artifacts and
attaches them to GitHub releases. Publishing those verified bytes to GHCR is a
separate explicit operation; pushes to `main` and prereleases do not deploy a
container or move `latest`.

Before configuring Docker or Portainer, verify that the intended stable version
has been published to `ghcr.io/4alvit/solar-forecast-langgraph`, then pin its image
digest. The [distribution runbook](docs/distribution-channels.md) describes the
verification and publication boundary.

## Related Projects

- **[inverter-monitoring](https://github.com/victron-venus/inverter-monitoring)** — Generation data webhook
- **[inverter-control](https://github.com/victron-venus/inverter-control)** — Grid-zero feed-in control

---

## License

MIT License — see [LICENSE](LICENSE) for details.

## Contributing and security

See [CONTRIBUTING.md](CONTRIBUTING.md) for development, bug reports and proposals,
[SECURITY.md](SECURITY.md) for confidential vulnerability reports and deployment
boundaries, and the [OpenSSF evidence index](docs/openssf-evidence.md) for assessment
scope and verification.

HTTPS transport and runtime requirements are described in [TLS policy](docs/tls-policy.md).

Project responsibilities and decisions are documented in [Governance](GOVERNANCE.md), expected conduct in the [Code of conduct](CODE_OF_CONDUCT.md), and priorities in the [Roadmap](ROADMAP.md). The [security assurance case](docs/security-assurance.md) records controls, evidence and unresolved limits.

## Release-candidate fuzzing

The production panel-configuration validator and effective-capacity calculation receive synthetic JSON. Accepted configuration must serialize losslessly and yield finite nonnegative capacity bounded by rated DC capacity. No weather service, LLM, inverter or MQTT broker is contacted.

[RC-only fuzzing policy, local reproduction and failure handling](docs/fuzzing.md).

Release artifact identity, build receipts and local reproduction are documented in
[release packaging](docs/release-packaging.md).
