Files
SITER-Solar/solar-analysis/siter-solar-analysis.sh
Charles N Wyble 400764a9ff 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>
2026-02-27 16:45:41 -05:00

675 lines
21 KiB
Bash
Executable File
Raw 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
# 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"
# 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:
-k, --api-key KEY NREL API key (or set NREL_API_KEY env var)
--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")
# With your API key
$(basename "$0") -k YOUR_API_KEY
# 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
# shellcheck disable=SC2086
response=$(curl -s -w "\n%{http_code}" ${url} 2>/dev/null)
http_code=$(echo "$response" | tail -n1)
response=$(echo "$response" | sed '$d')
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
-k|--api-key)
api_key="$2"
shift 2
;;
--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."
;;
*)
# Positional argument - treat as API key
api_key="$1"
shift
;;
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