Repository: PolicyEngine/policyengine (language: python)

Issue:
Add `init` files so the Python package exposes chart functions



Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index f9e37db2..61dcdb08 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,5 @@
 openfisca_uk/
 openfisca_uk_data/
 policyengine-client/src/countries/debug.jsx
+docs/_build/
+policyengine/web_server/logs.yaml
diff --git a/Makefile b/Makefile
index 7b9436a8..bf639efa 100644
--- a/Makefile
+++ b/Makefile
@@ -17,7 +17,7 @@ publish-server: policyengine
 publish-client:
 	cd policyengine-client; pnpm publish
 debug-server:
-	POLICYENGINE_DEBUG=1 FLASK_APP=policyengine/server.py FLASK_DEBUG=1 flask run
+	POLICYENGINE_DEBUG=1 FLASK_APP=policyengine.server:app FLASK_DEBUG=1 flask run
 debug-client:
 	cd policyengine-client; pnpm start
 format:
@@ -26,8 +26,7 @@ format:
 	black policyengine -l 79
 	black . -l 79
 test:
-	pytest policyengine/tests -vv
-	python policyengine/monitoring/api_monitoring.py
+	pytest policyengine/tests -vv --disable-warnings
 deploy: build-client
 	cat $(GOOGLE_APPLICATION_CREDENTIALS) > .gac.json
 	gcloud config set app/cloud_build_timeout 3000
@@ -46,6 +45,8 @@ server: install-server test-server
 changelog:
 	build-changelog changelog.yaml --output changelog.yaml --update-last-date --start-from 1.4.1 --append-file changelog_entry.yaml
 	build-changelog changelog.yaml --org PolicyEngine --repo policyengine --output CHANGELOG.md --template .github/changelog_template.md
-	bump-version changelog.yaml policyengine-client/package.json policyengine/__init__.py
+	bump-version changelog.yaml policyengine-client/package.json setup.py policyengine/policyengine.py
 	rm changelog_entry.yaml || true
 	touch changelog_entry.yaml
+documentation:
+	jb build docs
diff --git a/changelog_entry.yaml b/changelog_entry.yaml
index e69de29b..0c552b0a 100644
--- a/changelog_entry.yaml
+++ b/changelog_entry.yaml
@@ -0,0 +1,4 @@
+- bump: minor
+  changes:
+    changed:
+    - Refactored server package, adding documentation, stability and organisation.
diff --git a/debug_server.py b/debug_server.py
new file mode 100644
index 00000000..55c6311f
--- /dev/null
+++ b/debug_server.py
@@ -0,0 +1,4 @@
+from policyengine import PolicyEngine
+
+app = PolicyEngine(debug=True).app
+app.run(debug=True)
diff --git a/policyengine-client/src/countries/country.jsx b/policyengine-client/src/countries/country.jsx
index ee1e994d..083124e4 100644
--- a/policyengine-client/src/countries/country.jsx
+++ b/policyengine-client/src/countries/country.jsx
@@ -114,7 +114,7 @@ export default class Country {
         });
     }
 
-    useLocalServer = false;
+    useLocalServer = true;
     usePolicyEngineOrgServer = false;
 
     waitingOnPopulationImpact = false;
diff --git a/policyengine-client/src/countries/uk/uk.jsx b/policyengine-client/src/countries/uk/uk.jsx
index fa4fcbaa..112e2372 100644
--- a/policyengine-client/src/countries/uk/uk.jsx
+++ b/policyengine-client/src/countries/uk/uk.jsx
@@ -26,7 +26,6 @@ const childNamer = {
 function validatePolicy(policy, defaultPolicy) {
   if (defaultPolicy) {
     for (let parameter in policy) {
-      policy[parameter].defaultValue = defaultPolicy[parameter].value;
       if (policy[parameter].baselineValue === undefined) {
         policy[parameter].baselineValue = defaultPolicy[parameter].value;
       }
@@ -423,6 +422,7 @@ export class UK extends Country {
       Location: ["region", "BRMA", "council_tax_band"],
       Relationships: ["is_married"],
       Benefits: ["claims_legacy_benefits"],
+      Taxes: ["would_evade_tv_licence_fee"],
       Consumption: [
         "council_tax",
         "rent",
diff --git a/policyengine-client/src/countries/us/us.jsx b/policyengine-client/src/countries/us/us.jsx
index 9eb00da3..0d603b8f 100644
--- a/policyengine-client/src/countries/us/us.jsx
+++ b/policyengine-client/src/countries/us/us.jsx
@@ -33,7 +33,6 @@ const childNamer = {
 function validatePolicy(policy, defaultPolicy) {
     if (defaultPolicy) {
         for (let parameter in policy) {
-            policy[parameter].defaultValue = defaultPolicy[parameter].value;
             if (policy[parameter].baselineValue === undefined) {
                 policy[parameter].baselineValue = defaultPolicy[parameter].value;
             }
diff --git a/policyengine-client/src/landing.jsx b/policyengine-client/src/landing.jsx
index 9f0630f2..9f0a645d 100644
--- a/policyengine-client/src/landing.jsx
+++ b/policyengine-client/src/landing.jsx
@@ -152,8 +152,8 @@ function LandingPageContent() {
             href={inUs ? usLink : ukLink}
             block
           >
-            <div style={{ margin: 20 }}>
-              <h1 style={{color: "white"}}>Enter PolicyEngine {inUs ? "US" : "UK"}</h1>
+            <div style={{ margin: 20, marginBottom: 50 }}>
+              <h2 style={{color: "white"}}>Enter PolicyEngine {inUs ? "US" : "UK"}</h2>
             </div>
           </Button>
           <Button block size="large" href={inUs ? ukLink : usLink}>
diff --git a/policyengine-client/src/policyengine/header/title.jsx b/policyengine-client/src/policyengine/header/title.jsx
index a1ff2ab4..2b294170 100644
--- a/policyengine-client/src/policyengine/header/title.jsx
+++ b/policyengine-client/src/policyengine/header/title.jsx
@@ -22,7 +22,7 @@ export default function Title(props) {
 		</a>
 	);
 	return (
-		<div style={{ minWidth: 300 }}>
+		<div style={{ minWidth: 200 }}>
 			<div className="d-none d-lg-flex align-items-center ">
 				<PageHeader
 					title={title}
diff --git a/policyengine-client/src/policyengine/pages/household/accountingTable.jsx b/policyengine-client/src/policyengine/pages/household/accountingTable.jsx
index 8644a86c..db576568 100644
--- a/policyengine-client/src/policyengine/pages/household/accountingTable.jsx
+++ b/policyengine-client/src/policyengine/pages/household/accountingTable.jsx
@@ -41,7 +41,7 @@ export default class AccountingTable extends React.Component {
                     'Accept': 'application/json',
                     'Content-Type': 'application/json'
                 },
-                body: JSON.stringify(Object.assign({ "household": this.context.situation}, baselineSubmission))
+                body: JSON.stringify({ "household": this.context.situation, ...baselineSubmission })
             }).then((res) => {
                 if (res.ok) {
                     return res.json();
diff --git a/policyengine-client/src/policyengine/pages/household/earningsCharts.jsx b/policyengine-client/src/policyengine/pages/household/earningsCharts.jsx
index d94f0410..1bcb6a09 100644
--- a/policyengine-client/src/policyengine/pages/household/earningsCharts.jsx
+++ b/policyengine-client/src/policyengine/pages/household/earningsCharts.jsx
@@ -21,29 +21,40 @@ export default class AccountingTable extends React.Component {
     updateCharts() {
         this.context.setState({ waitingOnEarningsCharts: true, }, () => {
             const submission = this.context.getPolicyJSONPayload();
+            const reformExists = Object.keys(submission).length > 1;
+            const eta = this.context["endpoint-runtimes"][reformExists ? "household_variation_reform_and_baseline" : "household_variation_baseline_only"];
             let url = new URL(this.context.apiURL + "/household-variation");
             url.search = new URLSearchParams(submission).toString();
-            fetch(url, {
+            const requestOptions = {
                 method: 'POST',
                 headers: {
                     'Accept': 'application/json',
                     'Content-Type': 'application/json'
                 },
                 body: JSON.stringify({ "household": this.context.situation })
-            }).then((res) => {
+            };
+            fetch(url, requestOptions).then((res) => {
                 if (res.ok) {
-                    return res.json();
+                    let checker = setInterval(() => {
+                        fetch(url, requestOptions).then(res => res.json()).then((data) => {
+                            if(data.status === "completed") {
+                                clearInterval(checker);
+                                if(data.error) {
+                                    throw new Error(data.error);
+                                }
+                                this.context.setState({ computedSituationVariationCharts: data, situationVariationImpactIsOutdated: false, waitingOnEarningsCharts: false }, () => {
+                                    this.setState({ error: false });
+                                });
+                            }
+                        }).catch(e => {
+                            this.context.setState({ waitingOnEarningsCharts: false });
+                            this.setState({ error: true, });
+                        });
+                    }, 1000 * eta * 0.5);
                 } else {
                     throw res;
                 }
-            }).then((data) => {
-                this.context.setState({ computedSituationVariationCharts: data, situationVariationImpactIsOutdated: false, waitingOnEarningsCharts: false }, () => {
-                    this.setState({ error: false });
-                });
-            }).catch(e => {
-                this.context.setState({ waitingOnEarningsCharts: false });
-                this.setState({ error: true, });
-            });
+            })
         });
     }
 
@@ -57,13 +68,14 @@ export default class AccountingTable extends React.Component {
         // Update situations where necessary (re-using where not)
         // If the policy changes, we need to update only the reform 
         // situation. If the situation changes, we need to update both.
+        const reformExists = Object.keys(this.context.getPolicyJSONPayload()).length > 0;
+		const eta = this.context["endpoint-runtimes"][reformExists ? "household_variation_reform_and_baseline" : "household_variation_baseline_only"];
         if (!this.context.computedSituationVariationCharts || this.context.waitingOnEarningsCharts) {
-            const message = "Calculating tax-benefit responses to your income...";
+            const message = <><p>{`Calculating tax-benefit responses to your income...`}</p><p>{`(this usually takes around ${eta} seconds)`}</p></>;
             return <Centered><Spinner rightSpacing={10} />{message}</Centered>
         } else if (this.state.error) {
             return <Centered>Something went wrong.</Centered>
         }
-        const reformExists = Object.keys(this.context.getPolicyJSONPayload()).length > 0;
         if (reformExists) {
             return (
                 <>
diff --git a/policyengine-client/src/policyengine/pages/policy/parameter/parameter.jsx b/policyengine-client/src/policyengine/pages/policy/parameter/parameter.jsx
index 3cc0baa6..e882c9eb 100644
--- a/policyengine-client/src/policyengine/pages/policy/parameter/parameter.jsx
+++ b/policyengine-client/src/policyengine/pages/policy/parameter/parameter.jsx
@@ -85,7 +85,7 @@ function BreakdownParameterControl(props) {
 	let dropDowns = [];
 	let possibleValues;
 	for(let i = 0; i < numBreakdowns; i++) {
-		possibleValues = childParameters.map(p => p.breakdown_parts[i]);
+		possibleValues = childParameters.map(p => p.breakdownParts[i]);
 		let keyToValueMap = {};
 		possibleValues.forEach(value => {
 			keyToValueMap[value[0]] = value[1];
diff --git a/policyengine-client/src/policyengine/pages/populationImpact/ageChart.jsx b/policyengine-client/src/policyengine/pages/populationImpact/ageChart.jsx
index 08340d9d..66c9e0be 100644
--- a/policyengine-client/src/policyengine/pages/populationImpact/ageChart.jsx
+++ b/policyengine-client/src/policyengine/pages/populationImpact/ageChart.jsx
@@ -23,26 +23,35 @@ export default class AgeChart extends React.Component {
 
     fetchResults() {
         let url = new URL(`${this.context.apiURL}/age-chart`);
+        const eta = this.context["endpoint-runtimes"]["age_chart"];
+        const submission = this.context.getPolicyJSONPayload();
+        const requestOptions = {
+            method: "POST",
+            headers: {
+                "Content-Type": "application/json"
+            },
+            body: JSON.stringify(submission)
+        };
 		this.context.setState({ waitingOnAgeChart: true }, () => {
-			fetch(url, {
-                method: "POST",
-                headers: {
-                    "Content-Type": "application/json"
-                },
-                body: JSON.stringify(submission)
-            })
+			fetch(url, requestOptions)
 				.then((res) => {
-					if (res.ok) {
-						return res.json();
-					} else {
-						throw res;
-					}
-				}).then((data) => {
-					this.setState({ error: false });
-                    this.context.setState({ageChartResult: data, waitingOnAgeChart: false, ageChartIsOutdated: false});
-				}).catch(e => {
-                    this.context.setState({ waitingOnAgeChart: false});
-					this.setState({ error: true });
+                    if(res.ok) {
+                        let checker = setInterval(() => {
+                            fetch(url, requestOptions).then(res => res.json()).then(data => {
+                                if(data.status === "completed") {
+                                    clearInterval(checker);
+                                    if(data.error) {
+                                        throw new Error(data.error);
+                                    }
+                                    this.setState({ error: false });
+                                    this.context.setState({ageChartResult: data, waitingOnAgeChart: false, ageChartIsOutdated: false});
+                                }
+                            }).catch(e => {
+                                this.context.setState({ waitingOnAgeChart: false});
+                                this.setState({ error: true });
+                            });
+                        }, 1000 * eta * 0.5);
+                    }
 				});
 		});
     }
diff --git a/policyengine-client/src/policyengine/pages/populationImpact/breakdown.jsx b/policyengine-client/src/policyengine/pages/populationImpact/breakdown.jsx
index 1631b4fc..f88d0be4 100644
--- a/policyengine-client/src/policyengine/pages/populationImpact/breakdown.jsx
+++ b/policyengine-client/src/policyengine/pages/populationImpact/breakdown.jsx
@@ -18,27 +18,35 @@ export class BreakdownTable extends React.Component {
 
     fetchResults() {
         let url = new URL(`${this.context.apiURL}/population-breakdown`);
+        const requestOptions = {
+            method: "POST",
+            headers: {
+                "Content-Type": "application/json"
+            },
+            body: JSON.stringify(this.context.getPolicyJSONPayload())
+        };
 		this.context.setState({ waitingOnPopulationBreakdown: true }, () => {
-			fetch(url, {
-                method: "POST",
-                headers: {
-                    "Content-Type": "application/json"
-                },
-                body: JSON.stringify(submission)
-            })
-				.then((res) => {
-					if (res.ok) {
-						return res.json();
-					} else {
-						throw res;
-					}
-				}).then((data) => {
-					this.setState({ error: false });
-                    this.context.setState({populationImpactBreakdownResults: data, waitingOnPopulationBreakdown: false});
-				}).catch(e => {
-                    this.context.setState({ waitingOnPopulationBreakdown: false});
-					this.setState({ error: true });
-				});
+			fetch(url, requestOptions).then((res) => {
+                if (res.ok) {
+                    let checker = setInterval(() => {
+                        fetch(url, requestOptions).then(res => res.json()).then(data => {
+                            if(data.status === "completed") {
+                                clearInterval(checker);
+                                if(data.error) {
+                                    throw new Error(data.error);
+                                }
+                                this.setState({ error: false });
+                                this.context.setState({populationImpactBreakdownResults: data, waitingOnPopulationBreakdown: false});
+                            }
+                        }).catch(e => {
+                            this.context.setState({ waitingOnPopulationBreakdown: false});
+                            this.setState({ error: true });
+                        });
+                    }, 5000);
+                } else {
+                    throw res;
+                }
+            });
 		});
     }
 
diff --git a/policyengine-client/src/policyengine/pages/populationImpact/populationImpact.jsx b/policyengine-client/src/policyengine/pages/populationImpact/populationImpact.jsx
index 04a15402..c728617f 100644
--- a/policyengine-client/src/policyengine/pages/populationImpact/populationImpact.jsx
+++ b/policyengine-client/src/policyengine/pages/populationImpact/populationImpact.jsx
@@ -22,9 +22,9 @@ function USPopulationResultsCaveats() {
 
 function Loading(props) {
 	const country = useContext(CountryContext);
-	const message = `Simulating your results on the ${country.properName} population (this usually takes about 20 seconds)`;
 	return (
-		<Centered><Empty description={message}>
+		<Centered><Empty description={props.message}>
+			{props.children}
 			{!props.noSpin && <Spinner />}
 		</Empty>
 		</Centered>
@@ -37,8 +37,8 @@ export function PopulationResultsPane(props) {
 
 	// process take-away figures
 	const results = country.populationImpactResults;
-	const isSurplus = results.net_cost[0] === "-";
-	const cost = isSurplus ? results.net_cost.slice(1) : results.net_cost;
+	const isSurplus = results.budgetary_impact_str[0] === "-";
+	const cost = isSurplus ? results.budgetary_impact_str.slice(1) : results.budgetary_impact_str;
 	const costColor = isSurplus ? "green" : "darkred";
 	const isPovRise = +results.poverty_change > 0;
 	const isPovFall = +results.poverty_change < 0;
@@ -164,37 +164,80 @@ export default class PopulationImpact extends React.Component {
 	simulate() {
 		const submission = this.context.getPolicyJSONPayload();
 		let url = new URL(`${this.context.apiURL}/population-reform`);
+		const requestOptions = {
+			method: "POST",
+			headers: {
+				"Content-Type": "application/json",
+				"Accept": "application/json"
+			},
+			body: JSON.stringify(submission)
+		};
+		const editsBaseline = Object.keys(submission).some(key => key.includes("baseline_"));
+		const eta = this.context["endpoint-runtimes"][editsBaseline ? "population_impact_reform_and_baseline" : "population_impact_reform_only"];
 		this.context.setState({ waitingOnPopulationImpact: true }, () => {
-			fetch(
-				url, {
-					method: "POST",
-					headers: {
-						"Content-Type": "application/json",
-						"Accept": "application/json"
-					},
-					body: JSON.stringify(submission)
-				}
-				)
+			fetch(url, requestOptions)
 				.then((res) => {
 					if (res.ok) {
-						return res.json();
+						// Got a receipt of submission.
+						let checker = setInterval(() => {
+							fetch(url, requestOptions).then(res => res.json()).then(data => {
+								if(data.status === "completed") {
+									clearInterval(checker);
+									if(data.error) {
+										throw new Error(data.error);
+									}
+									this.context.setState({ populationImpactResults: data, populationImpactIsOutdated: false }, () => {
+										this.setState({ error: false });
+										this.context.setState({ waitingOnPopulationImpact: false });
+									});
+								}
+							}).catch(e => {
+								this.setState({ error: true });
+								this.context.setState({ waitingOnPopulationImpact: false });
+							});
+						}, 1000 * eta * 0.5);
 					} else {
 						throw res;
 					}
-				}).then((data) => {
-					this.context.setState({ populationImpactResults: data, populationImpactIsOutdated: false }, () => {
-						this.setState({ error: false });
-						this.context.setState({ waitingOnPopulationImpact: false });
-					});
-				}).catch(e => {
-					this.setState({ error: true });
-					this.context.setState({ waitingOnPopulationImpact: false });
 				});
 		});
 	}
 
 	render() {
-		return <>
+		const editsBaseline = Object.keys(this.context.getPolicyJSONPayload()).some(key => key.includes("baseline_"));
+		let eta;
+		try {
+			eta = this.context["endpoint-runtimes"][editsBaseline ? "population_impact_reform_and_baseline" : "population_impact_reform_only"];
+		} catch {
+			eta = 10;
+		}
+		const overview = (
+		  <OverviewHolder>
+			<PolicyOverview page="policy" />
+			<SharePolicyLinks page="policy" />
+			<div className="d-block align-middle">
+			  <div className="justify-content-center">
+				{this.context.showPopulationImpact && (
+				  <NavigationButton
+					primary
+					target="population-impact"
+					text={`Compute population impact`}
+				  />
+				)}
+			  </div>
+			  <div className="justify-content-center">
+				{this.context.showHousehold && (
+				  <NavigationButton
+					target="household"
+					text="Compute household impact"
+					primary={!this.context.showPopulationImpact}
+				  />
+				)}
+			  </div>
+			</div>
+		  </OverviewHolder>
+		);
+		const desktopView = (
 			<Row>
 				<Col xl={1} />
 				<Col xl={8} style={{
@@ -205,39 +248,67 @@ export default class PopulationImpact extends React.Component {
 				}}>
 					{
 						(this.context.waitingOnPopulationImpact || (!this.state.error & (this.context.populationImpactResults === null))) ?
-							<Loading message={`Simulating your results on the ${this.context.properName} population (this usually takes about 20 seconds)`} /> :
+							<Loading message={`Simulating your results on the ${this.context.properName} population (this usually takes about ${Math.round(eta)} seconds)`} /> :
 							this.state.error ?
 								<Loading noSpin message="Something went wrong (try navigating back and returning to this page)" /> :
 								<PopulationResultsPane />
 					}
 				</Col>
-				<Col xl={3}>
-					<OverviewHolder>
-						<Affix offsetTop={55}>
-							<PolicyOverview page="population-impact" />
-						</Affix>
-						<Affix offsetTop={450}>
-							<SharePolicyLinks page="population-impact" />
-							<div className="d-block align-middle">
-								<div className="justify-content-center">
-									{this.context.showPopulationImpact &&
-										<NavigationButton
-											target="policy"
-											text={<><ArrowLeftOutlined /> Edit your policy</>}
-										/>}
-								</div>
-								<div className="justify-content-center">
-									{this.context.showHousehold && <NavigationButton
-										target="household"
-										text="Compute household impact"
-										primary={!this.context.showPopulationImpact}
-									/>}
-								</div>
-							</div>
-						</Affix>
-					</OverviewHolder>
-				</Col>
+			  	<Col xl={3}>{overview}</Col>
 			</Row>
-		</>;
+		  );
+		  const mobileView = (
+			<div style={{ paddingLeft: 15, paddingRight: 15 }}>
+			<div style={{ position: "fixed", top: 120, height: "60vh", overflowY: "scroll"}}>
+				<Row>
+					<Col>
+						{
+							(this.context.waitingOnPopulationImpact || (!this.state.error & (this.context.populationImpactResults === null))) ?
+								<Loading message={`Simulating your results on the ${this.context.properName} population (this usually takes about ${Math.round(eta)} seconds)`} /> :
+								this.state.error ?
+									<Loading noSpin message="Something went wrong (try navigating back and returning to this page)" /> :
+									<PopulationResultsPane />
+						}
+					</Col>
+				</Row>
+			</div>
+			  <div
+				style={{
+				  position: "fixed",
+				  top: "calc(60vh + 120px)",
+				  left: 0,
+				  width: "100%",
+				  padding: 10,
+				}}
+			  >
+				<Row>
+				  <Col>
+				  	<SharePolicyLinks page="population-impact" />
+					<div className="d-block align-middle">
+						<div className="justify-content-center">
+							<NavigationButton
+								target="policy"
+								text={<><ArrowLeftOutlined /> Edit your policy</>}
+							/>
+						</div>
+						<div className="justify-content-center">
+							<NavigationButton
+								target="household"
+								text="Compute household impact"
+								primary={!this.context.showPopulationImpact}
+							/>
+						</div>
+					</div>
+					</Col>
+				</Row>
+			  </div>
+			</div>
+		  );
+		  return (
+			<>
+			  <div className="d-none d-lg-block">{desktopView}</div>
+			  <div className="d-block d-lg-none">{mobileView}</div>
+			</>
+		  );
 	}
 }
diff --git a/policyengine-client/src/policyengine/policyengine.jsx b/policyengine-client/src/policyengine/policyengine.jsx
index 2415a0b9..38422974 100644
--- a/policyengine-client/src/policyengine/policyengine.jsx
+++ b/policyengine-client/src/policyengine/policyengine.jsx
@@ -72,9 +72,13 @@ export default class PolicyEngine extends React.Component {
     const situation = this.state.country.validateSituation(
       this.state.country.situation
     ).situation;
+    let parameters = JSON.parse(JSON.stringify(policy));
+    for(let parameter in parameters) {
+      parameters[parameter].value = parameters[parameter].defaultValue;
+    }
     this.setCountryState({
       situation: situation,
-      parameters: policy,
+      parameters: parameters,
       policy: JSON.parse(JSON.stringify(policy)),
       fullyLoaded: true,
     });
@@ -112,7 +116,7 @@ export default class PolicyEngine extends React.Component {
           this.setCountryState({ [name]: data }, checkAllFetchesComplete);
         });
     };
-    ["parameters", "variables", "entities"].forEach(fetchEndpoint);
+    ["parameters", "variables", "entities", "endpoint-runtimes"].forEach(fetchEndpoint);
   }
 
   render() {
@@ -127,6 +131,7 @@ export default class PolicyEngine extends React.Component {
       );
     };
 
+
     // Once fully loaded, direct onto individual pages
     if (!this.state.country.fullyLoaded) {
       return (
diff --git a/policyengine-client/src/policyengine/tools/url.jsx b/policyengine-client/src/policyengine/tools/url.jsx
index e245f776..6beaa80e 100644
--- a/policyengine-client/src/policyengine/tools/url.jsx
+++ b/policyengine-client/src/policyengine/tools/url.jsx
@@ -8,6 +8,7 @@ export function policyToURL(targetPage, policy) {
 		const targetKey = editingReform ? "value" : "baselineValue";
 		const comparisonKey = editingReform ? "baselineValue" : "defaultValue";
 		for (const key in policy) {
+
 			if (policy[key][targetKey] !== policy[key][comparisonKey]) {
 				let value;
 				if(policy[key].unit === "/1") {
@@ -44,6 +45,12 @@ export function urlToPolicy(defaultPolicy, policyRenames) {
 		} else {
 			return 0;
 		}});
+	for (const parameter in plan) {
+		plan[parameter].defaultValue = defaultPolicy[parameter].value;
+		if (plan[parameter].baselineValue === undefined) {
+			plan[parameter].baselineValue = defaultPolicy[parameter].value;
+		}
+	}
 	if(policyRenames) {
 		for (const key in policyRenames) {
 			if (searchParams.has(key)) {
diff --git a/policyengine/__init__.py b/policyengine/__init__.py
index 002dfc08..d11647e6 100644
--- a/policyengine/__init__.py
+++ b/policyengine/__init__.py
@@ -1,1 +1,1 @@
-VERSION = "1.83.0"
+from policyengine.policyengine import PolicyEngine
diff --git a/policyengine/countries/__init__.py b/policyengine/countries/__init__.py
deleted file mode 100644
index 50024df1..00000000
--- a/policyengine/countries/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from policyengine.countries.country import PolicyEngineCountry
-from policyengine.countries.uk import UK
-from policyengine.countries.us import US
diff --git a/policyengine/countries/country.py b/policyengine/countries/country.py
deleted file mode 100644
index 9d160c2d..00000000
--- a/policyengine/countries/country.py
+++ /dev/null
@@ -1,572 +0,0 @@
-from pathlib import Path
-from typing import Tuple, Type
-from openfisca_core.indexed_enums.enum import Enum
-from openfisca_core.model_api import Reform
-import dpath.util
-from openfisca_core.taxbenefitsystems.tax_benefit_system import (
-    TaxBenefitSystem,
-)
-from openfisca_core.simulation_builder import SimulationBuilder
-from policyengine.impact.population.breakdown import (
-    get_breakdown_and_chart_per_provision,
-)
-from policyengine.impact.population.charts import age_chart
-from policyengine.utils.computation_trees import get_computation_trees_json
-from policyengine.utils.general import (
-    PolicyEngineResultsConfig,
-    exclude_from_cache,
-)
-from openfisca_tools import Microsimulation, IndividualSim
-from policyengine.countries.entities import build_entities
-from policyengine.impact.household.charts import (
-    budget_chart,
-    household_waterfall_chart,
-    mtr_chart,
-)
-from policyengine.impact.household.metrics import headline_figures
-from policyengine.impact.population.metrics import headline_metrics
-from policyengine.impact.population.charts import (
-    decile_chart,
-    inequality_chart,
-    poverty_chart,
-    population_waterfall_chart,
-    intra_decile_chart,
-)
-from policyengine.utils.reforms import (
-    add_parameter_file,
-    apply_reform,
-    create_reform,
-    get_PE_parameters,
-    use_current_parameters,
-)
-from policyengine.utils.situations import create_situation, get_PE_variables
-
-
-class PolicyEngineCountry:
-    name: str
-    system: Type[TaxBenefitSystem]
-    Microsimulation: Type[Microsimulation]
-    IndividualSim: Type[IndividualSim]
-    default_reform: type = ()
-    parameter_file: Path = None
-    default_dataset: type
-    calculate_only: bool = False
-    version: str
-    results_config: Type[PolicyEngineResultsConfig]
-
-    def __init__(self):
-        self.default_reform = (
-            add_parameter_file(self.parameter_file.absolute())
-            if self.parameter_file is not None
-            else (),
-            self.default_reform,
-            self.Microsimulation.post_reform,
-            use_current_parameters(),
-        )
-
-        self.baseline_system = apply_reform(
-            self.default_reform[:-1], self.system()
-        )
-
-        self.baseline = None
-
-        self.year = 2022
-
-        self.policyengine_parameters = get_PE_parameters(self.baseline_system)
-
-        self.policyengine_variables = get_PE_variables(self.baseline_system)
-
-        self.api_endpoints = dict(
-            household_reform=self.household_reform,
-            population_reform=self.population_reform,
-            ubi=self.ubi,
-            parameters=self.parameters,
-            entities=self.entities,
-            variables=self.variables,
-            population_breakdown=self.population_breakdown,
-            calculate=self.calculate,
-            computation_tree=self.computation_tree,
-            household_variation=self.household_variation,
-            dependencies=self.dependencies,
-            leaf_nodes=self.leaf_nodes,
-            age_chart=self.age_chart,
-        )
-
-        self.entities = build_entities(self.baseline_system)
-
-    def initialise_baseline_microsimulation(self):
-        self.baseline = self.Microsimulation(
-            self.default_reform,
-            dataset=self.default_dataset,
-            post_reform=(),
-        )
-
-    def _get_microsimulations(
-        self, params: dict, refresh_baseline: bool = False
-    ) -> Tuple[Microsimulation, Microsimulation]:
-        if self.baseline is None:
-            self.initialise_baseline_microsimulation()
-        if isinstance(params, type) or isinstance(params, tuple):
-            reform_config = dict(
-                baseline=dict(
-                    has_changed=False,
-                ),
-                reform=dict(
-                    reform=(
-                        self.default_reform[:-1],
-                        params,
-                        self.default_reform[-1],
-                    ),
-                ),
-            )
-        else:
-            reform_config = create_reform(
-                params, self.policyengine_parameters, self.default_reform[:-1]
-            )
-        baseline = (
-            self.baseline
-            if not reform_config["baseline"]["has_changed"]
-            else self.Microsimulation(
-                (
-                    reform_config["baseline"]["reform"],
-                    self.default_reform[-1],
-                ),
-                dataset=self.default_dataset,
-                year=self.default_dataset_year,
-                post_reform=(),
-            )
-        )
-        if refresh_baseline and not reform_config["baseline"]["has_changed"]:
-            baseline = self.Microsimulation(
-                self.default_reform,
-                dataset=self.default_dataset,
-                post_reform=(),
-            )
-        reformed = self.Microsimulation(
-            (
-                reform_config["reform"]["reform"],
-                self.default_reform[-1],
-            ),
-            dataset=self.default_dataset,
-            year=self.default_dataset_year,
-            post_reform=(),
-        )
-        baseline.year = 2022
-        reformed.year = 2022
-        return baseline, reformed
-
-    def _get_individualsims(
-        self, params: dict
-    ) -> Tuple[IndividualSim, IndividualSim]:
-        if isinstance(params, type) or isinstance(params, tuple):
-            reform_config = dict(
-                baseline=dict(
-                    has_changed=False,
-                ),
-                reform=dict(
-                    reform=(
-                        self.default_reform[:-1],
-                        params,
-                        self.default_reform[-1],
-                    ),
-                ),
-            )
-        else:
-            reform_config = create_reform(
-                params, self.policyengine_parameters, self.default_reform[:-1]
-            )
-        situation = create_situation(params["household"])
-        baseline = situation(
-            self.IndividualSim(reform_config["baseline"]["reform"], 2022)
-        )
-        if len(params.keys()) - 1 > 0:
-            reformed = situation(
-                self.IndividualSim(reform_config["reform"]["reform"], 2022)
-            )
-        else:
-            reformed = baseline
-        return baseline, reformed
-
-    def population_reform(self, params: dict = None):
-        baseline, reformed = self._get_microsimulations(params)
-        rel_income_decile_chart, avg_income_decile_chart = decile_chart(
-            baseline, reformed, self.results_config
-        )
-        rel_wealth_decile_chart, avg_wealth_decile_chart = decile_chart(
-            baseline,
-            reformed,
-            self.results_config,
-            decile_type="wealth",
-        )
-        return dict(
-            **headline_metrics(baseline, reformed, self.results_config),
-            rel_income_decile_chart=rel_income_decile_chart,
-            avg_income_decile_chart=avg_income_decile_chart,
-            rel_wealth_decile_chart=rel_wealth_decile_chart,
-            avg_wealth_decile_chart=avg_wealth_decile_chart,
-            poverty_chart=poverty_chart(
-                baseline, reformed, False, self.results_config
-            ),
-            deep_poverty_chart=poverty_chart(
-                baseline, reformed, True, self.results_config
-            ),
-            waterfall_chart=population_waterfall_chart(
-                baseline, reformed, self.results_config
-            ),
-            intra_income_decile_chart=intra_decile_chart(
-                baseline, reformed, self.results_config
-            ),
-            intra_wealth_decile_chart=intra_decile_chart(
-                baseline,
-                reformed,
-                self.results_config,
-                decile_type="wealth",
-            ),
-            inequality_chart=inequality_chart(
-                baseline,
-                reformed,
-                self.results_config,
-            ),
-        )
-
-    @exclude_from_cache
-    def household_reform(self, params=None):
-        # Deprecated - use /calculate or /household_variation
-        has_reform = len(params.keys()) - 1 > 0
-        baseline, reformed = self._get_individualsims(params)
-        headlines = headline_figures(baseline, reformed, self.results_config)
-        waterfall = household_waterfall_chart(
-            baseline, reformed, self.results_config
-        )
-        vary_max = max(200000, baseline.calc("employment_income").sum() * 1.5)
-        baseline.vary(
-            "employment_income",
-            step=100,
-            max=vary_max,
-        )
-        reformed.vary(
-            "employment_income",
-            step=100,
-            max=vary_max,
-        )
-        budget = budget_chart(
-            baseline, reformed, False, self.results_config, has_reform
-        )
-        budget_difference = budget_chart(
-            baseline, reformed, True, self.results_config, has_reform
-        )
-        mtr = mtr_chart(baseline, reformed, self.results_config, has_reform)
-        return dict(
-            **headlines,
-            waterfall_chart=waterfall,
-            budget_chart=budget,
-            budget_difference_chart=budget_difference,
-            mtr_chart=mtr,
-        )
-
-    def ubi(self, params=None):
-        baseline, reformed = self._get_microsimulations(params)
-        revenue = (
-            baseline.calc(
-                self.results_config.household_net_income_variable
-            ).sum()
-            - reformed.calc(
-                self.results_config.household_net_income_variable
-            ).sum()
-        )
-        UBI_amount = max(
-            0,
-            revenue / baseline.calc(self.results_config.person_variable).sum(),
-        )
-        return {"UBI": float(UBI_amount)}
-
-    @exclude_from_cache
-    def parameters(self, params=None):
-        if "policy_date" in params:
-            return get_PE_parameters(
-                self.baseline_system, date=params.get("policy_date")
-            )
-        return self.policyengine_parameters
-
-    @exclude_from_cache
-    def entities(self, params=None):
-        return self.entities
-
-    @exclude_from_cache
-    def variables(self, params=None):
-        return self.policyengine_variables
-
-    def population_breakdown(self, params=None):
-        reform_config = create_reform(
-            params, self.policyengine_parameters, self.default_reform[:-1]
-        )
-        if reform_config["baseline"]["has_changed"]:
-            baseline_reform_indices = [
-                i
-                for i in range(len(reform_config["reform"]["descriptions"]))
-                if reform_config["reform"]["descriptions"][i]
-                in reform_config["baseline"]["descriptions"]
-            ]
-            filtered_reforms = [
-                reform
-                for i, reform in enumerate(reform_config["reform"]["reform"])
-                if i not in baseline_reform_indices
-            ]
-            filtered_descriptions = [
-                description
-                for i, description in enumerate(
-                    reform_config["reform"]["descriptions"]
-                )
-                if i not in baseline_reform_indices
-            ]
-        else:
-            filtered_reforms = reform_config["reform"]["reform"][1:]
-            filtered_descriptions = reform_config["reform"]["descriptions"][1:]
-
-        def _create_reform(reform):
-            if reform_config["baseline"]["has_changed"]:
-                reform_input = (
-                    reform_config["baseline"]["reform"][1:],
-                    reform_config["reform"]["reform"][0],
-                    *reform,
-                )
-            else:
-                reform_input = (reform_config["reform"]["reform"][0], *reform)
-            sim = self.Microsimulation(
-                reform_input,
-                dataset=self.default_dataset,
-                post_reform=(),
-            )
-            sim.year = 2022
-            return sim
-
-        baseline, reformed = self._get_microsimulations(params)
-        return get_breakdown_and_chart_per_provision(
-            filtered_reforms,
-            filtered_descriptions,
-            baseline,
-            reformed,
-            _create_reform,
-            self.results_config,
-        )
-
-    @exclude_from_cache
-    def calculate(self, params=None):
-        if (len(params) == 1) and hasattr(self, "baseline_tax_benefit_system"):
-            # Cache the tax-benefit system for no-reform simulations
-            system = self.baseline_tax_benefit_system
-        else:
-            reform = create_reform(
-                {x: y for x, y in params.items() if x != "ignoreReform"},
-                self.policyengine_parameters,
-                self.default_reform[:-1],
-            )
-            if "ignoreReform" not in params:
-                system = apply_reform(
-                    reform["reform"]["reform"], self.system()
-                )
-            else:
-                system = apply_reform(
-                    reform["baseline"]["reform"], self.system()
-                )
-            if len(params) == 1:
-                self.baseline_tax_benefit_system = system
-        simulation = SimulationBuilder().build_from_entities(
-            system, params["household"]
-        )
-
-        requested_computations = dpath.util.search(
-            params["household"],
-            "*/*/*/*",
-            afilter=lambda t: t is None,
-            yielded=True,
-        )
-        computation_results = {}
-
-        for computation in requested_computations:
-            path = computation[0]
-            entity_plural, entity_id, variable_name, period = path.split("/")
-            variable = system.get_variable(variable_name)
-            result = simulation.calculate(variable_name, period)
-            population = simulation.get_population(entity_plural)
-            entity_index = population.get_index(entity_id)
-
-            if variable.value_type == Enum:
-                entity_result = result.decode()[entity_index].name
-            elif variable.value_type == float:
-                entity_result = float(str(result[entity_index]))
-            elif variable.value_type == str:
-                entity_result = str(result[entity_index])
-            else:
-                entity_result = result.tolist()[entity_index]
-
-            # Bug fix, unclear of the root cause
-
-            if isinstance(entity_result, list) and len(entity_result) > 2_000:
-                entity_result = {period: entity_result[-1]}
-
-            dpath.util.new(computation_results, path, entity_result)
-
-        dpath.util.merge(params["household"], computation_results)
-
-        return params["household"]
-
-    @exclude_from_cache
-    def computation_tree(self, params=None):
-        reform = create_reform(
-            {x: y for x, y in params.items() if x != "ignoreReform"},
-            self.policyengine_parameters,
-            self.default_reform[:-1],
-        )
-        if "ignoreReform" not in params:
-            system = apply_reform(reform["reform"]["reform"], self.system())
-        else:
-            system = apply_reform(reform["baseline"]["reform"], self.system())
-        simulation = SimulationBuilder().build_from_entities(
-            system, params["household"]
-        )
-
-        return get_computation_trees_json(simulation, params)
-
-    @exclude_from_cache
-    def dependencies(self, params=None):
-        reform = create_reform(
-            {x: y for x, y in params.items() if x != "ignoreReform"},
-            self.policyengine_parameters,
-            self.default_reform[:-1],
-        )
-        if "ignoreReform" not in params:
-            system = apply_reform(reform["reform"]["reform"], self.system())
-        else:
-            system = apply_reform(reform["baseline"]["reform"], self.system())
-
-        simulation = SimulationBuilder().build_from_entities(
-            system, params["household"]
-        )
-
-        trees = get_computation_trees_json(simulation, params)[
-            "computation_trees"
-        ]
-
-        def get_dependencies(node):
-            nodes = [node["name"]]
-            for child in node["children"]:
-                nodes += get_dependencies(child)
-            return list(set(nodes))
-
-        return dict(
-            dependencies=list(
-                set(sum([get_dependencies(tree) for tree in trees], []))
-            )
-        )
-
-    @exclude_from_cache
-    def leaf_nodes(self, params=None):
-        reform = create_reform(
-            {x: y for x, y in params.items() if x != "ignoreReform"},
-            self.policyengine_parameters,
-            self.default_reform[:-1],
-        )
-        if "ignoreReform" not in params:
-            system = apply_reform(reform["reform"]["reform"], self.system())
-        else:
-            system = apply_reform(reform["baseline"]["reform"], self.system())
-
-        simulation = SimulationBuilder().build_from_entities(
-            system, params["household"]
-        )
-
-        trees = get_computation_trees_json(simulation, params)[
-            "computation_trees"
-        ]
-
-        def get_leaf_nodes(node):
-            if len(node["children"]) == 0:
-                return [node["name"]]
-            else:
-                return sum(
-                    [get_leaf_nodes(child) for child in node["children"]], []
-                )
-
-        return dict(
-            leaf_nodes=list(
-                set(sum([get_leaf_nodes(tree) for tree in trees], []))
-            )
-        )
-
-    @exclude_from_cache
-    def household_variation(self, params=None):
-        has_reform = len(params.keys()) - 1 > 0
-        baseline, reformed = self._get_individualsims(params)
-        employment_income = baseline.calc(
-            self.results_config.employment_income_variable
-        ).sum()
-        self_employment_income = baseline.calc(
-            self.results_config.self_employment_income_variable
-        ).sum()
-        earnings_variable = (
-            self.results_config.employment_income_variable
-            if employment_income >= self_employment_income
-            else self.results_config.self_employment_income_variable
-        )
-        earnings = max(employment_income, self_employment_income)
-        total_income = baseline.calc(
-            self.results_config.total_income_variable
-        ).sum()
-        vary_max = max(200_000, earnings * 1.5)
-        baseline.vary(
-            earnings_variable,
-            step=100,
-            max=vary_max,
-        )
-        if len(params.keys()) - 1 > 0:
-            reformed.vary(
-                earnings_variable,
-                step=100,
-                max=vary_max,
-            )
-        budget = budget_chart(
-            baseline,
-            reformed,
-            False,
-            self.results_config,
-            has_reform,
-            total_income,
-        )
-        budget_difference = budget_chart(
-            baseline,
-            reformed,
-            True,
-            self.results_config,
-            has_reform,
-            total_income,
-        )
-        mtr = mtr_chart(
-            baseline,
-            reformed,
-            False,
-            self.results_config,
-            has_reform,
-            total_income,
-        )
-        mtr_difference = mtr_chart(
-            baseline,
-            reformed,
-            True,
-            self.results_config,
-            has_reform,
-            total_income,
-        )
-        return dict(
-            budget_chart=budget,
-            budget_difference_chart=budget_difference,
-            mtr_chart=mtr,
-            mtr_difference_chart=mtr_difference,
-        )
-
-    def age_chart(self, params: dict = None):
-        baseline, reformed = self._get_microsimulations(params)
-        chart = age_chart(baseline, reformed, self.results_config)
-        return dict(
-            age_chart=chart,
-        )
diff --git a/policyengine/countries/uk/__init__.py b/policyengine/countries/uk/__init__.py
deleted file mode 100644
index 435dcd65..00000000
--- a/policyengine/countries/uk/__init__.py
+++ /dev/null
@@ -1,106 +0,0 @@
-from pathlib import Path
-from typing import Tuple
-import numpy as np
-from openfisca_uk import (
-    Microsimulation,
-    IndividualSim,
-    CountryTaxBenefitSystem,
-)
-from openfisca_uk.entities import *
-from openfisca_uk.data import EnhancedFRS, SynthFRS
-from policyengine.utils.general import PolicyEngineResultsConfig
-from policyengine.countries.country import PolicyEngineCountry
-from policyengine.countries.uk.default_reform import create_default_reform
-import os
-import logging
-
-UK_FOLDER = Path(__file__).parent
-
-
-class UKResultsConfig(PolicyEngineResultsConfig):
-    net_income_variable: str = "net_income"
-    in_poverty_variable: str = "in_poverty_bhc"
-    in_deep_poverty_variable: str = "in_deep_poverty_bhc"
-    household_net_income_variable: str = "household_net_income"
-    household_wealth_variable: str = "total_wealth"
-    equiv_household_net_income_variable: str = "equiv_household_net_income"
-    child_variable: str = "is_child"
-    working_age_variable: str = "is_WA_adult"
-    senior_variable: str = "is_SP_age"
-    person_variable: str = "people"
-    tax_variable: str = "household_tax"
-    benefit_variable: str = "household_benefits"
-    employment_income_variable: str = "employment_income"
-    self_employment_income_variable: str = "self_employment_income"
-    total_income_variable: str = "total_income"
-    currency = "£"
-
-
-class UK(PolicyEngineCountry):
-    name = "uk"
-    system = CountryTaxBenefitSystem
-    Microsimulation = Microsimulation
-    IndividualSim = IndividualSim
-    default_dataset = EnhancedFRS
-    default_dataset_year = 2022
-    default_reform = create_default_reform()
-    parameter_file = UK_FOLDER / "reform_parameters.yaml"
-    default_household_file = UK_FOLDER / "default_household.yaml"
-    entity_hierarchy_file = UK_FOLDER / "entities.yaml"
-    version = "0.2.0"
-    results_config = UKResultsConfig
-
-    @property
-    def synthetic(self):
-        return bool(os.environ.get("UK_SYNTHETIC"))
-
-    def __init__(self):
-        if self.synthetic:
-            self.default_dataset = SynthFRS
-            logging.warn("Using the synthetic FRS.")
-        if self.default_dataset_year not in self.default_dataset.years:
-            logging.info(
-                f"{self.default_dataset_year} not found in dataset {self.default_dataset.name} years, downloading."
-            )
-            self.default_dataset.download(self.default_dataset_year)
-        super().__init__()
-
-    def _get_microsimulations(
-        self, params: dict
-    ) -> Tuple[Microsimulation, Microsimulation]:
-        if isinstance(params, dict) and "baseline_country_specific" in params:
-            baseline, reformed = super()._get_microsimulations(
-                params, refresh_baseline=True
-            )
-            filtered_country = params["baseline_country_specific"]
-            household_weights = baseline.calc("household_weight")
-            country = baseline.calc("country")
-            baseline.set_input(
-                "household_weight",
-                baseline.year,
-                np.where(country == filtered_country, household_weights, 0),
-            )
-            reformed.set_input(
-                "household_weight",
-                reformed.year,
-                np.where(country == filtered_country, household_weights, 0),
-            )
-            person_weights = baseline.calc("person_weight")
-            person_country = baseline.calc("country", map_to="person")
-            baseline.set_input(
-                "person_weight",
-                baseline.year,
-                np.where(
-                    person_country == filtered_country, person_weights, 0
-                ),
-            )
-            reformed.set_input(
-                "person_weight",
-                reformed.year,
-                np.where(
-                    person_country == filtered_country, person_weights, 0
-                ),
-            )
-        else:
-            baseline, reformed = super()._get_microsimulations(params)
-        return baseline, reformed
diff --git a/policyengine/countries/uk/default_reform/__init__.py b/policyengine/countries/uk/default_reform/__init__.py
deleted file mode 100644
index fc6a1382..00000000
--- a/policyengine/countries/uk/default_reform/__init__.py
+++ /dev/null
@@ -1,1 +0,0 @@
-from policyengine.countries.uk.default_reform.reform import *
diff --git a/policyengine/country/__init__.py b/policyengine/country/__init__.py
new file mode 100644
index 00000000..a347b276
--- /dev/null
+++ b/policyengine/country/__init__.py
@@ -0,0 +1,3 @@
+from .country import PolicyEngineCountry
+from .uk import UK
+from .us import US
diff --git a/policyengine/country/country.py b/policyengine/country/country.py
new file mode 100644
index 00000000..7c0ad8ae
--- /dev/null
+++ b/policyengine/country/country.py
@@ -0,0 +1,434 @@
+from time import time
+from types import ModuleType
+from typing import Callable, Dict, Type
+import numpy as np
+from openfisca_core.taxbenefitsystems import TaxBenefitSystem
+from openfisca_core.simulation_builder import SimulationBuilder, Simulation
+from openfisca_core.model_api import Enum
+from openfisca_core.reforms import Reform
+from policyengine.country.openfisca.computation_trees import (
+    get_computation_trees_json,
+)
+from policyengine.country.openfisca.entities import build_entities
+from policyengine.country.openfisca.parameters import build_parameters
+from policyengine.country.openfisca.reforms import apply_reform, PolicyReform
+from policyengine.country.openfisca.variables import build_variables
+from policyengine.country.results_config import PolicyEngineResultsConfig
+from policyengine.impact.household.earnings_impact import earnings_impact
+from policyengine.impact.population.charts.age import age_chart
+from policyengine.web_server.cache import PolicyEngineCache, cached_endpoint
+from policyengine.web_server.logging import PolicyEngineLogger
+import dpath
+from policyengine.impact.population.charts import (
+    decile_chart,
+    inequality_chart,
+    intra_decile_chart,
+    poverty_chart,
+    waterfall_chart,
+)
+from policyengine.impact.population.by_provision import (
+    get_breakdown_and_chart_per_provision,
+)
+from policyengine.impact.population.metrics import headline_metrics
+
+
+class PolicyEngineCountry:
+    """Base class for a PolicyEngine country. Each country has a set of API endpoints available."""
+
+    api_endpoints: Dict[str, Callable] = None
+    """The API endpoints available for this country.
+    """
+
+    name: str = None
+    """The name of the country.
+    """
+
+    openfisca_country_model: ModuleType = None
+    """The OpenFisca country model for this country.
+    """
+
+    default_reform: Reform = None
+    """An OpenFisca reform to apply to the country model before use.
+    """
+
+    results_config: Type[PolicyEngineResultsConfig] = None
+    """The results configuration for this country. Used to interface with the OpenFisca country model.
+    """
+
+    def __init__(self):
+        self.api_endpoints = dict(
+            entities=self.entities,
+            variables=self.variables,
+            parameters=self.parameters,
+            parameter=self.parameter,
+            budgetary_impact=self.budgetary_impact,
+            calculate=self.calculate,
+            population_reform=self.population_reform,
+            endpoint_runtimes=self.endpoint_runtimes,
+            household_variation=self.household_variation,
+            computation_tree=self.computation_tree,
+            dependencies=self.dependencies,
+            leaf_nodes=self.leaf_nodes,
+            age_chart=self.age_chart,
+            population_breakdown=self.population_breakdown,
+        )
+        if self.name is None:
+            self.name = self.__class__.__name__.lower()
+        if self.openfisca_country_model is None:
+            raise ValueError("No OpenFisca country model specified.")
+        (
+            self.tax_benefit_system_type,
+            self.microsimulation_type,
+            self.individualsim_type,
+        ) = map(
+            lambda name: getattr(self.openfisca_country_model, name),
+            ("CountryTaxBenefitSystem", "Microsimulation", "IndividualSim"),
+        )
+        self.tax_benefit_system = self.tax_benefit_system_type()
+        self.baseline_tax_benefit_system = self.tax_benefit_system_type()
+        if self.default_reform is not None:
+            apply_reform(self.default_reform, self.tax_benefit_system)
+
+        self.entity_data = build_entities(self.tax_benefit_system)
+        self.variable_data = build_variables(self.tax_benefit_system)
+        self.parameter_data = build_parameters(self.tax_benefit_system)
+
+        self.baseline_microsimulation = None
+
+        self.endpoint_runtimes = dict(
+            population_impact_reform_only=[10],
+            population_impact_reform_and_baseline=[20],
+            household_variation_baseline_only=[10],
+            household_variation_reform_and_baseline=[20],
+            auto_ubi=[10],
+            age_chart=[10],
+        )
+
+    def create_reform(self, parameters: dict) -> PolicyReform:
+        """Generate an OpenFisca reform from PolicyEngine parameters.
+
+        Args:
+            parameters (dict): The PolicyEngine parameters.
+
+        Returns:
+            Reform: The OpenFisca reform.
+        """
+        return PolicyReform(
+            parameters, self.parameter_data, default_reform=self.default_reform
+        )
+
+    def create_microsimulations(self, parameters: dict):
+        """Generate a microsimulations from PolicyEngine parameters.
+
+        Args:
+            parameters (dict): The PolicyEngine parameters.
+        """
+        policy_reform = self.create_reform(parameters)
+        if (
+            not policy_reform.edits_baseline
+            and self.baseline_microsimulation is None
+        ):
+            baseline = (
+                self.baseline_microsimulation
+            ) = self.microsimulation_type(self.default_reform)
+        elif policy_reform.edits_baseline:
+            baseline = self.microsimulation_type(policy_reform.baseline)
+        else:
+            baseline = self.baseline_microsimulation
+        reformed = self.microsimulation_type(policy_reform.reform)
+        return baseline, reformed
+
+    def create_individualsims(self, parameters: dict, situation: dict):
+        """Generate a individual simulations from PolicyEngine parameters.
+
+        Args:
+            parameters (dict): The PolicyEngine parameters.
+            situation (dict): The OpenFisca situation JSON.
+        """
+        policy_reform = self.create_reform(parameters)
+        baseline = self.individualsim_type(policy_reform.baseline)
+        baseline.situation_data = situation
+        baseline.build()
+        if len(parameters) > 1:
+            reformed = self.individualsim_type(policy_reform.reform)
+            reformed.situation_data = situation
+            reformed.build()
+        else:
+            reformed = None
+        return baseline, reformed
+
+    def create_openfisca_simulation(self, parameters: dict) -> Simulation:
+        """Initialises an OpenFisca simulation from given household or reform parameters (optimised for performance by skipping no-reform applications).
+
+        Args:
+            parameters (dict): Policy reform parameters, and a 'household' entry.
+
+        Returns:
+            Simulation: The OpenFisca Simulation object.
+        """
+        if len(parameters) == 1:
+            # Cache the tax-benefit system for no-reform simulations
+            system = self.tax_benefit_system
+        else:
+            reform = self.create_reform(parameters)
+            system = apply_reform(
+                reform.reform, self.tax_benefit_system_type()
+            )
+        return SimulationBuilder().build_from_entities(
+            system, parameters["household"]
+        )
+
+    def entities(self, params: dict, logger: PolicyEngineLogger) -> dict:
+        """Get the available entities for the OpenFisca country model."""
+        return self.entity_data
+
+    def variables(self, params: dict, logger: PolicyEngineLogger) -> dict:
+        """Get the available entities for the OpenFisca country model."""
+        return self.variable_data
+
+    def parameters(self, params: dict, logger: PolicyEngineLogger) -> dict:
+        """Get the available entities for the OpenFisca country model."""
+        if "policy_date" in params:
+            return build_parameters(
+                self.baseline_tax_benefit_system,
+                date=params.get("policy_date"),
+            )
+        return self.parameter_data
+
+    def parameter(self, params: dict, logger: PolicyEngineLogger) -> dict:
+        """Get a specific parameter."""
+        return self.parameter_data[params["q"]]
+
+    @cached_endpoint
+    def budgetary_impact(
+        self, params: dict, logger: PolicyEngineLogger
+    ) -> dict:
+        """Get the budgetary impact of a reform."""
+        baseline, reformed = self.create_microsimulations(params)
+        baseline_net_income = baseline.calc(
+            self.results_config.household_net_income_variable
+        ).sum()
+        reformed_net_income = reformed.calc(
+            self.results_config.household_net_income_variable
+        ).sum()
+        difference = reformed_net_income - baseline_net_income
+        return {
+            "budgetary_impact": difference,
+        }
+
+    def calculate(
+        self,
+        params: dict,
+        logger: PolicyEngineLogger,
+    ) -> dict:
+        """Calculate variables for a given household and policy reform."""
+        simulation = self.create_openfisca_simulation(params)
+        system = simulation.tax_benefit_system
+
+        requested_computations = dpath.util.search(
+            params["household"],
+            "*/*/*/*",
+            afilter=lambda t: t is None,
+            yielded=True,
+        )
+        computation_results = {}
+
+        for computation in requested_computations:
+            path = computation[0]
+            entity_plural, entity_id, variable_name, period = path.split("/")
+            variable = system.get_variable(variable_name)
+            result = simulation.calculate(variable_name, period)
+            population = simulation.get_population(entity_plural)
+            entity_index = population.get_index(entity_id)
+
+            if variable.value_type == Enum:
+                entity_result = result.decode()[entity_index].name
+            elif variable.value_type == float:
+                entity_result = float(str(result[entity_index]))
+            elif variable.value_type == str:
+                entity_result = str(result[entity_index])
+            else:
+                entity_result = result.tolist()[entity_index]
+
+            # Bug fix, unclear of the root cause
+
+            if isinstance(entity_result, list) and len(entity_result) > 2_000:
+                entity_result = {period: entity_result[-1]}
+
+            dpath.util.new(computation_results, path, entity_result)
+
+        dpath.util.merge(params["household"], computation_results)
+
+        return params["household"]
+
+    def endpoint_runtimes(
+        self, params: dict, logger: PolicyEngineLogger
+    ) -> dict:
+        """Get the average runtime of a population reform."""
+        average_runtimes = {}
+        for key in self.endpoint_runtimes:
+            while len(self.endpoint_runtimes[key]) > 10:
+                self.endpoint_runtimes[key].pop(0)
+            average_runtimes[key] = np.average(self.endpoint_runtimes[key])
+        return average_runtimes
+
+    @cached_endpoint
+    def population_reform(
+        self, params: dict, logger: PolicyEngineLogger
+    ) -> dict:
+        """Compute the population-level impact of a reform."""
+        start_time = time()
+        edits_baseline = any(["baseline_" in param for param in params])
+        baseline, reformed = self.create_microsimulations(params)
+        rel_income_decile_chart, avg_income_decile_chart = decile_chart(
+            baseline, reformed, self.results_config
+        )
+        rel_wealth_decile_chart, avg_wealth_decile_chart = decile_chart(
+            baseline,
+            reformed,
+            self.results_config,
+            decile_type="wealth",
+        )
+        result = dict(
+            **headline_metrics(baseline, reformed, self.results_config),
+            rel_income_decile_chart=rel_income_decile_chart,
+            avg_income_decile_chart=avg_income_decile_chart,
+            rel_wealth_decile_chart=rel_wealth_decile_chart,
+            avg_wealth_decile_chart=avg_wealth_decile_chart,
+            poverty_chart=poverty_chart(
+                baseline, reformed, False, self.results_config
+            ),
+            deep_poverty_chart=poverty_chart(
+                baseline, reformed, True, self.results_config
+            ),
+            waterfall_chart=waterfall_chart(
+                baseline, reformed, self.results_config
+            ),
+            intra_income_decile_chart=intra_decile_chart(
+                baseline, reformed, self.results_config
+            ),
+            intra_wealth_decile_chart=intra_decile_chart(
+                baseline,
+                reformed,
+                self.results_config,
+                decile_type="wealth",
+            ),
+            inequality_chart=inequality_chart(
+                baseline,
+                reformed,
+                self.results_config,
+            ),
+        )
+        classification = (
+            "reform_and_baseline" if edits_baseline else "reform_only"
+        )
+        self.endpoint_runtimes[f"population_impact_{classification}"].append(
+            time() - start_time
+        )
+        return result
+
+    @cached_endpoint
+    def auto_ubi(self, params=None, logger=None):
+        """Compute the size of a UBI which makes a given policy reform budget-neutral."""
+        start_time = time()
+        baseline, reformed = self.create_microsimulations(params)
+        revenue = (
+            baseline.calc(
+                self.results_config.household_net_income_variable
+            ).sum()
+            - reformed.calc(
+                self.results_config.household_net_income_variable
+            ).sum()
+        )
+        UBI_amount = max(
+            0,
+            revenue / baseline.calc(self.results_config.person_variable).sum(),
+        )
+        self.endpoint_runtimes["auto_ubi"].append(time() - start_time)
+        return {"UBI": float(UBI_amount)}
+
+    @cached_endpoint
+    def household_variation(self, params=None, logger=None):
+        """Compute how changes in earnings affect household income."""
+        start_time = time()
+        baseline, reformed = self.create_individualsims(
+            params, params["household"]
+        )
+        result = earnings_impact(baseline, reformed, self.results_config)
+        has_reform = len(params) > 1
+        classification = (
+            "reform_and_baseline" if has_reform else "baseline_only"
+        )
+        self.endpoint_runtimes[f"household_variation_{classification}"].append(
+            time() - start_time
+        )
+        return result
+
+    def computation_tree(self, params=None):
+        """Get the computation tree for a given household (with any policy reforms applied)."""
+        simulation = self.create_openfisca_simulation(params)
+        return get_computation_trees_json(simulation, params)
+
+    def dependencies(self, params=None):
+        """Get a list of all variables needed to compute a given household simulation."""
+        simulation = self.create_openfisca_simulation(params)
+        trees = get_computation_trees_json(simulation, params)[
+            "computation_trees"
+        ]
+
+        def get_dependencies(node):
+            nodes = [node["name"]]
+            for child in node["children"]:
+                nodes += get_dependencies(child)
+            return list(set(nodes))
+
+        return dict(
+            dependencies=list(
+                set(sum([get_dependencies(tree) for tree in trees], []))
+            )
+        )
+
+    def leaf_nodes(self, params=None):
+        """Get a list of the input variables involved in a given household simulation."""
+        simulation = self.create_openfisca_simulation(params)
+
+        trees = get_computation_trees_json(simulation, params)[
+            "computation_trees"
+        ]
+
+        def get_leaf_nodes(node):
+            if len(node["children"]) == 0:
+                return [node["name"]]
+            else:
+                return sum(
+                    [get_leaf_nodes(child) for child in node["children"]], []
+                )
+
+        return dict(
+            leaf_nodes=list(
+                set(sum([get_leaf_nodes(tree) for tree in trees], []))
+            )
+        )
+
+    @cached_endpoint
+    def age_chart(self, params: dict = None, logger=None):
+        """Generate a chart showing the average change to income by age."""
+        start_time = time()
+        baseline, reformed = self.create_microsimulations(params)
+        chart = age_chart(baseline, reformed, self.results_config)
+        self.endpoint_runtimes["age_chart"].append(time() - start_time)
+        return dict(
+            age_chart=chart,
+        )
+
+    @cached_endpoint
+    def population_breakdown(self, params=None, logger=None):
+        """Score a policy reform with a breakdown by provision."""
+        baseline, reformed = self.create_microsimulations(params)
+        return get_breakdown_and_chart_per_provision(
+            self,
+            params,
+            baseline,
+            reformed,
+            self.results_config,
+        )
diff --git a/policyengine/utils/computation_trees.py b/policyengine/country/openfisca/computation_trees.py
similarity index 100%
rename from policyengine/utils/computation_trees.py
rename to policyengine/country/openfisca/computation_trees.py
diff --git a/policyengine/countries/entities.py b/policyengine/country/openfisca/entities.py
similarity index 100%
rename from policyengine/countries/entities.py
rename to policyengine/country/openfisca/entities.py
diff --git a/policyengine/country/openfisca/parameters.py b/policyengine/country/openfisca/parameters.py
new file mode 100644
index 00000000..272bc227
--- /dev/null
+++ b/policyengine/country/openfisca/parameters.py
@@ -0,0 +1,315 @@
+from datetime import datetime
+from collections import Sequence, OrderedDict
+from typing import Dict, List, Type
+import numpy as np
+from openfisca_core.parameters import ParameterNode, Parameter, ParameterScale
+from openfisca_core.variables import Variable
+from openfisca_core.model_api import Enum
+from openfisca_core.taxbenefitsystems import TaxBenefitSystem
+
+NOW = datetime.now().strftime("%Y-%m-%d")
+
+
+class PolicyEngineParameter:
+    PROPERTIES = [
+        "name",
+        "parameter",
+        "description",
+        "label",
+        "reference",
+        "unit",
+        "valueType",
+        "variable",
+        "period",
+        "value",
+        "breakdownParts",
+    ]
+
+    def __init__(self, openfisca_parameter: Parameter, date: str = NOW):
+        self.openfisca_parameter = openfisca_parameter
+        self.date = date
+
+    def __getattr__(self, key: str):
+        return self.openfisca_parameter.metadata.get(
+            key, self.openfisca_parameter.__dict__.get(key)
+        )
+
+    @property
+    def value(self):
+        if not isinstance(self.openfisca_parameter, Parameter):
+            return None
+        current_value = self.openfisca_parameter(self.date)
+        value_type = type(current_value).__name__
+        if value_type == "list":
+            raise NotImplementedError(
+                "List parameters are not currently supported."
+            )
+        if value_type == "Enum":
+            return current_value[0]
+        if current_value == np.inf:
+            return "inf"
+        elif current_value == -np.inf:
+            return "-inf"
+        return current_value
+
+    @property
+    def name(self):
+        name = self.openfisca_parameter.metadata.get(
+            "name", self.openfisca_parameter.name.replace(".", "_")
+        )
+        assert name != "__pycache__", "Parameter name cannot be __pycache__."
+        return name
+
+    @property
+    def parameter(self):
+        return self.openfisca_parameter.name
+
+    @property
+    def breakdownParts(self):
+        return self.openfisca_parameter.metadata.get("breakdown_parts")
+
+    @property
+    def reference(self):
+        try:
+            if self.openfisca_parameter.metadata.get("reference") is not None:
+                reference = self.openfisca_parameter.metadata["reference"]
+                if isinstance(reference, dict):
+                    reference = [reference]
+                for item in reference:
+                    if isinstance(item, str):
+                        item = dict(title=item, href=item)
+                    if "name" in item:
+                        item["title"] = item["name"]
+                return {item["title"]: item["href"] for item in reference}
+            else:
+                return {}
+        except:
+            return {}
+
+    @property
+    def valueType(self):
+        if isinstance(self.openfisca_parameter, ParameterNode):
+            return "parameter_node"
+        return self.openfisca_parameter.metadata.get(
+            "value_type",
+            self.value.__class__.__name__,
+        )
+
+    def to_dict(self) -> dict:
+        """Return a dictionary representation of the parameter.
+
+        Raises:
+            ValueError: If a required property is not found. This will likely be due to a change in the OpenFisca API.
+
+        Returns:
+            dict: The parameter metadata.
+        """
+        data = {}
+        for prop in self.PROPERTIES:
+            try:
+                if hasattr(self, prop):
+                    data[prop] = getattr(self, prop)
+                else:
+                    raise ValueError(f"Property {prop} not found.")
+            except:
+                # In the case of an exception, abandon the entire variable.
+                return None
+        return data
+
+
+class PolicyEngineScaleParameter(PolicyEngineParameter):
+    PROPERTIES = PolicyEngineParameter.PROPERTIES + [
+        "brackets",
+        "scaleType",
+    ]
+
+    @property
+    def brackets(self):
+        return len(self.openfisca_parameter.brackets)
+
+    @property
+    def scaleType(self):
+        return self.openfisca_parameter.metadata.get("type")
+
+    @property
+    def valueType(self):
+        return "parameter_scale"
+
+
+class PolicyEngineScaleComponentParameter(PolicyEngineParameter):
+    def __init__(
+        self,
+        openfisca_parameter: Parameter,
+        parent: ParameterScale,
+        is_threshold: bool,
+        value_type: str,
+        index: int,
+        date: str = NOW,
+    ):
+        super().__init__(openfisca_parameter, date)
+        self.parent = parent
+        self.is_threshold = is_threshold
+        self.type = "threshold" if is_threshold else "rate"
+        self.index = index
+        self.value_type = value_type
+
+    @property
+    def label(self):
+        metadata_label = self.openfisca_parameter.metadata.get("label")
+        if metadata_label is not None:
+            return metadata_label
+        parent_name = self.parent.metadata.get("label", self.parent.name)
+        if self.is_threshold:
+            return f"{parent_name} (threshold {self.index + 1})"
+        else:
+            return f"{parent_name} (rate {self.index + 1})"
+
+    @property
+    def name(self):
+        defined_name = self.openfisca_parameter.metadata.get("name")
+        if defined_name is not None:
+            return defined_name
+        stem = self.parent.metadata.get(
+            "name", self.parent.name.replace(".", "_")
+        )
+        if self.is_threshold:
+            return f"{stem}_{self.index}_threshold"
+        else:
+            return f"{stem}_{self.index}_rate"
+
+    @property
+    def unit(self):
+        return self.metadata.get(
+            "unit", self.parent.metadata.get(f"{self.value_type}_unit")
+        )
+
+    @property
+    def period(self):
+        default_period = "year" if self.is_threshold else None
+        return self.metadata.get(
+            "period",
+            self.parent.metadata.get(f"{self.type}_period", default_period),
+        )
+
+
+def get_enum_map_from_variable_name(
+    variable: str, variables: Dict[str, Variable]
+) -> Dict[str, str]:
+    """Get the mapping of enum values to their labels from a variable name.
+
+    Args:
+        variable (str): The name of the variable.
+        variables (Dict[str, Variable]): The variables in the tax benefit system.
+
+    Returns:
+        Dict[str, str]: The mapping of enum values to their labels.
+    """
+    if not isinstance(variable, str) or variable not in variables:
+        return {}
+    enum: Type[Enum] = variables[variable].possible_values
+    return {key: enum.value for key, enum in enum._member_map_.items()}
+
+
+def flow_breakdown_parameter_metadata_down(
+    parameters: ParameterNode, variables: List[Variable]
+) -> ParameterNode:
+    """Recursively add metadata to parameters that are part of a flow breakdown.
+
+    Args:
+        parameters (ParameterNode): The parameters to add metadata to.
+        variables (List[Variable]): The variables to use to determine the metadata.
+
+    Returns:
+        ParameterNode: The parameters with metadata added.
+    """
+    for parameter in parameters.get_descendants():
+        parameter_name = parameter.metadata.get(
+            "name", parameter.name.replace(".", "_")
+        )
+        if parameter.metadata.get("breakdown"):
+            enum_maps = [
+                get_enum_map_from_variable_name(variable, variables)
+                for variable in parameter.metadata["breakdown"]
+            ]
+            for descendant in parameter.get_descendants():
+                if not isinstance(descendant, Parameter):
+                    continue
+                descendant_path = descendant.name.replace(parameter.name, "")[
+                    1:
+                ]
+                descendant.metadata.update(
+                    {
+                        x: y
+                        for x, y in parameter.metadata.items()
+                        if x != "breakdown"
+                    }
+                )
+                descendant.metadata["name"] = (
+                    parameter_name + "_" + "_".join(descendant_path.split("."))
+                )
+                step_labels = [
+                    (key, enum_maps[i].get(key, key))
+                    for i, key in enumerate(descendant_path.split("."))
+                ]
+                step_label = f"({', '.join([x[1] for x in step_labels])})"
+                descendant.metadata["label"] = (
+                    parameter.metadata.get("label", parameter.name)
+                    + " "
+                    + step_label
+                )
+                descendant.metadata["breakdown_parts"] = step_labels
+
+    return parameters
+
+
+def build_parameters(
+    system: TaxBenefitSystem, date: str = None
+) -> Dict[str, dict]:
+    """Extracts PolicyEngine parameters from OpenFisca parameter metadata.
+
+    Args:
+        system (TaxBenefitSystem): The tax-benefit system to extract from.
+        date (str): The date to extract parameters for. Defaults to the current date.
+
+    Returns:
+        Dict[str, dict]: The parameter metadata.
+    """
+    if date is not None:
+        date = datetime.strptime(date, "%Y-%m-%d").strftime("%Y-%m-%d")
+    else:
+        date = NOW
+    parameters = []
+    system.parameters = flow_breakdown_parameter_metadata_down(
+        system.parameters, system.variables
+    )
+    for parameter in system.parameters.get_descendants():
+        if isinstance(parameter, Parameter):
+            parameters.append(PolicyEngineParameter(parameter))
+        elif isinstance(parameter, ParameterScale):
+            i = 0
+            for bracket in parameter.brackets:
+                for attribute in ("rate", "amount", "threshold"):
+                    if hasattr(bracket, attribute):
+                        component = getattr(bracket, attribute)
+                        parameters.append(
+                            PolicyEngineScaleComponentParameter(
+                                component,
+                                parameter,
+                                attribute == "threshold",
+                                attribute,
+                                i,
+                            )
+                        )
+                i += 1
+            parameters.append(PolicyEngineScaleParameter(parameter))
+        else:
+            parameters += [PolicyEngineParameter(parameter)]
+    parameter_metadata = OrderedDict()
+    for parameter in parameters:
+        try:
+            data = parameter.to_dict()
+            if data is not None:
+                parameter_metadata[data["name"]] = data
+        except:
+            pass
+    return parameter_metadata
diff --git a/policyengine/country/openfisca/reforms.py b/policyengine/country/openfisca/reforms.py
new file mode 100644
index 00000000..e402f88a
--- /dev/null
+++ b/policyengine/country/openfisca/reforms.py
@@ -0,0 +1,325 @@
+from datetime import datetime
+from pathlib import Path
+from tkinter import Variable
+from typing import Type
+from openfisca_core.parameters.helpers import load_parameter_file
+from openfisca_core.parameters import ParameterNode, Parameter, ParameterScale
+from openfisca_core.reforms.reform import Reform
+from openfisca_core.taxbenefitsystems import TaxBenefitSystem
+from openfisca_core.tracers.tracing_parameter_node_at_instant import (
+    ParameterNode,
+)
+
+
+def add_parameter_file(path: str) -> Reform:
+    """Generates a reform adding a parameter file to the tree.
+
+    Args:
+        path (str): The path to the parameter YAML file.
+
+    Returns:
+        Reform: The Reform adding the parameters.
+    """
+
+    def modify_parameters(parameters: ParameterNode):
+        file_path = Path(path)
+        reform_parameters_subtree = load_parameter_file(file_path)
+        parameters.add_child("reforms", reform_parameters_subtree.reforms)
+        return parameters
+
+    class reform(Reform):
+        def apply(self):
+            self.modify_parameters(modify_parameters)
+
+    return reform
+
+
+def structural(variable: Type[Variable]) -> Reform:
+    """Generates a structural reform.
+
+    Args:
+        variable (Type[Variable]): The class definition of a variable to replace.
+
+    Returns:
+        Reform: The reform object.
+    """
+    return type(
+        variable.__name__,
+        (Reform,),
+        dict(apply=lambda self: self.update_variable(variable)),
+    )
+
+
+def reinstate_variable(system, variable):
+    clone = system.variables[variable].clone()
+    clone.is_neutralized = False
+    system.variables[variable] = clone
+
+
+def abolish(variable: str, neutralize: bool = True) -> Reform:
+    if neutralize:
+        return type(
+            f"abolish_{variable}",
+            (Reform,),
+            dict(apply=lambda self: self.neutralize_variable(variable)),
+        )
+    else:
+        return type(
+            f"reinstate_{variable}",
+            (Reform,),
+            dict(apply=lambda self: reinstate_variable(self, variable)),
+        )
+
+
+def parametric(
+    parameter: str, value: float, period: str = "year:2015:20"
+) -> Reform:
+    """Generates a parametric reform.
+
+    Args:
+        parameter (str): The name of the parameter, e.g. tax.income_tax.rate.
+        value (float): The value to set as the parameter value.
+        period (str): The time period to set it for. Defaults to a ten-year period from 2015 to 2025.
+
+    Returns:
+        Reform: The reform object.
+    """
+
+    def modifier_fn(parameters: ParameterNode):
+        node = parameters
+        for name in parameter.split("."):
+            try:
+                if "[" not in name:
+                    node = node.children[name]
+                else:
+                    try:
+                        name, index = name.split("[")
+                        index = int(index[:-1])
+                        node = node.children[name].brackets[index]
+                    except:
+                        raise ValueError(
+                            "Invalid bracket syntax (should be e.g. tax.brackets[3].rate"
+                        )
+            except:
+                raise ValueError(
+                    f"Could not find the parameter (failed at {name})."
+                )
+        node.update(period=period, value=value)
+        return parameters
+
+    return type(
+        parameter,
+        (Reform,),
+        dict(apply=lambda self: self.modify_parameters(modifier_fn)),
+    )
+
+
+def apply_reform(reform: tuple, system: TaxBenefitSystem) -> TaxBenefitSystem:
+    """Applies a reform to a system.
+
+    Args:
+        reform (tuple): The reform to apply.
+        system (TaxBenefitSystem): The system to apply it to.
+
+    Returns:
+        TaxBenefitSystem: The system with the reform applied.
+    """
+    if not hasattr(system, "modify_parameters"):
+
+        def modify_parameters(self, modifier):
+            self.parameters = modifier(self.parameters)
+
+        system.modify_parameters = modify_parameters.__get__(system)
+    if isinstance(reform, tuple):
+        for subreform in reform:
+            system = apply_reform(subreform, system)
+    else:
+        reform.apply(system)
+    return system
+
+
+IGNORED_POLICY_PARAMETERS = [
+    "household",
+    "baseline_country_specific",
+    "country_specific",
+    "baseline_state_specific",
+    "state_specific",
+    "policy_date",
+]
+
+
+class PolicyReform:
+    """A PolicyReform is a complete specification of two policies: a baseline and a reformed policy."""
+
+    def __init__(
+        self,
+        parameters: dict,
+        policyengine_parameters: dict,
+        default_reform: Reform = None,
+    ):
+        self.parameters = {
+            key: value
+            for key, value in parameters.items()
+            if key not in IGNORED_POLICY_PARAMETERS
+        }
+        self.policyengine_parameters = policyengine_parameters
+        self.default_reform = default_reform
+        self._sanitise_parameters()
+
+    def _sanitise_parameters(self):
+        """Sanitises the parameters to ensure they are in the correct format."""
+        for key in self.parameters:
+            if isinstance(self.parameters[key], list):
+                self.parameters[key] = self.parameters[key][0]
+            metadata = self.policyengine_parameters.get(
+                key.replace("baseline_", "")
+            )
+            if metadata is None:
+                continue
+            if metadata["valueType"] == "bool":
+                # Extra safety checks
+                self.parameters[key] = {
+                    "true": True,
+                    "false": False,
+                    1: True,
+                    0: False,
+                    True: True,
+                    False: False,
+                }[self.parameters[key]]
+            try:
+                self.parameters[key] = float(self.parameters[key])
+            except:
+                pass
+
+    @property
+    def edits_baseline(self) -> bool:
+        """Returns whether the reform edits the baseline policy."""
+        return any(["baseline_" in key for key in self.parameters])
+
+    @property
+    def baseline(self) -> Reform:
+        """Returns the baseline policy."""
+        if not self.edits_baseline:
+            return Policy(
+                {},
+                self.policyengine_parameters,
+                default_reform=self.default_reform,
+            )
+        relevant_parameters = {
+            key.replace("baseline_", ""): value
+            for key, value in self.parameters.items()
+            if "baseline_" in key
+        }
+        return Policy(
+            relevant_parameters,
+            self.policyengine_parameters,
+            default_reform=self.default_reform,
+        )
+
+    @property
+    def reform(self) -> Reform:
+        """Returns the reform policy."""
+        relevant_parameters = self.baseline.parameters.copy()
+        relevant_parameters.update(
+            {
+                key: value
+                for key, value in self.parameters.items()
+                if "baseline_" not in key
+            }
+        )
+        return Policy(
+            relevant_parameters,
+            self.policyengine_parameters,
+            default_reform=self.default_reform,
+        )
+
+
+class Policy:
+    def __init__(
+        self,
+        parameters: dict,
+        policyengine_parameters: dict,
+        default_reform: Reform = None,
+    ):
+        self.parameters = parameters
+        self.policyengine_parameters = policyengine_parameters
+        self.default_reform = default_reform
+
+    def __repr__(self):
+        items = list(self.parameters.items())
+        if len(items) == 0:
+            return "<Policy (empty)>"
+        return f"<Policy {' '.join([f'{key}={value}' for key, value in items[:4]])}{f' ...+{len(items)-4}' if len(items) > 4 else ''}>"
+
+    def apply(self, system: TaxBenefitSystem) -> TaxBenefitSystem:
+        """Applies the policy to a system. This essentially does the same as any `Reform.apply` method.
+
+        Args:
+            system (TaxBenefitSystem): The system to apply it to.
+        """
+        if self.default_reform is not None:
+            system = apply_reform(self.default_reform, system)
+        for key, value in self.parameters.items():
+            metadata = self.policyengine_parameters[key]
+            if metadata["unit"] == "abolition":
+                variables_to_neutralise = metadata["variable"]
+                if not isinstance(variables_to_neutralise, list):
+                    variables_to_neutralise = [variables_to_neutralise]
+                for variable in variables_to_neutralise:
+                    if system.variables[variable].is_neutralized:
+                        reinstate_variable(system, variable)
+                    else:
+                        system.neutralize_variable(variable)
+            else:
+                parametric(metadata["parameter"], value).apply(system)
+
+
+def use_current_parameters(date: str = None) -> Reform:
+    """Backdates parameters at a given instant to the start of the year.
+
+    Args:
+        date (str, optional): The given instant. Defaults to now.
+
+    Returns:
+        Reform: The reform backdating parameters.
+    """
+    if date is None:
+        date = datetime.now()
+    else:
+        date = datetime.strptime(date, "%Y-%m-%d")
+
+    year = date.year
+    date = datetime.strftime(date, "%Y-%m-%d")
+
+    def modify_parameters(parameters: ParameterNode):
+        for child in parameters.get_descendants():
+            if isinstance(child, Parameter):
+                current_value = child(date)
+                child.update(period=f"year:{year-10}:20", value=current_value)
+            elif isinstance(child, ParameterScale):
+                for bracket in child.brackets:
+                    if "rate" in bracket.children:
+                        current_rate = bracket.rate(date)
+                        bracket.rate.update(
+                            period=f"year:{year-10}:20", value=current_rate
+                        )
+                    if "threshold" in bracket.children:
+                        current_threshold = bracket.threshold(date)
+                        bracket.threshold.update(
+                            period=f"year:{year-10}:20",
+                            value=current_threshold,
+                        )
+        try:
+            parameters.reforms.policy_date.update(
+                value=int(datetime.now().strftime("%Y%m%d")),
+                period=f"year:{year-10}:20",
+            )
+        except:
+            pass
+        return parameters
+
+    class reform(Reform):
+        def apply(self):
+            self.modify_parameters(modify_parameters)
+
+    return reform
diff --git a/policyengine/country/openfisca/variables.py b/policyengine/country/openfisca/variables.py
new file mode 100644
index 00000000..ceb12d8d
--- /dev/null
+++ b/policyengine/country/openfisca/variables.py
@@ -0,0 +1,140 @@
+from typing import Callable, Dict, List, Tuple
+from openfisca_core.taxbenefitsystems import TaxBenefitSystem
+from openfisca_core.model_api import Enum, Variable
+from openfisca_tools.model_api import FLOW
+from openfisca_tools import IndividualSim
+from collections import Sequence
+
+
+class PolicyEngineVariable:
+    PROPERTIES: List[str] = (
+        "name",
+        "unit",
+        "label",
+        "description",
+        "valueType",
+        "defaultValue",
+        "definitionPeriod",
+        "entity",
+        "possibleValues",
+        "reference",
+        "quantityType",
+    )
+
+    def __init__(self, openfisca_variable: Variable):
+        self.openfisca_variable = openfisca_variable
+
+    @property
+    def reference(self):
+        try:
+            if isinstance(self.openfisca_variable.reference, Sequence):
+                return {v: v for v in self.openfisca_variable.reference}
+            elif self.openfisca_variable.reference is None:
+                return {}
+            elif isinstance(reference, dict):
+                reference = [reference]
+            else:
+                return {
+                    self.openfisca_variable.reference: self.openfisca_variable.reference
+                }
+        except:
+            return {}
+
+    @property
+    def description(self):
+        if self.openfisca_variable.documentation is not None:
+            description = self.openfisca_variable.documentation
+            if description[-1] != ".":
+                description += "."
+        else:
+            description = None
+        return description
+
+    @property
+    def possibleValues(self):
+        if self.openfisca_variable.value_type == Enum:
+            if len(self.openfisca_variable.possible_values) > 200:
+                raise ValueError(
+                    f"Enums with more than 100 values are not supported ({self.openfisca_variable.name} has {len(self.openfisca_variable.possible_values)})."
+                )
+            return [
+                dict(key=enum.name, value=enum.value)
+                for enum in self.openfisca_variable.possible_values
+            ]
+        else:
+            return None
+
+    @property
+    def defaultValue(self):
+        if self.openfisca_variable.value_type == Enum:
+            return dict(
+                key=self.openfisca_variable.default_value.name,
+                value=self.openfisca_variable.default_value.value,
+            )
+        else:
+            return self.openfisca_variable.default_value
+
+    @property
+    def quantityType(self):
+        if hasattr(self.openfisca_variable, "quantity_type"):
+            return self.openfisca_variable.quantity_type.lower()
+        else:
+            return FLOW.lower()
+
+    @property
+    def valueType(self):
+        return self.openfisca_variable.value_type.__name__
+
+    @property
+    def entity(self):
+        return self.openfisca_variable.entity.key
+
+    @property
+    def definitionPeriod(self):
+        return self.openfisca_variable.definition_period
+
+    def __getattr__(self, key: str):
+        if hasattr(self.openfisca_variable, key):
+            return getattr(self.openfisca_variable, key)
+        else:
+            raise ValueError(f"Property {key} not found.")
+
+    def to_dict(self) -> dict:
+        """Return a dictionary representation of the variable.
+
+        Raises:
+            ValueError: If a required property is not found. This will likely be due to a change in the OpenFisca API.
+
+        Returns:
+            dict: The variable metadata.
+        """
+        data = {}
+        for prop in self.PROPERTIES:
+            try:
+                if hasattr(self, prop):
+                    data[prop] = getattr(self, prop)
+                else:
+                    raise ValueError(f"Property {prop} not found.")
+            except:
+                # In the case of an exception, abandon the entire variable.
+                return None
+        return data
+
+
+def build_variables(system: TaxBenefitSystem) -> Dict[str, dict]:
+    """Extracts PolicyEngine parameters from OpenFisca parameter metadata.
+
+    Args:
+        system (TaxBenefitSystem): The tax-benefit system to extract from.
+
+    Returns:
+        Dict[str, dict]: The parameter metadata.
+    """
+    variables = system.variables.values()
+    variable_metadata = {}
+    for variable in variables:
+        processed_variable = PolicyEngineVariable(variable)
+        data = processed_variable.to_dict()
+        if data is not None:
+            variable_metadata[variable.name] = data
+    return variable_metadata
diff --git a/policyengine/country/results_config.py b/policyengine/country/results_config.py
new file mode 100644
index 00000000..171a0959
--- /dev/null
+++ b/policyengine/country/results_config.py
@@ -0,0 +1,24 @@
+class PolicyEngineResultsConfig:
+    """Configuration class for calculating and displaying policy impacts using an OpenFisca country model."""
+
+    in_poverty_variable: str
+    in_deep_poverty_variable: str
+
+    household_net_income_variable: str
+    household_wealth_variable: str
+    equiv_household_net_income_variable: str
+
+    child_variable: str
+    working_age_variable: str
+    senior_variable: str
+    person_variable: str
+
+    tax_variable: str
+    benefit_variable: str
+    employment_income_variable: str
+    self_employment_income_variable: str
+    total_income_variable: str
+
+    currency: str
+    household_entity: str
+    region_variable: str
diff --git a/policyengine/country/uk/__init__.py b/policyengine/country/uk/__init__.py
new file mode 100644
index 00000000..48f5b690
--- /dev/null
+++ b/policyengine/country/uk/__init__.py
@@ -0,0 +1,1 @@
+from .uk import UK
diff --git a/policyengine/countries/uk/reform_parameters.yaml b/policyengine/country/uk/additional_parameters.yaml
similarity index 100%
rename from policyengine/countries/uk/reform_parameters.yaml
rename to policyengine/country/uk/additional_parameters.yaml
diff --git a/policyengine/countries/uk/default_reform/reform.py b/policyengine/country/uk/default_reform.py
similarity index 91%
rename from policyengine/countries/uk/default_reform/reform.py
rename to policyengine/country/uk/default_reform.py
index a462525e..aaea0c9f 100644
--- a/policyengine/countries/uk/default_reform/reform.py
+++ b/policyengine/country/uk/default_reform.py
@@ -13,6 +13,11 @@
 import warnings
 from openfisca_core.model_api import Reform
 
+from policyengine.country.openfisca.reforms import (
+    add_parameter_file,
+    use_current_parameters,
+)
+
 
 warnings.filterwarnings("ignore")
 baseline_parameters = CountryTaxBenefitSystem().parameters
@@ -95,30 +100,6 @@ def create_default_reform() -> ReformType:
         for name, variable in baseline_system.variables.items()
     }
 
-    class net_financial_wealth_tax(Variable):
-        entity = Household
-        label = "Wealth tax"
-        documentation = "Flat tax on net financial wealth"
-        unit = "currency-GBP"
-        definition_period = YEAR
-        value_type = float
-
-        def formula(household, period, parameters):
-            rate = parameters(period).reforms.wealth_tax.rate
-            return max_(0, household("net_financial_wealth", period)) * rate
-
-    class property_tax(Variable):
-        entity = Household
-        label = "Property tax"
-        documentation = "Flat tax on property values"
-        unit = "currency-GBP"
-        definition_period = YEAR
-        value_type = float
-
-        def formula(household, period, parameters):
-            rate = parameters(period).reforms.property_tax.rate
-            return household("property_wealth", period) * rate
-
     class LVT(Variable):
         entity = Household
         label = "Land value tax"
@@ -173,8 +154,6 @@ def formula(household, period, parameters):
             return (
                 original_tax
                 + household("LVT", period)
-                + household("net_financial_wealth_tax", period)
-                + household("property_tax", period)
                 + household("carbon_tax", period)
             )
 
@@ -337,10 +316,6 @@ def apply(self):
             self.update_variable(carbon_tax)
             self.update_variable(household_tax)
             self.modify_parameters(add_extra_band)
-            self.add_variables(
-                net_financial_wealth_tax,
-                property_tax,
-            )
 
             self.update_variable(meets_marriage_allowance_income_conditions)
 
@@ -351,5 +326,9 @@ def apply(self):
             self.update_variable(smf_tax_cash_payment)
             self.update_variable(household_benefits)
             self.update_variable(personal_allowance)
+            add_parameter_file(
+                Path(__file__).parent / "additional_parameters.yaml"
+            ).apply(self)
+            use_current_parameters().apply(self)
 
     return default_reform
diff --git a/policyengine/country/uk/results_config.py b/policyengine/country/uk/results_config.py
new file mode 100644
index 00000000..fbb462ab
--- /dev/null
+++ b/policyengine/country/uk/results_config.py
@@ -0,0 +1,25 @@
+from policyengine.country.results_config import PolicyEngineResultsConfig
+
+
+class UKResultsConfig(PolicyEngineResultsConfig):
+    in_poverty_variable = "in_poverty_bhc"
+    in_deep_poverty_variable = "in_deep_poverty_bhc"
+
+    household_net_income_variable = "household_net_income"
+    household_wealth_variable = "total_wealth"
+    equiv_household_net_income_variable = "equiv_household_net_income"
+
+    child_variable = "is_child"
+    working_age_variable = "is_WA_adult"
+    senior_variable = "is_SP_age"
+    person_variable = "people"
+
+    tax_variable = "household_tax"
+    benefit_variable = "household_benefits"
+    employment_income_variable = "employment_income"
+    self_employment_income_variable = "self_employment_income"
+    total_income_variable = "total_income"
+
+    currency = "£"
+    household_entity = "household"
+    region_variable = "region"
diff --git a/policyengine/country/uk/uk.py b/policyengine/country/uk/uk.py
new file mode 100644
index 00000000..e46984e2
--- /dev/null
+++ b/policyengine/country/uk/uk.py
@@ -0,0 +1,46 @@
+import numpy as np
+from policyengine.country.uk.default_reform import create_default_reform
+from policyengine.country.uk.results_config import UKResultsConfig
+from .. import PolicyEngineCountry
+import openfisca_uk
+
+
+class UK(PolicyEngineCountry):
+    openfisca_country_model = openfisca_uk
+    default_reform = create_default_reform()
+    results_config = UKResultsConfig
+
+    def create_microsimulations(self, parameters):
+        baseline, reformed = super().create_microsimulations(parameters)
+        filtered_country = parameters.get("baseline_country_specific")
+        if filtered_country is not None:
+            # Specific country selected: filter out other countries.
+            household_weights = baseline.calc("household_weight")
+            country = baseline.calc("country")
+            baseline.set_input(
+                "household_weight",
+                baseline.year,
+                np.where(country == filtered_country, household_weights, 0),
+            )
+            reformed.set_input(
+                "household_weight",
+                reformed.year,
+                np.where(country == filtered_country, household_weights, 0),
+            )
+            person_weights = baseline.calc("person_weight")
+            person_country = baseline.calc("country", map_to="person")
+            baseline.set_input(
+                "person_weight",
+                baseline.year,
+                np.where(
+                    person_country == filtered_country, person_weights, 0
+                ),
+            )
+            reformed.set_input(
+                "person_weight",
+                reformed.year,
+                np.where(
+                    person_country == filtered_country, person_weights, 0
+                ),
+            )
+        return baseline, reformed
diff --git a/policyengine/country/us/__init__.py b/policyengine/country/us/__init__.py
new file mode 100644
index 00000000..4791f58d
--- /dev/null
+++ b/policyengine/country/us/__init__.py
@@ -0,0 +1,1 @@
+from .us import US
diff --git a/policyengine/countries/us/reform_parameters.yaml b/policyengine/country/us/additional_parameters.yaml
similarity index 100%
rename from policyengine/countries/us/reform_parameters.yaml
rename to policyengine/country/us/additional_parameters.yaml
diff --git a/policyengine/countries/us/default_reform.py b/policyengine/country/us/default_reform.py
similarity index 59%
rename from policyengine/countries/us/default_reform.py
rename to policyengine/country/us/default_reform.py
index eaab6792..4a3e4097 100644
--- a/policyengine/countries/us/default_reform.py
+++ b/policyengine/country/us/default_reform.py
@@ -2,11 +2,16 @@
     spm_unit_net_income as original_spm_unit_net_income,
 )
 from openfisca_us.model_api import *
+from ..openfisca.reforms import add_parameter_file, use_current_parameters
 
 
 def create_default_reform():
     class us_default_reform(Reform):
         def apply(self):
             self.neutralize_variable("spm_unit_net_income_reported")
+            add_parameter_file(
+                Path(__file__).parent / "additional_parameters.yaml"
+            ).apply(self)
+            use_current_parameters().apply(self)
 
     return us_default_reform
diff --git a/policyengine/country/us/results_config.py b/policyengine/country/us/results_config.py
new file mode 100644
index 00000000..f4269121
--- /dev/null
+++ b/policyengine/country/us/results_config.py
@@ -0,0 +1,26 @@
+from policyengine.country.results_config import PolicyEngineResultsConfig
+
+
+class USResultsConfig(PolicyEngineResultsConfig):
+    in_poverty_variable = "spm_unit_is_in_spm_poverty"
+    in_deep_poverty_variable = "spm_unit_is_in_deep_spm_poverty"
+
+    household_net_income_variable = "spm_unit_net_income"
+    # Placeholder until we implement wealth data in OpenFisca US.
+    household_wealth_variable = "spm_unit_net_income"
+    equiv_household_net_income_variable = "spm_unit_oecd_equiv_net_income"
+
+    child_variable = "is_child"
+    working_age_variable = "is_wa_adult"
+    senior_variable = "is_senior"
+    person_variable = "people"
+
+    tax_variable = "spm_unit_taxes"
+    benefit_variable = "spm_unit_benefits"
+    employment_income_variable = "employment_income"
+    self_employment_income_variable = "self_employment_income"
+    total_income_variable = "spm_unit_market_income"
+
+    currency = "$"
+    household_entity = "spm_unit"
+    region_variable = "state"
diff --git a/policyengine/countries/us/__init__.py b/policyengine/country/us/us.py
similarity index 50%
rename from policyengine/countries/us/__init__.py
rename to policyengine/country/us/us.py
index 16295fd5..9765ebcc 100644
--- a/policyengine/countries/us/__init__.py
+++ b/policyengine/country/us/us.py
@@ -1,60 +1,31 @@
-from pathlib import Path
-from typing import Tuple
 import numpy as np
-from openfisca_us import CountryTaxBenefitSystem
-from openfisca_us.entities import *
-from policyengine.countries.us.default_reform import create_default_reform
-from policyengine.utils.general import PolicyEngineResultsConfig
-from policyengine.countries.country import PolicyEngineCountry
-from openfisca_us import IndividualSim, Microsimulation, CPS
-
-US_FOLDER = Path(__file__).parent
-
-
-class USResultsConfig(PolicyEngineResultsConfig):
-    household_net_income_variable: str = "spm_unit_net_income"
-    tax_variable: str = "spm_unit_taxes"
-    benefit_variable: str = "spm_unit_benefits"
-    employment_income_variable: str = "employment_income"
-    self_employment_income_variable: str = "self_employment_income"
-    total_income_variable: str = "spm_unit_market_income"
-    in_poverty_variable = "spm_unit_is_in_spm_poverty"
-    in_deep_poverty_variable = "spm_unit_is_in_deep_spm_poverty"
-    household_wealth_variable = "spm_unit_net_income"  # Placeholder
-    equiv_household_net_income_variable = "spm_unit_oecd_equiv_net_income"
-    child_variable = "is_child"
-    working_age_variable = "is_wa_adult"
-    senior_variable = "is_senior"
-    person_variable = "people"
-    earnings_variable = "employment_income"
-    household_entity = "spm_unit"
-    currency = "$"
+from policyengine.country.us.default_reform import create_default_reform
+from policyengine.country.us.results_config import USResultsConfig
+from .. import PolicyEngineCountry
+import openfisca_us
 
 
 class US(PolicyEngineCountry):
-    name = "us"
-    system = CountryTaxBenefitSystem
-    IndividualSim = IndividualSim
-    results_config = USResultsConfig
-    Microsimulation = Microsimulation
-    default_dataset = CPS
-    default_dataset_year = 2020
+    openfisca_country_model = openfisca_us
     default_reform = create_default_reform()
-    parameter_file = US_FOLDER / "reform_parameters.yaml"
-
-    def __init__(self):
-        if not self.default_dataset_year in self.default_dataset.years:
-            self.default_dataset.download(self.default_dataset_year)
-        super().__init__()
+    results_config = USResultsConfig
 
-    def _get_microsimulations(
-        self, params: dict
-    ) -> Tuple[Microsimulation, Microsimulation]:
-        if isinstance(params, dict) and "baseline_state_specific" in params:
-            baseline, reformed = super()._get_microsimulations(
-                params, refresh_baseline=True
-            )
-            filtered_state = params["baseline_state_specific"]
+    def create_microsimulations(self, parameters):
+        baseline, reformed = super().create_microsimulations(parameters)
+        filtered_state = parameters.get("baseline_state_specific")
+        if filtered_state is None:
+            # US-wide analyses hold State tax policy fixed.
+            for sim in baseline, reformed:
+                reported_state_tax = sim.calc(
+                    "spm_unit_state_tax_reported"
+                ).values
+                sim.set_input(
+                    "spm_unit_state_tax",
+                    2022,
+                    reported_state_tax,
+                )
+        else:
+            # Specific State selected: filter out other States.
             household_weights = baseline.calc("household_weight")
             state = baseline.calc("state_code")
             baseline.set_input(
@@ -98,16 +69,5 @@ def _get_microsimulations(
                     baseline.year,
                     np.where(subgroup_in_state, weight, 0),
                 )
-        else:
-            baseline, reformed = super()._get_microsimulations(params)
-            # US-wide, take State tax as reported
-            for sim in baseline, reformed:
-                reported_state_tax = sim.calc(
-                    "spm_unit_state_tax_reported"
-                ).values
-                sim.set_input(
-                    "spm_unit_state_tax",
-                    2022,
-                    reported_state_tax,
-                )
+
         return baseline, reformed
diff --git a/policyengine/impact/household/charts.py b/policyengine/impact/household/charts/budget.py
similarity index 52%
rename from policyengine/impact/household/charts.py
rename to policyengine/impact/household/charts/budget.py
index 7ac08c07..8c0a3373 100644
--- a/policyengine/impact/household/charts.py
+++ b/policyengine/impact/household/charts/budget.py
@@ -1,18 +1,18 @@
 from typing import Callable, Type
 import numpy as np
-from openfisca_uk import IndividualSim
-from policyengine.utils.general import PolicyEngineResultsConfig
-from policyengine.utils import charts
+from openfisca_tools import IndividualSim
+from policyengine.country.results_config import PolicyEngineResultsConfig
+from policyengine.impact.utils import *
 import plotly.express as px
 import pandas as pd
 import plotly.graph_objects as go
 
 
 COLOR_MAP = {
-    "Baseline": charts.GRAY,
-    "Reform": charts.BLUE,
-    "Marginal tax rate": charts.BLUE,
-    "Net income": charts.BLUE,
+    "Baseline": GRAY,
+    "Reform": BLUE,
+    "Marginal tax rate": BLUE,
+    "Net income": BLUE,
 }
 
 
@@ -118,6 +118,8 @@ def budget_chart(
     config: Type[PolicyEngineResultsConfig],
     has_reform: bool = True,
     original_total_income: float = None,
+    original_tax: float = None,
+    original_benefits: float = None,
 ) -> str:
     """Produces line chart with employment income on the x axis and net income
     on the y axis, for baseline and reform simulations.
@@ -141,13 +143,14 @@ def budget_chart(
             variable_values[explaining_variable + "_baseline"] = baseline.calc(
                 explaining_variable
             ).sum(axis=0)
+            variable_values[explaining_variable + "_reform"] = reformed.calc(
+                explaining_variable
+            ).sum(axis=0)
         else:
             variable_values[explaining_variable + "_baseline"] = [
                 baseline.calc(explaining_variable).sum(axis=0)[i]
             ] * len(total_income)
-        variable_values[explaining_variable + "_reform"] = reformed.calc(
-            explaining_variable
-        ).sum(axis=0)
+
     explainer_names = []
     if DEBUG_MODE:
         for variable in DEBUG_VARIABLES:
@@ -175,12 +178,15 @@ def budget_chart(
             "Baseline": baseline.calc(
                 config.household_net_income_variable
             ).sum(axis=0),
-            "Reform": reformed.calc(config.household_net_income_variable).sum(
-                axis=0
-            ),
             **variable_values,
         }
     )
+    if has_reform:
+        df["Reform"] = reformed.calc(config.household_net_income_variable).sum(
+            axis=0
+        )
+    else:
+        df["Reform"] = df.Baseline
     original_net_income = (
         original_total_income
         + df[config.benefit_variable + "_baseline"][i]
@@ -194,9 +200,11 @@ def budget_chart(
             x["Total income"] if has_reform else original_total_income,
             x["Total income"],
             x[config.tax_variable + "_baseline"],
-            x[config.tax_variable + "_reform"],
+            x[config.tax_variable + "_reform"] if has_reform else original_tax,
             x[config.benefit_variable + "_baseline"],
-            x[config.benefit_variable + "_reform"],
+            x[config.benefit_variable + "_reform"]
+            if has_reform
+            else original_benefits,
             config,
         ),
         axis=1,
@@ -219,9 +227,10 @@ def budget_chart(
     fig = go.Figure()
     # Shade baseline and reformed net income cliffs.
     ymax = df[y_fig].max().max() * 1.05  # Add a buffer.
-    shade_cliffs(baseline, config, fig, charts.GRAY, ymax)
-    shade_cliffs(reformed, config, fig, charts.BLUE, ymax)
-    charts.add_zero_line(fig)
+    shade_cliffs(baseline, config, fig, GRAY, ymax)
+    if has_reform:
+        shade_cliffs(reformed, config, fig, BLUE, ymax)
+    add_zero_line(fig)
     add_you_are_here(fig, df["Total income"][i])
     line_chart = px.line(
         df.round(0),
@@ -232,7 +241,7 @@ def budget_chart(
         custom_data=["hover"],
     )
     fig.add_traces(list(line_chart.select_traces()))
-    charts.add_custom_hovercard(fig)
+    add_custom_hovercard(fig)
     fig.update_layout(
         title=d_title,
         xaxis_title="Employment income",
@@ -245,11 +254,11 @@ def budget_chart(
         legend_title=None,
     )
     if show_difference:
-        fig.update_traces(line_color=charts.BLUE)
+        fig.update_traces(line_color=BLUE)
     # Hide legend if there's no reform.
     if not has_reform and not DEBUG_MODE:
         fig.update_layout(showlegend=False)
-    return charts.formatted_fig_json(fig)
+    return formatted_fig_json(fig)
 
 
 def add_you_are_here(fig: go.Figure, x):
@@ -265,22 +274,6 @@ def add_you_are_here(fig: go.Figure, x):
     )
 
 
-def describe_change(
-    x: float,
-    y: float,
-    formatter: Callable = lambda x: x,
-    change_formatter=lambda x: x,
-    plural: bool = False,
-) -> str:
-    s = "" if plural else "s"
-    if y > x:
-        return f"rise{s} from {formatter(x)} to {formatter(y)} (+{change_formatter(y - x)})"
-    elif y == x:
-        return f"remain{s} at {formatter(x)}"
-    else:
-        return f"fall{s} from {formatter(x)} to {formatter(y)} (-{change_formatter(x - y)})"
-
-
 def budget_hover_label(
     earnings: float,
     baseline_budget: float,
@@ -310,206 +303,3 @@ def formatter(x):
         benefits_baseline, benefits_reform, formatter, formatter, plural=True
     )
     return f"<b>At {earnings_str} employment income:<br>Your net income {budget_change} </b><br><br>Market income {total_income_change}<br>Tax {tax_change}<br>Benefits {benefits_change}"
-
-
-def mtr_hover_label(
-    earnings: float,
-    baseline_mtr: float,
-    reform_mtr: float,
-    tax_baseline,
-    tax_reform,
-    benefits_baseline,
-    benefits_reform,
-    config: Type[PolicyEngineResultsConfig],
-) -> str:
-    earnings_str = f"{config.currency}{round(earnings):,}"
-
-    def pct_formatter(x):
-        return str(round(x * 1000) / 10) + "%"
-
-    def pp_formatter(x):
-        return str(round(x * 1000) / 10) + "pp"
-
-    mtr_change = describe_change(
-        baseline_mtr, reform_mtr, pct_formatter, pp_formatter
-    )
-    tax_change = describe_change(
-        tax_baseline, tax_reform, pct_formatter, pp_formatter
-    )
-    benefits_change = describe_change(
-        benefits_baseline, benefits_reform, pct_formatter, pp_formatter
-    )
-    return f"<b>At {earnings_str} employment income:<br>Your MTR {mtr_change}</b><br><br>Tax MTR {tax_change}<br>Benefits MTR {benefits_change}"
-
-
-def get_mtr(x, y):
-    return 1 - ((y[1:] - y[:-1]) / (x[1:] - x[:-1]))
-
-
-def mtr_chart(
-    baseline: IndividualSim,
-    reformed: IndividualSim,
-    show_difference: bool,
-    config: Type[PolicyEngineResultsConfig],
-    has_reform: bool = True,
-    original_total_income: float = None,
-) -> str:
-    """Produces line chart with employment income on the x axis and marginal
-    tax rate on the y axis, for baseline and reform simulations.
-    :param baseline: Baseline simulation.
-    :type baseline: IndividualSim
-    :param reformed: Reform simulation.
-    :type reformed: IndividualSim
-    :return: Representation of the marginal tax rate plotly chart as a JSON
-        string.
-    :rtype: str
-    """
-    earnings = baseline.calc(config.total_income_variable).sum(axis=0)
-    baseline_net = baseline.calc(config.household_net_income_variable).sum(
-        axis=0
-    )
-    reform_net = reformed.calc(config.household_net_income_variable).sum(
-        axis=0
-    )
-
-    total_income = baseline.calc(config.total_income_variable).sum(axis=0)
-    # Find the x-point on the chart which is the current situation
-    i = (total_income < original_total_income).sum()
-
-    if has_reform:
-        baseline_mtr = get_mtr(earnings, baseline_net)
-    else:
-        baseline_mtr = [get_mtr(earnings, baseline_net)[i]] * (
-            len(baseline_net) - 1
-        )
-    reform_mtr = get_mtr(earnings, reform_net)
-    variable_mtrs = {}
-
-    for explaining_variable, inverted, name in zip(
-        (
-            config.tax_variable,
-            config.benefit_variable,
-        ),
-        (False, True, False),
-        (
-            "tax",
-            "benefits",
-        ),
-    ):
-        baseline_values = baseline.calc(explaining_variable).sum(axis=0)
-        reform_values = reformed.calc(explaining_variable).sum(axis=0)
-        multiplier = 1 if inverted else -1
-        addition = -1 if inverted else 1
-        if has_reform:
-            variable_mtrs[name + "_baseline"] = (
-                get_mtr(earnings, baseline_values) * multiplier + addition
-            )
-        else:
-            variable_mtrs[name + "_baseline"] = [
-                (get_mtr(earnings, baseline_values) * multiplier + addition)[i]
-            ] * (len(total_income) - 1)
-        variable_mtrs[name + "_reform"] = (
-            get_mtr(earnings, reform_values) * multiplier + addition
-        )
-    inverted = False
-    if DEBUG_MODE:
-        explainer_names = []
-        for variable in DEBUG_VARIABLES:
-            baseline_values = baseline.calc(variable).sum(axis=0)
-            multiplier = 1 if inverted else -1
-            addition = -1 if inverted else 1
-            name = (
-                baseline.simulation.tax_benefit_system.variables[
-                    variable
-                ].label
-                or variable
-            )
-            explainer_names += [name]
-            variable_mtrs[name] = (
-                get_mtr(earnings, baseline_values) * multiplier + addition
-            )
-    df = pd.DataFrame(
-        {
-            "Earnings": earnings[:-1].round(0),
-            "Baseline": baseline_mtr,
-            "Reform": reform_mtr,
-            **variable_mtrs,
-        }
-    )
-    if not has_reform:
-        df["Marginal tax rate"] = df["Baseline"]
-    df["hover"] = df.apply(
-        lambda x: mtr_hover_label(
-            x.Earnings,
-            x.Baseline,
-            x.Reform,
-            x.tax_baseline,
-            x.tax_reform,
-            x.benefits_baseline,
-            x.benefits_reform,
-            config,
-        ),
-        axis=1,
-    )
-    if not has_reform:
-        df["Marginal tax rate"] = df["Reform"]
-    df["Difference"] = df["Reform"] - df["Baseline"]
-    if show_difference:
-        y_fig = "Difference"
-        d_title = "Difference in marginal tax rate by employment income"
-        y_title = "Difference in marginal tax rate"
-    else:
-        y_fig = (
-            ["Baseline", "Reform"]
-            if has_reform
-            else ["Marginal tax rate"]
-            + (explainer_names if DEBUG_MODE else [])
-        )
-        d_title = "Marginal tax rate by employment income"
-        y_title = "Marginal tax rate"
-    line_chart = px.line(
-        df,
-        x="Earnings",
-        y=y_fig,
-        labels=dict(LABELS, value="Marginal tax rate"),
-        color_discrete_map=COLOR_MAP,
-        custom_data=["hover"],
-        line_shape="hv",
-    )
-    # Shade baseline and reformed net income cliffs.
-    ymax = df[y_fig].max() * 1.05  # Add a buffer.
-    fig = go.Figure()
-    shade_cliffs(baseline, config, fig, charts.GRAY, ymax)
-    shade_cliffs(reformed, config, fig, charts.BLUE, ymax)
-    fig.add_traces(list(line_chart.select_traces()))
-    add_you_are_here(fig, df.Earnings[i])
-    charts.add_zero_line(fig)
-    charts.add_custom_hovercard(fig)
-    fig.update_layout(
-        title=d_title,
-        xaxis_title="Employment income",
-        xaxis_tickprefix=config.currency,
-        yaxis_tickformat=",.0%",
-        yaxis_title=y_title,
-        yaxis_range=(min(0, np.floor(df["Reform"].min() * 10) / 10), 1),
-        xaxis_showgrid=False,
-        yaxis_showgrid=False,
-        legend_title=None,
-    )
-    if show_difference:
-        fig.update_layout(
-            yaxis_range=(min(0, np.floor(df[y_fig].min() * 10) / 10), 1),
-        )
-        fig.update_traces(line_color=charts.BLUE)
-    # Hide legend if there's no reform.
-    if not has_reform and not DEBUG_MODE:
-        fig.update_layout(showlegend=False)
-    return charts.formatted_fig_json(fig)
-
-
-def household_waterfall_chart(
-    baseline: IndividualSim,
-    reformed: IndividualSim,
-    config: Type[PolicyEngineResultsConfig],
-) -> dict:
-    return charts.waterfall_chart(baseline, reformed, config)
diff --git a/policyengine/impact/household/charts/marginal_tax_rate.py b/policyengine/impact/household/charts/marginal_tax_rate.py
new file mode 100644
index 00000000..3c107282
--- /dev/null
+++ b/policyengine/impact/household/charts/marginal_tax_rate.py
@@ -0,0 +1,217 @@
+from typing import Callable, Type
+import numpy as np
+from openfisca_tools import IndividualSim
+from policyengine.country.results_config import PolicyEngineResultsConfig
+from policyengine.impact.utils import *
+import plotly.express as px
+import pandas as pd
+import plotly.graph_objects as go
+from .budget import shade_cliffs, LABELS, COLOR_MAP, add_you_are_here
+
+DEBUG_MODE = False
+
+DEBUG_VARIABLES = [
+    "ssi",
+]
+
+
+def mtr_hover_label(
+    earnings: float,
+    baseline_mtr: float,
+    reform_mtr: float,
+    tax_baseline,
+    tax_reform,
+    benefits_baseline,
+    benefits_reform,
+    config: Type[PolicyEngineResultsConfig],
+) -> str:
+    earnings_str = f"{config.currency}{round(earnings):,}"
+
+    def pct_formatter(x):
+        return str(round(x * 1000) / 10) + "%"
+
+    def pp_formatter(x):
+        return str(round(x * 1000) / 10) + "pp"
+
+    mtr_change = describe_change(
+        baseline_mtr, reform_mtr, pct_formatter, pp_formatter
+    )
+    tax_change = describe_change(
+        tax_baseline, tax_reform, pct_formatter, pp_formatter
+    )
+    benefits_change = describe_change(
+        benefits_baseline, benefits_reform, pct_formatter, pp_formatter
+    )
+    return f"<b>At {earnings_str} employment income:<br>Your MTR {mtr_change}</b><br><br>Tax MTR {tax_change}<br>Benefits MTR {benefits_change}"
+
+
+def get_mtr(x, y):
+    return 1 - ((y[1:] - y[:-1]) / (x[1:] - x[:-1]))
+
+
+def mtr_chart(
+    baseline: IndividualSim,
+    reformed: IndividualSim,
+    show_difference: bool,
+    config: Type[PolicyEngineResultsConfig],
+    has_reform: bool = True,
+    original_total_income: float = None,
+) -> str:
+    """Produces line chart with employment income on the x axis and marginal
+    tax rate on the y axis, for baseline and reform simulations.
+    :param baseline: Baseline simulation.
+    :type baseline: IndividualSim
+    :param reformed: Reform simulation.
+    :type reformed: IndividualSim
+    :return: Representation of the marginal tax rate plotly chart as a JSON
+        string.
+    :rtype: str
+    """
+    earnings = baseline.calc(config.total_income_variable).sum(axis=0)
+    baseline_net = baseline.calc(config.household_net_income_variable).sum(
+        axis=0
+    )
+    if has_reform:
+        reform_net = reformed.calc(config.household_net_income_variable).sum(
+            axis=0
+        )
+
+    total_income = baseline.calc(config.total_income_variable).sum(axis=0)
+    # Find the x-point on the chart which is the current situation
+    i = (total_income < original_total_income).sum()
+
+    if has_reform:
+        baseline_mtr = get_mtr(earnings, baseline_net)
+        reform_mtr = get_mtr(earnings, reform_net)
+    else:
+        baseline_mtr = [get_mtr(earnings, baseline_net)[i]] * (
+            len(baseline_net) - 1
+        )
+        reform_mtr = get_mtr(earnings, baseline_net)
+    variable_mtrs = {}
+
+    for explaining_variable, inverted, name in zip(
+        (
+            config.tax_variable,
+            config.benefit_variable,
+        ),
+        (False, True, False),
+        (
+            "tax",
+            "benefits",
+        ),
+    ):
+        baseline_values = baseline.calc(explaining_variable).sum(axis=0)
+        reform_values = (
+            reformed.calc(explaining_variable).sum(axis=0)
+            if has_reform
+            else baseline_values
+        )
+        multiplier = 1 if inverted else -1
+        addition = -1 if inverted else 1
+        if has_reform:
+            variable_mtrs[name + "_baseline"] = (
+                get_mtr(earnings, baseline_values) * multiplier + addition
+            )
+        else:
+            variable_mtrs[name + "_baseline"] = [
+                (get_mtr(earnings, baseline_values) * multiplier + addition)[i]
+            ] * (len(total_income) - 1)
+        variable_mtrs[name + "_reform"] = (
+            get_mtr(earnings, reform_values) * multiplier + addition
+        )
+    inverted = False
+    if DEBUG_MODE:
+        explainer_names = []
+        for variable in DEBUG_VARIABLES:
+            baseline_values = baseline.calc(variable).sum(axis=0)
+            multiplier = 1 if inverted else -1
+            addition = -1 if inverted else 1
+            name = (
+                baseline.simulation.tax_benefit_system.variables[
+                    variable
+                ].label
+                or variable
+            )
+            explainer_names += [name]
+            variable_mtrs[name] = (
+                get_mtr(earnings, baseline_values) * multiplier + addition
+            )
+    df = pd.DataFrame(
+        {
+            "Earnings": earnings[:-1].round(0),
+            "Baseline": baseline_mtr,
+            "Reform": reform_mtr,
+            **variable_mtrs,
+        }
+    )
+    if not has_reform:
+        df["Marginal tax rate"] = df["Baseline"]
+    df["hover"] = df.apply(
+        lambda x: mtr_hover_label(
+            x.Earnings,
+            x.Baseline,
+            x.Reform,
+            x.tax_baseline,
+            x.tax_reform,
+            x.benefits_baseline,
+            x.benefits_reform,
+            config,
+        ),
+        axis=1,
+    )
+    if not has_reform:
+        df["Marginal tax rate"] = df["Reform"]
+    df["Difference"] = df["Reform"] - df["Baseline"]
+    if show_difference:
+        y_fig = "Difference"
+        d_title = "Difference in marginal tax rate by employment income"
+        y_title = "Difference in marginal tax rate"
+    else:
+        y_fig = (
+            ["Baseline", "Reform"]
+            if has_reform
+            else ["Marginal tax rate"]
+            + (explainer_names if DEBUG_MODE else [])
+        )
+        d_title = "Marginal tax rate by employment income"
+        y_title = "Marginal tax rate"
+    line_chart = px.line(
+        df,
+        x="Earnings",
+        y=y_fig,
+        labels=dict(LABELS, value="Marginal tax rate"),
+        color_discrete_map=COLOR_MAP,
+        custom_data=["hover"],
+        line_shape="hv",
+    )
+    # Shade baseline and reformed net income cliffs.
+    ymax = df[y_fig].max() * 1.05  # Add a buffer.
+    fig = go.Figure()
+    shade_cliffs(baseline, config, fig, GRAY, ymax)
+    if has_reform:
+        shade_cliffs(reformed, config, fig, BLUE, ymax)
+    fig.add_traces(list(line_chart.select_traces()))
+    add_you_are_here(fig, df.Earnings[i])
+    add_zero_line(fig)
+    add_custom_hovercard(fig)
+    fig.update_layout(
+        title=d_title,
+        xaxis_title="Employment income",
+        xaxis_tickprefix=config.currency,
+        yaxis_tickformat=",.0%",
+        yaxis_title=y_title,
+        yaxis_range=(min(0, np.floor(df["Reform"].min() * 10) / 10), 1),
+        xaxis_showgrid=False,
+        yaxis_showgrid=False,
+        legend_title=None,
+    )
+    if show_difference:
+        fig.update_layout(
+            yaxis_range=(min(0, np.floor(df[y_fig].min() * 10) / 10), 1),
+        )
+        fig.update_traces(line_color=BLUE)
+    # Hide legend if there's no reform.
+    if not has_reform and not DEBUG_MODE:
+        fig.update_layout(showlegend=False)
+    return formatted_fig_json(fig)
diff --git a/policyengine/impact/household/earnings_impact.py b/policyengine/impact/household/earnings_impact.py
new file mode 100644
index 00000000..ab01aee1
--- /dev/null
+++ b/policyengine/impact/household/earnings_impact.py
@@ -0,0 +1,85 @@
+from openfisca_tools import IndividualSim
+from policyengine.country.results_config import PolicyEngineResultsConfig
+from policyengine.impact.household.charts.budget import budget_chart
+from policyengine.impact.household.charts.marginal_tax_rate import mtr_chart
+
+
+def earnings_impact(
+    baseline: IndividualSim,
+    reformed: IndividualSim,
+    config: PolicyEngineResultsConfig,
+) -> dict:
+    """Calculates the impact of a reform on household earnings.
+
+    Args:
+        baseline (IndividualSim): The baseline simulation.
+        reformed (IndividualSim): The reformed simulation.
+        config (PolicyEngineResultsConfig): The results configuration.
+    """
+    employment_income = baseline.calc(config.employment_income_variable).sum()
+    self_employment_income = baseline.calc(
+        config.self_employment_income_variable
+    ).sum()
+    earnings_variable = (
+        config.employment_income_variable
+        if employment_income >= self_employment_income
+        else config.self_employment_income_variable
+    )
+    earnings = max(employment_income, self_employment_income)
+    total_income = baseline.calc(config.total_income_variable).sum()
+    benefits = baseline.calc(config.benefit_variable).sum()
+    tax = baseline.calc(config.tax_variable).sum()
+    vary_max = max(200_000, earnings * 1.5)
+    baseline.vary(
+        earnings_variable,
+        step=100,
+        max=vary_max,
+    )
+    if reformed is not None:
+        reformed.vary(
+            earnings_variable,
+            step=100,
+            max=vary_max,
+        )
+    budget = budget_chart(
+        baseline,
+        reformed,
+        False,
+        config,
+        reformed is not None,
+        total_income,
+        tax,
+        benefits,
+    )
+    budget_difference = budget_chart(
+        baseline,
+        reformed,
+        True,
+        config,
+        reformed is not None,
+        total_income,
+        tax,
+        benefits,
+    )
+    mtr = mtr_chart(
+        baseline,
+        reformed,
+        False,
+        config,
+        reformed is not None,
+        total_income,
+    )
+    mtr_difference = mtr_chart(
+        baseline,
+        reformed,
+        True,
+        config,
+        reformed is not None,
+        total_income,
+    )
+    return dict(
+        budget_chart=budget,
+        budget_difference_chart=budget_difference,
+        mtr_chart=mtr,
+        mtr_difference_chart=mtr_difference,
+    )
diff --git a/policyengine/impact/household/metrics.py b/policyengine/impact/household/metrics.py
deleted file mode 100644
index cc2cd3d3..00000000
--- a/policyengine/impact/household/metrics.py
+++ /dev/null
@@ -1,34 +0,0 @@
-from typing import Type
-from openfisca_uk.tools.simulation import IndividualSim
-import numpy as np
-
-from policyengine.utils.general import PolicyEngineResultsConfig
-
-
-def headline_figures(
-    baseline: IndividualSim,
-    reformed: IndividualSim,
-    config: Type[PolicyEngineResultsConfig],
-) -> dict:
-    """Create dictionary of totals for the reform and baseline.
-
-    :param baseline: Baseline simulation
-    :type baseline: IndividualSim
-    :param reformed: Reform simulation
-    :type reformed: IndividualSim
-    :return: Dictionary of baseline and reformed sums for a set of variables
-    """
-
-    def get_value(sim, name):
-        return float(np.array(sim.calc(name, map_to="household")).sum())
-
-    def get_values(name):
-        return {
-            "old": get_value(baseline, name),
-            "new": get_value(reformed, name),
-        }
-
-    VARIABLES = [
-        config.household_net_income_variable,
-    ]
-    return {name: get_values(name) for name in VARIABLES}
diff --git a/policyengine/impact/population/breakdown.py b/policyengine/impact/population/by_provision.py
similarity index 75%
rename from policyengine/impact/population/breakdown.py
rename to policyengine/impact/population/by_provision.py
index a4c1cacc..fce79e83 100644
--- a/policyengine/impact/population/breakdown.py
+++ b/policyengine/impact/population/by_provision.py
@@ -1,25 +1,13 @@
-from typing import Callable, List, Tuple
-import numpy as np
-from openfisca_tools.microsimulation import Microsimulation
-from openfisca_tools.model_api import ReformType
+from matplotlib import cm
+from policyengine.country.results_config import PolicyEngineResultsConfig
 import pandas as pd
 import plotly.express as px
-from policyengine.utils import charts
-from matplotlib import cm
-
-from policyengine.utils.general import PolicyEngineResultsConfig
-
-
-def get_spending(
-    sim: Microsimulation,
-    baseline: Microsimulation,
-    config: PolicyEngineResultsConfig,
-) -> float:
-    return (
-        sim.calc(config.household_net_income_variable).sum()
-        - baseline.calc(config.household_net_income_variable).sum()
-    )
-
+from policyengine.impact.population.metrics import spending
+from policyengine.impact.utils.text import format_summary_of_parameter_value
+from ..utils import *
+import numpy as np
+from openfisca_tools import ReformType, Microsimulation
+from typing import List, Tuple, Callable
 
 greys = cm.get_cmap("Greys")
 greens = cm.get_cmap("Greens")
@@ -43,20 +31,19 @@ def colour_position_to_rgb(position: int, colour_positions: List[int]) -> str:
 
 
 def get_breakdown_and_chart_per_provision(
-    reform: ReformType,
-    provisions: Tuple[str],
+    country,
+    parameters: dict,
     baseline: Microsimulation,
     reformed: Microsimulation,
-    create_reform_sim: Callable,
     config: PolicyEngineResultsConfig,
 ) -> dict:
     """Generates a breakdown data structure with spending per provision.
 
     Args:
-        reform (ReformType): Reform object, a tuple of reforms.
-        provisions (Tuple[str]): Provision names (same length as reform).
+        country (PolicyEngineCountry): Country object.
+        parameters (dict): Parameters for the reform.
         baseline (Microsimulation): The baseline microsimulation.
-        create_reform_sim (Callable): Function that creates a microsimulation from a reform.
+        reformed (Microsimulation): The reformed microsimulation.
         config (PolicyEngineResultsConfig): Country configuration.
 
     Returns:
@@ -80,13 +67,30 @@ def formatter(x):
     previous_gains = pd.Series([0] * 10, index=list(range(1, 11)))
     colour_positions = [0]
     provision_data = {}
+    parameter_keys = [
+        key
+        for key in parameters.keys()
+        if "baseline_" not in key
+        and f"{config.region_variable}_specific" not in key
+    ]
+    provisions = []
+    for parameter_key in parameter_keys:
+        provisions.append(
+            format_summary_of_parameter_value(
+                country.parameter_data[parameter_key],
+                parameters[parameter_key],
+            )
+        )
 
-    for step in range(1, len(reform) + 1):
-        if step == len(reform):
+    for step in range(1, len(provisions) + 1):
+        step_parameters = {
+            key: parameters[key] for key in parameter_keys[:step]
+        }
+        if step == len(provisions):
             reform_sim = reformed
         else:
-            reform_sim = create_reform_sim(reform[:step])
-        cumulative_spending += [get_spending(reform_sim, baseline, config)]
+            _, reform_sim = country.create_microsimulations(step_parameters)
+        cumulative_spending += [spending(reform_sim, baseline, config)]
         gain = (
             reform_sim.calc(
                 config.household_net_income_variable, map_to="person"
@@ -140,7 +144,7 @@ def formatter(x):
         for provision in provision_data
     }
 
-    rel_decile_chart = charts.formatted_fig_json(
+    rel_decile_chart = formatted_fig_json(
         px.bar(
             decile_impacts,
             x="Decile",
@@ -154,7 +158,7 @@ def formatter(x):
         )
     )
 
-    avg_decile_chart = charts.formatted_fig_json(
+    avg_decile_chart = formatted_fig_json(
         px.bar(
             decile_impacts,
             x="Decile",
diff --git a/policyengine/impact/population/charts.py b/policyengine/impact/population/charts.py
deleted file mode 100644
index c923fbfd..00000000
--- a/policyengine/impact/population/charts.py
+++ /dev/null
@@ -1,699 +0,0 @@
-from typing import Tuple, Type
-from xmlrpc.client import Boolean
-from policyengine.impact.population.metrics import (
-    poverty_rate,
-    deep_poverty_rate,
-    pct_change,
-)
-import plotly.express as px
-import plotly.graph_objects as go
-from plotly.subplots import make_subplots
-import numpy as np
-from openfisca_tools import Microsimulation
-import pandas as pd
-from policyengine.utils import charts
-from policyengine.utils.general import PolicyEngineResultsConfig
-
-
-def individual_decile_chart(
-    df: pd.DataFrame,
-    metric: str,
-    config: Type[PolicyEngineResultsConfig],
-    decile_type: str,
-) -> dict:
-    """Chart of average or relative net effect of a reform by income decile.
-
-    :param df: DataFrame with columns for Decile, Relative change, and Average change.
-    :type df: pd.DataFrame
-    :param metric: "Relative change" or "Average change".
-    :type metric: str
-    :return: Decile chart (relative or absolute) as a JSON representation of a Plotly chart.
-    :rtype: dict
-    """
-    fig = (
-        px.bar(df, x="Decile", y=metric)
-        .update_layout(
-            title=f"Change to net income by {decile_type} decile",
-            xaxis_title=f"{'Equivalised disposable income' if decile_type == 'income' else 'Wealth'} decile",
-            yaxis_title="Change to household net income",
-            yaxis_tickformat=",~%" if metric == "Relative change" else ",",
-            yaxis_tickprefix=""
-            if metric == "Relative change"
-            else config.currency,
-            showlegend=False,
-            xaxis_tickvals=list(range(1, 11)),
-        )
-        .update_traces(
-            marker_color=np.where(
-                df[metric] > 0, charts.DARK_GREEN, charts.GRAY
-            )
-        )
-    )
-    charts.add_zero_line(fig)
-    return charts.formatted_fig_json(fig)
-
-
-def decile_chart(
-    baseline: Microsimulation,
-    reformed: Microsimulation,
-    config: Type[PolicyEngineResultsConfig],
-    decile_type: str = "income",
-) -> Tuple[dict, dict]:
-    """Chart of average net effect of a reform by income decile.
-
-    :param baseline: Baseline microsimulation.
-    :type baseline: Microsimulation
-    :param reformed: Reform microsimulation.
-    :type reformed: Microsimulation
-    :return: Decile charts (relative and absolute) as JSON representations of Plotly charts.
-    :rtype: Tuple[dict, dict]
-    """
-    baseline_household_net_income = baseline.calc(
-        config.household_net_income_variable
-    )
-    baseline_household_equiv_income = baseline.calc(
-        config.equiv_household_net_income_variable
-        if decile_type == "income"
-        else config.household_wealth_variable
-    )
-    reform_household_net_income = reformed.calc(
-        config.household_net_income_variable
-    )
-    household_gain = (
-        reform_household_net_income - baseline_household_net_income
-    )
-    household_size = baseline.calc("people", map_to=config.household_entity)
-    # Group households in decile such that each decile has the same
-    # number of people
-    baseline_household_equiv_income.weights *= household_size
-    household_decile = baseline_household_equiv_income.decile_rank()
-    agg_gain_by_decile = household_gain.groupby(household_decile).sum()
-    households_by_decile = baseline_household_net_income.groupby(
-        household_decile
-    ).count()
-    baseline_agg_income_by_decile = baseline_household_net_income.groupby(
-        household_decile
-    ).sum()
-    reform_agg_income_by_decile = reform_household_net_income.groupby(
-        household_decile
-    ).sum()
-    baseline_mean_income_by_decile = (
-        baseline_agg_income_by_decile / households_by_decile
-    )
-    reform_mean_income_by_decile = (
-        reform_agg_income_by_decile / households_by_decile
-    )
-    # Total decile gain / total decile income.
-    rel_agg_changes = (
-        (agg_gain_by_decile / baseline_agg_income_by_decile)
-        .round(3)
-        .astype(float)
-    )
-    # Total gain / number of households by decile.
-    mean_gain_by_decile = (agg_gain_by_decile / households_by_decile).round()
-    # Write out hovercard.
-    decile_number = rel_agg_changes.index
-    verb = np.where(
-        mean_gain_by_decile > 0,
-        "rise",
-        np.where(mean_gain_by_decile < 0, "fall", "remain"),
-    )
-    label_prefix = (
-        "<b>Household incomes in the "
-        + pd.Series(decile_number)
-        .astype(int)
-        .reset_index(drop=True)
-        .apply(charts.ordinal)
-        + " decile <br>"
-        + pd.Series(verb).reset_index(drop=True)
-        + " by an average of "
-    )
-    label_value_abs = (
-        pd.Series(np.abs(mean_gain_by_decile))
-        .apply(lambda x: f"{config.currency}{x:,.0f}")
-        .reset_index(drop=True)
-    )
-    label_value_rel = (
-        pd.Series(rel_agg_changes)
-        .apply(lambda x: f"{x:.1%}")
-        .reset_index(drop=True)
-    )
-    label_suffix = (
-        f"</b><br>from {config.currency}"
-        + pd.Series(baseline_mean_income_by_decile)
-        .apply(lambda x: f"{x:,.0f}")
-        .reset_index(drop=True)
-        + f" to {config.currency}"
-        + pd.Series(reform_mean_income_by_decile)
-        .apply(lambda x: f"{x:,.0f}")
-        .reset_index(drop=True)
-        + " per year"
-    )
-    label_rel = label_prefix + label_value_rel + label_suffix
-    label_abs = label_prefix + label_value_abs + label_suffix
-    """
-    Examples:
-    - Household incomes in the 1st decile rise by an average of $1, from $1,000 to $1,001 per year
-    - Household incomes in the 2nd decile fall by an average of $1, from $1,000 to $999 per year
-    - Household incomes in the 3rd decile remain at $1,000 per year
-    """
-    df = pd.DataFrame(
-        {
-            "Decile": decile_number,
-            "Relative change": rel_agg_changes.values,
-            "Average change": mean_gain_by_decile.values,
-            "label_rel": label_rel,
-            "label_abs": label_abs,
-        }
-    )
-    return (
-        individual_decile_chart(df, "Relative change", config, decile_type),
-        individual_decile_chart(df, "Average change", config, decile_type),
-    )
-
-
-def pov_chg(
-    baseline: Microsimulation,
-    reformed: Microsimulation,
-    criterion: str,
-    config: Type[PolicyEngineResultsConfig],
-) -> float:
-    """Calculate change in poverty rates.
-
-    :param baseline: Baseline simulation.
-    :type baseline: Microsimulation
-    :param reform: Reform simulation.
-    :type reform: Microsimulation
-    :param criterion: Filter for each simulation.
-    :type criterion: str
-    :return: Percentage (not percentage point) difference in poverty rates.
-    :rtype: float
-    """
-    return pct_change(
-        poverty_rate(baseline, criterion, config),
-        poverty_rate(reformed, criterion, config),
-    )
-
-
-def deep_pov_chg(
-    baseline: Microsimulation,
-    reformed: Microsimulation,
-    criterion: str,
-    config: Type[PolicyEngineResultsConfig],
-) -> float:
-    """Calculate change in poverty rates.
-
-    :param baseline: Baseline simulation.
-    :type baseline: Microsimulation
-    :param reform: Reform simulation.
-    :type reform: Microsimulation
-    :param criterion: Filter for each simulation.
-    :type criterion: str
-    :return: Percentage (not percentage point) difference in poverty rates.
-    :rtype: float
-    """
-    return pct_change(
-        deep_poverty_rate(baseline, criterion, config),
-        deep_poverty_rate(reformed, criterion, config),
-    )
-
-
-def poverty_chart(
-    baseline: Microsimulation,
-    reformed: Microsimulation,
-    is_deep: bool,
-    config: Type[PolicyEngineResultsConfig],
-) -> dict:
-    """Chart of poverty impact by age group and overall.
-
-    :param baseline: Baseline microsimulation.
-    :type baseline: Microsimulation
-    :param reformed: Reform microsimulation.
-    :type reformed: Microsimulation
-    :return: JSON representation of Plotly chart with poverty impact for:
-        - Children (under 18)
-        - Working age adults (18 to State Pension age)
-        - Pensioners (State Pension age and above)
-        - Overall
-    :rtype: dict
-    """
-    if is_deep:
-        f_pov_chg = deep_pov_chg
-        f_poverty_rate = deep_poverty_rate
-        metric_name = "Deep poverty"
-    else:
-        f_pov_chg = pov_chg
-        f_poverty_rate = poverty_rate
-        metric_name = "Poverty"
-    df = pd.DataFrame(
-        {
-            "group": ["Child", "Working-age", "Senior", "All"],
-            "pov_chg": [
-                f_pov_chg(baseline, reformed, i, config)
-                for i in [
-                    config.child_variable,
-                    config.working_age_variable,
-                    config.senior_variable,
-                    config.person_variable,
-                ]
-            ],
-            "baseline": [
-                f_poverty_rate(baseline, i, config)
-                for i in [
-                    config.child_variable,
-                    config.working_age_variable,
-                    config.senior_variable,
-                    config.person_variable,
-                ]
-            ],
-            "reformed": [
-                f_poverty_rate(reformed, i, config)
-                for i in [
-                    config.child_variable,
-                    config.working_age_variable,
-                    config.senior_variable,
-                    config.person_variable,
-                ]
-            ],
-        }
-    )
-    df["abs_chg_str"] = df.pov_chg.abs().map("{:.1%}".format)
-    df["label"] = (
-        "<b>"
-        + np.where(df.group == "All", "Total", df.group)
-        + " "
-        + metric_name.lower()
-        + " "
-        + np.where(
-            df.abs_chg_str == "0.0%",
-            "does not change",
-            (
-                np.where(df.pov_chg < 0, "falls ", "rises ")
-                + df.abs_chg_str
-                + "</b><br> from "
-                + df.baseline.map("{:.1%}".format)
-                + " to "
-                + df.reformed.map("{:.1%}".format)
-            ),
-        )
-    )
-    fig = px.bar(
-        df,
-        x="group",
-        y="pov_chg",
-        custom_data=["label"],
-        labels={"group": "Group", "pov_chg": metric_name + " rate change"},
-    )
-    fig.update_layout(
-        title=metric_name + " impact by age group",
-        xaxis_title=None,
-        yaxis=dict(title="Percent change", tickformat=",~%"),
-    )
-    fig.update_traces(
-        marker_color=np.where(df.pov_chg < 0, charts.DARK_GREEN, charts.GRAY)
-    )
-    charts.add_custom_hovercard(fig)
-    charts.add_zero_line(fig)
-    return charts.formatted_fig_json(fig)
-
-
-def spending(
-    baseline: Microsimulation,
-    reformed: Microsimulation,
-    config: Type[PolicyEngineResultsConfig],
-) -> float:
-    """Budgetary impact of a reform (difference in net income).
-
-    :param baseline: Baseline microsimulation.
-    :type baseline: Microsimulation
-    :param reformed: Reform microsimulation.
-    :type reformed: Microsimulation
-    :return: Reform net income minus baseline net income.
-    :rtype: float
-    """
-    return (
-        reformed.calc(config.net_income_variable).sum()
-        - baseline.calc(config.net_income_variable).sum()
-    )
-
-
-def population_waterfall_chart(
-    baseline: Microsimulation,
-    reformed: Microsimulation,
-    config: Type[PolicyEngineResultsConfig],
-) -> dict:
-    """Waterfall chart showing a reform's budgetary impact.
-
-    :param baseline: Baseline simulation.
-    :type baseline: Microsimulation
-    :param reformed: Reform simulation.
-    :type reformed: Microsimulation
-    :return: JSON representation of Plotly chart with waterfall chart.
-    :rtype: dict
-    """
-    return charts.waterfall_chart(baseline, reformed, config)
-
-
-NAMES = (
-    "Gain more than 5%",
-    "Gain less than 5%",
-    "No change",
-    "Lose less than 5%",
-    "Lose more than 5%",
-)
-
-
-def intra_decile_graph_data(
-    baseline: Microsimulation,
-    reformed: Microsimulation,
-    config: Type[PolicyEngineResultsConfig],
-    decile_type: str = "income",
-) -> pd.DataFrame:
-    """Data for the distribution of net income changes by decile and overall.
-
-    :param baseline: Baseline simulation.
-    :type baseline: Microsimulation
-    :param reformed: Reform simulation.
-    :type reformed: Microsimulation
-    :return: DataFrame with share of each decile experiencing each outcome.
-    :rtype: pd.DataFrame
-    """
-    l = []
-    income = baseline.calc(
-        config.equiv_household_net_income_variable
-        if decile_type == "income"
-        else config.household_wealth_variable,
-        map_to="person",
-    )
-    decile = income.decile_rank()
-    baseline_hh_net_income = baseline.calc(
-        config.household_net_income_variable, map_to="person"
-    )
-    reformed_hh_net_income = reformed.calc(
-        config.household_net_income_variable, map_to="person"
-    )
-    gain = reformed_hh_net_income - baseline_hh_net_income
-    rel_gain = gain / np.maximum(baseline_hh_net_income, 1)
-    BANDS = (None, 0.05, 1e-3, -1e-3, -0.05, None)
-    for upper, lower, name in zip(BANDS[:-1], BANDS[1:], NAMES):
-        fractions = []
-        for j in range(1, 11):
-            subset = rel_gain[decile == j]
-            if lower is not None:
-                subset = subset[rel_gain > lower]
-            if upper is not None:
-                subset = subset[rel_gain <= upper]
-            fractions += [subset.count() / rel_gain[decile == j].count()]
-        tmp = pd.DataFrame(
-            {
-                "fraction": fractions,
-                "decile": list(map(str, range(1, 11))),
-                "outcome": name,
-            }
-        )
-        l.append(tmp)
-        subset = rel_gain
-        if lower is not None:
-            subset = subset[rel_gain > lower]
-        if upper is not None:
-            subset = subset[rel_gain <= upper]
-        all_row = pd.DataFrame(
-            {
-                "fraction": [subset.count() / rel_gain.count()],
-                "decile": "All",
-                "outcome": name,
-            }
-        )
-        l.append(all_row)
-    return pd.concat(l).reset_index()
-
-
-INTRA_DECILE_COLORS = (
-    charts.DARK_GRAY,
-    charts.GRAY,
-    charts.LIGHT_GRAY,
-    charts.LIGHT_GREEN,
-    charts.DARK_GREEN,
-)[::-1]
-
-
-def intra_decile_label(
-    fraction: float, decile: str, outcome: str, decile_type: str
-) -> str:
-    """Label for a data point in the intra-decile chart for hovercards.
-
-    :param fraction: Share of the decile experiencing the outcome.
-    :type fraction: float
-    :param decile: Decile number as a string, or "All".
-    :type decile: str
-    :param outcome: Outcome, e.g. "Gain more than 5%".
-    :type outcome: str
-    :return: String representation of the hovercard label.
-    :rtype: str
-    """
-    res = "{:.0%}".format(fraction) + " of "  # x% of
-    if decile == "All":
-        res += "all people "
-    else:
-        res += (
-            "people in the "
-            + charts.ordinal(int(decile))
-            + f" {decile_type} decile "
-        )
-    if outcome == "No change":
-        return res + "experience no change"
-    else:
-        return res + outcome.lower() + " of their income"
-
-
-def single_intra_decile_graph(df: pd.DataFrame) -> go.Figure:
-    """Single intra-decile graph, for either by-decile or overall.
-
-    :param df: DataFrame with intra-decile or intra-overall data.
-    :type df: pd.DataFrame
-    :return: Plotly bar chart.
-    :rtype: go.Figure
-    """
-    fig = px.bar(
-        df,
-        x="fraction",
-        y="decile",
-        color="outcome",
-        custom_data=["hover"],
-        color_discrete_sequence=INTRA_DECILE_COLORS,
-        orientation="h",
-    )
-    charts.add_custom_hovercard(fig)
-    return fig
-
-
-def intra_decile_chart(
-    baseline: Microsimulation,
-    reformed: Microsimulation,
-    config: Type[PolicyEngineResultsConfig],
-    decile_type: str = "income",
-) -> dict:
-    """Full intra-decile chart, including a top bar for overall.
-
-    :param baseline: Baseline simulation.
-    :type baseline: Microsimulation
-    :param reformed: Reform simulation.
-    :type reformed: Microsimulation
-    :return: JSON representation of Plotly intra-decile chart.
-    :rtype: dict
-    """
-    df = intra_decile_graph_data(
-        baseline, reformed, config, decile_type=decile_type
-    )
-    df["hover"] = df.apply(
-        lambda x: intra_decile_label(
-            x.fraction, x.decile, x.outcome, decile_type
-        ),
-        axis=1,
-    )
-    # Create the decile figure first, then the total to go above it.
-    decile_fig = single_intra_decile_graph(df[df.decile != "All"])
-    total_fig = single_intra_decile_graph(df[df.decile == "All"])
-    fig = make_subplots(
-        rows=2,
-        cols=1,
-        shared_xaxes=True,
-        row_heights=[1, 10],
-        vertical_spacing=0.05,
-        x_title="Population share",
-        y_title=f"{'Income' if decile_type == 'income' else 'Wealth'} decile",
-    )
-    fig.update_xaxes(showgrid=False, tickformat=",.0%")
-    fig.add_traces(total_fig.data, 1, 1)
-    fig.add_traces(decile_fig.data, 2, 1)
-    fig.update_layout(
-        barmode="stack",
-        title=f"Distribution of gains and losses by {decile_type} decile",
-    )
-    for i in range(5):
-        fig.data[i].showlegend = False
-    return charts.formatted_fig_json(fig)
-
-
-def inequality_chart(
-    baseline: Microsimulation,
-    reformed: Microsimulation,
-    config: Type[PolicyEngineResultsConfig],
-) -> dict:
-    equiv_income = baseline.calc(
-        config.equiv_household_net_income_variable, map_to="person"
-    )
-    reform_equiv_income = reformed.calc(
-        config.equiv_household_net_income_variable, map_to="person"
-    )
-    baseline_gini = equiv_income.gini()
-    reform_gini = reform_equiv_income.gini()
-    gini_change = reform_gini / baseline_gini - 1
-    baseline_top_ten_pct_share = (
-        equiv_income[equiv_income.decile_rank() == 10].sum()
-        / equiv_income.sum()
-    )
-    reform_top_ten_pct_share = (
-        reform_equiv_income[reform_equiv_income.decile_rank() == 10].sum()
-        / reform_equiv_income.sum()
-    )
-    top_ten_pct_share_change = (
-        reform_top_ten_pct_share / baseline_top_ten_pct_share - 1
-    )
-    baseline_top_one_pct_share = (
-        equiv_income[equiv_income.percentile_rank() == 100].sum()
-        / equiv_income.sum()
-    )
-    reform_top_one_pct_share = (
-        reform_equiv_income[reform_equiv_income.percentile_rank() == 100].sum()
-        / reform_equiv_income.sum()
-    )
-    top_one_pct_share_change = (
-        reform_top_one_pct_share / baseline_top_one_pct_share - 1
-    )
-    df = pd.DataFrame(
-        {
-            "Metric": ["Gini index", f"Top 10% share", f"Top 1% share"],
-            "Percent change": [
-                gini_change,
-                top_ten_pct_share_change,
-                top_one_pct_share_change,
-            ],
-            "Baseline": [
-                baseline_gini,
-                baseline_top_ten_pct_share,
-                baseline_top_one_pct_share,
-            ],
-            "Reform": [
-                reform_gini,
-                reform_top_ten_pct_share,
-                reform_top_one_pct_share,
-            ],
-        }
-    )
-    df["pct_change_str"] = df["Percent change"].abs().map("{:.1%}".format)
-    df["label"] = (
-        "<b>"
-        + df.Metric
-        + " "
-        + np.where(
-            df.pct_change_str == "0.0%",
-            "does not change",
-            (
-                np.where(df["Percent change"] < 0, "falls ", "rises ")
-                + df.pct_change_str.astype(str)
-            ),
-        )
-        + "</b><br> from "
-        + np.where(
-            df.Metric == "Gini index",
-            df.Baseline.map("{:.3}".format).astype(str),
-            df.Baseline.map("{:.1%}".format).astype(str),
-        )
-        + " to "
-        + np.where(
-            df.Metric == "Gini index",
-            df.Reform.map("{:.3}".format).astype(str),
-            df.Reform.map("{:.1%}".format).astype(str),
-        )
-    )
-    fig = (
-        px.bar(df, x="Metric", y="Percent change", custom_data=["label"])
-        .update_layout(
-            title="Income inequality impact",
-            xaxis_title=None,
-            yaxis_title="Percent change",
-            yaxis_tickformat="~%",
-        )
-        .update_traces(
-            marker_color=np.where(
-                df["Percent change"] < 0, charts.DARK_GREEN, charts.GRAY
-            )
-        )
-    )
-    charts.add_zero_line(fig)
-    charts.add_custom_hovercard(fig)
-    return charts.formatted_fig_json(fig)
-
-
-def age_chart(
-    baseline: Microsimulation,
-    reformed: Microsimulation,
-    config: Type[PolicyEngineResultsConfig],
-) -> dict:
-    """Generates a bar chart showing the impact of a reform by age.
-
-    Args:
-        baseline (Microsimulation): The baseline simulation.
-        reformed (Microsimulation): The reformed simulation.
-        config (Type[PolicyEngineResultsConfig]): The country metadata.
-
-    Returns:
-        dict: The Plotly JSON.
-    """
-
-    baseline_household_net_income = baseline.calc(
-        config.household_net_income_variable,
-        map_to="person",
-    )
-    reform_household_net_income = reformed.calc(
-        config.household_net_income_variable,
-        map_to="person",
-    )
-    age = baseline.calc("age")
-    gain = reform_household_net_income - baseline_household_net_income
-    gain_by_age = gain.groupby(age).sum() / gain.groupby(age).count()
-    df = pd.DataFrame(
-        {
-            "Age": gain_by_age.index,
-            "Average increase": gain_by_age.values,
-        }
-    )
-    hover_labels = []
-    for age, gain in zip(df.Age, df["Average increase"]):
-        hover_labels += [
-            f"<b>{age}-year olds</b> see their household's net income <br>{'rise' if gain >= 0 else 'fall '} by <b>{config.currency}{gain:,.0f}</b> on average."
-        ]
-    df["Label"] = hover_labels
-    fig = (
-        px.bar(
-            df,
-            x="Age",
-            y="Average increase",
-            custom_data=["Label"],
-        )
-        .update_layout(
-            title="Average net income increase by age",
-            yaxis_tickformat=f",.0f",
-            yaxis_tickprefix=config.currency,
-            xaxis_tickvals=list(range(0, 100, 10)),
-        )
-        .update_traces(
-            marker_color=np.where(
-                df["Average increase"] > 0, charts.DARK_GREEN, charts.GRAY
-            ),
-            hovertemplate="%{customdata[0]}",
-        )
-    )
-    charts.add_zero_line(fig)
-    return charts.formatted_fig_json(fig)
diff --git a/policyengine/impact/population/charts/__init__.py b/policyengine/impact/population/charts/__init__.py
new file mode 100644
index 00000000..4d1748d5
--- /dev/null
+++ b/policyengine/impact/population/charts/__init__.py
@@ -0,0 +1,5 @@
+from .budgetary_impact import waterfall_chart
+from .inequality import inequality_chart
+from .decile import decile_chart
+from .poverty import poverty_chart
+from .intra_decile import intra_decile_chart
diff --git a/policyengine/impact/population/charts/age.py b/policyengine/impact/population/charts/age.py
new file mode 100644
index 00000000..eaa0dbab
--- /dev/null
+++ b/policyengine/impact/population/charts/age.py
@@ -0,0 +1,70 @@
+from typing import Type
+import plotly.express as px
+import numpy as np
+from openfisca_tools import Microsimulation
+import pandas as pd
+from policyengine.impact.utils import *
+from policyengine.country.results_config import PolicyEngineResultsConfig
+
+
+def age_chart(
+    baseline: Microsimulation,
+    reformed: Microsimulation,
+    config: Type[PolicyEngineResultsConfig],
+) -> dict:
+    """Generates a bar chart showing the impact of a reform by age.
+
+    Args:
+        baseline (Microsimulation): The baseline simulation.
+        reformed (Microsimulation): The reformed simulation.
+        config (Type[PolicyEngineResultsConfig]): The country metadata.
+
+    Returns:
+        dict: The Plotly JSON.
+    """
+
+    baseline_household_net_income = baseline.calc(
+        config.household_net_income_variable,
+        map_to="person",
+    )
+    reform_household_net_income = reformed.calc(
+        config.household_net_income_variable,
+        map_to="person",
+    )
+    age = baseline.calc("age")
+    gain = reform_household_net_income - baseline_household_net_income
+    gain_by_age = gain.groupby(age).sum() / gain.groupby(age).count()
+    df = pd.DataFrame(
+        {
+            "Age": gain_by_age.index,
+            "Average increase": gain_by_age.values,
+        }
+    )
+    hover_labels = []
+    for age, gain in zip(df.Age, df["Average increase"]):
+        hover_labels += [
+            f"<b>{int(age)}-year olds</b> see their household's net income <br>{'rise' if gain >= 0 else 'fall '} by <b>{config.currency}{abs(gain):,.0f}</b> on average."
+        ]
+    df["Label"] = hover_labels
+    fig = (
+        px.bar(
+            df,
+            x="Age",
+            y="Average increase",
+            custom_data=["Label"],
+        )
+        .update_layout(
+            title="Average net income increase by age",
+            yaxis_tickformat=f",.0f",
+            yaxis_tickprefix=config.currency,
+            xaxis_tickvals=list(range(0, 100, 10)),
+        )
+        .update_traces(
+            marker_color=np.where(
+                df["Average increase"] > 0, DARK_GREEN, GRAY
+            ),
+            hovertemplate="%{customdata[0]}",
+        )
+    )
+    add_zero_line(fig)
+    return formatted_fig_json(fig)
diff --git a/policyengine/utils/charts.py b/policyengine/impact/population/charts/budgetary_impact.py
similarity index 59%
rename from policyengine/utils/charts.py
rename to policyengine/impact/population/charts/budgetary_impact.py
index 249a25ec..7211046a 100644
--- a/policyengine/utils/charts.py
+++ b/policyengine/impact/population/charts/budgetary_impact.py
@@ -1,83 +1,10 @@
-"""
-Utility functions for formatting charts.
-"""
-from math import floor, log10
-import plotly.graph_objects as go
+from typing import Tuple, Type, Union
 import plotly.express as px
+import numpy as np
+from openfisca_tools import Microsimulation
 import pandas as pd
-import json
-from openfisca_tools import (
-    Microsimulation,
-    IndividualSim,
-)
-from typing import Type, Union
-
-from policyengine.utils.general import PolicyEngineResultsConfig
-from io import StringIO
-
-
-def plotly_json_to_fig(json):
-    """Converts a JSON dict to a plotly figure.
-
-    :param json: JSON dict.
-    :type json: dict
-    :return: Plotly figure.
-    :rtype: go.Figure
-    """
-    return go.Figure(data=json["data"], layout=json["layout"])
-
-
-def num(x: float) -> str:
-    """Converts a number to a human-readable string, using the k/m/bn/tr suffixes after rounding to 2 significant figures."""
-
-    if x < 0:
-        return "-" + num(-x)
-    if x < 1e3:
-        return f"{x:.2f}"
-    if x < 1e6:
-        return f"{x / 1e3:.0f}k"
-    if x < 1e9:
-        return f"{x / 1e6:.0f}m"
-    if x < 1e10:
-        return f"{x / 1e9:.2f}bn"
-    if x < 1e12:
-        return f"{x / 1e9:.1f}bn"
-    return f"{x / 1e12:.2f}tr"
-
-
-WHITE = "#FFF"
-BLUE = "#5091cc"
-GRAY = "#BDBDBD"
-DARK_GRAY = "#616161"
-LIGHT_GRAY = "#F5F5F5"
-LIGHT_GREEN = "#C5E1A5"
-DARK_GREEN = "#558B2F"
-
-
-def formatted_fig_json(fig: go.Figure) -> dict:
-    """Formats figure with styling and returns as JSON.
-
-    :param fig: Plotly figure.
-    :type fig: go.Figure
-    :return: Formatted plotly figure as a JSON dict.
-    :rtype: dict
-    """
-    fig.update_xaxes(
-        title_font=dict(size=16, color="black"), tickfont={"size": 14}
-    )
-    fig.update_yaxes(
-        title_font=dict(size=16, color="black"), tickfont={"size": 14}
-    )
-    fig.update_layout(
-        hoverlabel_align="right",
-        font_family="Ubuntu",
-        font_color="Black",
-        title_font_size=20,
-        plot_bgcolor="white",
-        paper_bgcolor="white",
-        hoverlabel=dict(font_family="Ubuntu"),
-    )
-    return json.loads(fig.to_json())
+from policyengine.impact.utils import *
+from policyengine.country.results_config import PolicyEngineResultsConfig
 
 
 def bar_data(start: float, amount: float, label: str) -> pd.DataFrame:
@@ -148,16 +75,11 @@ def waterfall_data(amounts: list, labels: list) -> pd.DataFrame:
     benefit_variable="Benefit outlays",
     total="Net impact",
 )
-HH_LABELS = dict(
-    tax_variable="Your taxes",
-    benefit_variable="Your benefits",
-    total="Your net income",
-)
 
 
 def tax_benefit_waterfall_data(
-    baseline: Union[Microsimulation, IndividualSim],
-    reformed: Union[Microsimulation, IndividualSim],
+    baseline: Microsimulation,
+    reformed: Microsimulation,
     config: Type[PolicyEngineResultsConfig],
 ) -> pd.DataFrame:
     """Generates data for tax benefit waterfall charts.
@@ -170,8 +92,7 @@ def tax_benefit_waterfall_data(
     :rtype: pd.DataFrame
     """
     GROUPS = [config.tax_variable, config.benefit_variable]
-    is_pop = isinstance(baseline, Microsimulation)
-    multipliers = [1, -1] if is_pop else [-1, 1]
+    multipliers = [1, -1]
     effects = [
         float(
             (reformed.calc(var).sum() - baseline.calc(var).sum()) * multiplier
@@ -179,17 +100,13 @@ def tax_benefit_waterfall_data(
         for var, multiplier in zip(GROUPS, multipliers)
     ]
     res = waterfall_data(effects, ["tax_variable", "benefit_variable"])
-    if is_pop:
-        res.label = res.label.map(POP_LABELS)
-    else:
-        res.label = res.label.map(HH_LABELS)
+    res.label = res.label.map(POP_LABELS)
     return res
 
 
 def hover_label(
     component: str,
     amount: float,
-    is_pop: bool,
     config: Type[PolicyEngineResultsConfig],
 ) -> str:
     """Create a label for an individual point in a waterfall hovercard.
@@ -203,21 +120,14 @@ def hover_label(
     :return: Label for hovercard.
     :rtype: str
     """
-    # Reset household net income label to match the headline.
-    if component == "Your net income":
-        component = "Your annual net income"
     # Net impact bars should match the title.
     res = component
     # Flip the amount for labeling population benefits and household taxes.
-    if component in ["Benefit outlays", "Your taxes"]:
+    if component == "Benefit outlays":
         amount *= -1
     # Round population estimates, not individual.
     abs_amount = round(abs(amount))
-    abs_amount_display = (
-        config.currency + num(abs_amount)
-        if is_pop
-        else f"{config.currency}{abs_amount:,}"
-    )
+    abs_amount_display = config.currency + num(abs_amount)
     # Branch logic, starting with no change.
     # More special handling of the net impact to match the title.
     if amount == 0:
@@ -235,8 +145,8 @@ def hover_label(
 
 
 def waterfall_chart(
-    baseline: Union[Microsimulation, IndividualSim],
-    reformed: Union[Microsimulation, IndividualSim],
+    baseline: Microsimulation,
+    reformed: Microsimulation,
     config: Type[PolicyEngineResultsConfig],
 ) -> dict:
     """Create a waterfall chart for tax and benefit changes.
@@ -248,10 +158,9 @@ def waterfall_chart(
     :return: Waterfall chart as a JSON dict.
     :rtype: dict
     """
-    is_pop = isinstance(baseline, Microsimulation)
     data = tax_benefit_waterfall_data(baseline, reformed, config)
     data["hover"] = data.apply(
-        lambda x: hover_label(x.label, x.amount, is_pop, config), axis=1
+        lambda x: hover_label(x.label, x.amount, config), axis=1
     )
     fig = px.bar(
         data,
@@ -264,9 +173,7 @@ def waterfall_chart(
         ),
         barmode="relative",
         category_orders={
-            "label": list(POP_LABELS.values())
-            if is_pop
-            else list(HH_LABELS.values()),
+            "label": list(POP_LABELS.values()),
             "color": ["blank", "negative", "positive"],
         },
     )
@@ -280,48 +187,3 @@ def waterfall_chart(
         xaxis_title=None,
     )
     return formatted_fig_json(fig)
-
-
-def ordinal(n: int) -> str:
-    """Create an ordinal number (1st, 2nd, etc.) from an integer.
-
-    Source: https://stackoverflow.com/a/20007730/1840471
-
-    :param n: Number.
-    :type n: int
-    :return: Ordinal number (1st, 2nd, etc.).
-    :rtype: str
-    """
-    return "%d%s" % (
-        n,
-        "tsnrhtdd"[(n // 10 % 10 != 1) * (n % 10 < 4) * n % 10 :: 4],
-    )
-
-
-def add_custom_hovercard(fig: go.Figure) -> None:
-    """Add a custom hovercard to the figure based on the first element of
-    customdata, without the title to the right.
-
-    :param fig: Plotly figure.
-    :type fig: go.Figure
-    """
-    # Per https://stackoverflow.com/a/69430974/1840471.
-    fig.update_traces(hovertemplate="%{customdata[0]}<extra></extra>")
-
-
-def add_zero_line(fig: go.Figure) -> None:
-    """Add a solid line across y=0.
-
-    :param fig: Plotly figure.
-    :type fig: go.Figure
-    """
-    fig.add_shape(
-        type="line",
-        xref="paper",
-        yref="y",
-        x0=0,
-        y0=0,
-        x1=1,
-        y1=0,
-        line=dict(color="grey", width=1),
-    )
diff --git a/policyengine/impact/population/charts/decile.py b/policyengine/impact/population/charts/decile.py
new file mode 100644
index 00000000..5d46b5c3
--- /dev/null
+++ b/policyengine/impact/population/charts/decile.py
@@ -0,0 +1,160 @@
+from typing import Tuple, Type
+import plotly.express as px
+import numpy as np
+from openfisca_tools import Microsimulation
+import pandas as pd
+from policyengine.impact.utils import *
+from policyengine.country.results_config import PolicyEngineResultsConfig
+
+
+def individual_decile_chart(
+    df: pd.DataFrame,
+    metric: str,
+    config: Type[PolicyEngineResultsConfig],
+    decile_type: str,
+) -> dict:
+    """Chart of average or relative net effect of a reform by income decile.
+
+    :param df: DataFrame with columns for Decile, Relative change, and Average change.
+    :type df: pd.DataFrame
+    :param metric: "Relative change" or "Average change".
+    :type metric: str
+    :return: Decile chart (relative or absolute) as a JSON representation of a Plotly chart.
+    :rtype: dict
+    """
+    fig = (
+        px.bar(df, x="Decile", y=metric)
+        .update_layout(
+            title=f"Change to net income by {decile_type} decile",
+            xaxis_title=f"{'Equivalised disposable income' if decile_type == 'income' else 'Wealth'} decile",
+            yaxis_title="Change to household net income",
+            yaxis_tickformat=",~%" if metric == "Relative change" else ",",
+            yaxis_tickprefix=""
+            if metric == "Relative change"
+            else config.currency,
+            showlegend=False,
+            xaxis_tickvals=list(range(1, 11)),
+        )
+        .update_traces(marker_color=np.where(df[metric] > 0, DARK_GREEN, GRAY))
+    )
+    add_zero_line(fig)
+    return formatted_fig_json(fig)
+
+
+def decile_chart(
+    baseline: Microsimulation,
+    reformed: Microsimulation,
+    config: Type[PolicyEngineResultsConfig],
+    decile_type: str = "income",
+) -> Tuple[dict, dict]:
+    """Chart of average net effect of a reform by income decile.
+
+    :param baseline: Baseline microsimulation.
+    :type baseline: Microsimulation
+    :param reformed: Reform microsimulation.
+    :type reformed: Microsimulation
+    :return: Decile charts (relative and absolute) as JSON representations of Plotly charts.
+    :rtype: Tuple[dict, dict]
+    """
+    baseline_household_net_income = baseline.calc(
+        config.household_net_income_variable
+    )
+    baseline_household_equiv_income = baseline.calc(
+        config.equiv_household_net_income_variable
+        if decile_type == "income"
+        else config.household_wealth_variable
+    )
+    reform_household_net_income = reformed.calc(
+        config.household_net_income_variable
+    )
+    household_gain = (
+        reform_household_net_income - baseline_household_net_income
+    )
+    household_size = baseline.calc("people", map_to=config.household_entity)
+    # Group households in decile such that each decile has the same
+    # number of people
+    baseline_household_equiv_income.weights *= household_size
+    household_decile = baseline_household_equiv_income.decile_rank()
+    agg_gain_by_decile = household_gain.groupby(household_decile).sum()
+    households_by_decile = baseline_household_net_income.groupby(
+        household_decile
+    ).count()
+    baseline_agg_income_by_decile = baseline_household_net_income.groupby(
+        household_decile
+    ).sum()
+    reform_agg_income_by_decile = reform_household_net_income.groupby(
+        household_decile
+    ).sum()
+    baseline_mean_income_by_decile = (
+        baseline_agg_income_by_decile / households_by_decile
+    )
+    reform_mean_income_by_decile = (
+        reform_agg_income_by_decile / households_by_decile
+    )
+    # Total decile gain / total decile income.
+    rel_agg_changes = (
+        (agg_gain_by_decile / baseline_agg_income_by_decile)
+        .round(3)
+        .astype(float)
+    )
+    # Total gain / number of households by decile.
+    mean_gain_by_decile = (agg_gain_by_decile / households_by_decile).round()
+    # Write out hovercard.
+    decile_number = rel_agg_changes.index
+    verb = np.where(
+        mean_gain_by_decile > 0,
+        "rise",
+        np.where(mean_gain_by_decile < 0, "fall", "remain"),
+    )
+    label_prefix = (
+        "<b>Household incomes in the "
+        + pd.Series(decile_number)
+        .astype(int)
+        .reset_index(drop=True)
+        .apply(ordinal)
+        + " decile <br>"
+        + pd.Series(verb).reset_index(drop=True)
+        + " by an average of "
+    )
+    label_value_abs = (
+        pd.Series(np.abs(mean_gain_by_decile))
+        .apply(lambda x: f"{config.currency}{x:,.0f}")
+        .reset_index(drop=True)
+    )
+    label_value_rel = (
+        pd.Series(rel_agg_changes)
+        .apply(lambda x: f"{x:.1%}")
+        .reset_index(drop=True)
+    )
+    label_suffix = (
+        f"</b><br>from {config.currency}"
+        + pd.Series(baseline_mean_income_by_decile)
+        .apply(lambda x: f"{x:,.0f}")
+        .reset_index(drop=True)
+        + f" to {config.currency}"
+        + pd.Series(reform_mean_income_by_decile)
+        .apply(lambda x: f"{x:,.0f}")
+        .reset_index(drop=True)
+        + " per year"
+    )
+    label_rel = label_prefix + label_value_rel + label_suffix
+    label_abs = label_prefix + label_value_abs + label_suffix
+    """
+    Examples:
+    - Household incomes in the 1st decile rise by an average of $1, from $1,000 to $1,001 per year
+    - Household incomes in the 2nd decile fall by an average of $1, from $1,000 to $999 per year
+    - Household incomes in the 3rd decile remain at $1,000 per year
+    """
+    df = pd.DataFrame(
+        {
+            "Decile": decile_number,
+            "Relative change": rel_agg_changes.values,
+            "Average change": mean_gain_by_decile.values,
+            "label_rel": label_rel,
+            "label_abs": label_abs,
+        }
+    )
+    return (
+        individual_decile_chart(df, "Relative change", config, decile_type),
+        individual_decile_chart(df, "Average change", config, decile_type),
+    )
diff --git a/policyengine/impact/population/charts/inequality.py b/policyengine/impact/population/charts/inequality.py
new file mode 100644
index 00000000..e619ad06
--- /dev/null
+++ b/policyengine/impact/population/charts/inequality.py
@@ -0,0 +1,106 @@
+from typing import Tuple, Type
+import plotly.express as px
+import numpy as np
+from openfisca_tools import Microsimulation
+import pandas as pd
+from policyengine.impact.utils import *
+from policyengine.country.results_config import PolicyEngineResultsConfig
+
+
+def inequality_chart(
+    baseline: Microsimulation,
+    reformed: Microsimulation,
+    config: Type[PolicyEngineResultsConfig],
+) -> dict:
+    equiv_income = baseline.calc(
+        config.equiv_household_net_income_variable, map_to="person"
+    )
+    reform_equiv_income = reformed.calc(
+        config.equiv_household_net_income_variable, map_to="person"
+    )
+    baseline_gini = equiv_income.gini()
+    reform_gini = reform_equiv_income.gini()
+    gini_change = reform_gini / baseline_gini - 1
+    baseline_top_ten_pct_share = (
+        equiv_income[equiv_income.decile_rank() == 10].sum()
+        / equiv_income.sum()
+    )
+    reform_top_ten_pct_share = (
+        reform_equiv_income[reform_equiv_income.decile_rank() == 10].sum()
+        / reform_equiv_income.sum()
+    )
+    top_ten_pct_share_change = (
+        reform_top_ten_pct_share / baseline_top_ten_pct_share - 1
+    )
+    baseline_top_one_pct_share = (
+        equiv_income[equiv_income.percentile_rank() == 100].sum()
+        / equiv_income.sum()
+    )
+    reform_top_one_pct_share = (
+        reform_equiv_income[reform_equiv_income.percentile_rank() == 100].sum()
+        / reform_equiv_income.sum()
+    )
+    top_one_pct_share_change = (
+        reform_top_one_pct_share / baseline_top_one_pct_share - 1
+    )
+    df = pd.DataFrame(
+        {
+            "Metric": ["Gini index", f"Top 10% share", f"Top 1% share"],
+            "Percent change": [
+                gini_change,
+                top_ten_pct_share_change,
+                top_one_pct_share_change,
+            ],
+            "Baseline": [
+                baseline_gini,
+                baseline_top_ten_pct_share,
+                baseline_top_one_pct_share,
+            ],
+            "Reform": [
+                reform_gini,
+                reform_top_ten_pct_share,
+                reform_top_one_pct_share,
+            ],
+        }
+    )
+    df["pct_change_str"] = df["Percent change"].abs().map("{:.1%}".format)
+    df["label"] = (
+        "<b>"
+        + df.Metric
+        + " "
+        + np.where(
+            df.pct_change_str == "0.0%",
+            "does not change",
+            (
+                np.where(df["Percent change"] < 0, "falls ", "rises ")
+                + df.pct_change_str.astype(str)
+            ),
+        )
+        + "</b><br> from "
+        + np.where(
+            df.Metric == "Gini index",
+            df.Baseline.map("{:.3}".format).astype(str),
+            df.Baseline.map("{:.1%}".format).astype(str),
+        )
+        + " to "
+        + np.where(
+            df.Metric == "Gini index",
+            df.Reform.map("{:.3}".format).astype(str),
+            df.Reform.map("{:.1%}".format).astype(str),
+        )
+    )
+    fig = (
+        px.bar(df, x="Metric", y="Percent change", custom_data=["label"])
+        .update_layout(
+            title="Income inequality impact",
+            xaxis_title=None,
+            yaxis_title="Percent change",
+            yaxis_tickformat="~%",
+        )
+        .update_traces(
+            marker_color=np.where(df["Percent change"] < 0, DARK_GREEN, GRAY)
+        )
+    )
+    add_zero_line(fig)
+    add_custom_hovercard(fig)
+    return formatted_fig_json(fig)
diff --git a/policyengine/impact/population/charts/intra_decile.py b/policyengine/impact/population/charts/intra_decile.py
new file mode 100644
index 00000000..de329e9d
--- /dev/null
+++ b/policyengine/impact/population/charts/intra_decile.py
@@ -0,0 +1,188 @@
+from typing import Type
+import plotly.express as px
+from plotly.subplots import make_subplots
+import plotly.graph_objects as go
+import numpy as np
+from openfisca_tools import Microsimulation
+import pandas as pd
+from policyengine.impact.utils import *
+from policyengine.country.results_config import PolicyEngineResultsConfig
+
+
+NAMES = (
+    "Gain more than 5%",
+    "Gain less than 5%",
+    "No change",
+    "Lose less than 5%",
+    "Lose more than 5%",
+)
+
+
+def intra_decile_graph_data(
+    baseline: Microsimulation,
+    reformed: Microsimulation,
+    config: Type[PolicyEngineResultsConfig],
+    decile_type: str = "income",
+) -> pd.DataFrame:
+    """Data for the distribution of net income changes by decile and overall.
+
+    :param baseline: Baseline simulation.
+    :type baseline: Microsimulation
+    :param reformed: Reform simulation.
+    :type reformed: Microsimulation
+    :return: DataFrame with share of each decile experiencing each outcome.
+    :rtype: pd.DataFrame
+    """
+    l = []
+    income = baseline.calc(
+        config.equiv_household_net_income_variable
+        if decile_type == "income"
+        else config.household_wealth_variable,
+        map_to="person",
+    )
+    decile = income.decile_rank()
+    baseline_hh_net_income = baseline.calc(
+        config.household_net_income_variable, map_to="person"
+    )
+    reformed_hh_net_income = reformed.calc(
+        config.household_net_income_variable, map_to="person"
+    )
+    gain = reformed_hh_net_income - baseline_hh_net_income
+    rel_gain = gain / np.maximum(baseline_hh_net_income, 1)
+    BANDS = (None, 0.05, 1e-3, -1e-3, -0.05, None)
+    for upper, lower, name in zip(BANDS[:-1], BANDS[1:], NAMES):
+        fractions = []
+        for j in range(1, 11):
+            subset = rel_gain[decile == j]
+            if lower is not None:
+                subset = subset[rel_gain > lower]
+            if upper is not None:
+                subset = subset[rel_gain <= upper]
+            fractions += [subset.count() / rel_gain[decile == j].count()]
+        tmp = pd.DataFrame(
+            {
+                "fraction": fractions,
+                "decile": list(map(str, range(1, 11))),
+                "outcome": name,
+            }
+        )
+        l.append(tmp)
+        subset = rel_gain
+        if lower is not None:
+            subset = subset[rel_gain > lower]
+        if upper is not None:
+            subset = subset[rel_gain <= upper]
+        all_row = pd.DataFrame(
+            {
+                "fraction": [subset.count() / rel_gain.count()],
+                "decile": "All",
+                "outcome": name,
+            }
+        )
+        l.append(all_row)
+    return pd.concat(l).reset_index()
+
+
+INTRA_DECILE_COLORS = (
+    DARK_GRAY,
+    GRAY,
+    LIGHT_GRAY,
+    LIGHT_GREEN,
+    DARK_GREEN,
+)[::-1]
+
+
+def intra_decile_label(
+    fraction: float, decile: str, outcome: str, decile_type: str
+) -> str:
+    """Label for a data point in the intra-decile chart for hovercards.
+
+    :param fraction: Share of the decile experiencing the outcome.
+    :type fraction: float
+    :param decile: Decile number as a string, or "All".
+    :type decile: str
+    :param outcome: Outcome, e.g. "Gain more than 5%".
+    :type outcome: str
+    :return: String representation of the hovercard label.
+    :rtype: str
+    """
+    res = "{:.0%}".format(fraction) + " of "  # x% of
+    if decile == "All":
+        res += "all people "
+    else:
+        res += (
+            "people in the " + ordinal(int(decile)) + f" {decile_type} decile "
+        )
+    if outcome == "No change":
+        return res + "experience no change"
+    else:
+        return res + outcome.lower() + " of their income"
+
+
+def single_intra_decile_graph(df: pd.DataFrame) -> go.Figure:
+    """Single intra-decile graph, for either by-decile or overall.
+
+    :param df: DataFrame with intra-decile or intra-overall data.
+    :type df: pd.DataFrame
+    :return: Plotly bar chart.
+    :rtype: go.Figure
+    """
+    fig = px.bar(
+        df,
+        x="fraction",
+        y="decile",
+        color="outcome",
+        custom_data=["hover"],
+        color_discrete_sequence=INTRA_DECILE_COLORS,
+        orientation="h",
+    )
+    add_custom_hovercard(fig)
+    return fig
+
+
+def intra_decile_chart(
+    baseline: Microsimulation,
+    reformed: Microsimulation,
+    config: Type[PolicyEngineResultsConfig],
+    decile_type: str = "income",
+) -> dict:
+    """Full intra-decile chart, including a top bar for overall.
+
+    :param baseline: Baseline simulation.
+    :type baseline: Microsimulation
+    :param reformed: Reform simulation.
+    :type reformed: Microsimulation
+    :return: JSON representation of Plotly intra-decile chart.
+    :rtype: dict
+    """
+    df = intra_decile_graph_data(
+        baseline, reformed, config, decile_type=decile_type
+    )
+    df["hover"] = df.apply(
+        lambda x: intra_decile_label(
+            x.fraction, x.decile, x.outcome, decile_type
+        ),
+        axis=1,
+    )
+    # Create the decile figure first, then the total to go above it.
+    decile_fig = single_intra_decile_graph(df[df.decile != "All"])
+    total_fig = single_intra_decile_graph(df[df.decile == "All"])
+    fig = make_subplots(
+        rows=2,
+        cols=1,
+        shared_xaxes=True,
+        row_heights=[1, 10],
+        vertical_spacing=0.05,
+        x_title="Population share",
+        y_title=f"{'Income' if decile_type == 'income' else 'Wealth'} decile",
+    )
+    fig.update_xaxes(showgrid=False, tickformat=",.0%")
+    fig.add_traces(total_fig.data, 1, 1)
+    fig.add_traces(decile_fig.data, 2, 1)
+    fig.update_layout(
+        barmode="stack",
+        title=f"Distribution of gains and losses by {decile_type} decile",
+    )
+    for i in range(5):
+        fig.data[i].showlegend = False
+    return formatted_fig_json(fig)
diff --git a/policyengine/impact/population/charts/poverty.py b/policyengine/impact/population/charts/poverty.py
new file mode 100644
index 00000000..6991fc12
--- /dev/null
+++ b/policyengine/impact/population/charts/poverty.py
@@ -0,0 +1,154 @@
+from typing import Tuple, Type
+import plotly.express as px
+import numpy as np
+from openfisca_tools import Microsimulation
+import pandas as pd
+from policyengine.impact.utils import *
+from policyengine.impact.population.metrics import (
+    deep_poverty_rate,
+    poverty_rate,
+)
+from policyengine.country.results_config import PolicyEngineResultsConfig
+
+
+def pov_chg(
+    baseline: Microsimulation,
+    reformed: Microsimulation,
+    criterion: str,
+    config: Type[PolicyEngineResultsConfig],
+) -> float:
+    """Calculate change in poverty rates.
+
+    :param baseline: Baseline simulation.
+    :type baseline: Microsimulation
+    :param reform: Reform simulation.
+    :type reform: Microsimulation
+    :param criterion: Filter for each simulation.
+    :type criterion: str
+    :return: Percentage (not percentage point) difference in poverty rates.
+    :rtype: float
+    """
+    return pct_change(
+        poverty_rate(baseline, criterion, config),
+        poverty_rate(reformed, criterion, config),
+    )
+
+
+def deep_pov_chg(
+    baseline: Microsimulation,
+    reformed: Microsimulation,
+    criterion: str,
+    config: Type[PolicyEngineResultsConfig],
+) -> float:
+    """Calculate change in poverty rates.
+
+    :param baseline: Baseline simulation.
+    :type baseline: Microsimulation
+    :param reform: Reform simulation.
+    :type reform: Microsimulation
+    :param criterion: Filter for each simulation.
+    :type criterion: str
+    :return: Percentage (not percentage point) difference in poverty rates.
+    :rtype: float
+    """
+    return pct_change(
+        deep_poverty_rate(baseline, criterion, config),
+        deep_poverty_rate(reformed, criterion, config),
+    )
+
+
+def poverty_chart(
+    baseline: Microsimulation,
+    reformed: Microsimulation,
+    is_deep: bool,
+    config: Type[PolicyEngineResultsConfig],
+) -> dict:
+    """Chart of poverty impact by age group and overall.
+
+    :param baseline: Baseline microsimulation.
+    :type baseline: Microsimulation
+    :param reformed: Reform microsimulation.
+    :type reformed: Microsimulation
+    :return: JSON representation of Plotly chart with poverty impact for:
+        - Children (under 18)
+        - Working age adults (18 to State Pension age)
+        - Pensioners (State Pension age and above)
+        - Overall
+    :rtype: dict
+    """
+    if is_deep:
+        f_pov_chg = deep_pov_chg
+        f_poverty_rate = deep_poverty_rate
+        metric_name = "Deep poverty"
+    else:
+        f_pov_chg = pov_chg
+        f_poverty_rate = poverty_rate
+        metric_name = "Poverty"
+    df = pd.DataFrame(
+        {
+            "group": ["Child", "Working-age", "Senior", "All"],
+            "pov_chg": [
+                f_pov_chg(baseline, reformed, i, config)
+                for i in [
+                    config.child_variable,
+                    config.working_age_variable,
+                    config.senior_variable,
+                    config.person_variable,
+                ]
+            ],
+            "baseline": [
+                f_poverty_rate(baseline, i, config)
+                for i in [
+                    config.child_variable,
+                    config.working_age_variable,
+                    config.senior_variable,
+                    config.person_variable,
+                ]
+            ],
+            "reformed": [
+                f_poverty_rate(reformed, i, config)
+                for i in [
+                    config.child_variable,
+                    config.working_age_variable,
+                    config.senior_variable,
+                    config.person_variable,
+                ]
+            ],
+        }
+    )
+    df["abs_chg_str"] = df.pov_chg.abs().map("{:.1%}".format)
+    df["label"] = (
+        "<b>"
+        + np.where(df.group == "All", "Total", df.group)
+        + " "
+        + metric_name.lower()
+        + " "
+        + np.where(
+            df.abs_chg_str == "0.0%",
+            "does not change",
+            (
+                np.where(df.pov_chg < 0, "falls ", "rises ")
+                + df.abs_chg_str
+                + "</b><br> from "
+                + df.baseline.map("{:.1%}".format)
+                + " to "
+                + df.reformed.map("{:.1%}".format)
+            ),
+        )
+    )
+    fig = px.bar(
+        df,
+        x="group",
+        y="pov_chg",
+        custom_data=["label"],
+        labels={"group": "Group", "pov_chg": metric_name + " rate change"},
+    )
+    fig.update_layout(
+        title=metric_name + " impact by age group",
+        xaxis_title=None,
+        yaxis=dict(title="Percent change", tickformat=",~%"),
+    )
+    fig.update_traces(marker_color=np.where(df.pov_chg < 0, DARK_GREEN, GRAY))
+    add_custom_hovercard(fig)
+    add_zero_line(fig)
+    return formatted_fig_json(fig)
diff --git a/policyengine/impact/population/metrics.py b/policyengine/impact/population/metrics.py
index 0ac7729a..be4ccad6 100644
--- a/policyengine/impact/population/metrics.py
+++ b/policyengine/impact/population/metrics.py
@@ -2,13 +2,9 @@
 Functions generating aggregates and other numerical outputs from microsimulation results.
 """
 from typing import Type
-from policyengine.utils.general import PolicyEngineResultsConfig
-from policyengine.utils.charts import num
+from policyengine.country.results_config import PolicyEngineResultsConfig
 from openfisca_tools import Microsimulation
-
-
-def pct_change(x: float, y: float) -> float:
-    return (y - x) / x
+from policyengine.impact.utils import *
 
 
 def poverty_rate(
@@ -61,16 +57,32 @@ def headline_metrics(
         baseline.calc(config.in_poverty_variable, map_to="person").mean(),
         reformed.calc(config.in_poverty_variable, map_to="person").mean(),
     )
-    winner_share = (gain > 1).mean()
-    loser_share = (gain < -1).mean()
-    gini_change = pct_change(old_income.gini(), new_income.gini())
+    winner_share = (gain > 0).mean()
+    loser_share = (gain < 0).mean()
     return dict(
-        net_cost=("-" if net_cost < 0 else "")
-        + config.currency
-        + num(abs(net_cost)),
-        net_cost_numeric=float(net_cost),
+        budgetary_impact_str=f'{"-" if net_cost < 0 else ""}{config.currency}{num(abs(net_cost))}',
+        budgetary_impact=float(net_cost),
         poverty_change=float(poverty_change),
         winner_share=float(winner_share),
         loser_share=float(loser_share),
-        gini_change=float(gini_change),
+    )
+
+
+def spending(
+    baseline: Microsimulation,
+    reformed: Microsimulation,
+    config: Type[PolicyEngineResultsConfig],
+) -> float:
+    """Budgetary impact of a reform (difference in net income).
+
+    :param baseline: Baseline microsimulation.
+    :type baseline: Microsimulation
+    :param reformed: Reform microsimulation.
+    :type reformed: Microsimulation
+    :return: Reform net income minus baseline net income.
+    :rtype: float
+    """
+    return (
+        reformed.calc(config.household_net_income_variable).sum()
+        - baseline.calc(config.household_net_income_variable).sum()
     )
diff --git a/policyengine/impact/utils/__init__.py b/policyengine/impact/utils/__init__.py
new file mode 100644
index 00000000..41c32487
--- /dev/null
+++ b/policyengine/impact/utils/__init__.py
@@ -0,0 +1,14 @@
+from .numeric import num, ordinal, pct_change, describe_change
+from .plotly import (
+    plotly_json_to_fig,
+    WHITE,
+    BLUE,
+    GRAY,
+    DARK_GRAY,
+    LIGHT_GRAY,
+    LIGHT_GREEN,
+    DARK_GREEN,
+    formatted_fig_json,
+    add_custom_hovercard,
+    add_zero_line,
+)
diff --git a/policyengine/impact/utils/numeric.py b/policyengine/impact/utils/numeric.py
new file mode 100644
index 00000000..8bb80a74
--- /dev/null
+++ b/policyengine/impact/utils/numeric.py
@@ -0,0 +1,60 @@
+"""
+Utility functions for handling numeric operations.
+"""
+
+
+from typing import Callable
+
+
+def pct_change(x: float, y: float) -> float:
+    return (y - x) / x
+
+
+def num(x: float) -> str:
+    """Converts a number to a human-readable string, using the k/m/bn/tr suffixes after rounding to 2 significant figures."""
+
+    if x < 0:
+        return "-" + num(-x)
+    if x < 1e3:
+        return f"{x:.2f}"
+    if x < 1e6:
+        return f"{x / 1e3:.0f}k"
+    if x < 1e9:
+        return f"{x / 1e6:.0f}m"
+    if x < 1e10:
+        return f"{x / 1e9:.2f}bn"
+    if x < 1e12:
+        return f"{x / 1e9:.1f}bn"
+    return f"{x / 1e12:.2f}tr"
+
+
+def ordinal(n: int) -> str:
+    """Create an ordinal number (1st, 2nd, etc.) from an integer.
+
+    Source: https://stackoverflow.com/a/20007730/1840471
+
+    :param n: Number.
+    :type n: int
+    :return: Ordinal number (1st, 2nd, etc.).
+    :rtype: str
+    """
+    return "%d%s" % (
+        n,
+        "tsnrhtdd"[(n // 10 % 10 != 1) * (n % 10 < 4) * n % 10 :: 4],
+    )
+
+
+def describe_change(
+    x: float,
+    y: float,
+    formatter: Callable = lambda x: x,
+    change_formatter=lambda x: x,
+    plural: bool = False,
+) -> str:
+    s = "" if plural else "s"
+    if y > x:
+        return f"rise{s} from {formatter(x)} to {formatter(y)} (+{change_formatter(y - x)})"
+    elif y == x:
+        return f"remain{s} at {formatter(x)}"
+    else:
+        return f"fall{s} from {formatter(x)} to {formatter(y)} (-{change_formatter(x - y)})"
diff --git a/policyengine/impact/utils/plotly.py b/policyengine/impact/utils/plotly.py
new file mode 100644
index 00000000..3c42df8c
--- /dev/null
+++ b/policyengine/impact/utils/plotly.py
@@ -0,0 +1,80 @@
+"""
+Utility functions for formatting charts.
+"""
+import plotly.graph_objects as go
+import json
+
+
+def plotly_json_to_fig(json):
+    """Converts a JSON dict to a plotly figure.
+
+    :param json: JSON dict.
+    :type json: dict
+    :return: Plotly figure.
+    :rtype: go.Figure
+    """
+    return go.Figure(data=json["data"], layout=json["layout"])
+
+
+WHITE = "#FFF"
+BLUE = "#5091cc"
+GRAY = "#BDBDBD"
+DARK_GRAY = "#616161"
+LIGHT_GRAY = "#F5F5F5"
+LIGHT_GREEN = "#C5E1A5"
+DARK_GREEN = "#558B2F"
+
+
+def formatted_fig_json(fig: go.Figure) -> dict:
+    """Formats figure with styling and returns as JSON.
+
+    :param fig: Plotly figure.
+    :type fig: go.Figure
+    :return: Formatted plotly figure as a JSON dict.
+    :rtype: dict
+    """
+    fig.update_xaxes(
+        title_font=dict(size=16, color="black"), tickfont={"size": 14}
+    )
+    fig.update_yaxes(
+        title_font=dict(size=16, color="black"), tickfont={"size": 14}
+    )
+    fig.update_layout(
+        hoverlabel_align="right",
+        font_family="Ubuntu",
+        font_color="Black",
+        title_font_size=20,
+        plot_bgcolor="white",
+        paper_bgcolor="white",
+        hoverlabel=dict(font_family="Ubuntu"),
+    )
+    return json.loads(fig.to_json())
+
+
+def add_custom_hovercard(fig: go.Figure) -> None:
+    """Add a custom hovercard to the figure based on the first element of
+    customdata, without the title to the right.
+
+    :param fig: Plotly figure.
+    :type fig: go.Figure
+    """
+    # Per https://stackoverflow.com/a/69430974/1840471.
+    fig.update_traces(hovertemplate="%{customdata[0]}<extra></extra>")
+
+
+def add_zero_line(fig: go.Figure) -> None:
+    """Add a solid line across y=0.
+
+    :param fig: Plotly figure.
+    :type fig: go.Figure
+    """
+    fig.add_shape(
+        type="line",
+        xref="paper",
+        yref="y",
+        x0=0,
+        y0=0,
+        x1=1,
+        y1=0,
+        line=dict(color="grey", width=1),
+    )
diff --git a/policyengine/impact/utils/text.py b/policyengine/impact/utils/text.py
new file mode 100644
index 00000000..55947443
--- /dev/null
+++ b/policyengine/impact/utils/text.py
@@ -0,0 +1,44 @@
+from typing import Any
+
+
+def format_summary_of_parameter_value(metadata: dict, value: Any) -> str:
+    """Formats a parameter value for display.
+
+    Args:
+        metadata (dict): The parameter metadata.
+        value (Any): The parameter value.
+
+    Returns:
+        str: The formatted parameter value.
+    """
+    unit = metadata.get("unit")
+    if unit == "currency-GBP":
+        prefix = "£"
+    elif unit == "currency-USD":
+        prefix = "$"
+    else:
+        prefix = ""
+
+    period = metadata.get("period")
+    if period == "year":
+        suffix = "/year"
+    elif period == "month":
+        suffix = "/month"
+    elif period == "week":
+        suffix = "/week"
+    else:
+        suffix = ""
+    label = metadata.get("label")
+    uncapitalised_label = f"{label.lower()[0]}{label[1:]}"
+    if unit == "abolition":
+        return f"Abolish {metadata.get('variable')}"
+    elif (unit == "bool") and value:
+        return metadata.label
+    elif unit == "bool":
+        return f"Revoke {uncapitalised_label}"
+    elif unit == "Enum":
+        return f"Set {uncapitalised_label} to {value}"
+    elif unit == "/1":
+        return f"Set {uncapitalised_label} to {value:.1%}"
+    else:
+        return f"Set {uncapitalised_label} to {prefix}{value:,.2f}{suffix}"
diff --git a/policyengine/monitoring/api_monitoring.py b/policyengine/monitoring/api_monitoring.py
deleted file mode 100644
index e29702e7..00000000
--- a/policyengine/monitoring/api_monitoring.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from policyengine.server import app
-from pathlib import Path
-import json
-
-
-def write_api_results(url: str) -> str:
-    with app.test_client() as client:
-        response = client.get(url)
-        data = json.loads(response.data.decode("utf-8"))
-    data = {key: value for key, value in data.items() if "chart" not in key}
-    text = f"""
-* Net cost: {data["net_cost"]}
-* Poverty change: {round(100 * data["poverty_change"], 1)}%
-* Winner share: {round(100 * data["winner_share"], 1)}%
-* Loser share: {round(100 * data["loser_share"], 1)}%\n\n"""
-    return text
-
-
-WATCHED_URLS = {
-    "Autumn Budget UC reform": "/uk/api/population-reform?UC_reduction_rate=0.55&UC_work_allowance_without_housing=557&UC_work_allowance_with_housing=335&policy_date=20211030",
-}
-
-
-def write_all_watched_urls():
-    text = ""
-    for name, url in WATCHED_URLS.items():
-        text += f"## {name}\n{write_api_results(url)}"
-    return text
-
-
-with open(Path(__file__).parent / "watched_reforms.md", "w+") as f:
-    f.write(write_all_watched_urls())
diff --git a/policyengine/monitoring/watched_reforms.md b/policyengine/monitoring/watched_reforms.md
deleted file mode 100644
index c2722019..00000000
--- a/policyengine/monitoring/watched_reforms.md
+++ /dev/null
@@ -1,7 +0,0 @@
-## Autumn Budget UC reform
-
-* Net cost: £2.20bn
-* Poverty change: -1.9%
-* Winner share: 9.4%
-* Loser share: 0.0%
-
diff --git a/policyengine/package.py b/policyengine/package.py
new file mode 100644
index 00000000..122fe61f
--- /dev/null
+++ b/policyengine/package.py
@@ -0,0 +1,3 @@
+from pathlib import Path
+
+POLICYENGINE_PACKAGE_PATH = Path(__file__).parent
diff --git a/policyengine/policyengine.py b/policyengine/policyengine.py
new file mode 100644
index 00000000..8bc7871e
--- /dev/null
+++ b/policyengine/policyengine.py
@@ -0,0 +1,99 @@
+import os
+from typing import Tuple, Type
+from flask import Flask
+from flask_cors import CORS
+from policyengine.web_server.cache import (
+    DisabledCache,
+    LocalCache,
+    PolicyEngineCache,
+    add_params_and_caching,
+)
+from policyengine.web_server.logging import PolicyEngineLogger, logged_endpoint
+from policyengine.web_server.cors import after_request_func
+from policyengine.web_server.static_site import add_static_site_handling
+from policyengine.package import POLICYENGINE_PACKAGE_PATH
+from .country import PolicyEngineCountry, UK, US
+
+
+class PolicyEngine:
+    """Class initialising and running the PolicyEngine API."""
+
+    version: str = "1.83.0"
+    """The version of the PolicyEngine API, used to identify the API version in the cache.
+    """
+
+    cache_bucket_name: str = "uk-policy-engine.appspot.com"
+    """The name of the Google Cloud Storage bucket used to cache results.
+    """
+
+    countries: Tuple[Type[PolicyEngineCountry]] = (UK, US)
+    """The country models supported by the PolicyEngine API.
+    """
+
+    app: Flask
+    """The Flask application handling the web server."""
+
+    @property
+    def debug_mode(self):
+        """Whether the PolicyEngine API is running in debug mode."""
+        return bool(os.environ.get("POLICYENGINE_DEBUG")) or self._debug
+
+    def log(self, message: str):
+        """Log a message to the PolicyEngine API logger as a general server message."""
+        self.logger.log(event="general_server_message", message=message)
+
+    def __init__(self, debug: bool = False):
+        self._debug = debug
+        self._init_logger()
+        self.log("Initialising server.")
+        self._init_countries()
+        self._init_cache()
+        self._init_flask()
+        self._init_routes()
+        self.log("Initialisation complete.")
+
+    def _init_countries(self):
+        """Initialise the country models."""
+        self.countries = tuple(map(lambda country: country(), self.countries))
+
+    def _init_cache(self):
+        """Initialise the cache for load-intensive endpoint results."""
+        if self.cache_bucket_name is not None and not self.debug_mode:
+            print("Initialising cache.")
+            self.cache = PolicyEngineCache(
+                self.version, self.cache_bucket_name
+            )
+        else:
+            self.cache = LocalCache(self.version)
+
+    def _init_flask(self):
+        """Initialise the Flask application."""
+        self.app = Flask(
+            type(self).__name__,
+            static_url_path="",
+            static_folder=str(
+                (POLICYENGINE_PACKAGE_PATH / "static").absolute()
+            ),
+        )
+        CORS(self.app)
+
+    def _init_routes(self):
+        """Initialise Flask routing to direct non-API requests to a static assets folder."""
+        add_static_site_handling(self.app)
+        for country in self.countries:
+            for endpoint, endpoint_fn in country.api_endpoints.items():
+                endpoint_fn = add_params_and_caching(
+                    endpoint_fn, self.cache, self.logger
+                )
+                endpoint_fn = logged_endpoint(endpoint_fn, self.logger)
+                self.app.route(
+                    f"/{country.name}/api/{endpoint.replace('_', '-')}",
+                    methods=["GET", "POST"],
+                    endpoint=f"{country.name}_{endpoint}",
+                )(endpoint_fn)
+                setattr(self, endpoint_fn.__name__, endpoint_fn)
+        self.after_request_func = self.app.after_request(after_request_func)
+
+    def _init_logger(self):
+        """Initialise the logger for the PolicyEngine API."""
+        self.logger = PolicyEngineLogger(local=self.debug_mode)
diff --git a/policyengine/server.py b/policyengine/server.py
index 9085dc44..a8264795 100644
--- a/policyengine/server.py
+++ b/policyengine/server.py
@@ -1,141 +1,3 @@
-"""
-The PolicyEngine server logic (Flask-based).
-"""
-import os
-from pathlib import Path
-from typing import Tuple, Type
-from flask import Flask, request, send_from_directory
-from flask_cors import CORS
-from time import time
-from policyengine.utils.general import (
-    get_cached_result,
-    after_request_func,
-    set_cached_result,
-)
-from policyengine.countries import UK, US, PolicyEngineCountry
-from policyengine import VERSION
-from policyengine.utils.social_card import add_social_card_metadata
-
-
-class PolicyEngine:
-    version: str = VERSION
-    cache_bucket_name: str = "uk-policy-engine.appspot.com"
-    countries: Tuple[Type[PolicyEngineCountry]] = (UK, US)
-
-    def _init_countries(self):
-        self.countries = list(map(lambda country: country(), self.countries))
-
-    def _init_forwarding(self):
-        def static_site(e):
-            with open(
-                str(Path(__file__).parent / "static" / "index.html")
-            ) as f:
-                text = f.read()
-                modified = add_social_card_metadata(request.path, text)
-            with open(
-                str(Path(__file__).parent / "static" / "index_mod.html"), "w"
-            ) as f:
-                f.write(modified)
-            response = send_from_directory(
-                str(Path(__file__).parent / "static"), "index_mod.html"
-            )
-            return response
-
-        self.static_site = self.app.errorhandler(404)(static_site)
-
-        def timed_endpoint(fn):
-            def new_fn(*args, **kwargs):
-                start_time = time()
-                result = fn(*args, **kwargs)
-                duration = time() - start_time
-                self.app.logger.info(
-                    f"{fn.__name__} completed in {round(duration, 1)}s."
-                )
-                return result
-
-            new_fn.__name__ = "timed_" + fn.__name__
-            return new_fn
-
-        def pass_params_and_cache(fn):
-            def new_fn(*args, **kwargs):
-                params = {**request.args, **(request.json or {})}
-                cached_result = None
-                cache = (
-                    self.cache
-                    if not hasattr(fn, "_exclude_from_cache")
-                    else None
-                )
-                if cache is not None:
-                    cached_result = get_cached_result(
-                        params, fn.__name__, self.version, self.cache
-                    )
-                if cached_result is not None:
-                    return cached_result
-                else:
-                    result = fn(*args, params=params, **kwargs)
-                    if cache is not None:
-                        set_cached_result(
-                            params,
-                            fn.__name__,
-                            self.version,
-                            self.cache,
-                            result,
-                        )
-                    return result
-
-            new_fn.__name__ = (
-                "cached_" if not hasattr(fn, "_exclude_from_cache") else ""
-            ) + fn.__name__
-            return new_fn
-
-        self.api_decorators = (
-            pass_params_and_cache,
-            timed_endpoint,
-        )
-
-        for country in self.countries:
-            for route, handler in country.api_endpoints.items():
-                fn = handler
-                for decorator in (
-                    *self.api_decorators,
-                    self.app.route(
-                        f"/{country.name}/api/{route.replace('_', '-')}",
-                        methods=["GET", "POST"],
-                        endpoint=f"{country.name}_{route}",
-                    ),
-                ):
-                    fn = decorator(fn)
-                    setattr(self, fn.__name__, fn)
-
-        self.after_request_func = self.app.after_request(after_request_func)
-
-    def _init_flask(self):
-        self.app = Flask(
-            type(self).__name__,
-            static_url_path="",
-            static_folder=str((Path(__file__).parent / "static").absolute()),
-        )
-        CORS(self.app)
-
-    @property
-    def debug_mode(self):
-        return bool(os.environ.get("POLICYENGINE_DEBUG"))
-
-    def _init_cache(self):
-        if self.cache_bucket_name is not None and not self.debug_mode:
-            from google.cloud import storage
-
-            self.cache = storage.Client().get_bucket(self.cache_bucket_name)
-        else:
-            self.cache = None
-
-    def __init__(self):
-        self._init_flask()
-        self.app.logger.info("Initialising server.")
-        self._init_countries()
-        self._init_cache()
-        self._init_forwarding()
-        self.app.logger.info("Initialisation complete.")
-
+from policyengine import PolicyEngine
 
 app = PolicyEngine().app
diff --git a/policyengine/utils/general.py b/policyengine/utils/general.py
deleted file mode 100644
index 69388166..00000000
--- a/policyengine/utils/general.py
+++ /dev/null
@@ -1,75 +0,0 @@
-import json
-from time import time
-from typing import Callable
-from flask import request, make_response
-from openfisca_core.model_api import *
-
-
-class PolicyEngineResultsConfig:
-    net_income_variable: str
-    in_poverty_variable: str
-    in_deep_poverty_variable: str
-    household_net_income_variable: str
-    household_wealth_variable: str
-    equiv_household_net_income_variable: str
-    child_variable: str
-    working_age_variable: str
-    senior_variable: str
-    person_variable: str
-    earnings_variable: str
-    tax_variable: str
-    benefit_variable: str
-    currency: str
-    household_entity: str = "household"
-
-
-def dict_to_string(d: dict) -> str:
-    return "_".join(["_".join((str(x), str(y))) for x, y in d.items()])
-
-
-def exclude_from_cache(f: Callable) -> Callable:
-    setattr(f, "_exclude_from_cache", True)
-    return f
-
-
-def get_cached_result(
-    params: dict, endpoint: str, version: str, bucket
-) -> dict:
-    request_id = f"{endpoint}-{dict_to_string(params)}-{version}"
-    blob = bucket.blob(request_id + ".json")
-    if blob.exists():
-        return json.loads(blob.download_as_string())
-
-
-def set_cached_result(
-    params: dict, endpoint: str, version: str, bucket, result: dict
-) -> None:
-    request_id = f"{endpoint}-{dict_to_string(params)}-{version}"
-    blob = bucket.blob(request_id + ".json")
-    blob.upload_from_string(json.dumps(result))
-
-
-def after_request_func(response):
-    origin = request.headers.get("Origin")
-    if request.method == "OPTIONS":
-        response = make_response()
-        response.headers.add("Access-Control-Allow-Credentials", "true")
-        response.headers.add("Access-Control-Allow-Headers", "Content-Type")
-        response.headers.add("Access-Control-Allow-Headers", "x-csrf-token")
-        response.headers.add(
-            "Access-Control-Allow-Methods",
-            "GET, POST, OPTIONS, PUT, PATCH, DELETE",
-        )
-        if origin:
-            response.headers.add("Access-Control-Allow-Origin", origin)
-    else:
-        response.headers.add("Access-Control-Allow-Credentials", "true")
-        if origin:
-            response.headers.add("Access-Control-Allow-Origin", origin)
-        response.headers[
-            "Cache-Control"
-        ] = "no-cache, no-store, must-revalidate, public, max-age=0"
-        response.headers["Pragma"] = "no-cache"
-        response.headers["Expires"] = "0"
-
-    return response
diff --git a/policyengine/utils/reforms.py b/policyengine/utils/reforms.py
deleted file mode 100644
index ee721a54..00000000
--- a/policyengine/utils/reforms.py
+++ /dev/null
@@ -1,603 +0,0 @@
-"""
-Utility functions for writing reforms.
-"""
-from collections import OrderedDict, Sequence
-from pathlib import Path
-from typing import Any, Callable, Dict, List, Tuple, Type
-import numpy as np
-from openfisca_core.parameters.helpers import load_parameter_file
-from openfisca_core.parameters import Parameter, ParameterNode
-from openfisca_core.parameters.parameter_scale import ParameterScale
-from openfisca_core.reforms.reform import Reform
-from openfisca_core.tracers.tracing_parameter_node_at_instant import (
-    ParameterNode,
-)
-from openfisca_core.variables import Variable
-from datetime import datetime
-from openfisca_core.taxbenefitsystems import TaxBenefitSystem
-from openfisca_tools.model_api import ReformType
-from openfisca_core.model_api import Enum
-
-
-def structural(variable: Type[Variable]) -> Reform:
-    """Generates a structural reform.
-
-    Args:
-        variable (Type[Variable]): The class definition of a variable to replace.
-
-    Returns:
-        Reform: The reform object.
-    """
-    return type(
-        variable.__name__,
-        (Reform,),
-        dict(apply=lambda self: self.update_variable(variable)),
-    )
-
-
-def reinstate_variable(system, variable):
-    clone = system.variables[variable].clone()
-    clone.is_neutralized = False
-    system.variables[variable] = clone
-
-
-def abolish(variable: str, neutralize: bool = True) -> Reform:
-    if neutralize:
-        return type(
-            f"abolish_{variable}",
-            (Reform,),
-            dict(apply=lambda self: self.neutralize_variable(variable)),
-        )
-    else:
-        return type(
-            f"reinstate_{variable}",
-            (Reform,),
-            dict(apply=lambda self: reinstate_variable(self, variable)),
-        )
-
-
-def parametric(
-    parameter: str, value: float, period: str = "year:2015:20"
-) -> Reform:
-    """Generates a parametric reform.
-
-    Args:
-        parameter (str): The name of the parameter, e.g. tax.income_tax.rate.
-        value (float): The value to set as the parameter value.
-        period (str): The time period to set it for. Defaults to a ten-year period from 2015 to 2025.
-
-    Returns:
-        Reform: The reform object.
-    """
-
-    def modifier_fn(parameters: ParameterNode):
-        node = parameters
-        for name in parameter.split("."):
-            try:
-                if "[" not in name:
-                    node = node.children[name]
-                else:
-                    try:
-                        name, index = name.split("[")
-                        index = int(index[:-1])
-                        node = node.children[name].brackets[index]
-                    except:
-                        raise ValueError(
-                            "Invalid bracket syntax (should be e.g. tax.brackets[3].rate"
-                        )
-            except:
-                raise ValueError(
-                    f"Could not find the parameter (failed at {name})."
-                )
-        node.update(period=period, value=value)
-        return parameters
-
-    return type(
-        parameter,
-        (Reform,),
-        dict(apply=lambda self: self.modify_parameters(modifier_fn)),
-    )
-
-
-def add_parameter_file(path: str) -> Reform:
-    """Generates a reform adding a parameter file to the tree.
-
-    Args:
-        path (str): The path to the parameter YAML file.
-
-    Returns:
-        Reform: The Reform adding the parameters.
-    """
-
-    def modify_parameters(parameters: ParameterNode):
-        file_path = Path(path)
-        reform_parameters_subtree = load_parameter_file(file_path)
-        parameters.add_child("reforms", reform_parameters_subtree.reforms)
-        return parameters
-
-    class reform(Reform):
-        def apply(self):
-            self.modify_parameters(modify_parameters)
-
-    return reform
-
-
-def summary_from_metadata(metadata: dict) -> str:
-    """Generates a summary from parameter metadata.
-
-    Args:
-        metadata (dict): The parameter metadata.
-
-    Returns:
-        str: The summary.
-    """
-    if metadata["type"] == "abolish":
-        return f"Abolish {metadata['title']}"
-    else:
-        return f"Change the {metadata['title']} to @"
-
-
-def get_enum_map_from_variable_name(
-    variable: str, variables: Dict[str, Variable]
-) -> Dict[str, str]:
-    if not isinstance(variable, str) or variable not in variables:
-        return {}
-    enum: Type[Enum] = variables[variable].possible_values
-    return {key: enum.value for key, enum in enum._member_map_.items()}
-
-
-def flow_breakdown_parameter_metadata_down(
-    parameters: ParameterNode, variables: List[Variable]
-) -> ParameterNode:
-    for parameter in parameters.get_descendants():
-        parameter_name = parameter.metadata.get(
-            "name", parameter.name.replace(".", "_")
-        )
-        if parameter.metadata.get("breakdown"):
-            enum_maps = [
-                get_enum_map_from_variable_name(variable, variables)
-                for variable in parameter.metadata["breakdown"]
-            ]
-            for descendant in parameter.get_descendants():
-                if not isinstance(descendant, Parameter):
-                    continue
-                descendant_path = descendant.name.replace(parameter.name, "")[
-                    1:
-                ]
-                descendant.metadata.update(
-                    {
-                        x: y
-                        for x, y in parameter.metadata.items()
-                        if x != "breakdown"
-                    }
-                )
-                descendant.metadata["name"] = (
-                    parameter_name + "_" + "_".join(descendant_path.split("."))
-                )
-                step_labels = [
-                    (key, enum_maps[i].get(key, key))
-                    for i, key in enumerate(descendant_path.split("."))
-                ]
-                step_label = f"({', '.join([x[1] for x in step_labels])})"
-                descendant.metadata["label"] = (
-                    parameter.metadata.get("label", parameter.name)
-                    + " "
-                    + step_label
-                )
-                descendant.metadata["breakdown_parts"] = step_labels
-
-    return parameters
-
-
-EXCLUDED_PARAMETERS = [
-    "gov_hhs_medicaid_geography",
-]  # Temporary fix: skip parameters with very large subtrees.
-
-
-def get_PE_parameter_scale(
-    parameter: ParameterNode, name: str, reference: dict, now: datetime
-) -> dict:
-    """Transforms an OpenFisca ParameterScale into metadata served over the PolicyEngine API.
-
-    Args:
-        parameter (ParameterNode): The ParameterScale.
-        name (str): The name of the parameter.
-        reference (dict): The reference of the parameter.
-        now (datetime): The current date.
-
-    Returns:
-        dict: The resulting metadata.
-    """
-
-    data = dict(
-        name=name,
-        parameter=parameter.name,
-        description=parameter.description,
-        label=parameter.metadata.get("label", parameter.name),
-        valueType="parameter_scale",
-        thresholdUnit=parameter.metadata.get("threshold_unit"),
-        thresholdPeriod=parameter.metadata.get("threshold_period", "year"),
-        rateUnit=parameter.metadata.get("rate_unit"),
-        ratePeriod=parameter.metadata.get("rate_period"),
-        possibleValues=parameter.metadata.get("possible_values"),
-        reference=reference,
-        brackets=len(parameter.brackets),
-        scaleType=parameter.metadata.get("type"),
-    )
-    return data
-
-
-def get_PE_parameters(
-    system: TaxBenefitSystem, date: str = None
-) -> Dict[str, dict]:
-    """Extracts PolicyEngine parameters from OpenFisca parameter metadata.
-
-    Args:
-        system (TaxBenefitSystem): The tax-benefit system to extract from.
-        date (str): The date to extract parameters for. Defaults to the current date.
-
-    Returns:
-        Dict[str, dict]: The parameter metadata.
-    """
-
-    now = datetime.now().strftime("%Y-%m-%d")
-    if date is not None:
-        now = datetime.strptime(date, "%Y-%m-%d").strftime("%Y-%m-%d")
-    parameters = []
-    system.parameters = flow_breakdown_parameter_metadata_down(
-        system.parameters, system.variables
-    )
-    for parameter in system.parameters.get_descendants():
-        if isinstance(parameter, Parameter):
-            parameters += [parameter]
-        elif isinstance(parameter, ParameterScale):
-            for bracket in parameter.brackets:
-                for attribute in ("rate", "amount", "threshold"):
-                    if hasattr(bracket, attribute):
-                        parameters += [
-                            (getattr(bracket, attribute), parameter)
-                        ]
-            parameters += [parameter]
-        else:
-            parameters += [parameter]
-    parameter_metadata = OrderedDict()
-    for parameter in parameters:
-        try:
-            if isinstance(parameter, tuple):
-                parameter, parent = parameter
-            else:
-                parent = None
-            if "name" in parameter.metadata:
-                name = parameter.metadata["name"]
-            else:
-                name = parameter.name.replace(".", "_")
-            if "[" in name:
-                # Parameter scale component
-                main_name = name.split("[")[0]
-                if parent is not None:
-                    main_name = parent.metadata.get(
-                        "name", parent.name.replace(".", "_")
-                    )
-                index = int(name.split("[")[1].split("]")[0])
-                suffix = name.split("]")[-1].replace("_", "")
-                if suffix == "amount":
-                    suffix = "rate"
-                name = f"{main_name}_{index}_{suffix}"
-            if any(
-                [
-                    excluded_parameter in name
-                    for excluded_parameter in EXCLUDED_PARAMETERS
-                ]
-            ):
-                continue
-            if isinstance(parameter, Parameter):
-                value_type = parameter.metadata.get(
-                    "value_type", parameter(now).__class__.__name__
-                )
-            else:
-                value_type = "parameter_node"
-            assert value_type not in ("list",)
-            if value_type == "Enum":
-                value = parameter(now)[0]
-            else:
-                value = parameter(now)
-            try:
-                if parameter.metadata.get("reference") is not None:
-                    reference = parameter.metadata["reference"]
-                    if isinstance(reference, dict):
-                        reference = [reference]
-                    for item in reference:
-                        if isinstance(item, str):
-                            item = dict(title=item, href=item)
-                        if "name" in item:
-                            item["title"] = item["name"]
-                    reference = {
-                        item["title"]: item["href"] for item in reference
-                    }
-                else:
-                    reference = {}
-            except:
-                reference = {}
-            if isinstance(parameter, ParameterScale):
-                parameter_metadata[name] = get_PE_parameter_scale(
-                    parameter, name, reference, now
-                )
-            else:
-                if parent is not None:
-                    label = f"{parameter.metadata.get('label', parent.metadata.get('label', parent.name))} ({suffix} {index + 1})"
-                else:
-                    label = parameter.metadata.get("label", parameter.name)
-                parameter_metadata[name] = dict(
-                    name=name,
-                    parameter=parameter.name,
-                    description=parameter.description,
-                    label=label,
-                    value=value if isinstance(parameter, Parameter) else None,
-                    valueType=value_type,
-                    unit=None,
-                    period=None,
-                    variable=None,
-                    max=None,
-                    min=None,
-                    possibleValues=parameter.metadata.get("possible_values"),
-                    reference=reference,
-                )
-                if parameter(now) == np.inf:
-                    parameter_metadata[name]["value"] = "inf"
-                if parameter(now) == -np.inf:
-                    parameter_metadata[name]["value"] = "-inf"
-            OPTIONAL_ATTRIBUTES = (
-                "period",
-                "variable",
-                "max",
-                "min",
-                "unit",
-                "breakdown_parts",
-            )
-            for attribute in OPTIONAL_ATTRIBUTES:
-                if attribute in parameter.metadata:
-                    parameter_metadata[name][attribute] = parameter.metadata[
-                        attribute
-                    ]
-            if parent is not None:
-                if "rate" in parameter.name:
-                    parameter_metadata[name]["unit"] = parent.metadata.get(
-                        "rate_unit", parameter.metadata.get("unit")
-                    )
-                if "threshold" in parameter.name:
-                    parameter_metadata[name]["unit"] = parent.metadata.get(
-                        "threshold_unit", parameter.metadata.get("unit")
-                    )
-        except Exception as e:
-            if "rate_schedule" in parameter.name:
-                raise e
-            pass
-    return parameter_metadata
-
-
-CURRENCY_SYMBOLS = {
-    "currency-GBP": "£",
-    "USD": "$",
-}
-
-
-def apply_reform(reform: tuple, system: TaxBenefitSystem) -> TaxBenefitSystem:
-    if not hasattr(system, "modify_parameters"):
-
-        def modify_parameters(self, modifier):
-            self.parameters = modifier(self.parameters)
-
-        system.modify_parameters = modify_parameters.__get__(system)
-    if isinstance(reform, tuple):
-        for subreform in reform:
-            system = apply_reform(subreform, system)
-    else:
-        reform.apply(system)
-    return system
-
-
-def get_formatter(parameter: dict) -> Callable:
-    if parameter["unit"] == "/1":
-        return (
-            lambda value: f"{f'{round(value * 100, 2):,}'.rstrip('0').rstrip('.')}%"
-        )
-    for currency_type in CURRENCY_SYMBOLS:
-        if parameter["unit"] == currency_type:
-            return (
-                lambda value: f"{CURRENCY_SYMBOLS[currency_type]}{value}/{parameter['period']}"
-            )
-    return lambda value: str(value)
-
-
-def get_summary(parameter: dict, value: Any) -> str:
-    formatter = get_formatter(parameter)
-    if parameter["valueType"] in ("float", "int"):
-        change_label = "Increase" if value > parameter["value"] else "Decrease"
-        return f"{change_label} {parameter['label']} from {formatter(parameter['value'])} to {formatter(value)}"
-    if parameter["valueType"] == "bool":
-        if parameter["unit"] == "abolition":
-            return parameter["label"]
-    return parameter["label"]
-
-
-def create_reform(
-    parameters: dict,
-    policyengine_parameters: dict = {},
-    default_reform: ReformType = (),
-) -> Tuple[Reform, Reform]:
-    """Translates URL parameters into an OpenFisca reform.
-
-    Args:
-        parameters (dict): The URL parameters.
-        policyengine_parameters (dict, optional): The exposed OpenFisca parameters. Defaults to {}.
-        default_reform (ReformType, optional): The default reform to apply. Defaults to ().
-
-    Returns:
-        Reform: The OpenFisca reform.
-    """
-    params = {}
-    for key, value in parameters.items():
-        components = key.split("_")
-        name = "_".join(components)
-        if isinstance(value, list):
-            value = value[0]
-        try:
-            params[name] = float(value)
-        except:
-            params[name] = value
-    result = dict(
-        baseline=dict(
-            reform=[],
-            names=[],
-            descriptions=[],
-        ),
-        reform=dict(
-            reform=[],
-            names=[],
-            descriptions=[],
-        ),
-    )
-    baseline_reform_passed = False
-    policy_date_reform = None
-    pairs = sorted(
-        list(params.items()),
-        key=lambda pair: 0 if "baseline_" in pair[0] else 1,
-    )
-    for param, value in pairs:
-        if param == "policy_date":
-            str_value = str(value)
-            baseline_reform_passed = True
-            policy_date_reform = use_current_parameters(
-                f"{str_value[:4]}-{str_value[4:6]}-{str_value[6:8]}"
-            )
-        elif param in [
-            "country_specific",
-            "baseline_country_specific",
-            "state_specific",
-            "baseline_state_specific",
-        ]:
-            pass  # Do not attempt to apply the country specifier as a reform
-        elif param != "household":
-            if (
-                (param in ("reform", "baseline_reform"))
-                and isinstance(value, type)
-                or isinstance(value, tuple)
-            ):
-                name = "Structural reform"
-                reform = value
-                description = "Custom-defined structural reform"
-            else:
-                metadata = policyengine_parameters[
-                    param.replace("baseline_", "")
-                ]
-                name = metadata["label"]
-                description = get_summary(metadata, value)
-                if metadata["valueType"] == "bool":
-                    # Extra safety checks
-                    value = {
-                        "true": True,
-                        "false": False,
-                        1: True,
-                        0: False,
-                        True: True,
-                        False: False,
-                    }[value]
-                if metadata["unit"] == "abolition":
-                    if metadata["variable"] is not None:
-                        if isinstance(metadata["variable"], list):
-                            reform = tuple(
-                                [
-                                    abolish(variable, value)
-                                    for variable in metadata["variable"]
-                                ]
-                            )
-                        else:
-                            reform = abolish(metadata["variable"], value)
-                    else:
-                        reform = parametric(metadata["parameter"], value)
-                else:
-                    reform = parametric(metadata["parameter"], value)
-            if "baseline" in param:
-                result["baseline"]["reform"] += [reform]
-                result["baseline"]["names"] += [name]
-                result["baseline"]["descriptions"] += [description]
-                result["reform"]["reform"] += [reform]
-                result["reform"]["names"] += [name]
-                result["reform"]["descriptions"] += [description]
-                baseline_reform_passed = True
-            else:
-                result["reform"]["reform"] += [reform]
-                result["reform"]["names"] += [name]
-                result["reform"]["descriptions"] += [description]
-    if policy_date_reform is None:
-        policy_date_reform = use_current_parameters()
-    default_reform = (*default_reform, policy_date_reform)
-    for sim in ("baseline", "reform"):
-        result[sim]["reform"] = [default_reform] + result[sim]["reform"]
-        result[sim]["names"] = ["Default reform"] + result[sim]["names"]
-        result[sim]["descriptions"] = ["Default reform"] + result[sim][
-            "descriptions"
-        ]
-        result[sim]["reform"] = tuple(result[sim]["reform"])
-    result["baseline"]["has_changed"] = baseline_reform_passed
-    return result
-
-
-EXCLUDED_CURRENT_PARAMETER_NODES = [
-    "gov.hhs.medicaid.geography",
-]
-
-
-def use_current_parameters(date: str = None) -> Reform:
-    """Backdates parameters at a given instant to the start of the year.
-
-    Args:
-        date (str, optional): The given instant. Defaults to now.
-
-    Returns:
-        Reform: The reform backdating parameters.
-    """
-    if date is None:
-        date = datetime.now()
-    else:
-        date = datetime.strptime(date, "%Y-%m-%d")
-
-    year = date.year
-    date = datetime.strftime(date, "%Y-%m-%d")
-
-    def modify_parameters(parameters: ParameterNode):
-        for child in parameters.get_descendants():
-            if any(
-                name in child.name for name in EXCLUDED_CURRENT_PARAMETER_NODES
-            ):
-                continue
-            if isinstance(child, Parameter):
-                current_value = child(date)
-                child.update(period=f"year:{year-10}:20", value=current_value)
-            elif isinstance(child, ParameterScale):
-                for bracket in child.brackets:
-                    if "rate" in bracket.children:
-                        current_rate = bracket.rate(date)
-                        bracket.rate.update(
-                            period=f"year:{year-10}:20", value=current_rate
-                        )
-                    if "threshold" in bracket.children:
-                        current_threshold = bracket.threshold(date)
-                        bracket.threshold.update(
-                            period=f"year:{year-10}:20",
-                            value=current_threshold,
-                        )
-        try:
-            parameters.reforms.policy_date.update(
-                value=int(datetime.now().strftime("%Y%m%d")),
-                period=f"year:{year-10}:20",
-            )
-        except:
-            pass
-        return parameters
-
-    class reform(Reform):
-        def apply(self):
-            self.modify_parameters(modify_parameters)
-
-    return reform
diff --git a/policyengine/utils/situations.py b/policyengine/utils/situations.py
deleted file mode 100644
index c52d388c..00000000
--- a/policyengine/utils/situations.py
+++ /dev/null
@@ -1,85 +0,0 @@
-"""
-Functions to convert URL query parameters into OpenFisca situation initialiser functions.
-"""
-
-from typing import Callable, Dict, Tuple
-from openfisca_core.taxbenefitsystems import TaxBenefitSystem
-from openfisca_core.model_api import Enum
-from openfisca_tools.model_api import FLOW
-from openfisca_tools import IndividualSim
-from collections import Sequence
-
-
-def get_PE_variables(system: TaxBenefitSystem) -> Dict[str, dict]:
-    """Extracts PolicyEngine parameters from OpenFisca parameter metadata.
-
-    Args:
-        system (TaxBenefitSystem): The tax-benefit system to extract from.
-
-    Returns:
-        Dict[str, dict]: The parameter metadata.
-    """
-    variables = list(system.variables.values())
-    variable_metadata = {}
-    for variable in variables:
-        try:
-            try:
-                if isinstance(variable.reference, Sequence):
-                    reference = {v: v for v in variable.reference}
-                elif variable.reference is None:
-                    reference = {}
-                else:
-                    reference = {variable.reference: variable.reference}
-            except:
-                reference = {}
-            if variable.documentation is not None:
-                description = variable.documentation
-                if description[-1] != ".":
-                    description += "."
-            else:
-                description = None
-            variable_metadata[variable.name] = dict(
-                name=variable.name,
-                unit=variable.unit,
-                label=variable.label,
-                documentation=description,
-                description=description,
-                valueType=variable.value_type.__name__,
-                defaultValue=variable.default_value,
-                definitionPeriod=variable.definition_period,
-                entity=variable.entity.key,
-                possibleValues=None,
-                possibleKeys=None,
-                reference=reference,
-                quantityType=FLOW.lower()
-                if not hasattr(variable, "quantity_type")
-                else variable.quantity_type.lower(),
-            )
-            if variable_metadata[variable.name]["valueType"] == "Enum":
-                if (len(variable.possible_values) > 100) and (
-                    variable.name != "BRMA"
-                ):
-                    del variable_metadata[variable.name]
-                    raise ValueError(
-                        f"Enums with more than 100 values are not supported ({variable.name} has {len(variable.possible_values)})."
-                    )
-                variable_metadata[variable.name]["possibleValues"] = [
-                    dict(key=enum.name, value=enum.value)
-                    for enum in variable.possible_values
-                ]
-                variable_metadata[variable.name]["defaultValue"] = dict(
-                    key=variable.default_value.name,
-                    value=variable.default_value.value,
-                )
-        except:
-            pass
-    return variable_metadata
-
-
-def create_situation(situation_json: dict) -> Callable:
-    def situation(sim: IndividualSim):
-        sim.situation_data = situation_json
-        sim.build()
-        return sim
-
-    return situation
diff --git a/policyengine/utils/__init__.py b/policyengine/web_server/__init__.py
similarity index 100%
rename from policyengine/utils/__init__.py
rename to policyengine/web_server/__init__.py
diff --git a/policyengine/web_server/cache.py b/policyengine/web_server/cache.py
new file mode 100644
index 00000000..b0b2aa17
--- /dev/null
+++ b/policyengine/web_server/cache.py
@@ -0,0 +1,229 @@
+import json
+from multiprocessing import Process
+from time import time
+from typing import Callable
+from flask import request, make_response
+from threading import Thread
+import traceback
+from policyengine.web_server.logging import PolicyEngineLogger
+from typing import Dict, Any
+import hashlib
+import json
+
+
+def dict_hash(dictionary: Dict[str, Any]) -> str:
+    """MD5 hash of a dictionary."""
+    dhash = hashlib.md5()
+    # We need to sort arguments so {'a': 1, 'b': 2} is
+    # the same as {'b': 2, 'a': 1}
+    encoded = json.dumps(dictionary, sort_keys=True).encode()
+    dhash.update(encoded)
+    return dhash.hexdigest()
+
+
+def cached_endpoint(f: Callable) -> Callable:
+    """Marks a function as a cached endpoint.
+
+    Args:
+        f (Callable): The function.
+
+    Returns:
+        Callable: The function with metadata added.
+    """
+    setattr(f, "_cached_endpoint", True)
+    return f
+
+
+class TaskStatus:
+    QUEUED = "queued"
+    IN_PROGRESS = "in_progress"
+    COMPLETED = "completed"
+
+
+class PolicyEngineCache:
+    def __init__(self, version, bucket_name: str = None):
+        """Initialises the cache.
+
+        Args:
+            version (str): The version of the API.
+            bucket (google.cloud.Bucket): The Google Cloud bucket.
+        """
+        from google.cloud import storage
+
+        self.bucket = storage.Client().get_bucket(bucket_name)
+        self.version = version
+
+    def get_name(self, params: dict, endpoint: str) -> str:
+        """Gets the key representing a request in the cache.
+
+        Args:
+            params (dict): The parameters of the request.
+            endpoint (str): The endpoint name.
+
+        Returns:
+            str: The key.
+        """
+        return f"{endpoint}-{dict_hash(params)}-{self.version}"
+
+    def get(self, params: dict, endpoint: str) -> dict:
+        """Looks up an API request in the cache, returning a result if it exists.
+
+        Args:
+            params (dict): The parameters of the request.
+            endpoint (str): The endpoint name.
+
+        Returns:
+            dict: _description_
+        """
+        request_id = self.get_name(params, endpoint)
+        blob = self.bucket.blob(request_id + ".json")
+        if blob.exists():
+            return json.loads(blob.download_as_string())
+
+    def set(self, params: dict, endpoint: str, result: dict) -> None:
+        """Sets a result in the cache.
+
+        Args:
+            params (dict): The parameters of the request.
+            endpoint (str): The endpoint name.
+            result (dict): The API result.
+        """
+        request_id = self.get_name(params, endpoint)
+        blob = self.bucket.blob(request_id + ".json")
+        blob.upload_from_string(json.dumps(result))
+
+
+class LocalCache(PolicyEngineCache):
+    def __init__(self, version):
+        self.cache = {}
+        self.version = version
+
+    def get(self, params: dict, endpoint: str) -> dict:
+        request_id = self.get_name(params, endpoint)
+        return self.cache.get(request_id)
+
+    def set(self, params: dict, endpoint: str, result: dict) -> None:
+        request_id = self.get_name(params, endpoint)
+        self.cache[request_id] = result
+
+
+class DisabledCache(PolicyEngineCache):
+    __init__ = lambda *args, **kwargs: None
+    get = lambda *args, **kwargs: None
+    set = lambda *args, **kwargs: None
+
+
+class PolicyEngineTask:
+    key: str
+    """A unique identifier for the task."""
+
+    status: str = TaskStatus.QUEUED
+    """The status of the task."""
+
+    def mark_in_progress(self):
+        """Marks the task as in progress."""
+        self.status = TaskStatus.IN_PROGRESS
+
+    def mark_completed(self):
+        """Marks the task as completed."""
+        self.status = TaskStatus.COMPLETED
+
+    def __init__(
+        self,
+        task: Callable,
+        params: dict,
+        endpoint: str,
+        kwargs: dict,
+        cache: PolicyEngineCache,
+        logger: PolicyEngineLogger,
+    ):
+        """Initialises the task.
+
+        Args:
+            task (Callable): The task.
+            params (dict): The parameters of the request.
+            endpoint (str): The endpoint name.
+            kwargs (dict): The keyword arguments of the request.
+            cache (PolicyEngineCache): The cache.
+            logger (PolicyEngineLogger): The logger.
+        """
+        self.task = task
+        self.params = params
+        self.endpoint = endpoint
+        self.kwargs = kwargs
+        self.cache = cache
+        self.logger = logger
+
+    def execute(self):
+        """Executes the task."""
+        self.mark_in_progress()
+        # Ensure that if an endpoint modifies the params, it doesn't affect the cache key.
+        params_copy = json.loads(json.dumps(self.params))
+        self.cache.set(
+            params_copy,
+            self.endpoint,
+            {"status": TaskStatus.IN_PROGRESS},
+        )
+        start_time = time()
+        try:
+            result = self.task(params=self.params, **self.kwargs)
+        except Exception as e:
+            self.logger.log(
+                event="task_error",
+                endpoint=self.endpoint,
+                error=str(e),
+                full_trace=traceback.format_exc(),
+            )
+            result = {"status": "error", "error": str(e)}
+        duration = time() - start_time
+        self.logger.log(
+            event="endpoint_thread_completion",
+            endpoint=self.endpoint,
+            time=duration,
+            cache_key=self.cache.get_name(params_copy, self.endpoint),
+        )
+        self.cache.set(
+            params_copy,
+            self.endpoint,
+            {**result, "status": TaskStatus.COMPLETED},
+        )
+        self.mark_completed()
+
+
+def add_params_and_caching(
+    fn: Callable, cache: PolicyEngineCache, logger: PolicyEngineLogger
+) -> Callable:
+    """Adds request parameters to a function call under the variable `params` and caches the result if the caching decorator is present.
+
+    Args:
+        fn (Callable): The endpoint function defining behaviour.
+        cache (PolicyEngineCache): The cache to lookup from and store results to.
+        logger (PolicyEngineLogger): The logger to use to log timings.
+
+    Returns:
+        Callable: The function with the Flask handling added.
+    """
+    should_cache = hasattr(fn, "_cached_endpoint")
+    cache = cache if should_cache else None
+
+    def new_fn(*args, **kwargs):
+        params = {**request.args, **(request.json or {})}
+        if should_cache:
+            cached_result = cache.get(params, fn.__name__)
+        else:
+            return fn(params=params, *args, **kwargs)
+        if cached_result is not None:
+            return cached_result
+        else:
+            result = {"status": TaskStatus.QUEUED}
+            cache.set(params, fn.__name__, result)
+            task = Thread(
+                target=PolicyEngineTask(
+                    fn, params, fn.__name__, kwargs, cache, logger
+                ).execute
+            )
+            task.start()
+            return result
+
+    new_fn.__name__ = fn.__name__
+    return new_fn
diff --git a/policyengine/web_server/cors.py b/policyengine/web_server/cors.py
new file mode 100644
index 00000000..8b282fda
--- /dev/null
+++ b/policyengine/web_server/cors.py
@@ -0,0 +1,28 @@
+from flask import request, make_response
+
+
+def after_request_func(response):
+    """Adds CORS headers to the API response."""
+    origin = request.headers.get("Origin")
+    if request.method == "OPTIONS":
+        response = make_response()
+        response.headers.add("Access-Control-Allow-Credentials", "true")
+        response.headers.add("Access-Control-Allow-Headers", "Content-Type")
+        response.headers.add("Access-Control-Allow-Headers", "x-csrf-token")
+        response.headers.add(
+            "Access-Control-Allow-Methods",
+            "GET, POST, OPTIONS, PUT, PATCH, DELETE",
+        )
+        if origin:
+            response.headers.add("Access-Control-Allow-Origin", origin)
+    else:
+        response.headers.add("Access-Control-Allow-Credentials", "true")
+        if origin:
+            response.headers.add("Access-Control-Allow-Origin", origin)
+        response.headers[
+            "Cache-Control"
+        ] = "no-cache, no-store, must-revalidate, public, max-age=0"
+        response.headers["Pragma"] = "no-cache"
+        response.headers["Expires"] = "0"
+
+    return response
diff --git a/policyengine/web_server/logging.py b/policyengine/web_server/logging.py
new file mode 100644
index 00000000..24181d30
--- /dev/null
+++ b/policyengine/web_server/logging.py
@@ -0,0 +1,96 @@
+from datetime import datetime
+from pathlib import Path
+from time import time
+from typing import Callable
+import yaml
+from policyengine.package import POLICYENGINE_PACKAGE_PATH
+
+# Imports the Cloud Logging client library
+import logging
+import google.cloud.logging
+
+# Instantiates a client
+client = google.cloud.logging.Client()
+
+# Retrieves a Cloud Logging handler based on the environment
+# you're running in and integrates the handler with the
+# Python logging module. By default this captures all logs
+# at INFO level and higher
+client.setup_logging()
+
+
+class PolicyEngineLogger:
+    """Class managing PolicyEngine server logs."""
+
+    local: bool = True
+    """Whether to log to a local YAML file or to Google Cloud.
+    """
+
+    print_to_console: bool = True
+    """Whether to print log messages to the console as well as to a log file.
+    """
+
+    def __init__(self, local: bool = True, print_to_console: bool = True):
+        self.local = local
+        self.print_to_console = print_to_console
+
+    def log(self, *messages, **data: dict):
+        """Log a message to the PolicyEngine server logs.
+
+        Args:
+            data (dict): The data to log.
+        """
+        if len(messages) > 0:
+            data["message"] = "\n".join([str(x) for x in messages])
+        if self.local:
+            with open(Path(__file__).parent / "logs.yaml", "a") as f:
+                yaml.dump(
+                    [
+                        {
+                            "time": datetime.now().isoformat(),
+                            **data,
+                        }
+                    ],
+                    f,
+                )
+        else:
+            if data.get("status") == "error":
+                log = logging.error
+            else:
+                log = logging.info
+            log(
+                data.get("message"),
+                extra={k: v for k, v in data.items() if k != "message"},
+            )
+
+        if self.print_to_console:
+            print(
+                f"[{datetime.now().isoformat()}]"
+                + "".join([f" {k}: {v}" for k, v in data.items()])
+            )
+
+
+def logged_endpoint(fn: Callable, logger: PolicyEngineLogger) -> Callable:
+    """Decorator for logging the start and end of an endpoint.
+
+    Args:
+        fn (Callable): The endpoint function.
+        logger (PolicyEngineLogger): The logger to use.
+
+    Returns:
+        Callable: The decorated function.
+    """
+
+    def new_fn(*args, **kwargs):
+        start_time = time()
+        result = fn(*args, **kwargs, logger=logger)
+        duration = time() - start_time
+        logger.log(
+            event="endpoint_call",
+            endpoint=fn.__name__,
+            time=duration,
+        )
+        return result
+
+    new_fn.__name__ = "timed_" + fn.__name__
+    return new_fn
diff --git a/policyengine/utils/social_card.py b/policyengine/web_server/social_card.py
similarity index 100%
rename from policyengine/utils/social_card.py
rename to policyengine/web_server/social_card.py
diff --git a/policyengine/web_server/static_site.py b/policyengine/web_server/static_site.py
new file mode 100644
index 00000000..7754c752
--- /dev/null
+++ b/policyengine/web_server/static_site.py
@@ -0,0 +1,29 @@
+from flask import Flask, request, send_from_directory
+from .social_card import add_social_card_metadata
+from pathlib import Path
+from policyengine.package import POLICYENGINE_PACKAGE_PATH
+
+
+def add_static_site_handling(app: Flask):
+    """Add a handling route to a Flask app, directing 404 results to index.html, adding country-specific social card metadata.
+
+    Args:
+        app (Flask): A Flask application.
+    """
+
+    def static_site(e):
+        with open(
+            str(POLICYENGINE_PACKAGE_PATH / "static" / "index.html")
+        ) as f:
+            text = f.read()
+            modified = add_social_card_metadata(request.path, text)
+        with open(
+            str(POLICYENGINE_PACKAGE_PATH / "static" / "index_mod.html"), "w"
+        ) as f:
+            f.write(modified)
+        response = send_from_directory(
+            str(POLICYENGINE_PACKAGE_PATH / "static"), "index_mod.html"
+        )
+        return response
+
+    app.errorhandler(404)(static_site)
diff --git a/setup.py b/setup.py
index c89235e1..e580b203 100644
--- a/setup.py
+++ b/setup.py
@@ -1,9 +1,8 @@
 from setuptools import setup, find_packages
-from policyengine import VERSION
 
 setup(
     name="PolicyEngine",
-    version=VERSION,
+    version="1.83.0",
     author="PolicyEngine",
     license="http://www.fsf.org/licensing/licenses/agpl-3.0.html",
     url="https://github.com/policyengine/policyengine",
@@ -12,6 +11,7 @@
         "flask",
         "flask_cors",
         "google-cloud-storage>=1.42.0",
+        "google-cloud-logging",
         "gunicorn",
         "itsdangerous==2.0.1",
         "Jinja2==3.0.3",


Test patch:
diff --git a/policyengine/tests/basic.yaml b/policyengine/tests/basic.yaml
new file mode 100644
index 00000000..6e7f461a
--- /dev/null
+++ b/policyengine/tests/basic.yaml
@@ -0,0 +1,48 @@
+- label: Basic UK calculation endpoint
+  endpoint: /uk/api/calculate
+  input:
+    household:
+      people:
+        adult:
+          age: 
+            "2022": 45
+          is_WA_adult:
+            "2022": null
+  output:
+    people:
+      adult:
+        is_WA_adult:
+          "2022": true
+
+- label: Basic US calculation endpoint
+  endpoint: /us/api/calculate
+  input:
+    household:
+      people:
+        adult:
+          age: 
+            "2022": 45
+          is_adult:
+            "2022": null
+  output:
+    people:
+      adult:
+        is_adult:
+          "2022": true
+
+- label: Basic UK tax reform (basic rate rise)
+  endpoint: /uk/api/population-reform
+  async: true
+  input:
+    basic_rate: 0.22
+  output:
+    budgetary_impact: -10e9 < x < -8e9
+
+- label: Basic US tax reform (CTC abolition)
+  endpoint: /us/api/population-reform
+  async: true
+  time: t < 90
+  input:
+    abolish_refundable_ctc: 1
+  output:
+    budgetary_impact: -30e9 < x < 15e9
diff --git a/policyengine/tests/client/test_API_endpoint.py b/policyengine/tests/client/test_API_endpoint.py
deleted file mode 100644
index 970c68c7..00000000
--- a/policyengine/tests/client/test_API_endpoint.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from pathlib import Path
-
-REPO = Path(__file__).parents[3]
-
-
-def in_text_files(folder: Path, text: str):
-    for child in folder.iterdir():
-        if child.is_dir():
-            if in_text_files(child, text):
-                return True, child
-        else:
-            with open(child, encoding="utf-8") as f:
-                try:
-                    contents = f.read()
-                    if text in contents:
-                        return True, child
-                except:
-                    pass
-
-
-def test_localhost_included():
-    assert not in_text_files(
-        REPO / "policyengine-client" / "src", "useLocalServer = true"
-    )
diff --git a/policyengine/tests/server/initialisation.py b/policyengine/tests/server/initialisation.py
deleted file mode 100644
index 971b4362..00000000
--- a/policyengine/tests/server/initialisation.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from policyengine.countries.uk import UK
-from policyengine.server import app
-import logging
-
-logging.info(f"Initialising PolicyEngine for test runs")
-
-PolicyEngineUK = UK()
-
-logging.info(f"Initialising Flask test client")
-
-test_client = app.test_client()
diff --git a/policyengine/tests/server/populations/test_population_charts.py b/policyengine/tests/server/populations/test_population_charts.py
deleted file mode 100644
index 6cbafed7..00000000
--- a/policyengine/tests/server/populations/test_population_charts.py
+++ /dev/null
@@ -1,48 +0,0 @@
-from policyengine.impact.population.charts import (
-    decile_chart,
-    poverty_chart,
-    population_waterfall_chart,
-)
-from policyengine.countries.uk import UK, UKResultsConfig
-import pytest
-
-PolicyEngineUK = UK()
-
-reform_examples = (
-    {},
-    dict(personal_allowance=0),
-    dict(basic_rate=0.21),
-)
-
-is_deep_examples = (
-    dict(is_deep=False),
-    dict(is_deep=True),
-)
-
-# Test charts for each reform
-
-
-@pytest.mark.parametrize("reform", reform_examples)
-def test_UK_decile_chart(reform):
-    decile_chart(
-        *PolicyEngineUK._get_microsimulations(reform),
-        UKResultsConfig,
-    )
-
-
-@pytest.mark.parametrize("reform", reform_examples)
-@pytest.mark.parametrize("is_deep", reform_examples)
-def test_UK_poverty_chart(reform, is_deep):
-    poverty_chart(
-        *PolicyEngineUK._get_microsimulations(reform),
-        is_deep,
-        UKResultsConfig,
-    )
-
-
-@pytest.mark.parametrize("reform", reform_examples)
-def test_UK_population_waterfall_chart(reform):
-    population_waterfall_chart(
-        *PolicyEngineUK._get_microsimulations(reform),
-        UKResultsConfig,
-    )
diff --git a/policyengine/tests/server/populations/test_population_metrics.py b/policyengine/tests/server/populations/test_population_metrics.py
deleted file mode 100644
index 67a664c9..00000000
--- a/policyengine/tests/server/populations/test_population_metrics.py
+++ /dev/null
@@ -1,39 +0,0 @@
-from policyengine.impact.population.metrics import headline_metrics
-from policyengine.countries.uk import UK, UKResultsConfig
-from policyengine.utils.reforms import parametric, abolish
-import pytest
-
-PolicyEngineUK = UK()
-
-reform_examples = (
-    {},
-    dict(personal_allowance=0),
-    dict(basic_rate=0.21),
-    dict(UC_single_old=1000),
-    dict(CB_eldest=1000),
-)
-
-# Wide ranges - these tests are verifying that the model is being used
-# correctly rather testing than the actual model output
-
-EXPECTED_RESULTS = (
-    dict(net_cost_numeric=(0, 0)),
-    dict(net_cost_numeric=(-115e9, -90e9)),
-    dict(net_cost_numeric=(-6e9, -3e9)),
-    dict(net_cost_numeric=(10e9, 35e9)),
-    dict(loser_share=(0, 1e-3)),
-)
-
-# Test metrics for each reform
-
-
-@pytest.mark.parametrize(
-    "reform_params,expected", zip(reform_examples, EXPECTED_RESULTS)
-)
-def test_headline_metrics(reform_params, expected):
-    results = headline_metrics(
-        *PolicyEngineUK._get_microsimulations(reform_params),
-        UKResultsConfig,
-    )
-    for result in expected:
-        assert expected[result][0] <= results[result] <= expected[result][1]
diff --git a/policyengine/tests/server/situations/test_calculate_endpoint.py b/policyengine/tests/server/situations/test_calculate_endpoint.py
deleted file mode 100644
index 8a64a681..00000000
--- a/policyengine/tests/server/situations/test_calculate_endpoint.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from policyengine.tests.server.initialisation import test_client
-
-
-def test_basic_usage():
-    response = test_client.post(
-        "/us/api/calculate",
-        json={
-            "household": {
-                "people": {"person": {}},
-                "spm_units": {
-                    "spm_unit": {
-                        "members": ["person"],
-                        "snap": {"2022": None},
-                    },
-                },
-            }
-        },
-    )
-
-    snap = response.json["spm_units"]["spm_unit"]["snap"]
-
-    assert not isinstance(snap, list)
-    assert snap["2022"] > 0
diff --git a/policyengine/tests/server/situations/test_situation_charts.py b/policyengine/tests/server/situations/test_situation_charts.py
deleted file mode 100644
index a6424f8c..00000000
--- a/policyengine/tests/server/situations/test_situation_charts.py
+++ /dev/null
@@ -1,80 +0,0 @@
-from openfisca_uk import IndividualSim, reforms
-from policyengine.countries.uk import UKResultsConfig
-from policyengine.impact.household.charts import (
-    household_waterfall_chart,
-    budget_chart,
-    mtr_chart,
-)
-import pytest
-import itertools
-
-
-def single_adult(sim):
-    sim.add_person(age=18, name="p")
-    sim.add_benunit(adults=["p"])
-    sim.add_household(adults=["p"])
-    return sim
-
-
-situation_examples = (single_adult,)
-
-empty_reform = ()
-abolish_personal_allowance = reforms.structural.abolish("personal_allowance")
-raise_basic_rate = reforms.parametric.set_parameter(
-    "tax.income_tax.rates.uk[0].rate", 0.21
-)
-
-reform_examples = (
-    empty_reform,
-    abolish_personal_allowance,
-    raise_basic_rate,
-)
-
-# Test charts for each possible (reform, situation) pair
-
-
-@pytest.mark.parametrize(
-    "situation,reform", itertools.product(situation_examples, reform_examples)
-)
-def test_household_waterfall_chart(situation, reform):
-    baseline = situation(IndividualSim())
-    reformed = situation(IndividualSim(reform))
-    baseline.vary("employment_income")
-    reformed.vary("employment_income")
-    household_waterfall_chart(baseline, reformed, UKResultsConfig)
-
-
-@pytest.mark.parametrize(
-    "situation,reform", itertools.product(situation_examples, reform_examples)
-)
-def test_budget_chart(situation, reform):
-    baseline = situation(IndividualSim())
-    reformed = situation(IndividualSim(reform))
-    baseline.vary("employment_income")
-    reformed.vary("employment_income")
-    budget_chart(
-        baseline,
-        reformed,
-        False,
-        UKResultsConfig,
-        has_reform=True,
-        original_total_income=0,
-    )
-
-
-@pytest.mark.parametrize(
-    "situation,reform", itertools.product(situation_examples, reform_examples)
-)
-def test_mtr_chart(situation, reform):
-    baseline = situation(IndividualSim())
-    reformed = situation(IndividualSim(reform))
-    baseline.vary("employment_income")
-    reformed.vary("employment_income")
-    mtr_chart(
-        baseline,
-        reformed,
-        False,
-        UKResultsConfig,
-        has_reform=True,
-        original_total_income=0,
-    )
diff --git a/policyengine/tests/server/test_cloud_storage.py b/policyengine/tests/server/test_cloud_storage.py
deleted file mode 100644
index 0da0fd39..00000000
--- a/policyengine/tests/server/test_cloud_storage.py
+++ /dev/null
@@ -1,4 +0,0 @@
-def test_storage():
-    from google.cloud import storage
-
-    storage.Client()
diff --git a/policyengine/tests/server/test_server.py b/policyengine/tests/server/test_server.py
deleted file mode 100644
index 13faf673..00000000
--- a/policyengine/tests/server/test_server.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from policyengine.tests.server.initialisation import test_client
-
-
-def test_server_starts():
-    for url in [
-        "/",
-        "/us",
-        "/uk/policy",
-        "/uk/population-impact",
-        "/uk/household",
-        "/us/policy",
-        "/us/household",
-    ]:
-        response = test_client.get(url)
-        assert response.status_code == 200
diff --git a/policyengine/tests/test_api.py b/policyengine/tests/test_api.py
new file mode 100644
index 00000000..3cdb4bc5
--- /dev/null
+++ b/policyengine/tests/test_api.py
@@ -0,0 +1,93 @@
+import pytest
+from pathlib import Path
+import yaml
+from policyengine import PolicyEngine
+from time import sleep, time
+import warnings
+
+warnings.filterwarnings("ignore")
+
+# Initialise the API
+test_client = PolicyEngine(debug=True).app.test_client()
+
+# Each YAML file in this directory has the structure:
+# - endpoint: /uk/policy
+# - input: {}
+# - output: {}
+
+# This function will test each endpoint with the given input and check that the
+# output contains the the data given in the output object. It uses PyTest's parametric
+# testing feature to run each test case.
+
+files = Path(__file__).parent.glob("**/*.yaml")
+tests = []
+for test_file in files:
+    with open(test_file, "r") as f:
+        cases = yaml.safe_load(f)
+        i = 0
+        for case in cases:
+            i += 1
+            tests.append(
+                (
+                    case.get("label", f"{test_file}-{i}"),
+                    case.get("async", False),
+                    case["endpoint"],
+                    case["input"],
+                    case["output"],
+                    case.get("time"),
+                )
+            )
+
+
+def match_value(actual, target):
+    if isinstance(target, str):
+        # Decode Python
+        assert eval(
+            target, dict(x=actual)
+        ), f"{actual} does not match {target}"
+    else:
+        assert actual == target
+
+
+def match_object(actual, target):
+    if isinstance(target, dict):
+        for key in target.keys():
+            if key not in actual:
+                raise ValueError(
+                    f"Key {key} not found in the response: {actual}, but it is in the target: {target}"
+                )
+            match_object(actual[key], target[key])
+    else:
+        match_value(actual, target)
+
+
+@pytest.mark.parametrize(
+    "label,asynchronous,endpoint,input,output,time_limit",
+    tests,
+    ids=[t[0] for t in tests],
+)
+def test_endpoint(label, asynchronous, endpoint, input, output, time_limit):
+    start_time = time()
+    response = test_client.post(endpoint, json=input)
+    if response.status_code != 200:
+        raise ValueError(
+            f"Endpoint {endpoint} failed with status code {response.status_code}. The full result is {response.json}"
+        )
+    if asynchronous:
+        # Keep trying every 5 seconds until we get a response with data.
+        while response.json is not None and response.json.get("status") in (
+            "queued",
+            "in_progress",
+        ):
+            sleep(5)
+            response = test_client.post(endpoint, json=input)
+    match_object(response.json, output)
+    t = time() - start_time
+    if time_limit is not None:
+        if isinstance(time_limit, str):
+            assert eval(
+                time_limit,
+                dict(t=t, s=1, m=60, h=60 * 60, ms=1 / 1000),
+            )
+        else:
+            assert t < time_limit