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.
- testerkit/__init__.py +68 -0
- testerkit/_docs/_assets/operator-ui/channels/table.png +0 -0
- testerkit/_docs/_assets/operator-ui/dashboard/runs.png +0 -0
- testerkit/_docs/_assets/operator-ui/dashboard/stations.png +0 -0
- testerkit/_docs/_assets/operator-ui/events/filters.png +0 -0
- testerkit/_docs/_assets/operator-ui/events/table.png +0 -0
- testerkit/_docs/_assets/operator-ui/explore/chart.png +0 -0
- testerkit/_docs/_assets/operator-ui/explore/filters.png +0 -0
- testerkit/_docs/_assets/operator-ui/explore/plot-controls.png +0 -0
- testerkit/_docs/_assets/operator-ui/files/table.png +0 -0
- testerkit/_docs/_assets/operator-ui/launch/form.png +0 -0
- testerkit/_docs/_assets/operator-ui/metrics/filters.png +0 -0
- testerkit/_docs/_assets/operator-ui/metrics/pareto.png +0 -0
- testerkit/_docs/_assets/operator-ui/metrics/ppk.png +0 -0
- testerkit/_docs/_assets/operator-ui/metrics/yield.png +0 -0
- testerkit/_docs/_assets/operator-ui/results/detail-header.png +0 -0
- testerkit/_docs/_assets/operator-ui/results/detail-measurements.png +0 -0
- testerkit/_docs/_assets/operator-ui/results/detail-overview.png +0 -0
- testerkit/_docs/_assets/operator-ui/results/detail-steps.png +0 -0
- testerkit/_docs/_assets/operator-ui/results/stats.png +0 -0
- testerkit/_docs/_assets/operator-ui/results/table.png +0 -0
- testerkit/_docs/_assets/operator-ui/tour/designer.png +0 -0
- testerkit/_docs/_assets/operator-ui/tour/docs.png +0 -0
- testerkit/_docs/_assets/operator-ui/tour/fixtures.png +0 -0
- testerkit/_docs/_assets/operator-ui/tour/instruments.png +0 -0
- testerkit/_docs/_assets/operator-ui/tour/parts.png +0 -0
- testerkit/_docs/_assets/operator-ui/tour/profiles.png +0 -0
- testerkit/_docs/_assets/operator-ui/tour/stations.png +0 -0
- testerkit/_docs/_assets/operator-ui/tour/tests.png +0 -0
- testerkit/_docs/_assets/operator-ui/tour/uuts.png +0 -0
- testerkit/_docs/concepts/configuration/capabilities.md +447 -0
- testerkit/_docs/concepts/configuration/fixtures.md +335 -0
- testerkit/_docs/concepts/configuration/index.md +14 -0
- testerkit/_docs/concepts/configuration/parts.md +276 -0
- testerkit/_docs/concepts/configuration/stations.md +214 -0
- testerkit/_docs/concepts/data/data-stores.md +199 -0
- testerkit/_docs/concepts/data/event-log.md +185 -0
- testerkit/_docs/concepts/data/event-sourcing.md +76 -0
- testerkit/_docs/concepts/data/flight-streaming.md +58 -0
- testerkit/_docs/concepts/data/index.md +17 -0
- testerkit/_docs/concepts/data/sessions.md +78 -0
- testerkit/_docs/concepts/data/three-verbs.md +260 -0
- testerkit/_docs/concepts/execution/index.md +13 -0
- testerkit/_docs/concepts/execution/outcomes.md +199 -0
- testerkit/_docs/concepts/execution/step-hierarchy.md +170 -0
- testerkit/_docs/concepts/execution/step-manifest.md +115 -0
- testerkit/_docs/concepts/index.md +40 -0
- testerkit/_docs/concepts/overview/ai-integration.md +57 -0
- testerkit/_docs/concepts/overview/architecture.md +357 -0
- testerkit/_docs/concepts/overview/index.md +14 -0
- testerkit/_docs/concepts/overview/platform-vs-framework.md +147 -0
- testerkit/_docs/concepts/overview/pytest.md +53 -0
- testerkit/_docs/concepts/overview/tiers.md +46 -0
- testerkit/_docs/how-to/catalog/datasheet-to-test.md +169 -0
- testerkit/_docs/how-to/catalog/index.md +11 -0
- testerkit/_docs/how-to/configuration/configuring-stations.md +364 -0
- testerkit/_docs/how-to/configuration/custom-drivers.md +484 -0
- testerkit/_docs/how-to/configuration/index.md +13 -0
- testerkit/_docs/how-to/configuration/mock-mode.md +342 -0
- testerkit/_docs/how-to/data/benchmarking.md +120 -0
- testerkit/_docs/how-to/data/capture-an-artifact.md +84 -0
- testerkit/_docs/how-to/data/capture-waveform.md +110 -0
- testerkit/_docs/how-to/data/choosing-a-channel-verb.md +108 -0
- testerkit/_docs/how-to/data/compare-runs.md +120 -0
- testerkit/_docs/how-to/data/export-results.md +104 -0
- testerkit/_docs/how-to/data/find-flaky-tests.md +108 -0
- testerkit/_docs/how-to/data/grafana-dashboards.md +148 -0
- testerkit/_docs/how-to/data/index.md +23 -0
- testerkit/_docs/how-to/data/mcp-debug-failures.md +125 -0
- testerkit/_docs/how-to/data/mcp-query-runs.md +120 -0
- testerkit/_docs/how-to/data/querying-channels.md +117 -0
- testerkit/_docs/how-to/data/querying-events.md +85 -0
- testerkit/_docs/how-to/data/stream-live-channel.md +134 -0
- testerkit/_docs/how-to/execution/custom-operator-ui.md +136 -0
- testerkit/_docs/how-to/execution/index.md +21 -0
- testerkit/_docs/how-to/execution/limits.md +193 -0
- testerkit/_docs/how-to/execution/managing-sessions.md +100 -0
- testerkit/_docs/how-to/execution/multi-uut-testing.md +211 -0
- testerkit/_docs/how-to/execution/operator-prompts.md +151 -0
- testerkit/_docs/how-to/execution/profiles.md +304 -0
- testerkit/_docs/how-to/execution/spec-driven-testing.md +169 -0
- testerkit/_docs/how-to/execution/test-context.md +212 -0
- testerkit/_docs/how-to/execution/traceability.md +252 -0
- testerkit/_docs/how-to/execution/vector-expansion.md +394 -0
- testerkit/_docs/how-to/execution/writing-tests.md +363 -0
- testerkit/_docs/how-to/index.md +59 -0
- testerkit/_docs/how-to/overview/index.md +11 -0
- testerkit/_docs/how-to/overview/mcp-integration.md +168 -0
- testerkit/_docs/how-to/overview/operator-ui-tour.md +234 -0
- testerkit/_docs/integration/data/grafana.md +162 -0
- testerkit/_docs/integration/data/index.md +15 -0
- testerkit/_docs/integration/data/lakehouse-import.md +226 -0
- testerkit/_docs/integration/data/logging.md +131 -0
- testerkit/_docs/integration/data/results-api.md +51 -0
- testerkit/_docs/integration/index.md +20 -0
- testerkit/_docs/integration/runtime/harness.md +281 -0
- testerkit/_docs/integration/runtime/index.md +13 -0
- testerkit/_docs/integration/runtime/instruments.md +284 -0
- testerkit/_docs/integration/runtime/pytest-existing.md +315 -0
- testerkit/_docs/reference/catalog/cookbook.md +389 -0
- testerkit/_docs/reference/catalog/index.md +11 -0
- testerkit/_docs/reference/catalog/schema.md +375 -0
- testerkit/_docs/reference/cli.md +660 -0
- testerkit/_docs/reference/configuration.md +406 -0
- testerkit/_docs/reference/data/channels-schema.md +145 -0
- testerkit/_docs/reference/data/event-types.md +554 -0
- testerkit/_docs/reference/data/events-schema.md +120 -0
- testerkit/_docs/reference/data/files-schema.md +141 -0
- testerkit/_docs/reference/data/index.md +18 -0
- testerkit/_docs/reference/data/models.md +2229 -0
- testerkit/_docs/reference/data/outputs.md +82 -0
- testerkit/_docs/reference/data/parquet-schema.md +529 -0
- testerkit/_docs/reference/data/performance-limits.md +224 -0
- testerkit/_docs/reference/data/query-api.md +247 -0
- testerkit/_docs/reference/index.md +69 -0
- testerkit/_docs/reference/operator-ui/channels/detail.md +82 -0
- testerkit/_docs/reference/operator-ui/channels/list.md +70 -0
- testerkit/_docs/reference/operator-ui/dashboard.md +87 -0
- testerkit/_docs/reference/operator-ui/designer.md +167 -0
- testerkit/_docs/reference/operator-ui/events.md +89 -0
- testerkit/_docs/reference/operator-ui/files.md +74 -0
- testerkit/_docs/reference/operator-ui/fixtures.md +100 -0
- testerkit/_docs/reference/operator-ui/instruments.md +126 -0
- testerkit/_docs/reference/operator-ui/launch.md +79 -0
- testerkit/_docs/reference/operator-ui/live.md +102 -0
- testerkit/_docs/reference/operator-ui/measurements.md +159 -0
- testerkit/_docs/reference/operator-ui/metrics.md +217 -0
- testerkit/_docs/reference/operator-ui/parts.md +98 -0
- testerkit/_docs/reference/operator-ui/profiles.md +103 -0
- testerkit/_docs/reference/operator-ui/results/detail.md +214 -0
- testerkit/_docs/reference/operator-ui/results/list.md +106 -0
- testerkit/_docs/reference/operator-ui/stations.md +98 -0
- testerkit/_docs/reference/operator-ui/tests.md +173 -0
- testerkit/_docs/reference/operator-ui/uuts.md +67 -0
- testerkit/_docs/reference/overview/index.md +11 -0
- testerkit/_docs/reference/overview/pytest-native.md +142 -0
- testerkit/_docs/reference/overview/skills.md +177 -0
- testerkit/_docs/reference/pytest/fixtures.md +342 -0
- testerkit/_docs/reference/pytest/index.md +12 -0
- testerkit/_docs/reference/pytest/markers.md +257 -0
- testerkit/_docs/reference/runtime/api.md +246 -0
- testerkit/_docs/reference/runtime/client.md +280 -0
- testerkit/_docs/reference/runtime/connect.md +140 -0
- testerkit/_docs/reference/runtime/index.md +13 -0
- testerkit/_docs/tutorial/01-first-test.md +113 -0
- testerkit/_docs/tutorial/02-mock-instruments.md +88 -0
- testerkit/_docs/tutorial/03-fixtures.md +169 -0
- testerkit/_docs/tutorial/04-limits.md +139 -0
- testerkit/_docs/tutorial/05-configuration.md +194 -0
- testerkit/_docs/tutorial/06-specifications.md +273 -0
- testerkit/_docs/tutorial/07-real-instruments.md +245 -0
- testerkit/_docs/tutorial/08-capabilities.md +223 -0
- testerkit/_docs/tutorial/09-production.md +374 -0
- testerkit/_docs/tutorial/10-live-monitoring.md +156 -0
- testerkit/_docs/tutorial/11-waveforms-and-evidence.md +169 -0
- testerkit/_docs/tutorial/12-continuous-monitoring.md +145 -0
- testerkit/_docs/tutorial/13-parallel-testing.md +233 -0
- testerkit/_docs/tutorial/index.md +61 -0
- testerkit/_docs/tutorial/quickstart.md +282 -0
- testerkit/analysis/__init__.py +9 -0
- testerkit/analysis/_common.py +24 -0
- testerkit/analysis/measurement_facets.py +524 -0
- testerkit/analysis/measurements_query.py +1458 -0
- testerkit/analysis/metrics.py +389 -0
- testerkit/analysis/runs_query.py +434 -0
- testerkit/analysis/steps_query.py +413 -0
- testerkit/api/__init__.py +6 -0
- testerkit/api/_mime.py +71 -0
- testerkit/api/app.py +1171 -0
- testerkit/api/dialogs/__init__.py +36 -0
- testerkit/api/dialogs/manager.py +617 -0
- testerkit/api/dialogs/models.py +79 -0
- testerkit/api/models.py +80 -0
- testerkit/api/responses.py +205 -0
- testerkit/api/runner.py +222 -0
- testerkit/api/schemas.py +311 -0
- testerkit/benchmark/__init__.py +34 -0
- testerkit/benchmark/concurrency.py +248 -0
- testerkit/benchmark/core.py +232 -0
- testerkit/benchmark/runner.py +628 -0
- testerkit/benchmark/scenario.py +313 -0
- testerkit/benchmark/system.py +191 -0
- testerkit/benchmark/workloads.py +507 -0
- testerkit/catalog/__init__.py +6 -0
- testerkit/catalog/generic/generic_dmm.yaml +61 -0
- testerkit/catalog/generic/generic_eload.yaml +31 -0
- testerkit/catalog/generic/generic_fgen.yaml +56 -0
- testerkit/catalog/generic/generic_oscilloscope.yaml +45 -0
- testerkit/catalog/generic/generic_psu.yaml +77 -0
- testerkit/catalog/generic/generic_smu.yaml +73 -0
- testerkit/channels.py +538 -0
- testerkit/cli/__init__.py +32 -0
- testerkit/cli/_common.py +113 -0
- testerkit/cli/_time.py +179 -0
- testerkit/cli/benchmark_cmd.py +56 -0
- testerkit/cli/catalog_cmd.py +39 -0
- testerkit/cli/daemon.py +157 -0
- testerkit/cli/data_cmd.py +1040 -0
- testerkit/cli/discover_cmd.py +94 -0
- testerkit/cli/docs_cmd.py +100 -0
- testerkit/cli/instrument.py +172 -0
- testerkit/cli/mcp_cmd.py +37 -0
- testerkit/cli/metrics.py +366 -0
- testerkit/cli/project.py +312 -0
- testerkit/cli/root.py +14 -0
- testerkit/cli/runs.py +412 -0
- testerkit/cli/schema_cmd.py +87 -0
- testerkit/cli/serve_cmd.py +50 -0
- testerkit/cli/setup_cmd.py +604 -0
- testerkit/cli/station.py +294 -0
- testerkit/cli/validate.py +112 -0
- testerkit/client.py +463 -0
- testerkit/connect.py +635 -0
- testerkit/data/__init__.py +8 -0
- testerkit/data/_accumulator_pool.py +455 -0
- testerkit/data/_atomic.py +59 -0
- testerkit/data/_collection_indices.py +67 -0
- testerkit/data/_daemon_lifecycle.py +535 -0
- testerkit/data/_duckdb_daemon.py +695 -0
- testerkit/data/_duckdb_flight_server.py +813 -0
- testerkit/data/_event_filters.py +15 -0
- testerkit/data/_event_reader.py +101 -0
- testerkit/data/_flight_errors.py +199 -0
- testerkit/data/_flight_query.py +202 -0
- testerkit/data/_flight_retry.py +148 -0
- testerkit/data/_flight_subscribe.py +67 -0
- testerkit/data/_index_epoch.py +342 -0
- testerkit/data/_ipc_writer.py +185 -0
- testerkit/data/_json_safe.py +63 -0
- testerkit/data/_process.py +18 -0
- testerkit/data/_push_relay.py +124 -0
- testerkit/data/_runs_duckdb_daemon.py +2704 -0
- testerkit/data/_session_reaper.py +110 -0
- testerkit/data/_sql_helpers.py +30 -0
- testerkit/data/_store.py +24 -0
- testerkit/data/backends/__init__.py +7 -0
- testerkit/data/backends/_event_accumulator.py +957 -0
- testerkit/data/backends/_row_helpers.py +1172 -0
- testerkit/data/backends/parquet.py +1143 -0
- testerkit/data/backends/protocol.py +35 -0
- testerkit/data/channels/__init__.py +8 -0
- testerkit/data/channels/_flight_daemon.py +75 -0
- testerkit/data/channels/client.py +242 -0
- testerkit/data/channels/flight_manager.py +88 -0
- testerkit/data/channels/index.py +664 -0
- testerkit/data/channels/models.py +418 -0
- testerkit/data/channels/server.py +162 -0
- testerkit/data/channels/store.py +1481 -0
- testerkit/data/data_dir.py +61 -0
- testerkit/data/duckdb_manager.py +79 -0
- testerkit/data/event_log.py +384 -0
- testerkit/data/event_store.py +768 -0
- testerkit/data/events.py +1025 -0
- testerkit/data/exporters/__init__.py +6 -0
- testerkit/data/exporters/_helpers.py +40 -0
- testerkit/data/exporters/csv_exporter.py +168 -0
- testerkit/data/exporters/hdf5.py +234 -0
- testerkit/data/exporters/json_exporter.py +229 -0
- testerkit/data/exporters/mdf4.py +201 -0
- testerkit/data/exporters/stdf.py +255 -0
- testerkit/data/exporters/tdms.py +292 -0
- testerkit/data/files/__init__.py +71 -0
- testerkit/data/files/_backend.py +207 -0
- testerkit/data/files/_catalog_daemon.py +84 -0
- testerkit/data/files/catalog.py +218 -0
- testerkit/data/files/catalog_manager.py +325 -0
- testerkit/data/files/models.py +60 -0
- testerkit/data/files/serializers.py +414 -0
- testerkit/data/files/store.py +463 -0
- testerkit/data/files/streaming.py +783 -0
- testerkit/data/models.py +604 -0
- testerkit/data/ref.py +152 -0
- testerkit/data/retention.py +297 -0
- testerkit/data/run_store.py +367 -0
- testerkit/data/runs_duckdb_manager.py +103 -0
- testerkit/data/schema_dispatch.py +191 -0
- testerkit/data/schema_migrate.py +76 -0
- testerkit/data/schema_versions.py +85 -0
- testerkit/data/schemas.py +288 -0
- testerkit/data/subscribers/__init__.py +48 -0
- testerkit/data/subscribers/_base.py +20 -0
- testerkit/data/subscribers/_output_file.py +25 -0
- testerkit/data/subscribers/replay.py +55 -0
- testerkit/environment.py +84 -0
- testerkit/execution/__init__.py +11 -0
- testerkit/execution/_git.py +190 -0
- testerkit/execution/_state.py +740 -0
- testerkit/execution/accessors.py +54 -0
- testerkit/execution/audit.py +51 -0
- testerkit/execution/cascade.py +85 -0
- testerkit/execution/connections.py +491 -0
- testerkit/execution/harness.py +1931 -0
- testerkit/execution/instrument_events.py +42 -0
- testerkit/execution/limits.py +154 -0
- testerkit/execution/metadata.py +102 -0
- testerkit/execution/mocks.py +63 -0
- testerkit/execution/profiles.py +616 -0
- testerkit/execution/run_scope.py +1331 -0
- testerkit/execution/session_scope.py +196 -0
- testerkit/execution/sidecar.py +280 -0
- testerkit/execution/site_runner.py +705 -0
- testerkit/execution/sites.py +183 -0
- testerkit/execution/sync.py +294 -0
- testerkit/execution/uut_provider.py +231 -0
- testerkit/execution/vectors.py +205 -0
- testerkit/execution/verify.py +396 -0
- testerkit/expand.py +75 -0
- testerkit/files.py +235 -0
- testerkit/fixtures/__init__.py +6 -0
- testerkit/fixtures/manager.py +357 -0
- testerkit/grafana/__init__.py +1 -0
- testerkit/grafana/bootstrap.py +61 -0
- testerkit/grafana/cli.py +314 -0
- testerkit/grafana/dashboards/asset_utilization.json +92 -0
- testerkit/grafana/dashboards/channel_explorer.json +77 -0
- testerkit/grafana/dashboards/event_log.json +92 -0
- testerkit/grafana/dashboards/failure_pareto.json +104 -0
- testerkit/grafana/dashboards/measurement_distribution.json +97 -0
- testerkit/grafana/dashboards/measurement_trend.json +161 -0
- testerkit/grafana/dashboards/station_comparison.json +95 -0
- testerkit/grafana/dashboards/test_duration.json +104 -0
- testerkit/grafana/dashboards/unit_traceability.json +73 -0
- testerkit/grafana/dashboards/yield_overview.json +189 -0
- testerkit/grafana/provisioning/dashboards.yaml.j2 +12 -0
- testerkit/grafana/provisioning/datasources.yaml.j2 +17 -0
- testerkit/grafana/server.py +282 -0
- testerkit/init.py +724 -0
- testerkit/instruments/__init__.py +10 -0
- testerkit/instruments/base.py +82 -0
- testerkit/instruments/discovery/__init__.py +62 -0
- testerkit/instruments/discovery/_base.py +174 -0
- testerkit/instruments/discovery/lxi.py +152 -0
- testerkit/instruments/discovery/ni.py +95 -0
- testerkit/instruments/discovery/serial.py +73 -0
- testerkit/instruments/discovery/visa.py +98 -0
- testerkit/instruments/lifecycle.py +213 -0
- testerkit/instruments/loader.py +79 -0
- testerkit/instruments/locks.py +178 -0
- testerkit/instruments/mocks.py +230 -0
- testerkit/instruments/observer.py +270 -0
- testerkit/instruments/observers/__init__.py +36 -0
- testerkit/instruments/observers/_base.py +68 -0
- testerkit/instruments/observers/daqmx.py +70 -0
- testerkit/instruments/observers/descriptor.py +69 -0
- testerkit/instruments/observers/generic.py +92 -0
- testerkit/instruments/observers/lantz.py +31 -0
- testerkit/instruments/observers/modbus.py +96 -0
- testerkit/instruments/observers/motion.py +68 -0
- testerkit/instruments/observers/ni_modular.py +73 -0
- testerkit/instruments/observers/ophyd.py +80 -0
- testerkit/instruments/observers/pymeasure.py +120 -0
- testerkit/instruments/observers/qcodes.py +66 -0
- testerkit/instruments/observers/scpi.py +88 -0
- testerkit/instruments/observers/tektronix.py +57 -0
- testerkit/instruments/observers/visa.py +103 -0
- testerkit/instruments/pool.py +459 -0
- testerkit/instruments/proxy.py +59 -0
- testerkit/instruments/route_manager.py +329 -0
- testerkit/instruments/routed_proxy.py +88 -0
- testerkit/instruments/server.py +508 -0
- testerkit/instruments/switch.py +43 -0
- testerkit/instruments/visa.py +294 -0
- testerkit/matching/__init__.py +6 -0
- testerkit/matching/service.py +969 -0
- testerkit/mcp/__init__.py +6 -0
- testerkit/mcp/server.py +681 -0
- testerkit/mcp/tools.py +1868 -0
- testerkit/models/__init__.py +18 -0
- testerkit/models/capability.py +598 -0
- testerkit/models/catalog.py +76 -0
- testerkit/models/data_options.py +153 -0
- testerkit/models/enums.py +334 -0
- testerkit/models/instrument.py +152 -0
- testerkit/models/instrument_asset.py +25 -0
- testerkit/models/part.py +271 -0
- testerkit/models/part_manifest.py +96 -0
- testerkit/models/project.py +114 -0
- testerkit/models/station.py +140 -0
- testerkit/models/test_config.py +748 -0
- testerkit/ontology/__init__.py +39 -0
- testerkit/ontology/schema.py +188 -0
- testerkit/ontology/testerkit.yaml +1420 -0
- testerkit/parts/__init__.py +8 -0
- testerkit/parts/context.py +181 -0
- testerkit/parts/folder.py +251 -0
- testerkit/parts/loader.py +27 -0
- testerkit/prompts/__init__.py +17 -0
- testerkit/prompts/core.py +133 -0
- testerkit/pytest_plugin/__init__.py +1287 -0
- testerkit/pytest_plugin/autouse.py +369 -0
- testerkit/pytest_plugin/helpers.py +354 -0
- testerkit/pytest_plugin/hooks.py +1982 -0
- testerkit/pytest_plugin/markers.py +203 -0
- testerkit/pytest_plugin/retry.py +37 -0
- testerkit/pytest_plugin/sweeps.py +117 -0
- testerkit/queries.py +78 -0
- testerkit/reports/__init__.py +6 -0
- testerkit/reports/core.py +492 -0
- testerkit/reports/datasheet.py +831 -0
- testerkit/reports/templates/datasheet.html +383 -0
- testerkit/reports/templates/default.html +153 -0
- testerkit/sbom.py +142 -0
- testerkit/schema_export.py +101 -0
- testerkit/signals.py +63 -0
- testerkit/skills/README.md +58 -0
- testerkit/skills/templates/project-instructions.md +125 -0
- testerkit/skills/testerkit-analysis/SKILL.md +87 -0
- testerkit/skills/testerkit-capture/SKILL.md +114 -0
- testerkit/skills/testerkit-data/SKILL.md +138 -0
- testerkit/skills/testerkit-datasheets/SKILL.md +100 -0
- testerkit/skills/testerkit-datasheets/agents/scaffold-writer.md +115 -0
- testerkit/skills/testerkit-datasheets/agents/section-extractor.md +98 -0
- testerkit/skills/testerkit-datasheets/agents/section-reviewer.md +153 -0
- testerkit/skills/testerkit-datasheets/agents/section-splitter.md +89 -0
- testerkit/skills/testerkit-datasheets/agents/section-writer.md +226 -0
- testerkit/skills/testerkit-datasheets/references/catalog-pipeline.md +271 -0
- testerkit/skills/testerkit-datasheets/references/process-queue.md +42 -0
- testerkit/skills/testerkit-datasheets/references/scaffold.md +156 -0
- testerkit/skills/testerkit-datasheets/references/test-pipeline.md +312 -0
- testerkit/skills/testerkit-debug/SKILL.md +127 -0
- testerkit/skills/testerkit-interactive/SKILL.md +188 -0
- testerkit/skills/testerkit-interactive/references/live-ui-patterns.md +102 -0
- testerkit/skills/testerkit-mocks/SKILL.md +128 -0
- testerkit/skills/testerkit-parts/SKILL.md +126 -0
- testerkit/skills/testerkit-profiles/SKILL.md +139 -0
- testerkit/skills/testerkit-sites/SKILL.md +140 -0
- testerkit/skills/testerkit-stations/SKILL.md +146 -0
- testerkit/skills/testerkit-tests/SKILL.md +136 -0
- testerkit/store.py +1639 -0
- testerkit/ui/__init__.py +71 -0
- testerkit/ui/_asgi.py +162 -0
- testerkit/ui/app.py +32 -0
- testerkit/ui/components/__init__.py +13 -0
- testerkit/ui/components/artifact_viewer.py +250 -0
- testerkit/ui/components/channel_values.py +159 -0
- testerkit/ui/components/event_timeline.py +222 -0
- testerkit/ui/components/execution_gantt.py +210 -0
- testerkit/ui/components/file_streams.py +147 -0
- testerkit/ui/components/instrument_activity.py +146 -0
- testerkit/ui/components/session_table.py +118 -0
- testerkit/ui/pages/__init__.py +25 -0
- testerkit/ui/pages/channels/__init__.py +4 -0
- testerkit/ui/pages/channels/detail.py +932 -0
- testerkit/ui/pages/channels/list.py +515 -0
- testerkit/ui/pages/dashboard.py +236 -0
- testerkit/ui/pages/designer/__init__.py +5 -0
- testerkit/ui/pages/designer/graph.py +635 -0
- testerkit/ui/pages/designer/matching.py +663 -0
- testerkit/ui/pages/designer/page.py +593 -0
- testerkit/ui/pages/designer/properties.py +591 -0
- testerkit/ui/pages/designer/state.py +468 -0
- testerkit/ui/pages/docs/__init__.py +6 -0
- testerkit/ui/pages/docs/index.py +95 -0
- testerkit/ui/pages/docs/page.py +698 -0
- testerkit/ui/pages/events/__init__.py +3 -0
- testerkit/ui/pages/events/list.py +263 -0
- testerkit/ui/pages/explore.py +1108 -0
- testerkit/ui/pages/files/__init__.py +4 -0
- testerkit/ui/pages/files/detail.py +426 -0
- testerkit/ui/pages/files/list.py +447 -0
- testerkit/ui/pages/fixtures/__init__.py +8 -0
- testerkit/ui/pages/fixtures/detail.py +280 -0
- testerkit/ui/pages/fixtures/edit.py +370 -0
- testerkit/ui/pages/fixtures/list.py +180 -0
- testerkit/ui/pages/fixtures/new.py +195 -0
- testerkit/ui/pages/instruments/__init__.py +4 -0
- testerkit/ui/pages/instruments/detail.py +320 -0
- testerkit/ui/pages/instruments/edit.py +482 -0
- testerkit/ui/pages/instruments/list.py +249 -0
- testerkit/ui/pages/instruments/new.py +200 -0
- testerkit/ui/pages/launch.py +261 -0
- testerkit/ui/pages/live.py +124 -0
- testerkit/ui/pages/metrics_page.py +1229 -0
- testerkit/ui/pages/parts/__init__.py +11 -0
- testerkit/ui/pages/parts/detail.py +202 -0
- testerkit/ui/pages/parts/edit.py +310 -0
- testerkit/ui/pages/parts/list.py +160 -0
- testerkit/ui/pages/parts/new.py +170 -0
- testerkit/ui/pages/parts/requirements.py +153 -0
- testerkit/ui/pages/parts/stations.py +199 -0
- testerkit/ui/pages/profiles/__init__.py +4 -0
- testerkit/ui/pages/profiles/detail.py +113 -0
- testerkit/ui/pages/profiles/list.py +86 -0
- testerkit/ui/pages/results/__init__.py +4 -0
- testerkit/ui/pages/results/detail.py +631 -0
- testerkit/ui/pages/results/list.py +230 -0
- testerkit/ui/pages/stations/__init__.py +4 -0
- testerkit/ui/pages/stations/detail.py +256 -0
- testerkit/ui/pages/stations/edit.py +273 -0
- testerkit/ui/pages/stations/list.py +180 -0
- testerkit/ui/pages/stations/new.py +311 -0
- testerkit/ui/pages/tests/__init__.py +4 -0
- testerkit/ui/pages/tests/detail.py +152 -0
- testerkit/ui/pages/tests/list.py +194 -0
- testerkit/ui/pages/uuts/__init__.py +4 -0
- testerkit/ui/pages/uuts/list.py +97 -0
- testerkit/ui/shared/__init__.py +8 -0
- testerkit/ui/shared/components.py +1826 -0
- testerkit/ui/shared/dialogs.py +105 -0
- testerkit/ui/shared/event_binding.py +194 -0
- testerkit/ui/shared/layout.py +314 -0
- testerkit/ui/shared/services.py +1719 -0
- testerkit/ui/shared/timestamps.py +21 -0
- testerkit/ui/static/branding/favicon-16.png +0 -0
- testerkit/ui/static/branding/favicon-32.png +0 -0
- testerkit/ui/static/branding/favicon-48.png +0 -0
- testerkit/ui/static/branding/testerkit-icon-256.png +0 -0
- testerkit/ui/static/branding/testerkit-mark.svg +1 -0
- testerkit/ui/static/branding/testerkit-wordmark.svg +1 -0
- testerkit/ui/static/global.css +465 -0
- testerkit/utils/__init__.py +17 -0
- testerkit/utils/enum_meta.py +800 -0
- testerkit/utils/paths.py +75 -0
- testerkit/utils/ranges.py +257 -0
- testerkit/validation.py +82 -0
- testerkit/verbs.py +142 -0
- testerkit-0.4.0.dist-info/METADATA +250 -0
- testerkit-0.4.0.dist-info/RECORD +521 -0
- testerkit-0.4.0.dist-info/WHEEL +4 -0
- testerkit-0.4.0.dist-info/entry_points.txt +5 -0
- 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])
|