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,674 @@
#!/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