From 141d7db037b716e4def3ae982a71c2f455b8c6f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Lenartowicz?= <6quarg@gmail.com> Date: Fri, 20 Feb 2026 16:23:52 +0100 Subject: [PATCH 1/9] documentation --- CHANGELOG.md | 15 ++++++++++++++- README.md | 4 ++-- examples/01_basic_power.py | 4 ++-- examples/02_sample_size.py | 6 ++---- examples/03_interactions.py | 12 +++++------- examples/04_correlations.py | 12 +++++------- examples/05_multiple_testing.py | 4 ++-- pyproject.toml | 3 +-- 8 files changed, 33 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a694a84..db740b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,23 @@ All notable changes to this project will be documented in this file. -## [0.5.1] - 2026-02-20 + + +## [0.5.2] - 2026-02-20 ### Documentation +- Fixed incorrect dummy variable names in README upload-data example (`cyl[2]` → `cyl[6]`, `cyl[3]` → `cyl[8]`) +- Fixed dependencies section in README +- Removed scipy from `[all]` optional dependencies (unused since C++ backend became required in v0.5.0) +- Updated all example scripts to use current `from mcpower import MCPower` API + +### Fixed +- Auto-tag workflow now uses `RELEASE_TOKEN` for downstream workflow triggering + +## [0.5.1] - 2026-02-20 + +### Documentation - Rewrote "Why MCPower?" section — replaced dense paragraphs with bullet points; added mixed models entry - Removed inaccurate "logistic regression and ANOVA are on the way" note from feature description diff --git a/README.md b/README.md index 7691a0c..ab6295d 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,7 @@ data = {col: [float(r[col]) for r in rows] for col in ["hp", "wt", "cyl"]} # Upload with automatic type detection model = MCPower("mpg = hp + wt + cyl") model.upload_data(data) -model.set_effects("hp=0.5, wt=0.3, cyl[2]=0.2, cyl[3]=0.4") +model.set_effects("hp=0.5, wt=0.3, cyl[6]=0.2, cyl[8]=0.4") model.find_power(sample_size=100) ``` @@ -538,7 +538,7 @@ model.set_correlations("(x1, x2)=0.3, (x1, x3)=-0.2") - NumPy, matplotlib, joblib - C++ compiler (required for building the native backend during install) - pandas (optional, for DataFrame input — install with `pip install mcpower[pandas]`) -- statsmodels (optional, for mixed-effects models — install with `pip install mcpower[lme]`) +- statsmodels (optional, for mixed-effects models — install with `pip install mcpower[all]`) ## Documentation diff --git a/examples/01_basic_power.py b/examples/01_basic_power.py index 71a79e0..5862d1a 100644 --- a/examples/01_basic_power.py +++ b/examples/01_basic_power.py @@ -6,7 +6,7 @@ Shows how to check if your planned sample size provides enough statistical power. """ -import mcpower +from mcpower import MCPower # Example: Clinical trial testing new therapy vs control # Research question: Does the new therapy improve patient outcomes? @@ -16,7 +16,7 @@ print("=" * 60) # 1. Define your model using R-style formula -model = mcpower.LinearRegression("patient_outcome = treatment + baseline_score") +model = MCPower("patient_outcome = treatment + baseline_score") # 2. Set expected effect sizes # treatment = 0.5 means therapy improves outcomes by 0.5 standard deviations diff --git a/examples/02_sample_size.py b/examples/02_sample_size.py index 27a3f0d..4f54ea8 100644 --- a/examples/02_sample_size.py +++ b/examples/02_sample_size.py @@ -6,7 +6,7 @@ your expected effects with adequate statistical power. """ -import mcpower +from mcpower import MCPower # Example: Educational intervention study # Research question: What sample size do we need to detect the intervention effect? @@ -16,9 +16,7 @@ print("=" * 60) # 1. Define your study model -model = mcpower.LinearRegression( - "test_score = intervention + prior_knowledge + motivation" -) +model = MCPower("test_score = intervention + prior_knowledge + motivation") # 2. Set expected effect sizes based on literature/pilot data # intervention = 0.4 means a medium-sized improvement from the intervention diff --git a/examples/03_interactions.py b/examples/03_interactions.py index 0bb987d..41732e0 100644 --- a/examples/03_interactions.py +++ b/examples/03_interactions.py @@ -6,7 +6,7 @@ of one variable depends on the level of another variable. """ -import mcpower +from mcpower import MCPower # Example: Marketing study with interaction # Research question: Does the effect of advertising depend on customer age? @@ -16,8 +16,8 @@ print("=" * 60) # 1. Define model with interaction term -# advertising*age creates: advertising + age + advertising:age -model = mcpower.LinearRegression("sales = advertising + age + advertising*age") +# advertising*age expands to: advertising + age + advertising:age +model = MCPower("sales = advertising * age") # 2. Set effect sizes for all terms # advertising:age = 0.3 means the advertising effect varies by age @@ -78,9 +78,7 @@ print("=" * 60) # More complex model with three-way interaction -complex_model = mcpower.LinearRegression( - "outcome = treatment + gender + age + treatment*gender*age" -) +complex_model = MCPower("outcome = treatment * gender * age") # Set effects (three-way interactions need large samples) complex_model.set_effects( @@ -129,6 +127,6 @@ loss in overall power is small at worst 5. ATTENTION! - - Most variablity in power for replication is from correlation (next example) + - Most variability in power for replication is from correlation (next example) """ ) diff --git a/examples/04_correlations.py b/examples/04_correlations.py index de2e7b6..21456c5 100644 --- a/examples/04_correlations.py +++ b/examples/04_correlations.py @@ -6,7 +6,7 @@ Real-world predictors are often correlated, which affects statistical power. """ -import mcpower +from mcpower import MCPower import numpy as np # Example: Social science study with correlated predictors @@ -17,9 +17,7 @@ print("=" * 60) # 1. Define model with multiple predictors -model = mcpower.LinearRegression( - "life_satisfaction = income + education + social_support + health" -) +model = MCPower("life_satisfaction = income + education + social_support + health") # 2. Set effect sizes model.set_effects("income=0.3, education=0.25, social_support=0.4, health=0.5") @@ -63,7 +61,7 @@ print("=" * 60) # Create new model for matrix example -matrix_model = mcpower.LinearRegression("wellbeing = stress + exercise + sleep") +matrix_model = MCPower("wellbeing = stress + exercise + sleep") matrix_model.set_effects("stress=-0.4, exercise=0.3, sleep=0.5") # Define full correlation matrix @@ -98,7 +96,7 @@ print("=" * 60) # Model without correlations -uncorr_model = mcpower.LinearRegression("outcome = x1 + x2 + x3") +uncorr_model = MCPower("outcome = x1 + x2 + x3") uncorr_model.set_effects("x1=0.4, x2=0.3, x3=0.5") # No correlation specification = independent predictors @@ -108,7 +106,7 @@ ) # Same model with strong correlations -corr_model = mcpower.LinearRegression("outcome = x1 + x2 + x3") +corr_model = MCPower("outcome = x1 + x2 + x3") corr_model.set_effects("x1=0.4, x2=0.3, x3=0.5") corr_model.set_correlations("corr(x1,x2)=0.7, corr(x1,x3)=0.6, corr(x2,x3)=0.8") diff --git a/examples/05_multiple_testing.py b/examples/05_multiple_testing.py index 46f8a5b..d03b262 100644 --- a/examples/05_multiple_testing.py +++ b/examples/05_multiple_testing.py @@ -6,7 +6,7 @@ hypotheses simultaneously. Essential for studies with many variables. """ -import mcpower +from mcpower import MCPower # Example: Medical study testing multiple biomarkers # Research question: Which biomarkers predict treatment response? @@ -16,7 +16,7 @@ print("=" * 60) # 1. Define model with multiple predictors to test -model = mcpower.LinearRegression( +model = MCPower( "treatment_response = biomarker1 + biomarker2 + biomarker3 + biomarker4 + age" ) diff --git a/pyproject.toml b/pyproject.toml index 13976b8..aa8b753 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "scikit_build_core.build" [project] name = "MCPower" -version = "0.5.1" +version = "0.5.2" description = "Monte Carlo Power Analysis for Statistical Models" readme = "README.md" license = {text = "GPL-3.0-or-later"} @@ -53,7 +53,6 @@ dev = [ all = [ "pandas>=2.0.0", "statsmodels>=0.14.0", - "scipy>=1.11.0", ] [project.urls] From b1ef3202a2543b95b9c2a82ec3929761eb54844d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Lenartowicz?= <6quarg@gmail.com> Date: Sat, 21 Feb 2026 16:46:03 +0100 Subject: [PATCH 2/9] Fixed factor interaction expansion --- CHANGELOG.md | 7 ++++ mcpower/core/variables.py | 74 ++++++++++++++++++------------------ pyproject.toml | 2 +- tests/unit/test_variables.py | 38 ++++++++++++++++++ 4 files changed, 83 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db740b9..81830d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. +## [0.5.3] - 2026-02-21 + +### Fixed + +- **Factor:factor interaction expansion** — `expand_factors()` now produces Cartesian product of non-reference dummy levels (e.g. `a[2]:b[2]`, `a[3]:b[2]`) instead of incorrect partial expansion (`a:b[2]`, `a[2]:b`). Affects any model with interactions between two or more factor variables +- Factor:factor interactions with named level labels now expand correctly (e.g. `origin[Japan]:cyl[6]` instead of `origin:cyl[6]`) + ## [0.5.2] - 2026-02-20 ### Documentation diff --git a/mcpower/core/variables.py b/mcpower/core/variables.py index 61579dd..322238c 100644 --- a/mcpower/core/variables.py +++ b/mcpower/core/variables.py @@ -438,49 +438,49 @@ def expand_factors(self) -> None: col_idx += 1 - # Handle interactions involving factors + # Handle interactions involving factors — Cartesian product of + # non-reference dummy levels across all factor components. + from itertools import product as cartesian_product + for _name, eff in original_effects.items(): if eff.effect_type == "interaction": factor_vars = [vn for vn in eff.var_names if vn in self._factors] if factor_vars: - for factor_var in factor_vars: - factor_info = self._factors[factor_var] - n_levels = factor_info["n_levels"] - level_labels = factor_info.get("level_labels") - reference_level = factor_info.get("reference_level", 1) - - if level_labels is not None: - non_ref_labels = [lb for lb in level_labels if lb != str(reference_level)] - for label in non_ref_labels: - dummy_name = f"{factor_var}[{label}]" - - # Replace factor name with dummy name - new_var_names = [dummy_name if vn == factor_var else vn for vn in eff.var_names] - new_interaction_name = ":".join(new_var_names) - - new_eff = Effect( - name=new_interaction_name, - effect_type="interaction", - var_names=new_var_names, - column_indices=[], # Updated later - ) - new_effects[new_interaction_name] = new_eff + # Build per-component level options + level_options: list[list[str]] = [] + for vn in eff.var_names: + if vn in self._factors: + factor_info = self._factors[vn] + n_levels = factor_info["n_levels"] + level_labels = factor_info.get("level_labels") + reference_level = factor_info.get("reference_level", 1) + + if level_labels is not None: + non_ref = [ + f"{vn}[{lb}]" + for lb in level_labels + if lb != str(reference_level) + ] + else: + non_ref = [ + f"{vn}[{lvl}]" + for lvl in range(2, n_levels + 1) + ] + level_options.append(non_ref) else: - for level in range(2, n_levels + 1): - dummy_name = f"{factor_var}[{level}]" - - # Replace factor name with dummy name - new_var_names = [dummy_name if vn == factor_var else vn for vn in eff.var_names] - new_interaction_name = ":".join(new_var_names) - - new_eff = Effect( - name=new_interaction_name, - effect_type="interaction", - var_names=new_var_names, - column_indices=[], # Updated later - ) - new_effects[new_interaction_name] = new_eff + level_options.append([vn]) + + for combo in cartesian_product(*level_options): + new_var_names = list(combo) + new_interaction_name = ":".join(new_var_names) + new_eff = Effect( + name=new_interaction_name, + effect_type="interaction", + var_names=new_var_names, + column_indices=[], # Updated later + ) + new_effects[new_interaction_name] = new_eff # Update predictors and effects self._predictors = new_predictors diff --git a/pyproject.toml b/pyproject.toml index aa8b753..37f14c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "scikit_build_core.build" [project] name = "MCPower" -version = "0.5.2" +version = "0.5.3" description = "Monte Carlo Power Analysis for Statistical Models" readme = "README.md" license = {text = "GPL-3.0-or-later"} diff --git a/tests/unit/test_variables.py b/tests/unit/test_variables.py index 022ed84..ea8d01a 100644 --- a/tests/unit/test_variables.py +++ b/tests/unit/test_variables.py @@ -277,6 +277,44 @@ def test_expand_factors_interaction_with_labels(self): assert "origin[Japan]:x1" in effect_names assert "origin[USA]:x1" in effect_names + def test_expand_factors_factor_factor_interaction(self): + """Factor:factor interaction produces Cartesian product of non-ref levels.""" + from mcpower.core.variables import VariableRegistry + + reg = VariableRegistry("y = a + b + a:b") + reg.set_variable_type("a", "factor", n_levels=3) + reg.set_variable_type("b", "factor", n_levels=2) + reg.expand_factors() + effect_names = reg.effect_names + # (3-1) * (2-1) = 2 interaction effects + assert "a[2]:b[2]" in effect_names + assert "a[3]:b[2]" in effect_names + # No bare factor names in interaction terms + assert "a[2]:b" not in effect_names + assert "a:b[2]" not in effect_names + + def test_expand_factors_factor_factor_with_labels(self): + """Factor:factor with level_labels uses label names in cross-product.""" + from mcpower.core.variables import VariableRegistry + + reg = VariableRegistry("y = origin + cyl + origin:cyl") + reg.set_variable_type("origin", "factor", n_levels=3, + level_labels=["Europe", "Japan", "USA"]) + reg.set_variable_type("cyl", "factor", n_levels=3, + level_labels=["4", "6", "8"]) + reg.expand_factors() + effect_names = reg.effect_names + # (3-1) * (3-1) = 4 interaction effects + expected = [ + "origin[Japan]:cyl[6]", "origin[Japan]:cyl[8]", + "origin[USA]:cyl[6]", "origin[USA]:cyl[8]", + ] + for name in expected: + assert name in effect_names, f"{name} missing from {effect_names}" + # No bare factor names + assert "origin:cyl[6]" not in effect_names + assert "origin[Japan]:cyl" not in effect_names + def test_get_factor_specs_includes_labels(self): """get_factor_specs returns level_labels when present.""" from mcpower.core.variables import VariableRegistry From d6775cade0f098a97a6b54d79a8a526aab22c40b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Lenartowicz?= <6quarg@gmail.com> Date: Sat, 21 Feb 2026 17:41:04 +0100 Subject: [PATCH 3/9] formating --- mcpower/core/variables.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/mcpower/core/variables.py b/mcpower/core/variables.py index 322238c..e311606 100644 --- a/mcpower/core/variables.py +++ b/mcpower/core/variables.py @@ -457,16 +457,9 @@ def expand_factors(self) -> None: reference_level = factor_info.get("reference_level", 1) if level_labels is not None: - non_ref = [ - f"{vn}[{lb}]" - for lb in level_labels - if lb != str(reference_level) - ] + non_ref = [f"{vn}[{lb}]" for lb in level_labels if lb != str(reference_level)] else: - non_ref = [ - f"{vn}[{lvl}]" - for lvl in range(2, n_levels + 1) - ] + non_ref = [f"{vn}[{lvl}]" for lvl in range(2, n_levels + 1)] level_options.append(non_ref) else: level_options.append([vn]) From d127a707bda65d6a8db975ece43fec3d039658ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Lenartowicz?= <6quarg@gmail.com> Date: Sat, 21 Feb 2026 18:45:19 +0100 Subject: [PATCH 4/9] support fo Python3.14 fixed, --- .github/workflows/ci.yml | 4 ++-- .github/workflows/lint.yml | 32 ++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 4 ++-- CHANGELOG.md | 6 ++++-- README.md | 1 - 5 files changed, 40 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/lint.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b921771..f3de4a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,10 +43,10 @@ jobs: - uses: actions/checkout@v4 - name: Build wheels - uses: pypa/cibuildwheel@v2.22 + uses: pypa/cibuildwheel@v3 env: CIBW_BUILD: "cp310-* cp311-* cp312-* cp313-* cp314-*" - CIBW_SKIP: "*-win32 *-manylinux_i686 *-musllinux_*" + CIBW_SKIP: "*-win32 *-manylinux_i686 *-musllinux_* cp31?t-*" CIBW_ARCHS_LINUX: "x86_64" CIBW_ARCHS_MACOS: "x86_64 arm64" CIBW_ARCHS_WINDOWS: "AMD64" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..9d1269d --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,32 @@ +name: Lint + +on: + push: + branches: [dev] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install ruff mypy + pip install numpy pandas joblib + pip install -e . + + - name: Ruff check + run: ruff check mcpower/ + + - name: Ruff format check + run: ruff format --check mcpower/ + + - name: Mypy type check + run: mypy mcpower/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9fbd9b1..b2fdb15 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,10 +52,10 @@ jobs: - uses: actions/checkout@v4 - name: Build wheels - uses: pypa/cibuildwheel@v2.22 + uses: pypa/cibuildwheel@v3 env: CIBW_BUILD: "cp310-* cp311-* cp312-* cp313-* cp314-*" - CIBW_SKIP: "*-win32 *-manylinux_i686 *-musllinux_*" + CIBW_SKIP: "*-win32 *-manylinux_i686 *-musllinux_* cp31?t-*" CIBW_ARCHS_LINUX: "x86_64" CIBW_ARCHS_MACOS: "x86_64 arm64" CIBW_ARCHS_WINDOWS: "AMD64" diff --git a/CHANGELOG.md b/CHANGELOG.md index 81830d7..74e01f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,12 @@ All notable changes to this project will be documented in this file. - - ## [0.5.3] - 2026-02-21 +### Technical +- Added `lint.yml` GitHub Actions workflow — runs ruff check, ruff format check, and mypy on every push to `dev` +- Updated cibuildwheel from v2.22 to v3 — enables Python 3.14 wheel builds in CI + ### Fixed - **Factor:factor interaction expansion** — `expand_factors()` now produces Cartesian product of non-reference dummy levels (e.g. `a[2]:b[2]`, `a[3]:b[2]`) instead of incorrect partial expansion (`a:b[2]`, `a[2]:b`). Affects any model with interactions between two or more factor variables diff --git a/README.md b/README.md index ab6295d..0b2a761 100644 --- a/README.md +++ b/README.md @@ -536,7 +536,6 @@ model.set_correlations("(x1, x2)=0.3, (x1, x3)=-0.2") - Python ≥ 3.10 - NumPy, matplotlib, joblib -- C++ compiler (required for building the native backend during install) - pandas (optional, for DataFrame input — install with `pip install mcpower[pandas]`) - statsmodels (optional, for mixed-effects models — install with `pip install mcpower[all]`) From 6c3aa03bdeabc0c70dd8cd008d89808e780c2294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Lenartowicz?= <6quarg@gmail.com> Date: Sat, 21 Feb 2026 18:52:20 +0100 Subject: [PATCH 5/9] fix cibuildwheel version --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- CHANGELOG.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3de4a8..e8f2d84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: - uses: actions/checkout@v4 - name: Build wheels - uses: pypa/cibuildwheel@v3 + uses: pypa/cibuildwheel@v3.3.1 env: CIBW_BUILD: "cp310-* cp311-* cp312-* cp313-* cp314-*" CIBW_SKIP: "*-win32 *-manylinux_i686 *-musllinux_* cp31?t-*" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b2fdb15..93975f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,7 +52,7 @@ jobs: - uses: actions/checkout@v4 - name: Build wheels - uses: pypa/cibuildwheel@v3 + uses: pypa/cibuildwheel@v3.3.1 env: CIBW_BUILD: "cp310-* cp311-* cp312-* cp313-* cp314-*" CIBW_SKIP: "*-win32 *-manylinux_i686 *-musllinux_* cp31?t-*" diff --git a/CHANGELOG.md b/CHANGELOG.md index 74e01f2..d651229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. ### Technical - Added `lint.yml` GitHub Actions workflow — runs ruff check, ruff format check, and mypy on every push to `dev` -- Updated cibuildwheel from v2.22 to v3 — enables Python 3.14 wheel builds in CI +- Updated cibuildwheel from v2.22 to v3.3.1 — enables Python 3.14 wheel builds in CI ### Fixed From 85d0465e772a4a7f378631e889f50e481f0c61c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Lenartowicz?= <6quarg@gmail.com> Date: Sun, 22 Feb 2026 21:44:27 +0100 Subject: [PATCH 6/9] LME is no longer experimental + some fixes --- .github/workflows/ci.yml | 7 ++++--- .github/workflows/release.yml | 7 ++++--- CHANGELOG.md | 9 +++++++++ README.md | 13 ++++++++----- cpp/src/lme_solver.cpp | 16 ++++++++++------ mcpower/model.py | 21 --------------------- mcpower/stats/distributions.py | 4 ++-- mcpower/utils/validators.py | 2 +- pyproject.toml | 2 +- 9 files changed, 39 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8f2d84..004e7fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,12 +32,13 @@ jobs: run: mypy mcpower/ build_wheels: - name: Build & test wheels on ${{ matrix.os }} + name: Build & test wheels (${{ matrix.os }}, Python ${{ matrix.python }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] + python: [cp310, cp311, cp312, cp313, cp314] steps: - uses: actions/checkout@v4 @@ -45,7 +46,7 @@ jobs: - name: Build wheels uses: pypa/cibuildwheel@v3.3.1 env: - CIBW_BUILD: "cp310-* cp311-* cp312-* cp313-* cp314-*" + CIBW_BUILD: "${{ matrix.python }}-*" CIBW_SKIP: "*-win32 *-manylinux_i686 *-musllinux_* cp31?t-*" CIBW_ARCHS_LINUX: "x86_64" CIBW_ARCHS_MACOS: "x86_64 arm64" @@ -72,7 +73,7 @@ jobs: - uses: actions/upload-artifact@v4 with: - name: wheels-${{ matrix.os }} + name: wheels-${{ matrix.os }}-${{ matrix.python }} path: ./wheelhouse/*.whl build_sdist: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 93975f9..b1cb756 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,13 +40,14 @@ jobs: echo "Versions match." build_wheels: - name: Build & test wheels on ${{ matrix.os }} + name: Build & test wheels (${{ matrix.os }}, Python ${{ matrix.python }}) needs: [version-check] runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] + python: [cp310, cp311, cp312, cp313, cp314] steps: - uses: actions/checkout@v4 @@ -54,7 +55,7 @@ jobs: - name: Build wheels uses: pypa/cibuildwheel@v3.3.1 env: - CIBW_BUILD: "cp310-* cp311-* cp312-* cp313-* cp314-*" + CIBW_BUILD: "${{ matrix.python }}-*" CIBW_SKIP: "*-win32 *-manylinux_i686 *-musllinux_* cp31?t-*" CIBW_ARCHS_LINUX: "x86_64" CIBW_ARCHS_MACOS: "x86_64 arm64" @@ -81,7 +82,7 @@ jobs: - uses: actions/upload-artifact@v4 with: - name: wheels-${{ matrix.os }} + name: wheels-${{ matrix.os }}-${{ matrix.python }} path: ./wheelhouse/*.whl build_sdist: diff --git a/CHANGELOG.md b/CHANGELOG.md index d651229..b654c49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to this project will be documented in this file. +## [0.5.4] - 2026-02-22 + +### Changed +- **Mixed-Effects Models no longer experimental** — removed experimental warnings and labels after [validation against R's lme4](https://github.com/pawlenartowicz/MCPower/wiki/Concept-LME-Validation) across 95 scenarios using four independent strategies (all 230 scenario-strategy combinations pass) + +### Fixed +- Removed unused `predictor_names` assignment in `MCPower.__init__` (ruff F841) +- Fixed mypy `attr-defined` errors in `distributions.py` — `_Result` fallback classes now use `__slots__` for proper attribute declaration + ## [0.5.3] - 2026-02-21 ### Technical diff --git a/README.md b/README.md index 0b2a761..a230021 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,7 @@ model.upload_data(data[["hp", "wt", "cyl"]]) **Auto-Detection** Variables are automatically classified based on unique values: + - **1 unique value**: Dropped (constant) - **2 unique values**: Binary variable - **3-6 unique values**: Factor/categorical variable @@ -311,7 +312,7 @@ model.set_heteroskedasticity(0.15) # Violation of equal variance assumption model.find_sample_size(target_test="treatment") ``` -### Mixed-Effects Models (Experimental) +### Mixed-Effects Models ```python from mcpower import MCPower @@ -343,6 +344,8 @@ model.find_power(sample_size=1500) See the [Mixed-Effects Models wiki page](https://github.com/pawlenartowicz/MCPower/wiki/Mixed-Effects-Models) for detailed documentation on all model types, parameters, and design recommendations. +MCPower's mixed-effects solver is [validated against R's lme4](https://github.com/pawlenartowicz/MCPower/wiki/Concept-LME-Validation) across 95 scenarios using four independent strategies — all 230 scenario-strategy combinations pass. + ### More precision ```python # To make a more precise estimation, consider increasing the number of simulations. @@ -390,9 +393,9 @@ model.find_power(sample_size=200, progress_callback=False) | Correlated predictors | `model.set_correlations("corr(var1, var2)=0.4")` | | Multiple testing correction | Add `correction="FDR"`, `"Holm"`, `"Bonferroni"`, or `"Tukey"`| | Post-hoc pairwise comparison | `target_test="group[0] vs group[1]"` with `correction="tukey"` | -| Mixed model (random intercept) | `MCPower("y ~ x + (1\|group)")` + `model.set_cluster(...)` (experimental) | -| Random slopes | `MCPower("y ~ x + (1+x\|group)")` + `set_cluster(..., random_slopes=["x"], slope_variance=0.1)` (experimental) | -| Nested random effects | `MCPower("y ~ x + (1\|A/B)")` + two `set_cluster()` calls (experimental) | +| Mixed model (random intercept) | `MCPower("y ~ x + (1\|group)")` + `model.set_cluster(...)` | +| Random slopes | `MCPower("y ~ x + (1+x\|group)")` + `set_cluster(..., random_slopes=["x"], slope_variance=0.1)` | +| Nested random effects | `MCPower("y ~ x + (1\|A/B)")` + two `set_cluster()` calls | | Reproducible results | `model.set_seed(42)` | | Get results as dict | Add `return_results=True` to either method | | Stricter significance | `model.set_alpha(0.01)` | @@ -563,7 +566,7 @@ Full documentation is available on the **[MCPower Wiki](https://github.com/pawle - ✅ Scenarios, robustness analysis - ✅ Factor variables (categorical predictors) - ✅ C++ native backend (pybind11 + Eigen, 3x speedup) -- ⚠️ Mixed Effects Models (random intercepts, random slopes, nested effects) — experimental +- ✅ Mixed Effects Models (random intercepts, random slopes, nested effects) — [validated against lme4](https://github.com/pawlenartowicz/MCPower/wiki/Concept-LME-Validation) - 🚧 Logistic Regression (coming soon) - 🚧 ANOVA (coming soon) - 🚧 Guide about methods, corrections (coming soon) diff --git a/cpp/src/lme_solver.cpp b/cpp/src/lme_solver.cpp index 8c8a0cf..2fd5b9e 100644 --- a/cpp/src/lme_solver.cpp +++ b/cpp/src/lme_solver.cpp @@ -992,16 +992,20 @@ Eigen::VectorXd LMESolver::analyze_general( if (!full_ml.converged) { wald_fallback = true; } else { - // Null ML fit (q=1 intercept only, using existing q=1 solver) + // Null ML fit (intercept-only fixed effects, same random slopes structure) MatrixXd X_null = MatrixXd::Ones(n, 1); - SufficientStatsQ1 null_stats = compute_sufficient_stats(X_null, y, cluster_ids, K); - LMEFitResult null_ml = fit_q1(null_stats, false); + SufficientStatsGeneral null_stats = compute_sufficient_stats_general(X_null, y, Z, cluster_ids, K, q); + LMEFitResultGeneral null_ml = fit_general(null_stats, false, full_ml.theta); - double lr_stat = 2.0 * (full_ml.log_likelihood - null_ml.log_likelihood); - if (std::isnan(lr_stat) || lr_stat < 0.0 || !std::isfinite(lr_stat)) { + if (!null_ml.converged) { wald_fallback = true; } else { - f_significant = (lr_stat > chi2_crit) ? 1.0 : 0.0; + double lr_stat = 2.0 * (full_ml.log_likelihood - null_ml.log_likelihood); + if (std::isnan(lr_stat) || lr_stat < 0.0 || !std::isfinite(lr_stat)) { + wald_fallback = true; + } else { + f_significant = (lr_stat > chi2_crit) ? 1.0 : 0.0; + } } } diff --git a/mcpower/model.py b/mcpower/model.py index 37bd65e..4fa2c4b 100644 --- a/mcpower/model.py +++ b/mcpower/model.py @@ -5,7 +5,6 @@ using Monte Carlo simulations. """ -import warnings from typing import Any, Dict, List, Optional, Tuple, Union import numpy as np @@ -132,12 +131,6 @@ def __init__(self, data_generation_formula: str): # Detect mixed model formula if self._registry._random_effects_parsed: self._generation_method = "mixed_model" - warnings.warn( - "Mixed-effects models are experimental and still under active development. " - "Results may be unreliable. Use at your own risk.", - UserWarning, - stacklevel=2, - ) # Applied state self._applied = False @@ -158,14 +151,6 @@ def __init__(self, data_generation_formula: str): # Phase 2 Optimization: Effect plan cache for _create_X_extended self._effect_plan_cache: Optional[List[Tuple[str, Any]]] = None - # Print summary - predictor_names = self._registry.predictor_names - if predictor_names: - print(f"Variables: {self._registry.dependent} (dependent), {', '.join(predictor_names)} (predictors)") - print(f"Found {len(predictor_names)} predictor variables") - if len(predictor_names) == 1: - print("Single predictor - no correlation matrix needed") - # ========================================================================= # Formula properties # ========================================================================= @@ -2104,12 +2089,6 @@ def _resolve_test_formula(self, test_formula: str) -> str: if random_effects: self._test_method = "mixed_model" - warnings.warn( - "Mixed-effects models are experimental and still under active development. " - "Results may be unreliable. Use at your own risk.", - UserWarning, - stacklevel=2, - ) else: self._test_method = "linear_regression" diff --git a/mcpower/stats/distributions.py b/mcpower/stats/distributions.py index b083d66..cf13bca 100644 --- a/mcpower/stats/distributions.py +++ b/mcpower/stats/distributions.py @@ -249,7 +249,7 @@ def minimize_lbfgsb(objective, x0, bounds, maxiter=200, ftol=1e-10, gtol=1e-6): ) class _Result: - pass + __slots__ = ("x", "fun", "converged") r = _Result() r.x = result.x @@ -303,7 +303,7 @@ def minimize_scalar_brent(objective, bounds, tol=1e-8, maxiter=150): ) class _Result: - pass + __slots__ = ("x", "fun", "converged") r = _Result() r.x = result.x diff --git a/mcpower/utils/validators.py b/mcpower/utils/validators.py index 4e7d40c..5853af6 100644 --- a/mcpower/utils/validators.py +++ b/mcpower/utils/validators.py @@ -112,7 +112,7 @@ def _validate_simulations(n_simulations: Any) -> Tuple[int, _ValidationResult]: if result.is_valid: rounded = int(round(n_simulations)) - if rounded < 1000: + if rounded < 800: result.warnings.append(f"Low simulation count ({rounded}). Consider using at least 1000 for reliable results.") return rounded, result diff --git a/pyproject.toml b/pyproject.toml index 37f14c1..983e3d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "scikit_build_core.build" [project] name = "MCPower" -version = "0.5.3" +version = "0.5.4" description = "Monte Carlo Power Analysis for Statistical Models" readme = "README.md" license = {text = "GPL-3.0-or-later"} From 9d0fef1e83f32e8b5c6f090364ea69c14642e893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Lenartowicz?= <6quarg@gmail.com> Date: Thu, 26 Feb 2026 02:00:32 +0100 Subject: [PATCH 7/9] v0.6.0 --- CHANGELOG.md | 70 +++ README.md | 81 ++- cpp/src/bindings.cpp | 11 +- cpp/src/ols.cpp | 51 +- cpp/src/ols.hpp | 6 +- docs/screenshots/gui-model-setup.png | Bin 0 -> 132567 bytes docs/screenshots/gui-results.png | Bin 0 -> 140208 bytes mcpower/__init__.py | 7 +- mcpower/backends/__init__.py | 74 +-- mcpower/backends/native.py | 86 +-- mcpower/core/results.py | 2 +- mcpower/core/scenarios.py | 146 ++--- mcpower/core/simulation.py | 169 ++++-- mcpower/core/variables.py | 135 ++--- mcpower/model.py | 521 +++++++++++------- mcpower/progress.py | 5 +- mcpower/stats/data_generation.py | 116 ++-- mcpower/stats/distributions.py | 261 +-------- mcpower/stats/mixed_models.py | 119 ++-- mcpower/tables/lookup.py | 54 +- mcpower/utils/formatters.py | 243 ++++---- mcpower/utils/parsers.py | 8 +- mcpower/utils/test_formula_utils.py | 147 +++++ mcpower/utils/updates.py | 22 +- mcpower/utils/validators.py | 46 +- mcpower/utils/visualization.py | 7 +- pyproject.toml | 35 +- tests/config.py | 18 +- tests/conftest.py | 46 +- tests/helpers/power_helpers.py | 39 -- tests/integration/test_find_power_api.py | 11 +- tests/integration/test_model.py | 39 +- tests/integration/test_parallel.py | 7 + tests/integration/test_posthoc_integration.py | 36 +- tests/integration/test_scenarios.py | 297 ++++++++++ tests/integration/test_test_formula.py | 388 +++++++++++++ tests/integration/test_upload_data.py | 84 +-- tests/mixed_models/test_cluster_validators.py | 12 +- tests/mixed_models/test_integration_phase2.py | 4 +- tests/mixed_models/test_mixed_models.py | 1 - .../test_mixed_models_validation.py | 4 +- tests/mixed_models/test_scenarios_lme.py | 112 +--- tests/specs/test_alpha_levels.py | 59 +- tests/specs/test_corrections.py | 18 +- tests/specs/test_monotonicity.py | 25 +- tests/specs/test_power_accuracy.py | 17 +- tests/specs/test_type1_error.py | 27 +- tests/unit/test_distributions.py | 24 - tests/unit/test_distributions_coverage.py | 42 ++ tests/unit/test_formatters_edge.py | 230 ++++++++ tests/unit/test_mixed_models_coverage.py | 292 ++++++++++ tests/unit/test_model_coverage.py | 117 ++++ tests/unit/test_native_backend.py | 60 ++ tests/unit/test_ols_corrections.py | 251 +++++++++ tests/unit/test_parsers_errors.py | 168 ++++++ tests/unit/test_progress.py | 51 +- tests/unit/test_results.py | 138 +++++ tests/unit/test_scenarios_coverage.py | 218 ++++++++ tests/unit/test_simulation_coverage.py | 274 +++++++++ tests/unit/test_test_formula_utils.py | 319 +++++++++++ tests/unit/test_updates.py | 16 +- tests/unit/test_upload_data_utils.py | 62 +++ tests/unit/test_utils_mixed_models.py | 27 + 63 files changed, 4420 insertions(+), 1535 deletions(-) create mode 100644 docs/screenshots/gui-model-setup.png create mode 100644 docs/screenshots/gui-results.png create mode 100644 mcpower/utils/test_formula_utils.py create mode 100644 tests/integration/test_test_formula.py create mode 100644 tests/unit/test_distributions_coverage.py create mode 100644 tests/unit/test_formatters_edge.py create mode 100644 tests/unit/test_mixed_models_coverage.py create mode 100644 tests/unit/test_model_coverage.py create mode 100644 tests/unit/test_native_backend.py create mode 100644 tests/unit/test_ols_corrections.py create mode 100644 tests/unit/test_parsers_errors.py create mode 100644 tests/unit/test_results.py create mode 100644 tests/unit/test_scenarios_coverage.py create mode 100644 tests/unit/test_simulation_coverage.py create mode 100644 tests/unit/test_test_formula_utils.py create mode 100644 tests/unit/test_upload_data_utils.py create mode 100644 tests/unit/test_utils_mixed_models.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b654c49..50b15b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,76 @@ All notable changes to this project will be documented in this file. +## [0.6.0] - 2026-02-24 + +### Breaking changes +- **Removed `set_backend()`, `get_backend_info()`, `reset_backend()`** — only one backend (C++ native) exists since v0.5.0, so the multi-backend API was dead code. Use `from mcpower.backends import get_backend` if you need the backend instance directly +- **Removed `set_heterogeneity()` and `set_heteroskedasticity()`** — heterogeneity and heteroskedasticity are now controlled exclusively through scenario configurations (`set_scenario_configs()`). The optimistic scenario uses zero perturbation; realistic/doomer scenarios apply these automatically +- **Removed dead scipy fallback code** from `distributions.py` — scipy was never a runtime dependency since v0.5.0, so the fallback paths were unreachable dead code. The module now cleanly fails with an `ImportError` if the C++ native backend is missing +- **`_create_power_plot()` returns `fig`** — the function now accepts a `show=True` parameter and always returns the matplotlib figure object. Set `show=False` to suppress `plt.show()` for programmatic use +- **`apply()` made private (`_apply()`)** — the method is now `_apply()` and called automatically by `find_power()` / `find_sample_size()`. Direct calls should use `model._apply()` instead +- **`[all]` extra no longer includes `statsmodels`** — use `pip install mcpower[lme]` to get statsmodels for mixed-effects models + +### Added +- **`test_formula` parameter** on `find_power()` and `find_sample_size()` — test a reduced model against data generated from the full model to evaluate power under model misspecification. For example, generate data with `y = x1 + x2 + x3` but test with `test_formula="y ~ x1 + x2"` to see power when `x3` is omitted. Supports interactions, factors, and mixed models. See the [wiki tutorial](https://github.com/pawlenartowicz/MCPower/wiki/Model-Misspecification-Testing) +- **C++ non-normal residual generation** — scenario perturbations now generate heavy-tailed (Student-t) and skewed (chi-squared) residuals directly in C++ via `residual_dist`/`residual_df` parameters in `generate_y()`, replacing the Python-side post-hoc perturbation approach. Applies to all model types (OLS and LME) +- **`optimistic` scenario** is now a first-class entry in `DEFAULT_SCENARIO_CONFIG` with all-zero perturbation values, eliminating the special `scenario_config=None` code path. Custom scenarios inherit from the optimistic baseline, ensuring all required keys exist + +### Fixed +- **`set_variable_type()` docstring listed wrong distribution types** — documented non-existent `"skewed"` type; now lists all supported types: `right_skewed`, `left_skewed`, `high_kurtosis`, `uniform` +- **`set_scenario_configs()` docstring referenced non-existent keys** — `"effect_size_jitter"` and `"distribution_jitter"` replaced with actual keys (`correlation_noise_sd`, `distribution_change_prob`, etc.) +- **String factor levels crash in LME variance computation** — `proportions[level - 1]` crashed when factor levels were strings (e.g. `"Japan"`). Now looks up level position in the label list +- **Division by zero on constant-variance columns** — `upload_data()` normalization produced `inf`/`NaN` when a column had zero variance. Now raises `ValueError` with the column name +- **Pending state not cleared after `_apply()`** — calling `_apply()` twice could re-apply the same effects. Pending fields are now reset after each `_apply()` call +- **Parser crash on unbalanced parentheses** — unmatched `)` caused `paren_count` to go negative, producing silent misparses. Now raises `ValueError` +- **Update checker wrote cache inside installed package** — moved cache file to `~/.cache/mcpower/update_cache.json` +- **Update checker unbounded response read** — `response.read()` now limited to 1 MB +- **`scenario_config` dict access on `None`** — added `None` guards for optional scenario configuration lookups +- **NaN values in uploaded data** — `upload_data()` now rejects data containing NaN values with a clear error message listing affected columns +- **Formula minus-sign silently dropped terms** — `y = x1 - x2` silently ignored `x2`. Now raises `ValueError` explaining that term removal with `-` is not supported +- **`_create_table` crash on empty rows** — formatter now handles empty row lists by computing column widths from headers only +- **`_create_power_plot` crash when `first_achieved` not in sample sizes** — added bounds check before `.index()` call +- **Redundant `_validate_cluster_sample_size` call** — removed duplicate validation in `find_power()` (already called per-sample-size in `find_sample_size()`) + +### Changed +- **`upload_data()` returns `self`** for method chaining consistency +- **Assert statements replaced with `RuntimeError`** — internal assertions now raise proper exceptions instead of using `assert` +- **Removed "(not yet implemented)" from mixed-model docstrings** — mixed model testing has been implemented since v0.4.2 +- **Thread-safe RNG in data generation** — replaced global `np.random.seed()` with local `np.random.RandomState()` for thread safety +- **Update checker runs in a background thread** — no longer blocks `import mcpower` on slow networks +- **Module-level deduplication for update checker** — prevents redundant version checks within the same Python session +- **Removed unused `cluster_column_indices` parameter** from `_lme_analysis_wrapper()` and `_lme_analysis_statsmodels()` — was explicitly marked unused and kept only for API compatibility +- **Scenario formatters iterate dynamically** — no longer hardcode scenario names, enabling custom scenario display + +### Packaging +- **`tqdm` added as core dependency** (`>=4.60.0`) — used for progress bars +- **Removed stale pytest warning filter** for `"Mixed-effects models are experimental"` (warning was removed in v0.5.4) +- **NumPy minimum version relaxed** to `>=1.26.0` (was `>=2.0.0`) in both build-requires and runtime dependencies +- **`scikit-build-core` bumped** to `>=0.10` (was `>=0.5`) +- **`statsmodels` added to `[dev]` extras** for test/development convenience +- **Documentation URL** now points to the GitHub wiki +- **Changelog URL** added to project URLs +- **Removed unused pytest markers** (`unit`, `integration`) — only `lme` marker remains +- **Per-module mypy overrides** replace blanket `ignore_missing_imports` + +### Documentation +- Updated README requirements section: added `tqdm`, specified `NumPy (>=1.26.0)` +- Changed `pip install mcpower[all]` → `pip install mcpower[lme]` for statsmodels installation +- Wiki documentation review and cleanup: fixed broken links, corrected API signatures (`set_scenario_configs` parameter name), removed stale `apply()` and `set_heterogeneity()` wiki pages, fixed formula redundancy in Model Specification, corrected Tukey return value docs, added mixed-model caveats + +### Technical +- Removed ~150 lines of dead scipy fallback shims from `distributions.py` +- Removed `_BACKEND` sentinel variable (only one backend exists) +- C++ `generate_y()` now accepts `residual_dist` and `residual_df` parameters for non-normal error generation +- `suppress_output` test fixture now actually suppresses stdout (was a no-op) +- Removed unused `correlation_matrix_3x3` test fixture +- Removed empty `tests/mcpower/` artifact directory +- Added unit tests for `ResultsProcessor` (`test_results.py`) +- Added unit tests for `normalize_upload_input` (`test_upload_data_utils.py`) +- Added integration tests for `test_formula` feature (`test_test_formula.py`) +- Added unit tests for `test_formula_utils` (`test_test_formula_utils.py`) +- Rewrote optimizer tests to test native backend directly (removed dead scipy fallback tests) + ## [0.5.4] - 2026-02-22 ### Changed diff --git a/README.md b/README.md index a230021..2bd003a 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,10 @@ It's a Python package, but prefer a graphical interface? **[MCPower GUI](https://github.com/pawlenartowicz/mcpower-gui)** is a standalone desktop app — no Python installation required. Download ready-to-run executables for Windows, Linux, and macOS from the [releases page](https://github.com/pawlenartowicz/mcpower-gui/releases/latest). +| Model setup | Results | +|:---:|:---:| +| MCPower GUI — model setup | MCPower GUI — results | + ## Why MCPower? Traditional power formulas break down with interactions, correlated predictors, categorical variables, or non-normal data. MCPower simulates instead — generates thousands of datasets like yours, fits your model, and counts how often the effects are detected. @@ -297,19 +301,20 @@ model.set_effects("group[2]=0.4, group[3]=0.6, covariate=0.3") # Use "vs" syntax for pairwise comparisons + correction="tukey" model.find_power( sample_size=150, - target_test="group[0] vs group[1], group[0] vs group[2]", + target_test="group[1] vs group[2], group[1] vs group[3]", correction="tukey" ) ``` ### Test Individual Assumption Violations ```python -# Manually add specific violations (without full scenario analysis) -model.set_heterogeneity(0.2) # Effect sizes vary between people -model.set_heteroskedasticity(0.15) # Violation of equal variance assumption +# Add specific violations via custom scenario configs +model.set_scenario_configs({ + "my_test": {"heterogeneity": 0.2, "heteroskedasticity": 0.15} +}) -# Run with your manual settings (no automatic scenario variations) -model.find_sample_size(target_test="treatment") +# Run with scenario variations +model.find_sample_size(target_test="treatment", scenarios=True) ``` ### Mixed-Effects Models @@ -392,7 +397,7 @@ model.find_power(sample_size=200, progress_callback=False) | **Factor effects** | **`model.set_effects("var[2]=0.5, var[3]=0.7")`** | | Correlated predictors | `model.set_correlations("corr(var1, var2)=0.4")` | | Multiple testing correction | Add `correction="FDR"`, `"Holm"`, `"Bonferroni"`, or `"Tukey"`| -| Post-hoc pairwise comparison | `target_test="group[0] vs group[1]"` with `correction="tukey"` | +| Post-hoc pairwise comparison | `target_test="group[1] vs group[2]"` with `correction="tukey"` | | Mixed model (random intercept) | `MCPower("y ~ x + (1\|group)")` + `model.set_cluster(...)` | | Random slopes | `MCPower("y ~ x + (1+x\|group)")` + `set_cluster(..., random_slopes=["x"], slope_variance=0.1)` | | Nested random effects | `MCPower("y ~ x + (1\|A/B)")` + two `set_cluster()` calls | @@ -424,7 +429,7 @@ model.find_power(sample_size=200, progress_callback=False) - For simple models where all assumptions are clearly met. - For large analyses with tens of thousands of observations, tiny effects, or very low alpha levels. -## What Makes Scenarios Different? (Be careful, unvalidated, preliminary scenarios) +## What Makes Scenarios Different? (Rule-of-thumb scenarios) **Traditional power analysis assumes perfect conditions.** MCPower's scenarios add realistic "messiness": @@ -478,8 +483,8 @@ model.set_variable_type("treatment=(factor,3), education=(factor,4)") # Set effects for specific levels model.set_effects("treatment[2]=0.5, treatment[3]=0.7, education[2]=0.3") -# Or set same effect for all levels of a factor -model.set_effects("treatment=0.5") # Applies to treatment[2] and treatment[3] +# Each non-reference level needs its own effect +model.set_effects("treatment[2]=0.5, treatment[3]=0.7") # Important: Factors cannot be used in correlations # This will error: model.set_correlations("corr(treatment, education)=0.3") @@ -508,12 +513,31 @@ model.set_alpha(0.01) # Stricter significance (p < 0.01) model.set_simulations(10000) # High precision (slower) ``` +### Model Misspecification Testing + +Use `test_formula` to generate data with one model but test with a simpler one -- useful for evaluating the power impact of omitting variables: + +```python +# Generate with 3 predictors, test with 2 (omitting x3) +model = MCPower("y = x1 + x2 + x3") +model.set_effects("x1=0.5, x2=0.3, x3=0.2") +model.find_power(100, test_formula="y = x1 + x2") + +# Generate with clusters, test without (ignoring clustering) +model = MCPower("y ~ treatment + (1|school)") +model.set_cluster("school", ICC=0.2, n_clusters=20) +model.set_effects("treatment=0.5") +model.find_power(1000, test_formula="y ~ treatment") +``` + +See the [Test Formula Tutorial](https://github.com/pawlenartowicz/MCPower/wiki/Tutorial-Test-Formula) for details. + ### Formula Syntax ```python # These are equivalent: -"y = x1 + x2 + x1*x2" # Assignment style -"y ~ x1 + x2 + x1*x2" # R-style formula -"x1 + x2 + x1*x2" # Predictors only +"y = x1 + x2 + x1:x2" # Assignment style +"y ~ x1 + x2 + x1:x2" # R-style formula +"x1 + x2 + x1:x2" # Predictors only # Interactions: "x1*x2" # Main effects + interaction (x1 + x2 + x1:x2) @@ -538,9 +562,8 @@ model.set_correlations("(x1, x2)=0.3, (x1, x3)=-0.2") ## Requirements - Python ≥ 3.10 -- NumPy, matplotlib, joblib +- NumPy (≥1.26.0), matplotlib, joblib, tqdm - pandas (optional, for DataFrame input — install with `pip install mcpower[pandas]`) -- statsmodels (optional, for mixed-effects models — install with `pip install mcpower[all]`) ## Documentation @@ -549,11 +572,11 @@ Full documentation is available on the **[MCPower Wiki](https://github.com/pawle - [Quick Start](https://github.com/pawlenartowicz/MCPower/wiki/Quick-Start) - [Model Specification](https://github.com/pawlenartowicz/MCPower/wiki/Model-Specification) -- [Variable Types](https://github.com/pawlenartowicz/MCPower/wiki/Variable-Types) -- [Effect Sizes](https://github.com/pawlenartowicz/MCPower/wiki/Effect-Sizes) -- [Mixed-Effects Models](https://github.com/pawlenartowicz/MCPower/wiki/Mixed-Effects-Models) (random intercepts, slopes, nested effects) -- [ANOVA & Post-Hoc Tests](https://github.com/pawlenartowicz/MCPower/wiki/ANOVA-and-Post-Hoc-Tests) -- [Scenario Analysis](https://github.com/pawlenartowicz/MCPower/wiki/Scenario-Analysis) +- [Variable Types](https://github.com/pawlenartowicz/MCPower/wiki/Concept-Variable-Types) +- [Effect Sizes](https://github.com/pawlenartowicz/MCPower/wiki/Concept-Effect-Sizes) +- [Mixed-Effects Models](https://github.com/pawlenartowicz/MCPower/wiki/Concept-Mixed-Effects) (random intercepts, slopes, nested effects) +- [ANOVA & Post-Hoc Tests](https://github.com/pawlenartowicz/MCPower/wiki/Tutorial-ANOVA-PostHoc) +- [Scenario Analysis](https://github.com/pawlenartowicz/MCPower/wiki/Concept-Scenario-Analysis) - [API Reference](https://github.com/pawlenartowicz/MCPower/wiki/API-Reference) ## Need Help? @@ -568,8 +591,8 @@ Full documentation is available on the **[MCPower Wiki](https://github.com/pawle - ✅ C++ native backend (pybind11 + Eigen, 3x speedup) - ✅ Mixed Effects Models (random intercepts, random slopes, nested effects) — [validated against lme4](https://github.com/pawlenartowicz/MCPower/wiki/Concept-LME-Validation) - 🚧 Logistic Regression (coming soon) -- 🚧 ANOVA (coming soon) -- 🚧 Guide about methods, corrections (coming soon) +- ✅ ANOVA (factor variables as ANOVA, post-hoc pairwise comparisons) +- ✅ Guide about methods, corrections - 📋 2 groups comparison with alternative tests - 📋 Robust regression methods @@ -578,16 +601,18 @@ Full documentation is available on the **[MCPower Wiki](https://github.com/pawle GPL v3. If you use MCPower in research, please cite: -Lenartowicz, P. (2025). MCPower: Monte Carlo Power Analysis for Statistical Models. Zenodo. DOI: 10.5281/zenodo.16502734 +Lenartowicz, P. (2025). MCPower: Monte Carlo Power Analysis for Complex Statistical Models (Version ) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.16502734 + +*Replace `` with the version you used — check with `import mcpower; print(mcpower.__version__)`.* ```bibtex @software{mcpower2025, - author = {Pawel Lenartowicz}, - title = {MCPower: Monte Carlo Power Analysis for Statistical Models}, - year = {2025}, + author = {Lenartowicz, Pawe{\l}}, + title = {{MCPower}: Monte Carlo Power Analysis for Complex Statistical Models}, + year = {2025}, publisher = {Zenodo}, - doi = {10.5281/zenodo.16502734}, - url = {https://doi.org/10.5281/zenodo.16502734} + doi = {10.5281/zenodo.16502734}, + url = {https://doi.org/10.5281/zenodo.16502734} } ``` diff --git a/cpp/src/bindings.cpp b/cpp/src/bindings.cpp index 26fee22..8c02998 100644 --- a/cpp/src/bindings.cpp +++ b/cpp/src/bindings.cpp @@ -110,7 +110,9 @@ py::array_t generate_y_wrapper( py::array_t effects, double heterogeneity, double heteroskedasticity, - int seed + int seed, + int residual_dist, + double residual_df ) { auto X_buf = X.request(); auto effects_buf = effects.request(); @@ -129,7 +131,8 @@ py::array_t generate_y_wrapper( ); Eigen::VectorXd y = generate_y( - X_map, effects_map, heterogeneity, heteroskedasticity, seed + X_map, effects_map, heterogeneity, heteroskedasticity, seed, + residual_dist, residual_df ); py::array_t result(n); @@ -447,7 +450,9 @@ PYBIND11_MODULE(mcpower_native, m) { py::arg("heterogeneity") = 0.0, py::arg("heteroskedasticity") = 0.0, py::arg("seed") = -1, - "Generate dependent variable with heterogeneity and heteroskedasticity" + py::arg("residual_dist") = 0, + py::arg("residual_df") = 10.0, + "Generate dependent variable with heterogeneity, heteroskedasticity, and non-normal residuals" ); // LME analysis (q=1 random intercept) diff --git a/cpp/src/ols.cpp b/cpp/src/ols.cpp index 7d04ec2..11bd62f 100644 --- a/cpp/src/ols.cpp +++ b/cpp/src/ols.cpp @@ -151,19 +151,14 @@ Eigen::VectorXd generate_y( const Eigen::Ref& effects, double heterogeneity, double heteroskedasticity, - int seed + int seed, + int residual_dist, + double residual_df ) { const int n = static_cast(X.rows()); const int p = static_cast(X.cols()); - // Set up random generator std::mt19937 gen; - if (seed >= 0) { - gen.seed(static_cast(seed)); - } else { - std::random_device rd; - gen.seed(rd()); - } std::normal_distribution normal(0.0, 1.0); // Linear predictor with heterogeneity @@ -176,9 +171,12 @@ Eigen::VectorXd generate_y( // Heterogeneity: vary effect sizes per observation linear_pred.setZero(); - // Change seed for heterogeneity noise + // Seed at offset +1 for heterogeneity noise if (seed >= 0) { gen.seed(static_cast(seed + 1)); + } else { + std::random_device rd; + gen.seed(rd()); } for (int j = 0; j < p; ++j) { @@ -192,14 +190,43 @@ Eigen::VectorXd generate_y( } } - // Generate errors + // Generate errors — seed at offset +2 if (seed >= 0) { gen.seed(static_cast(seed + 2)); + } else { + std::random_device rd; + gen.seed(rd()); } Eigen::VectorXd error(n); - for (int i = 0; i < n; ++i) { - error(i) = normal(gen); + + if (residual_dist == 1) { + // Heavy-tailed: Student's t distribution + double df = std::max(residual_df, 3.0); + std::student_t_distribution t_dist(df); + double theoretical_scale = 1.0 / std::sqrt(df / (df - 2.0)); + for (int i = 0; i < n; ++i) { + error(i) = t_dist(gen) * theoretical_scale; + } + } else if (residual_dist == 2) { + // Skewed: chi-squared, centered and scaled + double df = std::max(residual_df, 3.0); + std::chi_squared_distribution chi2_dist(df); + double scale = 1.0 / std::sqrt(2.0 * df); + for (int i = 0; i < n; ++i) { + error(i) = (chi2_dist(gen) - df) * scale; + } + } else { + // Normal (default) + for (int i = 0; i < n; ++i) { + error(i) = normal(gen); + } + } + + // Empirical re-standardization to SD = 1 + double empirical_sd = std::sqrt(error.array().square().mean()); + if (empirical_sd > FLOAT_NEAR_ZERO) { + error /= empirical_sd; } // Apply heteroskedasticity diff --git a/cpp/src/ols.hpp b/cpp/src/ols.hpp index ad1f9b6..9e046eb 100644 --- a/cpp/src/ols.hpp +++ b/cpp/src/ols.hpp @@ -65,6 +65,8 @@ class OLSAnalyzer { * @param heterogeneity SD of effect size variation * @param heteroskedasticity Correlation between predictor and error variance * @param seed Random seed (-1 for random) + * @param residual_dist Error distribution: 0=normal, 1=heavy_tailed (t), 2=skewed (chi2) + * @param residual_df Degrees of freedom for non-normal residuals (min clamped to 3) * @return Response vector (n_samples,) */ Eigen::VectorXd generate_y( @@ -72,7 +74,9 @@ Eigen::VectorXd generate_y( const Eigen::Ref& effects, double heterogeneity, double heteroskedasticity, - int seed + int seed, + int residual_dist = 0, + double residual_df = 10.0 ); } // namespace mcpower diff --git a/docs/screenshots/gui-model-setup.png b/docs/screenshots/gui-model-setup.png new file mode 100644 index 0000000000000000000000000000000000000000..7f87a5392631a74aaf87bc7e9dcd8e6a550c69ec GIT binary patch literal 132567 zcmb??bx_>R)+UlbkU(&k1PktNNpMeacXzh|hTu*J?!n#N-DQG10}O7%;Lhf5)$YxE zzweK&t*@(QeqCK%ZRecs=RAEn{HvnWTU26HI5@bsGScG8aB#@}aB#1x-yr=dA!gIK z{_{a`l-6>AgTv_m*AG6C5rgDUA*!o{rmL-kg_VhsttK3s8AQD7KT5UW*i7HEWc(>b z|5sfX6K69sdpI-kugvUta@x6YaFlQ|;-6JL^^ey)bQ9+ghhU%%{|)gaN`?Sb;?s_s zb-+&X_;o!;dXnfE7boXfE!Pdxjeg6GezOz??mO{MuTOpbfN-c9ShE~`(1)wmDveB+ ztUVDBKWG!P>n39e7Z1;j^20yUiwupv4)#l_yDIfiMVUAx@`FoMPucVbisw8|J_r

FFA(4zd|!Q5Xg85( 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

U*C(`mkv#KS4w?Q=~ye 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

v{VPd<)Y}RddUJgfl;H3`Q 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 M>FVdQ&MBb@0H%MVj{pDw literal 0 HcmV?d00001 diff --git a/docs/screenshots/gui-results.png b/docs/screenshots/gui-results.png new file mode 100644 index 0000000000000000000000000000000000000000..f84152dfc1c1d191a0a20115991816b81734adcc GIT binary patch literal 140208 zcmbTdbyQnj(>IJ1C|;mgaVSuVLvgF%T1s(uD6YXN#odDiC{Wy`I0T0jD^ei1yL(6o zAJ?C^_;0s|5}M<22?2JkOfCTD$i{0 z#L_rsEccwP8LEAz&8E+u91thM#l_5*==p3gHo^%_hlUjFh27X)>@F3w9(Y0B!C*^7 z8Hx1$LO2`af16srIYw8L+Q?S2AnDoYMpU$ZK-6bP2zhyrw|mvI;oAOssPBc5%J3HF zh>k~Cn|hXm={&(*Qe6Mx@r-v7g{iVlMtIo9SmJAa<)>yAvEuF^QjD)ENLjyAOQF|R z{Dl=Bx$yai5#Hv`Q+z*HwrBo=i&9}oPgx2Qy=_K0RaOuPKK_eO zj5|y>*Y<9wc~(<6%}^ckAD3URrR+HJI6!qId`|a7R9XY{S^H+ZEO>lcMaAbwOO5qaAs=j$ypMS0I~j2 zfkZ5d8&x{w(}+%hTO5%}*F&f29SmMPAjfr|US;n1dwtvx)Z(0pUXGM|xF#L4vopM) zJp1m(@Lvrx0wiQns1@aH$bJcxglcc&%~`t@JO7bwPZ46fZ}yME$o=`_TeZ+~sNz(~ z176$`npxw8O1Y7;R9kfQ0r0!d9v6b=*kRtUJ{Grr`G;~X@n^dB*yd&$zfZq%Z-fTX z06Iu5hWS`f_xBl6w!_pkzkok`*I;yMR=Rj*kV>!E$J&pQ;<%|r>R^Q!-!@?fRyh+B zXNMh~)<3UR-#Kx&;z}Y#6a7Rt;B=yf`vV(geXRQKFGg54-TuXoXsHfD;hDWIatt`KI~)Q^-lA zTCJWt+y!?H_#`gAOs4aCDtX?5;c}d;xn+8_&0GOYRS%-YVCUQX>OpVe>@ld*I(t1h z*oGog?!Mg?YYMP-rkVv@Tu}Wv8_p%nH51qsK z;tA&G%B(j`qI)lBcvICfQ+tI1jc`8Oj1P^5>+sbulOozrhm2!n(Sfc_k+!jkn-^JH z4~s%!t$bT%&ry?or)u08+B?M0&Z>X0gK4)<4Bjq*cv+WI#rXZU)uwT2yMMzJ>hELa z2@udM*+QK4y*-u0*EE!^>xho5Cyq4MW5t!FyIELGi#gBj#FPA8utB$Zg0$IID^$G$ zq(u#9t6M8vX5I8=u2eW+5ulXsY_UZ*V)LS0v1tWRALt|XB9%=#fY9kzBQi`yJBj-^ z9JKO}DOI5wt5T}&lp$|7hn2dcjwoxlaKebjqT=<>&VD&-`Rl})_5Yf*wtYjPJtv&c z?=cM9*x86w+bi;GL@)y1>=(Z{*rNp7X0L!5*NAtxnSYp*V)CqiXmN(#-E%B9I|osm zWWHhB`{o~}-0YQ_7eEtOmr3uqQ=D>r(A(NZt~Kd>D4M$fIV!uQeW1D*{e$R$q>aaa zsgAdty?TBWAn!fDA-T773}2)g;zmD~BL8c7xvJfvo3!!0aY%{`4Zb^r7u8D_D^few z!xvQjylQZyPQ>4x29>_SrX8|lx^^8rW0uT3$JV4CK0n(Jrd}3`?35TdH7`aqH{R3v z64F_0tST;oPcJxU;6`dv+BU zD1_jyX1C@dz0>cQA`jbBn2J?N{G3IkI`xA$EADI~mriNuDsDF5*{s6;ED;VG;2$az z6<&>W{(Z)$9sYbx#%o;LIdOU8Y_yzt0v8RO0t8K6t`+X{7k}ly?T-)l};yU9d^oX0&@@R>&k=Ooi-9y&Mp?9RWSfs_5xN_`%Amn=r>inY`j3ZY7S;C&8Uu|Q# z_}V?}Shin`>=!(CYbB?&_8XX6av`K+Qt(x(4?L#&K(e=46T*;7TW~!c{3KU$bm9tp z=2C!WA@AZINUkw9|EAis_gV!W(Z9MsNw|BvTbd79l-@0@;J$c#u+hq4)cke`gX{}Af&x=q> zaO_Euy_eR}ubbg(A_~74QYR>%$Exskp{7)!d(6t4ai@I=3EDQs&m74DhIE_TAj{H^ zLq2-dlsBTIY;FkFJHL*vy+rX+vr=o*Z;CaZyk)-bMUF`ovRi@UPC_!(*im+CQ|tS77+Th9$XtO|2`OXo=Mu2^fkEG!?2Sh#w>$U(Wk^%KkaxJ*2uP zmV(qoqMR#HW%=7?{9Ip3MwSST6=ay9ag%P`8ea%Tg)gx!(7 zxK`9ww3=w=9lTmHze-z>n&uF)It3iFyy5phrsU{4qH)tn`W+G{z^{LTJnOT!Iv2;9 z_vDh3bu-fQeBFNyxoELLjWF9d+_E^j)XBIS;%t z@>C4El%-IkFqe#rJ9|L3diM5o3o(~v z579i(%oK4jgKXPGEcMp?AnSFNmQ(+3}8+&n((5)+c3jS2=ig z;bT{K2%D12z7}M(zn)y)YZ`{pm9xL(bCwiMlV1O+UBThpSh&9hi%`1@h#)=qIKqt1 za(GsyNqQ2Yv?yeIjXKF6K(~V^%7Ub7t;GEJX&n~M)$dftEniI6`F#RiGSD8U9TG#z z28XX?w0P{ zPS{oknf`l}oaa6|q!e8PsW{aEqcN4?P|-%oHFLcwpH0*CbuJR0EgZKMjsPJF&iggl zm}Z;&9N|Ttjm>S>li`45{6;}C4ze!S;F<`p#q;JR_9hpWG`ml3%23pm=S#1b=wSpD78aunc)e@_Z=5n% zY6kiDAE;Nq^Z$iaX=Uj3YM~KSY+o7mBTkAlBs7f3cU6D?jyd#7M(@lw0>5@*g5pfO z#dV65@Wxj|V|1*=MMZM+_Gu53;n0R>HNF+AkU<67DyT~J1~Uj^w#I~?4En`!6exq zRTL?js~+oC3=2BZso|xI(H}#iQr<^ZU`!}$?!^c-~@gm(Hwb~+>$TDMrNr!}jX zSLPC|U`zx4u7f&($C3X9L_;46Q?bO~%Pzk5v>wVrQm?cpKc%uZaP#@?p@%XUPp9UG zk-LRht}X?opl8U_{7C;Q$IM=YH9+}QgndlJ;VB_Lm^??vk=UERpMc-zW5J;O_Q~@2 zHz8BA*_(8?8)%-|m|!$XRf}sPJ8Ua6S=m;VQ!sf6jGv3Fjr*W4tM0RcVs!5)bw+3d zhW^3dp<*zQKdV>@iDoc()0Qv{GqSNz{lm;s_h;|@R0+liv@;H7XJGTEbJ|!RbIvsp zBf065&#s=QJ+ueNsXtfek87S~WHvDE-bw~ zSEk6KOekJxWw2-<-~8uqCm#Oy@6z zk^&n>BQ=2R1_WyAv>w4(TCu7ZFMe9x?D8ggKD?}>{m@vowRirwo;g`x!a~W<4!++wG7}7q09>feFO5;-+0PYc z+&XY{tK$gL8_c~xcVXXFQ-$L+I5>>AI|Z2O@6vS9U+eL}LCRB($WS=ZF!~_PyBo5l=Cj?`mLwl*tewZ3)uct~4iO!qbHjaR8>X?0@wGN9f z8(F2$1U`tom<{eXXLaEi_Tfy8q!ig=Xk$7?dY5GLt6D)ouWwjNKj?D%{f;w?6puxWQ2q ztqpI>c6lyNhDolTiGz%z>8S+S2x&BaRSs;(*_}$Pn(FJj%^bE7iSr-9=R`X<>LXC+ zU)bH@anH~x!b0tcW?e4X6xKkECM^RwE$qTB0J3tA`{_2{^)t9o9YGLD?+OetjWAuS z4o#pLo^#*iSW~aVo-{HPMp22zcD_fw{m&Ibgy#kztvid|M!1XhIQ$uy-w*;LM*R$i)qKS9Gt zU5dhR_1gTG=gG^Ekr8dh#M#kcGtTgOPMAosf--^)PAG4O%K_#{G6^P1l3#xo`Z zA$ley)Vsni6?zajoGKW2zCdM^j_Q{4<+GdUJLhA}>2U{4<>r|xCFB&VOT{OeYOD|$ ztN1ts-~a7Y8kdZb!uBO;?-f~F%Gkdh&4 z9JfOQk9@Qyz`HOUXF7ggZxzfm*bGcYJ zmd|v{v;Rh-y}L82jKN4hKY*ji=v>$z=<@w54Lw#lLhi8JE~05$pAY~*^}D9433DKr zir?l$D)Pkw@HdlF=2^-Rn2UjnAUWG`o?(CG<=XA5{TWgD+*Ry1!#0L@yRQG`K?H-} zPrW>v3T$*=8M0Vj6$o&KF~{`>r8HKLXxFrWnH&^ksjoee@jj#f!0~}4h{~s%Ky!3Gb0a0hj#C=bGkB% zVj7o&;pKa18-N=H1Lt`S7xCY3_GXzw3keB%MoKEh>nx?J|3#U z2?@{Q=%nH*E01uet_KtAa*d`fbLpi0{|+BBj`J;)l$5lgmh_v|as_f_j9Q6{i$BG| z>6x0k2>k-zkUuV7T{Ssi{9A4!Z%nAHoE+HspQpO*1xcu=;5|R@U0&XhDDZ!i`PXbf ztuT#-3geR(5R8rYE+v6~|0so-#deR0z>htVxg{r4+5NRM%zMw9q8S8U zU+itmc`ix~H;+8L5H=Ca;$4_a;*DmL8cGN5u0{bpGaOM6LD= z1w9?2`HGI!YJAJ048b4QTfalBS@Vp5O-M>G`6|phnmL7e3Xlxe!E>{s-OPxjP=UVs z)Fj#OCa!dE&G$dv2%L&gT>eSaO&43Q?rSf%Hr)dDm6NEK zy-RpgtMg+n?MvU^gx*JeESot%)QBKDGX~W(dP-ZArL(8a_?69=H* z^|Nj1`CunQ>Y1-Ut@is_{`N=VNY8p_t0_`5@y#roQC-?7KIgqbZyZ47rOv*l9esVb zv2&obHu=;0=!Qv+T8k>|OotoqYhU1?T()^{I`*M^wLG=C2aI>fR$JuI)fZ+=LPud zod*aUSskJcgt7&ek8IX+7;|C?H+O`VEe(UXbzFoax(t)|o=E^_CH0h>QTvWJ;K#vF zOWuSz1id@VxZ!G~(sGrwwW{5kw7>JrQXVkE6@KOwk(w*azjH*G?h9Lq`nn?J^T=E4A2P~i6aoUy2-l$zEC!R zasHuUkTfCFAOK6BsC`LZAmH=)S?JsA1sq*b02Ppjy5^egIn~e6x@O^3$*|?DO!(}m zbtyX<(;XkC_k^U%wa99TE5X*z`maT#9yjMlE88Jgjw?!$K%*UWRg z9t`9fn-h(aoO=_CSaKwDTekASlh_~$w~Qw!^;Cs+K$fNs>_bCq-W7(e_4pEN+MIp%l)&dOv&txxa7uNs*KRUyZgS*qt{#7WHmipp)wgN_{F4;E%1~jrTL> z8sNA$rP}eB_*4_fL5V&VE+bGT8+oj8X+KoNO_+?XcgDNi*=7cab8U6*ew&IBtmNwI zj%D>(#b;5s8YH%sSxrYaJ_QjzPg4pnJh=6z@cpv9g9aV4*-pT zBcB7s3S-^jmwhgOr`!{ zEB?W4t-`DNV!GxVKSK?(TQ9m{U6%F>GB=vG3;3jFg-&M-TZiJ0T8{ci`=3U63e#rW zCyQPAS7QzK$)9IaBGK^gPB-$i?P*pLe$yFk3}2O!Ct}ukuqhRHC(qURveE4r^|zfM zAsG$llYvPeFqY{i6E=D!O;t;RDg*V6*cyONojL=1Hn<6IH!~!taU#RhO|B2|8RpT1 zx4TzXvhivG5&ZT;TiLmIa)Pz@nWz+tb1+tZ!U27-KBJo_$gKx*A0B~;*W0tzEB3UN zI68<^1Q+Q>Hz8TjQ{j(-)F(SI$I1nLQzMVwDeJ9|zf4 zp>-|mD+o6&Q2bzo*l7FBm-Vf~m@Hq^eYFutu~d6#HNJ%dLS$tYXLX5BhRwK=%pnk> zSYOmaOWl|Svb}y6=*n#qsmar=|s{MA1D5`;UeLzuXvVw z4+n20@4t(Y26Tj)=bXFyT#zcps(^^?$EI>70tZg})3Qr%11OqQGMu@WbGQRX}YW2uF z61GRP>Ly-guD&1y?rxi(SNz(Z_w+-?)J} zAbA1)KVZb^@1yz=jM$^DZ)_;Y%hz!ERxx8`ahomBh5s9jBt8)6e+>zF^4PC(*yt&@ z>%zYT*o2w+UhQi(I;}W%+=reqHc51hSk+$@kps)i*~_)78<#o$Ui0V5H5fJ%`Jq>y zMBZP2rLyZg{a^5KBv(p`_YtEe2F^b2m(89>FCJmoj0_{=Ii9np=Kur}$tV-0L;jyA zzqrP8l;{Y=a2&PB`}gnB1inoFgE$kdhn72hv7bKu{>bc~r~gkIkNJ#x3Q1;-jEUKN zR1w>wdWNLvm4F-;!@yX(*(x>!DG`kDl|~UBm7smie?>o6ZlOZS757d`Pp1%aEi5*dPG`+(e^fCuG6ss(Lu10j zvAVmv{}Sr4zuKlFir>YyN+|g8I`MxH=7-6cziaUl!LNC}<^P9C4wwJ&UkGw&g-sLX zUz5!ExU=GX{g;}gb4UP>_VkZv=f=cmaEdBbBn{JboW}%u2A{=C>m5g7fbUT{j-MuP9| zq3;f(>bVr+b+oR}3ugdwkxg7l@ctt+`Pg56t*}T@v7gO`cx97`r9@#Kkw0bw+X)}# zwyMuaPfvG$G;g<7Yuw?2|AUUfrX0f>rW?>0HSm!_>Y*BIP3*!nIkyNC*>s$jM?S#s35o&c!uQ?E8ruW1)6qN#||y-eJ(Y$^EH!4Hto;ZdWl`;pvC~rPriq zeZ9RT;JT~b^C_-;R1WPqq?_7{v~=~Hh_^S!8zjfevs>bOesmy^+H%#p?XJ`!*me6C0$e>0T3qp;aIM?*;vbU zyOVCvR{KU2IH$k%WaJ?F;JnF3%I|^Svo|HfUYDDvXx91qWpQ0R)( zumXn!jp9!sH`W*Bm%H=$s-q6w8S!F#?rI#)S(U+JXPVNiKv3-FmoBH2g~ed)!5oCr zLv=0aA-HK-N47f=9vOjWIx2>Z_#J>!?u4czEL6)M8*W$x8D9|bW&U;=9qq+_(u<6y zl>0r`bj$lvNJQhauT9%jH%mFLh^W89_CC$A-8>)Py;gcT8nEyya_Zfo-W|XH0X8r+sc)1#7>b@avfxdBo8_T`8&?_Xd05lTcx< zlZ6kT3*T{wKO5O&QrgXRx1R29J0%1sS&ynwmfFqVdEJ z%sQ!VwA$~~pk6tbUzfO5)3@k^|wtV@gRN-SNv>jT@Grlw-Avvn^YpXH_Py*-7q_5O#%XLCF; zmtZqBzG7pED4*u_!_D{0W>+v*a|<(Fcls%~(2sOqEoy$THBC2)HZy#@VUKIXeD({B zC1+Px2IJCgJOjTlo!%}U$NCaCN4@x!o@Q;u4IrDCgVl0Zv&T2+$!u{+8!lH1u6n`F zOSJqGshqi3x=)O1j;=eZ_vd~NlImI>BwSUS7u$tHxx*ONQV{EQmj$OxSuVI1J$i4l7;?jPUUS+(ixTI{@Zqw4~VfuIa;DZBx& zL3ZF9$DK`E=hB&?q&xK;H;uYr9_2X$BZwS+RN~4gHfwyHJ9T|{7R190En~S_QszQ< zZ4(rpe|5)uWaDlIJ?eLNVH+s+JwB51vKKnWFv2L752|h-5iMe<;j<^snQaq{F(ZPA2GRAcpcg^>2 zEtuRO<4|(zXXJI?xb;#PtIKg6mr3{dd{}2W6u@cQ9wZRtnUi|KcPg$b+UE|ZM7XMh z%l^4I(0RU{>-)h|X>PolWveU3mN-a|LE-#`)l@r3lDf}fgJEZY_}6V1v{d~3C3aYl z>7t1vmNlQ(jituqPtvwRsnGLI$S?hY4^_UnrW;fnOXg$;oerw%#mbxhzW9KOcoGsW z2ds-KZ@doKLnZOX=Yvi^tVb*w28*XG1QE~|gu$QhiRl_5Fp9rS+;sdBhNcgFT6d7N zcL}D{01#8PWwk_`*}}T@pO2gM47A|f!!?;q=Iy^twie+r)#@k59U;>~PgC8XS`}Dy z0?va3SU8@y5nRx1e%m9Jm^s53ngGxY7%_6N<(fFbBX3_8WZLKE=kDN2y5HNXe{<~( z?~-3eECs(iN$Z`FKZD&oo(5NnYz%xL{qUK!k7cwOHO*Nc zsrZZkIBecC67M*YULNB`V>K2kM)us^-Kp2<$H@Z<~g9rw0a5(*&HVZjeGEOO5->39Yg`uCQp~_+w+oDuYB~&B9Hrq{Ar%$Jz@m zKjCh#$xg<@+Til5o4c5|?)qMUmj3^D3^f=#CPLT!svL64bn{+>$&4l2zx?8g;ov(L zGl}5Z0p(=pi^8b8b^UxUi2*ugkR^^Y{K*AYV}YWJv7djmM&Z%h?QHo>0xJVcJvq9} zdrzcM1Wj8M0P%%SoL(|EAa3*S*yXas7?>Z>d3%>u{4N&?QsCc;-suZzg4vjQ&A;S8?lHR`C1#2>Cz_V$=*{|`+n{GvPz+enTWaW}=Ot$HYgP3~vRd8wf9yuSlI$AE9AuL=}G?aota|8b8tf z=HBrMjZzDXMDL#zcO@CL;s7U^#Hid_=Kc*TbM%0`hZOPlmGb9}fVLrNS-8P8(h4=u zoogsr`D433Tre*$ueq&l<8dGE^n*DA0IVDwF$+z(&Uz~=FFlWVmJJP4YQwpSXkCKP z-N1z(&PLw8p^~yw$d%Z-H63Jj48f>#HPDWNQX1&qa&d+m7EFEH2$2b?x$q5~@v!R^ zf}1B_`-J1?26n||g6pK-ghY<=*K7=HT`p?}OT`(ipGUU55j$g`2|Mn&+sp9$)4BC_ zNQTQu`Cjy1&W*3IjB-4O4$jMa@YXXlgeZV5VXL53V?uyjr87%}tOGlK7y&HUFHW`=MZk z+w;=cJM8ykV#!f-K#iuYZrWN{>_d-meGxgJ({4PRor%t8$w2>%wz3nipcg@6(CMDO z^2_nN?=0^Vi8Jf2m6u#eZVRlZQ|-ek<+=pn;n`WQ$jfHWc6RSL4FtZH#B$6Z2Z1;A zfOIif#KN_<{6ep13-a%eHK1*yrb8B0nV1+6Tm{Ia_B9t8!rcY+u8JXm(?w= zIim(4y^oURLcG4kXS2wy^)$0QR8KUb$lwkTKX;Cso1%hJ>*DSBca&<@JzHMxFPXA4 zn=B)TH6QAKL;0XbI!#+F|*=>}dtGk;;Z zeK#M}F3R`~nCwYJQ4>k0XrE_XeFkbko(ow$7`TMQ-KSNC&sNv*^khoax_v}?1WBm$gA*!AR5430a z%E##7aaE^}t5(l6aNX5N47#~PRmHKm&7I!Tiv?75P2?H#Tztc5?!PvzKG!uNm36|Q z-`!;ym=T4nRvFhavjqmeDSs^9Ox-g^bd9Rw&INKrbS>dh!aOy_k!C3G5JUrht$5Wz zN+RwSud|oF!Hy~cI!7=Oc0wBShjyU|nuS>InI*qnkK|*!Rl1!aB^5h2w_mJyIW6J8 zKdI-!1>*iPtHaAPX{QNqOjeM=hjs5DM zkWe5JvM`mQuWpv#Satem5oIs?b}6q~0E8PZ=m`pk*UhQMGf1o|to zoHD1&TX_glEFXoCr31^z0)hS+PY^N|H(=NgLOCY^C8D?b2KCAG&0d@Qbk%ua`rR^ zuXKiJd>!i?=lSXYun_HNi*9Ai#V>fPy*@kXq7F`zD#DmWU+7MI4=i1VLD*QHJeKpC znSOI==OXqzmghUMLS@bY6J}^Rmk4u~rfNcfr?P3!uL1)m_MAe5vv)pNn(Vz6=k`Yk zt>2(5?s0P#Tq%CPsg+KNi)*b&?1?{Pu?DwUNrj|Yx) z-i^K1H;BYe&5I;~ zdM3(I5jLH!pkB8C>xoXDb()KhJuCN}V2LI&kKTnP8bJIx-n>tQx(A7q{HZ+*i%^eY zDPmY|hbY))H91>3?wSI%KHc?}*(dN*)}m*^L^JRKR5|`R|MvC? zAJ8W^cST`!5D>U2f9_8f7#yR4EK-T7vlkt8mInn5(Gq~w*EGkx+#hgiIFT!wQ@ zJj*_kS~MHaEZ?kVd^5q!pG2R}^4HtTQJZz{9Hz}*vNJNCX8Tnept&0?k9dE1c(o^S zIQDGVhm0#Y)$>}Fb|UBA??x46B4K$y>&z8FH}^OEx+~t6_x~xX7`ix$?DWLq@x4tr z`9)?m0{xZoimJ|+yq`S_d;OXxQ2k9T{HpK^EtdUJ_UW|?6O+1qW>Z+j(cNU{N2`&! zHN8^8lNmhIvBf@0eG?O(J(|va9pYK#xVZ(HdfOSk!-dPj0w)&bZTpnt$*>+dU_Ej& zIS>Zgu9XV9HD}i))k>x9FzSJj*}DO^MrX^CZ%D|J<7fBiRahM$Bx}r`}{qv1| zJ4C1WCP_kZ@ztpj4jbrL$^Y!BUjtE%6uO%fH_N5D*Gvb zNCY2X`#R5RZ{MAUFReg`1l-wp<{b=)4p%?x@iHnm0Ny#pTJ)E`8H;quu+5HMJ=*)Q z=Lsg7{latdc`ObR!LSdcAs}G$S=szr(94-`P8=9xFr9VItn_K}PP2?Jd*BH%Id43` zm~yNW-gzk0-01y^j+3j8m2QRs_Zf*luJ7Qq@xGYoJP`?LFf&Ug#i2`k8lQ=)b033b z*e?7!TQ%M$m-#VMRpn@|^x1Bpav4(PQBWGN9{o@cSea1&>`yTWZp%{rnxMLyknr30 zM5XLAQQ@nok~yl9?{EAQx#VlvBR-}6Zgb!32zxCN^zs>fti9*XJriorB-5*b9XHi6 zhMTdmvE`Pm1pjS7IJvB2Zxr@QSO@I*LiM=^TFh_-)nYX1&#>QZTB^zxoo0tC1E1C< zwmFYwI)8SQQhoYnb)sUW){e?H0cv~xwv#@*+~|-zBC3%%o%_`Xfd=-Li#uXBL6(lB zmG?ObJ+thd8u<}1aaMY1Jx?$&h@L-}-lZDmt-$`F2D7UuUn#aS`8U-96ONh}-Zv4>@30d)aRFdDgW(Ar3;3SNbJ zW>Z>@d>g&#_Qj%WsC)7I=bV1`_6y@VEU#TCol=LA0|Ia+Z3mb?B@x%GcrJr>ls_}d z4K~pS-ieIrd(ex4KFK?So&=I4Ke&y=D!n{&Brkg3_n@R>l`=f=4o8m~tlqO56xQl$#Z-C!qAkKy(G|lRrJ6=XAln^`f8&iRd z>g*fe!12YAq*0RDAJR!x*=WH(LZpX)G-_&U;_{3PXLm5(Bw_{E^OwFw+EqepYkTip z-+v0*vuEPv4Ye1kGp&R2_~~CDeLaSb(8#J4FTL_Gi?yp=bQJpNJ=M9FmluK6cUn8g z0?3TyN-MZsDp;F6Na8@e@Y#PVfRGy0{GGm5*#4;y{`1p6yK1%q>wNtSU?s4T12z*d zeK;Ux?5M^LE;H7zgIz+B&hXWlfoCY|!rE*~>hqiAHs^QZRT3<5_0}XJo$XAPtj6~t zVyWz^p(Ru&r)zGL@0Y74k_!nm1+8*fZ@)leHM^Q5uBm2UcXZ(01oCtBx$??A3z{W( zv4}t)$_QxY+REKlc<@x#CzH$ObIa%e^JRAuv?`&P_AIOj^$OaehAa{2m}XUWuY^0O_1XwdRCl>LJksy*q1?XSf5MpQAfP_NVR(;`h|qH(Jo#OymL< zmx<&pXIEv$pT+%eEdYz(`UA;S4!GBj*IT`$X8b1;->mw_=^35i5x;BTVuY0xXD<~>rOtMxcN}DBN zK__>Lt+Q`n-uvuA++c@qn@krJf;QM5zEWx#4OhuilmH(hpS)69BLZ3hmjwOG3- zhjf8EqS|ZGCo^u7S~&;a!0yl$tl!qYxC02iNc)5UB1mwmR3P%vWIWrl+r2N)#L+an ztXxyQGlMj8whQ>#Va9{3-d{dG6}yA49(c!1sh56a>}{JpRQV3(Ph?d!XQIEy3goj^ z9atsb1RP1&^I4}Ca|JxP-QEc`zUIr{&Yr4j*s>277F@}MB>x7|oqia;iGYg7?JP)i zV}%l&tMoP$b~@eaS_+yA9#U?%W?%5`Oo4?qjt*jv;^oZ_e}sScTpt@WTnR6vFw9|B z_yci+UhX(i+ujmz-aB3KpUW0ym0?YZU?qsn>Z*cjjp$aUr$7;x^gKLbEFY(y&V<49#b zyoiy@+nyGY0C~H;ho&@1(rpU`AE{m})4%9)_t3rbUDe9T*)TQ@xR|Aos^jA-YJj*H zpHO0{<}qVS{!9(V{`eZ}Ee_T-ZNE^(D@hTD+kKm5OiM=-R9@r8A!TZ zl5Xbr&6i3~b`CwJmhBpQ4<&Y`<-^gM$)2(VuG2{mP_1TPb2S$IFllaH&vf}7V`Jsd z8yT?#wbk;n*VF&SoWH^O=2b(I5pLS4(EuzB8%Q0{oK^Au7#?LDR@9a0gV)l=9y@V2o^iBu?a^ri> zc|Xrr#{I{C|8d8?Ym8(ktFJltTzjp#pE=ha+JkdL$2zPxzdGRi*;V7Ru!aZ0{HWHU z$&lr;AsbZ9>r6IrPuB-7VG@B#zAUPb5hqh+6XDis_nq%N{*f8mc+w2-OJ__OatF#k zv)wu@ZZAf=gL2=Cb3RU|e#|mTS}AeZ}hle{^oB9gzbXVb^SI z>y9jYJ_)Q*nl6vI&K5CzTQ7!D+Iu_5GFGCeEt2>KAme1cS;Ch2V_4`N?+(3(gcVh% z^=RpXlZy{eVqf3d-OeytNGVvDm+y1oI`B6bIzp#YhCYqF4gPgoKo3>4tx3*U@j{%P zPb2FYH@#~VkzyGOc=^k94Vvo%w|^{f^<|o6bC^9iV9#20Y#)c(eoC|KbaivPe%I1q zZ{Cx98y8lpMP(ra)?VK_m%M(7Q3>RJbm@N ztvq{f@GK`qiP-qO67q+(^ebNJEG6h4c|4g?{z5)IZ$!KD z-)&zp>ZY(vD`d)aXnd;;`iPY2IFq|nGdhu5r5ytsKb^}2CDPMGOjV~>!M*uWpI=N{ zp3-p}V>Y!oKT4w1(?(xSD zRk&*M{IYGBYN!Mm-W+rx=d*(8UDgNJr3vSuHa76;jU zE+1b2oddQ~_YdIkypRBK3w6?$fG5Q8g!%-sfUybj+Gl0f2k$lsL$oWp-yrMKc3Wni zW{^3$NkRB6h$l%C2d>uasMW~Gjk?%SMQ0U*Yf=S0@LjvS!ABj7NpqsnYNPHRWO**o zcO$gq-4C}nSkTw9skV&F^SqDCR{F|*!-NX@ z4l^_c1HUX(CE`-49~;X;LxOCzmTC{zKx>b$7`6C^)V0h%xoa*bk={~v%lgs|s~!Zg zjJ4<d0d6Z2Us+XI`D*V$yQ_Sd1og|tT6|xkIY&!Iz!TzndXvM zs6m>O{VaszxJzFxNy~0-o-pZ`}X(m`ql6t1CsO zXi3eR{uTsoYXKW;j$)a1N9IjP9g4peiz`}JvLWjksOIXpz6Vzi74z1@&e8?y%a*9@ z*)ad4r5#M|P4{gyWNvD5k1MdWmBq0gKB1*Go)Z69(7V>vEf*2^8gc*YddiW@G9jm# z;c;NfT-A)$!_WMg1)b)-V;_bt^`bg8lfHV^Gzj~HL_4z_eVriosug0Xr>{)BVY*=i zuyY+y5QTb!Si4MzP+C*^Scx0E5#`KqUa8gk=7oEKJq!|6QXNLqhi?vcH4{Z5#2)zY zScXvbhW{;a%9fmPz5Z#Fzf7nSZEU*GL{`*g01*v%`<1Q;48*5Pz>_BzH6BL zn2)cCbYgjR*T)sUk1C7n*(XyqXxJD3aBV6FU2X|iIq8~eqlecbQVzPms8a9|w9G== z-s^2Y$GE_ilNV;81W@?4May)W(ILiEk%uKkZb>?lE4!ses4qf^kM zqzP$7f!z$MH~T^hL7;rFx=(B9O%@GH^I>7bdk3s~Z)kKA8R75iRBb?TnpUdew*r)g z9>uoA=N{KyIw)Nk!U?}ihN_tl4pkvE*C47Ph1q;upnD6}wk2hdBVB<r_2eqX3Wxe4oh4PLpbv!w;k7u#f(m%K74x_qh3lHcl1HZ6iF9&zo9hJjp(U~FEr?y{4uA@g4&#B`c$JDOq9~!(q3Bmz)gK9!~W&QT{ zflpWBz)*ApvGel zXVc#LkP|tJkWY9W6`pYB3v8ZNrwQdU+FlMX+{&y7?P0eKRINj9RAR=`z3OeVt>> zD`rCds01U(%_b&T-*xPZpsq=)xVR=@H~wI-K))vr#yTH{>)E_#;v(nr$S0Mk_STU0 z1?v*ugl+Ix!6Oz#Jfl?j-qcv1oj{Q6Zemp9mm#d~^hYnspv`Dj&k4RdQ~z;L+B8ek zk3|et-0uNhg01t%N&k2sEUd!{)6bZ3bh9khwDaw^NPU0=TrdBDiR>@M*p11QDdO^M zlV021z^*8DMR6CAl?_OEJX!W?N6Y-TEvIWMtVXcyPV7pu2QogiRfTCC=f^^if|`7@ zTRHkV(qHb0Jbo?)oK@ctl`Xsww~7_ld60gwF6hP3v8gX!=YMtg1k&1|4X8#;{L+MHr@-(20yS~^~P5&>anIaQ*F00_4gj&K7$NNT`WJkB#!XhHgze9K!Z0KJo^oWL1lf>tBk>##>kOKj6;6~-aLRJDOCD?mrX9j$w{$AO zUHM|9_@VX77h931(vuw(n_2&1ffuJ};Okpo*%LmbF&8EfSdq^@1U6<3YIR~ftz{@v zmsq;}*cw%#30@7czj;D0<{6~ik{)>yo zM^6dM|EwiKiTXF1^Uv`AYIoY<6-!@)mOrG*eSn5Hf18C8B?az^g^1RdUy^hIWj_oQ zJ}g0ODjA4%J6#4v)svlz+uSK2q94r(P!Cj(F|wA?^VWPlG;bgX8#M^lgjh zC+p`;!eQqFjl*eW&sK=g4%4WV==QT>0WQEMh}4*nS`G1dHlb_cSX%8eB8963OcsyZFn6qv&Cf~y{0%}RvrR>KRumk{G{{SME#w6 z9&!!J6>e@)W8G1f4*Ofu(UH_!S4TSKlA&F5o<6JMNiTS6ysJ&pKsSLOzoVA=b3KGr%xn(kVVR4&xoPV1~HRU z8dV$NBT;f7t5e5=>nqS*TY)CKMVRQDYNoUynw)lJ(N<*bl>$N4^;e^Gj<)(j7Q$xu zO+H}HAnQVvusqtsA?#2Ky;K}kX(^tZ#*-ky;k^ zhF8XkD`lKC^PhCsNn0`}WRZ1Yy13x}k1*e`Kdc^AKhw@NQQv7SH~EvF zNJ3w6!aWgAXv&!~I?K7GlmIhf zVw-v8;(;8BYOCv~mP7{^ox#IO3!SH$`J;9!Gza3|HpvBahH>ojRRcskvp67(g$ zvUq41wXr2~0uJ3=Cw3X<4@Fo&({GbBOY(3uv-OVp*NbOjHYN~|8j+727jhPZE0k|g znQ$dZk+6AHRFx%)dl>+I_~JYL@n$kdUHXmsr$1v522C~;amqWVp*p!QSH2_B{B229 zTl%%ZuL1@w2VQ>q7%Nhs5x12Zr9TlQm&IZ0{&EGvwKppgdk<^r+N&4cJ^S;^XT_N@ z`VU;w_14_!6wlq@JIV|0u_FyHX(a}{H2`#d6q~^K0yhf70B7^HQ5p94!e77I($+A9 zE+UiDm_NyHV6N*vG9DaE*cT_ED)6p)o=EE296tH?`uFz~=Z<4`C%9F*nzfqHrmh^@ zv^Xj(%OLeJ6b{rm*;bn@59+5E`p8jusdlk{<>kxu@x1)Dz~ckYWU}wd?Q?o|=k3BV z2r88YNI)lwR0lB;Q1G*u)q0w6OUDgskwC(Z=D-*5MHaEE%T?g7$ z?dmMU$XQ3E+L6IDv&!Vf~8^fLdz%&6N(ws(&p{dk~=de9~)fSrQBP zyTx3QsuVt6-}XLWm4@zT9`ab(oZZ!NQ-jrMA+anq(!_{7!M}#ji^XB0)4V&%Y*sUs zKc7CbG;tBSr^;wpnV3l|R(1H1aPXerc3;`AIPt^~uWMCY&i5iGxyDK{HC|tLO*Wpi zI3c1abi2$Oy*B_zupN22EN_3M&&=NPkL!2KjwSA#EC^2E)dx7yp@Y@ZzFZnMs|~$i z?-kUcdb+i!=Eb|0MgXRT!wOaZwy+#QuaNM$d%(TpTeeHvUuonO{@L5NJ$Gglb}4eq7N%5L>G8jxoxu%3N`@ zR5onZ3GIvItT^`-B;msKZ^&jjULy5k3TRWCRrY;1?`D`B@iOF;>KQD=$m~mVu}ze_ zUeIj?ON7gTPzA#UI6U_`-Cu1fp#4RYK~mA%iuq$6=IUWTPnNq2D;UkH!a4@{DuJRh zPpfxqw!0o4luapBTC_tQ**2!~4?&2|jEc0qvSXfI;SaVmiys^j=MqF9P#PWaD6@U! zoaX!KgwK|tJ+F8YKf~&GY9#bLOzoQD?oh@EcrE%iymI+f4;ML^+V02r-MupzKUbt? zt!dZGMMq?=Dx1Y9m6S2GmTPZp)}A$Vn_!kz!mI0=cwE5?P=@*rl8-hbRCP*&(PC&{ zD3{*c9j^K1zEE=FQ>4#3nZgel;@;F!>#r8kZQhmwW0PIx9ZBT{viXV_U++h9k9(?K zDH)v7@19dVVI90W?i-)XvBfy=9(Sf0Ug@Ra)iUEn*8xc6@^5||r=COCaw@(Qq&Ny) z=e3g(iGtwE5CMubVbO7HMUIAzsnTTA7O4 z)gP`Sv>=%J5Iq$RmkA!`2j9Fm6pc~p3W;4TGVPf5U5Jq=7oBg=U7RZ}gD_R3yDTs& zbkK#mU}vZ<|6Cm^*~lOXrWRFxFm@^A2BEaZV}VXVOkygw56nJ2~F&lN~!&MJ$VKUFAvmBJ&<~qr40S7mpbQ-K9(&irS_X{N=WMZN} zT+=@oSxSgID!o?2SDHUAR`9{7nY!;2y>+^1_-PRD>1*99-CBUtYiI83j>1NIf#E|l zs#9)X^+V21U6^{$t`e#3ocbF`c#po<0}2_pw8soImgkeSrdDdZOd00;@a{dnHmBtF zb-A(GPIi}LZ3`1m2uu7Nby$)@CJObxlgh_(qwadJO!K@;gI+mz0A1S8@LUKvQz4wa zAmzKHM8W*X0IPHW^e0*6h@qvbh9ooy=T?1TlP1hkzWd>`$&!_S&7BcWsPn`b( zd`oH}%-QAWi-TK;6XnEh2NLa=wLr<&j7}c8ay_-#UmP?nh#oC~>a(SuW;9Yvs}hPR#%kiXkN0I%wQr}=|#7`fepmeP@D%K3x zp9)`HiJlgKHgQne*ir=EO#Jc$WqK4)!a4}!C;&hm2A`B>P`9=U1)Nmh7nZo8UGrSW zE4^B2voAJjH9)mC_&jQ&~zeVo!v@6d;>bgT$gP};*@>+Ynk zNiFh=ai}zfb2%B&m6^SfbVOD4?ls+LTKT}UW(OuZz2yi=zxV_^$Evb58_?zx5;O~0 zHcF!Po=dFPuB9vkEeX~#Os}5#TU>L5(?ETRqkf13=?HAx{a2fIH_Qm6S*(VSM(RQQ zsb&+|Yd_E6x2EcYljNxn+yhy?TaoMGW?kcX(N~u#$DSI_LTc2`?6;3)5AVgtM>`Of zbMR{#iqxhv0PQHc_-}osX~sJ&q?E`AM2!fnK77{s$!Kzu9Fz!HmLcbH7??@~%#H7)M5rci!!{)Z)%V8hLupM_^tc$KnXHXLi;z02V~ zuVWX;w}qOhxc`;EOk?5c?${ySa0y8Q>+)2&Ig?Bgs^bHx{!q!{ROB;wQ!) z-SBqeOaZ0}D>x!vQ}O8YyA-<3&>TmpMTHP^Qq@w~x6^@<=4XO^;I!U2Ya-PbM$a#Q zMlN!cjr#x6EsjZFSb7E_ioCo0+NMK-H$d+ZN~ZvSd$fEY_2!3CbBcH8&o_k6k$;uM+bz+fh?axo^xs)nPYJUL36b!CvPC@F9TCN3DBBfPclP?!l@_E7VfY z^!ov6(1eNQ;pt8_W`s!9o)td9=$U#TIV35KCPJBp)?uqHyAEM)J7>K=#Q6QFziMSZ zjTt}>Q9UlAnH6*#r_yj?(MUMx7)t{@sRosD6DIYIm9%RfAv}}!Qi^V8O++fL+OF&D z*bi)92rP^44-^soira`P3a707p>yleRVuQar`Xb;^l0Y36>^~p)n}Yn7N6F%rDHI8 zI&!)bS*+#`$1 z^kL^r(+xKe-2sOhHI6lonYss-evviYx9s2tJB@Q=n;t9 ze34aV7WG(DFT=R`;forkZ6566;na&YwX@)pS0Vdu6$y?tizc}|03+Qlbg9rn=*%v- zE&Vw1)7V{2o!lpHP_cer&M(5IfQh~yH(_KuPor5E>Ln#s=ycNZT>`s{U&d0phEFC} zSpNG~4V3yXmG$19D_$2T9~juQB6=WJ&Vq}#Ns_g8*pD^9VjJIIk~m*skZWdZ;{$fV zx&v!Ly2mrket&XC0l9#;voh`T@95~j5#RgUw)7v04|mP~sxE;vNfk-IS-T>m?^zmd z<&nIGL0W&!@^t(+H&}7~TEzdKQjq+w=xbcv9qaYNDOlD<^HhIybxC?FbDo}_eiE|P z_(;XQJcYL|{v)_+3f?1Nkqx9xfGafnA2T*0`KB7Yc=5$K#l@#8RR5e~6p*F(%w`Rq z%1gWQFTOk_)-Bd=E7UHCh>hiX@Sx2t0oUX>UAcreu~=yG6VdRAb1VL{I1=_io3K1N z2;^&NWwn2B@YQvx9q*D$jWc0|6K4=+VNQFkKqGT`u`NVgLc%V?nQcWsQ#L3~+V4Qz z`Hvps)rK$fubBJQAFK^T!mZn#>!iFdP5`2T%xiAWymmw)bBCohc|AIVW_$YKvt3RQh=UNq{&Fuv+gqkx-QZy^u3lLiVhJH1VvH8vZfjCsHpM^0j$fnr z-XxxQxgBWI1##TeBrY*+a;Tz_Kz$y z(!4|Cljfw2$ZhS_`-c6AW^7I_UVwO^6=a#AtnvhoT`+fsG^#`7;GG>O-}l4cOuXC? z0y4E88U&bw6D@&KYqP>IT?HW z#Pw#LNp_3p!*%482ixoo5YCGURhr4-(6=Ig__io7%ae2Sp7USfB=diUAoM|p}=F7>NrNjkZSgOAxYKA8jNN0ArnD_l){z*0v?V};roz05Dv8o{is&c}| zvR8Tkc{F$C=HS~pFPzRbFqHE*Z|aPNHo~p@j347#CA42JKG(>U=qSuFN)ZFuNygjZ`21P-IwEXeXNxvLp{zNg3hw)JAZqaO zBaTdG0cs!2nS+`*sz^h37US z4(3P^<>*!)&tk2&y_!*0VEdbqW@LGndqH-pj~P;?Fk=DLg{DzYF{IkX_IjMmexGZ4 z(fwQTkx$2^1b?N(37;Q&g!RWLr>nrjujA^8L{!7?(i^6wX@dIu<&`H{d<#IEoM#hn z;5ZPSN{Zy`8)W1;DvfU+O*emxGuhMzK1y8_jP`Swc~EM?;<~E2cC`C=K+EENQnAV= zy1;|~M(cfVcP^fjjDZ<}AuXY5g4Y2hEtled>WJ@r;s_u3co(3R`TPW1tOABvZ@0qnR$t zq9vD2qOk*dPes-~;MNFx3)mOl1f}-Ywx<1jw0yNzJ`g+V@CYy!+u6uEC0oV7y<%$K zdQd%5eVX+7xRFx7&dOm09nBGW->Ns=Imps$IUm=r@{B%WvX_KRVm+@ zQ}slJYo^5uof31p%=PA*ndME9@jhqx0X+Rw%9is+(wmGVX2)VR7gYW5bO=c9)cYz{ zw`RamzmbVoxtQf=QtZj3b^k^)<5XRm+cCu$Zd%iTfnu(PrQCU?0J760X3KD=(e|)7 zh;@F3j1ALC=NL0J^u2{xU&36tkCL#o+{;(bfA{-fsz|^z%W&dW9Pk$N@Hs3c=q(@FBKt?h7h z4Vw^UDi;AZR3B(+qW8(tJc0w_3+<7NBz2R1@G>~?dq0=^-YCEEcDjGc z?8A=BIA<%J@j+d_hde_cwPWYa)Fu_JMnghrfcZ(OMzi~%f?}sB>55Ay(lnTru-ib; zj@oF@xNTZ2aoKO4Nvtu{ccS0;=?QtWZ%Y#&P=opEd#ZL+_5hfW6x!tBx~e6uyGd;` zpq`6i;CngPcjJ+1;`#}7iPMsrQT8pLO_J8kv?0$tH4Pz7v^o7a;GH()#ItBEgmUnz z$eYJ--U4yA1@Z|%|EWk!w`l;x-IQ95*}`)}CrOBu=1yE%i_}nl(=6KUX|s&0PjjD4 z>Yooiq}(>1$eAiH3Z83-zUNykmd~d&8ytVr$IJv-Vy^Vo$QBQoo~7Xv z3cjH|^L3!kR%LeW5(0m_EBF-W?O(SNofBliAT_wLfQhLcEz?y2kj3_WsL zn1$9}XqB5a`nau=z5^QBa+TOx{OMl^bV|z1V7EFDK>}Bc?Bpg)-9`w%|3s=~CvsL1 z3whs?`s5x{RZ-FJpZD|(!>v3{F=?L5>3%#zq9EB`{H z2|neWWuq)Nq>PxmWN4NGwNB7q_?8VG-yHo(q>Wjw%VDRxb^{AG@8LrgPBs9i39IV( zB-nf^uo+~xuuekL{r?HWN$H?+^O{Z5g}a*@jyxtW*uuwGH|2Y^%W8lB6E^(-QR1x+ z8Pfp~sZvQJvQ*SJy)Y4jaKtEhRKPJ9KFNY$9H48qqX;<34ch)EG*&_`9o+~T$eS?Z zcNnpx24<-5&N!o~vZOGta2#)7uRDZ=!XutO}G=k zd>+lS;Z*_dJT07)MHY%(GeX0oc4;GSxRxHVvWUsaBi!F23omaNF`JK+$k@|e7`v8*naT*9bQB*l*-lHZu%^hW;rU897PM{5RanG3VZb>yFK9cx0{vC)a zh(V%c+@{L!fH&@U^UWXAv0MrH`MzokcVV;;*dS)adz5dxl9Bp9V;Vynj}d$*73q<# z=;@B`Dp?+4{_-xS*WGVZrGOoOdtCax5_IPUue4WkN~?Mww1r~}aAUg|;vX{EUxsRkDTiu$h8Gp`o&<<{9nY4dr*EfR?yATb zG?k%_)rI&;WCnYZVub|dXCp$urr0{}0J|&(wA4^^UwNBcyFzJ~jNb+J`7QdEv}^Jk zdk1wZO50))wX0@pfgeK|F}$Nrr?FIJ!!c-6qfSMpP5wS!sjSfS1M76b49~edq7MHI z3=0}rctIo3*bpZJ1}rciJ!+%oPYMe2VcBf^?B9v0ZY5PqL)(NYrtP`NM6rCuj^dy^ zx5NX8QvzcJ%n$UadfbWJOqR+*`cW38fH98xi|)6xPpn3>3}{d~ofMz8z()6knSCVwvEP&(qU(jf4LkBhk~`<4JJtnz|PNlBE#@F8vMs`A2nVq+ESZ<)0!Lp)_$6 z@rr>7Xqw`1T0ml#6csTlh*7=7ovcC!T|0gg^kJrRX@%ih! zeKG73Cv(}bCYBIVDci#_7o!igQYZh=CiHBJONjn+;5|7!Nvd4lRbHqE8qd1p-Vk%4 zGfq*YUpz6RR6Tw$@ETVIxJ??U>!|n&cjWK`j}T;Fd?%9L+_+ zz)8YMpyqpekNwBq10%zKK2_#Iuor#*g1%hd5w-LIS`UWlyHoROM&E?O*X>zX4-78n znxi{~G_?w!dGuoDvDs(Om)$O-R63=sa;75}d0q+CS`S`qb%WyMVmmGQ0XYj_{;UGTkszKQFR>#ApLYZ!>L=0k)fC!Lq#p6sru?wtGeh@$mGOXgLVCSLd0 z2}aDyCLjjGM%Z1b7-}z+)#7hZz^){g>Lv(6=gYetZBH_y&$FAQD5@$;u_TwWLPWqv zl|sRdpWoFdH-1=6OMVN@i+~u$%G9RXY!0~z_i+WxsLl#|nBDZ?_3;?Ge~-9Mp~KoN z@mZ+F+W94I;?m-6QIuP1$EKHwM<-?ujgmrmoEkqkTx;1~kBr2icENGoT1m`OF}*LP zU|9j60LA1O@dqU2rONbXy(X{#VH32USsJHInr%K%$qL0h{bsiL;BA}g!B6?vwRP}7 z-|}~7y7F=7XD3Ey6a$s);ODdxJ&%LPs^tiYR8oAMzNF> z4L^2@pIgZkj13N=-wYGES9ak<;0B>gayj;nv#r!P7C!5(8co~9?ifQ5>94L7U42Pq zt&9{d7GKHP-Jr)YQw7@88lZ|WqKgUjm9Kt&9?fL<10x6s0H;!S@t5NLw93UA47c}sQdZvdhG3@u ziAr7nP|;yzU*s#Ist(gxYxCIB_0eW<*iMa6?YFeF0yAF#VDJq6Q90#7BQhxcNMjN> zuiZG{J?(fAMkP8Y(MYkLvSZSak6x3qE|OIhvORII)vU&i|ybS)AZCv6;LO za?Q4<^*AN8x#-K%sDf-Ti#NjkvH$TeNK1(5(DP>9j~#>r1QYgG%<(<_M$6k( z7z&gbtMZ6^s*X6?OlmsWkP59X(o%{wl`%ZO{Y6{S>$^%FGehp#wxPPc{Y^VZ$H2x) z;{wOZzJ=*Zx2IjEZeGq%+=nJ{pZ&(0Um_#dR>#AD>yxm}jqomf3l0wm`Trux_t9oW zG!^y0KvQR4cbZ6ldOGoXGV{D%l%n1(gGd*e1(OIvrB~y4KB+o&pcwc05O= zh9FWdRWIXU#^6`Z=h!IW#H^__%Y75#zbY?E!kzp+%(~cZV)o79t%qs@Ka{JgMn!7X z%hn(T`MHHS>gnp)gBP{+u7e-nx^?ucWv4q1TZOC<)W1-A&`E`;vaaDYaj5IZqGcY1 z?~Zd-F@t%^ggKQTgg*7?nN52-$vpkp=B3S`@x$MTX6qCGH5gBVP-i3C*h~i@@Eib& zY5U-3cb3r~;H6=s<|i3D=kLJ`nHAebIN+N)_&S$w=Xc)r0dg|4>1WjzrPhEegBUX= zT7;w%Ew=m5kCymbm_npQNEZTy%3#pyXC4wF)@z59hs#HP2B$Xi$xX6iW3~Xb3e`GK zZuY{B_#Kl3KW)>V{%0<0(UUY~jP5=&4`&_c0YW&C3v;HlP#GXK+fOLp`at+Cp74)V zMe>O81Tt6G$y}HPi?i(RTX$)ured@F_{+rDGba%DgT&~QiO>tcbWgMWGR&)>Fx&1H zyj5~^tn~>4+&@Tkz?ozh^2No3W6mz230wy7m}CPFCTZ&Cp<`I!7E&eU3J|K%>b-0_ zbdQ;k%Sb$x!-VD#p^^2EOR0R9SMi?kPxIb=upPQ=20oS)jgXU`Z}PQxum9TDcD$9y ze;p}ZUhWM*%F7>wdih(gm{9cF5UFV3yR091ue5QeVfow396|4`sVI689#R_MBN}QPqgU|P z;gNfzR^72EB$Mjq?`TQAzjIvw?~36g9rB)jd13gwFabd@xh6rs;pSG=5D?JpkieHiVz-XwpsS&!YHW>*wSQS@f1aH^NGtG3MWL zsIEaU);7bM|4M4c&IXWchl$KI!b&_+1-HKBpzFNw5&uz9%tE$czoij+c;SM(gWduA zyNc9~+Om<6zbYuXu%)@F%m937w~2Of0RPp_-&Ho_aKGEfHzFC%-N7vB4>rOVnvz%C z3u zjk$Q`ekKF&H-Mk+a@7U$K>c;&#S*f!ApOjlY6hRw&b;u)^TcOg3V8_o=WGnFj^zlMC-gVQY zql?D}d3WIVCIPTYGytE%kWw7DGf>A7iG!$>s)u>0hwUb+fq3ln)6Tcj;UI)Y?RTKT zEdt3>yd)Q^1Dp2R@d794G*f+xmj82g(+vLJj+aKydH9{J7pV3Xm6Qbh`gNf!=sZ_1 zI`E&Q$Mc7T^lGBEiYlIH)i(1_;HjkXX1nn}o8lSkj1DrT;#JA*WG~zK;z>1?cT(eT z>5Fk+%1EqLTTilZ#>ObG$$Vn;R;on)SUY6MR*qp#*z$kozI_RY0ac$fLT+V!VnnA~ zx0@w|%BjkV@zaSuvhx+34p6A{tGid5#J=wBda#{-O;M5&fX6Imh)bwWA2Y`dR!`RD8t=?4Or=VX|Nh;2;}!fhQNjv1 z?v|Xu^jR)4ek|UZBjw8Eq!<`_lQx+ADSo1ILLTiVB`+kQZ1~{Gq13o17@*hDCA;67 z9foPEGylBR@rl1CEOtMOrKYHi%k1zJr+N7Rbipl$;%)q7R6H`Wwih-d^cSIuRmNM9 zt#A8GZ>f+^0iiY<2dD*6$Be)-;IyT|*RxYHxSU+#`SQhp+_RlVy#pvwLK#2`yetAw z2w)f6N%2&voPG&!GgIx7jSoBx>C>?PGFA(BDFc)M$2c%Q*5Suo>}F6o$h;V4_1oHg z@80&cS)(!pYoZBEwbJutr8v9+kZe%R5BKNIo;`hD=~-nxRct*shcu&L#~-0Hh(lQS zb0v`C;-&YL+%L1>F3&m5=9)Uc5)AL-ts#C(2hY45E-?d}fZT@+)P2ahaIEa?S?zWZ z)f9Dclh`h%Q;vP2+y{11JNVM zm2`GZma_sB29fmS45fi;z;DlV&Np_dwKUdBFOt%yhskGOQpT-^E{3{}SS2J9ojgU)ETMgm7{-Nty&R z#!u&8R+U3nmz?8|c4h-rS33drhi0m2XTrD!GpEs&a%5;E^P%l*Mds!*57&1es}MIVfENHhTONqD&a5Gr<(h&WCdnnd0xT=-M~3 zK9QR2=s;FGe*uZpa%2WJeo#*ByF*$w#8`ViF!Bw-uSLVlIP%}be4JHwJe&bfQIvA}-nyIFP zx8~|>1=si29@Q773QH#ip4B5uB{&cd;Wi-&C>QlC-xAF6SK9;zX4Noq^@(Y2iEj?> zZG7>>>hLX+1eUoZ#*>OQ6t2b7N${*6xtgClrU8#*z|SBc7W@6;MazTkhTK^?c*}fz zbU24cwklrFs22EO+NO`1fhagenc&v%LA-$5*J`$SSsu*G#G`l9qRx~B4^K!r=(@Yo?iwqcU{vj+NUK z@qA=1JDJBi?z1xRt^73)7^t%s#VluyLHl2PK^pOKAtW0x-w`|E>^KoI30DMia6W;u z&J+YB@O+W?`&bry_{_$8r0i@me7+fA=1gv_c4Bs=h)~( z@i#=ahFVuCf-!>=-g*&VbmA~wG1n@efF)PwR5x6Z^LxH-(C4YH(NvDv%q*4?$Bc)- z%Ty@Xr!g7U6B2!9Wpx9+D2=r0^U2L!NEED~v&}0(yG&KX>E)GRa_3jLF4EuK39cF4 zgv+D3$^ZdbyK`>vSZ9rs)8M|31m&CfgqE)H63fJQp_g0a!B#Bwq z35qTLDF%tZl7Au~jN{AHg9MMm7paH;DmI+f(2%SDsN_m3z3M-hh-c%2e-;@|J3GsL z@$kP-Bp|r=^uLt&e;E0HzBga5Tv^P6ynxC3v-nt1r!X4=B0W~znych5-ai7!R8Q!q zrfV=}m<(Y8{NzIN^$re$8c`iENRs7xMP1G6A)n2Ji)_XY_ENe`IEncmi{IcHO(~Mr z&VKOG!8yoMew}bZd((Du$oSJ4TdkVc`h&mdO7OYk4T7LvoDbZv*atl;ti9SA`r7Oq z^?lSO<)y@%H#>#jH72xc(tIWA!OWD#zi$F=j`y z8Ywo^Uy{v`rY<(mligxrLC-8HBYzDfS;yyB1Qi&%94cK0{iyL{Q?6uB0ORtuV6PEJ zzeMgE?Sh|QO6Tx|vUfsYeLb?X1v~qzVwAwaCgJdt8BxSJ_PaB`vC?tW>_JjUnC;4C z^Qglx*1cb|+jhw)9O28Um+5UDMz)n?+Du|IMOd)fTVUoQTYH)xwJs!$poIlqgoSGi zeTtS#Z%nbC2E;gcoVqagKg;n^!ySwD+?i}jhEfE{b#GM~oT1k<7ix^GCn;ADOl@`20tTLr>+!N>Otm07%hkkdRZh2d1?{H2wE_Vv;C)D-Yf;O3gK6Cn zlOPX`%w%eAssu%#T=!0c^4ZCHO0>_Z?_i_D^R(TJrgPDL4`*>;O^9aS1z@M3d9iCn z$h-g!qnVe9rO zlFCjr^PsAb4GPmI_vwZi)*YBRnut7}Lr2G9mJ?WdU9!_4P$90h%8)EL9UCmIktVTp zEUgoYDjqxRAps|G*=P*x%8E**1Y&0WwtoZ&y*|n4PYW~y`JJ^)=SmiC2nh#7PYD?v zgEs5(bA5DB(pqvn*;p+8(U(gHIc}ed3cT!-*f5H8 z=lMjo<(XHSDfAH+^L}6P;cCF)L2;qq(d>mgrUIA5n_qIeH%CS;4rFnlO)p}86EmBj z=Yf3%)`(^6wl32cLMkf8!Uv3uKf%;9Gr4`T(>iGwA$&ML(4lS6GNa&mwUtv)cf zuDLc!ArmJzhx3ky-7GpgM;LcvgGk80ePv|^e75trp?=7rBk-biMPmD#$3c%&hOwDh z_FI&i>H1$2D*=JzcQiXe>4I+&UVp!1?2eJ~vv}g=Pc|(Z5^&BXM&jHHb8Zx%^cLqZ z!}UC0&-m_%ILQqJ>1VZuIqYNJQ)m#*VE({}w_m8(1~&PGWf7AX-f z^4;X`I(g`Ujo)CG+(1r}uXJ@x*%41Nn)*_nY7(xyvA%GkA^E+4Q_k75?>(P$&OX2Uxu2VVSgiGy=UwZ0)_Tf!JxgV2G_?12ghg zH0nB&W39Q8?Xot1l>JtFt^c+~uWpWHT8fBUwlkoN%4Q+q{Phf`6l3fWK4kOIcT3>- z4$J;m&Q+&wA^J-}{BZ(gQP&u#CRp-e;Z(=GQD*;xs;J{qQ47Qw=4hY%#MqcO*)gYz zx#ge{_}l=jP)$yax?kPad<#D)0AVc5M0VTPyxZg6k9Xb=poP&VER~`}5!c!`WBsj( z1KBC$AH@sTD0veF;n&cOgdW|N{Ku;G+N8$DLCmmd6;{)dnx+~HVM6dXY&S;N`lrQ$ zpXrW`nfnHq44+XSai?Sm3D$k1Rwn5EVoF;L+V!?|uEn9mA@9cv4VN<--3~_*hurlu zO%2tw(T0&Z0S*1ZY^tmENYnKZHQ6@PBJfnXBPx77n^4q3w-Av@xZmUcg)dkx3JpY3+h1DWC-Qj3Zv)ch?x_Xg?TV6!Tz$UFUr z7j_=sBl)Fxnh6q`e7=#nSUX71aNX;3jb*P6rpDRp?yNiKWS#OtD}m!rpd8N->R1x7 zvsTFB;-5vibDNuldv94>)r`k4{CUZ>Rtj)D=%_G97 zW&IN`k7lbPr?gnE5uQiecpI(N{1vtX8iA+}em2ynleYmrhZW1Bn{?2phV9k7r#4C| zDCy@*t!&5Y-hu?2bz$LdO1hl4eUJ)O19j3McDetO z_iU-qaEraIk6B(j7p~ow9RUFyyEW#t$+cgN(M!vp;qISbR8I#TYifGG(5jCHZ?s37 z25zzIZP>GRLthFhL!f$Zq@~rXq7142juaBuH+gXLz7NN|rO2Z<`A}J8fhBF8-7Tj< z2x`i-fz(~pa??;|S>6y{`2jqW9^c(wciN`B?~-7YOovp8wl3pG%~qiStRZ?4{g7j7LJ2t^6E2b6-YM%ilc~`b z8rZI}F~I$>U{~otX*IfpxZdVsjrft8(|p!g)s$$6M4RVk6$LzwPSQVCi+~5ktgg3e z6shjx=_sVQD|!ueCt?jPd1nYs#KqR}%mUx5ZV?|}wKlzEip!MX)sROFPP5#(V=laP zH!Be*_Zl0#?BW6V$s z-KePZ@$b2ySA8swa2#a5avvhshdH0O{>GK%sd(EwZ5fBIspqUHk zZzhgP>dQn&u9;IVwUDN(kg4&5GX{CzMXS*V)YWb-_XG^XMkL^?%ZK(R46+GF(?L=w zz<1INTQm5OBo;Z!gAjJ3B0q_S;8YAV<@xYwCZ){-k04v?d>Rq-YCBA*8d{RpS$XO! zhW=Fji2Z(A+ma_zkuf{iejGH-=H=G8@|nx(8r?H$X9=5gU*j76lgWW}91Zy-2#o_> zXe1a&3vb(!)`bb;kgPVW~M&lMV9$~PC$ zmuF!Viu~p*iH(T$szWOJ8W$qNOdH+q))F!*F}09(Yb)2Z1QC$3o?(=!90eX_I0bh6 z*{#;RJ(S|joWD?N1;HlQM1az&uL>E|0rThWKRIZpQ44(YbrKES2NSaqNK^ z-auC2f{Zct0C)k5&96UCi7s}_S}Cr)(wBkfjp>R-%_oJho`9S_tjjyMpHv?FnQf}h z%$%#4rgy(-xSl5wiOZFksu}hDDTe!T{%~0vY(@i4+%vurV>n`Acpk8&`ep3~r*MD5ab9`r$pkF({p8d8y)VIwf@j zQ_n~8**p->(P?`L2JFu+=Zv1+JI@|^^X-lU2yg-YB5-7<^6B|6UZJO&VPVFv9Gt$s zt(pE8jjOB1fcO6|8~^fg|HaCGDK(?1hpz>OT%d>*7Q(!pf}P~Xgik6}H30Di87u~& ztnS}<8u+(?3WOl!d(~Yby+ZY|spWH!F0VC?8SdYlJv6A&K@q-e(m(jc_0rW-WgTK*QJtx+LMK-g?6 zA`qAdRBW-NcjnfEUI5PS+R1T)Iv5Z+>zdMyEzS)w@%>idHtX>gd-p676oC*Q7UPzz zv@dTftgs~;7XS3$>$30{`U^_FEKjF=^C5!{qt_{8WBTCnICLVYYAWC9BuPWfccK{zzA1n-cY4D=%D}mSWf$f;*)=2!t zl=dI5{FhEQUnbc9W2kW~lx1;YT4LNn{mCnlUSX#P6S`Lig;x*O)G`0I!_-&8cfbE` zTw@dO623IAk$!ZiR^U{!K+|>21$Q8t7p7gWB%oaITR>P~{L1e4iIpGjxge%?iD|ur zM6$>!La|brP%2zAMTO%f3 z`~4La3L?7#*?m8D)CTk27BxRb>ky0QYCl(WbWW)8MCHN1zUM8Dm04;{d3f;%=6tgY z$%nwCk9S+ZG^zUa{CG%T=9Y`?VPSues%uUMf^kjxhc?fRD)f(uO+GQ%MYHM6PxQRz zVxoszu(t#1Ss5X7k3Kpp?)1vJ&c6%5Zkkf{Rf_TL=E}KerJ+QN{$E}ZFsjpmk1Yxv{aaUmr|2*}_f$zoDyb7fxR?vTDO* zK$j%ldoPJZDZwGC8d8Rvar*V2UAg{shl{l^S!odlS_4m&`~8=Mjy%qXm>aY1h72Dt z4Z#$$ND245LsnOzAw4UC#S^7;f=!`iAle4KT5~0jOfJ&UlJ(8bSX38RyY}Kve-b!v z|4g-|4>ohGFR(CPsWG=e_wN0GCkxJ%eV622($G;5l0E|@2<}%}>jf)w{fX@zMppLr z871Jr_F>U2l3!1gmr~xILVWX`fd8RgH)HM7@~5Rzpj9y%Duyvv!+F!9A@dP)H5$gp z75BCp+m@DQ+ci1&4x?%rf|KcA1{<6T#8_hN@fSA<> znWltNwKb+8Q<_4i=>?gkO!BmPm3gGTijw2u=i>p%N?N(uu~N5GO_RNPO;N<;X*#(^ zzusrvDC9C6dU&RJ**NDYx+fJe){&C+j>n!i?9Of`{RovwGcn;DFZ^x zW`Rp6k-C^l-hXs+H8&FpuG+O(vp|AicZ@?~zxk)%umFE$C`RC}FD>WSR`QSmY|;z0 zCKxh6_has9eR{z%eSAfxWa_L@v|p?~>sFG4L1cHRU+||{#RAeBP)`DBe~)G?B&*$k z1Map)_B?Rs8@dxppvi-{r2#2Y(?omPY~z$FJaPTTU_Sg@;!JZ<=(EFdS*#wBkooY< zky^{Zy@4>le4X{@qljCZ!w0uR!vaq~mY*_yvFGKV?6|Sl7Z`Ur!Wa>!(MTEtGqXOg z?QD~r+s@5!Z3O9vf}%o{gMyo=zaCbx8W7)ixG0jPs^e3(U`dSzFU=426X%E03oWGS zbE5K%Cqprx=MdQ~iG1JqsgN=8quKkY`Wm8jr(-z&{V`W-=^G`3QjCa?^9)N3v9abz!#<2 z4k?g1RvUVLkUz9bhhMTvZ?{(NwUvegGg8TKcGR}p2Y3bEfA=*zOH4SM96iDq8`j-^ zFM#2Bc}i)gC@`e@kCu~@wSC5+_(EF&2hYE1bVnZg?w>RY{BCpo$5n#=-If32)cj=b zyK7uddo=$lVD%JzU+ir~xjjtxTLPda^cgNmtv0+L_}71F3u=|DKeC!Wa|M2R#|)y( zBGt-YHEct{jJEBchBX*-TTk^z>jc0N@a-lztC6*h5`+DY)%sm|Rp>a~ux(H6D6hY1 z!CwhHjsE7gV_9BEpTd?NJzQWcrWN4uMUO_Y@bFr!A?J%Jx1~pSJreuZ>d{+FE-sDMi|gIA%E}uUpM~A3jE`a|6=9;qvqcm*nf2Ge^=jJ zwRXB)-+NEg&0$MQ%yZIr?b*AYq9gnU!z5Ny{;=qNGx2^iH-86r z?wFf@i4v{XX??c#Xc$1A_cwj5u`>t%8N&*IFkkb*wDykWQgXvC5ZEXAvDx2;_kG(Y z_|v(P=gd>Pe>~@R@j>~H>l{#dBhx3UIV zHE2-sxwA4Izhtq@iSzAMOD@Lacz;ZO8bM)6A57a(Vtz+e{0SprY6VRG7b6q}PB(sm zT>QCFE$WulskFx3uS5P3n(?RW{=jSe7b~k}0N35s)W3dVwM<8>e4*7pq4hsagITf| ziv2RIW=t%p_*s;-zeZa=VR^;??xrhyk=+2pV9bq}gGC)k?l+*8o>Wto87mKwGLh5Q zxT9yhjw>!|z4QImjDWBK9Wi`hn@tAfi<@#v2rQ6NaB8zbBHKV~Iopa|G=4H|DqMey z4{r1Qx4e||{t~-i(=Q3My5!{KBp!LZM>+bKsARk22Fy`M&`nz@ixG7xw+?=P|8k1q zo^i}tw|myR+QXp=_+C#@sT9cxT25tNOChcK0`%Qvm(0Z?-rS5t!x(vSy4c-rn#s`N zR2pX?7Uo&X9r~;HrPZ$G9fa~{Sc%s+%#D2BXY*BHSmPhu&`KS#LD_GjIR*S|XYa|X zql44?+&LQNJujt}QxXK3m$5v5aEoUpW}i}|tBuKO!8DY7R2*Etjn$A3`BWv<|L9&$ z4kudFmVd6jo>v^w2U(({5(JA`eFn7~#qG)c`Pq)`@;m%*BeUys2qcKg<~1^g5;NvO zDcz*LXOfJe&(GK!b9w*{HAT&Pre~Qo)60JAC<5d*HxpymFT;rI%kYJfEZykq`K|2S zs2B=aqbVko^WBCKy+pW6|`?(pa)lE0<#=`(3Cy%U`AqybHSX4}&R%1kgO za4~qOI$*-XEGFV}^-Rs0PJ|a%JYs=bUycHJ;&`H>wgUCJ_ zY{Rk=jlgrd+*>#Fb?8!#9Iyzn&=@k00!8Us=EsD@@J+0fbuira_^bTOF)&wB6rY!& z9!9QZZx$na9=e~E7E$scedveVQGzZTl+hWD-jFUwBoe)V+oZvrZ;xYP!aELxwO zA~sa7mB+YN*}Q>?qNX0K^~RD@{j>+)8Aa<>;cr>z2W95B@NPlvcppDU1(wAu+F0k@ z5id_^A2ZLoIY)y|=yuP;IaOI{CYoZ^)ArDFgcfyrJ`7u%SJf{p7JjaKCJNJ*l~s_r z(dYstJ(UPqm$zPJhM~wMfs6#&a`GL?WDEb8sAiWs==KI00urM+HX1(Ffoq6f@TB+=!M88eg-inrRKT#eO|EpQ1 zn(PZNjgoj4PZ1>)DXd+X-a_u4M1KPA97O~y(pgG@X}Vu2BEX$>uL-e5-1 zSPEm6$+petyb?$|-weFN+An|F{XYDuO?}V8Mcp-XzG2yd;)H$*v~j8WQ;;!!&>x5q zf2jY~ci{F#9<*$@5kV-g+k6;7hZq$T1S{?6yd~+0fRG!)uFhg!E_7Hg}jyP^q4g<$9?>aL+4s1nK*M?t2(ChC5 z5!DVuWbcxFe#!xr0b@1!s+q_b-e<39_9xR~908^pheIvBWiW3U!vlN)2hV~W@wyL7>^bb*l;OU?!3x|-!p4X#j#aN-GeR~Pt^l${;7A#xd;lZc7c2F=Pw59a~-7+ z)VKo2MN^X#^)#YlzX9j|euI{%3&~cr01k#gLK03B9y6!N{_NRkcSVTXGFCxo-6|++ zt?;4~vCPdoBR2-4tkVYEU?b9(55{L3U|s4~6m^2%A!1$&BEoe8kPP=Ik0-9_T+W&S z8Ilz~`ZGd9PJviRS_99MrMNExEWa>v)b49M5e?!ZL!_s=-_309Ii`@xo>8IoWg!*# zL4wI~nc$XG>arOUzEnc`cb<2mxZlfItn5w9b+{+;r?LEB96GMjLQcHG9MYd1RhCwVxsEX5zadOqPXb?wob zTdeP0Ri|lm+xEZXYxc1fG<5mWnZPq$HPqh=gehG!afu6X+V2IV*2~WEqd?70p`(V1 z)H>pMv8to)&5??V3j(*ledQ=2FtpyyF+{rvd~3cgO&iC?tH+BbH7) z>`zgxWATjDe1{9{#;*SlQFr5DSXi$}%oY510{u!EJJ#u+%t?pj4;?91C9U5Jq&x4v zw!?aV73ccHVEWU||C!3$vL$7vanJwyg|}tJ4D}V#`kZ%NL;Yjm^FQ;t{~fgs$pCci zGS3{}BPe_t67*^f4>I1`S_saMC~8G@I>Gl-+5q>> z#-|q*r2x188z!f;36JdctZP8PcX*ysCG1Qelz$@_; z20+c1g7+*45Vp8&K^*m1>b^k(?l|(Iep7VgW&Pw7k}$>F7SXLo_H~`5HqG^E@3A?w z>x^RET#{aUtMB3?3$=pQV1+F?3V|d`_K9bl_fLA(#5m)yDwgL zj3>RrZ%rMS6gd-0xAv__qcbM^kOcA{AAlF1(-!3wMeRG+8T{@4BG~^2Ti=#F;+Byb zN4FDji1|lPWBSveTUM!Ctjhi$l8v7~gc+BdgnWH_A>$u#BGN<)^6vt_+kMhI*oc4DZJ7<~aI;FCVAg)eLi@;7H`xo}da8&h zAG_govzVrfNz1{#>#dw0QC(n0N`>8^g=lMX(e=puCT3;;+Jetq)H``$?X0GDs|^Uz zs0td?%0Xx&+7_A1HFm7gm{w(e(2YJtY0Ew^2o+=}IV9h?6DU@Kx58b@USjn9 zO#2uZMYKdw*%avfuDgsug2lklhZN|iggG_u;Sm%wg*E?4IwlP)PNGd~P(Ma7-U_qV zUfQd%-vAlb%xyPNtW0JtO^jF*#0S=1gP=E-$jN0IhzNqVC-nk;+e{zY*V|sm^-$wW&*+^JY;k?t4YbBdRJQuh>bR(MyrX zT;97Mb3iW<6DFN+?zuUKT2q0LpdD*w*pHY;$TNy6F4gvXMUFfM+-+WIO0lGe*%4Vf z(Qh>K{Kxwu{cX|OK`Y@$(cA)wx)D3}e7ZAuiIUi>IVWy?dO`N-5)kz~A=SF0e4W#) zzw9JfG4u?vcP%&7-mS_~%js|9TCMr^asV28*R0j43Lx(TB`bx@OGP03oPu5wKpD$6 zIX0RF2(&V8zK%nGzbNkWBvadX_^h09MHH|DxfgT^2ACU(BsQ+`OPX`H?5lW(?pRYdwV9!t%JxE@%yO;$BOX zuF*2c03kl69xKuAt1Dw{vRKbzqZS44s)bC-El#w-Bg@xHqE=!2!8+NmUMtd~od(y} zU3EfgF|`ZhdJF#i8QoawByNJWHP`glT^n zw2RQaTS*##-@o%4Zr7cD^SQaeX3Jkj|3?R1^;dru(CzF0@*)3im9PK4zsl5P+nI>| z3uodLZG8okg$jXc?mzA+|8JuK$N=!$&h|fbPT97OjYfUeUNATHe*4R}VrOq&i-5-0 zZ|SQ*3WIXiz@L=t`Eer%2CTvj*t%lvNO~9_SW6Wnx7XR# zUiza@Q30&KTk7!d1>d_)0ow}lP64y4VQ1O(=cp_JZ3(-E z+yLM9Gf5z`!WQYwPw&Lkye@wsJ%rZA@`;rUKM5#2I~u$Ukio#uR;l5CnrhJE`W$qo zqEW8XNKdG#9)zyn+*YZ!^^bQ)1St1R&wa7ke;=QJ$(S1h$ihQgOg8h6Om@2W^9SRS z?G;Puzb8+^Kl_A}2}e|)AJGlHaVb$E>)C}^A0Rxpz=?Xjl`$jq(-585Sz1}C4Efo+ z(&kj!7g2xBZ@z#3KE0^(2b~bF@Y%2lI#J{kaM){$BdzC&w6%5np7L+vo}c{UT)xc@ zOGrpqFc0jLeY!JM{6}zOcM5I1K6bWv7g{M4-fJv%)1jxM7WC-5pp1_~gu#YrzgoiB z2%*t?u-2ZmjxA4fb8RVNIUxsM*&C+l(5COQ(=}qrHL}*~{mH*!0kZLtr!x@#$be=) zY-ok6&Rs3gF)+bE!F*qX=!jH5BysY@UHE8`Ghwxsqo7S(xL@<5U8oS?hOSEN4-ywL zORB72M0IJ2tu4heEDnTCp6_%{BC+IYNTmsKM!q7cQ7UVhJ)RgQ`eLj_*<_ zukUJ&WR&2dH`^D->%n%jA3t-+Phijgp5dhmbwIdVIJEgZ(LG%_Hx9x~cG24v8;GOdmL2a#6(3DVu^9unCqynhb$uC4S6hN2ka?~-tfMAkk;G!^>n$d(+z4o za6rI?8j9}bNDBBeGaOHPS!r@Qpry(dASNsmu;5 z-5^G-KH)~iu$p;RC^m_HPifqY9}_(S8|tRxtc{5G?uk4YKczIpmC)k1V$Pc%r1d!p z2Bg1fv$LprljFlz55g|(lVXf|#B1oqj-SFf48yCW=-F3Xl{3D2{o2Z}FV)z(c1oMt zU%s(1Ix$h)9%G`+4=AQfI@)xUq1e-U-XwK-uh;KuV)0$YjCkqxC-UJhwtvrUm#v_ZRp7rOG62@kUjFoky z`$efk!lva(v-}Wz6p7M$bM8&Vl6-726^}BGYYm%g44I_a_4TqW2Ojk%&ByMk!gzGM zrx=e5(R{;&Q;HN0GS+;+3ne=HmTm@halLXvPfEqg^p+dNWP0*76wAV+swx)8`srgT ze9fhN&QC=a2m@Gjn<{$!ZHd?_Z&hXA=^){eGqNc@K;q1qjc05OOXxH22Vfw;hN5M$N=or$gpHwikbu47He%mok5 z9+&j27{1;)I^v+@4C0#mLLF0tHfHAYe2+t#d5TYM7y0N%-phVw|(ro)TVtoemrYc<|0 zXzf~s`{2U~3u6y}jWB~BV)Y^F?jvO=?x&6rS0s%@t3u~J<-KEKX&W7MXQQ`Aiw-6ZtZ+k=)5K92YE5gzmI55;kp*khJS z1L>Q3vbF{_y>B?)BVsD^Z0+pi4I@TI4sn4|ERmB0t1>YsSd!);fym;jqaT3#Pp-}! zFXnFu>b&=Cvr5Tk&Tj0(`OkhE5i=72%oK%&E%P}9{`CgQQJq4Y!frV@xVRoa&|fuZ z@&w1`xCfaMq%Hf8FNGVIjJ#i71$BcvS}F%Hc0x427~zx+g%n0O`iVbtDg${TTync>`9M8*v2Z=&+C{CpOsC)FX#NYN3Y#ER&&hu>cjX` zYQDcFLC4-VyEr!5k5df!c~~o7-f73OICHKgjYHq9A#iWuq`!NZGk$Z3^yr;;0=&H+KD(BO`v|{jPwX;4Ra}{ zB0DWP^b^`q%h@1Hv&`_7oo|*8>GOsBM&sl|w3!rfnmfo{Cz?<`hDuEY5wvk$vA8r|1Ja6ggTF%%Lrw!$lxMM-Ama}2buozazy77gsgoxt?+B0W14_Bu9 zjrU$!!t`ery9XD&d!Bwa7T^wwSx)$cM7I`1Bx^aNzCrgSm`fRBBb&f?h*V&;@XpTZ zkm+Q$G6oe^$Is|TO`vNw_SQ0%o;iEn)wIXzI!k2M zQgbuq0HHobAK!$5nU6Kqo&_ZZG*EwVjNYV#%(F!nD$YyB$S{s#zee9Wt(UjHQ0^@0 zpO)kmc_|O>Q6#~vkT_fIRWWQeyVln$59_@sDAccmK*?rAFTKvyqmAZ7c zII6>O;kx7_sO(n5g@jgmlDk(=_8P2+Ux)Nn|vS7MXSo41BJ?60gm|xr8id zi6KU@A>6%VUYD)N$y0;1 zY8`t%a+G|9t^01PB=zjW8X$w`ihcT;rWv!E)B$@|S^tH~anyqo2aRIXaZarV;`&(>0Lsk`I;hW=tsjgtGN0;qj${VG#%2$8y)Hr((SWEATZO{&G+^GgHDZLt4f7RMB2PlJhb0x}|M`CUc-(*R$lPvKu zGGq0wBfe|p12HUibq|oeI=OPzYKbhZRHW=&`JrAsgbTLBG9iS=?zCC$LYcy;$`Z`- zK<+AjU+s0^uFA#iAjP&SJ!3UkK;@AJ$RLd|GINtyJRJCAt!}_G3l1^v;fR|5U|s9Y zk#||)#5ElMx#+M>sd=L^L(PBu1oCf6K0wm!sn8|zu9av&B{jAhQ`+2snZ{aKjd*rM z@dhB?u@kfbSFH1Z@+gxmMX#rsV+@eIIDhxep+$ADt@FXoqq_K`V9k$X6s>M)QN)~WE` z58~pr!-E8~S-gLdX-VpoP~yNVxF(SYM_RP$y66hMU`!)t)66Z-8X_G&bFWv_)cKI^ zFtFH61B-!-@d(;Jr3za^7ge^3PGs?j`Vuo%oyyLj)1&3R*0NgW8KMTrOTbhDEbNRA zz{r;zO+HxFe9T#YJuRSpdw)XX?6B{D2%9_saB^6=t>Mo;?g#Bk`WMm! zj@+n!{_MCpjq}ytOkeoiO{g98f5BKDxTnORFOSR(gYT}6^ykKSkwaFiE*j zj-ck77+A#=wPQ!wQIX`3;5|B5lqly1vNTEOUaH{uw~tT<5?=;)HcQva$a!v)_) z%*`4H^tDC^El&CdCq(~phR}?!^fgtr-ciefFxf6B)N6`t8|OGrkTwQ zj+zg>T%$-~1c}xmfR?Hp7hdn72PcuFj8~m5KtFe}7Fyl8>ek{pY7~&@LNS7xxhXt( zLb4C$voh)(KOhEm6rP6OB3u6OWFH7^LIw}?JQ7n^hr3x1doU!ThWbz+a^o4J%p#4} zm2)w?_?8+XkK+?fQ$v?!@@~Xvg`!>5KFPBgNid5f9d$L&u15p8BahEZHk^ARW;y%B zSt?qVHlUb1O2QOZV!oD^_o)rkHt!DYU5lmWz+(6*F4{riaS>Id1}VNAAGh{;8HZI1 zXcx)ph=|x|0FmAVTdNl<_}*G7;jWA#7d|MpKgL!N3;9uFj3#9o`G@lPnFvld z4%Z&u9|ayo(_8Ke7SP;ceTu=#eU?E|$cWBzeFP>6+L>w)G93fI=P8idIDC@9X9FtuL-B#Ni?+ z`B3_1nYcQ0-?Yyw`O2!ty5Q~?!j_fm$XrfUiSLbDdX{cH#-q$y|26z`K;4f|9fs4Y zya@J#{R$bNJYVzGEc*Kh9a;k(2gN2>6$(37`npt}1$*t4U?eOLhdl}RPb)m5lEf#@ z=vQ}Yhj5AUHkrUG6y~hH=&UPZQQn@9&$$qW+YOC(Mo*T#G>L1a*;xlL zXS?Gzo}Y|!049UtTk9mwp`0pQqXVj;;nO&na=pa^q(aRZbuj!}d?cszI+Cg_TVu~*tjdcMX&h1U z+22TY_)JLXBVebJIm=FcDHKd z!BuRZSN$8&@s+O4qIVn5mY=1)(I!-A0?OQtQ}_t($B&;ZD;$}n6f33!W293x17tgJ zCDzoNIP0!BPKBRa>F$kJIJ31JM0)-d{Q0(KoF^@Fg(oyVprp}u>m#iDuwLNVrXy!> zTv5F_6xj(*Y_7gFPx)HYw_o|NP2(TUvM?d1>o7DiX(4`fy))}GVs@H!N44LYjJxp!+j2(V(Kss*A2R7gHL=13= z0sDrp<{v=(8(-Mk)ooBD5cHA(vKZr7$Rr6JH6X>Te*{A?lrptCTMh&6R^GLZodX~o z3jgR{{!imS{}-^3-<~;yACSzt&|+KITqGx{8)`Dq?hjttvlPj+jL8ku;s@~}M;3A371WZ=Tdus#_JiT#>cl<*@kt;$xnsQCK_Ml4pj%JIT*Ws)-!vR-{&m<9t(g7m2Ee#Q3yOGV=komuzD%>g#tZ z`MH9*t!?kRKYg~$u6Ng_@}RP~Nbh06+#G~C643`Kp$*~!=Ze+H`3n1#zXw?Soay#P z+ouM85-G@_X-&nj#k8<+UO3h5g>s`?)Rm3&Z2ATUF#x(Rfusq!O6QsOnv~H}q<~cV z`k@Q%^x>`+uUKJ`dAvN?+N@1l+kp2l_vSE|{?Q$RT`%g=hZ8olRu^4sFLsNe7_5n6 z#s@<0Rq-(Ml2W6F4e|lp`a|)MX@3T`Pl;^bUyU5|#@53N8v0EfnG$#n{fu{W8(tkk zk;SPpWBxw1$F1o-FCab}Xx@`|OO&I=FP8XVv(^hIm0;- z@So(@S_p$`E-1Ir8L0P}i#jp$6>>MfUvts&6Cv;0>95$i+cwyq{fNm%b=gnP2PLEVpQ zIRA4dg@!oqs-y*X691G)RlRBW4_DF(;Nf~9XfxD295w$++X`8bsjl-vN<630Da0fQ zql>$S(N-hk8v6#{E77|W!cZ@#joE{PvsSfJkIPxa$wxUOk42G3Gi1)q9*bQ(@$at9!z5K3vb!q_z4DEXbAwHUg4Q5bhhyxAh!Z_ZM3+H?GQJW>Q|L<)sX5#Dmg1f}|<1urg$8Irus%@i1@! z@cZxH$ahUa)4WML!!6uhPAd)KSW`4X#Vpu^GJ8$m#U{PXd{SJAmT7#H=iFXso9))7 zF6zE=uISK$^8>c;lktIfMiKk7RzF6w=kB2gB1rD(aCiUHgqff^A3TqCD1!5$4EiQe zrHGxCpC!)=bqk7WV7CFEpy}7Q1-G~Xw~daSSD&^kCRs)AZ&X#84Zg}_A}w&_2P$CB zuJ}-8d>@*3)0djp{G%a%wXN1-l}kjW@N*E{%@2 z{Q*^(Ye>2o8g!3JZnm`~L{i%@YtN5Zy~Xk~G29E>*6Uw`C}+)l?mWzaoP3zKSM}w^ z$B_wqQ5_omK}1}FV&nS}=y&$TO><9|<&JM3kdvV=dwb{5y20tM3&=AUjLmK;my7J% zwX525`att9Mz#ev2U4bcP6{8Zwo>Z}4v|Dg)1@mZ8>->g_Q#~}wvf{Kc9-&%-Ax5* z%ZVNZM>@SI?)|MQq#E7jy^%kSYrkNk-~Q}w_V<{@ml+y`2*edd~+lE&2NKD zs_@?er?@{#hz&Wj`NO-wPsg0Ta(**#B6?-F9Yy~~izg3ZW_o+wa8FLcUWdmi#iNNW zkE|yR&Km~~3*3z}J1Q%$RkNN$!v#;>hhO>5RdDTj@U<~w`rPa5c!!U@J^p$JLvN}= zDpm@j?{1bGgD$l%8gakBdt^pVV&atZt|J)xoMw~9RC=lU_f`ToRV4%+xLtqOzhv>;^rj)a z-Tn2jP;77Q`rl?tRrct0zBfK9?t?i|c2O{H;-LDla zGjadJfuEeJ4ls>>7E&i%bC1_Ml5W*=-%3VQOpo+Ny8()q>ukW;%`^PP??_Mw)qMw&=}+bCdn-05tsH_VKA(&EtM zCzWq+MkGJz;}mV^rVYPVIFQwS-w}CJLph<<90WbneaF&8Z$o7Ha-EfET$71;~@HJyuTCEeKo+f2hzxH9g2FLpzv7 z1|5#lNDz88n{~_kyL)5N=?kk%6|*vHYuD%Au1b7M>94TY?3`QHD_UPyIqk~aBi2oS ztr%w^Z8TyyTF&Fzl-3@n9QULIS!CQgc=yuG?^u9|5|HGKR*l!Hg~j*H2k?={I+{D@ zY{3RE2iyW@g)y$!0n6~mp{ov4UmZClAevfeqT%=BKKbPjCf7B_6>-P7CV?(N+(j>Vt!!%sBco5Hny-b1XKHBI^P zVbhWPuVi7@e`-9k`?NgUgJ@JfGxdvVMbz207(-JFxijQ+{|9xsJ{|T_jQAIhi>8yp zHS~DfLokW%(W3TwE>{ORz<>C8h4O8=_sPsan#%H`c4BYz?qG?{{49058l}KPn6}&r z4b{!$%d1)a4SNh8B*1oY4m41hCo0e)iwoag<6K>n43!SP!Nkd22{Str$j-&L!Aj* z{H)ls0hiuM4;_P#>?YE__5LJef52Pf+M*F-U28dVuhv%s>f9Rryu>|n!SGCZWl>r7 zzOxq9A6i1Wk+ZK~egzBAk?(DY+{OO?So`XzxR#|~NC+eWLV)0w;4Z;EhFgH(?#|$D zfdrS}Fjxo#f;$X0gS!uIgS!q61H4JjIrqEwervt8-db<|*fXM*X!@dT#rtqEvdQf6U1=$C2#cMjNk&63%e(Y0!ZeHN+J)BXC#qpC zg6&;pV&(^M2m39en3}xdwb}EIVAhqEWjyVCkc07s-~0o^`%LZ>qt9zpLeh#KxZ4JS z!>U0$(b;`u+(*xB-o4nB`KoKpcOvrGuP^5oh zKk7m{lU6*Xjw@m$Vt0#8-mSq^)i7MynX>As9~bZVBicgNU+MMKkomPm zR{V$sYy1u0cfY>5QvksJXO`mh8B2E+ILgyQ zQ0zzB5n>!BogB}r*rR@4J@5IQc^zMP-ESYB#Bc!43zdLpT( z(o8{zrz3||bxj~n5xt<=hCBe19gW9Iwxf4pufOP8F`Z;F%5sACxkig9PpklpywZt$ zs3`F@LR!YAC9~v)5#2VVwsKB9iWVlj&0O^@kXorpG@~jeaeC|uab@9g&^eV_0jK4< z4H;oaVj;9a8I#qOCY%m9b`p&pB4bdc(UBWEvaG zy-mC3m6$t@_izF`n>%=Ofj5sUOpqiui>r0O+}n#UENWPB8=F4g&7PsmNJsl?gUQmn zH>=NhYV9rNjJ&s*C7)ZB;2&27E|a{^VZ@x#dSrIk_&Q#7j=Luu@V@9HKUM@s6p-#? zlLdc1bD1rn@<+p!^?kL~>Y88!9_;p}3gaaTG9_sv_Ifh_h8+2RWB&8C<=}{#9ZKs` zRfhhZ4`zhaX|>d2?I_78z*>c_>AH~BXl=20n#>Q~mrkckj^rCw=X>sSHFo4*Z_ZrT z!>_+G^kxXfFC9jw$xRfEBWsr;smsM0H$0>>Tq+2~M|Y<}i6%5*!_h+vYo3M_Z+~B| zeIx3qDp1bwfg`G?>3WhsC+|d1-(hZTgWaoN3I^H)zDTW3^o?>ph%%}$x{l4lgJDc$ z28X2-jjjWCiSRv?{xit9l|$_Fwap2_`CvwkykTT<$6 zoazPps}Btg1H>M9OqJ@f>NO;+_b10ECaNiCrl(`IobS)E^dW9BrPRdeE|fRBL3JiymrgnykdZTGwKQX07~8 ziQlPeE9)51lS^(M%kBp9&kdCtgI1T--}rr&iGsY_j_W(}4vt;nnT4u{$o8b+2Gd2z?PC;BOLw{`VPm*lefMtm~B1Qu6!c~SS% zn{%H}nz>ZE@ERjQF@I+Od_kt4QUAG#H0GAoMxM&Pp#j^EKp39AooM2qRTb9>)i4UP zQ99JO3lpIJjiG&kGKL8d}oQ7nFudF-d~6RzXp4a9WsY)vqM_`%92TusKM zYML+Al|O@(9wLS_NXSs6*j#t!-EEham;KH;IH*J-6jd#O(>4fs`3ELBTo6%d^CV1f zIpeKNQn_;+NI5cy55AO7==M_7>L<3%f@qVL0h9X}7R@xrNRg07IhR;p^|J{gY?^|1 zr%TgyeDLv-i)m@9unQXNoaw=$PYw*j_WGt-{LLr&lss4G-T>VW-%pLV$Wkz1J(!DL zW4@RuPQTz|5mDWm&letSAMy*{EL6IM=X~}*d*4cNb@jqiO)P8NIV;mc%|({0Tzlcz zf#rTKu+vz*upQYC!hbaE*7APYV0)(}de2irPwO;P@;!2STg?%t+qiXSrgi!6J=;%K!o9HcV(q*!5xO( zf^?kzyr4hJ&BMYHf$Rv-JO#49&D%A1)6JGX4o$t0sN+?#)x(vgqslrhlmOw(gHH0{ zy0==)Gpy`Crdbs}X^J!-J+<#vn{uOHSe*9$h$cA`y z`4OUg_3^@woS~u$i(_~fjd-$1eC1kU#{9gGNK-TCd|(*1USDhEf~hwC)>I4r#U*<2 zVlzQZj#x+a1-g8b$bxjk3Vm>%fYKX1vGK}loNiKB*4*LYp{}mKeQT~8g`#x`)K*U* zjYH#$gY!LeJa2Nr-C2=pLRL&O!gkK7m&t`;zCB>RYB&Afvkosc=|gW|iK)Q3ksPJ; zudlL@dz+>0eVMoFl+XI|cAeJZ;t%!dwsp9Js+epaB2Q2D0iGGl!ZhOF!i}AaM~2{@Rjdln&4=M;$H;n!dZqGAYNIU zg$0zK^z)+|^s2KaY!u^1I$|Od$*i~<2v9fGL0&~Ql#K*SMr+A#4UTNhcj4jTeWmYW zyyo%yKY8O63GLVX4zSyE)$k3lw)ykduV1ooqVLE9ay`7gSNHc7e0+Qo&9t<%Uc7km zi3$IY0di$0$jHdp#lrZ)fQSek)l{CL!==DtFKLIP%CxfFC){f(SVw_yIPjkFwL1k#dB#{CtLyWn~8p=H4F*dxddaj7X z$V*J#_J*%=*#nnef?V$&{{}%Cw`9N@;qq#80WV3ivi`G#xR$g6rqJdEGT4*qF{~;& zE1JN_n8tZdsCG6^I;xn3z!a%YB`1$n&3qqV1;(i!FJ3(qryS}=O_9(d7fszfogqf! z*_D=EvjOi}NW=SA z%a=H)t#%y+jackQIf|?>9a^qe4AY=vtkE!BO-Ba$SD`s%e9rW*qdEJ(X5>5720qvm zd-cUbrzN)kU>~FCwEmaOZCw@KsK6udYbQPnURt(V7sg7_UE*#hyA;i%r&yiC6CDRU z*iHvwG^PY;h9Kxm9u%CAAb41Mkw&+L7KY6SJZ!nzO7jz@aE0>qj1~FTvboKA>q3mg zse%%FBIl!<&b{!R!2r}-ON&yYV-79Ij^9TcQMV}iSMV2PQ&cZuXx>AVe7RZd87uXR z7agz_%0H2i;lhz9Zkf@{FJA^Qspgei3{#TwShfuE!v<89L?z74&7>UE?qaLk;^MZ7*?7-d;>FAjU*F$ z6}8P>NOh;UtGu5Qy@|{`p>OgN~CvF7D9%IgG!W$CK!}5-ZE} z^OTik*ZAGRe5cJ>NTDS{%7IcncVH4CC)6Uyc0207e8ZXm*x{Am)x+n zb$Z&$77blXA6_ic3Sl2g7PE|JE>u0A`5|g#Ft}dh-!SH}g?$y24^|);F6HYn3~eg)dAcWKmD-;{&m@DUD0)@@bvI_PEJ0M!ezq9&b|@FzxYe~ z?!gN}LS(2;9;7|djoj4~8XesQg;w>6{VVHVCWSrZ>hV%Q0E&?7PRDK;L{VKm>BJc2 zccz>ev2X}Pe4)XWhLdx|li-h_{&gv6M^5Dw<>fn!D@GbzZJuIcaw$MP6QAnq>!YEe zef#!psY&`ji`qF#V+h!H6RxPJ=;-Pa;wX$=uy<$2`xnLEKVpRt8xYk0k_`msAH3rq zg6z1mC=q{E%->gC>I9U(s`4Lt^&c4ji;z>;$G`E0f9R~?Wuuk<2b~_ecTWueA@S!s zR_;rL^IPA+RPOHj|9YdlAa+L7`Rw-?o7Df21@{W4@jF_1R#ujC?0--XMTBhg|Bc=J z!^8i-cUfN1{YYgqS?u`VLXG(KKIGYuge!7F!uh65zd1buS@@cJTx{%HS=n&s3M7xZ zD4l9Z@kdBGsv(nBv>IFroRym`pIZLMi^a-yJc>c#Q-J=Fgdaw*ks&b87@5CE;yZ|$ zn%e1@u{1n16yg3y&};tRNXL=wn0>vJ41AP6W_}(*@u~H{P(2#mB5f$zD=C|@Qh>uF2SpVkMGc%x{N|j zUhh=s{${@laD(AHR=Ns>65(+9E@^KDAhI60VJ6RU)Phx9u|kopK~nmaQ` zt_MwwMJS6ew+b_=@lStDW{*N4yi!O>zcPGrtP0NS@x)dC0k{R0aJaNjcq;4dd0&}t z_-RM)OHO}vfpHFwb(E#THL&WmKD3&w+D%6EwC@L(-`*(<5K~1uy2&gQ z7ewCf;!meFjypm)ne=q8Gl%qQO4q5A8P0wYGMNZ>gZ3i?~ogQbEL;xE3B9=$Sq<2($9cB%EUHnFxqCQG^lE3w5wU+GB9gYy z+X&E;s$}_CnQC#fAlrHp;aObcZKY#yteUHcq|eKu<;kId(bV>eHQ;j^fR;%^w|C8( zYAIMT8^TFc3DQ(jeA`enZurcM3Llun;oXX zj~EDQ?LNZ0$8u6WbJ*L2dUwW;DE3m@77(rLhdk#R2&N{ zf?)TGN4`J7Hgm{8pC^^CkR^LFF-o-m*G2>gS1iF%k7}8!iti*u3aBwp96mWm^uw=O$?n%u?m0L94o!RMJR%F=hG{=4i~GY zp00zVW2-e6^Q~Q{HJ4!XPL}*Z(mQz<)?SLzja0L4ri^{An88EEpMWZlA34-vGTS{M z4gNUDHbIxdC4btOfw>3t6Axs+ajbdS9R~jzMyi zUMxKnM1KnQCG;-5xwAv#veX4c`w9hR$@XDlUix(ry}%-uGVp(>bB!TSF`r2d7emO0Pb!d zo(s$7C-{7q;Gp!nInM7dF#MiL|UgVaMr1E5 z4Z(Wt>6J`OR7iY83U(`;Gs{Q)S_GIEM7gqSkaRRY{ft&B1V3~7tBzRDcb**ruEStP zA(09aGWt|;MEq_;9xf4u#3vv8h6My}qceCjY`N}ebdk!V$-+b>O<}3|y_+IfII{e0 zS@P}P=OBER`_?nju~4jM)U06aIv=m5oglkz<&CCe-Y4;;KU2vB6!%T*yh#-v=!M!Z zerY>(aoP{LysSv2y}zQI_4qV9`PkxeO>EEa(3j70NFEorfaoDVM4(}wGun+#c*e2k znQIL)ru0eQ8n{|UftK$za6Gf__&C}+&$DzaSR~8MaUF!2GJ`-&?qgZvzLz#+Xtgr=CGGVsnL6RG=svJq9LS=Wi#8NK!Bzqrub*SwEE!zMG_B+#hfyB(%MaB{ zeK#!xTZWlZOu>qzDx##( z_C>Mp=lt{N%?d7Dq(|042|d><1m+@Q$NjSRU2K1(BuWHgV@RLnXEU(X@sh?q^@tF4 zn8Wbq$g+~~E*3TZ@TK{gRHXBLIs{=w=9^@@oYs~3B1_sr>MeO@#o*;pYL>JYYm#6Q zWp+KL;qX#cyuG2{oVzAhGZAWtn7Qn|jYs*iw*J9p`)JI;Qp1Iahto1UJx`!$)uHj$ zsWEAkXdOeIKgi5M>Z{gLUu=x--2tIh=kw$N>$t`)iji0%l5HOWWhp4zf zqs($9sG>(RURml$5AiUckM}7*wp(dhaO9mzq~gu9GA?lSX4G#E(5o9ZTPPY$@wRZh zNsO?mb>@x4BLlgLH=D3e(+5FEfelkC2(f>7RKq8pDv*D6EHbP)$6Z zJTe7d4N2DtJLAbV6+Ijv?MRM*TCF+*3H&K~w`?+LUC&@7m*YC~6}6b+i+A``(eMW? z;AWmL8j<>pTHR^f=&#)!(b@F4FE@KA<%!FI!sY{J{S0^H8IBJ{5B-zAw!=)z-LHNb z;X}b`7xxHOttmgHF6;`>29BgsV31yK-Hmd2w68o zVI;n4`ZYaC@~XGMHifRMi|T=F5+dbISw0PyJU+DCkZXUMg8muwv+kF6rhm=_u;LP^ z+LF;{C+W+TShc)gBB7QN=}n6{rJY7O9BSTnI5l9MvC44`@8axTS8!CzMK{;dWr#&%HAihwzMVT(;@n%W{m?*ez7Zt~y053h^0q^<7wDWDBkFLx z7MbmQk?88e$EAS500yOTDVaHLO6^`XB@(4J#;n8{7I;MocPe4AOj8KRV>}B9>ttN% zCdxGRJ}i3Dy#G|25}rSmWw1H3T%oCqcu|0Z9HMqIu-y|a(wyk5wm3<}Z|V9krY$M5 zhQ#UTd)i8&T4K!@*_|XlE;rI$iv&G=by9dfOM3R{M`FqxY%fhT$-6$+Ben~}7Fv_p8zhYx-=n2IQKwmTb)D&lUo z&+wtuCRH=#%S%VeP_0P^WWZ6WRpXHF2UD+EJ)Rq|_8XsmBgSjLI66z?-uAgrtB#SU z=KX#OteR11`MhA>jy_#Gu+~0E0XkVNMI+-2rR>TD$2gsA7WE!ZBmp4WZ?{18k+Kf$ z_94@+R7o-rg5ooF)3&vR&@c}dqth0=JU=)^jN6VxFFzGG%!ZMw+MTU=(U%ljLt{AW zaOs|FLB^jc>XBfaM#a|{w#^-SbN~7a?Zw!_jO?C(JbuVR@Su5tv$sv3wEZ4_^n`iP z)+>36Th<`(brQFUR3c^Fc8htHGN`210udGsqiK0l$@wZDU&_H*$}$+XgbrWfNnXfN1xHcXj;wh6i~eRtp2s3uqxZJn9dp z?Vib#3)u465(+!en#b-j2?2JJ{0Y>x#&LRakfi=Z_oWm<0U%lRF5nXey1#=Pwd z`_iH)!gocKuYTH@yxU&PdGa-jVnseIs>ON5&PunsaT213mAF#6k0mE|MG84~uCGu- zQGW?CB2Q6+un2T3(bO<;-PZ-P2^JLV#J|sZb#mF?RvWp0^8~=_8GEQd-_!9*sS0!< zzP%r|k}u7MkxK{FN5FJgLao`4InaNFbb;(e_%2jOQ@TnhOjgC!Jga#;*>{U)4m#O9zl z&lgxxlPjmwr+HFEZT!~cVXW%=fDdVBF(+?3K1i|wC3~}yUY8x|U*D~(qRUgg|Au1|NQzREuJRTk%VW11k&83YSlZ0=evPgK=r%+~G_BY|A zZt=x=;DoSM~sS zTJJ+2muy#F>_+<@o}lP0ANNV{2g3=8pq%rIT7ciZ|w z!0M@tq7LcvEe%3lEULi3AvgTjP1KJs23K6ndC4@okLYMG_a5p;OoVree>awuZa3}> ze;9Id5Q&cE<%nDMtY@vcm%?)KgkB$U@K_OFS-rirV3nal@`I|$7SdIV9Nz46%!G4Z zYL{|7HR;1$z+YE(*n4)6RS8;nA6c>2WjPT|JWMN8!*1HYQ9P#H5sc%vNVm43ey{ib z!-w1iObV*|*qrW|%7X&M7n$YwWCtT^yNz#;bpGnnmfKSXGAr<;s(e2ItTZ|W1rD2bv$r}%rw;@t;3-a(9JSj!$V4l zPugO&weoT4B@=RGA$9dqIT~Ny^4p{CFq8E22cR$n zDxE1VudkEX_#Zd#80D>r30EhTw~`_nmQ;~}quV{!O8t$_lHzI{~mO_5Vo2Aqe59wJfizzwgy zZuaM!p>w9^u`_Y-E08nnE-pI4i5d3jV}CC62)JFMB?Y|n!LHi7c%i^KI2%(BPe`Fo zE{8M03mc=6>o4H_#*w`(myKJv#jN_3zwpXN|}wzB-@x-ct#u0h*qivDgMId7I! zK@7o!Ri!GF9G*P&hT31JdUY#a?Jd7Tv(}GGdH52$g{u&UaGNS0ojS$_7+hol;1zkA zdcoy4M)la>{*?)}0QO-;J4O0oS(D}~Y&ea0DK zVOHAGpmQ$zX6~erMT$WSRXl4z&ddepeqQf@M;M%k>jpd2j#O{G@lPH<^ z?i_nw6M-c#C79zgDq%FGQ1~EL$yN50;4w5N`ogCt&if^xGmH2VQPbpZ_B;*DQ?A*< z!=_pCjQqUvqWF?Yg$6lYnN2hx`8g%yY4u8c7!kQI)4ok=ZH*?>d%_U1s11gEIh&qHp07WAwvof+$=OcAjNHskz_U{(ox zv3q5W*xa1^fcdP(4I|pq3!kq3D#ekrK6Zio!ZNv%ZsbB?GQJ#w7hx6Gt5Y}MWz+8- zuVtFU=*-We56RGhIj?tx&_CC?vzuaFYplzwIH<<9?bf<$D=Lm8?Fir@D!;^k@wk!C zo*q(-?Q@)BF%9-(>BgimJfY6{whfa!?ecm1`x3ODv?d2L3el@PbM9J} z3G?5pjr7aoBip%XCq$w`LPMq1DDCP`V*U0!?#g`p28xl>=bn`{tDtE@w#Uv7xdCKZ zel^)+)v>$+F>a0%<@-_>xXl8$%T-Nv+gNUUYn0L!0Jn)6>&PrRoz(LVp=)tBwkPTu zaVj*^kQ>JHKnhb+`Zzp2K2yzORpeSKx)g4zx4+?rL7Dl=F8b8H@6!Tx2N{aqiKwZg zzI(P+ORf~pxzww@8}fbOXfUdJ8-~L7?#w#R^CI6Dz>gjdsKbhLZ_>Cl@C@c&5Kk1Y zYPn|2Tcy0{{2Zxg7UVJHM1C0x@tsM)$mi=`po^^z4wUba8HgDWwLsG7 zAR|w`(FAJ@9nb3;`sP{yt_ulG-?c4UgHsMlj!!hb7LDnqYy?iaQ{}WA(O0f}WP0T@ zaj|<}t|<90-IiFCdw497&1HCJWy;e;gpKY99UO{(?H1-=skltL^k~gWxgO%N#ktLR zr2ts^6iWaBxy_%hxR6SDJQq8Eq(A3OgmWgdp^tR}d?5_!!*gUMkWwU7v-Cy+P&4MMwq37&ZUE)kt!Vxl9DkU% zze2X<0DJa2>TxOP4qKm;LcPF3d!}JBK|5LthPMX)cm;f3-*h#4d(3gkx$z{k(H+Hq z&Nw!&m5Ls9#QtBW&s^72w*I%1%V5M-N#O+1W2@5S=i5tRHI zAj5Cp3?ZmnoB@82W1&pBc|yaY2TKa(hhL{@iCorY^MG%8=)|x|E>?&T1_|9W1$90` zqdA?vKJ^GrcRDGJ(*sr+|0qbwLFZ}=Plqv3Q)s@8N-Vt(lnpQpyP<*3HW&iwuZoh( z`F@e9l4-g8qg!*plodFBhn^JMXLCHd1!~ou~-g`7)k`@ z;#)_aeUUGaAxL}?@G`MWM-Go4aw7By2k}+RD!#WhYaAKgq&2?%ao3knE`-fB38j}M zvBF_r$3e!M7_!BUv)xs$^sJvi3lq<6n7P&fYer`u&!BdeT6dD!w;}u80a@*fA&&)c zLd~O~Ep;)$ zL}AjdvXe(2L&%u?wiz;ay$YhnxdhLyqFj8rQd1)+zi(fWXL_cm#y1>Grg3OGxXYG6 z5*eMCRCNjJ$(j7Z5$eckJ^OpQG|#+4;#ycuAivc3kxnZw=7rJsieJ z7~3~lswWrnj3WKt0NQBe!VnVKDPuZxEzo3DQ6gy0ph#YG#TQsprUZ%tA@Wr14DqYG zS1)I2cN;A{w?}xJ1lUL-ytMnx)4;JKGf@VSpjROWZiZ=4f}tNlk2f5UKp#QxXCDQx8Ne}kbNNFgXdMaul|OZK*vpo;*kstAo)eP3lm5hwF3uUU&}n$H&IN2F zB@Tv)$GmDv*79Xw<924dC(rl-SQS6X@sbEbR(1Xc=&8hB#y!uJn;iZ4Fj>|Tt(T&2 z7TeX(4WmU|{ei1J)kx&l(wo4*oD>1!DNi~ik|~!E6vT1Swoor>O$>5Hj*_#2hnfZF zNP|jx0m;&yNE^TVu8X}Dk>p-U1miW;9|+P(hVe33T*>3%g*dwj>=>V1|NcSPN$F;$ zlA@vAF|jyN(nl4QyRuL{jtjct{x=ObWQxMCd+)Z*x?I4)SEY`Z21oplkcHnH)pV#q z9mBC%aK=uRPNq{+`wOt|bFVJyoZBH|6~c!Yj5F{D?PXqM$-Clf$u#+buJGrFF>ptH z1!SAXGkfK=V+?1R&7n(pL1oefk_Mh1$~B$&82_9tLdx{a6}Qdw)tq>y?4k0ZGBHK? zgr_5$?D_EO=rUW_lV`M?tYP-5`s>}?^jcX;v`wR;#f(CY_Cv{2c(D#-a+s}dmZ<&| zqj6i`4BPO8IJXUK1Wg{Kq zpURe_^i&bqXzsdakj{)xvwzRrP^QY&tHXm32)vfSiJia*xf$^}Fa2A0^q|A~;7e!l z6Jn6}2hJ@R?07rjlWsZ@Qj6$5vyLcAk`5L>1{{zje94+<5pBRR@d~K~bT3-twRp1n z+|kL}d&g{c^Dy+(RFBu+BqDnz-C(&P8z80uWVq**Oq5)+T$+})>ZD(xvi}>oz82c7 z0QhE5EbIn)zmcN6vVP`oxr?1Hd|fw-+fu6_!hfwzm@6_o+nz62zbjWd>>G`%YN0yq zPTelMadg}lSeZCnezf|ChnPEzy>z>Y#~#laKg(ocIeyQ;OITt!Y8m`!#%xzeK2d<0b3^dQ1sRrl8O;!k^WFl>(`&p64`4TAFPK&&7*VmZ6-JmxK_T;d4qFc zI?C*SC*V%g=vK0EHQqUoY_qoF?jWS+Mi1R^#f5L?4J%ORa4VcBJP|1jNaF7m)ctek zdqEVaqkaADyyd=tUqbxd=m)P1wkpZ4=i36vi95Rs$ebMYM9f&ZHVEHYe4}yPCS{}} ze0A$p^ajUDY4zi__Vy<@F=mJXWC$X-L&71$tJDrzy|Rdl!Od+ljL~{K9U+&!jsOM? z)yM)rW27?g<&5#~i)yB%y>G4Ao2^gwg)PXmuKI$kXNP2=%0SbhTXFqiieb^5wjp2X z-%d#%VIb>`^1CbL$eSh#zz`@T?|yi(xqDOh-k;oGf6Clh%(m7>ijTGXRlcB{N-VVR z%14$i{FBP95Yv|zKXZFt36lNz@;sZVTVZJPgtIG{qICD<#)R9=E|_I~cXrjU(f4lp z<^(J@B{ntRR64wQkU<%v8b-I8#=`(deD{5q#TF*cLr8`@EZg3FT#e zec{<^m(@l5!-q(7TSfcL9DPB|V`bc3JBn7nY{x`J@%XN%!>=Ld?C! zSJEnl`pWjgSbH0k;w3rM3c&t__LMg?$deOx?R7L1h-#K|99(tOrC@g=pJF@~jWrX! zzsX+fjTCVAZYRSeRer<`95}f*ciNz!IH+_L%#iXfC@lEf=TT4fzpTMS3Gn~g&K+>i zGzH(E;B{#hyS4nPNG~Iau=l-htrMEaEdqL_&?bkBXFCg)q#=UAFm(;Rtn+xSIGe9- zJMa-%Y=1z(GrDP7E!XWMgIPzB&O0`h8TOq$&-ieS0%`;P-q4uIfk$bHwlEx&01VC2 zC6%Ys7x9F)G4Tp#AWyk>7?uHwZOG<0|g~rE3Vc@P;$2jQVdqJ*XCR0tBKoHLqQ>5bwIHtw&M2XwvBsT z^V2>RlrY*~W$|lbng3Hi>^)_x!kNOs+whbT$whjZ*F~DU>|P$LDz^*2(ttz?WXJ2; z`eg&DrAgi5BRQRzMQ=X;V4hl`xb@j1L@l?WJG&}RE{!fO37=^DB#dybud&X-dzK}L zf^zSzThx?&7!qQsmH+hVDUXdZJ~|hHk0|1x28X$NfN;Mnpsx zIg5IVgQMZ&)8v~aPfbHZKuBml1^kggL%Ne1e`wVWlC1Pcj3V76$B{m8jq1p-fuY4V zT*#J%=1PDP;2qNN6s4sZ>AqJ00)d1Ji>^*Km4>}&sHw^Q(H+c7neV%V_G^D1^zhFI z!9e~jph|a=BY)juXvH%8_ryQP2mO6xiB|Ig>Hj3Ae`!Z8 zpRlg^C-~*>lzFLV7%fapOy>&!^IX79&oYX~?e*EYLLC7Ki4_bD*(^TjJwjPp%r1R^ z;&Av;0~G}XK{BdSV{HaYDEW#>#LUAp4n{I9@RFH1{P#ehs5RIjk-(E?m_`B*{qjSsjnAr3fat( ziTp?;=j&TJWewR3UW@Xgsg6W1bzN)5uc_7uuznVy9)8RIo4LO}J%R10M-SU;(q|#} zu|gUHW)Nf`H3lPSSXtUHX|PY9MIC{?fI3ET946$oc>#7Z(y?oE|S8VTTxC0cv ze#-7uE!;DzgdtmaJZC>-SS2GOLUsOZf5k|_X0^yEMznB+eX9Dnos!fM+)TJ9nuD*? zRkRa)w{voaYE~8fwbYjKXYz)DGK$GB6coW|g>LgxmCGJtUW!IZZ$ut+Ao+Pg@Ds(al`czaCh8cXqmy z!aq@{YgA9n4liERR!R^m0htlq|Kkhr5Tk6-553&Av8$;;lP8r)go%HU#ZMREAfVu=r8E12P~6)72HeBXK@Gub?oUMBj!0+YeUdG+vgY8hXs96?vFuv>b^t@JZw90ese ze5%k9wl&Yq!8G4Zy9!6$k9eX!^Sthq@=7*2@&0qhZ2W`=+iIXXSX4T8P<$$l7yc0$ zJybCI>`15tLSFbo2V=XJG@Dtcwh;L2Qs19p`hw?0)e-mL095amlEo0hO@P$>-`dTP z^{$UpnlPmD@nMma&*h_J%P(j4+;=xKr)4>Seu3K0{1jr)))H+pe=rlQ6wS+y6p}aMKl^yk$|CA#NC_D)jPh8e_ajFJW)F~U?dKISc4hom%AQj^`Jw1gLFXT|` zFOih|%e~Ed&=@}(RnnWHyIr9;&Ml=x`lN{@#zq!f{uAAxCAWS}` z0t|kVUQ8PQszBSjw!&OB_-)^9Gwm*%OrBCgjaIahzkkId7S~hXk*{D$GzN}}f|FM? zdpV&oJ9p`O`|)+n$5i;bhQ6L!8^L?#1Z~$S8x5BU-Yj^dg-^}?C2KSMZLxmJoQBx~ ztzJKf3fcc#trM4L-UXqL$H%16))nqRb801hYxJdxC)D~R;;{4(SKJDUmibI2P0%tM zfFyFDetFa2bxNmHid5W}L;~a}7TS}7ju3N^DSdpA5bHY~qdp7b?cw@n-gtR_J-W1J zRygzcyTcDkDpfxG7x4+FMIt8|6TMI-m6^0S4QC@y?{(#`8p$?iML!dn;s-fJ=8BD% zDL(mLlCv%DW-G9;THlzMt;fkoG&is5*FpH$_SiXYST1OBJlBNtRk`q%$>xz$82l+?Isy-~97P7=xHKl$00)&gEPnoa1CB3~w?HAcR9Ue`#hR08 ziZ?%gH$eKv24LgM-?K&p*@1$Khig7~e^e1)WN|96f^9vE>1#80>AH52Nso2Zon`7X zhToRKRS%&=mUQeq(u46tS6jfTn6HCYrj|vh#s)Gp_jId5anSXk5$AMa@tHn20 z`@+bn#xGjCGyz4jBJeMFxVLJLK9OM&7o?i46) zEfjZmcM0y6wiGB*XoD3mF2RGly9WsF7TjHKy3fCR@9y{h-h1Br;eNSiehDl|)|zXs zImYvhF~`$P$dZpVV*t}Apq}gcpYOIm(s4C<&Wi&_qJ+z}EmaXY=DDdME@-!F5NoCg(d0sZ!AiUw7C_A2Nr@36~Crd@fsR}lw5)!V`I@&O! z$z*%;YHMzdi)cYL<}L+lvODGRWEIYeqyScK06+@-q*m1=yKO7(7C2%rpX~%|E%MEF z_gT+%wIaU=JRP-PmDrrVU0q@5cH6Zt(5hCi9hhDL4~ov=hcdyr_e7kySCv1c;o}V~ z@57NGf{bbHjcf8NwItSv@yJXjjC@W>CD(?b6+bJ8{A9TGTK19Z4;_$TT!rqc>nIZI zZJX-+$3z*%^Sc*5AtNjN{P|HpP!OY#&|HH8&9`sgkjr`^soMLki$(@ndyl(NE2k*S z%({=Pdhui!)$Y^jspOtOGwMw*~`bz4^5d;(9KRv{O~uxIGK#r$Zf7XWl&$b0n=wt)32IH@`Z%FYw#(FyRYZnr@P$7{|fjYz&_aEDY zoOl58Qc@+q!xtpB+JYBV+Se51CTdcb9T|_VyHi z!%OGn$a}lqV{@^Boy9kDO!^_=QkVM2+hk77f;_bD=~fjn-vp}`wgf8ri+zgv$u#hN z=DEW|hOE2-;n5yg?$!3JoB?v?fN?T!D84a|l*-!D+0A}U&E@KFG&?3ON^qN)g9@R`#DcU#v?<*9w)pm zl>3Yk<*fo@^L_|;&%{dtFi4rSsV!E61(m7HmVP)a@#y8w2(R&}(O(B*4*v5d`G=TyyP~niHX4?iMx3fV;Y;Cd-Z`7yjK{gAupvIi6_?8H?%kjtZjQSFsGWCQ_nUoO~azQ(9W0>hqzBzJa5&gVg zjID&W%u1I2ropflOdVI(I#zq(dGpHEWa{WWR+9E>l`RoIh;!z40}YjpMB5$%uU9&f zh0FGxhr_~O7Flg7C1fyaU#T-!pT^R#| zp-9{}n~v`-c+y}ePyq5hwg03gHneObbbS;U*eG<}Xx^i0wwHxeCmrai%CD+Qk3W|E zS{L%{+o0&l*bQvnb=)nsoZYily@)kO@eFlSxi(Q6pEb3oAB}Oz@N&Z(0B{QYBnyEw#IvtR5lZyPDT`mT(WygoT``wb-5x zT5p}Ta^7s?T3<;v+e&cOK4UCcfI^OE&mgV~rJ({M47sX{+xm`-T%>nqC2p#IZ|%7r zMWmH{JmOAt;TIgbTSZP?pe8=t&fM%#RL`D zDpT~QkZ@hdQInp^(Q?K;J|;#{ZEaO^qEE}anOFASVv@UIhKMzm8h4E@xPIADlM}i) zqK~Kz5)nDH?5wjdvy=%_KD>k|qY8_c-UIA@K50`T6>b!Vu(*!*HJ)xYRnS}4U2&F^ z66Ko4t!P1F(O!Rs)jcWN4B*D^EtrH_EBDOaz8cXxK?O8Pt>!@%LA&s|n@*S0YboGu zVe|lKM7lmZC2Ggc+N?*4RItH-k?YyW4{Cmwg(h)dW%Eog*Bf1LXu}Z};QbLBi&g(! z{SO1O8|79aycj8D0SVwCw%l{5emUB)5~^LueZYPM5>n_Cj1x~sw|j9-P_XVj^*X+x z^g5n>X>H8Leq6Gye9L)LW?Xz9M4om@-J93_gD@m~u=Lo1*;liE~@7nW7(rnBF+0+0h+#v zvFR+!o`U5TQ>L~Tt%FCR*Ajpcf zQmdbyno*}U@H2D|6Y!pc-Q{*2etei^@%mtPDo&$U(qf|pJRJXgUI|4bVTuOosQ8xeeUk4?96Hs~bG=xm+2lcd&7Wkt#~i1Yp)7%wR4x@%A*XmE zY@j|JajPsnb%eoD*J2wpS==2nXQ;AWJyDj@e@M6aatoot7dzLS6&js_L{%7`PA(aDpbvM$m}F8p+GfnDZ`tZo_*NUnf+dtz1Oy4wkwA^ zvn?6cPE7kkial;8d{18%S@$M0W^b-&^(Mo9>UgZrS(DM zBf+Ls)xFH-)ob}hISuwgGzJ>Og`- zR&8$d?pp?g``he+nJTx};HS~1Zn3pn%!Np?P6wI}EwQkKtXUg{bcCu?cw{eiQn0I4Z?>WTi9`+PT^Pku$YbN+jC!hAs z=1+zL$2$CDqbR!jYx5aUR~71&fGqiG$s-K~{83-IhT+u386niynXdeWlHhy`zu}e7 ztyWNzehiIJ9H=Taiwd<%q2r z>s%RH9Om0|4zXl~Z}|jep4SrbX4VA*SWYs9?P}}`8C8i^54N=eMZBy6XDk;HPo-F#%O5Yfph=wQd=KIKe~Bj9k=Y2B{H^QyCZvoa*l- zq@o$h)I!(x#_&YJ%<|J~cNsU$t9W3UQqtsf4Zg-g)J|J=Jfwy=(#QF*v*N|9OO_cK z8)^1j!`0`6uAZYcZ{Mwa)h|KY(^)UtM$L7H@JIVzLs2wsq^ZnAHY#QIhGD&V9%zeR z5$R~+M8@*<3*=LJyID?G0H-Q|30z<7GtT=`Yb9la1DWX-da&CcbPQCJ7G<&$J)PII=?Q`O~sW+TP zA=aC>JfcigZ5c(h8oQ#b@7r0IcR!K{Vw27epqAK!gYnxh2S0AGWDBMZT`W@jA`Ud^FpFHX*kaas8(MUgz;=& zW#avpykms2??}}G(8esVhCw45WL81LeXCQXdZK)9-s^JT=i$x19QOsk4<|OD$-gZ_ zK#b||Kq~2GYk8S6g~HR+t)EqaTw&Ec+LS*Hk!}t#Ec)QpV>d3}feh;acB8CzXR5_L zJgSi4>t{&7cz*72B^38gT%3W4Nfw+cLoB2+UrDodHfs@W*^}uL!)`s~SS z;4b0H`3o+#3HQI1W6<>e(O@7r?YLskk*;eOV%RHI7 z2G2hJ>kPocy`NC#$E$~@<1M)LJ4lz|S8GBbZJRLNKhfli?5YMb_Fo4eZ|I)HZ;`H# zf9U}uk7<(n(>ALAM2EjxlkbWHAQ22U{uz1u)v6+puLZowz<%CE2MG>;2JD*N{s|FD z|AvTHy^DY%nhVmaLh^wkb`NBv!rjbma3#EJ>rrkKec`K_ahnT zUj&@bn`|5!K+7C7N^h{Qvk&vUiXx6rVVNt&jgJBQjkK#_usfm9i9f`Sd{kaA%S=ecQam?Mu!vqjTw`y7mpVpARY5PECVa zlz>}doRJyANHt_|4=LTqNFgK|LA?9ZM-5?wc#2Cc75Gf2jxvQgCNwkgH3d3aDJ zs&Wj3yj70eZX*E`Q5DUO=ALRcsU4X|^V13=rZttFC;&-t*m?a_)u4`GHgLS;yUG+> zbxFA2kPI@$CR3rgGvOez`mkT)xcfQjl}VWhH}>db+|!AKn&T7s`*IS zyWTn3ui=IL9Al;i8r0N#m%2NA@{u-hq!_^dr*QJ=BKy{(=@V(!p9rg~8#$6WWX9); zt`MQPLJUr{5^_R0pPT!U1^n4i&mS^mXg|g>aMu*Zvde{ogdhfJYD$x((PMj^9@KYi z@9<)Cp*X%Ry$+@tx*Y!{cYimT@K)?#5=G*TUt;6lTx{E$4+i5WMkL4*}AFCGjEH`IHR!-V?numn`}mqkhpNF>0m@~}X;XDu~Pt1ek+F4-v*t{+oKI`r`+vuftMANTzkH-qm9 zTb`Z7>2**8lST~`)LCfk6+%PQY>VvAw5SO~W?#t*5=AMMG%YdcLDR(xgOxTlkBNI< zb>2?MG^hHP-)5bZEyoH$p01*8R@x}{bmI|?H)M%~pfcp>qMY1rvbO6DUIVksH`BTD z@V6Noe_(j!-|3Rpq#pn(FRr2d^9j`Uqr}m~h=OkNJlFC+P|weEWzz{bffgTq6_6P? zaTstI#AHs{&*-qg6pnK1fnfADFcu zKvR9eIX-PKScsxxv60m~P&V*$t}2BImS1*zfP76k+ zSO!xjaONJ6VOgDp`_(SvNjyE79(p-7YFI~sLq5daKVWsKll}}2j5{SkMzi>1w?bpd zmDmWnx3#p(x5kt|cb*8x&f4g#jAR+@a9Zm4%FQ@wN~BxlPTq2ltu8l4(TT3(TPV7@R{J7O_DRk~Kcp?N!dyy#?T@=3wTVH>qJdurol-yy8l{`K$< zBhq>fw1~AQZ=_{4?}BemY@NW!7)*8TeAR5?G+ZG-fn-GK3h@1Bq6u)*I?C@V{e4d@ zv+|wWv*cADq-(ex+!zLhgTWLpR5nf7SN3)YMcA5hDbGg9kA)~_=@Ce4o@ zjn%L0#w!TnM5`(hB*?Q~=-Wu2ju12ZM@_FV$fPG@u-1r&!l(Mf`*jpZTW619iLTOL zP2282ztQ}a3vioO4Y?kz5XFmg?}yE0v!k zeVFSjsUIinsYX%PI3*aRx9_KSnsQe#2*2e4%X*F$uRG^yPl!34D9^rS&1>#XIf2b< zxuOARzg8*C*S{9YuEA1C3EB?TtE&h@WC!mSSzCIiFo{bitSF_|UXFcHX`{8@Ty>A> zDUiYu)Nh$>phVh8aCdsvX6$4ZdBbeRV|99JHzRSO8w3aD1a%;7S$ACB3>&T3ta&48$s8H>r+g@!YP}p28(nQnp*W`{V^h5rScJMM?b+u zJ9{t*R+nssM?#XnuwWQ~t<6li8p5K(O1G9T*YKgiZ4h2ezcxxpZAVxbNUdUkOERL2 z8&OTiCN#GTcU3t#El~5Av@NKyg;l4lTk9cCtu@BQDmZ9e*ESjIFCBu*&R7a|k67su z)*o#vc#=ZPySaz^{OPKA#u)N)$SVZ;lJ~h#@#NrsJ?S!Fp0m6 z->X7<*H=tIfFPAa>xLZpXylib#cvvJRMjXARCf;XRH3sP(s?(Z@1ulv&-=~%%&mC8 zJ(*t`Cpx-#lI&IW8M3lja5?C%dXY2#%(J^tbM8;mP{kh?5|=q@mW0*L0+K1Rw@WP| z7*o`5RaCZio*EfEEvkvr3J{&%Ov(kGbmW#_R_V~Xl4(gN%rQq@ZqH%r$*~hxlv%(S zn2+nfj(6r$B$Jf_1qzP1x(5?$?v?_oh&vtV3&Qn!A-9Q{ms?#Tq_)T4c+Xj(Pp{+b zhG}EPgaS-lwoOY_xppvwS2My>1}Hz9%y{mi9LoW+XqMY z18~Z0#^020y?)ccZ=PFku{KZHYBO86vO}0drcz<~4tUiruX?La{73B5Ss@USh)T6N28xAx?rI)hWhb)8(^j8Xo_K5&08MUe>evHUR=ClvH4 zh@~6)S&A)tpo?K|^sJ&CVVvmOm_K`XGjUFkyq z$i)q=$q5;fHE}l+1gU{-J1ZFvhs-i1osJj^hKw(w!HHC@x|+#kBey%lxxHA#V3{}Q zs8qJYmm7>=Dw{rOxvK-osclW@eKMOgr|HJA?8apEQrnPuZXE+wK0nyD7W7)@OmUVJ zm(%o=o)a;liCw_uo1}x~YN_!~MwqZj*hGbDOAL79m9ZAzGL~v**IoWy`aDH>C9qW* zoRL8pFYKGSRQkB>OX$=UT)tfwDz$c>K|ePvgh)!dOuB;d=tO+5vTWm=**w1{AlElx zCZ2nnKTZ-r;UPxGu<^rev~T)jr=BHHWVUTo zDlM0@EYNe7H}azZa9&aRg?3cfXYXAHT6YXdsBUQ2A+wO99hk)ecM|=)E2F&%^V0{j zaYkD0&_g03G<;e(wW3P~zGFlyc8g4xn=T6Ju~AL3SaqhE@%^Eyp1IwL%)$7oqDQ`y z;G)lOj-#Bihq0OIcM_fO)7sBE`f4WCT-YFjTFWj~!M5mErpK5mS<{Gq?)hhw% zXD%R9B#~iC(=HVt?T+H8!4{Ho;49Fnl!u55QxAUCk3Zc~Jz+4DPe4!Py(ml4%EW0E z#oN%3ZN}jo&4J8v+8KU#iW3;~SpZzzPRN^uM5_%}ubUm(6s7LPj)ky{sTMRZdu>)z zGu-s0BV=CGM(slEh3$WAd>B(>dhyQn;jZd(@)wsQLu-|9G@Ik+Wfl5Xx#1^w2=0#p zc?aXRzS^$!m*bDX1`Dm5CHWab@kigtx>>t3^Dlh_-k@f2X+#$XdZo47OzA_uh(}j)*th-2=7q6k}2Y( zdl}j;L2Z3Dv`#$GLlema>_7zFUT{K1Q7^nMb7VsowLx=V|L@Zo+WJ7O-T6)X;d#%h zWoyY08Ch@(>e?8!J4EE@_KE{1^D<*7wW8V-=GfsfJ-F|1i$^|kN^kVD#K%=~RXxEN zHT5&~WGw;bLpxLV1}z8r!mJ40vCfECrS~8+!N%MWp*SwhvC=DVKoiQ^$db|J0$g;8 zt)!meT+_59!|on4?dC=^q1WA!9ABx?r}&tfl0oJCY`c`6FB!uF_yp?Z4tKTU>%|Nn;C>TA#fN0pVPhzLJYZQE>SaEFaV z{wD##e)n>ooyFQ{kby{_LS*Z%2)t}#u-pu;Q?ge@q-Go_5vioSJOw05Ao-TR2$?_G z82&%mm@mlOfcEzGS@y_!t=sDh7<3LX^%@a=l7y@&5eDyx*;ON4E6e(S7;uznLxtr67s9QSE5; zT7~R9orRN=H@d17drSp;HfQ&*CC;z^B2eCY|0Gb}8ewk~PF~ecb5mw_smV~LxlLCh zY4ZQHpI?0bHtybkqE3_|>+&fi zS^rntr9`GSBWsHP&Nd;busWp7%vIJjvfg{?&-@DPfAE9ue|Dq+l1chP%w2&@v=9lv znEdCLZZjD)%pj8{zW;BNCVnTq{?vB;&t)}9{V8?%pa0%RzW6OMp~ z@TX|-e>U)-|K1t@^Mk*7>VLZQzddLQPqEw?e&zt7|LQ#CdqvLs3=k9$@Y|r@<%oHY zOuYKlX#amQOyA#G>#03O=m5wfB>!lKpA-TArDOSPJ@pEDD&SvrrEy!<;vIx*TtK~?Qoj!SuTQe{Y8Y)b z%`8k5^*u|t|Fw)f`45c$xuC!Lhk`=kQG!hi2<#bM;4HMf?u>0KrZZF|hi>c+ z>TfC6=z?bmvPSJUJItHk{|@$0*Vg9q=E zEG`g{p4Wc;=z$;FH$Ny0yy+RUZi2oQiV*6b@cbf-uVAE{Lpn%9?NOWgg}2_D-8hBx zOwLYKcM|VTf+sjGN-_;4e|L8Rv&r$igiKg%&2_-mpjDxf{G~v%|Mmy7T>V-GF{q%& zF_+)YE56gK7#>Q3b&OpT5iEB&6KGwQqItK;_^7FWvqtH+0eMRxm<%Xh79d`iUxUKJ zYr$AL*w&aAf$f6Q%bxHU2~(^e=AoC@8;@vTt!R}BOFN8XT1Z_v)isQ#2yJm4y)E83 zAQR>s2+iLZ{$v-b@Q3}J6qgSX8}l*a)QU&(MaORQ;BdvJk(Cs%`U`&E11;P< zpW+0kbP=sF!TI!uh~KIjtlZ5kqXXv67qs$F1vfpQ&XW{e$>=7_--GiJuObLI?xDv{ zLhU$-?`pzkvjKRU+^OP*G+{b20x9n4vXcCV+jrquw76aBHEhhp_>|BstG;(6grUmy z`bM_NQZpf`b3Bz_IBhB%SEQd3Ium3%`PW1Zt_FMFFinH>d&wnOp6v8V&Xt~cmgp<7 z>wq6k^F_xsb{O-uUI5n=(bfk<`!#fyE-IJ#gLzBh$l-xb6eBNm78zb!tH#yo)5qjA zd=1DW)n2IFd*~6lYk_#4P!b4nhfu^mJ zP7b^4gWqd8pUyJ?EjO!^q8n3!1Qd>ZLZ_bf0MIQ!ria%_RNrYV#%0aBGMS!P&xGP@ zRUEd5h@Pu@2aiWP{RnRU$CyVhe%B#$20X8mF8?vVP?LB(CszLCs+d-nl477u_B#4) zKOZ~L0COP0^Gn1b?%^bZIag?7bsN;5>9P4Aq>flY=+8~!#(+Tjla6x0=FD?r_RQ;3-HGhfP@)e9Xj>p&V5n{pR|7%!WPJpJL@yADM`;-rkPsVUO(ECa9twpDQR@ zy}akZcztc<2s6uSIh$^OTc%FXgGUgVuW3xJ_oJJnWmB zvc7BVm~7D2)GX{gZ1*)&g#WUBI;OM%?Vz)9ZT4+%mDAQ?p9f2j79G5Xe+2zlrl0=tPz7$#v1EqY`7-N_bRe)->bX$6)upvNA2*~2 z2IL>1+Un0tjg)s@*vIlYjQycHJo2muJOv)635MH!g*4mLO%=b=50vT~$PLq3m4Q|2 zsLWtBi8rr5e6_Z$=ZzmpgP#*QcpsC<-j@~ z;OSiZwCzdEjT`nu!~V@6jR{AJ`qD*UA|;;>ZEAMXjF)qP&Z@o-*P>b}kb7d%h0o&Y zohjTPII7|5ox9Yz3Ci@6Z1LsMVOv00k>eS0kW3?AvXRcIkOLj5mBL!aJf1b~qKBiG6IHoQ2{u@kfb$`?;Op&7fUF-Y=X%ja}_K2JAWQI6h~) zPh(v9nmTUKzfaJeUdjrQD;~O0$H5zFE@&TDiL)>)fI>V6Z$W_zKvy&mDrNA_y@bA2+&16SRNJ^~o`{A4@0>5Ipa8(;$bl9G_g z@Zg}m6CnGL+m2^<<&HSAt!itce}y{wrgWZ9`LJT2(;k)s^9gUy77pbR95_E;p-y5J zGVQiSm1Q^Y`E>6OkUuf~Qti0z?Mli0PS%4h{ef^}RN>U0vP-hK02GwffRv0iY*)nk5s9o+Z?WI?~(Sn{nI z-pTnd^CvMw`RRON=15TVObNy6>)1!a`d}p#e8+)DFN)t2qoGy!u)gv_5RDh!#Els! zSe#4j4N+{!e}^?7dn{bva4^q+cMXv5x6+$1*K3JYceL6$h`|$n02}E~toBK6ZDciD zA|O7uYBmnmdvCp%C*Fvrk?05Yt^~2DplJYIKiG)5gXFzEysHc z9h$v*Pjym(m?(;{zj^>Q#yr%CaRoR-r4+!fh&mr9^>uBb%}O?CQ|ge z%0}aJollskh4JT!3)I$2EW@RE6{;FC+24JHBeEmR1yk`te4hy$uC1U?PY;e1(O4j@ zpX>_aimmQt59S-bnNHb1eNPNuE{P%+X|-#vJag5(4z$B-tahcE!3Y{kOZ>uI>3p43 zV9d`Kquz4NdiScz49Y%hwLmTIJ8M%q zg$P`Gj7#XZ&J^V%p|$^dOXM#j)yU(& zq!nRhnY<+q$`g&V7Z{e_Q?&aNBM;}+6<7)v_~y!lNHCTTf<(dWlgU9<@~F=S#wpUuc!*# z-0Uk%Z*O9W6wlwbo{ZN;F{H$X?IxQt>n3np^k?W8F;4W1IM48pGA80FB=E-_MrG)J z^NE5WRZ$6j9_6b=Q{Z^9{Ds1P{&68}fmBZ&wH?nV?2Tp3)`FTNb%koyF4lR&*{a{- z31O5kPr7h*5>zxzb4JY2^79Mjyh}$`a$4Ky;s(iORhS=!{Byb&_{MJD=fOW->vM7V zFtP5^{?(q}9dbp~Kif>oo&LYD-aE1$(6U_7_zSP1$Jv!OH3AQ~lgTPVA+5Tw-%Yht zHsSw=;K|5Yu8N%Beb+_qUkXc|Imr7flsY?Byl42nCz`RA23hv(AFYcL0|Uc&u_d6e z`)oB87X-?kM0;y1sL;x6I8 zflgUNLqaGR7QQ3D*vVkUcmOk zM%`T4Y|NXPnr6)NU5qRnd;)Zfakfka{`khBu(g*vh{|I+slvEhR^FoGqoDpo*BZMj za_sOvzW{infT&k3{~&ADI-@hsro-&vD`PE=A1vilgG0S zymj-LcrRbREc2*-2y=U9GF8cTS&ddVl*J6xsmvH-^w+GS-Aq0?Gg!c!Zi`9=TF%w+ z7Sx0Oe~4I=QXrgM2(V?l8;`#Eby=y?snOtqqaCFngcbk_^OLvD?~;7<0&CE zmJ)@0gkpKf#4Pr=z zgRX|(R@GlW(PQwaHh=iSZ{3<}#j-U_9QB|AfKPs1IyYn4;b=KYl~pM8_P${+{PW!2 zC&kfa0IYUkv4g|!UGf*oOOfYsL}w<3&=&$DQ=QYz&D8#J2V0KewCWkI`@9t-VXaJ4 z-_YxOovnuzvkDt!KTY#y2no;i%E%VHhs4IWwW|?VzAhJi4K3aI$R??ZhiogYZ;$)=mUd~9fOmLc!+4MedVzbff{S+OYpEe);KU2nCWiN6|O zGcn%?;C6d$#5um+dEoQI{qR;_t2@4q9`&(SPt;40DV;`=VaJZi@VQk!f9wN~eN|{B zVzvyhzFt3I(Lnipx^M5ae4;Wu?P$EFZ%Zd}Id?1ouBGlP5IhDVrLD7cCTasx9&11K zG$84}!h&X!g}El1Hg8c%dby+NeawF&z~A9!F7=H*4CoRj9R4i38vqJZF(^HM_kwy! zbdnU7I1{HLNVGsj!*`F|plEb3mL@WXnd73Po|6<~aFrO||JzJ)*EtgQQi!eyL+?3n zG5UwqnpbYeGEwx$_fa|{gj1vkO_?@2$Mbf&w)t{7I<)3VJLa0JbsoZ(2q;2(`vjN=2iaa_LmC@_rM`6sX z@y1ZB)OHJ-V0>rVC5633t*&qvEi!wl$iK(gLOG(7IN6r$;hzNc9^Q%8*x>abYcyy$ zyPP)PChxoGk?)1)eeL!XE=TIT{oM}tylj#A(Io2w;IG*UpUZ^lH>e z=2vF~;Hg^ddshN+lP439|FC(}y@ghVs#KjwUaO1q6+>nqMAe&tS2MX@8V^HuzE`R9 zNHurt>w9i^aiuf*#|V4#2oSUWK#Y0LF%7QF8?%oflGbY(Gmm9Y#-MRPRxm=A_LcQ~ z|1o^(pcy}pGILbef(P9U(wr{-S!s9ES@Oj;hhB7Xw4-LUizI88cfEE%k>DptZJPVC{Wwpik_k;01vAoy z*rJpIDHj)gX1-NPW6np8JL75I#I4GPiD^M1fsbR{v=QyY*Fac3Vhe>&UR~U$NV)q= zq=kiDWA|HOP$|*!DJ-iyX&m?ps13=JB|Ndcw8fm?w-cwp>$&Wa%M{P?Z-q+fd2MZ@ zsq1JPnG(=cbueKy?dM9TFFn?RQey0hdLI;>uxst&-Juqv9l8X+Q`d(`N}WNwt?&&F zFI!nVTOHNAj?UPf7-i11D04wazRdt0q_DkI4|)-e;d3)kaU9o){ zzxki`t>6^540yu>sQtMKch{kS7?QEvq#Z#n60hKeFIV*Hdssf0FVe}iTGtN`*2Okn z7xHQKndsJm17@`L91U9~lO1gjvK;)ipJ}>%hhmRl1Ez6Gt)|7W?Y$GQAHQTfr1)0B zjUeX#Uc!nef7E&Z?{@-OmiDgl$B!5A43Mh!V&Ov*(@d|3#WEc1m4LU)9uRSWnzy{+ zbxzsG#_l@CQJV8E<2AcRkr6!|+Xq6Vvd66RPl&#ec)nh4=QB+*MG_G zFMO{-U9zSx>%==)A+EVU0rQjN#Z=OxoNu|93Ix}YdmtW9jw1h!BZ6Qq;4=oYHUyBr zYV*NUg;EoE=9TvSDZAf*aZGSu_! z;)rCV-0qJ}`j7c`QPzr)U8@}D2cxdCO`d`uyFB1S&KTo9QH+G`_KhTzWUox_OmDCm zGkaFrmf~XT#_Mex51*~}#HM@nsryBo(59qZiH?cYFiqyF^Ak*Ku$t~Hf=3G+IHY&f z6Q#|PG(YMnvl8e|mwcSB>&4x6-V#lCN3Pw8lS&`6#I^jW_E}P0Vj1BeLQaP_q4Cj$ z@;k>QjkDs*?8eV^YOn295#{?jF-*E$EdePEk?u0{J zTA(;xgdHWJymU{$b`6Vheca686FXxOkC{5Y*uYT9K3mH~ms%)W&_Qsr-_ys_w~#!_ zmG!MfNzt}7JXnp}ZQY1(KcREas8*<; zrhGnuAegChQqB}XFBQ+^$?^8oF4O7%UddO&kj~vc9iE|3Q1{yV4oE>8u~Hr_HGxS> z79irW=f6&`xaZ1u#|y6HV`-k|W;MgWt1c3$z@ra5+VZROV~ZiSQy9(!iC)_UuFp!p zUWrriKJ=Zt=hEW>S~LJ)KF0U2y+4$kX<+7&J^20HPbaJWqj%O*m*<>`1kTxAE(AC| z4VPq`oeFFHg~1cJwh!ug-dBcB0YHZr$!3`v4?{fgU*ry{;gdq7^mV3O53~b)`-s_7 zb}ST}cC_hy=VsmnRG=XA$Ii$;08!>Gta0}dlGvlfgWi?q`ouz`8^aMOt+d0bY65}w zynL7*_wJ^Jfeuc7M;c6z&eG%qNIdR+SK5w0dUA9ozUUwl*u;12SBcP*iJ6YLYq<{4 ze=tfPymzDe<8PM`SLgwheSTl8BSic9Ro!39x>=A*oa~^ zm~%avH|_g=;Fj-xakv^`g>HEv!P`0xy)>mJ(oo#j_>JNi$!Z7Q^(HRnC%pPpbjnM2 zv8gCM>#YX-9lzdvzu$CchA+I?z!6gC#+@u zQ03}RxIc?0iv$>bTM`$3+X;0fPemj{$6YQAQE5plEBJV!8QcYE1&evTAGT@z%uZ5< z&jfBMOYDs|oXJa?cWV~s%{q21`dKA{-DmfU`yly*qakxL0}C#*rxBrM{qHYdTaU+7 z0CiwUzB7wj?A;CYW|_@_Bypg(oQT~4=wwh46rS$cPZKtCNtZOxeZefeFEO!f zH5+UJKa!fqF2h8BnPKLA$uy(z(I)F^X=UZGxo$H3VNr#(>=j{9mEfpg6ey2XP@YBu zX4!h_=Tem4MtrK1=UiCN1U@!$ZjgYmSs*1ka&2N*&N+ zUTo%t{@Cdez|}jD!#i#&dfY7|g_DSyI!5$3zWa7>;65VIMKFeyIap(Q9De z(7f{ayel>{>AP(Fek-z(aQC&{nh5sK%j;*vL;C?Md)P0T^^H0jsG6vDyBKIr5N|?} zPg{SpJ?aaAedX$>NC+VeO1U#tnXU)N(Yn^y?iY%F*LJHWi-3iQ>orOu)%V`r3VaU7 zbr)K>*<=g83wX;KhU|5h4`o~TpAOm^YB#c!0gs;U@P!+R=A-je@4G4xz4l|Pt`pTh z7*C7j?(~Sv(Q`&jvwT|X*BL_iXOh$;Cnl@wQP~p8BqYpVAxGXLtMTt%{T)xNAFi(! z^xgg--qEg16O+{OlE03A?2aW)Gu#-Fsy3^k>A*e~@{4M@=$z2SB?~6(EioZ`z*+8K zY=wzxeQsQ}Cel^%%Z%@7F-6bfhr3lXi7y0_Tf+ zFf)wkQ;r7@9ylbur@m@P%>)7qm)GG$D4K_U zYBRcn_KcFy*u!Dh@rc*w=crCXEfV(_C!u1+&Kl2{iP}qS?4MRU(YS|Xm>3*3ui(BW z5{g*-Fy}z|QIbQ&iQIuvA%d4OcW-B9DyHQ4d(|fl=UU}sH_#<3J5n8Y(PblrY829T zFeK`+($HDu$ZF2y-FO!txkIGP)=8=MU}mEK_`}iXEuMH*>#Jp}jwermt*xB%z9ovq zlZHZ8Yb$-Rg6A*BU9VctA6||un~Im3^im(1R=2GQ9;g;9A>x-CCZ*jBw@0z5ZcgZv zvTE&oB_lkUUQ@aiZ$^APzD*O3CcjP3>J7V&j^k*};c%tzzn(2dN}X;w+Pw3s@;320 zdv22|e!OWzyU{@G>jnQhE_m}+(A406WlCzzy)c>lMtvI(NPxe|23a7~_ZHBRZ z4$XpAn#Mbx)Riw1=9zkBwA?gWK)MoAaI(Ws82HygT|>=WG;)t#P7e@<-4^_TLJ*em z=?YaHd$YHVQ|$RL4}XO*@HuZey$za+>0W5cNaM)-?g&m(cXdjmas4vkX6#icC4B`? zIE&Ftylx$*{qsypf&=k&r9S!_R9}V+*kTgfyLP;+)=~PpXJ4H%*qRsLemhziPA+t&*E0{ixhLWuL;4tpKsXvOHR6CiuOfi zCw(K>)MM36)ytQA8h3bBaMXj^IJW)APiwL#0LN~u#)RB-GdFN@?v12?JtI4 z&a3ZLedEyIT_o7Tlc< z1WC}~PUDStfZ$F89b6jM$Nla--#O>r@4fMUzw!D{)ucL{A2H z5n1(2i?fxo#>`ox$`G+;1;o>=6>J8Y-1X5cXmati0F7&2!t$+>Gg_{(@Pavj^nOrc zDfPqbcyJE#vf*&y%XDskB-0$-_Gc(!!rEFzuQTomanFFv-1(i)cR$tjX@bIwxV9AD zo+ht5UL->4WGA9ZlQA0`xe3e^_2a=cPx{4dFnAF_hf~M$Rqhg}IOz6SYaHaQHzNIL z_KqrjU;l^LH#Q1VwY7*lIbrXqWRt3Z=&1_PrQ*|XNw|wbElGpPx-Re0l?~C(R{l!w ze#<2YbR>eq{j+NtEijOJf9$E9Li;t5vmW7G%fp+sCC5(u(Y9R3R%60*_uWhZGjT&W zb1)M}z0uOF$@G+DKx(f(yH@l2cUP1OKxkXPnDZ6W*k@cs%0j!{LS>YDiHhxRt3-Z$P7%;EGE@UE~-ah~!&&5#zY!UGmI<<|DGgGjpa<2&TEOL`cXZJBGWMsk zmGs`pD~Rsy=?dsa%*@oVQM{0vce6Lfo&R* zQa9^#fJl_{ISd^q2<}w^EOFb$DOhi)GL_Ct&(K_u5azb_NuCn)UBJ9Re(tf&l-T!5 zv;*H8qH#ESUohe#XTbeUTo*6xGsuIqeT$sQhH`V2k*QrH_diG*TdFAT=ntWRajJYk zdKS@oR}S5i%MMdEW>w2?TS~q{w+puGOKcPN0c48BnvZ6`gbg_yN+n)ABg<{+m+w-K z81}DGjZ7}4{{!{h#yS}-D)8}ntIf}RxTPf}Fz$@|THXa!*!pJIbAxm`s=1)*%Z0 z&x^De852wgWg12Xu@AQpa-;{Y&K`N|H|mJ1`#+x4=M17>>o?mwo(+P;dP;ecHK4HB zxRcS}KX2U*FiJSF`Tn+k)@t`uQxF}zpaYKx!d_NW5H1dTArOLPtAULB_DAgr*J3ni zd|83f%)DnecQOEurcK{GNjhk+W&^G>(KhcQtHvMGi^{UL3@hdtjOb*3c3;h`J9yhz zzLf<9bdNEj>cYvVM>vQiDG7f~}EJuXgJsta9IDOO)xK)e0Wb4TammrT@ey zUxLmQG#qKm8WN|<8Gg{)?j@lJidnl*n^~jC$!!P3C2Qr&%maUnvb?q6OT6$3h4yu} z>KRk6T-zUDY`7jUz8l9Pdh`IK?FFi#DO1lNczCV4d(he-TlH_$5TYnXd)-3uvOm7A zV!H*U<7IzGRd?G4va0E7uiG$PUV2`0H*E1`PF0)EU&z9OioJ7tVnMf&qEvFHVcfj@ z2e3|SNuNSrut3yed+@ks(0W^6HaoUkqRpZm-nrJHQfkd(uiwd1{K%U?Y(oqHFS2r# zD>ymhFIGT6`@PqLvPeg$m@g%d@Ida-6z~@Zh{B=uepL$Dja8^m#u~RyV6pTSp|$r1 z5`(k44c81qS6;8mTQ}D*8Cs@=tY5iziFQ-9OEvRP9^Pj${a3&^;gU!BPKBP7{tinF zt3ll}5aAFGjhx;`*ot@1hfVlaB1FBD@O__ZvZQX~kv@dC0rKCR|8{L_d;hTosNxq} z)}K#doRQ;74k>8GO!yKw36 zK&@wOq8!hm*$PV?rWSW=?@!AVs;Z}i-hZbT6a2zwJr7EO@`r*tv38v4q_drsco{b_ zQH9d%)Xmg~%bJ-qdn(=I*r!)6IM|>L`QVxOoam^CKC#T_OL6dPA6J1P2<2S4<=|B4 zF~iSPxvfw<|f0*!!ae|9?g;%_a< zIweoL>KzE#q@pq6c~zzcQ>Epq#U5Lf+iI1gPO8c!w7*_|bj+yMjT}fwrk++R&%0*} zYkBtO?}FJExr{|t)6SqQpx{T1V)r-Y8NJN&QOiP6f)^qRCr=BUkOy__$(yQ%ZukX0 zU3|Ae?09ZsLz4l-Pj$ZaHhgMujAs;YdV9ay7);T(%}4lDESvA{d^ZNs3#Sr#p%wPJ zY_J|!%zZV>7X!Hx^<{tIKz_{hJsp?l9oVVz)-$nC3CdS@DDst%R&D>k@bX;Siz4%i z2)>`&Z~_~F%q95fYO;y%A*H;7~P4K&opmGaNvh})0zGh)r! zoZR%Y;0hriK@cV#Qhm#dru(aOz0Y?kvi=nNfh3c=rinl%F(*0ZWTBHy8y(PY53~E% z)y<~P8t)rEFx+UlgYGw|);L^bh?A*!>N46VEF2jL+xh2v;utc*{j# z*S>##g`O05Kahli^ST`jZ`5YY?h`H&yl%IbN8}N@Sin4F%ZJB;x1;gSdE{eSnrNRze<6&wj=JXifecj>!zqI-9AdO+Tl+j;?<&2uF z6ANG`Tg4$OhXq{$^*Uaza2AK8bf&;#BiPxcfC+bU{1EHO`oHZ4s6ZDsnZy7y2XQKu zJZ@a!vZcqCFot7`m*GH$MLh8fo99FUz?RL*&D>m>FLFs0O>}rSTig=mc!%Ci19EIt z#n3Npwy->xjE{%yT{T!oY%3uLRe#>B7_S7X6f{{5}N5Mj#O8)q*;bUk5s$x5)G$FA}0xX}N-4lVj_5Qh>E>3lR z0Z%P$6)z91l~YUZ+a;;R34%op$cSQ6jh~${Vfkjx5ppq_o@k%`L{Z_|5z-`c$M1&~~8y!*CKKfcP`)5vj|%NGeSnaJDrpgP9{ zRe_3ksOW8cP`JT(5gnH2+byTysdyEMVxfQFg#*wH9?{&^9@hhQJYTzY*Zt?|d^aoZ z6h@QpC&4!7ACiO`0&&w-Z~G!m?30pTT10UKMKL(x1Rpa1rh`KdeP53x3_eLk`S^=FA}_t%tqNjR`i) zrvtDGFIG|glIOXcvUh&>gM6&z%a71Ej*FY^qvgcMMoY_GDKHS@>T~a}HH7Nz_FC_{ zVa)a6S>bhHz*b$RQFJ7Od&3_PGRqbB(tOt3VIQV3JQMt+le@XWm5GhF!NSPbxgp5j zyf~oAM)m|&_u@9E3P3!}EZ15qfF0lO_75%qZC{NnPWakuw+r^`XSp(QzM(%oTkjVK zlcr)b285IsHQUo}tPCIpZfl2`4ZX2DcXv?_*qyx|#rI8QXg5y9(CIk1%7ESvoc3i$ zDOIJH;5}ja&jxpc7MKG=eDzA)S2xgK-h`cw`}$)K#{k_xnlIsb^nU{w?%~&N>v@5A zzf$uh@1VHl?D|UM-zt^NTiTvfrsU?k>n2|{n_beHU_gX2)yPI8li5^`VqIF@hGHOQeJ+eo6q=T1okwH-ie;5A^8a&$YR6 zmZ9gsXzb4}oWw`q@?rh(8gpxKf3CC2FjMLvxQY=R8!^kQ zqOe_u2nMJy#&)E<>xrnTQZ_SLstCB4VqZ3DN#e8+FH~QCuk<8mM(AkCDD8j%SCFc@ zo5r^1Ea|G$-Kv!;BuZ+gEvYlw>0nXF5rQ4$P+~L7V}3tP>m3--N!I<+ARs3L4bYqT&6OOGsS+AusvN3vbx_VJmN1-66&x?4iy>cMC zd`V$i90&>C5CPvsJNx!(+0cH;cEU_TCW63pAlRE#Q5}!Ku7}0}Hwk=d1 zm^HkwkHShob-SuEN>##mzyJ9Po|Fuj&R<=j^%K)6#yi;bbv%jCg6_7cm=jXWE-q>a zLvxMYWozDCaLb-?MwXyR@coo;7fxddD3U=-%gJV@Lb={zTD&Tl(Q67HOxUQr9O&P{ z-RlOLl77eP6r6}gudHzG5ca&jg8q3lCLH-;rTopTUK z+Eb_;%y7lMvimK6u0_!sDJ!^w${dd`Uvs~@M7{N()Xinz;^8zIlsYif{FIaUMHL*c z)@@>WDF-jk$TT-4N7D%ye*ev`A)gw$QK<9vr(6`CLQ}JZ!I5AlZqd6}uTO~R@(Amy z@j8zft0ZpSM5#exqF`qWC#!97dxo>ar#f>y1o*|7i$X>&zFw2)Hr2EoK z-B)j7sp~*Y;NVwY%dK1#PRl+kD23CzL9LFLb4Gu1;SaV=P# zS3$qZRiv}DujvK})5;!T-0O`XY-8+MeG|v6WVN!^y^an4GB)fT4CJvsr=y4iRo%JYmcK@ICc5gLtl~j-meO~6hFjeh{y&9u69^<0Um1GM8Cb<1bJ;D_+dXE>=NIH6g*qf!kynR5Vl7UXWgKuOq=ydGIxqS(U9@I+t5 z3AZRpy~@P3_%0wz<|zW3A@4pfpYC4fzEN(H>1a#HVx=ePO3=sYy6g|!?h{msP_{!P zer1QSNVUH;e*fJu95Rr`*NMv7V`z5(kQwgs?5uj3E zvw?Vb-<5UrM&m1VMIux=Gmw@1Q!IuFPC}x&8X2SYlrJ@wS-YoEv|t$3pMuVso*d6$ zpnya<6x?07$i-bCN88krdZZlG2>z2|XS`@>ve5fA_oK}`$C>UxlNFcJ-H=Jl?Jh=C zhvBT>IlDS0;6zNJ_lt2K_3vD&JBcDgKQ^p?BuApMKgD`{Tx4}hFUVDIRzH}KzmIFQ z!}?T@#OjY%1YF{q4yxx~V|#Oh?^WL>p}mZXiENM8@4}(r^?Ttf9MijCD}VtZWV%j2 zd=e`!9zsFae{}>9ew~+`ty6Qh_`N-9ct9(>1pj{ULL!HOHYpt_22T9@SeAy!_fE;& zVz{kFDJ-r5XWfR@vRJicahydfO_Am9Wt(j+!Q*_yg4F7_j3 z-k>Qy6SYID^KeR*xSa-4Bz4;yRk+MV308?^QZz!img%}G73sLwiwZ28Jhz?terioe zF*Y%txpmdkuw9O}igtf7E>=Zi|;M>XtoW^g*@KzbNj%4p6+2z+r z4ypSo#^&%T26qoziklTq^F42$Q+m-k-jIe8OyQg?6$omcz7bU9|DmvPLUHS}Bf_-W zMk{F<;D>GXUH)o4$K(N@Y%iEEq+<7>SE31P*~E9yerfoeO-;QrK=>=nVe>-ou_)iX zA_r8={$Jy1gZwz<+nJ2m~|q{<0*C{9AIlcvw_5!U_O@wx}xu zy{wJNo9WDUK+%6ws>Ms@0^6OZu%7`jZj*%k1uA0ExHn7ew1GjlKA6?pJu8%SJ#!3d zsqdfsn9HGzShhIs=PN{;t$YqKz0*VpvmL-{Vd}d&aW&d3bx5k9rd2eDoGUL(n4)g+ z7hR{Pr1I7a4O&CG6^cdXJPz|_pI$GLX*L|#9~j?Q5ks|UdrISVYTPyz#r;|(%oYTz z;XV5^zT2Ydx7|;O1VZ=OSE^Q0P0|g2r4)SZDRa0z#cx2`e7M^kRuf8YCWm05fBdoF zgqAy$V}5gA_ljb1XpG0nmmJ)<19K809($SXMUQb~Wj@BRMn{rx+!vV*iB+xa2ll3L z$a$|O|s+ai07wGFa>Ixg0V zLy;sQ8sfuaij8gYjRu+#?4KG3yi_wZZz)`u?uV`&t%r1P5hYI#ZZq@7C}HPC_;V!p zLZeSNzj-X}S`snt=qlZCOdAf2xo=xDojj6@<;}$n>Nq3RhScvb z=3WeGbLIWqpS;hz>KRlPY}r8d_5%+YsvYKi5^nn@t(8lt?;?*89j^n9Nc?n1Gg+0- zm?CCgahj+;Q0aI3a;}BKIq2|aW{;-`zlvQ34?C+b2;a?lEXzB=-7B6x@LWh3-p$?x za;$btq`VI@+s(J^9PEQM)JWWQte-chjQi-<}|`rTW*5e8@-xekENj`$l-DsgyV#7l;b`=63`C zkzg76MuH9#5Y!Q48~HLm?{HTdpN}&{vtm{%`}$rs^B4tK(TW-Nneii6JBsNu$h2v^ zI{KoW9TDAgh#Y8BSeH65vC+%hB%lRjIfLbQIemRUyjV^Ucq>iC>*FS;sJ+`0U9rXM z?E>1$WH)_Uta=!AN}vKs+!lhz5v3oyN4NapeD3C7OQ-=hollN0d5|q6+w|u!Hy_53 ziK87U`kU^1PE1AGBNceS3J=sxRI^O{{z7k-vFXPmPso_hEb87P2uHWH@&D zkxIymJ>0$6^>jf!k2(BVzW(*Rd)h?&u}5_}`N}@-Ehhf)Kt{Ew&g+o#?+edq#8OP~ z9Saj6O!N`zlVsG`PhIwV^>?%fRDyxRZN;}T%bdR4X2{MRJofqL!_CPsVGyO#w zKVI+KX<6jU){Ha60-w;Va3AsdYt~F!_SZex{F8MR;Il#P%XDT+6WolIYaLV6cgEV+ z0L(z0MjLJ4&|2&m1A}>Q$|GiurvV*b1X<;d9M3*sJ}6GH9v$eM>)UIL<9Buq#uxay z&j{bf#eTcHvGw=U{yVykM>PK04h!1;K6}hDi6^A&x9=asm(0%d-J6l?qu1#eq>~SW zeYUupKZYZ{BN9N94onr|-P#eDnqxJhqISQZDd_^Z4ov+6KF-TD>C8)x6kq6(B9)Tu ze0kIUw((;oj{~xjb{>Y~L4ZU%zO>9G>t!Q4)0pZy|X3p4k0{ypgj$(pcg8 zRcvrv_B-}P;#!8XE(>`;l3D;jv%7@5p@7Wi;gj@Jz0R2>y`jL&!!1>-vr1xWRrz&! zEwFgSLMUD?n>D+Vtyo)})gjKOAFldtEdpVNJ#-Ac+$_(Xwt5@PwOCfAkDun}+`bKC?jALD^9K5ViH4{!Ue{t$0|IasQi7=2 zmt%fZSIC2h_2sQ+ikC=yzd85sj2Hhl_(K>JrvmjZso6c)9_cfj==jah?_nH~?rdVG z%ceB2S?4&|x&oU2n%fJb-P*HtW{SHEyD9TtZ*H(U?w7d?RdMkG)3{d&KIrW~l42@Q zu;cp|xTFp)K&9*HXZ=SWT;;iYTN!&tzvF3K9h&;|hQ1%jY8#?EV4bQ83A^C5^eqC_^IzJzS&=!;p<9^ZV%*3_*<-crOdGPwP4mJom9 ztPJ6)U1po3USE}q%AtfMYqMxjo+6%g+z^13T>?J8a+a9w2{Dy?pkoOKr!hUeb+EJn} z=*+W1-P##-moER6s%rpFZ!=y(^z1IkYVN4-*1XoVK^X7Hbm}nPobl#9 zRoN#Hzl6|aqkBA&;Gq;}$;3u3(|q5ja)zgv$PT91Du|s;WUhRsti)9KAm$DW_54AT zrz420$1JDjgV3!z3gs++nL6ms@cPIj%ym2(((E9ATom4Q%p?G%V zU$J`7WP*cR>-qMdYc5--KUO>FT7%L#LL+Xn`V?Sqh`4PPiCX+{UmBE>6iFv_BHUT; z>h$Yq@+v>hjS)C<=jy|~HnPm#e$%oK_u>c{^v!|*!JiwwjQbDJpyn#%Lbr)^kfP(C zG8gJJ86pE<8(_qX&8_G#ql7L^*s&njpI+0$8^Rs892r0pd~Sr zCR(3ijHO;+dIQ-%q7m4<+@OAlf`Vc&i{CynCWe8BXWSE#y*FDOYEW<2Ogaosq4(ks z^*elXvQK8?K?U``PIoa`i?9v7EZy^2ldCl`Ol|4 zo^83Jb#oj8|HKTEqdL;i>>5??k|6TjzYWBYD$0 z)&sC&TX}JK-V;~BYbl{fW;)JJ@q0NeD{RZeBeKfaRxHO55tr8CBF6|FNTnLy^{ zz!Gf9@LiojQhG9ek!Nq%XYRZus-Y_92r8e$-R?h5M%0jn=F0xSFU5P7{xP91B>{7; z!bDfM=+rq^9&eL_$~&9FELhSfW$bQ0YPe^1;`nGY?wqCDZXK?!6HP|>Sz%A5ofoFe zonimm>gt^`#t+`dl`x&?J`@VP=8nRu!LR@pBj_X?@cIq(u(ip2_`FcZnsXke)BP_b_rm1!ft0@sv>4vy)4xXe=BPd?Urfvo+)2JwrQ5<)GxxK!k$r8yB%0}zoqf{(o0t0DMZjG` zLh7k0>*aIIi2C9bXl@|eV!brYt0`Tw>TpJ z$w-8d9OkfQ|1|(={7wZjC;j7>FS~lS)KAkmW^vZM;h?i9%DHgfdz`T^OKlSF=qouj z=)x=%!Wp0ANqPWInC>MNTgV<*6VwXVZEtZv-=32)ekW?>MEKVEP*Tel^ppS4rgCf2@|!n4t+eq` zMz7d3{!Vyt3Uy|tXt(1Nqhtjt!wwb1vjwu@7t~CZT5MU&j%GI>sj%}hy8n_kH|GWz z{_CsVfc&k*?%Z0h@C~xIPnwDUzQ0AvPJ4r9G9%a!7&R9k(D!4&#!I{@l?Cc9hD1Ah?m zkx9q~X^K)Yoq>k3o-mQMKv4c;rqOqac+$}PKbM*B#VM2yLa=`w=#890hIoUz$h+G$ zg_=CjCUteJB^%9Cye0_`C^kx|`bJv3g1{BbRg1qy2UG3)$VCr%#BCnlbtRKd9!`oo zTrbIDQ}^aQwH!(6N;y@udRP}9F)+>zGd`)cI3@{-%;*duqd!tz94xzIs$pN}_e--I z2;M%Mh5o?56X@l6bVez~DVEFsZawa>t2H0^DNb0EjtRNXP^gM~pZO%~!){|KHEiHsgtKUx+`g#mm*gZe z%LN~woT7S|88b=JHJ3a0dA#BduVXt~I9TW`F$mi3?)Wh5H1l{<29u{rf~?p+t^PJ4 zoO7hUwxSE8;%9oSdVSd!gK zRLgw-O|adc$a1S#<|S9J`LK~%y4L`wW?sMs5AJgktuN*u+m29t@(MWIM_l@8bum-f z=9~19H~VN&4T#$l!zX8yWQ@3`SSck6o>Z+~{npoxW_)I6S(@8^m)VZ0%4_vMNE(wG z&&j^a4xaihs4}anN$l7!4R(V7V+ zZ{3_PYbiEpc-n4{;dRqhgWR2Kuia_?zK=O&^x^O^B-}jS;_E(x7>0IqOe~{K`oum>KWm-NM&;kLBjG&zEDU z4Ewk`xrV)3EVj)5uw)N0la*IQX7tGQh^#TFOU{0%Zm-EQBo%DMu*t8J}n zyizm4PliYYd2f#Xt2ij~_Q5?p`i{dbr9vuSY~`p28`l&u(nuww)56PGGl2a-&4)dl zwsEp`wx_0u|Al4{Ji_E@ zw0>w9jIm|xAO=i&r(O1GYsflCnc%zW55Yb=t+!@Nat`roN^H4<$jGSS$n*z+pZ<+C zeB*dyY&~3=scz|WO$W~hgZYkir=9sUJIt32y|8-V5Nfh9w}bGXBaP<& zz?>wcblkC2vae6hlC<}fT+mdL5C{b*yFNY zPFpq?!usPy&z46CwdqhcWVlTBH*(`+C3(?b-WK6}CQQmx&_E(Z+n)>k@}D}l?>sDI zEQOdRUp>WvY!I)Er5v%wRQ$zWmiUu^L3&g%9YJO|YIU$WHo~XhNur#)OuU_W!@JUK zk2^DD{mb3-42#q;a&vJIk;51J{cyTX%MOr5`=;{7+WI-kfoTuEI=dJoSnQT3{ERgQ zFLX#O@B7+_&Gm(HUzYis^(b`$POGl-1-E{#`Oaz!p8vSVO5BMkkHeGElah};|0fV+ zRi~QAG=rOB@mH@$q8EZ9LnwOCcm7H}|_#pqLK)Q#Jm+4fv$;!&sSdUUZwm(u(R`yszG6B5y zzUfpLRQ5e6#${|0kY|5@&iHA(y93!hEae#K1G1c2>3zX6u`w&c|0eNF1jln2HhikL z)1Fz+#=^p~v~0JZE^po(f}U_~+J3b9x1_IG@IMmdfBiMf-JU_wZ;3!p?RkA7y#K)k zfGr9dDLsr5U>GWIg_8bT9{u*MTh%1Ne=`pB|AO)VU-`$pCjP&gjb4d&Xbnyf$?Qak z>)dt=Be5Ly$_V)fEiGpe(GwxfMg!xECZ(79fKt}jJdb$~s=~`j_BB#HlmT>QebmHgL_d45^JTJBSeAKV7+vi9Zv8$CHzb_X!MC<#~ znF}mYRsEuUr;wysZt)}qiCgXc3phyZgah)B6rp?|>Td-Id4jEHt zkG}%%P+edkv8Ur4dpch+Hp7z zaOUj-97F_XTu@)8<4v%_)!?K1mrzctXM}%$tepRzMj)biY&byRgyOu(37Q>q^F(EC z@SGf%F~q9|;YNFMg()P2+LmFu)BEg-5BtPf67}aYx^RnEQZz%!!`#cm@PtZX&sa2= zY`I+OY>ofOOY8wSq;Pv(6yOU6kjnU2y}Mvy5#nkN8NtFvG7J44sN0A3O75ji1#Gd) zMxRLD>Kd-4X;it)9jK|Ufd6=ZoFH%hF>d?Btoh&p6qww{LHzM83}sgsN_;5G!#VEh z?={!#cSV_&gYLaX``z_e&7AZ4IcB&))WBNHCf&RL9JA7kw*ZntWD=K+_`d7OJpiZm zA62`^frqkI5;Q=hK2J&%z&#N$62zkY0`lEoD7g`H(j*|VEvnTQyoJ#rY=!CeiPw!A zla4h!9`)`F<17NMptbMG|Kzy|kD~cRd|7=aIQEzCmQ|ex<0it@$US5x zm3=N${ruBWI2|E~`FzLBZL;VmsY7f*cD*}5LUS)lBK2^}Zj)1R-v{*{}s+RA=mt6LB zDuh;Y<@)L%C=#h`et0q{Qx)6ggpNVQZDXK2WU}13__J)*^{@9>hU}z8u3%acO$KiP zt1uB)(F_4s|0p<1s<=*nn?^U)dN3sdFJ_@PL?L?4V=%fip?gF13&w|u;`HtI4nq8X zeuhpoxkWhwDvy+s6>?>(_^6y%3y6x<-^!jG@xwx8q^szKi&@*Rm$gCSe9KE83)Xs{ z1O^{SL-Mx*bvgp?Sp~!|(cv>F#q2Oq9~KZKFj?GJkjH!8E=W8e=El(LHEb+4r+%y8 zI3wMsbhVy+A-@gQQdE5}luc$1G1FQ2WoSYtw}=z(=>o{BqS;6m9xq>f5i!M0JmJGq z>!;l}voD6m>68Um8uoTQ-*CNMU^kP5g=7R59x_i@EMcFA%#B24y&i1Xk2B!kqGLJUB;&Pk6sKuJJasPO8rA)XE z4vNrC3xH8j2?Z*iZ2XcG)f$ivT{y4#29x5P8iCbkU~vL}Y1Az}xfX1SbzKu0DE zwsE@lXXw_L$Xg@+WV_C`Ts-A#-!Fu5Guh z)(rrfn!sFMF4h@0z`{`ME29*HA$L4EN`xg-?g$&hexjZiu(TOa%_$5el>1z-nO^$l z{o5Ew=9H~4BdO|UqLJ)8`$mJIkIfmm9q{Bg^PxnZ6~G8itJyeOfGQIvzmp|-H33Oc zm}{vLj$Eio(N~&w6&j2hEWx4yQo_GuD<`ZMtBDZS_;nji=ZcJ0&nm{;*$@!RFesjGS<=G;7YQN4?2OpsgZ8xl~qEDtzwZ<0zIyJSD z9_;L&AMb4p&GX1OJ}n9n&buu1;k=n*wzH%fx}>7bQ?d8*F>AV zEP%A?$ov!0*UDFAwXL%M#+iFo(|p~-1vvGNdcGr-qpO6~xQOXy24k9-T1+$2@1qzs zzL2IW>aIm>QMdTnXIb8bT8D@*t!J%6%ab}URYzaj>7#`rz26+vWghjD27$ewLXfoG zWmV~&GeKEI5%C)pz%$uG+bvyvRIdk z$@GSbjJhV9_BJ>%x4x1_$cbI@>XN=z-@RVR2 zoj5a{mUf+Tb!GLPM2A2&kWIzhwlT>k*P!n=2#nbY?U&Z_cVt`vI(!&@-qd8pM`q_) zkW-QnU00++WQc>>Y17DvZ~OS?_p3b{fh0<#xcU5UR9(;+|4xKZ#W%K5%}v&kv!Bm!qR6RV<7&m%drRbm z&|qN04&CT-$2V^-X}i#APRNHh z3IrkpRHwGp5@4EMR${6ez0t~h#2CjwUI9BEsz%Z*o(xf%b$&EqO;^ z>o8{1?ytD{5dU|lA9zm+-S$r|g9uC!zo#90Y3F(h(d{QyAzAX~nR*VE^k@QZ6CQj1 z?>#x7jLgMH23ptV@0|N|3J)!|>dm3q>=R3sgXR$5;^42zcIr7z}C6EljIx<~sj`~|^6mqLo`dvk-i zDg=!Xi_1EFcNmX;Nn$UvWb}Q`^^fdm&9?FIlLA>(i|re{RprIL=@oO~S{tmu*g6!@ z9ZTanBsu1%cV&(L9s8!u@xappIh8G`>=K{yAlDG%i)Q0<%{>5#RPK4-G4=p3nKDdb z>t(PvHf<>YaRSJzeLbF8DH2|%fp5?F52dQ^P3ysm5n{@eJ zk$BvRP<832?bW%M0vG5{W}X(mu7Lyqa39Xz7=4U)f6$Fgw8h-c%5Mdr>>2*CRbqJIJ z!0`gCAoom}lGqI3xq8#M{D~o&)Q`y~_HoF;o8zdq%94Q)dq|rnAVX?sa&buoNqAu2 z;NCh*w;UiFX*=hc2ose1^dIE4e>pEwzC}p1<5Y zR?MEx&?%r-)p6JBq<#wW5_2W3X(K(~o1!APRZ3&r_t#t|VWX2=l*5(xAY~l5liY3_ ze6`+}s-z%ve11gvzr*6&?j^!tj%5?_@XuyE7GsxRroWg8xeDFSPU4Zv&J!{vvm$zo zdK?BxI5YA5E!j~3vgX*%ZPFS*C`qSL45gLk)rPs}kS|1;4K<|Z^H+h8u5rBX&!~zG z=kjOUamPVO^SYP!zUkY-Pvi=_P-F=)qs`5%mH2E!uBjaQ&ZUU3GuEW(kr9qo+m&Y} z-_;cYc^%a>hab)Hs!XZcpGj($$T}$XyTkp8V)3bgLeKG}EtQ#KYjL-=b)2T5p)hzEN}(xxV$+|q4W&1D-zC0nA163Ayme_Hn)?yj6txyw1*da# zzk@1=qZ7jDJkfH(KYvD--I~zS(BQk{$pr?l#Lm2<+F#XW``TRJdA%ePzOwc5cjl57 zt~X-tMYLJQYPh(2ZnlN!kYpdzRA#F=-?7!pSxfuQ-{xC&Y&+KM!sh7iTQ>=Fft>-E zm~=o$HVj*JgmIq7YRlrwV%xYFXlvINcHlyz#HswzhTlxQf?CeUet?D@eP8oygC^Oe z&BaW>rIbOM; z4Ib>6S^;pZQD^FwrVWeMMDP;>%i3k3>f%DTyBB}!7F;uG?9$7Nif4~bbS~Nc_bPJ( zI7#RZa=J^FrnHXqdq&9Plpopj7{`1v*~mVC03_D&Nu=ub3wG|Bj8Sp#95INO43Yf$ z+Y`@)XWuk4H62zUk;MN*LczxWL3#_`?)a)TQk`1m4eY`zq&9&UM_c{W>J4l%#nLI1 zO$1(@g5?x&I#s1cC|-)T7wwry*kc^yc?I;85>reii_8RBd&~NhUt(XnZSOTxb-$&5 z{Tzy7j%vnvoQC>+-AeQf{7)@Dq7sg=T{yFFCyRwd3DOvWO=m?^&Vt^}xHadLoy?aT zrlulltFlivaKT%bF7#O;)ILsJYNaw>wB(0iL6B-r7vU`Yw)foPfUII{wVLLhkhZ^e zTU;^yd>wfPXxezq!Jw$xzqXxWc=T=|WgawBZ9Xci97{>;grR%@OWOYLBsG-xvx9^p z50!U0w;}Q8j73((aSW=kUJw@0X-@5EvSD%-ZDG5@0RYV54ZD|-bL(+s%=0M|^nE9) z3=bMTzVc-*;cZdZL3Dosev^nQQ7w+0O!@Ax;7f?RShIhy-1O^HfgMF$6%c02wi*dS zT?6sTv`E43Xp$Y(Ig!;Egq-(vshEwfccP7$LY%kVNZ|fvO^Lszh}s3eQnee2#Ie{) zp1i`sT%vN#lsn{fM5e3%*=j3)xgI#qKxNWeyR|AbtibB6P=^&28MQ4Gy?q}!Yj`2& zU1RJlztozV8>Q-EVunl$++Z_KsykzF>UseioJ1#i(z?XR@y`_Ge&N9KT2)~4n_E26 zMu;!#UQDyPSTEAYoFr69pPM3)Ai(_TK#xPxPWp70dJtGQ?Y#OF@bVU z-$v!3>!4E?muM21wRdsvGhFMfd>V(a0XpxbCfM*A8KoMF3^fOO}IyqH;u+-GohG^2kV@#*#kkeH@LMvH_ECyLeZEcq z6f=09<<<|AHfOcIyfRYE2nqQ}Gp29a%x-GrlIhQypEN_CzSx{BzW%?V3j?Xd7(PE!9|7DnYO8Q^4hzn#Rfg8erLUI=U zQ5N*J9%GIBOZ>i7VvDAf3CG2v5pIk|ow^oUHvr15OxlQ&?+lj4OO}~73}<7t3oF<~ z{Ng1PxBiJ>4^M;Zu2(R(B?Jvz)t_~T>i;jg**2C)r&X+3`^)lN=KcHk%+)Jwma|V` z5`q^oru0v5Rsf&gFm;-quG1qD*G8p*qvzGz5A2>{zps7_)!jNj$lS%cSJjyy`az-49qLA)#y1CNIxReONrw9HKADztFxj+ux+7 zaCVk@N5>&f&L#lwb6)P=L?M|R!(t5BImzlb;eFzmuT1-tNwXGyRTdMu1$~?Lh_V@Y zaC+A0p4@<$b}JEfSn z19?lE$3gslTs>opHwWhG1m2x5fUg7V&CXY-Li3g zWFxa=pM{cSFVEaYzt`6vW28O0xpp=9!h5;sV<>~<@I#CT@LNc#@cDuBiHHfW7z#HagcwA{93 zHtvZaU@xXtb7|KN2?Zlfq-p?vYB6Fgm{^r2$2}kB%3qa=A@97SK9Wf&%8gzDhpQTa>WFo$F zm;SJgK+eg@dHm`K1+Y+a?fd7e6&l*_JEJ4%jo)2{J=mSm*l!OI`1vbzY=SR+V9DQ{ zf&c4D_y4rs^tmTI6UmzHgg{r_e?V1jhlehhlHHd!d<_AnJl>-yLP&LlU~`tS6+H0; ze#^`!+@3O@Qun6Bh%o4Dc3>#Hu9V4M|NOG+>Si+FnqK8URYWR>gx4(+Bz>gClo@o0 zY=8EI;d1R2JSW3t&S}h@G1O$qPSd>Ov^jE?G8{P^0ZRd^7qhuQly>pZ5PX;Mn7BtZ zbblOy3z7#H6)jd%~ly3R#cyK{{BD^sKyJ+RF1H( zZ3Gq5i8h(=<&CZGvyr#9Mn$ONl>|;wS|I3@ekibxx=+Os(cPWjCQUa$X1QxDqC958 zcAi68so(+FbzAG7OsgDmUh$Yp=j~UG;zEvsmWcS>*JW95w013pFmG%CyBNSpDDhX@#NYQdSPg=@rz*5EUsKa|bb zQMV%N^S6m9c<`n2JmR>$nd=PT_Y|i;s4s9e)7WBp z1mVP*dtXI!qTO#7!A~E^hb)(PYMiwhQ)6%q)wPtcmTbP@LCgkz0DTVti>-@|afX;u395!xr0omah z$(*kmUefw}7v#@+8d5+>YnaY~Z6zo~-L$^qb{eaci4ZkaHB*D#FE#=6a>qaVMuZCA zR+PyKG0zd1#N7H{E}1*cVQdzDbmOer;p$9c{kIq3pb80{RmY?cxlO6!obi2>k^X-m zv#m3Z1Vw^8O+8UlR>*-YbkHE*b9>vrwOrXYFlTuc-L7SMXd34S9|a2sqFgYvWVOD zHR|1>+~xXNEa-Yj5lV%AG@e21()fbAw+eUt^eCwIwI>3USB!R!HfjizAV;{G>i$|tTNGK* zx1(cN!-c-dqS%=DPQUB2NhZ~r z+I2>k`gW&y- zf~F6~N1i&fe;w+lbcW#&gfF){P(OW@ce8=pJ=@feMvto`+}yJzp0?Eci4XB^+guUc z`;<|ojR+Q(ZUjstvc2my_U`Xc*BF7MFY)WokTAKD8O+iz#sXoZUwlD7BbvcTg~B6lx=ITZ%@D=Z)wPD9q?fH7gRd zdN1qH4EwZv3oYmTjepBV+YPYwOSUWAoJG(Pi%xQlBT2$(F?4KJo%wIRy1a$Qw-{iQ(;a2ZqK`Aw7ibK`w#%yG!^WNT&ej=KR zg@|Np%vfkHC1N7KH34U}Z=7}RHoasa5snL@?CI%Y$g08jOFT4YN1uJ9i9%&Qn;5yz zJ0F{_ZoGNqilV-PqYVv3Rf0{wZ2DS7N%ll42vj zRfY=>QoMhMNvr$7o7IT{M60S;&LJM_xLm^*z@-N}AxeaxJjO79876PbYCa z&B)+Y+E_3(RfQoA{3IN@JJr>gKp~?S_)Cc6-g^jV5`u19uI^+)DMyiCTMAn7Y>OUg zWl~UTd#{2d|Gj>;@%137?Y+NuSR(q*L`=EiT2BoT9@aQH8vwZV&U1hU;#&1fn3(v5 zs+E}fST83LXTJ#x_TWtRMnTs7`qy7%zWId?WaQ^BKqQ+6)Ug>n4NIW7cq|k{*9}|Q zJg(e`+WV?}6XtKy{q17{45McGnTu~($pD8TjjXWW) zzXdcPk1<1DIE7v=Oz8ULmws5|TPfN(yAtnP@FL$tf0xT3|M1k9GQ%^flp|zvvduLz zaRhe!h^F6EoyIpOg`%#UWsP_B5iXvAK9y#HW01Zl4|OKubwKWLG#<(?Wh+@dj;+0s zb^AF6cwAqq-f8}YZsbGL_Ycs`XMKx$VJw;lOys?GY}e~wcLNCd2#;zz7Ir}WQOjcm zkw(p2$%Z&0(5@MueH)n@sB}ThPg<%K+9}(n=70;V@$DS_7gl<`R-=p!PVDME4KAdc}5;u?84=O7qS0dyhOe(UiTW}J62faA|D@uY*gT;R-(3Jj=?wG z6Ca(j`zKs6>E|fcfOo)!6lMZwg;9ubIT0v?7}FiobtlQGJf-U`>*=g>0x#MH`#$}Z zlLr4VZyw+ZII7*{E#$2aVRIOXPKO7p-BI^$7=gO{fsJHuLTY*Hr7ugW0X zz&f42?v;%D(SayKhPPVn>%yNQJD4u@p?IZu;gz3hUBysFBe9tJq<1FqR60C&x*Lut z-Jf|8Q`hW??;b34caReV;W=mT6yrD@Z+uVF<9moY71ZEcQ_U#!zr=28gF<^;mWwo? z{C>LKcy&lP6s3$M?15BNhev61vy?rAC9TqBe50*MrS&Ea3vO>Tg?+6M=m`Q=C6ix~ z{!%9KK(G~}@-`3-xgnvtP~yLv2ozGVr=Ez55d--bRlv~kdnMt-KTfT~e}rx!G2xCj zRgd@j>1~RBIu>DUmQ*=`klm=Z1&)0_lUnUa4=d&J;7f!bph^$h8*qI=gdv*!uIPcc zDP&rNC~7qpvF!HLIGynco%dPA=eYqoqLRYs#y7x!y{8%n9s+7m4Vl!TPsoO<V>x zwXQGUBP>dZM8o$6muc6KDJo{|t3NYnqK%=Ju$ZHCd6-OUh+vRoy=Q^%zU1r^w-!;Y z4R`mjtC%CrhF0)Q)l06qCHFGU1+3wD6;KdSnsln%NWipoBYH5Xmruiev20&K>w^qp{%ah@2!m zBBIGiDqCQ1a9muRq4$``&FQM#TS0k5(C^&X1{=obyj2r}ukJ%dh|+z@mn*!w$*cjY z_e2~z1*mNXoFa0y`hd1i+T{!p6kj)Idvlz)PzS4pW?0@vo#3my;r-fVP+s9UGt2qI zQA5}1CcnP;^G5{t^A6HV-1^j3a183hb>DQoERq13E7EZG?JvbhygAyw)s~Dj)3>qr zk1C%ZE!A3Lb#!#h_8{B4y0#_#{W#s!{JW?03DgDFI*N#Bz%!CEe2cGCubhoYU7FG> zn{uuiBH-*-1g}$j1@Vtt{>NnURu@#0-iRj0?Qi&HCzfFT_QkyECNK$YLeZIdj=^ zU&?!U^KMcMiZZ>azHeJQJ2-!vH~v+3-Z8?#p)e~-epbSJx~TG@EGh2rn$+QH)!FR@ z1+F##Li}uIUzH0{FXkisE|%8RHrZBG@NB{ByT;=P-(aZ{HMg#&v#fkwqsy8JFVAji z`8g)#euppjI-}0P=OzS)lSKn5eo(fWSOAj$@s!=kVfD{B#ua#-&>e-#hu(>}cP0(j z&|{3De~cmDysrpzzHdt&jZuRd-M~0>_-nmGcO&s}ssGZHzcp$MRssV4uUEnlcRLEB zw*S%Q|7!htx?3S?d#|^W8J`;;2iqZyM)BM{Zvh9Nhf|UFB}Yobl9_j3e1Wd%YZHOC zVmZTBV(g`*z|M2s@!s8u=ashIkE>yt#VDQO0`7_X0H(6G3oKpMx#QNZSS~*7 z3nSWFBAM+ZgEyZoM}nSs5VOthD!!vu%?EZQTbV4335~^@5Bb&xcWIlyUoCO&jl3vD z*jGAN&T0VMO@YXsH=lE*q*8CxJM{JyAtEQ9c=;n|=`_c4t>pkOm|5Pmk8>XNUqdX-`$NrlO!xoSPxP-_p9v-$ct zNo12N549y1xje*{lvwS&MyO@*fS(}6dwS)=!)--SV}{gD{N>)VDI1f5-Y3j+#EDzo zZ<{-#VS&EYQGrLg+W+cxFvOW(0-QToIhfJDhh6JkZe968vVfJ6_ zLx}UXT*me(#T2;;f%sl5HXS4eM6RQsseV#eE@fH=XUj@w&cIGhEI4IadSBa@bU(9Z z*w#i5Yv|z3BIb|eYy7NaO%2v{dFW0araR?G{#^{c{d$5^Szvcpl#qTpA86tJO4o8# zvsLkmKHQ)dr9ay)2x5t5YMc9@I{grLCiqRZNn|Ed>h6k7&T8%~aLYjk#TT03 zGp5XplmPrvCnLp5O*cyFFz7^P?+zD3nQX44^<#f;b4}&GFpRw9=4%9nIa_;m=~ul-QM|b*a5U7fz!B0;SH8_1o87?#U_NP9_ShrM z1eRIvSrr`>0My>Qz@qL=iy?nj3eFJ9DAioKYD4RZVIk3Ie8m+Ljgbp@Cw-_I27j|? z&4L6$o#gwRz{7x;KMmPvPS7e!vvz9KUS#&^g+FD1h%NOx9`Jj8PopBiT4-}x=cw5r z_e={7dvQKRDJDE1%`gw#(V$<-fZtHH>Bz6)lU$lazcK6a&XF5k2)cOA$Jnk7SfjNZ z^DEl#9v!|(w!@&Cmm90?aZ6Zx6k`9R?oB)96gJ%Y|7o6Ug0S5D>jRVX<%8JQ8D-Uvqww%s*8h`)cW|!2a7si zl+{S1VT~lQ?G3dBN7{g3hDBN4;sFkc^Rtd2Ku^qDp1R3nz2W3a7l)fG4CdUf`O~tz z@k0_E0?p(=Hpo5CMtHsJU!m_g!x z$ECySwatf=%^9d;>LpVoz~}(>#?+Jf`2@`3hbH0bDCd4RKXZa4C6AM@sk;ia%|Dio za`z7ml=(r)Oj0j>jgA#>(BX$tn;z*%6>dvWtN4$HJQm-&Il9Rk+N~*fo^M}cB>8oT_@fhhPV0t;2Sg1bBYk5kgF%7tz6NyDaa`42 z+&~|0vFcx4IcsD9IpL3NKG2T6Bc|eU zkc9j_XEOes$idGjR^>tZ(MuiQ&86n!i*qOmmEBE0p*SCxSz_NC37fWZOt)|Ds3_Wy z<|_MS#jGG#Wi0y)I!f%S_&M~n*k9Qd8QVi5KiP)T3tZ!Ekr`fQIaT6+&rs3(%NUBw zlQP_AvL&_x@LJ#^0tb_!oX^N3-D>ns)I4aX3ixNdGfHN8J7n@Rz-!i#R_ZK{ zsrKH<9y(GF<6oId1`il1IQNCP&DQ_|;tPG`Uc#7kH7S(}IO3 z1JKo4Fj%~L?j)&`;w+9UT-0F7?!}FrAbin-BN0ro3QVX@j{IPH;R74?{aKYx$ z`z@Xia^dW#G(NyQ83n7HqSmfy}5&%BIh1)aCuz9<9rB zc=vVtVR5FxRzEt{2-zhuu(*;Ep=O(#Cj$WFzUEQXH6$eX@K}l z5)esqc%R?L9{CGPXrvi+7Bn0n5!t8jh!6zo>;5I9KH>Rku6e9Td>p*pGrCY*xXJ#| z>EAwg5X)@BlC)|jhXS^~Ipvj+5Ad%;D@&Pfn985Y=rer=hw42N8%X)?*4i^T4)$`R zt4=lrKQ5oV$hNGQY^TWYjJ?3D4)e1Scsk(^Q?$BOKPLrwIOAzCC}%5ZK83LD;k9-CD;Vqo!O@c6Lqhmtq(j$mF_=_tLgOt-qK^$_mT z!U&mv(dqlm`nEk|rhZ?C@bu}PDTPGnHuj#<996A8_j)VU6L%fFo{dM;FS>Y*hke}T zhhakl6OPHe#Btv(-f)J8AD<4XPpZn|SyEp+e~f=aYUPg^^T4Wm5}J-%czG7P0No-g z={i6GQS#zyO}OFa_P-@fPcBQb?O4#!O7vznMlLJO-<-Pb6+tX_zza@HSE_)nZ54#OwYz6YYmY0<`En>X^%*qO zdd9+AuR1jxA#YDKHL0C1Z1;v%&dbx-s#iF}b0KZnAybp4M)Zwy1zM&ro z%~l%AzWfqpxWWpL0qTEwhr=n7Aym(XvakZ0DKcML(H$Mg2B|V?masT|mqWOZCDm7G zT`L8g3r+1Wi6Mklrfuo1LTQ@9^hYF}m2o4y>UJnML(e~dhDV`+Kq8)m2p z^igrFR~tFO_K-ffMq_^zR;xj&nTd0^j4*o@!B6JKiOFFG-j(s z|0pW6eRZ*pka9)>EDDZWU1(lDb+G#R0@JgSI~Ys#;6LiC== z?`{|S*Xg%Qyj-9|;AV;fY;VkY`ogb^pT8s76@485ElW!FdOcH$&)_u-PFo@BGul?C| z#7`)G1QPm62?01MCj2Qi{`++R5t1xma2+mtNL)TU`k103x8~7xn2aojA#)(9u|$|X z%ka-VWoXex7`Y!}G!kX_cf;|#wp-mwB+&_Z;L+LyglN@r_SF8#@vOpCFzso^0oAk@ zXO=7Vi}8w_Grd$=*ky_D*6ZENp}*7RqaPKd#ioC7tCJSFv^OM?=c(N8e7sdXR5%C2 zZJOw*q^eFqZw@7Qf22|LLTJFYt806QK517nkYfTk+1RxHAgE;3-C@)0IE4k5S&OUa zF;IyQ+8&5)VHmd)=66cAt`myVLVPIOl+Ehe0(}QYT#~pXZ5M+55`{c^!M@2%rEuJ$ zRV@KdUL;STyyQoTFEfv$`PHR^FAc_C7MgJxH_M3iA}gA#ziznn(xtwZTb!z^@*G7I z^PC{kL`7aX(c(~8-3HMMAf|4atQF?fl-vl6Dw_$mHIL2baPn+ARSFAERqKQZ0+*r8 zaQ2rE6Fs%<>Q|VpNsx3Yvbr&qrsxb+vP^utI;Sg@#>n8wzBlVjt+G zzUP&^wY8(CGi?=gbd{DlC&A$I-X@tp}!F$ z(bwpF8(G1arT{3kt2?L%h-?_gjG3H!`udrt!a!Y>Di@l5Cggs+Qf#nnx6;kFZEC>oK)$s7dbAznGYU zOG|L~bhEX&ppmS=a&d6k2GUYPb>O-U2tRt{kC#RE_ZJzDu<_beXwW>zqjd~c_NAkx z+cCiXwCkghS2$-c^k(bv7WruAL1LFI2}o9(Bk+hsymJ9p(xFRQ-a_*(9C^Ew35RDz z(wE=G{EpE3lByM;SD@A868c2*e`LpFx+Sn7^S}2gB{aCle`5F5vP$`LoquX0P^Cm! zi$5H&bw!|}Z)h%bxIQ?hHTo7(8iGfa#1LEdZhv>dahzG;_%`U@xH=(C(a(eo=Wj)h zeN$x7d)Oq|4o0UovfKK+^Wj~3`vTHx^UqW4xi+gYnTZ~el_Alaf>9`umU=fn4hm9a z{PZ|1eu=$3t1o>8xdl{lc^_8t%<{>};|j+_7J^SFD^1=-AMy0H$n#prRH5;hrXBM5 z{gsr{@w7XN+zOLXa~rkQtt+Z#cV+Wf}Vn`B`h? za-b@8v~bog-4q(FncQSZ4=`ua`5Mihwk1B}*8`FKGrw#P*zyy$mWu+$A3!OT;t2KxqOK>5T1OyvW%`>(kcqxD z$EUN|PY(%f*-FCum$M(pgIobQA;>*LNgV>0h-yhc?8NGmd&+z=lFM2AM$@pGW5|YY zakWgxOfQ2z@lO`T*6PfaYg>Yks3N|LW7ojc4~*gyRdg)OW16;UJ!BqWfip53QFdm2y#} zxzEy0P#@pwUU=&c>)PYeY~8bda+pv__-!jXVA)1H=?H0QM)9`F?Mm&a)hQE?<7T35 zt}?I&PwX}cbllz1_4bLIAS{K_KE~5I&J6|~QeBSkq2T-CWdJ2u_d={Ag&L8D$@KK4a<1l(x-1BFAPjJ!fM<+9&2iKwA)@MMTLY(ElfY{jd9{t@kyCZXw@n?nO(+- z;gk>i#p&LOpm;Hi73kfn+vGQWnsnr>J%-W|f8h`?Zf`t~v}%LR<7cRCMlABbeU3Hx z7pOiR_F>a`NU2g{M@`Df+4nA)v>^4SHj(*>Z@9yg*NAaW*L7gr)-!v8tq3Zax^b=> zhAA3y;8+&!OrD_oFsE5=-e`b|k}5(h{k7R7@e!Ze#q_0E5?}p_Nspj4OylER z9OC|^f#ZdVgmB4j(s;|9!pfb+mk}VJp$3`F(AVDb=3v8d#{K90@k;93+?Rf~9@Qg3 zweFh-q&4|gSccJ)0lA=%@r9G~_JwiiaT_zsFC@_8))iq+aFcdtf))*a8`FL+*L6%l zORU7ZyHD~}-^ohef{@KEplNt>rUC~_(huLl=Ucd%NEb3_llMpmLOn_(Y?I9ALFi)&j1Gwp2|le=1nDA)?_YGnDdtQO_Aux+oYMZQ=d z!>TbjJonN+T;BzG)vlOc@nG0{9o3EpXzF;{>mM<4+d6gd=#hmdjSMs{uI|hbx68da-UG;=k5ZVb)P0a>`F1e~9-eCw>9o@?SqNfxfp8%*pxEF95c- zR*cXQ+~;1X=KgrMVb0O1-U5A03X6!mB`ZeE$;rvj&sSf){PFyFs{{nd92%bUeiIl> z*Nqt9v4Eq4-`NA(#^Mzk4ZGVCGDxQimEvbGJ=`j6s!r8EDhRi|BtML4Ly3^sgY_T8 z+lD$rFXT4SXvPwzN4m_L`IAMJM67*Xj|6s*+F~C42(Gahw|b5Q+&*WjiM?R2raBX^ zGM=cY9Y&1t(wWm3K^^TR>=T{G?E>q3{=|hZ$1cZ1 zg=E#{R&tmG-glAe{}3apOz9^su+zWqJ*`_lttNb!{_fMrPkyI2&=9eoQFJc8W50Au z<<`cXJ|<9nwnZUbC!Y86%$zG5t6juZo7uR z9MJn?UC_<)b*0N&I~+Mm+(*jj@M1UgACf~`dk*QYTUokehJlwxU(;1qp@Yu| zHhDQ;jL|WFB%RCckv@T_rVK!XrgW)?1iN_N5e1_>;??|sja?3Jq+WdZgEN6hP_wD@ ztN89Ji^Rj15Om%_j+_+dsy~;rPhF(%XuAbEog44wrtZ%N z+ciLjI`l7j_{D6-h_z)AL4SUDYmvo`vG~uDzwiD+mw$Zt{Xa^1Dc-*6e`*?Grqu7O zmN|lE1Lho+fUR*s9#JDo9#eVZu8SPL@nOwTv#z`v2hKN@OC4l055n~3uvYjhsr)p# z_fje^L7Zk(ee(@u;jD-7e*5!YO*Lu572-NxxvX(6vZPN_$IJbbt2k=6Ko3PuTQN9(bqNEl1Gv>O6<*R7at0F->xYg7u~(qyhEyk zJDmVilZzQ(BJP6sh6|IU>DRk5`9k+5;|lpy?RuKR%$t*!E8s6L z1JEfeE{umj1oZ0zk6`g(W8VV-ZnuBCV-8y>k`TM0e;1C7Z_2`)U|7}lyf37S=4-Y% zaz|#d{vitNYgV@F!N2H*2HbB{dOi9UN%(%{m(t6nYJslF2gP3}Pz<|;V?-Dg-`Wsd zP^Oq@zHeiA4GX-GDNZ=;*h?Pu>gpe(SG3#{PV{a5S=kE`dPk5AHeNb%?`BUO3_oox zl&b@uGE+u4J~$g*lr0S7k3+1AKTFDPn{#Dz$Vly#SMm~Y%Yopx zI))}7ZS@#H+NT_fNtH~es(hWGnO%3qA-})&vF_FFbLJ5Ck~b%D`+R`AT{s`C3C+6K z3uV>)B8$0`(nSHj!NN3`RjbwebV{_v82%BdjWY1g)@^*sgEu<4DKZ>}i(o-|fh1i* z{OXwgY_MZKWbhZ6eU{&3CSbw@zCr)U%O_TI)Y36yefeQd)&1Ul-P$vGWmIHZlZWre z-Km0(^j%AkkCQdU(P%VcOs4haZRcqebnxI#O0neM44=lWkZ$<-0!h z954ar9q!?Z!U@7;}CD>S2`4n z_O}2&x4*`&-fN(?gfk#MM&{=1Qv#DtL3+`#0=|oZo~7SXF&(%4QS^C7SEl?~@f2aU zJ2_|t)Z#A8A35C)>8MYZVvGWWl%3joh=3fM+V>xkPPrne@}D-5y*BB|MQKT9ZjltK z?n|_p!_bOYirLJd3^1;{t)A1VR`JQ-_S8?h8_(}f6*fN-VCOAxdh<#lgL?-^h(!4& zbHWQG9hZ?XrL{UUzaomBXAklOgchd>s1YH21J@@r=^bw!?b8^44XIky5YL$tXXdVf z=ravD7tINi_Mu_m{_MPDwz8_5^s(!r`YNE2bf$kSBiY!79GhywMVR*?U5r{KmrgYED zBbT3>;GJadt=p|K5a1hR2+}_*w3AoRb<0LW_-Un%WpboC>Ow2D?@K98_t&8z7A+! zmTPvXY?m49X11S;5gv=FUT#O={q*frY?Cb%3Jb?4-g~;i+WWx%oi@grXWi71wyaOJ zLr%%HG|X<7Lp`jQs`61{tk4iaytST;wRI^|@LkC-?ILxCbD!1PXgqv%2^bT0=%Xn$ znU{LSep~s9K+Vw#1Bs{1YCOMKx4|3kbOYSLQsW65#}l!jjJlw8YZsFe+9+@K2L@V* znkzKz3%uc<75ATCJRgF}w*)V9raM*fA(Sh!B0J@GFuVv;j;F_~Ph?fmI2}9ulIEzz z5IH4;Q!|CHSMvIn-tvNKQ%tZCxnJ8`B{xT*g2m9z!0HubsS=>XK-+AheR0fl6)%=x zm+~9D!)aJy#Hj%}on+3oDSXLc9xBj<5)xpw zb6HiH`^=p7@w}3GvlR0#O|JTLE(a5Y1;T%ZurXG^LS%b*_n0u1wBNb6pDSFmUfZER zyw@ugAN)C@?UTnR>QukbcF8J)2(^);4Y4Vz2r0(Gz6_1Z!365_@)qqBmYy2-)yAh%04&`j*Gw>vplO9aK_ zHbSL)pc?I9&2J!fsfUe>CkBRxj<3XfmEsbd?-R;ExuX{5UdF$XkyrD!0T@epYU~O z@C-Jr;L**P=Kny&4hdT(^Ft)VJ8ODNCFA22l4!k>Z}5W%qHwc$!SxzjP3%hlqhD&U zg5%~dICr9y^xYzv9fcktb+>n&P;I8`{trR{b8nmX(R9iOZE}0&oDY~4VWMY4WR4Pj$kNGl}eew`Ss?v}+^d6;p8=+|1d>@sP zH@<9ofjYPY-30>weX+(&45VM8Lo&E;Gc=mv>e%6=o#5&e#JC zri|pZ){4H;yG67TReu<7R5}{7z@^;q8^lAbH3)v%zc&g1enV6d}Xq7>s)w zfz9CH&#eC@Iose1X#BTX9k^TN@cD)c8=pWP{bNtIxG#fvu80EbG_5-aqCEN#B6r4# zE~@+lUNydNX_5Tpy4OmHTSauYcc`PrqIA04Gs$b;tsZiI+A6d(jyxVR>s7%Lv%vx0 z9d8&YdP8ESf+cy$i3m?^BzO1X!i$qHQnF%}17Jeq0!OJ)-wF7U8M~bolOGE@W+^17 z?ASW5RA}$$j!ntXF!$DqUN{v%hsI}#arO~pw`asgNRHHSja}UIPf~mL-7=|537QkD zK=UKTjwpr54G5)U|U~ugG zp8BvKmkYxqeR8>Nb*??nIaJfr%N@60s*@j`Ct+mJ#zC-Br^<$7oNw)vuM2bxm~*dR zkCSLF*Qj3tZTw!5Z9YX{mguv@$I0A<+oIUqo)&rh_azFVqH2v8l{+DLp0gLtT8s&N z*IUT29Ta2&GMZ59={}jxhhr$p<mV!yC?>o$iRZ45~~gr6`7 zYa11Oq{WG+IV+Bzkuc z@V;fw37mNa{mIx}Gs=i```i*0L1(wK0sGW#)_mzWt8HS<-YG*RAJ1-Uc@F-vzk72s zh5GqH9OmMCPB#7)x675Biz(({i_DgOXs^Oso9(AZlSIlnMD z8(aRokBU0FhY3ucsYC-hLyfy%8s1g~-s>729^G5D{t&zxAXThHG&~E2NDT~85@DDx*u4|8KimwSfCqgk}~ zcKjjIP*>!Lw?9S^uIJtXWXlP=Kzf^@ZBDUnV3;p1yLk?2#2_1;0X`&UiwTrfrU19w zIo><;VoAZE;pq(AE=)Xow?-elUok12k2+VJkIFke*|NZ4cz?Ue*|Mb6k0Ju`EVnsIh-s#h7@bYO%cw9)1DoUl+AhO5F>>D zY7CX3S(M|ZRxtI0gDM|VvTi%i1}k~Fo{nZD8r^(3$zZ11s-BJDUtDjQfGS3A;FTLv zprL!37_1H$ZBH*E_vZJ)AQJUg>)OBAExPh{{b;iiRHB?)V=}q_p9vGM%~Qw5n9r8+ z28L`Ashwf}XP?W;p~vSmrRA0T-Ea=)#n#b8oOLISlVGiH*ep#!>PRmxrLt4)?g(JL zfH3cBDyRGB@Tt-SYtz z#q&zVKe(9Oou_LSA8yzo)Re%34yM@8deR)3KYYC=rlm3#w3ht%5>~q!UzP2uTmQ}f z8C!1HBjvFEq;a?*?y>t!_*CVMCY3}_#>qMVaPcn?cFF1jAz)hbl|AZsAU6-0NVn|P zcHnNnnv%S+=7@?AguRlK##3o0b=h%A)ZYg9n(?Jk*3sgbCa#Toq7;h7ZKfxLLl^J8 z`zbKSe8Y10mQi87iF`wEswhC}rv{!%A&bG8(d^0Fad-d}JQ;1t39oU#e)yZa`_q2eEq z?Cm>3P2)-Zg4`IOVPDk?-4|Uq{wxxdxAOFR&ineSz=Z1z3{a<|)jiLVo z))LiledZt7Vi;RQ(%)>Qsv!hrrlnpd&`w)gWlp9M@-=`vsW4@BYM9s7`la|xO#i(6!JCnAf)?trV7BuE_??YXa%t~j$hWZY<7EF zUf9uYmrSn?mt$SIUDjHJ;1}YPamb8V1!9o%oFm>q)lz&)ThD4l7Vaw^i)UTITMKKD z@W#fl90?=Zx>ot3kFQs!Vh0(zG@0COsoSFAOqNH}>vYIGM2R$!SHFF4ndhayrY?PS zGQLqjgZOu|D|u?~NCq10t1%v|ulWV+Umwtuv#TqFfccp6dromD9}u%0InY!lUt~>b zzTJwur;w}-#k#Kbhm<7YFV`cvIggJonrE-V_`_Jf+e@!JG7ltib`PjB`+V7C+Zkn* zyav5WW$O4MEv|<@#2d9-e%J!Cibew~?>zRgyp#bPS-x07I0+3|g_q^Cv0SBU!0Z+h z0ol}^x-?ufjsW${geI+Z7siNm^N5IUr$FzsjeY@@{9wK+%Yc~(9?xjr| zXn?r5zlYBLYSqIt|0^CP;fkB5mJbL7Is@Hm!s*s>PnZY9*ADT;Q*y02}&*4-AnPXk-1EXytFumZJ43LQG-hpw6(7(<33mBx~Qhd>=bQT z!_%jEj9i}b#lIn`w`o-@O46Tvpn&Y@Bm|7;=8{?dbX13lL&FOHuYT3RCD^pY!K&@e zux0Fk1qe5g^r}Qg$y%|!QQxjt^%J8SgHCt(_4^$s%I~8#N4R!|$}~Q{*F0jg_P>vu zjyU{EiM%9YKJ!>J>a7)>E8XvRS`Uk%$!;&6Pxz6ePlyA3HV|eBA~qbpv$U+fBDIJx zf&n+#!NlP+Mu4jA3k>>h6~G;Gm)k&xVn(7>-s{`(1-!aY9)^clC#r-knsYZ0d#FLq zJI;m3fCAa6fsv;Zm8|`b)%hLHglB^d$r7 zqXv5_t9R9*5JjSaw@#l&g3*N=&k%>k67szk`H@yEgx*#55zyQ`Y~Wz`{W^YuUwotj z*SGekqg>mm3AWroLOy2hW-vUU{&vRZbGcJc$oghz@r6U@aMH-m@;w-R*q%q7(Hhnh zl?}Zofcg~SORmj}6@PkmSH0QCCvSv1bi~e8@g(b`i(VV!gJ~iz0=yB?S36Z1tKg*f z*keG$wZ5;l$sNCwyc-;ik-PyGSCI%safjAvRZ5$#jHb(8E~H#*>q-c#=#*ad&bl8d z)t2$ZkzFM*InD%&o$pIlKQo}Dz2Lu5%s>A+)h^fK4oQP&%5`2=bb$> z@67Wsrl6Y#jy)}f&^|9mc!rHfi#q4}cmcz`x-!5ggEScA`y<=%kx6q^UILSIY~qPf zeSf{HqSY&Rm!=QN^&&q%b=go;!^^G+sr1V4_XR3v4Q_{2T=4`afn)43V*ej=J7wli zj()B2otnu2Qgqgt zA7#>l{2aR~l-&j$QSA2dS2}D{v}1xVl_4IK{7FN{79PA%9Xj3 zWaw>3{U*HR-qkbIH+a|Cyt1x-MD32qUQ2dy*Q!WBfQBT4!$T7fgoS6EaqKzlhgZ#| z#EEbt=cy1?m&Hz8hi|%iVg0)YPoJlL#K?91I2H?f0Gw~JRN>oBc^RZ}!f`KXq2dWL z*;tnGOd4%Y6DILy3qG8?nDea)p}F~>#Co3Kxu!{!XJ zZF~~OO%CmeZ@XHFY0YCGugqAx>)m{HV_bj!sabsIq=e^MR|1Yx;%9gbr9?{Z9iIG3 zjNyEzx|TiN*mQ=H8nFanv0aNzHNJQkNxe#>Nav4%mMHU&9e~eXg+`?`(j)(j2Q!PDn zZq8`qXHZRj{cO)sk#;#NSlriX|K)%ACyywG#_j6P|23ZMJH|g$>tCI_UvC_?yVJXA zd=q8BH@fRM+=z2ieT{XeX!L)J#oh2r6?0l9=<4cXW@gqhB%RnjI%@0b3H#2Xq+{se z+j_e9S`RJmIC)VhRtp3s6^8af;b zsqpa1OQJDq|AZE{vvY8j29xrFN#8(;oWFlQcDV&@#K`DwD}w1|23V?moPTuh?ZFKd)^GTxx~%e0&80_6?< z)D<$&7=^5IZ{6zQ`BCJ?9_D>f7P^HbGM8``Cb{0Osi{FxPo_4;G%h8OPR$}Q;DV8-+vQ2gmC!G z%e6y$^HA

JtaNI%VV4&QeqT6K`_syI4-1#xO~)3hzh+8h>k8Q?wEVHBHnqKGsL> z{+WoKK`kGk+M1=R1nW-^1K<@Q_cN_nEcB4x_@5`!EI`c0mGrRJx9$!}^nz_S$v{mpqZn+exWn z75)vPKzjj#B5>^N&qEhP-SQ>WT49*wfP}*9fRuYubScXGhA-p z9%Kx>EKg~q)qDg0B4l87AC`K_>(oJLHh!fsQRP9vBX5ueJ(~hoIh|4GNDIg8R$f`E zuLYv}QSIc)MfOFk2of%FzBnSHgx6QbR1kXpcu&s2a0Ob~2wQ_s(ughy~FCl?%>0}oCMNZy%7ak~3wIC{dj-#DPJWupzTXBsxL%4!cYxGzKfJ}qsVIBb$I;vF~d zutI;auy3SwO-m;P)gfP{CCi+ijb=^^tZmlaU(t+Y&fOli>F1!?NI-07d7PrW;_z#` zHcnT3bH3u7l2Oitz8k*onGg_6Ci;*!8vc%H^8LIKmV)Ef#=-17rplkSoiIBi99p__ zUs?;onwliYv6rt}ly?W`XKOMF{$OoI&3>;Fo)*Ry{KJzL-dv(Wc?2r>ug?c+HjsqU z4h6{w!G4EuF3FX_Y%2n{>D6(sS1=KY%<07$>7&WmRZHn_pQYt2d;8slDVwgQu|%qN zo@$$x9!0Pvy+5N`SdRE@;eS(C#;eel_hrO-I8E~}WQzXXZDWYEwH2u}hmc##bWQZE zPz%&>nq$D@N!KI7Y|bw*(kI6PiK{Z6p0yH5t-JYJ;6a-L4?wQw<~Qy9Zq2ikz4MDG zFX2)#>-O-u2H7tJlrw$}89YW1TZ1Viwo%jHFGC)a7*dxHJFP|8yczIpE zNRcLcOg9rw)y4r^(Qf|Z4K5ND)-@Klt6j?=Xxo_$&C;)wdhY#06V<#8B4iUQ zw*B6hIEenU$i*Jlqb2WJr(HSC&C3w7j_Q5&N}$9+J3voh>?cH7vWi=$9pCz9+E-yA zk~Cd&Gd`)h;*pqWWhOCKdbX&YIq3)?0rSP@M2j7;%-GuPUwy?ADtL#@EIybUh$a1= znbL18?C<2jN51*(91XE;jaPB%>*6Mqi47Zr;7hE=DM)yX=gjZ5ZP<&u+W@!4 zy@$ev-FBYw2tZ*=;m*@!@y(S6hAb_8RK`v09qqqo(wQufxVO=-?*eJzYwDbmu^ulI zIT*&xQ8kSE^U8W%lw2ysc(LuGQ0AnxXw=$9?7Q+pqgJ3nVzY0@!fO{R_~|{~N7{Ql ztTwNW(CKPa>rUa?D8m)xZY7Pf@`7ozM~>8Jyp{QdXkxBTKADO1WNTE1s^illz;9MH zqfxcmaUX+j>2$@+mS!APR+hbC;f&pvV~=Q*+#;bJ&-V2|uGkhCs8(>fyPYaO!&4hpNN4VL#NT95@=Q8v+)T>dHGZWulR~dgR=ZzfC(AjGZo6ep8_#@T&Y2~H< zgJo;0BOE&d>tN6;3l5QGdTDENMU0fx;3s<+5qf=;6vT`fG)f!Ue+woPVn7*Vr|TBg zoMxqVh93O$!)V#Y%Njt^LwP5V78$Mt%vW;d0ETJ3|3mSD^;8q11)gxc#nh>+dV>-h z(j>+I{E8v|mqG!hJdx)|)az}&;37J}*~e`G4g#8vf!$D}JmnY?{*fzVmJQOa#xUK{ zx&(gd4*9{uqw~~&J*x3=GE5K0^nA&UMb}q>0o;N%x^_8 zvw#PG|ExL1o<>m|=r5SjNH+h`?OV&cZjYON0r!H7NTxL20E%1$4j%t_6$`Ut$UuT< zV}NND1Z-a3#ZY0*$cEXlIbVz$=RTK2GK_rcFA6ZD@E?q0Il0jDE3zh55R%g(Cf$`T z#XHT|$n0!>^E{*YucTe;7Y1b zQktj_);||a!=3img`dcWF$ZysF`d(wPb8MaxXW_&Qja95Q+m~&y1W+laRJ}V)t%#$s87IJo){zaLKMF$;eNSnK88=&5 zB^@RK>Eg1pCx{;V*je4fBcq{Fe!UU0S!L?9mvGs-AE*9rm1$8Ay1w$CsM!@%=e1#V zeMO}IGt0i#D!fcddvgk~;TZvL=W|q?zNAkN-hREIS!g+RY@2QfCQ0cFG!_|RuA{fL zrr2+D=ll)oC}VeiIyi4$@cX-B!zGIvut3J%o(~Q;CNOkN-|PXD#M?t!Xu8P^v%g2D zcX!dlSZw$Co_yd@&NfJb0lDJ{K+;~E)-V$UPo z1L70}ihTkvXJ5=W5Gtq3YNvjek=G#&YBX#c3DRA#?Dw?Gt1)2Q>b9HZo0ne27W#Y^ z|BHBRA>klNEA9@RUahTgDn=t~(4<^dZYww}!J%wb!M)i2uz5-{qIHpRHo8w`-ZW2e zW`-vbqa@mgBf<|6GpR0;gry;oXOttM64W+F0So38?kE+=7x$mi&tQaU_9m6-n|OvS5mUWr0_CfKhyKRIu}ePEiogWLF|ilv};4W23V z7`_>p@SBVTqTBO>NkPPM@xDT2>QhlmY_+jca+sO+hC%rBWf=z7$hx?%m~KLujXRAs zO`Tv#T)B4#n!X;)%4UKeMeG%I;62OcgOV4r(P~k8w{$QIvJa+g@@?YPhHgK~ze!!n zC>#3q8XX_D4_D%$C!AP(6hz|PLdgU@-om%zVSy7pcv0nw%=%}}LPjc5ir**5?4qC< zmF$o)`!D-{gFRM1Yi=T2Lhaciw0W_Z7pVcoJFnTH5GkU|@?1<4D zt~UYNMVebcTas&(^edD;FX=%in~hj1ysftH@RxnM2(3A*iNTU14odZ*^q+PQab{0 zTy=)OO#cr;E;Q>GvNb?ge`8ZuAH4wj;>`#w>&eo(bC$)N{EYi&ZZ_Vj60@8=)E9Ec zhpyz4QJ~ADQnRv~+?GvGC%jW}DZguYoO~dlzml6Wr~5-8RuFh=>8G*L1z|m4Ybrsf z!15B@=TSwkI*(kU5f~=Z5_JN0857>GPF*@gu;u}4uF2k7dUW z^4+%z5zl5Vo`T~A`3L}$gxl?FY6il+xK%xtmo=zI7DXOK0;3HTXG@TazvSaF zn1tw_`r^WlUm)je8Xxa}#gUx9D!}c|3~AxYYK%`bTk!pR6cS3@l+5QOCCb!p>PJ;X~yPb#ZU)3!|8z6jv>zz*@FKTdBkA1~2H*w4W{6&q~Y_-Y?%NfQt= zrEt~TiCS1>AB1^Q68h=$QB7ofl_v74P3*fK7)Uf9(}w6(JfT!=94SO8)oyZ?6?*{o zt1tlGMb+=o7*T+F!{2T+OmtHeA6-lLiR*xQyBt~2NyPLuDD;ydH z0+~prw-=j+3v(a8P3y!DaXv%fg%k0w{7u1H70i~B-g5Y5=j=`MCei<>S5?YAkC@x) z0=1^^FBIhyiLN=j%zw{fZ`1kSY3A3_7yOFC`3Ue)yaXTm?RB+57>&Me1-Z9@($lml zgXJStm-_{y!Fbg|ixFz=gPns-nGf{oJ;^iTu>a^c5HSIMC%7^1AC>KZx4w6ko9cC3az1yzhtVcHsr3U*p1zTEG4~^#0#k_Jq-plLV8IJ9|In#Vt0*J1jkSAq@_@-n3fw^#BOcDHaio5jG zYNit3z^-F$kWZMrK0U=5G;PW+;N-{Dljzb_XU`QJTYx7Cu6kU)nhem)(wUa(vy&VQ72fp%HH-tip)bJB7hzz z+kHd7l-8!}g#kQzC3a46M;l|qwN4f`RkM!A8tylzP8Dx}@AE>4-SVU?0fkE9i!1i@9J_6n68u29s<(eGD?HwH0JCAtu>ne5yOJJjYQvnIp4~KBa39?$b zyKC9^pw}}#V%I1WEMf1ADc}^ns zj(Nf(;1^L;u~ZZ49>CVt?~fdj%GpY!TL_lkel%t#ek6yBx=wp9?0 z#c*|TVPRnSGFfSDB9OG1|BFB@ByTYOgLeNfiAcuSJMI(-N$sm= zn0GhVG0gtidDJhZ1d6NQcV-cM89>hLdxNJ2g38}sf}Gs7I?X`8;cdH260PgE)*3<< zc=hVe&m?CX4!L@@Wmmv|{QOX0%J2?@Hc2#$_5as`-ePIpneZaT9^Oqk?a<00Q)UN& zy3Mi6SVtXt*4ffty2nRoGynRq0PptzkC8}E`KI-qk#e?37iM3VI`}9KM1gCjTTX5 zkURPm8r9agF#0`)FA6k!k^KB0tIj4cWA-IfEllleuRGzlbmNxFs82rhBTFa`hCGd3 z6$b2ngqe>J70Da9QEy)u0RRWOi*$*IEJ@v=Ox0)bzb%%M>$0wf95l2|A`QMWK%M=` zbR^^0vS5jalfiUaOH$$c+G2h08;i;+Qh~WeuhaA{f11BrR6$#+QCOu#_I{Cp|773! z?s+u}OaAB7>tlU+aDkeOAwAa16n}^SI~*ZeYPle&~sTPiEkJP$}tA!l1v8kKy;g~d%o3!#bg6jOm@Dk9olI{;Y!Z3 zFq}+o-BJC;V@(gNagW$Gr{e+h*WmS&Cvw9(E2Ifp&YbIJjfr$YvtiZ~o`;m;*Tb=r zyedWpKGb1g9PjkASi57I#oc2|kuQ% zw%15nw_2U=utWXMs&|9E!YWn`XW~ZB7bqA#mDAKD%qof_vP(ZqDA{NRISNjo?{k z!i>|TqREM4&5N2*Ror=~8@92(@fCJ@kt2(Te%V%ZfS-X!ZBQ>dLqi-J#R4s8nmF!p zR*t_o1CXNxe8#1@Bc)Ol!%I@>ZU1bPp6jm_N$fSy31GXhdhg*j1Ib9u`c#w9ca+J5 zy7hBjxiNU(APE5W)(=UbzF;yzDirE4jrGUK@4;WOgH8XVGavYaNIiG0AXb7W(^zVF z&kPHhXc@UiHeIB_7YTS_c+ucl#eh~+Ho2v=n=&Vq7Oq!|Nh~o$IJJ_0X6G8sj%f-( zG{3S~Jsg*P?pki-SkyPb(5I+Fi@0u985Eu|xMLD*sgG+%7zNH;`>}|a)6HI|y~3c> z2AsBe65T)qN1HEU_I^0p%QliN>hkk%3+%%+V)@)dFmrk$Q1#o?xGGTue8|`jESAEp z^@{PNTAlWH-{^nz*GSX>zmM`bTG~7&q~ET%Wi5ssDp*E^(ih*In>AdR7m{i_*KMX} zaZQqbKkE_FAJb1pA{hxv-%#5MeHGIrB%o?xwD+(nUu$wo-a|)>!Vn_yuqeg;=%7WE zDbAT12Rku;LF4ERwjf0ql`t4ogoK-x8u8l%bbWSuERjb((+TfL-~f<{m}F%4DK34G z$EB7Zv*?TaTVifpbLKR^{`Ttp_|Ea;I8|egH7-&gKamf0w>osjzkXa0l7OvfPl(+dcv#dm;8DCA!aH%wZ~tZ+vb5id18g z&HXROmsnZ88~owW*ksyX{Q`=^2=sOB0cae{A{zyCcTi%^B-=MqNad!8A4y~HM}a?#IvGP3=AxF zMLC%d7Xf;isS3|XWFB{Q^;gv=P@y9wEfe|tLzEy3KM5WC>C7P?DWvpqhmTh~ly%%w zm9YiL#`rAHd7coj%DdF>5E@pe()K1*eR)4&Bwp?RXQ!xrZ`&5B-Cv|%n?tY)`ceaF z=&Al)hT*>{7&K9eelFCc_lqO${Ye?`^n*actnh!_?~6KfEVn@RJ1TJp;@;HSl@+Lsq@O~ z^qn*hbjn}sS_)@c^eZu>Cb)|?`D8hq8LXqm=;xp7jo2_fM1Lh#xMwK-!i-NNa)AyZ zO7Ka`KQmaSV3)R|05pmEIQxh#SyDQqE28>9xtq4{4B#R`xo-F{R zJIY+p!tA;n|Ia7hFsIxC5f5cQLHCLDgkGfVdqijnNrBLl7Vpvbw1`!InAA^8xVo`Q z_Dqf4NudWLX@u+GM__YQZ#+A+vXt(GIO0@^4RYu3lDmEeal;yqf1V;U4@dIv>poFS zZ~nGmp?b$Q2Y0%hAJAaljvQ_tNLWbK)IYBHUtGRR;w2-%25&Rf0>;fp@s+Av(Mu!0 F{{hp np.ndarray: """Run OLS regression and return significance flags. @@ -40,9 +39,15 @@ def generate_y( heterogeneity: float, heteroskedasticity: float, seed: int, + residual_dist: int = 0, + residual_df: float = 10.0, ) -> np.ndarray: """Generate the dependent variable ``y = X @ effects + error``. + Args: + residual_dist: Error distribution (0=normal, 1=heavy_tailed, 2=skewed). + residual_df: Degrees of freedom for non-normal residuals. + Returns: 1-D array of length ``n_samples``. """ @@ -88,12 +93,8 @@ def lme_analysis( ... -# Valid backend names for set_backend() -_BACKEND_NAMES = {"default", "c++"} - # Global backend instance _backend_instance: Optional[ComputeBackend] = None -_backend_forced = False def get_backend() -> ComputeBackend: @@ -101,7 +102,7 @@ def get_backend() -> ComputeBackend: Get the active compute backend. On first call, instantiates the C++ native backend. - Subsequent calls return the cached instance unless reset_backend() is called. + Subsequent calls return the cached instance. Raises: ImportError: If the C++ extension is not compiled/installed. @@ -117,64 +118,7 @@ def get_backend() -> ComputeBackend: return _backend_instance -def set_backend(backend: Union[str, ComputeBackend]) -> None: - """ - Set the compute backend. - - Args: - backend: One of: - - 'default' -- use native C++ backend - - 'c++' -- force native C++ backend - - A ComputeBackend instance - - Raises: - ImportError: If the C++ backend is not available. - ValueError: If the string is not recognized. - """ - global _backend_instance, _backend_forced - - if isinstance(backend, str): - name = backend.lower().strip() - if name not in _BACKEND_NAMES: - raise ValueError(f"Unknown backend {backend!r}. Choose from: {', '.join(sorted(_BACKEND_NAMES))}") - - from .native import NativeBackend - - _backend_instance = NativeBackend() - _backend_forced = name != "default" - else: - _backend_instance = backend - _backend_forced = True - - -def reset_backend() -> None: - """Reset backend to automatic selection.""" - global _backend_instance, _backend_forced - _backend_instance = None - _backend_forced = False - - -def get_backend_info() -> dict: - """ - Get information about the current backend. - - Returns: - Dictionary with backend name, type, and whether it was forced. - """ - backend = get_backend() - name = type(backend).__name__ - return { - "name": name, - "is_native": name == "NativeBackend", - "module": type(backend).__module__, - "forced": _backend_forced, - } - - __all__ = [ "ComputeBackend", "get_backend", - "set_backend", - "reset_backend", - "get_backend_info", ] diff --git a/mcpower/backends/native.py b/mcpower/backends/native.py index acd7633..2338cfe 100644 --- a/mcpower/backends/native.py +++ b/mcpower/backends/native.py @@ -17,6 +17,11 @@ mcpower_native = None +def _prep(arr: np.ndarray, dtype=np.float64) -> np.ndarray: + """Ensure array is contiguous with the expected dtype for C++ interop.""" + return np.ascontiguousarray(arr, dtype=dtype) + + class NativeBackend: """ C++ compute backend using pybind11 bindings. @@ -46,8 +51,8 @@ def _initialize_tables(self) -> None: t3_ppf = manager.load_t3_ppf_table() # Ensure correct dtypes - norm_cdf = np.ascontiguousarray(norm_cdf.astype(np.float64)) - t3_ppf = np.ascontiguousarray(t3_ppf.astype(np.float64)) + norm_cdf = _prep(norm_cdf) + t3_ppf = _prep(t3_ppf) # Initialize C++ tables (generation tables only) mcpower_native.init_tables(norm_cdf, t3_ppf) @@ -77,10 +82,10 @@ def ols_analysis( Returns: Array: [f_sig, uncorrected..., corrected...] """ - X = np.ascontiguousarray(X, dtype=np.float64) - y = np.ascontiguousarray(y, dtype=np.float64) - target_indices = np.ascontiguousarray(target_indices, dtype=np.int32) - correction_t_crits = np.ascontiguousarray(correction_t_crits, dtype=np.float64) + X = _prep(X) + y = _prep(y) + target_indices = _prep(target_indices, np.int32) + correction_t_crits = _prep(correction_t_crits) return mcpower_native.ols_analysis(X, y, target_indices, f_crit, t_crit, correction_t_crits, correction_method) # type: ignore[no-any-return] @@ -91,6 +96,8 @@ def generate_y( heterogeneity: float, heteroskedasticity: float, seed: int, + residual_dist: int = 0, + residual_df: float = 10.0, ) -> np.ndarray: """ Generate dependent variable. @@ -101,14 +108,16 @@ def generate_y( heterogeneity: Effect size variation SD heteroskedasticity: Error-predictor correlation seed: Random seed (-1 for random) + residual_dist: Error distribution (0=normal, 1=heavy_tailed, 2=skewed) + residual_df: Degrees of freedom for non-normal residuals Returns: Response vector (n_samples,) """ - X = np.ascontiguousarray(X, dtype=np.float64) - effects = np.ascontiguousarray(effects, dtype=np.float64) + X = _prep(X) + effects = _prep(effects) - return mcpower_native.generate_y(X, effects, heterogeneity, heteroskedasticity, seed) # type: ignore[no-any-return] + return mcpower_native.generate_y(X, effects, heterogeneity, heteroskedasticity, seed, residual_dist, residual_df) # type: ignore[no-any-return] def generate_X( self, @@ -137,11 +146,11 @@ def generate_X( Returns: Design matrix (n_samples, n_vars) """ - correlation_matrix = np.ascontiguousarray(correlation_matrix, dtype=np.float64) - var_types = np.ascontiguousarray(var_types, dtype=np.int32) - var_params = np.ascontiguousarray(var_params, dtype=np.float64) - upload_normal = np.ascontiguousarray(upload_normal, dtype=np.float64) - upload_data = np.ascontiguousarray(upload_data, dtype=np.float64) + correlation_matrix = _prep(correlation_matrix) + var_types = _prep(var_types, np.int32) + var_params = _prep(var_params) + upload_normal = _prep(upload_normal) + upload_data = _prep(upload_data) return mcpower_native.generate_X( # type: ignore[no-any-return] n_samples, @@ -185,12 +194,15 @@ def lme_analysis( Returns: Array: [f_sig, uncorrected..., corrected..., wald_flag] or empty array on failure + + wald_flag: 1.0 if the Wald test was used as fallback for the overall + significance test (instead of the likelihood ratio test), 0.0 otherwise. """ - X = np.ascontiguousarray(X, dtype=np.float64) - y = np.ascontiguousarray(y, dtype=np.float64) - cluster_ids = np.ascontiguousarray(cluster_ids, dtype=np.int32) - target_indices = np.ascontiguousarray(target_indices, dtype=np.int32) - correction_z_crits = np.ascontiguousarray(correction_z_crits, dtype=np.float64) + X = _prep(X) + y = _prep(y) + cluster_ids = _prep(cluster_ids, np.int32) + target_indices = _prep(target_indices, np.int32) + correction_z_crits = _prep(correction_z_crits) return mcpower_native.lme_analysis( # type: ignore[no-any-return] X, @@ -240,14 +252,17 @@ def lme_analysis_general( Returns: Array: [f_sig, uncorrected..., corrected..., wald_flag] or empty array on failure + + wald_flag: 1.0 if the Wald test was used as fallback for the overall + significance test (instead of the likelihood ratio test), 0.0 otherwise. """ - X = np.ascontiguousarray(X, dtype=np.float64) - y = np.ascontiguousarray(y, dtype=np.float64) - Z = np.ascontiguousarray(Z, dtype=np.float64) - cluster_ids = np.ascontiguousarray(cluster_ids, dtype=np.int32) - target_indices = np.ascontiguousarray(target_indices, dtype=np.int32) - correction_z_crits = np.ascontiguousarray(correction_z_crits, dtype=np.float64) - warm_theta = np.ascontiguousarray(warm_theta, dtype=np.float64) + X = _prep(X) + y = _prep(y) + Z = _prep(Z) + cluster_ids = _prep(cluster_ids, np.int32) + target_indices = _prep(target_indices, np.int32) + correction_z_crits = _prep(correction_z_crits) + warm_theta = _prep(warm_theta) return mcpower_native.lme_analysis_general( # type: ignore[no-any-return] X, @@ -301,15 +316,18 @@ def lme_analysis_nested( Returns: Array: [f_sig, uncorrected..., corrected..., wald_flag] or empty array on failure + + wald_flag: 1.0 if the Wald test was used as fallback for the overall + significance test (instead of the likelihood ratio test), 0.0 otherwise. """ - X = np.ascontiguousarray(X, dtype=np.float64) - y = np.ascontiguousarray(y, dtype=np.float64) - parent_ids = np.ascontiguousarray(parent_ids, dtype=np.int32) - child_ids = np.ascontiguousarray(child_ids, dtype=np.int32) - child_to_parent = np.ascontiguousarray(child_to_parent, dtype=np.int32) - target_indices = np.ascontiguousarray(target_indices, dtype=np.int32) - correction_z_crits = np.ascontiguousarray(correction_z_crits, dtype=np.float64) - warm_theta = np.ascontiguousarray(warm_theta, dtype=np.float64) + X = _prep(X) + y = _prep(y) + parent_ids = _prep(parent_ids, np.int32) + child_ids = _prep(child_ids, np.int32) + child_to_parent = _prep(child_to_parent, np.int32) + target_indices = _prep(target_indices, np.int32) + correction_z_crits = _prep(correction_z_crits) + warm_theta = _prep(warm_theta) return mcpower_native.lme_analysis_nested( # type: ignore[no-any-return] X, diff --git a/mcpower/core/results.py b/mcpower/core/results.py index 45b178a..dfe6b77 100644 --- a/mcpower/core/results.py +++ b/mcpower/core/results.py @@ -54,6 +54,7 @@ def calculate_powers( # Individual powers individual_powers = {} individual_powers_corrected = {} + non_overall_tests = [t for t in target_tests if t != "overall"] for test in target_tests: if test == "overall": @@ -62,7 +63,6 @@ def calculate_powers( individual_powers_corrected[test] = np.mean(results_corrected_array[:, 0]) * 100 else: # Find position among non-'overall' tests and add 1 for F-test offset - non_overall_tests = [t for t in target_tests if t != "overall"] pos = non_overall_tests.index(test) col_idx = pos + 1 # +1 because column 0 is F-test individual_powers[test] = np.mean(results_array[:, col_idx]) * 100 diff --git a/mcpower/core/scenarios.py b/mcpower/core/scenarios.py index 454f8e3..2d2dd01 100644 --- a/mcpower/core/scenarios.py +++ b/mcpower/core/scenarios.py @@ -13,35 +13,51 @@ from ..utils.visualization import _create_power_plot # Default scenario configurations. +# "optimistic" is the zero-perturbation baseline — also used as the default +# scenario_config when scenarios=False and as a template for custom scenarios +# (ensures all required keys exist). # "realistic" introduces moderate assumption violations; "doomer" introduces # severe violations. Each simulation iteration draws random perturbations # from these parameters (correlation noise, distribution swaps, etc.). DEFAULT_SCENARIO_CONFIG = { + "optimistic": { + "heterogeneity": 0.0, + "heteroskedasticity": 0.0, + "correlation_noise_sd": 0.0, + "distribution_change_prob": 0.0, + "new_distributions": ["right_skewed", "left_skewed", "uniform"], + # Mixed model perturbations (only consumed when cluster_specs present) + "random_effect_dist": "normal", + "random_effect_df": 5, + "icc_noise_sd": 0.0, + # Residual distribution perturbations (all model types) + "residual_dists": ["heavy_tailed", "skewed"], + "residual_change_prob": 0.0, + "residual_df": 10, + }, "realistic": { "heterogeneity": 0.2, - "heteroskedasticity": 0.1, - "correlation_noise_sd": 0.2, - "distribution_change_prob": 0.3, + "heteroskedasticity": 0.15, + "correlation_noise_sd": 0.15, + "distribution_change_prob": 0.5, "new_distributions": ["right_skewed", "left_skewed", "uniform"], - # LME-specific keys (only consumed when cluster_specs present) "random_effect_dist": "heavy_tailed", - "random_effect_df": 5, + "random_effect_df": 10, "icc_noise_sd": 0.15, - "residual_dist": "heavy_tailed", - "residual_change_prob": 0.3, - "residual_df": 10, + "residual_dists": ["heavy_tailed", "skewed"], + "residual_change_prob": 0.5, + "residual_df": 8, }, "doomer": { "heterogeneity": 0.4, - "heteroskedasticity": 0.2, - "correlation_noise_sd": 0.4, - "distribution_change_prob": 0.6, + "heteroskedasticity": 0.35, + "correlation_noise_sd": 0.30, + "distribution_change_prob": 0.8, "new_distributions": ["right_skewed", "left_skewed", "uniform"], - # LME-specific keys (only consumed when cluster_specs present) "random_effect_dist": "heavy_tailed", - "random_effect_df": 3, + "random_effect_df": 5, "icc_noise_sd": 0.30, - "residual_dist": "heavy_tailed", + "residual_dists": ["heavy_tailed", "skewed"], "residual_change_prob": 0.8, "residual_df": 5, }, @@ -111,15 +127,7 @@ def run_power_analysis( if progress is not None: progress.start() - # Optimistic (user's original settings) - results["optimistic"] = run_find_power_func( - sample_size=sample_size, - target_tests=target_tests, - correction=correction, - scenario_config=None, - ) - - # Realistic & Doomer scenarios + # Run all scenarios (optimistic is always present as zero-perturbation baseline) for scenario_name, config in self.configs.items(): results[scenario_name] = run_find_power_func( sample_size=sample_size, @@ -175,15 +183,7 @@ def run_sample_size_analysis( if progress is not None: progress.start() - # Optimistic - results["optimistic"] = run_sample_size_func( - sample_sizes=sample_sizes, - target_tests=target_tests, - correction=correction, - scenario_config=None, - ) - - # Other scenarios + # Run all scenarios (optimistic is always present as zero-perturbation baseline) for scenario_name, config in self.configs.items(): results[scenario_name] = run_sample_size_func( sample_sizes=sample_sizes, @@ -209,8 +209,9 @@ def run_sample_size_analysis( def _create_scenario_plots(self, results: Dict) -> None: """Create visualizations for scenario analysis.""" scenarios = results["scenarios"] - scenario_names = ["optimistic", "realistic", "doomer"] - scenario_labels = ["Optimistic", "Realistic", "Doomer"] + # Derive scenario order from results: optimistic first, then config keys + scenario_names = ["optimistic"] + [k for k in scenarios if k != "optimistic"] + scenario_labels = [name.title() for name in scenario_names] first_scenario = scenarios.get("optimistic", {}) if "results" not in first_scenario or "sample_sizes_tested" not in first_scenario["results"]: @@ -286,7 +287,7 @@ def apply_lme_perturbations( if icc_noise_sd == 0.0 and re_dist == "normal": return None - rng = np.random.RandomState(sim_seed + 5000 if sim_seed is not None else None) + rng = np.random.RandomState(sim_seed + 6 if sim_seed is not None else None) # ICC jitter: multiplicative noise on tau_squared per grouping variable tau_squared_multipliers: Dict[str, float] = {} @@ -304,70 +305,6 @@ def apply_lme_perturbations( } -def apply_lme_residual_perturbations( - y: np.ndarray, - scenario_config: Dict, - sim_seed: Optional[int], -) -> np.ndarray: - """Replace normal residuals with non-normal if coin flip succeeds. - - For each simulation, independently flips a coin (probability - ``residual_change_prob``) to decide whether residuals are replaced. - If activated, reproduces the original N(0,1) errors via the known - seed, generates replacements from t(df) or shifted χ², and applies - the correction ``y += (new_error - original_error)``. - - Args: - y: Dependent variable array (modified in-place). - scenario_config: Scenario parameters with residual keys. - sim_seed: Random seed for reproducibility. - - Returns: - The (possibly modified) dependent variable array. - """ - residual_dist = scenario_config.get("residual_dist", "normal") - residual_change_prob = scenario_config.get("residual_change_prob", 0.0) - residual_df = scenario_config.get("residual_df", 10) - - if residual_dist == "normal" or residual_change_prob <= 0.0: - return y - - rng = np.random.RandomState(sim_seed + 6000 if sim_seed is not None else None) - - # Coin flip: should this simulation have non-normal residuals? - if rng.random() > residual_change_prob: - return y - - n = len(y) - - # Reproduce the original N(0,1) errors using the same seed as generate_y - # generate_y uses sim_seed + 2 for error generation - original_rng = np.random.RandomState(sim_seed + 2 if sim_seed is not None else None) - original_errors = original_rng.standard_normal(n) - - # Generate replacement errors - replacement_rng = np.random.RandomState(sim_seed + 6001 if sim_seed is not None else None) - - if residual_dist == "heavy_tailed": - # t(df) scaled to have variance 1 - df = max(residual_df, 3) - raw = replacement_rng.standard_t(df, size=n) - # t(df) has variance df/(df-2), scale to unit variance - scale = 1.0 / np.sqrt(df / (df - 2)) - new_errors = raw * scale - elif residual_dist == "skewed": - # Shifted chi-squared: mean=0, variance=1 - df = max(residual_df, 3) - raw = replacement_rng.chisquare(df, size=n) - new_errors = (raw - df) / np.sqrt(2 * df) - else: - return y - - # Apply correction: swap out original errors for new ones - y = y + (new_errors - original_errors) - return y - - def apply_per_simulation_perturbations( correlation_matrix: np.ndarray, var_types: np.ndarray, @@ -393,19 +330,22 @@ def apply_per_simulation_perturbations( if scenario_config is None: return correlation_matrix, var_types - rng = np.random.RandomState(sim_seed) + rng = np.random.RandomState(sim_seed + 5 if sim_seed is not None else None) # Perturb correlation matrix perturbed_corr = correlation_matrix - if correlation_matrix is not None and scenario_config["correlation_noise_sd"] > 0: + if correlation_matrix is not None and scenario_config.get("correlation_noise_sd", 0) > 0: perturbed_corr = correlation_matrix.copy() noise = rng.normal(0, scenario_config["correlation_noise_sd"], correlation_matrix.shape) noise = (noise + noise.T) / 2 # Keep symmetric perturbed_corr += noise + # Clip off-diagonal correlations to [-0.8, 0.8] to prevent near-singular + # matrices that cause Cholesky decomposition failures in data generation. perturbed_corr = np.clip(perturbed_corr, -0.8, 0.8) np.fill_diagonal(perturbed_corr, 1.0) - # Ensure positive semi-definiteness via eigenvalue clipping + # Nearest correlation matrix repair via spectral clipping: set negative + # eigenvalues to zero and reconstruct, then re-normalize to unit diagonal. eigvals, eigvecs = np.linalg.eigh(perturbed_corr) if np.any(eigvals < 0): eigvals = np.maximum(eigvals, 0.0) @@ -417,7 +357,7 @@ def apply_per_simulation_perturbations( # Perturb variable types perturbed_var_types = var_types.copy() - if scenario_config["distribution_change_prob"] > 0: + if scenario_config.get("distribution_change_prob", 0) > 0: type_mapping = {"right_skewed": 2, "left_skewed": 3, "uniform": 5} new_type_codes = [type_mapping[distribution] for distribution in scenario_config["new_distributions"]] diff --git a/mcpower/core/simulation.py b/mcpower/core/simulation.py index 266a39e..2223324 100644 --- a/mcpower/core/simulation.py +++ b/mcpower/core/simulation.py @@ -61,7 +61,7 @@ def __init__( Args: n_simulations: Number of Monte Carlo iterations. seed: Base random seed. Each iteration uses - ``seed + 4 * sim_id``. + ``seed + 12 * sim_id``. alpha: Significance level for hypothesis tests. parallel: Parallel processing mode (unused inside the runner itself; parallelism is handled at the @@ -143,12 +143,19 @@ def run_power_simulations( if metadata.cluster_specs: from ..stats.lme_solver import compute_lme_critical_values - n_fixed = len(metadata.target_indices) - # n_fixed_effects = number of columns in X_expanded (excluding intercept) - # This equals the total effect count minus cluster effects - n_fixed_total = len(metadata.effect_sizes) - if metadata.cluster_effect_indices: - n_fixed_total -= len(metadata.cluster_effect_indices) + # Use test formula dimensions when subsetting with random effects + if metadata.test_column_indices is not None and metadata.test_has_random_effects: + if metadata.test_target_indices is None: + raise RuntimeError("test_target_indices must be set when test_column_indices is present") + n_fixed = len(metadata.test_target_indices) + n_fixed_total = metadata.test_effect_count + else: + n_fixed = len(metadata.target_indices) + # n_fixed_effects = number of columns in X_expanded (excluding intercept) + # This equals the total effect count minus cluster effects + n_fixed_total = len(metadata.effect_sizes) + if metadata.cluster_effect_indices: + n_fixed_total -= len(metadata.cluster_effect_indices) chi2_crit, z_crit, correction_z_crits = compute_lme_critical_values( self.alpha, n_fixed_total, n_fixed, metadata.correction_method ) @@ -162,19 +169,18 @@ def run_power_simulations( raise SimulationCancelled("Simulation cancelled by user") - sim_seed = self.seed + 4 * sim_id if self.seed is not None else None - - # Apply perturbations if in scenario mode - if scenario_config is not None and apply_perturbations_func is not None: - perturbed_corr, perturbed_types = apply_perturbations_func( - metadata.correlation_matrix, - metadata.var_types, - scenario_config, - sim_seed, - ) - else: - perturbed_corr = metadata.correlation_matrix - perturbed_types = metadata.var_types + sim_seed = self.seed + 12 * sim_id if self.seed is not None else None + + # Apply per-simulation perturbations (correlation noise, distribution swaps) + # Zero-valued params in optimistic scenario are no-ops + if apply_perturbations_func is None: + raise RuntimeError("apply_perturbations_func must be provided") + perturbed_corr, perturbed_types = apply_perturbations_func( + metadata.correlation_matrix, + metadata.var_types, + scenario_config, + sim_seed, + ) result = self._single_simulation( sim_id=sim_id, @@ -326,7 +332,9 @@ def _single_simulation( first_spec = next(iter(metadata.cluster_specs.values())) sample_size = first_spec["n_clusters"] * first_spec["cluster_size"] - # Check if strict mode with uploaded data + # Strict-mode bootstrap: resample whole rows from uploaded data to + # preserve exact inter-variable relationships, then generate y from + # the bootstrapped X. This bypasses the normal X-generation pipeline. if metadata.preserve_correlation == "strict" and metadata.uploaded_raw_data is not None: # Strict mode: bootstrap uploaded data + generate created variables separately from ..stats.data_generation import bootstrap_uploaded_data @@ -336,7 +344,7 @@ def _single_simulation( sample_size, metadata.uploaded_raw_data, metadata.uploaded_var_metadata, - sim_seed, + sim_seed + 3 if sim_seed is not None else None, ) # Merge uploaded and created non-factor variables @@ -367,7 +375,7 @@ def _single_simulation( X_factors = X_uploaded_factors else: # Mixed: generate all factors, replace uploaded factor columns - X_factors = _generate_factors(sample_size, metadata.factor_specs, sim_seed) + X_factors = _generate_factors(sample_size, metadata.factor_specs, sim_seed + 3 if sim_seed is not None else None) # Overwrite uploaded factor dummy columns with bootstrapped data if X_uploaded_factors.shape[1] > 0: col_offset = 0 @@ -400,14 +408,14 @@ def _single_simulation( X_non_factors = np.empty((sample_size, 0), dtype=float) # Generate factor variables (as dummy variables) - X_factors = _generate_factors(sample_size, metadata.factor_specs, sim_seed) + X_factors = _generate_factors(sample_size, metadata.factor_specs, sim_seed + 3 if sim_seed is not None else None) # Compute LME perturbations (ICC jitter, non-normal RE dist) lme_perturbations = None - if metadata.cluster_specs and scenario_config is not None: + if metadata.cluster_specs: from ..core.scenarios import apply_lme_perturbations - lme_perturbations = apply_lme_perturbations(metadata.cluster_specs, scenario_config, sim_seed) + lme_perturbations = apply_lme_perturbations(metadata.cluster_specs, scenario_config or {}, sim_seed) # Generate cluster random effects (independent of upload mode) re_result = None # Phase 2: random effects result for slopes/nesting @@ -448,6 +456,16 @@ def _single_simulation( # Create extended design matrix with interactions (excludes cluster effects) X_expanded = create_X_extended_func(X) + # Test formula column subsetting: use reduced design matrix for analysis + if metadata.test_column_indices is not None: + X_test = X_expanded[:, metadata.test_column_indices] + if metadata.test_target_indices is None: + raise RuntimeError("test_target_indices must be set when test_column_indices is present") + test_target_indices = metadata.test_target_indices + else: + X_test = X_expanded + test_target_indices = metadata.target_indices + # Split effect sizes: fixed effects vs cluster effects # Use precomputed values (Phase 2 optimization) if metadata.cluster_effect_indices: @@ -457,6 +475,21 @@ def _single_simulation( fixed_effect_sizes = metadata.fixed_effect_sizes_cached cluster_effect_sizes = None + # Residual coin flip: decide whether this simulation uses non-normal errors + residual_dist = 0 # normal + residual_df = 10.0 + residual_change_prob = scenario_config.get("residual_change_prob", 0.0) if scenario_config else 0.0 + if residual_change_prob > 0: + if scenario_config is None: + raise RuntimeError("scenario_config must be provided when residual_change_prob > 0") + coin_rng = np.random.RandomState(sim_seed + 7 if sim_seed is not None else None) + if coin_rng.random() < residual_change_prob: + residual_dists = scenario_config.get("residual_dists", ["heavy_tailed", "skewed"]) + picked = coin_rng.choice(residual_dists) + dist_map = {"heavy_tailed": 1, "skewed": 2} + residual_dist = dist_map.get(picked, 0) + residual_df = float(scenario_config.get("residual_df", 10)) + # Generate dependent variable with fixed effects only y = generate_y_func( X_expanded=X_expanded, @@ -464,6 +497,8 @@ def _single_simulation( heterogeneity=metadata.heterogeneity, heteroskedasticity=metadata.heteroskedasticity, sim_seed=sim_seed, + residual_dist=residual_dist, + residual_df=residual_df, ) # Add cluster random effects contribution @@ -478,12 +513,6 @@ def _single_simulation( if re_result is not None and not np.allclose(re_result.slope_contribution, 0): y = y + re_result.slope_contribution - # Apply LME residual perturbations (non-normal residuals) - if metadata.cluster_specs and scenario_config is not None: - from ..core.scenarios import apply_lme_residual_perturbations - - y = apply_lme_residual_perturbations(y, scenario_config, sim_seed) - # Determine cluster IDs for the solver cluster_ids: Optional[np.ndarray] if re_result is not None: @@ -496,23 +525,25 @@ def _single_simulation( cluster_ids = metadata.cluster_ids_template # Route to correct analysis method - if cluster_ids is not None: + # When test_formula specifies no random effects, use OLS even if generation has clusters + use_lme = cluster_ids is not None and not (metadata.test_column_indices is not None and not metadata.test_has_random_effects) + if use_lme: # Mixed model path (LME) from ..stats.mixed_models import _lme_analysis_wrapper + assert cluster_ids is not None # narrowed by use_lme guard above lme_result = _lme_analysis_wrapper( - X_expanded, + X_test, y, - metadata.target_indices, + test_target_indices, cluster_ids, - metadata.cluster_column_indices, metadata.correction_method, self.alpha, backend="custom", verbose=metadata.verbose, - chi2_crit=getattr(metadata, "lme_chi2_crit", None), - z_crit=getattr(metadata, "lme_z_crit", None), - correction_z_crits=getattr(metadata, "lme_correction_z_crits", None), + chi2_crit=metadata.lme_chi2_crit, + z_crit=metadata.lme_z_crit, + correction_z_crits=metadata.lme_correction_z_crits, re_result=re_result, ) @@ -539,16 +570,20 @@ def _single_simulation( else: # Standard OLS path results = analyze_func( - X_expanded, + X_test, y, - metadata.target_indices, + test_target_indices, self.alpha, metadata.correction_method, ) diagnostics = None - # Extract results: [f_sig, uncorr..., corr..., (wald_flag)] - n_targets = len(metadata.target_indices) + # Result array layout: [F_sig, uncorrected[n_targets], corrected[n_targets], wald_flag?] + # - F_sig (index 0): overall model F-test significance (1.0 or 0.0) + # - uncorrected[1..n]: per-target t-test significance without correction + # - corrected[n+1..2n]: per-target significance with multiple-comparison correction + # - wald_flag (optional, LME only): 1.0 if Wald test was used instead of LRT + n_targets = len(test_target_indices) f_significant = bool(results[0]) uncorrected = results[1 : 1 + n_targets].astype(bool) corrected = results[1 + n_targets : 1 + 2 * n_targets].astype(bool) @@ -560,19 +595,19 @@ def _single_simulation( wald_flag = bool(results[expected_len]) # Post-hoc pairwise contrasts (OLS path only) - if metadata.posthoc_specs and cluster_ids is None: + if metadata.posthoc_specs and not use_lme: from ..stats.ols import compute_posthoc_contrasts ph_uncorr, ph_corr, regular_override = compute_posthoc_contrasts( - X_expanded, + X_test, y, metadata.posthoc_specs, metadata.posthoc_method, metadata.posthoc_t_crit, metadata.posthoc_tukey_crits, - target_indices=metadata.target_indices, + target_indices=test_target_indices, correction_method=metadata.correction_method, - correction_t_crits_combined=getattr(metadata, "posthoc_correction_t_crits_combined", None), + correction_t_crits_combined=metadata.posthoc_correction_t_crits_combined, ) # If FDR/Holm combined correction was applied, override regular corrected @@ -645,7 +680,7 @@ class SimulationMetadata: correction_method: Encoded multiple-comparison correction (0=none, 1=Bonferroni, 2=BH, 3=Holm). heterogeneity: SD of random effect-size multiplier. - heteroskedasticity: Correlation between first predictor and error SD. + heteroskedasticity: Correlation between predicted values and error SD. preserve_correlation: Upload correlation mode (``"no"``/``"partial"``/``"strict"``). uploaded_raw_data: Normalised raw data for strict-mode bootstrap. @@ -728,6 +763,13 @@ def __init__( self.posthoc_method: str = "t-test" self.posthoc_tukey_crits: Dict[str, float] = {} self.posthoc_t_crit: float = 0.0 + self.posthoc_correction_t_crits_combined: Optional[np.ndarray] = None + + # Test formula fields (for model misspecification testing) + self.test_column_indices: Optional[np.ndarray] = None + self.test_target_indices: Optional[np.ndarray] = None + self.test_effect_count: Optional[int] = None # p for critical value computation + self.test_has_random_effects: bool = False # Whether test formula has (1|group) etc. def _compute_fixed_effect_variance(registry) -> float: @@ -779,8 +821,13 @@ def _compute_fixed_effect_variance(registry) -> float: factor_info = registry._factors[factor_name] proportions = factor_info.get("proportions") if proportions is not None: - # level is 1-indexed; proportions list is 0-indexed - p_k = proportions[level - 1] + level_labels = factor_info.get("level_labels") + if level_labels is not None: + # String level labels — look up position by label + p_k = proportions[level_labels.index(str(level))] + else: + # Integer levels are 1-indexed; proportions list is 0-indexed + p_k = proportions[level - 1] else: # Equal proportions (default) n_levels = factor_info["n_levels"] @@ -814,6 +861,7 @@ def prepare_metadata( model, target_tests: List[str], correction: Optional[str] = None, + test_formula_effects: Optional[List[str]] = None, ) -> SimulationMetadata: """ Prepare simulation metadata from model state. @@ -825,6 +873,9 @@ def prepare_metadata( model: MCPowerModel instance target_tests: List of effects to test correction: Multiple comparison correction method + test_formula_effects: Optional list of effect names from a test + formula. When provided, the metadata will include column + indices for subsetting X_expanded to the test model. Returns: SimulationMetadata instance @@ -960,8 +1011,6 @@ def prepare_metadata( upload_data_values=model.upload_data_values if model.upload_data_values is not None else np.zeros((2, 2), dtype=np.float64), effect_sizes=effect_sizes, correction_method=correction_method, - heterogeneity=model.heterogeneity, - heteroskedasticity=model.heteroskedasticity, preserve_correlation=model._preserve_correlation, uploaded_raw_data=model._uploaded_raw_data, uploaded_var_metadata=model._uploaded_var_metadata, @@ -982,4 +1031,20 @@ def prepare_metadata( metadata.posthoc_specs = model._posthoc_specs metadata.posthoc_method = "tukey" if is_tukey_correction else "t-test" + # Test formula column subsetting + if test_formula_effects is not None: + from ..utils.test_formula_utils import _compute_test_column_indices, _remap_target_indices + + # Get all non-cluster effect names in registry order + all_effect_names = [name for name in registry._effects if name not in registry.cluster_effect_names] + + test_col_indices = _compute_test_column_indices(all_effect_names, test_formula_effects) + metadata.test_column_indices = test_col_indices + metadata.test_effect_count = len(test_col_indices) + + # Remap target indices to X_test space + # Only remap targets that exist in the test formula + valid_targets = np.array([idx for idx in target_indices if idx in test_col_indices], dtype=np.int64) + metadata.test_target_indices = _remap_target_indices(valid_targets, test_col_indices) + return metadata diff --git a/mcpower/core/variables.py b/mcpower/core/variables.py index e311606..338d40b 100644 --- a/mcpower/core/variables.py +++ b/mcpower/core/variables.py @@ -367,76 +367,42 @@ def expand_factors(self) -> None: level_labels = factor_info.get("level_labels") reference_level = factor_info.get("reference_level", 1) + # Compute non-reference levels once if level_labels is not None: - # Named levels: skip the reference, create dummies for the rest - non_ref_labels = [lb for lb in level_labels if lb != str(reference_level)] - for label in non_ref_labels: - dummy_name = f"{factor_name}[{label}]" - - # Create dummy predictor - dummy_pred = PredictorVar( - name=dummy_name, - var_type="factor_dummy", - is_dummy=True, - factor_source=factor_name, - factor_level=label, - column_index=col_idx, - level_labels=level_labels, - ) - new_predictors[dummy_name] = dummy_pred - - # Create main effect for dummy - dummy_eff = Effect( - name=dummy_name, - effect_type="main", - var_names=[dummy_name], - column_index=col_idx, - factor_source=factor_name, - factor_level=label, - ) - new_effects[dummy_name] = dummy_eff - - # Store dummy mapping - self._factor_dummies[dummy_name] = { - "factor_name": factor_name, - "level": label, - } - - col_idx += 1 + non_ref = [lb for lb in level_labels if lb != str(reference_level)] else: - # Original integer-indexed behavior - for level in range(2, n_levels + 1): - dummy_name = f"{factor_name}[{level}]" - - # Create dummy predictor - dummy_pred = PredictorVar( - name=dummy_name, - var_type="factor_dummy", - is_dummy=True, - factor_source=factor_name, - factor_level=level, - column_index=col_idx, - ) - new_predictors[dummy_name] = dummy_pred - - # Create main effect for dummy - dummy_eff = Effect( - name=dummy_name, - effect_type="main", - var_names=[dummy_name], - column_index=col_idx, - factor_source=factor_name, - factor_level=level, - ) - new_effects[dummy_name] = dummy_eff + non_ref = list(range(2, n_levels + 1)) + + for level in non_ref: + dummy_name = f"{factor_name}[{level}]" + + dummy_pred = PredictorVar( + name=dummy_name, + var_type="factor_dummy", + is_dummy=True, + factor_source=factor_name, + factor_level=level, + column_index=col_idx, + level_labels=level_labels if level_labels is not None else None, + ) + new_predictors[dummy_name] = dummy_pred + + dummy_eff = Effect( + name=dummy_name, + effect_type="main", + var_names=[dummy_name], + column_index=col_idx, + factor_source=factor_name, + factor_level=level, + ) + new_effects[dummy_name] = dummy_eff - # Store dummy mapping - self._factor_dummies[dummy_name] = { - "factor_name": factor_name, - "level": level, - } + self._factor_dummies[dummy_name] = { + "factor_name": factor_name, + "level": level, + } - col_idx += 1 + col_idx += 1 # Handle interactions involving factors — Cartesian product of # non-reference dummy levels across all factor components. @@ -503,6 +469,13 @@ def get_effect_sizes(self) -> np.ndarray: def get_var_types(self) -> np.ndarray: """Get variable types as numpy array (for data generation).""" + # Type codes: 0-5 are parametric distributions generated from scratch. + # 97/98/99 are sentinel codes for uploaded-data variables whose values + # come from bootstrapped/quantile-matched empirical data rather than + # parametric generation: + # 97 = uploaded_factor (factor from uploaded data) + # 98 = uploaded_binary (binary from uploaded data) + # 99 = uploaded_data (continuous from uploaded data) type_mapping = { "normal": 0, "binary": 1, @@ -717,28 +690,20 @@ def register_cluster( def _reindex_predictors(self) -> None: """Reindex all predictors to maintain order: non_factor | cluster_effect | dummies.""" - col_idx = 0 + non_factor = [] + cluster = [] + dummies = [] - # Non-factor predictors first - for name in sorted(self._predictors.keys(), key=lambda x: self._predictors[x].column_index or 0): - pred = self._predictors[name] - if not pred.is_factor and not pred.is_dummy and pred.var_type != "cluster_effect": - pred.column_index = col_idx - col_idx += 1 - - # Cluster effect predictors second - for name in sorted(self._predictors.keys(), key=lambda x: self._predictors[x].column_index or 0): - pred = self._predictors[name] - if pred.var_type == "cluster_effect": - pred.column_index = col_idx - col_idx += 1 - - # Factor dummies last for name in sorted(self._predictors.keys(), key=lambda x: self._predictors[x].column_index or 0): pred = self._predictors[name] if pred.is_dummy: - pred.column_index = col_idx - col_idx += 1 + dummies.append(pred) + elif pred.var_type == "cluster_effect": + cluster.append(pred) + elif not pred.is_factor: + non_factor.append(pred) + + for col_idx, pred in enumerate(non_factor + cluster + dummies): + pred.column_index = col_idx - # Update effect indices self._update_effect_indices() diff --git a/mcpower/model.py b/mcpower/model.py index 4fa2c4b..9a5f813 100644 --- a/mcpower/model.py +++ b/mcpower/model.py @@ -123,10 +123,9 @@ def __init__(self, data_generation_formula: str): self._pending_factor_levels: Optional[str] = None self._pending_effects: Optional[str] = None self._pending_correlations: Optional[Union[str, np.ndarray]] = None - self._pending_heterogeneity: Optional[float] = None - self._pending_heteroskedasticity: Optional[float] = None self._pending_data: Optional[Dict[str, Any]] = None self._pending_clusters: Dict[str, Dict] = {} # {grouping_var: {n_clusters, cluster_size, icc}} + self._effects_set: bool = False # True after set_effects() has been called # Detect mixed model formula if self._registry._random_effects_parsed: @@ -134,8 +133,6 @@ def __init__(self, data_generation_formula: str): # Applied state self._applied = False - self.heterogeneity = 0.0 - self.heteroskedasticity = 0.0 # Data storage self.upload_normal_values: Optional[np.ndarray] = None @@ -385,6 +382,7 @@ def set_effects(self, effects_string: str): raise ValueError("effects_string cannot be empty") self._pending_effects = effects_string + self._effects_set = True self._applied = False return self @@ -432,13 +430,16 @@ def set_variable_type(self, variable_types_string: str): - ``"normal"`` — standard normal (default). - ``"binary"`` or ``"binary(p)"`` — Bernoulli with proportion *p* (default 0.5). - - ``"skewed"`` — heavy-tailed (t-distribution, df=3). + - ``"right_skewed"`` — positively skewed distribution. + - ``"left_skewed"`` — negatively skewed distribution. + - ``"high_kurtosis"`` — heavy-tailed (t-distribution, df=3). + - ``"uniform"`` — uniform distribution. - ``"factor(k)"`` — categorical with *k* levels (creates *k-1* dummy variables). - ``"factor(k, p1, p2, ...)"`` — factor with custom level proportions. - Example: ``"x1=binary, x2=skewed, x3=factor(3)"``. + Example: ``"x1=binary, x2=right_skewed, x3=factor(3)"``. Returns: self: For method chaining. @@ -479,62 +480,6 @@ def set_factor_levels(self, spec: str): self._applied = False return self - def set_heterogeneity(self, heterogeneity: float): - """Set heterogeneity (random variation) in effect sizes. - - When non-zero, each simulation draws a per-simulation effect-size - multiplier from a normal distribution with mean 1 and the given - standard deviation. This models uncertainty about the true effect - size — for example, ``heterogeneity=0.1`` means effect sizes vary - by roughly +/- 10% across simulations. - - This setting is deferred until ``apply()`` is called. - - Args: - heterogeneity: Standard deviation of the random effect-size - multiplier. Must be non-negative. Default is 0 (no variation). - - Returns: - self: For method chaining. - - Raises: - TypeError: If *heterogeneity* is not numeric. - """ - if not isinstance(heterogeneity, (int, float)): - raise TypeError("heterogeneity must be a number") - - self._pending_heterogeneity = float(heterogeneity) - self._applied = False - return self - - def set_heteroskedasticity(self, heteroskedasticity_correlation: float): - """Set heteroskedasticity (non-constant error variance). - - Introduces a correlation between the first predictor's values and - the error standard deviation, producing variance that increases (or - decreases) with the predictor. This violates the homoskedasticity - assumption and typically reduces power. - - This setting is deferred until ``apply()`` is called. - - Args: - heteroskedasticity_correlation: Correlation between the first - predictor and the error standard deviation, in the range - [-1, 1]. Default is 0 (homoskedastic errors). - - Returns: - self: For method chaining. - - Raises: - TypeError: If the value is not numeric. - """ - if not isinstance(heteroskedasticity_correlation, (int, float)): - raise TypeError("heteroskedasticity_correlation must be a number") - - self._pending_heteroskedasticity = float(heteroskedasticity_correlation) - self._applied = False - return self - def set_cluster( self, grouping_var: str, @@ -769,6 +714,7 @@ def upload_data( "preserve_factor_level_names": preserve_factor_level_names, } self._applied = False + return self def set_scenario_configs(self, configs_dict: Dict): """Set custom scenario configurations for robustness analysis. @@ -786,7 +732,9 @@ def set_scenario_configs(self, configs_dict: Dict): configs_dict: Mapping of scenario names to configuration dicts. Each configuration may include keys such as ``"heterogeneity"``, ``"heteroskedasticity"``, - ``"effect_size_jitter"``, and ``"distribution_jitter"``. + ``"correlation_noise_sd"``, and ``"distribution_change_prob"``. + See ``DEFAULT_SCENARIO_CONFIG`` in ``mcpower.core.scenarios`` + for the full list of keys. Returns: self: For method chaining. @@ -802,7 +750,8 @@ def set_scenario_configs(self, configs_dict: Dict): if scenario in merged: merged[scenario].update(config) else: - merged[scenario] = config + # New custom scenarios inherit all keys from optimistic baseline + merged[scenario] = {**DEFAULT_SCENARIO_CONFIG["optimistic"], **config} self._scenario_configs = merged print(f"Custom scenario configs set: {', '.join(configs_dict.keys())}") @@ -812,7 +761,7 @@ def set_scenario_configs(self, configs_dict: Dict): # Apply method (processes all pending settings) # ========================================================================= - def apply(self): + def _apply(self): """ Apply all pending settings to the model. @@ -857,16 +806,22 @@ def apply(self): # 7. Apply correlations self._apply_correlations(_parser) - # 8. Apply heterogeneity/heteroskedasticity - self._apply_heterogeneity() - - # 9. Validate model is ready + # 8. Validate model is ready model_result = _validate_model_ready(self) model_result.raise_if_invalid() - # Invalidate effect plan cache when settings change (Phase 2 optimization) + # Invalidate the effect plan cache — apply() rebuilds the variable + # registry state, so any cached column mappings are now stale. self._effect_plan_cache = None + # Clear pending state to prevent double-application + self._pending_variable_types = None + self._pending_factor_levels = None + self._pending_effects = None + self._pending_correlations = None + self._pending_data = None + self._pending_clusters = {} + self._applied = True print("Model settings applied successfully") return self @@ -1024,6 +979,21 @@ def _apply_data(self): # Extract matched data matched_data = data[:, matched_indices] + # Reject NaN values early + try: + if np.isnan(matched_data.astype(np.float64)).any(): + nan_cols = [ + matched_columns[i] for i in range(matched_data.shape[1]) if np.isnan(matched_data[:, i].astype(np.float64)).any() + ] + raise ValueError( + f"Uploaded data contains NaN values in columns: {', '.join(nan_cols)}. " + f"Remove or impute missing values before uploading." + ) + except (ValueError, TypeError): + # Object dtype columns (strings) can't be converted to float for NaN check. + # NaN check for numeric columns will happen after string encoding below. + pass + # Convert to float64 if object dtype (common with mixed-type DataFrames) # String columns are encoded to integer indices; mapping is stored in string_col_indices string_col_indices = {} @@ -1178,11 +1148,7 @@ def _apply_data_normal_mode(self, data, columns, type_info, mode, data_types_ove level_labels = info.get("level_labels") # Determine reference from data_types tuple override - reference_level = None - if col in data_types_override: - dt = data_types_override[col] - if isinstance(dt, tuple) and len(dt) == 2: - reference_level = str(dt[1]) + reference_level = self._extract_reference_level(data_types_override, col) # Calculate proportions for each level proportions = [] @@ -1200,7 +1166,10 @@ def _apply_data_normal_mode(self, data, columns, type_info, mode, data_types_ove else: # continuous # Normalize: mean=0, sd=1 - normalized = (col_data - np.mean(col_data)) / np.std(col_data, ddof=1) + std = np.std(col_data, ddof=1) + if std < 1e-15: + raise ValueError(f"Column '{col}' has zero variance (constant value). Remove it from the model or check your data.") + normalized = (col_data - np.mean(col_data)) / std # Create lookup tables (type 99) normal_vals, uploaded_vals = create_uploaded_lookup_tables(normalized.reshape(-1, 1)) @@ -1324,11 +1293,7 @@ def _apply_data_strict_mode(self, data, columns, type_info, data_types_override= level_labels = info.get("level_labels") # Determine reference from data_types tuple override - reference_level = None - if col in data_types_override: - dt = data_types_override[col] - if isinstance(dt, tuple) and len(dt) == 2: - reference_level = str(dt[1]) + reference_level = self._extract_reference_level(data_types_override, col) self._uploaded_var_metadata[col] = { "type": "factor", @@ -1355,7 +1320,10 @@ def _apply_data_strict_mode(self, data, columns, type_info, data_types_override= continuous_cols.append(idx) # Normalize col_data = data[:, idx] - normalized_data[:, idx] = (col_data - np.mean(col_data)) / np.std(col_data, ddof=1) + std = np.std(col_data, ddof=1) + if std < 1e-15: + raise ValueError(f"Column '{col}' has zero variance (constant value). Remove it from the model or check your data.") + normalized_data[:, idx] = (col_data - np.mean(col_data)) / std self._uploaded_var_metadata[col] = { "type": "continuous", @@ -1481,22 +1449,6 @@ def _apply_correlations(self, _parser): self._registry.set_correlation_matrix(correlations_input) print("Correlation matrix set") - def _apply_heterogeneity(self): - """Validate and apply pending heterogeneity and heteroskedasticity settings.""" - if self._pending_heterogeneity is not None: - if self._pending_heterogeneity < 0: - raise ValueError("heterogeneity must be non-negative") - self.heterogeneity = self._pending_heterogeneity - if self.heterogeneity > 0: - print(f"Heterogeneity: SD = {self.heterogeneity}") - - if self._pending_heteroskedasticity is not None: - if not -1 <= self._pending_heteroskedasticity <= 1: - raise ValueError("heteroskedasticity_correlation must be between -1 and 1") - self.heteroskedasticity = self._pending_heteroskedasticity - if abs(self.heteroskedasticity) > 1e-8: - print(f"Heteroskedasticity: correlation = {self.heteroskedasticity}") - # ========================================================================= # Analysis methods # ========================================================================= @@ -1507,7 +1459,7 @@ def find_power( target_test: str = "all", correction: Optional[str] = None, print_results: bool = True, - scenarios: bool = False, + scenarios: Union[bool, List[str]] = False, summary: str = "short", return_results: bool = False, test_formula: str = "", @@ -1529,12 +1481,16 @@ def find_power( Duplicate tests raise ``ValueError``. correction: Multiple comparison correction (None, "bonferroni", "benjamini-hochberg", "holm") print_results: Whether to print results - scenarios: Run scenario analysis + scenarios: Scenario analysis control: + - ``False`` (default): no scenario analysis. + - ``True``: run all configured scenarios. + - List of scenario names: run only the specified scenarios + (e.g. ``["optimistic", "extreme"]``). Case-insensitive. summary: Output detail level ("short" or "long") return_results: Return results dict test_formula: Formula for statistical testing (default: use data generation formula). If the formula contains random effects like (1|school), analysis switches to - mixed model testing (not yet implemented). + mixed model testing. progress_callback: Progress reporting control: - ``None`` (default): auto-use ``PrintReporter`` when *print_results* is ``True``. @@ -1549,7 +1505,10 @@ def find_power( """ # Auto-apply if settings have changed if not self._applied: - self.apply() + self._apply() + + # Resolve scenarios parameter + scenario_filter = self._resolve_scenarios(scenarios) # Validate sample size (basic: >= 20, type check) _validate_sample_size(sample_size).raise_if_invalid() @@ -1558,9 +1517,6 @@ def find_power( n_variables = len(self._registry.effect_names) _validate_sample_size_for_model(sample_size, n_variables).raise_if_invalid() - # Validate and adjust cluster sample sizes - self._validate_cluster_sample_size(sample_size) - # Warn if sample size is much larger than uploaded data if self._uploaded_data_n > 0 and sample_size > 3 * self._uploaded_data_n: print( @@ -1570,33 +1526,13 @@ def find_power( ) self._validate_analysis_inputs(correction) - resolved_test_formula = self._resolve_test_formula(test_formula) - target_tests = self._parse_target_tests(target_test) - - if correction and correction.lower() == "tukey" and not self._posthoc_specs: - raise ValueError( - "Tukey correction requires at least one post-hoc comparison " - "(e.g., target_test='group[0] vs group[1]'). " - "Tukey HSD only applies to pairwise contrasts between factor levels." - ) - - # Resolve progress callback - from .progress import PrintReporter, ProgressReporter, compute_total_simulations - - if progress_callback is None: - effective_cb = PrintReporter() if print_results else None - elif progress_callback is False: - effective_cb = None - else: - effective_cb = progress_callback + resolved_test_formula, test_formula_effects, test_random_effects = self._resolve_test_formula(test_formula) + target_tests = self._parse_target_tests(target_test, test_formula_effects=test_formula_effects) + self._validate_tukey_posthoc(correction) - reporter = None - if effective_cb is not None: - n_scenarios = (len(self._scenario_configs or DEFAULT_SCENARIO_CONFIG) + 1) if scenarios else 1 - total = compute_total_simulations(self._effective_n_simulations, 1, n_scenarios) - reporter = ProgressReporter(total, effective_cb) + reporter = self._resolve_progress(progress_callback, print_results, scenario_filter) - if scenarios: + if scenario_filter is not None: result = self._run_scenario_analysis( "power", sample_size=sample_size, @@ -1605,8 +1541,11 @@ def find_power( summary=summary, print_results=print_results, test_formula=resolved_test_formula, + test_formula_effects=test_formula_effects, + test_random_effects=test_random_effects, progress=reporter, cancel_check=cancel_check, + scenario_filter=scenario_filter, ) else: if reporter is not None: @@ -1615,7 +1554,10 @@ def find_power( sample_size, target_tests, correction, + scenario_config=DEFAULT_SCENARIO_CONFIG["optimistic"], test_formula=resolved_test_formula, + test_formula_effects=test_formula_effects, + test_random_effects=test_random_effects, progress=reporter, cancel_check=cancel_check, ) @@ -1623,7 +1565,7 @@ def find_power( if reporter is not None: reporter.finish() - if not scenarios and print_results: + if scenario_filter is None and print_results: print(f"\n{'=' * 80}") print("MONTE CARLO POWER ANALYSIS RESULTS") print(f"{'=' * 80}") @@ -1641,7 +1583,7 @@ def find_sample_size( by: int = 5, correction: Optional[str] = None, print_results: bool = True, - scenarios: bool = False, + scenarios: Union[bool, List[str]] = False, summary: str = "short", return_results: bool = False, test_formula: str = "", @@ -1659,12 +1601,16 @@ def find_sample_size( by: Step size between sample sizes correction: Multiple comparison correction print_results: Whether to print results - scenarios: Run scenario analysis + scenarios: Scenario analysis control: + - ``False`` (default): no scenario analysis. + - ``True``: run all configured scenarios. + - List of scenario names: run only the specified scenarios + (e.g. ``["optimistic", "extreme"]``). Case-insensitive. summary: Output detail level return_results: Return results dict test_formula: Formula for statistical testing (default: use data generation formula). If the formula contains random effects like (1|school), analysis switches to - mixed model testing (not yet implemented). + mixed model testing. progress_callback: Progress reporting control: - ``None`` (default): auto-use ``PrintReporter`` when *print_results* is ``True``. @@ -1680,7 +1626,10 @@ def find_sample_size( """ # Auto-apply if settings have changed if not self._applied: - self.apply() + self._apply() + + # Resolve scenarios parameter + scenario_filter = self._resolve_scenarios(scenarios) # Validate from_size meets minimum requirements _validate_sample_size(from_size).raise_if_invalid() @@ -1696,40 +1645,20 @@ def find_sample_size( ) self._validate_analysis_inputs(correction) - resolved_test_formula = self._resolve_test_formula(test_formula) + resolved_test_formula, test_formula_effects, test_random_effects = self._resolve_test_formula(test_formula) validation_result = _validate_sample_size_range(from_size, to_size, by) for warning in validation_result.warnings: print(f"Warning: {warning}") validation_result.raise_if_invalid() - target_tests = self._parse_target_tests(target_test) - - if correction and correction.lower() == "tukey" and not self._posthoc_specs: - raise ValueError( - "Tukey correction requires at least one post-hoc comparison " - "(e.g., target_test='group[0] vs group[1]'). " - "Tukey HSD only applies to pairwise contrasts between factor levels." - ) + target_tests = self._parse_target_tests(target_test, test_formula_effects=test_formula_effects) + self._validate_tukey_posthoc(correction) sample_sizes = list(range(from_size, to_size + 1, by)) - # Resolve progress callback - from .progress import PrintReporter, ProgressReporter, compute_total_simulations - - if progress_callback is None: - effective_cb = PrintReporter() if print_results else None - elif progress_callback is False: - effective_cb = None - else: - effective_cb = progress_callback - - reporter = None - if effective_cb is not None: - n_scenarios = (len(self._scenario_configs or DEFAULT_SCENARIO_CONFIG) + 1) if scenarios else 1 - total = compute_total_simulations(self._effective_n_simulations, len(sample_sizes), n_scenarios) - reporter = ProgressReporter(total, effective_cb) + reporter = self._resolve_progress(progress_callback, print_results, scenario_filter, n_sample_sizes=len(sample_sizes)) - if scenarios: + if scenario_filter is not None: result = self._run_scenario_analysis( "sample_size", target_tests=target_tests, @@ -1738,8 +1667,11 @@ def find_sample_size( summary=summary, print_results=print_results, test_formula=resolved_test_formula, + test_formula_effects=test_formula_effects, + test_random_effects=test_random_effects, progress=reporter, cancel_check=cancel_check, + scenario_filter=scenario_filter, ) else: if reporter is not None: @@ -1748,7 +1680,10 @@ def find_sample_size( sample_sizes, target_tests, correction, + scenario_config=DEFAULT_SCENARIO_CONFIG["optimistic"], test_formula=resolved_test_formula, + test_formula_effects=test_formula_effects, + test_random_effects=test_random_effects, progress=reporter, cancel_check=cancel_check, ) @@ -1756,7 +1691,7 @@ def find_sample_size( if reporter is not None: reporter.finish() - if not scenarios and print_results: + if scenario_filter is None and print_results: print(f"\n{'=' * 80}") print("SAMPLE SIZE ANALYSIS RESULTS") print(f"{'=' * 80}") @@ -1780,6 +1715,8 @@ def _generate_dependent_variable( heterogeneity: float = 0.0, heteroskedasticity: float = 0.0, sim_seed: Optional[int] = None, + residual_dist: int = 0, + residual_df: float = 10.0, ) -> np.ndarray: """Generate the dependent variable as y = X @ beta + error via the active backend.""" return get_backend().generate_y( @@ -1788,19 +1725,103 @@ def _generate_dependent_variable( heterogeneity, heteroskedasticity, sim_seed if sim_seed is not None else -1, + residual_dist, + residual_df, ) # ========================================================================= # Internal methods # ========================================================================= + @staticmethod + def _extract_reference_level(data_types_override, col): + """Extract reference level from data_types_override tuple for a column.""" + dt = data_types_override.get(col) + if isinstance(dt, tuple) and len(dt) == 2: + return str(dt[1]) + return None + + def _resolve_scenarios(self, scenarios: Union[bool, List[str]]) -> Optional[List[str]]: + """Resolve the scenarios parameter into a list of scenario names or None. + + Args: + scenarios: ``False`` for no scenarios, ``True`` for all configured + scenarios, or a list of scenario names (case-insensitive). + + Returns: + List of validated, lowercase scenario names, or ``None`` if + scenarios are disabled. + + Raises: + ValueError: If any requested scenario name is not configured. + TypeError: If *scenarios* is not ``bool`` or a list of strings. + """ + if scenarios is False: + return None + + all_configs = self._scenario_configs or DEFAULT_SCENARIO_CONFIG + available = set(all_configs.keys()) + + if scenarios is True: + return list(all_configs.keys()) + + if not isinstance(scenarios, list): + raise TypeError(f"scenarios must be True, False, or a list of scenario names, got {type(scenarios).__name__}") + + # Case-insensitive matching + available_lower = {k.lower(): k for k in available} + resolved = [] + invalid = [] + for name in scenarios: + if not isinstance(name, str): + raise TypeError(f"Scenario names must be strings, got {type(name).__name__}") + key = available_lower.get(name.lower()) + if key is None: + invalid.append(name) + else: + resolved.append(key) + + if invalid: + raise ValueError(f"Unknown scenario(s): {', '.join(repr(n) for n in invalid)}. Available: {', '.join(sorted(available))}") + + return resolved + + def _resolve_progress(self, progress_callback, print_results, scenario_filter, n_sample_sizes=1): + """Resolve progress_callback into a ProgressReporter or None.""" + from .progress import PrintReporter, ProgressReporter, compute_total_simulations + + if progress_callback is None: + effective_cb = PrintReporter() if print_results else None + elif progress_callback is False: + effective_cb = None + else: + effective_cb = progress_callback + + if effective_cb is None: + return None + + n_scenarios = len(scenario_filter) if scenario_filter is not None else 1 + total = compute_total_simulations(self._effective_n_simulations, n_sample_sizes, n_scenarios) + return ProgressReporter(total, effective_cb) + def _validate_analysis_inputs(self, correction): """Validate the multiple-comparison correction method before analysis.""" result = _validate_correction_method(correction) result.raise_if_invalid() + def _validate_tukey_posthoc(self, correction): + """Raise if Tukey correction is requested without posthoc specs.""" + if correction and correction.lower() == "tukey" and not self._posthoc_specs: + raise ValueError( + "Tukey correction requires at least one post-hoc comparison " + "(e.g., target_test='group[0] vs group[1]'). " + "Tukey HSD only applies to pairwise contrasts between factor levels." + ) + def _validate_cluster_sample_size(self, sample_size: int): """Derive missing cluster dimensions from sample_size and validate minimums.""" + # NOTE: This method both validates AND mutates — it derives missing + # cluster_size/n_clusters from sample_size before checking minimums. if not self._registry.cluster_names: return # No clusters, nothing to do @@ -1811,10 +1832,12 @@ def _validate_cluster_sample_size(self, sample_size: int): if spec.n_clusters is not None: spec.cluster_size = sample_size // spec.n_clusters else: - assert spec.cluster_size is not None + if spec.cluster_size is None: + raise RuntimeError(f"Cluster '{gv}': either n_clusters or cluster_size must be set") spec.n_clusters = sample_size // spec.cluster_size - assert spec.n_clusters is not None and spec.cluster_size is not None + if spec.n_clusters is None or spec.cluster_size is None: + raise RuntimeError(f"Cluster '{gv}': failed to derive n_clusters and cluster_size from sample_size={sample_size}") actual_n = spec.n_clusters * spec.cluster_size if actual_n != sample_size: print( @@ -1825,7 +1848,7 @@ def _validate_cluster_sample_size(self, sample_size: int): _validate_cluster_sample_size(sample_size, spec.n_clusters, spec.cluster_size).raise_if_invalid() - def _parse_target_tests(self, target_test: Union[str, List[str]]) -> List[str]: + def _parse_target_tests(self, target_test: Union[str, List[str]], test_formula_effects: Optional[List[str]] = None) -> List[str]: """Parse a target_test argument into a list of effect names to test. Supports regular effect names (e.g. ``"x1"``, ``"overall"``), @@ -1875,7 +1898,10 @@ def _parse_target_tests(self, target_test: Union[str, List[str]]) -> List[str]: cluster_effects = self._registry.cluster_effect_names if "all" in keywords: - fixed_effects = [e for e in self._registry.effect_names if e not in cluster_effects] + if test_formula_effects is not None: + fixed_effects = [e for e in test_formula_effects if e not in cluster_effects] + else: + fixed_effects = [e for e in self._registry.effect_names if e not in cluster_effects] keyword_expansion += ["overall"] + fixed_effects if "all-posthoc" in keywords: @@ -1929,6 +1955,17 @@ def _parse_target_tests(self, target_test: Union[str, List[str]]) -> List[str]: "(e.g. 'all'), do not also list tests that are already included." ) + # -- Phase 7b: Validate explicit tests against test formula ---------------- + if test_formula_effects is not None: + test_formula_set = set(test_formula_effects) + for test in expanded: + if " vs " in test or test == "overall": + continue + if test not in test_formula_set: + raise ValueError( + f"Target test '{test}' is not in the test formula. Available effects: {', '.join(test_formula_effects)}" + ) + # -- Phase 8: Parse posthoc specs + validate ------------------------------ regular_tests: list[str] = [] posthoc_specs: list[PostHocSpec] = [] @@ -1982,6 +2019,8 @@ def _parse_target_tests(self, target_test: Union[str, List[str]]) -> List[str]: # User level k (k≥2) = dummy factor[k] effect_order = list(self._registry._effects.keys()) + # Returns None for the reference level, which is absorbed into the + # intercept in dummy coding and has no dedicated design matrix column. def _level_to_col(factor_name, user_level, _effect_order=effect_order): factor_info = self._registry._factors[factor_name] reference = factor_info.get("reference_level", 1) @@ -2069,30 +2108,60 @@ def _create_X_extended(self, X): return np.column_stack(columns) if columns else np.empty((X.shape[0], 0)) - def _prepare_metadata(self, target_tests, correction=None): + def _prepare_metadata(self, target_tests, correction=None, test_formula_effects=None): """Pre-compute all static simulation metadata from the current model state.""" - return prepare_metadata(self, target_tests, correction) + return prepare_metadata(self, target_tests, correction, test_formula_effects=test_formula_effects) - def _resolve_test_formula(self, test_formula: str) -> str: - """Resolve test formula and update _test_method accordingly. + def _resolve_test_formula(self, test_formula: str): + """Resolve test formula, validate, parse, and update _test_method. - Returns the resolved formula string. + Returns: + Tuple of (formula_string, test_effect_names, random_effects). + test_effect_names is None when test_formula is empty (use generation formula). """ + from .utils.parsers import _parse_equation + if not test_formula: resolved = self._registry.equation - else: - resolved = test_formula + _, _, random_effects = _parse_equation(resolved) + if random_effects: + self._test_method = "mixed_model" + else: + self._test_method = "linear_regression" + return resolved, None, [] - from .utils.parsers import _parse_equation + # Validate test formula variables exist in the model + from .utils.validators import _validate_test_formula - _, _, random_effects = _parse_equation(resolved) + available_vars = ( + [self._registry.dependent] + self._registry.non_factor_names + self._registry.factor_names + self._registry.cluster_names + ) + validation = _validate_test_formula(test_formula, available_vars) + validation.raise_if_invalid() + + # Parse test formula to get effects and random effects + from .utils.test_formula_utils import _extract_test_formula_effects + + test_effects, random_effects = _extract_test_formula_effects(test_formula, self._registry) + + if not test_effects: + raise ValueError(f"test_formula '{test_formula}' contains no testable effects from the data generation model.") + + # Check for OLS -> LME cross (invalid: no cluster data to fit) + if random_effects and not self._registry._cluster_specs: + grouping_vars = [re["grouping_var"] for re in random_effects] + raise ValueError( + f"test_formula contains random effects ({grouping_vars}) but the " + f"data generation model has no cluster structure. Cannot fit a " + f"mixed model to data without clusters." + ) if random_effects: self._test_method = "mixed_model" else: self._test_method = "linear_regression" - return resolved + return test_formula, test_effects, random_effects def _run_find_power( self, @@ -2101,6 +2170,8 @@ def _run_find_power( correction, scenario_config=None, test_formula=None, + test_formula_effects=None, + test_random_effects=None, progress=None, cancel_check=None, ): @@ -2109,13 +2180,15 @@ def _run_find_power( self._validate_cluster_sample_size(sample_size) # Route based on test method (routing logic handled in simulation.py) - metadata = self._prepare_metadata(target_tests, correction) + metadata = self._prepare_metadata(target_tests, correction, test_formula_effects) - if scenario_config: - metadata.heterogeneity = scenario_config["heterogeneity"] - metadata.heteroskedasticity = scenario_config["heteroskedasticity"] - if metadata.cluster_specs: - metadata.lme_scenario_config = scenario_config + # Set the random effects flag for test formula + if test_random_effects: + metadata.test_has_random_effects = True + + # scenario_config is always a dict (SCENARIO_ZERO or user-provided) + metadata.heterogeneity = scenario_config["heterogeneity"] + metadata.heteroskedasticity = scenario_config["heteroskedasticity"] runner = SimulationRunner( n_simulations=self._effective_n_simulations, @@ -2127,9 +2200,15 @@ def _run_find_power( ) # Compute critical values once before the simulation loop - p = len(metadata.effect_sizes) + # Use test formula's effect count for critical values when subsetting + if metadata.test_column_indices is not None: + p = metadata.test_effect_count + n_targets = len(metadata.test_target_indices) + else: + p = len(metadata.effect_sizes) + n_targets = len(metadata.target_indices) + dof = sample_size - p - 1 - n_targets = len(metadata.target_indices) n_posthoc = len(metadata.posthoc_specs) if n_posthoc > 0 and metadata.posthoc_method == "t-test": @@ -2185,7 +2264,7 @@ def analyze_func(X, y, indices, alpha, correction): analyze_func=analyze_func, create_X_extended_func=self._create_X_extended, scenario_config=scenario_config, - apply_perturbations_func=(apply_per_simulation_perturbations if scenario_config else None), + apply_perturbations_func=apply_per_simulation_perturbations, progress=progress, cancel_check=cancel_check, ) @@ -2193,11 +2272,17 @@ def analyze_func(X, y, indices, alpha, correction): if not sim_results: return {} + # When test formula is active, filter target_tests to only effects in the test model + effective_target_tests = target_tests + if test_formula_effects is not None: + test_effect_set = set(test_formula_effects) + effective_target_tests = [t for t in target_tests if t == "overall" or t in test_effect_set] + processor = ResultsProcessor(target_power=self.power) power_results = processor.calculate_powers( sim_results["all_results"], sim_results["all_results_corrected"], - target_tests, + effective_target_tests, ) # Add n_simulations_failed to power_results @@ -2207,13 +2292,13 @@ def analyze_func(X, y, indices, alpha, correction): # Tukey correction only applies to pairwise contrasts; NaN-ify others if correction and correction.lower() == "tukey" and power_results.get("individual_powers_corrected"): posthoc_labels = {s.label for s in self._posthoc_specs} - for test in target_tests: + for test in effective_target_tests: if test not in posthoc_labels: power_results["individual_powers_corrected"][test] = float("nan") return build_power_result( model_type=self.model_type, - target_tests=target_tests, + target_tests=effective_target_tests, formula_to_test=test_formula, equation=self.equation, sample_size=sample_size, @@ -2243,12 +2328,15 @@ def _run_sample_size_analysis( correction, scenario_config=None, test_formula=None, + test_formula_effects=None, + test_random_effects=None, progress=None, cancel_check=None, ): """Iterate over sample sizes, running power analysis for each.""" from .progress import SimulationCancelled + use_sequential = True if self._is_parallel_effective(): from joblib import Parallel, delayed @@ -2258,7 +2346,18 @@ def _run_sample_size_analysis( backend="loky", verbose=0, return_as="generator", - )(delayed(self._run_find_power)(ss, target_tests, correction, scenario_config, test_formula) for ss in sample_sizes) + )( + delayed(self._run_find_power)( + ss, + target_tests, + correction, + scenario_config, + test_formula, + test_formula_effects, + test_random_effects, + ) + for ss in sample_sizes + ) results = [] for ss, result in zip(sample_sizes, power_results, strict=False): if cancel_check is not None and cancel_check(): @@ -2266,25 +2365,13 @@ def _run_sample_size_analysis( results.append((ss, result)) if progress is not None: progress.advance(self._effective_n_simulations) + use_sequential = False except Exception as e: if isinstance(e, SimulationCancelled): raise print(f"Warning: Parallel execution failed ({e}). Falling back to sequential.") - results = [] - for ss in sample_sizes: - if cancel_check is not None and cancel_check(): - raise SimulationCancelled("Simulation cancelled by user") from None - result = self._run_find_power( - ss, - target_tests, - correction, - scenario_config, - test_formula, - progress=progress, - cancel_check=cancel_check, - ) - results.append((ss, result)) - else: + + if use_sequential: results = [] for sample_size in sample_sizes: if cancel_check is not None and cancel_check(): @@ -2295,19 +2382,28 @@ def _run_sample_size_analysis( correction, scenario_config, test_formula, + test_formula_effects=test_formula_effects, + test_random_effects=test_random_effects, progress=progress, cancel_check=cancel_check, ) results.append((sample_size, power_result)) processor = ResultsProcessor(target_power=self.power) - analysis_results = processor.process_sample_size_results(results, target_tests, correction) + # Filter target_tests to match test formula effects + if test_formula_effects is not None: + test_set = set(test_formula_effects) + effective_target_tests = [t for t in target_tests if t in test_set or t == "overall"] + else: + effective_target_tests = target_tests + + analysis_results = processor.process_sample_size_results(results, effective_target_tests, correction) # Tukey correction only applies to pairwise contrasts; NaN-ify others if correction and correction.lower() == "tukey": posthoc_labels = {s.label for s in self._posthoc_specs} if analysis_results.get("powers_by_test_corrected"): - for test in target_tests: + for test in effective_target_tests: if test not in posthoc_labels: n_points = len(analysis_results["powers_by_test_corrected"][test]) analysis_results["powers_by_test_corrected"][test] = [float("nan")] * n_points @@ -2315,7 +2411,7 @@ def _run_sample_size_analysis( return build_sample_size_result( model_type=self.model_type, - target_tests=target_tests, + target_tests=effective_target_tests, formula_to_test=test_formula, equation=self.equation, sample_sizes=sample_sizes, @@ -2331,9 +2427,16 @@ def _run_scenario_analysis(self, analysis_type, **kwargs): """Delegate to ScenarioRunner for multi-scenario power or sample-size analysis.""" from functools import partial - configs = self._scenario_configs or DEFAULT_SCENARIO_CONFIG + all_configs = self._scenario_configs or DEFAULT_SCENARIO_CONFIG + scenario_filter = kwargs.pop("scenario_filter", None) + if scenario_filter is not None: + configs = {k: all_configs[k] for k in scenario_filter} + else: + configs = all_configs scenario_runner = ScenarioRunner(self, configs) test_formula = kwargs.get("test_formula") + test_formula_effects = kwargs.get("test_formula_effects") + test_random_effects = kwargs.get("test_random_effects") progress = kwargs.get("progress") cancel_check = kwargs.get("cancel_check") @@ -2341,6 +2444,8 @@ def _run_scenario_analysis(self, analysis_type, **kwargs): run_power_func = partial( self._run_find_power, test_formula=test_formula, + test_formula_effects=test_formula_effects, + test_random_effects=test_random_effects, progress=progress, cancel_check=cancel_check, ) @@ -2357,6 +2462,8 @@ def _run_scenario_analysis(self, analysis_type, **kwargs): run_ss_func = partial( self._run_sample_size_analysis, test_formula=test_formula, + test_formula_effects=test_formula_effects, + test_random_effects=test_random_effects, progress=progress, cancel_check=cancel_check, ) diff --git a/mcpower/progress.py b/mcpower/progress.py index e733148..dca3c25 100644 --- a/mcpower/progress.py +++ b/mcpower/progress.py @@ -87,10 +87,7 @@ def __init__(self, **tqdm_kwargs): self._bar = None def __call__(self, current: int, total: int): - try: - from tqdm import tqdm - except ImportError: - raise ImportError("tqdm is required for TqdmReporter. Install with: pip install tqdm") from None + from tqdm import tqdm if self._bar is None: self._bar = tqdm(total=total, unit="sim", **self._tqdm_kwargs) diff --git a/mcpower/stats/data_generation.py b/mcpower/stats/data_generation.py index 0d8800c..3c46d89 100644 --- a/mcpower/stats/data_generation.py +++ b/mcpower/stats/data_generation.py @@ -23,7 +23,6 @@ SKEW_STD = np.sqrt(np.exp(2) - np.exp(1)) NORM_SCALE = (DIST_RESOLUTION - 1) / (NORM_RANGE[1] - NORM_RANGE[0]) PERC_SCALE = (DIST_RESOLUTION - 1) / (PERCENTILE_RANGE[1] - PERCENTILE_RANGE[0]) -FLOAT_NEAR_ZERO = 1e-15 # Global lookup tables NORM_CDF_TABLE = None @@ -58,13 +57,12 @@ def _compute_t3_sd(): Replicates the vectorised norm-CDF -> t(3)-PPF lookup chain on a large fixed-seed sample to get a stable SD estimate. """ - assert NORM_CDF_TABLE is not None - assert T3_PPF_TABLE is not None + if NORM_CDF_TABLE is None or T3_PPF_TABLE is None: + raise RuntimeError("Distribution tables not initialized — _init_tables() must be called first") - rng_state = np.random.get_state() - np.random.seed(999999) - z = np.random.standard_normal(200000) - np.random.set_state(rng_state) + # Use a local RNG to avoid affecting the global state and to be thread-safe. + rng = np.random.RandomState(999999) + z = rng.standard_normal(200000) # Step 1: Normal CDF lookup (z -> percentile) z_clipped = np.clip(z, NORM_RANGE[0], NORM_RANGE[1]) @@ -107,9 +105,16 @@ def create_uploaded_lookup_tables( for var_idx in range(n_vars): data = data_matrix[:, var_idx] - normalized = (data - np.mean(data)) / np.std(data) + std = np.std(data) + if std < 1e-15: + raise ValueError( + f"Variable at index {var_idx} has zero variance (constant value). Remove it from the model or check your data." + ) + normalized = (data - np.mean(data)) / std sorted_uploaded = np.sort(normalized) + # Weibull plotting positions: i/(n+1) avoids 0 and 1, which would map + # to -inf/+inf under the normal PPF, giving well-behaved quantiles. percentiles = np.linspace(1 / (n_samples + 1), n_samples / (n_samples + 1), n_samples) normal_quantiles = norm_ppf_array(percentiles) @@ -126,13 +131,12 @@ def _generate_factors(sample_size, factor_specs, seed): Args: sample_size: Number of observations factor_specs: List of {'n_levels': int, 'proportions': [float, ...]} - seed: Random seed + seed: Random seed (callers pass sim_seed + 3) Returns: X_factors: (sample_size, total_dummies) array """ - if seed is not None: - np.random.seed(seed) + rng = np.random.RandomState(seed) if not factor_specs: return np.empty((sample_size, 0), dtype=float) @@ -141,7 +145,7 @@ def _generate_factors(sample_size, factor_specs, seed): for spec in factor_specs: n_levels = spec["n_levels"] proportions = spec["proportions"] - factor_data = np.random.choice(n_levels, size=sample_size, p=proportions) + factor_data = rng.choice(n_levels, size=sample_size, p=proportions) dummies = np.eye(n_levels, dtype=float)[factor_data] factor_columns.append(dummies[:, 1:]) @@ -170,12 +174,11 @@ def bootstrap_uploaded_data( X_non_factors: Non-factor variables (continuous + binary mapped to 0-1) X_factors: Factor dummy variables """ - if seed is not None: - np.random.seed(seed) + rng = np.random.RandomState(seed) # Bootstrap whole rows n_samples = raw_data.shape[0] - row_indices = np.random.choice(n_samples, size=sample_size, replace=True) + row_indices = rng.choice(n_samples, size=sample_size, replace=True) bootstrapped_data = raw_data[row_indices, :] # Separate by type @@ -286,12 +289,17 @@ def _generate_cluster_effects( Returns: X_cluster: (sample_size, n_cluster_vars) array of random effect columns """ - if sim_seed is not None: - # Use a derived seed to avoid collision with X generation seed - np.random.seed(sim_seed + 3) + rng = np.random.RandomState(sim_seed + 4 if sim_seed is not None else None) columns = [] + # Extract perturbation defaults once + perturb = lme_perturbations or {} + tau_mults = perturb.get("tau_squared_multipliers", {}) + re_dist_val = perturb.get("random_effect_dist", "normal") + re_df_val = perturb.get("random_effect_df", 5) + has_perturb = lme_perturbations is not None + for gv, spec in cluster_specs.items(): n_clusters = spec["n_clusters"] cluster_size = spec["cluster_size"] @@ -302,19 +310,16 @@ def _generate_cluster_effects( cluster_size = sample_size // n_clusters # Apply LME perturbations if present - if lme_perturbations is not None: - multiplier = lme_perturbations["tau_squared_multipliers"].get(gv, 1.0) - tau_sq = tau_sq * multiplier + if has_perturb: + tau_sq = tau_sq * tau_mults.get(gv, 1.0) tau = np.sqrt(tau_sq) # Generate random intercepts (possibly non-normal) - if lme_perturbations is not None: - re_dist = lme_perturbations.get("random_effect_dist", "normal") - re_df = lme_perturbations.get("random_effect_df", 5) - random_intercepts = _generate_non_normal_intercepts(n_clusters, tau, re_dist, re_df) + if has_perturb: + random_intercepts = _generate_non_normal_intercepts(n_clusters, tau, re_dist_val, re_df_val, rng_state=rng) else: - random_intercepts = np.random.normal(0, tau, size=n_clusters) + random_intercepts = rng.normal(0, tau, size=n_clusters) # Create id_effect column: repeat each cluster's intercept # cluster_id assignment: [0,0,...,0, 1,1,...,1, ..., K-1,K-1,...,K-1] @@ -413,14 +418,20 @@ def _generate_random_effects( A :class:`RandomEffectsResult` with intercept columns, slope contributions, cluster IDs, Z matrices, and nesting metadata. """ - if sim_seed is not None: - np.random.seed(sim_seed + 3) + rng = np.random.RandomState(sim_seed + 4 if sim_seed is not None else None) intercept_cols: List[np.ndarray] = [] slope_contribution = np.zeros(sample_size) cluster_ids_dict: Dict[str, np.ndarray] = {} Z_matrices: Dict[str, np.ndarray] = {} + # Extract perturbation defaults once (avoids repeated dict lookups) + perturb = lme_perturbations or {} + tau_multipliers = perturb.get("tau_squared_multipliers", {}) + re_dist = perturb.get("random_effect_dist", "normal") + re_df = perturb.get("random_effect_df", 5) + has_perturbations = lme_perturbations is not None + # Nested model bookkeeping child_to_parent: Optional[np.ndarray] = None K_parent = 0 @@ -460,19 +471,16 @@ def _generate_random_effects( # Apply LME perturbations: ICC jitter on tau_squared tau_sq = spec["tau_squared"] - if lme_perturbations is not None: - multiplier = lme_perturbations["tau_squared_multipliers"].get(gv, 1.0) - tau_sq = tau_sq * multiplier + if has_perturbations: + tau_sq = tau_sq * tau_multipliers.get(gv, 1.0) if q == 1: # --- Random intercept only --- tau = np.sqrt(tau_sq) - if lme_perturbations is not None: - re_dist = lme_perturbations.get("random_effect_dist", "normal") - re_df = lme_perturbations.get("random_effect_df", 5) - random_intercepts = _generate_non_normal_intercepts(n_clusters, tau, re_dist, re_df) + if has_perturbations: + random_intercepts = _generate_non_normal_intercepts(n_clusters, tau, re_dist, re_df, rng_state=rng) else: - random_intercepts = np.random.normal(0, tau, size=n_clusters) + random_intercepts = rng.normal(0, tau, size=n_clusters) id_effect = _trim_or_pad(np.repeat(random_intercepts, cluster_size), sample_size) intercept_cols.append(id_effect) @@ -482,33 +490,29 @@ def _generate_random_effects( slope_vars = spec.get("random_slope_vars", []) # Apply ICC jitter to G_matrix intercept variance - if lme_perturbations is not None: + if has_perturbations: ratio = tau_sq / spec["tau_squared"] if spec["tau_squared"] > 0 else 1.0 # Scale intercept row/column of G by sqrt(ratio) sqrt_ratio = np.sqrt(ratio) G_matrix[0, :] *= sqrt_ratio G_matrix[:, 0] *= sqrt_ratio - # Draw correlated [b_int, b_slope1, ...] per cluster - re_dist = lme_perturbations.get("random_effect_dist", "normal") if lme_perturbations else "normal" - re_df = lme_perturbations.get("random_effect_df", 5) if lme_perturbations else 5 - - if re_dist == "heavy_tailed" and lme_perturbations is not None: + if re_dist == "heavy_tailed" and has_perturbations: # Multivariate t: MVN(0, G * (df-2)/df) × sqrt(df / chi2(df)) df = max(re_df, 3) G_scaled = G_matrix * ((df - 2.0) / df) - b_normal = np.random.multivariate_normal(np.zeros(q), G_scaled, size=n_clusters) - chi2_samples = np.random.chisquare(df, size=n_clusters) + b_normal = rng.multivariate_normal(np.zeros(q), G_scaled, size=n_clusters) + chi2_samples = rng.chisquare(df, size=n_clusters) mixing = np.sqrt(df / chi2_samples) b = b_normal * mixing[:, np.newaxis] - elif re_dist == "skewed" and lme_perturbations is not None: + elif re_dist == "skewed" and has_perturbations: # Independent skewed marginals via shifted chi-squared, scaled by Cholesky df = max(re_df, 3) L = np.linalg.cholesky(G_matrix) - raw = (np.random.chisquare(df, size=(n_clusters, q)) - df) / np.sqrt(2 * df) + raw = (rng.chisquare(df, size=(n_clusters, q)) - df) / np.sqrt(2 * df) b = raw @ L.T else: - b = np.random.multivariate_normal(np.zeros(q), G_matrix, size=n_clusters) + b = rng.multivariate_normal(np.zeros(q), G_matrix, size=n_clusters) # Intercept component intercept_effect = _trim_or_pad(np.repeat(b[:, 0], cluster_size), sample_size) @@ -549,21 +553,19 @@ def _generate_random_effects( tau_sq_parent = p_spec["tau_squared"] tau_sq_child = c_spec["tau_squared"] - if lme_perturbations is not None: - tau_sq_parent *= lme_perturbations["tau_squared_multipliers"].get(p_gv, 1.0) - tau_sq_child *= lme_perturbations["tau_squared_multipliers"].get(c_gv, 1.0) + if has_perturbations: + tau_sq_parent *= tau_multipliers.get(p_gv, 1.0) + tau_sq_child *= tau_multipliers.get(c_gv, 1.0) tau_parent = np.sqrt(tau_sq_parent) tau_child = np.sqrt(tau_sq_child) - if lme_perturbations is not None: - re_dist = lme_perturbations.get("random_effect_dist", "normal") - re_df = lme_perturbations.get("random_effect_df", 5) - b_parent = _generate_non_normal_intercepts(K_parent, tau_parent, re_dist, re_df) - b_child = _generate_non_normal_intercepts(K_child, tau_child, re_dist, re_df) + if has_perturbations: + b_parent = _generate_non_normal_intercepts(K_parent, tau_parent, re_dist, re_df, rng_state=rng) + b_child = _generate_non_normal_intercepts(K_child, tau_child, re_dist, re_df, rng_state=rng) else: - b_parent = np.random.normal(0, tau_parent, size=K_parent) - b_child = np.random.normal(0, tau_child, size=K_child) + b_parent = rng.normal(0, tau_parent, size=K_parent) + b_child = rng.normal(0, tau_child, size=K_child) # IDs: parent_ids assigns each observation to a parent cluster, # child_ids assigns each observation to a child cluster. diff --git a/mcpower/stats/distributions.py b/mcpower/stats/distributions.py index cf13bca..6ee5265 100644 --- a/mcpower/stats/distributions.py +++ b/mcpower/stats/distributions.py @@ -3,9 +3,7 @@ Provides F, t, chi2, normal, and studentized range distribution functions plus batch critical-value computation and table generation. -Backend priority: - 1. C++ native (Boost.Math + R Tukey port) via mcpower_native - 2. scipy (optional shim, for when C++ is not compiled) +All functions are provided by the C++ native backend (Boost.Math + R Tukey port). Usage: from mcpower.stats.distributions import norm_ppf, compute_critical_values_ols @@ -14,13 +12,11 @@ import numpy as np # ============================================================================ -# Backend selection +# Backend — native C++ only # ============================================================================ -_BACKEND = None - try: - from mcpower.backends.mcpower_native import ( # type: ignore[import] + from mcpower.backends.mcpower_native import ( # type: ignore[import] # noqa: F401 chi2_cdf, chi2_ppf, compute_critical_values_lme, @@ -36,171 +32,17 @@ t_ppf, ) - _BACKEND = "native" - -except ImportError: - # ------------------------------------------------------------------- - # scipy shim -- temporary fallback for when C++ is not compiled. - # Will be removed when Python fallback backends are fully dropped. - # ------------------------------------------------------------------- - try: - from scipy.stats import ( # isort: skip - chi2 as _chi2_dist, - f as _f_dist, - norm as _norm_dist, - studentized_range as _sr_dist, - t as _t_dist, - ) - - def norm_ppf(p): # noqa: F811 - """Standard normal quantile function (inverse CDF).""" - return float(_norm_dist.ppf(p)) - - def norm_cdf(x): # noqa: F811 - """Standard normal CDF.""" - return float(_norm_dist.cdf(x)) - - def t_ppf(p, df): # noqa: F811 - """Student's t quantile function.""" - return float(_t_dist.ppf(p, df)) - - def f_ppf(p, dfn, dfd): # noqa: F811 - """Fisher F quantile function.""" - return float(_f_dist.ppf(p, dfn, dfd)) - - def chi2_ppf(p, df): # noqa: F811 - """Chi-squared quantile function.""" - return float(_chi2_dist.ppf(p, df)) - - def chi2_cdf(x, df): # noqa: F811 - """Chi-squared CDF.""" - return float(_chi2_dist.cdf(x, df)) - - def studentized_range_ppf(p, k, df): # noqa: F811 - """Studentized range quantile (Tukey). k=groups, df=denom df.""" - if df < 2 or k < 2 or k > 200 or p <= 0.0 or p >= 1.0: - return float("inf") - return float(_sr_dist.ppf(p, k, df)) - - def compute_critical_values_ols(alpha, dfn, dfd, n_targets, correction_method): # noqa: F811 - """Compute OLS critical values using scipy (fallback). - - Args: - alpha: Significance level. - dfn: Numerator degrees of freedom (number of predictors). - dfd: Denominator degrees of freedom (n - p - 1). - n_targets: Number of individual effects being tested. - correction_method: 0=none, 1=Bonferroni, 2=FDR (BH), 3=Holm. - - Returns: - Tuple of (f_crit, t_crit, correction_t_crits) where - correction_t_crits is an ndarray of length n_targets. - """ - if dfd <= 0: - return np.inf, np.inf, np.full(max(n_targets, 1), np.inf) - - f_crit = _f_dist.ppf(1 - alpha, dfn, dfd) if dfn > 0 else np.inf - t_crit = _t_dist.ppf(1 - alpha / 2, dfd) - - m = n_targets - if m == 0: - return f_crit, t_crit, np.empty(0) - - if correction_method == 0: # None - correction_t_crits = np.full(m, t_crit) - elif correction_method == 1: # Bonferroni - bonf_crit = _t_dist.ppf(1 - alpha / (2 * m), dfd) - correction_t_crits = np.full(m, bonf_crit) - elif correction_method == 2: # FDR (Benjamini-Hochberg) - correction_t_crits = np.array( - [_t_dist.ppf(1 - (k + 1) / m * alpha / 2, dfd) if (k + 1) / m * alpha / 2 >= 1e-12 else np.inf for k in range(m)] - ) - elif correction_method == 3: # Holm - correction_t_crits = np.array( - [_t_dist.ppf(1 - alpha / (2 * (m - k)), dfd) if alpha / (2 * (m - k)) >= 1e-12 else np.inf for k in range(m)] - ) - else: - correction_t_crits = np.full(m, t_crit) - - return f_crit, t_crit, correction_t_crits - - def compute_tukey_critical_value(alpha, n_levels, dfd): # noqa: F811 - """Compute Tukey HSD critical value (q / sqrt(2)).""" - if dfd <= 0: - return np.inf - q_crit = _sr_dist.ppf(1 - alpha, n_levels, dfd) - return q_crit / np.sqrt(2) - - def compute_critical_values_lme(alpha, n_fixed, n_targets, correction_method): # noqa: F811 - """Compute LME critical values using scipy (fallback). - - Args: - alpha: Significance level. - n_fixed: Number of fixed effects (excluding intercept). - n_targets: Number of individual effects being tested. - correction_method: 0=none, 1=Bonferroni, 2=FDR (BH), 3=Holm. - - Returns: - Tuple of (chi2_crit, z_crit, correction_z_crits) where - correction_z_crits is an ndarray of length n_targets. - """ - chi2_crit = _chi2_dist.ppf(1 - alpha, n_fixed) if n_fixed > 0 else np.inf - z_crit = _norm_dist.ppf(1 - alpha / 2) - - m = n_targets - if m == 0: - return chi2_crit, z_crit, np.empty(0) - - if correction_method == 0: # None - correction_z_crits = np.full(m, z_crit) - elif correction_method == 1: # Bonferroni - bonf = _norm_dist.ppf(1 - alpha / (2 * m)) - correction_z_crits = np.full(m, bonf) - elif correction_method == 2: # FDR (Benjamini-Hochberg) - correction_z_crits = np.array( - [_norm_dist.ppf(1 - (k + 1) / m * alpha / 2) if (k + 1) / m * alpha / 2 >= 1e-12 else np.inf for k in range(m)] - ) - elif correction_method == 3: # Holm - correction_z_crits = np.array( - [_norm_dist.ppf(1 - alpha / (2 * (m - k))) if alpha / (2 * (m - k)) >= 1e-12 else np.inf for k in range(m)] - ) - else: - correction_z_crits = np.full(m, z_crit) - - return chi2_crit, z_crit, correction_z_crits - - def generate_norm_cdf_table(x_min, x_max, resolution): # noqa: F811 - """Generate normal CDF lookup table.""" - x = np.linspace(x_min, x_max, resolution) - return _norm_dist.cdf(x).astype(np.float64) - - def generate_t3_ppf_table(perc_min, perc_max, resolution): # noqa: F811 - """Generate t(3) PPF lookup table (divided by sqrt(3)).""" - p = np.linspace(perc_min, perc_max, resolution) - return (_t_dist.ppf(p, 3) / np.sqrt(3)).astype(np.float64) - - def norm_ppf_array(percentiles): # noqa: F811 - """Vectorized normal PPF for percentile array.""" - return _norm_dist.ppf(np.asarray(percentiles)).astype(np.float64) - - _BACKEND = "scipy" - - except ImportError as exc: - raise ImportError( - "No distribution backend available. " - "Install from PyPI for prebuilt C++ wheels: pip install MCPower\n" - "Or install scipy as fallback: pip install scipy" - ) from exc +except ImportError as exc: + raise ImportError("Native C++ backend not available. Install from PyPI for prebuilt wheels: pip install MCPower") from exc # ============================================================================ -# Also re-export scipy optimizer shims for lme_solver.py -# These replace scipy.optimize.minimize and minimize_scalar +# Optimizer wrappers for lme_solver.py # ============================================================================ def minimize_lbfgsb(objective, x0, bounds, maxiter=200, ftol=1e-10, gtol=1e-6): - """L-BFGS-B minimization -- C++ native or scipy fallback. + """L-BFGS-B minimization via native C++ backend. Args: objective: Callable f(x) -> float @@ -213,53 +55,15 @@ def minimize_lbfgsb(objective, x0, bounds, maxiter=200, ftol=1e-10, gtol=1e-6): Returns: Object with .x (optimal point), .fun (optimal value), .converged (bool) """ - if _BACKEND == "native": - try: - from mcpower.backends.mcpower_native import lbfgsb_minimize_fd # type: ignore[import] - - lb = np.array([b[0] for b in bounds]) - ub = np.array([b[1] for b in bounds]) - return lbfgsb_minimize_fd(objective, np.asarray(x0, dtype=np.float64), lb, ub, maxiter, ftol, gtol) - except ImportError: - import warnings - - warnings.warn( - "Native L-BFGS-B optimizer not available despite native backend being loaded. Falling back to scipy.", - RuntimeWarning, - stacklevel=2, - ) - except Exception as e: - import warnings + from mcpower.backends.mcpower_native import lbfgsb_minimize_fd # type: ignore[import] - warnings.warn( - f"Native L-BFGS-B optimizer failed ({type(e).__name__}: {e}), falling back to scipy.", - RuntimeWarning, - stacklevel=2, - ) - - # scipy fallback - from scipy.optimize import minimize - - result = minimize( - objective, - x0, - method="L-BFGS-B", - bounds=bounds, - options={"maxiter": maxiter, "ftol": ftol, "gtol": gtol}, - ) - - class _Result: - __slots__ = ("x", "fun", "converged") - - r = _Result() - r.x = result.x - r.fun = result.fun - r.converged = result.success - return r + lb = np.array([b[0] for b in bounds]) + ub = np.array([b[1] for b in bounds]) + return lbfgsb_minimize_fd(objective, np.asarray(x0, dtype=np.float64), lb, ub, maxiter, ftol, gtol) def minimize_scalar_brent(objective, bounds, tol=1e-8, maxiter=150): - """Brent 1D minimization -- C++ native or scipy fallback. + """Brent 1D minimization via native C++ backend. Args: objective: Callable f(x) -> float @@ -270,43 +74,6 @@ def minimize_scalar_brent(objective, bounds, tol=1e-8, maxiter=150): Returns: Object with .x (optimal point), .fun (optimal value), .converged (bool) """ - if _BACKEND == "native": - try: - from mcpower.backends.mcpower_native import brent_minimize_scalar # type: ignore[import] - - return brent_minimize_scalar(objective, bounds[0], bounds[1], tol, maxiter) - except ImportError: - import warnings - - warnings.warn( - "Native Brent optimizer not available despite native backend being loaded. Falling back to scipy.", - RuntimeWarning, - stacklevel=2, - ) - except Exception as e: - import warnings - - warnings.warn( - f"Native Brent optimizer failed ({type(e).__name__}: {e}), falling back to scipy.", - RuntimeWarning, - stacklevel=2, - ) - - # scipy fallback - from scipy.optimize import minimize_scalar - - result = minimize_scalar( - objective, - bounds=bounds, - method="bounded", - options={"xatol": tol, "maxiter": maxiter}, - ) - - class _Result: - __slots__ = ("x", "fun", "converged") + from mcpower.backends.mcpower_native import brent_minimize_scalar # type: ignore[import] - r = _Result() - r.x = result.x - r.fun = result.fun - r.converged = bool(getattr(result, "success", True)) - return r + return brent_minimize_scalar(objective, bounds[0], bounds[1], tol, maxiter) diff --git a/mcpower/stats/mixed_models.py b/mcpower/stats/mixed_models.py index 414e911..07de5e8 100644 --- a/mcpower/stats/mixed_models.py +++ b/mcpower/stats/mixed_models.py @@ -11,10 +11,12 @@ import threading import warnings -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, Optional, Union import numpy as np +from ..backends.native import _prep + # Suppress statsmodels convergence warnings (expected with small samples/low ICC). # Module-level filterwarnings with module= is unreliable for statsmodels internals, # so we also use catch_warnings() context managers around .fit() calls below. @@ -30,7 +32,6 @@ def _lme_analysis_wrapper( y: np.ndarray, target_indices: np.ndarray, cluster_ids: np.ndarray, - cluster_column_indices: List[int], correction_method: int, alpha: float, backend: str = "custom", @@ -51,7 +52,6 @@ def _lme_analysis_wrapper( y: (n,) response vector target_indices: Coefficient indices to test (fixed effects only) cluster_ids: (n,) cluster membership array [0,0,0, 1,1,1, ...] - cluster_column_indices: Indices of cluster effect columns (unused) correction_method: 0=none, 1=Bonferroni, 2=FDR, 3=Holm alpha: Significance level backend: "custom" (default) or "statsmodels" (fallback) @@ -118,9 +118,7 @@ def _lme_analysis_wrapper( verbose=verbose, ) elif backend == "statsmodels": - return _lme_analysis_statsmodels( - X_expanded, y, target_indices, cluster_ids, cluster_column_indices, correction_method, alpha, verbose - ) + return _lme_analysis_statsmodels(X_expanded, y, target_indices, cluster_ids, correction_method, alpha, verbose) else: raise ValueError(f"Unknown backend: {backend}") @@ -130,7 +128,6 @@ def _lme_analysis_statsmodels( y: np.ndarray, target_indices: np.ndarray, cluster_ids: np.ndarray, - cluster_column_indices: List[int], correction_method: int, alpha: float, verbose: bool = False, @@ -145,11 +142,10 @@ def _lme_analysis_statsmodels( - Convergence retry strategy (allows ≤3% failures) Args: - X_expanded: (n, p) design matrix (includes cluster effect columns) + X_expanded: (n, p) design matrix (excludes cluster effect columns) y: (n,) response vector target_indices: Coefficient indices to test (fixed effects only) cluster_ids: (n,) cluster membership array - cluster_column_indices: Indices of cluster effect columns to remove correction_method: 0=none, 1=Bonferroni, 2=FDR, 3=Holm alpha: Significance level verbose: Return detailed diagnostics @@ -172,8 +168,6 @@ def _lme_analysis_statsmodels( n, p = X_expanded.shape n_targets = len(target_indices) - # Note: X_expanded already excludes cluster effects (they're not in the design matrix) - # cluster_column_indices is now unused in this function but kept for API compatibility X_fixed = X_expanded # Step 1: Add intercept to fixed effects @@ -530,6 +524,29 @@ def _compute_wald_test(result, alpha): return results_array +def _ensure_lme_crits(alpha, p, n_targets, correction_method, chi2_crit, z_crit, correction_z_crits): + """Compute LME critical values on-the-fly if not precomputed.""" + if z_crit is None or chi2_crit is None or correction_z_crits is None: + from .lme_solver import compute_lme_critical_values + + return compute_lme_critical_values(alpha, p, n_targets, correction_method) + return chi2_crit, z_crit, correction_z_crits + + +def _wrap_native_result(result, verbose, solver_name, extra_diag=None) -> Optional[Union[np.ndarray, Dict]]: + """Wrap C++ solver result with optional verbose diagnostics.""" + if len(result) > 0: + if verbose: + diag = {"solver": solver_name} + if extra_diag: + diag.update(extra_diag) + return {"results": result, "diagnostics": diag} + return np.asarray(result) + if verbose: + return {"results": None, "failure_reason": f"C++ {solver_name} returned empty result"} + return None + + def _lme_analysis_custom( X_expanded: np.ndarray, y: np.ndarray, @@ -545,39 +562,29 @@ def _lme_analysis_custom( """LME analysis for random-intercept models via C++ backend. Uses precomputed critical values (chi2_crit, z_crit) to avoid - per-simulation scipy calls. Falls back to computing them if not provided. + per-simulation distribution calls. Falls back to computing them if not provided. """ n, p = X_expanded.shape n_targets = len(target_indices) K = int(cluster_ids.max()) + 1 - if z_crit is None or chi2_crit is None or correction_z_crits is None: - from .lme_solver import compute_lme_critical_values - - chi2_crit, z_crit, correction_z_crits = compute_lme_critical_values(alpha, p, n_targets, correction_method) + chi2_crit, z_crit, correction_z_crits = _ensure_lme_crits(alpha, p, n_targets, correction_method, chi2_crit, z_crit, correction_z_crits) from mcpower.backends import mcpower_native as _native # type: ignore[attr-defined] result = _native.lme_analysis( - np.ascontiguousarray(X_expanded, dtype=np.float64), - np.ascontiguousarray(y, dtype=np.float64), - np.ascontiguousarray(cluster_ids, dtype=np.int32), + _prep(X_expanded), + _prep(y), + _prep(cluster_ids, np.int32), K, - np.ascontiguousarray(target_indices, dtype=np.int32), + _prep(target_indices, np.int32), float(chi2_crit), float(z_crit), - np.ascontiguousarray(correction_z_crits, dtype=np.float64), + _prep(correction_z_crits), int(correction_method), float(-1.0), ) - if len(result) > 0: - if verbose: - return {"results": result, "diagnostics": {"solver": "native_q1"}} - return result # type: ignore[no-any-return] - - if verbose: - return {"results": None, "failure_reason": "C++ solver returned empty result"} - return None + return _wrap_native_result(result, verbose, "native_q1") def _lme_analysis_custom_general( @@ -594,8 +601,6 @@ def _lme_analysis_custom_general( verbose: bool = False, ) -> Optional[Union[np.ndarray, Dict]]: """LME analysis for random slopes (q > 1) via C++ backend.""" - from .lme_solver import compute_lme_critical_values - n, p = X_expanded.shape n_targets = len(target_indices) @@ -604,34 +609,26 @@ def _lme_analysis_custom_general( q = Z.shape[1] K = int(cluster_ids.max()) + 1 - if z_crit is None or chi2_crit is None or correction_z_crits is None: - chi2_crit, z_crit, correction_z_crits = compute_lme_critical_values(alpha, p, n_targets, correction_method) + chi2_crit, z_crit, correction_z_crits = _ensure_lme_crits(alpha, p, n_targets, correction_method, chi2_crit, z_crit, correction_z_crits) from mcpower.backends import mcpower_native as _native # type: ignore[attr-defined] warm_theta_arr = np.empty(0, dtype=np.float64) result = _native.lme_analysis_general( - np.ascontiguousarray(X_expanded, dtype=np.float64), - np.ascontiguousarray(y, dtype=np.float64), - np.ascontiguousarray(Z, dtype=np.float64), - np.ascontiguousarray(cluster_ids, dtype=np.int32), + _prep(X_expanded), + _prep(y), + _prep(Z), + _prep(cluster_ids, np.int32), K, q, - np.ascontiguousarray(target_indices, dtype=np.int32), + _prep(target_indices, np.int32), float(chi2_crit), float(z_crit), - np.ascontiguousarray(correction_z_crits, dtype=np.float64), + _prep(correction_z_crits), int(correction_method), warm_theta_arr, ) - if len(result) > 0: - if verbose: - return {"results": result, "diagnostics": {"solver": "native_general", "q": q}} - return result # type: ignore[no-any-return] - - if verbose: - return {"results": None, "failure_reason": "C++ general solver returned empty result"} - return None + return _wrap_native_result(result, verbose, "native_general", extra_diag={"q": q}) def _lme_analysis_custom_nested( @@ -647,8 +644,6 @@ def _lme_analysis_custom_nested( verbose: bool = False, ) -> Optional[Union[np.ndarray, Dict]]: """LME analysis for nested random intercepts via C++ backend.""" - from .lme_solver import compute_lme_critical_values - n, p = X_expanded.shape n_targets = len(target_indices) @@ -658,35 +653,27 @@ def _lme_analysis_custom_nested( K_child = re_result.K_child child_to_parent = re_result.child_to_parent - if z_crit is None or chi2_crit is None or correction_z_crits is None: - chi2_crit, z_crit, correction_z_crits = compute_lme_critical_values(alpha, p, n_targets, correction_method) + chi2_crit, z_crit, correction_z_crits = _ensure_lme_crits(alpha, p, n_targets, correction_method, chi2_crit, z_crit, correction_z_crits) from mcpower.backends import mcpower_native as _native # type: ignore[attr-defined] warm_theta_arr = np.empty(0, dtype=np.float64) result = _native.lme_analysis_nested( - np.ascontiguousarray(X_expanded, dtype=np.float64), - np.ascontiguousarray(y, dtype=np.float64), - np.ascontiguousarray(parent_ids, dtype=np.int32), - np.ascontiguousarray(child_ids, dtype=np.int32), + _prep(X_expanded), + _prep(y), + _prep(parent_ids, np.int32), + _prep(child_ids, np.int32), K_parent, K_child, - np.ascontiguousarray(child_to_parent, dtype=np.int32), - np.ascontiguousarray(target_indices, dtype=np.int32), + _prep(child_to_parent, np.int32), + _prep(target_indices, np.int32), float(chi2_crit), float(z_crit), - np.ascontiguousarray(correction_z_crits, dtype=np.float64), + _prep(correction_z_crits), int(correction_method), warm_theta_arr, ) - if len(result) > 0: - if verbose: - return {"results": result, "diagnostics": {"solver": "native_nested", "K_parent": K_parent, "K_child": K_child}} - return result # type: ignore[no-any-return] - - if verbose: - return {"results": None, "failure_reason": "C++ nested solver returned empty result"} - return None + return _wrap_native_result(result, verbose, "native_nested", extra_diag={"K_parent": K_parent, "K_child": K_child}) def reset_warm_start_cache(): diff --git a/mcpower/tables/lookup.py b/mcpower/tables/lookup.py index b66fefa..5f4d691 100644 --- a/mcpower/tables/lookup.py +++ b/mcpower/tables/lookup.py @@ -15,7 +15,7 @@ class LookupTableManager: """Manages pre-computed lookup tables for data-generation transforms. Tables are lazily loaded from disk (``tables/data/*.npz``) on first - access and generated from scipy if the cache files are missing. + access and generated via the C++ native backend if the cache files are missing. The C++ native backend consumes these tables for distribution transforms. @@ -47,47 +47,37 @@ def ensure_data_dir(self) -> None: """Ensure data directory exists.""" self.data_dir.mkdir(parents=True, exist_ok=True) - def load_norm_cdf_table(self) -> np.ndarray: - """Load (or generate and cache) the normal CDF lookup table. + def _load_table(self, key: str, generate_fn) -> np.ndarray: + """Load a table from cache, disk, or generate it on the fly. + + Args: + key: Cache key and npz array name (e.g. ``"norm_cdf"``). + generate_fn: Bound method to generate and cache the table. Returns: 1-D float64 array of length ``DIST_RESOLUTION``. """ - if "norm_cdf" in self._tables: - return self._tables["norm_cdf"] - - cache_file = self.data_dir / "norm_cdf.npz" + if key in self._tables: + return self._tables[key] + cache_file = self.data_dir / f"{key}.npz" try: data = np.load(cache_file) - self._tables["norm_cdf"] = data["norm_cdf"] - return self._tables["norm_cdf"] + self._tables[key] = data[key] + return self._tables[key] except (FileNotFoundError, KeyError): pass - self._generate_norm_cdf_table() - return self._tables["norm_cdf"] - - def load_t3_ppf_table(self) -> np.ndarray: - """Load (or generate and cache) the t(df=3) PPF lookup table. + generate_fn() + return self._tables[key] - Returns: - 1-D float64 array of length ``DIST_RESOLUTION``. - """ - if "t3_ppf" in self._tables: - return self._tables["t3_ppf"] - - cache_file = self.data_dir / "t3_ppf.npz" - - try: - data = np.load(cache_file) - self._tables["t3_ppf"] = data["t3_ppf"] - return self._tables["t3_ppf"] - except (FileNotFoundError, KeyError): - pass + def load_norm_cdf_table(self) -> np.ndarray: + """Load (or generate and cache) the normal CDF lookup table.""" + return self._load_table("norm_cdf", self._generate_norm_cdf_table) - self._generate_t3_ppf_table() - return self._tables["t3_ppf"] + def load_t3_ppf_table(self) -> np.ndarray: + """Load (or generate and cache) the t(df=3) PPF lookup table.""" + return self._load_table("t3_ppf", self._generate_t3_ppf_table) def load_all_generation_tables(self) -> Tuple[np.ndarray, np.ndarray]: """ @@ -110,6 +100,8 @@ def _generate_norm_cdf_table(self) -> None: self._tables["norm_cdf"] = norm_cdf self.ensure_data_dir() + # Silently ignore cache write failures (e.g. read-only filesystem, + # permission denied). Tables are still usable from memory. try: np.savez_compressed(self.data_dir / "norm_cdf.npz", norm_cdf=norm_cdf, x_range=x_norm) except Exception: @@ -125,6 +117,8 @@ def _generate_t3_ppf_table(self) -> None: self._tables["t3_ppf"] = t3_ppf self.ensure_data_dir() + # Silently ignore cache write failures (e.g. read-only filesystem, + # permission denied). Tables are still usable from memory. try: np.savez_compressed( self.data_dir / "t3_ppf.npz", diff --git a/mcpower/utils/formatters.py b/mcpower/utils/formatters.py index b8bd0b0..8bc91b3 100644 --- a/mcpower/utils/formatters.py +++ b/mcpower/utils/formatters.py @@ -6,6 +6,7 @@ """ import math +from itertools import combinations from typing import Any, Dict, List, Optional import numpy as np @@ -13,6 +14,11 @@ __all__ = [] +def _is_nan(value) -> bool: + """Check if a value is NaN (float type check + math.isnan).""" + return isinstance(value, float) and math.isnan(value) + + class _TableFormatter: """Static helpers for building fixed-width text tables.""" @@ -25,7 +31,10 @@ def _create_table( """Create formatted table with headers and rows.""" if not col_widths: - col_widths = [max(len(str(h)), max(len(str(row[i])) + 2 for row in rows)) for i, h in enumerate(headers)] + if rows: + col_widths = [max(len(str(h)), max(len(str(row[i])) + 2 for row in rows)) for i, h in enumerate(headers)] + else: + col_widths = [len(str(h)) for h in headers] lines = [] @@ -131,7 +140,7 @@ def _format_short_power(self, data: Dict) -> str: for test in model["target_tests"]: power_corr = results["individual_powers_corrected"][test] - if isinstance(power_corr, float) and math.isnan(power_corr): + if _is_nan(power_corr): rows_corrected.append([test, "-", f"{target:.0f}", "-"]) else: status = "✓" if power_corr >= target else "✗" @@ -162,7 +171,7 @@ def _format_long_power(self, data: Dict) -> str: power = results["individual_powers"][test] power_corr = results.get("individual_powers_corrected", {}).get(test, power) target = model.get("target_power", 80.0) - if isinstance(power_corr, float) and math.isnan(power_corr): + if _is_nan(power_corr): rows.append([test, f"{power:.2f}", "-", f"{target:.1f}", "-"]) else: achieved = "✓" if power_corr >= target else "✗" @@ -331,13 +340,15 @@ def _format_scenario_power_short(self, scenarios: Dict, target_tests: List[str], lines = [f"\n{'=' * 80}", "SCENARIO SUMMARY", f"{'=' * 80}"] + scenario_names = list(scenarios.keys()) + headers = ["Test"] + [name.title() for name in scenario_names] + col_widths = [40] + [12] * len(scenario_names) + # Uncorrected table - headers = ["Test", "Optimistic", "Realistic", "Doomer"] rows = [] - for test in target_tests: row = [test] - for scenario in ["optimistic", "realistic", "doomer"]: + for scenario in scenario_names: if scenario in scenarios and "results" in scenarios[scenario]: power = scenarios[scenario]["results"]["individual_powers"][test] row.append(f"{power:.1f}") @@ -346,17 +357,17 @@ def _format_scenario_power_short(self, scenarios: Dict, target_tests: List[str], rows.append(row) lines.append("\nUncorrected Power:") - lines.append(self._table._create_table(headers, rows, [40, 12, 12, 12])) + lines.append(self._table._create_table(headers, rows, col_widths)) # Corrected table if applicable if correction: rows_corr = [] for test in target_tests: row = [test] - for scenario in ["optimistic", "realistic", "doomer"]: + for scenario in scenario_names: if scenario in scenarios and "results" in scenarios[scenario]: power_corr = scenarios[scenario]["results"]["individual_powers_corrected"][test] - if isinstance(power_corr, float) and math.isnan(power_corr): + if _is_nan(power_corr): row.append("-") else: row.append(f"{power_corr:.1f}") @@ -365,7 +376,7 @@ def _format_scenario_power_short(self, scenarios: Dict, target_tests: List[str], rows_corr.append(row) lines.append(f"\nCorrected Power ({correction}):") - lines.append(self._table._create_table(headers, rows_corr, [40, 12, 12, 12])) + lines.append(self._table._create_table(headers, rows_corr, col_widths)) lines.append(f"{'=' * 80}") @@ -395,74 +406,74 @@ def _format_scenario_power_long( lines.append("DETAILED SCENARIO RESULTS") lines.append(f"{'=' * 80}") - for scenario_name in ["optimistic", "realistic", "doomer"]: - if scenario_name in scenarios: - lines.append(f"\n{'-' * 80}") - lines.append(f"{scenario_name.upper()} SCENARIO") - lines.append(f"{'-' * 80}") - - # Use regular power formatter for each scenario - scenario_data = { - "model": scenarios[scenario_name]["model"], - "results": scenarios[scenario_name]["results"], - } - lines.append(self._format_long_power(scenario_data)) - - # 3. Comparison analysis - lines.append(f"\n{'=' * 80}") - lines.append("ROBUSTNESS ANALYSIS") - lines.append(f"{'=' * 80}") - - # Power reduction table - headers = ["Test", "Opt→Real Drop", "Opt→Doom Drop", "Vulnerability"] - rows = [] - vulnerable_tests = [] - inflated_tests = [] + for scenario_name in scenarios: + lines.append(f"\n{'-' * 80}") + lines.append(f"{scenario_name.upper()} SCENARIO") + lines.append(f"{'-' * 80}") + + scenario_data = { + "model": scenarios[scenario_name]["model"], + "results": scenarios[scenario_name]["results"], + } + lines.append(self._format_long_power(scenario_data)) + + # 3. Comparison analysis — compare each non-optimistic scenario to optimistic + if "optimistic" in scenarios and len(scenarios) > 1: + lines.append(f"\n{'=' * 80}") + lines.append("ROBUSTNESS ANALYSIS") + lines.append(f"{'=' * 80}") + + other_scenarios = [s for s in scenarios if s != "optimistic"] + headers = ["Test"] + [f"Opt→{s.title()} Drop" for s in other_scenarios] + ["Vulnerability"] + rows = [] + vulnerable_tests = [] + inflated_tests = [] - for test in target_tests: - opt_power = scenarios["optimistic"]["results"]["individual_powers"][test] - real_power = scenarios.get("realistic", {}).get("results", {}).get("individual_powers", {}).get(test, opt_power) - doom_power = scenarios.get("doomer", {}).get("results", {}).get("individual_powers", {}).get(test, opt_power) - - real_drop = opt_power - real_power - doom_drop = opt_power - doom_power - - # Format drops with proper signs - real_drop_str = f"+{abs(real_drop):.1f}%" if real_drop < 0 else f"-{real_drop:.1f}%" - doom_drop_str = f"+{abs(doom_drop):.1f}%" if doom_drop < 0 else f"-{doom_drop:.1f}%" - - # Vulnerability assessment and categorization - if doom_drop > HIGH_VULNERABILITY_THRESHOLD: - vulnerability = "HIGH" - vulnerable_tests.append(test) - elif doom_drop > MEDIUM_VULNERABILITY_THRESHOLD: - vulnerability = "MEDIUM" - elif doom_drop < INFLATED_ERROR_THRESHOLD: - vulnerability = "INFLATED FALSE POSITIVES" - inflated_tests.append(test) - else: - vulnerability = "LOW" + for test in target_tests: + opt_power = scenarios["optimistic"]["results"]["individual_powers"][test] + row = [test] + max_drop = 0.0 + + for scenario in other_scenarios: + other_power = scenarios.get(scenario, {}).get("results", {}).get("individual_powers", {}).get(test, opt_power) + drop = opt_power - other_power + max_drop = max(max_drop, drop) + drop_str = f"+{abs(drop):.1f}%" if drop < 0 else f"-{drop:.1f}%" + row.append(drop_str) + + if max_drop > HIGH_VULNERABILITY_THRESHOLD: + vulnerability = "HIGH" + vulnerable_tests.append(test) + elif max_drop > MEDIUM_VULNERABILITY_THRESHOLD: + vulnerability = "MEDIUM" + elif max_drop < INFLATED_ERROR_THRESHOLD: + vulnerability = "INFLATED FALSE POSITIVES" + inflated_tests.append(test) + else: + vulnerability = "LOW" - rows.append([test, real_drop_str, doom_drop_str, vulnerability]) + row.append(vulnerability) + rows.append(row) - lines.append(self._table._create_table(headers, rows)) + lines.append(self._table._create_table(headers, rows)) # 4. Recommendations - lines.append(f"\n{'=' * 80}") - lines.append("RECOMMENDATIONS") - lines.append(f"{'=' * 80}") + if "optimistic" in scenarios and len(scenarios) > 1: + lines.append(f"\n{'=' * 80}") + lines.append("RECOMMENDATIONS") + lines.append(f"{'=' * 80}") - if vulnerable_tests: - lines.append(f"• High vulnerability tests: {', '.join(vulnerable_tests)}") - lines.append("• Consider increasing sample size to maintain power under adverse conditions") + if vulnerable_tests: + lines.append(f"• High vulnerability tests: {', '.join(vulnerable_tests)}") + lines.append("• Consider increasing sample size to maintain power under adverse conditions") - if inflated_tests: - lines.append(f"• Inflated false positive risk: {', '.join(inflated_tests)}") - lines.append("• Be careful about interpretation") + if inflated_tests: + lines.append(f"• Inflated false positive risk: {', '.join(inflated_tests)}") + lines.append("• Be careful about interpretation") - if not vulnerable_tests and not inflated_tests: - lines.append("• Power analysis appears robust to assumption violations") - lines.append("• Original sample size should be sufficient") + if not vulnerable_tests and not inflated_tests: + lines.append("• Power analysis appears robust to assumption violations") + lines.append("• Original sample size should be sufficient") return "\n".join(lines) @@ -484,25 +495,22 @@ def _format_scenario_sample_size_short(self, scenarios: Dict, target_tests: List """Short scenario sample size summary.""" lines = [f"\n{'=' * 80}", "SCENARIO SUMMARY", f"{'=' * 80}"] + scenario_names = list(scenarios.keys()) if correction: # Combined table with uncorrected and corrected lines.append("\nSample Size Requirements:") - headers = [ - "Test", - "Opt(U)", - "Opt(C)", - "Real(U)", - "Real(C)", - "Doom(U)", - "Doom(C)", - ] + headers = ["Test"] + for name in scenario_names: + abbrev = name[:4].title() + headers.extend([f"{abbrev}(U)", f"{abbrev}(C)"]) + col_widths = [40] + [8] * (len(scenario_names) * 2) rows = [] for test in target_tests: - row = [test[:40]] # Truncate to 40 chars + row = [test[:40]] - for scenario in ["optimistic", "realistic", "doomer"]: + for scenario in scenario_names: if scenario in scenarios and "results" in scenarios[scenario]: n_uncorr = scenarios[scenario]["results"]["first_achieved"][test] n_corr = scenarios[scenario]["results"]["first_achieved_corrected"][test] @@ -520,16 +528,17 @@ def _format_scenario_sample_size_short(self, scenarios: Dict, target_tests: List row.extend(["N/A", "N/A"]) rows.append(row) - lines.append(self._table._create_table(headers, rows, [40, 8, 8, 8, 8, 8, 8])) + lines.append(self._table._create_table(headers, rows, col_widths)) lines.append("Note: (U) = Uncorrected, (C) = Corrected") else: # Uncorrected only - headers = ["Test", "Optimistic", "Realistic", "Doomer"] + headers = ["Test"] + [name.title() for name in scenario_names] + col_widths = [40] + [12] * len(scenario_names) rows = [] for test in target_tests: - row = [test[:40]] # Truncate to 40 chars - for scenario in ["optimistic", "realistic", "doomer"]: + row = [test[:40]] + for scenario in scenario_names: if scenario in scenarios and "results" in scenarios[scenario]: n_required = scenarios[scenario]["results"]["first_achieved"][test] if n_required > 0: @@ -542,7 +551,7 @@ def _format_scenario_sample_size_short(self, scenarios: Dict, target_tests: List rows.append(row) lines.append("\nUncorrected Sample Sizes:") - lines.append(self._table._create_table(headers, rows, [40, 12, 12, 12])) + lines.append(self._table._create_table(headers, rows, col_widths)) lines.append(f"{'=' * 80}") @@ -562,39 +571,33 @@ def _format_scenario_sample_size_long( # 1. Overall summary lines.append(self._format_scenario_sample_size_short(scenarios, target_tests, correction)) - # 2. Recommendations + # 2. Recommendations — summarize max N per non-optimistic scenario lines.append(f"\n{'=' * 80}") lines.append("RECOMMENDATIONS") lines.append(f"{'=' * 80}") - # Calculate max required N across scenarios - max_n_realistic = max( - (scenarios.get("realistic", {}).get("results", {}).get("first_achieved", {}).get(test, 0) for test in target_tests), - default=0, - ) - max_n_doomer = max( - (scenarios.get("doomer", {}).get("results", {}).get("first_achieved", {}).get(test, 0) for test in target_tests), - default=0, - ) - - max_tested = scenarios.get("realistic", {}).get("model", {}).get("sample_size_range", {}).get("to_size", 200) - - if max_n_realistic > 0 and max_n_realistic <= max_tested: - lines.append(f"• For robust power under realistic conditions: N = {max_n_realistic}") - elif max_n_realistic <= 0: - lines.append(f"• For robust power under realistic conditions: N > {max_tested}") - - if max_n_doomer > 0 and max_n_doomer <= max_tested: - lines.append(f"• For power under worst-case conditions: N = {max_n_doomer}") - elif max_n_doomer <= 0: - lines.append(f"• For power under worst-case conditions: N > {max_tested}") - - # Check if any tests couldn't achieve power - unachievable = [ - test for test in target_tests if scenarios.get("doomer", {}).get("results", {}).get("first_achieved", {}).get(test, -1) <= 0 - ] - if unachievable: - lines.append(f"• Warning: These tests may not achieve target power under adverse conditions: {', '.join(unachievable)}") + other_scenarios = [s for s in scenarios if s != "optimistic"] + for scenario in other_scenarios: + max_n = max( + (scenarios.get(scenario, {}).get("results", {}).get("first_achieved", {}).get(test, 0) for test in target_tests), + default=0, + ) + max_tested = scenarios.get(scenario, {}).get("model", {}).get("sample_size_range", {}).get("to_size", 200) + label = scenario.title() + + if max_n > 0 and max_n <= max_tested: + lines.append(f"• For power under {label} conditions: N = {max_n}") + elif max_n <= 0: + lines.append(f"• For power under {label} conditions: N > {max_tested}") + + # Check unachievable across worst scenario (last non-optimistic) + if other_scenarios: + worst = other_scenarios[-1] + unachievable = [ + test for test in target_tests if scenarios.get(worst, {}).get("results", {}).get("first_achieved", {}).get(test, -1) <= 0 + ] + if unachievable: + lines.append(f"• Warning: These tests may not achieve target power under {worst} conditions: {', '.join(unachievable)}") # Add cumulative probability analysis cumulative_lines = self._format_cumulative_recommendations(data, is_scenario=True) @@ -706,7 +709,7 @@ def _add_cumulative_sample_size_table( # Filter out tests with NaN power (e.g. non-contrast tests under Tukey correction) def _has_nan_power(t: str) -> bool: vals = powers_by_test[t] - return bool(vals and isinstance(vals[0], float) and math.isnan(vals[0])) + return bool(vals and _is_nan(vals[0])) valid_tests = [t for t in target_tests if not _has_nan_power(t)] if not valid_tests: @@ -741,8 +744,6 @@ def _has_nan_power(t: str) -> bool: else: # ≥k cases # Approximate using independence assumption prob_at_least_k = 0.0 - from itertools import combinations - # Sum over all ways to choose at least k tests for num_sig in range(k, n_tests + 1): for combo in combinations(range(n_tests), num_sig): @@ -859,6 +860,12 @@ def _format_cumulative_recommendations(self, results: Dict, is_scenario: bool = if prob >= target_power: min_n_target = sample_sizes[i] break + + if min_n_target: + lines.append(f"• N={min_n_target} for {target_power:.0f}% chance all tests significant") + else: + max_tested = sample_sizes[-1] + lines.append(f"• >{max_tested} needed for {target_power:.0f}% chance all tests significant") return lines diff --git a/mcpower/utils/parsers.py b/mcpower/utils/parsers.py index e89d140..c14533f 100644 --- a/mcpower/utils/parsers.py +++ b/mcpower/utils/parsers.py @@ -105,6 +105,8 @@ def _split_assignments(self, input_string: str) -> List[str]: paren_count += 1 elif char == ")": paren_count -= 1 + if paren_count < 0: + raise ValueError("Unbalanced parentheses: unexpected ')'") current.append(char) if current: @@ -424,7 +426,11 @@ def _parse_independent_variables(formula: str) -> Tuple[Dict, Dict]: """ from itertools import combinations - terms = re.split(r"[+\-]", formula) + # Check for minus sign (term removal) which is not supported + if re.search(r"(? Tuple[List[str], List[Dict]]: + """Extract effect names from a test formula, matched against the registry. + + Parses the test formula, expands factor variables to their dummies, + and returns the list of effect names (in registry order) that belong + to the test formula. + + Args: + test_formula: Formula string (e.g. ``"y ~ x1 + x2"``). + registry: ``VariableRegistry`` instance. + + Returns: + Tuple of ``(effect_names, random_effects)`` where *effect_names* + are the registry effect names present in the test formula (in + registry order), and *random_effects* is the list of parsed + random-effect dicts from the test formula. + """ + _dep_var, fixed_formula, random_effects = _parse_equation(test_formula) + + # Parse fixed effects into a set of term names + test_terms = _parse_fixed_terms(fixed_formula) + + # Determine which registry effects belong to the test formula + cluster_effects = set(registry.cluster_effect_names) + test_effects: List[str] = [] + + for effect_name in registry._effects: + if effect_name in cluster_effects: + continue + + effect = registry._effects[effect_name] + + if effect.effect_type == "main": + # Direct match (continuous or interaction-less variable) + if effect_name in test_terms: + test_effects.append(effect_name) + elif effect_name in registry._factor_dummies: + # Factor dummy -- include if parent factor is in test terms + parent_factor = registry._factor_dummies[effect_name]["factor_name"] + if parent_factor in test_terms: + test_effects.append(effect_name) + else: + # Interaction -- check if the interaction term is in test terms + if effect_name in test_terms: + test_effects.append(effect_name) + + return test_effects, random_effects + + +def _parse_fixed_terms(fixed_formula: str) -> Set[str]: + """Parse a fixed-effect formula string into a set of term names. + + Handles ``+`` for additive terms, ``:`` for specific interactions, + and ``*`` for full factorial expansion (main effects plus all + two-way through n-way interactions). + + Args: + fixed_formula: Right-hand side of the equation, spaces already + stripped by ``_parse_equation`` (e.g. ``"x1+x2+x1:x2"``). + + Returns: + Set of term names (variable names and interaction terms like + ``"x1:x2"``). + """ + if not fixed_formula.strip(): + return set() + + terms: Set[str] = set() + raw_terms = re.split(r"\+", fixed_formula) + + for raw in raw_terms: + raw = raw.strip() + if not raw: + continue + + if "*" in raw: + # Full factorial: x1*x2 -> x1, x2, x1:x2 + vars_in_star = [v.strip() for v in raw.split("*") if v.strip()] + for v in vars_in_star: + terms.add(v) + for r in range(2, len(vars_in_star) + 1): + for combo in combinations(vars_in_star, r): + terms.add(":".join(combo)) + else: + # Plain term (may contain ":" for explicit interaction) + terms.add(raw) + + return terms + + +def _compute_test_column_indices( + all_effect_names: List[str], + test_effect_names: List[str], +) -> np.ndarray: + """Compute column indices in X_expanded for test formula effects. + + Args: + all_effect_names: All non-cluster effect names in registry order. + test_effect_names: Effect names present in the test formula + (a subset of *all_effect_names*). + + Returns: + Integer array of column indices into X_expanded. + """ + test_set = set(test_effect_names) + indices = [i for i, name in enumerate(all_effect_names) if name in test_set] + return np.array(indices, dtype=np.int64) + + +def _remap_target_indices( + original_target_indices: np.ndarray, + test_column_indices: np.ndarray, +) -> np.ndarray: + """Remap target indices from full X_expanded space to X_test space. + + Args: + original_target_indices: Indices in X_expanded being tested. + test_column_indices: Columns of X_expanded included in X_test. + + Returns: + Indices remapped to positions within X_test. + """ + # Build mapping: full_index -> position in X_test + index_map = {int(full_idx): test_idx for test_idx, full_idx in enumerate(test_column_indices)} + return np.array( + [index_map[int(idx)] for idx in original_target_indices], + dtype=np.int64, + ) diff --git a/mcpower/utils/updates.py b/mcpower/utils/updates.py index 8c3c76b..c7f57a5 100644 --- a/mcpower/utils/updates.py +++ b/mcpower/utils/updates.py @@ -12,6 +12,8 @@ from datetime import datetime, timedelta from pathlib import Path +_already_checked = False + def _check_for_updates(current_version): """Check PyPI weekly for a newer MCPower version and warn if found. @@ -20,18 +22,24 @@ def _check_for_updates(current_version): silently in worker processes (detected via environment variable) and in frozen (PyInstaller) bundles where pip is unavailable. """ + global _already_checked # Skip in frozen bundles (PyInstaller) — the GUI has its own update checker if getattr(sys, "frozen", False): return + # Skip if already checked in this process + if _already_checked: + return + # Skip in worker processes (loky/joblib inherit env vars from parent) if os.environ.get("_MCPOWER_UPDATE_CHECKED"): return os.environ["_MCPOWER_UPDATE_CHECKED"] = "1" + _already_checked = True - cache_path = Path(__file__).parent.parent / ".mcpower_cache.json" - cache_path.parent.mkdir(exist_ok=True) + cache_path = Path.home() / ".cache" / "mcpower" / "update_cache.json" + cache_path.parent.mkdir(parents=True, exist_ok=True) # Load cache cache = {} @@ -57,9 +65,8 @@ def _check_for_updates(current_version): # Show update message only when PyPI version is strictly newer latest = cache.get("latest_version") - current = cache.get("current_version") - if latest and current and _is_newer(latest, current): - msg = f"\nNEW MCPower VERSION AVAILABLE: {latest} (you have {current})\nUpdate now: pip install --upgrade MCPower\n" + if latest and _is_newer(latest, current_version): + msg = f"\nNEW MCPower VERSION AVAILABLE: {latest} (you have {current_version})\nUpdate now: pip install --upgrade MCPower\n" warnings.warn(msg, stacklevel=3) @@ -77,7 +84,10 @@ def _get_latest_version(): """Fetch the latest MCPower version string from the PyPI JSON API.""" try: with urllib.request.urlopen("https://pypi.org/pypi/MCPower/json", timeout=5) as response: - data = json.loads(response.read()) + raw = response.read(1_000_000) + if len(raw) >= 1_000_000: + return None + data = json.loads(raw) return data["info"]["version"] except Exception: return None diff --git a/mcpower/utils/validators.py b/mcpower/utils/validators.py index 5853af6..1c344fd 100644 --- a/mcpower/utils/validators.py +++ b/mcpower/utils/validators.py @@ -27,6 +27,11 @@ class _ValidationResult: errors: List[str] warnings: List[str] + @classmethod + def from_errors(cls, errors: List[str], warnings: Optional[List[str]] = None) -> "_ValidationResult": + """Create a result from error/warning lists, deriving ``is_valid`` automatically.""" + return cls(len(errors) == 0, errors, warnings or []) + def raise_if_invalid(self): """Raise ``ValueError`` if the validation failed.""" if not self.is_valid: @@ -88,12 +93,12 @@ def _validate_numeric_parameter( errors.append(range_error) # Rounding warning for floats when int expected - if allow_rounding and isinstance(value, float) and (int, float) in expected_types: + if allow_rounding and isinstance(value, float) and int in expected_types: rounded = int(round(value)) if value != rounded: warnings.append(f"{name} rounded from {value} to {rounded}") - return _ValidationResult(len(errors) == 0, errors, warnings) + return _ValidationResult.from_errors(errors, warnings) def _validate_power(power: Any) -> _ValidationResult: @@ -112,6 +117,8 @@ def _validate_simulations(n_simulations: Any) -> Tuple[int, _ValidationResult]: if result.is_valid: rounded = int(round(n_simulations)) + # 800 simulations threshold: below this, Monte Carlo standard error + # exceeds ~1.5% for power near 50%, reducing result reliability. if rounded < 800: result.warnings.append(f"Low simulation count ({rounded}). Consider using at least 1000 for reliable results.") return rounded, result @@ -139,7 +146,7 @@ def _validate_sample_size(sample_size: Any) -> _ValidationResult: f"sample_size too large ({sample_size:,}). Maximum recommended: 100,000. We cannot guarantee stability for such small p-values." ) - return _ValidationResult(len(errors) == 0, errors, []) + return _ValidationResult.from_errors(errors) def _validate_sample_size_for_model(sample_size: int, n_variables: int) -> _ValidationResult: @@ -157,6 +164,8 @@ def _validate_sample_size_for_model(sample_size: int, n_variables: int) -> _Vali _ValidationResult with errors if sample size is insufficient. """ errors = [] + # Green's rule of thumb: N >= 15 + p for adequate power in regression, + # where p is the number of predictors (design matrix columns). min_required = 15 + n_variables if sample_size < min_required: @@ -165,7 +174,7 @@ def _validate_sample_size_for_model(sample_size: int, n_variables: int) -> _Vali f"variables. Minimum required: {min_required} (15 + {n_variables} variables)." ) - return _ValidationResult(len(errors) == 0, errors, []) + return _ValidationResult.from_errors(errors) def _validate_sample_size_range(from_size: Any, to_size: Any, by: Any) -> _ValidationResult: @@ -193,7 +202,7 @@ def _validate_sample_size_range(from_size: Any, to_size: Any, by: Any) -> _Valid if n_tests > 100: warnings.append(f"Large number of sample sizes to test ({n_tests}). This may take significant time.") - return _ValidationResult(len(errors) == 0, errors, warnings) + return _ValidationResult.from_errors(errors, warnings) def _validate_correlation_matrix( @@ -226,12 +235,14 @@ def _validate_correlation_matrix( # Positive semi-definite check try: eigenvals = np.linalg.eigvals(corr_matrix) + # -1e-8 tolerance for positive semi-definiteness: allows small negative + # eigenvalues from floating-point rounding in correlation matrices. if np.any(eigenvals < -1e-8): # Tolerance for floating point noise errors.append("Correlation matrix must be positive semi-definite. ") except np.linalg.LinAlgError: errors.append("Cannot compute eigenvalues of correlation matrix") - return _ValidationResult(len(errors) == 0, errors, []) + return _ValidationResult.from_errors(errors) def _validate_correction_method(correction: Optional[str]) -> _ValidationResult: @@ -285,7 +296,7 @@ def _validate_parallel_settings(enable: Any, n_cores: Optional[int]) -> Tuple[Tu else: validated_n_cores = min(n_cores, max_cores) - return (enable, validated_n_cores), _ValidationResult(len(errors) == 0, errors, []) + return (enable, validated_n_cores), _ValidationResult.from_errors(errors) def _validate_model_ready(model) -> _ValidationResult: @@ -301,9 +312,10 @@ def _validate_model_ready(model) -> _ValidationResult: errors: List[str] = [] warnings: List[str] = [] - # Check effect sizes - check if pending effects were set - has_effects = hasattr(model, "_pending_effects") and model._pending_effects is not None - if not has_effects: + # Check effect sizes — pending (pre-apply) or flagged as set by user + has_pending = hasattr(model, "_pending_effects") and model._pending_effects is not None + has_set = hasattr(model, "_effects_set") and model._effects_set + if not has_pending and not has_set: if hasattr(model, "_registry"): available = model._registry.effect_names errors.append( @@ -318,7 +330,7 @@ def _validate_model_ready(model) -> _ValidationResult: if not hasattr(model, attr): errors.append(f"Model missing required attribute: {attr}") - return _ValidationResult(len(errors) == 0, errors, warnings) + return _ValidationResult.from_errors(errors, warnings) def _validate_test_formula(test_formula: str, available_variables: List[str]) -> _ValidationResult: @@ -361,7 +373,7 @@ def _validate_test_formula(test_formula: str, available_variables: List[str]) -> f"Variables not found in original model: {', '.join(sorted(missing_vars))}. Available: {', '.join(available_variables)}" ) - return _ValidationResult(len(errors) == 0, errors, []) + return _ValidationResult.from_errors(errors) except Exception as e: errors.append(f"Error parsing test_formula: {str(e)}") @@ -399,6 +411,8 @@ def _validate_factor_specification(n_levels: int, proportions: List[float]) -> _ # Check if they sum to approximately 1 if not errors: # Only if no errors with individual proportions total = sum(proportions) + # 1e-6 tolerance: proportions are normalized later, so small deviations + # from 1.0 are acceptable and only warrant a warning. if abs(total - 1.0) > 1e-6: warnings.append(f"Proportions sum to {total:.4f}, not 1.0 (will be normalized)") @@ -406,7 +420,7 @@ def _validate_factor_specification(n_levels: int, proportions: List[float]) -> _ if n_levels > 10: warnings.append(f"Factor has {n_levels} levels. This creates {n_levels - 1} dummy variables, which may require large sample sizes") - return _ValidationResult(len(errors) == 0, errors, warnings) + return _ValidationResult.from_errors(errors, warnings) def _validate_upload_data(data: np.ndarray) -> _ValidationResult: @@ -425,7 +439,7 @@ def _validate_upload_data(data: np.ndarray) -> _ValidationResult: if data.shape[0] < 25: errors.append(f"Need at least 25 samples for reliable quantile matching, got {data.shape[0]}") - return _ValidationResult(len(errors) == 0, errors, []) + return _ValidationResult.from_errors(errors) def _validate_cluster_config( @@ -475,7 +489,7 @@ def _validate_cluster_config( if not isinstance(cluster_size, int) or cluster_size < 5: errors.append(f"cluster_size must be an integer >= 5 for reliable mixed model estimation. Got {cluster_size}.") - return _ValidationResult(len(errors) == 0, errors, warnings) + return _ValidationResult.from_errors(errors, warnings) def _validate_cluster_sample_size( @@ -517,4 +531,4 @@ def _validate_cluster_sample_size( f"Small cluster sizes may cause convergence issues or biased variance estimates." ) - return _ValidationResult(len(errors) == 0, errors, warnings) + return _ValidationResult.from_errors(errors, warnings) diff --git a/mcpower/utils/visualization.py b/mcpower/utils/visualization.py index 22544a2..ab7eadd 100644 --- a/mcpower/utils/visualization.py +++ b/mcpower/utils/visualization.py @@ -18,6 +18,7 @@ def _create_power_plot( target_tests: List[str], target_power: float, title: str, + show: bool = True, ): """Create a sample-size vs. power line plot with achievement markers. @@ -58,7 +59,7 @@ def _create_power_plot( ) # Mark achievement point - if first_achieved[test] > 0: + if first_achieved[test] > 0 and first_achieved[test] in sample_sizes: achieved_idx = sample_sizes.index(first_achieved[test]) achieved_power = powers[achieved_idx] ax.plot( @@ -112,4 +113,6 @@ def _create_power_plot( color="#888888", ) plt.tight_layout(rect=(0, 0.03, 1, 1)) - plt.show() + if show: + plt.show() + return fig diff --git a/pyproject.toml b/pyproject.toml index 983e3d3..ec1f41f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,14 @@ [build-system] requires = [ - "scikit-build-core>=0.5", + "scikit-build-core>=0.10", "pybind11>=2.11", - "numpy>=2.0.0", + "numpy>=1.26.0", ] build-backend = "scikit_build_core.build" [project] name = "MCPower" -version = "0.5.4" +version = "0.6.0" description = "Monte Carlo Power Analysis for Statistical Models" readme = "README.md" license = {text = "GPL-3.0-or-later"} @@ -31,9 +31,10 @@ classifiers = [ ] requires-python = ">=3.10" dependencies = [ - "numpy>=2.0.0", + "numpy>=1.26.0", "matplotlib>=3.8.0", "joblib>=1.3.0", + "tqdm>=4.60.0", ] [project.optional-dependencies] @@ -41,6 +42,7 @@ lme = ["statsmodels>=0.14.0"] pandas = ["pandas>=2.0.0"] dev = [ "pandas>=2.0.0", + "statsmodels>=0.14.0", "pytest>=7.0.0", "pytest-cov>=4.0.0", "scipy>=1.11.0", @@ -52,14 +54,14 @@ dev = [ ] all = [ "pandas>=2.0.0", - "statsmodels>=0.14.0", -] + ] [project.urls] Homepage = "https://github.com/pawlenartowicz/MCPower" -Documentation = "https://github.com/pawlenartowicz/MCPower#readme" +Documentation = "https://github.com/pawlenartowicz/MCPower/wiki" Repository = "https://github.com/pawlenartowicz/MCPower" Issues = "https://github.com/pawlenartowicz/MCPower/issues" +Changelog = "https://github.com/pawlenartowicz/MCPower/blob/main/CHANGELOG.md" [tool.scikit-build] wheel.packages = ["mcpower"] @@ -77,8 +79,6 @@ python_files = ["test_*.py"] python_classes = ["Test*"] python_functions = ["test_*"] markers = [ - "unit: Unit tests", - "integration: Integration tests", "lme: LME mixed-effects model tests", ] addopts = "-v --tb=short --strict-markers" @@ -86,7 +86,6 @@ filterwarnings = [ "ignore::FutureWarning", "ignore::DeprecationWarning", "ignore::UserWarning:statsmodels", - "ignore:Mixed-effects models are experimental:UserWarning", ] [tool.ruff] @@ -107,6 +106,20 @@ known-first-party = ["mcpower"] python_version = "3.10" warn_return_any = true warn_unused_configs = true -ignore_missing_imports = true check_untyped_defs = true exclude = ["build", "dist", "tests"] + +[[tool.mypy.overrides]] +module = [ + "mcpower_native", + "mcpower_native.*", + "statsmodels", + "statsmodels.*", + "tqdm", + "tqdm.*", + "joblib", + "joblib.*", + "pandas", + "pandas.*", +] +ignore_missing_imports = true diff --git a/tests/config.py b/tests/config.py index 63c1999..a8b874c 100644 --- a/tests/config.py +++ b/tests/config.py @@ -5,9 +5,21 @@ across the test suite. """ -# Monte Carlo simulation parameters -N_SIMS = 5000 -"""Number of Monte Carlo simulations for power analysis tests.""" +# Monte Carlo simulation parameters — 4-tier ladder +N_SIMS_CHECK = 50 +"""Smoke tests — just verify no crash, structure, API contract.""" + +N_SIMS_ORDERING = 1000 +"""Ordering tests — monotonicity, correction hierarchy, A < B checks.""" + +N_SIMS_STANDARD = 1600 +"""Standard tests — null calibration, Type I error, general validation.""" + +N_SIMS_ACCURACY = 5000 +"""Accuracy tests — comparison against analytical power formulas.""" + +N_SIMS = N_SIMS_ACCURACY +"""Backward-compat alias for accuracy-level simulations.""" SEED = 2137 """Default random seed for reproducibility.""" diff --git a/tests/conftest.py b/tests/conftest.py index 93c8814..d2100b5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -63,12 +63,6 @@ def correlation_matrix_2x2(): return np.array([[1.0, 0.5], [0.5, 1.0]]) -@pytest.fixture -def correlation_matrix_3x3(): - """Create a 3x3 correlation matrix.""" - return np.array([[1.0, 0.3, 0.2], [0.3, 1.0, 0.4], [0.2, 0.4, 1.0]]) - - @pytest.fixture def sample_data(): """Create sample empirical data.""" @@ -80,41 +74,13 @@ def sample_data(): @pytest.fixture -def suppress_output(capsys): - """Suppress print output during tests by capturing it.""" - yield - # Output is automatically captured by capsys - - -BACKENDS = ["c++"] - - -@pytest.fixture(params=BACKENDS) -def backend(request): - """ - Force MCPower to run on a specific backend. - - Parametrizes tests against C++ (primary backend). - Automatically resets backend after each test. - """ - from mcpower.backends import reset_backend, set_backend - - set_backend(request.param) - yield request.param - reset_backend() - - -@pytest.fixture(autouse=True) -def reset_backend_after_test(): - """ - Automatically reset backend to default after every test. - - Ensures no hidden backend state leaks between tests. - """ - yield - from mcpower.backends import reset_backend +def suppress_output(): + """Suppress print output during tests.""" + import contextlib + import io - reset_backend() + with contextlib.redirect_stdout(io.StringIO()): + yield def _statsmodels_available(): diff --git a/tests/helpers/power_helpers.py b/tests/helpers/power_helpers.py index d509a79..620e995 100644 --- a/tests/helpers/power_helpers.py +++ b/tests/helpers/power_helpers.py @@ -40,42 +40,3 @@ def compute_crits(X, target_indices, alpha=DEFAULT_ALPHA, correction_method=0): return compute_critical_values(alpha, p, dof, n_targets, correction_method) -def run_with_backend( - backend_name, - equation, - effects_str, - sample_size, - n_sims, - seed, - target_test="all", - correction=None, - correlations_str=None, - alpha=DEFAULT_ALPHA, -): - """Run a full MCPower power analysis with a specific backend forced.""" - import contextlib - import io - - from mcpower import MCPower - from mcpower.backends import reset_backend, set_backend - - set_backend(backend_name) - try: - m = MCPower(equation) - m.set_simulations(n_sims) - m.set_seed(seed) - m.set_alpha(alpha) - m.set_effects(effects_str) - if correlations_str: - m.set_correlations(correlations_str) - with contextlib.redirect_stdout(io.StringIO()): - result = m.find_power( - sample_size=sample_size, - target_test=target_test, - correction=correction, - print_results=False, - return_results=True, - ) - finally: - reset_backend() - return result diff --git a/tests/integration/test_find_power_api.py b/tests/integration/test_find_power_api.py index 69f6bc5..fe56de3 100644 --- a/tests/integration/test_find_power_api.py +++ b/tests/integration/test_find_power_api.py @@ -222,14 +222,14 @@ def test_all_targets(self, suppress_output): class TestHeterogeneity: - """Test heterogeneity settings.""" + """Test heterogeneity via scenario configs.""" def test_with_heterogeneity(self, suppress_output): from mcpower import MCPower model = MCPower("y = x1 + x2") model.set_effects("x1=0.3, x2=0.2") - model.set_heterogeneity(0.1) + model.set_scenario_configs({"het": {"heterogeneity": 0.1}}) result = model.find_power(100, print_results=False, return_results=True) assert result is not None @@ -239,7 +239,7 @@ def test_with_heteroskedasticity(self, suppress_output): model = MCPower("y = x1 + x2") model.set_effects("x1=0.3, x2=0.2") - model.set_heteroskedasticity(0.2) + model.set_scenario_configs({"hsked": {"heteroskedasticity": 0.2}}) result = model.find_power(100, print_results=False, return_results=True) assert result is not None @@ -249,8 +249,7 @@ def test_combined(self, suppress_output): model = MCPower("y = x1 + x2") model.set_effects("x1=0.3, x2=0.2") - model.set_heterogeneity(0.1) - model.set_heteroskedasticity(0.2) + model.set_scenario_configs({"combo": {"heterogeneity": 0.1, "heteroskedasticity": 0.2}}) result = model.find_power(100, print_results=False, return_results=True) assert result is not None @@ -330,7 +329,7 @@ def test_all_features_combined(self, suppress_output): model.upload_data({"x1": np.random.exponential(2, 100)}) model.set_correlations("(x1,x2)=0.3") model.set_effects("group[2]=0.4, group[3]=0.3, x1=0.2, x2=0.15, x1:x2=0.1") - model.set_heterogeneity(0.05) + model.set_scenario_configs({"test": {"heterogeneity": 0.05}}) result = model.find_power(200, print_results=False, return_results=True) assert result is not None diff --git a/tests/integration/test_model.py b/tests/integration/test_model.py index da00d33..a57b051 100644 --- a/tests/integration/test_model.py +++ b/tests/integration/test_model.py @@ -86,16 +86,6 @@ def test_set_variable_type(self, suppress_output): assert model._pending_variable_types == "group=(factor,3)" assert model._applied is False - def test_set_heterogeneity(self, simple_model): - simple_model.set_heterogeneity(0.1) - assert simple_model._pending_heterogeneity == 0.1 - assert simple_model._applied is False - - def test_set_heteroskedasticity(self, simple_model): - simple_model.set_heteroskedasticity(0.2) - assert simple_model._pending_heteroskedasticity == 0.2 - assert simple_model._applied is False - def test_upload_data_dict(self, simple_model, sample_data): simple_model.upload_data(sample_data) assert simple_model._pending_data is not None @@ -131,12 +121,12 @@ class TestApply: """Test apply() method.""" def test_apply_sets_flag(self, configured_model): - configured_model.apply() + configured_model._apply() assert configured_model._applied is True def test_apply_processes_effects(self, simple_model): simple_model.set_effects("x1=0.5, x2=0.3") - simple_model.apply() + simple_model._apply() effect_sizes = simple_model._registry.get_effect_sizes() assert effect_sizes[0] == 0.5 assert effect_sizes[1] == 0.3 @@ -147,22 +137,17 @@ def test_apply_processes_variable_types(self, suppress_output): model = MCPower("y = group + x1") model.set_variable_type("group=(factor,3)") model.set_effects("group[2]=0.4, group[3]=0.3, x1=0.2") - model.apply() + model._apply() assert len(model._registry.factor_names) == 1 assert len(model._registry.dummy_names) == 2 def test_apply_processes_correlations(self, simple_model): simple_model.set_effects("x1=0.3, x2=0.2") simple_model.set_correlations("(x1,x2)=0.5") - simple_model.apply() + simple_model._apply() corr = simple_model.correlation_matrix assert corr[0, 1] == 0.5 - def test_apply_processes_heterogeneity(self, configured_model): - configured_model.set_heterogeneity(0.15) - configured_model.apply() - assert configured_model.heterogeneity == 0.15 - def test_apply_order_independence(self, suppress_output): """Test that set_* methods can be called in any order.""" from mcpower import MCPower @@ -172,14 +157,14 @@ def test_apply_order_independence(self, suppress_output): m1.set_effects("group[2]=0.4, group[3]=0.3, x1=0.2, x2=0.1") m1.set_variable_type("group=(factor,3)") m1.set_correlations("(x1,x2)=0.5") - m1.apply() + m1._apply() # Order 2: variable_type, correlations, effects m2 = MCPower("y = group + x1 + x2") m2.set_variable_type("group=(factor,3)") m2.set_correlations("(x1,x2)=0.5") m2.set_effects("group[2]=0.4, group[3]=0.3, x1=0.2, x2=0.1") - m2.apply() + m2._apply() # Both should have same effect sizes assert np.allclose(m1._registry.get_effect_sizes(), m2._registry.get_effect_sizes()) @@ -242,7 +227,7 @@ def test_sample_sizes_tested(self, configured_model): assert result["results"]["sample_sizes_tested"] == [50, 75, 100] def test_first_achieved(self, configured_model): - result = configured_model.find_sample_size(from_size=50, to_size=200, by=25, print_results=False, return_results=True) + result = configured_model.find_sample_size(from_size=50, to_size=200, by=50, print_results=False, return_results=True) assert "first_achieved" in result["results"] def test_find_sample_size_runs(self, configured_model): @@ -257,7 +242,7 @@ class TestErrors: def test_invalid_effect_name(self, simple_model): simple_model.set_effects("invalid=0.3") with pytest.raises(ValueError, match="not found"): - simple_model.apply() + simple_model._apply() def test_missing_effects(self, simple_model): with pytest.raises(ValueError, match="Effect sizes must be set"): @@ -290,7 +275,7 @@ def test_basic_named_levels(self): model = MCPower("y = treatment + x1") model.set_factor_levels("treatment=placebo,drug_a,drug_b") model.set_effects("treatment[drug_a]=0.5, treatment[drug_b]=0.8, x1=0.3") - model.apply() + model._apply() assert "treatment" in model._registry.factor_names assert "treatment[drug_a]" in model._registry.dummy_names assert "treatment[drug_b]" in model._registry.dummy_names @@ -302,7 +287,7 @@ def test_multiple_factors(self): model = MCPower("y = group + dose") model.set_factor_levels("group=control,treatment; dose=low,medium,high") model.set_effects("group[treatment]=0.5, dose[medium]=0.3, dose[high]=0.6") - model.apply() + model._apply() assert "group[treatment]" in model._registry.dummy_names assert "dose[medium]" in model._registry.dummy_names assert "dose[high]" in model._registry.dummy_names @@ -313,7 +298,7 @@ def test_unknown_variable_raises(self): model = MCPower("y = x1") with pytest.raises(ValueError, match="not found"): model.set_factor_levels("unknown=a,b,c") - model.apply() + model._apply() def test_single_level_raises(self): from mcpower import MCPower @@ -321,7 +306,7 @@ def test_single_level_raises(self): model = MCPower("y = x1") with pytest.raises(ValueError, match="at least 2"): model.set_factor_levels("x1=only_one") - model.apply() + model._apply() def test_find_power_with_named_levels(self): """End-to-end: find_power works with set_factor_levels.""" diff --git a/tests/integration/test_parallel.py b/tests/integration/test_parallel.py index 682372c..2048d7e 100644 --- a/tests/integration/test_parallel.py +++ b/tests/integration/test_parallel.py @@ -4,6 +4,8 @@ import pytest +from tests.config import N_SIMS_CHECK + def _joblib_available(): """Check if joblib is available.""" @@ -26,6 +28,7 @@ def test_parallel_results_match_sequential(self, suppress_output): model = MCPower("y = x1 + x2") model.set_effects("x1=0.3, x2=0.2") model.set_seed(42) + model.set_simulations(N_SIMS_CHECK) # Run sequential analysis model.set_parallel(False) @@ -56,6 +59,7 @@ def test_parallel_with_scenarios(self, suppress_output): model = MCPower("y = x1 + x2") model.set_effects("x1=0.3, x2=0.2") model.set_seed(42) + model.set_simulations(N_SIMS_CHECK) # Run sequential with scenarios model.set_parallel(False) @@ -93,6 +97,7 @@ def test_parallel_with_interactions(self, suppress_output): model = MCPower("y = a + b + a:b") model.set_effects("a=0.4, b=0.3, a:b=0.2") model.set_seed(42) + model.set_simulations(N_SIMS_CHECK) # Run sequential model.set_parallel(False) @@ -128,6 +133,7 @@ def test_parallel_fallback_on_failure(self, suppress_output, monkeypatch): model = MCPower("y = x1 + x2") model.set_effects("x1=0.3, x2=0.2") model.set_seed(42) + model.set_simulations(N_SIMS_CHECK) model.set_parallel(True, n_cores=2) # Mock joblib.Parallel to raise an exception @@ -157,6 +163,7 @@ def test_find_power_ignores_parallel(self, suppress_output): model = MCPower("y = x1 + x2") model.set_effects("x1=0.3, x2=0.2") model.set_seed(42) + model.set_simulations(N_SIMS_CHECK) # Run with parallel=False model.set_parallel(False) diff --git a/tests/integration/test_posthoc_integration.py b/tests/integration/test_posthoc_integration.py index 9499388..b6b7f21 100644 --- a/tests/integration/test_posthoc_integration.py +++ b/tests/integration/test_posthoc_integration.py @@ -15,7 +15,7 @@ def test_parse_vs_syntax(self, suppress_output): model = MCPower("y = group + x1") model.set_variable_type("group=(factor,3)") model.set_effects("group[2]=0.4, group[3]=0.3, x1=0.2") - model.apply() + model._apply() tests = model._parse_target_tests("group[1] vs group[2]") assert "group[1] vs group[2]" in tests @@ -27,7 +27,7 @@ def test_parse_multiple_vs(self, suppress_output): model = MCPower("y = group + x1") model.set_variable_type("group=(factor,3)") model.set_effects("group[2]=0.4, group[3]=0.3, x1=0.2") - model.apply() + model._apply() tests = model._parse_target_tests("group[1] vs group[2], group[2] vs group[3]") assert "group[1] vs group[2]" in tests @@ -40,7 +40,7 @@ def test_parse_mixed_regular_and_posthoc(self, suppress_output): model = MCPower("y = group + x1") model.set_variable_type("group=(factor,3)") model.set_effects("group[2]=0.4, group[3]=0.3, x1=0.2") - model.apply() + model._apply() tests = model._parse_target_tests("overall, group[1] vs group[2]") assert "overall" in tests @@ -52,7 +52,7 @@ def test_all_does_not_include_posthoc(self, suppress_output): model = MCPower("y = group + x1") model.set_variable_type("group=(factor,3)") model.set_effects("group[2]=0.4, group[3]=0.3, x1=0.2") - model.apply() + model._apply() tests = model._parse_target_tests("all") # "all" should NOT include any post-hoc comparisons @@ -66,7 +66,7 @@ def test_invalid_factor_name(self, suppress_output): model = MCPower("y = group + x1") model.set_variable_type("group=(factor,3)") model.set_effects("group[2]=0.4, group[3]=0.3, x1=0.2") - model.apply() + model._apply() with pytest.raises(ValueError, match="Factor.*not found"): model._parse_target_tests("notafactor[1] vs notafactor[2]") @@ -77,7 +77,7 @@ def test_invalid_level(self, suppress_output): model = MCPower("y = group + x1") model.set_variable_type("group=(factor,3)") model.set_effects("group[2]=0.4, group[3]=0.3, x1=0.2") - model.apply() + model._apply() with pytest.raises(ValueError, match="out of range"): model._parse_target_tests("group[0] vs group[5]") @@ -88,7 +88,7 @@ def test_same_level_comparison_rejected(self, suppress_output): model = MCPower("y = group + x1") model.set_variable_type("group=(factor,3)") model.set_effects("group[2]=0.4, group[3]=0.3, x1=0.2") - model.apply() + model._apply() with pytest.raises(ValueError, match="Cannot compare a level to itself"): model._parse_target_tests("group[2] vs group[2]") @@ -99,7 +99,7 @@ def test_cross_factor_comparison_rejected(self, suppress_output): model = MCPower("y = a + b") model.set_variable_type("a=(factor,3), b=(factor,2)") model.set_effects("a[2]=0.3, a[3]=0.2, b[2]=0.1") - model.apply() + model._apply() with pytest.raises(ValueError, match="same factor"): model._parse_target_tests("a[1] vs b[1]") @@ -396,7 +396,7 @@ def test_all_posthoc_keyword(self, suppress_output): model = MCPower("y = group + x1") model.set_variable_type("group=(factor,3)") model.set_effects("group[2]=0.4, group[3]=0.3, x1=0.2") - model.apply() + model._apply() tests = model._parse_target_tests("all-posthoc") # 3-level factor → C(3,2) = 3 pairs @@ -415,7 +415,7 @@ def test_all_plus_all_posthoc(self, suppress_output): model = MCPower("y = group + x1") model.set_variable_type("group=(factor,3)") model.set_effects("group[2]=0.4, group[3]=0.3, x1=0.2") - model.apply() + model._apply() tests = model._parse_target_tests("all, all-posthoc") # "all" → overall + group[2] + group[3] + x1 = 4 @@ -434,7 +434,7 @@ def test_all_posthoc_multiple_factors(self, suppress_output): model = MCPower("y = a + b") model.set_variable_type("a=(factor,3), b=(factor,2)") model.set_effects("a[2]=0.3, a[3]=0.2, b[2]=0.1") - model.apply() + model._apply() tests = model._parse_target_tests("all-posthoc") # a: C(3,2)=3, b: C(2,2)=1 → 4 total @@ -448,7 +448,7 @@ def test_all_posthoc_no_factors_with_all(self, suppress_output): model = MCPower("y = x1 + x2") model.set_effects("x1=0.5, x2=0.3") - model.apply() + model._apply() tests = model._parse_target_tests("all, all-posthoc") assert "overall" in tests @@ -461,7 +461,7 @@ def test_all_posthoc_alone_no_factors_raises(self, suppress_output): model = MCPower("y = x1 + x2") model.set_effects("x1=0.5, x2=0.3") - model.apply() + model._apply() with pytest.raises(ValueError, match="no factor variables"): model._parse_target_tests("all-posthoc") @@ -472,7 +472,7 @@ def test_exclusion_removes_test(self, suppress_output): model = MCPower("y = x1 + x2") model.set_effects("x1=0.5, x2=0.3") - model.apply() + model._apply() tests = model._parse_target_tests("all, -overall") assert "overall" not in tests @@ -486,7 +486,7 @@ def test_exclusion_posthoc(self, suppress_output): model = MCPower("y = group + x1") model.set_variable_type("group=(factor,3)") model.set_effects("group[2]=0.4, group[3]=0.3, x1=0.2") - model.apply() + model._apply() tests = model._parse_target_tests("all-posthoc, -group[1] vs group[2]") assert "group[1] vs group[2]" not in tests @@ -500,7 +500,7 @@ def test_exclusion_invalid_raises(self, suppress_output): model = MCPower("y = x1 + x2") model.set_effects("x1=0.5, x2=0.3") - model.apply() + model._apply() with pytest.raises(ValueError, match="does not match"): model._parse_target_tests("all, -nonexistent") @@ -511,7 +511,7 @@ def test_exclusion_all_raises(self, suppress_output): model = MCPower("y = x1 + x2") model.set_effects("x1=0.5, x2=0.3") - model.apply() + model._apply() with pytest.raises(ValueError, match="nothing left"): model._parse_target_tests("all, -overall, -x1, -x2") @@ -522,7 +522,7 @@ def test_duplicate_raises(self, suppress_output): model = MCPower("y = x1 + x2") model.set_effects("x1=0.5, x2=0.3") - model.apply() + model._apply() with pytest.raises(ValueError, match="Duplicate"): model._parse_target_tests("all, x1") diff --git a/tests/integration/test_scenarios.py b/tests/integration/test_scenarios.py index 5172fb3..b0339cc 100644 --- a/tests/integration/test_scenarios.py +++ b/tests/integration/test_scenarios.py @@ -5,8 +5,10 @@ from unittest.mock import MagicMock import numpy as np +import pytest from mcpower.core.scenarios import ( + DEFAULT_SCENARIO_CONFIG, ScenarioRunner, apply_per_simulation_perturbations, ) @@ -82,6 +84,152 @@ def test_create_scenario_plots_early_return(self): runner._create_scenario_plots({"scenarios": {"optimistic": {}}}) +class TestSetScenarioConfigs: + """Test set_scenario_configs() merge behavior and KeyError prevention.""" + + # All keys that must exist in every scenario config + ALL_KEYS = sorted(DEFAULT_SCENARIO_CONFIG["optimistic"].keys()) + + def _make_model(self): + from mcpower import MCPower + + m = MCPower("y = x1 + x2") + m.set_effects("x1=0.3, x2=0.2") + return m + + # ── Merge semantics ────────────────────────────────────────── + + def test_custom_scenario_inherits_all_optimistic_keys(self): + """New custom scenario with one key still has every required key.""" + m = self._make_model() + m.set_scenario_configs({"extreme": {"heterogeneity": 0.6}}) + cfg = m._scenario_configs["extreme"] + missing = set(self.ALL_KEYS) - set(cfg.keys()) + assert not missing, f"Missing keys: {missing}" + + def test_custom_scenario_overrides_value(self): + """Provided key overrides the optimistic default.""" + m = self._make_model() + m.set_scenario_configs({"extreme": {"heterogeneity": 0.6}}) + assert m._scenario_configs["extreme"]["heterogeneity"] == 0.6 + + def test_custom_scenario_non_overridden_keys_are_optimistic(self): + """Non-overridden keys equal the optimistic baseline.""" + m = self._make_model() + m.set_scenario_configs({"extreme": {"heterogeneity": 0.6}}) + opt = DEFAULT_SCENARIO_CONFIG["optimistic"] + cfg = m._scenario_configs["extreme"] + for key in self.ALL_KEYS: + if key != "heterogeneity": + assert cfg[key] == opt[key], f"Key {key}: {cfg[key]} != {opt[key]}" + + def test_existing_scenario_update_preserves_other_keys(self): + """Updating one key on 'realistic' keeps the rest intact.""" + m = self._make_model() + m.set_scenario_configs({"realistic": {"heterogeneity": 0.99}}) + cfg = m._scenario_configs["realistic"] + assert cfg["heterogeneity"] == 0.99 + # Other keys should match original realistic defaults + assert cfg["correlation_noise_sd"] == DEFAULT_SCENARIO_CONFIG["realistic"]["correlation_noise_sd"] + + def test_defaults_still_present_after_adding_custom(self): + """Adding a custom scenario doesn't remove optimistic/realistic/doomer.""" + m = self._make_model() + m.set_scenario_configs({"custom": {"heterogeneity": 0.1}}) + for name in ("optimistic", "realistic", "doomer", "custom"): + assert name in m._scenario_configs + + def test_multiple_custom_scenarios(self): + """Multiple custom scenarios each inherit independently.""" + m = self._make_model() + m.set_scenario_configs({ + "mild": {"heterogeneity": 0.05}, + "severe": {"heterogeneity": 0.8, "heteroskedasticity": 0.5}, + }) + assert m._scenario_configs["mild"]["heterogeneity"] == 0.05 + assert m._scenario_configs["mild"]["heteroskedasticity"] == 0.0 # optimistic default + assert m._scenario_configs["severe"]["heterogeneity"] == 0.8 + assert m._scenario_configs["severe"]["heteroskedasticity"] == 0.5 + + def test_empty_custom_scenario_equals_optimistic(self): + """An empty custom config is identical to the optimistic baseline.""" + m = self._make_model() + m.set_scenario_configs({"empty": {}}) + opt = DEFAULT_SCENARIO_CONFIG["optimistic"] + for key in self.ALL_KEYS: + assert m._scenario_configs["empty"][key] == opt[key] + + # ── Type validation ────────────────────────────────────────── + + def test_non_dict_raises_type_error(self): + m = self._make_model() + with pytest.raises(TypeError): + m.set_scenario_configs("not_a_dict") + + def test_returns_self_for_chaining(self): + m = self._make_model() + result = m.set_scenario_configs({"custom": {"heterogeneity": 0.1}}) + assert result is m + + # ── End-to-end: no KeyError during simulation ──────────────── + + def test_custom_partial_config_runs_without_error(self): + """Custom scenario with only one key runs find_power without KeyError.""" + m = self._make_model() + m.set_scenario_configs({"partial": {"heterogeneity": 0.3}}) + result = m.find_power( + 50, scenarios=True, print_results=False, return_results=True + ) + assert "partial" in result["scenarios"] + power = result["scenarios"]["partial"]["results"]["individual_powers"]["overall"] + assert 0 <= power <= 100 + + def test_custom_residual_only_config_runs(self): + """Custom scenario with only residual keys runs without error.""" + m = self._make_model() + m.set_scenario_configs({ + "residual_test": { + "residual_change_prob": 1.0, + "residual_dists": ["heavy_tailed"], + "residual_df": 5, + } + }) + result = m.find_power( + 50, scenarios=True, print_results=False, return_results=True + ) + assert "residual_test" in result["scenarios"] + + def test_custom_lme_keys_on_ols_model_ignored(self): + """LME-specific keys on an OLS model don't cause errors.""" + m = self._make_model() + m.set_scenario_configs({ + "lme_on_ols": { + "icc_noise_sd": 0.3, + "random_effect_dist": "heavy_tailed", + "random_effect_df": 3, + } + }) + result = m.find_power( + 50, scenarios=True, print_results=False, return_results=True + ) + assert "lme_on_ols" in result["scenarios"] + + def test_overriding_all_three_defaults(self): + """Overriding optimistic, realistic, and doomer all at once.""" + m = self._make_model() + m.set_scenario_configs({ + "optimistic": {"heterogeneity": 0.01}, + "realistic": {"heterogeneity": 0.5}, + "doomer": {"heterogeneity": 0.9}, + }) + assert m._scenario_configs["optimistic"]["heterogeneity"] == 0.01 + assert m._scenario_configs["realistic"]["heterogeneity"] == 0.5 + assert m._scenario_configs["doomer"]["heterogeneity"] == 0.9 + # Other keys preserved from defaults + assert m._scenario_configs["realistic"]["correlation_noise_sd"] == DEFAULT_SCENARIO_CONFIG["realistic"]["correlation_noise_sd"] + assert m._scenario_configs["doomer"]["correlation_noise_sd"] == DEFAULT_SCENARIO_CONFIG["doomer"]["correlation_noise_sd"] + + class TestApplyPerSimulationPerturbations: """Test apply_per_simulation_perturbations function.""" @@ -122,3 +270,152 @@ def test_var_type_perturbation(self): # All normal (type 0) vars should be changed to right_skewed (type 2) assert np.all(p_types == 2) + + +class TestScenarioConfigKeysE2E: + """End-to-end tests for each individual config key and mixed combinations. + + Each test verifies that setting a single config key (or combination) + via set_scenario_configs() runs find_power(scenarios=True) without + error and produces valid power values. + """ + + N_SIMS = 50 + SAMPLE_SIZE = 80 + + def _make_model(self): + from mcpower import MCPower + + m = MCPower("y = x1 + x2") + m.set_effects("x1=0.3, x2=0.2") + m.set_simulations(self.N_SIMS) + return m + + def _run(self, model, config, scenario_name="test_scenario"): + model.set_scenario_configs({scenario_name: config}) + result = model.find_power( + self.SAMPLE_SIZE, + scenarios=True, + print_results=False, + return_results=True, + ) + power = result["scenarios"][scenario_name]["results"]["individual_powers"]["overall"] + assert 0 <= power <= 100, f"Power out of range: {power}" + return result + + # ── Individual general keys ─────────────────────────────────── + + def test_heterogeneity_only(self): + self._run(self._make_model(), {"heterogeneity": 0.3}) + + def test_heteroskedasticity_only(self): + self._run(self._make_model(), {"heteroskedasticity": 0.2}) + + def test_correlation_noise_sd_only(self): + m = self._make_model() + m.set_correlations("(x1,x2)=0.4") + self._run(m, {"correlation_noise_sd": 0.3}) + + def test_distribution_change_prob_only(self): + self._run(self._make_model(), {"distribution_change_prob": 0.5}) + + def test_new_distributions_with_change_prob(self): + self._run(self._make_model(), { + "distribution_change_prob": 1.0, + "new_distributions": ["uniform"], + }) + + # ── Individual residual keys ────────────────────────────────── + + def test_residual_change_prob_only(self): + self._run(self._make_model(), {"residual_change_prob": 0.5}) + + def test_residual_df_only(self): + self._run(self._make_model(), { + "residual_change_prob": 1.0, + "residual_df": 3, + }) + + def test_residual_dists_only(self): + self._run(self._make_model(), { + "residual_change_prob": 1.0, + "residual_dists": ["heavy_tailed"], + }) + + # ── Mixed general combinations ──────────────────────────────── + + def test_heterogeneity_and_correlation_noise(self): + m = self._make_model() + m.set_correlations("(x1,x2)=0.3") + self._run(m, { + "heterogeneity": 0.25, + "correlation_noise_sd": 0.3, + }) + + def test_distribution_change_and_heteroskedasticity(self): + self._run(self._make_model(), { + "distribution_change_prob": 0.5, + "heteroskedasticity": 0.15, + }) + + def test_all_general_keys_together(self): + m = self._make_model() + m.set_correlations("(x1,x2)=0.3") + self._run(m, { + "heterogeneity": 0.2, + "heteroskedasticity": 0.1, + "correlation_noise_sd": 0.2, + "distribution_change_prob": 0.3, + }) + + # ── Mixed general + residual ────────────────────────────────── + + def test_general_plus_residual_keys(self): + self._run(self._make_model(), { + "heterogeneity": 0.2, + "residual_change_prob": 0.5, + "residual_df": 5, + }) + + def test_all_ols_keys_together(self): + m = self._make_model() + m.set_correlations("(x1,x2)=0.3") + self._run(m, { + "heterogeneity": 0.3, + "heteroskedasticity": 0.15, + "correlation_noise_sd": 0.25, + "distribution_change_prob": 0.4, + "new_distributions": ["right_skewed", "uniform"], + "residual_change_prob": 0.5, + "residual_dists": ["heavy_tailed", "skewed"], + "residual_df": 6, + }) + + # ── Boundary values ─────────────────────────────────────────── + + def test_zero_perturbation_matches_optimistic(self): + """A custom scenario with all zeros should match optimistic power.""" + m = self._make_model() + m.set_seed(42) + result = self._run(m, { + "heterogeneity": 0.0, + "heteroskedasticity": 0.0, + "correlation_noise_sd": 0.0, + "distribution_change_prob": 0.0, + "residual_change_prob": 0.0, + }) + opt_power = result["scenarios"]["optimistic"]["results"]["individual_powers"]["overall"] + custom_power = result["scenarios"]["test_scenario"]["results"]["individual_powers"]["overall"] + # Same seed, same zero config → should be close (not exact due to seed offsets) + assert abs(opt_power - custom_power) < 15 + + def test_max_perturbation_runs(self): + """Extreme perturbation values should not crash.""" + self._run(self._make_model(), { + "heterogeneity": 0.9, + "heteroskedasticity": 0.5, + "correlation_noise_sd": 0.8, + "distribution_change_prob": 1.0, + "residual_change_prob": 1.0, + "residual_df": 2, + }) diff --git a/tests/integration/test_test_formula.py b/tests/integration/test_test_formula.py new file mode 100644 index 0000000..77b2e6e --- /dev/null +++ b/tests/integration/test_test_formula.py @@ -0,0 +1,388 @@ +""" +End-to-end integration tests for the test_formula feature. + +The test_formula feature generates data using one model formula but fits a +different (reduced) model for statistical testing, enabling model +misspecification analysis (e.g. omitted variable bias). +""" + +import numpy as np +import pandas as pd +import pytest + +from mcpower import MCPower + +N_SIMS = 200 +SEED = 42 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _power_model(formula, effects, *, n_sims=N_SIMS, seed=SEED, **kwargs): + """Create a configured MCPower model ready for find_power.""" + model = MCPower(formula) + + # Apply optional configuration before effects + if "variable_types" in kwargs: + model.set_variable_type(kwargs.pop("variable_types")) + if "correlations" in kwargs: + model.set_correlations(kwargs.pop("correlations")) + if "cluster" in kwargs: + cluster_cfg = kwargs.pop("cluster") + model.set_cluster(**cluster_cfg) + if "max_failed" in kwargs: + model.set_max_failed_simulations(kwargs.pop("max_failed")) + if "upload_data" in kwargs: + model.upload_data(kwargs.pop("upload_data")) + + model.set_effects(effects) + model.set_simulations(n_sims) + model.set_seed(seed) + return model + + +def _run_power(model, sample_size, **kwargs): + """Run find_power with standard test defaults.""" + return model.find_power( + sample_size, + print_results=False, + return_results=True, + progress_callback=False, + **kwargs, + ) + + +def _individual_powers(result): + """Extract individual_powers dict from a result.""" + return result["results"]["individual_powers"] + + +# =========================================================================== +# Class 1: TestOLSSubset +# =========================================================================== + + +class TestOLSSubset: + """Test basic OLS test_formula subsetting scenarios.""" + + def test_omitted_variable_reduces_power(self): + """Omitting x3 from test formula excludes it from results.""" + model = _power_model( + "y = x1 + x2 + x3", + "x1=0.5, x2=0.3, x3=0.5", + ) + result = _run_power(model, 100, test_formula="y = x1 + x2") + + powers = _individual_powers(result) + assert "x1" in powers + assert "x2" in powers + assert "x3" not in powers + + def test_omitted_interaction(self): + """Omitting interaction from test formula excludes it from results.""" + model = _power_model( + "y = x1 + x2 + x1:x2", + "x1=0.5, x2=0.3, x1:x2=0.2", + ) + result = _run_power(model, 100, test_formula="y = x1 + x2") + + powers = _individual_powers(result) + assert "x1" in powers + assert "x2" in powers + assert "x1:x2" not in powers + + def test_single_variable_test(self): + """Testing only x1 from a 3-variable generation model.""" + model = _power_model( + "y = x1 + x2 + x3", + "x1=0.5, x2=0.3, x3=0.2", + ) + result = _run_power(model, 100, test_formula="y = x1") + + powers = _individual_powers(result) + assert "x1" in powers + assert "overall" in powers + assert "x2" not in powers + assert "x3" not in powers + + def test_same_formula_matches_no_test_formula(self): + """Using test_formula identical to generation gives same powers.""" + model_a = _power_model("y = x1 + x2", "x1=0.5, x2=0.3") + result_a = _run_power(model_a, 100, test_formula="y = x1 + x2") + + model_b = _power_model("y = x1 + x2", "x1=0.5, x2=0.3") + result_b = _run_power(model_b, 100) + + powers_a = _individual_powers(result_a) + powers_b = _individual_powers(result_b) + + for key in powers_b: + assert abs(powers_a[key] - powers_b[key]) < 0.01, ( + f"Power mismatch for {key}: {powers_a[key]} vs {powers_b[key]}" + ) + + def test_empty_test_formula_uses_generation(self): + """Empty test_formula string uses the generation formula (default).""" + model_a = _power_model("y = x1 + x2", "x1=0.5, x2=0.3") + result_a = _run_power(model_a, 100, test_formula="") + + model_b = _power_model("y = x1 + x2", "x1=0.5, x2=0.3") + result_b = _run_power(model_b, 100) + + powers_a = _individual_powers(result_a) + powers_b = _individual_powers(result_b) + + for key in powers_b: + assert abs(powers_a[key] - powers_b[key]) < 0.01, ( + f"Power mismatch for {key}: {powers_a[key]} vs {powers_b[key]}" + ) + + +# =========================================================================== +# Class 2: TestFactorVariables +# =========================================================================== + + +class TestFactorVariables: + """Test test_formula with factor (categorical) variables.""" + + def test_omitted_factor(self): + """Omitting a factor variable from test formula excludes its dummies.""" + model = _power_model( + "y = x1 + x2", + "x1=0.5, x2[2]=0.3, x2[3]=0.4", + variable_types="x2=(factor,3)", + ) + result = _run_power(model, 150, test_formula="y = x1") + + powers = _individual_powers(result) + assert "x1" in powers + # Factor dummies should not be in results + assert "x2[2]" not in powers + assert "x2[3]" not in powers + + def test_factor_kept_continuous_dropped(self): + """Keeping factor but dropping continuous variable.""" + model = _power_model( + "y = x1 + x2", + "x1=0.5, x2[2]=0.3, x2[3]=0.4", + variable_types="x2=(factor,3)", + ) + result = _run_power(model, 150, test_formula="y = x2") + + powers = _individual_powers(result) + # x1 excluded + assert "x1" not in powers + # Factor dummies should be present + assert "x2[2]" in powers + assert "x2[3]" in powers + + +# =========================================================================== +# Class 3: TestCorrelationStructures +# =========================================================================== + + +class TestCorrelationStructures: + """Test test_formula with correlated predictors.""" + + def test_correlated_variables_subset(self): + """Subsetting correlated variables runs without error.""" + model = _power_model( + "y = x1 + x2", + "x1=0.5, x2=0.3", + correlations="(x1,x2)=0.5", + ) + result = _run_power(model, 100, test_formula="y = x1") + + assert result is not None + powers = _individual_powers(result) + assert "x1" in powers + assert "x2" not in powers + + +# =========================================================================== +# Class 4: TestResultsStructure +# =========================================================================== + + +class TestResultsStructure: + """Test that result dict contains correct test_formula metadata.""" + + def test_results_contain_both_formulas(self): + """Result should have data_formula and test_formula fields.""" + model = _power_model( + "y = x1 + x2 + x3", + "x1=0.5, x2=0.3, x3=0.2", + ) + result = _run_power(model, 100, test_formula="y = x1 + x2") + + assert "data_formula" in result["model"] + assert "test_formula" in result["model"] + # data_formula should be the generation formula + assert "x3" in result["model"]["data_formula"] + # test_formula should be the reduced formula + assert result["model"]["test_formula"] == "y = x1 + x2" + + def test_target_tests_reflect_test_formula(self): + """target_tests in results should not contain excluded effects.""" + model = _power_model( + "y = x1 + x2 + x3", + "x1=0.5, x2=0.3, x3=0.2", + ) + result = _run_power(model, 100, test_formula="y = x1 + x2") + + target_tests = result["model"]["target_tests"] + assert "x1" in target_tests + assert "x2" in target_tests + assert "x3" not in target_tests + + +# =========================================================================== +# Class 5: TestValidation +# =========================================================================== + + +class TestValidation: + """Test validation errors for invalid test_formula usage.""" + + def test_nonexistent_variable_raises(self): + """test_formula with unknown variable raises ValueError.""" + model = _power_model( + "y = x1 + x2", + "x1=0.5, x2=0.3", + ) + with pytest.raises(ValueError, match="not found"): + _run_power(model, 100, test_formula="y = x1 + x99") + + def test_ols_to_lme_raises(self): + """test_formula with random effects on OLS model raises ValueError. + + When the grouping variable (school) is not in the generation model, + validation fails with 'not found'. When it is present but has no + cluster config, it fails with 'random effects'. + """ + # Case 1: grouping var not in model at all -> "not found" + model = _power_model( + "y = x1 + x2", + "x1=0.5, x2=0.3", + ) + with pytest.raises(ValueError, match="not found"): + _run_power(model, 100, test_formula="y = x1 + (1|school)") + + def test_ols_with_cluster_var_but_no_cluster_config_raises(self): + """test_formula with random effects when var exists but no cluster config. + + When the generation model knows about 'school' as a variable but has + no cluster specification, the random effects check triggers. + """ + # This would require a model that has 'school' as a predictor but + # no set_cluster call. The generation model includes school as a + # fixed effect, so it's a known variable. + model = _power_model( + "y = x1 + school", + "x1=0.5, school=0.3", + ) + with pytest.raises(ValueError, match="random effects"): + _run_power(model, 100, test_formula="y = x1 + (1|school)") + + +# =========================================================================== +# Class 6: TestFindSampleSize +# =========================================================================== + + +class TestFindSampleSize: + """Test test_formula with find_sample_size.""" + + def test_subset_via_find_sample_size(self): + """find_sample_size with test_formula excludes omitted variable.""" + model = _power_model( + "y = x1 + x2 + x3", + "x1=0.5, x2=0.3, x3=0.2", + ) + result = model.find_sample_size( + target_test="x1", + from_size=30, + to_size=100, + by=10, + test_formula="y = x1 + x2", + print_results=False, + return_results=True, + progress_callback=False, + ) + + assert result is not None + powers_by_test = result["results"]["powers_by_test"] + assert "x1" in powers_by_test + assert "x3" not in powers_by_test + + +# =========================================================================== +# Class 7: TestMixedModelCross (LME) +# =========================================================================== + + +@pytest.mark.lme +class TestMixedModelCross: + """Test test_formula across mixed model boundaries.""" + + def test_lme_gen_ols_test(self): + """Generate with LME, test with OLS (drop random effects).""" + model = _power_model( + "y ~ x1 + x2 + (1|school)", + "x1=0.5, x2=0.3", + cluster={"grouping_var": "school", "ICC": 0.2, "n_clusters": 20}, + max_failed=0.10, + ) + result = _run_power(model, 1000, test_formula="y ~ x1 + x2") + + powers = _individual_powers(result) + assert "x1" in powers + assert "x2" in powers + + def test_lme_gen_lme_subset(self): + """Generate with LME full model, test with LME subset (drop x2).""" + model = _power_model( + "y ~ x1 + x2 + (1|school)", + "x1=0.5, x2=0.3", + cluster={"grouping_var": "school", "ICC": 0.2, "n_clusters": 20}, + max_failed=0.10, + ) + result = _run_power(model, 1000, test_formula="y ~ x1 + (1|school)") + + powers = _individual_powers(result) + assert "x1" in powers + assert "x2" not in powers + + +# =========================================================================== +# Class 8: TestUploadedData +# =========================================================================== + + +class TestUploadedData: + """Test test_formula with uploaded empirical data.""" + + def test_upload_with_test_formula(self): + """Uploaded data with test_formula excludes omitted variable.""" + np.random.seed(SEED) + data = pd.DataFrame({ + "x1": np.random.normal(0, 1, 50), + "x2": np.random.normal(0, 1, 50), + "x3": np.random.normal(0, 1, 50), + }) + + model = _power_model( + "y = x1 + x2 + x3", + "x1=0.5, x2=0.3, x3=0.2", + upload_data=data, + ) + result = _run_power(model, 100, test_formula="y = x1 + x2") + + powers = _individual_powers(result) + assert "x1" in powers + assert "x2" in powers + assert "x3" not in powers diff --git a/tests/integration/test_upload_data.py b/tests/integration/test_upload_data.py index 93a7d8f..05602f0 100644 --- a/tests/integration/test_upload_data.py +++ b/tests/integration/test_upload_data.py @@ -71,7 +71,7 @@ def test_binary_auto_detection(self, cars_data): model = MCPower("mpg = vs + am") model.upload_data(_select(cars_data, ["vs", "am"])) model.set_effects("vs=0.3, am=0.4") - model.apply() + model._apply() # Check that vs and am were detected as uploaded_binary vs_pred = model._registry.get_predictor("vs") @@ -85,7 +85,7 @@ def test_factor_auto_detection(self, cars_data): model = MCPower("mpg = cyl + gear") model.upload_data(_select(cars_data, ["cyl", "gear"]), preserve_factor_level_names=False) model.set_effects("cyl[2]=0.3, cyl[3]=0.4, gear[2]=0.2, gear[3]=0.3") - model.apply() + model._apply() # Check that cyl and gear were detected as factor # After expansion, check the factor names @@ -103,7 +103,7 @@ def test_continuous_auto_detection(self, cars_data): model = MCPower("mpg = hp + wt") model.upload_data(_select(cars_data, ["hp", "wt"])) model.set_effects("hp=0.5, wt=0.3") - model.apply() + model._apply() # Check that hp and wt were detected as continuous (uploaded_data) hp_pred = model._registry.get_predictor("hp") @@ -122,14 +122,14 @@ def test_constant_column_dropped(self, cars_data): # Should raise error because 'constant' will be dropped with pytest.raises(ValueError, match="All uploaded columns were dropped"): model.upload_data(_select(data, ["constant"])) - model.apply() + model._apply() def test_mixed_types_auto_detection(self, cars_data): """Test auto-detection with mixed variable types.""" model = MCPower("mpg = vs + cyl + hp") model.upload_data(_select(cars_data, ["vs", "cyl", "hp"]), preserve_factor_level_names=False) model.set_effects("vs=0.3, cyl[2]=0.2, cyl[3]=0.4, hp=0.5") - model.apply() + model._apply() vs_pred = model._registry.get_predictor("vs") hp_pred = model._registry.get_predictor("hp") @@ -147,7 +147,7 @@ def test_override_to_continuous(self, cars_data): model = MCPower("mpg = cyl + hp") model.upload_data(_select(cars_data, ["cyl", "hp"]), data_types={"cyl": "continuous"}) model.set_effects("cyl=0.4, hp=0.5") - model.apply() + model._apply() cyl_pred = model._registry.get_predictor("cyl") # Should be uploaded_data (continuous) instead of factor @@ -178,7 +178,7 @@ def test_override_to_binary(self, cars_data): model_binary = MCPower("mpg = hp_binary + wt") model_binary.upload_data(data, data_types={"hp_binary": "binary"}) model_binary.set_effects("hp_binary=0.4, wt=0.3") - model_binary.apply() + model_binary._apply() hp_pred = model_binary._registry.get_predictor("hp_binary") assert hp_pred.var_type == "uploaded_binary" @@ -206,7 +206,7 @@ def test_no_correlation_from_data(self, cars_data): model = MCPower("mpg = hp + wt") model.upload_data(_select(cars_data, ["hp", "wt"]), preserve_correlation="no") model.set_effects("hp=0.5, wt=0.3") - model.apply() + model._apply() # Correlation matrix should be identity (or user-specified) corr = model.correlation_matrix @@ -219,7 +219,7 @@ def test_binary_uses_standard_generation(self, cars_data): model = MCPower("mpg = vs + am") model.upload_data(_select(cars_data, ["vs", "am"]), preserve_correlation="no") model.set_effects("vs=0.3, am=0.4") - model.apply() + model._apply() # Should detect proportions from data vs_pred = model._registry.get_predictor("vs") @@ -231,7 +231,7 @@ def test_continuous_uses_lookup_tables(self, cars_data): model = MCPower("mpg = hp + wt") model.upload_data(_select(cars_data, ["hp", "wt"]), preserve_correlation="no") model.set_effects("hp=0.5, wt=0.3") - model.apply() + model._apply() # Should have lookup tables populated assert model.upload_normal_values.shape[0] > 0 @@ -246,7 +246,7 @@ def test_strict_is_default(self, cars_data): model = MCPower("mpg = hp + wt") model.upload_data(_select(cars_data, ["hp", "wt"])) model.set_effects("hp=0.5, wt=0.3") - model.apply() + model._apply() assert model._preserve_correlation == "strict" @@ -255,7 +255,7 @@ def test_correlations_computed_from_data(self, cars_data): model = MCPower("mpg = hp + wt") model.upload_data(_select(cars_data, ["hp", "wt"]), preserve_correlation="partial") model.set_effects("hp=0.5, wt=0.3") - model.apply() + model._apply() # Correlation should match data correlation hp_arr = np.array(cars_data["hp"]) @@ -278,7 +278,7 @@ def test_user_can_override_correlations(self, cars_data): # This tests that user correlations can override data correlations # For now, the implementation always uses data correlations # TODO: Implement user override priority - model.apply() + model._apply() # Just verify it doesn't crash assert model.correlation_matrix is not None @@ -292,7 +292,7 @@ def test_strict_mode_sets_metadata(self, cars_data): model = MCPower("mpg = hp + wt") model.upload_data(_select(cars_data, ["hp", "wt"]), preserve_correlation="strict") model.set_effects("hp=0.5, wt=0.3") - model.apply() + model._apply() assert model._preserve_correlation == "strict" assert model._uploaded_raw_data is not None @@ -303,7 +303,7 @@ def test_strict_mode_warns_cross_correlations(self, cars_data, capsys): model = MCPower("mpg = hp + wt + x1") # x1 is created, hp/wt uploaded model.upload_data(_select(cars_data, ["hp", "wt"]), preserve_correlation="strict") model.set_effects("hp=0.5, wt=0.3, x1=0.4") - model.apply() + model._apply() captured = capsys.readouterr() # Should warn about cross-correlations @@ -314,7 +314,7 @@ def test_strict_mode_bootstrap_preserves_relationships(self, cars_data): model = MCPower("mpg = hp + wt") model.upload_data(_select(cars_data, ["hp", "wt"]), preserve_correlation="strict") model.set_effects("hp=0.5, wt=0.3") - model.apply() + model._apply() # Should be able to run simulation without error result = model.find_power(sample_size=50, print_results=False, return_results=True) @@ -356,7 +356,7 @@ def test_strict_mode_with_binary(self, cars_data): model = MCPower("mpg = vs + am") model.upload_data(_select(cars_data, ["vs", "am"]), preserve_correlation="strict") model.set_effects("vs=0.3, am=0.4") - model.apply() + model._apply() # Check metadata assert "vs" in model._uploaded_var_metadata @@ -373,7 +373,7 @@ def test_strict_mode_with_factor(self, cars_data): preserve_factor_level_names=False, ) model.set_effects("cyl[2]=0.3, cyl[3]=0.4, gear[2]=0.2, gear[3]=0.3") - model.apply() + model._apply() # Check metadata assert "cyl" in model._uploaded_var_metadata @@ -390,7 +390,7 @@ def test_warning_for_unmatched_columns(self, cars_data, capsys): model = MCPower("mpg = hp + wt") model.upload_data(_select(cars_data, ["hp", "wt", "vs"])) # vs not in model model.set_effects("hp=0.5, wt=0.3") - model.apply() + model._apply() captured = capsys.readouterr() assert "Ignoring unmatched columns" in captured.out @@ -401,7 +401,7 @@ def test_warning_for_large_sample_size(self, cars_data, capsys): model = MCPower("mpg = hp + wt") model.upload_data(_select(cars_data, ["hp", "wt"])) model.set_effects("hp=0.5, wt=0.3") - model.apply() + model._apply() # 32 samples * 3 = 96, so 100 should trigger warning model.find_power(sample_size=100, print_results=False) @@ -424,7 +424,7 @@ def test_warning_for_dropped_constant_columns(self, cars_data, capsys): # This should raise an error because constant was dropped and no effect was set for it # But the auto-detection output should show it was dropped try: - model.apply() + model._apply() except ValueError: pass # Expected to fail because constant column missing @@ -442,7 +442,7 @@ def test_full_dict_with_unmatched_columns(self, cars_data): model = MCPower("mpg = hp + wt") model.upload_data(cars_data) # Full dict, not pre-filtered model.set_effects("hp=0.5, wt=0.3") - model.apply() + model._apply() hp_pred = model._registry.get_predictor("hp") wt_pred = model._registry.get_predictor("wt") @@ -463,7 +463,7 @@ def test_full_dict_with_mixed_var_types(self, cars_data): model = MCPower("mpg = vs + cyl + hp") model.upload_data(cars_data, preserve_factor_level_names=False) # Full dict model.set_effects("vs=0.3, cyl[2]=0.2, cyl[3]=0.4, hp=0.5") - model.apply() + model._apply() vs_pred = model._registry.get_predictor("vs") hp_pred = model._registry.get_predictor("hp") @@ -493,7 +493,7 @@ def test_string_matched_column_auto_detected_as_factor(self): model = MCPower("y = x") model.upload_data(data) model.set_effects("x[b]=0.3, x[c]=0.4") - model.apply() + model._apply() assert "x" in model._registry.factor_names assert "x[b]" in model._registry.dummy_names assert "x[c]" in model._registry.dummy_names @@ -507,7 +507,7 @@ def test_no_matching_columns_ignores_data(self, cars_data, capsys): model = MCPower("mpg = x1 + x2") model.upload_data(_select(cars_data, ["hp", "wt"])) model.set_effects("x1=0.3, x2=0.4") - model.apply() + model._apply() captured = capsys.readouterr() assert "uploaded data ignored" in captured.out.lower() @@ -557,7 +557,7 @@ def test_dict_format(self, cars_data): } model.upload_data(data_dict) model.set_effects("hp=0.5, wt=0.3") - model.apply() + model._apply() assert model._applied is True @@ -566,10 +566,10 @@ def test_sample_size_warning_in_find_sample_size(self, cars_data, capsys): model = MCPower("mpg = hp + wt") model.upload_data(_select(cars_data, ["hp", "wt"])) model.set_effects("hp=0.5, wt=0.3") - model.apply() + model._apply() - # 32 * 3 = 96, so to_size=150 should trigger warning - model.find_sample_size(from_size=30, to_size=150, by=20, print_results=False) + # 32 * 3 = 96, so size=110 > 96 triggers warning + model.find_sample_size(from_size=50, to_size=110, by=30, print_results=False) captured = capsys.readouterr() assert "Warning" in captured.out @@ -583,14 +583,14 @@ def test_string_column_auto_detected_as_factor(self, cars_data): model = MCPower("mpg = origin + hp") model.upload_data(_select(cars_data, ["origin", "hp"])) model.set_effects("origin[Japan]=0.3, origin[USA]=0.4, hp=0.5") - model.apply() + model._apply() assert "origin" in model._registry.factor_names def test_string_column_creates_named_dummies(self, cars_data): model = MCPower("mpg = origin + hp") model.upload_data(_select(cars_data, ["origin", "hp"])) model.set_effects("origin[Japan]=0.3, origin[USA]=0.4, hp=0.5") - model.apply() + model._apply() dummy_names = model._registry.dummy_names assert "origin[Japan]" in dummy_names assert "origin[USA]" in dummy_names @@ -600,7 +600,7 @@ def test_string_column_no_mode(self, cars_data): model = MCPower("mpg = origin + hp") model.upload_data(_select(cars_data, ["origin", "hp"]), preserve_correlation="no") model.set_effects("origin[Japan]=0.3, origin[USA]=0.4, hp=0.5") - model.apply() + model._apply() assert "origin" in model._registry.factor_names def test_too_many_string_levels_raises(self): @@ -611,7 +611,7 @@ def test_too_many_string_levels_raises(self): model = MCPower("y = name + x1") with pytest.raises(ValueError, match="too many unique"): model.upload_data(_select(data, ["name", "x1"])) - model.apply() + model._apply() class TestPreserveFactorLevelNames: @@ -621,7 +621,7 @@ def test_numeric_factor_uses_original_values(self, cars_data): model = MCPower("mpg = cyl + hp") model.upload_data(_select(cars_data, ["cyl", "hp"])) model.set_effects("cyl[6]=0.3, cyl[8]=0.4, hp=0.5") - model.apply() + model._apply() dummy_names = model._registry.dummy_names assert "cyl[6]" in dummy_names assert "cyl[8]" in dummy_names @@ -631,7 +631,7 @@ def test_preserve_false_uses_integer_indices(self, cars_data): model = MCPower("mpg = cyl + hp") model.upload_data(_select(cars_data, ["cyl", "hp"]), preserve_factor_level_names=False) model.set_effects("cyl[2]=0.3, cyl[3]=0.4, hp=0.5") - model.apply() + model._apply() dummy_names = model._registry.dummy_names assert "cyl[2]" in dummy_names assert "cyl[3]" in dummy_names @@ -640,7 +640,7 @@ def test_custom_reference_via_data_types_tuple(self, cars_data): model = MCPower("mpg = cyl + hp") model.upload_data(_select(cars_data, ["cyl", "hp"]), data_types={"cyl": ("factor", 6)}) model.set_effects("cyl[4]=0.3, cyl[8]=0.4, hp=0.5") - model.apply() + model._apply() dummy_names = model._registry.dummy_names assert "cyl[4]" in dummy_names assert "cyl[8]" in dummy_names @@ -650,7 +650,7 @@ def test_invalid_reference_level_raises(self, cars_data): model = MCPower("mpg = cyl + hp") with pytest.raises(ValueError, match="not found in"): model.upload_data(_select(cars_data, ["cyl", "hp"]), data_types={"cyl": ("factor", 99)}) - model.apply() + model._apply() def test_string_custom_reference(self, cars_data): model = MCPower("mpg = origin + hp") @@ -658,7 +658,7 @@ def test_string_custom_reference(self, cars_data): _select(cars_data, ["origin", "hp"]), data_types={"origin": ("factor", "Japan")} ) model.set_effects("origin[Europe]=0.3, origin[USA]=0.4, hp=0.5") - model.apply() + model._apply() dummy_names = model._registry.dummy_names assert "origin[Europe]" in dummy_names assert "origin[USA]" in dummy_names @@ -737,7 +737,7 @@ def test_origin_as_factor(self, cars_data): model = MCPower("mpg = origin + hp") model.upload_data(_select(cars_data, ["origin", "hp"])) model.set_effects("origin[Japan]=0.3, origin[USA]=0.5, hp=0.4") - model.apply() + model._apply() assert "origin" in model._registry.factor_names assert "origin[Japan]" in model._registry.dummy_names @@ -762,7 +762,7 @@ def test_origin_with_cyl_mixed(self, cars_data): model = MCPower("mpg = origin + cyl") model.upload_data(_select(cars_data, ["origin", "cyl"])) model.set_effects("origin[Japan]=0.3, origin[USA]=0.5, cyl[6]=0.2, cyl[8]=0.4") - model.apply() + model._apply() assert "origin[Japan]" in model._registry.dummy_names assert "cyl[6]" in model._registry.dummy_names @@ -822,7 +822,7 @@ def test_dataframe_upload(self): model = MCPower("mpg = hp + wt") model.upload_data(df[["hp", "wt"]]) model.set_effects("hp=0.5, wt=0.3") - model.apply() + model._apply() hp_pred = model._registry.get_predictor("hp") assert hp_pred.var_type == "uploaded_data" @@ -833,7 +833,7 @@ def test_dataframe_with_string_index_column(self): model = MCPower("mpg = hp + wt") model.upload_data(df) model.set_effects("hp=0.5, wt=0.3") - model.apply() + model._apply() hp_pred = model._registry.get_predictor("hp") assert hp_pred.var_type == "uploaded_data" diff --git a/tests/mixed_models/test_cluster_validators.py b/tests/mixed_models/test_cluster_validators.py index 0cca25e..27540d2 100644 --- a/tests/mixed_models/test_cluster_validators.py +++ b/tests/mixed_models/test_cluster_validators.py @@ -102,7 +102,7 @@ def test_sufficient_observations_per_cluster(self): model.set_cluster("cluster", ICC=0.2, n_clusters=5) model.set_effects("x=0.5") model.set_simulations(10) - model.apply() + model._apply() # 50 / 5 = 10 (above warning band) result = model.find_power(sample_size=50, return_results=True) @@ -118,7 +118,7 @@ def test_insufficient_observations_per_cluster_rejected(self): model.set_cluster("cluster", ICC=0.2, n_clusters=5) model.set_effects("x=0.5") model.set_simulations(10) - model.apply() + model._apply() # 20 / 5 = 4 (below minimum) with pytest.raises(ValueError, match="Insufficient observations per cluster"): @@ -134,7 +134,7 @@ def test_validation_message_suggestions(self): model.set_cluster("cluster", ICC=0.2, n_clusters=10) model.set_effects("x=0.5") model.set_simulations(10) - model.apply() + model._apply() with pytest.raises(ValueError) as exc_info: model.find_power(sample_size=30) # 30/10 = 3 < 5 @@ -155,7 +155,7 @@ def test_valid_config_runs_successfully(self): model.set_cluster("cluster", ICC=0.2, n_clusters=5) model.set_effects("x=0.5") model.set_simulations(10) - model.apply() + model._apply() result = model.find_power(sample_size=50, return_results=True) # 10 per cluster @@ -170,7 +170,7 @@ def test_edge_case_exactly_5_per_cluster(self): model.set_effects("x=0.5") model.set_simulations(10) model.set_max_failed_simulations(0.30) # Allow more failures at edge - model.apply() + model._apply() result = model.find_power(sample_size=20, return_results=True) # 20/4 = 5 @@ -182,7 +182,7 @@ def test_icc_zero_no_convergence_issues(self): model.set_cluster("cluster", ICC=0.0, n_clusters=5) model.set_effects("x=0.5") model.set_simulations(20) - model.apply() + model._apply() result = model.find_power(sample_size=250, return_results=True) diff --git a/tests/mixed_models/test_integration_phase2.py b/tests/mixed_models/test_integration_phase2.py index 42d8621..0d04919 100644 --- a/tests/mixed_models/test_integration_phase2.py +++ b/tests/mixed_models/test_integration_phase2.py @@ -23,7 +23,7 @@ def test_slope_model_setup(self): slope_intercept_corr=0.3, ) model.set_effects("x1=0.5") - model.apply() + model._apply() # Verify cluster spec was configured correctly spec = model._registry._cluster_specs["school"] @@ -106,7 +106,7 @@ def test_nested_model_setup(self): model.set_cluster("school", ICC=0.15, n_clusters=10) model.set_cluster("classroom", ICC=0.10, n_per_parent=3) model.set_effects("treatment=0.5") - model.apply() + model._apply() assert "school" in model._registry._cluster_specs assert "school:classroom" in model._registry._cluster_specs diff --git a/tests/mixed_models/test_mixed_models.py b/tests/mixed_models/test_mixed_models.py index de4b422..e0a867c 100644 --- a/tests/mixed_models/test_mixed_models.py +++ b/tests/mixed_models/test_mixed_models.py @@ -405,7 +405,6 @@ def test_unknown_backend_raises(self): np.zeros(10), np.array([0]), np.zeros(10, dtype=int), - [], 0, 0.05, backend="nonexistent", diff --git a/tests/mixed_models/test_mixed_models_validation.py b/tests/mixed_models/test_mixed_models_validation.py index fbe2ecf..5502bfe 100644 --- a/tests/mixed_models/test_mixed_models_validation.py +++ b/tests/mixed_models/test_mixed_models_validation.py @@ -90,7 +90,7 @@ def test_icc_recovery_medium(self): from mcpower.stats.data_generation import _generate_cluster_effects - sample_size = 500 + sample_size = 1000 n_clusters = 20 icc_target = ICC_MODERATE_HIGH @@ -270,7 +270,7 @@ def test_diagnostics_available(self): y=y, target_indices=np.array([0]), cluster_ids=cluster_ids, - cluster_column_indices=[], + correction_method=0, alpha=0.05, backend="statsmodels", diff --git a/tests/mixed_models/test_scenarios_lme.py b/tests/mixed_models/test_scenarios_lme.py index f3d5ad6..41597d3 100644 --- a/tests/mixed_models/test_scenarios_lme.py +++ b/tests/mixed_models/test_scenarios_lme.py @@ -14,7 +14,6 @@ from mcpower.core.scenarios import ( DEFAULT_SCENARIO_CONFIG, apply_lme_perturbations, - apply_lme_residual_perturbations, ) from mcpower.stats.data_generation import ( _generate_cluster_effects, @@ -35,7 +34,7 @@ class TestDefaultConfig: "random_effect_dist", "random_effect_df", "icc_noise_sd", - "residual_dist", + "residual_dists", "residual_change_prob", "residual_df", ] @@ -49,22 +48,37 @@ def test_doomer_has_lme_keys(self): assert key in DEFAULT_SCENARIO_CONFIG["doomer"], f"Missing key: {key}" def test_realistic_values(self): + """Realistic scenario has non-zero LME perturbation values.""" cfg = DEFAULT_SCENARIO_CONFIG["realistic"] assert cfg["random_effect_dist"] == "heavy_tailed" - assert cfg["random_effect_df"] == 5 - assert cfg["icc_noise_sd"] == 0.15 - assert cfg["residual_dist"] == "heavy_tailed" - assert cfg["residual_change_prob"] == 0.3 - assert cfg["residual_df"] == 10 + assert cfg["random_effect_df"] > 0 + assert cfg["icc_noise_sd"] > 0 + assert cfg["residual_dists"] == ["heavy_tailed", "skewed"] + assert cfg["residual_change_prob"] > 0 + assert cfg["residual_df"] > 2 def test_doomer_values(self): - cfg = DEFAULT_SCENARIO_CONFIG["doomer"] - assert cfg["random_effect_dist"] == "heavy_tailed" - assert cfg["random_effect_df"] == 3 - assert cfg["icc_noise_sd"] == 0.30 - assert cfg["residual_dist"] == "heavy_tailed" - assert cfg["residual_change_prob"] == 0.8 - assert cfg["residual_df"] == 5 + """Doomer scenario has more severe perturbation than realistic.""" + real = DEFAULT_SCENARIO_CONFIG["realistic"] + doom = DEFAULT_SCENARIO_CONFIG["doomer"] + assert doom["random_effect_dist"] == "heavy_tailed" + assert doom["random_effect_df"] <= real["random_effect_df"] + assert doom["icc_noise_sd"] >= real["icc_noise_sd"] + assert doom["residual_dists"] == ["heavy_tailed", "skewed"] + assert doom["residual_change_prob"] >= real["residual_change_prob"] + assert doom["residual_df"] <= real["residual_df"] + + def test_optimistic_has_lme_keys(self): + for key in self.LME_KEYS: + assert key in DEFAULT_SCENARIO_CONFIG["optimistic"], f"Missing key: {key}" + + def test_optimistic_values_are_zero(self): + cfg = DEFAULT_SCENARIO_CONFIG["optimistic"] + assert cfg["heterogeneity"] == 0.0 + assert cfg["heteroskedasticity"] == 0.0 + assert cfg["residual_change_prob"] == 0.0 + assert cfg["icc_noise_sd"] == 0.0 + assert cfg["random_effect_dist"] == "normal" # --------------------------------------------------------------------------- @@ -309,73 +323,3 @@ def test_slopes_without_perturbations(self): assert result.intercept_columns.shape == (1000, 1) -# --------------------------------------------------------------------------- -# apply_lme_residual_perturbations -# --------------------------------------------------------------------------- -class TestApplyLmeResidualPerturbations: - """Test apply_lme_residual_perturbations() function.""" - - def _make_y(self, seed=42): - """Generate a deterministic y vector with known errors.""" - rng = np.random.RandomState(seed + 2) - return rng.standard_normal(500) - - def test_normal_dist_returns_unchanged(self): - y = self._make_y() - config = {"residual_dist": "normal", "residual_change_prob": 1.0, "residual_df": 5} - result = apply_lme_residual_perturbations(y.copy(), config, 42) - np.testing.assert_array_equal(result, y) - - def test_zero_prob_returns_unchanged(self): - y = self._make_y() - config = {"residual_dist": "heavy_tailed", "residual_change_prob": 0.0, "residual_df": 5} - result = apply_lme_residual_perturbations(y.copy(), config, 42) - np.testing.assert_array_equal(result, y) - - def test_prob_1_always_applies(self): - y = self._make_y() - config = {"residual_dist": "heavy_tailed", "residual_change_prob": 1.0, "residual_df": 5} - result = apply_lme_residual_perturbations(y.copy(), config, 42) - # Should be different from original - assert not np.array_equal(result, y) - - def test_heavy_tailed_residuals_have_excess_kurtosis(self): - """When residuals are replaced with t(5), the diff should have heavy tails.""" - y_orig = self._make_y() - config = {"residual_dist": "heavy_tailed", "residual_change_prob": 1.0, "residual_df": 5} - y_perturbed = apply_lme_residual_perturbations(y_orig.copy(), config, 42) - diff = y_perturbed - y_orig - # The diff = new_errors - original_errors. Both have finite variance, - # but the new_errors are t(5) which has excess kurtosis. - # For large enough N, the kurtosis of the difference should be positive. - sp_stats.kurtosis(diff + y_orig, fisher=True) - # Just check it ran without error and output differs - assert not np.array_equal(y_perturbed, y_orig) - - def test_skewed_residuals_applied(self): - y_orig = self._make_y() - config = {"residual_dist": "skewed", "residual_change_prob": 1.0, "residual_df": 5} - y_perturbed = apply_lme_residual_perturbations(y_orig.copy(), config, 42) - assert not np.array_equal(y_perturbed, y_orig) - - def test_coin_flip_seed_reproducible(self): - y = self._make_y() - config = {"residual_dist": "heavy_tailed", "residual_change_prob": 0.5, "residual_df": 5} - r1 = apply_lme_residual_perturbations(y.copy(), config, 42) - r2 = apply_lme_residual_perturbations(y.copy(), config, 42) - np.testing.assert_array_equal(r1, r2) - - def test_coin_flip_prob_respected(self): - """With prob=0.3, roughly 30% of simulations should be perturbed.""" - config = {"residual_dist": "heavy_tailed", "residual_change_prob": 0.3, "residual_df": 5} - n_perturbed = 0 - n_trials = 200 - y_template = np.ones(100) - for i in range(n_trials): - y = y_template.copy() - result = apply_lme_residual_perturbations(y, config, i * 100) - if not np.array_equal(result, y_template): - n_perturbed += 1 - # Should be roughly 30% ± some tolerance - pct = n_perturbed / n_trials - assert 0.10 < pct < 0.55, f"Expected ~30% perturbed, got {pct:.1%}" diff --git a/tests/specs/test_alpha_levels.py b/tests/specs/test_alpha_levels.py index 529e9db..c1f0908 100644 --- a/tests/specs/test_alpha_levels.py +++ b/tests/specs/test_alpha_levels.py @@ -1,9 +1,8 @@ """ -Non-default alpha level tests — backend-agnostic. +Non-default alpha level tests. Validates that the full alpha pipeline (power accuracy, corrections, null calibration) works correctly at alpha != 0.05. -Tests run on ALL available backends via the backend fixture. """ import contextlib @@ -12,7 +11,7 @@ import numpy as np import pytest -from tests.config import N_SIMS, SEED +from tests.config import N_SIMS, N_SIMS_ORDERING, N_SIMS_STANDARD, SEED from tests.helpers.analytical import analytical_f_power, analytical_t_power from tests.helpers.mc_margins import mc_accuracy_margin, mc_margin from tests.helpers.power_helpers import get_power, get_power_corrected, make_null_model @@ -44,7 +43,7 @@ class TestAlphaAccuracyVsAnalytical: (0.5, 100), ], ) - def test_single_predictor_t_test_alpha(self, backend, alpha, beta, n): + def test_single_predictor_t_test_alpha(self, alpha, beta, n): """t-test power matches analytical non-central t at non-default alpha.""" from mcpower import MCPower @@ -63,7 +62,7 @@ def test_single_predictor_t_test_alpha(self, backend, alpha, beta, n): exact_power = analytical_t_power(beta, n, p=1, sigma_eps=1.0, vif_j=1.0, alpha=alpha) margin = mc_accuracy_margin(exact_power, N_SIMS) assert abs(mc_power - exact_power) < margin, ( - f"[{backend}] alpha={alpha}, β={beta}, n={n}: MC={mc_power:.2f}%, analytical={exact_power:.2f}% ± {margin:.2f}%" + f"alpha={alpha}, β={beta}, n={n}: MC={mc_power:.2f}%, analytical={exact_power:.2f}% ± {margin:.2f}%" ) @pytest.mark.parametrize("alpha", [0.01, 0.10]) @@ -74,7 +73,7 @@ def test_single_predictor_t_test_alpha(self, backend, alpha, beta, n): (0.5, 0.3, 80), ], ) - def test_two_predictors_uncorrelated_alpha(self, backend, alpha, b1, b2, n): + def test_two_predictors_uncorrelated_alpha(self, alpha, b1, b2, n): """Each t-test and F-test with Σ = I at non-default alpha.""" from mcpower import MCPower @@ -103,14 +102,14 @@ def test_two_predictors_uncorrelated_alpha(self, backend, alpha, b1, b2, n): ) margin = mc_accuracy_margin(exact, N_SIMS) assert abs(mc_power - exact) < margin, ( - f"[{backend}] alpha={alpha}, {var}: MC={mc_power:.2f}%, analytical={exact:.2f}% ± {margin:.2f}%" + f"alpha={alpha}, {var}: MC={mc_power:.2f}%, analytical={exact:.2f}% ± {margin:.2f}%" ) mc_f = get_power(result, "overall") exact_f = analytical_f_power([b1, b2], n, Sigma, sigma_eps=1.0, alpha=alpha) margin_f = mc_accuracy_margin(exact_f, N_SIMS) assert abs(mc_f - exact_f) < margin_f, ( - f"[{backend}] alpha={alpha}, F-test: MC={mc_f:.2f}%, analytical={exact_f:.2f}% ± {margin_f:.2f}%" + f"alpha={alpha}, F-test: MC={mc_f:.2f}%, analytical={exact_f:.2f}% ± {margin_f:.2f}%" ) @pytest.mark.parametrize("alpha", [0.01, 0.10]) @@ -121,7 +120,7 @@ def test_two_predictors_uncorrelated_alpha(self, backend, alpha, b1, b2, n): (0.5, 0.3, 0.5, 80), ], ) - def test_two_predictors_correlated_alpha(self, backend, alpha, b1, b2, rho, n): + def test_two_predictors_correlated_alpha(self, alpha, b1, b2, rho, n): """VIF-corrected t-tests with correlated predictors at non-default alpha.""" from mcpower import MCPower @@ -154,7 +153,7 @@ def test_two_predictors_correlated_alpha(self, backend, alpha, b1, b2, rho, n): ) margin = mc_accuracy_margin(exact, N_SIMS) assert abs(mc_power - exact) < margin, ( - f"[{backend}] alpha={alpha}, rho={rho}, {var}: MC={mc_power:.2f}%, analytical={exact:.2f}% ± {margin:.2f}%" + f"alpha={alpha}, rho={rho}, {var}: MC={mc_power:.2f}%, analytical={exact:.2f}% ± {margin:.2f}%" ) @@ -170,9 +169,9 @@ class TestAlphaCorrectionAccuracy: @pytest.mark.parametrize("alpha", [0.01, 0.10]) @pytest.mark.parametrize("correction", ["bonferroni", "holm", "fdr"]) - def test_corrected_leq_uncorrected_at_alpha(self, backend, alpha, correction): + def test_corrected_leq_uncorrected_at_alpha(self, alpha, correction): """Corrected power <= uncorrected power when all effects = 0.""" - m = make_null_model("y = x1 + x2 + x3", n_sims=N_SIMS, alpha=alpha, seed=SEED) + m = make_null_model("y = x1 + x2 + x3", n_sims=N_SIMS_ORDERING, alpha=alpha, seed=SEED) result = m.find_power( sample_size=100, target_test="x1, x2, x3", @@ -184,14 +183,14 @@ def test_corrected_leq_uncorrected_at_alpha(self, backend, alpha, correction): uncorr = get_power(result, var) corr = get_power_corrected(result, var) assert corr <= uncorr + 0.5, ( - f"[{backend}] alpha={alpha}, {correction}: corrected {corr:.2f}% > uncorrected {uncorr:.2f}% for {var}" + f"alpha={alpha}, {correction}: corrected {corr:.2f}% > uncorrected {uncorr:.2f}% for {var}" ) @pytest.mark.parametrize("alpha", [0.01, 0.10]) @pytest.mark.parametrize("correction", ["bonferroni", "holm"]) - def test_fwer_controlled_at_alpha(self, backend, alpha, correction): + def test_fwer_controlled_at_alpha(self, alpha, correction): """FWER-controlling methods keep per-test rejection below nominal alpha.""" - m = make_null_model("y = x1 + x2 + x3", n_sims=N_SIMS, alpha=alpha, seed=SEED) + m = make_null_model("y = x1 + x2 + x3", n_sims=N_SIMS_ORDERING, alpha=alpha, seed=SEED) result = m.find_power( sample_size=100, target_test="x1, x2, x3", @@ -201,17 +200,17 @@ def test_fwer_controlled_at_alpha(self, backend, alpha, correction): ) for var in ["x1", "x2", "x3"]: corr = get_power_corrected(result, var) - assert corr < alpha * 100 + mc_margin(alpha, N_SIMS), ( - f"[{backend}] alpha={alpha}, {correction} FWER violation for {var}: corrected power = {corr:.2f}%" + assert corr < alpha * 100 + mc_margin(alpha, N_SIMS_ORDERING), ( + f"alpha={alpha}, {correction} FWER violation for {var}: corrected power = {corr:.2f}%" ) @pytest.mark.parametrize("alpha", [0.01, 0.10]) - def test_bonferroni_more_conservative_than_fdr_at_alpha(self, backend, alpha): + def test_bonferroni_more_conservative_than_fdr_at_alpha(self, alpha): """Bonferroni should reject <= FDR (BH) under non-null at non-default alpha.""" from mcpower import MCPower m = MCPower("y = x1 + x2 + x3") - m.set_simulations(N_SIMS) + m.set_simulations(N_SIMS_ORDERING) m.set_seed(SEED) m.set_alpha(alpha) m.set_effects("x1=0.3, x2=0.2, x3=0.1") @@ -233,7 +232,7 @@ def test_bonferroni_more_conservative_than_fdr_at_alpha(self, backend, alpha): for var in ["x1", "x2", "x3"]: bonf = get_power_corrected(result_bonf, var) fdr = get_power_corrected(result_fdr, var) - assert bonf <= fdr + 2.0, f"[{backend}] alpha={alpha}: Bonferroni ({bonf:.2f}%) > FDR ({fdr:.2f}%) for {var}" + assert bonf <= fdr + 2.0, f"alpha={alpha}: Bonferroni ({bonf:.2f}%) > FDR ({fdr:.2f}%) for {var}" # ── Class 3: Null calibration at alpha != 0.05 (multi-predictor) ──── @@ -245,29 +244,29 @@ class TestAlphaCalibrationExtended: to multi-predictor models and corrected rejection under the null. """ - @pytest.mark.parametrize("alpha", [0.01, 0.05, 0.10]) - def test_null_rejection_multi_predictor(self, backend, alpha): + @pytest.mark.parametrize("alpha", [0.01, 0.10]) + def test_null_rejection_multi_predictor(self, alpha): """Two-predictor null: each t-test and overall F-test reject at ~alpha.""" - m = make_null_model("y = x1 + x2", n_sims=N_SIMS, alpha=alpha, seed=SEED) + m = make_null_model("y = x1 + x2", n_sims=N_SIMS_STANDARD, alpha=alpha, seed=SEED) result = m.find_power( sample_size=100, target_test="all", print_results=False, return_results=True, ) - margin = mc_margin(alpha, N_SIMS) + margin = mc_margin(alpha, N_SIMS_STANDARD) expected = alpha * 100 for test_name in ["x1", "x2", "overall"]: power = get_power(result, test_name) assert abs(power - expected) < margin, ( - f"[{backend}] alpha={alpha}, {test_name}: observed {power:.2f}%, expected {expected}% ± {margin:.2f}%" + f"alpha={alpha}, {test_name}: observed {power:.2f}%, expected {expected}% ± {margin:.2f}%" ) - @pytest.mark.parametrize("alpha", [0.01, 0.05, 0.10]) + @pytest.mark.parametrize("alpha", [0.01, 0.10]) @pytest.mark.parametrize("correction", ["bonferroni", "holm"]) - def test_null_rejection_corrected_at_alpha(self, backend, alpha, correction): + def test_null_rejection_corrected_at_alpha(self, alpha, correction): """Corrected null rejection stays below alpha + MC margin for 3 predictors.""" - m = make_null_model("y = x1 + x2 + x3", n_sims=N_SIMS, alpha=alpha, seed=SEED) + m = make_null_model("y = x1 + x2 + x3", n_sims=N_SIMS_STANDARD, alpha=alpha, seed=SEED) result = m.find_power( sample_size=100, target_test="x1, x2, x3", @@ -275,9 +274,9 @@ def test_null_rejection_corrected_at_alpha(self, backend, alpha, correction): print_results=False, return_results=True, ) - margin = mc_margin(alpha, N_SIMS) + margin = mc_margin(alpha, N_SIMS_STANDARD) for var in ["x1", "x2", "x3"]: corr = get_power_corrected(result, var) assert corr < alpha * 100 + margin, ( - f"[{backend}] alpha={alpha}, {correction}, {var}: corrected rejection {corr:.2f}% exceeds {alpha * 100}% + {margin:.2f}%" + f"alpha={alpha}, {correction}, {var}: corrected rejection {corr:.2f}% exceeds {alpha * 100}% + {margin:.2f}%" ) diff --git a/tests/specs/test_corrections.py b/tests/specs/test_corrections.py index b26b8e1..025aee7 100644 --- a/tests/specs/test_corrections.py +++ b/tests/specs/test_corrections.py @@ -1,7 +1,5 @@ """ -Multiple comparison correction tests — backend-agnostic. - -Tests run on ALL available backends via the backend fixture. +Multiple comparison correction tests. """ import contextlib @@ -9,7 +7,7 @@ import pytest -from tests.config import N_SIMS, SEED +from tests.config import N_SIMS_ORDERING as N_SIMS, SEED from tests.helpers.mc_margins import mc_margin from tests.helpers.power_helpers import get_power, get_power_corrected, make_null_model @@ -28,7 +26,7 @@ class TestCorrectionConservativeness: """ @pytest.mark.parametrize("correction", ["bonferroni", "holm", "fdr"]) - def test_corrected_leq_uncorrected_under_null(self, backend, correction): + def test_corrected_leq_uncorrected_under_null(self, correction): """Corrected power ≤ uncorrected power when all effects = 0.""" m = make_null_model("y = x1 + x2 + x3", n_sims=N_SIMS, seed=SEED) result = m.find_power( @@ -42,11 +40,11 @@ def test_corrected_leq_uncorrected_under_null(self, backend, correction): uncorr = get_power(result, var) corr = get_power_corrected(result, var) assert corr <= uncorr + 0.5, ( # tiny tolerance for MC noise - f"[{backend}] {correction}: corrected {corr:.2f}% > uncorrected {uncorr:.2f}% for {var}" + f"{correction}: corrected {corr:.2f}% > uncorrected {uncorr:.2f}% for {var}" ) @pytest.mark.parametrize("correction", ["bonferroni", "holm"]) - def test_fwer_controlled_under_null(self, backend, correction): + def test_fwer_controlled_under_null(self, correction): """ Family-wise error rate under H0 should be ≤ alpha. @@ -65,10 +63,10 @@ def test_fwer_controlled_under_null(self, backend, correction): # Under complete null, FWER-controlling methods should have # per-test rejection well below the nominal alpha assert corr < m.alpha * 100 + mc_margin(m.alpha, m.n_simulations), ( - f"[{backend}] {correction} FWER violation for {var}: corrected power = {corr:.2f}%" + f"{correction} FWER violation for {var}: corrected power = {corr:.2f}%" ) - def test_bonferroni_more_conservative_than_fdr(self, backend): + def test_bonferroni_more_conservative_than_fdr(self): """Bonferroni should reject ≤ FDR (BH) under non-null.""" from mcpower import MCPower @@ -95,4 +93,4 @@ def test_bonferroni_more_conservative_than_fdr(self, backend): bonf = get_power_corrected(result_bonf, var) fdr = get_power_corrected(result_fdr, var) # Bonferroni ≤ BH-FDR (with MC tolerance) - assert bonf <= fdr + 2.0, f"[{backend}] Bonferroni ({bonf:.2f}%) > FDR ({fdr:.2f}%) for {var}" + assert bonf <= fdr + 2.0, f"Bonferroni ({bonf:.2f}%) > FDR ({fdr:.2f}%) for {var}" diff --git a/tests/specs/test_monotonicity.py b/tests/specs/test_monotonicity.py index 4261b99..bac2fe9 100644 --- a/tests/specs/test_monotonicity.py +++ b/tests/specs/test_monotonicity.py @@ -1,8 +1,7 @@ """ -Power monotonicity tests — backend-agnostic. +Power monotonicity tests. Power must increase with effect size, sample size, and alpha. -Tests run on ALL available backends via the backend fixture. """ import contextlib @@ -10,7 +9,7 @@ import pytest -from tests.config import N_SIMS, SEED +from tests.config import N_SIMS_ORDERING as N_SIMS, SEED from tests.helpers.power_helpers import get_power @@ -24,7 +23,7 @@ def _quiet(): class TestPowerMonotonicity: """Power must increase with effect size, sample size, and alpha.""" - def test_power_increases_with_effect_size(self, backend): + def test_power_increases_with_effect_size(self): """Larger standardised beta → higher power.""" from mcpower import MCPower @@ -43,9 +42,9 @@ def test_power_increases_with_effect_size(self, backend): powers.append(get_power(result, "x1")) for i in range(len(powers) - 1): - assert powers[i] < powers[i + 1], f"[{backend}] Power not monotonic in effect size: {powers}" + assert powers[i] < powers[i + 1], f"Power not monotonic in effect size: {powers}" - def test_power_increases_with_sample_size(self, backend): + def test_power_increases_with_sample_size(self): """Larger N → higher power (for non-zero effect).""" from mcpower import MCPower @@ -64,9 +63,9 @@ def test_power_increases_with_sample_size(self, backend): powers.append(get_power(result, "x1")) for i in range(len(powers) - 1): - assert powers[i] < powers[i + 1], f"[{backend}] Power not monotonic in N: {powers}" + assert powers[i] < powers[i + 1], f"Power not monotonic in N: {powers}" - def test_power_increases_with_alpha(self, backend): + def test_power_increases_with_alpha(self): """Less stringent alpha → higher power.""" from mcpower import MCPower @@ -86,13 +85,13 @@ def test_power_increases_with_alpha(self, backend): powers.append(get_power(result, "x1")) for i in range(len(powers) - 1): - assert powers[i] < powers[i + 1], f"[{backend}] Power not monotonic in alpha: {powers}" + assert powers[i] < powers[i + 1], f"Power not monotonic in alpha: {powers}" class TestPowerConvergence: """Power must approach 100% when signal is overwhelming.""" - def test_large_effect_high_power(self, backend): + def test_large_effect_high_power(self): """Very large effect → power near 100%.""" from mcpower import MCPower @@ -107,9 +106,9 @@ def test_large_effect_high_power(self, backend): return_results=True, ) power = get_power(result, "x1") - assert power > 99.0, f"[{backend}] Large-effect power should be ~100%, got {power:.2f}%" + assert power > 99.0, f"Large-effect power should be ~100%, got {power:.2f}%" - def test_large_n_moderate_effect(self, backend): + def test_large_n_moderate_effect(self): """Large N with moderate effect → power near 100%.""" from mcpower import MCPower @@ -124,4 +123,4 @@ def test_large_n_moderate_effect(self, backend): return_results=True, ) power = get_power(result, "x1") - assert power > 99.0, f"[{backend}] Large-N power should be ~100%, got {power:.2f}%" + assert power > 99.0, f"Large-N power should be ~100%, got {power:.2f}%" diff --git a/tests/specs/test_power_accuracy.py b/tests/specs/test_power_accuracy.py index 755d0bb..fff7235 100644 --- a/tests/specs/test_power_accuracy.py +++ b/tests/specs/test_power_accuracy.py @@ -1,9 +1,8 @@ """ -Power accuracy tests — backend-agnostic. +Power accuracy tests. Compare MC power estimates against exact analytical power from non-central t / F distributions. -Tests run on ALL available backends via the backend fixture. """ import contextlib @@ -42,7 +41,7 @@ class TestAccuracyVsAnalytical: (0.5, 150), ], ) - def test_single_predictor_t_test(self, backend, beta, n): + def test_single_predictor_t_test(self, beta, n): """t-test power matches analytical non-central t.""" from mcpower import MCPower @@ -60,7 +59,7 @@ def test_single_predictor_t_test(self, backend, beta, n): exact_power = analytical_t_power(beta, n, p=1, sigma_eps=1.0, vif_j=1.0) margin = mc_accuracy_margin(exact_power, N_SIMS) assert abs(mc_power - exact_power) < margin, ( - f"[{backend}] β={beta}, n={n}: MC={mc_power:.2f}%, analytical={exact_power:.2f}% ± {margin:.2f}%" + f"β={beta}, n={n}: MC={mc_power:.2f}%, analytical={exact_power:.2f}% ± {margin:.2f}%" ) @pytest.mark.parametrize( @@ -71,7 +70,7 @@ def test_single_predictor_t_test(self, backend, beta, n): (0.2, 0.2, 200), ], ) - def test_two_predictors_uncorrelated(self, backend, b1, b2, n): + def test_two_predictors_uncorrelated(self, b1, b2, n): """Each t-test and F-test with Σ = I.""" from mcpower import MCPower @@ -91,12 +90,12 @@ def test_two_predictors_uncorrelated(self, backend, b1, b2, n): mc_power = get_power(result, var) exact = analytical_t_power(beta, n, p=2, sigma_eps=1.0, vif_j=1.0) margin = mc_accuracy_margin(exact, N_SIMS) - assert abs(mc_power - exact) < margin, f"[{backend}] {var}: MC={mc_power:.2f}%, analytical={exact:.2f}% ± {margin:.2f}%" + assert abs(mc_power - exact) < margin, f"{var}: MC={mc_power:.2f}%, analytical={exact:.2f}% ± {margin:.2f}%" mc_f = get_power(result, "overall") exact_f = analytical_f_power([b1, b2], n, Sigma, sigma_eps=1.0) margin_f = mc_accuracy_margin(exact_f, N_SIMS) - assert abs(mc_f - exact_f) < margin_f, f"[{backend}] F-test: MC={mc_f:.2f}%, analytical={exact_f:.2f}% ± {margin_f:.2f}%" + assert abs(mc_f - exact_f) < margin_f, f"F-test: MC={mc_f:.2f}%, analytical={exact_f:.2f}% ± {margin_f:.2f}%" @pytest.mark.parametrize( "b1,b2,rho,n", @@ -107,7 +106,7 @@ def test_two_predictors_uncorrelated(self, backend, b1, b2, n): (0.5, 0.3, 0.5, 80), ], ) - def test_two_predictors_correlated_t_tests(self, backend, b1, b2, rho, n): + def test_two_predictors_correlated_t_tests(self, b1, b2, rho, n): """Individual t-tests with correlated predictors: VIF matters.""" from mcpower import MCPower @@ -132,5 +131,5 @@ def test_two_predictors_correlated_t_tests(self, backend, b1, b2, rho, n): exact = analytical_t_power(beta, n, p=2, sigma_eps=1.0, vif_j=vif) margin = mc_accuracy_margin(exact, N_SIMS) assert abs(mc_power - exact) < margin, ( - f"[{backend}] rho={rho}, {var}: MC={mc_power:.2f}%, analytical={exact:.2f}% ± {margin:.2f}%" + f"rho={rho}, {var}: MC={mc_power:.2f}%, analytical={exact:.2f}% ± {margin:.2f}%" ) diff --git a/tests/specs/test_type1_error.py b/tests/specs/test_type1_error.py index c56b4fe..565d80e 100644 --- a/tests/specs/test_type1_error.py +++ b/tests/specs/test_type1_error.py @@ -1,8 +1,7 @@ """ -Type I error control tests — backend-agnostic. +Type I error control tests. Under H0 (effect = 0), rejection rate must equal alpha. -Tests run on ALL available backends via the backend fixture. """ import contextlib @@ -10,7 +9,7 @@ import pytest -from tests.config import N_SIMS, SEED +from tests.config import N_SIMS_STANDARD as N_SIMS, SEED from tests.helpers.mc_margins import mc_margin from tests.helpers.power_helpers import get_power, make_null_model @@ -25,7 +24,7 @@ def _quiet(): class TestTypeIErrorControl: """Under H0 (effect = 0), rejection rate must equal alpha.""" - def test_single_predictor_null_overall(self, backend): + def test_single_predictor_null_overall(self): """F-test rejection rate ≈ alpha with one predictor at zero effect.""" m = make_null_model("y = x1", n_sims=N_SIMS, seed=SEED) result = m.find_power( @@ -37,9 +36,9 @@ def test_single_predictor_null_overall(self, backend): power = get_power(result, "overall") margin = mc_margin(m.alpha, m.n_simulations) expected = m.alpha * 100 - assert abs(power - expected) < margin, f"[{backend}] F-test power under H0: {power:.2f}%, expected {expected}% ± {margin:.2f}%" + assert abs(power - expected) < margin, f"F-test power under H0: {power:.2f}%, expected {expected}% ± {margin:.2f}%" - def test_single_predictor_null_individual(self, backend): + def test_single_predictor_null_individual(self): """t-test rejection rate ≈ alpha for a single zero-effect predictor.""" m = make_null_model("y = x1", n_sims=N_SIMS, seed=SEED) result = m.find_power( @@ -51,9 +50,9 @@ def test_single_predictor_null_individual(self, backend): power = get_power(result, "x1") margin = mc_margin(m.alpha, m.n_simulations) expected = m.alpha * 100 - assert abs(power - expected) < margin, f"[{backend}] t-test power under H0: {power:.2f}%, expected {expected}% ± {margin:.2f}%" + assert abs(power - expected) < margin, f"t-test power under H0: {power:.2f}%, expected {expected}% ± {margin:.2f}%" - def test_two_predictors_null_each(self, backend): + def test_two_predictors_null_each(self): """Both predictors at zero → each t-test rejects at ~alpha.""" m = make_null_model("y = x1 + x2", n_sims=N_SIMS, seed=SEED) result = m.find_power( @@ -66,9 +65,9 @@ def test_two_predictors_null_each(self, backend): expected = m.alpha * 100 for var in ["x1", "x2"]: power = get_power(result, var) - assert abs(power - expected) < margin, f"[{backend}] {var} power under H0: {power:.2f}%, expected {expected}% ± {margin:.2f}%" + assert abs(power - expected) < margin, f"{var} power under H0: {power:.2f}%, expected {expected}% ± {margin:.2f}%" - def test_large_sample_null(self, backend): + def test_large_sample_null(self): """ Large N with zero effect must NOT inflate Type I error. @@ -76,7 +75,7 @@ def test_large_sample_null(self, backend): """ m = make_null_model("y = x1", n_sims=N_SIMS, seed=SEED) result = m.find_power( - sample_size=1000, + sample_size=500, target_test="x1", print_results=False, return_results=True, @@ -85,7 +84,7 @@ def test_large_sample_null(self, backend): margin = mc_margin(m.alpha, m.n_simulations) expected = m.alpha * 100 assert abs(power - expected) < margin, ( - f"[{backend}] Large-N null power: {power:.2f}%, expected {expected}% ± {margin:.2f}% (Type I error inflated with N?)" + f"Large-N null power: {power:.2f}%, expected {expected}% ± {margin:.2f}% (Type I error inflated with N?)" ) @@ -93,7 +92,7 @@ class TestAlphaCalibration: """Rejection rate tracks the nominal alpha across levels.""" @pytest.mark.parametrize("alpha", [0.01, 0.05, 0.10]) - def test_null_rejection_matches_alpha(self, backend, alpha): + def test_null_rejection_matches_alpha(self, alpha): m = make_null_model("y = x1", n_sims=N_SIMS, alpha=alpha, seed=SEED) result = m.find_power( sample_size=100, @@ -104,4 +103,4 @@ def test_null_rejection_matches_alpha(self, backend, alpha): power = get_power(result, "x1") margin = mc_margin(alpha, m.n_simulations) expected = alpha * 100 - assert abs(power - expected) < margin, f"[{backend}] alpha={alpha}: observed {power:.2f}%, expected {expected}% ± {margin:.2f}%" + assert abs(power - expected) < margin, f"alpha={alpha}: observed {power:.2f}%, expected {expected}% ± {margin:.2f}%" diff --git a/tests/unit/test_distributions.py b/tests/unit/test_distributions.py index d3e77f9..693d1ff 100644 --- a/tests/unit/test_distributions.py +++ b/tests/unit/test_distributions.py @@ -24,7 +24,6 @@ import pytest from mcpower.stats.distributions import ( - _BACKEND, chi2_cdf, chi2_ppf, compute_critical_values_lme, @@ -587,29 +586,6 @@ def test_studentized_range_k_too_large_returns_inf(self): # =========================================================================== # 15. Backend detection -# =========================================================================== -class TestBackendDetection: - """Verify the distribution backend is correctly detected.""" - - def test_backend_is_set(self): - assert _BACKEND is not None - - def test_backend_is_string(self): - assert isinstance(_BACKEND, str) - - def test_backend_is_known_value(self): - assert _BACKEND in ("native", "scipy") - - def test_native_backend_when_compiled(self): - """When the C++ extension is compiled, backend should be 'native'.""" - try: - import mcpower.backends.mcpower_native # noqa: F401 - - assert _BACKEND == "native" - except ImportError: - pytest.skip("C++ native backend not compiled") - - # =========================================================================== # Cross-consistency checks # =========================================================================== diff --git a/tests/unit/test_distributions_coverage.py b/tests/unit/test_distributions_coverage.py new file mode 100644 index 0000000..224c8e6 --- /dev/null +++ b/tests/unit/test_distributions_coverage.py @@ -0,0 +1,42 @@ +"""Tests for distributions.py — optimizer functions and edge cases.""" + +import numpy as np +import pytest + +from mcpower.stats.distributions import minimize_lbfgsb, minimize_scalar_brent + + +class TestOptimizerLBFGSB: + """L-BFGS-B optimizer via native backend.""" + + def test_finds_correct_minimum(self): + # Simple quadratic: f(x) = (x-2)^2 + result = minimize_lbfgsb( + lambda x: float((x[0] - 2) ** 2), + x0=np.array([0.0]), + bounds=[(-10.0, 10.0)], + ) + assert abs(result.x[0] - 2.0) < 0.01 + assert result.fun < 0.01 + + +class TestOptimizerBrent: + """Brent scalar minimizer via native backend.""" + + def test_finds_correct_minimum(self): + # f(x) = (x - 3)^2 + result = minimize_scalar_brent( + lambda x: (x - 3) ** 2, + bounds=(0.0, 10.0), + ) + assert abs(result.x - 3.0) < 0.01 + assert result.fun < 0.01 + + def test_converged_flag(self): + result = minimize_scalar_brent( + lambda x: (x - 5) ** 2, + bounds=(0.0, 10.0), + ) + assert result.converged + + diff --git a/tests/unit/test_formatters_edge.py b/tests/unit/test_formatters_edge.py new file mode 100644 index 0000000..f52fd25 --- /dev/null +++ b/tests/unit/test_formatters_edge.py @@ -0,0 +1,230 @@ +"""Tests for formatter edge cases — scenario sample-size long format, cumulative recs, NaN filtering.""" + +import math + +import pytest + +from mcpower.utils.formatters import _ResultFormatter, _is_nan + + +_fmt = _ResultFormatter() + + +def _make_scenario_sample_size_data( + target_tests=("x1", "x2"), + correction=None, + sample_sizes=(50, 100, 150), + optimistic_achieved=None, + realistic_achieved=None, + doomer_achieved=None, +): + """Build a scenario sample_size result dict for formatting tests.""" + if optimistic_achieved is None: + optimistic_achieved = {"x1": 50, "x2": 100} + if realistic_achieved is None: + realistic_achieved = {"x1": 100, "x2": 150} + if doomer_achieved is None: + doomer_achieved = {"x1": 0, "x2": 0} # Not achieved + + def _make_scenario(achieved): + achieved_corr = {t: -1 for t in target_tests} if not correction else achieved + return { + "model": { + "target_tests": list(target_tests), + "correction": correction, + "sample_size_range": {"from_size": sample_sizes[0], "to_size": sample_sizes[-1]}, + "target_power": 80.0, + }, + "results": { + "first_achieved": achieved, + "first_achieved_corrected": achieved_corr, + "sample_sizes_tested": list(sample_sizes), + "powers_by_test": { + t: [30.0 + 25.0 * i for i in range(len(sample_sizes))] + for t in target_tests + }, + "powers_by_test_corrected": ( + {t: [25.0 + 25.0 * i for i in range(len(sample_sizes))] for t in target_tests} + if correction + else None + ), + }, + } + + return { + "analysis_type": "sample_size", + "scenarios": { + "optimistic": _make_scenario(optimistic_achieved), + "realistic": _make_scenario(realistic_achieved), + "doomer": _make_scenario(doomer_achieved), + }, + "comparison": {}, + } + + +class TestScenarioSampleSizeLongFormat: + """Test _format_scenario_sample_size with summary='long'.""" + + def test_recommendations_present(self): + data = _make_scenario_sample_size_data() + output = _fmt.format("scenario_sample_size", data, "long") + assert "RECOMMENDATIONS" in output + + def test_unachievable_tests_warning(self): + data = _make_scenario_sample_size_data( + doomer_achieved={"x1": 0, "x2": 0}, + ) + output = _fmt.format("scenario_sample_size", data, "long") + assert "Warning" in output or "may not achieve" in output + + def test_realistic_recommendation_shown(self): + data = _make_scenario_sample_size_data( + realistic_achieved={"x1": 100, "x2": 150}, + ) + output = _fmt.format("scenario_sample_size", data, "long") + assert "150" in output # max N for realistic + + def test_short_format_produces_table(self): + data = _make_scenario_sample_size_data() + output = _fmt.format("scenario_sample_size", data, "short") + assert "SCENARIO SUMMARY" in output + + def test_with_correction(self): + data = _make_scenario_sample_size_data(correction="bonferroni") + output = _fmt.format("scenario_sample_size", data, "short") + assert "Opt(U)" in output or "Uncorrected" in output.lower() or "(U)" in output + + +class TestCumulativeRecommendations: + """Test _format_cumulative_recommendations paths.""" + + def test_non_scenario_target_met(self): + data = { + "model": { + "target_tests": ["x1", "x2"], + "target_power": 80.0, + }, + "results": { + "sample_sizes_tested": [50, 100, 150], + "powers_by_test": { + "x1": [60.0, 85.0, 95.0], + "x2": [70.0, 90.0, 98.0], + }, + }, + } + lines = _fmt._format_cumulative_recommendations(data, is_scenario=False) + joined = "\n".join(lines) + assert "N=" in joined # Found a sample size + + def test_non_scenario_target_not_met(self): + data = { + "model": { + "target_tests": ["x1", "x2"], + "target_power": 80.0, + }, + "results": { + "sample_sizes_tested": [50, 100], + "powers_by_test": { + "x1": [10.0, 20.0], + "x2": [15.0, 25.0], + }, + }, + } + lines = _fmt._format_cumulative_recommendations(data, is_scenario=False) + joined = "\n".join(lines) + assert ">100" in joined # Exceeded max tested + + def test_scenario_recommendations(self): + data = _make_scenario_sample_size_data( + sample_sizes=(50, 100, 150, 200), + optimistic_achieved={"x1": 100, "x2": 150}, + ) + # Override powers so all > 80% + for scenario in data["scenarios"].values(): + scenario["results"]["powers_by_test"] = { + "x1": [50.0, 85.0, 92.0, 98.0], + "x2": [40.0, 75.0, 88.0, 95.0], + } + lines = _fmt._format_cumulative_recommendations(data, is_scenario=True) + assert len(lines) > 0 + + def test_empty_scenarios(self): + data = {"scenarios": {}} + lines = _fmt._format_cumulative_recommendations(data, is_scenario=True) + assert lines == [] + + def test_no_results_key(self): + data = {} + lines = _fmt._format_cumulative_recommendations(data, is_scenario=False) + assert lines == [] + + +class TestNaNPowerFiltering: + """NaN power values in cumulative table should be filtered out.""" + + def test_nan_power_filtered_in_cumulative_sample_size_table(self): + lines = [] + _fmt._add_cumulative_sample_size_table( + lines, + sample_sizes=[50, 100], + target_tests=["x1", "x2_nan"], + powers_by_test={ + "x1": [50.0, 80.0], + "x2_nan": [float("nan"), float("nan")], + }, + ) + # Should still produce output for x1 (x2_nan filtered out) + output = "\n".join(lines) + assert "N=50" in output or "50" in output + + def test_all_nan_produces_no_table(self): + lines = [] + _fmt._add_cumulative_sample_size_table( + lines, + sample_sizes=[50], + target_tests=["x1"], + powers_by_test={"x1": [float("nan")]}, + ) + # All NaN → no valid tests → no table + assert len(lines) == 0 + + +class TestIsNan: + """Test _is_nan utility.""" + + def test_nan_float(self): + assert _is_nan(float("nan")) + + def test_regular_float(self): + assert not _is_nan(42.0) + + def test_non_float(self): + assert not _is_nan("nan") + assert not _is_nan(None) + assert not _is_nan(42) + + +class TestExtractScenarioMeta: + """Test _extract_scenario_meta.""" + + def test_no_model_returns_none(self): + target_tests, correction = _fmt._extract_scenario_meta({"opt": {"results": {}}}) + assert target_tests is None + + def test_extracts_from_first_scenario(self): + scenarios = { + "optimistic": { + "model": {"target_tests": ["a", "b"], "correction": "holm"}, + } + } + target_tests, correction = _fmt._extract_scenario_meta(scenarios) + assert target_tests == ["a", "b"] + assert correction == "holm" + + +class TestFormatUnknownType: + """Unknown result type should raise.""" + + def test_unknown_result_type(self): + with pytest.raises(ValueError, match="Unknown result type"): + _fmt.format("nonexistent", {}) diff --git a/tests/unit/test_mixed_models_coverage.py b/tests/unit/test_mixed_models_coverage.py new file mode 100644 index 0000000..bc99974 --- /dev/null +++ b/tests/unit/test_mixed_models_coverage.py @@ -0,0 +1,292 @@ +"""Tests for stats/mixed_models.py — statsmodels convergence, corrections, native wrappers. + +Uses pytest.mark.lme to skip when statsmodels is not installed. +""" + +import warnings +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from mcpower.stats.mixed_models import ( + _ensure_lme_crits, + _lme_analysis_wrapper, + _wrap_native_result, + reset_warm_start_cache, +) + +pytestmark = pytest.mark.lme + + +class TestWrapNativeResult: + """Test _wrap_native_result helper.""" + + def test_non_empty_non_verbose(self): + result = np.array([1.0, 0.0, 1.0]) + wrapped = _wrap_native_result(result, verbose=False, solver_name="native_q1") + np.testing.assert_array_equal(wrapped, result) + + def test_non_empty_verbose(self): + result = np.array([1.0, 0.0, 1.0]) + wrapped = _wrap_native_result(result, verbose=True, solver_name="native_q1") + assert isinstance(wrapped, dict) + assert "results" in wrapped + assert "diagnostics" in wrapped + assert wrapped["diagnostics"]["solver"] == "native_q1" + + def test_non_empty_verbose_with_extra_diag(self): + result = np.array([1.0]) + wrapped = _wrap_native_result( + result, verbose=True, solver_name="native_general", + extra_diag={"q": 3}, + ) + assert wrapped["diagnostics"]["q"] == 3 + + def test_empty_non_verbose_returns_none(self): + result = np.array([]) + assert _wrap_native_result(result, verbose=False, solver_name="native_q1") is None + + def test_empty_verbose_returns_failure_dict(self): + result = np.array([]) + wrapped = _wrap_native_result(result, verbose=True, solver_name="native_q1") + assert wrapped["results"] is None + assert "failure_reason" in wrapped + assert "empty result" in wrapped["failure_reason"] + + +class TestEnsureLMECrits: + """Test _ensure_lme_crits computes when None.""" + + def test_computes_when_none(self): + chi2, z, crits = _ensure_lme_crits( + alpha=0.05, p=3, n_targets=2, correction_method=0, + chi2_crit=None, z_crit=None, correction_z_crits=None, + ) + assert np.isfinite(chi2) + assert np.isfinite(z) + assert len(crits) == 2 + + def test_passthrough_when_provided(self): + chi2, z, crits = _ensure_lme_crits( + alpha=0.05, p=3, n_targets=2, correction_method=0, + chi2_crit=7.8, z_crit=1.96, correction_z_crits=np.array([1.96, 1.96]), + ) + assert chi2 == 7.8 + assert z == 1.96 + assert len(crits) == 2 + + +class TestLMEAnalysisWrapperRouting: + """Test _lme_analysis_wrapper routes to correct backend.""" + + def test_unknown_backend_raises(self): + with pytest.raises(ValueError, match="Unknown backend"): + _lme_analysis_wrapper( + np.eye(10), np.ones(10), np.array([0, 1]), + np.zeros(10, dtype=np.int32), + correction_method=0, alpha=0.05, backend="nonexistent", + ) + + +class TestStatsmodelsConvergence: + """Test statsmodels fallback path with mocked MixedLM.""" + + def _make_mock_result(self, converged=True, params=None, pvalues=None, n_params=3): + """Create a mock MixedLM result.""" + result = MagicMock() + result.converged = converged + result.params = params if params is not None else np.array([1.0, 0.5, 0.3]) + result.pvalues = pvalues if pvalues is not None else np.array([0.01, 0.02, 0.04]) + result.fe_params = result.params + result.bse = np.array([0.1, 0.1, 0.1]) + + # cov_re: random effects variance (needs .iloc[0, 0]) + cov_re = MagicMock() + cov_re.iloc.__getitem__ = MagicMock(return_value=0.5) + result.cov_re = cov_re + + result.scale = 1.0 + result.llf = -50.0 + + # Make cov_params return a proper matrix + result.cov_params.return_value = np.eye(n_params) * 0.01 + + # model attribute + result.model = MagicMock() + result.model.exog = MagicMock() + result.model.exog.shape = (100, n_params) + + return result + + @patch("statsmodels.regression.mixed_linear_model.MixedLM") + def test_warm_start_retry_chain(self, mock_mixedlm_cls): + """First fit fails, cold start succeeds.""" + from mcpower.stats.mixed_models import _lme_analysis_statsmodels, _lme_thread_local + + _lme_thread_local.warm_start_params = np.array([1.0, 0.5, 0.3]) + + mock_model = MagicMock() + mock_mixedlm_cls.return_value = mock_model + + good_result = self._make_mock_result() + mock_model.fit.side_effect = [ + Exception("warm start diverged"), + good_result, + ] + mock_model.loglike.return_value = -50.0 + + result = _lme_analysis_statsmodels( + X_expanded=np.random.randn(100, 2), + y=np.random.randn(100), + target_indices=np.array([0, 1]), + cluster_ids=np.repeat(np.arange(10), 10), + + correction_method=0, + alpha=0.05, + ) + assert result is not None + + @patch("statsmodels.regression.mixed_linear_model.MixedLM") + def test_all_attempts_fail_returns_none(self, mock_mixedlm_cls): + from mcpower.stats.mixed_models import _lme_analysis_statsmodels, _lme_thread_local + + _lme_thread_local.warm_start_params = None + + mock_model = MagicMock() + mock_mixedlm_cls.return_value = mock_model + mock_model.fit.side_effect = Exception("always fails") + + result = _lme_analysis_statsmodels( + X_expanded=np.random.randn(100, 2), + y=np.random.randn(100), + target_indices=np.array([0, 1]), + cluster_ids=np.repeat(np.arange(10), 10), + + correction_method=0, + alpha=0.05, + ) + assert result is None + + @patch("statsmodels.regression.mixed_linear_model.MixedLM") + def test_all_attempts_fail_verbose_returns_dict(self, mock_mixedlm_cls): + from mcpower.stats.mixed_models import _lme_analysis_statsmodels, _lme_thread_local + + _lme_thread_local.warm_start_params = None + + mock_model = MagicMock() + mock_mixedlm_cls.return_value = mock_model + mock_model.fit.side_effect = Exception("always fails") + + result = _lme_analysis_statsmodels( + X_expanded=np.random.randn(100, 2), + y=np.random.randn(100), + target_indices=np.array([0, 1]), + cluster_ids=np.repeat(np.arange(10), 10), + + correction_method=0, + alpha=0.05, + verbose=True, + ) + assert isinstance(result, dict) + assert result["results"] is None + assert "failure_reason" in result + + @patch("statsmodels.regression.mixed_linear_model.MixedLM") + def test_not_converged_returns_none(self, mock_mixedlm_cls): + """When result.converged is False for all attempts.""" + from mcpower.stats.mixed_models import _lme_analysis_statsmodels, _lme_thread_local + + _lme_thread_local.warm_start_params = None + + mock_model = MagicMock() + mock_mixedlm_cls.return_value = mock_model + + bad_result = self._make_mock_result(converged=False) + mock_model.fit.return_value = bad_result + + result = _lme_analysis_statsmodels( + X_expanded=np.random.randn(100, 2), + y=np.random.randn(100), + target_indices=np.array([0, 1]), + cluster_ids=np.repeat(np.arange(10), 10), + + correction_method=0, + alpha=0.05, + ) + assert result is None + + +class TestCorrections: + """Test statsmodels FDR, Holm, Bonferroni, no-correction paths.""" + + def _make_mock_result(self): + result = MagicMock() + result.converged = True + result.params = np.array([1.0, 0.5, 0.3]) + result.pvalues = np.array([0.001, 0.02, 0.04]) + result.fe_params = result.params + result.bse = np.array([0.1, 0.1, 0.1]) + result.scale = 1.0 + result.llf = -50.0 + result.model = MagicMock() + result.model.exog = MagicMock() + result.model.exog.shape = (100, 3) + + cov_re_mock = MagicMock() + cov_re_mock.iloc.__getitem__ = MagicMock(return_value=0.5) + result.cov_re = cov_re_mock + result.cov_params.return_value = np.eye(3) * 0.01 + + return result + + def _run_with_correction(self, correction_method): + from mcpower.stats.mixed_models import _lme_analysis_statsmodels, _lme_thread_local + + _lme_thread_local.warm_start_params = None + + mock_result = self._make_mock_result() + + with patch("statsmodels.regression.mixed_linear_model.MixedLM") as mock_cls: + mock_model = MagicMock() + mock_cls.return_value = mock_model + mock_model.fit.return_value = mock_result + mock_model.loglike.return_value = -50.0 + + out = _lme_analysis_statsmodels( + X_expanded=np.random.randn(100, 2), + y=np.random.randn(100), + target_indices=np.array([0, 1]), + cluster_ids=np.repeat(np.arange(10), 10), + + correction_method=correction_method, + alpha=0.05, + ) + return out + + def test_no_correction(self): + result = self._run_with_correction(0) + assert result is not None + + def test_bonferroni(self): + result = self._run_with_correction(1) + assert result is not None + + def test_fdr(self): + result = self._run_with_correction(2) + assert result is not None + + def test_holm(self): + result = self._run_with_correction(3) + assert result is not None + + +class TestResetWarmStartCache: + """Test reset_warm_start_cache.""" + + def test_clears_params(self): + from mcpower.stats.mixed_models import _lme_thread_local + + _lme_thread_local.warm_start_params = np.array([1.0]) + reset_warm_start_cache() + assert _lme_thread_local.warm_start_params is None diff --git a/tests/unit/test_model_coverage.py b/tests/unit/test_model_coverage.py new file mode 100644 index 0000000..23d47c9 --- /dev/null +++ b/tests/unit/test_model_coverage.py @@ -0,0 +1,117 @@ +"""Tests for model.py — parallel fallback, Tukey validation, NaN under Tukey correction.""" + +import warnings +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from mcpower import MCPower + + +class TestTukeyWithoutPosthoc: + """Tukey correction without posthoc specs should raise ValueError.""" + + def test_tukey_without_posthoc_raises(self): + model = MCPower("y = x1 + x2") + model.set_effects("x1=0.5, x2=0.3") + + with pytest.raises(ValueError, match="Tukey correction requires"): + model.find_power( + sample_size=100, + correction="tukey", + print_results=False, + ) + + +class TestTukeyNaNification: + """Non-posthoc tests should be NaN-ified under Tukey correction.""" + + def test_non_posthoc_tests_nan_under_tukey(self): + model = MCPower("y = group + x1") + model.set_variable_type("group=(factor,3)") + model.set_effects("group[2]=0.5, group[3]=0.4, x1=0.3") + model.n_simulations = 50 + model.seed = 42 + + result = model.find_sample_size( + target_test="all, all-posthoc", + correction="tukey", + from_size=30, + to_size=60, + by=30, + print_results=False, + return_results=True, + ) + + assert result is not None + results = result["results"] + corrected = results.get("powers_by_test_corrected", {}) + + # Post-hoc comparisons should have real power values + # Non-posthoc tests (like "x1", "group[2]", "group[3]", "overall") + # should have NaN values + posthoc_labels = {s.label for s in model._posthoc_specs} + for test_name, powers in corrected.items(): + if test_name not in posthoc_labels: + assert all(isinstance(v, float) and np.isnan(v) for v in powers), \ + f"Expected NaN for non-posthoc test '{test_name}', got {powers}" + + # first_achieved_corrected for non-posthoc should be -1 + for test_name, n in results.get("first_achieved_corrected", {}).items(): + if test_name not in posthoc_labels: + assert n == -1, f"Expected -1 for '{test_name}', got {n}" + + +class TestParallelFallback: + """Parallel execution falls back to sequential on exception.""" + + def test_parallel_exception_falls_back(self, capsys): + model = MCPower("y = x1 + x2") + model.set_effects("x1=0.5, x2=0.3") + model.parallel = True + model.n_simulations = 50 + model.seed = 42 + + # Parallel is imported inside the function via `from joblib import Parallel`, + # so we patch it at the joblib module level. + with patch("joblib.Parallel", side_effect=RuntimeError("joblib broken")): + # Should still complete via sequential fallback + result = model.find_sample_size( + from_size=30, + to_size=60, + by=30, + print_results=False, + return_results=True, + ) + assert result is not None + captured = capsys.readouterr() + assert "Falling back to sequential" in captured.out + + +class TestIsParallelEffective: + """Test _is_parallel_effective resolution.""" + + def test_true_always_parallel(self): + model = MCPower("y = x1 + x2") + model.parallel = True + assert model._is_parallel_effective() is True + + def test_false_never_parallel(self): + model = MCPower("y = x1 + x2") + model.parallel = False + assert model._is_parallel_effective() is False + + def test_mixedmodels_with_clusters(self): + model = MCPower("y ~ x1 + (1|school)") + model.set_cluster("school", ICC=0.2, n_clusters=20) + model.set_effects("x1=0.5") + model._apply() # cluster_specs are deferred until apply() + model.parallel = "mixedmodels" + assert model._is_parallel_effective() is True + + def test_mixedmodels_without_clusters(self): + model = MCPower("y = x1 + x2") + model.set_effects("x1=0.5, x2=0.3") + model.parallel = "mixedmodels" + assert model._is_parallel_effective() is False diff --git a/tests/unit/test_native_backend.py b/tests/unit/test_native_backend.py new file mode 100644 index 0000000..93b4a3e --- /dev/null +++ b/tests/unit/test_native_backend.py @@ -0,0 +1,60 @@ +"""Tests for mcpower.backends.native — import fallback and _prep utility.""" + +import numpy as np +import pytest +from unittest.mock import patch, MagicMock + +from mcpower.backends.native import _prep + + +class TestPrep: + """Test _prep array coercion for C++ interop.""" + + def test_contiguous_passthrough(self): + arr = np.array([1.0, 2.0, 3.0], dtype=np.float64) + result = _prep(arr) + assert result.flags["C_CONTIGUOUS"] + assert result.dtype == np.float64 + + def test_non_contiguous_becomes_contiguous(self): + arr = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float64) + col = arr[:, 1] # non-contiguous column slice + assert not col.flags["C_CONTIGUOUS"] + result = _prep(col) + assert result.flags["C_CONTIGUOUS"] + np.testing.assert_array_equal(result, [2.0, 4.0]) + + def test_dtype_conversion_float32_to_float64(self): + arr = np.array([1.0, 2.0], dtype=np.float32) + result = _prep(arr, np.float64) + assert result.dtype == np.float64 + + def test_dtype_conversion_int64_to_int32(self): + arr = np.array([0, 1, 2], dtype=np.int64) + result = _prep(arr, np.int32) + assert result.dtype == np.int32 + np.testing.assert_array_equal(result, [0, 1, 2]) + + def test_2d_array(self): + arr = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float64, order="F") + assert not arr.flags["C_CONTIGUOUS"] + result = _prep(arr) + assert result.flags["C_CONTIGUOUS"] + assert result.dtype == np.float64 + + +class TestNativeBackendImport: + """Test NativeBackend init when C++ extension is unavailable.""" + + def test_init_raises_when_unavailable(self): + """NativeBackend() should raise ImportError when _NATIVE_AVAILABLE=False.""" + with patch("mcpower.backends.native._NATIVE_AVAILABLE", False): + from mcpower.backends.native import NativeBackend + with pytest.raises(ImportError, match="Native C\\+\\+ backend not available"): + NativeBackend() + + def test_is_native_available_reflects_module_state(self): + from mcpower.backends.native import is_native_available + # Just verify it returns a bool + result = is_native_available() + assert isinstance(result, bool) diff --git a/tests/unit/test_ols_corrections.py b/tests/unit/test_ols_corrections.py new file mode 100644 index 0000000..8c6728a --- /dev/null +++ b/tests/unit/test_ols_corrections.py @@ -0,0 +1,251 @@ +"""Tests for OLS post-hoc contrast corrections and edge cases.""" + +from dataclasses import dataclass +from typing import Optional + +import numpy as np +import pytest + +from mcpower.stats.ols import compute_posthoc_contrasts + + +@dataclass +class _PostHocSpec: + """Minimal PostHocSpec stub for tests.""" + factor_name: str + col_idx_a: Optional[int] + col_idx_b: Optional[int] + label: str = "" + level_a: str = "" + level_b: str = "" + n_levels: int = 3 + + +def _make_ols_data(n=100, p=3, seed=42): + """Generate simple OLS data: X, y, and target_indices.""" + rng = np.random.RandomState(seed) + X = rng.randn(n, p) + beta = np.array([0.5, 0.3, -0.2])[:p] + y = X @ beta + rng.randn(n) + return X, y + + +class TestDegenerateDesign: + """When dof <= 0, posthoc should return zeros.""" + + def test_dof_zero_returns_zeros(self): + # n = p+1 → dof = 0 + n, p = 4, 3 + rng = np.random.RandomState(42) + X = rng.randn(n, p) + y = rng.randn(n) + specs = [_PostHocSpec("grp", 0, 1)] + + uncorr, corr, override = compute_posthoc_contrasts( + X, y, specs, "t-test", 2.0, {}, target_indices=np.array([0, 1, 2]), + ) + assert uncorr.shape == (1,) + assert not uncorr[0] + assert not corr[0] + assert override is None + + def test_singular_contrast_variance_stays_zero(self): + """When both col_idx_a and col_idx_b are None, t_abs stays 0.""" + X, y = _make_ols_data() + specs = [_PostHocSpec("grp", None, None)] + + uncorr, corr, _ = compute_posthoc_contrasts( + X, y, specs, "t-test", 2.0, {}, + ) + assert not uncorr[0] + assert not corr[0] + + +class TestCombinedFDR: + """FDR (correction_method=2) step-up across regular+posthoc t-stats.""" + + def test_fdr_combined_ranking(self): + X, y = _make_ols_data(n=200, p=3, seed=10) + specs = [ + _PostHocSpec("grp", 0, 1), + _PostHocSpec("grp", 0, 2), + ] + target_indices = np.array([0, 1, 2]) + # Create combined crits of length n_regular + n_posthoc = 5 + # Use very lenient crits so everything passes + combined_crits = np.full(5, 0.01) + + uncorr, corr, override = compute_posthoc_contrasts( + X, y, specs, "t-test", 0.01, {}, + target_indices=target_indices, + correction_method=2, + correction_t_crits_combined=combined_crits, + ) + assert override is not None + assert len(override) == 3 # n_regular + assert len(corr) == 2 # n_posthoc + + def test_fdr_no_significant(self): + """With very strict crits, nothing should be significant.""" + X, y = _make_ols_data(n=200, p=3, seed=10) + specs = [_PostHocSpec("grp", 0, 1)] + target_indices = np.array([0, 1, 2]) + # Very strict thresholds + combined_crits = np.full(4, 100.0) + + uncorr, corr, override = compute_posthoc_contrasts( + X, y, specs, "t-test", 100.0, {}, + target_indices=target_indices, + correction_method=2, + correction_t_crits_combined=combined_crits, + ) + assert not np.any(corr) + assert override is not None + assert not np.any(override) + + +class TestCombinedHolm: + """Holm (correction_method=3) step-down with early termination.""" + + def test_holm_combined_ranking(self): + X, y = _make_ols_data(n=200, p=3, seed=10) + specs = [_PostHocSpec("grp", 0, 1)] + target_indices = np.array([0, 1, 2]) + combined_crits = np.full(4, 0.01) # Very lenient + + uncorr, corr, override = compute_posthoc_contrasts( + X, y, specs, "t-test", 0.01, {}, + target_indices=target_indices, + correction_method=3, + correction_t_crits_combined=combined_crits, + ) + assert override is not None + assert len(override) == 3 + + def test_holm_early_termination(self): + """If the most significant test doesn't pass, none should.""" + X, y = _make_ols_data(n=200, p=3, seed=10) + specs = [_PostHocSpec("grp", 0, 1)] + target_indices = np.array([0, 1, 2]) + combined_crits = np.full(4, 1000.0) # Impossible threshold + + uncorr, corr, override = compute_posthoc_contrasts( + X, y, specs, "t-test", 1000.0, {}, + target_indices=target_indices, + correction_method=3, + correction_t_crits_combined=combined_crits, + ) + assert not np.any(corr) + + +class TestFallbackPaths: + """Fallback when correction_t_crits_combined is None or wrong length.""" + + def test_combined_crits_none_fallback(self): + X, y = _make_ols_data(n=200, p=3, seed=10) + specs = [_PostHocSpec("grp", 0, 1)] + target_indices = np.array([0, 1, 2]) + + uncorr, corr, override = compute_posthoc_contrasts( + X, y, specs, "t-test", 2.0, {}, + target_indices=target_indices, + correction_method=2, + correction_t_crits_combined=None, + ) + # Fallback: corrected = uncorrected copy, no override + np.testing.assert_array_equal(corr, uncorr) + assert override is None + + def test_combined_crits_wrong_length_fallback(self): + X, y = _make_ols_data(n=200, p=3, seed=10) + specs = [_PostHocSpec("grp", 0, 1)] + target_indices = np.array([0, 1, 2]) + # Wrong length: should be 4 (3 regular + 1 posthoc) + wrong_crits = np.full(2, 2.0) + + uncorr, corr, override = compute_posthoc_contrasts( + X, y, specs, "t-test", 2.0, {}, + target_indices=target_indices, + correction_method=2, + correction_t_crits_combined=wrong_crits, + ) + np.testing.assert_array_equal(corr, uncorr) + assert override is None + + +class TestTukeyMethod: + """Tukey post-hoc method path.""" + + def test_tukey_uses_factor_crit(self): + X, y = _make_ols_data(n=200, p=3, seed=10) + specs = [_PostHocSpec("grp", 0, 1, n_levels=3)] + tukey_crits = {"grp": 0.01} # Very lenient + + uncorr, corr, override = compute_posthoc_contrasts( + X, y, specs, "tukey", 2.0, tukey_crits, + ) + # Tukey correction: uncorrected == corrected + np.testing.assert_array_equal(uncorr, corr) + assert override is None + + def test_tukey_missing_factor_uses_inf(self): + """When factor not in tukey_crits, inf is used → not significant.""" + X, y = _make_ols_data(n=200, p=3, seed=10) + specs = [_PostHocSpec("missing_factor", 0, 1)] + + uncorr, corr, override = compute_posthoc_contrasts( + X, y, specs, "tukey", 2.0, {}, + ) + assert not uncorr[0] + assert not corr[0] + + +class TestBonferroniPosthoc: + """Bonferroni correction for posthoc (correction_method=1).""" + + def test_bonferroni_uses_combined_first_crit(self): + X, y = _make_ols_data(n=200, p=3, seed=10) + specs = [_PostHocSpec("grp", 0, 1)] + target_indices = np.array([0, 1, 2]) + combined_crits = np.full(4, 0.01) # Very lenient + + uncorr, corr, override = compute_posthoc_contrasts( + X, y, specs, "t-test", 0.01, {}, + target_indices=target_indices, + correction_method=1, + correction_t_crits_combined=combined_crits, + ) + assert override is None # Bonferroni doesn't produce override + + +class TestEmptySpecs: + """Empty posthoc specs return empty arrays.""" + + def test_no_specs(self): + X, y = _make_ols_data() + uncorr, corr, override = compute_posthoc_contrasts( + X, y, [], "t-test", 2.0, {}, + ) + assert len(uncorr) == 0 + assert len(corr) == 0 + assert override is None + + +class TestSingleColumnContrasts: + """Contrasts where one side is the reference level (None).""" + + def test_col_idx_a_none(self): + X, y = _make_ols_data(n=200) + specs = [_PostHocSpec("grp", None, 1)] + uncorr, corr, _ = compute_posthoc_contrasts( + X, y, specs, "t-test", 2.0, {}, + ) + assert uncorr.shape == (1,) + + def test_col_idx_b_none(self): + X, y = _make_ols_data(n=200) + specs = [_PostHocSpec("grp", 0, None)] + uncorr, corr, _ = compute_posthoc_contrasts( + X, y, specs, "t-test", 2.0, {}, + ) + assert uncorr.shape == (1,) diff --git a/tests/unit/test_parsers_errors.py b/tests/unit/test_parsers_errors.py new file mode 100644 index 0000000..b3c8c01 --- /dev/null +++ b/tests/unit/test_parsers_errors.py @@ -0,0 +1,168 @@ +"""Tests for parser error paths and edge cases.""" + +import pytest + +from mcpower.utils.parsers import _AssignmentParser, _parse_equation + + +_parser = _AssignmentParser() + + +class TestAssignmentParserErrors: + """Error paths in _AssignmentParser._parse.""" + + def test_missing_equals_sign(self): + parsed, errors = _parser._parse("x1 0.5", "effect", ["x1"]) + assert len(errors) == 1 + assert "Invalid format" in errors[0] + + def test_unknown_parse_type(self): + parsed, errors = _parser._parse("x1=0.5", "unknown_type", ["x1"]) + assert len(errors) == 1 + assert "Unknown parse type" in errors[0] + + def test_unavailable_variable(self): + parsed, errors = _parser._parse("x_missing=0.5", "effect", ["x1", "x2"]) + assert len(errors) == 1 + assert "not found" in errors[0] + assert "x_missing" in errors[0] + + def test_invalid_effect_value(self): + parsed, errors = _parser._parse("x1=abc", "effect", ["x1"]) + assert len(errors) == 1 + assert "Invalid effect size" in errors[0] + + def test_multiple_errors(self): + parsed, errors = _parser._parse("x_bad=abc, x_also_bad=xyz", "effect", ["x1"]) + assert len(errors) == 2 + + +class TestCorrelationParserErrors: + """Error paths for correlation parsing.""" + + def test_invalid_correlation_format(self): + parsed, errors = _parser._parse("x1_x2=0.5", "correlation", ["x1", "x2"]) + assert len(errors) == 1 + assert "Invalid format" in errors[0] or "Invalid correlation" in errors[0] + + def test_correlation_var_not_found(self): + parsed, errors = _parser._parse("corr(x1, x_missing)=0.5", "correlation", ["x1", "x2"]) + assert len(errors) == 1 + assert "not found" in errors[0] + + def test_self_correlation(self): + parsed, errors = _parser._parse("corr(x1, x1)=0.5", "correlation", ["x1", "x2"]) + assert len(errors) == 1 + assert "Cannot correlate variable with itself" in errors[0] + + def test_correlation_value_out_of_range(self): + parsed, errors = _parser._parse("corr(x1, x2)=1.5", "correlation", ["x1", "x2"]) + assert len(errors) == 1 + assert "between -1 and 1" in errors[0] + + def test_invalid_correlation_value(self): + parsed, errors = _parser._parse("corr(x1, x2)=abc", "correlation", ["x1", "x2"]) + assert len(errors) == 1 + assert "Invalid correlation value" in errors[0] + + +class TestVariableTypeErrors: + """Error paths for variable type parsing.""" + + def test_unsupported_type(self): + parsed, errors = _parser._parse("x1=crazy_type", "variable_type", ["x1"]) + assert len(errors) == 1 + assert "Unsupported type" in errors[0] + + def test_binary_proportion_out_of_range(self): + parsed, errors = _parser._parse("x1=(binary,1.5)", "variable_type", ["x1"]) + assert len(errors) == 1 + assert "between 0 and 1" in errors[0] + + def test_binary_non_numeric_proportion(self): + parsed, errors = _parser._parse("x1=(binary,abc)", "variable_type", ["x1"]) + assert len(errors) == 1 + assert "Invalid proportion" in errors[0] + + def test_binary_wrong_param_count(self): + parsed, errors = _parser._parse("x1=(binary,0.3,0.4)", "variable_type", ["x1"]) + assert len(errors) == 1 + assert "exactly 2 values" in errors[0] + + def test_factor_less_than_2_levels(self): + parsed, errors = _parser._parse("x1=(factor,1)", "variable_type", ["x1"]) + assert len(errors) == 1 + assert "at least 2 levels" in errors[0] + + def test_factor_more_than_20_levels(self): + parsed, errors = _parser._parse("x1=(factor,21)", "variable_type", ["x1"]) + assert len(errors) == 1 + assert "more than 20 levels" in errors[0] + + def test_factor_non_integer_levels(self): + parsed, errors = _parser._parse("x1=(factor,abc)", "variable_type", ["x1"]) + assert len(errors) == 1 + assert "Must be integer" in errors[0] + + def test_factor_proportions_more_than_20(self): + props = ",".join(["0.04"] * 21) + parsed, errors = _parser._parse(f"x1=(factor,{props})", "variable_type", ["x1"]) + assert len(errors) == 1 + assert "more than 20 levels" in errors[0] + + def test_factor_zero_proportion(self): + parsed, errors = _parser._parse("x1=(factor,0.5,0.0,0.5)", "variable_type", ["x1"]) + assert len(errors) == 1 + assert "positive" in errors[0] + + def test_factor_non_numeric_proportions(self): + parsed, errors = _parser._parse("x1=(factor,abc,def)", "variable_type", ["x1"]) + assert len(errors) == 1 + assert "numeric" in errors[0] + + def test_tuple_no_comma(self): + parsed, errors = _parser._parse("x1=(binary)", "variable_type", ["x1"]) + assert len(errors) == 1 + assert "Invalid tuple format" in errors[0] + + def test_tuple_unsupported_type_in_tuple(self): + parsed, errors = _parser._parse("x1=(normal,0.5)", "variable_type", ["x1"]) + assert len(errors) == 1 + assert "only supported for binary and factor" in errors[0] + + +class TestEquationParsing: + """Edge cases in _parse_equation.""" + + def test_nested_random_effects(self): + dep, formula, ranefs = _parse_equation("y ~ x1 + (1|A/B)") + assert dep == "y" + assert len(ranefs) == 2 + group_vars = {r["grouping_var"] for r in ranefs} + assert "A" in group_vars + assert "A:B" in group_vars + + def test_duplicate_grouping_var_raises(self): + with pytest.raises(ValueError, match="Duplicate random effect grouping variable"): + _parse_equation("y ~ x1 + (1|school) + (1|school)") + + def test_random_slopes(self): + dep, formula, ranefs = _parse_equation("y ~ x1 + (1 + x1|school)") + assert len(ranefs) == 1 + assert ranefs[0]["type"] == "random_slope" + assert ranefs[0]["slope_vars"] == ["x1"] + assert ranefs[0]["grouping_var"] == "school" + + def test_random_slope_duplicate_grouping_raises(self): + with pytest.raises(ValueError, match="Duplicate"): + _parse_equation("y ~ x1 + (1|school) + (1 + x1|school)") + + def test_no_separator_uses_default_dep(self): + dep, formula, ranefs = _parse_equation("x1+x2") + assert dep == "explained_variable" + assert "x1" in formula + assert "x2" in formula + + def test_nested_duplicate_parent_raises(self): + with pytest.raises(ValueError, match="Duplicate"): + _parse_equation("y ~ (1|A) + (1|A/B)") diff --git a/tests/unit/test_progress.py b/tests/unit/test_progress.py index 329acca..768420a 100644 --- a/tests/unit/test_progress.py +++ b/tests/unit/test_progress.py @@ -127,12 +127,6 @@ def test_completion_newline(self): class TestTqdmReporter: """Test TqdmReporter with mock tqdm.""" - def test_tqdm_missing_raises(self): - reporter = TqdmReporter() - with patch.dict("sys.modules", {"tqdm": None}): - with pytest.raises(ImportError, match="tqdm"): - reporter(0, 100) - def test_tqdm_basic_flow(self): mock_bar = MagicMock() mock_bar.n = 0 @@ -154,6 +148,51 @@ def test_tqdm_basic_flow(self): reporter(100, 100) # closes mock_bar.close.assert_called_once() + def test_tqdm_successive_sessions(self): + """After close, a new session creates a fresh bar.""" + mock_bar = MagicMock() + mock_bar.n = 0 + mock_tqdm_cls = MagicMock(return_value=mock_bar) + mock_tqdm_module = MagicMock() + mock_tqdm_module.tqdm = mock_tqdm_cls + + reporter = TqdmReporter() + + with patch.dict("sys.modules", {"tqdm": mock_tqdm_module}): + # First session + reporter(0, 50) + mock_bar.n = 0 + reporter(50, 50) + mock_bar.close.assert_called_once() + assert reporter._bar is None + + # Second session — should create a new bar + mock_tqdm_cls.reset_mock() + mock_bar2 = MagicMock() + mock_bar2.n = 0 + mock_tqdm_cls.return_value = mock_bar2 + + reporter(0, 200) + assert mock_tqdm_cls.call_count == 1 + mock_tqdm_cls.assert_called_with(total=200, unit="sim") + + def test_tqdm_no_negative_delta(self): + """When current <= bar.n, update should not be called with negative delta.""" + mock_bar = MagicMock() + mock_bar.n = 50 + mock_tqdm_cls = MagicMock(return_value=mock_bar) + mock_tqdm_module = MagicMock() + mock_tqdm_module.tqdm = mock_tqdm_cls + + reporter = TqdmReporter() + + with patch.dict("sys.modules", {"tqdm": mock_tqdm_module}): + reporter(0, 100) # creates bar + mock_bar.n = 50 + reporter(30, 100) # current < bar.n + # update should NOT have been called (delta = 30 - 50 = -20, not > 0) + mock_bar.update.assert_not_called() + class TestComputeTotalSimulations: """Test compute_total_simulations helper.""" diff --git a/tests/unit/test_results.py b/tests/unit/test_results.py new file mode 100644 index 0000000..31c3082 --- /dev/null +++ b/tests/unit/test_results.py @@ -0,0 +1,138 @@ +"""Unit tests for mcpower.core.results — ResultsProcessor and builder functions.""" + +import numpy as np +import pytest + +from mcpower.core.results import ResultsProcessor, build_power_result, build_sample_size_result + + +class TestCalculatePowers: + """Tests for ResultsProcessor.calculate_powers.""" + + def test_basic_two_tests(self): + """Power calculation with two tests (overall + one predictor).""" + proc = ResultsProcessor(target_power=80.0) + # 10 simulations, 2 columns: [overall, x1] + # overall: 8/10 sig, x1: 6/10 sig + results = [np.array([True, True])] * 6 + [ + np.array([True, False]), + np.array([True, False]), + np.array([False, False]), + np.array([False, False]), + ] + corrected = results # same for this test + + out = proc.calculate_powers(results, corrected, ["overall", "x1"]) + + assert out["individual_powers"]["overall"] == pytest.approx(80.0) + assert out["individual_powers"]["x1"] == pytest.approx(60.0) + assert out["n_simulations_used"] == 10 + + def test_all_significant(self): + proc = ResultsProcessor() + results = [np.array([True, True])] * 5 + out = proc.calculate_powers(results, results, ["overall", "x1"]) + assert out["individual_powers"]["overall"] == pytest.approx(100.0) + assert out["individual_powers"]["x1"] == pytest.approx(100.0) + + def test_none_significant(self): + proc = ResultsProcessor() + results = [np.array([False, False])] * 5 + out = proc.calculate_powers(results, results, ["overall", "x1"]) + assert out["individual_powers"]["overall"] == pytest.approx(0.0) + assert out["individual_powers"]["x1"] == pytest.approx(0.0) + + def test_combined_probabilities(self): + proc = ResultsProcessor() + # 4 sims, 2 tests: exactly 0, 1, 2 significant + results = [ + np.array([False, False]), # 0 sig + np.array([True, False]), # 1 sig + np.array([False, True]), # 1 sig + np.array([True, True]), # 2 sig + ] + out = proc.calculate_powers(results, results, ["overall", "x1"]) + combined = out["combined_probabilities"] + assert combined["exactly_0_significant"] == pytest.approx(25.0) + assert combined["exactly_1_significant"] == pytest.approx(50.0) + assert combined["exactly_2_significant"] == pytest.approx(25.0) + + def test_cumulative_probabilities(self): + proc = ResultsProcessor() + results = [ + np.array([False, False]), + np.array([True, True]), + np.array([True, True]), + np.array([True, True]), + ] + out = proc.calculate_powers(results, results, ["overall", "x1"]) + cumulative = out["cumulative_probabilities"] + assert cumulative["at_least_0_significant"] == pytest.approx(100.0) + assert cumulative["at_least_2_significant"] == pytest.approx(75.0) + + +class TestBuildPowerResult: + """Tests for build_power_result.""" + + def test_basic_structure(self): + power_results = { + "individual_powers": {"overall": 80.0}, + "n_simulations_used": 1000, + } + result = build_power_result( + model_type="OLS", + target_tests=["overall"], + formula_to_test=None, + equation="y = x1", + sample_size=100, + alpha=0.05, + n_simulations=1000, + correction=None, + target_power=80.0, + parallel=False, + power_results=power_results, + ) + assert result["model"]["model_type"] == "OLS" + assert result["model"]["sample_size"] == 100 + assert result["model"]["alpha"] == 0.05 + assert result["results"] is power_results + + +class TestBuildSampleSizeResult: + """Tests for build_sample_size_result.""" + + def test_basic_structure(self): + analysis_results = {"sample_sizes_tested": [50, 100]} + result = build_sample_size_result( + model_type="OLS", + target_tests=["overall"], + formula_to_test=None, + equation="y = x1", + sample_sizes=[50, 100], + alpha=0.05, + n_simulations=1000, + correction=None, + target_power=80.0, + parallel=False, + analysis_results=analysis_results, + ) + assert result["model"]["sample_size_range"]["from_size"] == 50 + assert result["model"]["sample_size_range"]["to_size"] == 100 + assert result["model"]["sample_size_range"]["by"] == 50 + assert result["results"] is analysis_results + + def test_single_sample_size(self): + result = build_sample_size_result( + model_type="OLS", + target_tests=["overall"], + formula_to_test=None, + equation="y = x1", + sample_sizes=[100], + alpha=0.05, + n_simulations=1000, + correction=None, + target_power=80.0, + parallel=False, + analysis_results={}, + ) + assert result["model"]["sample_size_range"]["by"] == 1 diff --git a/tests/unit/test_scenarios_coverage.py b/tests/unit/test_scenarios_coverage.py new file mode 100644 index 0000000..ea0d4f2 --- /dev/null +++ b/tests/unit/test_scenarios_coverage.py @@ -0,0 +1,218 @@ +"""Tests for scenario analysis — plot creation, correlation matrix repair, LME perturbations.""" + +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from mcpower.core.scenarios import ( + ScenarioRunner, + apply_lme_perturbations, + apply_per_simulation_perturbations, +) + + +class TestCorrelationMatrixRepair: + """Spectral clipping when noise creates negative eigenvalues.""" + + def test_negative_eigenvalue_repaired(self): + """After heavy noise, result should be positive semi-definite with unit diagonal.""" + # Create a 3x3 identity correlation matrix + corr = np.eye(3) + var_types = np.zeros(3, dtype=np.int64) # all normal + + config = { + "correlation_noise_sd": 2.0, # Very heavy noise → guaranteed negative eigenvalues + "distribution_change_prob": 0.0, + "new_distributions": [], + } + + perturbed_corr, _ = apply_per_simulation_perturbations(corr, var_types, config, sim_seed=42) + + # Eigenvalues should all be >= 0 + eigvals = np.linalg.eigvalsh(perturbed_corr) + assert np.all(eigvals >= -1e-10) + + # Diagonal should be 1.0 + np.testing.assert_allclose(np.diag(perturbed_corr), 1.0, atol=1e-10) + + # Should be symmetric + np.testing.assert_allclose(perturbed_corr, perturbed_corr.T, atol=1e-10) + + def test_no_repair_needed_when_no_noise(self): + corr = np.array([[1.0, 0.3], [0.3, 1.0]]) + var_types = np.zeros(2, dtype=np.int64) + + config = { + "correlation_noise_sd": 0.0, + "distribution_change_prob": 0.0, + "new_distributions": [], + } + + perturbed_corr, _ = apply_per_simulation_perturbations(corr, var_types, config, sim_seed=42) + np.testing.assert_array_equal(perturbed_corr, corr) + + +class TestDistributionPerturbation: + """Variable type swaps in scenario mode.""" + + def test_distribution_swap_occurs(self): + var_types = np.zeros(10, dtype=np.int64) # All normal + config = { + "correlation_noise_sd": 0.0, + "distribution_change_prob": 1.0, # Always swap + "new_distributions": ["right_skewed"], + } + + _, perturbed_types = apply_per_simulation_perturbations( + np.eye(10), var_types, config, sim_seed=42, + ) + # All should be swapped from 0 to 2 (right_skewed) + assert np.all(perturbed_types == 2) + + def test_non_normal_not_swapped(self): + """Binary (1) and uploaded (99) vars should not be swapped.""" + var_types = np.array([0, 1, 99], dtype=np.int64) + config = { + "correlation_noise_sd": 0.0, + "distribution_change_prob": 1.0, + "new_distributions": ["right_skewed"], + } + + _, perturbed_types = apply_per_simulation_perturbations( + np.eye(3), var_types, config, sim_seed=42, + ) + assert perturbed_types[0] == 2 # normal → right_skewed + assert perturbed_types[1] == 1 # binary unchanged + assert perturbed_types[2] == 99 # uploaded unchanged + + def test_none_config_passthrough(self): + corr = np.eye(2) + var_types = np.zeros(2, dtype=np.int64) + result_corr, result_types = apply_per_simulation_perturbations( + corr, var_types, None, sim_seed=42, + ) + np.testing.assert_array_equal(result_corr, corr) + np.testing.assert_array_equal(result_types, var_types) + + +class TestLMEPerturbations: + """LME perturbation computation.""" + + def test_icc_noise_creates_multipliers(self): + cluster_specs = {"school": {"n_clusters": 20, "cluster_size": 10, "icc": 0.2}} + config = { + "icc_noise_sd": 0.3, + "random_effect_dist": "normal", + "random_effect_df": 5, + } + + result = apply_lme_perturbations(cluster_specs, config, sim_seed=42) + assert result is not None + assert "tau_squared_multipliers" in result + assert "school" in result["tau_squared_multipliers"] + # Multiplier should be exp(N(0, 0.3)) — positive, around 1 + mult = result["tau_squared_multipliers"]["school"] + assert mult > 0 + + def test_no_perturbation_returns_none(self): + cluster_specs = {"school": {"n_clusters": 20, "cluster_size": 10, "icc": 0.2}} + config = { + "icc_noise_sd": 0.0, + "random_effect_dist": "normal", + "random_effect_df": 5, + } + result = apply_lme_perturbations(cluster_specs, config, sim_seed=42) + assert result is None + + def test_empty_cluster_specs_returns_none(self): + result = apply_lme_perturbations({}, {"icc_noise_sd": 0.5}, sim_seed=42) + assert result is None + + def test_heavy_tailed_re_dist(self): + cluster_specs = {"school": {"n_clusters": 20, "cluster_size": 10, "icc": 0.2}} + config = { + "icc_noise_sd": 0.0, + "random_effect_dist": "heavy_tailed", + "random_effect_df": 3, + } + result = apply_lme_perturbations(cluster_specs, config, sim_seed=42) + assert result is not None + assert result["random_effect_dist"] == "heavy_tailed" + assert result["random_effect_df"] == 3 + + +class TestScenarioRunnerPlots: + """Test _create_scenario_plots path.""" + + def test_plot_creation_with_mock(self): + model = MagicMock() + model.power = 80.0 + runner = ScenarioRunner(model) + + results = { + "analysis_type": "sample_size", + "scenarios": { + "optimistic": { + "model": { + "target_tests": ["x1"], + "correction": None, + }, + "results": { + "sample_sizes_tested": [50, 100], + "powers_by_test": {"x1": [50.0, 85.0]}, + "first_achieved": {"x1": 100}, + }, + }, + }, + } + + with patch("mcpower.core.scenarios._create_power_plot") as mock_plot: + runner._create_scenario_plots(results) + mock_plot.assert_called_once() + + def test_plot_with_correction(self): + model = MagicMock() + model.power = 80.0 + runner = ScenarioRunner(model) + + results = { + "analysis_type": "sample_size", + "scenarios": { + "optimistic": { + "model": { + "target_tests": ["x1"], + "correction": "bonferroni", + }, + "results": { + "sample_sizes_tested": [50, 100], + "powers_by_test": {"x1": [50.0, 85.0]}, + "powers_by_test_corrected": {"x1": [40.0, 75.0]}, + "first_achieved": {"x1": 100}, + "first_achieved_corrected": {"x1": 150}, + }, + }, + }, + } + + with patch("mcpower.core.scenarios._create_power_plot") as mock_plot: + runner._create_scenario_plots(results) + # Should be called for both uncorrected and corrected + assert mock_plot.call_count == 2 + + def test_no_plot_when_missing_sample_sizes(self): + model = MagicMock() + model.power = 80.0 + runner = ScenarioRunner(model) + + results = { + "scenarios": { + "optimistic": { + "results": {"powers_by_test": {"x1": [50.0]}}, + }, + }, + } + + with patch("mcpower.core.scenarios._create_power_plot") as mock_plot: + runner._create_scenario_plots(results) + mock_plot.assert_not_called() diff --git a/tests/unit/test_simulation_coverage.py b/tests/unit/test_simulation_coverage.py new file mode 100644 index 0000000..8bae7ff --- /dev/null +++ b/tests/unit/test_simulation_coverage.py @@ -0,0 +1,274 @@ +"""Tests for simulation.py — failure handling, Wald fallback, verbose diagnostics, ICC mismatch.""" + +import warnings +from typing import Dict, List, Optional +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from mcpower.core.simulation import SimulationMetadata, SimulationRunner, _warn_icc_mismatch + + +def _make_metadata( + n_targets=2, + cluster_specs=None, + verbose=False, + correction_method=0, +): + """Create a minimal SimulationMetadata for testing.""" + return SimulationMetadata( + target_indices=np.arange(n_targets), + n_non_factor_vars=n_targets, + correlation_matrix=np.eye(n_targets), + var_types=np.zeros(n_targets, dtype=np.int64), + var_params=np.zeros(n_targets, dtype=np.float64), + factor_specs=[], + upload_normal_values=np.zeros((2, 2), dtype=np.float64), + upload_data_values=np.zeros((2, 2), dtype=np.float64), + effect_sizes=np.array([0.5] * n_targets), + correction_method=correction_method, + cluster_specs=cluster_specs or {}, + verbose=verbose, + ) + + +def _noop_perturbations(corr, types, config, seed): + return corr, types + + +class TestAllSimulationsFail: + """When all simulations return None, RuntimeError should be raised.""" + + def test_all_fail_raises(self): + runner = SimulationRunner(n_simulations=5, seed=42) + metadata = _make_metadata() + + def failing_sim(*args, **kwargs): + return None + + with patch.object(runner, "_single_simulation", return_value=None): + with pytest.raises(RuntimeError, match="All simulations failed"): + runner.run_power_simulations( + sample_size=100, + metadata=metadata, + generate_y_func=MagicMock(), + analyze_func=MagicMock(), + create_X_extended_func=MagicMock(), + apply_perturbations_func=_noop_perturbations, + ) + + +class TestLMEThresholdExceeded: + """LME failure rate exceeding threshold raises RuntimeError.""" + + def test_high_failure_rate_raises(self): + runner = SimulationRunner(n_simulations=10, seed=42, max_failed_simulations=0.05) + metadata = _make_metadata(cluster_specs={"school": {"n_clusters": 5, "cluster_size": 10}}) + + call_count = [0] + + def sometimes_fail(*args, **kwargs): + call_count[0] += 1 + if call_count[0] <= 5: + return None # 5 out of 10 fail = 50% + return (np.array([1, 1, 1]), np.array([1, 1, 1]), False) + + with patch.object(runner, "_single_simulation", side_effect=sometimes_fail): + with pytest.raises(RuntimeError, match="Too many failed simulations"): + runner.run_power_simulations( + sample_size=100, + metadata=metadata, + generate_y_func=MagicMock(), + analyze_func=MagicMock(), + create_X_extended_func=MagicMock(), + apply_perturbations_func=_noop_perturbations, + ) + + +class TestOLSHighFailureWarns: + """OLS high failure rate warns but doesn't raise.""" + + def test_ols_warns_above_10_percent(self): + runner = SimulationRunner(n_simulations=10, seed=42) + metadata = _make_metadata() # No cluster_specs = OLS + + call_count = [0] + + def sometimes_fail(*args, **kwargs): + call_count[0] += 1 + if call_count[0] <= 2: + return None # 2 out of 10 fail = 20% + return (np.array([1, 1, 1]), np.array([1, 1, 1])) + + with patch.object(runner, "_single_simulation", side_effect=sometimes_fail): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = runner.run_power_simulations( + sample_size=100, + metadata=metadata, + generate_y_func=MagicMock(), + analyze_func=MagicMock(), + create_X_extended_func=MagicMock(), + apply_perturbations_func=_noop_perturbations, + ) + assert any("failed" in str(warning.message).lower() for warning in w) + + +class TestWaldFallbackWarning: + """Warn if >10% iterations use Wald test.""" + + def test_wald_warning_above_threshold(self): + runner = SimulationRunner(n_simulations=10, seed=42) + metadata = _make_metadata() + + call_count = [0] + + def wald_heavy(*args, **kwargs): + call_count[0] += 1 + # All return wald_flag=True + return (np.array([1, 1, 1]), np.array([1, 1, 1]), True) + + with patch.object(runner, "_single_simulation", side_effect=wald_heavy): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = runner.run_power_simulations( + sample_size=100, + metadata=metadata, + generate_y_func=MagicMock(), + analyze_func=MagicMock(), + create_X_extended_func=MagicMock(), + apply_perturbations_func=_noop_perturbations, + ) + assert any("Wald test fallback" in str(warning.message) for warning in w) + assert result["n_wald_fallbacks"] == 10 + + +class TestVerboseDiagnostics: + """Verbose mode collects diagnostics and failure reasons.""" + + def test_verbose_success_collects_diagnostics(self): + runner = SimulationRunner(n_simulations=3, seed=42) + metadata = _make_metadata(verbose=True) + + def verbose_result(*args, **kwargs): + return { + "results": (np.array([1, 1, 1]), np.array([1, 1, 1])), + "diagnostics": {"icc_estimated": 0.2}, + "wald_fallback": False, + } + + with patch.object(runner, "_single_simulation", side_effect=verbose_result): + result = runner.run_power_simulations( + sample_size=100, + metadata=metadata, + generate_y_func=MagicMock(), + analyze_func=MagicMock(), + create_X_extended_func=MagicMock(), + apply_perturbations_func=_noop_perturbations, + ) + assert "diagnostics" in result + assert len(result["diagnostics"]) == 3 + + def test_verbose_failure_tracking(self): + runner = SimulationRunner(n_simulations=5, seed=42) + metadata = _make_metadata(verbose=True) + + call_count = [0] + + def mixed_results(*args, **kwargs): + call_count[0] += 1 + if call_count[0] <= 2: + return {"failed": True, "failure_reason": "Convergence failed"} + return { + "results": (np.array([1, 1, 1]), np.array([1, 1, 1])), + "diagnostics": {}, + "wald_fallback": False, + } + + with patch.object(runner, "_single_simulation", side_effect=mixed_results): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + result = runner.run_power_simulations( + sample_size=100, + metadata=metadata, + generate_y_func=MagicMock(), + analyze_func=MagicMock(), + create_X_extended_func=MagicMock(), + apply_perturbations_func=_noop_perturbations, + ) + assert "failure_reasons" in result + assert result["failure_reasons"]["Convergence failed"] == 2 + + def test_verbose_none_tracking(self): + """None results in verbose mode are tracked as unknown failures.""" + runner = SimulationRunner(n_simulations=3, seed=42) + metadata = _make_metadata(verbose=True) + + call_count = [0] + + def mixed(*args, **kwargs): + call_count[0] += 1 + if call_count[0] == 1: + return None + return { + "results": (np.array([1, 1, 1]), np.array([1, 1, 1])), + "diagnostics": {}, + "wald_fallback": False, + } + + with patch.object(runner, "_single_simulation", side_effect=mixed): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + result = runner.run_power_simulations( + sample_size=100, + metadata=metadata, + generate_y_func=MagicMock(), + analyze_func=MagicMock(), + create_X_extended_func=MagicMock(), + apply_perturbations_func=_noop_perturbations, + ) + assert "Unknown (returned None)" in result["failure_reasons"] + + +class TestICCMismatchWarning: + """ICC mismatch warning when estimated ICC differs by >50%.""" + + def test_large_mismatch_warns(self): + metadata = _make_metadata( + cluster_specs={"school": {"icc": 0.2, "n_clusters": 20, "cluster_size": 10}}, + ) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + _warn_icc_mismatch(metadata, mean_estimated_icc=0.05) # 75% deviation + assert any("differs from specified" in str(warning.message) for warning in w) + + def test_within_tolerance_no_warning(self): + metadata = _make_metadata( + cluster_specs={"school": {"icc": 0.2, "n_clusters": 20, "cluster_size": 10}}, + ) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + _warn_icc_mismatch(metadata, mean_estimated_icc=0.18) # 10% deviation + icc_warnings = [x for x in w if "differs from specified" in str(x.message)] + assert len(icc_warnings) == 0 + + def test_zero_estimated_icc_no_warning(self): + metadata = _make_metadata( + cluster_specs={"school": {"icc": 0.2, "n_clusters": 20, "cluster_size": 10}}, + ) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + _warn_icc_mismatch(metadata, mean_estimated_icc=0.0) + icc_warnings = [x for x in w if "differs from specified" in str(x.message)] + assert len(icc_warnings) == 0 + + def test_no_icc_in_spec_no_warning(self): + metadata = _make_metadata( + cluster_specs={"school": {"icc": None, "n_clusters": 20, "cluster_size": 10}}, + ) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + _warn_icc_mismatch(metadata, mean_estimated_icc=0.5) + icc_warnings = [x for x in w if "differs from specified" in str(x.message)] + assert len(icc_warnings) == 0 diff --git a/tests/unit/test_test_formula_utils.py b/tests/unit/test_test_formula_utils.py new file mode 100644 index 0000000..f3db882 --- /dev/null +++ b/tests/unit/test_test_formula_utils.py @@ -0,0 +1,319 @@ +"""Tests for test_formula parsing utilities.""" + +from collections import OrderedDict +from unittest.mock import MagicMock + +import numpy as np + + +class TestExtractTestFormulaEffects: + """Test _extract_test_formula_effects helper.""" + + def _make_registry( + self, + effect_names, + factor_names=None, + factor_dummies=None, + cluster_effect_names=None, + ): + """Create a minimal mock registry for testing.""" + reg = MagicMock() + reg.effect_names = effect_names + reg.factor_names = factor_names or [] + reg.cluster_effect_names = cluster_effect_names or [] + + # Build _effects dict with correct ordering + effects = OrderedDict() + for name in effect_names: + eff = MagicMock() + eff.effect_type = "interaction" if ":" in name else "main" + effects[name] = eff + reg._effects = effects + + # Factor dummies + reg._factor_dummies = factor_dummies or {} + return reg + + def test_simple_subset(self): + """y ~ x1 + x2 from generation y ~ x1 + x2 + x3.""" + from mcpower.utils.test_formula_utils import _extract_test_formula_effects + + registry = self._make_registry(["x1", "x2", "x3"]) + effects, random_effects = _extract_test_formula_effects("y ~ x1 + x2", registry) + assert effects == ["x1", "x2"] + assert random_effects == [] + + def test_single_variable(self): + """y ~ x1 from generation y ~ x1 + x2 + x3.""" + from mcpower.utils.test_formula_utils import _extract_test_formula_effects + + registry = self._make_registry(["x1", "x2", "x3"]) + effects, random_effects = _extract_test_formula_effects("y ~ x1", registry) + assert effects == ["x1"] + + def test_with_interaction(self): + """y ~ x1 + x2 + x1:x2 from generation y ~ x1 + x2 + x3 + x1:x2.""" + from mcpower.utils.test_formula_utils import _extract_test_formula_effects + + registry = self._make_registry(["x1", "x2", "x3", "x1:x2"]) + effects, _ = _extract_test_formula_effects("y ~ x1 + x2 + x1:x2", registry) + assert effects == ["x1", "x2", "x1:x2"] + + def test_interaction_omitted(self): + """y ~ x1 + x2 from generation y ~ x1 + x2 + x1:x2.""" + from mcpower.utils.test_formula_utils import _extract_test_formula_effects + + registry = self._make_registry(["x1", "x2", "x1:x2"]) + effects, _ = _extract_test_formula_effects("y ~ x1 + x2", registry) + assert effects == ["x1", "x2"] + + def test_factor_expands_to_dummies(self): + """y ~ x1 + gender from generation y ~ x1 + x2 + gender.""" + from mcpower.utils.test_formula_utils import _extract_test_formula_effects + + registry = self._make_registry( + ["x1", "x2", "gender[F]", "gender[Other]"], + factor_names=["gender"], + factor_dummies={ + "gender[F]": {"factor_name": "gender", "level": "F"}, + "gender[Other]": {"factor_name": "gender", "level": "Other"}, + }, + ) + effects, _ = _extract_test_formula_effects("y ~ x1 + gender", registry) + assert effects == ["x1", "gender[F]", "gender[Other]"] + + def test_factor_omitted(self): + """y ~ x1 from generation y ~ x1 + gender.""" + from mcpower.utils.test_formula_utils import _extract_test_formula_effects + + registry = self._make_registry( + ["x1", "gender[F]", "gender[Other]"], + factor_names=["gender"], + factor_dummies={ + "gender[F]": {"factor_name": "gender", "level": "F"}, + "gender[Other]": {"factor_name": "gender", "level": "Other"}, + }, + ) + effects, _ = _extract_test_formula_effects("y ~ x1", registry) + assert effects == ["x1"] + + def test_with_random_effects(self): + """y ~ x1 + (1|school) extracts random effects.""" + from mcpower.utils.test_formula_utils import _extract_test_formula_effects + + registry = self._make_registry(["x1", "x2"]) + effects, random_effects = _extract_test_formula_effects( + "y ~ x1 + (1|school)", registry + ) + assert effects == ["x1"] + assert len(random_effects) == 1 + assert random_effects[0]["grouping_var"] == "school" + + def test_star_operator_expands(self): + """y ~ x1*x2 expands to x1 + x2 + x1:x2.""" + from mcpower.utils.test_formula_utils import _extract_test_formula_effects + + registry = self._make_registry(["x1", "x2", "x3", "x1:x2"]) + effects, _ = _extract_test_formula_effects("y ~ x1*x2", registry) + assert effects == ["x1", "x2", "x1:x2"] + + def test_equals_sign_formula(self): + """y = x1 + x2 works same as y ~ x1 + x2.""" + from mcpower.utils.test_formula_utils import _extract_test_formula_effects + + registry = self._make_registry(["x1", "x2", "x3"]) + effects, _ = _extract_test_formula_effects("y = x1 + x2", registry) + assert effects == ["x1", "x2"] + + def test_preserves_registry_order(self): + """Effects returned in registry order, not formula order.""" + from mcpower.utils.test_formula_utils import _extract_test_formula_effects + + registry = self._make_registry(["x1", "x2", "x3", "x1:x2"]) + # Formula lists x2 before x1 + effects, _ = _extract_test_formula_effects("y ~ x2 + x1", registry) + assert effects == ["x1", "x2"] # registry order preserved + + +class TestComputeTestColumnIndices: + """Test _compute_test_column_indices helper.""" + + def test_subset_two_of_three(self): + """Selecting 2 of 3 effects gives correct indices.""" + from mcpower.utils.test_formula_utils import _compute_test_column_indices + + all_effect_names = ["x1", "x2", "x3"] + test_effect_names = ["x1", "x2"] + result = _compute_test_column_indices(all_effect_names, test_effect_names) + assert list(result) == [0, 1] + + def test_skip_middle(self): + """Selecting first and last of 3 effects.""" + from mcpower.utils.test_formula_utils import _compute_test_column_indices + + all_effect_names = ["x1", "x2", "x3"] + test_effect_names = ["x1", "x3"] + result = _compute_test_column_indices(all_effect_names, test_effect_names) + assert list(result) == [0, 2] + + def test_single_effect(self): + """Single effect selected.""" + from mcpower.utils.test_formula_utils import _compute_test_column_indices + + all_effect_names = ["x1", "x2", "x3"] + test_effect_names = ["x2"] + result = _compute_test_column_indices(all_effect_names, test_effect_names) + assert list(result) == [1] + + def test_all_effects_returns_all_indices(self): + """Selecting all effects returns full range.""" + from mcpower.utils.test_formula_utils import _compute_test_column_indices + + all_effect_names = ["x1", "x2", "x3"] + test_effect_names = ["x1", "x2", "x3"] + result = _compute_test_column_indices(all_effect_names, test_effect_names) + assert list(result) == [0, 1, 2] + + def test_with_interactions(self): + """Interaction effects have correct indices.""" + from mcpower.utils.test_formula_utils import _compute_test_column_indices + + all_effect_names = ["x1", "x2", "x3", "x1:x2"] + test_effect_names = ["x1", "x2", "x1:x2"] + result = _compute_test_column_indices(all_effect_names, test_effect_names) + assert list(result) == [0, 1, 3] + + +class TestRemapTargetIndices: + """Test _remap_target_indices helper.""" + + def test_simple_remap(self): + """Target indices remapped to positions within test columns.""" + from mcpower.utils.test_formula_utils import _remap_target_indices + + # Original target_indices: [0, 1] (x1, x2 in full model) + # test_column_indices: [0, 1] (x1, x2 at positions 0, 1 in X_expanded) + # In X_test, x1 is at 0, x2 is at 1 -> remapped: [0, 1] + original = np.array([0, 1]) + test_cols = np.array([0, 1]) + result = _remap_target_indices(original, test_cols) + assert list(result) == [0, 1] + + def test_remap_with_gap(self): + """Target indices remapped when test columns skip positions.""" + from mcpower.utils.test_formula_utils import _remap_target_indices + + # Full model: [x1=0, x2=1, x3=2, x1:x2=3] + # Test model: [x1=0, x1:x2=3] -> X_test columns at [0, 3] + # target_test="x1" -> original target_indices=[0] + # In X_test, x1 is at position 0 -> remapped: [0] + original = np.array([0]) + test_cols = np.array([0, 3]) + result = _remap_target_indices(original, test_cols) + assert list(result) == [0] + + def test_remap_target_at_end(self): + """Target index that moves to different position in X_test.""" + from mcpower.utils.test_formula_utils import _remap_target_indices + + # Full model: [x1=0, x2=1, x3=2] + # Test model: [x2=1, x3=2] -> test_column_indices=[1, 2] + # target_test="x3" -> original target_indices=[2] + # In X_test, x3 is at position 1 (second column) -> remapped: [1] + original = np.array([2]) + test_cols = np.array([1, 2]) + result = _remap_target_indices(original, test_cols) + assert list(result) == [1] + + +class TestPrepareMetadataWithTestFormula: + """Integration test: prepare_metadata with test_formula_effects.""" + + def test_metadata_has_test_indices_when_provided(self): + from mcpower import MCPower + from mcpower.core.simulation import prepare_metadata + + model = MCPower("y = x1 + x2 + x3") + model.set_effects("x1=0.5, x2=0.3, x3=0.2") + model._apply() + + metadata = prepare_metadata(model, ["x1", "x2"], test_formula_effects=["x1", "x2"]) + assert metadata.test_column_indices is not None + assert list(metadata.test_column_indices) == [0, 1] + assert metadata.test_target_indices is not None + assert metadata.test_effect_count == 2 + + def test_metadata_no_test_indices_by_default(self): + from mcpower import MCPower + from mcpower.core.simulation import prepare_metadata + + model = MCPower("y = x1 + x2") + model.set_effects("x1=0.5, x2=0.3") + model._apply() + + metadata = prepare_metadata(model, ["x1", "x2"]) + assert metadata.test_column_indices is None + + def test_remap_skips_targets_not_in_test_formula(self): + from mcpower import MCPower + from mcpower.core.simulation import prepare_metadata + + model = MCPower("y = x1 + x2 + x3") + model.set_effects("x1=0.5, x2=0.3, x3=0.2") + model._apply() + + # target_tests = all 3, but test formula only has x1, x2 + metadata = prepare_metadata(model, ["x1", "x2", "x3"], test_formula_effects=["x1", "x2"]) + # test_target_indices should only have indices for x1 and x2 in X_test + assert len(metadata.test_target_indices) == 2 + + +class TestParseTargetTestsWithTestFormula: + """Test _parse_target_tests limits 'all' when test_formula is active.""" + + def test_all_expands_to_test_formula_effects_only(self): + from mcpower import MCPower + + model = MCPower("y = x1 + x2 + x3") + model.set_effects("x1=0.5, x2=0.3, x3=0.2") + model._apply() + + result = model._parse_target_tests("all", test_formula_effects=["x1", "x2"]) + assert "x3" not in result + assert "x1" in result + assert "x2" in result + assert "overall" in result + + def test_explicit_target_not_in_test_formula_raises(self): + from mcpower import MCPower + + import pytest + + model = MCPower("y = x1 + x2 + x3") + model.set_effects("x1=0.5, x2=0.3, x3=0.2") + model._apply() + + with pytest.raises(ValueError, match="x3"): + model._parse_target_tests("x3", test_formula_effects=["x1", "x2"]) + + def test_overall_always_allowed(self): + from mcpower import MCPower + + model = MCPower("y = x1 + x2 + x3") + model.set_effects("x1=0.5, x2=0.3, x3=0.2") + model._apply() + + result = model._parse_target_tests("overall", test_formula_effects=["x1", "x2"]) + assert "overall" in result + + def test_no_test_formula_uses_all_effects(self): + from mcpower import MCPower + + model = MCPower("y = x1 + x2 + x3") + model.set_effects("x1=0.5, x2=0.3, x3=0.2") + model._apply() + + result = model._parse_target_tests("all") + assert "x1" in result + assert "x2" in result + assert "x3" in result diff --git a/tests/unit/test_updates.py b/tests/unit/test_updates.py index ee7a2a0..90b3003 100644 --- a/tests/unit/test_updates.py +++ b/tests/unit/test_updates.py @@ -101,14 +101,17 @@ def test_shows_warning_when_newer(self, monkeypatch): """Show warning when PyPI version is newer.""" monkeypatch.delenv("_MCPOWER_UPDATE_CHECKED", raising=False) - # Write a cache file at the path the installed module actually reads from - from datetime import datetime - import mcpower.utils.updates as upd_mod + + # Reset the module-level dedup flag + upd_mod._already_checked = False + + # Write a cache file at the path the module actually reads from + from datetime import datetime from pathlib import Path - cache_path = Path(upd_mod.__file__).parent.parent / ".mcpower_cache.json" - cache_path.parent.mkdir(exist_ok=True) + cache_path = Path.home() / ".cache" / "mcpower" / "update_cache.json" + cache_path.parent.mkdir(parents=True, exist_ok=True) cache_data = { "last_check": datetime.now().isoformat(), "latest_version": "99.0.0", @@ -120,5 +123,6 @@ def test_shows_warning_when_newer(self, monkeypatch): with pytest.warns(match="NEW MCPower VERSION"): _check_for_updates("1.0.0") finally: - # Clean up the cache file + # Clean up the cache file and reset flag cache_path.unlink(missing_ok=True) + upd_mod._already_checked = False diff --git a/tests/unit/test_upload_data_utils.py b/tests/unit/test_upload_data_utils.py new file mode 100644 index 0000000..c498b6a --- /dev/null +++ b/tests/unit/test_upload_data_utils.py @@ -0,0 +1,62 @@ +"""Unit tests for mcpower.utils.upload_data_utils — normalize_upload_input.""" + +import numpy as np +import pytest + +from mcpower.utils.upload_data_utils import normalize_upload_input + + +class TestNormalizeUploadInput: + """Tests for normalize_upload_input.""" + + def test_dict_input(self): + data = {"x1": [1.0, 2.0, 3.0], "x2": [4.0, 5.0, 6.0]} + arr, cols = normalize_upload_input(data) + assert cols == ["x1", "x2"] + assert arr.shape == (3, 2) + np.testing.assert_array_equal(arr[:, 0], [1.0, 2.0, 3.0]) + + def test_dict_with_strings(self): + data = {"group": ["a", "b", "a"], "x1": [1.0, 2.0, 3.0]} + arr, cols = normalize_upload_input(data) + assert arr.dtype == object + assert cols == ["group", "x1"] + + def test_list_input(self): + data = [1.0, 2.0, 3.0] + arr, cols = normalize_upload_input(data) + assert arr.shape == (3, 1) + assert cols == ["column_1"] + + def test_1d_array(self): + data = np.array([1.0, 2.0, 3.0]) + arr, cols = normalize_upload_input(data) + assert arr.shape == (3, 1) + assert cols == ["column_1"] + + def test_2d_array(self): + data = np.array([[1.0, 2.0], [3.0, 4.0]]) + arr, cols = normalize_upload_input(data) + assert arr.shape == (2, 2) + assert cols == ["column_1", "column_2"] + + def test_2d_array_with_columns(self): + data = np.array([[1.0, 2.0], [3.0, 4.0]]) + arr, cols = normalize_upload_input(data, columns=["a", "b"]) + assert cols == ["a", "b"] + + def test_dataframe_input(self): + pd = pytest.importorskip("pandas") + df = pd.DataFrame({"x1": [1.0, 2.0], "x2": [3.0, 4.0]}) + arr, cols = normalize_upload_input(df) + assert cols == ["x1", "x2"] + assert arr.shape == (2, 2) + + def test_mismatched_columns_raises(self): + data = np.array([[1.0, 2.0], [3.0, 4.0]]) + with pytest.raises(ValueError, match="columns length"): + normalize_upload_input(data, columns=["a", "b", "c"]) + + def test_unsupported_type_raises(self): + with pytest.raises(TypeError, match="data must be"): + normalize_upload_input("not valid data") diff --git a/tests/unit/test_utils_mixed_models.py b/tests/unit/test_utils_mixed_models.py new file mode 100644 index 0000000..de4d98a --- /dev/null +++ b/tests/unit/test_utils_mixed_models.py @@ -0,0 +1,27 @@ +"""Tests for mcpower.utils.mixed_models backward-compat re-exports.""" + +import threading + +from mcpower.utils.mixed_models import ( + _lme_analysis_wrapper, + _lme_thread_local, + reset_warm_start_cache, +) + + +class TestReExports: + """Verify that the backward-compatibility re-exports resolve correctly.""" + + def test_lme_analysis_wrapper_is_callable(self): + assert callable(_lme_analysis_wrapper) + + def test_lme_thread_local_is_threading_local(self): + assert isinstance(_lme_thread_local, threading.local) + + def test_reset_warm_start_cache_is_callable(self): + assert callable(reset_warm_start_cache) + + def test_reset_warm_start_cache_clears_params(self): + _lme_thread_local.warm_start_params = "dummy" + reset_warm_start_cache() + assert _lme_thread_local.warm_start_params is None From 48c945ba8da4b1bd80f3376bc05b832f4e5591b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Lenartowicz?= <6quarg@gmail.com> Date: Thu, 26 Feb 2026 02:03:54 +0100 Subject: [PATCH 8/9] Fix misteak in CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50b15b2..c56f9b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ All notable changes to this project will be documented in this file. - **`[all]` extra no longer includes `statsmodels`** — use `pip install mcpower[lme]` to get statsmodels for mixed-effects models ### Added -- **`test_formula` parameter** on `find_power()` and `find_sample_size()` — test a reduced model against data generated from the full model to evaluate power under model misspecification. For example, generate data with `y = x1 + x2 + x3` but test with `test_formula="y ~ x1 + x2"` to see power when `x3` is omitted. Supports interactions, factors, and mixed models. See the [wiki tutorial](https://github.com/pawlenartowicz/MCPower/wiki/Model-Misspecification-Testing) +- **`test_formula` parameter** on `find_power()` and `find_sample_size()` — test a reduced model against data generated from the full model to evaluate power under model misspecification. For example, generate data with `y = x1 + x2 + x3` but test with `test_formula="y ~ x1 + x2"` to see power when `x3` is omitted. Supports interactions, factors, and mixed models. - **C++ non-normal residual generation** — scenario perturbations now generate heavy-tailed (Student-t) and skewed (chi-squared) residuals directly in C++ via `residual_dist`/`residual_df` parameters in `generate_y()`, replacing the Python-side post-hoc perturbation approach. Applies to all model types (OLS and LME) - **`optimistic` scenario** is now a first-class entry in `DEFAULT_SCENARIO_CONFIG` with all-zero perturbation values, eliminating the special `scenario_config=None` code path. Custom scenarios inherit from the optimistic baseline, ensuring all required keys exist From f4fa4384681cf131c5794ee20465253b588c86e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Lenartowicz?= <6quarg@gmail.com> Date: Thu, 26 Feb 2026 02:17:57 +0100 Subject: [PATCH 9/9] Fixed date --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c56f9b9..24d9439 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project will be documented in this file. -## [0.6.0] - 2026-02-24 +## [0.6.0] - 2026-02-26 ### Breaking changes - **Removed `set_backend()`, `get_backend_info()`, `reset_backend()`** — only one backend (C++ native) exists since v0.5.0, so the multi-backend API was dead code. Use `from mcpower.backends import get_backend` if you need the backend instance directly