solar_panel_yield_2026 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +23 -0
- data/lib/solar_panel_yield_2026.rb +189 -0
- metadata +43 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4ec088af1f46477feb41662dbf9fbc93d19e3bfd6701f7850654ea4cf8e4958b
|
|
4
|
+
data.tar.gz: a833f25cd886c69215697f8f91ac9cd24ef589169d6ade3638648bf326ecb5e8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a9f8d9609cffa9efa6c7ac39d481728db88717ff3621816d077b294e9f9f97ba08bfe0b129fc04375d4bb5a566f028ef5d4f235002ef37d30181928df0d302f8
|
|
7
|
+
data.tar.gz: ecc290c8b4a6422deb08c65b358ea7cba449a94e6155fec0ba957ad150b756521111bcb8a1d1840624e3f49cce63a8fbe93723701e2b860348ca3d7c4ff29912
|
data/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# solar_panel_yield_2026 (Ruby)
|
|
2
|
+
|
|
3
|
+
Ruby port of the Solar Panel Cleaning Yield Recovery reference engine.
|
|
4
|
+
See the project root README for full documentation and the working paper.
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
require "solar_panel_yield_2026"
|
|
8
|
+
|
|
9
|
+
result = SolarPanelYield2026.calculate(
|
|
10
|
+
system_capacity_kwp: 5.0,
|
|
11
|
+
ghi_annual_avg: 3.54,
|
|
12
|
+
days_since_cleaning: 30,
|
|
13
|
+
soil_class: "medium",
|
|
14
|
+
climate_zone: "temperate",
|
|
15
|
+
panel_tilt_deg: 15,
|
|
16
|
+
electricity_price_cad_per_kwh: 0.14,
|
|
17
|
+
cleaning_visit_cost_cad: 120.0,
|
|
18
|
+
tap_water_tds_ppm: 180,
|
|
19
|
+
panel_height_ft: 12.0
|
|
20
|
+
)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
License: MIT.
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Solar Panel Cleaning Yield Recovery -- Ruby reference engine.
|
|
4
|
+
# Hand-port of the Python reference engine. See the project repository for
|
|
5
|
+
# the full working paper and companion datasets.
|
|
6
|
+
module SolarPanelYield2026
|
|
7
|
+
VERSION = "0.1.0"
|
|
8
|
+
|
|
9
|
+
CLIMATE_RATE = {
|
|
10
|
+
"arid" => 0.30,
|
|
11
|
+
"temperate" => 0.18,
|
|
12
|
+
"humid" => 0.12,
|
|
13
|
+
"cold_snow" => 0.20,
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
TILT_MULT = { 5 => 1.15, 15 => 1.00, 25 => 0.88, 35 => 0.80, 45 => 0.74 }.freeze
|
|
17
|
+
SOIL_MULT = { "low" => 0.60, "medium" => 1.00, "high" => 1.55 }.freeze
|
|
18
|
+
|
|
19
|
+
CEILING_BASE = 12.0
|
|
20
|
+
DEFAULT_PR = 0.80
|
|
21
|
+
RESIN_CAPACITY_CONST = 17118.0
|
|
22
|
+
RO_THRESHOLD_PPM = 250.0
|
|
23
|
+
|
|
24
|
+
STANDARD_POLE_FT = [8, 10, 15, 20, 25, 30, 35, 40, 45].freeze
|
|
25
|
+
CARTRIDGES = [
|
|
26
|
+
["1L_cartridge", 0.80],
|
|
27
|
+
["3L_cartridge", 2.40],
|
|
28
|
+
["7L_cartridge", 5.60],
|
|
29
|
+
["14L_tank", 11.20],
|
|
30
|
+
["28L_tank", 22.40],
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
INTERVALS = [7, 14, 21, 30, 45, 60, 75, 90, 120, 150, 180, 240, 300, 365].freeze
|
|
34
|
+
|
|
35
|
+
DISCLAIMER = "This reference model is not professional engineering advice. " \
|
|
36
|
+
"Site-specific cleaning decisions should consult module warranty requirements, " \
|
|
37
|
+
"site fall-protection plans, and a qualified PV operations and maintenance provider."
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
def daily_rate(zone, tilt_deg, soil_class)
|
|
42
|
+
raise ArgumentError, "invalid climate_zone" unless CLIMATE_RATE.key?(zone)
|
|
43
|
+
raise ArgumentError, "invalid soil_class" unless SOIL_MULT.key?(soil_class)
|
|
44
|
+
tilt_key = TILT_MULT.keys.min_by { |k| (k - tilt_deg).abs }
|
|
45
|
+
CLIMATE_RATE[zone] * TILT_MULT[tilt_key] * SOIL_MULT[soil_class]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def ceiling(soil_class)
|
|
49
|
+
raise ArgumentError, "invalid soil_class" unless SOIL_MULT.key?(soil_class)
|
|
50
|
+
CEILING_BASE * SOIL_MULT[soil_class]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def current_loss_pct(days, daily, ceiling)
|
|
54
|
+
return 0.0 if daily <= 0 || days <= 0 || ceiling <= 0
|
|
55
|
+
ceiling * (1.0 - Math.exp(-daily * days / ceiling))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def average_loss_pct(interval_days, daily, ceiling)
|
|
59
|
+
return 0.0 if daily <= 0 || interval_days <= 0 || ceiling <= 0
|
|
60
|
+
k_over_c = daily / ceiling
|
|
61
|
+
ceiling * (1.0 - (1.0 - Math.exp(-k_over_c * interval_days)) / (k_over_c * interval_days))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def clean_annual_kwh(kwp, ghi, pr)
|
|
65
|
+
kwp * ghi * 365.0 * pr
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def recommend_pole_length_ft(panel_height_ft)
|
|
69
|
+
needed = [8.0, panel_height_ft + 3.0 - 5.0].max
|
|
70
|
+
STANDARD_POLE_FT.each { |len| return len if len >= needed }
|
|
71
|
+
STANDARD_POLE_FT.last
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def recommend_cartridge(inlet_tds_ppm, panels_estimated)
|
|
75
|
+
ro_pre = inlet_tds_ppm > RO_THRESHOLD_PPM
|
|
76
|
+
effective_tds = ro_pre ? 15.0 : inlet_tds_ppm.to_f
|
|
77
|
+
gallons_per_cleaning = panels_estimated / 3.78541
|
|
78
|
+
target_gal = [gallons_per_cleaning * 2.0, 50.0].max
|
|
79
|
+
CARTRIDGES.each do |name, kgr|
|
|
80
|
+
capacity = (kgr * RESIN_CAPACITY_CONST) / [effective_tds, 1.0].max
|
|
81
|
+
return [name, ro_pre] if capacity >= target_gal
|
|
82
|
+
end
|
|
83
|
+
[CARTRIDGES.last[0], ro_pre]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def calculate(req)
|
|
87
|
+
r = {
|
|
88
|
+
system_capacity_kwp: req[:system_capacity_kwp],
|
|
89
|
+
ghi_annual_avg: req[:ghi_annual_avg],
|
|
90
|
+
days_since_cleaning: req[:days_since_cleaning],
|
|
91
|
+
soil_class: req[:soil_class] || "medium",
|
|
92
|
+
climate_zone: req[:climate_zone] || "temperate",
|
|
93
|
+
panel_tilt_deg: req[:panel_tilt_deg] || 15,
|
|
94
|
+
electricity_price_cad_per_kwh: req[:electricity_price_cad_per_kwh] || 0.14,
|
|
95
|
+
cleaning_visit_cost_cad: req[:cleaning_visit_cost_cad] || 120.0,
|
|
96
|
+
tap_water_tds_ppm: req[:tap_water_tds_ppm] || 160,
|
|
97
|
+
panel_height_ft: req[:panel_height_ft] || 12.0,
|
|
98
|
+
performance_ratio: req[:performance_ratio] || DEFAULT_PR,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
raise ArgumentError, "system_capacity_kwp must be in (0, 10000]" \
|
|
102
|
+
if r[:system_capacity_kwp] <= 0 || r[:system_capacity_kwp] > 10000
|
|
103
|
+
raise ArgumentError, "ghi_annual_avg must be in (0, 10]" \
|
|
104
|
+
if r[:ghi_annual_avg] <= 0 || r[:ghi_annual_avg] > 10
|
|
105
|
+
raise ArgumentError, "days_since_cleaning out of range" \
|
|
106
|
+
if r[:days_since_cleaning] < 0 || r[:days_since_cleaning] > 3650
|
|
107
|
+
|
|
108
|
+
daily = daily_rate(r[:climate_zone], r[:panel_tilt_deg], r[:soil_class])
|
|
109
|
+
ceil = ceiling(r[:soil_class])
|
|
110
|
+
variability = Math.sqrt(0.15**2 + 0.10**2)
|
|
111
|
+
|
|
112
|
+
loss_pct = current_loss_pct(r[:days_since_cleaning], daily, ceil)
|
|
113
|
+
loss_range = { low: loss_pct * (1 - variability), expected: loss_pct, high: loss_pct * (1 + variability) }
|
|
114
|
+
|
|
115
|
+
annual_kwh = clean_annual_kwh(r[:system_capacity_kwp], r[:ghi_annual_avg], r[:performance_ratio])
|
|
116
|
+
lost_cad_per_day = (annual_kwh / 365.0) * (loss_pct / 100.0) * r[:electricity_price_cad_per_kwh]
|
|
117
|
+
lost_range = {
|
|
118
|
+
low: lost_cad_per_day * (1 - variability),
|
|
119
|
+
expected: lost_cad_per_day,
|
|
120
|
+
high: lost_cad_per_day * (1 + variability),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
never_clean_loss_cad = annual_kwh * (ceil / 100.0) * r[:electricity_price_cad_per_kwh]
|
|
124
|
+
|
|
125
|
+
best_t = 365
|
|
126
|
+
best_net = -Float::INFINITY
|
|
127
|
+
INTERVALS.each do |t|
|
|
128
|
+
avg_loss = average_loss_pct(t, daily, ceil)
|
|
129
|
+
lost_cad_t = annual_kwh * (avg_loss / 100.0) * r[:electricity_price_cad_per_kwh]
|
|
130
|
+
avoided = never_clean_loss_cad - lost_cad_t
|
|
131
|
+
annual_clean_cost = (365.0 / t) * r[:cleaning_visit_cost_cad]
|
|
132
|
+
net = avoided - annual_clean_cost
|
|
133
|
+
if net > best_net
|
|
134
|
+
best_net = net
|
|
135
|
+
best_t = t
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
optimal_range = { low: [7, (best_t * 0.8).to_i].max.to_f, expected: best_t.to_f, high: [365, (best_t * 1.2).to_i].min.to_f }
|
|
140
|
+
recovered_range = if best_net >= 0
|
|
141
|
+
{ low: best_net * (1 - variability), expected: best_net, high: best_net * (1 + variability) }
|
|
142
|
+
else
|
|
143
|
+
{ low: best_net * (1 + variability), expected: best_net, high: best_net * (1 - variability) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
panels_estimated = [1.0, r[:system_capacity_kwp] / 0.33].max
|
|
147
|
+
pole_ft = recommend_pole_length_ft(r[:panel_height_ft])
|
|
148
|
+
cartridge, ro_pre = recommend_cartridge(r[:tap_water_tds_ppm], panels_estimated)
|
|
149
|
+
water_per_cleaning_l = panels_estimated * 1.0
|
|
150
|
+
|
|
151
|
+
{
|
|
152
|
+
current_loss_pct: loss_range,
|
|
153
|
+
lost_cad_per_day: lost_range,
|
|
154
|
+
optimal_interval_days: optimal_range,
|
|
155
|
+
annual_recovered_cad: recovered_range,
|
|
156
|
+
wfp_pole_ft: pole_ft,
|
|
157
|
+
wfp_cartridge: cartridge,
|
|
158
|
+
water_per_cleaning_l: (water_per_cleaning_l * 10).round / 10.0,
|
|
159
|
+
ro_prestage_recommended: ro_pre,
|
|
160
|
+
clean_annual_kwh: (annual_kwh * 10).round / 10.0,
|
|
161
|
+
never_clean_annual_loss_cad: (never_clean_loss_cad * 100).round / 100.0,
|
|
162
|
+
daily_rate_pct_per_day: (daily * 10000).round / 10000.0,
|
|
163
|
+
ceiling_pct: (ceil * 100).round / 100.0,
|
|
164
|
+
disclaimer: DISCLAIMER,
|
|
165
|
+
version: VERSION,
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def canonical_vector
|
|
170
|
+
{
|
|
171
|
+
system_capacity_kwp: 5.0,
|
|
172
|
+
ghi_annual_avg: 3.54,
|
|
173
|
+
days_since_cleaning: 30,
|
|
174
|
+
soil_class: "medium",
|
|
175
|
+
climate_zone: "temperate",
|
|
176
|
+
panel_tilt_deg: 15,
|
|
177
|
+
electricity_price_cad_per_kwh: 0.14,
|
|
178
|
+
cleaning_visit_cost_cad: 120.0,
|
|
179
|
+
tap_water_tds_ppm: 180,
|
|
180
|
+
panel_height_ft: 12.0,
|
|
181
|
+
performance_ratio: DEFAULT_PR,
|
|
182
|
+
}
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
if __FILE__ == $PROGRAM_NAME
|
|
187
|
+
require "json"
|
|
188
|
+
puts JSON.pretty_generate(SolarPanelYield2026.calculate(SolarPanelYield2026.canonical_vector))
|
|
189
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: solar_panel_yield_2026
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Dave Cook
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-04-12 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Ruby port of the Solar Panel Cleaning Yield Recovery reference engine.
|
|
13
|
+
email:
|
|
14
|
+
- dave@binx.ca
|
|
15
|
+
executables: []
|
|
16
|
+
extensions: []
|
|
17
|
+
extra_rdoc_files: []
|
|
18
|
+
files:
|
|
19
|
+
- README.md
|
|
20
|
+
- lib/solar_panel_yield_2026.rb
|
|
21
|
+
homepage: https://github.com/DaveCookVectorLabs/solar_panel_yield_2026
|
|
22
|
+
licenses:
|
|
23
|
+
- MIT
|
|
24
|
+
metadata:
|
|
25
|
+
source_code_uri: https://github.com/DaveCookVectorLabs/solar_panel_yield_2026
|
|
26
|
+
rdoc_options: []
|
|
27
|
+
require_paths:
|
|
28
|
+
- lib
|
|
29
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: 2.7.0
|
|
34
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '0'
|
|
39
|
+
requirements: []
|
|
40
|
+
rubygems_version: 3.6.3
|
|
41
|
+
specification_version: 4
|
|
42
|
+
summary: Reference calculator for PV soiling loss and water-fed pole cleaning ROI.
|
|
43
|
+
test_files: []
|