feat(workbench): add runtime GIS and sample integrations

This commit is contained in:
2026-09-22 10:44:31 +08:00
parent a3b4e75317
commit 7ab9b36a5e
62 changed files with 6685 additions and 51 deletions
@@ -0,0 +1,49 @@
-- Append-only task alert events plus a monotonic current-state projection.
BEGIN;
CREATE SCHEMA IF NOT EXISTS workbench;
CREATE TABLE IF NOT EXISTS workbench.alert_events (
project_id uuid NOT NULL,
producer_id text NOT NULL CHECK (length(producer_id) > 0),
event_id uuid NOT NULL,
alert_id text NOT NULL CHECK (length(alert_id) > 0),
event_type text NOT NULL CHECK (event_type IN ('raised','acknowledged','resolved')),
occurred_at timestamptz NOT NULL,
ingested_at timestamptz NOT NULL DEFAULT now(),
document jsonb NOT NULL,
PRIMARY KEY (project_id,producer_id,event_id),
CHECK ((jsonb_typeof(document)='object'
AND document->>'producer_id'=producer_id
AND document->>'event_id'=event_id::text
AND document->>'alert_id'=alert_id
AND document->>'event_type'=event_type
AND (document->>'occurred_at')::timestamptz=occurred_at) IS TRUE)
);
CREATE INDEX IF NOT EXISTS alert_events_alert_idx
ON workbench.alert_events(project_id,alert_id,occurred_at,event_id);
CREATE TABLE IF NOT EXISTS workbench.alerts (
project_id uuid NOT NULL,
alert_id text NOT NULL CHECK (length(alert_id) > 0),
revision bigint NOT NULL CHECK (revision >= 1),
status text NOT NULL CHECK (status IN ('active','acknowledged','resolved')),
occurred_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
document jsonb NOT NULL,
PRIMARY KEY (project_id,alert_id),
CHECK ((jsonb_typeof(document)='object'
AND document->>'alert_id'=alert_id
AND document->>'status'=status
AND (document->>'occurred_at')::timestamptz=occurred_at) IS TRUE)
);
CREATE INDEX IF NOT EXISTS alerts_active_recent_idx
ON workbench.alerts(project_id,occurred_at DESC,alert_id)
WHERE status='active';
REVOKE ALL ON ALL TABLES IN SCHEMA workbench FROM PUBLIC;
COMMIT;
@@ -0,0 +1,12 @@
-- Bound and index the persisted analysis reads used by the workbench and API.
-- Safe to re-run after analysis.runs and analysis.results are provisioned.
BEGIN;
CREATE INDEX IF NOT EXISTS runs_created_at_run_id_idx
ON analysis.runs(created_at DESC, run_id);
CREATE INDEX IF NOT EXISTS results_run_created_at_result_id_idx
ON analysis.results(run_id, created_at DESC, result_id DESC);
COMMIT;
@@ -0,0 +1,74 @@
CREATE SCHEMA IF NOT EXISTS source_gis;
CREATE TABLE IF NOT EXISTS source_gis.datasets (
dataset_key TEXT PRIMARY KEY,
title TEXT NOT NULL,
source_date DATE NOT NULL,
declared_srid INTEGER,
effective_srid INTEGER NOT NULL,
coordinate_note TEXT NOT NULL,
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS source_gis.layers (
layer_key TEXT PRIMARY KEY,
dataset_key TEXT NOT NULL REFERENCES source_gis.datasets(dataset_key) ON DELETE CASCADE,
display_name TEXT NOT NULL,
source_file TEXT NOT NULL,
feature_kind TEXT NOT NULL,
geometry_type TEXT NOT NULL,
feature_count BIGINT NOT NULL CHECK (feature_count >= 0),
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS source_gis.features (
layer_key TEXT NOT NULL REFERENCES source_gis.layers(layer_key) ON DELETE CASCADE,
source_fid BIGINT NOT NULL,
source_object_id TEXT,
source_keyword TEXT,
source_asset_id TEXT,
source_diameter DOUBLE PRECISION,
from_node_id TEXT,
to_node_id TEXT,
attributes JSONB NOT NULL CHECK (jsonb_typeof(attributes) = 'object'),
geom geometry(Geometry, 900914) NOT NULL,
PRIMARY KEY (layer_key, source_fid)
);
ALTER TABLE source_gis.features
ADD COLUMN IF NOT EXISTS source_diameter DOUBLE PRECISION;
CREATE TABLE IF NOT EXISTS source_gis.element_links (
element_type TEXT NOT NULL,
element_id TEXT NOT NULL,
layer_key TEXT NOT NULL,
source_fid BIGINT NOT NULL,
match_method TEXT NOT NULL,
distance_m DOUBLE PRECISION,
PRIMARY KEY (element_type, element_id, layer_key),
FOREIGN KEY (layer_key, source_fid)
REFERENCES source_gis.features(layer_key, source_fid)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_source_gis_layers_dataset
ON source_gis.layers(dataset_key);
CREATE INDEX IF NOT EXISTS idx_source_gis_features_object_id
ON source_gis.features(layer_key, source_object_id);
CREATE INDEX IF NOT EXISTS idx_source_gis_features_nodes
ON source_gis.features(layer_key, from_node_id, to_node_id);
CREATE INDEX IF NOT EXISTS idx_source_gis_features_nodes_reverse
ON source_gis.features(layer_key, to_node_id, from_node_id);
CREATE INDEX IF NOT EXISTS idx_source_gis_features_geom
ON source_gis.features USING GIST (geom);
CREATE INDEX IF NOT EXISTS idx_source_gis_element_links_lookup
ON source_gis.element_links(element_type, element_id);
COMMENT ON SCHEMA source_gis IS
'Imported source GIS inventories retained separately from the hydraulic model.';
COMMENT ON COLUMN source_gis.datasets.declared_srid IS
'SRID declared by the source file, which may be incorrect.';
COMMENT ON COLUMN source_gis.datasets.effective_srid IS
'SRID assigned after validating source coordinates against the project model.';
COMMENT ON COLUMN source_gis.features.attributes IS
'Original non-geometry GIS fields, including null values, preserved as JSONB.';
@@ -0,0 +1,28 @@
-- Read-only work-order projection synchronized by an external work-order source.
BEGIN;
CREATE SCHEMA IF NOT EXISTS workbench;
CREATE TABLE IF NOT EXISTS workbench.work_orders (
project_id uuid NOT NULL,
work_order_id text NOT NULL CHECK (length(work_order_id) > 0),
scheduled_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
document jsonb NOT NULL,
PRIMARY KEY (project_id, work_order_id),
CHECK ((jsonb_typeof(document) = 'object'
AND document->>'id' = work_order_id
AND (document->>'scheduled_at')::timestamptz = scheduled_at
AND (document->>'updated_at')::timestamptz = updated_at) IS TRUE)
);
CREATE INDEX IF NOT EXISTS work_orders_project_schedule_idx
ON workbench.work_orders(project_id, scheduled_at, work_order_id);
CREATE INDEX IF NOT EXISTS work_orders_project_updated_idx
ON workbench.work_orders(project_id, updated_at DESC, work_order_id);
REVOKE ALL ON workbench.work_orders FROM PUBLIC;
COMMIT;
+13
View File
@@ -0,0 +1,13 @@
BEGIN;
ALTER TABLE peripheral_sample.demo_datasets
DROP CONSTRAINT IF EXISTS demo_datasets_document_check;
ALTER TABLE peripheral_sample.demo_datasets
ADD CONSTRAINT demo_datasets_document_check CHECK ((
jsonb_typeof(document) = 'object'
AND document->>'origin' = 'synthetic'
AND document->>'generator_version' IN ('water-demo@1', 'water-demo@2')
) IS TRUE);
COMMIT;
+122
View File
@@ -0,0 +1,122 @@
DO $$
DECLARE
layer RECORD;
BEGIN
FOR layer IN
SELECT *
FROM (VALUES
('fx_20200423_drain_valves', 'fx_20200423_drain_valves', 'Point'),
('fx_20200423_service_connections', 'fx_20200423_service_connections', 'Point'),
('fx_20200423_flow_meters', 'fx_20200423_flow_meters', 'Point'),
('fx_20200423_hydrants', 'fx_20200423_hydrants', 'Point'),
('fx_20200423_customer_meters', 'fx_20200423_customer_meters', 'Point'),
('fx_20200423_pipes', 'fx_20200423_pipes', 'LineString'),
('fx_20200423_valves', 'fx_20200423_valves', 'Point'),
('fx_20200423_non_residential_meters', 'fx_20200423_non_residential_meters', 'Point'),
('fx_20200811_dn100_pipes', 'fx_20200811_dn100_pipes', 'LineString')
) AS configured(view_name, layer_key, geometry_type)
LOOP
IF to_regclass(format('gis.%I', layer.view_name)) IS NULL THEN
EXECUTE format(
$view$
CREATE MATERIALIZED VIEW gis.%I AS
SELECT
feature.source_fid::text AS id,
feature.source_fid,
feature.source_object_id,
feature.source_diameter AS diameter,
CASE
WHEN GeometryType(feature.geom) IN ('LINESTRING', 'MULTILINESTRING')
THEN ST_Length(feature.geom)
ELSE NULL
END AS length,
dataset.dataset_key,
dataset.title AS dataset_title,
dataset.source_date,
source_layer.layer_key AS source_layer_key,
source_layer.display_name AS source_layer_name,
source_layer.feature_kind,
ST_Transform(feature.geom, 3857)::geometry(%s, 3857) AS geom
FROM source_gis.features AS feature
JOIN source_gis.layers AS source_layer
ON source_layer.layer_key = feature.layer_key
JOIN source_gis.datasets AS dataset
ON dataset.dataset_key = source_layer.dataset_key
WHERE feature.layer_key = %L
AND feature.geom IS NOT NULL
WITH DATA
$view$,
layer.view_name,
layer.geometry_type,
layer.layer_key
);
END IF;
EXECUTE format(
'CREATE UNIQUE INDEX IF NOT EXISTS %I ON gis.%I (id)',
'ux_' || layer.view_name || '_id',
layer.view_name
);
EXECUTE format(
'CREATE INDEX IF NOT EXISTS %I ON gis.%I USING GIST (geom)',
'ix_' || layer.view_name || '_geom',
layer.view_name
);
END LOOP;
END
$$;
DROP VIEW IF EXISTS gis.water_quality_monitoring_points;
CREATE VIEW gis.water_quality_monitoring_points AS
WITH latest_result AS (
SELECT
run.run_id,
run.name AS source_analysis,
run.created_at,
result.payload
FROM analysis.runs AS run
JOIN LATERAL (
SELECT stored.payload
FROM analysis.results AS stored
WHERE stored.run_id = run.run_id
AND stored.result_type = 'sensor_placement'
ORDER BY stored.created_at DESC, stored.result_id DESC
LIMIT 1
) AS result ON true
WHERE run.run_type = 'sensor_placement'
AND run.status = 'completed'
AND result.payload->>'sensor_type' = 'water_quality'
AND jsonb_typeof(result.payload->'sensor_locations') = 'array'
ORDER BY run.created_at DESC, run.run_id DESC
LIMIT 1
), ranked_locations AS (
SELECT
latest.run_id,
latest.source_analysis,
latest.created_at,
latest.payload,
location.node_id,
location.rank
FROM latest_result AS latest
CROSS JOIN LATERAL jsonb_array_elements_text(
latest.payload->'sensor_locations'
) WITH ORDINALITY AS location(node_id, rank)
)
SELECT
ranked.node_id AS id,
ranked.node_id,
ranked.rank::integer AS monitoring_point_no,
ranked.run_id,
ranked.source_analysis,
ranked.created_at,
ranked.payload->>'method' AS selection_method,
(ranked.payload->>'minimum_diameter')::integer AS minimum_diameter,
junction.elevation,
junction.geom
FROM ranked_locations AS ranked
JOIN gis.junctions AS junction
ON junction.id = ranked.node_id;
COMMENT ON VIEW gis.water_quality_monitoring_points IS
'Current water-quality monitoring points selected by the sensitivity service.';