[Racket] Ferti hydroponic nutrient solver, redux.
1
#lang racket
2
3
(provide find-ferti-recipe)
4
5
(require math/array
6
math/matrix
7
"../models/nutrient.rkt"
8
"../models/fertilizer-product.rkt"
9
"../models/nutrient-measurement.rkt"
10
"../models/crop-rotation.rkt"
11
"../models/crop-requirement.rkt")
12
13
(define (find-ferti-recipe date-string)
14
(define fertilizers (get-fertilizer-products))
15
(define measurement-values
16
(nutrient-measurement-values
17
(or (get-nutrient-measurement #:date date-string)
18
(error 'nnls
19
"Can't compute the Ferti recipe (missing nutrient measurement for ~a)"
20
date-string))))
21
(define rotation-values
22
(average-crop-requirement-nutrient-values
23
(crop-rotation-requirements
24
(or
25
(get-crop-rotation #:date date-string)
26
(error 'nnls "Can't compute the Ferti recipe (missing crop rotation for ~a)" date-string)))))
27
(define solution-array (solve-nnls fertilizers measurement-values rotation-values))
28
(for/hash ([fertilizer (in-list fertilizers)]
29
[quantity (in-array solution-array)])
30
(values fertilizer quantity)))
31
32
(define (solve-nnls fertilizers measurement-values rotation-values)
33
(define nutrients (get-nutrients))
34
(define fertilizer-product-matrix (get-fertilizer-product-matrix nutrients fertilizers))
35
(define deficits
36
(->col-matrix (for/list ([n nutrients])
37
(define measured (hash-ref measurement-values n 0))
38
(define required (hash-ref rotation-values n 0))
39
(define deficit
40
(if (zero? required)
41
0
42
(* 100 (/ (- required measured) required))))
43
deficit)))
44
(define error-threshold 10e-4)
45
(lawson-hanson-1974 fertilizer-product-matrix deficits error-threshold))
46
47
;; Algorithm lifted from the Wikipedia article on NNLS
48
(define (lawson-hanson-1974 A y ε)
49
;;;;;;;;;
50
;; Inputs
51
;;;;;;;;;
52
53
(-> matrix? ; Real-valued matrix A of dimension m × n
54
col-matrix? ; Real-valued column matrix (vector) y of dimension m
55
real? ; Real-value ɛ, tolerance for the stopping criterion
56
col-matrix?) ; Real-valued solution column matrix x
57
(define-values (m ; Number of nutrients
58
n) ; Number of fertilizer products
59
(matrix-shape A))
60
61
;;;;;;;;;;;;;
62
;; Initialize
63
;;;;;;;;;;;;;
64
65
;; The passive set P is initially empty.
66
(define P (mutable-set))
67
;; The active set R initially contains the indexes to the nutrients allowed to be...
68
(define R (list->mutable-set (range n)))
69
70
(define (colv-ref v i)
71
(matrix-ref v i 0))
72
73
;; Gradient-like vector for residual error.
74
(define (compute-w x)
75
(matrix* (matrix-transpose A) (matrix- y (matrix* A x))))
76
77
;; max over j in R of w_j.
78
(define (max-w-in-R w)
79
(if (set-empty? R)
80
(values -inf.0 #f)
81
(let ([max-j (argmax (λ (j) (colv-ref w j)) (set->list R))])
82
(values (colv-ref w max-j) max-j))))
83
84
;; Build full candidate vector s from current P:
85
;; s_P = (A_Pᵀ A_P)⁻¹ A_Pᵀ y, s_R = 0
86
(define (make-s-from-P)
87
(if (set-empty? P)
88
(make-matrix n 1 0)
89
(let* ([idxs (sort (set->list P) <)]
90
[AP (submatrix A (::) idxs)]
91
[sP (matrix* (matrix-inverse (matrix* (matrix-transpose AP) AP))
92
(matrix-transpose AP)
93
y)])
94
;; map: column index j in P -> corresponding sP entry
95
(define mapping
96
(for/list ([j idxs]
97
[k (in-naturals)])
98
(cons j (colv-ref sP k))))
99
(define (s-at i)
100
(define p (assoc i mapping))
101
(if p
102
(cdr p)
103
0))
104
(build-matrix n 1 (λ (i j) (s-at i))))))
105
106
;; The "first try" x represents no addition of any fertilizer.
107
(define x (make-matrix n 1 0))
108
109
;;;;;;;;;;;;;
110
;; Outer loop
111
;;;;;;;;;;;;;
112
113
(let outer-loop ()
114
(define w (compute-w x))
115
116
(cond
117
;; If no remaining candidates in R, we're done.
118
[(set-empty? R) x]
119
120
[else
121
(define-values (max-val j*) (max-w-in-R w))
122
123
;; Stopping criterion: max(w_R) <= ε
124
(cond
125
[(or (not j*) (<= max-val ε)) x]
126
127
[else
128
;; Add j* to P, remove from R
129
(set-remove! R j*)
130
(set-add! P j*)
131
132
;; Inner loop: adjust until s_P > 0
133
(let inner-loop ()
134
(define s (make-s-from-P))
135
(define min-sP
136
(if (set-empty? P)
137
+inf.0
138
(for/fold ([mn +inf.0]) ([j (in-set P)])
139
(min mn (colv-ref s j)))))
140
(cond
141
;; If all s_P > 0 (or P empty), accept s as new x and go back to outer loop
142
[(or (set-empty? P) (> min-sP 0))
143
(set! x s)
144
(outer-loop)]
145
146
[else
147
;; Compute α = min_{i in P, s_i <= 0} x_i / (x_i - s_i)
148
(define α
149
(for/fold ([a +inf.0]) ([j (in-set P)])
150
(define sj (colv-ref s j))
151
(if (<= sj 0)
152
(let* ([xj (colv-ref x j)]
153
[den (- xj sj)])
154
(if (> den 0)
155
(min a (/ xj den))
156
a))
157
a)))
158
159
(when (or (equal? α +inf.0) (<= α 0))
160
(error 'lawson-hanson-1974 "no valid α in inner loop"))
161
162
;; x ← x + α (s − x)
163
(define new-x (matrix+ x (matrix-scale (matrix- s x) α)))
164
165
;; Move to R all indices j in P with x_j <= 0
166
(define to-remove '())
167
(for ([j (in-set P)])
168
(when (<= (colv-ref new-x j) 0)
169
(set! to-remove (cons j to-remove))))
170
(for ([j to-remove])
171
(set-remove! P j)
172
(set-add! R j))
173
174
(set! x new-x)
175
(inner-loop)]))])])))
176
177
(define (get-fertilizer-product-matrix nutrients fertilizers)
178
;; Lines are nutrients, columns are fertilizers
179
(build-matrix (length nutrients)
180
(length fertilizers)
181
(λ (i j)
182
(define selected-nutrient (list-ref nutrients i))
183
(define product (list-ref fertilizers j))
184
(hash-ref (fertilizer-product-values product) selected-nutrient 0))))
185
186
(module+ test
187
(require rackunit
188
rackunit/text-ui
189
"../db/conn.rkt"
190
"../db/migrations.rkt")
191
192
(run-tests
193
(test-suite "NNLS"
194
#:before (λ ()
195
(connect! #:path 'memory)
196
(migrate-all!)
197
(create-nutrient! "Nitrogen" "Azote" "N")
198
(create-nutrient! "Phosphorus" "Phosphore" "P")
199
(create-nutrient! "Potassium" "Potassium" "K"))
200
#:after (λ () (disconnect!))
201
202
(test-case "Build fertilizer product matrix"
203
(define n (get-nutrient #:name "Nitrogen"))
204
(define p (get-nutrient #:name "Phosphorus"))
205
206
(define nutrients (list n p))
207
208
(define f1 (create-fertilizer-product! "F1" "F1" (hash n 10 p 20)))
209
(define f2 (create-fertilizer-product! "F2" "F2" (hash n 30 p 5)))
210
(define fertilizers (list f1 f2))
211
212
(define matrix (get-fertilizer-product-matrix nutrients fertilizers))
213
214
(check-= (matrix-ref matrix 0 0) 10 0 "N1 in F1")
215
(check-= (matrix-ref matrix 0 1) 30 0 "N1 in F2")
216
(check-= (matrix-ref matrix 1 0) 20 0 "N2 in F1")
217
(check-= (matrix-ref matrix 1 1) 5 0 "N2 in F2"))
218
219
(test-case "Single nutrient, single fertilizer"
220
(define A (matrix [[2]]))
221
(define y (col-matrix [10]))
222
(define ε 1e-6)
223
224
(define result (lawson-hanson-1974 A y ε))
225
226
(check-= (matrix-ref result 0 0) 5.0 ε "Should give x = 5 since 2*5 = 10"))
227
228
(test-case "Two variables, known solution"
229
(define A (matrix [[1 0] [0 1]]))
230
(define y (col-matrix [3 4]))
231
(define ε 1e-6)
232
233
(define result (lawson-hanson-1974 A y ε))
234
235
(check-= (matrix-ref result 0 0) 3.0 ε "x1 should be 3")
236
(check-= (matrix-ref result 1 0) 4.0 ε "x2 should be 4"))
237
238
(test-case "Overdetermined system"
239
(define A (matrix [[1 1] [2 1] [1 2]]))
240
(define y (col-matrix [3 5 5]))
241
(define ε 1e-4)
242
243
(define result (lawson-hanson-1974 A y ε))
244
245
;; Solution should be approximately [1.636, 1.636] (least squares fit)
246
(check-= (matrix-ref result 0 0) 1.636 0.01 "x1 approximately 1.636")
247
(check-= (matrix-ref result 1 0) 1.636 0.01 "x2 approximately 1.636"))
248
249
(test-case "Non-negativity enforcement"
250
(define A (matrix [[1 -1] [1 1]]))
251
(define y (col-matrix [1 3]))
252
(define ε 1e-6)
253
254
(define result (lawson-hanson-1974 A y ε))
255
256
;; All results should be non-negative
257
(check-true (>= (matrix-ref result 0 0) 0) "x1 should be non-negative")
258
(check-true (>= (matrix-ref result 1 0) 0) "x2 should be non-negative"))
259
260
(test-case "Zero target"
261
(define A (matrix [[1 2] [3 4]]))
262
(define y (col-matrix [0 0]))
263
(define ε 1e-6)
264
265
(define result (lawson-hanson-1974 A y ε))
266
267
(check-= (matrix-ref result 0 0) 0.0 ε "x1 should be 0")
268
(check-= (matrix-ref result 1 0) 0.0 ε "x2 should be 0"))
269
270
(test-case "Ferti recipe"
271
(define test-date "2025-01-01")
272
(define n (get-nutrient #:name "Nitrogen"))
273
(define p (get-nutrient #:name "Phosphorus"))
274
(define k (get-nutrient #:name "Potassium"))
275
276
(create-nutrient-measurement! (nutrient-measurement #f test-date (hash n 0 p 0)))
277
(define test-crop-requirement (create-crop-requirement! "Test requirement" (hash n 100 p 50)))
278
(create-crop-rotation! test-date (hash test-crop-requirement 100))
279
280
(create-fertilizer-product! "Nitrogen" "King Nitrogen" (hash n 100))
281
(create-fertilizer-product! "Phosphorus" "Phosphorescent Baboon" (hash n 10 p 100))
282
(create-fertilizer-product! "Diluted phosphorus" "John's Phosphorus" (hash n 3 p 30))
283
284
(define recipe (find-ferti-recipe test-date))
285
286
(check-equal? (hash-count recipe) 5 "Should have 5 fertilizer products")
287
288
(for ([(fertilizer quantity) (in-hash recipe)])
289
(check-true (>= quantity 0) "Fertilizer quantity should be non-negative")))
290
291
(test-case "Recipe calculation with real-world scenario"
292
(define test-date "2025-01-02")
293
(define n (get-nutrient #:name "Nitrogen"))
294
(define p (get-nutrient #:name "Phosphorus"))
295
(define k (get-nutrient #:name "Potassium"))
296
297
;; Current levels
298
(create-nutrient-measurement! (nutrient-measurement #f test-date (hash n 50 p 10 k 100)))
299
300
;; Target levels
301
(define test-crop-requirement
302
(create-crop-requirement! "Test requirement 2" (hash n 150 p 30 k 200)))
303
(create-crop-rotation! test-date (hash test-crop-requirement 100))
304
305
;; Available fertilizer products
306
(create-fertilizer-product! "" "Balanced" (hash n 100 p 100 k 100))
307
(create-fertilizer-product! "Nitrogen blend" "High-N" (hash n 200 p 50 k 50))
308
(create-fertilizer-product! "Phosphorus blend" "High-P" (hash n 50 p 200 k 50))
309
(create-fertilizer-product! "Potassium blend" "High-K" (hash n 50 p 50 k 200))
310
311
(define recipe (find-ferti-recipe test-date))
312
313
(check-equal? (hash-count recipe) 9 "Recipe should have 9 fertilizers")
314
315
;; Verify solution is non-negative
316
(for ([(fertilizer quantity) (in-hash recipe)])
317
(check-true (>= quantity 0)
318
(format "~a quantity must be non-negative"
319
(fertilizer-product-name fertilizer))))))))
320