testerkit 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (521) hide show
  1. testerkit/__init__.py +68 -0
  2. testerkit/_docs/_assets/operator-ui/channels/table.png +0 -0
  3. testerkit/_docs/_assets/operator-ui/dashboard/runs.png +0 -0
  4. testerkit/_docs/_assets/operator-ui/dashboard/stations.png +0 -0
  5. testerkit/_docs/_assets/operator-ui/events/filters.png +0 -0
  6. testerkit/_docs/_assets/operator-ui/events/table.png +0 -0
  7. testerkit/_docs/_assets/operator-ui/explore/chart.png +0 -0
  8. testerkit/_docs/_assets/operator-ui/explore/filters.png +0 -0
  9. testerkit/_docs/_assets/operator-ui/explore/plot-controls.png +0 -0
  10. testerkit/_docs/_assets/operator-ui/files/table.png +0 -0
  11. testerkit/_docs/_assets/operator-ui/launch/form.png +0 -0
  12. testerkit/_docs/_assets/operator-ui/metrics/filters.png +0 -0
  13. testerkit/_docs/_assets/operator-ui/metrics/pareto.png +0 -0
  14. testerkit/_docs/_assets/operator-ui/metrics/ppk.png +0 -0
  15. testerkit/_docs/_assets/operator-ui/metrics/yield.png +0 -0
  16. testerkit/_docs/_assets/operator-ui/results/detail-header.png +0 -0
  17. testerkit/_docs/_assets/operator-ui/results/detail-measurements.png +0 -0
  18. testerkit/_docs/_assets/operator-ui/results/detail-overview.png +0 -0
  19. testerkit/_docs/_assets/operator-ui/results/detail-steps.png +0 -0
  20. testerkit/_docs/_assets/operator-ui/results/stats.png +0 -0
  21. testerkit/_docs/_assets/operator-ui/results/table.png +0 -0
  22. testerkit/_docs/_assets/operator-ui/tour/designer.png +0 -0
  23. testerkit/_docs/_assets/operator-ui/tour/docs.png +0 -0
  24. testerkit/_docs/_assets/operator-ui/tour/fixtures.png +0 -0
  25. testerkit/_docs/_assets/operator-ui/tour/instruments.png +0 -0
  26. testerkit/_docs/_assets/operator-ui/tour/parts.png +0 -0
  27. testerkit/_docs/_assets/operator-ui/tour/profiles.png +0 -0
  28. testerkit/_docs/_assets/operator-ui/tour/stations.png +0 -0
  29. testerkit/_docs/_assets/operator-ui/tour/tests.png +0 -0
  30. testerkit/_docs/_assets/operator-ui/tour/uuts.png +0 -0
  31. testerkit/_docs/concepts/configuration/capabilities.md +447 -0
  32. testerkit/_docs/concepts/configuration/fixtures.md +335 -0
  33. testerkit/_docs/concepts/configuration/index.md +14 -0
  34. testerkit/_docs/concepts/configuration/parts.md +276 -0
  35. testerkit/_docs/concepts/configuration/stations.md +214 -0
  36. testerkit/_docs/concepts/data/data-stores.md +199 -0
  37. testerkit/_docs/concepts/data/event-log.md +185 -0
  38. testerkit/_docs/concepts/data/event-sourcing.md +76 -0
  39. testerkit/_docs/concepts/data/flight-streaming.md +58 -0
  40. testerkit/_docs/concepts/data/index.md +17 -0
  41. testerkit/_docs/concepts/data/sessions.md +78 -0
  42. testerkit/_docs/concepts/data/three-verbs.md +260 -0
  43. testerkit/_docs/concepts/execution/index.md +13 -0
  44. testerkit/_docs/concepts/execution/outcomes.md +199 -0
  45. testerkit/_docs/concepts/execution/step-hierarchy.md +170 -0
  46. testerkit/_docs/concepts/execution/step-manifest.md +115 -0
  47. testerkit/_docs/concepts/index.md +40 -0
  48. testerkit/_docs/concepts/overview/ai-integration.md +57 -0
  49. testerkit/_docs/concepts/overview/architecture.md +357 -0
  50. testerkit/_docs/concepts/overview/index.md +14 -0
  51. testerkit/_docs/concepts/overview/platform-vs-framework.md +147 -0
  52. testerkit/_docs/concepts/overview/pytest.md +53 -0
  53. testerkit/_docs/concepts/overview/tiers.md +46 -0
  54. testerkit/_docs/how-to/catalog/datasheet-to-test.md +169 -0
  55. testerkit/_docs/how-to/catalog/index.md +11 -0
  56. testerkit/_docs/how-to/configuration/configuring-stations.md +364 -0
  57. testerkit/_docs/how-to/configuration/custom-drivers.md +484 -0
  58. testerkit/_docs/how-to/configuration/index.md +13 -0
  59. testerkit/_docs/how-to/configuration/mock-mode.md +342 -0
  60. testerkit/_docs/how-to/data/benchmarking.md +120 -0
  61. testerkit/_docs/how-to/data/capture-an-artifact.md +84 -0
  62. testerkit/_docs/how-to/data/capture-waveform.md +110 -0
  63. testerkit/_docs/how-to/data/choosing-a-channel-verb.md +108 -0
  64. testerkit/_docs/how-to/data/compare-runs.md +120 -0
  65. testerkit/_docs/how-to/data/export-results.md +104 -0
  66. testerkit/_docs/how-to/data/find-flaky-tests.md +108 -0
  67. testerkit/_docs/how-to/data/grafana-dashboards.md +148 -0
  68. testerkit/_docs/how-to/data/index.md +23 -0
  69. testerkit/_docs/how-to/data/mcp-debug-failures.md +125 -0
  70. testerkit/_docs/how-to/data/mcp-query-runs.md +120 -0
  71. testerkit/_docs/how-to/data/querying-channels.md +117 -0
  72. testerkit/_docs/how-to/data/querying-events.md +85 -0
  73. testerkit/_docs/how-to/data/stream-live-channel.md +134 -0
  74. testerkit/_docs/how-to/execution/custom-operator-ui.md +136 -0
  75. testerkit/_docs/how-to/execution/index.md +21 -0
  76. testerkit/_docs/how-to/execution/limits.md +193 -0
  77. testerkit/_docs/how-to/execution/managing-sessions.md +100 -0
  78. testerkit/_docs/how-to/execution/multi-uut-testing.md +211 -0
  79. testerkit/_docs/how-to/execution/operator-prompts.md +151 -0
  80. testerkit/_docs/how-to/execution/profiles.md +304 -0
  81. testerkit/_docs/how-to/execution/spec-driven-testing.md +169 -0
  82. testerkit/_docs/how-to/execution/test-context.md +212 -0
  83. testerkit/_docs/how-to/execution/traceability.md +252 -0
  84. testerkit/_docs/how-to/execution/vector-expansion.md +394 -0
  85. testerkit/_docs/how-to/execution/writing-tests.md +363 -0
  86. testerkit/_docs/how-to/index.md +59 -0
  87. testerkit/_docs/how-to/overview/index.md +11 -0
  88. testerkit/_docs/how-to/overview/mcp-integration.md +168 -0
  89. testerkit/_docs/how-to/overview/operator-ui-tour.md +234 -0
  90. testerkit/_docs/integration/data/grafana.md +162 -0
  91. testerkit/_docs/integration/data/index.md +15 -0
  92. testerkit/_docs/integration/data/lakehouse-import.md +226 -0
  93. testerkit/_docs/integration/data/logging.md +131 -0
  94. testerkit/_docs/integration/data/results-api.md +51 -0
  95. testerkit/_docs/integration/index.md +20 -0
  96. testerkit/_docs/integration/runtime/harness.md +281 -0
  97. testerkit/_docs/integration/runtime/index.md +13 -0
  98. testerkit/_docs/integration/runtime/instruments.md +284 -0
  99. testerkit/_docs/integration/runtime/pytest-existing.md +315 -0
  100. testerkit/_docs/reference/catalog/cookbook.md +389 -0
  101. testerkit/_docs/reference/catalog/index.md +11 -0
  102. testerkit/_docs/reference/catalog/schema.md +375 -0
  103. testerkit/_docs/reference/cli.md +660 -0
  104. testerkit/_docs/reference/configuration.md +406 -0
  105. testerkit/_docs/reference/data/channels-schema.md +145 -0
  106. testerkit/_docs/reference/data/event-types.md +554 -0
  107. testerkit/_docs/reference/data/events-schema.md +120 -0
  108. testerkit/_docs/reference/data/files-schema.md +141 -0
  109. testerkit/_docs/reference/data/index.md +18 -0
  110. testerkit/_docs/reference/data/models.md +2229 -0
  111. testerkit/_docs/reference/data/outputs.md +82 -0
  112. testerkit/_docs/reference/data/parquet-schema.md +529 -0
  113. testerkit/_docs/reference/data/performance-limits.md +224 -0
  114. testerkit/_docs/reference/data/query-api.md +247 -0
  115. testerkit/_docs/reference/index.md +69 -0
  116. testerkit/_docs/reference/operator-ui/channels/detail.md +82 -0
  117. testerkit/_docs/reference/operator-ui/channels/list.md +70 -0
  118. testerkit/_docs/reference/operator-ui/dashboard.md +87 -0
  119. testerkit/_docs/reference/operator-ui/designer.md +167 -0
  120. testerkit/_docs/reference/operator-ui/events.md +89 -0
  121. testerkit/_docs/reference/operator-ui/files.md +74 -0
  122. testerkit/_docs/reference/operator-ui/fixtures.md +100 -0
  123. testerkit/_docs/reference/operator-ui/instruments.md +126 -0
  124. testerkit/_docs/reference/operator-ui/launch.md +79 -0
  125. testerkit/_docs/reference/operator-ui/live.md +102 -0
  126. testerkit/_docs/reference/operator-ui/measurements.md +159 -0
  127. testerkit/_docs/reference/operator-ui/metrics.md +217 -0
  128. testerkit/_docs/reference/operator-ui/parts.md +98 -0
  129. testerkit/_docs/reference/operator-ui/profiles.md +103 -0
  130. testerkit/_docs/reference/operator-ui/results/detail.md +214 -0
  131. testerkit/_docs/reference/operator-ui/results/list.md +106 -0
  132. testerkit/_docs/reference/operator-ui/stations.md +98 -0
  133. testerkit/_docs/reference/operator-ui/tests.md +173 -0
  134. testerkit/_docs/reference/operator-ui/uuts.md +67 -0
  135. testerkit/_docs/reference/overview/index.md +11 -0
  136. testerkit/_docs/reference/overview/pytest-native.md +142 -0
  137. testerkit/_docs/reference/overview/skills.md +177 -0
  138. testerkit/_docs/reference/pytest/fixtures.md +342 -0
  139. testerkit/_docs/reference/pytest/index.md +12 -0
  140. testerkit/_docs/reference/pytest/markers.md +257 -0
  141. testerkit/_docs/reference/runtime/api.md +246 -0
  142. testerkit/_docs/reference/runtime/client.md +280 -0
  143. testerkit/_docs/reference/runtime/connect.md +140 -0
  144. testerkit/_docs/reference/runtime/index.md +13 -0
  145. testerkit/_docs/tutorial/01-first-test.md +113 -0
  146. testerkit/_docs/tutorial/02-mock-instruments.md +88 -0
  147. testerkit/_docs/tutorial/03-fixtures.md +169 -0
  148. testerkit/_docs/tutorial/04-limits.md +139 -0
  149. testerkit/_docs/tutorial/05-configuration.md +194 -0
  150. testerkit/_docs/tutorial/06-specifications.md +273 -0
  151. testerkit/_docs/tutorial/07-real-instruments.md +245 -0
  152. testerkit/_docs/tutorial/08-capabilities.md +223 -0
  153. testerkit/_docs/tutorial/09-production.md +374 -0
  154. testerkit/_docs/tutorial/10-live-monitoring.md +156 -0
  155. testerkit/_docs/tutorial/11-waveforms-and-evidence.md +169 -0
  156. testerkit/_docs/tutorial/12-continuous-monitoring.md +145 -0
  157. testerkit/_docs/tutorial/13-parallel-testing.md +233 -0
  158. testerkit/_docs/tutorial/index.md +61 -0
  159. testerkit/_docs/tutorial/quickstart.md +282 -0
  160. testerkit/analysis/__init__.py +9 -0
  161. testerkit/analysis/_common.py +24 -0
  162. testerkit/analysis/measurement_facets.py +524 -0
  163. testerkit/analysis/measurements_query.py +1458 -0
  164. testerkit/analysis/metrics.py +389 -0
  165. testerkit/analysis/runs_query.py +434 -0
  166. testerkit/analysis/steps_query.py +413 -0
  167. testerkit/api/__init__.py +6 -0
  168. testerkit/api/_mime.py +71 -0
  169. testerkit/api/app.py +1171 -0
  170. testerkit/api/dialogs/__init__.py +36 -0
  171. testerkit/api/dialogs/manager.py +617 -0
  172. testerkit/api/dialogs/models.py +79 -0
  173. testerkit/api/models.py +80 -0
  174. testerkit/api/responses.py +205 -0
  175. testerkit/api/runner.py +222 -0
  176. testerkit/api/schemas.py +311 -0
  177. testerkit/benchmark/__init__.py +34 -0
  178. testerkit/benchmark/concurrency.py +248 -0
  179. testerkit/benchmark/core.py +232 -0
  180. testerkit/benchmark/runner.py +628 -0
  181. testerkit/benchmark/scenario.py +313 -0
  182. testerkit/benchmark/system.py +191 -0
  183. testerkit/benchmark/workloads.py +507 -0
  184. testerkit/catalog/__init__.py +6 -0
  185. testerkit/catalog/generic/generic_dmm.yaml +61 -0
  186. testerkit/catalog/generic/generic_eload.yaml +31 -0
  187. testerkit/catalog/generic/generic_fgen.yaml +56 -0
  188. testerkit/catalog/generic/generic_oscilloscope.yaml +45 -0
  189. testerkit/catalog/generic/generic_psu.yaml +77 -0
  190. testerkit/catalog/generic/generic_smu.yaml +73 -0
  191. testerkit/channels.py +538 -0
  192. testerkit/cli/__init__.py +32 -0
  193. testerkit/cli/_common.py +113 -0
  194. testerkit/cli/_time.py +179 -0
  195. testerkit/cli/benchmark_cmd.py +56 -0
  196. testerkit/cli/catalog_cmd.py +39 -0
  197. testerkit/cli/daemon.py +157 -0
  198. testerkit/cli/data_cmd.py +1040 -0
  199. testerkit/cli/discover_cmd.py +94 -0
  200. testerkit/cli/docs_cmd.py +100 -0
  201. testerkit/cli/instrument.py +172 -0
  202. testerkit/cli/mcp_cmd.py +37 -0
  203. testerkit/cli/metrics.py +366 -0
  204. testerkit/cli/project.py +312 -0
  205. testerkit/cli/root.py +14 -0
  206. testerkit/cli/runs.py +412 -0
  207. testerkit/cli/schema_cmd.py +87 -0
  208. testerkit/cli/serve_cmd.py +50 -0
  209. testerkit/cli/setup_cmd.py +604 -0
  210. testerkit/cli/station.py +294 -0
  211. testerkit/cli/validate.py +112 -0
  212. testerkit/client.py +463 -0
  213. testerkit/connect.py +635 -0
  214. testerkit/data/__init__.py +8 -0
  215. testerkit/data/_accumulator_pool.py +455 -0
  216. testerkit/data/_atomic.py +59 -0
  217. testerkit/data/_collection_indices.py +67 -0
  218. testerkit/data/_daemon_lifecycle.py +535 -0
  219. testerkit/data/_duckdb_daemon.py +695 -0
  220. testerkit/data/_duckdb_flight_server.py +813 -0
  221. testerkit/data/_event_filters.py +15 -0
  222. testerkit/data/_event_reader.py +101 -0
  223. testerkit/data/_flight_errors.py +199 -0
  224. testerkit/data/_flight_query.py +202 -0
  225. testerkit/data/_flight_retry.py +148 -0
  226. testerkit/data/_flight_subscribe.py +67 -0
  227. testerkit/data/_index_epoch.py +342 -0
  228. testerkit/data/_ipc_writer.py +185 -0
  229. testerkit/data/_json_safe.py +63 -0
  230. testerkit/data/_process.py +18 -0
  231. testerkit/data/_push_relay.py +124 -0
  232. testerkit/data/_runs_duckdb_daemon.py +2704 -0
  233. testerkit/data/_session_reaper.py +110 -0
  234. testerkit/data/_sql_helpers.py +30 -0
  235. testerkit/data/_store.py +24 -0
  236. testerkit/data/backends/__init__.py +7 -0
  237. testerkit/data/backends/_event_accumulator.py +957 -0
  238. testerkit/data/backends/_row_helpers.py +1172 -0
  239. testerkit/data/backends/parquet.py +1143 -0
  240. testerkit/data/backends/protocol.py +35 -0
  241. testerkit/data/channels/__init__.py +8 -0
  242. testerkit/data/channels/_flight_daemon.py +75 -0
  243. testerkit/data/channels/client.py +242 -0
  244. testerkit/data/channels/flight_manager.py +88 -0
  245. testerkit/data/channels/index.py +664 -0
  246. testerkit/data/channels/models.py +418 -0
  247. testerkit/data/channels/server.py +162 -0
  248. testerkit/data/channels/store.py +1481 -0
  249. testerkit/data/data_dir.py +61 -0
  250. testerkit/data/duckdb_manager.py +79 -0
  251. testerkit/data/event_log.py +384 -0
  252. testerkit/data/event_store.py +768 -0
  253. testerkit/data/events.py +1025 -0
  254. testerkit/data/exporters/__init__.py +6 -0
  255. testerkit/data/exporters/_helpers.py +40 -0
  256. testerkit/data/exporters/csv_exporter.py +168 -0
  257. testerkit/data/exporters/hdf5.py +234 -0
  258. testerkit/data/exporters/json_exporter.py +229 -0
  259. testerkit/data/exporters/mdf4.py +201 -0
  260. testerkit/data/exporters/stdf.py +255 -0
  261. testerkit/data/exporters/tdms.py +292 -0
  262. testerkit/data/files/__init__.py +71 -0
  263. testerkit/data/files/_backend.py +207 -0
  264. testerkit/data/files/_catalog_daemon.py +84 -0
  265. testerkit/data/files/catalog.py +218 -0
  266. testerkit/data/files/catalog_manager.py +325 -0
  267. testerkit/data/files/models.py +60 -0
  268. testerkit/data/files/serializers.py +414 -0
  269. testerkit/data/files/store.py +463 -0
  270. testerkit/data/files/streaming.py +783 -0
  271. testerkit/data/models.py +604 -0
  272. testerkit/data/ref.py +152 -0
  273. testerkit/data/retention.py +297 -0
  274. testerkit/data/run_store.py +367 -0
  275. testerkit/data/runs_duckdb_manager.py +103 -0
  276. testerkit/data/schema_dispatch.py +191 -0
  277. testerkit/data/schema_migrate.py +76 -0
  278. testerkit/data/schema_versions.py +85 -0
  279. testerkit/data/schemas.py +288 -0
  280. testerkit/data/subscribers/__init__.py +48 -0
  281. testerkit/data/subscribers/_base.py +20 -0
  282. testerkit/data/subscribers/_output_file.py +25 -0
  283. testerkit/data/subscribers/replay.py +55 -0
  284. testerkit/environment.py +84 -0
  285. testerkit/execution/__init__.py +11 -0
  286. testerkit/execution/_git.py +190 -0
  287. testerkit/execution/_state.py +740 -0
  288. testerkit/execution/accessors.py +54 -0
  289. testerkit/execution/audit.py +51 -0
  290. testerkit/execution/cascade.py +85 -0
  291. testerkit/execution/connections.py +491 -0
  292. testerkit/execution/harness.py +1931 -0
  293. testerkit/execution/instrument_events.py +42 -0
  294. testerkit/execution/limits.py +154 -0
  295. testerkit/execution/metadata.py +102 -0
  296. testerkit/execution/mocks.py +63 -0
  297. testerkit/execution/profiles.py +616 -0
  298. testerkit/execution/run_scope.py +1331 -0
  299. testerkit/execution/session_scope.py +196 -0
  300. testerkit/execution/sidecar.py +280 -0
  301. testerkit/execution/site_runner.py +705 -0
  302. testerkit/execution/sites.py +183 -0
  303. testerkit/execution/sync.py +294 -0
  304. testerkit/execution/uut_provider.py +231 -0
  305. testerkit/execution/vectors.py +205 -0
  306. testerkit/execution/verify.py +396 -0
  307. testerkit/expand.py +75 -0
  308. testerkit/files.py +235 -0
  309. testerkit/fixtures/__init__.py +6 -0
  310. testerkit/fixtures/manager.py +357 -0
  311. testerkit/grafana/__init__.py +1 -0
  312. testerkit/grafana/bootstrap.py +61 -0
  313. testerkit/grafana/cli.py +314 -0
  314. testerkit/grafana/dashboards/asset_utilization.json +92 -0
  315. testerkit/grafana/dashboards/channel_explorer.json +77 -0
  316. testerkit/grafana/dashboards/event_log.json +92 -0
  317. testerkit/grafana/dashboards/failure_pareto.json +104 -0
  318. testerkit/grafana/dashboards/measurement_distribution.json +97 -0
  319. testerkit/grafana/dashboards/measurement_trend.json +161 -0
  320. testerkit/grafana/dashboards/station_comparison.json +95 -0
  321. testerkit/grafana/dashboards/test_duration.json +104 -0
  322. testerkit/grafana/dashboards/unit_traceability.json +73 -0
  323. testerkit/grafana/dashboards/yield_overview.json +189 -0
  324. testerkit/grafana/provisioning/dashboards.yaml.j2 +12 -0
  325. testerkit/grafana/provisioning/datasources.yaml.j2 +17 -0
  326. testerkit/grafana/server.py +282 -0
  327. testerkit/init.py +724 -0
  328. testerkit/instruments/__init__.py +10 -0
  329. testerkit/instruments/base.py +82 -0
  330. testerkit/instruments/discovery/__init__.py +62 -0
  331. testerkit/instruments/discovery/_base.py +174 -0
  332. testerkit/instruments/discovery/lxi.py +152 -0
  333. testerkit/instruments/discovery/ni.py +95 -0
  334. testerkit/instruments/discovery/serial.py +73 -0
  335. testerkit/instruments/discovery/visa.py +98 -0
  336. testerkit/instruments/lifecycle.py +213 -0
  337. testerkit/instruments/loader.py +79 -0
  338. testerkit/instruments/locks.py +178 -0
  339. testerkit/instruments/mocks.py +230 -0
  340. testerkit/instruments/observer.py +270 -0
  341. testerkit/instruments/observers/__init__.py +36 -0
  342. testerkit/instruments/observers/_base.py +68 -0
  343. testerkit/instruments/observers/daqmx.py +70 -0
  344. testerkit/instruments/observers/descriptor.py +69 -0
  345. testerkit/instruments/observers/generic.py +92 -0
  346. testerkit/instruments/observers/lantz.py +31 -0
  347. testerkit/instruments/observers/modbus.py +96 -0
  348. testerkit/instruments/observers/motion.py +68 -0
  349. testerkit/instruments/observers/ni_modular.py +73 -0
  350. testerkit/instruments/observers/ophyd.py +80 -0
  351. testerkit/instruments/observers/pymeasure.py +120 -0
  352. testerkit/instruments/observers/qcodes.py +66 -0
  353. testerkit/instruments/observers/scpi.py +88 -0
  354. testerkit/instruments/observers/tektronix.py +57 -0
  355. testerkit/instruments/observers/visa.py +103 -0
  356. testerkit/instruments/pool.py +459 -0
  357. testerkit/instruments/proxy.py +59 -0
  358. testerkit/instruments/route_manager.py +329 -0
  359. testerkit/instruments/routed_proxy.py +88 -0
  360. testerkit/instruments/server.py +508 -0
  361. testerkit/instruments/switch.py +43 -0
  362. testerkit/instruments/visa.py +294 -0
  363. testerkit/matching/__init__.py +6 -0
  364. testerkit/matching/service.py +969 -0
  365. testerkit/mcp/__init__.py +6 -0
  366. testerkit/mcp/server.py +681 -0
  367. testerkit/mcp/tools.py +1868 -0
  368. testerkit/models/__init__.py +18 -0
  369. testerkit/models/capability.py +598 -0
  370. testerkit/models/catalog.py +76 -0
  371. testerkit/models/data_options.py +153 -0
  372. testerkit/models/enums.py +334 -0
  373. testerkit/models/instrument.py +152 -0
  374. testerkit/models/instrument_asset.py +25 -0
  375. testerkit/models/part.py +271 -0
  376. testerkit/models/part_manifest.py +96 -0
  377. testerkit/models/project.py +114 -0
  378. testerkit/models/station.py +140 -0
  379. testerkit/models/test_config.py +748 -0
  380. testerkit/ontology/__init__.py +39 -0
  381. testerkit/ontology/schema.py +188 -0
  382. testerkit/ontology/testerkit.yaml +1420 -0
  383. testerkit/parts/__init__.py +8 -0
  384. testerkit/parts/context.py +181 -0
  385. testerkit/parts/folder.py +251 -0
  386. testerkit/parts/loader.py +27 -0
  387. testerkit/prompts/__init__.py +17 -0
  388. testerkit/prompts/core.py +133 -0
  389. testerkit/pytest_plugin/__init__.py +1287 -0
  390. testerkit/pytest_plugin/autouse.py +369 -0
  391. testerkit/pytest_plugin/helpers.py +354 -0
  392. testerkit/pytest_plugin/hooks.py +1982 -0
  393. testerkit/pytest_plugin/markers.py +203 -0
  394. testerkit/pytest_plugin/retry.py +37 -0
  395. testerkit/pytest_plugin/sweeps.py +117 -0
  396. testerkit/queries.py +78 -0
  397. testerkit/reports/__init__.py +6 -0
  398. testerkit/reports/core.py +492 -0
  399. testerkit/reports/datasheet.py +831 -0
  400. testerkit/reports/templates/datasheet.html +383 -0
  401. testerkit/reports/templates/default.html +153 -0
  402. testerkit/sbom.py +142 -0
  403. testerkit/schema_export.py +101 -0
  404. testerkit/signals.py +63 -0
  405. testerkit/skills/README.md +58 -0
  406. testerkit/skills/templates/project-instructions.md +125 -0
  407. testerkit/skills/testerkit-analysis/SKILL.md +87 -0
  408. testerkit/skills/testerkit-capture/SKILL.md +114 -0
  409. testerkit/skills/testerkit-data/SKILL.md +138 -0
  410. testerkit/skills/testerkit-datasheets/SKILL.md +100 -0
  411. testerkit/skills/testerkit-datasheets/agents/scaffold-writer.md +115 -0
  412. testerkit/skills/testerkit-datasheets/agents/section-extractor.md +98 -0
  413. testerkit/skills/testerkit-datasheets/agents/section-reviewer.md +153 -0
  414. testerkit/skills/testerkit-datasheets/agents/section-splitter.md +89 -0
  415. testerkit/skills/testerkit-datasheets/agents/section-writer.md +226 -0
  416. testerkit/skills/testerkit-datasheets/references/catalog-pipeline.md +271 -0
  417. testerkit/skills/testerkit-datasheets/references/process-queue.md +42 -0
  418. testerkit/skills/testerkit-datasheets/references/scaffold.md +156 -0
  419. testerkit/skills/testerkit-datasheets/references/test-pipeline.md +312 -0
  420. testerkit/skills/testerkit-debug/SKILL.md +127 -0
  421. testerkit/skills/testerkit-interactive/SKILL.md +188 -0
  422. testerkit/skills/testerkit-interactive/references/live-ui-patterns.md +102 -0
  423. testerkit/skills/testerkit-mocks/SKILL.md +128 -0
  424. testerkit/skills/testerkit-parts/SKILL.md +126 -0
  425. testerkit/skills/testerkit-profiles/SKILL.md +139 -0
  426. testerkit/skills/testerkit-sites/SKILL.md +140 -0
  427. testerkit/skills/testerkit-stations/SKILL.md +146 -0
  428. testerkit/skills/testerkit-tests/SKILL.md +136 -0
  429. testerkit/store.py +1639 -0
  430. testerkit/ui/__init__.py +71 -0
  431. testerkit/ui/_asgi.py +162 -0
  432. testerkit/ui/app.py +32 -0
  433. testerkit/ui/components/__init__.py +13 -0
  434. testerkit/ui/components/artifact_viewer.py +250 -0
  435. testerkit/ui/components/channel_values.py +159 -0
  436. testerkit/ui/components/event_timeline.py +222 -0
  437. testerkit/ui/components/execution_gantt.py +210 -0
  438. testerkit/ui/components/file_streams.py +147 -0
  439. testerkit/ui/components/instrument_activity.py +146 -0
  440. testerkit/ui/components/session_table.py +118 -0
  441. testerkit/ui/pages/__init__.py +25 -0
  442. testerkit/ui/pages/channels/__init__.py +4 -0
  443. testerkit/ui/pages/channels/detail.py +932 -0
  444. testerkit/ui/pages/channels/list.py +515 -0
  445. testerkit/ui/pages/dashboard.py +236 -0
  446. testerkit/ui/pages/designer/__init__.py +5 -0
  447. testerkit/ui/pages/designer/graph.py +635 -0
  448. testerkit/ui/pages/designer/matching.py +663 -0
  449. testerkit/ui/pages/designer/page.py +593 -0
  450. testerkit/ui/pages/designer/properties.py +591 -0
  451. testerkit/ui/pages/designer/state.py +468 -0
  452. testerkit/ui/pages/docs/__init__.py +6 -0
  453. testerkit/ui/pages/docs/index.py +95 -0
  454. testerkit/ui/pages/docs/page.py +698 -0
  455. testerkit/ui/pages/events/__init__.py +3 -0
  456. testerkit/ui/pages/events/list.py +263 -0
  457. testerkit/ui/pages/explore.py +1108 -0
  458. testerkit/ui/pages/files/__init__.py +4 -0
  459. testerkit/ui/pages/files/detail.py +426 -0
  460. testerkit/ui/pages/files/list.py +447 -0
  461. testerkit/ui/pages/fixtures/__init__.py +8 -0
  462. testerkit/ui/pages/fixtures/detail.py +280 -0
  463. testerkit/ui/pages/fixtures/edit.py +370 -0
  464. testerkit/ui/pages/fixtures/list.py +180 -0
  465. testerkit/ui/pages/fixtures/new.py +195 -0
  466. testerkit/ui/pages/instruments/__init__.py +4 -0
  467. testerkit/ui/pages/instruments/detail.py +320 -0
  468. testerkit/ui/pages/instruments/edit.py +482 -0
  469. testerkit/ui/pages/instruments/list.py +249 -0
  470. testerkit/ui/pages/instruments/new.py +200 -0
  471. testerkit/ui/pages/launch.py +261 -0
  472. testerkit/ui/pages/live.py +124 -0
  473. testerkit/ui/pages/metrics_page.py +1229 -0
  474. testerkit/ui/pages/parts/__init__.py +11 -0
  475. testerkit/ui/pages/parts/detail.py +202 -0
  476. testerkit/ui/pages/parts/edit.py +310 -0
  477. testerkit/ui/pages/parts/list.py +160 -0
  478. testerkit/ui/pages/parts/new.py +170 -0
  479. testerkit/ui/pages/parts/requirements.py +153 -0
  480. testerkit/ui/pages/parts/stations.py +199 -0
  481. testerkit/ui/pages/profiles/__init__.py +4 -0
  482. testerkit/ui/pages/profiles/detail.py +113 -0
  483. testerkit/ui/pages/profiles/list.py +86 -0
  484. testerkit/ui/pages/results/__init__.py +4 -0
  485. testerkit/ui/pages/results/detail.py +631 -0
  486. testerkit/ui/pages/results/list.py +230 -0
  487. testerkit/ui/pages/stations/__init__.py +4 -0
  488. testerkit/ui/pages/stations/detail.py +256 -0
  489. testerkit/ui/pages/stations/edit.py +273 -0
  490. testerkit/ui/pages/stations/list.py +180 -0
  491. testerkit/ui/pages/stations/new.py +311 -0
  492. testerkit/ui/pages/tests/__init__.py +4 -0
  493. testerkit/ui/pages/tests/detail.py +152 -0
  494. testerkit/ui/pages/tests/list.py +194 -0
  495. testerkit/ui/pages/uuts/__init__.py +4 -0
  496. testerkit/ui/pages/uuts/list.py +97 -0
  497. testerkit/ui/shared/__init__.py +8 -0
  498. testerkit/ui/shared/components.py +1826 -0
  499. testerkit/ui/shared/dialogs.py +105 -0
  500. testerkit/ui/shared/event_binding.py +194 -0
  501. testerkit/ui/shared/layout.py +314 -0
  502. testerkit/ui/shared/services.py +1719 -0
  503. testerkit/ui/shared/timestamps.py +21 -0
  504. testerkit/ui/static/branding/favicon-16.png +0 -0
  505. testerkit/ui/static/branding/favicon-32.png +0 -0
  506. testerkit/ui/static/branding/favicon-48.png +0 -0
  507. testerkit/ui/static/branding/testerkit-icon-256.png +0 -0
  508. testerkit/ui/static/branding/testerkit-mark.svg +1 -0
  509. testerkit/ui/static/branding/testerkit-wordmark.svg +1 -0
  510. testerkit/ui/static/global.css +465 -0
  511. testerkit/utils/__init__.py +17 -0
  512. testerkit/utils/enum_meta.py +800 -0
  513. testerkit/utils/paths.py +75 -0
  514. testerkit/utils/ranges.py +257 -0
  515. testerkit/validation.py +82 -0
  516. testerkit/verbs.py +142 -0
  517. testerkit-0.4.0.dist-info/METADATA +250 -0
  518. testerkit-0.4.0.dist-info/RECORD +521 -0
  519. testerkit-0.4.0.dist-info/WHEEL +4 -0
  520. testerkit-0.4.0.dist-info/entry_points.txt +5 -0
  521. testerkit-0.4.0.dist-info/licenses/LICENSE +191 -0
@@ -0,0 +1,1458 @@
1
+ """Read-only query client over the runs DuckDB daemon.
2
+
3
+ Each method sends SQL to the daemon over Arrow Flight against the
4
+ ``measurements`` view (the ``measurements_materialized`` table, JOINed with
5
+ ``runs`` for identity, plus the in-flight overlay). Dynamic input/output
6
+ fields are read by anchoring on the core ``measurements`` view and LEFT
7
+ JOINing the typed ``inputs``/``outputs`` tables (one honestly-named table per
8
+ role — no ``role`` column, projection-normalization 0.3.1) per referenced
9
+ field. Returns typed rows (long-format, aggregated, or schema descriptions).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import re
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from testerkit.analysis.measurement_facets import (
20
+ ColumnSchema,
21
+ DynamicFieldDescriptor,
22
+ FacetOption,
23
+ FieldRef,
24
+ FieldRole,
25
+ FilterSet,
26
+ FixedColumnDescriptor,
27
+ HistogramRow,
28
+ LimitBandRow,
29
+ ParametricRow,
30
+ ParetoRow,
31
+ PpkRow,
32
+ RetestRow,
33
+ SummaryCounts,
34
+ TimeLossRow,
35
+ TrendRow,
36
+ YieldRow,
37
+ facet_sql_expr,
38
+ )
39
+ from testerkit.data import runs_duckdb_manager
40
+ from testerkit.data._flight_query import FlightQueryClient
41
+ from testerkit.data._sql_helpers import sql_escape
42
+ from testerkit.data.data_dir import resolve_data_dir
43
+
44
+ _IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
45
+
46
+
47
+ def _safe_ident(name: str) -> str:
48
+ """Reject any identifier that isn't a bare SQL column name."""
49
+ if not _IDENT_RE.match(name):
50
+ raise ValueError(f"invalid column identifier: {name!r}")
51
+ return name
52
+
53
+
54
+ # Join key; IS NOT DISTINCT FROM on nullable columns because NULL = NULL is FALSE in SQL.
55
+ # step_path is part of the key because step_index resets per parent bucket
56
+ # (_collection_indices.assign_indices) — two unswept steps can share
57
+ # step_index=0 with identical NULL vector coords, so step_index alone would
58
+ # cross-join their input/output values onto each other. ``step_retry`` is in
59
+ # the key (full snowflake, 0.3.1 — "step_retry all the way down") so two reruns
60
+ # of the same step don't fan their inputs/outputs into each other.
61
+ _VECTOR_KEY = (
62
+ "{a}.run_id = m.run_id"
63
+ " AND {a}.step_index = m.step_index"
64
+ " AND {a}.step_path IS NOT DISTINCT FROM m.step_path"
65
+ " AND {a}.step_retry IS NOT DISTINCT FROM m.step_retry"
66
+ " AND {a}.vector_index IS NOT DISTINCT FROM m.vector_index"
67
+ " AND {a}.vector_outer_index IS NOT DISTINCT FROM m.vector_outer_index"
68
+ " AND {a}.vector_retry IS NOT DISTINCT FROM m.vector_retry"
69
+ )
70
+
71
+ # Fixed infrastructure columns that a bare string selector resolves to
72
+ # directly, bypassing the FieldRef.measurement() default.
73
+ _FIXED_COLUMNS: frozenset[str] = frozenset(
74
+ {
75
+ "vector_index",
76
+ "vector_retry",
77
+ "step_index",
78
+ "run_started_at",
79
+ "run_ended_at",
80
+ "step_started_at",
81
+ "step_ended_at",
82
+ "measurement_timestamp",
83
+ "limit_low",
84
+ "limit_high",
85
+ "limit_nominal",
86
+ "measurement_value",
87
+ "measurement_name",
88
+ "measurement_outcome",
89
+ "measurement_unit",
90
+ "run_outcome",
91
+ "step_outcome",
92
+ "vector_outcome",
93
+ "uut_serial_number",
94
+ "uut_part_number",
95
+ "uut_revision",
96
+ "uut_lot_number",
97
+ "test_phase",
98
+ "step_name",
99
+ "step_path",
100
+ "limit_comparator",
101
+ "uut_pin",
102
+ "index",
103
+ }
104
+ )
105
+
106
+ # Registry of operator-meaningful fixed columns exposed as plot axes by
107
+ # describe_columns(). Excludes identity/admin columns (UUIDs, file_path, etc.).
108
+ _PLOTTABLE_FIXED_COLUMNS: tuple[tuple[str, str], ...] = (
109
+ ("run_started_at", "TIMESTAMPTZ"),
110
+ ("run_ended_at", "TIMESTAMPTZ"),
111
+ ("step_started_at", "TIMESTAMPTZ"),
112
+ ("step_ended_at", "TIMESTAMPTZ"),
113
+ ("measurement_timestamp", "TIMESTAMPTZ"),
114
+ ("vector_index", "BIGINT"),
115
+ ("vector_retry", "BIGINT"),
116
+ ("step_index", "INTEGER"),
117
+ ("measurement_value", "DOUBLE"),
118
+ ("measurement_name", "VARCHAR"),
119
+ ("measurement_outcome", "VARCHAR"),
120
+ ("measurement_unit", "VARCHAR"),
121
+ ("run_outcome", "VARCHAR"),
122
+ ("step_outcome", "VARCHAR"),
123
+ ("vector_outcome", "VARCHAR"),
124
+ ("limit_low", "DOUBLE"),
125
+ ("limit_high", "DOUBLE"),
126
+ ("limit_nominal", "DOUBLE"),
127
+ ("limit_comparator", "VARCHAR"),
128
+ ("uut_serial_number", "VARCHAR"),
129
+ ("uut_part_number", "VARCHAR"),
130
+ ("uut_pin", "VARCHAR"),
131
+ ("test_phase", "VARCHAR"),
132
+ ("step_name", "VARCHAR"),
133
+ ("index", "BIGINT"),
134
+ )
135
+
136
+ assert all( # noqa: S101
137
+ name in _FIXED_COLUMNS for name, _ in _PLOTTABLE_FIXED_COLUMNS
138
+ ), "Drift: _PLOTTABLE_FIXED_COLUMNS contains names absent from _FIXED_COLUMNS"
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Occurrence index — now a MATERIALIZED column on the ``measurements`` view
143
+ # ---------------------------------------------------------------------------
144
+ # ``index`` is the 0-based occurrence ordinal of a measurement within its run,
145
+ # per ``measurement_name``, ordered by execution position (step, then vector),
146
+ # retries EXCLUDED. Full snowflake (0.3.1 phase 8): it is computed ONCE at
147
+ # ingest (``_runs_duckdb_daemon._occurrence_index_expr``) and stored as a
148
+ # column — the ``measurements`` view exposes it directly (the daemon computes
149
+ # the same window for not-yet-materialized inflight rows). So a read that plots
150
+ # ``index`` just selects ``m.index``; there is no longer a query-time
151
+ # ``DENSE_RANK`` wrap. ``index`` stays DERIVED (rebuilt from parquet on ingest),
152
+ # not an at-rest field.
153
+
154
+
155
+ def _resolve_selector(selector: str | FieldRef) -> str | FieldRef:
156
+ """Normalize a bare string or FieldRef.
157
+
158
+ Returns a bare ``str`` when the selector names a fixed infrastructure
159
+ column (caller uses ``m.<col>`` directly). Returns a ``FieldRef`` for
160
+ real EAV/measurement fields.
161
+ """
162
+ if isinstance(selector, str):
163
+ if selector in _FIXED_COLUMNS:
164
+ return selector
165
+ return FieldRef.measurement(selector)
166
+ return selector
167
+
168
+
169
+ def _eav_typed_expr(ref: FieldRef, alias: str) -> str:
170
+ """Typed SQL expression selecting the correct value_* column from an EAV alias.
171
+
172
+ When ``value_type`` is ``None`` (field absent from the ``inputs``/
173
+ ``outputs`` table), returns a typed-NULL expression so callers'
174
+ ``IS NOT NULL`` filters yield empty results rather than a SQL type error.
175
+ """
176
+ vt = ref.value_type or ""
177
+ if vt == "scalar:bool":
178
+ return f"{alias}.value_bool"
179
+ if vt == "scalar:int":
180
+ return f"CAST({alias}.value_int AS DOUBLE)"
181
+ if vt == "scalar:float":
182
+ return f"{alias}.value_double"
183
+ if vt == "scalar:datetime":
184
+ return f"{alias}.value_timestamp"
185
+ if vt in ("list", "dict"):
186
+ return f"{alias}.value_json"
187
+ if not vt:
188
+ return "CAST(NULL AS DOUBLE)"
189
+ return f"{alias}.value_text"
190
+
191
+
192
+ # Which table a role-scoped EAV read hits — the table IS the role
193
+ # (projection-normalization, 0.3.1: ``measurements_dynamic``'s ``role``
194
+ # column is gone; a role-scoped query selects its matching table instead of
195
+ # filtering ``role = 'input'``/``'output'``).
196
+ _ROLE_TABLE: dict[FieldRole, str] = {
197
+ FieldRole.INPUT: "inputs",
198
+ FieldRole.OUTPUT: "outputs",
199
+ }
200
+
201
+
202
+ class _EAVJoins:
203
+ """Collects EAV joins for input/output FieldRefs and emits their SQL.
204
+
205
+ Also accumulates measurement-name predicates for MEASUREMENT-role
206
+ FieldRefs so ``parametric(y="v_rail")`` scopes to that measurement.
207
+ """
208
+
209
+ def __init__(self) -> None:
210
+ self._joins: list[tuple[str, FieldRef]] = [] # (alias, ref)
211
+ self._seen: dict[tuple[str, str, str | None], str] = {}
212
+ self._meas_name_predicates: list[str] = []
213
+
214
+ def register(self, ref: FieldRef) -> str:
215
+ """Register a FieldRef and return its join alias."""
216
+ key = (ref.role.value, ref.name, ref.value_type)
217
+ existing = self._seen.get(key)
218
+ if existing is not None:
219
+ return existing
220
+ alias = f"eav_{len(self._joins)}"
221
+ self._joins.append((alias, ref))
222
+ self._seen[key] = alias
223
+ return alias
224
+
225
+ def add_meas_name_predicate(self, name: str) -> None:
226
+ """Record a measurement_name scoping predicate for a MEASUREMENT FieldRef."""
227
+ pred = f"m.measurement_name = '{sql_escape(name)}'"
228
+ if pred not in self._meas_name_predicates:
229
+ self._meas_name_predicates.append(pred)
230
+
231
+ def meas_name_clauses(self) -> list[str]:
232
+ """Return accumulated measurement_name predicates for the WHERE clause."""
233
+ return list(self._meas_name_predicates)
234
+
235
+ def join_sql(self) -> str:
236
+ clauses = []
237
+ for alias, ref in self._joins:
238
+ vkey = _VECTOR_KEY.format(a=alias)
239
+ table = _ROLE_TABLE[ref.role]
240
+ name_pred = f" AND {alias}.name = '{sql_escape(ref.name)}'"
241
+ vtype_pred = (
242
+ f" AND {alias}.value_type = '{sql_escape(ref.value_type)}'"
243
+ if ref.value_type
244
+ else ""
245
+ )
246
+ clauses.append(f" LEFT JOIN {table} {alias} ON {vkey}{name_pred}{vtype_pred}")
247
+ return "".join(clauses)
248
+
249
+
250
+ def _filter_clauses(filters: FilterSet | None) -> list[str]:
251
+ """Build SQL WHERE-clause fragments from a ``FilterSet``.
252
+
253
+ Multi-value filters become ``col IN ('a','b','c')``. Date range
254
+ bounds become ``CAST(run_started_at AS DATE) >= 'YYYY-MM-DD'``.
255
+ Empty / None filter sets contribute nothing.
256
+ """
257
+ if filters is None:
258
+ return []
259
+ clauses: list[str] = []
260
+ for col, values in {**filters.string_filters, **filters.enum_filters}.items():
261
+ if not values:
262
+ continue
263
+ escaped = ", ".join(f"'{sql_escape(v)}'" for v in values)
264
+ col_expr = facet_sql_expr(col) or _safe_ident(col)
265
+ clauses.append(f"{col_expr} IN ({escaped})")
266
+ if filters.since is not None:
267
+ clauses.append(f"CAST(run_started_at AS DATE) >= '{filters.since.isoformat()}'")
268
+ if filters.until is not None:
269
+ clauses.append(f"CAST(run_started_at AS DATE) <= '{filters.until.isoformat()}'")
270
+ return clauses
271
+
272
+
273
+ def _filters_excluding(filters: FilterSet | None, column: str) -> FilterSet | None:
274
+ """Return a copy of ``filters`` with ``column`` removed — for cross-filtering.
275
+
276
+ Used when populating a facet's own options: that facet's value
277
+ must NOT be a constraint, otherwise the option list collapses to
278
+ whatever the user already picked.
279
+ """
280
+ if filters is None:
281
+ return None
282
+ return FilterSet(
283
+ string_filters={k: v for k, v in filters.string_filters.items() if k != column},
284
+ enum_filters={k: v for k, v in filters.enum_filters.items() if k != column},
285
+ since=filters.since,
286
+ until=filters.until,
287
+ )
288
+
289
+
290
+ logger = logging.getLogger(__name__)
291
+
292
+
293
+ def _build_filter_clauses(
294
+ *,
295
+ part: str | list[str] | None = None,
296
+ station: str | list[str] | None = None,
297
+ phase: str | list[str] | None = None,
298
+ since: str | None = None,
299
+ until: str | None = None,
300
+ part_expr: str = "part",
301
+ station_expr: str = "station",
302
+ phase_expr: str = "phase",
303
+ date_expr: str = "period_day",
304
+ ) -> list[str]:
305
+ """Build SQL filter clauses from parameters.
306
+
307
+ Each filter accepts a single value, a list (multi-select), or
308
+ ``None`` (no filter). Lists become ``IN (...)`` clauses; single
309
+ values become ``= ...`` clauses.
310
+
311
+ Phase filtering:
312
+ - phase=None (default): excludes ``development`` phase
313
+ - phase='all' or ['all']: no phase filter (includes development)
314
+ - phase='<value>' or list: filters to those specific phases
315
+
316
+ The ``*_expr`` parameters control the SQL column/expression names,
317
+ allowing the same filter logic to work in both subquery-based queries
318
+ (where columns are aliased) and inline queries (where raw column
319
+ names with COALESCE wrappers are used).
320
+ """
321
+ clauses: list[str] = []
322
+ phase_values = _coerce_filter_values(phase)
323
+ if phase_values and "all" in phase_values:
324
+ # 'all' is a sentinel meaning "no phase filter" — match
325
+ # historical single-value behavior even when bundled in a
326
+ # list (e.g. ['all', 'production'] disables filtering).
327
+ pass
328
+ elif phase_values:
329
+ clauses.append(_in_or_eq(phase_expr, phase_values))
330
+ else:
331
+ # Default: hide development phase from analytics
332
+ clauses.append(f"{phase_expr} != 'development'")
333
+ part_values = _coerce_filter_values(part)
334
+ if part_values:
335
+ clauses.append(_in_or_eq(part_expr, part_values))
336
+ station_values = _coerce_filter_values(station)
337
+ if station_values:
338
+ clauses.append(_in_or_eq(station_expr, station_values))
339
+ if since:
340
+ clauses.append(f"{date_expr} >= '{sql_escape(since)}'")
341
+ if until:
342
+ clauses.append(f"{date_expr} <= '{sql_escape(until)}'")
343
+ return clauses
344
+
345
+
346
+ def _coerce_filter_values(value: str | list[str] | None) -> list[str]:
347
+ """Normalize a filter value into a list of non-empty strings.
348
+
349
+ ``None`` / empty string / empty list → ``[]`` (no filter).
350
+ Single string → ``[value]``.
351
+ List → filter out empty strings.
352
+ """
353
+ if value is None or value == "":
354
+ return []
355
+ if isinstance(value, str):
356
+ return [value]
357
+ return [v for v in value if v]
358
+
359
+
360
+ def _in_or_eq(column_expr: str, values: list[str]) -> str:
361
+ """Render ``column = 'x'`` for one value or ``column IN (...)`` for many.
362
+
363
+ DuckDB handles both the same way at the planner level; the
364
+ ``=`` form is just shorter and reads better in logs.
365
+ """
366
+ if len(values) == 1:
367
+ return f"{column_expr} = '{sql_escape(values[0])}'"
368
+ quoted = ", ".join(f"'{sql_escape(v)}'" for v in values)
369
+ return f"{column_expr} IN ({quoted})"
370
+
371
+
372
+ def _build_where(
373
+ *,
374
+ part: str | list[str] | None = None,
375
+ station: str | list[str] | None = None,
376
+ phase: str | list[str] | None = None,
377
+ since: str | None = None,
378
+ until: str | None = None,
379
+ ) -> str:
380
+ """Build a SQL ``WHERE`` clause for subquery-based queries.
381
+
382
+ Uses aliased column names (part, station, phase, period_day).
383
+ """
384
+ clauses = _build_filter_clauses(
385
+ part=part,
386
+ station=station,
387
+ phase=phase,
388
+ since=since,
389
+ until=until,
390
+ )
391
+ return (" WHERE " + " AND ".join(clauses)) if clauses else ""
392
+
393
+
394
+ def _build_and_clauses(
395
+ *,
396
+ part: str | list[str] | None = None,
397
+ station: str | list[str] | None = None,
398
+ phase: str | list[str] | None = None,
399
+ since: str | None = None,
400
+ until: str | None = None,
401
+ ) -> str:
402
+ """Build SQL ``AND`` clauses for inline queries (pareto, ppk).
403
+
404
+ Uses raw measurement column names with COALESCE wrappers.
405
+ """
406
+ clauses = _build_filter_clauses(
407
+ part=part,
408
+ station=station,
409
+ phase=phase,
410
+ since=since,
411
+ until=until,
412
+ part_expr="COALESCE(uut_part_number, 'unknown')",
413
+ # Match the same column the operator's dropdown is built
414
+ # from (``station_hostname`` first; see ``get_runs_filter_options``
415
+ # in ``ui/shared/services.py``). ``station_name`` is admin-
416
+ # facing — never used as a filter target.
417
+ station_expr="COALESCE(station_hostname, 'unknown')",
418
+ phase_expr="COALESCE(test_phase, 'unknown')",
419
+ date_expr="CAST(run_started_at AS DATE)",
420
+ )
421
+ return ("\n AND " + "\n AND ".join(clauses)) if clauses else ""
422
+
423
+
424
+ def _period_col(period: str) -> str:
425
+ """Return the SQL expression for a period bucket (measurements view column: run_started_at)."""
426
+ if period == "week":
427
+ return "DATE_TRUNC('week', run_started_at::TIMESTAMP)::DATE"
428
+ if period == "month":
429
+ return "DATE_TRUNC('month', run_started_at::TIMESTAMP)::DATE"
430
+ return "CAST(run_started_at AS DATE)"
431
+
432
+
433
+ # ---------------------------------------------------------------------------
434
+ # SQL templates — pure-aggregate metric paths read the LEAN ``measurement_facts``
435
+ # view (fact + runs identity only); ``pareto`` (needs ``step_name``) reads the
436
+ # full ``measurements`` view. Both are daemon views (see _create_views); the
437
+ # lean one skips the ``steps``/``vectors`` reconstruction joins that DuckDB
438
+ # won't prune for an aggregate that never reads those columns (0.3.1 phase 9).
439
+ # ---------------------------------------------------------------------------
440
+
441
+ _YIELD_SQL = """
442
+ WITH runs AS (
443
+ SELECT DISTINCT ON (run_id)
444
+ run_id,
445
+ COALESCE(uut_part_number, 'unknown') AS part,
446
+ COALESCE(station_hostname, 'unknown') AS station,
447
+ COALESCE(test_phase, 'unknown') AS phase,
448
+ uut_serial_number,
449
+ run_outcome,
450
+ run_started_at,
451
+ run_ended_at,
452
+ {period_expr} AS period_day
453
+ FROM measurement_facts
454
+ ORDER BY run_id
455
+ ),
456
+ runs_filtered AS (
457
+ SELECT * FROM runs {where}
458
+ ),
459
+ first_runs AS (
460
+ SELECT *,
461
+ ROW_NUMBER() OVER (
462
+ PARTITION BY uut_serial_number, part, station, phase
463
+ ORDER BY run_started_at
464
+ ) AS rn
465
+ FROM runs_filtered
466
+ ),
467
+ last_runs AS (
468
+ SELECT *,
469
+ ROW_NUMBER() OVER (
470
+ PARTITION BY uut_serial_number, part, station, phase
471
+ ORDER BY run_started_at DESC
472
+ ) AS rn
473
+ FROM runs_filtered
474
+ ),
475
+ step_agg AS (
476
+ -- Per-(part, station, phase, period_day, step_path) pass rate, joined from
477
+ -- steps + runs views. Filters to runs already matching the caller's scope
478
+ -- by joining on run_id from runs_filtered.
479
+ SELECT
480
+ rf.part,
481
+ rf.station,
482
+ rf.phase,
483
+ rf.period_day,
484
+ s.step_path,
485
+ COUNT(*) AS step_total,
486
+ COUNT(*) FILTER (WHERE s.outcome = 'passed') AS step_passed
487
+ FROM steps AS s
488
+ JOIN runs_filtered AS rf USING (run_id)
489
+ WHERE s.step_path IS NOT NULL AND s.outcome IS NOT NULL
490
+ GROUP BY rf.part, rf.station, rf.phase, rf.period_day, s.step_path
491
+ ),
492
+ step_rty AS (
493
+ -- RTY = EXP(SUM(LN(step_fpy))) over distinct steps, guarded against zero.
494
+ SELECT
495
+ part,
496
+ station,
497
+ phase,
498
+ period_day,
499
+ EXP(SUM(LN(NULLIF(step_passed::DOUBLE / NULLIF(step_total, 0), 0)))) AS rty
500
+ FROM step_agg
501
+ GROUP BY part, station, phase, period_day
502
+ ),
503
+ meas_agg AS (
504
+ -- Per-(part, station, phase, period_day) measurement defect counts.
505
+ -- Predicate mirrors pareto: failed=measurement_outcome='failed'.
506
+ SELECT
507
+ rf.part,
508
+ rf.station,
509
+ rf.phase,
510
+ rf.period_day,
511
+ COUNT(*) AS total_measurements,
512
+ COUNT(*) FILTER (WHERE m.measurement_outcome = 'failed') AS failed_measurements
513
+ FROM measurement_facts AS m
514
+ JOIN runs_filtered AS rf USING (run_id)
515
+ GROUP BY rf.part, rf.station, rf.phase, rf.period_day
516
+ ),
517
+ meas_dpmo AS (
518
+ -- DPMO = failed measurement opportunities / total measurement opportunities x 1e6.
519
+ SELECT
520
+ part,
521
+ station,
522
+ phase,
523
+ period_day,
524
+ ROUND(failed_measurements * 1000000.0 / NULLIF(total_measurements, 0), 0) AS dpmo
525
+ FROM meas_agg
526
+ ),
527
+ run_dppm AS (
528
+ -- DPPM = (failed + errored) runs / total_runs x 1e6.
529
+ SELECT
530
+ part,
531
+ station,
532
+ phase,
533
+ period_day,
534
+ ROUND(
535
+ COUNT(*) FILTER (WHERE run_outcome IN ('failed', 'errored')) * 1000000.0
536
+ / NULLIF(COUNT(*), 0),
537
+ 0) AS dppm
538
+ FROM runs_filtered
539
+ GROUP BY part, station, phase, period_day
540
+ )
541
+ SELECT
542
+ r.part,
543
+ r.station,
544
+ r.phase,
545
+ r.period_day AS period,
546
+ COUNT(*) AS total_runs,
547
+ COUNT(*) FILTER (WHERE r.run_outcome = 'passed') AS passed,
548
+ COUNT(*) FILTER (WHERE r.run_outcome = 'failed') AS failed,
549
+ COUNT(*) FILTER (WHERE r.run_outcome = 'errored') AS errored,
550
+ COUNT(DISTINCT r.uut_serial_number) AS unique_serials,
551
+ COUNT(DISTINCT r.uut_serial_number) FILTER (
552
+ WHERE r.run_id IN (SELECT run_id FROM first_runs WHERE rn = 1)
553
+ ) AS first_pass_total,
554
+ COUNT(DISTINCT r.uut_serial_number) FILTER (
555
+ WHERE r.run_id IN (SELECT run_id FROM first_runs WHERE rn = 1 AND run_outcome = 'passed')
556
+ ) AS first_pass_passed,
557
+ COUNT(DISTINCT r.uut_serial_number) FILTER (
558
+ WHERE r.run_id IN (SELECT run_id FROM last_runs WHERE rn = 1 AND run_outcome = 'passed')
559
+ ) AS final_passed,
560
+ ROUND(AVG(EPOCH(r.run_ended_at::TIMESTAMP - r.run_started_at::TIMESTAMP)), 2) AS avg_duration_s,
561
+ ROUND(QUANTILE_CONT(EPOCH(r.run_ended_at::TIMESTAMP - r.run_started_at::TIMESTAMP), 0.95), 2)
562
+ AS p95_duration_s,
563
+ ROUND(MIN(EPOCH(r.run_ended_at::TIMESTAMP - r.run_started_at::TIMESTAMP)), 2) AS min_duration_s,
564
+ ROUND(MAX(EPOCH(r.run_ended_at::TIMESTAMP - r.run_started_at::TIMESTAMP)), 2) AS max_duration_s,
565
+ sr.rty,
566
+ md.dpmo,
567
+ rd.dppm
568
+ FROM runs_filtered AS r
569
+ LEFT JOIN step_rty AS sr
570
+ ON sr.part = r.part AND sr.station = r.station
571
+ AND sr.phase = r.phase AND sr.period_day = r.period_day
572
+ LEFT JOIN meas_dpmo AS md
573
+ ON md.part = r.part AND md.station = r.station
574
+ AND md.phase = r.phase AND md.period_day = r.period_day
575
+ LEFT JOIN run_dppm AS rd
576
+ ON rd.part = r.part AND rd.station = r.station
577
+ AND rd.phase = r.phase AND rd.period_day = r.period_day
578
+ GROUP BY r.part, r.station, r.phase, r.period_day, sr.rty, md.dpmo, rd.dppm
579
+ ORDER BY r.period_day
580
+ """
581
+
582
+ _YIELD_OVERALL_SQL = """
583
+ WITH runs AS (
584
+ SELECT DISTINCT ON (run_id)
585
+ run_id,
586
+ COALESCE(uut_part_number, 'unknown') AS part,
587
+ COALESCE(station_hostname, 'unknown') AS station,
588
+ COALESCE(test_phase, 'unknown') AS phase,
589
+ uut_serial_number,
590
+ run_outcome,
591
+ run_started_at,
592
+ run_ended_at,
593
+ CAST(run_started_at AS DATE) AS period_day
594
+ FROM measurement_facts
595
+ ORDER BY run_id
596
+ ),
597
+ runs_filtered AS (
598
+ SELECT * FROM runs {where}
599
+ ),
600
+ first_runs AS (
601
+ SELECT *,
602
+ ROW_NUMBER() OVER (
603
+ PARTITION BY uut_serial_number
604
+ ORDER BY run_started_at
605
+ ) AS rn
606
+ FROM runs_filtered
607
+ ),
608
+ last_runs AS (
609
+ SELECT *,
610
+ ROW_NUMBER() OVER (
611
+ PARTITION BY uut_serial_number
612
+ ORDER BY run_started_at DESC
613
+ ) AS rn
614
+ FROM runs_filtered
615
+ ),
616
+ step_agg AS (
617
+ SELECT
618
+ s.step_path,
619
+ COUNT(*) AS step_total,
620
+ COUNT(*) FILTER (WHERE s.outcome = 'passed') AS step_passed
621
+ FROM steps AS s
622
+ JOIN runs_filtered AS rf USING (run_id)
623
+ WHERE s.step_path IS NOT NULL AND s.outcome IS NOT NULL
624
+ GROUP BY s.step_path
625
+ ),
626
+ step_rty AS (
627
+ SELECT
628
+ EXP(SUM(LN(NULLIF(step_passed::DOUBLE / NULLIF(step_total, 0), 0)))) AS rty
629
+ FROM step_agg
630
+ ),
631
+ meas_agg AS (
632
+ -- Measurement defect counts over all filtered runs.
633
+ -- Predicate mirrors pareto: failed=measurement_outcome='failed'.
634
+ SELECT
635
+ COUNT(*) AS total_measurements,
636
+ COUNT(*) FILTER (WHERE m.measurement_outcome = 'failed') AS failed_measurements
637
+ FROM measurement_facts AS m
638
+ JOIN runs_filtered AS rf USING (run_id)
639
+ ),
640
+ meas_dpmo AS (
641
+ -- DPMO = failed measurement opportunities / total measurement opportunities x 1e6.
642
+ SELECT
643
+ ROUND(failed_measurements * 1000000.0 / NULLIF(total_measurements, 0), 0) AS dpmo
644
+ FROM meas_agg
645
+ ),
646
+ run_dppm AS (
647
+ SELECT
648
+ ROUND(
649
+ COUNT(*) FILTER (WHERE run_outcome IN ('failed', 'errored')) * 1000000.0
650
+ / NULLIF(COUNT(*), 0),
651
+ 0) AS dppm
652
+ FROM runs_filtered
653
+ )
654
+ SELECT
655
+ 'all' AS part,
656
+ 'all' AS station,
657
+ 'all' AS phase,
658
+ 'all' AS period,
659
+ COUNT(*) AS total_runs,
660
+ COUNT(*) FILTER (WHERE r.run_outcome = 'passed') AS passed,
661
+ COUNT(*) FILTER (WHERE r.run_outcome = 'failed') AS failed,
662
+ COUNT(*) FILTER (WHERE r.run_outcome = 'errored') AS errored,
663
+ COUNT(DISTINCT r.uut_serial_number) AS unique_serials,
664
+ COUNT(DISTINCT r.uut_serial_number) FILTER (
665
+ WHERE r.run_id IN (SELECT run_id FROM first_runs WHERE rn = 1)
666
+ ) AS first_pass_total,
667
+ COUNT(DISTINCT r.uut_serial_number) FILTER (
668
+ WHERE r.run_id IN (SELECT run_id FROM first_runs WHERE rn = 1 AND run_outcome = 'passed')
669
+ ) AS first_pass_passed,
670
+ COUNT(DISTINCT r.uut_serial_number) FILTER (
671
+ WHERE r.run_id IN (SELECT run_id FROM last_runs WHERE rn = 1 AND run_outcome = 'passed')
672
+ ) AS final_passed,
673
+ ROUND(AVG(EPOCH(r.run_ended_at::TIMESTAMP - r.run_started_at::TIMESTAMP)), 2) AS avg_duration_s,
674
+ ROUND(QUANTILE_CONT(EPOCH(r.run_ended_at::TIMESTAMP - r.run_started_at::TIMESTAMP), 0.95), 2)
675
+ AS p95_duration_s,
676
+ ROUND(MIN(EPOCH(r.run_ended_at::TIMESTAMP - r.run_started_at::TIMESTAMP)), 2) AS min_duration_s,
677
+ ROUND(MAX(EPOCH(r.run_ended_at::TIMESTAMP - r.run_started_at::TIMESTAMP)), 2) AS max_duration_s,
678
+ sr.rty,
679
+ md.dpmo,
680
+ rd.dppm
681
+ FROM runs_filtered AS r
682
+ CROSS JOIN step_rty AS sr
683
+ CROSS JOIN meas_dpmo AS md
684
+ CROSS JOIN run_dppm AS rd
685
+ GROUP BY sr.rty, md.dpmo, rd.dppm
686
+ """
687
+
688
+ _PARETO_SQL = """
689
+ SELECT
690
+ COALESCE(uut_part_number, 'unknown') AS part,
691
+ COALESCE(station_hostname, 'unknown') AS station,
692
+ step_name,
693
+ measurement_name,
694
+ COUNT(*) AS total_count,
695
+ COUNT(*) FILTER (WHERE measurement_outcome = 'failed') AS fail_count,
696
+ ROUND(COUNT(*) FILTER (WHERE measurement_outcome = 'failed') * 100.0
697
+ / NULLIF(COUNT(*), 0), 2) AS fail_rate
698
+ FROM measurements
699
+ WHERE TRUE
700
+ {and_clauses}
701
+ GROUP BY part, station, step_name, measurement_name
702
+ HAVING COUNT(*) FILTER (WHERE measurement_outcome = 'failed') > 0
703
+ ORDER BY fail_count DESC
704
+ LIMIT {top_n}
705
+ """
706
+
707
+ _PPK_SQL = """
708
+ SELECT
709
+ COALESCE(uut_part_number, 'unknown') AS part,
710
+ COALESCE(station_hostname, 'unknown') AS station,
711
+ measurement_name,
712
+ characteristic_id,
713
+ uut_pin,
714
+ COUNT(*) AS n,
715
+ ROUND(AVG(measurement_value), 6) AS mean,
716
+ ROUND(STDDEV_SAMP(measurement_value), 6) AS sigma,
717
+ MIN(limit_low) AS lsl,
718
+ MAX(limit_high) AS usl,
719
+ CASE WHEN STDDEV_SAMP(measurement_value) > 0
720
+ AND MIN(limit_low) IS NOT NULL AND MAX(limit_high) IS NOT NULL
721
+ THEN ROUND((MAX(limit_high) - MIN(limit_low))
722
+ / (6 * STDDEV_SAMP(measurement_value)), 3)
723
+ END AS pp,
724
+ CASE WHEN STDDEV_SAMP(measurement_value) > 0
725
+ AND (MIN(limit_low) IS NOT NULL OR MAX(limit_high) IS NOT NULL)
726
+ THEN ROUND(LEAST(
727
+ COALESCE((MAX(limit_high) - AVG(measurement_value))
728
+ / (3 * STDDEV_SAMP(measurement_value)), 1e9),
729
+ COALESCE((AVG(measurement_value) - MIN(limit_low))
730
+ / (3 * STDDEV_SAMP(measurement_value)), 1e9)
731
+ ), 3)
732
+ END AS ppk
733
+ FROM measurement_facts
734
+ WHERE measurement_value IS NOT NULL
735
+ {and_clauses}
736
+ GROUP BY part, station, measurement_name, characteristic_id, uut_pin, limit_low, limit_high
737
+ HAVING COUNT(*) >= {min_samples}
738
+ ORDER BY ppk ASC NULLS LAST
739
+ """
740
+
741
+ _TREND_SQL = """
742
+ WITH runs AS (
743
+ SELECT DISTINCT ON (run_id)
744
+ run_id,
745
+ COALESCE(uut_part_number, 'unknown') AS part,
746
+ COALESCE(station_hostname, 'unknown') AS station,
747
+ COALESCE(test_phase, 'unknown') AS phase,
748
+ run_outcome,
749
+ {period_expr} AS period_day
750
+ FROM measurement_facts
751
+ ORDER BY run_id
752
+ )
753
+ SELECT
754
+ part,
755
+ station,
756
+ phase,
757
+ period_day AS period,
758
+ COUNT(*) AS total,
759
+ COUNT(*) FILTER (WHERE run_outcome = 'passed') AS passed,
760
+ ROUND(COUNT(*) FILTER (WHERE run_outcome = 'passed') * 100.0
761
+ / NULLIF(COUNT(*), 0), 1) AS yield_pct
762
+ FROM runs
763
+ {where}
764
+ GROUP BY part, station, phase, period_day
765
+ ORDER BY period_day
766
+ """
767
+
768
+ _RETEST_SQL = """
769
+ WITH runs AS (
770
+ SELECT DISTINCT ON (run_id)
771
+ run_id,
772
+ COALESCE(uut_part_number, 'unknown') AS part,
773
+ COALESCE(station_hostname, 'unknown') AS station,
774
+ COALESCE(test_phase, 'unknown') AS phase,
775
+ uut_serial_number,
776
+ {period_expr} AS period_day
777
+ FROM measurement_facts
778
+ ORDER BY run_id
779
+ ),
780
+ serial_counts AS (
781
+ SELECT part, station, phase, period_day,
782
+ uut_serial_number, COUNT(*) AS executions
783
+ FROM runs
784
+ WHERE uut_serial_number IS NOT NULL
785
+ GROUP BY part, station, phase, period_day, uut_serial_number
786
+ )
787
+ SELECT
788
+ part,
789
+ station,
790
+ phase,
791
+ period_day AS period,
792
+ COUNT(*) AS total_serials,
793
+ COUNT(*) FILTER (WHERE executions > 1) AS retested_count,
794
+ ROUND(COUNT(*) FILTER (WHERE executions > 1) * 100.0
795
+ / NULLIF(COUNT(*), 0), 2) AS retest_rate,
796
+ ROUND(AVG(executions - 1), 2) AS avg_retries
797
+ FROM serial_counts
798
+ {where}
799
+ GROUP BY part, station, phase, period_day
800
+ ORDER BY period_day
801
+ """
802
+
803
+ _TIME_LOSS_SQL = """
804
+ WITH runs AS (
805
+ SELECT DISTINCT ON (run_id)
806
+ run_id,
807
+ COALESCE(uut_part_number, 'unknown') AS part,
808
+ COALESCE(station_hostname, 'unknown') AS station,
809
+ COALESCE(test_phase, 'unknown') AS phase,
810
+ run_outcome,
811
+ EPOCH(run_ended_at::TIMESTAMP - run_started_at::TIMESTAMP) AS duration_s,
812
+ {period_expr} AS period_day
813
+ FROM measurement_facts
814
+ WHERE run_started_at IS NOT NULL AND run_ended_at IS NOT NULL
815
+ ORDER BY run_id
816
+ )
817
+ SELECT
818
+ part,
819
+ station,
820
+ phase,
821
+ period_day AS period,
822
+ ROUND(SUM(duration_s), 2) AS total_time_s,
823
+ ROUND(SUM(duration_s) FILTER (WHERE run_outcome = 'passed'), 2) AS pass_time_s,
824
+ ROUND(SUM(duration_s) FILTER (WHERE run_outcome = 'failed'), 2) AS fail_time_s,
825
+ ROUND(SUM(duration_s) FILTER (WHERE run_outcome = 'errored'), 2) AS error_time_s
826
+ FROM runs
827
+ {where}
828
+ GROUP BY part, station, phase, period_day
829
+ ORDER BY period_day
830
+ """
831
+
832
+
833
+ # ---------------------------------------------------------------------------
834
+ # MeasurementsQuery
835
+ # ---------------------------------------------------------------------------
836
+
837
+
838
+ class MeasurementsQuery:
839
+ """Read-only client over the runs DuckDB daemon's ``measurements`` view.
840
+
841
+ The daemon exposes a single ``measurements`` view that unions all
842
+ raw measurement parquet files. Methods on this class send SQL
843
+ queries over Arrow Flight and return typed result rows.
844
+
845
+ Construct once and reuse — no explicit close needed::
846
+
847
+ q = MeasurementsQuery()
848
+ rows = q.yield_summary(part="PN-123", period="week")
849
+
850
+ Or use as a context manager for deterministic cleanup::
851
+
852
+ with MeasurementsQuery() as q:
853
+ rows = q.yield_summary(part="PN-123", period="week")
854
+ """
855
+
856
+ def __init__(self, *, _data_dir: Path | str | None = None) -> None:
857
+ data_dir = resolve_data_dir(_data_dir)
858
+ self._runs_dir = data_dir / "runs"
859
+ self._runs_dir.mkdir(parents=True, exist_ok=True)
860
+
861
+ location = runs_duckdb_manager.acquire(self._runs_dir)
862
+ self._flight = FlightQueryClient(
863
+ location,
864
+ "runs",
865
+ reacquire=lambda: runs_duckdb_manager.acquire(self._runs_dir),
866
+ label="MeasurementsQuery",
867
+ )
868
+
869
+ def _query_dicts(self, sql: str) -> list[dict[str, Any]]:
870
+ """Execute SQL via Flight and return list of dicts."""
871
+ return self._flight.query(sql)
872
+
873
+ def close(self) -> None:
874
+ """Release daemon ref and close Flight client."""
875
+ self._flight.close()
876
+ runs_duckdb_manager.release(self._runs_dir)
877
+
878
+ def __enter__(self) -> MeasurementsQuery:
879
+ return self
880
+
881
+ def __exit__(self, *_: object) -> None:
882
+ self.close()
883
+
884
+ def yield_summary(
885
+ self,
886
+ *,
887
+ part: str | list[str] | None = None,
888
+ station: str | list[str] | None = None,
889
+ phase: str | list[str] | None = None,
890
+ since: str | None = None,
891
+ until: str | None = None,
892
+ period: str = "day",
893
+ ) -> list[YieldRow]:
894
+ """Yield summary: FPY, final yield, run counts, duration stats.
895
+
896
+ Returns one row per (part, station, phase, period).
897
+ """
898
+ where = _build_where(
899
+ part=part,
900
+ station=station,
901
+ phase=phase,
902
+ since=since,
903
+ until=until,
904
+ )
905
+ sql = _YIELD_SQL.format(
906
+ period_expr=_period_col(period),
907
+ where=where,
908
+ )
909
+ return [YieldRow(**r) for r in self._query_dicts(sql)]
910
+
911
+ def yield_overall(
912
+ self,
913
+ *,
914
+ part: str | list[str] | None = None,
915
+ station: str | list[str] | None = None,
916
+ phase: str | list[str] | None = None,
917
+ since: str | None = None,
918
+ until: str | None = None,
919
+ ) -> YieldRow | None:
920
+ """Pooled yield metrics over the entire filtered set — no part/station/period grouping.
921
+
922
+ Returns one :class:`YieldRow` with ``part``, ``station``, and ``period`` all
923
+ set to ``"all"``, or ``None`` when no runs match the filters.
924
+
925
+ Use this for headline cards (FPY, Final Yield, RTY, DPMO, DPPM) where
926
+ per-group aggregation would produce incorrect values:
927
+
928
+ - ``unique_serials`` is ``COUNT(DISTINCT uut_serial_number)`` over all rows — no
929
+ double-counting of serials tested on multiple stations.
930
+ - ``rty`` is ``EXP(SUM(LN(per-step FPY)))`` pooled over all filtered runs.
931
+ - ``dpmo`` is pooled over all filtered measurement records; ``dppm`` is pooled over runs.
932
+ """
933
+ where = _build_where(
934
+ part=part,
935
+ station=station,
936
+ phase=phase,
937
+ since=since,
938
+ until=until,
939
+ )
940
+ sql = _YIELD_OVERALL_SQL.format(where=where)
941
+ rows = self._query_dicts(sql)
942
+ if not rows:
943
+ return None
944
+ return YieldRow(**rows[0])
945
+
946
+ def pareto(
947
+ self,
948
+ *,
949
+ part: str | list[str] | None = None,
950
+ station: str | list[str] | None = None,
951
+ phase: str | list[str] | None = None,
952
+ since: str | None = None,
953
+ until: str | None = None,
954
+ top_n: int = 10,
955
+ ) -> list[ParetoRow]:
956
+ """Failure pareto analysis: top failure modes by count.
957
+
958
+ Returns one row per (part, station, step, measurement).
959
+ """
960
+ sql = _PARETO_SQL.format(
961
+ and_clauses=_build_and_clauses(
962
+ part=part,
963
+ station=station,
964
+ phase=phase,
965
+ since=since,
966
+ until=until,
967
+ ),
968
+ top_n=int(top_n),
969
+ )
970
+ return [ParetoRow(**r) for r in self._query_dicts(sql)]
971
+
972
+ def ppk(
973
+ self,
974
+ field: str | FieldRef | None = None,
975
+ *,
976
+ part: str | list[str] | None = None,
977
+ station: str | list[str] | None = None,
978
+ phase: str | list[str] | None = None,
979
+ since: str | None = None,
980
+ until: str | None = None,
981
+ min_samples: int = 10,
982
+ ) -> list[PpkRow]:
983
+ """Process performance (Ppk/Pp) per measurement.
984
+
985
+ ``field`` selects which measurement to scope — a bare string or
986
+ ``FieldRef.measurement(...)``. Passing a non-measurement FieldRef
987
+ is an error (outputs have no limits). ``None`` includes all
988
+ measurements (existing behavior).
989
+
990
+ Returns one row per homogeneous population — (part, station,
991
+ measurement_name, characteristic_id, uut_pin) sharing a single spec
992
+ limit pair — so Ppk is computed over one distribution rather than
993
+ pooling the same name across pins / characteristics / differing specs.
994
+ """
995
+ name_clause = ""
996
+ if field is not None:
997
+ resolved = _resolve_selector(field)
998
+ if isinstance(resolved, str):
999
+ # Fixed column — treat as a measurement_name filter
1000
+ name_clause = f"\n AND measurement_name = '{sql_escape(resolved)}'"
1001
+ else:
1002
+ ref = resolved
1003
+ if ref.role is not FieldRole.MEASUREMENT:
1004
+ raise ValueError(
1005
+ f"ppk() requires a measurement FieldRef; got role={ref.role.value!r}. "
1006
+ "Outputs and inputs have no limits — use histogram() for distributions."
1007
+ )
1008
+ name_clause = f"\n AND measurement_name = '{sql_escape(ref.name)}'"
1009
+ base_and = _build_and_clauses(
1010
+ part=part,
1011
+ station=station,
1012
+ phase=phase,
1013
+ since=since,
1014
+ until=until,
1015
+ )
1016
+ sql = _PPK_SQL.format(
1017
+ and_clauses=base_and + name_clause,
1018
+ min_samples=int(min_samples),
1019
+ )
1020
+ return [PpkRow(**r) for r in self._query_dicts(sql)]
1021
+
1022
+ def trend(
1023
+ self,
1024
+ *,
1025
+ part: str | list[str] | None = None,
1026
+ station: str | list[str] | None = None,
1027
+ phase: str | list[str] | None = None,
1028
+ since: str | None = None,
1029
+ until: str | None = None,
1030
+ period: str = "day",
1031
+ ) -> list[TrendRow]:
1032
+ """Yield trend over time.
1033
+
1034
+ Returns one row per (part, station, phase, period).
1035
+ """
1036
+ where = _build_where(
1037
+ part=part,
1038
+ station=station,
1039
+ phase=phase,
1040
+ since=since,
1041
+ until=until,
1042
+ )
1043
+ sql = _TREND_SQL.format(
1044
+ period_expr=_period_col(period),
1045
+ where=where,
1046
+ )
1047
+ return [TrendRow(**r) for r in self._query_dicts(sql)]
1048
+
1049
+ def retest(
1050
+ self,
1051
+ *,
1052
+ part: str | list[str] | None = None,
1053
+ station: str | list[str] | None = None,
1054
+ phase: str | list[str] | None = None,
1055
+ since: str | None = None,
1056
+ until: str | None = None,
1057
+ period: str = "day",
1058
+ ) -> list[RetestRow]:
1059
+ """Retest rates: how often UUTs require multiple attempts.
1060
+
1061
+ Returns one row per (part, station, phase, period).
1062
+ """
1063
+ where = _build_where(
1064
+ part=part,
1065
+ station=station,
1066
+ phase=phase,
1067
+ since=since,
1068
+ until=until,
1069
+ )
1070
+ sql = _RETEST_SQL.format(
1071
+ period_expr=_period_col(period),
1072
+ where=where,
1073
+ )
1074
+ return [RetestRow(**r) for r in self._query_dicts(sql)]
1075
+
1076
+ def time_loss(
1077
+ self,
1078
+ *,
1079
+ part: str | list[str] | None = None,
1080
+ station: str | list[str] | None = None,
1081
+ phase: str | list[str] | None = None,
1082
+ since: str | None = None,
1083
+ until: str | None = None,
1084
+ period: str = "day",
1085
+ ) -> list[TimeLossRow]:
1086
+ """Time lost to failures and errors.
1087
+
1088
+ Returns one row per (part, station, phase, period).
1089
+ """
1090
+ where = _build_where(
1091
+ part=part,
1092
+ station=station,
1093
+ phase=phase,
1094
+ since=since,
1095
+ until=until,
1096
+ )
1097
+ sql = _TIME_LOSS_SQL.format(
1098
+ period_expr=_period_col(period),
1099
+ where=where,
1100
+ )
1101
+ return [TimeLossRow(**r) for r in self._query_dicts(sql)]
1102
+
1103
+ # ------------------------------------------------------------------
1104
+ # Parametric viewer — generic Y/X query over measurements
1105
+ # ------------------------------------------------------------------
1106
+
1107
+ def describe_columns(self) -> ColumnSchema:
1108
+ """Return the plottable column schema — curated fixed columns plus role-keyed fields.
1109
+
1110
+ Fixed columns are operator-meaningful (excludes identity/admin columns).
1111
+ Dynamic fields are sourced from the ``measurement_io_schema`` catalog and
1112
+ grouped by ``(role, name)`` with their observed ``value_type`` set.
1113
+ """
1114
+ fixed = [
1115
+ FixedColumnDescriptor(name=name, column_type=col_type)
1116
+ for name, col_type in _PLOTTABLE_FIXED_COLUMNS
1117
+ ]
1118
+ raw_fields = self._query_dicts(
1119
+ "SELECT role, name, value_type FROM measurement_io_schema"
1120
+ " WHERE role IS NOT NULL AND name IS NOT NULL"
1121
+ )
1122
+ by_key: dict[tuple[str, str], list[str]] = {}
1123
+ for row in raw_fields:
1124
+ key = (str(row["role"]), str(row["name"]))
1125
+ vt = str(row["value_type"]) if row.get("value_type") else ""
1126
+ by_key.setdefault(key, [])
1127
+ if vt and vt not in by_key[key]:
1128
+ by_key[key].append(vt)
1129
+ fields = [
1130
+ DynamicFieldDescriptor(role=FieldRole(role), name=name, value_types=vts)
1131
+ for (role, name), vts in by_key.items()
1132
+ ]
1133
+ return ColumnSchema(fixed=fixed, fields=fields)
1134
+
1135
+ def _resolve_eav_field(
1136
+ self,
1137
+ selector: str | FieldRef,
1138
+ joins: _EAVJoins,
1139
+ filters: FilterSet | None = None,
1140
+ ) -> str:
1141
+ """Resolve a selector to a SQL column expression, registering any EAV join.
1142
+
1143
+ A bare ``str`` from ``_resolve_selector`` is a fixed infrastructure
1144
+ column — returned as ``m.<col>`` directly. A ``FieldRef`` with role
1145
+ MEASUREMENT maps to ``m.measurement_value`` and registers a
1146
+ ``measurement_name`` predicate on ``joins`` so the query scopes to
1147
+ that measurement (e.g. ``parametric(y="v_rail")`` returns only v_rail
1148
+ rows). A bare fixed column resolved as a plain ``str`` gets no
1149
+ predicate. Input/output FieldRefs get a typed EAV join; type
1150
+ coherence is checked when ``value_type`` is not specified.
1151
+
1152
+ ``filters`` is forwarded to ``_resolve_value_type`` so ambiguity is
1153
+ judged within the caller's active filter scope, not globally.
1154
+ """
1155
+ resolved = _resolve_selector(selector)
1156
+ if isinstance(resolved, str):
1157
+ _safe_ident(resolved)
1158
+ return f"m.{resolved}"
1159
+
1160
+ ref = resolved
1161
+ if ref.role is FieldRole.MEASUREMENT:
1162
+ joins.add_meas_name_predicate(ref.name)
1163
+ return "m.measurement_value"
1164
+
1165
+ # input/output: check type coherence if value_type not specified
1166
+ if ref.value_type is None:
1167
+ vt = self._resolve_value_type(ref, filters=filters)
1168
+ ref = FieldRef(role=ref.role, name=ref.name, value_type=vt)
1169
+
1170
+ alias = joins.register(ref)
1171
+ return _eav_typed_expr(ref, alias)
1172
+
1173
+ def _resolve_value_type(
1174
+ self,
1175
+ ref: FieldRef,
1176
+ filters: FilterSet | None = None,
1177
+ ) -> str | None:
1178
+ """Resolve the value_type for a (role, name) pair within the active filter scope.
1179
+
1180
+ Returns ``None`` when the field has no rows in its role's table
1181
+ (``inputs``/``outputs``) within the filtered scope (the query will
1182
+ yield empty results — correct absence-as-empty behaviour). Raises
1183
+ ``ValueError`` when multiple value_types are present within the scope
1184
+ (ambiguity requires the caller to supply an explicit ``value_type=``
1185
+ on ``FieldRef``).
1186
+
1187
+ ``filters`` scopes the lookup via a join on ``measurements`` so a field
1188
+ that is mixed-type globally but uniform within the user's active filter
1189
+ (e.g. a date range or part filter) resolves cleanly without raising.
1190
+ """
1191
+ table = _ROLE_TABLE[ref.role]
1192
+ filter_clauses = _filter_clauses(filters)
1193
+ if filter_clauses:
1194
+ filter_join = f" JOIN measurements m ON {_VECTOR_KEY.format(a=table)}"
1195
+ filter_where = " AND ".join(filter_clauses)
1196
+ sql = (
1197
+ f"SELECT value_type, COUNT(*) AS cnt"
1198
+ f" FROM {table}{filter_join}"
1199
+ f" WHERE {table}.name = '{sql_escape(ref.name)}'"
1200
+ f" AND {filter_where}"
1201
+ f" GROUP BY value_type"
1202
+ )
1203
+ else:
1204
+ sql = (
1205
+ f"SELECT value_type, COUNT(*) AS cnt"
1206
+ f" FROM {table}"
1207
+ f" WHERE name = '{sql_escape(ref.name)}'"
1208
+ f" GROUP BY value_type"
1209
+ )
1210
+ rows = self._query_dicts(sql)
1211
+ if not rows:
1212
+ return None
1213
+ if len(rows) == 1:
1214
+ vt = rows[0]["value_type"]
1215
+ return str(vt) if vt else None
1216
+ breakdown = ", ".join(f"{r['value_type']} ({r['cnt']})" for r in rows)
1217
+ raise ValueError(
1218
+ f"{ref.role.value} {ref.name!r} has {len(rows)} value_types in scope: "
1219
+ f"{breakdown}. Specify value_type= on FieldRef to disambiguate."
1220
+ )
1221
+
1222
+ def parametric(
1223
+ self,
1224
+ *,
1225
+ y: str | FieldRef,
1226
+ x: str | FieldRef,
1227
+ filters: FilterSet | None = None,
1228
+ group_by: str | FieldRef | None = None,
1229
+ limit: int = 5000,
1230
+ include_incomplete: bool = False,
1231
+ ) -> list[ParametricRow]:
1232
+ """Y vs X scatter/line points over measurements, optionally split by ``group_by``.
1233
+
1234
+ Returns ``ParametricRow`` per measurement. Scatter vs line is a render
1235
+ choice — pass the same points to either. ``group_by`` accepts a bare
1236
+ string (fixed column name) or a ``FieldRef`` for EAV role fields.
1237
+ ``y`` and ``x`` accept a bare string (measurement shorthand) or an
1238
+ explicit ``FieldRef``.
1239
+
1240
+ ``include_incomplete`` (default ``False``) excludes measurements whose
1241
+ owning run has not finalized. UI live views pass ``True``.
1242
+ """
1243
+ joins = _EAVJoins()
1244
+ y_col = self._resolve_eav_field(y, joins, filters=filters)
1245
+ x_col = self._resolve_eav_field(x, joins, filters=filters)
1246
+ group_col: str | None = None
1247
+ if group_by is not None:
1248
+ if isinstance(group_by, FieldRef):
1249
+ group_col = self._resolve_eav_field(group_by, joins, filters=filters)
1250
+ else:
1251
+ _safe_ident(group_by)
1252
+ group_col = f"m.{group_by}"
1253
+
1254
+ clauses = [f"{y_col} IS NOT NULL", f"{x_col} IS NOT NULL"]
1255
+ if not include_incomplete:
1256
+ clauses.append("m.run_outcome IS NOT NULL")
1257
+ clauses.extend(joins.meas_name_clauses())
1258
+ clauses.extend(_filter_clauses(filters))
1259
+ where = " WHERE " + " AND ".join(clauses)
1260
+ base = "measurements"
1261
+ frm = f"{base} m{joins.join_sql()}"
1262
+ group_expr = group_col if group_col else "''"
1263
+ sql = f"""
1264
+ SELECT
1265
+ {x_col} AS x,
1266
+ {y_col} AS y,
1267
+ {group_expr} AS "group"
1268
+ FROM {frm}{where}
1269
+ LIMIT {int(limit)}
1270
+ """
1271
+ return [ParametricRow(**row) for row in self._query_dicts(sql)]
1272
+
1273
+ def histogram(
1274
+ self,
1275
+ *,
1276
+ field: str | FieldRef,
1277
+ bins: int = 30,
1278
+ group_by: str | FieldRef | None = None,
1279
+ filters: FilterSet | None = None,
1280
+ ) -> list[HistogramRow]:
1281
+ """Distribution of one field's values, bucketed into ``bins`` bins.
1282
+
1283
+ Returns one ``HistogramRow`` per (bin, group) with ``y`` = count and
1284
+ ``x`` = bin midpoint. ``field`` accepts a bare string (measurement
1285
+ shorthand) or an explicit ``FieldRef``. ``group_by`` accepts a bare
1286
+ string (fixed column name) or a ``FieldRef`` for EAV role fields.
1287
+ """
1288
+ joins = _EAVJoins()
1289
+ field_col = self._resolve_eav_field(field, joins, filters=filters)
1290
+ group_col: str | None = None
1291
+ if group_by is not None:
1292
+ if isinstance(group_by, FieldRef):
1293
+ group_col = self._resolve_eav_field(group_by, joins, filters=filters)
1294
+ else:
1295
+ _safe_ident(group_by)
1296
+ group_col = f"m.{group_by}"
1297
+
1298
+ clauses = [f"{field_col} IS NOT NULL", "m.run_outcome IS NOT NULL"]
1299
+ clauses.extend(joins.meas_name_clauses())
1300
+ clauses.extend(_filter_clauses(filters))
1301
+ where = " WHERE " + " AND ".join(clauses)
1302
+ base = "measurements"
1303
+ frm = f"{base} m{joins.join_sql()}"
1304
+ group_expr = group_col if group_col else "''"
1305
+
1306
+ sql = f"""
1307
+ WITH stats AS (
1308
+ SELECT MIN({field_col}) AS lo, MAX({field_col}) AS hi
1309
+ FROM {frm}{where}
1310
+ ),
1311
+ bucketed AS (
1312
+ SELECT
1313
+ LEAST(
1314
+ CAST(FLOOR(({field_col} - stats.lo)
1315
+ / NULLIF((stats.hi - stats.lo) / {int(bins)}, 0)) AS INTEGER),
1316
+ {int(bins) - 1}
1317
+ ) AS bin,
1318
+ stats.lo AS lo, stats.hi AS hi,
1319
+ {group_expr} AS "group"
1320
+ FROM {frm}, stats{where}
1321
+ )
1322
+ SELECT
1323
+ bin,
1324
+ lo + (bin + 0.5) * (hi - lo) / {int(bins)} AS x,
1325
+ COUNT(*) AS y,
1326
+ "group"
1327
+ FROM bucketed
1328
+ GROUP BY bin, lo, hi, "group"
1329
+ ORDER BY "group", bin
1330
+ """
1331
+ return [HistogramRow(**row) for row in self._query_dicts(sql)]
1332
+
1333
+ def latest_run_limits(
1334
+ self,
1335
+ *,
1336
+ x: str | FieldRef,
1337
+ filters: FilterSet | None = None,
1338
+ ) -> list[LimitBandRow]:
1339
+ """Limit envelope from the most recent run, keyed by the chart's X.
1340
+
1341
+ Meaningful only when ``filters`` scope to a single
1342
+ ``measurement_name`` (the caller checks). Picks the latest
1343
+ finalized run that carries limits for the scoped rows, then
1344
+ returns its ``(x, limit_low, limit_high)`` — one row per distinct
1345
+ X, ordered by X. A condition-indexed limit renders as a step
1346
+ band; a constant limit collapses to a flat one.
1347
+ """
1348
+ joins = _EAVJoins()
1349
+ x_col = self._resolve_eav_field(x, joins, filters=filters)
1350
+ clauses = [f"{x_col} IS NOT NULL", "m.run_outcome IS NOT NULL"]
1351
+ clauses.extend(joins.meas_name_clauses())
1352
+ clauses.extend(_filter_clauses(filters))
1353
+ where = " WHERE " + " AND ".join(clauses)
1354
+ base = "measurements"
1355
+ frm = f"{base} m{joins.join_sql()}"
1356
+ sql = f"""
1357
+ WITH latest AS (
1358
+ SELECT m.run_id AS run_id
1359
+ FROM {frm}{where}
1360
+ AND (m.limit_low IS NOT NULL OR m.limit_high IS NOT NULL)
1361
+ ORDER BY m.run_started_at DESC
1362
+ LIMIT 1
1363
+ )
1364
+ SELECT
1365
+ {x_col} AS x,
1366
+ MAX(m.limit_low) AS low,
1367
+ MAX(m.limit_high) AS high
1368
+ FROM {frm}{where}
1369
+ AND m.run_id = (SELECT run_id FROM latest)
1370
+ GROUP BY {x_col}
1371
+ ORDER BY {x_col}
1372
+ """
1373
+ return [LimitBandRow(**row) for row in self._query_dicts(sql)]
1374
+
1375
+ def distinct_values(
1376
+ self,
1377
+ column: str,
1378
+ *,
1379
+ role: FieldRole | str | None = None,
1380
+ filters: FilterSet | None = None,
1381
+ exclude_self: bool = True,
1382
+ limit: int = 500,
1383
+ ) -> list[FacetOption]:
1384
+ """Return distinct values for ``column`` with their counts.
1385
+
1386
+ With ``exclude_self=True`` (Tableau-style cross-filter), the
1387
+ column being enumerated is dropped from the WHERE clause —
1388
+ otherwise selecting one value would collapse the option list
1389
+ to that one value.
1390
+
1391
+ ``role`` selects the ``inputs``/``outputs`` table (the table IS the
1392
+ role — projection-normalization, 0.3.1) when ``column`` is ``"name"``
1393
+ — use it to enumerate only input or output field names.
1394
+
1395
+ Counts are aggregated so the UI can show "PN-100 (12,304)" —
1396
+ useful for spotting which buckets actually have data.
1397
+ """
1398
+ col = facet_sql_expr(column) or _safe_ident(column)
1399
+ if role is not None:
1400
+ role_val = FieldRole(role) if isinstance(role, str) else role
1401
+ table = _ROLE_TABLE[role_val]
1402
+ scoped = _filters_excluding(filters, column) if exclude_self else filters
1403
+ filter_clauses = _filter_clauses(scoped)
1404
+ clauses = [f"{col} IS NOT NULL", *filter_clauses]
1405
+ # inputs/outputs have no run-level columns; join measurements
1406
+ # to apply FilterSet predicates when filters are present.
1407
+ join_sql = (
1408
+ f" JOIN measurements m ON {_VECTOR_KEY.format(a=table)}" if filter_clauses else ""
1409
+ )
1410
+ dyn_where = " WHERE " + " AND ".join(clauses)
1411
+ sql = f"""
1412
+ SELECT {col} AS value, COUNT(*) AS count
1413
+ FROM {table}{join_sql}{dyn_where}
1414
+ GROUP BY {col}
1415
+ ORDER BY count DESC, value ASC
1416
+ LIMIT {int(limit)}
1417
+ """
1418
+ else:
1419
+ scoped = _filters_excluding(filters, column) if exclude_self else filters
1420
+ clauses = [f"{col} IS NOT NULL", *_filter_clauses(scoped)]
1421
+ where = " WHERE " + " AND ".join(clauses)
1422
+ sql = f"""
1423
+ SELECT {col} AS value, COUNT(*) AS count
1424
+ FROM measurements{where}
1425
+ GROUP BY {col}
1426
+ ORDER BY count DESC, value ASC
1427
+ LIMIT {int(limit)}
1428
+ """
1429
+ return [
1430
+ FacetOption(value=str(row["value"]), count=int(row["count"]))
1431
+ for row in self._query_dicts(sql)
1432
+ ]
1433
+
1434
+ def summary_counts(self, *, filters: FilterSet | None = None) -> SummaryCounts:
1435
+ """Cardinality stats for the filter section's badge.
1436
+
1437
+ One round-trip; empty result on no-data is zeros across the
1438
+ board so the UI can render unconditionally.
1439
+ """
1440
+ clauses = _filter_clauses(filters)
1441
+ where = " WHERE " + " AND ".join(clauses) if clauses else ""
1442
+ sql = f"""
1443
+ SELECT
1444
+ COUNT(*) AS total_rows,
1445
+ COUNT(DISTINCT run_id) AS distinct_runs,
1446
+ COUNT(DISTINCT measurement_name) AS distinct_measurements,
1447
+ COUNT(DISTINCT COALESCE(uut_part_number, 'unknown')) AS distinct_parts
1448
+ FROM measurement_facts{where}
1449
+ """
1450
+ rows = self._query_dicts(sql)
1451
+ if not rows:
1452
+ return SummaryCounts(
1453
+ total_rows=0,
1454
+ distinct_runs=0,
1455
+ distinct_measurements=0,
1456
+ distinct_parts=0,
1457
+ )
1458
+ return SummaryCounts(**rows[0])