bricklogger 0.2.3__tar.gz → 0.2.4__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 (168) hide show
  1. {bricklogger-0.2.3 → bricklogger-0.2.4}/PKG-INFO +2 -2
  2. {bricklogger-0.2.3 → bricklogger-0.2.4}/README.md +1 -1
  3. {bricklogger-0.2.3 → bricklogger-0.2.4}/pyproject.toml +1 -1
  4. {bricklogger-0.2.3 → bricklogger-0.2.4}/pyproject.toml.orig +1 -1
  5. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/__init__.py +1 -1
  6. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/cli/model.py +24 -3
  7. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/cli/plugins.py +1 -0
  8. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/cli/roles.py +12 -1
  9. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/daemon/api.py +12 -4
  10. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/daemon/core.py +139 -10
  11. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/daemon/instances.py +25 -1
  12. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/daemon/plugins.py +1 -0
  13. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/daemon/sparql.py +1 -1
  14. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/daemon/spool.py +17 -9
  15. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/docs/architecture.md +44 -6
  16. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/docs/docker.md +5 -5
  17. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/docs/features/api.md +7 -4
  18. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/docs/features/cli.md +16 -14
  19. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/docs/features/daemon.md +5 -4
  20. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/docs/features/destinations.md +77 -0
  21. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/docs/features/mcp.md +5 -3
  22. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/docs/features/plugins.md +14 -3
  23. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/docs/features/web.md +4 -3
  24. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/docs/getting-started.md +1 -1
  25. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/mcp/http.py +5 -1
  26. bricklogger-0.2.4/src/bricklogger/model/timeseries.py +55 -0
  27. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/model/working_graph.py +40 -7
  28. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/ops/status.py +51 -13
  29. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/plugins/timescaledb/declaration.py +3 -1
  30. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/plugins/timescaledb/destination.py +56 -4
  31. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/sdk/__init__.py +7 -3
  32. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/sdk/contract.py +45 -0
  33. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/sdk/declaration.py +1 -0
  34. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/app.py +6 -3
  35. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/partials/instances.html +1 -0
  36. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/texts.py +2 -0
  37. {bricklogger-0.2.3 → bricklogger-0.2.4}/LICENSE +0 -0
  38. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/__main__.py +0 -0
  39. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/cli/__init__.py +0 -0
  40. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/cli/client.py +0 -0
  41. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/cli/config.py +0 -0
  42. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/cli/context.py +0 -0
  43. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/cli/daemon_commands.py +0 -0
  44. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/cli/guided.py +0 -0
  45. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/cli/init.py +0 -0
  46. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/cli/mcp_command.py +0 -0
  47. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/cli/notify.py +0 -0
  48. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/cli/output.py +0 -0
  49. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/cli/query.py +0 -0
  50. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/cli/serve.py +0 -0
  51. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/cli/services.py +0 -0
  52. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/cli/update.py +0 -0
  53. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/config/__init__.py +0 -0
  54. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/config/daemon.py +0 -0
  55. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/config/examples.py +0 -0
  56. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/config/instances.py +0 -0
  57. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/config/issues.py +0 -0
  58. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/config/loader.py +0 -0
  59. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/config/schema.py +0 -0
  60. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/config/validation.py +0 -0
  61. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/config/values.py +0 -0
  62. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/daemon/__init__.py +0 -0
  63. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/daemon/codes.py +0 -0
  64. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/daemon/explorer.py +0 -0
  65. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/daemon/jobs.py +0 -0
  66. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/daemon/logging.py +0 -0
  67. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/daemon/metadata.py +0 -0
  68. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/daemon/notify.py +0 -0
  69. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/daemon/overlay.py +0 -0
  70. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/daemon/plan.py +0 -0
  71. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/daemon/rules.py +0 -0
  72. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/daemon/run.py +0 -0
  73. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/daemon/sink.py +0 -0
  74. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/daemon/state.py +0 -0
  75. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/daemon/updates.py +0 -0
  76. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/docs/README.md +0 -0
  77. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/docs/features/configuration.md +0 -0
  78. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/docs/features/notifications.md +0 -0
  79. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/docs/features/sources.md +0 -0
  80. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/docs/vision.md +0 -0
  81. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/mcp/__init__.py +0 -0
  82. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/mcp/instructions.py +0 -0
  83. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/mcp/server.py +0 -0
  84. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/model/Brick.ttl +0 -0
  85. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/model/LICENSE-Brick.txt +0 -0
  86. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/model/__init__.py +0 -0
  87. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/model/diff.py +0 -0
  88. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/model/inference.py +0 -0
  89. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/model/ontology.py +0 -0
  90. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/model/prefixes.py +0 -0
  91. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/model/shapes.ttl +0 -0
  92. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/model/upload.py +0 -0
  93. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/model/versions.py +0 -0
  94. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/ops/__init__.py +0 -0
  95. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/ops/catalogue.py +0 -0
  96. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/ops/client.py +0 -0
  97. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/ops/config.py +0 -0
  98. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/ops/context.py +0 -0
  99. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/ops/docs.py +0 -0
  100. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/ops/environment.py +0 -0
  101. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/ops/errors.py +0 -0
  102. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/ops/plugin_volume.py +0 -0
  103. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/ops/services.py +0 -0
  104. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/ops/tools.py +0 -0
  105. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/ops/tree.py +0 -0
  106. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/ops/units.py +0 -0
  107. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/ops/updates.py +0 -0
  108. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/plugins/__init__.py +0 -0
  109. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/plugins/bacnet_ip/__init__.py +0 -0
  110. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/plugins/bacnet_ip/config.py +0 -0
  111. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/plugins/bacnet_ip/declaration.py +0 -0
  112. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/plugins/bacnet_ip/references.py +0 -0
  113. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/plugins/bacnet_ip/source.py +0 -0
  114. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/plugins/bacnet_ip/tools.py +0 -0
  115. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/plugins/bacnet_ip/values.py +0 -0
  116. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/plugins/timescaledb/__init__.py +0 -0
  117. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/plugins/timescaledb/config.py +0 -0
  118. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/py.typed +0 -0
  119. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/sdk/bacnet.py +0 -0
  120. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/sdk/registry.py +0 -0
  121. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/sdk/testing.py +0 -0
  122. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/units/bricklogger-mcp.service +0 -0
  123. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/units/bricklogger-web.service +0 -0
  124. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/units/bricklogger.service +0 -0
  125. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/__init__.py +0 -0
  126. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/client.py +0 -0
  127. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/forms.py +0 -0
  128. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/static/css/app.css +0 -0
  129. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/static/css/explorer.css +0 -0
  130. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/static/fonts/ibm-plex-mono-400.woff2 +0 -0
  131. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/static/fonts/ibm-plex-mono-500.woff2 +0 -0
  132. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/static/fonts/ibm-plex-mono-600.woff2 +0 -0
  133. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/static/fonts/ibm-plex-sans-var.woff2 +0 -0
  134. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/static/js/explorer.js +0 -0
  135. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/static/vendor/LICENSES.md +0 -0
  136. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/static/vendor/codemirror-yaml.min.js +0 -0
  137. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/static/vendor/codemirror.min.css +0 -0
  138. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/static/vendor/codemirror.min.js +0 -0
  139. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/static/vendor/cytoscape.min.js +0 -0
  140. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/static/vendor/htmx.min.js +0 -0
  141. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/static/vendor/yasgui.min.css +0 -0
  142. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/static/vendor/yasgui.min.js +0 -0
  143. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/base.html +0 -0
  144. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/config.html +0 -0
  145. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/daemon.html +0 -0
  146. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/explorer.html +0 -0
  147. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/instances.html +0 -0
  148. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/login.html +0 -0
  149. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/model.html +0 -0
  150. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/notifications.html +0 -0
  151. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/overview.html +0 -0
  152. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/partials/config_result.html +0 -0
  153. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/partials/daemon.html +0 -0
  154. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/partials/diff.html +0 -0
  155. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/partials/health.html +0 -0
  156. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/partials/job.html +0 -0
  157. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/partials/model_versions.html +0 -0
  158. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/partials/notifications.html +0 -0
  159. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/partials/notify_test.html +0 -0
  160. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/partials/overview.html +0 -0
  161. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/partials/plugins.html +0 -0
  162. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/partials/points.html +0 -0
  163. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/partials/statusline.html +0 -0
  164. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/partials/tool_result.html +0 -0
  165. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/partials/unreachable.html +0 -0
  166. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/plugins.html +0 -0
  167. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/points.html +0 -0
  168. {bricklogger-0.2.3 → bricklogger-0.2.4}/src/bricklogger/web/templates/query.html +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bricklogger
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Data bridge for building automation: collects data based on a Brick model and writes it to time-series databases
5
5
  Keywords: brick,bacnet,building-automation,timescaledb,data-logger
6
6
  Author: Michael Rosenlund
@@ -84,7 +84,7 @@ uv tool install bricklogger
84
84
  uv brings a Python that fits, so the machine needs nothing else. Everything
85
85
  stays in that login's home directory: the program in uv's tool directory, the
86
86
  command in `~/.local/bin`, the configuration in `~/.config/bricklogger` and the
87
- data in `~/.local/share/bricklogger`. `uv tool install bricklogger==0.2.3`
87
+ data in `~/.local/share/bricklogger`. `uv tool install bricklogger==0.2.4`
88
88
  installs a given version.
89
89
 
90
90
  **As a container** the same program is `ghcr.io/cx1-aps/bricklogger`, with a
@@ -39,7 +39,7 @@ uv tool install bricklogger
39
39
  uv brings a Python that fits, so the machine needs nothing else. Everything
40
40
  stays in that login's home directory: the program in uv's tool directory, the
41
41
  command in `~/.local/bin`, the configuration in `~/.config/bricklogger` and the
42
- data in `~/.local/share/bricklogger`. `uv tool install bricklogger==0.2.3`
42
+ data in `~/.local/share/bricklogger`. `uv tool install bricklogger==0.2.4`
43
43
  installs a given version.
44
44
 
45
45
  **As a container** the same program is `ghcr.io/cx1-aps/bricklogger`, with a
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bricklogger"
3
- version = "0.2.3"
3
+ version = "0.2.4"
4
4
  description = "Data bridge for building automation: collects data based on a Brick model and writes it to time-series databases"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bricklogger"
3
- version = "0.2.3"
3
+ version = "0.2.4"
4
4
  description = "Data bridge for building automation: collects data based on a Brick model and writes it to time-series databases"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -14,4 +14,4 @@ The package is laid out after the architecture in ``docs/architecture.md``:
14
14
  external plugins.
15
15
  """
16
16
 
17
- __version__ = "0.2.3"
17
+ __version__ = "0.2.4"
@@ -299,11 +299,30 @@ def export(
299
299
  values: Annotated[
300
300
  bool, typer.Option("--values", help="Add the value overlay.")
301
301
  ] = False,
302
+ timeseries: Annotated[
303
+ bool,
304
+ typer.Option(
305
+ "--timeseries",
306
+ help="Add the time-series references of the destination that stores "
307
+ "the model.",
308
+ ),
309
+ ] = False,
310
+ destination: Annotated[
311
+ str | None,
312
+ typer.Option(
313
+ "--destination",
314
+ help="With --timeseries: the destination instance, when several "
315
+ "store the model.",
316
+ ),
317
+ ] = None,
302
318
  output: Annotated[
303
319
  Path | None, typer.Option("-o", "--output", help="Write to a file.")
304
320
  ] = None,
305
321
  ) -> None:
306
- """The model as uploaded; the inferred graph and the values need the daemon."""
322
+ """The model as uploaded; the inferred graph, the values and the time-series
323
+ references need the daemon."""
324
+ if destination is not None and not timeseries:
325
+ raise fail("--destination goes with --timeseries")
307
326
  context = cli_context(ctx)
308
327
  client = reachable_client(context)
309
328
  if client is not None:
@@ -316,11 +335,13 @@ def export(
316
335
  f"/v1/models/{number}",
317
336
  inferred="true" if inferred else None,
318
337
  values="true" if values else None,
338
+ timeseries=(destination or "true") if timeseries else None,
319
339
  )
320
340
  else:
321
- if inferred or values:
341
+ if inferred or values or timeseries:
322
342
  raise fail(
323
- "--inferred and --values need the running daemon's working graph"
343
+ "--inferred, --values and --timeseries need the running daemon's "
344
+ "working graph"
324
345
  )
325
346
  store = _store(ctx)
326
347
  number = version if version is not None else store.active()
@@ -327,6 +327,7 @@ def _echo_declaration(data: dict[str, Any]) -> None:
327
327
  echo_schema(tool.get("parameters"), indent=" ")
328
328
  else:
329
329
  typer.echo(f"stores metadata: {'yes' if data.get('stores_metadata') else 'no'}")
330
+ typer.echo(f"stores the model: {'yes' if data.get('stores_model') else 'no'}")
330
331
  typer.echo("configuration:")
331
332
  echo_schema(data.get("config_schema"), indent=" ")
332
333
 
@@ -335,6 +335,8 @@ def _row(role: str, name: str, type_name: str, state: str) -> dict[str, Any]:
335
335
  "written": 0,
336
336
  "last_write": None,
337
337
  "stores_metadata": None,
338
+ "stores_model": None,
339
+ "model_version": None,
338
340
  "spool": {
339
341
  "observations": 0,
340
342
  "bytes": 0,
@@ -438,7 +440,8 @@ def _echo_detail(role: str, row: Mapping[str, Any]) -> None:
438
440
  age = spool.get("oldest_age_seconds")
439
441
  typer.echo(
440
442
  f"{row['name']}: last write {row.get('last_write') or 'never'}, "
441
- f"stores metadata {'yes' if row.get('stores_metadata') else 'no'}"
443
+ f"stores metadata {'yes' if row.get('stores_metadata') else 'no'}, "
444
+ f"stores the model {_model_note(row)}"
442
445
  )
443
446
  typer.echo(
444
447
  f" spool: {spool.get('observations', 0)} observations, "
@@ -683,3 +686,11 @@ def _file(role: str) -> str:
683
686
 
684
687
  sources_app = _role_app("source", _SourcesGroup)
685
688
  destinations_app = _role_app("destination", _DestinationsGroup)
689
+
690
+
691
+ def _model_note(row: Mapping[str, Any]) -> str:
692
+ """Whether a destination stores the model, and the version it last wrote."""
693
+ if not row.get("stores_model"):
694
+ return "no"
695
+ version = row.get("model_version")
696
+ return "yes" if version is None else f"yes (version {version})"
@@ -32,6 +32,7 @@ from bricklogger.daemon.core import (
32
32
  from bricklogger.daemon.jobs import Job
33
33
  from bricklogger.daemon.sparql import QueryError
34
34
  from bricklogger.model import ModelNotFound, ModelUnreadable
35
+ from bricklogger.ops.status import live_answer
35
36
 
36
37
  PROBLEM = "application/problem+json"
37
38
  YAML = "application/yaml"
@@ -135,8 +136,8 @@ def create_app(
135
136
  # --- health and daemon ---------------------------------------------------
136
137
 
137
138
  @app.get("/health/live")
138
- def live() -> dict[str, str]:
139
- return {"live": "ok"}
139
+ def live() -> dict[str, Any]:
140
+ return live_answer(daemon.started_at)
140
141
 
141
142
  @app.get("/health")
142
143
  def health() -> JSONResponse:
@@ -483,12 +484,19 @@ def create_app(
483
484
 
484
485
  @app.get("/v1/models/{version}")
485
486
  def export_model(
486
- version: int, inferred: bool = False, values: bool = False
487
+ version: int,
488
+ inferred: bool = False,
489
+ values: bool = False,
490
+ timeseries: str | None = None,
487
491
  ) -> Response:
488
492
  try:
489
- data, fmt = daemon.export(version, inferred=inferred, values=values)
493
+ data, fmt = daemon.export(
494
+ version, inferred=inferred, values=values, timeseries=timeseries
495
+ )
490
496
  except ModelNotFound as exc:
491
497
  return problem(404, "Model version not found", str(exc))
498
+ except NotFound as exc:
499
+ return problem(404, "No time-series references", str(exc))
492
500
  except ExportUnavailable as exc:
493
501
  return problem(409, "Export unavailable", str(exc))
494
502
  return Response(
@@ -61,6 +61,7 @@ from bricklogger.model import (
61
61
  Inference,
62
62
  ModelDiff,
63
63
  ModelInvalid,
64
+ ModelNotFound,
64
65
  ModelReport,
65
66
  ModelStore,
66
67
  ModelUnreadable,
@@ -78,8 +79,14 @@ from bricklogger.model.prefixes import (
78
79
  declared_prefixes,
79
80
  expand,
80
81
  )
82
+ from bricklogger.model.timeseries import (
83
+ REF,
84
+ TIMESERIES_GRAPH_PREFIX,
85
+ reference_triples,
86
+ timeseries_graph,
87
+ )
81
88
  from bricklogger.model.working_graph import GRAPH_DIR
82
- from bricklogger.sdk.contract import AssignedPoint, Outcome
89
+ from bricklogger.sdk.contract import AssignedPoint, ModelDocument, Outcome
83
90
  from bricklogger.sdk.registry import PluginRegistry
84
91
 
85
92
  log = logging.getLogger(__name__)
@@ -225,6 +232,7 @@ class Daemon:
225
232
  self.model_error: str | None = None
226
233
  self.started_at: datetime | None = None
227
234
  self._fallback_active: set[str] = set()
235
+ self._turtle: dict[int, str] = {}
228
236
  self._lock = threading.RLock()
229
237
  self.jobs = JobRunner()
230
238
 
@@ -375,6 +383,9 @@ class Daemon:
375
383
  self.graph.replace_model(
376
384
  inference.model, inference.ontology, inference.inferred
377
385
  )
386
+ if number != self.active_version:
387
+ # another version's references until each destination writes this one
388
+ self._clear_timeseries_graphs()
378
389
  self.model_store.set_active(number)
379
390
  self.state.record_activation(number)
380
391
  self.active_version = number
@@ -488,10 +499,8 @@ class Daemon:
488
499
 
489
500
  def models(self) -> dict[str, Any]:
490
501
  """The stored versions, which one is active, and when each was activated."""
491
- assert self.model_store is not None and self.state is not None
492
- history: dict[int, list[str]] = {}
493
- for row in self.state.activations():
494
- history.setdefault(int(row["version"]), []).append(str(row["activated_at"]))
502
+ assert self.model_store is not None
503
+ history = self._activation_history()
495
504
  return {
496
505
  "active": self.active_version,
497
506
  "prefixes": dict(self.graph_access.prefixes) if self.graph_access else {},
@@ -512,20 +521,30 @@ class Daemon:
512
521
  return run_query(self.graph, prefixes, query, accept)
513
522
 
514
523
  def export(
515
- self, version: int, *, inferred: bool = False, values: bool = False
524
+ self,
525
+ version: int,
526
+ *,
527
+ inferred: bool = False,
528
+ values: bool = False,
529
+ timeseries: str | None = None,
516
530
  ) -> tuple[bytes, str]:
517
531
  """A version as uploaded, with its format name.
518
532
 
519
- With the inferred graph or the values added the result is Turtle built
520
- from the working graph, which holds them for the active version only.
533
+ With the inferred graph, the values or a destination's time-series
534
+ references added the result is Turtle built from the working graph,
535
+ which holds them for the active version only. ``timeseries`` names the
536
+ destination instance, or is ``"true"`` for the one that stores the
537
+ model.
521
538
  """
522
539
  assert self.model_store is not None and self.graph is not None
523
540
  stored = self.model_store.get(version)
524
- if not inferred and not values:
541
+ if not inferred and not values and timeseries is None:
525
542
  return self.model_store.read(version), stored.format
543
+ instance = None if timeseries is None else self._reference_instance(timeseries)
526
544
  if version != self.active_version:
527
545
  raise ExportUnavailable(
528
- "the inferred graph and the values exist for the active version only"
546
+ "the inferred graph, the values and the time-series references "
547
+ "exist for the active version only"
529
548
  )
530
549
  graph = rdflib.Graph()
531
550
  graph.parse(data=self.graph.dump(MODEL_GRAPH), format="nt")
@@ -533,6 +552,9 @@ class Daemon:
533
552
  graph.parse(data=self.graph.dump(INFERRED_GRAPH), format="nt")
534
553
  if values:
535
554
  graph.parse(data=self.graph.dump(VALUES_GRAPH), format="nt")
555
+ if instance is not None:
556
+ graph.parse(data=self.graph.dump(timeseries_graph(instance)), format="nt")
557
+ graph.bind("ref", REF)
536
558
  prefixes = self.graph_access.prefixes if self.graph_access else WELL_KNOWN
537
559
  for prefix, iri in prefixes.items():
538
560
  graph.bind(prefix, iri, override=True)
@@ -551,6 +573,7 @@ class Daemon:
551
573
  last is None or last[0] != marker or self.graph.count(MODEL_GRAPH) == 0
552
574
  )
553
575
  if needs_build:
576
+ self._clear_timeseries_graphs()
554
577
  try:
555
578
  activate_version(
556
579
  self.model_store,
@@ -571,6 +594,101 @@ class Daemon:
571
594
  self.active_version = marker
572
595
  self._refresh_graph_access()
573
596
 
597
+ # --- the model in destinations -------------------------------------------
598
+
599
+ def _reference_instance(self, timeseries: str) -> str:
600
+ """The destination instance whose references an export adds: the one
601
+ named, or with ``"true"`` the only one that stores the model."""
602
+ storing = sorted(
603
+ name for name, runner in self.destinations.items() if runner.stores_model
604
+ )
605
+ if timeseries == "true":
606
+ if not storing:
607
+ raise NotFound("no destination stores the model")
608
+ if len(storing) > 1:
609
+ raise ExportUnavailable(
610
+ "several destinations store the model; name one: "
611
+ + ", ".join(storing)
612
+ )
613
+ return storing[0]
614
+ if timeseries not in storing:
615
+ raise NotFound(f"no destination {timeseries!r} stores the model")
616
+ return timeseries
617
+
618
+ def _clear_timeseries_graphs(self, instance: str | None = None) -> None:
619
+ assert self.graph is not None
620
+ for iri in self.graph.named_graphs():
621
+ if iri.startswith(TIMESERIES_GRAPH_PREFIX) and (
622
+ instance is None or iri == timeseries_graph(instance)
623
+ ):
624
+ self.graph.clear_graph(iri)
625
+
626
+ def _model_document(
627
+ self, version: int, activations: Sequence[str]
628
+ ) -> ModelDocument:
629
+ """A version as Turtle, without references; the runner adds them."""
630
+ assert self.model_store is not None
631
+ stored = self.model_store.get(version)
632
+ turtle = self._turtle.get(version)
633
+ if turtle is None:
634
+ model = parse_model(self.model_store.read(version), stored.format)
635
+ turtle = model.serialize(format="turtle")
636
+ self._turtle[version] = turtle
637
+ return ModelDocument(
638
+ version=version,
639
+ uploaded_at=stored.uploaded_at,
640
+ activations=tuple(datetime.fromisoformat(a) for a in activations),
641
+ turtle=turtle,
642
+ )
643
+
644
+ def _activation_history(self) -> dict[int, list[str]]:
645
+ assert self.state is not None
646
+ history: dict[int, list[str]] = {}
647
+ for row in self.state.activations():
648
+ history.setdefault(int(row["version"]), []).append(str(row["activated_at"]))
649
+ return history
650
+
651
+ def _offer_models(self, spools: Sequence[Spool], *, every: bool) -> None:
652
+ """Spool the active version, or every version that has been active,
653
+ for the destinations that store the model."""
654
+ assert self.model_store is not None
655
+ if not spools:
656
+ return
657
+ history = self._activation_history()
658
+ if every:
659
+ versions = sorted(history)
660
+ elif self.active_version is not None:
661
+ versions = [self.active_version]
662
+ else:
663
+ versions = []
664
+ for version in versions:
665
+ try:
666
+ document = self._model_document(version, history.get(version, []))
667
+ except (ModelNotFound, ModelUnreadable):
668
+ log.warning("model version %d cannot be offered", version)
669
+ continue
670
+ for spool in spools:
671
+ spool.append_model(document)
672
+
673
+ def _model_spools(self) -> list[Spool]:
674
+ return [
675
+ self.spools[name]
676
+ for name, runner in self.destinations.items()
677
+ if runner.stores_model
678
+ ]
679
+
680
+ def _on_model_written(
681
+ self, instance: str, version: int, ids: Mapping[str, str]
682
+ ) -> None:
683
+ """Called from a destination's thread once it stored a version; the
684
+ active version's keys become the instance's graph of references."""
685
+ if version != self.active_version or self.graph is None:
686
+ return
687
+ try:
688
+ self.graph.replace_graph(timeseries_graph(instance), reference_triples(ids))
689
+ except Exception:
690
+ log.exception("the time-series references of %s were not stored", instance)
691
+
574
692
  def _refresh_graph_access(self) -> None:
575
693
  assert self.graph is not None and self.model_store is not None
576
694
  prefixes = {**WELL_KNOWN, **self.registry.prefixes()}
@@ -615,8 +733,12 @@ class Daemon:
615
733
  spool_max_age=instance.spool.max_age,
616
734
  stop_timeout=self.configuration.daemon.stop_timeout,
617
735
  stores_metadata=declaration.stores_metadata,
736
+ stores_model=declaration.stores_model,
737
+ on_model_written=self._on_model_written,
618
738
  )
619
739
  self.destinations[name] = runner
740
+ if declaration.stores_model:
741
+ self._offer_models([spool], every=True)
620
742
  if self.state.stop_intent(name):
621
743
  runner.current_state = "stopped"
622
744
  self.state.set_instance(
@@ -693,6 +815,7 @@ class Daemon:
693
815
  continue
694
816
  self.destinations.pop(name).stop()
695
817
  self.spools.pop(name).close()
818
+ self._clear_timeseries_graphs(name)
696
819
  for name, instance in new.items():
697
820
  if name in old and old[name] == instance:
698
821
  continue
@@ -854,6 +977,8 @@ class Daemon:
854
977
  if assigned and self.sink is not None:
855
978
  entries = graph_metadata(self.graph, self.graph_access.prefixes, assigned)
856
979
  self.sink.metadata(entries.values())
980
+ # behind the metadata, so the destinations hold the keys the model needs
981
+ self._offer_models(self._model_spools(), every=False)
857
982
  for name, runner in self.sources.items():
858
983
  runner.assign(plan.assignments.get(name, []))
859
984
 
@@ -1047,6 +1172,8 @@ class Daemon:
1047
1172
  "type": runner.type_name,
1048
1173
  "state": runner.current_state,
1049
1174
  "stores_metadata": runner.stores_metadata,
1175
+ "stores_model": runner.stores_model,
1176
+ "model_version": runner.model_version,
1050
1177
  "last_error": row.get("last_error"),
1051
1178
  "last_write": runner.last_write,
1052
1179
  "written": self.state.counter(f"written:{name}"),
@@ -1067,6 +1194,8 @@ class Daemon:
1067
1194
  "type": entry.type_name,
1068
1195
  "state": "failed",
1069
1196
  "stores_metadata": None,
1197
+ "stores_model": None,
1198
+ "model_version": None,
1070
1199
  "last_error": entry.error,
1071
1200
  "last_write": None,
1072
1201
  "written": 0,
@@ -7,11 +7,13 @@ from __future__ import annotations
7
7
  import logging
8
8
  import threading
9
9
  import time
10
- from collections.abc import Callable, Iterable, Sequence
10
+ from collections.abc import Callable, Iterable, Mapping, Sequence
11
+ from dataclasses import replace
11
12
  from datetime import timedelta
12
13
 
13
14
  from bricklogger.daemon.spool import Entry, Spool
14
15
  from bricklogger.daemon.state import RuntimeState
16
+ from bricklogger.model.timeseries import with_references
15
17
  from bricklogger.sdk.contract import (
16
18
  AssignedPoint,
17
19
  Destination,
@@ -256,6 +258,8 @@ class DestinationRunner:
256
258
  spool_max_age: timedelta,
257
259
  stop_timeout: timedelta,
258
260
  stores_metadata: bool,
261
+ stores_model: bool = False,
262
+ on_model_written: Callable[[str, int, Mapping[str, str]], None] | None = None,
259
263
  ) -> None:
260
264
  self.name = name
261
265
  self.type_name = type_name
@@ -268,6 +272,9 @@ class DestinationRunner:
268
272
  self.spool_max_age = spool_max_age
269
273
  self.stop_timeout = stop_timeout
270
274
  self.stores_metadata = stores_metadata
275
+ self.stores_model = stores_model
276
+ self.on_model_written = on_model_written
277
+ self.model_version: int | None = None
271
278
  self._thread: threading.Thread | None = None
272
279
  self._stopping = threading.Event()
273
280
  self._failures = 0
@@ -375,6 +382,10 @@ class DestinationRunner:
375
382
  if self.stores_metadata:
376
383
  for entry in batch:
377
384
  self.destination.write_metadata(entry.metadata())
385
+ elif batch[0].kind == "model":
386
+ if self.stores_model:
387
+ for entry in batch:
388
+ self._write_model(entry)
378
389
  else:
379
390
  observations = [o for entry in batch for o in entry.observations()]
380
391
  self.destination.write(observations)
@@ -397,6 +408,19 @@ class DestinationRunner:
397
408
  self.last_write = time.strftime("%Y-%m-%dT%H:%M:%S%z")
398
409
  return True
399
410
 
411
+ def _write_model(self, entry: Entry) -> None:
412
+ """Add the destination's references to a model version and store it.
413
+
414
+ The keys are asked for at write time, after the metadata ahead of the
415
+ model in the spool, so every point the plan offered already has one.
416
+ """
417
+ model = entry.model()
418
+ turtle, used = with_references(model.turtle, self.destination.timeseries_ids())
419
+ self.destination.write_model(replace(model, turtle=turtle))
420
+ self.model_version = model.version
421
+ if self.on_model_written is not None:
422
+ self.on_model_written(self.name, model.version, used)
423
+
400
424
  def _enforce_caps(self) -> None:
401
425
  dropped = self.spool.enforce_caps(self.spool_max_size, self.spool_max_age)
402
426
  if dropped:
@@ -141,6 +141,7 @@ def describe_plugin(
141
141
  ]
142
142
  elif destination is not None:
143
143
  data["stores_metadata"] = destination.stores_metadata
144
+ data["stores_model"] = destination.stores_model
144
145
  return data
145
146
 
146
147
 
@@ -80,7 +80,7 @@ def run_query(
80
80
  if is_update(query):
81
81
  raise QueryError("update requests are refused: the graph is read-only")
82
82
  try:
83
- result = graph.query(with_prefixes(query, prefixes))
83
+ result = graph.query(with_prefixes(query, prefixes), references=True)
84
84
  except SyntaxError as exc:
85
85
  raise QueryError(f"the query does not parse: {exc}") from exc
86
86
  except (OSError, ValueError) as exc:
@@ -1,8 +1,8 @@
1
1
  """The persistent spool between the daemon and one destination.
2
2
 
3
- An on-disk queue that observations and metadata are appended to and drained
4
- from in order, in batches; it survives a daemon restart and is bounded by size
5
- and age, dropping the oldest entries when a cap is reached. See
3
+ An on-disk queue that observations, metadata and model versions are appended
4
+ to and drained from in order, in batches; it survives a daemon restart and is
5
+ bounded by size and age, dropping the oldest entries when a cap is reached. See
6
6
  ``docs/architecture.md``, "Backpressure: the spool".
7
7
  """
8
8
 
@@ -18,16 +18,17 @@ from datetime import timedelta
18
18
  from pathlib import Path
19
19
  from typing import Any, Literal
20
20
 
21
- from bricklogger.sdk.contract import Observation, PointMetadata
21
+ from bricklogger.sdk.contract import ModelDocument, Observation, PointMetadata
22
22
 
23
23
  SPOOL_DIR = "spool"
24
24
 
25
- EntryKind = Literal["observations", "metadata"]
25
+ EntryKind = Literal["observations", "metadata", "model"]
26
26
 
27
27
 
28
28
  @dataclass(frozen=True)
29
29
  class Entry:
30
- """One spooled batch: observations or metadata, as it was appended."""
30
+ """One spooled batch: observations, metadata or one model version, as it
31
+ was appended."""
31
32
 
32
33
  id: int
33
34
  kind: EntryKind
@@ -39,6 +40,9 @@ class Entry:
39
40
  def metadata(self) -> list[PointMetadata]:
40
41
  return [PointMetadata.from_dict(item) for item in self.payload]
41
42
 
43
+ def model(self) -> ModelDocument:
44
+ return ModelDocument.from_dict(self.payload[0])
45
+
42
46
 
43
47
  @dataclass(frozen=True)
44
48
  class SpoolStats:
@@ -82,6 +86,9 @@ class Spool:
82
86
  if entries:
83
87
  self._append("metadata", [e.as_dict() for e in entries])
84
88
 
89
+ def append_model(self, model: ModelDocument) -> None:
90
+ self._append("model", [model.as_dict()])
91
+
85
92
  def _append(self, kind: EntryKind, items: list[dict[str, Any]]) -> None:
86
93
  payload = json.dumps(items, separators=(",", ":"))
87
94
  with self._lock:
@@ -95,8 +102,9 @@ class Spool:
95
102
  def next_entries(self, max_observations: int) -> list[Entry]:
96
103
  """The oldest entries, in order, up to about ``max_observations`` observations.
97
104
 
98
- Metadata entries break a run, so a destination learns about a point
99
- before it sees the point's values, in the order things were appended.
105
+ Metadata and model entries break a run, so a destination learns about
106
+ a point before it sees the point's values, and holds its key before
107
+ the model names it, in the order things were appended.
100
108
  """
101
109
  entries: list[Entry] = []
102
110
  collected = 0
@@ -110,7 +118,7 @@ class Spool:
110
118
  break
111
119
  entries.append(Entry(int(row["id"]), kind, json.loads(row["payload"])))
112
120
  collected += int(row["items"])
113
- if kind == "metadata" or collected >= max_observations:
121
+ if kind != "observations" or collected >= max_observations:
114
122
  break
115
123
  return entries
116
124