policyengine-uk 2.45.4__py3-none-any.whl → 2.47.3__py3-none-any.whl

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.

Potentially problematic release.


This version of policyengine-uk might be problematic. Click here for more details.

Files changed (29) hide show
  1. policyengine_uk/__init__.py +1 -0
  2. policyengine_uk/data/dataset_schema.py +6 -3
  3. policyengine_uk/data/economic_assumptions.py +1 -1
  4. policyengine_uk/dynamics/labour_supply.py +306 -0
  5. policyengine_uk/dynamics/participation.py +629 -0
  6. policyengine_uk/dynamics/progression.py +376 -0
  7. policyengine_uk/microsimulation.py +23 -1
  8. policyengine_uk/parameters/gov/dynamic/obr_labour_supply_assumptions.yaml +9 -0
  9. policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml +270 -32
  10. policyengine_uk/simulation.py +184 -9
  11. policyengine_uk/tax_benefit_system.py +4 -1
  12. policyengine_uk/tests/microsimulation/reforms_config.yaml +7 -7
  13. policyengine_uk/tests/microsimulation/test_validity.py +2 -3
  14. policyengine_uk/tests/microsimulation/update_reform_impacts.py +104 -40
  15. policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement.yaml +8 -8
  16. policyengine_uk/utils/__init__.py +1 -0
  17. policyengine_uk/utils/compare.py +28 -0
  18. policyengine_uk/utils/scenario.py +37 -2
  19. policyengine_uk/utils/solve_private_school_attendance_factor.py +4 -6
  20. policyengine_uk/variables/gov/dwp/additional_state_pension.py +1 -1
  21. policyengine_uk/variables/gov/dwp/basic_state_pension.py +1 -1
  22. policyengine_uk/variables/gov/gov_tax.py +0 -1
  23. policyengine_uk/variables/household/demographic/benunit/benunit_count_adults.py +11 -0
  24. policyengine_uk/variables/input/consumption/property/council_tax.py +0 -35
  25. policyengine_uk/variables/input/rent.py +0 -40
  26. {policyengine_uk-2.45.4.dist-info → policyengine_uk-2.47.3.dist-info}/METADATA +5 -4
  27. {policyengine_uk-2.45.4.dist-info → policyengine_uk-2.47.3.dist-info}/RECORD +29 -23
  28. {policyengine_uk-2.45.4.dist-info → policyengine_uk-2.47.3.dist-info}/WHEEL +0 -0
  29. {policyengine_uk-2.45.4.dist-info → policyengine_uk-2.47.3.dist-info}/licenses/LICENSE +0 -0
@@ -8,6 +8,18 @@ from pathlib import Path
8
8
  from policyengine_uk import Microsimulation
9
9
  import argparse
10
10
  from datetime import datetime
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+ from rich.progress import (
14
+ Progress,
15
+ SpinnerColumn,
16
+ TextColumn,
17
+ BarColumn,
18
+ TaskProgressColumn,
19
+ )
20
+ from rich.panel import Panel
21
+ from rich import print as rprint
22
+ import traceback
11
23
 
12
24
  baseline = Microsimulation()
13
25
 
@@ -40,57 +52,97 @@ def update_impacts(
40
52
  dry_run: If True, show changes without writing to file
41
53
  verbose: If True, show detailed output
42
54
  """
55
+ console = Console()
56
+
43
57
  # Load current configuration
44
58
  with open(config_path, "r") as f:
45
59
  config = yaml.safe_load(f)
46
60
 
47
61
  if verbose:
48
- print(f"Loaded configuration from {config_path}")
49
- print(f"Found {len(config['reforms'])} reforms to update\n")
62
+ console.print(
63
+ Panel.fit(
64
+ f"[bold cyan]Loaded configuration from {config_path}[/bold cyan]\n"
65
+ f"[green]Found {len(config['reforms'])} reforms to update[/green]",
66
+ title="Configuration loaded",
67
+ )
68
+ )
50
69
 
51
70
  # Track changes
52
71
  changes = []
53
72
 
54
- # Update each reform's expected impact
55
- for reform in config["reforms"]:
56
- print(f"Processing reform: {reform['name']}") if verbose else None
57
- old_impact = reform["expected_impact"]
58
- new_impact = round(get_fiscal_impact(reform["parameters"]), 1)
59
-
60
- if (
61
- abs(old_impact - new_impact) > 0.01
62
- ): # Only record meaningful changes
63
- changes.append(
64
- {
65
- "name": reform["name"],
66
- "old": old_impact,
67
- "new": new_impact,
68
- "diff": new_impact - old_impact,
69
- }
70
- )
71
-
72
- reform["expected_impact"] = new_impact
73
+ # Update each reform's expected impact with progress bar
74
+ with Progress(
75
+ SpinnerColumn(),
76
+ TextColumn("[progress.description]{task.description}"),
77
+ BarColumn(),
78
+ TaskProgressColumn(),
79
+ console=console,
80
+ ) as progress:
81
+ task = progress.add_task(
82
+ "[cyan]Processing reforms...", total=len(config["reforms"])
83
+ )
73
84
 
74
- if verbose:
75
- print(f"Reform: {reform['name']}")
76
- print(f" Old impact: {old_impact:.1f} billion")
77
- print(f" New impact: {new_impact:.1f} billion")
78
- if abs(old_impact - new_impact) > 0.01:
79
- print(f" Change: {new_impact - old_impact:+.1f} billion")
80
- print()
85
+ for reform in config["reforms"]:
86
+ progress.update(
87
+ task, description=f"[cyan]Processing: {reform['name'][:40]}..."
88
+ )
89
+ old_impact = reform["expected_impact"]
90
+ new_impact = round(get_fiscal_impact(reform["parameters"]), 1)
91
+
92
+ if (
93
+ abs(old_impact - new_impact) > 0.01
94
+ ): # Only record meaningful changes
95
+ changes.append(
96
+ {
97
+ "name": reform["name"],
98
+ "old": old_impact,
99
+ "new": new_impact,
100
+ "diff": new_impact - old_impact,
101
+ }
102
+ )
103
+
104
+ reform["expected_impact"] = new_impact
105
+ progress.advance(task)
106
+
107
+ # Show detailed output if verbose
108
+ if verbose and changes:
109
+ console.print("\n[bold]Detailed changes:[/bold]")
110
+ for change in changes:
111
+ color = "red" if change["diff"] < 0 else "green"
112
+ console.print(
113
+ f" [yellow]{change['name']}[/yellow]\n"
114
+ f" Old impact: [dim]{change['old']:.1f} billion[/dim]\n"
115
+ f" New impact: [bold]{change['new']:.1f} billion[/bold]\n"
116
+ f" Change: [{color}]{change['diff']:+.1f} billion[/{color}]\n"
117
+ )
81
118
 
82
119
  # Show summary of changes
83
120
  if changes:
84
- print("\nSummary of changes:")
85
- print("-" * 70)
121
+ table = Table(
122
+ title="Summary of changes",
123
+ show_header=True,
124
+ header_style="bold magenta",
125
+ )
126
+ table.add_column("Reform", style="cyan", no_wrap=False)
127
+ table.add_column("Old impact (£bn)", justify="right")
128
+ table.add_column("New impact (£bn)", justify="right")
129
+ table.add_column("Change (£bn)", justify="right")
130
+
86
131
  for change in changes:
87
- print(
88
- f"{change['name']:<50} {change['old']:>6.1f} → {change['new']:>6.1f} ({change['diff']:+.1f})"
132
+ color = "red" if change["diff"] < 0 else "green"
133
+ table.add_row(
134
+ change["name"],
135
+ f"{change['old']:.1f}",
136
+ f"{change['new']:.1f}",
137
+ f"[{color}]{change['diff']:+.1f}[/{color}]",
89
138
  )
90
- print("-" * 70)
91
- print(f"Total changes: {len(changes)}")
139
+
140
+ console.print("\n", table)
141
+ console.print(
142
+ f"\n[bold cyan]Total changes: {len(changes)}[/bold cyan]"
143
+ )
92
144
  else:
93
- print("\nNo significant changes detected.")
145
+ console.print("\n[green]✓ No significant changes detected.[/green]")
94
146
 
95
147
  # Write updated configuration
96
148
  if not dry_run:
@@ -109,13 +161,22 @@ def update_impacts(
109
161
  with open(config_path, "w") as f:
110
162
  yaml.dump(config, f, default_flow_style=False, sort_keys=False)
111
163
 
112
- print(f"\nConfiguration updated successfully!")
113
- print(f"Backup saved to: {backup_path}")
164
+ console.print(
165
+ Panel.fit(
166
+ f"[green]✓ Configuration updated successfully![/green]\n"
167
+ f"[dim]Backup saved to: {backup_path}[/dim]",
168
+ title="Success",
169
+ border_style="green",
170
+ )
171
+ )
114
172
  else:
115
- print("\nDry run - no changes written to file.")
173
+ console.print(
174
+ "\n[yellow]⚠ Dry run - no changes written to file.[/yellow]"
175
+ )
116
176
 
117
177
 
118
178
  def main():
179
+ console = Console()
119
180
  parser = argparse.ArgumentParser(
120
181
  description="Update reform impact expectations with current model values"
121
182
  )
@@ -143,14 +204,17 @@ def main():
143
204
  args = parser.parse_args()
144
205
 
145
206
  if not args.config.exists():
146
- print(f"Error: Configuration file '{args.config}' not found!")
207
+ console.print(
208
+ f"[bold red]Error:[/bold red] Configuration file '{args.config}' not found!"
209
+ )
147
210
  return 1
148
211
 
149
212
  try:
150
213
  update_impacts(args.config, dry_run=args.dry_run, verbose=args.verbose)
151
214
  return 0
152
215
  except Exception as e:
153
- print(f"Error updating impacts: {e}")
216
+ console.print(f"[bold red]Error updating impacts:[/bold red] {e}")
217
+ console.print(traceback.format_exc())
154
218
  return 1
155
219
 
156
220
 
@@ -1,6 +1,6 @@
1
1
  - name: Eligible for 30 hours - All conditions met
2
2
  period: 2025
3
- absolute_error_margin: 1
3
+ absolute_error_margin: 3
4
4
  input:
5
5
  people:
6
6
  child1:
@@ -14,7 +14,7 @@
14
14
 
15
15
  - name: Eligible for 15 hours - All first conditions met
16
16
  period: 2025
17
- absolute_error_margin: 1
17
+ absolute_error_margin: 2
18
18
  input:
19
19
  people:
20
20
  child1:
@@ -42,7 +42,7 @@
42
42
 
43
43
  - name: Eligible for mixed hours - Family with multiple children
44
44
  period: 2025
45
- absolute_error_margin: 1
45
+ absolute_error_margin: 4
46
46
  input:
47
47
  people:
48
48
  child1:
@@ -74,7 +74,7 @@
74
74
 
75
75
  - name: Eligible with one working parent and one disabled parent
76
76
  period: 2025
77
- absolute_error_margin: 1
77
+ absolute_error_margin: 6
78
78
  input:
79
79
  people:
80
80
  child1:
@@ -107,7 +107,7 @@
107
107
 
108
108
  - name: Child using fewer hours than maximum entitlement
109
109
  period: 2025
110
- absolute_error_margin: 1
110
+ absolute_error_margin: 2
111
111
  input:
112
112
  people:
113
113
  child1:
@@ -122,7 +122,7 @@
122
122
 
123
123
  - name: Child using fewer hours than maximum entitlement - multiple children
124
124
  period: 2025
125
- absolute_error_margin: 1
125
+ absolute_error_margin: 2
126
126
  input:
127
127
  people:
128
128
  child1:
@@ -140,7 +140,7 @@
140
140
 
141
141
  - name: Benefit unit maximum hours cap applied
142
142
  period: 2025
143
- absolute_error_margin: 1
143
+ absolute_error_margin: 5
144
144
  input:
145
145
  people:
146
146
  child1:
@@ -177,7 +177,7 @@
177
177
 
178
178
  - name: Benefit unit without maximum hours cap applied with 3 years old child
179
179
  period: 2025
180
- absolute_error_margin: 1
180
+ absolute_error_margin: 2
181
181
  input:
182
182
  people:
183
183
  child1:
@@ -0,0 +1 @@
1
+ from .compare import compare_simulations
@@ -0,0 +1,28 @@
1
+ import pandas as pd
2
+ from typing import TYPE_CHECKING
3
+
4
+ if TYPE_CHECKING:
5
+ from policyengine_uk.simulation import Microsimulation
6
+
7
+
8
+ def compare_simulations(
9
+ simulations: list["Microsimulation"],
10
+ names: list[str],
11
+ year: int,
12
+ variables: list[str],
13
+ ):
14
+ dfs = [
15
+ sim.calculate_dataframe(variables, year).rename(
16
+ columns=lambda x: f"{x}_{name}"
17
+ )
18
+ for sim, name in zip(simulations, names)
19
+ ]
20
+
21
+ df = pd.concat(dfs, axis=1).reset_index().rename(columns={"index": "id"})
22
+
23
+ # Sort columns by variable
24
+ columns = []
25
+ for var in variables:
26
+ columns.extend([col for col in df.columns if col.startswith(var)])
27
+
28
+ return df[columns]
@@ -2,6 +2,7 @@ from pydantic import BaseModel
2
2
  from typing import Optional, Callable, Dict, Type, Union
3
3
  from policyengine_core.simulations import Simulation
4
4
  from policyengine_core.reforms import Reform
5
+ from policyengine_core.periods import period, instant
5
6
 
6
7
 
7
8
  class Scenario(BaseModel):
@@ -111,8 +112,42 @@ class Scenario(BaseModel):
111
112
 
112
113
  elif isinstance(reform, dict):
113
114
  # Dictionary of parameter changes
114
- return cls(
115
- parameter_changes=reform,
115
+ # Make sure to capture YYYY-MM-DD.YYYY-MM-DD.
116
+
117
+ def modifier(sim: Simulation):
118
+ for parameter in reform:
119
+ if isinstance(reform[parameter], dict):
120
+ for period_str, value in reform[parameter].items():
121
+ if "." in period_str:
122
+ start = instant(period_str.split(".")[0])
123
+ stop = instant(period_str.split(".")[1])
124
+ period_ = None
125
+ else:
126
+ period_ = period(period_str)
127
+ sim.tax_benefit_system.parameters.get_child(
128
+ parameter
129
+ ).update(
130
+ start=start,
131
+ stop=stop,
132
+ period=period_,
133
+ value=value,
134
+ )
135
+ else:
136
+ start = instant("2023-01-01")
137
+ stop = None
138
+ period_ = None
139
+
140
+ sim.tax_benefit_system.parameters.get_child(
141
+ parameter
142
+ ).update(
143
+ start=start,
144
+ stop=stop,
145
+ period=period_,
146
+ value=reform[parameter],
147
+ )
148
+
149
+ return Scenario(
150
+ simulation_modifier=modifier,
116
151
  )
117
152
 
118
153
  elif isinstance(reform, tuple):
@@ -1,4 +1,4 @@
1
- from policyengine import Simulation
1
+ from policyengine_uk import Microsimulation
2
2
  from policyengine_core.reforms import Reform
3
3
  from tqdm import tqdm
4
4
 
@@ -19,11 +19,9 @@ for factor in tqdm([round(x * 0.01, 2) for x in range(70, 91)]):
19
19
  }
20
20
 
21
21
  # Run the reformed microsimulation
22
- reformed = Simulation(
23
- scope="macro",
24
- baseline=reform,
25
- country="uk",
26
- ).baseline_simulation
22
+ reformed = Microsimulation(
23
+ reform=reform,
24
+ )
27
25
  reformed.baseline_simulation.get_holder(
28
26
  "attends_private_school"
29
27
  ).delete_arrays()
@@ -13,7 +13,7 @@ class additional_state_pension(Variable):
13
13
  if simulation.dataset is None:
14
14
  return 0
15
15
 
16
- data_year = simulation.dataset.time_period
16
+ data_year = 2023
17
17
  reported = person("state_pension_reported", data_year) / WEEKS_IN_YEAR
18
18
  type = person("state_pension_type", data_year)
19
19
  maximum_basic_sp = parameters(
@@ -13,7 +13,7 @@ class basic_state_pension(Variable):
13
13
  if simulation.dataset is None:
14
14
  return 0
15
15
 
16
- data_year = simulation.dataset.time_period
16
+ data_year = 2023
17
17
  reported = person("state_pension_reported", data_year) / WEEKS_IN_YEAR
18
18
  type = person("state_pension_type", period)
19
19
  maximum_basic_sp = parameters(
@@ -26,7 +26,6 @@ class gov_tax(Variable):
26
26
  "national_insurance",
27
27
  "LVT",
28
28
  "carbon_tax",
29
- "vat_change",
30
29
  "capital_gains_tax",
31
30
  "private_school_vat",
32
31
  "corporate_incident_tax_revenue_change",
@@ -0,0 +1,11 @@
1
+ from policyengine_uk.model_api import *
2
+
3
+
4
+ class benunit_count_adults(Variable):
5
+ value_type = int
6
+ entity = BenUnit
7
+ label = "number of adults in the benefit unit"
8
+ definition_period = YEAR
9
+
10
+ def formula(benunit, period, parameters):
11
+ return add(benunit, period, ["is_adult"])
@@ -9,38 +9,3 @@ class council_tax(Variable):
9
9
  definition_period = YEAR
10
10
  unit = GBP
11
11
  quantity_type = FLOW
12
-
13
- def formula(household, period, parameters):
14
- if period.start.year < 2023:
15
- # We don't have growth rates for council tax by nation before this.
16
- return 0
17
-
18
- if household.simulation.dataset is None:
19
- return 0
20
-
21
- data_year = household.simulation.dataset.time_period
22
-
23
- original_ct = household("council_tax", data_year)
24
-
25
- ct = parameters.gov.economic_assumptions.indices.obr.council_tax
26
-
27
- def get_growth(country):
28
- param = getattr(ct, country)
29
- return param(period.start.year) / param(data_year)
30
-
31
- country = household("country", period).decode_to_str()
32
-
33
- return select(
34
- [
35
- country == "ENGLAND",
36
- country == "WALES",
37
- country == "SCOTLAND",
38
- True,
39
- ],
40
- [
41
- original_ct * get_growth("england"),
42
- original_ct * get_growth("wales"),
43
- original_ct * get_growth("scotland"),
44
- original_ct,
45
- ],
46
- )
@@ -11,43 +11,3 @@ class rent(Variable):
11
11
  value_type = float
12
12
  unit = GBP
13
13
  quantity_type = FLOW
14
-
15
- def formula(household, period, parameters):
16
- if period.start.year < 2023:
17
- # We don't have growth rates for rent before this.
18
- return 0
19
-
20
- if household.simulation.dataset is None:
21
- return 0
22
-
23
- data_year = household.simulation.dataset.time_period
24
- original_rent = household("rent", data_year)
25
- tenure_type = household("tenure_type", period).decode_to_str()
26
-
27
- is_social_rent = (tenure_type == "RENT_FROM_COUNCIL") | (
28
- tenure_type == "RENT_FROM_HA"
29
- )
30
-
31
- is_private_rent = tenure_type == "RENT_PRIVATELY"
32
-
33
- obr = parameters.gov.economic_assumptions.indices.obr
34
-
35
- private_rent_uprating = obr.lagged_average_earnings(
36
- period
37
- ) / obr.lagged_average_earnings(data_year)
38
- social_rent_uprating = obr.social_rent(period) / obr.social_rent(
39
- data_year
40
- )
41
-
42
- return select(
43
- [
44
- is_social_rent,
45
- is_private_rent,
46
- True,
47
- ],
48
- [
49
- original_rent * social_rent_uprating,
50
- original_rent * private_rent_uprating,
51
- original_rent,
52
- ],
53
- )
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: policyengine-uk
3
- Version: 2.45.4
4
- Summary: PolicyEngine tax and benefit system for the UK
3
+ Version: 2.47.3
4
+ Summary: PolicyEngine tax and benefit system for the UK.
5
5
  Project-URL: Homepage, https://github.com/PolicyEngine/policyengine-uk
6
6
  Project-URL: Repository, https://github.com/PolicyEngine/policyengine-uk
7
7
  Project-URL: Issues, https://github.com/PolicyEngine/policyengine-uk/issues
@@ -20,7 +20,7 @@ Classifier: Programming Language :: Python :: 3.11
20
20
  Classifier: Programming Language :: Python :: 3.12
21
21
  Classifier: Programming Language :: Python :: 3.13
22
22
  Classifier: Topic :: Scientific/Engineering :: Information Analysis
23
- Requires-Python: >=3.10
23
+ Requires-Python: >=3.13
24
24
  Requires-Dist: microdf-python>=1.0.2
25
25
  Requires-Dist: policyengine-core>=3.19.3
26
26
  Requires-Dist: pydantic>=2.11.7
@@ -28,10 +28,11 @@ Provides-Extra: dev
28
28
  Requires-Dist: black; extra == 'dev'
29
29
  Requires-Dist: coverage; extra == 'dev'
30
30
  Requires-Dist: furo<2023; extra == 'dev'
31
- Requires-Dist: jupyter-book; extra == 'dev'
31
+ Requires-Dist: jupyter-book>=2.0.0a0; extra == 'dev'
32
32
  Requires-Dist: linecheck; extra == 'dev'
33
33
  Requires-Dist: pytest; extra == 'dev'
34
34
  Requires-Dist: pytest-cov; extra == 'dev'
35
+ Requires-Dist: rich; extra == 'dev'
35
36
  Requires-Dist: setuptools; extra == 'dev'
36
37
  Requires-Dist: snowballstemmer<3,>=2; extra == 'dev'
37
38
  Requires-Dist: sphinx-argparse<1,>=0.3.2; extra == 'dev'