jact 0.1.1__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.
- jact/__init__.py +76 -0
- jact/callbacks.py +185 -0
- jact/cashflows.py +276 -0
- jact/initial_distribution.py +403 -0
- jact/model.py +418 -0
- jact/solver.py +1094 -0
- jact/state_space.py +418 -0
- jact-0.1.1.dist-info/METADATA +142 -0
- jact-0.1.1.dist-info/RECORD +12 -0
- jact-0.1.1.dist-info/WHEEL +5 -0
- jact-0.1.1.dist-info/licenses/LICENSE +202 -0
- jact-0.1.1.dist-info/top_level.txt +1 -0
jact/__init__.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""jact — JAX-based transition probability computation for multi-state models.
|
|
2
|
+
|
|
3
|
+
A framework for computing transition probabilities in semi-Markov
|
|
4
|
+
multi-state models with duration-dependent transition intensities.
|
|
5
|
+
|
|
6
|
+
Example
|
|
7
|
+
-------
|
|
8
|
+
>>> import jact
|
|
9
|
+
>>> import jax.numpy as jnp
|
|
10
|
+
>>>
|
|
11
|
+
>>> state_space = jact.StateSpace(
|
|
12
|
+
... states=["healthy", "disabled", "dead"],
|
|
13
|
+
... transitions=[
|
|
14
|
+
... ("healthy", "disabled"),
|
|
15
|
+
... ("healthy", "dead"),
|
|
16
|
+
... ("disabled", "dead"),
|
|
17
|
+
... ],
|
|
18
|
+
... )
|
|
19
|
+
>>>
|
|
20
|
+
>>> model = state_space.build(
|
|
21
|
+
... transitions={
|
|
22
|
+
... ("healthy", "disabled"): onset_fn,
|
|
23
|
+
... ("healthy", "dead"): mortality_fn,
|
|
24
|
+
... ("disabled", "dead"): disabled_mort_fn,
|
|
25
|
+
... }
|
|
26
|
+
... )
|
|
27
|
+
>>>
|
|
28
|
+
>>> result = model.solve(
|
|
29
|
+
... initial="healthy",
|
|
30
|
+
... horizon=10,
|
|
31
|
+
... steps_per_unit=12,
|
|
32
|
+
... age=ages,
|
|
33
|
+
... )
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
37
|
+
|
|
38
|
+
from . import callbacks
|
|
39
|
+
from .cashflows import (
|
|
40
|
+
ByKind,
|
|
41
|
+
ByState,
|
|
42
|
+
CashflowDeclaration,
|
|
43
|
+
Group,
|
|
44
|
+
Raw,
|
|
45
|
+
ScheduledEvent,
|
|
46
|
+
StateRate,
|
|
47
|
+
Total,
|
|
48
|
+
TransitionLump,
|
|
49
|
+
)
|
|
50
|
+
from .initial_distribution import InitialDistribution
|
|
51
|
+
from .model import Model
|
|
52
|
+
from .solver import solve
|
|
53
|
+
from .state_space import StateSpace
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
__version__ = version("jact")
|
|
57
|
+
except PackageNotFoundError:
|
|
58
|
+
__version__ = "0.1.0"
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"__version__",
|
|
62
|
+
"StateSpace",
|
|
63
|
+
"Model",
|
|
64
|
+
"InitialDistribution",
|
|
65
|
+
"solve",
|
|
66
|
+
"callbacks",
|
|
67
|
+
"StateRate",
|
|
68
|
+
"TransitionLump",
|
|
69
|
+
"ScheduledEvent",
|
|
70
|
+
"Raw",
|
|
71
|
+
"Group",
|
|
72
|
+
"Total",
|
|
73
|
+
"ByState",
|
|
74
|
+
"ByKind",
|
|
75
|
+
"CashflowDeclaration",
|
|
76
|
+
]
|
jact/callbacks.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Probability callbacks for extracting results from the solver state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, NamedTuple, Union
|
|
6
|
+
|
|
7
|
+
import jax
|
|
8
|
+
import jax.numpy as jnp
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"PointMass",
|
|
12
|
+
"StateCarry",
|
|
13
|
+
"density",
|
|
14
|
+
"density_probability",
|
|
15
|
+
"full",
|
|
16
|
+
"marginal_components",
|
|
17
|
+
"none_callback",
|
|
18
|
+
"point_mass",
|
|
19
|
+
"state_probability",
|
|
20
|
+
"resolve_callback",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _validate_shape(
|
|
25
|
+
name: str,
|
|
26
|
+
value: jnp.ndarray,
|
|
27
|
+
expected_shape: tuple[int, ...],
|
|
28
|
+
) -> None:
|
|
29
|
+
shape = jnp.shape(value)
|
|
30
|
+
if shape != expected_shape:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"{name} must have shape {expected_shape}, got {shape}."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _validate_non_negative_if_concrete(value: jnp.ndarray) -> None:
|
|
37
|
+
try:
|
|
38
|
+
arr = jnp.asarray(value)
|
|
39
|
+
if bool(jnp.any(arr < 0)):
|
|
40
|
+
raise ValueError("value must be non-negative.")
|
|
41
|
+
except Exception as exc: # pragma: no cover - tracer path
|
|
42
|
+
if "tracer" not in type(exc).__name__.lower():
|
|
43
|
+
try:
|
|
44
|
+
message = str(exc).lower()
|
|
45
|
+
except Exception: # pragma: no cover
|
|
46
|
+
message = ""
|
|
47
|
+
if "tracer" not in message and "concret" not in message:
|
|
48
|
+
raise
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@jax.tree_util.register_pytree_node_class
|
|
52
|
+
class PointMass:
|
|
53
|
+
"""Per-individual point mass carried along a characteristic."""
|
|
54
|
+
|
|
55
|
+
__slots__ = ("value", "d_0", "log_value")
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
value: jnp.ndarray,
|
|
60
|
+
d_0: jnp.ndarray,
|
|
61
|
+
log_value: jnp.ndarray | None = None,
|
|
62
|
+
):
|
|
63
|
+
value_shape = jnp.shape(value)
|
|
64
|
+
_validate_shape("d_0", d_0, value_shape)
|
|
65
|
+
if log_value is not None:
|
|
66
|
+
_validate_shape("log_value", log_value, value_shape)
|
|
67
|
+
_validate_non_negative_if_concrete(value)
|
|
68
|
+
self.value = value
|
|
69
|
+
self.d_0 = d_0
|
|
70
|
+
self.log_value = (
|
|
71
|
+
jnp.where(value > 0, jnp.log(value), -jnp.inf)
|
|
72
|
+
if log_value is None
|
|
73
|
+
else log_value
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def tree_flatten(self):
|
|
77
|
+
return (self.value, self.d_0, self.log_value), None
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def tree_unflatten(cls, _, children):
|
|
81
|
+
value, d_0, log_value = children
|
|
82
|
+
self = cls.__new__(cls)
|
|
83
|
+
self.value = value
|
|
84
|
+
self.d_0 = d_0
|
|
85
|
+
self.log_value = log_value
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class StateCarry(NamedTuple):
|
|
90
|
+
"""Per-state solver carry."""
|
|
91
|
+
|
|
92
|
+
density: jnp.ndarray
|
|
93
|
+
point_mass: PointMass | None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@jax.jit
|
|
97
|
+
def none_callback(state: tuple[StateCarry, ...]):
|
|
98
|
+
"""Return nothing. Use when only side effects of the solve matter."""
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@jax.jit
|
|
103
|
+
def full(state: tuple[StateCarry, ...]):
|
|
104
|
+
"""Return the full pytree state unchanged."""
|
|
105
|
+
return state
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@jax.jit
|
|
109
|
+
def marginal_components(state: tuple[StateCarry, ...]):
|
|
110
|
+
"""Marginalize density over duration while keeping point masses explicit."""
|
|
111
|
+
return tuple(
|
|
112
|
+
StateCarry(
|
|
113
|
+
density=jnp.sum(carry.density, axis=-1),
|
|
114
|
+
point_mass=carry.point_mass,
|
|
115
|
+
)
|
|
116
|
+
for carry in state
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@jax.jit
|
|
121
|
+
def state_probability(state: tuple[StateCarry, ...]):
|
|
122
|
+
"""Return total state occupancy after marginalizing over duration."""
|
|
123
|
+
return jnp.stack(
|
|
124
|
+
tuple(
|
|
125
|
+
jnp.sum(carry.density, axis=-1)
|
|
126
|
+
if carry.point_mass is None
|
|
127
|
+
else jnp.sum(carry.density, axis=-1) + carry.point_mass.value
|
|
128
|
+
for carry in state
|
|
129
|
+
),
|
|
130
|
+
axis=-1,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@jax.jit
|
|
135
|
+
def point_mass(state: tuple[StateCarry, ...]):
|
|
136
|
+
"""Return only the point-mass component per state."""
|
|
137
|
+
return tuple(carry.point_mass for carry in state)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@jax.jit
|
|
141
|
+
def density(state: tuple[StateCarry, ...]):
|
|
142
|
+
"""Return only the absolutely continuous density per state."""
|
|
143
|
+
return tuple(carry.density for carry in state)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@jax.jit
|
|
147
|
+
def density_probability(state: tuple[StateCarry, ...]):
|
|
148
|
+
"""Return the duration-marginal density, restacked across states."""
|
|
149
|
+
return jnp.stack(
|
|
150
|
+
tuple(jnp.sum(carry.density, axis=-1) for carry in state),
|
|
151
|
+
axis=-1,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
_CALLBACKS = {
|
|
156
|
+
"none": none_callback,
|
|
157
|
+
"full": full,
|
|
158
|
+
"marginal_components": marginal_components,
|
|
159
|
+
"state_probability": state_probability,
|
|
160
|
+
"point_mass": point_mass,
|
|
161
|
+
"density": density,
|
|
162
|
+
"density_probability": density_probability,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def resolve_callback(
|
|
167
|
+
callback: Union[None, str, Callable[[tuple[StateCarry, ...]], Any]],
|
|
168
|
+
) -> Callable[[tuple[StateCarry, ...]], Any]:
|
|
169
|
+
"""Resolve a callback specification to a callable."""
|
|
170
|
+
if callback is None:
|
|
171
|
+
return none_callback
|
|
172
|
+
if callable(callback):
|
|
173
|
+
return callback
|
|
174
|
+
if isinstance(callback, str):
|
|
175
|
+
if callback not in _CALLBACKS:
|
|
176
|
+
available = ", ".join(sorted(_CALLBACKS.keys()))
|
|
177
|
+
raise ValueError(
|
|
178
|
+
f"Unknown callback '{callback}'. "
|
|
179
|
+
f"Available callbacks: {available}"
|
|
180
|
+
)
|
|
181
|
+
return _CALLBACKS[callback]
|
|
182
|
+
raise TypeError(
|
|
183
|
+
"callback must be None, a string, or a callable, "
|
|
184
|
+
f"got {type(callback)}"
|
|
185
|
+
)
|
jact/cashflows.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Cashflow declarations and solve-time views."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from numbers import Number
|
|
7
|
+
from typing import Any, Callable, Mapping, Sequence
|
|
8
|
+
|
|
9
|
+
import jax.numpy as jnp
|
|
10
|
+
|
|
11
|
+
Scalar = int | float
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ByKind",
|
|
15
|
+
"ByState",
|
|
16
|
+
"CashflowDeclaration",
|
|
17
|
+
"Group",
|
|
18
|
+
"Raw",
|
|
19
|
+
"ScheduledEvent",
|
|
20
|
+
"StateRate",
|
|
21
|
+
"Total",
|
|
22
|
+
"TransitionLump",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class StateRate:
|
|
28
|
+
"""Payment-rate callables attached to occupied states."""
|
|
29
|
+
|
|
30
|
+
payments: Mapping[str, Callable[..., jnp.ndarray]]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class TransitionLump:
|
|
35
|
+
"""Lump-sum payment callables attached to transitions."""
|
|
36
|
+
|
|
37
|
+
payments: Mapping[tuple[str, str], Callable[..., jnp.ndarray]]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class ScheduledEvent:
|
|
42
|
+
"""State-conditioned payments at deterministic event times."""
|
|
43
|
+
|
|
44
|
+
when: Callable[..., jnp.ndarray]
|
|
45
|
+
payments: Mapping[str, Callable[..., jnp.ndarray]]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Raw:
|
|
50
|
+
"""Return one raw component or all raw components."""
|
|
51
|
+
|
|
52
|
+
name: str | None = None
|
|
53
|
+
weight: Callable[..., jnp.ndarray] | Scalar | None = None
|
|
54
|
+
terminal: bool = False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Group:
|
|
59
|
+
"""Return the sum of selected raw components."""
|
|
60
|
+
|
|
61
|
+
members: Sequence[str]
|
|
62
|
+
weight: Callable[..., jnp.ndarray] | Scalar | None = None
|
|
63
|
+
terminal: bool = False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class Total:
|
|
68
|
+
"""Return the sum of all raw components."""
|
|
69
|
+
|
|
70
|
+
weight: Callable[..., jnp.ndarray] | Scalar | None = None
|
|
71
|
+
terminal: bool = False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class ByState:
|
|
76
|
+
"""Return cashflows split by reachable state."""
|
|
77
|
+
|
|
78
|
+
weight: Callable[..., jnp.ndarray] | Scalar | None = None
|
|
79
|
+
terminal: bool = False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class ByKind:
|
|
84
|
+
"""Return cashflows split by component kind."""
|
|
85
|
+
|
|
86
|
+
weight: Callable[..., jnp.ndarray] | Scalar | None = None
|
|
87
|
+
terminal: bool = False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class CashflowDeclaration:
|
|
92
|
+
"""Validated cashflow components bound to a state-space topology."""
|
|
93
|
+
|
|
94
|
+
state_space: Any
|
|
95
|
+
components: tuple[tuple[str, StateRate | TransitionLump | ScheduledEvent], ...]
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def names(self) -> tuple[str, ...]:
|
|
99
|
+
return tuple(name for name, _ in self.components)
|
|
100
|
+
|
|
101
|
+
def component(self, name: str) -> StateRate | TransitionLump | ScheduledEvent:
|
|
102
|
+
for component_name, component in self.components:
|
|
103
|
+
if component_name == name:
|
|
104
|
+
return component
|
|
105
|
+
raise ValueError(f"Unknown cashflow component '{name}'.")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _check_component_name(name: Any) -> None:
|
|
109
|
+
if not isinstance(name, str) or not name:
|
|
110
|
+
raise ValueError("cashflow component names must be non-empty strings.")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _check_callable(value: Any, field: str) -> None:
|
|
114
|
+
if not callable(value):
|
|
115
|
+
raise TypeError(f"{field} must be callable.")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _validate_payment_mapping(payments: Any, field: str) -> Mapping[Any, Any]:
|
|
119
|
+
if not isinstance(payments, Mapping) or not payments:
|
|
120
|
+
raise ValueError(f"{field} must be a non-empty mapping.")
|
|
121
|
+
for fn in payments.values():
|
|
122
|
+
_check_callable(fn, f"{field} values")
|
|
123
|
+
return dict(payments)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _is_scalar_array_like(value: Any) -> bool:
|
|
127
|
+
if value is None:
|
|
128
|
+
return False
|
|
129
|
+
try:
|
|
130
|
+
return bool(jnp.asarray(value).ndim == 0)
|
|
131
|
+
except Exception:
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _normalise_weight(weight: Any) -> Any:
|
|
136
|
+
if weight is None:
|
|
137
|
+
return None
|
|
138
|
+
if _is_scalar_array_like(weight):
|
|
139
|
+
return jnp.asarray(weight).item()
|
|
140
|
+
return weight
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def validate_cashflow_components(
|
|
144
|
+
state_space: Any,
|
|
145
|
+
components: Mapping[str, StateRate | TransitionLump | ScheduledEvent],
|
|
146
|
+
) -> CashflowDeclaration:
|
|
147
|
+
"""Validate and freeze a component mapping for a state space."""
|
|
148
|
+
if not isinstance(components, Mapping) or not components:
|
|
149
|
+
raise ValueError("cashflows() requires a non-empty component mapping.")
|
|
150
|
+
|
|
151
|
+
frozen: list[tuple[str, StateRate | TransitionLump | ScheduledEvent]] = []
|
|
152
|
+
seen: set[str] = set()
|
|
153
|
+
for name, component in components.items():
|
|
154
|
+
_check_component_name(name)
|
|
155
|
+
if name in seen:
|
|
156
|
+
raise ValueError(f"Duplicate cashflow component name '{name}'.")
|
|
157
|
+
seen.add(name)
|
|
158
|
+
|
|
159
|
+
if isinstance(component, StateRate):
|
|
160
|
+
payments = _validate_payment_mapping(
|
|
161
|
+
component.payments,
|
|
162
|
+
f"StateRate('{name}').payments",
|
|
163
|
+
)
|
|
164
|
+
for state in payments:
|
|
165
|
+
state_space._check_state(state)
|
|
166
|
+
frozen_component = StateRate(payments=payments)
|
|
167
|
+
elif isinstance(component, TransitionLump):
|
|
168
|
+
payments = _validate_payment_mapping(
|
|
169
|
+
component.payments,
|
|
170
|
+
f"TransitionLump('{name}').payments",
|
|
171
|
+
)
|
|
172
|
+
for transition in payments:
|
|
173
|
+
if (
|
|
174
|
+
not isinstance(transition, tuple)
|
|
175
|
+
or len(transition) != 2
|
|
176
|
+
or not state_space.has_transition(*transition)
|
|
177
|
+
):
|
|
178
|
+
raise ValueError(
|
|
179
|
+
f"TransitionLump('{name}') references unknown "
|
|
180
|
+
f"transition {transition!r}."
|
|
181
|
+
)
|
|
182
|
+
frozen_component = TransitionLump(payments=payments)
|
|
183
|
+
elif isinstance(component, ScheduledEvent):
|
|
184
|
+
_check_callable(component.when, f"ScheduledEvent('{name}').when")
|
|
185
|
+
payments = _validate_payment_mapping(
|
|
186
|
+
component.payments,
|
|
187
|
+
f"ScheduledEvent('{name}').payments",
|
|
188
|
+
)
|
|
189
|
+
for state in payments:
|
|
190
|
+
state_space._check_state(state)
|
|
191
|
+
frozen_component = ScheduledEvent(
|
|
192
|
+
when=component.when,
|
|
193
|
+
payments=payments,
|
|
194
|
+
)
|
|
195
|
+
else:
|
|
196
|
+
raise TypeError(
|
|
197
|
+
"cashflow components must be StateRate, TransitionLump, "
|
|
198
|
+
f"or ScheduledEvent; got {type(component)}."
|
|
199
|
+
)
|
|
200
|
+
frozen.append((name, frozen_component))
|
|
201
|
+
|
|
202
|
+
return CashflowDeclaration(state_space=state_space, components=tuple(frozen))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _validate_view_common(view: Any) -> None:
|
|
206
|
+
if not isinstance(view.terminal, bool):
|
|
207
|
+
raise TypeError("cashflow view terminal must be a bool.")
|
|
208
|
+
weight = view.weight
|
|
209
|
+
if weight is None or callable(weight) or isinstance(weight, Number):
|
|
210
|
+
return
|
|
211
|
+
if _is_scalar_array_like(weight):
|
|
212
|
+
return
|
|
213
|
+
raise TypeError("cashflow view weight must be None, a scalar, or callable.")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def validate_cashflow_views(
|
|
217
|
+
declaration: CashflowDeclaration,
|
|
218
|
+
views: Mapping[str, Raw | Group | Total | ByState | ByKind] | None,
|
|
219
|
+
) -> tuple[tuple[str, Raw | Group | Total | ByState | ByKind], ...]:
|
|
220
|
+
"""Validate and freeze solve-time cashflow views."""
|
|
221
|
+
if views is None:
|
|
222
|
+
views = {"raw": Raw()}
|
|
223
|
+
if not isinstance(views, Mapping):
|
|
224
|
+
raise TypeError("cashflow_views must be a mapping or None.")
|
|
225
|
+
|
|
226
|
+
component_names = set(declaration.names)
|
|
227
|
+
frozen: list[tuple[str, Raw | Group | Total | ByState | ByKind]] = []
|
|
228
|
+
seen: set[str] = set()
|
|
229
|
+
for name, view in views.items():
|
|
230
|
+
if not isinstance(name, str) or not name:
|
|
231
|
+
raise ValueError("cashflow view names must be non-empty strings.")
|
|
232
|
+
if name in seen:
|
|
233
|
+
raise ValueError(f"Duplicate cashflow view name '{name}'.")
|
|
234
|
+
seen.add(name)
|
|
235
|
+
|
|
236
|
+
if isinstance(view, Raw):
|
|
237
|
+
_validate_view_common(view)
|
|
238
|
+
view = Raw(
|
|
239
|
+
name=view.name,
|
|
240
|
+
weight=_normalise_weight(view.weight),
|
|
241
|
+
terminal=view.terminal,
|
|
242
|
+
)
|
|
243
|
+
if view.name is not None and view.name not in component_names:
|
|
244
|
+
raise ValueError(
|
|
245
|
+
f"Raw view '{name}' references unknown component "
|
|
246
|
+
f"'{view.name}'."
|
|
247
|
+
)
|
|
248
|
+
elif isinstance(view, Group):
|
|
249
|
+
_validate_view_common(view)
|
|
250
|
+
if isinstance(view.members, str) or not view.members:
|
|
251
|
+
raise ValueError(f"Group view '{name}' requires members.")
|
|
252
|
+
members = tuple(view.members)
|
|
253
|
+
for member in members:
|
|
254
|
+
if member not in component_names:
|
|
255
|
+
raise ValueError(
|
|
256
|
+
f"Group view '{name}' references unknown component "
|
|
257
|
+
f"'{member}'."
|
|
258
|
+
)
|
|
259
|
+
view = Group(
|
|
260
|
+
members=members,
|
|
261
|
+
weight=_normalise_weight(view.weight),
|
|
262
|
+
terminal=view.terminal,
|
|
263
|
+
)
|
|
264
|
+
elif isinstance(view, (Total, ByState, ByKind)):
|
|
265
|
+
_validate_view_common(view)
|
|
266
|
+
view = type(view)(
|
|
267
|
+
weight=_normalise_weight(view.weight),
|
|
268
|
+
terminal=view.terminal,
|
|
269
|
+
)
|
|
270
|
+
else:
|
|
271
|
+
raise TypeError(
|
|
272
|
+
"cashflow views must be Raw, Group, Total, ByState, or ByKind; "
|
|
273
|
+
f"got {type(view)}."
|
|
274
|
+
)
|
|
275
|
+
frozen.append((name, view))
|
|
276
|
+
return tuple(frozen)
|