sqllocks-shape-behavior 0.9.1__tar.gz
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.
- sqllocks_shape_behavior-0.9.1/LICENSE +21 -0
- sqllocks_shape_behavior-0.9.1/PKG-INFO +48 -0
- sqllocks_shape_behavior-0.9.1/README.md +25 -0
- sqllocks_shape_behavior-0.9.1/pyproject.toml +50 -0
- sqllocks_shape_behavior-0.9.1/setup.cfg +4 -0
- sqllocks_shape_behavior-0.9.1/src/shape_behavior/__init__.py +40 -0
- sqllocks_shape_behavior-0.9.1/src/shape_behavior/behaviors.py +150 -0
- sqllocks_shape_behavior-0.9.1/src/shape_behavior/cli.py +240 -0
- sqllocks_shape_behavior-0.9.1/src/shape_behavior/conditions.py +221 -0
- sqllocks_shape_behavior-0.9.1/src/shape_behavior/dist.py +89 -0
- sqllocks_shape_behavior-0.9.1/src/shape_behavior/events.py +108 -0
- sqllocks_shape_behavior-0.9.1/src/shape_behavior/examples/equipment_maintenance.json +169 -0
- sqllocks_shape_behavior-0.9.1/src/shape_behavior/examples/healthcare_screening.json +255 -0
- sqllocks_shape_behavior-0.9.1/src/shape_behavior/examples/subscription.json +261 -0
- sqllocks_shape_behavior-0.9.1/src/shape_behavior/extension.py +109 -0
- sqllocks_shape_behavior-0.9.1/src/shape_behavior/gmf.py +417 -0
- sqllocks_shape_behavior-0.9.1/src/shape_behavior/model.py +495 -0
- sqllocks_shape_behavior-0.9.1/src/shape_behavior/params.py +85 -0
- sqllocks_shape_behavior-0.9.1/src/shape_behavior/population.py +224 -0
- sqllocks_shape_behavior-0.9.1/src/shape_behavior/primitives.py +734 -0
- sqllocks_shape_behavior-0.9.1/src/shape_behavior/rng.py +47 -0
- sqllocks_shape_behavior-0.9.1/src/shape_behavior/simulator.py +551 -0
- sqllocks_shape_behavior-0.9.1/src/shape_behavior/timeutil.py +81 -0
- sqllocks_shape_behavior-0.9.1/src/sqllocks_shape_behavior.egg-info/PKG-INFO +48 -0
- sqllocks_shape_behavior-0.9.1/src/sqllocks_shape_behavior.egg-info/SOURCES.txt +43 -0
- sqllocks_shape_behavior-0.9.1/src/sqllocks_shape_behavior.egg-info/dependency_links.txt +1 -0
- sqllocks_shape_behavior-0.9.1/src/sqllocks_shape_behavior.egg-info/entry_points.txt +12 -0
- sqllocks_shape_behavior-0.9.1/src/sqllocks_shape_behavior.egg-info/requires.txt +1 -0
- sqllocks_shape_behavior-0.9.1/src/sqllocks_shape_behavior.egg-info/top_level.txt +1 -0
- sqllocks_shape_behavior-0.9.1/tests/test_behaviors_group.py +97 -0
- sqllocks_shape_behavior-0.9.1/tests/test_cli.py +131 -0
- sqllocks_shape_behavior-0.9.1/tests/test_clock.py +123 -0
- sqllocks_shape_behavior-0.9.1/tests/test_determinism.py +92 -0
- sqllocks_shape_behavior-0.9.1/tests/test_examples.py +144 -0
- sqllocks_shape_behavior-0.9.1/tests/test_extension.py +92 -0
- sqllocks_shape_behavior-0.9.1/tests/test_gmf.py +138 -0
- sqllocks_shape_behavior-0.9.1/tests/test_guards.py +155 -0
- sqllocks_shape_behavior-0.9.1/tests/test_primitives.py +627 -0
- sqllocks_shape_behavior-0.9.1/tests/test_primitives_cli.py +280 -0
- sqllocks_shape_behavior-0.9.1/tests/test_primitives_scale.py +71 -0
- sqllocks_shape_behavior-0.9.1/tests/test_resume.py +77 -0
- sqllocks_shape_behavior-0.9.1/tests/test_rng.py +36 -0
- sqllocks_shape_behavior-0.9.1/tests/test_scale.py +36 -0
- sqllocks_shape_behavior-0.9.1/tests/test_skeleton.py +27 -0
- sqllocks_shape_behavior-0.9.1/tests/test_transitions.py +137 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 SQLLocks (Jonathan Stewart)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqllocks-shape-behavior
|
|
3
|
+
Version: 0.9.1
|
|
4
|
+
Summary: Shape plugin: behavior models (state machines on a virtual clock)
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/sqllocks/shape
|
|
7
|
+
Project-URL: Repository, https://github.com/sqllocks/shape
|
|
8
|
+
Project-URL: Issues, https://github.com/sqllocks/shape/issues
|
|
9
|
+
Project-URL: Documentation, https://github.com/sqllocks/shape/tree/main/plugins/shape-behavior
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Topic :: Database
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: sqllocks-shape==0.9.1
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# sqllocks-shape-behavior
|
|
25
|
+
|
|
26
|
+
Shape plugin: **behavior models**. Declarative state-machine modules (states with delays, guards,
|
|
27
|
+
weighted and conditional transitions, attributes, clinical-style events and an extension point for
|
|
28
|
+
domain events) run for a population on a virtual clock, deterministic per seed and resumable, and
|
|
29
|
+
return an Arrow table of timestamped events that a domain pack turns into tables. It also imports
|
|
30
|
+
Generic Module Framework JSON modules that you download yourself.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install sqllocks-shape-behavior
|
|
34
|
+
shape behave run subscription --population 10000 --years 3 --seed 7 -o out/
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from shape_behavior import Population, Simulator, load_module
|
|
39
|
+
|
|
40
|
+
sim = Simulator([load_module("subscription")], Population(size=10_000, start="2024-01-01"))
|
|
41
|
+
events = sim.run_until("2027-01-01") # a pyarrow.Table
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Behavior modules are plugins of the entry-point group `shape.behaviors`: `shape plugins list`
|
|
45
|
+
shows them, and `python -m shape.plugins.kit sqllocks-shape-behavior` checks them.
|
|
46
|
+
|
|
47
|
+
Full reference: `docs/plugins/behavior.md` in the repository. Its version always equals core's
|
|
48
|
+
(`sqllocks-shape`) and it is released together with core. It depends only on numpy and pyarrow.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# sqllocks-shape-behavior
|
|
2
|
+
|
|
3
|
+
Shape plugin: **behavior models**. Declarative state-machine modules (states with delays, guards,
|
|
4
|
+
weighted and conditional transitions, attributes, clinical-style events and an extension point for
|
|
5
|
+
domain events) run for a population on a virtual clock, deterministic per seed and resumable, and
|
|
6
|
+
return an Arrow table of timestamped events that a domain pack turns into tables. It also imports
|
|
7
|
+
Generic Module Framework JSON modules that you download yourself.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install sqllocks-shape-behavior
|
|
11
|
+
shape behave run subscription --population 10000 --years 3 --seed 7 -o out/
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from shape_behavior import Population, Simulator, load_module
|
|
16
|
+
|
|
17
|
+
sim = Simulator([load_module("subscription")], Population(size=10_000, start="2024-01-01"))
|
|
18
|
+
events = sim.run_until("2027-01-01") # a pyarrow.Table
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Behavior modules are plugins of the entry-point group `shape.behaviors`: `shape plugins list`
|
|
22
|
+
shows them, and `python -m shape.plugins.kit sqllocks-shape-behavior` checks them.
|
|
23
|
+
|
|
24
|
+
Full reference: `docs/plugins/behavior.md` in the repository. Its version always equals core's
|
|
25
|
+
(`sqllocks-shape`) and it is released together with core. It depends only on numpy and pyarrow.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sqllocks-shape-behavior"
|
|
7
|
+
# Lockstep with core (T-09): scripts/check_plugin_skeletons.py fails when this drifts.
|
|
8
|
+
version = "0.9.1"
|
|
9
|
+
description = "Shape plugin: behavior models (state machines on a virtual clock)"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Topic :: Database",
|
|
23
|
+
]
|
|
24
|
+
dependencies = ["sqllocks-shape==0.9.1"]
|
|
25
|
+
|
|
26
|
+
# Entry points (shape.* groups, plugin API v1); see docs/plugins/authoring.md.
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/sqllocks/shape"
|
|
29
|
+
Repository = "https://github.com/sqllocks/shape"
|
|
30
|
+
Issues = "https://github.com/sqllocks/shape/issues"
|
|
31
|
+
Documentation = "https://github.com/sqllocks/shape/tree/main/plugins/shape-behavior"
|
|
32
|
+
|
|
33
|
+
[project.entry-points."shape.commands"]
|
|
34
|
+
behave = "shape_behavior.cli:BehaveCommand"
|
|
35
|
+
|
|
36
|
+
[project.entry-points."shape.behaviors"]
|
|
37
|
+
subscription = "shape_behavior.behaviors:Subscription"
|
|
38
|
+
equipment_maintenance = "shape_behavior.behaviors:EquipmentMaintenance"
|
|
39
|
+
healthcare_screening = "shape_behavior.behaviors:HealthcareScreening"
|
|
40
|
+
event_sequence = "shape_behavior.behaviors:EventSequence"
|
|
41
|
+
telemetry_series = "shape_behavior.behaviors:TelemetrySeries"
|
|
42
|
+
transaction_stream = "shape_behavior.behaviors:TransactionStream"
|
|
43
|
+
file_arrival = "shape_behavior.behaviors:FileArrival"
|
|
44
|
+
entity_lifecycle = "shape_behavior.behaviors:EntityLifecycle"
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.packages.find]
|
|
47
|
+
where = ["src"]
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.package-data]
|
|
50
|
+
shape_behavior = ["examples/*.json"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Shape plugin: behavior models (state machines on a virtual clock).
|
|
2
|
+
|
|
3
|
+
The public names are importable from here, lazily: importing the package loads no module of
|
|
4
|
+
this plugin beyond this file, and none of NumPy or Arrow. See ``docs/plugins/behavior.md``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
SHAPE_API = "1.0"
|
|
13
|
+
|
|
14
|
+
_EXPORTS = {
|
|
15
|
+
"Module": "shape_behavior.model",
|
|
16
|
+
"ModuleError": "shape_behavior.model",
|
|
17
|
+
"load_module": "shape_behavior.model",
|
|
18
|
+
"Population": "shape_behavior.population",
|
|
19
|
+
"SimConfig": "shape_behavior.simulator",
|
|
20
|
+
"Simulator": "shape_behavior.simulator",
|
|
21
|
+
"Checkpoint": "shape_behavior.simulator",
|
|
22
|
+
"EVENT_SCHEMA": "shape_behavior.events",
|
|
23
|
+
"import_gmf": "shape_behavior.gmf",
|
|
24
|
+
"ImportResult": "shape_behavior.gmf",
|
|
25
|
+
"Unsupported": "shape_behavior.gmf",
|
|
26
|
+
"UnsupportedGmfError": "shape_behavior.gmf",
|
|
27
|
+
"register_state_type": "shape_behavior.extension",
|
|
28
|
+
"StateHandler": "shape_behavior.extension",
|
|
29
|
+
"StateContext": "shape_behavior.extension",
|
|
30
|
+
"Emission": "shape_behavior.extension",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
__all__ = sorted(_EXPORTS)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def __getattr__(name: str) -> Any:
|
|
37
|
+
module = _EXPORTS.get(name)
|
|
38
|
+
if module is None:
|
|
39
|
+
raise AttributeError(f"module 'shape_behavior' has no attribute {name!r}")
|
|
40
|
+
return getattr(importlib.import_module(module), name)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Behavior modules as ``shape.behaviors`` plugins (plugin API v1, ``Behavior`` protocol).
|
|
2
|
+
|
|
3
|
+
A :class:`ModuleBehavior` wraps one :class:`~shape_behavior.model.Module` so the engine, the
|
|
4
|
+
plugin host, ``shape plugins list`` and the conformance kit can all reach it by name::
|
|
5
|
+
|
|
6
|
+
from shape_behavior.behaviors import behavior
|
|
7
|
+
|
|
8
|
+
b = behavior({"format": "shape-behavior/1", "name": "my_process", "states": {...}})
|
|
9
|
+
events = b.simulate(population=1000, seed=7, years=3) # a pyarrow.Table
|
|
10
|
+
|
|
11
|
+
The built-in examples register under ``shape.behaviors`` through the classes at the bottom of
|
|
12
|
+
this file; a third-party package registers its own module the same way
|
|
13
|
+
(``docs/plugins/behavior.md``, section 10).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from shape_behavior.extension import get_handler
|
|
21
|
+
from shape_behavior.model import EVENT_KINDS, Module, load_module
|
|
22
|
+
|
|
23
|
+
SHAPE_API = "1.0"
|
|
24
|
+
VERSION = "1.0"
|
|
25
|
+
|
|
26
|
+
DEFAULT_POPULATION: dict[str, Any] = {
|
|
27
|
+
"age_at_start": {"kind": "uniform", "low": 0, "high": 90},
|
|
28
|
+
"attributes": {"gender": {"kind": "categorical", "values": {"F": 0.5, "M": 0.5}}},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def population_settings(modules: list[Module]) -> dict[str, Any]:
|
|
33
|
+
"""The population settings the modules ask for (``population_defaults``; the first module
|
|
34
|
+
wins on a clash), or :data:`DEFAULT_POPULATION` when none asks."""
|
|
35
|
+
import copy
|
|
36
|
+
|
|
37
|
+
spec: dict[str, Any] = {}
|
|
38
|
+
for m in reversed(modules):
|
|
39
|
+
spec.update(m.doc.get("population_defaults", {}))
|
|
40
|
+
return spec or copy.deepcopy(DEFAULT_POPULATION)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ModuleBehavior:
|
|
44
|
+
"""One module as a ``shape.behaviors`` plugin: name, version, states, attributes, events."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, module: Module | dict[str, Any] | str, version: str = VERSION) -> None:
|
|
47
|
+
self.module = load_module(module)
|
|
48
|
+
self.name: str = self.module.name
|
|
49
|
+
self.version: str = str(self.module.doc.get("version", version))
|
|
50
|
+
self.states: list[str] = list(self.module.states)
|
|
51
|
+
self.attributes: list[str] = sorted(self.module.attribute_uses())
|
|
52
|
+
self.events: list[str] = _event_kinds(self.module)
|
|
53
|
+
|
|
54
|
+
def simulate(self, population: int, seed: int, years: float) -> Any:
|
|
55
|
+
"""Events of ``population`` entities over ``years`` from 2020-01-01, deterministic per
|
|
56
|
+
``seed`` (an Arrow table in :data:`shape_behavior.EVENT_SCHEMA`)."""
|
|
57
|
+
from shape_behavior.population import Population
|
|
58
|
+
from shape_behavior.simulator import SimConfig, Simulator
|
|
59
|
+
from shape_behavior.timeutil import add_years, to_us
|
|
60
|
+
|
|
61
|
+
start = "2020-01-01"
|
|
62
|
+
pop = Population.from_dict(population_settings([self.module]), size=population, start=start)
|
|
63
|
+
sim = Simulator([self.module], pop, SimConfig(seed=seed))
|
|
64
|
+
return sim.run_until(add_years(to_us(start), years))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def behavior(module: Module | dict[str, Any] | str, version: str = VERSION) -> ModuleBehavior:
|
|
68
|
+
"""A :class:`ModuleBehavior` for a module, a module document or a module file path."""
|
|
69
|
+
return ModuleBehavior(module, version)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _event_kinds(module: Module) -> list[str]:
|
|
73
|
+
kinds: set[str] = set()
|
|
74
|
+
for state in module.states.values():
|
|
75
|
+
kind = state["type"]
|
|
76
|
+
if kind == "event":
|
|
77
|
+
kinds.add(str(state["event"]))
|
|
78
|
+
elif kind in EVENT_KINDS:
|
|
79
|
+
kinds.add(EVENT_KINDS[kind])
|
|
80
|
+
else: # a registered state type may declare the kinds it emits (optional `kinds`)
|
|
81
|
+
declared = getattr(get_handler(kind), "kinds", None)
|
|
82
|
+
if declared is not None:
|
|
83
|
+
kinds.update(declared(state))
|
|
84
|
+
return sorted(kinds)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Subscription(ModuleBehavior):
|
|
88
|
+
"""Subscription lifecycle: free trial, conversion, monthly payments, pause and cancel."""
|
|
89
|
+
|
|
90
|
+
def __init__(self) -> None:
|
|
91
|
+
super().__init__("subscription")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class EquipmentMaintenance(ModuleBehavior):
|
|
95
|
+
"""Equipment maintenance: run, degrade, fail, repair, with scheduled inspections."""
|
|
96
|
+
|
|
97
|
+
def __init__(self) -> None:
|
|
98
|
+
super().__init__("equipment_maintenance")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class HealthcareScreening(ModuleBehavior):
|
|
102
|
+
"""A tiny healthcare example (screening, diagnosis, treatment), written from scratch."""
|
|
103
|
+
|
|
104
|
+
def __init__(self) -> None:
|
|
105
|
+
super().__init__("healthcare_screening")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class EventSequence(ModuleBehavior):
|
|
109
|
+
"""An ordered funnel of named events with a continue probability per step (defaults)."""
|
|
110
|
+
|
|
111
|
+
def __init__(self) -> None:
|
|
112
|
+
from shape_behavior.primitives import event_sequence
|
|
113
|
+
|
|
114
|
+
super().__init__(event_sequence())
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class TelemetrySeries(ModuleBehavior):
|
|
118
|
+
"""A regular reading per device with drift, noise, skipped and stuck readings (defaults)."""
|
|
119
|
+
|
|
120
|
+
def __init__(self) -> None:
|
|
121
|
+
from shape_behavior.primitives import telemetry_series
|
|
122
|
+
|
|
123
|
+
super().__init__(telemetry_series())
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class TransactionStream(ModuleBehavior):
|
|
127
|
+
"""Transactions as a Poisson process per entity, with refunds and reversals (defaults)."""
|
|
128
|
+
|
|
129
|
+
def __init__(self) -> None:
|
|
130
|
+
from shape_behavior.primitives import transaction_stream
|
|
131
|
+
|
|
132
|
+
super().__init__(transaction_stream())
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class FileArrival(ModuleBehavior):
|
|
136
|
+
"""A feed with an expected arrival per schedule slot: on time, late, missing, duplicated."""
|
|
137
|
+
|
|
138
|
+
def __init__(self) -> None:
|
|
139
|
+
from shape_behavior.primitives import file_arrival
|
|
140
|
+
|
|
141
|
+
super().__init__(file_arrival())
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class EntityLifecycle(ModuleBehavior):
|
|
145
|
+
"""Create, update and delete events per entity (change-data-capture style)."""
|
|
146
|
+
|
|
147
|
+
def __init__(self) -> None:
|
|
148
|
+
from shape_behavior.primitives import entity_lifecycle
|
|
149
|
+
|
|
150
|
+
super().__init__(entity_lifecycle())
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""``shape behave``: run, check and import behavior modules.
|
|
2
|
+
|
|
3
|
+
Reference: ``docs/plugins/behavior.md``, section 7.
|
|
4
|
+
|
|
5
|
+
::
|
|
6
|
+
|
|
7
|
+
shape behave run subscription --population 10000 --years 3 --seed 7 -o out/
|
|
8
|
+
shape behave check my_module.json
|
|
9
|
+
shape behave import-gmf downloaded_module.json -o converted.json
|
|
10
|
+
shape behave examples -o modules/
|
|
11
|
+
|
|
12
|
+
Nothing heavy loads at import time: NumPy, Arrow and the simulator load when a command runs.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from shape_behavior.params import (
|
|
24
|
+
PRIMITIVE_NAMES,
|
|
25
|
+
PrimitiveParamsError,
|
|
26
|
+
read_params,
|
|
27
|
+
unknown_primitive,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
SHAPE_API = "1.0"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BehaveCommand:
|
|
34
|
+
"""``shape behave COMMAND ...``."""
|
|
35
|
+
|
|
36
|
+
name = "behave"
|
|
37
|
+
help = "run state-machine behavior modules on a virtual clock (events as Parquet)"
|
|
38
|
+
|
|
39
|
+
def configure(self, parser: Any) -> None:
|
|
40
|
+
sub = parser.add_subparsers(dest="behave_cmd", required=True, metavar="COMMAND")
|
|
41
|
+
run = sub.add_parser("run", help="simulate a population and write the event stream")
|
|
42
|
+
run.add_argument(
|
|
43
|
+
"modules",
|
|
44
|
+
nargs="+",
|
|
45
|
+
metavar="MODULES",
|
|
46
|
+
help="module files (native or GMF JSON) or example names",
|
|
47
|
+
)
|
|
48
|
+
run.add_argument(
|
|
49
|
+
"--population", type=int, required=True, metavar="N", help="number of entities"
|
|
50
|
+
)
|
|
51
|
+
run.add_argument(
|
|
52
|
+
"--years", type=float, required=True, metavar="Y", help="years to simulate"
|
|
53
|
+
)
|
|
54
|
+
run.add_argument("--seed", type=int, default=0, help="random seed (default 0)")
|
|
55
|
+
run.add_argument("-o", "--out", required=True, metavar="OUT", help="output directory")
|
|
56
|
+
run.add_argument(
|
|
57
|
+
"--start", default="2020-01-01", help="virtual clock start (default 2020-01-01)"
|
|
58
|
+
)
|
|
59
|
+
run.add_argument(
|
|
60
|
+
"--population-spec",
|
|
61
|
+
metavar="FILE",
|
|
62
|
+
help="JSON population settings (attributes, age_at_start, arrival, lifetime)",
|
|
63
|
+
)
|
|
64
|
+
run.add_argument(
|
|
65
|
+
"--window-years", type=float, default=1.0, help="years per output file (default 1)"
|
|
66
|
+
)
|
|
67
|
+
run.add_argument(
|
|
68
|
+
"--poll", default="7 days", help="guard polling interval (default '7 days')"
|
|
69
|
+
)
|
|
70
|
+
run.add_argument("--strict", action="store_true", help="fail on unsupported GMF elements")
|
|
71
|
+
run.add_argument(
|
|
72
|
+
"--params",
|
|
73
|
+
metavar="FILE.json",
|
|
74
|
+
help="parameters of a primitive named in MODULES "
|
|
75
|
+
'({"format": "shape-behavior-params", "version": 1, "primitive": NAME, "params": {}})',
|
|
76
|
+
)
|
|
77
|
+
run.set_defaults(fn=_run)
|
|
78
|
+
check = sub.add_parser("check", help="validate modules; exit 1 when one is invalid")
|
|
79
|
+
check.add_argument("modules", nargs="+", metavar="MODULES")
|
|
80
|
+
check.add_argument("--strict", action="store_true", help="fail on unsupported GMF elements")
|
|
81
|
+
check.set_defaults(fn=_check)
|
|
82
|
+
imp = sub.add_parser(
|
|
83
|
+
"import-gmf", help="convert a GMF module and report what is unsupported"
|
|
84
|
+
)
|
|
85
|
+
imp.add_argument("file", help="the GMF JSON file you downloaded")
|
|
86
|
+
imp.add_argument("-o", "--out", metavar="OUT.json", help="write the native module here")
|
|
87
|
+
imp.add_argument("--strict", action="store_true", help="fail on unsupported elements")
|
|
88
|
+
imp.set_defaults(fn=_import)
|
|
89
|
+
ex = sub.add_parser("examples", help="list the built-in example modules, or write them")
|
|
90
|
+
ex.add_argument("-o", "--out", metavar="DIR", help="write the examples into DIR")
|
|
91
|
+
ex.set_defaults(fn=_examples)
|
|
92
|
+
|
|
93
|
+
def run(self, args: Any) -> int:
|
|
94
|
+
try:
|
|
95
|
+
return int(args.fn(args))
|
|
96
|
+
except (FileNotFoundError, json.JSONDecodeError, PrimitiveParamsError) as exc:
|
|
97
|
+
print(f"shape behave: {exc}", file=sys.stderr)
|
|
98
|
+
return 2
|
|
99
|
+
except ValueError as exc: # includes ModuleError, UnsupportedGmfError
|
|
100
|
+
print(f"shape behave: {exc}", file=sys.stderr)
|
|
101
|
+
return 1
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _load(sources: list[str], strict: bool, params: str | None = None) -> list[Any]:
|
|
105
|
+
from shape_behavior.model import load_module
|
|
106
|
+
|
|
107
|
+
primitive, values = read_params(Path(params)) if params else ("", {})
|
|
108
|
+
if params and primitive not in sources:
|
|
109
|
+
if primitive not in PRIMITIVE_NAMES:
|
|
110
|
+
raise unknown_primitive(primitive)
|
|
111
|
+
raise PrimitiveParamsError(
|
|
112
|
+
f"--params is for primitive {primitive!r}, which is not among the modules to run "
|
|
113
|
+
f"({', '.join(sources)})",
|
|
114
|
+
"primitive",
|
|
115
|
+
)
|
|
116
|
+
if params:
|
|
117
|
+
from shape_behavior.primitives import build
|
|
118
|
+
modules = []
|
|
119
|
+
for src in sources:
|
|
120
|
+
module = (
|
|
121
|
+
build(primitive, values)
|
|
122
|
+
if params and src == primitive
|
|
123
|
+
else load_module(src, strict=strict)
|
|
124
|
+
)
|
|
125
|
+
report = module.import_report
|
|
126
|
+
if report is not None and (report.unsupported or report.warnings):
|
|
127
|
+
print(f"{src}:\n{report.report()}", file=sys.stderr)
|
|
128
|
+
modules.append(module)
|
|
129
|
+
return modules
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _run(args: Any) -> int:
|
|
133
|
+
import pyarrow.parquet as pq # type: ignore[import-untyped]
|
|
134
|
+
|
|
135
|
+
from shape_behavior.behaviors import population_settings
|
|
136
|
+
from shape_behavior.population import Population
|
|
137
|
+
from shape_behavior.simulator import SimConfig, Simulator
|
|
138
|
+
from shape_behavior.timeutil import add_years, to_us
|
|
139
|
+
|
|
140
|
+
if args.population < 0 or args.years <= 0 or args.window_years <= 0:
|
|
141
|
+
print(
|
|
142
|
+
"shape behave: --population must be >= 0 and --years and --window-years > 0",
|
|
143
|
+
file=sys.stderr,
|
|
144
|
+
)
|
|
145
|
+
return 2
|
|
146
|
+
modules = _load(args.modules, args.strict, args.params)
|
|
147
|
+
if args.population_spec:
|
|
148
|
+
spec = json.loads(Path(args.population_spec).read_text(encoding="utf-8"))
|
|
149
|
+
else:
|
|
150
|
+
spec = population_settings(modules)
|
|
151
|
+
population = Population.from_dict(spec, size=args.population, start=args.start)
|
|
152
|
+
sim = Simulator(modules, population, SimConfig(seed=args.seed, poll=args.poll))
|
|
153
|
+
out = Path(args.out)
|
|
154
|
+
(out / "events").mkdir(parents=True, exist_ok=True)
|
|
155
|
+
start = to_us(args.start)
|
|
156
|
+
end = add_years(start, args.years)
|
|
157
|
+
started = time.perf_counter()
|
|
158
|
+
windows: list[dict[str, Any]] = []
|
|
159
|
+
cursor, part = start, 0
|
|
160
|
+
while cursor < end:
|
|
161
|
+
nxt = min(add_years(start, args.window_years * (part + 1)), end)
|
|
162
|
+
events = sim.run_until(nxt)
|
|
163
|
+
pq.write_table(events, out / "events" / f"part-{part:04d}.parquet", compression="snappy")
|
|
164
|
+
windows.append(
|
|
165
|
+
{
|
|
166
|
+
"file": f"events/part-{part:04d}.parquet",
|
|
167
|
+
"until": str(sim.now),
|
|
168
|
+
"rows": events.num_rows,
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
cursor, part = nxt, part + 1
|
|
172
|
+
pq.write_table(sim.entities(), out / "entities.parquet", compression="snappy")
|
|
173
|
+
elapsed = time.perf_counter() - started
|
|
174
|
+
total = sum(w["rows"] for w in windows)
|
|
175
|
+
manifest = {
|
|
176
|
+
"seed": args.seed,
|
|
177
|
+
"years": args.years,
|
|
178
|
+
"start": args.start,
|
|
179
|
+
"population": population.to_dict(),
|
|
180
|
+
"modules": [_module_entry(m, s) for m, s in zip(modules, args.modules, strict=True)],
|
|
181
|
+
"events": total,
|
|
182
|
+
"windows": windows,
|
|
183
|
+
"seconds": round(elapsed, 3),
|
|
184
|
+
"unsupported": [
|
|
185
|
+
str(u) for m in modules if m.import_report for u in m.import_report.unsupported
|
|
186
|
+
],
|
|
187
|
+
}
|
|
188
|
+
(out / "run.json").write_text(
|
|
189
|
+
json.dumps(manifest, indent=2) + "\n", encoding="utf-8", newline="\n"
|
|
190
|
+
)
|
|
191
|
+
print(f"{total:,} events for {args.population:,} entities in {elapsed:.1f}s -> {out}")
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _module_entry(module: Any, source: str) -> dict[str, Any]:
|
|
196
|
+
entry: dict[str, Any] = {"name": module.name, "digest": module.digest(), "source": source}
|
|
197
|
+
if "primitive" in module.doc: # a primitive: record the parameters it ran with
|
|
198
|
+
entry["primitive"] = module.doc["primitive"]
|
|
199
|
+
entry["parameters"] = module.doc["parameters"]
|
|
200
|
+
return entry
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _check(args: Any) -> int:
|
|
204
|
+
modules = _load(args.modules, args.strict)
|
|
205
|
+
for m in modules:
|
|
206
|
+
print(f"ok {m.name} ({len(m.states)} states)")
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _import(args: Any) -> int:
|
|
211
|
+
from shape_behavior.gmf import import_gmf
|
|
212
|
+
|
|
213
|
+
result = import_gmf(Path(args.file), strict=args.strict)
|
|
214
|
+
print(result.report())
|
|
215
|
+
if args.out:
|
|
216
|
+
Path(args.out).write_text(
|
|
217
|
+
json.dumps(result.module.to_dict(), indent=2) + "\n", encoding="utf-8", newline="\n"
|
|
218
|
+
)
|
|
219
|
+
print(f"wrote {args.out}")
|
|
220
|
+
return 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _examples(args: Any) -> int:
|
|
224
|
+
import importlib.resources
|
|
225
|
+
|
|
226
|
+
from shape_behavior.model import examples
|
|
227
|
+
|
|
228
|
+
names = examples()
|
|
229
|
+
if not args.out:
|
|
230
|
+
print("\n".join(names))
|
|
231
|
+
return 0
|
|
232
|
+
out = Path(args.out)
|
|
233
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
234
|
+
for name in names:
|
|
235
|
+
text = (
|
|
236
|
+
importlib.resources.files("shape_behavior") / "examples" / f"{name}.json"
|
|
237
|
+
).read_text(encoding="utf-8")
|
|
238
|
+
(out / f"{name}.json").write_text(text, encoding="utf-8", newline="\n")
|
|
239
|
+
print(f"wrote {len(names)} modules to {out}")
|
|
240
|
+
return 0
|