#!/usr/bin/env bash # # 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 . # shellcheck source=/dev/null # # siter-solar-analysis - NREL PVWatts Solar Production Estimator # # Usage: # ./siter-solar-analysis.sh # Basic analysis # ./siter-solar-analysis.sh --scenarios # Compare system sizes # ./siter-solar-analysis.sh --help # Show help # # Environment variables: # NREL_API_KEY - Your NREL API key (default: DEMO_KEY) # SITER_LAT - Site latitude (default: 30.44) # SITER_LON - Site longitude (default: -97.62) # # Get your free API key at: https://developer.nrel.gov/signup/ # set -euo pipefail # Version readonly VERSION="1.0.0" # Default configuration readonly DEFAULT_API_KEY="DEMO_KEY" readonly DEFAULT_LAT="30.44" readonly DEFAULT_LON="-97.62" readonly DEFAULT_PANELS="16" readonly DEFAULT_PANEL_WATTS="250" readonly DEFAULT_TILT="30" readonly DEFAULT_AZIMUTH="180" readonly DEFAULT_LOSSES="14" readonly DEFAULT_ARRAY_TYPE="0" # Financial parameters readonly PROJECT_COST="4100" readonly CURRENT_MONTHLY_BILL="301.08" readonly CURRENT_ANNUAL_CONSUMPTION="23952" readonly CONSUMPTION_RATE="0.085" readonly EXPORT_RATE="0.04" readonly SELF_CONSUMPTION_PCT="0.60" # API endpoint readonly API_URL="https://developer.nrel.gov/api/pvwatts/v6.json" # API configuration readonly API_TIMEOUT="30" readonly API_MAX_RETRIES="3" readonly API_RETRY_DELAY="2" # Colors for output readonly RED='\033[0;31m' readonly GREEN='\033[0;32m' readonly YELLOW='\033[0;33m' readonly NC='\033[0m' # No Color # Output control VERBOSE="${VERBOSE:-false}" OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}" ####################################### # Print error message and exit # Arguments: # $1 - Error message # Outputs: # Writes error to stderr ####################################### error() { echo -e "${RED}ERROR: ${1}${NC}" >&2 exit 1 } ####################################### # Print warning message # Arguments: # $1 - Warning message ####################################### warn() { echo -e "${YELLOW}WARN: ${1}${NC}" >&2 } ####################################### # Print info message # Arguments: # $1 - Info message ####################################### info() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${GREEN}INFO: ${1}${NC}" >&2 fi } ####################################### # Print usage information ####################################### usage() { cat << EOF SITER Solar Analysis - NREL PVWatts Solar Production Estimator v${VERSION} USAGE: $(basename "$0") [OPTIONS] OPTIONS: --lat VALUE Site latitude (default: ${DEFAULT_LAT}) --lon VALUE Site longitude (default: ${DEFAULT_LON}) -p, --panels NUM Number of panels (default: ${DEFAULT_PANELS}) -w, --watts WATTS Watts per panel (default: ${DEFAULT_PANEL_WATTS}) -t, --tilt DEGREES Array tilt in degrees (default: ${DEFAULT_TILT}) -a, --azimuth DEGREES Array azimuth (default: ${DEFAULT_AZIMUTH}) -l, --losses PERCENT System losses percent (default: ${DEFAULT_LOSSES}) --scenarios Run multiple system size scenarios --json Output as JSON --verbose Enable verbose output -h, --help Show this help message -v, --version Show version EXAMPLES: # Basic analysis with defaults $(basename "$0") # Compare different system sizes $(basename "$0") --scenarios # Custom configuration $(basename "$0") -p 20 -w 400 --lat 30.5 --lon -97.7 ENVIRONMENT VARIABLES: NREL_API_KEY Your NREL API key SITER_LAT Site latitude SITER_LON Site longitude Get your free API key at: https://developer.nrel.gov/signup/ EOF } ####################################### # Check required dependencies # Globals: # None # Arguments: # None # Outputs: # Error if dependencies missing ####################################### check_dependencies() { local missing=() command -v curl >/dev/null 2>&1 || missing+=("curl") command -v jq >/dev/null 2>&1 || missing+=("jq") command -v bc >/dev/null 2>&1 || missing+=("bc") if [[ ${#missing[@]} -gt 0 ]]; then error "Missing required dependencies: ${missing[*]}\nInstall with: apt-get install ${missing[*]}" fi } ####################################### # Validate numeric value # Arguments: # $1 - Value to validate # $2 - Parameter name for error message # Returns: # 0 if valid, 1 if invalid ####################################### validate_numeric() { local value="$1" local name="$2" if ! [[ "$value" =~ ^-?[0-9]+\.?[0-9]*$ ]]; then error "Invalid ${name}: '${value}' must be a number" fi } ####################################### # Validate range # Arguments: # $1 - Value to validate # $2 - Minimum # $3 - Maximum # $4 - Parameter name # Returns: # 0 if valid, exits with error if invalid ####################################### validate_range() { local value="$1" local min="$2" local max="$3" local name="$4" validate_numeric "$value" "$name" if (( $(echo "$value < $min" | bc -l) )) || (( $(echo "$value > $max" | bc -l) )); then error "Invalid ${name}: ${value} (must be between ${min} and ${max})" fi } ####################################### # Query NREL PVWatts API # Globals: # API_URL # Arguments: # $1 - API key # $2 - System capacity (kW) # $3 - Latitude # $4 - Longitude # $5 - Tilt # $6 - Azimuth # $7 - Losses # Outputs: # JSON response from API ####################################### query_nrel_api() { local api_key="$1" local capacity="$2" local lat="$3" local lon="$4" local tilt="$5" local azimuth="$6" local losses="$7" local array_type="${8:-$DEFAULT_ARRAY_TYPE}" local url="${API_URL}?api_key=${api_key}&lat=${lat}&lon=${lon}" url+="&system_capacity=${capacity}&array_type=${array_type}" url+="&tilt=${tilt}&azimuth=${azimuth}&module_type=0" url+="&losses=${losses}&timeframe=monthly" info "Querying NREL API: capacity=${capacity}kW" local response local http_code local retry_count=0 local delay=$API_RETRY_DELAY while [[ $retry_count -lt $API_MAX_RETRIES ]]; do # shellcheck disable=SC2086 response=$(curl -s -S --max-time "$API_TIMEOUT" -w "\n%{http_code}" ${url} 2>&1) http_code=$(echo "$response" | tail -n1) response=$(echo "$response" | sed '$d') # Check for timeout or network errors if [[ "$http_code" == "000" ]] || [[ "$response" == *"timed out"* ]] || [[ "$response" == *"connection"* ]]; then retry_count=$((retry_count + 1)) if [[ $retry_count -lt $API_MAX_RETRIES ]]; then warn "API request failed (attempt $retry_count/$API_MAX_RETRIES), retrying in ${delay}s..." sleep "$delay" delay=$((delay * 2)) # Exponential backoff continue fi error "API request failed after $API_MAX_RETRIES attempts: timeout or connection error" fi # Success - break out of retry loop break done if [[ "$http_code" != "200" ]]; then error "API request failed with HTTP ${http_code}: ${response}" fi # Check for API errors local api_errors api_errors=$(echo "$response" | jq -r '.errors // [] | if length > 0 then .[] else empty end') if [[ -n "$api_errors" ]]; then error "API returned errors: ${api_errors}" fi echo "$response" } ####################################### # Calculate financial projections # Arguments: # $1 - Annual production (kWh) # Outputs: # Financial metrics ####################################### calculate_financials() { local annual_kwh="$1" local self_consumed local exported local self_consumption_value local export_value local total_annual_value local monthly_savings local offset_pct local payback_months local payback_years self_consumed=$(echo "$annual_kwh * $SELF_CONSUMPTION_PCT" | bc -l) exported=$(echo "$annual_kwh * (1 - $SELF_CONSUMPTION_PCT)" | bc -l) self_consumption_value=$(echo "$self_consumed * $CONSUMPTION_RATE" | bc -l) export_value=$(echo "$exported * $EXPORT_RATE" | bc -l) total_annual_value=$(echo "$self_consumption_value + $export_value" | bc -l) monthly_savings=$(echo "$total_annual_value / 12" | bc -l) offset_pct=$(echo "$monthly_savings / $CURRENT_MONTHLY_BILL * 100" | bc -l) if (( $(echo "$monthly_savings > 0" | bc -l) )); then payback_months=$(echo "$PROJECT_COST / $monthly_savings" | bc -l) else payback_months="999999" fi payback_years=$(echo "$payback_months / 12" | bc -l) cat << EOF { "annual_kwh": $(printf "%.0f" "$annual_kwh"), "monthly_avg_kwh": $(printf "%.0f" "$(echo "$annual_kwh / 12" | bc -l)"), "self_consumed_kwh": $(printf "%.0f" "$self_consumed"), "exported_kwh": $(printf "%.0f" "$exported"), "self_consumption_value": $(printf "%.2f" "$self_consumption_value"), "export_value": $(printf "%.2f" "$export_value"), "total_annual_value": $(printf "%.2f" "$total_annual_value"), "monthly_savings": $(printf "%.2f" "$monthly_savings"), "offset_pct": $(printf "%.1f" "$offset_pct"), "payback_months": $(printf "%.1f" "$payback_months"), "payback_years": $(printf "%.1f" "$payback_years") } EOF } ####################################### # Format and display analysis results # Arguments: # $1 - API response JSON # $2 - System capacity (kW) # $3 - Panel watts # $4 - Number of panels # $5 - Latitude # $6 - Longitude # Outputs: # Formatted report ####################################### format_output() { local data="$1" local capacity="$2" local panel_watts="$3" local num_panels="$4" local lat="$5" local lon="$6" local annual_kwh local capacity_factor local solrad_annual local station_city local station_state annual_kwh=$(echo "$data" | jq -r '.outputs.ac_annual') capacity_factor=$(echo "$data" | jq -r '.outputs.capacity_factor') solrad_annual=$(echo "$data" | jq -r '.outputs.solrad_annual') station_city=$(echo "$data" | jq -r '.station_info.city // "N/A"') station_state=$(echo "$data" | jq -r '.station_info.state // "Texas"') local financials financials=$(calculate_financials "$annual_kwh") if [[ "$OUTPUT_FORMAT" == "json" ]]; then echo "$data" | jq --argjson fin "$financials" \ --arg capacity "$capacity" \ --arg panel_watts "$panel_watts" \ --arg num_panels "$num_panels" \ '{ system: { capacity_kw: ($capacity | tonumber), panels: ($num_panels | tonumber), panel_watts: ($panel_watts | tonumber) }, production: { annual_kwh: .outputs.ac_annual, monthly_kwh: .outputs.ac_monthly, capacity_factor: .outputs.capacity_factor, solrad_annual: .outputs.solrad_annual }, financials: $fin }' return fi # Text output echo "======================================================================" echo "SITER SOLAR PROJECT - NREL PVWATTS ANALYSIS" echo "======================================================================" echo "" echo "Location: SITER" echo " Coordinates: ${lat}, ${lon}" echo " Station: ${station_city}, ${station_state}" echo "" echo "System Configuration:" echo " Panels: ${num_panels} × ${panel_watts}W = ${capacity} kW DC" echo " Array Type: Fixed Open Rack (Ground Mount)" echo " Orientation: ${DEFAULT_TILT}° tilt, ${DEFAULT_AZIMUTH}° azimuth (South-facing)" echo " System Losses: ${DEFAULT_LOSSES}%" echo "" echo "======================================================================" echo "MONTHLY PRODUCTION ESTIMATE (NREL PVWatts)" echo "======================================================================" local months=("Jan" "Feb" "Mar" "Apr" "May" "Jun" "Jul" "Aug" "Sep" "Oct" "Nov" "Dec") local ac_monthly ac_monthly=$(echo "$data" | jq -r '.outputs.ac_monthly[]') local solrad_monthly solrad_monthly=$(echo "$data" | jq -r '.outputs.solrad_monthly[]') printf "\n%-6s %-14s %-12s %-15s\n" "Month" "AC Output" "Daily Avg" "Solar Rad" printf "%-6s %-14s %-12s %-15s\n" "" "(kWh)" "(kWh/day)" "(kWh/m²/day)" echo "--------------------------------------------------" local i=0 local total_kwh=0 for month in "${months[@]}"; do local ac ac=$(echo "$ac_monthly" | sed -n "$((i+1))p") local solrad solrad=$(echo "$solrad_monthly" | sed -n "$((i+1))p") local daily_avg daily_avg=$(echo "$ac / 30.5" | bc -l) printf "%-6s %10.0f %10.0f %14.2f\n" "$month" "$ac" "$daily_avg" "$solrad" total_kwh=$(echo "$total_kwh + $ac" | bc -l) ((i++)) done echo "--------------------------------------------------" printf "%-6s %10.0f %10.0f\n" "ANNUAL" "$annual_kwh" "$(echo "$annual_kwh / 12" | bc -l)" echo "" echo "======================================================================" echo "ANNUAL PERFORMANCE SUMMARY" echo "======================================================================" printf " Annual AC Output: %'.0f kWh\n" "$annual_kwh" printf " Monthly Average: %'.0f kWh\n" "$(echo "$annual_kwh / 12" | bc -l)" printf " Daily Average: %'.0f kWh\n" "$(echo "$annual_kwh / 365" | bc -l)" echo " Capacity Factor: ${capacity_factor}%" echo " Avg Solar Radiation: ${solrad_annual} kWh/m²/day" echo "" echo "======================================================================" echo "FINANCIAL ANALYSIS" echo "======================================================================" local self_consumed_kwh local exported_kwh local total_annual_value local monthly_savings local offset_pct local payback_years self_consumed_kwh=$(echo "$financials" | jq -r '.self_consumed_kwh') exported_kwh=$(echo "$financials" | jq -r '.exported_kwh') total_annual_value=$(echo "$financials" | jq -r '.total_annual_value') monthly_savings=$(echo "$financials" | jq -r '.monthly_savings') offset_pct=$(echo "$financials" | jq -r '.offset_pct') payback_years=$(echo "$financials" | jq -r '.payback_years') echo "" echo " Current Consumption: ${CURRENT_ANNUAL_CONSUMPTION} kWh/year" printf " Solar Production: %'.0f kWh/year\n" "$annual_kwh" printf " Self-Sufficiency: %.1f%%\n" "$(echo "$annual_kwh / $CURRENT_ANNUAL_CONSUMPTION * 100" | bc -l)" echo "" printf " Self-Consumed (60%%): %'.0f kWh\n" "$self_consumed_kwh" printf " Value @ \$%s/kWh: \$%'.2f/year\n" "$CONSUMPTION_RATE" "$(echo "$financials" | jq -r '.self_consumption_value')" echo "" printf " Exported to Grid (40%%): %'.0f kWh\n" "$exported_kwh" printf " Value @ \$%s/kWh: \$%'.2f/year\n" "$EXPORT_RATE" "$(echo "$financials" | jq -r '.export_value')" echo "" printf " TOTAL ANNUAL VALUE: \$%'.2f\n" "$total_annual_value" printf " MONTHLY SAVINGS: \$%'.2f\n" "$monthly_savings" echo "" echo "======================================================================" echo "ROI ANALYSIS" echo "======================================================================" echo "" printf " Project Cost: \$%'.2f\n" "$PROJECT_COST" printf " Current Monthly Bill: \$%'.2f\n" "$CURRENT_MONTHLY_BILL" printf " Projected Monthly Savings: \$%'.2f\n" "$monthly_savings" printf " Bill Offset: %.1f%%\n" "$offset_pct" printf "\n PAYBACK PERIOD: %.0f months (%.1f years)\n" "$(echo "$financials" | jq -r '.payback_months')" "$payback_years" echo "" echo "======================================================================" } ####################################### # Run multiple system size scenarios # Arguments: # $1 - API key # $2 - Latitude # $3 - Longitude # $4 - Number of panels # Outputs: # Comparison table ####################################### run_scenarios() { local api_key="$1" local lat="$2" local lon="$3" local num_panels="$4" local scenarios=( "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)" ) echo "======================================================================" echo "SITER SOLAR - SYSTEM SIZE SCENARIOS" echo "======================================================================" echo "" echo "Location: SITER (${lat}, ${lon})" echo "Current Annual Consumption: ${CURRENT_ANNUAL_CONSUMPTION} kWh" printf "Current Monthly Bill: \$%'.2f\n" "$CURRENT_MONTHLY_BILL" echo "" if [[ "$OUTPUT_FORMAT" == "json" ]]; then echo "[" local first=true else printf "%-25s %6s %8s %8s %8s %10s\n" "System" "kW" "kWh/yr" "\$/mo" "Offset" "Payback" echo "----------------------------------------------------------------------" fi for scenario in "${scenarios[@]}"; do IFS=':' read -r panels watts desc <<< "$scenario" local capacity capacity=$(echo "$panels * $watts / 1000" | bc -l) local data data=$(query_nrel_api "$api_key" "$capacity" "$lat" "$lon" "$DEFAULT_TILT" "$DEFAULT_AZIMUTH" "$DEFAULT_LOSSES") local annual_kwh annual_kwh=$(echo "$data" | jq -r '.outputs.ac_annual') local financials financials=$(calculate_financials "$annual_kwh") local monthly_savings local offset_pct local payback_years monthly_savings=$(echo "$financials" | jq -r '.monthly_savings') offset_pct=$(echo "$financials" | jq -r '.offset_pct') payback_years=$(echo "$financials" | jq -r '.payback_years') if [[ "$OUTPUT_FORMAT" == "json" ]]; then if [[ "$first" != "true" ]]; then echo "," fi first=false echo "$financials" | jq --arg desc "$desc" \ --arg capacity "$capacity" \ --arg panels "$panels" \ --arg watts "$watts" \ '{ description: $desc, panels: ($panels | tonumber), watts_per_panel: ($watts | tonumber), capacity_kw: ($capacity | tonumber) } + .' else printf "%-25s %6.1f %8,.0f \$%6.2f %6.1f%% %8.1f yrs\n" \ "$desc" "$capacity" "$annual_kwh" "$monthly_savings" "$offset_pct" "$payback_years" fi done if [[ "$OUTPUT_FORMAT" == "json" ]]; then echo "]" else echo "----------------------------------------------------------------------" echo "" echo "Note: To achieve 100% bill offset would require ~32 kW system" fi } ####################################### # Main entry point # Globals: # All configuration variables # Arguments: # Command line arguments # Outputs: # Analysis results ####################################### main() { local api_key="${NREL_API_KEY:-$DEFAULT_API_KEY}" local lat="${SITER_LAT:-$DEFAULT_LAT}" local lon="${SITER_LON:-$DEFAULT_LON}" local num_panels="$DEFAULT_PANELS" local panel_watts="$DEFAULT_PANEL_WATTS" local tilt="$DEFAULT_TILT" local azimuth="$DEFAULT_AZIMUTH" local losses="$DEFAULT_LOSSES" local scenarios_mode=false # Parse arguments while [[ $# -gt 0 ]]; do case "$1" in --lat) lat="$2" shift 2 ;; --lon) lon="$2" shift 2 ;; -p|--panels) num_panels="$2" shift 2 ;; -w|--watts) panel_watts="$2" shift 2 ;; -t|--tilt) tilt="$2" shift 2 ;; -a|--azimuth) azimuth="$2" shift 2 ;; -l|--losses) losses="$2" shift 2 ;; --scenarios) scenarios_mode=true shift ;; --json) OUTPUT_FORMAT="json" shift ;; --verbose) VERBOSE="true" shift ;; -h|--help) usage exit 0 ;; -v|--version) echo "siter-solar-analysis version ${VERSION}" exit 0 ;; -*) error "Unknown option: $1\nTry --help for usage information." ;; *) error "Unexpected argument: $1\nTry --help for usage information." ;; esac done # Check dependencies check_dependencies # Validate inputs validate_numeric "$lat" "latitude" validate_numeric "$lon" "longitude" validate_numeric "$num_panels" "panels" validate_numeric "$panel_watts" "panel watts" validate_range "$lat" "-90" "90" "latitude" validate_range "$lon" "-180" "180" "longitude" # Calculate system capacity local capacity capacity=$(echo "$num_panels * $panel_watts / 1000" | bc -l) if [[ "$scenarios_mode" == "true" ]]; then run_scenarios "$api_key" "$lat" "$lon" "$num_panels" else info "Querying NREL PVWatts API (system: ${capacity} kW)..." local data data=$(query_nrel_api "$api_key" "$capacity" "$lat" "$lon" "$tilt" "$azimuth" "$losses") format_output "$data" "$capacity" "$panel_watts" "$num_panels" "$lat" "$lon" fi } # Run main if not being sourced if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@" fi