stat_power 0.1.0.alpha.1 → 0.1.0.alpha.2

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.
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ # Power analysis for general linear models using Cohen's f².
5
+ #
6
+ # The statistical parameterisation follows CRAN pwr.f2.test. Exactly one of
7
+ # numerator_df, denominator_df, effect_size, alpha, and power must be nil.
8
+ module F2
9
+ DF_LOWER = 1e-10
10
+ DF_UPPER = 1e9
11
+ EFFECT_SIZE_LOWER = 1e-10
12
+ EFFECT_SIZE_UPPER = 1e4
13
+ PROBABILITY_EPSILON = 1e-10
14
+
15
+ module_function
16
+
17
+ # Solve one missing parameter of a general linear-model power analysis.
18
+ #
19
+ # @param numerator_df [Numeric, nil] numerator degrees of freedom, u
20
+ # @param denominator_df [Numeric, nil] denominator degrees of freedom, v
21
+ # @param effect_size [Numeric, Symbol, String, nil] Cohen's f²
22
+ # @param alpha [Numeric, nil] Type I error probability
23
+ # @param power [Numeric, nil] statistical power
24
+ # @return [StatPower::F2Result]
25
+ def solve(
26
+ numerator_df: nil,
27
+ denominator_df: nil,
28
+ effect_size: nil,
29
+ alpha: 0.05,
30
+ power: nil
31
+ )
32
+ ensure_one_missing!(
33
+ numerator_df,
34
+ denominator_df,
35
+ effect_size,
36
+ alpha,
37
+ power
38
+ )
39
+
40
+ numerator_df = optional_float(numerator_df)
41
+ denominator_df = optional_float(denominator_df)
42
+ effect_size = normalize_effect_size(effect_size)
43
+ alpha = optional_float(alpha)
44
+ power = optional_float(power)
45
+
46
+ validate_known_values!(
47
+ numerator_df:,
48
+ denominator_df:,
49
+ effect_size:,
50
+ alpha:,
51
+ power:
52
+ )
53
+
54
+ numerator_df, denominator_df, effect_size, alpha, power = solve_missing(
55
+ numerator_df:,
56
+ denominator_df:,
57
+ effect_size:,
58
+ alpha:,
59
+ power:
60
+ )
61
+
62
+ F2Result.new(
63
+ numerator_df:,
64
+ denominator_df:,
65
+ power:,
66
+ effect_size:,
67
+ alpha:,
68
+ analysis_method: "general linear model power calculation"
69
+ )
70
+ end
71
+
72
+ def solve_missing(numerator_df:, denominator_df:, effect_size:, alpha:, power:)
73
+ if power.nil?
74
+ power = power_for(
75
+ numerator_df:,
76
+ denominator_df:,
77
+ effect_size:,
78
+ alpha:
79
+ )
80
+ elsif numerator_df.nil?
81
+ numerator_df = solve_numerator_df(
82
+ denominator_df:,
83
+ effect_size:,
84
+ alpha:,
85
+ power:
86
+ )
87
+ elsif denominator_df.nil?
88
+ denominator_df = solve_denominator_df(
89
+ numerator_df:,
90
+ effect_size:,
91
+ alpha:,
92
+ power:
93
+ )
94
+ elsif effect_size.nil?
95
+ effect_size = solve_effect_size(
96
+ numerator_df:,
97
+ denominator_df:,
98
+ alpha:,
99
+ power:
100
+ )
101
+ elsif alpha.nil?
102
+ alpha = solve_alpha(
103
+ numerator_df:,
104
+ denominator_df:,
105
+ effect_size:,
106
+ power:
107
+ )
108
+ end
109
+
110
+ [numerator_df, denominator_df, effect_size, alpha, power]
111
+ end
112
+ private_class_method :solve_missing
113
+
114
+ def power_for(numerator_df:, denominator_df:, effect_size:, alpha:)
115
+ noncentrality = effect_size * (numerator_df + denominator_df + 1.0)
116
+
117
+ critical = Distributions::FDistribution.quantile(
118
+ 1.0 - alpha,
119
+ numerator_df:,
120
+ denominator_df:
121
+ )
122
+
123
+ Distributions::NoncentralF.survival(
124
+ critical,
125
+ numerator_df:,
126
+ denominator_df:,
127
+ noncentrality:
128
+ )
129
+ end
130
+ private_class_method :power_for
131
+
132
+ def solve_numerator_df(denominator_df:, effect_size:, alpha:, power:)
133
+ Solvers::Bisection.solve(
134
+ lower: DF_LOWER,
135
+ upper: DF_UPPER
136
+ ) do |candidate|
137
+ power_for(
138
+ numerator_df: candidate,
139
+ denominator_df:,
140
+ effect_size:,
141
+ alpha:
142
+ ) - power
143
+ end
144
+ end
145
+ private_class_method :solve_numerator_df
146
+
147
+ def solve_denominator_df(numerator_df:, effect_size:, alpha:, power:)
148
+ Solvers::Bisection.solve(
149
+ lower: DF_LOWER,
150
+ upper: DF_UPPER
151
+ ) do |candidate|
152
+ power_for(
153
+ numerator_df:,
154
+ denominator_df: candidate,
155
+ effect_size:,
156
+ alpha:
157
+ ) - power
158
+ end
159
+ end
160
+ private_class_method :solve_denominator_df
161
+
162
+ def solve_effect_size(numerator_df:, denominator_df:, alpha:, power:)
163
+ Solvers::Bisection.solve(
164
+ lower: EFFECT_SIZE_LOWER,
165
+ upper: EFFECT_SIZE_UPPER
166
+ ) do |candidate|
167
+ power_for(
168
+ numerator_df:,
169
+ denominator_df:,
170
+ effect_size: candidate,
171
+ alpha:
172
+ ) - power
173
+ end
174
+ end
175
+ private_class_method :solve_effect_size
176
+
177
+ def solve_alpha(numerator_df:, denominator_df:, effect_size:, power:)
178
+ Solvers::Bisection.solve(
179
+ lower: PROBABILITY_EPSILON,
180
+ upper: 1.0 - PROBABILITY_EPSILON
181
+ ) do |candidate|
182
+ power_for(
183
+ numerator_df:,
184
+ denominator_df:,
185
+ effect_size:,
186
+ alpha: candidate
187
+ ) - power
188
+ end
189
+ end
190
+ private_class_method :solve_alpha
191
+
192
+ def ensure_one_missing!(*values)
193
+ return if values.count(&:nil?) == 1
194
+
195
+ raise StatPower::DomainError,
196
+ "exactly one of numerator_df, denominator_df, effect_size, alpha, and power must be nil"
197
+ end
198
+ private_class_method :ensure_one_missing!
199
+
200
+ def normalize_effect_size(value)
201
+ return nil if value.nil?
202
+ return EffectSize::Conventional.resolve(test: :f2, size: value) if value.is_a?(String) || value.is_a?(Symbol)
203
+
204
+ Float(value)
205
+ rescue ArgumentError, TypeError
206
+ raise StatPower::DomainError, "effect_size must be numeric or a conventional size"
207
+ end
208
+ private_class_method :normalize_effect_size
209
+
210
+ def optional_float(value)
211
+ value.nil? ? nil : Float(value)
212
+ rescue ArgumentError, TypeError
213
+ raise StatPower::DomainError, "numeric parameters must be coercible to Float"
214
+ end
215
+ private_class_method :optional_float
216
+
217
+ def validate_known_values!(numerator_df:, denominator_df:, effect_size:, alpha:, power:)
218
+ validate_df!("numerator_df", numerator_df) if numerator_df
219
+ validate_df!("denominator_df", denominator_df) if denominator_df
220
+ validate_effect_size!(effect_size) if effect_size
221
+ validate_probability!("alpha", alpha) if alpha
222
+ validate_probability!("power", power) if power
223
+ end
224
+ private_class_method :validate_known_values!
225
+
226
+ def validate_df!(name, value)
227
+ return if value.finite? && value.positive?
228
+
229
+ raise StatPower::DomainError, "#{name} must be finite and positive"
230
+ end
231
+ private_class_method :validate_df!
232
+
233
+ def validate_effect_size!(effect_size)
234
+ return if effect_size.finite? && !effect_size.negative?
235
+
236
+ raise StatPower::DomainError, "effect_size must be finite and non-negative"
237
+ end
238
+ private_class_method :validate_effect_size!
239
+
240
+ def validate_probability!(name, value)
241
+ return if value.finite? && value.positive? && value < 1.0
242
+
243
+ raise StatPower::DomainError, "#{name} must lie strictly between 0 and 1"
244
+ end
245
+ private_class_method :validate_probability!
246
+ end
247
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ # Immutable result for general linear-model power analyses based on Cohen's f².
5
+ F2Result = Data.define(
6
+ :numerator_df,
7
+ :denominator_df,
8
+ :power,
9
+ :effect_size,
10
+ :alpha,
11
+ :analysis_method
12
+ ) do
13
+ # Implied total sample size N = u + v + 1.
14
+ #
15
+ # @return [Float]
16
+ def total_sample_size
17
+ numerator_df + denominator_df + 1.0
18
+ end
19
+
20
+ # Smallest whole-number total sample size not below the continuous value.
21
+ #
22
+ # @return [Integer]
23
+ def required_total_sample_size
24
+ total_sample_size.ceil
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ # Data-first power-curve generation.
5
+ #
6
+ # A caller supplies the sample sizes and a block that evaluates an existing
7
+ # power solver at each sample size. The block may return either a numeric
8
+ # power value or any result object exposing a +power+ reader.
9
+ module PowerCurve
10
+ module_function
11
+
12
+ # Generate power-curve points.
13
+ #
14
+ # @param sample_sizes [Enumerable<Numeric>] sample sizes to evaluate
15
+ # @yieldparam sample_size [Float] current sample size
16
+ # @yieldreturn [Numeric, #power] achieved power or result object
17
+ # @return [Array<StatPower::PowerCurvePoint>]
18
+ def generate(sample_sizes:)
19
+ unless block_given?
20
+ raise StatPower::DomainError,
21
+ "a block evaluating power at each sample size is required"
22
+ end
23
+
24
+ sizes = normalize_sample_sizes(sample_sizes)
25
+
26
+ sizes.map do |sample_size|
27
+ evaluated = yield(sample_size)
28
+ power = extract_power(evaluated)
29
+
30
+ PowerCurvePoint.new(
31
+ sample_size:,
32
+ power:
33
+ )
34
+ end
35
+ end
36
+
37
+ def normalize_sample_sizes(values)
38
+ unless values.respond_to?(:map)
39
+ raise StatPower::DomainError,
40
+ "sample_sizes must be an enumerable collection"
41
+ end
42
+
43
+ sizes = values.map do |value|
44
+ sample_size = Float(value)
45
+
46
+ unless sample_size.finite? && sample_size.positive?
47
+ raise StatPower::DomainError,
48
+ "sample sizes must be finite and positive"
49
+ end
50
+
51
+ sample_size
52
+ rescue ArgumentError, TypeError
53
+ raise StatPower::DomainError,
54
+ "sample sizes must be numeric"
55
+ end
56
+
57
+ if sizes.empty?
58
+ raise StatPower::DomainError,
59
+ "sample_sizes must contain at least one value"
60
+ end
61
+
62
+ sizes
63
+ end
64
+ private_class_method :normalize_sample_sizes
65
+
66
+ def extract_power(value)
67
+ power = if value.respond_to?(:power)
68
+ value.power
69
+ else
70
+ Float(value)
71
+ end
72
+
73
+ power = Float(power)
74
+
75
+ return power if power.finite? && power >= 0.0 && power <= 1.0
76
+
77
+ raise StatPower::DomainError,
78
+ "power evaluations must lie in [0, 1]"
79
+ rescue ArgumentError, TypeError
80
+ raise StatPower::DomainError,
81
+ "power evaluations must be numeric or expose a numeric power value"
82
+ end
83
+ private_class_method :extract_power
84
+ end
85
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ # Immutable point on a statistical power curve.
5
+ PowerCurvePoint = Data.define(:sample_size, :power)
6
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ module SpecialFunctions
5
+ # Numerical utilities for the regularized incomplete gamma functions.
6
+ module Gamma
7
+ MAX_ITERATIONS = 10_000
8
+ EPSILON = 3e-14
9
+ MIN_DENOMINATOR = 1e-300
10
+
11
+ module_function
12
+
13
+ # Regularized lower incomplete gamma P(a, x).
14
+ #
15
+ # @param a [Numeric] positive shape parameter
16
+ # @param x [Numeric] non-negative evaluation point
17
+ # @return [Float]
18
+ def regularized_lower(a:, x:)
19
+ shape = Float(a)
20
+ value = Float(x)
21
+ validate_arguments!(shape, value)
22
+
23
+ return 0.0 if value.zero?
24
+
25
+ if value < shape + 1.0
26
+ lower_series(shape, value)
27
+ else
28
+ 1.0 - upper_continued_fraction(shape, value)
29
+ end
30
+ end
31
+
32
+ # Regularized upper incomplete gamma Q(a, x).
33
+ #
34
+ # @param a [Numeric] positive shape parameter
35
+ # @param x [Numeric] non-negative evaluation point
36
+ # @return [Float]
37
+ def regularized_upper(a:, x:)
38
+ shape = Float(a)
39
+ value = Float(x)
40
+ validate_arguments!(shape, value)
41
+
42
+ return 1.0 if value.zero?
43
+
44
+ if value < shape + 1.0
45
+ 1.0 - lower_series(shape, value)
46
+ else
47
+ upper_continued_fraction(shape, value)
48
+ end
49
+ end
50
+
51
+ def lower_series(shape, value)
52
+ term = 1.0 / shape
53
+ sum = term
54
+ shifted_shape = shape
55
+
56
+ 1.upto(MAX_ITERATIONS) do
57
+ shifted_shape += 1.0
58
+ term *= value / shifted_shape
59
+ sum += term
60
+
61
+ return sum * scale_factor(shape, value) if term.abs <= sum.abs * EPSILON
62
+ end
63
+
64
+ raise StatPower::ConvergenceError,
65
+ "regularized gamma series did not converge"
66
+ end
67
+ private_class_method :lower_series
68
+
69
+ def upper_continued_fraction(shape, value)
70
+ b = value + 1.0 - shape
71
+ c = 1.0 / MIN_DENOMINATOR
72
+ d = 1.0 / stabilize(b)
73
+ result = d
74
+
75
+ 1.upto(MAX_ITERATIONS) do |iteration|
76
+ coefficient = -iteration * (iteration - shape)
77
+ b += 2.0
78
+ d = 1.0 / stabilize((coefficient * d) + b)
79
+ c = stabilize(b + (coefficient / c))
80
+ delta = d * c
81
+ result *= delta
82
+
83
+ return result * scale_factor(shape, value) if (delta - 1.0).abs <= EPSILON
84
+ end
85
+
86
+ raise StatPower::ConvergenceError,
87
+ "regularized gamma continued fraction did not converge"
88
+ end
89
+ private_class_method :upper_continued_fraction
90
+
91
+ def scale_factor(shape, value)
92
+ Math.exp(
93
+ (-value) +
94
+ (shape * Math.log(value)) -
95
+ Math.lgamma(shape).first
96
+ )
97
+ end
98
+ private_class_method :scale_factor
99
+
100
+ def stabilize(value)
101
+ return value if value.abs >= MIN_DENOMINATOR
102
+
103
+ value.negative? ? -MIN_DENOMINATOR : MIN_DENOMINATOR
104
+ end
105
+ private_class_method :stabilize
106
+
107
+ def validate_arguments!(shape, value)
108
+ unless shape.finite? && shape.positive?
109
+ raise StatPower::DomainError, "gamma shape parameter must be finite and positive"
110
+ end
111
+
112
+ return if value.finite? && !value.negative?
113
+
114
+ raise StatPower::DomainError, "gamma evaluation point must be finite and non-negative"
115
+ end
116
+ private_class_method :validate_arguments!
117
+ end
118
+ end
119
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StatPower
4
- VERSION = "0.1.0.alpha.1"
4
+ VERSION = "0.1.0.alpha.2"
5
5
  end
data/lib/stat_power.rb CHANGED
@@ -4,22 +4,34 @@ require_relative "stat_power/version"
4
4
  require_relative "stat_power/result"
5
5
  require_relative "stat_power/power_result"
6
6
  require_relative "stat_power/unequal_power_result"
7
+ require_relative "stat_power/anova_result"
8
+ require_relative "stat_power/f2_result"
9
+ require_relative "stat_power/chi_square_result"
10
+ require_relative "stat_power/power_curve_point"
7
11
  require_relative "stat_power/errors"
8
12
  require_relative "stat_power/special_functions/beta"
13
+ require_relative "stat_power/special_functions/gamma"
9
14
  require_relative "stat_power/integration/adaptive_simpson"
10
15
  require_relative "stat_power/distributions/normal"
11
16
  require_relative "stat_power/distributions/student_t"
12
17
  require_relative "stat_power/distributions/noncentral_t"
13
18
  require_relative "stat_power/distributions/f_distribution"
14
19
  require_relative "stat_power/distributions/noncentral_f"
20
+ require_relative "stat_power/distributions/chi_square"
21
+ require_relative "stat_power/distributions/noncentral_chi_square"
15
22
  require_relative "stat_power/solvers/bisection"
16
23
  require_relative "stat_power/effect_size/conventional"
17
24
  require_relative "stat_power/effect_size/proportion"
25
+ require_relative "stat_power/effect_size/chi_square"
18
26
  require_relative "stat_power/normal_mean"
19
27
  require_relative "stat_power/proportion"
20
28
  require_relative "stat_power/t_test"
21
29
  require_relative "stat_power/t_test_unequal"
22
30
  require_relative "stat_power/correlation"
31
+ require_relative "stat_power/anova"
32
+ require_relative "stat_power/f2"
33
+ require_relative "stat_power/chi_square"
34
+ require_relative "stat_power/power_curve"
23
35
 
24
36
  module StatPower
25
37
  end
data/sig/stat_power.rbs CHANGED
@@ -69,6 +69,80 @@ module StatPower
69
69
  def required_total_sample_size: () -> Integer
70
70
  end
71
71
 
72
+ class AnovaResult
73
+ attr_reader groups: Float
74
+ attr_reader sample_size: Float
75
+ attr_reader power: Float
76
+ attr_reader effect_size: Float
77
+ attr_reader alpha: Float
78
+ attr_reader analysis_method: String
79
+
80
+ def initialize: (
81
+ groups: Float,
82
+ sample_size: Float,
83
+ power: Float,
84
+ effect_size: Float,
85
+ alpha: Float,
86
+ analysis_method: String
87
+ ) -> void
88
+
89
+ def required_groups: () -> Integer
90
+ def required_sample_size: () -> Integer
91
+ def total_sample_size: () -> Float
92
+ def required_total_sample_size: () -> Integer
93
+ end
94
+
95
+ class F2Result
96
+ attr_reader numerator_df: Float
97
+ attr_reader denominator_df: Float
98
+ attr_reader power: Float
99
+ attr_reader effect_size: Float
100
+ attr_reader alpha: Float
101
+ attr_reader analysis_method: String
102
+
103
+ def initialize: (
104
+ numerator_df: Float,
105
+ denominator_df: Float,
106
+ power: Float,
107
+ effect_size: Float,
108
+ alpha: Float,
109
+ analysis_method: String
110
+ ) -> void
111
+
112
+ def total_sample_size: () -> Float
113
+ def required_total_sample_size: () -> Integer
114
+ end
115
+
116
+ class ChiSquareResult
117
+ attr_reader degrees_of_freedom: Float
118
+ attr_reader sample_size: Float
119
+ attr_reader power: Float
120
+ attr_reader effect_size: Float
121
+ attr_reader alpha: Float
122
+ attr_reader analysis_method: String
123
+
124
+ def initialize: (
125
+ degrees_of_freedom: Float,
126
+ sample_size: Float,
127
+ power: Float,
128
+ effect_size: Float,
129
+ alpha: Float,
130
+ analysis_method: String
131
+ ) -> void
132
+
133
+ def required_sample_size: () -> Integer
134
+ end
135
+
136
+ class PowerCurvePoint
137
+ attr_reader sample_size: Float
138
+ attr_reader power: Float
139
+
140
+ def initialize: (
141
+ sample_size: Float,
142
+ power: Float
143
+ ) -> void
144
+ end
145
+
72
146
  module EffectSize
73
147
  module Conventional
74
148
  def self.resolve: (test: Symbol | String, size: Symbol | String) -> Float
@@ -77,6 +151,17 @@ module StatPower
77
151
  module Proportion
78
152
  def self.cohen_h: (p1: Numeric, p2: Numeric) -> Float
79
153
  end
154
+
155
+ module ChiSquare
156
+ def self.goodness_of_fit: (
157
+ null_probabilities: Array[Numeric],
158
+ alternative_probabilities: Array[Numeric]
159
+ ) -> Float
160
+
161
+ def self.association: (
162
+ probabilities: Array[Array[Numeric]]
163
+ ) -> Float
164
+ end
80
165
  end
81
166
 
82
167
  module NormalMean
@@ -161,10 +246,51 @@ module StatPower
161
246
  ) -> PowerResult
162
247
  end
163
248
 
249
+ module Anova
250
+ def self.solve: (
251
+ ?groups: Numeric | nil,
252
+ ?sample_size: Numeric | nil,
253
+ ?effect_size: Numeric | Symbol | String | nil,
254
+ ?alpha: Numeric | nil,
255
+ ?power: Numeric | nil
256
+ ) -> AnovaResult
257
+ end
258
+
259
+ module F2
260
+ def self.solve: (
261
+ ?numerator_df: Numeric | nil,
262
+ ?denominator_df: Numeric | nil,
263
+ ?effect_size: Numeric | Symbol | String | nil,
264
+ ?alpha: Numeric | nil,
265
+ ?power: Numeric | nil
266
+ ) -> F2Result
267
+ end
268
+
269
+ module ChiSquare
270
+ def self.solve: (
271
+ degrees_of_freedom: Numeric,
272
+ ?effect_size: Numeric | Symbol | String | nil,
273
+ ?sample_size: Numeric | nil,
274
+ ?alpha: Numeric | nil,
275
+ ?power: Numeric | nil
276
+ ) -> ChiSquareResult
277
+ end
278
+
279
+ module PowerCurve
280
+ def self.generate: (
281
+ sample_sizes: Enumerable[Numeric]
282
+ ) { (Float) -> Numeric | PowerResult | UnequalPowerResult | AnovaResult | F2Result | ChiSquareResult } -> Array[PowerCurvePoint]
283
+ end
284
+
164
285
  module SpecialFunctions
165
286
  module Beta
166
287
  def self.regularized: (Numeric x, a: Numeric, b: Numeric) -> Float
167
288
  end
289
+
290
+ module Gamma
291
+ def self.regularized_lower: (a: Numeric, x: Numeric) -> Float
292
+ def self.regularized_upper: (a: Numeric, x: Numeric) -> Float
293
+ end
168
294
  end
169
295
 
170
296
  module Integration
@@ -248,6 +374,27 @@ module StatPower
248
374
  noncentrality: Numeric
249
375
  ) -> Float
250
376
  end
377
+
378
+ module ChiSquare
379
+ def self.pdf: (Numeric x, degrees_of_freedom: Numeric) -> Float
380
+ def self.cdf: (Numeric x, degrees_of_freedom: Numeric) -> Float
381
+ def self.survival: (Numeric x, degrees_of_freedom: Numeric) -> Float
382
+ def self.quantile: (Numeric probability, degrees_of_freedom: Numeric) -> Float
383
+ end
384
+
385
+ module NoncentralChiSquare
386
+ def self.cdf: (
387
+ Numeric x,
388
+ degrees_of_freedom: Numeric,
389
+ noncentrality: Numeric
390
+ ) -> Float
391
+
392
+ def self.survival: (
393
+ Numeric x,
394
+ degrees_of_freedom: Numeric,
395
+ noncentrality: Numeric
396
+ ) -> Float
397
+ end
251
398
  end
252
399
 
253
400
  module Solvers