dsgelab 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dsgelab/__init__.py ADDED
@@ -0,0 +1,129 @@
1
+ """dsgelab -- a general toolkit for solving DSGE models.
2
+
3
+ A model is declared once, as a list of state names, a list of jump names, a list
4
+ of shock names and one function returning the equilibrium residuals. Everything
5
+ else -- steady state, first-order solution, impulse responses, moments,
6
+ second-order risk corrections, nonlinear perfect-foresight transitions,
7
+ calibration on targets, optimal policy and welfare -- is computed from that
8
+ single declaration, so the stochastic block and the deterministic transition
9
+ cannot drift apart into two models that merely look alike.
10
+
11
+ Quick start::
12
+
13
+ import dsgelab as dsg
14
+ from dsgelab.examples import rbc
15
+
16
+ model, params, guess = rbc.build()
17
+ sol = dsg.solve(model, params, guess=guess)
18
+ print(sol.summary())
19
+ irf = sol.irf("eps_a", size=0.01, periods=40)
20
+
21
+ The canonical form behind it is Klein's::
22
+
23
+ A E_t x_{t+1} = B x_t + C eps_{t+1}, u_t = F k_t, k_{t+1} = P k_t + Q eps_{t+1}
24
+
25
+ with the Blanchard-Kahn condition enforced rather than reported.
26
+ """
27
+
28
+ from typing import Any
29
+
30
+ from .calibration import CalibrationResult, calibrate, target_jacobian
31
+ from .diagnostics import (blanchard_kahn_report, check_identity, check_slack,
32
+ compare_irfs, diagnose, steady_state_invariance)
33
+ from .bgp import BalancedGrowth, balanced_growth
34
+ from .extended_path import ExtendedPath, extended_path, quadrature_nodes
35
+ from .foresight import News, TransitionPath, perfect_foresight, perfect_foresight_with_expectation_errors
36
+ from .klein import BlanchardKahnError, KleinSolution, klein
37
+ from .linearize import (Coordinates, LinearSystem, first_order_system, second_derivatives,
38
+ third_derivatives)
39
+ from .model import Model, VarView, get_param, set_params
40
+ from .dynare import DynareImportError, ModFile, from_mod, read_expectation_errors_csv
41
+ from .occbin import OccbinError, OccbinModel, OccbinResult, occbin_simulate
42
+ from .optimal import (DiscretionError, DiscretionSolution, OSRError, OSRResult, PrivateBlock,
43
+ RamseyError, discretionary_policy, osr, ramsey_model, ramsey_multipliers,
44
+ ramsey_steady_state)
45
+ from .welfare import Welfare, WelfareError, consumption_equivalent, welfare, welfare_model
46
+ from .secondorder import SecondOrderSolution, solve_second_order
47
+ from .thirdorder import ThirdOrderSolution, solve_third_order
48
+ from .estimate import EstimationResult, estimate, log_likelihood, log_posterior
49
+ from .estimation import (EstimationProblem, MCMCResult, ModeResult, SMCResult,
50
+ effective_sample_size, maximum_likelihood, metropolis_hastings,
51
+ model_probabilities, posterior_forecast, posterior_mode,
52
+ prior_predictive, rhat, smc)
53
+ from .kalman import (FilterResult, Forecast, SmootherResult, StateSpace, forecast,
54
+ kalman_filter, kalman_smoother, realtime_shock_decomposition,
55
+ shock_decomposition, state_space)
56
+ from .priors import Prior, prior
57
+ from .projection import Projection, solve_projection
58
+ from .solution import FirstOrderSolution, shock_covariance
59
+ from .solve import solve
60
+ from .steady import SteadyState, SteadyStateError, analytic_steady_state, steady_state
61
+
62
+ __version__ = "0.5.0"
63
+
64
+ __all__ = [
65
+ "Model", "VarView", "get_param", "set_params",
66
+ "steady_state", "SteadyState", "analytic_steady_state", "SteadyStateError",
67
+ "solve", "FirstOrderSolution", "shock_covariance",
68
+ "klein", "KleinSolution", "BlanchardKahnError",
69
+ "first_order_system", "second_derivatives", "LinearSystem", "Coordinates",
70
+ "solve_second_order", "SecondOrderSolution",
71
+ "solve_third_order", "ThirdOrderSolution", "third_derivatives",
72
+ "estimate", "EstimationResult", "log_likelihood", "log_posterior",
73
+ "EstimationProblem", "posterior_mode", "maximum_likelihood", "metropolis_hastings", "smc",
74
+ "prior_predictive", "model_probabilities", "posterior_forecast", "rhat",
75
+ "effective_sample_size", "ModeResult", "MCMCResult", "SMCResult",
76
+ "state_space", "StateSpace", "kalman_filter", "kalman_smoother", "FilterResult",
77
+ "SmootherResult", "shock_decomposition", "realtime_shock_decomposition", "forecast",
78
+ "Forecast", "prior", "Prior",
79
+ "solve_projection", "Projection",
80
+ "perfect_foresight", "TransitionPath", "perfect_foresight_with_expectation_errors", "News",
81
+ "calibrate", "CalibrationResult", "target_jacobian",
82
+ "check_identity", "check_slack", "blanchard_kahn_report", "compare_irfs",
83
+ "steady_state_invariance", "diagnose",
84
+ "ramsey_model", "ramsey_steady_state", "ramsey_multipliers", "RamseyError",
85
+ "discretionary_policy", "DiscretionSolution", "DiscretionError", "PrivateBlock",
86
+ "osr", "OSRResult", "OSRError",
87
+ "welfare", "welfare_model", "Welfare", "WelfareError", "consumption_equivalent",
88
+ "from_mod", "ModFile", "DynareImportError", "read_expectation_errors_csv",
89
+ "OccbinModel", "OccbinResult", "OccbinError", "occbin_simulate",
90
+ "extended_path", "ExtendedPath", "quadrature_nodes", "balanced_growth", "BalancedGrowth",
91
+ "__version__",
92
+ "from_equations", "optimality_conditions", "assemble", "to_dynare", "to_macromodelling",
93
+ ]
94
+
95
+
96
+ def from_equations(*args: Any, **kwargs: Any) -> Model:
97
+ """Declare a model as text equations. Requires sympy; see :mod:`dsgelab.symbolic`."""
98
+ from .symbolic import from_equations as _fe
99
+ return _fe(*args, **kwargs)
100
+
101
+
102
+ def optimality_conditions(*args: Any, **kwargs: Any) -> dict:
103
+ """Derive first-order conditions from a stated optimisation problem.
104
+
105
+ Requires sympy; see :mod:`dsgelab.blocks`.
106
+ """
107
+ from .blocks import optimality_conditions as _oc
108
+ return _oc(*args, **kwargs)
109
+
110
+
111
+ def assemble(*args: Any, **kwargs: Any) -> Model:
112
+ """Join several optimisation problems and closure equations into one Model.
113
+
114
+ Requires sympy; see :func:`dsgelab.blocks.assemble`.
115
+ """
116
+ from .blocks import assemble as _as
117
+ return _as(*args, **kwargs)
118
+
119
+
120
+ def to_dynare(*args: Any, **kwargs: Any) -> str:
121
+ """Write a symbolically-declared model as a Dynare ``.mod``. See :mod:`dsgelab.export`."""
122
+ from .export import to_dynare as _td
123
+ return _td(*args, **kwargs)
124
+
125
+
126
+ def to_macromodelling(*args: Any, **kwargs: Any) -> str:
127
+ """Write a symbolically-declared model for MacroModelling.jl. See :mod:`dsgelab.export`."""
128
+ from .export import to_macromodelling as _tm
129
+ return _tm(*args, **kwargs)
dsgelab/bgp.py ADDED
@@ -0,0 +1,141 @@
1
+ """The balanced growth path of a model written in levels.
2
+
3
+ Dynare's ``bgp.write``: every variable is assumed to grow at a constant factor,
4
+ ``x_t = X g^t``, and the model must hold at two consecutive dates,
5
+
6
+ f(X g, X, 0) = 0, f(X g^2, X g, 0) = 0,
7
+
8
+ ``2n`` equations in the ``n`` levels ``X`` and the ``n`` growth factors ``g``. The
9
+ growth factors are identified; the level of a growing variable is not -- only its
10
+ ratio to the variables that share its trend is -- so the system is solved by
11
+ least squares (Levenberg--Marquardt) from a start, as Dynare's tests do, and the
12
+ answer reports which variables grow.
13
+
14
+ For a ``from_mod`` model the variables are Dynare's, with Dynare's dating: the
15
+ level of ``v`` is the one of the period whose equations are evaluated first, and an
16
+ auxiliary ``LAG1_v`` grows with ``v``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+ from typing import Any, Mapping
23
+
24
+ import numpy as np
25
+ from numpy.typing import ArrayLike
26
+ from scipy.optimize import least_squares
27
+
28
+ from .model import Model, Params
29
+
30
+ __all__ = ["BalancedGrowth", "balanced_growth"]
31
+
32
+
33
+ @dataclass
34
+ class BalancedGrowth:
35
+ """Levels and growth factors along a balanced growth path."""
36
+
37
+ levels: dict
38
+ growth: dict
39
+ residual: float
40
+ model: Model = field(repr=False)
41
+ success: bool = True
42
+ degenerate: bool = False
43
+
44
+ def ratio(self, a: str, b: str) -> float:
45
+ """``a/b`` along the path; meaningful when both share a trend."""
46
+ if abs(self.growth[a] - self.growth[b]) > 1e-8 * max(1.0, abs(self.growth[b])):
47
+ raise ValueError(f"{a} and {b} grow at different factors: their ratio is not constant")
48
+ return self.levels[a] / self.levels[b]
49
+
50
+ @property
51
+ def growing(self) -> list[str]:
52
+ return [v for v, g in self.growth.items() if abs(g - 1) > 1e-8]
53
+
54
+
55
+ def bgp_residual(model: Model, params: Params, X: ArrayLike, G: ArrayLike,
56
+ ctx: Any = None) -> np.ndarray:
57
+ """``[f(X g, X, 0); f(X g^2, X g, 0)]``: the two dates of a balanced growth path."""
58
+ X, G = np.asarray(X, dtype=float), np.asarray(G, dtype=float)
59
+ e = np.zeros(model.n_shocks)
60
+ return np.concatenate([model.residuals(X * G, X, e, params, ctx),
61
+ model.residuals(X * G * G, X * G, e, params, ctx)])
62
+
63
+
64
+ def balanced_growth(model: Model, params: Params, *,
65
+ guess: dict[str, float] | ArrayLike | None = None,
66
+ growth: dict[str, float] | ArrayLike | None = None,
67
+ fixed: Mapping[str, float] | None = None, ctx: Any = None,
68
+ tol: float = 1e-12, seed: int | None = 0, max_nfev: int = 100000,
69
+ tries: int = 20) -> BalancedGrowth:
70
+ """Solve for the balanced growth path.
71
+
72
+ ``guess`` and ``growth`` are starting levels and growth factors, as mappings or
73
+ vectors in the model's order (by default, levels drawn around 1 with ``seed``,
74
+ as in Dynare's tests, and growth factors of 1). ``fixed`` maps variables that
75
+ do not grow to their level -- the states that carry exogenous variables, which
76
+ Dynare holds at zero -- and removes them from the unknowns.
77
+
78
+ A model in levels often has trivial paths too, on which a factor of production
79
+ is zero and output with it. A solution with a free level at zero is set aside
80
+ and the search restarts from another random start, up to ``tries`` times; if
81
+ every start ends there, the first solution comes back with ``degenerate=True``.
82
+ """
83
+ n = model.n_vars
84
+ rng = np.random.default_rng(seed)
85
+ fixed = dict(fixed or {})
86
+ fix_idx = np.array([model.position(k) for k in fixed], dtype=int)
87
+ free = np.setdiff1d(np.arange(n), fix_idx)
88
+ fix_val = np.array([float(v) for v in fixed.values()])
89
+
90
+ def vec(v, default):
91
+ if v is None:
92
+ return default
93
+ if isinstance(v, dict):
94
+ out = default.copy()
95
+ for k, x in v.items():
96
+ out[model.position(k)] = float(x)
97
+ return out
98
+ return np.asarray(v, dtype=float).copy()
99
+
100
+ e = np.zeros(model.n_shocks)
101
+ m = free.size
102
+
103
+ def full(z):
104
+ X, G = np.empty(n), np.ones(n)
105
+ X[free], G[free] = z[:m], z[m:]
106
+ X[fix_idx] = fix_val
107
+ return X, G
108
+
109
+ def F(z):
110
+ return bgp_residual(model, params, *full(z), ctx)
111
+
112
+ def jac(z):
113
+ X, G = full(z)
114
+ J1 = model.jacobian(X * G, X, e, params, ctx)
115
+ J2 = model.jacobian(X * G * G, X * G, e, params, ctx)
116
+ A1, B1 = J1[:, :n], J1[:, n:2 * n]
117
+ A2, B2 = J2[:, :n], J2[:, n:2 * n]
118
+ top = np.hstack([(A1 * G + B1)[:, free], (A1 * X)[:, free]])
119
+ bot = np.hstack([(A2 * G * G + B2 * G)[:, free], (A2 * (2 * X * G) + B2 * X)[:, free]])
120
+ return np.vstack([top, bot])
121
+
122
+ use_exact = None
123
+ first = None
124
+ for attempt in range(max(1, tries)):
125
+ X0 = vec(guess if attempt == 0 else None, 1 + (rng.random(n) - 0.5) * 0.2)
126
+ G0 = vec(growth, np.ones(n))
127
+ if use_exact is None:
128
+ use_exact = model.jacobian(X0 * G0, X0, e, params, ctx) is not None
129
+ sol = least_squares(F, np.concatenate([X0[free], G0[free]]),
130
+ jac=jac if use_exact else "2-point", method="lm",
131
+ xtol=1e-15, ftol=1e-15, gtol=1e-15, max_nfev=max_nfev)
132
+ X, G = full(sol.x)
133
+ res = float(np.max(np.abs(F(sol.x))))
134
+ degenerate = bool(np.min(np.abs(X[free]), initial=np.inf) < 1e-6)
135
+ out = BalancedGrowth(levels=dict(zip(model.names, X)), growth=dict(zip(model.names, G)),
136
+ residual=res, model=model, success=res < tol, degenerate=degenerate)
137
+ if out.success and not degenerate:
138
+ return out
139
+ if first is None or (out.success and not first.success):
140
+ first = out
141
+ return first
dsgelab/blocks.py ADDED
@@ -0,0 +1,304 @@
1
+ """Derive the first-order conditions from an agent's problem.
2
+
3
+ Optional: requires ``sympy`` (``pip install dsgelab[symbolic]``).
4
+
5
+ What it does, and what it does not
6
+ ----------------------------------
7
+ It takes a declared recursive optimisation problem (the period objective, the choice variables,
8
+ the states and the constraints) and returns the FOCs as EQUATIONS IN TEXT, ready for
9
+ :func:`dsgelab.symbolic.from_equations`. The gain is not in typing: the algebra is no longer done
10
+ by hand. Deriving a Lagrangian with four constraints and three states by hand is where sign errors
11
+ and forgotten terms come in, and they do not show up in the steady state.
12
+
13
+ What it does NOT do is choose the problem for you. Transversality conditions, inequality
14
+ constraints, complementarity and non-standard envelopes remain the modeller's job: the package
15
+ derives what you declared, and the declaration is the modelling.
16
+
17
+ The mathematics
18
+ ---------------
19
+ With ``y`` the controls, ``k`` the states and ``h_j`` the constraints written as ``= 0``, the
20
+ period Lagrangian is
21
+
22
+ L_t = u(y_t, k_t) + SUM_j lm_j,t * h_j(y_t, k_t, k_{t+1})
23
+
24
+ and the conditions are
25
+
26
+ dL_t/dy_t = 0 for each control
27
+ dL_t/dk_{t+1} + beta * E_t[ dL_{t+1}/dk_{t+1} ] = 0 for each state
28
+
29
+ The second is the Euler equation, and its second term is the derivative of the NEXT period's
30
+ Lagrangian with respect to the state it inherits, obtained by shifting ``dL_t/dk_t`` one period
31
+ ahead. If that shift produces a term in ``t+2``, the problem cannot be represented in two periods
32
+ and the function refuses, naming the state that caused it, instead of returning wrong equations.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import re
38
+ from typing import TYPE_CHECKING, Mapping, Sequence
39
+
40
+ if TYPE_CHECKING:
41
+ from .model import Model
42
+
43
+ __all__ = ["FOCError", "optimality_conditions", "Problem", "assemble"]
44
+
45
+ _LEAD = re.compile(r"\b([A-Za-z_]\w*)\s*\(\s*\+?\s*1\s*\)")
46
+
47
+
48
+ class FOCError(ValueError):
49
+ """Raised for a malformed problem, an undeclared name or a two-period-ahead term."""
50
+
51
+
52
+ def _require_sympy():
53
+ try:
54
+ import sympy # noqa: F401
55
+ except ImportError as exc: # pragma: no cover
56
+ raise FOCError("deriving FOCs requires sympy: "
57
+ "pip install 'dsgelab[symbolic]'") from exc
58
+ import sympy
59
+ return sympy
60
+
61
+
62
+ def _parse(text, local_dict, global_dict, label):
63
+ from sympy.parsing.sympy_parser import parse_expr, standard_transformations
64
+ t = _LEAD.sub(r"\1__next", str(text))
65
+ if t.count("=") > 1:
66
+ raise FOCError(f"{label}: more than one '='")
67
+ if "=" in t:
68
+ lhs, rhs = t.split("=")
69
+ t = f"({lhs}) - ({rhs})"
70
+ try:
71
+ return parse_expr(t, local_dict=local_dict, global_dict=global_dict,
72
+ transformations=standard_transformations)
73
+ except Exception as exc:
74
+ raise FOCError(f"{label}: cannot parse {text!r} ({exc})") from exc
75
+
76
+
77
+ class Problem:
78
+ """A declared recursive optimisation problem.
79
+
80
+ Parameters
81
+ ----------
82
+ objective : str
83
+ The PERIOD return, ``u(y_t, k_t)``. The discount factor enters through ``discount``, not
84
+ here.
85
+ controls : sequence of str
86
+ Variables chosen at ``t`` that are not states.
87
+ states : sequence of str
88
+ Predetermined variables. The value chosen at ``t`` is ``k(+1)``.
89
+ constraints : sequence of str
90
+ Equality constraints, ``"lhs = rhs"`` or an expression that equals zero. Each one gets a
91
+ multiplier.
92
+ discount : str
93
+ Name of the discount parameter.
94
+ parameters : sequence of str
95
+ Every name that is neither a variable nor a multiplier. An undeclared name raises an error
96
+ instead of becoming a free symbol: a typo cannot turn into a new parameter.
97
+ exogenous : sequence of str, optional
98
+ Variables that enter the problem but the agent does NOT choose, such as productivity or
99
+ prices taken as given. They produce no FOC.
100
+ multipliers : sequence of str, optional
101
+ Names of the multipliers, in the order of the constraints. When absent they are called
102
+ ``lm_1``, ``lm_2``…
103
+ """
104
+
105
+ def __init__(self, objective: str, *, controls: Sequence[str], states: Sequence[str] = (),
106
+ constraints: Sequence[str] = (), discount: str = "beta",
107
+ parameters: Sequence[str] = (), exogenous: Sequence[str] = (),
108
+ multipliers: Sequence[str] | None = None, name: str = "agent"):
109
+ self.objective = objective
110
+ self.controls = list(controls)
111
+ self.states = list(states)
112
+ self.constraints = list(constraints)
113
+ self.discount = discount
114
+ self.parameters = list(parameters)
115
+ self.exogenous = list(exogenous)
116
+ self.name = name
117
+ n = len(self.constraints)
118
+ self.multipliers = (list(multipliers) if multipliers is not None
119
+ else [f"lm_{i + 1}" for i in range(n)])
120
+ if len(self.multipliers) != n:
121
+ raise FOCError(f"{n} constraints and {len(self.multipliers)} multipliers")
122
+ all_names = self.controls + self.states + self.exogenous + self.multipliers
123
+ dup = {v for v in all_names if all_names.count(v) > 1}
124
+ if dup:
125
+ raise FOCError(f"names repeated among variables and multipliers: {sorted(dup)}")
126
+
127
+ def __repr__(self) -> str:
128
+ return (f"Problem({self.name!r}, {len(self.controls)} controls, "
129
+ f"{len(self.states)} states, {len(self.constraints)} constraints)")
130
+
131
+
132
+ def optimality_conditions(problem: Problem) -> dict:
133
+ """Derive the FOCs. Returns text equations, ready for ``from_equations``.
134
+
135
+ Returns
136
+ -------
137
+ dict with
138
+ ``equations`` the FOCs plus the constraints, as strings;
139
+ ``jumps`` controls and multipliers, the non-predetermined variables that the
140
+ problem introduces;
141
+ ``states`` the states, in the declared order;
142
+ ``multipliers`` the names used;
143
+ ``detail`` a map from each equation to its origin (which control, which state, which
144
+ constraint), so that the result can be audited rather than being an opaque
145
+ block.
146
+ """
147
+ sympy = _require_sympy()
148
+ p = problem
149
+
150
+ variables = p.controls + p.states + p.exogenous + p.multipliers
151
+ cur = {v: sympy.Symbol(v) for v in variables}
152
+ nxt = {v: sympy.Symbol(f"{v}__next") for v in variables}
153
+ nxt2 = {v: sympy.Symbol(f"{v}__next2") for v in variables}
154
+ par = {q: sympy.Symbol(q) for q in list(p.parameters) + [p.discount]}
155
+
156
+ local = {}
157
+ local.update(cur)
158
+ local.update({f"{v}__next": nxt[v] for v in variables})
159
+ local.update(par)
160
+ known = set(cur.values()) | set(nxt.values()) | set(par.values())
161
+ functions = {"log": sympy.log, "exp": sympy.exp, "sqrt": sympy.sqrt, "Abs": sympy.Abs,
162
+ "sin": sympy.sin, "cos": sympy.cos, "Min": sympy.Min, "Max": sympy.Max}
163
+ local_dict = dict(local); local_dict.update(functions)
164
+ global_dict = {"Symbol": sympy.Symbol, "Integer": sympy.Integer,
165
+ "Float": sympy.Float, "Rational": sympy.Rational,
166
+ "factorial": sympy.factorial}
167
+
168
+ def parse_checked(text, label):
169
+ e = _parse(text, local_dict, global_dict, label)
170
+ unknown = {s for s in e.free_symbols if s not in known}
171
+ if unknown:
172
+ raise FOCError(
173
+ f"{label}: undeclared name(s) {sorted(str(s) for s in unknown)}. "
174
+ f"Declare them in controls, states, exogenous or parameters.")
175
+ return e
176
+
177
+ u = parse_checked(p.objective, "objective")
178
+ hs = [parse_checked(c, f"constraint {i + 1}") for i, c in enumerate(p.constraints)]
179
+
180
+ # period Lagrangian
181
+ L = u + sum(cur[m] * h for m, h in zip(p.multipliers, hs))
182
+
183
+ def lead(expr):
184
+ """Shift the expression one period ahead: x -> x(+1), x(+1) -> x(+2)."""
185
+ return expr.xreplace({nxt[v]: nxt2[v] for v in variables}) \
186
+ .xreplace({cur[v]: nxt[v] for v in variables})
187
+
188
+ printer = _plain_printer(sympy)
189
+ equations, detail, jump_order = [], {}, []
190
+
191
+ # (1) FOC for each control
192
+ for c in p.controls:
193
+ # No `simplify`: it sometimes puts the expression over a denominator that is a VARIABLE,
194
+ # and a residual divided by k(+1) is equivalent only while k(+1) stays away from zero.
195
+ # The raw derivative is exactly the FOC.
196
+ foc = sympy.expand(sympy.diff(L, cur[c]))
197
+ txt = printer(foc)
198
+ equations.append(txt)
199
+ detail[txt] = f"FOC for control {c}"
200
+
201
+ # (2) Euler equation for each state
202
+ for k in p.states:
203
+ now = sympy.diff(L, nxt[k]) # effect of choosing k(+1) today
204
+ later = lead(sympy.diff(L, cur[k])) # what k(+1) yields tomorrow
205
+ forbidden = [v for v in variables if nxt2[v] in later.free_symbols]
206
+ if forbidden:
207
+ raise FOCError(
208
+ f"the Euler equation for state {k!r} requires terms in t+2 ({sorted(forbidden)}): "
209
+ f"the problem cannot be represented in two periods. Introduce the variable at t+1 "
210
+ f"as a control with its own definition.")
211
+ euler = sympy.expand(now + par[p.discount] * later)
212
+ txt = printer(euler)
213
+ equations.append(txt)
214
+ detail[txt] = f"Euler equation for state {k}"
215
+
216
+ # (3) the constraints, which are also equations of the model
217
+ for i, (c, h) in enumerate(zip(p.constraints, hs), start=1):
218
+ txt = printer(h)
219
+ equations.append(txt)
220
+ detail[txt] = f"constraint {i}: {c}"
221
+
222
+ jump_order = list(p.controls) + list(p.multipliers)
223
+ return dict(equations=equations, jumps=jump_order, states=list(p.states),
224
+ multipliers=list(p.multipliers), detail=detail)
225
+
226
+
227
+ def _plain_printer(sympy):
228
+ """Print in the syntax of ``from_equations``: ``x__next`` becomes ``x(+1)`` again."""
229
+ from sympy.printing.str import StrPrinter
230
+ pr = StrPrinter(settings={"order": "none"})
231
+
232
+ def do(expr):
233
+ s = pr.doprint(expr)
234
+ return re.sub(r"\b([A-Za-z_]\w*)__next\b", r"\1(+1)", s)
235
+
236
+ return do
237
+
238
+
239
+ def assemble(problems: Sequence[Problem], *, equations: Sequence[str] = (),
240
+ states: Sequence[str] = (), jumps: Sequence[str] = (),
241
+ shocks: Sequence[str] = (), parameters: Sequence[str] = (),
242
+ name: str = "model", derivatives: str = "both") -> Model:
243
+ """Combine several optimisation problems and the closing equations into one ``Model``.
244
+
245
+ Each problem contributes its FOCs, its constraints and its multipliers
246
+ (:func:`optimality_conditions`). The closure -- market clearing, exogenous
247
+ processes, definitions -- is the modeller's text, in ``equations``, and the
248
+ variables that only the closure introduces (prices, aggregate shocks) are
249
+ declared in ``states`` and ``jumps``. This is not a block language: the limits
250
+ are those of :class:`Problem` (equality constraints, nothing in ``t+2``).
251
+
252
+ What is checked before assembling: a variable chosen by two agents is refused
253
+ (a price that both take as given is ``exogenous`` to both); multipliers whose
254
+ default name is repeated across agents get the agent's name as a prefix
255
+ (``household_lm_1``); and the number of equations must equal the number of
256
+ variables, with the count per block in the message when it does not. The
257
+ origin of each equation is kept in ``model.assembly``.
258
+ """
259
+ from .symbolic import from_equations
260
+
261
+ chosen: dict = {}
262
+ for p in problems:
263
+ for v in p.controls + p.states:
264
+ if v in chosen:
265
+ raise FOCError(f"{v!r} is chosen by two agents, {chosen[v]!r} and {p.name!r}: "
266
+ f"one of them must take it as given (exogenous)")
267
+ chosen[v] = p.name
268
+ names = [p.name for p in problems]
269
+ if len(set(names)) != len(names):
270
+ raise FOCError(f"two problems share a name: {names}")
271
+ all_mult = [m for p in problems for m in p.multipliers]
272
+ clash = {m for m in all_mult if all_mult.count(m) > 1}
273
+ renamed = {}
274
+ eqs, st, jp, origin = [], [], [], []
275
+ for p in problems:
276
+ if clash & set(p.multipliers):
277
+ new = [f"{p.name}_{m}" if m in clash else m for m in p.multipliers]
278
+ renamed.update({f"{p.name}_{m}": (p.name, m) for m in p.multipliers if m in clash})
279
+ p = Problem(p.objective, controls=p.controls, states=p.states,
280
+ constraints=p.constraints, discount=p.discount,
281
+ parameters=p.parameters, exogenous=p.exogenous, multipliers=new,
282
+ name=p.name)
283
+ foc = optimality_conditions(p)
284
+ for e in foc["equations"]:
285
+ eqs.append(e)
286
+ origin.append((p.name, foc["detail"].get(e, "")))
287
+ st += [s for s in foc["states"] if s not in st]
288
+ jp += [j for j in foc["jumps"] if j not in jp]
289
+ for e in equations:
290
+ eqs.append(e)
291
+ origin.append(("closure", e))
292
+ st += [s for s in states if s not in st]
293
+ jp += [j for j in jumps if j not in jp and j not in st]
294
+ n = len(st) + len(jp)
295
+ if len(eqs) != n:
296
+ counts = {nm: sum(1 for o in origin if o[0] == nm) for nm in names + ["closure"]}
297
+ raise FOCError(f"{len(eqs)} equations for {n} variables ({len(st)} states, "
298
+ f"{len(jp)} jumps); equations by block: {counts}")
299
+ params = sorted(set(parameters) | {q for p in problems for q in p.parameters}
300
+ | {p.discount for p in problems})
301
+ model = from_equations(eqs, states=st, jumps=jp, shocks=list(shocks), parameters=params,
302
+ name=name, derivatives=derivatives)
303
+ model.assembly = dict(origin=origin, renamed_multipliers=renamed, agents=names)
304
+ return model