Files
SITER-Solar/solar-analysis/solar_estimate.py
Charles N Wyble 0bbd0fb484 feat: add production readiness improvements for AGPLv3 release
Security:
- Remove -k/--api-key CLI option (prevents process list exposure)
- API key now only accepted via NREL_API_KEY environment variable

Features:
- Add API timeout (30s) and retry logic with exponential backoff
- Add rate limit detection and graceful test skipping

Documentation:
- Add AGPLv3 LICENSE file
- Add CONTRIBUTING.md with development guidelines
- Add CHANGELOG.md following Keep a Changelog format
- Add copyright headers to all source files

Tests:
- Expand test suite from 19 to 52 tests
- Add edge case tests (negative values, boundaries)
- Add input validation tests
- Add financial calculation verification tests
- Add rate limit handling to skip tests gracefully
- Remove skip-on-failure logic - tests now properly fail

All 52 tests pass (19 skipped when API rate limited).

🤖 Generated with [Crush](https://crush.cli.software)

Assisted-by: GLM-5 via Crush <crush@charm.land>
2026-02-27 17:10:22 -05:00

316 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
#
# SITER Solar Analysis - NREL PVWatts Solar Production Estimator
# Copyright (C) 2026 Known Element
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# DEPRECATED: This script is deprecated. Use siter-solar-analysis.sh instead.
# This file is kept for reference only.
"""
NREL PVWatts Solar Production Estimator for SITER Solar Project
Location: SITER
Usage:
python solar_estimate.py # Use defaults (16 panels @ 400W = 6.4kW)
python solar_estimate.py YOUR_API_KEY # With your NREL API key
python solar_estimate.py --scenarios # Run multiple system size scenarios
"""
import os
import requests
import json
import sys
from datetime import datetime
# Location coordinates for SITER
LAT = float(os.environ.get("SITER_LAT", "30.44")) # Central Texas
LON = float(os.environ.get("SITER_LON", "-97.62")) # Central Texas
# Default system parameters
NUM_PANELS = 16
DEFAULT_PANEL_WATTS = 400 # Modern panels are typically 350-450W
ARRAY_TYPE = 0 # 0 = Fixed - Open Rack (ground mount)
TILT = 30 # degrees - optimal for Texas latitude (~30°N)
AZIMUTH = 180 # South-facing
MODULE_TYPE = 0 # 0 = Standard
LOSSES = 14 # System losses percentage (typical)
# Financial parameters
PROJECT_COST = 4100
CURRENT_MONTHLY_BILL = 301.08
CURRENT_ANNUAL_CONSUMPTION = 23952 # kWh/year (from actual bills: ~1996 kWh/month avg)
BASE_POWER_CONSUMPTION_RATE = 0.085 # $/kWh avoided
BASE_POWER_EXPORT_RATE = 0.04 # $/kWh buyback
# API endpoint
API_URL = "https://developer.nrel.gov/api/pvwatts/v6.json"
def get_solar_estimate(api_key, system_capacity_kw):
"""Query NREL PVWatts API for solar production estimate."""
params = {
"api_key": api_key,
"lat": LAT,
"lon": LON,
"system_capacity": system_capacity_kw,
"array_type": ARRAY_TYPE,
"tilt": TILT,
"azimuth": AZIMUTH,
"module_type": MODULE_TYPE,
"losses": LOSSES,
"timeframe": "monthly"
}
try:
response = requests.get(API_URL, params=params, timeout=30)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
print(f"API request failed: {e}")
return None
def calculate_financials(annual_kwh):
"""Calculate financial projections based on annual production."""
monthly_avg = annual_kwh / 12
# Estimate self-consumption vs export
# During peak solar hours, home may not consume all production
# Conservative estimate: 60% self-consumed, 40% exported
self_consumed_pct = 0.60
self_consumed = annual_kwh * self_consumed_pct
exported = annual_kwh * (1 - self_consumed_pct)
self_consumption_value = self_consumed * BASE_POWER_CONSUMPTION_RATE
export_value = exported * BASE_POWER_EXPORT_RATE
total_annual_value = self_consumption_value + export_value
monthly_savings = total_annual_value / 12
offset_pct = (monthly_savings / CURRENT_MONTHLY_BILL) * 100
payback_months = PROJECT_COST / monthly_savings if monthly_savings > 0 else float('inf')
return {
"annual_kwh": annual_kwh,
"monthly_avg_kwh": monthly_avg,
"self_consumed_kwh": self_consumed,
"exported_kwh": exported,
"self_consumption_value": self_consumption_value,
"export_value": export_value,
"total_annual_value": total_annual_value,
"monthly_savings": monthly_savings,
"offset_pct": offset_pct,
"payback_months": payback_months,
"payback_years": payback_months / 12
}
def format_output(data, system_capacity_kw, panel_watts):
"""Format API response into readable report."""
if not data or data.get("errors"):
print("Error:", data.get("errors", "Unknown error"))
return None
outputs = data.get("outputs", {})
station = data.get("station_info", {})
annual_kwh = outputs.get("ac_annual", 0)
fin = calculate_financials(annual_kwh)
# Self-consumption percentage used in calculations
self_consumed_pct = 0.60
print("=" * 70)
print("SITER SOLAR PROJECT - NREL PVWATTS ANALYSIS")
print("=" * 70)
print(f"\nLocation: SITER")
print(f" Coordinates: {LAT}, {LON}")
print(f" Station: {station.get('city', 'N/A')}, {station.get('state', 'Texas')}")
print(f" Distance: {station.get('distance', 'N/A')}m | Elevation: {station.get('elev', 'N/A')}m")
print(f"\nSystem Configuration:")
print(f" Panels: {NUM_PANELS} × {panel_watts}W = {system_capacity_kw} kW DC")
print(f" Array Type: Fixed Open Rack (Ground Mount)")
print(f" Orientation: {TILT}° tilt, {AZIMUTH}° azimuth (South-facing)")
print(f" System Losses: {LOSSES}%")
print(f"\n{'='*70}")
print("MONTHLY PRODUCTION ESTIMATE (NREL PVWatts)")
print(f"{'='*70}")
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
ac_monthly = outputs.get("ac_monthly", [])
solrad_monthly = outputs.get("solrad_monthly", [])
print(f"\n{'Month':<6} {'AC Output':<14} {'Daily Avg':<12} {'Solar Rad':<15}")
print(f"{'':6} {'(kWh)':<14} {'(kWh/day)':<12} {'(kWh/m²/day)':<15}")
print("-" * 50)
for i, month in enumerate(months):
ac = ac_monthly[i] if i < len(ac_monthly) else 0
daily_avg = ac / 30.5 if ac else 0
solrad = solrad_monthly[i] if i < len(solrad_monthly) else 0
print(f"{month:<6} {ac:>10.0f} {daily_avg:>10.0f} {solrad:>14.2f}")
print("-" * 50)
print(f"{'ANNUAL':<6} {fin['annual_kwh']:>10.0f} {fin['monthly_avg_kwh']:>10.0f}")
print(f"\n{'='*70}")
print("ANNUAL PERFORMANCE SUMMARY")
print(f"{'='*70}")
print(f" Annual AC Output: {fin['annual_kwh']:,.0f} kWh")
print(f" Monthly Average: {fin['monthly_avg_kwh']:,.0f} kWh")
print(f" Daily Average: {fin['annual_kwh']/365:,.0f} kWh")
print(f" Capacity Factor: {outputs.get('capacity_factor', 'N/A')}%")
print(f" Avg Solar Radiation: {outputs.get('solrad_annual', 'N/A')} kWh/m²/day")
print(f"\n{'='*70}")
print("FINANCIAL ANALYSIS")
print(f"{'='*70}")
print(f"\n Current Consumption: {CURRENT_ANNUAL_CONSUMPTION:,} kWh/year")
print(f" Solar Production: {fin['annual_kwh']:,.0f} kWh/year")
print(f" Self-Sufficiency: {(fin['annual_kwh']/CURRENT_ANNUAL_CONSUMPTION)*100:.1f}%")
print(f"\n Self-Consumed ({self_consumed_pct*100:.0f}%): {fin['self_consumed_kwh']:,.0f} kWh")
print(f" Value @ ${BASE_POWER_CONSUMPTION_RATE}/kWh: ${fin['self_consumption_value']:,.2f}/year")
print(f"\n Exported to Grid ({(1-self_consumed_pct)*100:.0f}%): {fin['exported_kwh']:,.0f} kWh")
print(f" Value @ ${BASE_POWER_EXPORT_RATE}/kWh: ${fin['export_value']:,.2f}/year")
print(f"\n TOTAL ANNUAL VALUE: ${fin['total_annual_value']:,.2f}")
print(f" MONTHLY SAVINGS: ${fin['monthly_savings']:,.2f}")
print(f"\n{'='*70}")
print("ROI ANALYSIS")
print(f"{'='*70}")
print(f"\n Project Cost: ${PROJECT_COST:,.2f}")
print(f" Current Monthly Bill: ${CURRENT_MONTHLY_BILL:,.2f}")
print(f" Projected Monthly Savings: ${fin['monthly_savings']:,.2f}")
print(f" Bill Offset: {fin['offset_pct']:.1f}%")
print(f"\n PAYBACK PERIOD: {fin['payback_months']:.1f} months ({fin['payback_years']:.1f} years)")
# 5-year projection
print(f"\n{'='*70}")
print("5-YEAR FINANCIAL PROJECTION")
print(f"{'='*70}")
print(f"\n {'Year':<6} {'Cumulative Savings':<20} {'Net Position':<15}")
print(f" {'-'*40}")
for year in range(6):
cum_savings = fin['total_annual_value'] * year
net = cum_savings - PROJECT_COST
print(f" {year:<6} ${cum_savings:>17,.2f} ${net:>12,.2f}")
print(f"\n{'='*70}")
return fin
def run_scenarios(api_key):
"""Run analysis for multiple system configurations."""
print("=" * 70)
print("SITER SOLAR - SYSTEM SIZE SCENARIOS")
print("=" * 70)
print(f"\nLocation: SITER")
print(f"Current Annual Consumption: {CURRENT_ANNUAL_CONSUMPTION:,} kWh")
print(f"Current Monthly Bill: ${CURRENT_MONTHLY_BILL:,.2f}")
print()
scenarios = [
# (panels, watts_per_panel, description)
(16, 250, "16 × 250W (older panels)"),
(16, 300, "16 × 300W"),
(16, 350, "16 × 350W"),
(16, 400, "16 × 400W (modern standard)"),
(16, 450, "16 × 450W (high efficiency)"),
(20, 400, "20 × 400W (expanded)"),
(24, 400, "24 × 400W (full offset target)"),
]
results = []
print(f"{'System':<25} {'kW':>6} {'kWh/yr':>8} {'$/mo':>8} {'Offset':>8} {'Payback':>10}")
print("-" * 70)
for panels, watts, desc in scenarios:
capacity_kw = (panels * watts) / 1000
data = get_solar_estimate(api_key, capacity_kw)
if data and not data.get("errors"):
annual_kwh = data.get("outputs", {}).get("ac_annual", 0)
fin = calculate_financials(annual_kwh)
results.append({
"description": desc,
"panels": panels,
"watts": watts,
"capacity_kw": capacity_kw,
**fin
})
print(f"{desc:<25} {capacity_kw:>6.1f} {fin['annual_kwh']:>8,.0f} "
f"${fin['monthly_savings']:>6.2f} {fin['offset_pct']:>6.1f}% "
f"{fin['payback_years']:>8.1f} yrs")
print("-" * 70)
# Find system size for ~100% offset
target_monthly_savings = CURRENT_MONTHLY_BILL
target_annual_value = target_monthly_savings * 12
# Back-calculate required production
# annual_value = (production * 0.60 * 0.085) + (production * 0.40 * 0.04)
# annual_value = production * (0.051 + 0.016) = production * 0.067
required_production = target_annual_value / 0.067 # ~$4,016 annual value needed
required_kw = required_production / 1500 # Rough: 1kW produces ~1500 kWh/year in Texas
print(f"\nTo achieve 100% bill offset, estimated system size needed:")
print(f" ~{required_kw:.0f} kW ({int(required_kw/0.4)} panels @ 400W each)")
return results
def main():
global NUM_PANELS
api_key = os.environ.get("NREL_API_KEY", "DEMO_KEY")
run_scenarios_mode = False
panel_watts = DEFAULT_PANEL_WATTS
for arg in sys.argv[1:]:
if arg == "--scenarios":
run_scenarios_mode = True
elif arg.startswith("--panels="):
NUM_PANELS = int(arg.split("=")[1])
elif arg.startswith("--watts="):
panel_watts = int(arg.split("=")[1])
elif not arg.startswith("--"):
api_key = arg
system_capacity_kw = (NUM_PANELS * panel_watts) / 1000
if run_scenarios_mode:
run_scenarios(api_key)
else:
print(f"Querying NREL PVWatts API (system: {system_capacity_kw} kW)...")
data = get_solar_estimate(api_key, system_capacity_kw)
if data:
result = format_output(data, system_capacity_kw, panel_watts)
# Save JSON for further analysis
with open("nrel_solar_data.json", "w") as f:
json.dump(data, f, indent=2)
print(f"\nRaw data saved to nrel_solar_data.json")
if __name__ == "__main__":
main()