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.
Files changed (4) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +23 -0
  3. data/lib/solar_panel_yield_2026.rb +189 -0
  4. 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: []