policyengine 3.2.1__tar.gz → 3.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. policyengine-3.2.4/.github/check-changelog.sh +10 -0
  2. {policyengine-3.2.1 → policyengine-3.2.4}/.github/workflows/pr_code_changes.yaml +9 -2
  3. policyengine-3.2.4/.github/workflows/push.yaml +160 -0
  4. {policyengine-3.2.1 → policyengine-3.2.4}/CHANGELOG.md +25 -0
  5. {policyengine-3.2.1 → policyengine-3.2.4}/PKG-INFO +1 -1
  6. {policyengine-3.2.1 → policyengine-3.2.4}/pyproject.toml +1 -1
  7. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/core/__init__.py +8 -0
  8. policyengine-3.2.4/src/policyengine/core/parameter_node.py +29 -0
  9. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/core/region.py +9 -1
  10. policyengine-3.2.4/src/policyengine/core/scoping_strategy.py +224 -0
  11. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/core/simulation.py +28 -2
  12. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/core/tax_benefit_model_version.py +20 -2
  13. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/core/variable.py +3 -0
  14. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/countries/uk/regions.py +26 -4
  15. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/countries/us/regions.py +5 -0
  16. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/tax_benefit_models/uk/model.py +62 -2
  17. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/tax_benefit_models/us/model.py +65 -2
  18. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine.egg-info/PKG-INFO +1 -1
  19. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine.egg-info/SOURCES.txt +8 -4
  20. policyengine-3.2.4/tests/fixtures/variable_label_fixtures.py +53 -0
  21. {policyengine-3.2.1 → policyengine-3.2.4}/tests/test_filtering.py +19 -0
  22. {policyengine-3.2.1 → policyengine-3.2.4}/tests/test_models.py +97 -0
  23. policyengine-3.2.4/tests/test_scoping_strategy.py +339 -0
  24. {policyengine-3.2.1 → policyengine-3.2.4}/tests/test_uk_regions.py +14 -0
  25. policyengine-3.2.4/tests/test_variable_labels.py +190 -0
  26. policyengine-3.2.1/.github/workflows/code_changes.yaml +0 -60
  27. policyengine-3.2.1/.github/workflows/docs.yml +0 -49
  28. policyengine-3.2.1/.github/workflows/versioning.yaml +0 -75
  29. {policyengine-3.2.1 → policyengine-3.2.4}/.claude/policyengine-guide.md +0 -0
  30. {policyengine-3.2.1 → policyengine-3.2.4}/.claude/quick-reference.md +0 -0
  31. {policyengine-3.2.1 → policyengine-3.2.4}/.github/CONTRIBUTING.md +0 -0
  32. {policyengine-3.2.1 → policyengine-3.2.4}/.github/bump_version.py +0 -0
  33. {policyengine-3.2.1 → policyengine-3.2.4}/.github/changelog_template.md +0 -0
  34. {policyengine-3.2.1 → policyengine-3.2.4}/.github/fetch_version.py +0 -0
  35. {policyengine-3.2.1 → policyengine-3.2.4}/.github/get-changelog-diff.sh +0 -0
  36. {policyengine-3.2.1 → policyengine-3.2.4}/.github/has-functional-changes.sh +0 -0
  37. {policyengine-3.2.1 → policyengine-3.2.4}/.github/is-version-number-acceptable.sh +0 -0
  38. {policyengine-3.2.1 → policyengine-3.2.4}/.github/publish-git-tag.sh +0 -0
  39. {policyengine-3.2.1 → policyengine-3.2.4}/.github/workflows/pr_docs_changes.yaml +0 -0
  40. {policyengine-3.2.1 → policyengine-3.2.4}/.gitignore +0 -0
  41. {policyengine-3.2.1 → policyengine-3.2.4}/.python-version +0 -0
  42. {policyengine-3.2.1 → policyengine-3.2.4}/LICENSE +0 -0
  43. {policyengine-3.2.1 → policyengine-3.2.4}/Makefile +0 -0
  44. {policyengine-3.2.1 → policyengine-3.2.4}/README.md +0 -0
  45. {policyengine-3.2.1 → policyengine-3.2.4}/changelog.d/.gitkeep +0 -0
  46. {policyengine-3.2.1 → policyengine-3.2.4}/docs/.gitignore +0 -0
  47. {policyengine-3.2.1 → policyengine-3.2.4}/docs/core-concepts.md +0 -0
  48. {policyengine-3.2.1 → policyengine-3.2.4}/docs/country-models-uk.md +0 -0
  49. {policyengine-3.2.1 → policyengine-3.2.4}/docs/country-models-us.md +0 -0
  50. {policyengine-3.2.1 → policyengine-3.2.4}/docs/dev.md +0 -0
  51. {policyengine-3.2.1 → policyengine-3.2.4}/docs/index.md +0 -0
  52. {policyengine-3.2.1 → policyengine-3.2.4}/docs/myst.yml +0 -0
  53. {policyengine-3.2.1 → policyengine-3.2.4}/docs/visualisation.md +0 -0
  54. {policyengine-3.2.1 → policyengine-3.2.4}/examples/employment_income_variation_uk.py +0 -0
  55. {policyengine-3.2.1 → policyengine-3.2.4}/examples/employment_income_variation_us.py +0 -0
  56. {policyengine-3.2.1 → policyengine-3.2.4}/examples/household_impact_example.py +0 -0
  57. {policyengine-3.2.1 → policyengine-3.2.4}/examples/income_bands_uk.py +0 -0
  58. {policyengine-3.2.1 → policyengine-3.2.4}/examples/income_distribution_us.py +0 -0
  59. {policyengine-3.2.1 → policyengine-3.2.4}/examples/policy_change_uk.py +0 -0
  60. {policyengine-3.2.1 → policyengine-3.2.4}/examples/speedtest_us_simulation.py +0 -0
  61. {policyengine-3.2.1 → policyengine-3.2.4}/setup.cfg +0 -0
  62. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/__init__.py +0 -0
  63. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/core/cache.py +0 -0
  64. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/core/dataset.py +0 -0
  65. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/core/dataset_version.py +0 -0
  66. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/core/dynamic.py +0 -0
  67. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/core/output.py +0 -0
  68. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/core/parameter.py +0 -0
  69. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/core/parameter_value.py +0 -0
  70. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/core/policy.py +0 -0
  71. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/core/tax_benefit_model.py +0 -0
  72. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/countries/__init__.py +0 -0
  73. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/countries/uk/__init__.py +0 -0
  74. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/countries/us/__init__.py +0 -0
  75. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/countries/us/data/__init__.py +0 -0
  76. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/countries/us/data/districts.py +0 -0
  77. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/countries/us/data/places.py +0 -0
  78. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/countries/us/data/states.py +0 -0
  79. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/outputs/__init__.py +0 -0
  80. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/outputs/aggregate.py +0 -0
  81. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/outputs/change_aggregate.py +0 -0
  82. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/outputs/congressional_district_impact.py +0 -0
  83. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/outputs/constituency_impact.py +0 -0
  84. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/outputs/decile_impact.py +0 -0
  85. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/outputs/inequality.py +0 -0
  86. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/outputs/intra_decile_impact.py +0 -0
  87. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/outputs/local_authority_impact.py +0 -0
  88. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/outputs/poverty.py +0 -0
  89. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/tax_benefit_models/uk/__init__.py +0 -0
  90. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/tax_benefit_models/uk/analysis.py +0 -0
  91. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/tax_benefit_models/uk/datasets.py +0 -0
  92. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/tax_benefit_models/uk/outputs.py +0 -0
  93. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/tax_benefit_models/uk.py +0 -0
  94. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/tax_benefit_models/us/__init__.py +0 -0
  95. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/tax_benefit_models/us/analysis.py +0 -0
  96. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/tax_benefit_models/us/datasets.py +0 -0
  97. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/tax_benefit_models/us/outputs.py +0 -0
  98. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/tax_benefit_models/us.py +0 -0
  99. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/utils/__init__.py +0 -0
  100. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/utils/dates.py +0 -0
  101. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/utils/entity_utils.py +0 -0
  102. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/utils/parameter_labels.py +0 -0
  103. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/utils/parametric_reforms.py +0 -0
  104. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine/utils/plotting.py +0 -0
  105. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine.egg-info/dependency_links.txt +0 -0
  106. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine.egg-info/requires.txt +0 -0
  107. {policyengine-3.2.1 → policyengine-3.2.4}/src/policyengine.egg-info/top_level.txt +0 -0
  108. {policyengine-3.2.1 → policyengine-3.2.4}/tests/__init__.py +0 -0
  109. {policyengine-3.2.1 → policyengine-3.2.4}/tests/conftest.py +0 -0
  110. {policyengine-3.2.1 → policyengine-3.2.4}/tests/fixtures/__init__.py +0 -0
  111. {policyengine-3.2.1 → policyengine-3.2.4}/tests/fixtures/filtering_fixtures.py +0 -0
  112. {policyengine-3.2.1 → policyengine-3.2.4}/tests/fixtures/parameter_labels_fixtures.py +0 -0
  113. {policyengine-3.2.1 → policyengine-3.2.4}/tests/fixtures/parametric_reforms_fixtures.py +0 -0
  114. {policyengine-3.2.1 → policyengine-3.2.4}/tests/fixtures/poverty_by_demographics_fixtures.py +0 -0
  115. {policyengine-3.2.1 → policyengine-3.2.4}/tests/fixtures/region_fixtures.py +0 -0
  116. {policyengine-3.2.1 → policyengine-3.2.4}/tests/fixtures/us_reform_fixtures.py +0 -0
  117. {policyengine-3.2.1 → policyengine-3.2.4}/tests/test_aggregate.py +0 -0
  118. {policyengine-3.2.1 → policyengine-3.2.4}/tests/test_cache.py +0 -0
  119. {policyengine-3.2.1 → policyengine-3.2.4}/tests/test_change_aggregate.py +0 -0
  120. {policyengine-3.2.1 → policyengine-3.2.4}/tests/test_congressional_district_impact.py +0 -0
  121. {policyengine-3.2.1 → policyengine-3.2.4}/tests/test_constituency_impact.py +0 -0
  122. {policyengine-3.2.1 → policyengine-3.2.4}/tests/test_entity_mapping.py +0 -0
  123. {policyengine-3.2.1 → policyengine-3.2.4}/tests/test_entity_utils.py +0 -0
  124. {policyengine-3.2.1 → policyengine-3.2.4}/tests/test_household_impact.py +0 -0
  125. {policyengine-3.2.1 → policyengine-3.2.4}/tests/test_inequality.py +0 -0
  126. {policyengine-3.2.1 → policyengine-3.2.4}/tests/test_intra_decile_impact.py +0 -0
  127. {policyengine-3.2.1 → policyengine-3.2.4}/tests/test_local_authority_impact.py +0 -0
  128. {policyengine-3.2.1 → policyengine-3.2.4}/tests/test_pandas3_compatibility.py +0 -0
  129. {policyengine-3.2.1 → policyengine-3.2.4}/tests/test_parameter_labels.py +0 -0
  130. {policyengine-3.2.1 → policyengine-3.2.4}/tests/test_parametric_reforms.py +0 -0
  131. {policyengine-3.2.1 → policyengine-3.2.4}/tests/test_poverty.py +0 -0
  132. {policyengine-3.2.1 → policyengine-3.2.4}/tests/test_poverty_by_demographics.py +0 -0
  133. {policyengine-3.2.1 → policyengine-3.2.4}/tests/test_poverty_run.py +0 -0
  134. {policyengine-3.2.1 → policyengine-3.2.4}/tests/test_region.py +0 -0
  135. {policyengine-3.2.1 → policyengine-3.2.4}/tests/test_us_reform_application.py +0 -0
  136. {policyengine-3.2.1 → policyengine-3.2.4}/tests/test_us_regions.py +0 -0
  137. {policyengine-3.2.1 → policyengine-3.2.4}/uv.lock +0 -0
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ FRAGMENTS=$(find changelog.d -type f ! -name '.gitkeep' | wc -l)
5
+ if [ "$FRAGMENTS" -eq 0 ]; then
6
+ echo "::error::No changelog fragment found in changelog.d/"
7
+ echo "Add one with: echo 'Description.' > changelog.d/\$(git branch --show-current).<type>.md"
8
+ echo "Types: added, changed, fixed, removed, breaking"
9
+ exit 1
10
+ fi
@@ -7,14 +7,21 @@ on:
7
7
  - src/**
8
8
  - tests/**
9
9
  - .github/**
10
+ - changelog.d/**
10
11
  workflow_dispatch:
11
12
 
12
13
  jobs:
14
+ check-changelog:
15
+ name: Check changelog fragment
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - name: Check for changelog fragment
20
+ run: .github/check-changelog.sh
13
21
  Lint:
14
22
  runs-on: ubuntu-latest
15
23
  steps:
16
24
  - uses: actions/checkout@v4
17
- # Check formatting with ruff
18
25
  - name: Install ruff
19
26
  run: pip install ruff
20
27
 
@@ -62,4 +69,4 @@ jobs:
62
69
  - name: Run tests with coverage
63
70
  run: make test
64
71
  env:
65
- HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }}
72
+ HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }}
@@ -0,0 +1,160 @@
1
+ # Unified workflow for pushes to main.
2
+ #
3
+ # Phase 1 (normal push): Lint + Test → Docs + Versioning
4
+ # Versioning commits with message "Update package version".
5
+ # The commit pushes to main, re-triggering this workflow for Phase 2.
6
+ #
7
+ # Phase 2 (sentinel commit): Publish to PyPI + GitHub Release
8
+ # Skips Lint + Test since the code is identical to Phase 1.
9
+
10
+ name: Push to main
11
+
12
+ on:
13
+ push:
14
+ branches:
15
+ - main
16
+ workflow_dispatch:
17
+
18
+ permissions:
19
+ contents: write
20
+ pages: write
21
+ id-token: write
22
+
23
+ jobs:
24
+ # ── Phase 1 gates (skip on sentinel commit) ─────────────────
25
+ Lint:
26
+ if: github.event.head_commit.message != 'Update package version'
27
+ runs-on: ubuntu-latest
28
+ steps:
29
+ - uses: actions/checkout@v4
30
+ - name: Install ruff
31
+ run: pip install ruff
32
+ - name: Run ruff format check
33
+ run: ruff format --check .
34
+ - name: Run ruff check
35
+ run: ruff check .
36
+
37
+ Test:
38
+ if: github.event.head_commit.message != 'Update package version'
39
+ runs-on: macos-latest
40
+ strategy:
41
+ fail-fast: false
42
+ matrix:
43
+ python-version: ['3.13', '3.14']
44
+ steps:
45
+ - name: Checkout repo
46
+ uses: actions/checkout@v4
47
+ - name: Install uv
48
+ uses: astral-sh/setup-uv@v5
49
+ - name: Set up Python
50
+ uses: actions/setup-python@v5
51
+ with:
52
+ python-version: ${{ matrix.python-version }}
53
+ allow-prereleases: true
54
+ - name: Install package
55
+ run: uv pip install -e .[dev] --system
56
+ - name: Install policyengine
57
+ run: uv pip install policyengine --system
58
+ - name: Run tests with coverage
59
+ run: make test
60
+ env:
61
+ HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }}
62
+
63
+ # ── Phase 1: Docs + Versioning (skip on sentinel commit) ──
64
+ Docs:
65
+ name: Deploy documentation
66
+ runs-on: ubuntu-latest
67
+ needs: [Lint, Test]
68
+ if: github.event.head_commit.message != 'Update package version'
69
+ concurrency:
70
+ group: 'pages'
71
+ cancel-in-progress: false
72
+ environment:
73
+ name: github-pages
74
+ url: ${{ steps.deployment.outputs.page_url }}
75
+ env:
76
+ BASE_URL: /${{ github.event.repository.name }}
77
+ steps:
78
+ - uses: actions/checkout@v4
79
+ - name: Setup Pages
80
+ uses: actions/configure-pages@v3
81
+ - uses: actions/setup-node@v4
82
+ with:
83
+ node-version: 18.x
84
+ - name: Install MyST
85
+ run: npm install -g mystmd
86
+ - name: Build HTML Assets
87
+ run: cd docs && myst build --html
88
+ - name: Upload artifact
89
+ uses: actions/upload-pages-artifact@v3
90
+ with:
91
+ path: './docs/_build/html'
92
+ - name: Deploy to GitHub Pages
93
+ id: deployment
94
+ uses: actions/deploy-pages@v4
95
+
96
+ Versioning:
97
+ runs-on: ubuntu-latest
98
+ needs: [Lint, Test]
99
+ if: github.event.head_commit.message != 'Update package version'
100
+ steps:
101
+ - name: Generate GitHub App token
102
+ id: app-token
103
+ uses: actions/create-github-app-token@v1
104
+ with:
105
+ app-id: ${{ secrets.APP_ID }}
106
+ private-key: ${{ secrets.APP_PRIVATE_KEY }}
107
+ - name: Checkout repo
108
+ uses: actions/checkout@v4
109
+ with:
110
+ token: ${{ steps.app-token.outputs.token }}
111
+ - name: Setup Python
112
+ uses: actions/setup-python@v5
113
+ with:
114
+ python-version: '3.13'
115
+ - name: Build changelog
116
+ run: pip install yaml-changelog towncrier && make changelog
117
+ - name: Preview changelog update
118
+ run: ".github/get-changelog-diff.sh"
119
+ - name: Update changelog
120
+ uses: EndBug/add-and-commit@v9
121
+ with:
122
+ add: "."
123
+ message: Update package version
124
+
125
+ # ── Phase 2: Publish (only on sentinel commit) ────────────
126
+ Publish:
127
+ runs-on: ubuntu-latest
128
+ if: github.event.head_commit.message == 'Update package version'
129
+ env:
130
+ GH_TOKEN: ${{ github.token }}
131
+ steps:
132
+ - name: Checkout repo
133
+ uses: actions/checkout@v4
134
+ - name: Install uv
135
+ uses: astral-sh/setup-uv@v5
136
+ - name: Set up Python
137
+ uses: actions/setup-python@v5
138
+ with:
139
+ python-version: '3.13'
140
+ - name: Install package
141
+ run: uv pip install -e .[dev] --system
142
+ - name: Install policyengine
143
+ run: uv pip install policyengine --system
144
+ - name: Publish a git tag
145
+ run: ".github/publish-git-tag.sh"
146
+ - name: Build package
147
+ run: python -m build
148
+ - name: Publish a Python distribution to PyPI
149
+ uses: pypa/gh-action-pypi-publish@release/v1
150
+ with:
151
+ user: __token__
152
+ password: ${{ secrets.PYPI }}
153
+ skip_existing: true
154
+ - name: Create GitHub Release
155
+ run: |
156
+ VERSION=$(python .github/fetch_version.py)
157
+ gh release create "$VERSION" \
158
+ --title "v$VERSION" \
159
+ --notes "See [CHANGELOG.md](https://github.com/PolicyEngine/policyengine.py/blob/main/CHANGELOG.md) for details." \
160
+ --latest
@@ -1,3 +1,28 @@
1
+ ## [3.2.4] - 2026-03-17
2
+
3
+ ### Changed
4
+
5
+ - Skip redundant Lint and Test in Phase 2 of push workflow since code is identical to Phase 1
6
+
7
+
8
+ ## [3.2.3] - 2026-03-17
9
+
10
+ ### Fixed
11
+
12
+ - Use GitHub App token in push workflow Versioning job to enable auto-triggering of Phase 2 (Publish)
13
+
14
+
15
+ ## [3.2.2] - 2026-03-17
16
+
17
+ ### Changed
18
+
19
+ - Consolidate CI/CD workflows into a unified push workflow with two-phase sentinel pattern, enforce changelog fragments on PRs
20
+
21
+ ### Fixed
22
+
23
+ - Use GITHUB_TOKEN instead of missing POLICYENGINE_GITHUB PAT in push workflow
24
+
25
+
1
26
  ## [3.2.1] - 2026-03-10
2
27
 
3
28
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: policyengine
3
- Version: 3.2.1
3
+ Version: 3.2.4
4
4
  Summary: A package to conduct policy analysis using PolicyEngine tax-benefit models.
5
5
  Author-email: PolicyEngine <hello@policyengine.org>
6
6
  License: GNU AFFERO GENERAL PUBLIC LICENSE
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "policyengine"
7
- version = "3.2.1"
7
+ version = "3.2.4"
8
8
  description = "A package to conduct policy analysis using PolicyEngine tax-benefit models."
9
9
  readme = "README.md"
10
10
  authors = [
@@ -6,11 +6,18 @@ from .dynamic import Dynamic as Dynamic
6
6
  from .output import Output as Output
7
7
  from .output import OutputCollection as OutputCollection
8
8
  from .parameter import Parameter as Parameter
9
+ from .parameter_node import ParameterNode as ParameterNode
9
10
  from .parameter_value import ParameterValue as ParameterValue
10
11
  from .policy import Policy as Policy
11
12
  from .region import Region as Region
12
13
  from .region import RegionRegistry as RegionRegistry
13
14
  from .region import RegionType as RegionType
15
+ from .scoping_strategy import RegionScopingStrategy as RegionScopingStrategy
16
+ from .scoping_strategy import RowFilterStrategy as RowFilterStrategy
17
+ from .scoping_strategy import ScopingStrategy as ScopingStrategy
18
+ from .scoping_strategy import (
19
+ WeightReplacementStrategy as WeightReplacementStrategy,
20
+ )
14
21
  from .simulation import Simulation as Simulation
15
22
  from .tax_benefit_model import TaxBenefitModel as TaxBenefitModel
16
23
  from .tax_benefit_model_version import (
@@ -23,4 +30,5 @@ Dataset.model_rebuild()
23
30
  TaxBenefitModelVersion.model_rebuild()
24
31
  Variable.model_rebuild()
25
32
  Parameter.model_rebuild()
33
+ ParameterNode.model_rebuild()
26
34
  ParameterValue.model_rebuild()
@@ -0,0 +1,29 @@
1
+ from typing import TYPE_CHECKING
2
+ from uuid import uuid4
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+ if TYPE_CHECKING:
7
+ from .tax_benefit_model_version import TaxBenefitModelVersion
8
+
9
+
10
+ class ParameterNode(BaseModel):
11
+ """Represents a folder/category node in the parameter hierarchy.
12
+
13
+ Parameter nodes are intermediate nodes in the parameter tree (e.g., "gov",
14
+ "gov.hmrc", "gov.hmrc.income_tax"). They provide structure and human-readable
15
+ labels for navigating the parameter tree, but don't have values themselves.
16
+
17
+ Unlike Parameter objects (which are leaf nodes with actual values),
18
+ ParameterNode objects are purely organizational.
19
+ """
20
+
21
+ model_config = {"arbitrary_types_allowed": True}
22
+
23
+ id: str = Field(default_factory=lambda: str(uuid4()))
24
+ name: str = Field(description="Full path of the node (e.g., 'gov.hmrc')")
25
+ label: str | None = Field(
26
+ default=None, description="Human-readable label (e.g., 'HMRC')"
27
+ )
28
+ description: str | None = Field(default=None, description="Node description")
29
+ tax_benefit_model_version: "TaxBenefitModelVersion"
@@ -10,6 +10,8 @@ from typing import Literal
10
10
 
11
11
  from pydantic import BaseModel, Field, PrivateAttr
12
12
 
13
+ from .scoping_strategy import ScopingStrategy
14
+
13
15
  # Region type literals for US and UK
14
16
  USRegionType = Literal["national", "state", "congressional_district", "place"]
15
17
  UKRegionType = Literal["national", "country", "constituency", "local_authority"]
@@ -55,7 +57,13 @@ class Region(BaseModel):
55
57
  description="GCS path to dedicated dataset (e.g., 'gs://policyengine-us-data/states/CA.h5')",
56
58
  )
57
59
 
58
- # Filtering configuration (for regions that filter from parent datasets)
60
+ # Scoping strategy (preferred over legacy filter fields)
61
+ scoping_strategy: ScopingStrategy | None = Field(
62
+ default=None,
63
+ description="Strategy for scoping dataset to this region (row filtering or weight replacement)",
64
+ )
65
+
66
+ # Legacy filtering configuration (kept for backward compatibility)
59
67
  requires_filter: bool = Field(
60
68
  default=False,
61
69
  description="True if this region filters from a parent dataset rather than having its own",
@@ -0,0 +1,224 @@
1
+ """Region scoping strategies for geographic simulations.
2
+
3
+ Provides two concrete strategies for scoping datasets to sub-national regions:
4
+
5
+ 1. RowFilterStrategy: Filters dataset rows where a household variable matches
6
+ a specific value (e.g., UK countries by 'country' field, US places by 'place_fips').
7
+
8
+ 2. WeightReplacementStrategy: Replaces household weights from a pre-computed weight
9
+ matrix stored in GCS (e.g., UK constituencies and local authorities).
10
+ """
11
+
12
+ import logging
13
+ from abc import abstractmethod
14
+ from pathlib import Path
15
+ from typing import Annotated, Literal
16
+
17
+ import h5py
18
+ import numpy as np
19
+ import pandas as pd
20
+ from microdf import MicroDataFrame
21
+ from pydantic import BaseModel, Discriminator
22
+
23
+ from policyengine.utils.entity_utils import (
24
+ filter_dataset_by_household_variable,
25
+ )
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class RegionScopingStrategy(BaseModel):
31
+ """Base class for region scoping strategies.
32
+
33
+ Subclasses implement apply() to scope a dataset's entity data
34
+ to a specific sub-national region.
35
+ """
36
+
37
+ strategy_type: str
38
+
39
+ @abstractmethod
40
+ def apply(
41
+ self,
42
+ entity_data: dict[str, MicroDataFrame],
43
+ group_entities: list[str],
44
+ year: int,
45
+ ) -> dict[str, MicroDataFrame]:
46
+ """Apply the scoping strategy to entity data.
47
+
48
+ Args:
49
+ entity_data: Dict mapping entity names to their MicroDataFrames.
50
+ group_entities: List of group entity names for this country.
51
+ year: The simulation year (used for time-indexed weight matrices).
52
+
53
+ Returns:
54
+ A dict mapping entity names to scoped MicroDataFrames.
55
+ """
56
+
57
+ @property
58
+ def cache_key(self) -> str:
59
+ """Return a string key for deterministic simulation ID hashing."""
60
+ return f"{self.strategy_type}:{self.model_dump_json()}"
61
+
62
+
63
+ class RowFilterStrategy(RegionScopingStrategy):
64
+ """Scoping strategy that filters dataset rows by a household variable.
65
+
66
+ Used for regions where we want to keep only households matching a
67
+ specific variable value (e.g., UK countries, US places/cities).
68
+ """
69
+
70
+ strategy_type: Literal["row_filter"] = "row_filter"
71
+ variable_name: str
72
+ variable_value: str
73
+
74
+ def apply(
75
+ self,
76
+ entity_data: dict[str, MicroDataFrame],
77
+ group_entities: list[str],
78
+ year: int,
79
+ ) -> dict[str, MicroDataFrame]:
80
+ return filter_dataset_by_household_variable(
81
+ entity_data=entity_data,
82
+ group_entities=group_entities,
83
+ variable_name=self.variable_name,
84
+ variable_value=self.variable_value,
85
+ )
86
+
87
+ @property
88
+ def cache_key(self) -> str:
89
+ return f"row_filter:{self.variable_name}={self.variable_value}"
90
+
91
+
92
+ class WeightReplacementStrategy(RegionScopingStrategy):
93
+ """Scoping strategy that replaces household weights from a pre-computed matrix.
94
+
95
+ Used for UK constituencies and local authorities. Instead of removing
96
+ households, this strategy keeps all households but replaces their weights
97
+ with region-specific values from a weight matrix stored in GCS.
98
+
99
+ The weight matrix is an HDF5 file with shape (N_regions x N_households),
100
+ where each row contains household weights for a specific region.
101
+ A companion CSV maps region codes/names to row indices.
102
+ """
103
+
104
+ strategy_type: Literal["weight_replacement"] = "weight_replacement"
105
+ weight_matrix_bucket: str
106
+ weight_matrix_key: str
107
+ lookup_csv_bucket: str
108
+ lookup_csv_key: str
109
+ region_code: str
110
+
111
+ def apply(
112
+ self,
113
+ entity_data: dict[str, MicroDataFrame],
114
+ group_entities: list[str],
115
+ year: int,
116
+ ) -> dict[str, MicroDataFrame]:
117
+ from policyengine_core.tools.google_cloud import download_gcs_file
118
+
119
+ # Download lookup CSV and find region index
120
+ lookup_path = Path(
121
+ download_gcs_file(
122
+ bucket=self.lookup_csv_bucket,
123
+ file_path=self.lookup_csv_key,
124
+ )
125
+ )
126
+ lookup_df = pd.read_csv(lookup_path)
127
+
128
+ region_id = self._find_region_index(lookup_df, self.region_code)
129
+
130
+ # Download weight matrix and extract weights for this region
131
+ weights_path = download_gcs_file(
132
+ bucket=self.weight_matrix_bucket,
133
+ file_path=self.weight_matrix_key,
134
+ )
135
+ with h5py.File(weights_path, "r") as f:
136
+ weights = f[str(year)][...]
137
+
138
+ region_weights = weights[region_id]
139
+
140
+ # Validate weight row length matches household count
141
+ household_df = pd.DataFrame(entity_data["household"])
142
+ if len(region_weights) != len(household_df):
143
+ raise ValueError(
144
+ f"Weight matrix row length ({len(region_weights)}) does not match "
145
+ f"household count ({len(household_df)}) for region '{self.region_code}'. "
146
+ f"The weight matrix may be out of date."
147
+ )
148
+
149
+ # Replace household weights
150
+ result = {}
151
+ for entity_name, mdf in entity_data.items():
152
+ df = pd.DataFrame(mdf)
153
+ if entity_name == "household":
154
+ df["household_weight"] = region_weights
155
+ result[entity_name] = MicroDataFrame(df, weights="household_weight")
156
+ else:
157
+ weight_col = f"{entity_name}_weight"
158
+ if weight_col in df.columns:
159
+ # Map new household weights to sub-entities via their
160
+ # household membership. Build a mapping from household_id
161
+ # to new weight.
162
+ hh_ids = household_df["household_id"].values
163
+ weight_map = dict(zip(hh_ids, region_weights))
164
+
165
+ # Find the entity's household ID column
166
+ person_hh_col = self._find_household_id_column(df, entity_name)
167
+ if person_hh_col:
168
+ new_weights = np.array(
169
+ [
170
+ weight_map.get(hh_id, 0.0)
171
+ for hh_id in df[person_hh_col].values
172
+ ]
173
+ )
174
+ df[weight_col] = new_weights
175
+
176
+ result[entity_name] = MicroDataFrame(
177
+ df,
178
+ weights=(
179
+ f"{entity_name}_weight"
180
+ if f"{entity_name}_weight" in df.columns
181
+ else None
182
+ ),
183
+ )
184
+
185
+ return result
186
+
187
+ @staticmethod
188
+ def _find_region_index(lookup_df: pd.DataFrame, region_code: str) -> int:
189
+ """Find the row index for a region in the lookup CSV.
190
+
191
+ Searches by 'code' column first, then 'name' column.
192
+ """
193
+ if "code" in lookup_df.columns and region_code in lookup_df["code"].values:
194
+ return lookup_df[lookup_df["code"] == region_code].index[0]
195
+ if "name" in lookup_df.columns and region_code in lookup_df["name"].values:
196
+ return lookup_df[lookup_df["name"] == region_code].index[0]
197
+ raise ValueError(
198
+ f"Region '{region_code}' not found in lookup CSV. "
199
+ f"Available columns: {list(lookup_df.columns)}. "
200
+ f"Searched 'code' and 'name' columns."
201
+ )
202
+
203
+ @staticmethod
204
+ def _find_household_id_column(df: pd.DataFrame, entity_name: str) -> str | None:
205
+ """Find the column linking an entity to its household."""
206
+ candidates = [
207
+ "person_household_id",
208
+ f"{entity_name}_household_id",
209
+ "household_id",
210
+ ]
211
+ for col in candidates:
212
+ if col in df.columns:
213
+ return col
214
+ return None
215
+
216
+ @property
217
+ def cache_key(self) -> str:
218
+ return f"weight_replacement:{self.weight_matrix_key}:{self.region_code}"
219
+
220
+
221
+ ScopingStrategy = Annotated[
222
+ RowFilterStrategy | WeightReplacementStrategy,
223
+ Discriminator("strategy_type"),
224
+ ]
@@ -2,12 +2,13 @@ import logging
2
2
  from datetime import datetime
3
3
  from uuid import uuid4
4
4
 
5
- from pydantic import BaseModel, Field
5
+ from pydantic import BaseModel, Field, model_validator
6
6
 
7
7
  from .cache import LRUCache
8
8
  from .dataset import Dataset
9
9
  from .dynamic import Dynamic
10
10
  from .policy import Policy
11
+ from .scoping_strategy import RowFilterStrategy, ScopingStrategy
11
12
  from .tax_benefit_model_version import TaxBenefitModelVersion
12
13
 
13
14
  logger = logging.getLogger(__name__)
@@ -24,7 +25,13 @@ class Simulation(BaseModel):
24
25
  dynamic: Dynamic | None = None
25
26
  dataset: Dataset = None
26
27
 
27
- # Regional filtering parameters
28
+ # Scoping strategy (preferred over legacy filter fields)
29
+ scoping_strategy: ScopingStrategy | None = Field(
30
+ default=None,
31
+ description="Strategy for scoping dataset to a sub-national region",
32
+ )
33
+
34
+ # Legacy regional filtering parameters (kept for backward compatibility)
28
35
  filter_field: str | None = Field(
29
36
  default=None,
30
37
  description="Household-level variable to filter dataset by (e.g., 'place_fips', 'country')",
@@ -35,6 +42,25 @@ class Simulation(BaseModel):
35
42
  )
36
43
 
37
44
  tax_benefit_model_version: TaxBenefitModelVersion = None
45
+
46
+ @model_validator(mode="after")
47
+ def _auto_construct_strategy(self) -> "Simulation":
48
+ """Auto-construct a RowFilterStrategy from legacy filter fields.
49
+
50
+ If filter_field and filter_value are set but scoping_strategy is not,
51
+ create a RowFilterStrategy for backward compatibility.
52
+ """
53
+ if (
54
+ self.scoping_strategy is None
55
+ and self.filter_field is not None
56
+ and self.filter_value is not None
57
+ ):
58
+ self.scoping_strategy = RowFilterStrategy(
59
+ variable_name=self.filter_field,
60
+ variable_value=self.filter_value,
61
+ )
62
+ return self
63
+
38
64
  output_dataset: Dataset | None = None
39
65
 
40
66
  def run(self):
@@ -8,6 +8,7 @@ from .tax_benefit_model import TaxBenefitModel
8
8
 
9
9
  if TYPE_CHECKING:
10
10
  from .parameter import Parameter
11
+ from .parameter_node import ParameterNode
11
12
  from .parameter_value import ParameterValue
12
13
  from .region import Region, RegionRegistry
13
14
  from .simulation import Simulation
@@ -25,6 +26,7 @@ class TaxBenefitModelVersion(BaseModel):
25
26
 
26
27
  variables: list["Variable"] = Field(default_factory=list)
27
28
  parameters: list["Parameter"] = Field(default_factory=list)
29
+ parameter_nodes: list["ParameterNode"] = Field(default_factory=list)
28
30
 
29
31
  # Region registry for geographic simulations
30
32
  region_registry: "RegionRegistry | None" = Field(
@@ -43,6 +45,9 @@ class TaxBenefitModelVersion(BaseModel):
43
45
  parameters_by_name: dict[str, "Parameter"] = Field(
44
46
  default_factory=dict, exclude=True
45
47
  )
48
+ parameter_nodes_by_name: dict[str, "ParameterNode"] = Field(
49
+ default_factory=dict, exclude=True
50
+ )
46
51
 
47
52
  def run(self, simulation: "Simulation") -> "Simulation":
48
53
  raise NotImplementedError(
@@ -69,6 +74,11 @@ class TaxBenefitModelVersion(BaseModel):
69
74
  self.variables.append(var)
70
75
  self.variables_by_name[var.name] = var
71
76
 
77
+ def add_parameter_node(self, node: "ParameterNode") -> None:
78
+ """Add a parameter node and index it for fast lookup."""
79
+ self.parameter_nodes.append(node)
80
+ self.parameter_nodes_by_name[node.name] = node
81
+
72
82
  def get_parameter(self, name: str) -> "Parameter":
73
83
  """Get a parameter by name (O(1) lookup)."""
74
84
  if name in self.parameters_by_name:
@@ -85,6 +95,14 @@ class TaxBenefitModelVersion(BaseModel):
85
95
  f"Variable '{name}' not found in {self.model.id} version {self.version}"
86
96
  )
87
97
 
98
+ def get_parameter_node(self, name: str) -> "ParameterNode":
99
+ """Get a parameter node by name (O(1) lookup)."""
100
+ if name in self.parameter_nodes_by_name:
101
+ return self.parameter_nodes_by_name[name]
102
+ raise ValueError(
103
+ f"ParameterNode '{name}' not found in {self.model.id} version {self.version}"
104
+ )
105
+
88
106
  def get_region(self, code: str) -> "Region | None":
89
107
  """Get a region by its code.
90
108
 
@@ -99,5 +117,5 @@ class TaxBenefitModelVersion(BaseModel):
99
117
  return self.region_registry.get(code)
100
118
 
101
119
  def __repr__(self) -> str:
102
- # Give the id and version, and the number of variables, parameters, parameter values
103
- return f"<TaxBenefitModelVersion id={self.id} variables={len(self.variables)} parameters={len(self.parameters)} parameter_values={len(self.parameter_values)}>"
120
+ # Give the id and version, and the number of variables, parameters, parameter nodes, parameter values
121
+ return f"<TaxBenefitModelVersion id={self.id} variables={len(self.variables)} parameters={len(self.parameters)} parameter_nodes={len(self.parameter_nodes)} parameter_values={len(self.parameter_values)}>"
@@ -8,6 +8,7 @@ from .tax_benefit_model_version import TaxBenefitModelVersion
8
8
  class Variable(BaseModel):
9
9
  id: str
10
10
  name: str
11
+ label: str | None = None
11
12
  tax_benefit_model_version: TaxBenefitModelVersion
12
13
  entity: str
13
14
  description: str | None = None
@@ -15,3 +16,5 @@ class Variable(BaseModel):
15
16
  possible_values: list[Any] | None = None
16
17
  default_value: Any = None
17
18
  value_type: type | None = None
19
+ adds: list[str] | None = None
20
+ subtracts: list[str] | None = None