latentplant 0.4.0__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.
Files changed (30) hide show
  1. latentplant-0.4.0/LICENSE +21 -0
  2. latentplant-0.4.0/PKG-INFO +187 -0
  3. latentplant-0.4.0/README.md +158 -0
  4. latentplant-0.4.0/pyproject.toml +47 -0
  5. latentplant-0.4.0/setup.cfg +4 -0
  6. latentplant-0.4.0/src/latentplant/__init__.py +69 -0
  7. latentplant-0.4.0/src/latentplant/calibrate.py +187 -0
  8. latentplant-0.4.0/src/latentplant/data/__init__.py +26 -0
  9. latentplant-0.4.0/src/latentplant/data/contract.py +212 -0
  10. latentplant-0.4.0/src/latentplant/data/windows.py +231 -0
  11. latentplant-0.4.0/src/latentplant/export.py +264 -0
  12. latentplant-0.4.0/src/latentplant/metrics/__init__.py +27 -0
  13. latentplant-0.4.0/src/latentplant/metrics/rollout.py +171 -0
  14. latentplant-0.4.0/src/latentplant/models/__init__.py +7 -0
  15. latentplant-0.4.0/src/latentplant/models/base.py +131 -0
  16. latentplant-0.4.0/src/latentplant/models/ensemble.py +251 -0
  17. latentplant-0.4.0/src/latentplant/models/rssm.py +345 -0
  18. latentplant-0.4.0/src/latentplant/models/rssm_ensemble.py +217 -0
  19. latentplant-0.4.0/src/latentplant/planning.py +294 -0
  20. latentplant-0.4.0/src/latentplant.egg-info/PKG-INFO +187 -0
  21. latentplant-0.4.0/src/latentplant.egg-info/SOURCES.txt +28 -0
  22. latentplant-0.4.0/src/latentplant.egg-info/dependency_links.txt +1 -0
  23. latentplant-0.4.0/src/latentplant.egg-info/requires.txt +14 -0
  24. latentplant-0.4.0/src/latentplant.egg-info/top_level.txt +1 -0
  25. latentplant-0.4.0/tests/test_calibration_and_planning.py +392 -0
  26. latentplant-0.4.0/tests/test_contract_and_windows.py +192 -0
  27. latentplant-0.4.0/tests/test_ensemble_world_model.py +165 -0
  28. latentplant-0.4.0/tests/test_metrics.py +121 -0
  29. latentplant-0.4.0/tests/test_onnx_export.py +149 -0
  30. latentplant-0.4.0/tests/test_rssm.py +204 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Felipe Santibanez-Leal
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,187 @@
1
+ Metadata-Version: 2.4
2
+ Name: latentplant
3
+ Version: 0.4.0
4
+ Summary: Learned world models for industrial process time series: action-conditioned latent dynamics, imagination rollouts with calibrated uncertainty, planning and regime-change detection
5
+ Author-email: Felipe Santibanez-Leal <fsantibanez@gmail.com>
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/fsantibanezleal/CAOS_LatentPlant
8
+ Keywords: world-models,latent-dynamics,model-based-rl,imagination,process-control,time-series,uncertainty-quantification,mineral-processing,rssm,planning
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: numpy>=1.24
18
+ Requires-Dist: torch>=2.2
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=8; extra == "dev"
21
+ Requires-Dist: ruff>=0.4; extra == "dev"
22
+ Provides-Extra: onnx
23
+ Requires-Dist: onnx>=1.16; extra == "onnx"
24
+ Requires-Dist: onnxruntime>=1.18; extra == "onnx"
25
+ Provides-Extra: baselines
26
+ Requires-Dist: sysidentpy>=0.3; extra == "baselines"
27
+ Requires-Dist: pysindy>=1.7; extra == "baselines"
28
+ Dynamic: license-file
29
+
30
+ # latentplant
31
+
32
+ [![ci](https://github.com/fsantibanezleal/CAOS_LatentPlant/actions/workflows/ci.yml/badge.svg)](https://github.com/fsantibanezleal/CAOS_LatentPlant/actions/workflows/ci.yml)
33
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
34
+
35
+ **Learned world models for industrial process time series.** An action-conditioned model of
36
+ process dynamics that can be rolled forward in imagination, carries calibrated uncertainty, and
37
+ supports what-if queries, planning and regime-change detection.
38
+
39
+ ## What a world model is here, and why it is not a forecaster
40
+
41
+ A forecaster maps history to a future. A world model maps history **and a proposed action
42
+ sequence** to a distribution over futures:
43
+
44
+ ```python
45
+ rollout(obs_ctx, act_ctx, exo_ctx, act_fut, exo_fut, n_samples=64)
46
+ ```
47
+
48
+ `act_fut` is an argument. That single difference is what makes counterfactual questions
49
+ expressible ("what if we raise the amine dose for the next four hours") and what makes planning
50
+ possible at all. A model that ignores its action input is a forecaster wearing a costume, so the
51
+ test suite asserts direction-of-effect on a system with known gain.
52
+
53
+ ## The disciplines baked in
54
+
55
+ **Rollout-first evaluation.** One-step teacher-forced error is not a world-model metric: on any
56
+ autocorrelated process series it flatters a model that has learned nothing. Everything in
57
+ `latentplant.metrics` is indexed by horizon, and `break_even_horizon` reports where the model
58
+ stops beating persistence. That integer belongs next to every headline number.
59
+
60
+ **Two uncertainties, reported separately.** `uncertainty_split` returns aleatoric (process and
61
+ sensor noise, irreducible) and epistemic (the model does not know this region) as different
62
+ arrays. A noisy tag and an unfamiliar operating point look identical in a single band and mean
63
+ completely different things; the epistemic band is what a regime-change alarm should watch.
64
+
65
+ **Cadence is declared, not guessed.** An hourly lab assay carried on 20-second rows repeats about
66
+ 180 times. A model trained without masking those repeats learns to copy the previous row and
67
+ scores beautifully. `PlantSchema` takes `update_seconds` per tag and `stale_mask` marks the
68
+ repeats so the loss can ignore them.
69
+
70
+ **Leakage-safe by construction.** Episodes never cross an acquisition gap, windows never cross an
71
+ episode, splits are chronological, and standardization is fitted on train only.
72
+
73
+ ## Install
74
+
75
+ ```bash
76
+ pip install latentplant # core: numpy + torch
77
+ pip install "latentplant[onnx]" # ONNX export for a browser inference lane
78
+ pip install "latentplant[baselines]" # sysidentpy / pysindy baseline adapters
79
+ ```
80
+
81
+ ## Quick start
82
+
83
+ ```python
84
+ import numpy as np
85
+ from latentplant import (PlantSchema, TagSpec, ProbabilisticEnsemble,
86
+ split_on_gaps, make_windows, chronological_split)
87
+ from latentplant.metrics import rollout_report
88
+
89
+ schema = PlantSchema(
90
+ tags=(
91
+ TagSpec("silica_conc", "target", "pct", 0.0, 10.0, update_seconds=3600.0),
92
+ TagSpec("pulp_level", "observation", "pct", 0.0, 100.0),
93
+ TagSpec("amina_flow", "action", "m3/h", 0.0, 800.0),
94
+ TagSpec("iron_feed", "exogenous", "pct"),
95
+ ),
96
+ row_seconds=20.0,
97
+ )
98
+
99
+ episodes = split_on_gaps(schema, values, timestamps) # a plant stop ends an episode
100
+ windows = make_windows(schema, episodes, context=24, horizon=12)
101
+ train, val, test = chronological_split(windows)
102
+
103
+ wm = ProbabilisticEnsemble(n_obs=2, n_act=1, n_exo=1, n_members=5)
104
+ wm.fit(train, epochs=60, seed=0)
105
+
106
+ roll = wm.rollout(test.obs_ctx, test.act_ctx, test.exo_ctx,
107
+ test.act_fut, test.exo_fut, n_samples=64, seed=0)
108
+ lo, hi = roll.interval(0.9)
109
+ print(rollout_report(roll.samples, test.obs_fut, test.obs_ctx, test.mask_fut))
110
+ ```
111
+
112
+ Counterfactual A/B from the same anchor:
113
+
114
+ ```python
115
+ more, less = wm.counterfactual(ctx_obs, ctx_act, ctx_exo, action_a, action_b, exo_fut)
116
+ lift = more.mean - less.mean
117
+ ```
118
+
119
+ On real plant data this supports direction-of-effect claims only: logged actions come from a
120
+ closed loop, so observational data does not identify interventions without assumptions. On a
121
+ simulator the ground truth exists and the imagination-to-reality gap is measured instead.
122
+
123
+ Plan against the model, and measure what the plan was worth:
124
+
125
+ ```python
126
+ from latentplant import Planner, cvar_cost, target_tracking_cost, imagination_gap
127
+
128
+ planner = Planner(model=wm, bounds=action_bounds, horizon=24, method="cem")
129
+ plan = planner.plan(obs_ctx[:1], act_ctx[:1], exo_ctx[:1], exo_fut[:1],
130
+ cvar_cost(target_tracking_cost(target), alpha=0.2),
131
+ action_rate_penalty=2.0)
132
+
133
+ realized = env.execute(plan.actions) # only possible where truth exists
134
+ print(imagination_gap(plan, realized, persistence_from=obs_ctx[0]))
135
+ ```
136
+
137
+ Fix an overconfident model's intervals without retraining it:
138
+
139
+ ```python
140
+ from latentplant import fit_conformal, calibration_report
141
+
142
+ conf = fit_conformal(cal_roll, cal.obs_fut, level=0.9, mask=cal.mask_fut)
143
+ print(calibration_report(conf, test_roll, test.obs_fut, mask=test.mask_fut))
144
+ ```
145
+
146
+ ## Documentation
147
+
148
+ Full docs in [`docs/`](docs/README.md): concepts (what a world model is, the plant contract,
149
+ windows and leakage), models (probabilistic ensemble, RSSM, ensembled RSSM), evaluation (rollout
150
+ metrics, conformal calibration) and planning (imagination, the imagination-to-reality gap).
151
+
152
+ ## Status
153
+
154
+ 0.03.000, alpha. Shipping now: the ingestion contract with the multi-rate observation operator,
155
+ episodes/windows/leakage-safe splits, the probabilistic ensemble (PETS-class, TS-inf
156
+ propagation), the vector RSSM, the ensembled RSSM with the aleatoric/epistemic split, the
157
+ rollout metric suite, split-conformal interval calibration, and CEM/MPPI planning with the
158
+ imagination-to-reality gap. On the roadmap: ONNX export for a browser inference lane, a
159
+ Gymnasium imagination env, and baseline adapters.
160
+
161
+ ## Not to be confused with
162
+
163
+ [`phenoforge`](https://pypi.org/project/phenoforge/), the sibling engine, which fits and
164
+ ensembles **closed-form phenomenological equations**. `latentplant` **learns latent dynamics from
165
+ data**. Same industry, different object.
166
+
167
+ ## References
168
+
169
+ The design follows: Chua et al. 2018, *Deep RL in a Handful of Trials with Probabilistic Dynamics
170
+ Models*, [arXiv:1805.12114](https://arxiv.org/abs/1805.12114) (probabilistic ensembles, TS-inf);
171
+ Lakshminarayanan et al. 2017, *Simple and Scalable Predictive Uncertainty Estimation using Deep
172
+ Ensembles*, [arXiv:1612.01474](https://arxiv.org/abs/1612.01474); Hafner et al. 2019, *Learning
173
+ Latent Dynamics for Planning from Pixels*, [arXiv:1811.04551](https://arxiv.org/abs/1811.04551)
174
+ (RSSM); Hafner et al. 2023, *Mastering Diverse Domains through World Models*,
175
+ [arXiv:2301.04104](https://arxiv.org/abs/2301.04104); Janner et al. 2019, *When to Trust Your
176
+ Model*, [arXiv:1906.08253](https://arxiv.org/abs/1906.08253) (rollout horizon and compounding
177
+ error); Che et al. 2016, *Recurrent Neural Networks for Multivariate Time Series with Missing
178
+ Values*, [arXiv:1606.01865](https://arxiv.org/abs/1606.01865) (cadence and masking); Romano et
179
+ al. 2019, *Conformalized Quantile Regression*,
180
+ [arXiv:1905.03222](https://arxiv.org/abs/1905.03222) (interval calibration); Williams et al.
181
+ 2017, *Information Theoretic Model Predictive Control*,
182
+ [arXiv:1707.02342](https://arxiv.org/abs/1707.02342) (MPPI); Levine et al. 2020, *Offline
183
+ Reinforcement Learning: Tutorial, Review, and Perspectives*,
184
+ [arXiv:2005.01643](https://arxiv.org/abs/2005.01643) (why the planner refuses to issue
185
+ setpoints).
186
+
187
+ Developed by Felipe Santibanez-Leal. MIT licensed.
@@ -0,0 +1,158 @@
1
+ # latentplant
2
+
3
+ [![ci](https://github.com/fsantibanezleal/CAOS_LatentPlant/actions/workflows/ci.yml/badge.svg)](https://github.com/fsantibanezleal/CAOS_LatentPlant/actions/workflows/ci.yml)
4
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+
6
+ **Learned world models for industrial process time series.** An action-conditioned model of
7
+ process dynamics that can be rolled forward in imagination, carries calibrated uncertainty, and
8
+ supports what-if queries, planning and regime-change detection.
9
+
10
+ ## What a world model is here, and why it is not a forecaster
11
+
12
+ A forecaster maps history to a future. A world model maps history **and a proposed action
13
+ sequence** to a distribution over futures:
14
+
15
+ ```python
16
+ rollout(obs_ctx, act_ctx, exo_ctx, act_fut, exo_fut, n_samples=64)
17
+ ```
18
+
19
+ `act_fut` is an argument. That single difference is what makes counterfactual questions
20
+ expressible ("what if we raise the amine dose for the next four hours") and what makes planning
21
+ possible at all. A model that ignores its action input is a forecaster wearing a costume, so the
22
+ test suite asserts direction-of-effect on a system with known gain.
23
+
24
+ ## The disciplines baked in
25
+
26
+ **Rollout-first evaluation.** One-step teacher-forced error is not a world-model metric: on any
27
+ autocorrelated process series it flatters a model that has learned nothing. Everything in
28
+ `latentplant.metrics` is indexed by horizon, and `break_even_horizon` reports where the model
29
+ stops beating persistence. That integer belongs next to every headline number.
30
+
31
+ **Two uncertainties, reported separately.** `uncertainty_split` returns aleatoric (process and
32
+ sensor noise, irreducible) and epistemic (the model does not know this region) as different
33
+ arrays. A noisy tag and an unfamiliar operating point look identical in a single band and mean
34
+ completely different things; the epistemic band is what a regime-change alarm should watch.
35
+
36
+ **Cadence is declared, not guessed.** An hourly lab assay carried on 20-second rows repeats about
37
+ 180 times. A model trained without masking those repeats learns to copy the previous row and
38
+ scores beautifully. `PlantSchema` takes `update_seconds` per tag and `stale_mask` marks the
39
+ repeats so the loss can ignore them.
40
+
41
+ **Leakage-safe by construction.** Episodes never cross an acquisition gap, windows never cross an
42
+ episode, splits are chronological, and standardization is fitted on train only.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install latentplant # core: numpy + torch
48
+ pip install "latentplant[onnx]" # ONNX export for a browser inference lane
49
+ pip install "latentplant[baselines]" # sysidentpy / pysindy baseline adapters
50
+ ```
51
+
52
+ ## Quick start
53
+
54
+ ```python
55
+ import numpy as np
56
+ from latentplant import (PlantSchema, TagSpec, ProbabilisticEnsemble,
57
+ split_on_gaps, make_windows, chronological_split)
58
+ from latentplant.metrics import rollout_report
59
+
60
+ schema = PlantSchema(
61
+ tags=(
62
+ TagSpec("silica_conc", "target", "pct", 0.0, 10.0, update_seconds=3600.0),
63
+ TagSpec("pulp_level", "observation", "pct", 0.0, 100.0),
64
+ TagSpec("amina_flow", "action", "m3/h", 0.0, 800.0),
65
+ TagSpec("iron_feed", "exogenous", "pct"),
66
+ ),
67
+ row_seconds=20.0,
68
+ )
69
+
70
+ episodes = split_on_gaps(schema, values, timestamps) # a plant stop ends an episode
71
+ windows = make_windows(schema, episodes, context=24, horizon=12)
72
+ train, val, test = chronological_split(windows)
73
+
74
+ wm = ProbabilisticEnsemble(n_obs=2, n_act=1, n_exo=1, n_members=5)
75
+ wm.fit(train, epochs=60, seed=0)
76
+
77
+ roll = wm.rollout(test.obs_ctx, test.act_ctx, test.exo_ctx,
78
+ test.act_fut, test.exo_fut, n_samples=64, seed=0)
79
+ lo, hi = roll.interval(0.9)
80
+ print(rollout_report(roll.samples, test.obs_fut, test.obs_ctx, test.mask_fut))
81
+ ```
82
+
83
+ Counterfactual A/B from the same anchor:
84
+
85
+ ```python
86
+ more, less = wm.counterfactual(ctx_obs, ctx_act, ctx_exo, action_a, action_b, exo_fut)
87
+ lift = more.mean - less.mean
88
+ ```
89
+
90
+ On real plant data this supports direction-of-effect claims only: logged actions come from a
91
+ closed loop, so observational data does not identify interventions without assumptions. On a
92
+ simulator the ground truth exists and the imagination-to-reality gap is measured instead.
93
+
94
+ Plan against the model, and measure what the plan was worth:
95
+
96
+ ```python
97
+ from latentplant import Planner, cvar_cost, target_tracking_cost, imagination_gap
98
+
99
+ planner = Planner(model=wm, bounds=action_bounds, horizon=24, method="cem")
100
+ plan = planner.plan(obs_ctx[:1], act_ctx[:1], exo_ctx[:1], exo_fut[:1],
101
+ cvar_cost(target_tracking_cost(target), alpha=0.2),
102
+ action_rate_penalty=2.0)
103
+
104
+ realized = env.execute(plan.actions) # only possible where truth exists
105
+ print(imagination_gap(plan, realized, persistence_from=obs_ctx[0]))
106
+ ```
107
+
108
+ Fix an overconfident model's intervals without retraining it:
109
+
110
+ ```python
111
+ from latentplant import fit_conformal, calibration_report
112
+
113
+ conf = fit_conformal(cal_roll, cal.obs_fut, level=0.9, mask=cal.mask_fut)
114
+ print(calibration_report(conf, test_roll, test.obs_fut, mask=test.mask_fut))
115
+ ```
116
+
117
+ ## Documentation
118
+
119
+ Full docs in [`docs/`](docs/README.md): concepts (what a world model is, the plant contract,
120
+ windows and leakage), models (probabilistic ensemble, RSSM, ensembled RSSM), evaluation (rollout
121
+ metrics, conformal calibration) and planning (imagination, the imagination-to-reality gap).
122
+
123
+ ## Status
124
+
125
+ 0.03.000, alpha. Shipping now: the ingestion contract with the multi-rate observation operator,
126
+ episodes/windows/leakage-safe splits, the probabilistic ensemble (PETS-class, TS-inf
127
+ propagation), the vector RSSM, the ensembled RSSM with the aleatoric/epistemic split, the
128
+ rollout metric suite, split-conformal interval calibration, and CEM/MPPI planning with the
129
+ imagination-to-reality gap. On the roadmap: ONNX export for a browser inference lane, a
130
+ Gymnasium imagination env, and baseline adapters.
131
+
132
+ ## Not to be confused with
133
+
134
+ [`phenoforge`](https://pypi.org/project/phenoforge/), the sibling engine, which fits and
135
+ ensembles **closed-form phenomenological equations**. `latentplant` **learns latent dynamics from
136
+ data**. Same industry, different object.
137
+
138
+ ## References
139
+
140
+ The design follows: Chua et al. 2018, *Deep RL in a Handful of Trials with Probabilistic Dynamics
141
+ Models*, [arXiv:1805.12114](https://arxiv.org/abs/1805.12114) (probabilistic ensembles, TS-inf);
142
+ Lakshminarayanan et al. 2017, *Simple and Scalable Predictive Uncertainty Estimation using Deep
143
+ Ensembles*, [arXiv:1612.01474](https://arxiv.org/abs/1612.01474); Hafner et al. 2019, *Learning
144
+ Latent Dynamics for Planning from Pixels*, [arXiv:1811.04551](https://arxiv.org/abs/1811.04551)
145
+ (RSSM); Hafner et al. 2023, *Mastering Diverse Domains through World Models*,
146
+ [arXiv:2301.04104](https://arxiv.org/abs/2301.04104); Janner et al. 2019, *When to Trust Your
147
+ Model*, [arXiv:1906.08253](https://arxiv.org/abs/1906.08253) (rollout horizon and compounding
148
+ error); Che et al. 2016, *Recurrent Neural Networks for Multivariate Time Series with Missing
149
+ Values*, [arXiv:1606.01865](https://arxiv.org/abs/1606.01865) (cadence and masking); Romano et
150
+ al. 2019, *Conformalized Quantile Regression*,
151
+ [arXiv:1905.03222](https://arxiv.org/abs/1905.03222) (interval calibration); Williams et al.
152
+ 2017, *Information Theoretic Model Predictive Control*,
153
+ [arXiv:1707.02342](https://arxiv.org/abs/1707.02342) (MPPI); Levine et al. 2020, *Offline
154
+ Reinforcement Learning: Tutorial, Review, and Perspectives*,
155
+ [arXiv:2005.01643](https://arxiv.org/abs/2005.01643) (why the planner refuses to issue
156
+ setpoints).
157
+
158
+ Developed by Felipe Santibanez-Leal. MIT licensed.
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "latentplant"
7
+ version = "0.4.0"
8
+ description = "Learned world models for industrial process time series: action-conditioned latent dynamics, imagination rollouts with calibrated uncertainty, planning and regime-change detection"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Felipe Santibanez-Leal", email = "fsantibanez@gmail.com" }]
12
+ requires-python = ">=3.10"
13
+ dependencies = ["numpy>=1.24", "torch>=2.2"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Science/Research",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Scientific/Engineering",
20
+ ]
21
+ keywords = [
22
+ "world-models", "latent-dynamics", "model-based-rl", "imagination",
23
+ "process-control", "time-series", "uncertainty-quantification",
24
+ "mineral-processing", "rssm", "planning",
25
+ ]
26
+
27
+ [project.urls]
28
+ Repository = "https://github.com/fsantibanezleal/CAOS_LatentPlant"
29
+
30
+ [project.optional-dependencies]
31
+ dev = ["pytest>=8", "ruff>=0.4"]
32
+ onnx = ["onnx>=1.16", "onnxruntime>=1.18"]
33
+ baselines = ["sysidentpy>=0.3", "pysindy>=1.7"]
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
40
+ markers = ["slow: long-running training comparisons (deselect with -m 'not slow')"]
41
+
42
+ [tool.ruff]
43
+ line-length = 100
44
+ target-version = "py310"
45
+
46
+ [tool.ruff.lint]
47
+ select = ["E", "F", "W", "I", "UP", "B"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,69 @@
1
+ """latentplant: learned world models for industrial process time series.
2
+
3
+ An action-conditioned model of process dynamics that can be rolled forward in
4
+ imagination, carries calibrated uncertainty, and supports what-if queries,
5
+ planning and regime-change detection.
6
+
7
+ This package is the engine. It is deliberately domain-agnostic: it knows about
8
+ observations, actions, exogenous inputs and cadence, not about flotation. The
9
+ mining instantiation lives in the product that consumes it (Porvenir).
10
+
11
+ Sibling engine, different concept: `phenoforge` fits and ensembles CLOSED-FORM
12
+ phenomenological equations. `latentplant` learns latent dynamics from data. They
13
+ share a domain and nothing else.
14
+ """
15
+
16
+ from .calibrate import (
17
+ ConformalIntervals,
18
+ calibration_report,
19
+ coverage_per_horizon,
20
+ fit_conformal,
21
+ )
22
+ from .data.contract import PlantSchema, TagSpec, validate
23
+ from .data.windows import (
24
+ Episode,
25
+ Standardizer,
26
+ WindowSet,
27
+ chronological_split,
28
+ episodes_from_blocks,
29
+ make_windows,
30
+ split_on_gaps,
31
+ )
32
+ from .export import export_rssm, onnx_rollout
33
+ from .models.base import Rollout, WorldModel
34
+ from .models.ensemble import ProbabilisticEnsemble
35
+ from .models.rssm import RSSM
36
+ from .models.rssm_ensemble import RSSMEnsemble
37
+ from .planning import Plan, Planner, cvar_cost, imagination_gap, target_tracking_cost
38
+
39
+ __version__ = "0.04.000"
40
+
41
+ __all__ = [
42
+ "ConformalIntervals",
43
+ "Episode",
44
+ "Plan",
45
+ "Planner",
46
+ "PlantSchema",
47
+ "ProbabilisticEnsemble",
48
+ "RSSM",
49
+ "RSSMEnsemble",
50
+ "Rollout",
51
+ "Standardizer",
52
+ "TagSpec",
53
+ "WindowSet",
54
+ "WorldModel",
55
+ "__version__",
56
+ "calibration_report",
57
+ "chronological_split",
58
+ "coverage_per_horizon",
59
+ "cvar_cost",
60
+ "episodes_from_blocks",
61
+ "export_rssm",
62
+ "fit_conformal",
63
+ "imagination_gap",
64
+ "make_windows",
65
+ "onnx_rollout",
66
+ "split_on_gaps",
67
+ "target_tracking_cost",
68
+ "validate",
69
+ ]
@@ -0,0 +1,187 @@
1
+ """Split-conformal calibration of rollout intervals.
2
+
3
+ This module exists because of a measured failure, not because calibration is a
4
+ nice property to have. On the thickener PDE case the RSSM produced by far the
5
+ best POINT accuracy of any rung (37.5 percent better than persistence where the
6
+ others managed about 5) and simultaneously the worst calibration: its 90 percent
7
+ intervals covered 67.2 percent of outcomes. A model that is right about the
8
+ trajectory and wrong about its own confidence is dangerous in exactly the way a
9
+ process engineer cannot afford, because the interval is what decides whether the
10
+ advice gets acted on.
11
+
12
+ The method is conformalized quantile regression (Romano, Patterson and Candes,
13
+ "Conformalized Quantile Regression", arXiv:1905.03222), applied per horizon step
14
+ and per channel. The model's own predictive interval is taken as the starting
15
+ point and widened by a constant learned on a held-out calibration split:
16
+
17
+ E_i = max(lo_i - y_i, y_i - hi_i) nonconformity, signed to be
18
+ negative inside the interval
19
+ q = ceil((n+1)(1-alpha)) / n empirical quantile of E
20
+ [lo - q, hi + q] the calibrated interval
21
+
22
+ Per horizon step matters: compounding error means step 24 needs a wider
23
+ correction than step 1, and a single scalar would over-widen the near horizon to
24
+ fix the far one. Per channel matters for the same reason across units.
25
+
26
+ THE HONEST CAVEAT, which is stated here and must be repeated wherever these
27
+ intervals are shown: split conformal prediction guarantees marginal coverage
28
+ under EXCHANGEABILITY, and time series windows are not exchangeable. Overlapping
29
+ windows share rows, and process regimes drift, so the finite-sample guarantee
30
+ does not transfer. What this module actually delivers is a principled,
31
+ distribution-free WIDENING RULE fitted on data the model never trained on. Its
32
+ coverage is therefore something to MEASURE on a separate test split, never to
33
+ assert from the theorem. `calibration_report` measures exactly that, and it
34
+ reports the uncalibrated coverage next to the calibrated one so the correction
35
+ can be seen rather than trusted.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ from dataclasses import dataclass
41
+
42
+ import numpy as np
43
+
44
+ from .models.base import Rollout
45
+
46
+
47
+ @dataclass
48
+ class ConformalIntervals:
49
+ """A fitted per-horizon, per-channel widening rule.
50
+
51
+ width : (horizon, n_obs) additive correction applied to both interval ends
52
+ level : the nominal coverage this was fitted for
53
+ """
54
+
55
+ width: np.ndarray
56
+ level: float
57
+ n_calibration: int
58
+
59
+ def apply(self, roll: Rollout) -> tuple[np.ndarray, np.ndarray]:
60
+ """Correct a rollout's own interval into the calibrated one.
61
+
62
+ The correction can be NEGATIVE, which tightens rather than widens. That
63
+ is deliberate: a model whose intervals are too wide should have them
64
+ narrowed, and a rule that could only widen would be a one-way ratchet
65
+ that eventually makes every interval useless. The only thing enforced is
66
+ that the interval does not invert.
67
+ """
68
+ lo, hi = roll.interval(self.level)
69
+ if lo.shape[1:] != self.width.shape:
70
+ raise ValueError(
71
+ f"calibration was fitted for horizon/channels {self.width.shape} "
72
+ f"but this rollout is {lo.shape[1:]}"
73
+ )
74
+ new_lo, new_hi = lo - self.width, hi + self.width
75
+ mid = (lo + hi) / 2.0
76
+ inverted = new_lo > new_hi
77
+ return np.where(inverted, mid, new_lo), np.where(inverted, mid, new_hi)
78
+
79
+
80
+ def fit_conformal(
81
+ roll: Rollout,
82
+ obs_fut: np.ndarray,
83
+ *,
84
+ level: float = 0.9,
85
+ mask: np.ndarray | None = None,
86
+ ) -> ConformalIntervals:
87
+ """Fit the widening rule on a CALIBRATION split the model never trained on.
88
+
89
+ `mask` is the multi-rate freshness mask: on a case where the target is an
90
+ hourly assay repeated across minute rows, calibrating against the repeats
91
+ would measure how well the model copies a held value, which is not the
92
+ quantity anyone wants a confidence interval on. Stale entries are excluded
93
+ from the quantile.
94
+ """
95
+ y = np.asarray(obs_fut, dtype=float)
96
+ lo, hi = roll.interval(level)
97
+ scores = np.maximum(lo - y, y - hi) # (n, horizon, n_obs)
98
+
99
+ horizon, n_obs = y.shape[1], y.shape[2]
100
+ width = np.zeros((horizon, n_obs))
101
+ kept = 0
102
+ for h in range(horizon):
103
+ for c in range(n_obs):
104
+ s = scores[:, h, c]
105
+ if mask is not None:
106
+ s = s[np.asarray(mask, dtype=bool)[:, h, c]]
107
+ s = s[np.isfinite(s)]
108
+ if s.size == 0:
109
+ # nothing fresh at this horizon and channel: widening by zero is
110
+ # the only honest choice, and the empty count is reported so the
111
+ # caller can see the rule was never actually fitted here
112
+ continue
113
+ kept = max(kept, int(s.size))
114
+ # the finite-sample conformal quantile, which is slightly above the
115
+ # plain empirical one and is what makes the rule conservative rather
116
+ # than exactly-nominal on the calibration set itself
117
+ rank = min(1.0, np.ceil((s.size + 1) * level) / s.size)
118
+ # NOT clamped at zero: a negative quantile means the model's own
119
+ # interval was already wider than it needed to be, and tightening it
120
+ # is the correct response. Clamping would make this a one-way
121
+ # widener and would over-cover on every well-calibrated rung.
122
+ width[h, c] = float(np.quantile(s, rank))
123
+
124
+ return ConformalIntervals(width=width, level=level, n_calibration=kept)
125
+
126
+
127
+ def coverage_per_horizon(
128
+ lo: np.ndarray, hi: np.ndarray, obs_fut: np.ndarray, mask: np.ndarray | None = None
129
+ ) -> np.ndarray:
130
+ """Empirical coverage at each horizon step, averaged over windows and channels."""
131
+ y = np.asarray(obs_fut, dtype=float)
132
+ inside = (y >= lo) & (y <= hi)
133
+ if mask is None:
134
+ return inside.mean(axis=(0, 2))
135
+ m = np.asarray(mask, dtype=bool)
136
+ num = (inside & m).sum(axis=(0, 2))
137
+ den = np.maximum(m.sum(axis=(0, 2)), 1)
138
+ return num / den
139
+
140
+
141
+ def mean_interval_width(lo: np.ndarray, hi: np.ndarray) -> np.ndarray:
142
+ """Average interval width at each horizon step.
143
+
144
+ Reported alongside coverage because widening always buys coverage: an
145
+ infinitely wide interval covers everything and says nothing. The pair
146
+ (coverage, width) is the only honest way to show a calibration worked.
147
+ """
148
+ return (hi - lo).mean(axis=(0, 2))
149
+
150
+
151
+ def calibration_report(
152
+ conformal: ConformalIntervals,
153
+ roll: Rollout,
154
+ obs_fut: np.ndarray,
155
+ *,
156
+ mask: np.ndarray | None = None,
157
+ ) -> dict:
158
+ """Measure calibrated versus uncalibrated coverage on a TEST split.
159
+
160
+ Both are reported. The uncalibrated number is what the model believed and
161
+ the calibrated number is what the correction achieved, and showing only the
162
+ second would hide the size of the problem being corrected.
163
+ """
164
+ raw_lo, raw_hi = roll.interval(conformal.level)
165
+ cal_lo, cal_hi = conformal.apply(roll)
166
+
167
+ raw_cov = coverage_per_horizon(raw_lo, raw_hi, obs_fut, mask)
168
+ cal_cov = coverage_per_horizon(cal_lo, cal_hi, obs_fut, mask)
169
+ return {
170
+ "level": conformal.level,
171
+ "n_calibration": conformal.n_calibration,
172
+ "raw_coverage_per_horizon": raw_cov.tolist(),
173
+ "calibrated_coverage_per_horizon": cal_cov.tolist(),
174
+ "raw_coverage": float(raw_cov.mean()),
175
+ "calibrated_coverage": float(cal_cov.mean()),
176
+ "raw_width_per_horizon": mean_interval_width(raw_lo, raw_hi).tolist(),
177
+ "calibrated_width_per_horizon": mean_interval_width(cal_lo, cal_hi).tolist(),
178
+ "width_inflation": float(
179
+ mean_interval_width(cal_lo, cal_hi).mean()
180
+ / max(mean_interval_width(raw_lo, raw_hi).mean(), 1e-12)
181
+ ),
182
+ "caveat": (
183
+ "split conformal guarantees marginal coverage under exchangeability, "
184
+ "which time-series windows violate; these coverages are MEASURED on a "
185
+ "held-out split, not implied by the theorem"
186
+ ),
187
+ }