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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 66d3d0229cc19ab03fc96007a171298ae29f1f6964d62d51d945799fa3a7a465
4
- data.tar.gz: 1567693499022a1831ebaf0c216819458e9ddfd6389843d7581cd2c4d9534c65
3
+ metadata.gz: f1c655045d40fab84f807e1092e00ab08829ff0d9066f1081c14e9a75d411d52
4
+ data.tar.gz: bd2eff9c48b33c0512ce887eb704b674c2a6b8f511b65733a6c230de82f7617a
5
5
  SHA512:
6
- metadata.gz: 17de540e8e06c53b81e1295c0e9176a09cbf1f25458900524d7e318a627da780bc82becc0d9963cbc9ac4ce3d334ecff2686630993d87149792e01f65afccd40
7
- data.tar.gz: 278d9c38fbcf57c6d7bf522ab8126da06e1720f79b8393198620bc33049bc68123e6297f743e90b98fca31822173f8e65ae4f74e87866101921f21d453a79334
6
+ metadata.gz: 1f941dc407c89a7f931511b9355a0495924d0edaf09c6b599190fa39a80668224330513072c1f7cfd2f7b05dbb39d01a0bd405ef1fe1d6a7132aaf06b57cc3d7
7
+ data.tar.gz: eddde39e8855abf2e9df24160968f28b8ba52884b6d4d3bcf954bd0c1231740ee99e7d67007ff05f50b15b6a80728ff8a41abc2b948a38e97c6b4519bff69a06
data/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.1.0.alpha.2 - 2026-09-20
8
+
9
+ ### Added
10
+
11
+ - Balanced one-way ANOVA power analysis compatible with `pwr.anova.test`
12
+ - Dedicated ANOVA result object with per-group and total sample-size helpers
13
+ - General linear-model power analysis compatible with `pwr.f2.test`
14
+ - Dedicated `F2Result` with implied total sample-size helpers
15
+ - Regularized incomplete gamma functions
16
+ - Central and noncentral chi-square distribution utilities
17
+ - Cohen's `w` effect-size helpers for goodness-of-fit and association tests
18
+ - Chi-square power analysis compatible with `pwr.chisq.test`
19
+ - Dedicated `ChiSquareResult` with total sample-size helper
20
+ - Data-first power-curve generation via `StatPower::PowerCurve.generate`
21
+ - Immutable `PowerCurvePoint` values for plotting-library-independent output
22
+ - Fixture-driven CRAN `pwr` parity validation across all implemented solver families
23
+ - Documented numerical tolerance and reference-provenance policy
24
+
7
25
  ## 0.1.0.alpha.1 - 2026-09-19
8
26
 
9
27
  ### Added
data/README.md CHANGED
@@ -17,13 +17,13 @@ gem install stat_power --prerelease
17
17
  or pin the exact alpha:
18
18
 
19
19
  ```bash
20
- gem install stat_power -v 0.1.0.alpha.1
20
+ gem install stat_power -v 0.1.0.alpha.2
21
21
  ```
22
22
 
23
23
  With Bundler:
24
24
 
25
25
  ```ruby
26
- gem "stat_power", "0.1.0.alpha.1"
26
+ gem "stat_power", "0.1.0.alpha.2"
27
27
  ```
28
28
 
29
29
  ## Status
@@ -41,9 +41,16 @@ Implemented compatibility currently includes:
41
41
  - `pwr.t.test`
42
42
  - `pwr.t2n.test`
43
43
  - `pwr.r.test`
44
+ - `pwr.anova.test`
45
+ - `pwr.f2.test`
46
+ - `pwr.chisq.test`
47
+ - `ES.h`
48
+ - `ES.w1`
49
+ - `ES.w2`
50
+ - power-curve data generation equivalent in purpose to `plot.power.htest`
44
51
 
45
- The library also contains the numerical distribution machinery required for
46
- further power methods, including central/noncentral t and F distributions.
52
+ The library also contains the numerical distribution machinery required by
53
+ these methods, including central/noncentral t, F, and chi-square distributions.
47
54
 
48
55
  See [docs/pwr_parity.md](docs/pwr_parity.md) for the full compatibility matrix.
49
56
 
@@ -140,6 +147,9 @@ gem build stat_power.gemspec
140
147
 
141
148
  See [docs/mathematical_conventions.md](docs/mathematical_conventions.md).
142
149
 
150
+ For numerical reference fixtures and tolerance rules, see
151
+ [docs/validation.md](docs/validation.md).
152
+
143
153
  ## Roadmap
144
154
 
145
155
  See [ROADMAP.md](ROADMAP.md).
data/RELEASING.md CHANGED
@@ -2,13 +2,12 @@
2
2
 
3
3
  This project uses prerelease versions until the public API is ready to stabilize.
4
4
 
5
- ## One-time RubyGems trusted publisher setup
5
+ ## RubyGems trusted publishing
6
6
 
7
7
  RubyGems Trusted Publishing allows GitHub Actions to publish without storing a
8
8
  long-lived RubyGems API key.
9
9
 
10
- For the first release of `stat_power`, create a **pending trusted publisher**
11
- from the RubyGems.org account that should own the gem, using:
10
+ The trusted publisher for `stat_power` must match:
12
11
 
13
12
  - Gem name: `stat_power`
14
13
  - Repository owner: `DiogoRibeiro7`
@@ -18,14 +17,11 @@ from the RubyGems.org account that should own the gem, using:
18
17
 
19
18
  The workflow is stored at `.github/workflows/release.yml`.
20
19
 
21
- After the first successful publication, the pending publisher becomes the
22
- trusted publisher for the gem.
23
-
24
20
  ## Alpha release checklist
25
21
 
26
22
  1. Ensure all CI jobs are green on `main`.
27
23
  2. Confirm `lib/stat_power/version.rb` contains the intended version.
28
- 3. Update `CHANGELOG.md` with the release date.
24
+ 3. Move the release entries in `CHANGELOG.md` out of `Unreleased`.
29
25
  4. Build the gem locally:
30
26
 
31
27
  ```bash
@@ -47,19 +43,20 @@ trusted publisher for the gem.
47
43
  7. Run a smoke test against the installed package:
48
44
 
49
45
  ```bash
50
- ruby -e 'require "stat_power"; p StatPower::VERSION'
46
+ ruby -e 'require "stat_power"; puts StatPower::VERSION'
51
47
  ```
52
48
 
53
- 8. Confirm the RubyGems pending/trusted publisher is configured for
49
+ 8. Confirm the RubyGems trusted publisher still matches
54
50
  `.github/workflows/release.yml` and the `release` environment.
55
51
 
56
- 9. Create and push the release tag. The tag must match the version exactly:
52
+ 9. Create and push the release tag. For the current release:
57
53
 
58
54
  ```bash
55
+ VERSION=0.1.0.alpha.2
59
56
  git checkout main
60
57
  git pull --ff-only
61
- git tag v0.1.0.alpha.1
62
- git push origin v0.1.0.alpha.1
58
+ git tag "v${VERSION}"
59
+ git push origin "v${VERSION}"
63
60
  ```
64
61
 
65
62
  Pushing the tag triggers the release workflow. It validates that:
@@ -71,9 +68,24 @@ Pushing the tag triggers the release workflow. It validates that:
71
68
  - RBS signatures validate
72
69
  - the gem builds successfully
73
70
 
74
- The workflow then publishes to RubyGems using OIDC Trusted Publishing and
75
- creates a GitHub Release. Prerelease versions are marked as prereleases on
76
- GitHub automatically.
71
+ The workflow requests short-lived RubyGems credentials through OIDC, pushes the
72
+ already-built gem artifact with `gem push`, and creates a GitHub Release.
73
+ Prerelease versions are marked as prereleases on GitHub automatically.
74
+
75
+ ## Retrying a failed release
76
+
77
+ If a release tag already exists but the workflow fails before RubyGems accepts
78
+ the gem, do not create a new version merely to retry the same artifact.
79
+
80
+ 1. Open **Actions → Release gem**.
81
+ 2. Choose **Run workflow**.
82
+ 3. Enter the existing tag, for example `v0.1.0.alpha.2`.
83
+ 4. Run the workflow from `main`.
84
+
85
+ The manual dispatch checks out the existing tag and publishes that exact
86
+ artifact while using the latest release workflow definition from `main`.
87
+
88
+ Once a version appears on RubyGems.org, it is immutable and must not be reused.
77
89
 
78
90
  ## After publishing
79
91
 
@@ -89,17 +101,13 @@ Then confirm:
89
101
  ruby -e 'require "stat_power"; puts StatPower::VERSION'
90
102
  ```
91
103
 
92
- The expected output for the first alpha is:
104
+ For the current release the expected output is:
93
105
 
94
106
  ```text
95
- 0.1.0.alpha.1
107
+ 0.1.0.alpha.2
96
108
  ```
97
109
 
98
- ## Failed releases
99
-
100
- If the workflow fails before the RubyGems publishing step, fix the problem and
101
- move the release tag only if the version has not been published.
110
+ ## Failed releases after publication
102
111
 
103
112
  Once a version exists on RubyGems.org, do not overwrite or reuse that version.
104
- Increment the prerelease version, for example from `0.1.0.alpha.1` to
105
- `0.1.0.alpha.2`, and create a new tag.
113
+ Increment the prerelease version and create a new tag.
data/ROADMAP.md CHANGED
@@ -11,7 +11,7 @@
11
11
  - [x] Normal mean power analysis equivalent to `pwr.norm.test`
12
12
  - [x] Student t distribution utilities
13
13
  - [x] Noncentral t distribution utilities
14
- - [ ] Chi-square and noncentral chi-square utilities
14
+ - [x] Chi-square and noncentral chi-square utilities
15
15
  - [x] F and noncentral F utilities
16
16
 
17
17
  ### pwr compatibility
@@ -22,24 +22,24 @@
22
22
  - [x] `pwr.2p2n.test`
23
23
  - [x] `pwr.t.test`
24
24
  - [x] `pwr.t2n.test`
25
- - [ ] `pwr.anova.test`
25
+ - [x] `pwr.anova.test`
26
26
  - [x] `pwr.r.test`
27
- - [ ] `pwr.chisq.test`
28
- - [ ] `pwr.f2.test`
27
+ - [x] `pwr.chisq.test`
28
+ - [x] `pwr.f2.test`
29
29
  - [x] `ES.h`
30
- - [ ] `ES.w1`
31
- - [ ] `ES.w2`
32
- - [ ] power-curve data equivalent to `plot.power.htest`
30
+ - [x] `ES.w1`
31
+ - [x] `ES.w2`
32
+ - [x] power-curve data equivalent to `plot.power.htest`
33
33
 
34
34
  Each migrated family must support the inverse problems exposed by the reference
35
35
  method and include numerical parity tests.
36
36
 
37
37
  ## 0.2.x — Validation and usability
38
38
 
39
- - Automated parity fixtures generated from R `pwr`
39
+ - [x] Automated parity fixtures derived from CRAN `pwr` references
40
40
  - Published Cohen examples
41
41
  - Cross-validation against statsmodels where applicable
42
- - Documented numerical tolerance policy
42
+ - [x] Documented numerical tolerance policy
43
43
  - Stable result objects
44
44
  - User-facing method documentation
45
45
  - Benchmarks
data/docs/pwr_parity.md CHANGED
@@ -18,14 +18,14 @@ reference points.
18
18
  | `pwr.2p2n.test` | `StatPower::Proportion.two_sample_unequal` | Implemented |
19
19
  | `pwr.t.test` | `StatPower::TTest.one_sample`, `.paired`, `.two_sample` | Implemented |
20
20
  | `pwr.t2n.test` | `StatPower::TTest.two_sample_unequal` | Implemented |
21
- | `pwr.anova.test` | balanced one-way ANOVA power | Planned |
21
+ | `pwr.anova.test` | `StatPower::Anova.solve` | Implemented |
22
22
  | `pwr.r.test` | `StatPower::Correlation.solve` | Implemented |
23
- | `pwr.chisq.test` | chi-square power | Planned |
24
- | `pwr.f2.test` | general linear-model power | Planned |
23
+ | `pwr.chisq.test` | `StatPower::ChiSquare.solve` | Implemented |
24
+ | `pwr.f2.test` | `StatPower::F2.solve` | Implemented |
25
25
  | `ES.h` | `StatPower::EffectSize::Proportion.cohen_h` | Implemented |
26
- | `ES.w1` | Cohen w for goodness of fit | Planned |
27
- | `ES.w2` | Cohen w for contingency tables | Planned |
28
- | `plot.power.htest` | power-curve data generation | Planned |
26
+ | `ES.w1` | `StatPower::EffectSize::ChiSquare.goodness_of_fit` | Implemented |
27
+ | `ES.w2` | `StatPower::EffectSize::ChiSquare.association` | Implemented |
28
+ | `plot.power.htest` | `StatPower::PowerCurve.generate` | Implemented as data generation |
29
29
 
30
30
  ## Solver convention
31
31
 
@@ -60,3 +60,6 @@ Each migrated family should include:
60
60
  Parity means agreement in the statistical model and numerical result within a
61
61
  documented tolerance. Ruby naming and object design remain idiomatic rather
62
62
  than cloning R syntax.
63
+
64
+ See [validation.md](validation.md) for the fixture suite, reference provenance,
65
+ and numerical tolerance policy.
@@ -0,0 +1,60 @@
1
+ # Numerical validation
2
+
3
+ `stat_power` treats numerical validation as part of the public contract.
4
+
5
+ ## CRAN pwr reference fixtures
6
+
7
+ The first compatibility target is CRAN `pwr` 1.3-0. Reference cases live in
8
+ `spec/fixtures/pwr_parity.yml` and are exercised by
9
+ `spec/parity/pwr_parity_spec.rb`.
10
+
11
+ The fixture set deliberately uses public examples from the `pwr` reference
12
+ manual and Cohen (1988) where available. It covers every power-analysis family
13
+ implemented by `stat_power`, including both direct power calculations and
14
+ representative inverse problems.
15
+
16
+ The fixture file records:
17
+
18
+ - the corresponding `pwr` function,
19
+ - the source/example label,
20
+ - the Ruby public API call,
21
+ - the input arguments,
22
+ - the output field being checked,
23
+ - the expected numeric value,
24
+ - the accepted absolute tolerance.
25
+
26
+ Detailed unit tests remain alongside each implementation. The fixture suite is
27
+ an additional end-to-end compatibility layer, not a replacement for
28
+ distribution-level, solver-level, domain, or round-trip tests.
29
+
30
+ ## Tolerance policy
31
+
32
+ Numerical tolerances are explicit per fixture.
33
+
34
+ As a default policy:
35
+
36
+ - direct probabilities and distribution values use absolute tolerances around
37
+ `1e-8` or tighter when the reference is stable,
38
+ - inverse root solutions generally use `1e-5` because independent numerical
39
+ solvers can stop at slightly different points while agreeing statistically,
40
+ - looser tolerances are used only when the published reference itself is
41
+ reported with fewer digits.
42
+
43
+ A tolerance should not be widened merely to make a failing test pass. A change
44
+ must first be traced to one of:
45
+
46
+ 1. a documented difference in the statistical model,
47
+ 2. a known precision limit of the numerical method,
48
+ 3. a reference value with limited published precision,
49
+ 4. an implementation defect.
50
+
51
+ ## Reproducibility
52
+
53
+ The fixture values are versioned with the code so changes are reviewable in
54
+ pull requests. Adding a new statistical family requires adding at least one
55
+ forward reference case and one inverse reference case when the reference API
56
+ supports inverse solving.
57
+
58
+ The project does not execute R during normal gem CI. This keeps the Ruby test
59
+ suite self-contained and deterministic while preserving explicit provenance
60
+ for the reference numbers.
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ # Power analysis for balanced one-way analysis of variance.
5
+ #
6
+ # The statistical parameterisation follows CRAN pwr.anova.test. Exactly one
7
+ # of groups, sample_size, effect_size, alpha, and power must be nil.
8
+ module Anova
9
+ GROUPS_LOWER = 2.0 + 1e-10
10
+ GROUPS_UPPER = 100.0
11
+ SAMPLE_SIZE_LOWER = 2.0 + 1e-10
12
+ SAMPLE_SIZE_MAX = 1e9
13
+ EFFECT_SIZE_LOWER = 1e-7
14
+ EFFECT_SIZE_MAX = 1e4
15
+ PROBABILITY_EPSILON = 1e-10
16
+
17
+ module_function
18
+
19
+ # Solve one missing parameter of a balanced one-way ANOVA power analysis.
20
+ #
21
+ # @param groups [Numeric, nil] number of groups
22
+ # @param sample_size [Numeric, nil] observations per group
23
+ # @param effect_size [Numeric, Symbol, String, nil] Cohen's f
24
+ # @param alpha [Numeric, nil] Type I error probability
25
+ # @param power [Numeric, nil] statistical power
26
+ # @return [StatPower::AnovaResult]
27
+ def solve(
28
+ groups: nil,
29
+ sample_size: nil,
30
+ effect_size: nil,
31
+ alpha: 0.05,
32
+ power: nil
33
+ )
34
+ ensure_one_missing!(groups, sample_size, effect_size, alpha, power)
35
+
36
+ groups = optional_float(groups)
37
+ sample_size = optional_float(sample_size)
38
+ effect_size = normalize_effect_size(effect_size)
39
+ alpha = optional_float(alpha)
40
+ power = optional_float(power)
41
+
42
+ validate_known_values!(
43
+ groups:,
44
+ sample_size:,
45
+ effect_size:,
46
+ alpha:,
47
+ power:
48
+ )
49
+
50
+ groups, sample_size, effect_size, alpha, power = solve_missing(
51
+ groups:,
52
+ sample_size:,
53
+ effect_size:,
54
+ alpha:,
55
+ power:
56
+ )
57
+
58
+ AnovaResult.new(
59
+ groups:,
60
+ sample_size:,
61
+ power:,
62
+ effect_size:,
63
+ alpha:,
64
+ analysis_method: "balanced one-way analysis of variance power calculation"
65
+ )
66
+ end
67
+
68
+ def solve_missing(groups:, sample_size:, effect_size:, alpha:, power:)
69
+ if power.nil?
70
+ power = power_for(
71
+ groups:,
72
+ sample_size:,
73
+ effect_size:,
74
+ alpha:
75
+ )
76
+ elsif groups.nil?
77
+ groups = solve_groups(
78
+ sample_size:,
79
+ effect_size:,
80
+ alpha:,
81
+ power:
82
+ )
83
+ elsif sample_size.nil?
84
+ sample_size = solve_sample_size(
85
+ groups:,
86
+ effect_size:,
87
+ alpha:,
88
+ power:
89
+ )
90
+ elsif effect_size.nil?
91
+ effect_size = solve_effect_size(
92
+ groups:,
93
+ sample_size:,
94
+ alpha:,
95
+ power:
96
+ )
97
+ elsif alpha.nil?
98
+ alpha = solve_alpha(
99
+ groups:,
100
+ sample_size:,
101
+ effect_size:,
102
+ power:
103
+ )
104
+ end
105
+
106
+ [groups, sample_size, effect_size, alpha, power]
107
+ end
108
+ private_class_method :solve_missing
109
+
110
+ def power_for(groups:, sample_size:, effect_size:, alpha:)
111
+ numerator_df = groups - 1.0
112
+ denominator_df = (sample_size - 1.0) * groups
113
+ noncentrality = groups * sample_size * effect_size * effect_size
114
+
115
+ critical = Distributions::FDistribution.quantile(
116
+ 1.0 - alpha,
117
+ numerator_df:,
118
+ denominator_df:
119
+ )
120
+
121
+ Distributions::NoncentralF.survival(
122
+ critical,
123
+ numerator_df:,
124
+ denominator_df:,
125
+ noncentrality:
126
+ )
127
+ end
128
+ private_class_method :power_for
129
+
130
+ def solve_groups(sample_size:, effect_size:, alpha:, power:)
131
+ Solvers::Bisection.solve(
132
+ lower: GROUPS_LOWER,
133
+ upper: GROUPS_UPPER
134
+ ) do |candidate|
135
+ power_for(
136
+ groups: candidate,
137
+ sample_size:,
138
+ effect_size:,
139
+ alpha:
140
+ ) - power
141
+ end
142
+ end
143
+ private_class_method :solve_groups
144
+
145
+ def solve_sample_size(groups:, effect_size:, alpha:, power:)
146
+ upper = bracket_sample_size(
147
+ groups:,
148
+ effect_size:,
149
+ alpha:,
150
+ power:
151
+ )
152
+
153
+ Solvers::Bisection.solve(
154
+ lower: SAMPLE_SIZE_LOWER,
155
+ upper:,
156
+ absolute_tolerance: 1e-7,
157
+ relative_tolerance: 1e-9
158
+ ) do |candidate|
159
+ power_for(
160
+ groups:,
161
+ sample_size: candidate,
162
+ effect_size:,
163
+ alpha:
164
+ ) - power
165
+ end
166
+ end
167
+ private_class_method :solve_sample_size
168
+
169
+ def bracket_sample_size(groups:, effect_size:, alpha:, power:)
170
+ upper = 4.0
171
+
172
+ while upper < SAMPLE_SIZE_MAX
173
+ achieved = power_for(
174
+ groups:,
175
+ sample_size: upper,
176
+ effect_size:,
177
+ alpha:
178
+ )
179
+ return upper if achieved >= power
180
+
181
+ upper *= 2.0
182
+ end
183
+
184
+ raise StatPower::DomainError,
185
+ "target power cannot be bracketed within the supported sample-size range"
186
+ end
187
+ private_class_method :bracket_sample_size
188
+
189
+ def solve_effect_size(groups:, sample_size:, alpha:, power:)
190
+ upper = bracket_effect_size(
191
+ groups:,
192
+ sample_size:,
193
+ alpha:,
194
+ power:
195
+ )
196
+
197
+ Solvers::Bisection.solve(
198
+ lower: EFFECT_SIZE_LOWER,
199
+ upper:
200
+ ) do |candidate|
201
+ power_for(
202
+ groups:,
203
+ sample_size:,
204
+ effect_size: candidate,
205
+ alpha:
206
+ ) - power
207
+ end
208
+ end
209
+ private_class_method :solve_effect_size
210
+
211
+ def bracket_effect_size(groups:, sample_size:, alpha:, power:)
212
+ upper = 0.5
213
+
214
+ while upper < EFFECT_SIZE_MAX
215
+ achieved = power_for(
216
+ groups:,
217
+ sample_size:,
218
+ effect_size: upper,
219
+ alpha:
220
+ )
221
+ return upper if achieved >= power
222
+
223
+ upper *= 2.0
224
+ end
225
+
226
+ raise StatPower::DomainError,
227
+ "target power cannot be bracketed within the supported effect-size range"
228
+ end
229
+ private_class_method :bracket_effect_size
230
+
231
+ def solve_alpha(groups:, sample_size:, effect_size:, power:)
232
+ Solvers::Bisection.solve(
233
+ lower: PROBABILITY_EPSILON,
234
+ upper: 1.0 - PROBABILITY_EPSILON
235
+ ) do |candidate|
236
+ power_for(
237
+ groups:,
238
+ sample_size:,
239
+ effect_size:,
240
+ alpha: candidate
241
+ ) - power
242
+ end
243
+ end
244
+ private_class_method :solve_alpha
245
+
246
+ def ensure_one_missing!(*values)
247
+ return if values.count(&:nil?) == 1
248
+
249
+ raise StatPower::DomainError,
250
+ "exactly one of groups, sample_size, effect_size, alpha, and power must be nil"
251
+ end
252
+ private_class_method :ensure_one_missing!
253
+
254
+ def normalize_effect_size(value)
255
+ return nil if value.nil?
256
+ return EffectSize::Conventional.resolve(test: :anov, size: value) if value.is_a?(String) || value.is_a?(Symbol)
257
+
258
+ Float(value)
259
+ rescue ArgumentError, TypeError
260
+ raise StatPower::DomainError, "effect_size must be numeric or a conventional size"
261
+ end
262
+ private_class_method :normalize_effect_size
263
+
264
+ def optional_float(value)
265
+ value.nil? ? nil : Float(value)
266
+ rescue ArgumentError, TypeError
267
+ raise StatPower::DomainError, "numeric parameters must be coercible to Float"
268
+ end
269
+ private_class_method :optional_float
270
+
271
+ def validate_known_values!(groups:, sample_size:, effect_size:, alpha:, power:)
272
+ validate_groups!(groups) if groups
273
+ validate_sample_size!(sample_size) if sample_size
274
+ validate_effect_size!(effect_size) if effect_size
275
+ validate_probability!("alpha", alpha) if alpha
276
+ validate_probability!("power", power) if power
277
+ end
278
+ private_class_method :validate_known_values!
279
+
280
+ def validate_groups!(groups)
281
+ return if groups.finite? && groups >= 2.0
282
+
283
+ raise StatPower::DomainError, "groups must be finite and at least 2"
284
+ end
285
+ private_class_method :validate_groups!
286
+
287
+ def validate_sample_size!(sample_size)
288
+ return if sample_size.finite? && sample_size >= 2.0
289
+
290
+ raise StatPower::DomainError, "sample_size must be finite and at least 2"
291
+ end
292
+ private_class_method :validate_sample_size!
293
+
294
+ def validate_effect_size!(effect_size)
295
+ return if effect_size.finite? && !effect_size.negative?
296
+
297
+ raise StatPower::DomainError, "effect_size must be finite and non-negative"
298
+ end
299
+ private_class_method :validate_effect_size!
300
+
301
+ def validate_probability!(name, value)
302
+ return if value.finite? && value.positive? && value < 1.0
303
+
304
+ raise StatPower::DomainError, "#{name} must lie strictly between 0 and 1"
305
+ end
306
+ private_class_method :validate_probability!
307
+ end
308
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StatPower
4
+ # Immutable result for balanced one-way ANOVA power analyses.
5
+ AnovaResult = Data.define(
6
+ :groups,
7
+ :sample_size,
8
+ :power,
9
+ :effect_size,
10
+ :alpha,
11
+ :analysis_method
12
+ ) do
13
+ # Smallest whole-number group count not below the continuous solution.
14
+ #
15
+ # @return [Integer]
16
+ def required_groups
17
+ groups.ceil
18
+ end
19
+
20
+ # Smallest whole-number per-group sample size not below the solution.
21
+ #
22
+ # @return [Integer]
23
+ def required_sample_size
24
+ sample_size.ceil
25
+ end
26
+
27
+ # Continuous total sample size.
28
+ #
29
+ # @return [Float]
30
+ def total_sample_size
31
+ groups * sample_size
32
+ end
33
+
34
+ # Practical total after independently rounding groups and per-group n up.
35
+ #
36
+ # @return [Integer]
37
+ def required_total_sample_size
38
+ required_groups * required_sample_size
39
+ end
40
+ end
41
+ end