Files
SITER-Solar/solar-analysis/siter-solar-analysis.sh
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

704 lines
22 KiB
Bash
Executable File
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 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 <https://www.gnu.org/licenses/>.
# 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