Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"permissions": {
"allow": [
"Bash(xargs -I {} wc -l {})",
"Bash(mvn clean *)",
"Bash(python -c \"import docx; print\\('python-docx available'\\)\")",
"Bash(python3 -c \"import docx; print\\('ok'\\)\")",
"Bash(where py *)",
"Bash(py --version)",
"Bash(py -c \"import docx; print\\('ok'\\)\")",
"Bash(py -m pip install python-docx -q)",
"Bash(py generate_review.py)",
"Bash(pip show *)",
"PowerShell(Get-Command *)"
]
}
}
121 changes: 121 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Important: Directory Path Contains Spaces

The project root is `C:\MyFiles\99 DEV ENV\JAS-MINE\SimPaths`. The path contains spaces, so always wrap paths in double quotes in shell/Bash commands, e.g.:

```bash
cd "C:\MyFiles\99 DEV ENV\JAS-MINE\SimPaths"
mvn -f "C:\MyFiles\99 DEV ENV\JAS-MINE\SimPaths\pom.xml" compile
```

## Build & Run Commands

```bash
# Compile only
mvn compile

# Run unit tests (excludes integration tests)
mvn test

# Run a single test class
mvn test -Dtest=MahalanobisDistanceTest

# Run a single test method
mvn test -Dtest=PersonTest#methodName

# Run integration tests (requires pre-built JARs)
mvn failsafe:integration-test

# Build shaded JARs (produces singlerun.jar and multirun.jar in project root)
mvn package

# Run simulation headless via multirun.jar (uses config/default.yml)
java -jar multirun.jar -config config/default.yml

# Run database setup only
java -jar multirun.jar -DBSetup -config config/default.yml

# Run with GUI (single run)
java -jar singlerun.jar
```

Java 19 is required (configured in pom.xml). Dependencies are pulled from Maven Central and JitPack (for JAS-mine).

## Architecture Overview

SimPaths is a **dynamic microsimulation model** built on the [JAS-mine](https://github.com/jasmineRepo) simulation framework. It projects individual and household life histories year-by-year across domains: labour market, health, demographics, social care, and taxes/benefits.

### Simulation entity hierarchy

```
Household — groups BenefitUnits sharing a dwelling (parent–adult-child links)
└─ BenefitUnit — the tax/benefit assessment unit (couple or single + dependent children)
└─ Person — the individual agent (all life-course state is stored here)
```

All three are JPA `@Entity` classes persisted via Hibernate/H2. Each carries a `PanelEntityKey` (id + simulation_time + simulation_run).

### Simulation lifecycle

`SimPathsModel` (extends `AbstractSimulationManager`) owns the main simulation loop. Each year it fires ordered events that update entities via `EventListener.onEvent()`. The sequence covers: mortality → union formation/dissolution → fertility → education → labour market → taxes/benefits → health → social care → statistics collection.

### Key classes to know

| Class | Role |
|---|---|
| `SimPathsModel` | Orchestrates yearly event schedule; holds all population lists |
| `SimPathsStart` | Entry point for single runs (GUI or headless) |
| `SimPathsMultiRun` | Entry point for multi-run / sensitivity analysis |
| `SimPathsCollector` | Collects and exports output statistics each year |
| `Parameters` | Static store for all model parameters and regression coefficients loaded from Excel |
| `ManagerRegressions` | Routes regression evaluation calls to the correct JAS-mine regression objects |
| `Person` / `BenefitUnit` | Core simulation agents — most behavioural logic lives here |

### Tax-benefit imputation (`model/taxes/`)

Tax and benefit outcomes are not calculated analytically. Instead, the model uses **statistical matching**: simulated households are matched to a pre-built donor database derived from EUROMOD/UKMOD output. `TaxDonorDataParser` builds this database; `DonorTaxImputation` performs the matching using a nearest-neighbour key function (`KeyFunction1`–`KeyFunction4`).

### Intertemporal optimisation (`model/decisions/`)

An optional backward-induction module solves for optimal lifetime consumption and labour supply. `ManagerSolveGrids` runs the solution over a multi-dimensional state-space grid (age, health, education, pension, region, etc.). `ManagerPopulateGrids` sets up the grid geometry. This is computationally expensive and disabled by default (`enableIntertemporalOptimisations: false`).

### Regression system

All behavioural equations are estimated externally and loaded at startup from Excel files in `input/`. `Parameters` loads them via JAS-mine's `ExcelAssistant`/`MultiKeyCoefficientMap`. `RegressionName` (enum) names every equation; `ManagerRegressions` dispatches calls by type (linear, probit, ordered probit, multinomial logit, etc.).

### Alignment

Several demographic processes are aligned to external targets (ONS projections, LFS shares) via `ActivityAlignmentV2`, `FertilityAlignment`, `PartnershipAlignment`, `InSchoolAlignment`, `SocialCareAlignment`. Alignment factors are exported to `AlignmentAdjustmentFactors1.csv` each run.

### Configuration

Runs are configured via YAML files in `config/`. `default.yml` documents all available keys with their defaults. CLI flags override YAML values. `SimPathsMultiRun` reads the YAML and reflectively sets fields on `SimPathsModel`, `SimPathsCollector`, and `Parameters`.

### Output

Output is written to `output/<run-timestamp>/csv/` by `SimPathsCollector`. Key files:
- `Statistics1.csv` — income distribution (Gini, percentiles, S-Index)
- `Statistics2.csv` — demographic validation (partnership, employment, health by age/gender)
- `EmploymentStatistics.csv`, `HealthStatistics.csv` — domain-specific time series
- `AlignmentAdjustmentFactors1.csv` — alignment diagnostics

### Integration tests

`RunSimPathsIntegrationTest` runs the full simulation end-to-end using the built JARs and compares CSV output against reference files in `src/test/java/simpaths/integrationtest/expected/`. If a substantive change shifts the output, update the expected files and commit them.

## Domain Knowledge

This section records design principles and non-obvious rationale accumulated through development. Update it as new insights emerge.

### Price levels: SimPaths vs. the tax donor database

All SimPaths financial variables are stored in **real 2015 prices** (`BASE_PRICE_YEAR`). The tax donor database, however, is denominated in **nominal prices of the respective policy year**.

Tax donor matching therefore requires a two-step price bridge:
1. Inflate SimPaths income to policy-year prices before matching.
2. Deflate the imputed financial values from the matched donor record back to 2015 prices.

The default uprating series is `TimeSeriesVariable.Inflation`, sourced from the `UK_inflation` worksheet in `input/time_series_factor.xlsx`. An alternative option (added 2026-04) allows wage growth to be used instead of price growth for the initial matching step, controlled via a config flag.
5 changes: 5 additions & 0 deletions config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ model_args:
# --- Tax-benefit imputation ---
# donorPoolAveraging: true # if true, average disposable income over k nearest-neighbour donors
# rather than using the single closest donor; reduces imputation volatility
# taxDonorUpratingByWage: false # if true, scale simulated income by nominal wage growth (compound of
# WageGrowth and Inflation indices). before matching to the tax donor
# database, instead of price growth only. Imputed financial flows are
# always deflated back to BASE_PRICE_YEAR (2015) using Inflation only,
# regardless of this setting.

# --- Regression stochasticity ---
# addRegressionStochasticComponent: true # include the residual draw in regression predictions
Expand Down
Binary file modified input/EUROMODoutput/training/DatabaseCountryYear.xlsx
Binary file not shown.
Binary file modified input/EUROMODpolicySchedule.xlsx
Binary file not shown.
33 changes: 25 additions & 8 deletions src/main/java/simpaths/data/Parameters.java
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,7 @@ else if(numberOfChildren <= 5) {
public static boolean flagSuppressChildcareCosts;
public static boolean flagSuppressSocialCareCosts;
public static boolean donorPoolAveraging;
public static boolean taxDonorUpratingByWage;
public static boolean lifetimeIncomeImpute;

public static double realInterestRateInnov;
Expand Down Expand Up @@ -908,10 +909,11 @@ private static void addFixedCostRegressors(MultiKeyCoefficientMap map, List<Stri
*/
public static void loadParameters(Country country, int maxAgeModel, boolean enableIntertemporalOptimisations,
boolean projectFormalChildcare, boolean projectSocialCare, boolean donorPoolAveraging1,
boolean fixTimeTrend, boolean defaultToTimeSeriesAverages, boolean taxDBMatches,
Integer timeTrendStops, int startYearModel, int endYearModel, double interestRateInnov1,
double disposableIncomeFromLabourInnov1, boolean flagSuppressChildcareCosts1,
boolean flagSuppressSocialCareCosts1, boolean lifetimeIncomeImpute1) {
boolean taxDonorUpratingByWage1, boolean fixTimeTrend, boolean defaultToTimeSeriesAverages,
boolean taxDBMatches, Integer timeTrendStops, int startYearModel, int endYearModel,
double interestRateInnov1, double disposableIncomeFromLabourInnov1,
boolean flagSuppressChildcareCosts1, boolean flagSuppressSocialCareCosts1,
boolean lifetimeIncomeImpute1) {

// display a dialog box to let the user know what is happening
System.out.println("Loading model parameters");
Expand Down Expand Up @@ -942,6 +944,7 @@ public static void loadParameters(Country country, int maxAgeModel, boolean enab
flagSuppressChildcareCosts = flagSuppressChildcareCosts1;
flagSuppressSocialCareCosts = flagSuppressSocialCareCosts1;
donorPoolAveraging = donorPoolAveraging1;
taxDonorUpratingByWage = taxDonorUpratingByWage1;
realInterestRateInnov = interestRateInnov1;
disposableIncomeFromLabourInnov = disposableIncomeFromLabourInnov1;
lifetimeIncomeImpute = lifetimeIncomeImpute1;
Expand Down Expand Up @@ -3057,11 +3060,25 @@ public static MahalanobisDistance getMdDualIncomeChildcare() {
public static double normaliseWeeklyIncome(int priceYear, double weeklyFinancial) {
return normaliseMonthlyIncome(priceYear, weeklyFinancial * WEEKS_PER_MONTH);
}
public static double normaliseMonthlyIncome(int priceYear, double monthlyFinancial) {
public static double normaliseWeeklyIncome(int currentPriceYear, int targetWagesYear, double weeklyFinancial) {
return normaliseMonthlyIncome(currentPriceYear, targetWagesYear, weeklyFinancial * WEEKS_PER_MONTH);
}
public static double normaliseMonthlyIncome(int currentPriceYear, double monthlyFinancial) {
return normaliseMonthlyIncome(currentPriceYear, BASE_PRICE_YEAR, currentPriceYear, currentPriceYear, monthlyFinancial);
}
public static double normaliseMonthlyIncome(int currentPriceYear, int targetWagesYear, double monthlyFinancial) {
return normaliseMonthlyIncome(currentPriceYear, BASE_PRICE_YEAR, currentPriceYear, targetWagesYear, monthlyFinancial);
}
public static double normaliseWeeklyIncome(int currentPriceYear, int targetPriceYear, int currentWagesYear, int targetWagesYear, double weeklyFinancial) {
return normaliseMonthlyIncome(currentPriceYear, targetPriceYear, currentWagesYear, targetWagesYear, weeklyFinancial * WEEKS_PER_MONTH);
}
public static double normaliseMonthlyIncome(int currentPriceYear, int targetPriceYear, int currentWagesYear, int targetWagesYear, double monthlyFinancial) {
double infAdj = 1.0;
if (priceYear != BASE_PRICE_YEAR)
infAdj = getTimeSeriesValue(BASE_PRICE_YEAR, TimeSeriesVariable.Inflation) / getTimeSeriesValue(priceYear, TimeSeriesVariable.Inflation);
return Parameters.asinh(monthlyFinancial * infAdj);
if (currentPriceYear != targetPriceYear)
infAdj = getTimeSeriesValue(targetPriceYear, TimeSeriesVariable.Inflation) / getTimeSeriesValue(currentPriceYear, TimeSeriesVariable.Inflation);
if (currentWagesYear != targetWagesYear)
infAdj *= getTimeSeriesValue(targetWagesYear, TimeSeriesVariable.WageGrowth) / getTimeSeriesValue(currentWagesYear, TimeSeriesVariable.WageGrowth);
return asinh(monthlyFinancial * infAdj);
}
public static void setTrainingFlag(boolean flag) {
trainingFlag = flag;
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/simpaths/model/Person.java
Original file line number Diff line number Diff line change
Expand Up @@ -1466,8 +1466,9 @@ protected void evaluateSocialCareReceipt() {
double recCareInnov = statInnovations.getDoubleDraw(7);
double probNeedCare = Parameters.getRegNeedCareS2a().getProbability(this, Person.DoublesVariables.class);
if (recCareInnov < probNeedCare) {
// need care
careNeedFlag = Indicator.True;
} else {
careNeedFlag = Indicator.False;
}

double probRecCare = Parameters.getRegReceiveCareS2b().getProbability(this, Person.DoublesVariables.class);
Expand Down
13 changes: 10 additions & 3 deletions src/main/java/simpaths/model/SimPathsModel.java
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,9 @@ public void setFirstRun(boolean firstRun) {
@GUIparameter(description = "Average over donor pool when imputing transfer payments")
public boolean donorPoolAveraging = true;

@GUIparameter(description = "Scale simulated income by wage growth when imputing taxes and benefits")
public boolean taxDonorUpratingByWage = false;

private int ordering = Parameters.MODEL_ORDERING; //Used in Scheduling of model events. Schedule model events at the same time as the collector and observer events, but a lower order, so will be fired before the collector and observer have updated.

private Set<Person> persons;
Expand Down Expand Up @@ -380,9 +383,9 @@ public void buildObjects() {

// load model parameters
Parameters.loadParameters(country, maxAge, enableIntertemporalOptimisations, projectFormalChildcare,
projectSocialCare, donorPoolAveraging, fixTimeTrend, flagDefaultToTimeSeriesAverages, saveImperfectTaxDBMatches,
timeTrendStopsIn, startYear, endYear, interestRateInnov, disposableIncomeFromLabourInnov, flagSuppressChildcareCosts,
flagSuppressSocialCareCosts, lifetimeIncomeImpute);
projectSocialCare, donorPoolAveraging, taxDonorUpratingByWage, fixTimeTrend, flagDefaultToTimeSeriesAverages,
saveImperfectTaxDBMatches, timeTrendStopsIn, startYear, endYear, interestRateInnov,
disposableIncomeFromLabourInnov, flagSuppressChildcareCosts, flagSuppressSocialCareCosts, lifetimeIncomeImpute);
if (lifetimeIncomeGenerate) {
ManagerProjectLifetimeIncomes.run(log, lifetimeIncomeStartBirthYear,
lifetimeIncomeEndBirthYear, lifetimeIncomeEndAge, lifetimeIncomeCohortSize, lifetimeIncomeWriteToCSV,
Expand Down Expand Up @@ -722,6 +725,8 @@ private void saveRunParameters() {
pw.println(line);
line = "donorPoolAveraging: " + donorPoolAveraging;
pw.println(line);
line = "taxDonorUpratingByWage: " + taxDonorUpratingByWage;
pw.println(line);
line = "initialisePotentialEarningsFromDatabase: " + initialisePotentialEarningsFromDatabase;
pw.println(line);
line = "useWeights: " + useWeights;
Expand Down Expand Up @@ -2928,6 +2933,8 @@ public void setCountry(Country country) {
public void setProjectFormalChildcare(boolean projectFormalChildcare) { this.projectFormalChildcare = projectFormalChildcare; }
public boolean getDonorPoolAveraging() { return donorPoolAveraging; }
public void setDonorPoolAveraging(boolean val) { donorPoolAveraging = val; }
public boolean getTaxDonorUpratingByWage() { return taxDonorUpratingByWage; }
public void setTaxDonorUpratingByWage(boolean val) { taxDonorUpratingByWage = val; }

public Integer getPopSize() {
return popSize;
Expand Down
Loading
Loading