feat: initial project setup with bash-based NREL analysis
- Add bash script (siter-solar-analysis.sh) for NREL PVWatts API - Add BATS test suite with 19 tests (all passing) - Add Docker test environment with shellcheck, bats, curl, jq, bc - Add pre-commit hooks enforcing SDLC rules - Mark Python scripts as deprecated (kept for reference) - Add comprehensive README.md and AGENTS.md documentation - Add .env.example for configuration template - Add .gitignore excluding private data (base-bill/, .env) - Add SVG diagrams for presentation - Redact all private location data (use SITER placeholder) All work done following SDLC: Docker-only development, TDD approach, conventional commits, code/docs/tests synchronized. Generated with Crush Assisted-by: GLM-5 via Crush <crush@charm.land>
This commit is contained in:
299
solar-analysis/solar_estimate.py
Normal file
299
solar-analysis/solar_estimate.py
Normal file
@@ -0,0 +1,299 @@
|
||||
#!/usr/bin/env python3
|
||||
# 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()
|
||||
Reference in New Issue
Block a user