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)