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:
Charles N Wyble
2026-02-27 16:45:41 -05:00
commit 400764a9ff
22 changed files with 3587 additions and 0 deletions

View 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()