Examples

Short, self-contained recipes. Each one runs against a local EUROMOD model and its bundled training data; none of them needs a simulation.

import os

import pandas as pd

# Point this at your own EUROMOD model folder, or set EUROMOD_MODEL_PATH.
MODEL_PATH = os.environ.get("EUROMOD_MODEL_PATH", r"C:\EUROMOD_RELEASES")
DATA_DIR = os.path.join(MODEL_PATH, "Input")

# Keep DataFrame previews compact in the rendered docs.
pd.set_option("display.max_columns", 10)
pd.set_option("display.width", 110)
from euromod import Model

model = Model(MODEL_PATH)
system = model["BE"]["BE_2025"]
data = pd.read_csv(os.path.join(DATA_DIR, "BE_training_data.txt"), sep="\t")
len(data)
7482

Normalising shocks, and the id that follows

normalize_shocks validates raw records and canonicalises them — group keys sorted, rows ordered by (channel, metric, group, period). The canonical form hashes to a content id, so the same economic scenario always has the same id no matter which file or code path produced it.

from euromod_linking import normalize_shocks
from euromod_linking.shock_table import content_id

records = [
    {"channel": "align", "metric": "employment", "group": "dgn=1;deh=3-4",
     "period": "1", "op": "grow", "value": 0.0655},
    {"channel": "align", "metric": "unemployment", "group": "deh=3-4;dgn=1",
     "period": "1", "op": "grow", "value": -0.02},
]
table = normalize_shocks(records)
print("id:", content_id(table))
table
id: shk_a9766c732912
channel metric group period op value unit source
0 align employment deh=3-4;dgn=1 1 grow 0.0655
1 align unemployment deh=3-4;dgn=1 1 grow -0.0200
# Same shocks, written in a different order with the group keys swapped.
shuffled = normalize_shocks(list(reversed([
    {**records[0], "group": "deh=3-4;dgn=1"},
    {**records[1], "group": "dgn=1;deh=3-4"},
])))
print("id:", content_id(shuffled), "- identical:", content_id(shuffled) == content_id(table))
id: shk_a9766c732912 - identical: True

Ingesting an external model’s output file

External model output is read through a declarative mapping spec rather than bespoke parsing code. Here is a workbook in a typical layout: one sheet per quantity, two unnamed leading columns holding a region code and an education class, and numbered columns holding a growth rate per projection period.

workbook = os.path.join(os.getcwd(), "projections.xlsx")


def sheet(status, base):
    rows = []
    for i, region in enumerate(["BE10", "BE21", "BE32", "AT12"]):
        for j, education in enumerate(["low", "medium", "high"]):
            row = {"c0": status, "c1": region, "c2": education}
            for p in range(1, 4):
                row[p] = round(base + 0.01 * p + 0.001 * (i + j), 4)
            rows.append(row)
    return pd.DataFrame(rows)


with pd.ExcelWriter(workbook, engine="openpyxl") as xl:
    sheet("employment", 0.02).to_excel(xl, sheet_name="employment", index=False)
    sheet("unemployment", -0.01).to_excel(xl, sheet_name="unemployment", index=False)

pd.read_excel(workbook, sheet_name="employment").head()
c0 c1 c2 1 2 3
0 employment BE10 low 0.030 0.040 0.050
1 employment BE10 medium 0.031 0.041 0.051
2 employment BE10 high 0.032 0.042 0.052
3 employment BE21 low 0.031 0.041 0.051
4 employment BE21 medium 0.032 0.042 0.052

A spec describes that layout. It can be a YAML file shipped in the package or, as here, a plain dict — which is the quickest way to work while a layout is still moving.

spec = {
    "mapping_version": 1,
    "name": "regional_projections",
    "description": "Regional employment and unemployment projections by education class.",
    "reader": {"format": "excel", "sheets": [
        {"sheet": "employment", "channel": "align", "metric": "employment"},
        {"sheet": "unemployment", "channel": "align", "metric": "unemployment"},
    ]},
    "columns": {"region_code": {"position": 1}, "education": {"position": 2}},
    "periods": {"mode": "numbered_columns", "range": [1, 3]},
    "value_semantics": {"op": "grow", "unit": "rate"},
    "group": {
        "region": {"from": "region_code", "transform": "nuts_code"},
        "deh": {"from": "education",
                "allowed": ["low", "medium", "high"],
                "values": {"low": "0-2", "medium": "3-4", "high": "5-99"}},
    },
    "filters": [{"column": "region_code", "not_null": True}],
}

Ingesting translates the vocabulary at the model boundary: medium becomes deh=3-4, BE21 becomes region=21, and rows for other countries are filtered out. Everything downstream speaks plain EUROMOD variables.

from euromod_linking.ingest import ingest

ingested, warnings = ingest(workbook, spec, country="BE")
print(f"{len(ingested)} records")
for w in warnings:
    print("warning:", w)
normalize_shocks(ingested).head(8)
54 records
warning: Sheet 'employment': 3 rows for other countries skipped (kept BE)
warning: Sheet 'unemployment': 3 rows for other countries skipped (kept BE)
channel metric group period op value unit source
0 align employment deh=0-2;region=10 1 grow 0.030 rate projections.xlsx#employment
1 align employment deh=0-2;region=10 2 grow 0.040 rate projections.xlsx#employment
2 align employment deh=0-2;region=10 3 grow 0.050 rate projections.xlsx#employment
3 align employment deh=0-2;region=21 1 grow 0.031 rate projections.xlsx#employment
4 align employment deh=0-2;region=21 2 grow 0.041 rate projections.xlsx#employment
5 align employment deh=0-2;region=21 3 grow 0.051 rate projections.xlsx#employment
6 align employment deh=0-2;region=32 1 grow 0.032 rate projections.xlsx#employment
7 align employment deh=0-2;region=32 2 grow 0.042 rate projections.xlsx#employment

Scaling an income list

A scale shock can name an EUROMOD income list instead of a single variable. The list is expanded extension-aware by walking the model’s own DefIl definitions, so it stays correct when extensions change what the list contains.

from euromod_linking import apply_scenario

il_scenario = {
    "country_code": "BE",
    "system_name": "BE_2025",
    "shocks": {"inline": [
        {"channel": "scale", "metric": "ils_udb_yem", "group": "",
         "period": "1", "op": "grow", "value": 0.05},
    ]},
    "params": {"period": "1"},
}

plan = apply_scenario(system, data, il_scenario, validate_only=True)
plan["diagnostics"]["income_list_expansions"]
{'ils_udb_yem': {'scaled': ['yem'], 'skipped_not_in_input': []}}

Aligning the population to employment targets

lma_labour_alignment moves people between labour-market states until each cell matches an external target. Ask it what it would do before paying for the alignment itself:

target_scenario = {
    "country_code": "BE",
    "system_name": "BE_2025",
    "shocks": {"inline": [
        {"channel": "align", "metric": "employment", "group": "deh=3-4",
         "period": "1", "op": "grow", "value": 0.0655},
    ]},
    "params": {"period": "1"},
}

plan = apply_scenario(system, data, target_scenario, validate_only=True)
print("methodology:", plan["methodology"])
print("add-ons    :", plan["addons"])
print("extensions :", plan["extensions"])
plan["diagnostics"]["cell_population"]
methodology: lma_labour_alignment
add-ons    : [['LMA', 'LMA_BE']]
extensions : [['LMA_trans', True]]
{'deh=3-4': {'n_rows': 5460, 'n_weighted_all_ages': 8813907.8}}

When a model cannot run a methodology

The same scenario against an older system is rejected during validation — before either simulation runs, and before the alignment is computed. The LMA add-on does not cover every system in a country.

from euromod_linking import ScenarioError

old_system = model["BE"]["BE_2001"]
old_scenario = dict(target_scenario, system_name="BE_2001")

try:
    apply_scenario(old_system, data, old_scenario, validate_only=True)
except ScenarioError as e:
    for problem in e.problems:
        print("-", problem)
- lma_labour_alignment needs add-on system LMA_BE, but no system of the LMA add-on applies to BE_2001.

The report behind that message says which requirement was met and which was not:

from euromod_linking import check_compatibility

report = check_compatibility(old_system, "lma_labour_alignment")
for r in report.requirements:
    print(f"{r.kind:10} {r.name:12} satisfied={r.satisfied}  ({r.detail})")
print()
for note in report.notes:
    print("note:", note)
addon      LMA          satisfied=False  (no add-on system LMA_BE applies to BE_2001)
extension  LMA_trans    satisfied=True  (accepted by this system)

note: This model looks like J2.54 (from folder-name), at or above the J2.54 floor for lma_labour_alignment — so the release is not what is missing.

Note what the report separates. The LMA_trans extension is accepted — this model is new enough — but no LMA add-on system applies to BE_2001. Reading those two lines tells you the fix is a different system, not a different model.

Checking a whole model at once

compatibility_matrix answers “what can I run here” without writing a scenario. Pass a country code unless you mean it — walking every country of a full model means loading every country.

from euromod_linking import compatibility_matrix

matrix = compatibility_matrix(model, "BE")
matrix[["system", "methodology", "ok", "model_release", "min_model_release"]].head(6)
system methodology ok model_release min_model_release
0 BE_2001 lma_labour_alignment False J2.54 J2.54
1 BE_2001 scale_variables True J2.54
2 BE_2005 lma_labour_alignment False J2.54 J2.54
3 BE_2005 scale_variables True J2.54
4 BE_2006 lma_labour_alignment False J2.54 J2.54
5 BE_2006 scale_variables True J2.54
os.remove(workbook)