stat_power 0.1.0.alpha.1

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,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ module Distributions
5
+ # Utilities for the standard normal distribution.
6
+ module Normal
7
+ SQRT_TWO = Math.sqrt(2.0)
8
+ INV_SQRT_TWO_PI = 1.0 / Math.sqrt(2.0 * Math::PI)
9
+
10
+ A = [
11
+ -3.969683028665376e+01,
12
+ 2.209460984245205e+02,
13
+ -2.759285104469687e+02,
14
+ 1.383577518672690e+02,
15
+ -3.066479806614716e+01,
16
+ 2.506628277459239e+00
17
+ ].freeze
18
+
19
+ B = [
20
+ -5.447609879822406e+01,
21
+ 1.615858368580409e+02,
22
+ -1.556989798598866e+02,
23
+ 6.680131188771972e+01,
24
+ -1.328068155288572e+01
25
+ ].freeze
26
+
27
+ C = [
28
+ -7.784894002430293e-03,
29
+ -3.223964580411365e-01,
30
+ -2.400758277161838e+00,
31
+ -2.549732539343734e+00,
32
+ 4.374664141464968e+00,
33
+ 2.938163982698783e+00
34
+ ].freeze
35
+
36
+ D = [
37
+ 7.784695709041462e-03,
38
+ 3.224671290700398e-01,
39
+ 2.445134137142996e+00,
40
+ 3.754408661907416e+00
41
+ ].freeze
42
+
43
+ LOWER_TAIL = 0.02425
44
+ UPPER_TAIL = 1.0 - LOWER_TAIL
45
+
46
+ module_function
47
+
48
+ # Probability density function of the standard normal distribution.
49
+ #
50
+ # @param x [Numeric] evaluation point
51
+ # @return [Float] density at x
52
+ def pdf(x)
53
+ value = Float(x)
54
+ INV_SQRT_TWO_PI * Math.exp(-0.5 * value * value)
55
+ end
56
+
57
+ # Cumulative distribution function of the standard normal distribution.
58
+ #
59
+ # Uses erfc rather than 1 + erf to avoid cancellation in the lower tail.
60
+ #
61
+ # @param x [Numeric] evaluation point
62
+ # @return [Float] probability P(Z <= x)
63
+ def cdf(x)
64
+ value = Float(x)
65
+ 0.5 * Math.erfc(-value / SQRT_TWO)
66
+ end
67
+
68
+ # Survival function of the standard normal distribution.
69
+ #
70
+ # @param x [Numeric] evaluation point
71
+ # @return [Float] probability P(Z > x)
72
+ def survival(x)
73
+ value = Float(x)
74
+ 0.5 * Math.erfc(value / SQRT_TWO)
75
+ end
76
+
77
+ # Quantile function of the standard normal distribution.
78
+ #
79
+ # Uses Peter J. Acklam's rational approximation. Boundary probabilities
80
+ # map to the corresponding extended-real quantiles.
81
+ #
82
+ # @param probability [Numeric] probability in [0, 1]
83
+ # @return [Float] z such that P(Z <= z) = probability
84
+ # @raise [StatPower::DomainError] if probability is outside [0, 1]
85
+ def quantile(probability)
86
+ p = Float(probability)
87
+ validate_probability!(p)
88
+
89
+ return -Float::INFINITY if p.zero?
90
+ return Float::INFINITY if p >= 1.0
91
+
92
+ if p < LOWER_TAIL
93
+ lower_tail_quantile(p)
94
+ elsif p > UPPER_TAIL
95
+ -lower_tail_quantile(1.0 - p)
96
+ else
97
+ central_quantile(p)
98
+ end
99
+ end
100
+
101
+ def validate_probability!(probability)
102
+ return if probability.finite? && probability.between?(0.0, 1.0)
103
+
104
+ raise StatPower::DomainError, "probability must be finite and lie in [0, 1]"
105
+ end
106
+ private_class_method :validate_probability!
107
+
108
+ def lower_tail_quantile(probability)
109
+ q = Math.sqrt(-2.0 * Math.log(probability))
110
+
111
+ numerator = (((((C[0] * q) + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5]
112
+ denominator = ((((D[0] * q) + D[1]) * q + D[2]) * q + D[3]) * q + 1.0
113
+
114
+ numerator / denominator
115
+ end
116
+ private_class_method :lower_tail_quantile
117
+
118
+ def central_quantile(probability)
119
+ q = probability - 0.5
120
+ r = q * q
121
+
122
+ numerator = (((((A[0] * r) + A[1]) * r + A[2]) * r + A[3]) * r + A[4]) * r + A[5]
123
+ denominator = (((((B[0] * r) + B[1]) * r + B[2]) * r + B[3]) * r + B[4]) * r + 1.0
124
+
125
+ q * numerator / denominator
126
+ end
127
+ private_class_method :central_quantile
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ module Distributions
5
+ # Central Student t distribution utilities.
6
+ module StudentT
7
+ module_function
8
+
9
+ # Probability density function.
10
+ #
11
+ # @param x [Numeric] evaluation point
12
+ # @param degrees_of_freedom [Numeric] positive degrees of freedom
13
+ # @return [Float]
14
+ def pdf(x, degrees_of_freedom:)
15
+ value = Float(x)
16
+ df = normalize_degrees_of_freedom(degrees_of_freedom)
17
+ return Normal.pdf(value) if df.infinite?
18
+
19
+ log_scale = log_gamma((df + 1.0) / 2.0) -
20
+ log_gamma(df / 2.0) -
21
+ (0.5 * Math.log(df * Math::PI))
22
+ log_kernel = -((df + 1.0) / 2.0) * Math.log(1.0 + ((value * value) / df))
23
+
24
+ Math.exp(log_scale + log_kernel)
25
+ end
26
+
27
+ # Cumulative distribution function.
28
+ #
29
+ # @param x [Numeric] evaluation point
30
+ # @param degrees_of_freedom [Numeric] positive degrees of freedom
31
+ # @return [Float]
32
+ def cdf(x, degrees_of_freedom:)
33
+ value = Float(x)
34
+ df = normalize_degrees_of_freedom(degrees_of_freedom)
35
+ return Normal.cdf(value) if df.infinite?
36
+ return 0.5 if value.zero?
37
+
38
+ beta_argument = df / (df + (value * value))
39
+ beta = SpecialFunctions::Beta.regularized(
40
+ beta_argument,
41
+ a: df / 2.0,
42
+ b: 0.5
43
+ )
44
+
45
+ value.negative? ? 0.5 * beta : 1.0 - (0.5 * beta)
46
+ end
47
+
48
+ # Survival function P(T > x).
49
+ #
50
+ # Uses symmetry to avoid subtractive cancellation.
51
+ #
52
+ # @param x [Numeric] evaluation point
53
+ # @param degrees_of_freedom [Numeric] positive degrees of freedom
54
+ # @return [Float]
55
+ def survival(x, degrees_of_freedom:)
56
+ cdf(-Float(x), degrees_of_freedom:)
57
+ end
58
+
59
+ # Quantile function.
60
+ #
61
+ # @param probability [Numeric] probability in [0, 1]
62
+ # @param degrees_of_freedom [Numeric] positive degrees of freedom
63
+ # @return [Float]
64
+ def quantile(probability, degrees_of_freedom:)
65
+ target = Float(probability)
66
+ validate_probability!(target)
67
+ df = normalize_degrees_of_freedom(degrees_of_freedom)
68
+
69
+ return -Float::INFINITY if target.zero?
70
+ return Float::INFINITY if target >= 1.0
71
+ return 0.0 if (target - 0.5).abs <= Float::EPSILON
72
+ return Normal.quantile(target) if df.infinite?
73
+
74
+ sign = target < 0.5 ? -1.0 : 1.0
75
+ upper_target = target < 0.5 ? 1.0 - target : target
76
+ upper = quantile_upper_bound(upper_target, df)
77
+
78
+ root = Solvers::Bisection.solve(lower: 0.0, upper:) do |candidate|
79
+ cdf(candidate, degrees_of_freedom: df) - upper_target
80
+ end
81
+
82
+ sign * root
83
+ end
84
+
85
+ def quantile_upper_bound(target, degrees_of_freedom)
86
+ upper = 1.0
87
+
88
+ while cdf(upper, degrees_of_freedom:) < target
89
+ upper *= 2.0
90
+ if upper > 1e12
91
+ raise StatPower::ConvergenceError,
92
+ "unable to bracket Student t quantile"
93
+ end
94
+ end
95
+
96
+ upper
97
+ end
98
+ private_class_method :quantile_upper_bound
99
+
100
+ def normalize_degrees_of_freedom(value)
101
+ df = Float(value)
102
+ return df if df.infinite? && df.positive?
103
+ return df if df.finite? && df.positive?
104
+
105
+ raise StatPower::DomainError, "degrees_of_freedom must be positive"
106
+ rescue ArgumentError, TypeError
107
+ raise StatPower::DomainError, "degrees_of_freedom must be numeric"
108
+ end
109
+ private_class_method :normalize_degrees_of_freedom
110
+
111
+ def validate_probability!(probability)
112
+ return if probability.finite? && probability.between?(0.0, 1.0)
113
+
114
+ raise StatPower::DomainError, "probability must be finite and lie in [0, 1]"
115
+ end
116
+ private_class_method :validate_probability!
117
+
118
+ def log_gamma(value)
119
+ Math.lgamma(value).first
120
+ end
121
+ private_class_method :log_gamma
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ module EffectSize
5
+ # Cohen-style conventional effect-size lookup used by CRAN pwr.
6
+ module Conventional
7
+ VALUES = {
8
+ p: { small: 0.2, medium: 0.5, large: 0.8 },
9
+ t: { small: 0.2, medium: 0.5, large: 0.8 },
10
+ chisq: { small: 0.1, medium: 0.3, large: 0.5 },
11
+ r: { small: 0.1, medium: 0.3, large: 0.5 },
12
+ anov: { small: 0.1, medium: 0.25, large: 0.4 },
13
+ f2: { small: 0.02, medium: 0.15, large: 0.35 }
14
+ }.freeze
15
+
16
+ module_function
17
+
18
+ # Resolve a conventional effect size.
19
+ #
20
+ # @param test [Symbol, String] pwr-style test family
21
+ # @param size [Symbol, String] small, medium, or large
22
+ # @return [Float]
23
+ # @raise [StatPower::DomainError] for unknown tests or sizes
24
+ def resolve(test:, size:)
25
+ test_key = test.to_sym
26
+ size_key = size.to_sym
27
+ family = VALUES[test_key]
28
+
29
+ raise StatPower::DomainError, "unknown effect-size test family: #{test}" unless family
30
+
31
+ value = family[size_key]
32
+ return value if value
33
+
34
+ raise StatPower::DomainError, "unknown conventional effect size: #{size}"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ module EffectSize
5
+ # Effect-size utilities for proportions.
6
+ module Proportion
7
+ module_function
8
+
9
+ # Compute Cohen's h for two proportions.
10
+ #
11
+ # h = 2 asin(sqrt(p1)) - 2 asin(sqrt(p2))
12
+ #
13
+ # @param p1 [Numeric] first proportion in [0, 1]
14
+ # @param p2 [Numeric] second proportion in [0, 1]
15
+ # @return [Float] signed Cohen h
16
+ # @raise [StatPower::DomainError] if either proportion is outside [0, 1]
17
+ def cohen_h(p1:, p2:)
18
+ first = probability!(p1, "p1")
19
+ second = probability!(p2, "p2")
20
+
21
+ (2.0 * Math.asin(Math.sqrt(first))) -
22
+ (2.0 * Math.asin(Math.sqrt(second)))
23
+ end
24
+
25
+ def probability!(value, name)
26
+ probability = Float(value)
27
+ return probability if probability.finite? && probability.between?(0.0, 1.0)
28
+
29
+ raise StatPower::DomainError, "#{name} must be finite and lie in [0, 1]"
30
+ rescue ArgumentError, TypeError
31
+ raise StatPower::DomainError, "#{name} must be numeric"
32
+ end
33
+ private_class_method :probability!
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ # Base error class for stat_power.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when a numerical or statistical argument lies outside its domain.
8
+ class DomainError < Error; end
9
+
10
+ # Raised when an iterative numerical method fails to converge.
11
+ class ConvergenceError < Error; end
12
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ module Integration
5
+ # Adaptive Simpson quadrature for smooth one-dimensional integrands.
6
+ module AdaptiveSimpson
7
+ DEFAULT_TOLERANCE = 1e-10
8
+ DEFAULT_MAX_DEPTH = 30
9
+
10
+ module_function
11
+
12
+ # Numerically integrate a function over a finite interval.
13
+ #
14
+ # @param lower [Numeric] lower integration bound
15
+ # @param upper [Numeric] upper integration bound
16
+ # @param tolerance [Numeric] absolute error target
17
+ # @param max_depth [Integer] maximum recursive subdivision depth
18
+ # @yieldparam x [Float] evaluation point
19
+ # @yieldreturn [Numeric] function value
20
+ # @return [Float] approximate integral
21
+ def integrate(
22
+ lower:,
23
+ upper:,
24
+ tolerance: DEFAULT_TOLERANCE,
25
+ max_depth: DEFAULT_MAX_DEPTH,
26
+ &function
27
+ )
28
+ raise ArgumentError, "a function block is required" unless function
29
+
30
+ left = Float(lower)
31
+ right = Float(upper)
32
+ error_target = Float(tolerance)
33
+ validate_arguments!(left, right, error_target, max_depth)
34
+
35
+ midpoint = left + ((right - left) / 2.0)
36
+ f_left = finite_value!(function.call(left))
37
+ f_midpoint = finite_value!(function.call(midpoint))
38
+ f_right = finite_value!(function.call(right))
39
+ whole = simpson(left, right, f_left, f_midpoint, f_right)
40
+
41
+ recurse(
42
+ function:,
43
+ left:,
44
+ right:,
45
+ f_left:,
46
+ f_midpoint:,
47
+ f_right:,
48
+ whole:,
49
+ tolerance: error_target,
50
+ depth: max_depth
51
+ )
52
+ end
53
+
54
+ def recurse(
55
+ function:,
56
+ left:,
57
+ right:,
58
+ f_left:,
59
+ f_midpoint:,
60
+ f_right:,
61
+ whole:,
62
+ tolerance:,
63
+ depth:
64
+ )
65
+ midpoint = left + ((right - left) / 2.0)
66
+ left_midpoint = left + ((midpoint - left) / 2.0)
67
+ right_midpoint = midpoint + ((right - midpoint) / 2.0)
68
+
69
+ f_left_midpoint = finite_value!(function.call(left_midpoint))
70
+ f_right_midpoint = finite_value!(function.call(right_midpoint))
71
+
72
+ left_area = simpson(left, midpoint, f_left, f_left_midpoint, f_midpoint)
73
+ right_area = simpson(midpoint, right, f_midpoint, f_right_midpoint, f_right)
74
+ refined = left_area + right_area
75
+ correction = refined - whole
76
+
77
+ return refined + (correction / 15.0) if depth.zero? || correction.abs <= 15.0 * tolerance
78
+
79
+ half_tolerance = tolerance / 2.0
80
+ recurse(
81
+ function:,
82
+ left:,
83
+ right: midpoint,
84
+ f_left:,
85
+ f_midpoint: f_left_midpoint,
86
+ f_right: f_midpoint,
87
+ whole: left_area,
88
+ tolerance: half_tolerance,
89
+ depth: depth - 1
90
+ ) + recurse(
91
+ function:,
92
+ left: midpoint,
93
+ right:,
94
+ f_left: f_midpoint,
95
+ f_midpoint: f_right_midpoint,
96
+ f_right:,
97
+ whole: right_area,
98
+ tolerance: half_tolerance,
99
+ depth: depth - 1
100
+ )
101
+ end
102
+ private_class_method :recurse
103
+
104
+ def simpson(left, right, f_left, f_midpoint, f_right)
105
+ (right - left) * (f_left + (4.0 * f_midpoint) + f_right) / 6.0
106
+ end
107
+ private_class_method :simpson
108
+
109
+ def finite_value!(value)
110
+ numeric = Float(value)
111
+ return numeric if numeric.finite?
112
+
113
+ raise StatPower::DomainError, "integrand values must be finite"
114
+ end
115
+ private_class_method :finite_value!
116
+
117
+ def validate_arguments!(left, right, tolerance, max_depth)
118
+ unless left.finite? && right.finite? && left < right
119
+ raise StatPower::DomainError, "integration bounds must be finite with lower < upper"
120
+ end
121
+
122
+ unless tolerance.finite? && tolerance.positive?
123
+ raise StatPower::DomainError, "tolerance must be finite and positive"
124
+ end
125
+
126
+ return if max_depth.is_a?(Integer) && max_depth.positive?
127
+
128
+ raise StatPower::DomainError, "max_depth must be a positive integer"
129
+ end
130
+ private_class_method :validate_arguments!
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ # Power calculations for a normal mean with known variance.
5
+ #
6
+ # The parameterisation follows CRAN pwr's pwr.norm.test: exactly one of
7
+ # effect_size, sample_size, alpha, and power must be omitted and is solved
8
+ # from the remaining values.
9
+ module NormalMean
10
+ SAMPLE_SIZE_LOWER = 1.0 + 1e-10
11
+ SAMPLE_SIZE_UPPER = 1e9
12
+ PROBABILITY_EPSILON = 1e-10
13
+
14
+ EFFECT_SIZE_BOUNDS = {
15
+ two_sided: [1e-10, 10.0],
16
+ less: [-10.0, 5.0],
17
+ greater: [-5.0, 10.0]
18
+ }.freeze
19
+
20
+ module_function
21
+
22
+ # Solve one missing parameter of a normal-mean power analysis.
23
+ #
24
+ # @param effect_size [Numeric, Symbol, String, nil] standardised mean effect
25
+ # @param sample_size [Numeric, nil] number of observations
26
+ # @param alpha [Numeric, nil] Type I error probability
27
+ # @param power [Numeric, nil] statistical power
28
+ # @param alternative [Symbol, String] two_sided, less, or greater
29
+ # @return [StatPower::PowerResult]
30
+ def solve(
31
+ effect_size: nil,
32
+ sample_size: nil,
33
+ alpha: 0.05,
34
+ power: nil,
35
+ alternative: :two_sided
36
+ )
37
+ alternative = normalize_alternative(alternative)
38
+ ensure_one_missing!(effect_size, sample_size, alpha, power)
39
+
40
+ effect_size = normalize_effect_size(effect_size)
41
+ sample_size = optional_float(sample_size)
42
+ alpha = optional_float(alpha)
43
+ power = optional_float(power)
44
+
45
+ validate_known_values!(effect_size:, sample_size:, alpha:, power:)
46
+
47
+ effect_size, sample_size, alpha, power = solve_missing(
48
+ effect_size:,
49
+ sample_size:,
50
+ alpha:,
51
+ power:,
52
+ alternative:
53
+ )
54
+
55
+ PowerResult.new(
56
+ sample_size:,
57
+ power:,
58
+ effect_size:,
59
+ alpha:,
60
+ alternative:,
61
+ analysis_method: "normal mean with known variance"
62
+ )
63
+ end
64
+
65
+ def solve_missing(effect_size:, sample_size:, alpha:, power:, alternative:)
66
+ if power.nil?
67
+ power = power_for(effect_size:, sample_size:, alpha:, alternative:)
68
+ elsif effect_size.nil?
69
+ effect_size = solve_effect_size(sample_size:, alpha:, power:, alternative:)
70
+ elsif sample_size.nil?
71
+ sample_size = solve_sample_size(effect_size:, alpha:, power:, alternative:)
72
+ elsif alpha.nil?
73
+ alpha = solve_alpha(effect_size:, sample_size:, power:, alternative:)
74
+ end
75
+
76
+ [effect_size, sample_size, alpha, power]
77
+ end
78
+ private_class_method :solve_missing
79
+
80
+ def power_for(effect_size:, sample_size:, alpha:, alternative:)
81
+ d = alternative == :two_sided ? effect_size.abs : effect_size
82
+ noncentrality = d * Math.sqrt(sample_size)
83
+
84
+ case alternative
85
+ when :two_sided
86
+ critical = Distributions::Normal.quantile(1.0 - (alpha / 2.0))
87
+ Distributions::Normal.survival(critical - noncentrality) +
88
+ Distributions::Normal.cdf(-critical - noncentrality)
89
+ when :greater
90
+ critical = Distributions::Normal.quantile(1.0 - alpha)
91
+ Distributions::Normal.survival(critical - noncentrality)
92
+ when :less
93
+ critical = Distributions::Normal.quantile(alpha)
94
+ Distributions::Normal.cdf(critical - noncentrality)
95
+ end
96
+ end
97
+ private_class_method :power_for
98
+
99
+ def solve_effect_size(sample_size:, alpha:, power:, alternative:)
100
+ lower, upper = EFFECT_SIZE_BOUNDS.fetch(alternative)
101
+
102
+ Solvers::Bisection.solve(lower:, upper:) do |candidate|
103
+ power_for(
104
+ effect_size: candidate,
105
+ sample_size:,
106
+ alpha:,
107
+ alternative:
108
+ ) - power
109
+ end
110
+ end
111
+ private_class_method :solve_effect_size
112
+
113
+ def solve_sample_size(effect_size:, alpha:, power:, alternative:)
114
+ Solvers::Bisection.solve(
115
+ lower: SAMPLE_SIZE_LOWER,
116
+ upper: SAMPLE_SIZE_UPPER
117
+ ) do |candidate|
118
+ power_for(
119
+ effect_size:,
120
+ sample_size: candidate,
121
+ alpha:,
122
+ alternative:
123
+ ) - power
124
+ end
125
+ end
126
+ private_class_method :solve_sample_size
127
+
128
+ def solve_alpha(effect_size:, sample_size:, power:, alternative:)
129
+ Solvers::Bisection.solve(
130
+ lower: PROBABILITY_EPSILON,
131
+ upper: 1.0 - PROBABILITY_EPSILON
132
+ ) do |candidate|
133
+ power_for(
134
+ effect_size:,
135
+ sample_size:,
136
+ alpha: candidate,
137
+ alternative:
138
+ ) - power
139
+ end
140
+ end
141
+ private_class_method :solve_alpha
142
+
143
+ def ensure_one_missing!(*values)
144
+ return if values.count(&:nil?) == 1
145
+
146
+ raise StatPower::DomainError,
147
+ "exactly one of effect_size, sample_size, alpha, and power must be nil"
148
+ end
149
+ private_class_method :ensure_one_missing!
150
+
151
+ def normalize_effect_size(value)
152
+ return nil if value.nil?
153
+
154
+ return EffectSize::Conventional.resolve(test: :t, size: value) if value.is_a?(String) || value.is_a?(Symbol)
155
+
156
+ Float(value)
157
+ rescue ArgumentError, TypeError
158
+ raise StatPower::DomainError, "effect_size must be numeric or a conventional size"
159
+ end
160
+ private_class_method :normalize_effect_size
161
+
162
+ def optional_float(value)
163
+ value.nil? ? nil : Float(value)
164
+ rescue ArgumentError, TypeError
165
+ raise StatPower::DomainError, "numeric parameters must be coercible to Float"
166
+ end
167
+ private_class_method :optional_float
168
+
169
+ def normalize_alternative(value)
170
+ normalized = value.to_s.tr(".-", "_").to_sym
171
+ return normalized if EFFECT_SIZE_BOUNDS.key?(normalized)
172
+
173
+ raise StatPower::DomainError, "alternative must be two_sided, less, or greater"
174
+ end
175
+ private_class_method :normalize_alternative
176
+
177
+ def validate_known_values!(effect_size:, sample_size:, alpha:, power:)
178
+ validate_finite!("effect_size", effect_size) if effect_size
179
+ validate_sample_size!(sample_size) if sample_size
180
+ validate_probability!("alpha", alpha) if alpha
181
+ validate_probability!("power", power) if power
182
+ end
183
+ private_class_method :validate_known_values!
184
+
185
+ def validate_finite!(name, value)
186
+ return if value.finite?
187
+
188
+ raise StatPower::DomainError, "#{name} must be finite"
189
+ end
190
+ private_class_method :validate_finite!
191
+
192
+ def validate_sample_size!(sample_size)
193
+ return if sample_size.finite? && sample_size >= 1.0
194
+
195
+ raise StatPower::DomainError, "sample_size must be finite and at least 1"
196
+ end
197
+ private_class_method :validate_sample_size!
198
+
199
+ def validate_probability!(name, value)
200
+ return if value.finite? && value.positive? && value < 1.0
201
+
202
+ raise StatPower::DomainError, "#{name} must lie strictly between 0 and 1"
203
+ end
204
+ private_class_method :validate_probability!
205
+ end
206
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ # Immutable result returned by power-analysis solvers.
5
+ PowerResult = Data.define(
6
+ :sample_size,
7
+ :power,
8
+ :effect_size,
9
+ :alpha,
10
+ :alternative,
11
+ :analysis_method
12
+ ) do
13
+ # Smallest integer sample size that is at least the continuous solution.
14
+ #
15
+ # @return [Integer]
16
+ def required_sample_size
17
+ sample_size.ceil
18
+ end
19
+ end
20
+ end