diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..170e8da --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,105 @@ +# Context Glossary + +Canonical terms for the open_datEAUbase domain. Glossary only — no implementation +details. When code or conversation uses one of these words, it means *this*. + +## Stream + +The supertype of a sensor **Channel** and a lab **AnalysisSeries** (table-per-type +inheritance; every Channel and AnalysisSeries owns exactly one Stream row via +`Stream_ID`). The unit of selection in the Data Explorer. A "time series" the user +plots or downloads is a Stream. + +## Provenance + +The **processing-step lineage DAG** of a Stream: how it was derived from upstream +streams through processing steps (outlier removal, drift correction, smoothing, …), +plus accumulated traits. This is the *transformation history*. Surfaced by the +`/lineage/streams/{id}/provenance` endpoint and the Explore Provenance panel. Not to +be confused with [pedigree]. + +## Pedigree + +The **organizational and spatial context** of a Stream — distinct from [provenance]. +The pedigree splits into a time-invariant identity and a **time-bound deployment +timeline**: + +- Identity (fixed): parameter, unit, value kind, label. +- Deployment timeline (one segment per slice of the stream's life): + - Sampling location (the SamplingPoint) + - Process unit (the ProcessUnit the sampling point sits on) + - Site (owning the sampling point / campaign) + - Campaign + - Responsible person (the campaign's `ResponsiblePerson`) + - Equipment (sensor only) + +A **sensor channel's** location and campaign are *historical*: its equipment is +rewired (`EquipmentWiringHistory`) and moved between sampling points +(`EquipmentLocationHistory`) over time, so one channel's data can span several +locations and campaigns. The pedigree therefore carries a list of segments, each +with its own `valid_from`/`valid_to`, not a single snapshot. A **lab +AnalysisSeries** has a single fixed sampling point + campaign — one open segment. + +Pedigree is the *who/where/why*; provenance is the *how-derived*. The data-export +metadata YAML carries pedigree, and each exported CSV row carries the sampling +location + campaign active at that row's timestamp. + +## Event + +A discrete, time-stamped occurrence in the operational life of the system — +a calibration, maintenance action, failure, power outage, site visit, etc. +The supertype generalizing the former **EquipmentEvent**. Each Event attaches +to exactly one **target** at its *smallest logical unit* via an exclusive arc — +any node of the two operational hierarchies plus Campaign: a Channel, Equipment, +SignalInterface, DataAcquisitionSystem, SamplingPoint, ProcessUnit, Site, or +Campaign (exactly one non-NULL, enforced by CHECK). Has a start (and optional end / +instantaneous flag), a performed-by and a recorded-by Person, an +[EventKind], and free-text notes. The destination for plant **logbook** +entries. Not to be confused with an [annotation], which anchors to a +measurement [Stream] over a time range rather than to an operational unit. +_Avoid_: EquipmentEvent (now a special case), SiteEvent (never existed). + +## EventKind + +The controlled vocabulary classifying an [Event] (calibration, maintenance, +failure, power outage, …). The supertype generalizing the former +**EquipmentEventKind**. + +## Annotation + +A human-authored note anchored to a measurement [Stream] (one Channel or +AnalysisSeries) over a time range. Distinct from an [Event]: an annotation +is *about the data*; an event is *about the operational unit*. An annotation +may optionally reference the Event that explains it. + +## Measurement Range + +The min/max a **sensor** can physically produce when configured and operated +correctly. A property of the sensor itself, independent of location or time +(IWA Ch3 D3.6). _Avoid_: confusing with the [Variable Range] (process) or a +[Control Limit] (error tolerance) — they bound different things. + +## Variable Range + +The expected range of the **measured value** under normal operation — the +"normal operating range" (IWA Ch3 D3.7). Describes the *process*, not the +sensor, and is context-dependent (location, time of day, season). A per-[Stream] +property. A value outside it is a candidate process anomaly / contextual outlier. + +## Control Limit + +A tolerance on a **derived quality metric** — maintenance drift `%diff`, +offset/slope drift, bias — used to decide whether a sensor needs action. It +bounds the *error*, not the measured value, so it is **not** a [Variable Range]. +Per `(Stream × metric-type)`, and **historicized** (re-baselined over time, à la +SPC) like a calibration curve. The acceptance limits on the logbook's +per-equipment maintenance sheets are Control Limits. + +[pedigree]: #pedigree +[provenance]: #provenance +[annotation]: #annotation +[Stream]: #stream +[Event]: #event +[EventKind]: #eventkind +[Variable Range]: #variable-range +[Control Limit]: #control-limit diff --git a/api/v1/endpoints/channels.py b/api/v1/endpoints/channels.py index 60a634f..ee95712 100644 --- a/api/v1/endpoints/channels.py +++ b/api/v1/endpoints/channels.py @@ -227,23 +227,32 @@ def open_channel_port_history( body: ChannelPortHistoryIn, conn=Depends(get_db), ): - """Open a ChannelPortHistory row linking a channel to a port for a time period.""" + """Open a ChannelPortHistory row linking a channel to a port for a time period. + + Routes through ``set_channel_active_port``, the single writer of the CPH + active-row invariant: it closes the previous active row before opening the + new one, so a second post no longer collides with the + UQ_ChannelPortHistory_ActiveRow filtered unique index (previously a 500). + """ cursor = conn.cursor() - cursor.execute( - "INSERT INTO [dbo].[ChannelPortHistory]" - " ([Channel_ID], [SignalInterfacePort_ID], [ValidFrom], [GatingNote])" - " OUTPUT INSERTED.[ChannelPortHistory_ID], INSERTED.[Channel_ID]," - " INSERTED.[SignalInterfacePort_ID]," - " CONVERT(VARCHAR(50), INSERTED.[ValidFrom], 127)," - " INSERTED.[GatingNote]" - " VALUES (?, ?, ?, ?)", + channel_repository.set_channel_active_port( + cursor, channel_id, body.signal_interface_port_id, body.valid_from, body.gating_note, ) - row = cursor.fetchone() conn.commit() + # Return the resulting active row (the just-opened one, or the existing one + # when the port was already current — set_channel_active_port is a no-op then). + cursor.execute( + "SELECT [ChannelPortHistory_ID], [Channel_ID], [SignalInterfacePort_ID]," + " CONVERT(VARCHAR(50), [ValidFrom], 127), [GatingNote]" + " FROM [dbo].[ChannelPortHistory]" + " WHERE [Channel_ID] = ? AND [ValidTo] IS NULL", + channel_id, + ) + row = cursor.fetchone() return ChannelPortHistoryOut( channel_port_history_id=row[0], channel_id=row[1], diff --git a/api/v1/endpoints/das_move.py b/api/v1/endpoints/das_move.py index 23bc8ae..0387aa1 100644 --- a/api/v1/endpoints/das_move.py +++ b/api/v1/endpoints/das_move.py @@ -21,6 +21,8 @@ DASConflictResponse, DASDeployRequest, DASDeployResponse, + DASMoveConflictsResponse, + StrandedEquipment, ) router = APIRouter() @@ -140,3 +142,29 @@ def conflict_check_endpoint( conflicting_campaign_id=conflict.get("campaign_id"), conflicting_campaign_name=conflict.get("campaign_name"), ) + + +@router.get( + "/{das_id}/move-conflicts", + response_model=DASMoveConflictsResponse, +) +def move_conflicts_endpoint( + das_id: int, + site_id: int, + conn=Depends(get_db), +): + """Equipment that a pending move of this DAS to ``site_id`` would strand. + + Lists equipment currently wired to this DAS whose active location is at a + SamplingPoint in a *different* Site than ``site_id``. Empty list = the move + is coherent. The wizard surfaces this so the user can relocate those + equipment too rather than leaving a silent location/DAS mismatch (which + ``vw_DeploymentCoherence`` would then report).""" + rows = temporal_history_repository.get_das_move_equipment_conflicts( + conn, das_id=das_id, new_site_id=site_id + ) + return DASMoveConflictsResponse( + das_id=das_id, + site_id=site_id, + stranded_equipment=[StrandedEquipment(**r) for r in rows], + ) diff --git a/api/v1/endpoints/data_health.py b/api/v1/endpoints/data_health.py new file mode 100644 index 0000000..9385911 --- /dev/null +++ b/api/v1/endpoints/data_health.py @@ -0,0 +1,94 @@ +"""Data-health endpoints — surface the broken-link views (consistency audit F5, F11). + + GET /data-health/unlinked-channels — raw channels needing wiring (F5) + GET /data-health/inactive-parent-references — live wiring on soft-deleted parents (F11) + +Both read the reconciling views added in schema 2.3.0; they are reports, not +mutations, so the app can show a "N channels need wiring" banner or confirm +before deactivating a parent that still has live children. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query + +from api.database import get_db +from ..schemas.data_health import ( + InactiveParentReference, + InactiveParentReferencesResponse, + UnlinkedChannel, + UnlinkedChannelsResponse, +) + +router = APIRouter() + + +@router.get("/unlinked-channels", response_model=UnlinkedChannelsResponse) +def unlinked_channels(conn=Depends(get_db)): + """Raw channels that carry observations but have no active wiring (F5).""" + cursor = conn.cursor() + cursor.execute( + """ + SELECT ChannelID, TagName, SignalInterfaceID, SignalInterfaceName, + ObservationCount, FirstObservation, LastObservation + FROM [dbo].[vw_UnlinkedChannels] + ORDER BY ObservationCount DESC + """ + ) + channels = [ + UnlinkedChannel( + channel_id=r[0], + tag_name=r[1], + signal_interface_id=r[2], + signal_interface_name=r[3], + observation_count=r[4], + first_observation=r[5], + last_observation=r[6], + ) + for r in cursor.fetchall() + ] + return UnlinkedChannelsResponse(count=len(channels), channels=channels) + + +@router.get( + "/inactive-parent-references", + response_model=InactiveParentReferencesResponse, +) +def inactive_parent_references( + signal_interface_id: int | None = Query(default=None), + signal_interface_port_id: int | None = Query(default=None), + conn=Depends(get_db), +): + """Active wiring rows still pointing at a soft-deleted interface/port (F11). + + Optionally filter to a single parent — the deactivation flow passes the + interface/port about to be set inactive to ask "does this still have live + children?" before committing. + """ + sql = """ + SELECT ReferenceType, WiringHistoryID, EquipmentID, ParentID, ParentLabel + FROM [dbo].[vw_InactiveParentReferences] + """ + clauses, params = [], [] + if signal_interface_id is not None: + clauses.append("(ReferenceType = N'active-wiring->interface' AND ParentID = ?)") + params.append(signal_interface_id) + if signal_interface_port_id is not None: + clauses.append("(ReferenceType = N'active-wiring->port' AND ParentID = ?)") + params.append(signal_interface_port_id) + if clauses: + sql += " WHERE " + " OR ".join(clauses) + + cursor = conn.cursor() + cursor.execute(sql, *params) + refs = [ + InactiveParentReference( + reference_type=r[0], + wiring_history_id=r[1], + equipment_id=r[2], + parent_id=r[3], + parent_label=r[4], + ) + for r in cursor.fetchall() + ] + return InactiveParentReferencesResponse(count=len(refs), references=refs) diff --git a/api/v1/endpoints/equipment_move.py b/api/v1/endpoints/equipment_move.py index aa4901a..12d03f0 100644 --- a/api/v1/endpoints/equipment_move.py +++ b/api/v1/endpoints/equipment_move.py @@ -25,6 +25,7 @@ temporal_history_repository, ) from ..schemas.equipment_move import ( + ActiveCampaignDeploymentResponse, EquipmentRegisterInterfaceRequest, EquipmentRegisterInterfaceResponse, EquipmentRelocateRequest, @@ -331,3 +332,37 @@ def get_location_at_time_endpoint( valid_from=row["valid_from"], valid_to=row["valid_to"], ) + + +@router.get( + "/{equipment_id}/active-campaign", + response_model=ActiveCampaignDeploymentResponse, +) +def get_active_campaign_endpoint( + equipment_id: int, + conn=Depends(get_db), +): + """Return the still-running campaign whose deployment placed this equipment. + + Reconfiguring (relocate/rewire) equipment placed by a campaign that has not + ended will close that campaign's deployment, since physical configuration is + shared across campaigns. The move UIs call this to warn before acting. All + fields are None when no open campaign row exists.""" + row = temporal_history_repository.get_active_campaign_deployment(conn, equipment_id) + if row is None: + return ActiveCampaignDeploymentResponse( + equipment_id=equipment_id, + campaign_id=None, + campaign_name=None, + equipment_location_history_id=None, + sampling_point_id=None, + sampling_point_name=None, + ) + return ActiveCampaignDeploymentResponse( + equipment_id=equipment_id, + campaign_id=row["campaign_id"], + campaign_name=row.get("campaign_name"), + equipment_location_history_id=row.get("equipment_location_history_id"), + sampling_point_id=row.get("sampling_point_id"), + sampling_point_name=row.get("sampling_point_name"), + ) diff --git a/api/v1/endpoints/events.py b/api/v1/endpoints/events.py new file mode 100644 index 0000000..354cce2 --- /dev/null +++ b/api/v1/endpoints/events.py @@ -0,0 +1,146 @@ +"""Event and EventKind endpoints. + +Routers registered in api/v1/router.py: + - events_router → prefix /events (CRUD on Event) + - event_kinds_router → prefix /event-kinds (CRUD on EventKind) +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, Response + +from api.database import get_db +from api.v1.errors import EntityNotFoundError +from ..repositories import event_repository, maintenance_drift_repository +from ..schemas.events import EventIn, EventKindIn, EventKindOut, EventOut, EventPatch +from ..schemas.maintenance_drift import MaintenanceDriftReadback + + +# --------------------------------------------------------------------------- +# EventKind resource: /event-kinds +# --------------------------------------------------------------------------- + +event_kinds_router = APIRouter() + + +@event_kinds_router.get("", response_model=list[EventKindOut]) +def list_event_kinds(conn=Depends(get_db)): + """List all EventKind vocabulary entries.""" + return event_repository.get_event_kinds(conn) + + +@event_kinds_router.post("", response_model=EventKindOut, status_code=201) +def create_event_kind(body: EventKindIn, conn=Depends(get_db)): + """Create a new EventKind.""" + row = event_repository.insert_event_kind(conn, body.name, body.description) + return EventKindOut(**row) + + +@event_kinds_router.put("/{event_kind_id}", response_model=EventKindOut) +def update_event_kind(event_kind_id: int, body: EventKindIn, conn=Depends(get_db)): + """Replace an EventKind name/description.""" + row = event_repository.update_event_kind( + conn, event_kind_id, body.name, body.description + ) + if row is None: + raise HTTPException( + status_code=404, detail=f"EventKind {event_kind_id} not found." + ) + return EventKindOut(**row) + + +@event_kinds_router.delete("/{event_kind_id}", status_code=204) +def delete_event_kind(event_kind_id: int, conn=Depends(get_db)): + """Delete an EventKind by ID.""" + deleted = event_repository.delete_event_kind(conn, event_kind_id) + if not deleted: + raise HTTPException( + status_code=404, detail=f"EventKind {event_kind_id} not found." + ) + return Response(status_code=204) + + +# --------------------------------------------------------------------------- +# Event resource: /events +# --------------------------------------------------------------------------- + +events_router = APIRouter() + + +@events_router.get("", response_model=list[EventOut]) +def list_events( + channel_id: int | None = Query(None), + equipment_id: int | None = Query(None), + signal_interface_id: int | None = Query(None), + data_acquisition_system_id: int | None = Query(None), + sampling_point_id: int | None = Query(None), + process_unit_id: int | None = Query(None), + site_id: int | None = Query(None), + campaign_id: int | None = Query(None), + conn=Depends(get_db), +): + """List events, optionally filtered by any of the 8 arc-target FK columns.""" + return event_repository.get_events( + conn, + channel_id=channel_id, + equipment_id=equipment_id, + signal_interface_id=signal_interface_id, + data_acquisition_system_id=data_acquisition_system_id, + sampling_point_id=sampling_point_id, + process_unit_id=process_unit_id, + site_id=site_id, + campaign_id=campaign_id, + ) + + +@events_router.post("", response_model=EventOut, status_code=201) +def create_event(body: EventIn, conn=Depends(get_db)): + """Create a new Event (exactly one arc-target FK must be provided).""" + row = event_repository.insert_event(conn, body.model_dump()) + return EventOut(**row) + + +@events_router.get("/{event_id}", response_model=EventOut) +def get_event(event_id: int, conn=Depends(get_db)): + """Retrieve a single Event by ID.""" + row = event_repository.get_event_by_id(conn, event_id) + if row is None: + raise HTTPException(status_code=404, detail=f"Event {event_id} not found.") + return EventOut(**row) + + +@events_router.get( + "/{event_id}/maintenance-drift", + response_model=MaintenanceDriftReadback, + summary="Drift since last cleaning for a maintenance Event", + description=( + "Read-back (PRD-4 S4): the before/after readings derived from the " + "source stream around this Event's window, via its linked " + "maintenance-drift Channel. 404 if the Event has no drift Channel." + ), +) +def get_event_maintenance_drift(event_id: int, conn=Depends(get_db)): + """Return the drift read-back for a maintenance Event (before/after + %diff).""" + try: + result = maintenance_drift_repository.get_drift_readback(conn, event_id) + except EntityNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return MaintenanceDriftReadback(**result) + + +@events_router.put("/{event_id}", response_model=EventOut) +def update_event(event_id: int, body: EventPatch, conn=Depends(get_db)): + """Partial-update an Event (fields omitted or None are left unchanged).""" + row = event_repository.update_event(conn, event_id, body.model_dump(exclude_none=True)) + if row is None: + raise HTTPException(status_code=404, detail=f"Event {event_id} not found.") + return EventOut(**row) + + +@events_router.delete("/{event_id}", status_code=204) +def delete_event(event_id: int, conn=Depends(get_db)): + """Hard-delete an Event by ID.""" + deleted = event_repository.delete_event(conn, event_id) + if not deleted: + raise HTTPException(status_code=404, detail=f"Event {event_id} not found.") + return Response(status_code=204) diff --git a/api/v1/endpoints/ingest.py b/api/v1/endpoints/ingest.py index 83b48a4..f80f4c5 100644 --- a/api/v1/endpoints/ingest.py +++ b/api/v1/endpoints/ingest.py @@ -557,7 +557,7 @@ def ingest_sensor(data: SensorIngestRequest, conn=Depends(get_db)): Resolves (or creates) the Channel via the UNIQUE stream identity: (das_name, tag, parameter_name, data_provenance_kind_id, processing_degree). - DAS and SignalPort are auto-created with a warning on first encounter. + DAS and SignalInterface are auto-created with a warning on first encounter. Unrecognised parameter_name or unit_name returns 422 before any DB write. """ ( @@ -636,11 +636,11 @@ def ingest_sensor(data: SensorIngestRequest, conn=Depends(get_db)): def ingest_sensor_tagless(data: TaglessSensorIngestRequest, conn=Depends(get_db)): """Ingest raw sensor measurements from a direct-connect station (no SCADA tag). - A synthetic SignalPort tag is auto-generated as + A synthetic SignalInterface tag is auto-generated as ``"{equipment_name}/{parameter_name}"`` (lowercased, trimmed) — deterministic and stable across repeated runs. - On first ingest a SignalPortEquipmentHistory row is opened immediately so + On first ingest an EquipmentWiringHistory row is opened immediately so provenance is recorded from the start. Subsequent ingests for the same (DAS, equipment_name, parameter_name) are idempotent. diff --git a/api/v1/endpoints/lineage.py b/api/v1/endpoints/lineage.py index 01efa5e..b513060 100644 --- a/api/v1/endpoints/lineage.py +++ b/api/v1/endpoints/lineage.py @@ -2,7 +2,9 @@ from __future__ import annotations -from fastapi import APIRouter, Depends +from datetime import datetime + +from fastapi import APIRouter, Depends, Query from fastapi import HTTPException @@ -13,6 +15,7 @@ ProcessingStepCreate, ProcessingStepOut, ProvenanceGraphOut, + StreamPedigreeOut, StreamStoryOut, ) from ..services import lineage_service @@ -95,3 +98,23 @@ def get_stream_story(stream_id: int, conn=Depends(get_db)): return story +@router.get("/streams/{stream_id}/pedigree", response_model=StreamPedigreeOut) +def get_stream_pedigree( + stream_id: int, + from_: datetime | None = Query(None, alias="from"), + to: datetime | None = Query(None, alias="to"), + conn=Depends(get_db), +): + """Read-only stream pedigree: time-invariant identity plus a time-bound + deployment timeline (sampling location, process unit, site, campaign, + responsible person per deployment). Optional from/to restrict the timeline to + segments overlapping that window (the exported range). Distinct from + /provenance (the processing DAG). Powers the data-export metadata YAML.""" + pedigree = channel_repository.get_stream_pedigree( + conn, stream_id, from_dt=from_, to_dt=to + ) + if pedigree is None: + raise HTTPException(status_code=404, detail=f"Stream {stream_id} not found.") + return pedigree + + diff --git a/api/v1/endpoints/maintenance_drift.py b/api/v1/endpoints/maintenance_drift.py new file mode 100644 index 0000000..2af1bb2 --- /dev/null +++ b/api/v1/endpoints/maintenance_drift.py @@ -0,0 +1,50 @@ +"""Maintenance-drift derived Channel endpoint (PRD-2.5 S1). + +POST /channels/derived/maintenance-drift + - Creates a ProcessingStep (method='maintenance_drift') recording the source + channel and maintenance event IDs in MethodParameters JSON. + - Mints a derived Channel linked to that step. + - Does NOT compute or insert %diff Values (S2 scope). +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException + +from api.database import get_db +from api.v1.errors import EntityNotFoundError +from ..repositories import maintenance_drift_repository +from ..schemas.maintenance_drift import MaintenanceDriftIn, MaintenanceDriftOut + +router = APIRouter() + + +@router.post( + "/derived/maintenance-drift", + response_model=MaintenanceDriftOut, + status_code=201, + tags=["channels"], + summary="Create a maintenance-drift derived Channel", + description=( + "Model maintenance drift as a first-class derived Channel. " + "Creates a ProcessingStep storing source_channel_id + event_ids in " + "MethodParameters JSON, then mints a derived Channel linked to that step. " + "Value computation (%diff points) is S2 scope and is not performed here." + ), +) +def create_maintenance_drift_channel( + body: MaintenanceDriftIn, + conn=Depends(get_db), +) -> MaintenanceDriftOut: + """Create a ProcessingStep + derived Channel for maintenance drift tracking.""" + try: + result = maintenance_drift_repository.create_drift_channel( + conn, + source_channel_id=body.source_channel_id, + event_ids=body.event_ids, + name=body.name, + performed_by_person_id=body.performed_by_person_id, + ) + except EntityNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return MaintenanceDriftOut(**result) diff --git a/api/v1/repositories/annotation_repository.py b/api/v1/repositories/annotation_repository.py index efe2e0c..fdf2f17 100644 --- a/api/v1/repositories/annotation_repository.py +++ b/api/v1/repositories/annotation_repository.py @@ -95,7 +95,7 @@ def _row_to_annotation(row) -> dict: "author_name": row[10], "campaign_id": row[11], "campaign_name": row[12], - "equipment_event_id": row[13], + "event_id": row[13], "created_datetime": row[14], "modified_datetime": row[15], "stream_kind_id": row[16], @@ -121,7 +121,7 @@ def _row_to_annotation(row) -> dict: CONCAT(p.[FirstName], ' ', p.[LastName]) AS AuthorName, a.[Campaign_ID], c.[Name] AS CampaignName, - a.[EquipmentEvent_ID], + a.[Event_ID], a.[CreatedDateTime], a.[ModifiedDateTime], s.[StreamKind_ID], @@ -201,7 +201,7 @@ def get_annotations_for_stream( CONCAT(p.[FirstName], ' ', p.[LastName]) AS AuthorName, a.[Campaign_ID], c.[Name] AS CampaignName, - a.[EquipmentEvent_ID], + a.[Event_ID], a.[CreatedDateTime], a.[ModifiedDateTime], s.[StreamKind_ID], @@ -239,7 +239,7 @@ def get_annotations_for_stream( CONCAT(p.[FirstName], ' ', p.[LastName]) AS AuthorName, a.[Campaign_ID], c.[Name] AS CampaignName, - a.[EquipmentEvent_ID], + a.[Event_ID], a.[CreatedDateTime], a.[ModifiedDateTime], s.[StreamKind_ID], @@ -415,7 +415,7 @@ def create_annotation( end_time: datetime | None, author_person_id: int | None, campaign_id: int | None, - equipment_event_id: int | None, + event_id: int | None, title: str | None, comment: str | None, observation_id: int | None = None, @@ -433,7 +433,7 @@ def create_annotation( INSERT INTO [dbo].[Annotation] ( [Stream_ID], [AnnotationKind_ID], [StartTime], [EndTime], - [AuthorPerson_ID], [Campaign_ID], [EquipmentEvent_ID], + [AuthorPerson_ID], [Campaign_ID], [Event_ID], [Title], [Comment], [Observation_ID] ) OUTPUT INSERTED.[Annotation_ID], INSERTED.[CreatedDateTime] @@ -445,7 +445,7 @@ def create_annotation( end_time, author_person_id, campaign_id, - equipment_event_id, + event_id, title, comment, observation_id, @@ -625,7 +625,7 @@ def create_equipment_move_annotations( end_time=None, author_person_id=None, campaign_id=None, - equipment_event_id=None, + event_id=None, title=title, comment=comment, ) diff --git a/api/v1/repositories/campaign_repository.py b/api/v1/repositories/campaign_repository.py index ab67cbf..472e6d0 100644 --- a/api/v1/repositories/campaign_repository.py +++ b/api/v1/repositories/campaign_repository.py @@ -13,8 +13,6 @@ c.[Campaign_ID], c.[CampaignKind_ID], ct.[Name], - c.[Site_ID], - s.[Name] AS SiteName, c.[Name], c.[Description], c.[CampaignStartDateTime], @@ -23,24 +21,42 @@ CONCAT(p.[FirstName], ' ', p.[LastName]) AS ResponsiblePersonName FROM [dbo].[Campaign] c LEFT JOIN [dbo].[CampaignKind] ct ON ct.[CampaignKind_ID] = c.[CampaignKind_ID] - LEFT JOIN [dbo].[Site] s ON s.[Site_ID] = c.[Site_ID] LEFT JOIN [dbo].[Person] p ON p.[Person_ID] = c.[ResponsiblePerson_ID] """ +# A campaign's sites are derived from its sampling-location membership +# (CampaignSamplingLocation → SamplingPoint.Site); campaigns are multi-site. +_CAMPAIGN_SITES_SQL = """ + SELECT DISTINCT s.[Site_ID], s.[Name] + FROM [dbo].[CampaignSamplingLocation] csl + JOIN [dbo].[SamplingPoint] sp ON sp.[SamplingPoint_ID] = csl.[SamplingPoint_ID] + JOIN [dbo].[Site] s ON s.[Site_ID] = sp.[Site_ID] + WHERE csl.[Campaign_ID] = ? + ORDER BY s.[Name] +""" + + +def _campaign_sites(conn: pyodbc.Connection, campaign_id: int) -> list[dict]: + cursor = conn.cursor() + cursor.execute(_CAMPAIGN_SITES_SQL, campaign_id) + return [ + {"site_id": r[0], "site_name": r[1]} for r in cursor.fetchall() + ] + -def _row_to_dict(row) -> dict: +def _row_to_dict(row, sites: list[dict]) -> dict: return { "campaign_id": row[0], "campaign_kind_id": row[1], "campaign_kind_name": row[2], - "site_id": row[3], - "site_name": row[4], - "name": row[5], - "description": row[6], - "start_date": row[7], - "end_date": row[8], - "responsible_person_id": row[9], - "responsible_person_name": row[10], + "name": row[3], + "description": row[4], + "start_date": row[5], + "end_date": row[6], + "responsible_person_id": row[7], + "responsible_person_name": row[8], + "site_ids": [s["site_id"] for s in sites], + "site_names": [s["site_name"] for s in sites], } @@ -53,7 +69,11 @@ def list_campaigns( where_parts = [] params = [] if site_id is not None: - where_parts.append("c.[Site_ID] = ?") + where_parts.append( + "EXISTS (SELECT 1 FROM [dbo].[CampaignSamplingLocation] csl" + " JOIN [dbo].[SamplingPoint] sp ON sp.[SamplingPoint_ID] = csl.[SamplingPoint_ID]" + " WHERE csl.[Campaign_ID] = c.[Campaign_ID] AND sp.[Site_ID] = ?)" + ) params.append(site_id) if campaign_kind_id is not None: where_parts.append("c.[CampaignKind_ID] = ?") @@ -64,26 +84,26 @@ def list_campaigns( cursor.execute( _CAMPAIGN_SELECT + where_clause + " ORDER BY c.[Campaign_ID]", *params ) - return [_row_to_dict(row) for row in cursor.fetchall()] + rows = cursor.fetchall() + return [_row_to_dict(row, _campaign_sites(conn, row[0])) for row in rows] def get_campaign_by_id(conn: pyodbc.Connection, campaign_id: int) -> dict | None: cursor = conn.cursor() cursor.execute(_CAMPAIGN_SELECT + " WHERE c.[Campaign_ID] = ?", campaign_id) row = cursor.fetchone() - return _row_to_dict(row) if row else None + return _row_to_dict(row, _campaign_sites(conn, row[0])) if row else None def insert_campaign(conn: pyodbc.Connection, data: dict) -> dict | None: cursor = conn.cursor() cursor.execute( "INSERT INTO [dbo].[Campaign]" - " ([Name], [CampaignKind_ID], [Site_ID], [Description]," + " ([Name], [CampaignKind_ID], [Description]," " [CampaignStartDateTime], [CampaignEndDateTime], [ResponsiblePerson_ID])" - " VALUES (?, ?, ?, ?, ?, ?, ?)", + " VALUES (?, ?, ?, ?, ?, ?)", data.get("name"), data.get("campaign_kind_id"), - data.get("site_id"), data.get("description"), data.get("start_date"), data.get("end_date"), @@ -103,12 +123,11 @@ def update_campaign( cursor = conn.cursor() cursor.execute( "UPDATE [dbo].[Campaign]" - " SET [Name]=?, [CampaignKind_ID]=?, [Site_ID]=?, [Description]=?," + " SET [Name]=?, [CampaignKind_ID]=?, [Description]=?," " [CampaignStartDateTime]=?, [CampaignEndDateTime]=?, [ResponsiblePerson_ID]=?" " WHERE [Campaign_ID]=?", data.get("name"), data.get("campaign_kind_id"), - data.get("site_id"), data.get("description"), data.get("start_date"), data.get("end_date"), @@ -143,9 +162,6 @@ def patch_campaign( if "campaign_kind_id" in data: fields.append("[CampaignKind_ID]=?") values.append(data.get("campaign_kind_id")) - if "site_id" in data: - fields.append("[Site_ID]=?") - values.append(data.get("site_id")) if "description" in data: fields.append("[Description]=?") values.append(data.get("description")) @@ -376,21 +392,26 @@ def delete_campaign_deployment( ) -> None: """Reverse a deployment created by :func:`create_campaign_deployment`. - In one transaction: - 1. Delete the active (``ValidTo IS NULL``) ``EquipmentLocationHistory`` row - tagged with this campaign for the equipment (the physical placement that - create opened). EquipmentInstallation was dropped — placement lives here. - 2. Delete the ``CampaignEquipment`` link. - 3. Delete the ``CampaignSamplingLocation`` link **only if** no other equipment - remains placed at that sampling point under this campaign (the link is - campaign-level and shared across deployments). + Membership is junction-authoritative (consistency audit F10, decision D2): + ``CampaignEquipment`` / ``CampaignSamplingLocation`` define what a campaign + contains; ``ELH.Campaign_ID`` is provenance only. In one transaction: + 1. Delete the ``CampaignEquipment`` membership row. + 2. Delete the active (``ValidTo IS NULL``) ``EquipmentLocationHistory`` row + create opened (reverses the physical placement). Closed ELH rows keep + their provenance ``Campaign_ID`` untouched. + 3. Delete the ``CampaignSamplingLocation`` membership row **only if** no + *other* equipment still in this campaign has any placement provenance at + that sampling point. The check is junction-driven and agnostic to ELH + open/closed state — the previous "only if an open ELH row remains" + condition dropped the SP link whenever the last placement there had been + closed, so the junctions and ELH history could disagree. """ cursor = conn.cursor() cursor.execute( """ - DELETE FROM [dbo].[EquipmentLocationHistory] - WHERE [Campaign_ID] = ? AND [Equipment_ID] = ? AND [ValidTo] IS NULL + DELETE FROM [dbo].[CampaignEquipment] + WHERE [Campaign_ID] = ? AND [Equipment_ID] = ? """, campaign_id, equipment_id, @@ -398,8 +419,8 @@ def delete_campaign_deployment( cursor.execute( """ - DELETE FROM [dbo].[CampaignEquipment] - WHERE [Campaign_ID] = ? AND [Equipment_ID] = ? + DELETE FROM [dbo].[EquipmentLocationHistory] + WHERE [Campaign_ID] = ? AND [Equipment_ID] = ? AND [ValidTo] IS NULL """, campaign_id, equipment_id, @@ -411,16 +432,19 @@ def delete_campaign_deployment( DELETE FROM [dbo].[CampaignSamplingLocation] WHERE [Campaign_ID] = ? AND [SamplingPoint_ID] = ? AND NOT EXISTS ( - SELECT 1 FROM [dbo].[EquipmentLocationHistory] elh - WHERE elh.[Campaign_ID] = ? - AND elh.[SamplingPoint_ID] = ? - AND elh.[ValidTo] IS NULL + SELECT 1 + FROM [dbo].[CampaignEquipment] ce + JOIN [dbo].[EquipmentLocationHistory] elh + ON elh.[Equipment_ID] = ce.[Equipment_ID] + AND elh.[Campaign_ID] = ce.[Campaign_ID] + AND elh.[SamplingPoint_ID] = ? + WHERE ce.[Campaign_ID] = ? ) """, campaign_id, sampling_point_id, - campaign_id, sampling_point_id, + campaign_id, ) conn.commit() @@ -479,14 +503,16 @@ def get_campaign_overview(conn: pyodbc.Connection, campaign_id: int) -> dict: """ cur = conn.cursor() - # --- Watershed (via the campaign's site) ------------------------------ + # --- Watershed (via the campaign's derived site membership) ----------- + # ponytail: TOP 1 — a multi-site campaign shows one watershed in the header. cur.execute( """ - SELECT w.[Watershed_ID], w.[Name] - FROM [dbo].[Campaign] c - JOIN [dbo].[Site] s ON s.[Site_ID] = c.[Site_ID] + SELECT TOP 1 w.[Watershed_ID], w.[Name] + FROM [dbo].[CampaignSamplingLocation] csl + JOIN [dbo].[SamplingPoint] sp ON sp.[SamplingPoint_ID] = csl.[SamplingPoint_ID] + JOIN [dbo].[Site] s ON s.[Site_ID] = sp.[Site_ID] LEFT JOIN [dbo].[Watershed] w ON w.[Watershed_ID] = s.[Watershed_ID] - WHERE c.[Campaign_ID] = ? + WHERE csl.[Campaign_ID] = ? AND w.[Watershed_ID] IS NOT NULL """, campaign_id, ) diff --git a/api/v1/repositories/channel_repository.py b/api/v1/repositories/channel_repository.py index bd38cad..1f43b70 100644 --- a/api/v1/repositories/channel_repository.py +++ b/api/v1/repositories/channel_repository.py @@ -2,6 +2,8 @@ from __future__ import annotations +from datetime import datetime + import pyodbc # Channel is the Sensor subtype of Stream (StreamKind discriminator: 1=Sensor, @@ -31,7 +33,7 @@ u.[Unit] AS UnitName, ewh.[Equipment_ID], e.[Identifier] AS EquipmentIdentifier - FROM [dbo].[Channel] c + FROM [dbo].[vw_ChannelResolved] c LEFT JOIN [dbo].[SignalInterface] si ON si.[SignalInterface_ID] = c.[SignalInterface_ID] LEFT JOIN [dbo].[SignalInterfacePort] sip ON sip.[SignalInterfacePort_ID] = c.[SignalInterfacePort_ID] LEFT JOIN [dbo].[Channel] parent ON parent.[Stream_ID] = c.[ParentChannel_ID] @@ -73,7 +75,7 @@ u.[Unit] AS UnitName, ewh.[Equipment_ID], e.[Identifier] AS EquipmentIdentifier - FROM [dbo].[Channel] c + FROM [dbo].[vw_ChannelResolved] c LEFT JOIN [dbo].[SignalInterface] si ON si.[SignalInterface_ID] = c.[SignalInterface_ID] LEFT JOIN [dbo].[SignalInterfacePort] sip ON sip.[SignalInterfacePort_ID] = c.[SignalInterfacePort_ID] LEFT JOIN [dbo].[Channel] parent ON parent.[Stream_ID] = c.[ParentChannel_ID] @@ -163,7 +165,7 @@ def list_channels( if use_campaign: count_sql = ( - f"SELECT COUNT(*) FROM [dbo].[Channel] c " + f"SELECT COUNT(*) FROM [dbo].[vw_ChannelResolved] c " f"LEFT JOIN [dbo].[EquipmentWiringHistory] ewh " f" ON ewh.[SignalInterface_ID] = c.[SignalInterface_ID] " f" AND (ewh.[SignalInterfacePort_ID] = c.[SignalInterfacePort_ID] " @@ -178,7 +180,7 @@ def list_channels( else: if equipment_id is not None: count_sql = ( - f"SELECT COUNT(*) FROM [dbo].[Channel] c " + f"SELECT COUNT(*) FROM [dbo].[vw_ChannelResolved] c " f"LEFT JOIN [dbo].[EquipmentWiringHistory] ewh " f" ON ewh.[SignalInterface_ID] = c.[SignalInterface_ID] " f" AND (ewh.[SignalInterfacePort_ID] = c.[SignalInterfacePort_ID] " @@ -225,6 +227,81 @@ def _insert_stream(cursor: pyodbc.Cursor, stream_kind_id: int) -> int: return int(cursor.fetchone()[0]) +def set_channel_active_port( + cursor: pyodbc.Cursor, + channel_id: int, + port_id: int | None, + valid_from: datetime | str | None = None, + gating_note: str | None = None, +) -> tuple[int | None, int | None]: + """Make ``port_id`` the channel's active ChannelPortHistory row. + + The single writer of the CPH active-row invariant: closes the current + active row (if any) and opens a new one, in the caller's transaction (does + NOT commit). No-op when the requested port already matches the active row, + so it never churns rows. Passing ``port_id=None`` closes the active row + without opening a new one (the channel becomes untraced). + + Returns ``(new_cph_id, closed_cph_id)``; either may be ``None``. + """ + cursor.execute( + "SELECT [ChannelPortHistory_ID], [SignalInterfacePort_ID] " + "FROM [dbo].[ChannelPortHistory] " + "WHERE [Channel_ID] = ? AND [ValidTo] IS NULL", + channel_id, + ) + active = cursor.fetchone() + active_port = active[1] if active else None + if active_port == port_id: + return None, active[0] if active else None # already current — no churn + + closed_id: int | None = None + ts = valid_from + if active is not None: + if ts is None: + cursor.execute( + "UPDATE [dbo].[ChannelPortHistory] SET [ValidTo] = SYSUTCDATETIME() " + "OUTPUT DELETED.[ChannelPortHistory_ID] " + "WHERE [Channel_ID] = ? AND [ValidTo] IS NULL", + channel_id, + ) + else: + cursor.execute( + "UPDATE [dbo].[ChannelPortHistory] SET [ValidTo] = ? " + "OUTPUT DELETED.[ChannelPortHistory_ID] " + "WHERE [Channel_ID] = ? AND [ValidTo] IS NULL", + ts, + channel_id, + ) + closed_id = cursor.fetchone()[0] + + new_id: int | None = None + if port_id is not None: + if ts is None: + cursor.execute( + "INSERT INTO [dbo].[ChannelPortHistory] " + "([Channel_ID], [SignalInterfacePort_ID], [ValidFrom], [GatingNote]) " + "OUTPUT INSERTED.[ChannelPortHistory_ID] " + "VALUES (?, ?, SYSUTCDATETIME(), ?)", + channel_id, + port_id, + gating_note, + ) + else: + cursor.execute( + "INSERT INTO [dbo].[ChannelPortHistory] " + "([Channel_ID], [SignalInterfacePort_ID], [ValidFrom], [GatingNote]) " + "OUTPUT INSERTED.[ChannelPortHistory_ID] " + "VALUES (?, ?, ?, ?)", + channel_id, + port_id, + ts, + gating_note, + ) + new_id = int(cursor.fetchone()[0]) + return new_id, closed_id + + def insert_channel(conn: pyodbc.Connection, data: dict) -> dict | None: """Insert a new channel and return the created record. @@ -237,13 +314,12 @@ def insert_channel(conn: pyodbc.Connection, data: dict) -> dict | None: new_id = _insert_stream(cursor, STREAM_KIND_SENSOR) cursor.execute( "INSERT INTO [dbo].[Channel] " - "([Stream_ID], [SignalInterface_ID], [TagName], [SignalInterfacePort_ID], [ParentChannel_ID], " + "([Stream_ID], [SignalInterface_ID], [TagName], [ParentChannel_ID], " "[ChannelKind_ID], [Parameter_ID], [DataProvenanceKind_ID], [ProducedByStep_ID], [ValueKind_ID], [Unit_ID])" - " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", new_id, data.get("signal_interface_id"), data.get("tag_name"), - data.get("signal_interface_port_id"), data.get("parent_channel_id"), data.get("channel_kind_id", 1), # Default to 'Value' kind data.get("parameter_id"), @@ -253,6 +329,10 @@ def insert_channel(conn: pyodbc.Connection, data: dict) -> dict | None: # arg won't fire since model_dump() always includes the key as None. data.get("unit_id"), ) + # The port is no longer a Channel column (F3): record it as the active + # ChannelPortHistory row. Only when a port is actually supplied. + if data.get("signal_interface_port_id") is not None: + set_channel_active_port(cursor, new_id, data["signal_interface_port_id"]) conn.commit() return get_channel_by_id(conn, new_id) @@ -262,12 +342,11 @@ def update_channel(conn: pyodbc.Connection, channel_id: int, data: dict) -> dict cursor = conn.cursor() cursor.execute( "UPDATE [dbo].[Channel]" - " SET [SignalInterface_ID]=?, [TagName]=?, [SignalInterfacePort_ID]=?, [ParentChannel_ID]=?, " + " SET [SignalInterface_ID]=?, [TagName]=?, [ParentChannel_ID]=?, " "[ChannelKind_ID]=?, [Parameter_ID]=?, [DataProvenanceKind_ID]=?, [ProducedByStep_ID]=?, [ValueKind_ID]=?, [Unit_ID]=?" " WHERE [Stream_ID]=?", data.get("signal_interface_id"), data.get("tag_name"), - data.get("signal_interface_port_id"), data.get("parent_channel_id"), data.get("channel_kind_id", 1), data.get("parameter_id"), @@ -277,6 +356,11 @@ def update_channel(conn: pyodbc.Connection, channel_id: int, data: dict) -> dict data.get("unit_id"), channel_id, ) + # The port is no longer a Channel column (F3): reflect a provided port change + # in ChannelPortHistory. Only act when the caller explicitly sent the field + # (a PATCH that omits it must not clear the port). + if "signal_interface_port_id" in data: + set_channel_active_port(cursor, channel_id, data["signal_interface_port_id"]) conn.commit() return get_channel_by_id(conn, channel_id) @@ -391,7 +475,7 @@ def get_channel_ids_for_equipment( cursor.execute( """ SELECT c.[Stream_ID] - FROM [dbo].[Channel] c + FROM [dbo].[vw_ChannelResolved] c JOIN [dbo].[EquipmentWiringHistory] ewh ON ewh.[SignalInterface_ID] = c.[SignalInterface_ID] AND ( @@ -523,3 +607,207 @@ def get_stream_story(conn: pyodbc.Connection, stream_id: int) -> dict | None: return {"stream_id": stream_id, "record": record, "location_history": locations, "annotations": annotations} + + +def _pedigree_sampling_point(cur: pyodbc.Cursor, sp_id: int | None): + """Resolve (sampling_location, process_unit, site) for a sampling point.""" + if sp_id is None: + return None, None, None + cur.execute( + """ + SELECT sp.[SamplingPoint], sp.[LatitudeWGS84], sp.[LongitudeWGS84], + pu.[ProcessUnit_ID], pu.[Tag], pu.[Name], puk.[Name] AS pu_kind, + s.[Site_ID], s.[Name], s.[City], s.[Province], s.[Country] + FROM [dbo].[SamplingPoint] sp + LEFT JOIN [dbo].[ProcessUnit] pu ON pu.[ProcessUnit_ID] = sp.[ProcessUnit_ID] + LEFT JOIN [dbo].[ProcessUnitKind] puk ON puk.[ProcessUnitKind_ID] = pu.[ProcessUnitKind_ID] + LEFT JOIN [dbo].[Site] s ON s.[Site_ID] = sp.[Site_ID] + WHERE sp.[SamplingPoint_ID] = ? + """, + sp_id, + ) + r = cur.fetchone() + if r is None: + return None, None, None + location = {"sampling_point_id": sp_id, "name": r[0], + "latitude": r[1], "longitude": r[2]} + process_unit = ( + {"process_unit_id": r[3], "tag": r[4], "name": r[5], "kind": r[6]} + if r[3] is not None else None + ) + site = ( + {"site_id": r[7], "name": r[8], "city": r[9], "province": r[10], "country": r[11]} + if r[7] is not None else None + ) + return location, process_unit, site + + +def _pedigree_campaign(cur: pyodbc.Cursor, campaign_id: int | None): + """Resolve (campaign, responsible_person, site_fallback) for a campaign.""" + if campaign_id is None: + return None, None, None + cur.execute( + """ + SELECT c.[Name], ck.[Name] AS campaign_kind, + c.[CampaignStartDateTime], c.[CampaignEndDateTime], + per.[Person_ID], per.[FirstName], per.[LastName], + per.[Email], per.[Role], per.[Company] + FROM [dbo].[Campaign] c + LEFT JOIN [dbo].[CampaignKind] ck ON ck.[CampaignKind_ID] = c.[CampaignKind_ID] + LEFT JOIN [dbo].[Person] per ON per.[Person_ID] = c.[ResponsiblePerson_ID] + WHERE c.[Campaign_ID] = ? + """, + campaign_id, + ) + r = cur.fetchone() + if r is None: + return None, None, None + campaign = {"campaign_id": campaign_id, "name": r[0], "kind": r[1], + "start": r[2], "end": r[3]} + person = None + if r[4] is not None: + full_name = " ".join(n for n in (r[5], r[6]) if n) + person = {"person_id": r[4], "name": full_name or None, + "email": r[7], "role": r[8], "company": r[9]} + # Campaign sites are derived from sampling-location membership; use one as a + # pedigree fallback only when the campaign is unambiguously single-site. + cur.execute( + """ + SELECT DISTINCT s.[Site_ID], s.[Name], s.[City], s.[Province], s.[Country] + FROM [dbo].[CampaignSamplingLocation] csl + JOIN [dbo].[SamplingPoint] sp ON sp.[SamplingPoint_ID] = csl.[SamplingPoint_ID] + JOIN [dbo].[Site] s ON s.[Site_ID] = sp.[Site_ID] + WHERE csl.[Campaign_ID] = ? + """, + campaign_id, + ) + site_rows = cur.fetchall() + site_fallback = None + if len(site_rows) == 1: + s = site_rows[0] + site_fallback = { + "site_id": s[0], "name": s[1], "city": s[2], + "province": s[3], "country": s[4], + } + return campaign, person, site_fallback + + +def _pedigree_segment(cur, valid_from, valid_to, equipment_identifier, + sp_id, campaign_id) -> dict: + """Assemble one deployment segment (a slice of the stream's life with a + stable location + campaign).""" + location, process_unit, site = _pedigree_sampling_point(cur, sp_id) + campaign, person, site_fallback = _pedigree_campaign(cur, campaign_id) + return { + "valid_from": valid_from, + "valid_to": valid_to, + "equipment_identifier": equipment_identifier, + "sampling_location": location, + "process_unit": process_unit, + "site": site or site_fallback, + "campaign": campaign, + "responsible_person": person, + } + + +def get_stream_pedigree( + conn: pyodbc.Connection, + stream_id: int, + *, + from_dt: datetime | None = None, + to_dt: datetime | None = None, +) -> dict | None: + """Resolve the organizational/spatial *pedigree* of a stream (distinct from + its processing *provenance*): the time-invariant identity plus a **deployment + timeline** of where it lived and which campaign owned it over its life. + + The location/campaign of a sensor channel are *historical*: equipment is + rewired (EquipmentWiringHistory) and moved between sampling points + (EquipmentLocationHistory) over time, so a single channel's data can span + several sampling locations and campaigns. The pedigree therefore returns one + segment per deployment (with its own ValidFrom/ValidTo), not a single + snapshot. ``from_dt``/``to_dt`` restrict the timeline to segments overlapping + that window (i.e. the exported time range). A lab AnalysisSeries has a single, + fixed sampling point + campaign, so it returns exactly one open segment. + + Returns None if the stream id is unknown. + """ + cur = conn.cursor() + + # --- Identity (time-invariant) -------------------------------------- + cur.execute( + """ + SELECT p.[Parameter], u.[Unit], vk.[Name] AS value_kind, c.[TagName] + FROM [dbo].[Channel] c + LEFT JOIN [dbo].[Parameter] p ON p.[Parameter_ID] = c.[Parameter_ID] + LEFT JOIN [dbo].[Unit] u ON u.[Unit_ID] = c.[Unit_ID] + LEFT JOIN [dbo].[ValueKind] vk ON vk.[ValueKind_ID] = c.[ValueKind_ID] + WHERE c.[Stream_ID] = ? + """, + stream_id, + ) + row = cur.fetchone() + if row is not None: + record = {"kind": "sensor", "parameter": row[0], "unit": row[1], + "value_kind": row[2], "label": row[3]} + + # Deployment segments: the EWH×ELH temporal join (mirrors + # list_deployment_traces), one row per EquipmentLocationHistory the + # producing equipment occupied, restricted to the requested window. + where = ["ch.[Stream_ID] = ?"] + params: list = [stream_id] + if to_dt is not None: + where.append("elh.[ValidFrom] <= ?") + params.append(to_dt) + if from_dt is not None: + where.append("(elh.[ValidTo] IS NULL OR elh.[ValidTo] >= ?)") + params.append(from_dt) + cur.execute( + f""" + SELECT DISTINCT elh.[EquipmentLocationHistory_ID], elh.[ValidFrom], + elh.[ValidTo], e.[Identifier], elh.[SamplingPoint_ID], + elh.[Campaign_ID] + FROM [dbo].[vw_ChannelResolved] ch + JOIN [dbo].[EquipmentWiringHistory] ewh + ON ewh.[SignalInterface_ID] = ch.[SignalInterface_ID] + AND ( + ewh.[SignalInterfacePort_ID] = ch.[SignalInterfacePort_ID] + OR (ewh.[SignalInterfacePort_ID] IS NULL AND ch.[SignalInterfacePort_ID] IS NULL) + OR ch.[SignalInterfacePort_ID] IS NULL + ) + JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = ewh.[Equipment_ID] + JOIN [dbo].[EquipmentLocationHistory] elh + ON elh.[Equipment_ID] = e.[Equipment_ID] + AND ewh.[ValidFrom] <= ISNULL(elh.[ValidTo], GETUTCDATE()) + AND (ewh.[ValidTo] IS NULL OR ewh.[ValidTo] >= elh.[ValidFrom]) + WHERE {" AND ".join(where)} + ORDER BY elh.[ValidFrom] + """, + *params, + ) + seg_rows = cur.fetchall() + deployments = [ + _pedigree_segment(cur, r[1], r[2], r[3], r[4], r[5]) for r in seg_rows + ] + else: + cur.execute( + """ + SELECT p.[Parameter], u.[Unit], vk.[Name] AS value_kind, a.[Name], + a.[SamplingPoint_ID], a.[Campaign_ID] + FROM [dbo].[AnalysisSeries] a + LEFT JOIN [dbo].[Parameter] p ON p.[Parameter_ID] = a.[Parameter_ID] + LEFT JOIN [dbo].[Unit] u ON u.[Unit_ID] = a.[Unit_ID] + LEFT JOIN [dbo].[ValueKind] vk ON vk.[ValueKind_ID] = a.[ValueKind_ID] + WHERE a.[Stream_ID] = ? + """, + stream_id, + ) + row = cur.fetchone() + if row is None: + return None + record = {"kind": "lab", "parameter": row[0], "unit": row[1], + "value_kind": row[2], "label": row[3]} + # Lab series: a single open segment (its fixed sampling point + campaign). + deployments = [_pedigree_segment(cur, None, None, None, row[4], row[5])] + + return {"stream_id": stream_id, **record, "deployments": deployments} diff --git a/api/v1/repositories/equipment_repository.py b/api/v1/repositories/equipment_repository.py index f17fdf1..b66abf2 100644 --- a/api/v1/repositories/equipment_repository.py +++ b/api/v1/repositories/equipment_repository.py @@ -312,28 +312,30 @@ def get_equipment_installations( from_dt: datetime | None, to_dt: datetime | None, ) -> list[dict]: + # F4: EquipmentInstallation table was dropped; location history now lives in + # EquipmentLocationHistory (ValidFrom/ValidTo temporal rows). params: list = [equipment_id] - where = "WHERE ei.[Equipment_ID] = ?" + where = "WHERE elh.[Equipment_ID] = ?" if from_dt: - where += " AND (ei.[RemovedDate] IS NULL OR ei.[RemovedDate] >= ?)" + where += " AND (elh.[ValidTo] IS NULL OR elh.[ValidTo] >= ?)" params.append(from_dt) if to_dt: - where += " AND ei.[InstalledDate] <= ?" + where += " AND elh.[ValidFrom] <= ?" params.append(to_dt) cursor = conn.cursor() cursor.execute( f""" - SELECT ei.[Installation_ID], ei.[SamplingPoint_ID], + SELECT elh.[EquipmentLocationHistory_ID], elh.[SamplingPoint_ID], sp.[SamplingPoint] AS LocationName, - ei.[InstalledDate], ei.[RemovedDate], - ei.[Campaign_ID], c.[Name] AS CampaignName, - ei.[Notes] - FROM [dbo].[EquipmentInstallation] ei - LEFT JOIN [dbo].[SamplingPoint] sp ON sp.[SamplingPoint_ID] = ei.[SamplingPoint_ID] - LEFT JOIN [dbo].[Campaign] c ON c.[Campaign_ID] = ei.[Campaign_ID] + elh.[ValidFrom], elh.[ValidTo], + elh.[Campaign_ID], c.[Name] AS CampaignName, + elh.[Notes] + FROM [dbo].[EquipmentLocationHistory] elh + LEFT JOIN [dbo].[SamplingPoint] sp ON sp.[SamplingPoint_ID] = elh.[SamplingPoint_ID] + LEFT JOIN [dbo].[Campaign] c ON c.[Campaign_ID] = elh.[Campaign_ID] {where} - ORDER BY ei.[InstalledDate] + ORDER BY elh.[ValidFrom] """, *params, ) diff --git a/api/v1/repositories/event_repository.py b/api/v1/repositories/event_repository.py new file mode 100644 index 0000000..f008d86 --- /dev/null +++ b/api/v1/repositories/event_repository.py @@ -0,0 +1,308 @@ +"""Data access for Event and EventKind resources.""" + +from __future__ import annotations + +import pyodbc + +# --------------------------------------------------------------------------- +# Column list shared by Event read queries +# --------------------------------------------------------------------------- + +_EVENT_SELECT = """ + SELECT + e.[Event_ID], + e.[EventKind_ID], + ek.[Name] AS EventKindName, + e.[IsInstantaneous], + e.[EventDateTimeStart], + e.[EventDateTimeEnd], + e.[PerformedByPerson_ID], + e.[RecordedByPerson_ID], + e.[Notes], + e.[Channel_ID], + e.[Equipment_ID], + e.[SignalInterface_ID], + e.[DataAcquisitionSystem_ID], + e.[SamplingPoint_ID], + e.[ProcessUnit_ID], + e.[Site_ID], + e.[Campaign_ID] + FROM [dbo].[Event] e + JOIN [dbo].[EventKind] ek + ON ek.[EventKind_ID] = e.[EventKind_ID] +""" + + +def _row_to_event(row) -> dict: + return { + "event_id": row[0], + "event_kind_id": row[1], + "event_kind_name": row[2], + "is_instantaneous": bool(row[3]), + "start_datetime": row[4], + "end_datetime": row[5], + "performed_by_person_id": row[6], + "recorded_by_person_id": row[7], + "notes": row[8], + "channel_id": row[9], + "equipment_id": row[10], + "signal_interface_id": row[11], + "data_acquisition_system_id": row[12], + "sampling_point_id": row[13], + "process_unit_id": row[14], + "site_id": row[15], + "campaign_id": row[16], + } + + +# --------------------------------------------------------------------------- +# EventKind queries +# --------------------------------------------------------------------------- + + +def get_event_kinds(conn: pyodbc.Connection) -> list[dict]: + cursor = conn.cursor() + cursor.execute( + """ + SELECT [EventKind_ID], [Name], [Description] + FROM [dbo].[EventKind] + ORDER BY [EventKind_ID] + """ + ) + return [ + {"event_kind_id": row[0], "name": row[1], "description": row[2]} + for row in cursor.fetchall() + ] + + +def insert_event_kind( + conn: pyodbc.Connection, + name: str, + description: str | None, +) -> dict: + """Insert a new EventKind row and return it.""" + cursor = conn.cursor() + try: + cursor.execute( + "INSERT INTO [dbo].[EventKind] ([Name], [Description])" + " OUTPUT inserted.[EventKind_ID], inserted.[Name], inserted.[Description]" + " VALUES (?, ?)", + name, + description, + ) + row = cursor.fetchone() + conn.commit() + return {"event_kind_id": row[0], "name": row[1], "description": row[2]} + except Exception: + conn.rollback() + raise + + +def update_event_kind( + conn: pyodbc.Connection, + event_kind_id: int, + name: str, + description: str | None, +) -> dict | None: + """Update an EventKind row and return it, or None if not found.""" + cursor = conn.cursor() + try: + cursor.execute( + "UPDATE [dbo].[EventKind]" + " SET [Name]=?, [Description]=?" + " OUTPUT inserted.[EventKind_ID], inserted.[Name], inserted.[Description]" + " WHERE [EventKind_ID]=?", + name, + description, + event_kind_id, + ) + row = cursor.fetchone() + conn.commit() + if row is None: + return None + return {"event_kind_id": row[0], "name": row[1], "description": row[2]} + except Exception: + conn.rollback() + raise + + +def delete_event_kind(conn: pyodbc.Connection, event_kind_id: int) -> bool: + """Delete an EventKind row. Returns True if a row was deleted.""" + cursor = conn.cursor() + try: + cursor.execute( + "DELETE FROM [dbo].[EventKind] WHERE [EventKind_ID]=?", + event_kind_id, + ) + conn.commit() + return cursor.rowcount > 0 + except Exception: + conn.rollback() + raise + + +# --------------------------------------------------------------------------- +# Event queries +# --------------------------------------------------------------------------- + +# The 8 arc FK column names in DB notation (used for dynamic WHERE clauses). +_ARC_DB_COLUMNS = { + "channel_id": "e.[Channel_ID]", + "equipment_id": "e.[Equipment_ID]", + "signal_interface_id": "e.[SignalInterface_ID]", + "data_acquisition_system_id": "e.[DataAcquisitionSystem_ID]", + "sampling_point_id": "e.[SamplingPoint_ID]", + "process_unit_id": "e.[ProcessUnit_ID]", + "site_id": "e.[Site_ID]", + "campaign_id": "e.[Campaign_ID]", +} + + +def get_events( + conn: pyodbc.Connection, + *, + channel_id: int | None = None, + equipment_id: int | None = None, + signal_interface_id: int | None = None, + data_acquisition_system_id: int | None = None, + sampling_point_id: int | None = None, + process_unit_id: int | None = None, + site_id: int | None = None, + campaign_id: int | None = None, +) -> list[dict]: + """Return events, optionally filtered by any of the 8 arc FK columns.""" + filters = { + "channel_id": channel_id, + "equipment_id": equipment_id, + "signal_interface_id": signal_interface_id, + "data_acquisition_system_id": data_acquisition_system_id, + "sampling_point_id": sampling_point_id, + "process_unit_id": process_unit_id, + "site_id": site_id, + "campaign_id": campaign_id, + } + where_parts: list[str] = [] + params: list = [] + for key, value in filters.items(): + if value is not None: + where_parts.append(f"{_ARC_DB_COLUMNS[key]} = ?") + params.append(value) + + where_clause = (" WHERE " + " AND ".join(where_parts)) if where_parts else "" + sql = _EVENT_SELECT + where_clause + " ORDER BY e.[EventDateTimeStart] DESC, e.[Event_ID]" + cursor = conn.cursor() + cursor.execute(sql, *params) + return [_row_to_event(row) for row in cursor.fetchall()] + + +def get_event_by_id(conn: pyodbc.Connection, event_id: int) -> dict | None: + cursor = conn.cursor() + cursor.execute(_EVENT_SELECT + " WHERE e.[Event_ID] = ?", event_id) + row = cursor.fetchone() + return _row_to_event(row) if row else None + + +def insert_event(conn: pyodbc.Connection, data: dict) -> dict: + """INSERT INTO [dbo].[Event] from a dict of field values, return the new row.""" + cursor = conn.cursor() + try: + cursor.execute( + """ + INSERT INTO [dbo].[Event] ( + [EventKind_ID], + [IsInstantaneous], + [EventDateTimeStart], + [EventDateTimeEnd], + [PerformedByPerson_ID], + [RecordedByPerson_ID], + [Notes], + [Channel_ID], + [Equipment_ID], + [SignalInterface_ID], + [DataAcquisitionSystem_ID], + [SamplingPoint_ID], + [ProcessUnit_ID], + [Site_ID], + [Campaign_ID] + ) + OUTPUT INSERTED.[Event_ID] + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + data.get("event_kind_id"), + data.get("is_instantaneous", False), + data.get("start_datetime"), + data.get("end_datetime"), + data.get("performed_by_person_id"), + data.get("recorded_by_person_id"), + data.get("notes"), + data.get("channel_id"), + data.get("equipment_id"), + data.get("signal_interface_id"), + data.get("data_acquisition_system_id"), + data.get("sampling_point_id"), + data.get("process_unit_id"), + data.get("site_id"), + data.get("campaign_id"), + ) + row = cursor.fetchone() + conn.commit() + return get_event_by_id(conn, row[0]) # type: ignore[return-value] + except Exception: + conn.rollback() + raise + + +def update_event(conn: pyodbc.Connection, event_id: int, data: dict) -> dict | None: + """Partial update of an Event row from a dict. Only non-None values are applied.""" + # Map Python keys → DB column names + column_map = { + "event_kind_id": "[EventKind_ID]", + "is_instantaneous": "[IsInstantaneous]", + "start_datetime": "[EventDateTimeStart]", + "end_datetime": "[EventDateTimeEnd]", + "performed_by_person_id": "[PerformedByPerson_ID]", + "recorded_by_person_id": "[RecordedByPerson_ID]", + "notes": "[Notes]", + "channel_id": "[Channel_ID]", + "equipment_id": "[Equipment_ID]", + "signal_interface_id": "[SignalInterface_ID]", + "data_acquisition_system_id": "[DataAcquisitionSystem_ID]", + "sampling_point_id": "[SamplingPoint_ID]", + "process_unit_id": "[ProcessUnit_ID]", + "site_id": "[Site_ID]", + "campaign_id": "[Campaign_ID]", + } + set_parts: list[str] = [] + params: list = [] + for key, col in column_map.items(): + if key in data and data[key] is not None: + set_parts.append(f"{col} = ?") + params.append(data[key]) + + if not set_parts: + return get_event_by_id(conn, event_id) + + params.append(event_id) + cursor = conn.cursor() + try: + cursor.execute( + f"UPDATE [dbo].[Event] SET {', '.join(set_parts)} WHERE [Event_ID] = ?", + *params, + ) + conn.commit() + except Exception: + conn.rollback() + raise + return get_event_by_id(conn, event_id) + + +def delete_event(conn: pyodbc.Connection, event_id: int) -> bool: + """Hard-delete an Event row. Returns True if a row was deleted.""" + cursor = conn.cursor() + try: + cursor.execute("DELETE FROM [dbo].[Event] WHERE [Event_ID] = ?", event_id) + conn.commit() + return cursor.rowcount > 0 + except Exception: + conn.rollback() + raise diff --git a/api/v1/repositories/maintenance_drift_repository.py b/api/v1/repositories/maintenance_drift_repository.py new file mode 100644 index 0000000..f4b9379 --- /dev/null +++ b/api/v1/repositories/maintenance_drift_repository.py @@ -0,0 +1,250 @@ +"""Data access for maintenance-drift derived Channel creation (PRD-2.5 S1). + +Creates a ProcessingStep (method='maintenance_drift') with MethodParameters JSON, +then mints a derived Channel linked to that step. Does NOT read or write dbo.Value. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta + +import pyodbc + +from api.v1.errors import EntityNotFoundError +from . import value_repository + +# OperationKind seed ID 3 = "DriftCorrection" — closest semantic match for a +# maintenance-drift computation step (measures / quantifies sensor drift). +_OPERATION_KIND_DRIFT_CORRECTION = 3 + +# DataProvenanceKind seed ID 7 = "Derived". +_DERIVED_PROVENANCE_KIND_ID = 7 + +# StreamKind seed ID 1 = "Sensor" — Channel is the Sensor subtype of Stream. +_STREAM_KIND_SENSOR = 1 + + +def _insert_stream(cursor: pyodbc.Cursor) -> int: + """Mint a new Stream row and return its Stream_ID.""" + cursor.execute( + """ + INSERT INTO [dbo].[Stream] ([StreamKind_ID]) + OUTPUT INSERTED.[Stream_ID] + VALUES (?) + """, + _STREAM_KIND_SENSOR, + ) + return int(cursor.fetchone()[0]) + + +def create_drift_channel( + conn: pyodbc.Connection, + *, + source_channel_id: int, + event_ids: list[int], + name: str, + performed_by_person_id: int | None = None, +) -> dict: + """Create a maintenance-drift derived Channel linked to a new ProcessingStep. + + Steps: + 1. Validate the source Channel exists and fetch its Parameter_ID / ValueKind_ID / Unit_ID. + 2. INSERT a ProcessingStep with MethodName='maintenance_drift' and MethodParameters + JSON encoding the source channel and event window IDs. + 3. INSERT a ProcessingLineage edge from the source Channel to the new step. + 4. INSERT a derived Channel row (SignalInterface_ID=NULL, DataProvenanceKind=Derived) + with ProducedByStep_ID pointing to the new step and ParentChannel_ID = source. + 5. Return the new Channel's identifying fields. + + No dbo.Value rows are read or written. + """ + cursor = conn.cursor() + + # 1. Fetch source channel + cursor.execute( + """ + SELECT [TagName], [Parameter_ID], [ValueKind_ID], [Unit_ID] + FROM [dbo].[Channel] + WHERE [Stream_ID] = ? + """, + source_channel_id, + ) + row = cursor.fetchone() + if row is None: + raise EntityNotFoundError(f"Source channel {source_channel_id} not found.") + _tag_name, parameter_id, value_kind_id, unit_id = row + + method_parameters = json.dumps( + { + "source_channel_id": source_channel_id, + "event_ids": sorted(event_ids), + } + ) + + # 2. INSERT ProcessingStep + cursor.execute( + """ + INSERT INTO [dbo].[ProcessingStep] + ([MethodName], [MethodVersion], [OperationKind_ID], + [MethodParameters], [ExecutedAt], [ExecutedByPerson_ID]) + OUTPUT INSERTED.[ProcessingStep_ID] + VALUES ('maintenance_drift', NULL, ?, ?, GETUTCDATE(), ?) + """, + _OPERATION_KIND_DRIFT_CORRECTION, + method_parameters, + performed_by_person_id, + ) + step_id = int(cursor.fetchone()[0]) + + # 3. INSERT ProcessingLineage edge (source channel → step) + cursor.execute( + """ + INSERT INTO [dbo].[ProcessingLineage] ([ProcessingStep_ID], [Stream_ID]) + VALUES (?, ?) + """, + step_id, + source_channel_id, + ) + + # 4. Mint a new Stream row, then the derived Channel row + stream_id = _insert_stream(cursor) + cursor.execute( + """ + INSERT INTO [dbo].[Channel] + ([Stream_ID], [SignalInterface_ID], [TagName], [Parameter_ID], + [DataProvenanceKind_ID], [ProducedByStep_ID], [ValueKind_ID], + [Unit_ID], [ParentChannel_ID]) + VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?) + """, + stream_id, + name, + parameter_id, + _DERIVED_PROVENANCE_KIND_ID, + step_id, + value_kind_id or 1, + unit_id, + source_channel_id, + ) + + conn.commit() + + return { + "channel_id": stream_id, + "name": name, + "produced_by_step_id": step_id, + } + + +# --------------------------------------------------------------------------- +# Read-back (PRD-4 S4): drift since last cleaning for a maintenance Event +# --------------------------------------------------------------------------- + +# How far to look on each side of the event window for a source reading. The +# "before" reading is the last sample before the maintenance; the "after" the +# first after it. ponytail: fixed 7-day margin; widen if plants clean rarely. +_READBACK_MARGIN = timedelta(days=7) + + +def derive_before_after( + rows: list[dict], window_start: datetime, window_end: datetime | None +) -> dict: + """Split source-stream *rows* around a maintenance window into before/after. + + ``before`` = the last reading strictly before ``window_start``; ``after`` = + the first reading strictly after ``window_end`` (or ``window_start`` when + the event is instantaneous). ``percent_diff`` = 100·(after−before)/before, + or None when either side or a non-zero baseline is missing. + """ + before = None + after = None + for r in rows: + ts = r["timestamp"] + val = r["value"] + if ts < window_start: + if before is None or ts > before["timestamp"]: + before = {"timestamp": ts, "value": val} + elif window_end is not None and ts <= window_end: + continue # inside the spanning maintenance window → neither side + else: + # At/after an instantaneous event, or strictly after a span's end. + if after is None or ts < after["timestamp"]: + after = {"timestamp": ts, "value": val} + + percent_diff = None + if ( + before is not None and after is not None + and before["value"] not in (None, 0) + and after["value"] is not None + ): + percent_diff = (after["value"] - before["value"]) / before["value"] * 100.0 + return {"before": before, "after": after, "percent_diff": percent_diff} + + +def _find_drift_for_event(conn: pyodbc.Connection, event_id: int) -> dict | None: + """Find the drift Channel + source channel linked to *event_id*, or None. + + The link lives in the maintenance_drift ProcessingStep's MethodParameters + JSON (``event_ids`` + ``source_channel_id``); the derived Channel points + back to the step via ``ProducedByStep_ID``. + """ + cursor = conn.cursor() + cursor.execute( + """ + SELECT ps.[ProcessingStep_ID], ps.[MethodParameters], c.[Stream_ID] + FROM [dbo].[ProcessingStep] ps + JOIN [dbo].[Channel] c ON c.[ProducedByStep_ID] = ps.[ProcessingStep_ID] + WHERE ps.[MethodName] = 'maintenance_drift' + ORDER BY ps.[ProcessingStep_ID] DESC + """ + ) + for step_id, method_params, drift_channel_id in cursor.fetchall(): + try: + params = json.loads(method_params) if method_params else {} + except (ValueError, TypeError): + continue + if event_id in (params.get("event_ids") or []): + return { + "step_id": int(step_id), + "drift_channel_id": int(drift_channel_id), + "source_channel_id": params.get("source_channel_id"), + } + return None + + +def get_drift_readback(conn: pyodbc.Connection, event_id: int) -> dict: + """Assemble the drift read-back for a maintenance Event. + + Raises :class:`EntityNotFoundError` if the event does not exist or has no + maintenance-drift Channel linked to it. + """ + cursor = conn.cursor() + cursor.execute( + "SELECT [EventDateTimeStart], [EventDateTimeEnd] FROM [dbo].[Event] WHERE [Event_ID]=?", + event_id, + ) + row = cursor.fetchone() + if row is None: + raise EntityNotFoundError(f"Event {event_id} not found.") + window_start, window_end = row[0], row[1] + + link = _find_drift_for_event(conn, event_id) + if link is None: + raise EntityNotFoundError(f"No maintenance-drift Channel linked to Event {event_id}.") + + # Read the source stream in a bounded window around the event and derive. + from_dt = window_start - _READBACK_MARGIN + to_dt = (window_end or window_start) + _READBACK_MARGIN + source_rows = value_repository.get_scalar_values( + conn, link["source_channel_id"], from_dt, to_dt + ) + derived = derive_before_after(source_rows, window_start, window_end) + + return { + "event_id": event_id, + "drift_channel_id": link["drift_channel_id"], + "source_channel_id": link["source_channel_id"], + "window_start": window_start, + "window_end": window_end, + **derived, + } diff --git a/api/v1/repositories/sensor_status_repository.py b/api/v1/repositories/sensor_status_repository.py index 0dfe2c0..665c70f 100644 --- a/api/v1/repositories/sensor_status_repository.py +++ b/api/v1/repositories/sensor_status_repository.py @@ -165,7 +165,7 @@ def get_device_status_transitions( sc.IsOperational AS is_operational, sc.Severity AS severity FROM [dbo].[EquipmentWiringHistory] ewh - JOIN [dbo].[Channel] valueC ON valueC.[SignalInterface_ID] = ewh.[SignalInterface_ID] + JOIN [dbo].[vw_ChannelResolved] valueC ON valueC.[SignalInterface_ID] = ewh.[SignalInterface_ID] AND ( valueC.[SignalInterfacePort_ID] = ewh.[SignalInterfacePort_ID] OR (valueC.[SignalInterfacePort_ID] IS NULL AND ewh.[SignalInterfacePort_ID] IS NULL) @@ -288,7 +288,7 @@ def get_all_channel_statuses_for_equipment(self, equipment_id: int) -> list[dict sc.Severity AS severity, latestStatus.[Timestamp] AS status_since FROM [dbo].[EquipmentWiringHistory] ewh - JOIN [dbo].[Channel] valueC ON valueC.[SignalInterface_ID] = ewh.[SignalInterface_ID] + JOIN [dbo].[vw_ChannelResolved] valueC ON valueC.[SignalInterface_ID] = ewh.[SignalInterface_ID] AND ( valueC.[SignalInterfacePort_ID] = ewh.[SignalInterfacePort_ID] OR (valueC.[SignalInterfacePort_ID] IS NULL AND ewh.[SignalInterfacePort_ID] IS NULL) @@ -340,7 +340,7 @@ def get_current_device_status(self, equipment_id: int) -> Optional[dict]: sc.Severity AS severity, o.[Timestamp] AS status_since FROM [dbo].[EquipmentWiringHistory] ewh - JOIN [dbo].[Channel] valueC ON valueC.[SignalInterface_ID] = ewh.[SignalInterface_ID] + JOIN [dbo].[vw_ChannelResolved] valueC ON valueC.[SignalInterface_ID] = ewh.[SignalInterface_ID] AND ( valueC.[SignalInterfacePort_ID] = ewh.[SignalInterfacePort_ID] OR (valueC.[SignalInterfacePort_ID] IS NULL AND ewh.[SignalInterfacePort_ID] IS NULL) @@ -403,7 +403,7 @@ def get_equipment_for_channel(self, channel_id: int) -> Optional[int]: cursor.execute( """ SELECT ewh.[Equipment_ID] - FROM [dbo].[Channel] c + FROM [dbo].[vw_ChannelResolved] c JOIN [dbo].[EquipmentWiringHistory] ewh ON ewh.[SignalInterface_ID] = c.[SignalInterface_ID] AND ( diff --git a/api/v1/repositories/signal_interface_repository.py b/api/v1/repositories/signal_interface_repository.py index bd97ce0..2feb484 100644 --- a/api/v1/repositories/signal_interface_repository.py +++ b/api/v1/repositories/signal_interface_repository.py @@ -897,50 +897,6 @@ def find_signal_port_type_by_name(conn: pyodbc.Connection, name: str) -> int | N return find_channel_kind_by_name(conn, name) -def find_or_create_signal_port( - conn: pyodbc.Connection, - das_id: int, - tag: str, - signal_port_type_id: int, -) -> tuple[int, bool]: - """Deprecated stub.""" - raise NotImplementedError( - "find_or_create_signal_port is deprecated. Use find_or_create_signal_interface_port." - ) - - -def find_signal_port_by_tag( - conn: pyodbc.Connection, das_id: int, tag: str -) -> int | None: - """Deprecated stub.""" - raise NotImplementedError( - "find_signal_port_by_tag is deprecated. SignalPort table has been removed." - ) - - -def set_parent_port(conn: pyodbc.Connection, port_id: int, parent_port_id: int) -> None: - """Deprecated stub.""" - raise NotImplementedError( - "set_parent_port is deprecated. Use ParentChannel_ID on Channel instead." - ) - - -def open_port_equipment_history( - conn: pyodbc.Connection, port_id: int, equipment_id: int -) -> int: - """Deprecated stub.""" - raise NotImplementedError( - "open_port_equipment_history is deprecated. Use open_equipment_wiring_history." - ) - - -def deactivate_signal_port(conn: pyodbc.Connection, signal_port_id: int) -> bool: - """Deprecated stub.""" - raise NotImplementedError( - "deactivate_signal_port is deprecated. SignalPort table has been removed." - ) - - def generate_tagless_tag(equipment_identifier: str, parameter_name: str) -> str: """Deprecated alias for generate_tagless_tagname.""" return generate_tagless_tagname(equipment_identifier, parameter_name) diff --git a/api/v1/repositories/temporal_history_repository.py b/api/v1/repositories/temporal_history_repository.py index 93fc0c0..7cdc09d 100644 --- a/api/v1/repositories/temporal_history_repository.py +++ b/api/v1/repositories/temporal_history_repository.py @@ -19,6 +19,49 @@ import pyodbc +def _assert_valid_from_ok( + cursor: pyodbc.Cursor, + table: str, + entity_col: str, + entity_id: int, + valid_from: datetime, +) -> None: + """Reject a backdated ``valid_from`` that would overlap existing history (F7). + + The swap helpers open the new active row at ``valid_from`` (ValidTo NULL) and + close the prior active row at ``valid_from``. That is only coherent when + ``valid_from`` falls strictly after the active row's start and outside every + closed ``[ValidFrom, ValidTo)`` interval. A unique filtered index already + guarantees one *open* row, but nothing stops a backdated insert from inverting + the row it closes or landing inside an old interval — this is that guard. + + ``table``/``entity_col`` are fixed internal identifiers (never user input). + """ + cursor.execute( + f""" + SELECT TOP 1 [ValidFrom], [ValidTo] + FROM [dbo].[{table}] + WHERE [{entity_col}] = ? + AND ( + ([ValidTo] IS NULL AND [ValidFrom] >= ?) + OR ([ValidTo] IS NOT NULL AND [ValidFrom] <= ? AND ? < [ValidTo]) + ) + """, + entity_id, + valid_from, + valid_from, + valid_from, + ) + row = cursor.fetchone() + if row is not None: + raise ValueError( + f"valid_from {valid_from} conflicts with an existing {table} interval " + f"[{row[0]}, {row[1]}) for {entity_col}={entity_id}: the new active row " + "would overlap or invert history. Use a timestamp strictly after the " + "current active row's start and outside any past interval." + ) + + # --------------------------------------------------------------------------- # EquipmentWiringHistory # --------------------------------------------------------------------------- @@ -79,9 +122,27 @@ def rewire_equipment( The swap is atomic within a single transaction. """ + # F8: reject a rewire to the interface+port already active — no churn row. + active = get_active_wiring_for_equipment(conn, equipment_id) + if ( + active is not None + and active["signal_interface_id"] == new_signal_interface_id + and active["signal_interface_port_id"] == new_signal_interface_port_id + ): + raise ValueError( + f"Equipment {equipment_id} is already wired to SignalInterface " + f"{new_signal_interface_id} (port {new_signal_interface_port_id}); " + "nothing to rewire." + ) + cursor = conn.cursor() closed_id: int | None = None + # F7: a backdated swap_time must not overlap or invert existing history. + _assert_valid_from_ok( + cursor, "EquipmentWiringHistory", "Equipment_ID", equipment_id, swap_time + ) + # Close the active row, if any. cursor.execute( """ @@ -143,6 +204,7 @@ def register_equipment_at_interface( cursor = conn.cursor() if start_time is None: + # No start_time → "now", which is after all existing rows; no overlap risk. cursor.execute( """ INSERT INTO [dbo].[EquipmentWiringHistory] @@ -156,6 +218,10 @@ def register_equipment_at_interface( note, ) else: + # F7: a backdated first row must not land inside any closed interval. + _assert_valid_from_ok( + cursor, "EquipmentWiringHistory", "Equipment_ID", equipment_id, start_time + ) cursor.execute( """ INSERT INTO [dbo].[EquipmentWiringHistory] @@ -272,9 +338,22 @@ def relocate_equipment( ``start_time`` is required and must equal the physical move time. ``campaign_id`` is required by the schema (Campaign_ID NOT NULL). """ + # F8: reject a relocate to the SamplingPoint already active — no churn row. + active = get_active_location_for_equipment(conn, equipment_id) + if active is not None and active["sampling_point_id"] == new_sampling_point_id: + raise ValueError( + f"Equipment {equipment_id} is already located at SamplingPoint " + f"{new_sampling_point_id}; nothing to relocate." + ) + cursor = conn.cursor() closed_id: int | None = None + # F7: a backdated start_time must not overlap or invert existing history. + _assert_valid_from_ok( + cursor, "EquipmentLocationHistory", "Equipment_ID", equipment_id, start_time + ) + # Close the active row, if any. cursor.execute( """ @@ -332,6 +411,11 @@ def open_location_for_campaign( cursor = conn.cursor() closed_id: int | None = None + # F7: a backdated start_time must not overlap or invert existing history. + _assert_valid_from_ok( + cursor, "EquipmentLocationHistory", "Equipment_ID", equipment_id, start_time + ) + cursor.execute( """ UPDATE [dbo].[EquipmentLocationHistory] @@ -462,9 +546,22 @@ def deploy_das( The swap is atomic within a single transaction. """ + # F8: reject a redeploy to the Site already active — no churn row. + active = get_active_das_deployment(conn, das_id) + if active is not None and active["site_id"] == site_id: + raise ValueError( + f"DAS {das_id} is already deployed at Site {site_id}; " + "nothing to redeploy." + ) + cursor = conn.cursor() closed_id: int | None = None + # F7: a backdated valid_from must not overlap or invert existing history. + _assert_valid_from_ok( + cursor, "DASLocationHistory", "DataAcquisitionSystem_ID", das_id, valid_from + ) + # Close the active row, if any. cursor.execute( """ @@ -584,7 +681,7 @@ def list_deployment_traces( NULL AS ValidFrom, NULL AS ValidTo, 0 AS is_deployed - FROM [dbo].[Channel] ch + FROM [dbo].[vw_ChannelResolved] ch JOIN [dbo].[SignalInterface] si ON si.[SignalInterface_ID] = ch.[SignalInterface_ID] JOIN [dbo].[Parameter] p ON p.[Parameter_ID] = ch.[Parameter_ID] LEFT JOIN [dbo].[EquipmentWiringHistory] ewh @@ -638,7 +735,7 @@ def list_deployment_traces( ON ewh.[Equipment_ID] = elh.[Equipment_ID] AND ewh.[ValidFrom] <= ISNULL(elh.[ValidTo], GETUTCDATE()) AND (ewh.[ValidTo] IS NULL OR ewh.[ValidTo] >= elh.[ValidFrom]) - JOIN [dbo].[Channel] ch + JOIN [dbo].[vw_ChannelResolved] ch ON ch.[SignalInterface_ID] = ewh.[SignalInterface_ID] AND ( ewh.[SignalInterfacePort_ID] = ch.[SignalInterfacePort_ID] @@ -688,3 +785,96 @@ def get_das_conflict( if active["site_id"] == site_id: return None return active + + +def get_das_move_equipment_conflicts( + conn: pyodbc.Connection, das_id: int, new_site_id: int +) -> list[dict]: + """Equipment that would be left stranded if this DAS moves to ``new_site_id`` + (consistency audit F1). + + Returns the equipment currently wired (active EquipmentWiringHistory) to one + of this DAS's SignalInterfaces whose active location is at a SamplingPoint in + a *different* Site than ``new_site_id``. The DAS-move flow surfaces this list + so the user can relocate those equipment in the same step rather than leaving + a silent location/DAS mismatch (which ``vw_DeploymentCoherence`` would then + report). Empty list = the move is coherent. + """ + cursor = conn.cursor() + cursor.execute( + """ + SELECT DISTINCT + e.[Equipment_ID], + e.[Identifier] AS equipment_identifier, + sp.[SamplingPoint_ID], + sp.[SamplingPoint] AS sampling_point_name, + sp.[Site_ID] AS current_site_id, + s.[Name] AS current_site_name + FROM [dbo].[EquipmentWiringHistory] ewh + JOIN [dbo].[SignalInterface] si + ON si.[SignalInterface_ID] = ewh.[SignalInterface_ID] + AND si.[DataAcquisitionSystem_ID] = ? + JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = ewh.[Equipment_ID] + JOIN [dbo].[EquipmentLocationHistory] elh + ON elh.[Equipment_ID] = e.[Equipment_ID] AND elh.[ValidTo] IS NULL + JOIN [dbo].[SamplingPoint] sp ON sp.[SamplingPoint_ID] = elh.[SamplingPoint_ID] + LEFT JOIN [dbo].[Site] s ON s.[Site_ID] = sp.[Site_ID] + WHERE ewh.[ValidTo] IS NULL + AND sp.[Site_ID] <> ? + """, + das_id, + new_site_id, + ) + return [ + { + "equipment_id": row[0], + "equipment_identifier": row[1], + "sampling_point_id": row[2], + "sampling_point_name": row[3], + "current_site_id": row[4], + "current_site_name": row[5], + } + for row in cursor.fetchall() + ] + + +def get_active_campaign_deployment( + conn: pyodbc.Connection, equipment_id: int +) -> dict | None: + """The still-active campaign whose deployment placed this equipment, if any + (consistency audit F13). + + Physical configuration is shared across campaigns, so reconfiguring equipment + (relocate/rewire) that was placed by a campaign whose run has not ended will + close that campaign's deployment. This returns that campaign so the caller can + warn before acting. ``None`` when the equipment's active location row has no + campaign provenance, or that campaign has already ended. + """ + cursor = conn.cursor() + cursor.execute( + """ + SELECT + c.[Campaign_ID], + c.[Name] AS campaign_name, + elh.[EquipmentLocationHistory_ID], + elh.[SamplingPoint_ID], + sp.[SamplingPoint] AS sampling_point_name + FROM [dbo].[EquipmentLocationHistory] elh + JOIN [dbo].[Campaign] c ON c.[Campaign_ID] = elh.[Campaign_ID] + LEFT JOIN [dbo].[SamplingPoint] sp ON sp.[SamplingPoint_ID] = elh.[SamplingPoint_ID] + WHERE elh.[Equipment_ID] = ? + AND elh.[ValidTo] IS NULL + AND (c.[CampaignEndDateTime] IS NULL OR c.[CampaignEndDateTime] > SYSUTCDATETIME()) + """, + equipment_id, + ) + row = cursor.fetchone() + if row is None: + return None + return { + "campaign_id": row[0], + "campaign_name": row[1], + "equipment_location_history_id": row[2], + "sampling_point_id": row[3], + "sampling_point_name": row[4], + } diff --git a/api/v1/router.py b/api/v1/router.py index 1eb759b..0460dc4 100644 --- a/api/v1/router.py +++ b/api/v1/router.py @@ -7,6 +7,7 @@ from .endpoints.auth import get_current_user, router as auth_router from .endpoints.audit import router as audit_router from .endpoints.health import router as health_router +from .endpoints.data_health import router as data_health_router from .endpoints.sites import router as sites_router from .endpoints.channels import router as channels_router from .endpoints.timeseries import router as timeseries_router @@ -46,6 +47,8 @@ from .endpoints.convert import router as convert_router from .endpoints.deployment_traces import router as deployment_traces_router from .endpoints.admin_browse import router as admin_browse_router +from .endpoints.events import events_router, event_kinds_router +from .endpoints.maintenance_drift import router as maintenance_drift_router router = APIRouter() @@ -129,6 +132,12 @@ protected.include_router( admin_browse_router, prefix="/admin/tables", tags=["admin-browse"] ) +protected.include_router( + data_health_router, prefix="/data-health", tags=["data-health"] +) +protected.include_router(events_router, prefix="/events", tags=["events"]) +protected.include_router(event_kinds_router, prefix="/event-kinds", tags=["event-kinds"]) +protected.include_router(maintenance_drift_router, prefix="/channels", tags=["channels"]) # Mount the public and protected groups onto the v1 router. router.include_router(public_router) diff --git a/api/v1/schemas/annotations.py b/api/v1/schemas/annotations.py index e3cbd42..b09622d 100644 --- a/api/v1/schemas/annotations.py +++ b/api/v1/schemas/annotations.py @@ -39,7 +39,7 @@ class AnnotationResponse(BaseModel): author: Optional[AnnotationAuthor] = None campaign_id: Optional[int] = None campaign_name: Optional[str] = None - equipment_event_id: Optional[int] = None + event_id: Optional[int] = None created_at: datetime modified_at: Optional[datetime] = None # Cross-stream feed enrichment (/recent, /by-type): derived location + @@ -68,7 +68,7 @@ class AnnotationCreate(BaseModel): title: Optional[str] = Field(None, max_length=200) comment: Optional[str] = None campaign_id: Optional[int] = None - equipment_event_id: Optional[int] = None + event_id: Optional[int] = None author_person_id: Optional[int] = None # TODO: replace with auth context observation_id: Optional[int] = None # optional point pin (one exact Observation/Replicate) diff --git a/api/v1/schemas/campaigns.py b/api/v1/schemas/campaigns.py index e23c179..f185f27 100644 --- a/api/v1/schemas/campaigns.py +++ b/api/v1/schemas/campaigns.py @@ -11,8 +11,9 @@ class CampaignOut(BaseModel): campaign_id: int campaign_kind_id: int campaign_kind_name: str | None - site_id: int - site_name: str | None + # Campaigns are multi-site; sites are derived from sampling-location membership. + site_ids: list[int] = [] + site_names: list[str] = [] name: str description: str | None start_date: datetime | None @@ -24,7 +25,6 @@ class CampaignOut(BaseModel): class CampaignIn(BaseModel): name: str campaign_kind_id: int - site_id: int description: str | None = None start_date: str | None = None # ISO datetime string e.g. "2024-06-01T00:00:00" end_date: str | None = None @@ -36,7 +36,6 @@ class CampaignPatch(BaseModel): name: str | None = None campaign_kind_id: int | None = None - site_id: int | None = None description: str | None = None start_date: str | None = None end_date: str | None = None diff --git a/api/v1/schemas/das_move.py b/api/v1/schemas/das_move.py index e7ee4a6..f0dc828 100644 --- a/api/v1/schemas/das_move.py +++ b/api/v1/schemas/das_move.py @@ -45,3 +45,23 @@ class DASConflictResponse(BaseModel): conflicting_site_name: str | None conflicting_campaign_id: int | None conflicting_campaign_name: str | None + + +class StrandedEquipment(BaseModel): + """Equipment a pending DAS move would strand (consistency audit F1).""" + + equipment_id: int + equipment_identifier: str | None + sampling_point_id: int | None + sampling_point_name: str | None + current_site_id: int | None + current_site_name: str | None + + +class DASMoveConflictsResponse(BaseModel): + """Equipment wired to this DAS whose active location is at a Site other than + ``site_id`` — i.e. would be silently stranded if the DAS moves there.""" + + das_id: int + site_id: int + stranded_equipment: list[StrandedEquipment] diff --git a/api/v1/schemas/data_health.py b/api/v1/schemas/data_health.py new file mode 100644 index 0000000..6d55d8f --- /dev/null +++ b/api/v1/schemas/data_health.py @@ -0,0 +1,39 @@ +"""Schemas for the data-health / broken-link views (consistency audit F5, F11).""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel + + +class UnlinkedChannel(BaseModel): + """A raw channel with observations but no active wiring (F5).""" + + channel_id: int + tag_name: str | None + signal_interface_id: int | None + signal_interface_name: str | None + observation_count: int + first_observation: datetime | None + last_observation: datetime | None + + +class UnlinkedChannelsResponse(BaseModel): + count: int + channels: list[UnlinkedChannel] + + +class InactiveParentReference(BaseModel): + """An active wiring row pointing at a soft-deleted interface/port (F11).""" + + reference_type: str + wiring_history_id: int + equipment_id: int + parent_id: int + parent_label: str | None + + +class InactiveParentReferencesResponse(BaseModel): + count: int + references: list[InactiveParentReference] diff --git a/api/v1/schemas/equipment_move.py b/api/v1/schemas/equipment_move.py index 421c379..9b981f3 100644 --- a/api/v1/schemas/equipment_move.py +++ b/api/v1/schemas/equipment_move.py @@ -93,3 +93,16 @@ class LocationAtTimeResponse(BaseModel): sampling_point_name: str | None valid_from: datetime | None valid_to: datetime | None + + +class ActiveCampaignDeploymentResponse(BaseModel): + """The still-running campaign whose deployment placed this equipment, if any + (consistency audit F13). ``campaign_id`` is None when no open campaign row + exists — reconfiguring then closes no running campaign's deployment.""" + + equipment_id: int + campaign_id: int | None + campaign_name: str | None + equipment_location_history_id: int | None + sampling_point_id: int | None + sampling_point_name: str | None diff --git a/api/v1/schemas/events.py b/api/v1/schemas/events.py new file mode 100644 index 0000000..8fd2386 --- /dev/null +++ b/api/v1/schemas/events.py @@ -0,0 +1,113 @@ +"""Pydantic models for Event and EventKind request/response shapes.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, model_validator + + +# --------------------------------------------------------------------------- +# EventKind schemas +# --------------------------------------------------------------------------- + + +class EventKindOut(BaseModel): + event_kind_id: int + name: str + description: Optional[str] = None + + +class EventKindIn(BaseModel): + name: str + description: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Event schemas +# --------------------------------------------------------------------------- + +# The 8 exclusive-arc FK field names (Python snake_case). +_ARC_FIELDS = ( + "channel_id", + "equipment_id", + "signal_interface_id", + "data_acquisition_system_id", + "sampling_point_id", + "process_unit_id", + "site_id", + "campaign_id", +) + + +class EventOut(BaseModel): + event_id: int + event_kind_id: int + event_kind_name: Optional[str] = None + is_instantaneous: bool + start_datetime: datetime + end_datetime: Optional[datetime] = None + performed_by_person_id: Optional[int] = None + recorded_by_person_id: Optional[int] = None + notes: Optional[str] = None + # Exclusive-arc target FKs (exactly one non-NULL in a valid row) + channel_id: Optional[int] = None + equipment_id: Optional[int] = None + signal_interface_id: Optional[int] = None + data_acquisition_system_id: Optional[int] = None + sampling_point_id: Optional[int] = None + process_unit_id: Optional[int] = None + site_id: Optional[int] = None + campaign_id: Optional[int] = None + + +class EventIn(BaseModel): + event_kind_id: int + is_instantaneous: bool = False + start_datetime: datetime + end_datetime: Optional[datetime] = None + performed_by_person_id: Optional[int] = None + recorded_by_person_id: Optional[int] = None + notes: Optional[str] = None + # Exclusive-arc target FKs — exactly one must be provided + channel_id: Optional[int] = None + equipment_id: Optional[int] = None + signal_interface_id: Optional[int] = None + data_acquisition_system_id: Optional[int] = None + sampling_point_id: Optional[int] = None + process_unit_id: Optional[int] = None + site_id: Optional[int] = None + campaign_id: Optional[int] = None + + @model_validator(mode="after") + def exactly_one_target(self) -> "EventIn": + count = sum( + 1 for field in _ARC_FIELDS if getattr(self, field) is not None + ) + if count != 1: + raise ValueError( + f"exactly one target required — {count} arc FK(s) provided " + f"(must be exactly 1 of: {', '.join(_ARC_FIELDS)})" + ) + return self + + +class EventPatch(BaseModel): + """Partial update — all fields optional, arc-FK invariant re-checked if any FK provided.""" + + event_kind_id: Optional[int] = None + is_instantaneous: Optional[bool] = None + start_datetime: Optional[datetime] = None + end_datetime: Optional[datetime] = None + performed_by_person_id: Optional[int] = None + recorded_by_person_id: Optional[int] = None + notes: Optional[str] = None + channel_id: Optional[int] = None + equipment_id: Optional[int] = None + signal_interface_id: Optional[int] = None + data_acquisition_system_id: Optional[int] = None + sampling_point_id: Optional[int] = None + process_unit_id: Optional[int] = None + site_id: Optional[int] = None + campaign_id: Optional[int] = None diff --git a/api/v1/schemas/lineage.py b/api/v1/schemas/lineage.py index 78dcca4..1c5d3ad 100644 --- a/api/v1/schemas/lineage.py +++ b/api/v1/schemas/lineage.py @@ -112,3 +112,72 @@ class StreamStoryOut(BaseModel): record: dict location_history: list[dict] annotations: list[dict] + + +class SamplingLocationOut(BaseModel): + sampling_point_id: int + name: str | None = None + latitude: float | None = None + longitude: float | None = None + + +class ProcessUnitPedigreeOut(BaseModel): + process_unit_id: int + tag: str | None = None + name: str | None = None + kind: str | None = None + + +class SitePedigreeOut(BaseModel): + site_id: int + name: str | None = None + city: str | None = None + province: str | None = None + country: str | None = None + + +class CampaignPedigreeOut(BaseModel): + campaign_id: int + name: str | None = None + kind: str | None = None + start: datetime | None = None + end: datetime | None = None + + +class ResponsiblePersonOut(BaseModel): + person_id: int + name: str | None = None + email: str | None = None + role: str | None = None + company: str | None = None + + +class DeploymentSegmentOut(BaseModel): + """One slice of a stream's life with a stable location + campaign. Sensor + streams have one per EquipmentLocationHistory they spanned; a lab series has + a single open segment (valid_from/valid_to null).""" + + valid_from: datetime | None = None + valid_to: datetime | None = None + equipment_identifier: str | None = None + sampling_location: SamplingLocationOut | None = None + process_unit: ProcessUnitPedigreeOut | None = None + site: SitePedigreeOut | None = None + campaign: CampaignPedigreeOut | None = None + responsible_person: ResponsiblePersonOut | None = None + + +class StreamPedigreeOut(BaseModel): + """Organizational/spatial pedigree of a stream (see + channel_repository.get_stream_pedigree). Distinct from provenance: the + who/where/why, not the processing how. Identity is time-invariant; location, + campaign and responsible person are a time-bound deployment timeline. Powers + the data-export metadata YAML.""" + + stream_id: int + kind: str # "sensor" | "lab" + parameter: str | None = None + unit: str | None = None + value_kind: str | None = None + label: str | None = None + deployments: list[DeploymentSegmentOut] = [] diff --git a/api/v1/schemas/maintenance_drift.py b/api/v1/schemas/maintenance_drift.py new file mode 100644 index 0000000..88c6dc7 --- /dev/null +++ b/api/v1/schemas/maintenance_drift.py @@ -0,0 +1,62 @@ +"""Pydantic schemas for maintenance-drift derived Channel resources (PRD-2.5 S1).""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, field_validator + + +class MaintenanceDriftIn(BaseModel): + """Request body for POST /channels/derived/maintenance-drift.""" + + source_channel_id: int + event_ids: list[int] + name: str + performed_by_person_id: int | None = None + + @field_validator("source_channel_id") + @classmethod + def source_channel_positive(cls, v: int) -> int: + if v <= 0: + raise ValueError("source_channel_id must be a positive integer") + return v + + @field_validator("event_ids") + @classmethod + def event_ids_non_empty(cls, v: list[int]) -> list[int]: + if not v: + raise ValueError("event_ids must contain at least one event ID") + return v + + +class MaintenanceDriftOut(BaseModel): + """Response after creating a maintenance-drift derived Channel.""" + + channel_id: int + name: str + produced_by_step_id: int + + +class DriftReadbackPoint(BaseModel): + """A single source-stream reading (used for the before/after values).""" + + timestamp: datetime + value: float | None + + +class MaintenanceDriftReadback(BaseModel): + """Read-back (PRD-4 S4): drift since last cleaning for a maintenance Event. + + The before/after readings are derived from the *source* stream around the + event window (last sample before the start, first sample after the end). + """ + + event_id: int + drift_channel_id: int + source_channel_id: int + window_start: datetime + window_end: datetime | None = None + before: DriftReadbackPoint | None = None + after: DriftReadbackPoint | None = None + percent_diff: float | None = None diff --git a/api/v1/services/annotation_service.py b/api/v1/services/annotation_service.py index 4bb1e67..8acda7c 100644 --- a/api/v1/services/annotation_service.py +++ b/api/v1/services/annotation_service.py @@ -91,7 +91,7 @@ def _build_annotation_response(row: dict) -> dict: "author": author, "campaign_id": row.get("campaign_id"), "campaign_name": row.get("campaign_name"), - "equipment_event_id": row.get("equipment_event_id"), + "event_id": row.get("event_id"), "created_at": row.get("created_datetime") or row.get("created_at"), "modified_at": row.get("modified_at"), } @@ -163,7 +163,7 @@ def create_annotation( end_time=data.end_time, author_person_id=data.author_person_id, campaign_id=data.campaign_id, - equipment_event_id=data.equipment_event_id, + event_id=data.event_id, title=data.title, comment=data.comment, observation_id=data.observation_id, @@ -242,7 +242,7 @@ def create_annotation_for_series( end_time=data.end_time, author_person_id=data.author_person_id, campaign_id=data.campaign_id, - equipment_event_id=data.equipment_event_id, + event_id=data.event_id, title=data.title, comment=data.comment, observation_id=data.observation_id, diff --git a/app/Home.py b/app/Home.py index baf87ad..8fd8f4c 100644 --- a/app/Home.py +++ b/app/Home.py @@ -14,7 +14,18 @@ import streamlit as st -from app.api_client import APIError, get_health +from app.api_client import ( + APIError, + get_health, + list_analysis_series_lookup, + list_channels, + list_das_lookup, + list_laboratories_lookup, + list_persons_lookup, + list_sampling_points_lookup, + list_signal_interfaces_lookup, + list_sites_lookup, +) from app.auth import get_current_user, logout from app.auth import _show_auth_page as _login from app.config import settings @@ -59,8 +70,102 @@ def _home() -> None: """ ) + _onboarding_panel() + + +def _onboarding_panel() -> None: + # Session-scoped dismiss + if st.session_state.get("onboarding_dismissed", False): + return + + try: + sps = list_sampling_points_lookup() + sites = list_sites_lookup() + persons = list_persons_lookup() + ch_resp = list_channels(page_size=1) + channels = ch_resp.get("items", []) if isinstance(ch_resp, dict) else (ch_resp or []) + analysis_series = list_analysis_series_lookup() + except Exception: + return # API down — don't crash Home + + # Auto-hide once any ingestable Stream exists + if channels or analysis_series: + return + + # Fetch sensor/lab counts — failures are non-fatal + das: list = [] + signal_interfaces: list = [] + laboratories: list = [] + try: + das = list_das_lookup() + except Exception: + pass + try: + signal_interfaces = list_signal_interfaces_lookup() + except Exception: + pass + try: + laboratories = list_laboratories_lookup() + except Exception: + pass + + def _step(items: list, done_label: str, todo_label: str, url: str) -> str: + if items: + return f"- ✓ {done_label}" + return f"- ○ [{todo_label}]({url})" + + # Default data-type selection + if "onboarding_data_type" not in st.session_state: + st.session_state["onboarding_data_type"] = "Both" + + with st.container(border=True): + st.markdown("### Get started") + + st.radio( + "What will you load?", + ["Lab", "Sensor", "Both"], + horizontal=True, + key="onboarding_data_type", + ) + + data_type: str = st.session_state["onboarding_data_type"] + + foundation = ( + "Complete these foundation steps to start loading data:\n\n" + + _step(sites, "🏭 Site created", "Create a Site", "sites") + "\n" + + _step(persons, "👤 Person added", "Add a Person", "persons") + "\n" + + _step(sps, "📍 Sampling location added", "Add a Sampling Location", "sampling_locations") + ) + st.markdown(foundation) + + if data_type in ("Sensor", "Both"): + sensor_steps = ( + "\n**Sensor setup:**\n\n" + + _step(das, "📡 DAS added", "Add a Data Acquisition System (DAS)", "data_acquisition_systems") + "\n" + + _step(signal_interfaces, "🔌 Signal Interface added", "Add a Signal Interface", "signal_interfaces") + "\n" + + _step(channels, "📊 Channel added", "Add a Channel (Field System Wizard)", "field_system_wizard") + ) + st.markdown(sensor_steps) + + if data_type in ("Lab", "Both"): + lab_steps = ( + "\n**Lab setup:**\n\n" + + _step(laboratories, "🧪 Laboratory added", "Add a Laboratory", "laboratories") + "\n" + + _step(analysis_series, "🔬 Lab Experiment added", "Add a Lab Experiment", "lab_ingest") + ) + st.markdown(lab_steps) -_pages = Path(__file__).parent / "pages" + st.caption("💡 Campaign creation is optional — you can ingest data without one.") + st.markdown( + "_Once an ingestable Stream (Channel or AnalysisSeries) exists, this panel will disappear._" + ) + + if st.button("Dismiss", key="dismiss_onboarding"): + st.session_state["onboarding_dismissed"] = True + st.rerun() + + +_pages = Path(__file__).resolve().parent / "pages" # resolve so st.Page paths are absolute under AppTest _user = get_current_user() if not _user: @@ -77,12 +182,15 @@ def _home() -> None: st.Page(str(_pages / "sensor_ingest.py"), title="Insert Sensor Data", icon="📡"), st.Page(str(_pages / "lab_ingest.py"), title="Insert Lab Data", icon="🧪"), st.Page(str(_pages / "lab_panels.py"), title="Lab Panels", icon="🗂️"), + st.Page(str(_pages / "mapper.py"), title="Import Data (Mapper)", icon="📥"), st.Page(str(_pages / "explore.py"), title="Visualize Data", icon="📊"), st.Page(str(_pages / "equipment_move.py"), title="Move a sensor", icon="➡️"), + st.Page(str(_pages / "maintenance_control_chart.py"), title="Maintenance Control Chart", icon="📉"), ], "Reports": [ st.Page(str(_pages / "campaign_story.py"), title="Campaign Story", icon="📖"), st.Page(str(_pages / "equipment_story.py"), title="Equipment Story", icon="🔧"), + st.Page(str(_pages / "data_health.py"), title="Data Health", icon="🩺"), st.Page(str(_pages / "browse_tables.py"), title="Browse Tables", icon="🗄️"), ], "Workflows": [ @@ -131,6 +239,10 @@ def _home() -> None: ), ], + "Events": [ + st.Page(str(_pages / "events.py"), title="Events", icon="⚡"), + st.Page(str(_pages / "event_kinds.py"), title="Event Kinds", icon="🏷️"), + ], "Vocabulary": [ st.Page(str(_pages / "site_kinds.py"), title="Site Kinds"), st.Page(str(_pages / "campaign_kinds.py"), title="Campaign Kinds"), diff --git a/app/api_client.py b/app/api_client.py index ad171d0..00555f3 100644 --- a/app/api_client.py +++ b/app/api_client.py @@ -495,6 +495,22 @@ def get_stream_provenance(stream_id: int) -> dict: return _request("GET", f"/lineage/streams/{stream_id}/provenance") +def get_stream_pedigree( + stream_id: int, start: str | None = None, end: str | None = None +) -> dict: + """Fetch the pedigree of a Stream for the data-export metadata YAML: identity + plus a time-bound deployment timeline (sampling location, process unit, site, + campaign, responsible person per deployment). ``start``/``end`` (UTC ISO) + restrict the timeline to segments overlapping the exported window. Distinct + from get_stream_provenance (the processing DAG).""" + params: dict = {} + if start is not None: + params["from"] = start + if end is not None: + params["to"] = end + return _request("GET", f"/lineage/streams/{stream_id}/pedigree", params=params) + + def get_channel_thumbnail(channel_id: int, timestamp: str) -> bytes: """Fetch the JPEG thumbnail bytes for an image channel entry.""" return _request("GET", f"/timeseries/{channel_id}/thumbnail/{timestamp}", return_="content") @@ -919,6 +935,12 @@ def get_das_conflict(das_id: int, site_id: int) -> dict | None: return data if data.get("conflict") else None +def get_das_move_conflicts(das_id: int, site_id: int) -> list[dict]: + """Equipment wired to this DAS that a move to ``site_id`` would strand (F1).""" + data = _request("GET", f"/das/{das_id}/move-conflicts", params={"site_id": site_id}) + return data.get("stranded_equipment", []) + + def update_das(das_id: int, data: dict) -> dict: return _request("PUT", f"/signal-interfaces/das/{das_id}", json=data) @@ -975,6 +997,41 @@ def get_location_at_time(equipment_id: int, at: str) -> dict: return _request("GET", f"/equipment/{equipment_id}/location-at", params={"at": at}) +def get_active_campaign_deployment(equipment_id: int) -> dict | None: + """Return the still-running campaign whose deployment placed this equipment, + or None if reconfiguring would close no running campaign's deployment (F13).""" + data = _request("GET", f"/equipment/{equipment_id}/active-campaign") + return data if data.get("campaign_id") else None + + +# --------------------------------------------------------------------------- +# Data health — broken-link views (F5, F11) +# --------------------------------------------------------------------------- + + +def get_unlinked_channels() -> list[dict]: + """Raw channels with observations but no active wiring ("need wiring", F5).""" + return _request("GET", "/data-health/unlinked-channels").get("channels", []) + + +def get_inactive_parent_references( + signal_interface_id: int | None = None, + signal_interface_port_id: int | None = None, +) -> list[dict]: + """Active wiring rows pointing at a soft-deleted interface/port (F11). + + Pass an interface/port id to check whether that specific parent still has + live children before deactivating it.""" + params = {} + if signal_interface_id is not None: + params["signal_interface_id"] = signal_interface_id + if signal_interface_port_id is not None: + params["signal_interface_port_id"] = signal_interface_port_id + return _request( + "GET", "/data-health/inactive-parent-references", params=params + ).get("references", []) + + # --------------------------------------------------------------------------- # ControlLoop # --------------------------------------------------------------------------- @@ -1575,6 +1632,67 @@ def list_sample_kinds_lookup() -> list[dict]: return list_sample_kinds() +# --------------------------------------------------------------------------- +# EventKind CRUD /event-kinds +# --------------------------------------------------------------------------- + + +def list_event_kinds() -> list[dict]: + """Return all EventKind vocabulary entries.""" + return _request("GET", "/event-kinds") + + +def create_event_kind(data: dict) -> dict: + """Create a new EventKind.""" + return _request("POST", "/event-kinds", json=data) + + +def update_event_kind(event_kind_id: int, data: dict) -> dict: + """Replace an EventKind name/description.""" + return _request("PUT", f"/event-kinds/{event_kind_id}", json=data) + + +def delete_event_kind(event_kind_id: int) -> None: + """Delete an EventKind by ID.""" + return _request("DELETE", f"/event-kinds/{event_kind_id}") + + +def list_event_kinds_lookup() -> list[dict]: + """Return EventKinds for dropdowns (thin wrapper over list_event_kinds).""" + return list_event_kinds() + + +# --------------------------------------------------------------------------- +# Event CRUD /events +# --------------------------------------------------------------------------- + + +def list_events(**filters) -> list[dict]: + """List Events, optionally filtered by any of the 8 arc-target FK columns.""" + params = {k: v for k, v in filters.items() if v is not None} + return _request("GET", "/events", params=params or None) + + +def create_event(data: dict) -> dict: + """Create a new Event (exactly one arc-target FK must be provided).""" + return _request("POST", "/events", json=data) + + +def update_event(event_id: int, data: dict) -> dict: + """Partial-update an Event.""" + return _request("PUT", f"/events/{event_id}", json=data) + + +def delete_event(event_id: int) -> None: + """Hard-delete an Event by ID.""" + return _request("DELETE", f"/events/{event_id}") + + +def get_event_maintenance_drift(event_id: int) -> dict: + """Drift read-back for a maintenance Event (before/after + %diff). 404 if none.""" + return _request("GET", f"/events/{event_id}/maintenance-drift") + + # --------------------------------------------------------------------------- # Reference-data caching # --------------------------------------------------------------------------- diff --git a/app/components/campaign_wizard.py b/app/components/campaign_wizard.py index 39a4129..18326d2 100644 --- a/app/components/campaign_wizard.py +++ b/app/components/campaign_wizard.py @@ -21,6 +21,7 @@ create_site, deploy_das, get_das_conflict, + get_das_move_conflicts, list_campaign_kinds, list_das_lookup, list_equipment_lookup, @@ -753,6 +754,23 @@ def _step_das(lookups: dict) -> None: "but make sure the other campaign is aware." ) st.warning(msg) + # F1: name the equipment this move would strand at the old site. + try: + stranded = get_das_move_conflicts(resolved_das_id, current_site_id) + except APIError: + stranded = [] + if stranded: + names = ", ".join( + f"**{e.get('equipment_identifier') or f'#{e['equipment_id']}'}**" + f" (at {e.get('sampling_point_name') or '—'}," + f" {e.get('current_site_name') or 'other site'})" + for e in stranded + ) + st.warning( + f"Moving this DAS would strand {len(stranded)} wired " + f"equipment at the old site: {names}. Relocate them too " + "via **Equipment Move** so their location follows the DAS." + ) st.session_state[f"wiz_das_{das_id}_conflict"] = conflict else: st.info( @@ -1575,7 +1593,8 @@ def _execute_creates(lookups: dict) -> tuple[list[dict], list[str]]: "campaign_kind_id": _resolve_id( st.session_state.get("wiz_s0_campaign_type"), type_opts ), - "site_id": campaign_site_id, + # Campaign no longer stores a site; membership is derived from the + # sampling locations created below (campaigns are multi-site). "description": st.session_state.get("wiz_s0_description") or None, "start_date": start_date.isoformat() if start_date else None, "end_date": end_date.isoformat() if end_date else None, diff --git a/app/components/explore_echarts.py b/app/components/explore_echarts.py new file mode 100644 index 0000000..c427ee7 --- /dev/null +++ b/app/components/explore_echarts.py @@ -0,0 +1,376 @@ +"""ECharts scalar builder + brush-selection resolver for the Data Explorer. + +Replaces the Plotly scalar view on the Explore page (issue: plotting UX). Two +pure, unit-testable pieces — no Streamlit, no API — plus a thin glue layer that +explore.py wires to ``st_echarts``: + +* ``build_scalar_echarts_option`` — loads the same data the Plotly builder did + (via explore_data loaders) and returns ``(option, series_index_map, + overlay_rows)``. ``series_index_map`` lets the resolver translate ECharts + ``(seriesIndex, dataIndex)`` back to the stream/observation identity the + annotation workflow needs. +* ``resolve_brush_selection`` — turns the JS ``brushSelected`` payload into the + ``{"sensor_pts": [...], "lab_pts": [...]}`` shape the annotation/QC/event + buttons consume (mirrors the old Plotly ``customdata`` contract: + ``["sensor"|"lab", id, obs_id]``). + +The component round-trip itself can't run under AppTest (no browser/JS), so the +glue is intentionally tiny and everything decision-bearing is in these two +functions. +""" + +from __future__ import annotations + +from app.components.lttb import lttb +from app.components.explore_data import ( + DEFAULT_QUALITY_COLOR, + QUALITY_COLORS, + VIZ_MAX_POINTS, + _load_annotations, + _load_equipment_events, + _load_series_annotations, + _load_series_timeseries, + _load_timeseries, +) + +SENSOR_PALETTE = [ + "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", + "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf", +] +LAB_PALETTE = [ + "#1b9e77", "#d95f02", "#7570b3", "#e7298a", + "#66a61e", "#e6ab02", "#a6761d", "#666666", +] +EVENT_BAND = "#5588aa" +EVENT_LINE = "#7aaabb" + +# JS handler registered on the chart. ECharts fires brushSelected with a +# `batch`; each batch entry's `selected` lists the in-brush dataIndex per +# series. We hand the raw (seriesIndex, dataIndex[]) list back to Python and do +# the identity mapping there (resolve_brush_selection). +BRUSH_SELECTED_JS = ( + "function(params){" + " var b = (params.batch && params.batch[0]) ? params.batch[0].selected : [];" + " return b.map(function(s){return {seriesIndex: s.seriesIndex, dataIndex: s.dataIndex};});" + "}" +) + +# Click is the reliable, discoverable single-point selector (just click a marker). +# It returns the SAME shape as the brush handler so one resolver handles both. +CLICK_SELECTED_JS = ( + "function(p){" + " if(p.componentType!=='series'){return null;}" + " return [{seriesIndex: p.seriesIndex, dataIndex: [p.dataIndex]}];" + "}" +) + + +def _qc_color(qc) -> str: + return QUALITY_COLORS.get(qc, DEFAULT_QUALITY_COLOR) + + +def _series_value_label(data: dict | None, meta: dict) -> str: + """Y-axis label as 'parameter (unit)'. The loaded time-series payload carries + both fields reliably; the picker meta (DeploymentTraceLookupItem) has no + unit, so prefer the data and fall back to meta.""" + data = data or {} + param = data.get("parameter") or meta.get("parameter_name") or "" + unit = data.get("unit") or meta.get("unit_name") or "" + return f"{param} ({unit})" if param and unit else param or unit or "Value" + + +def _overlay_markers( + overlay_rows: list[dict], + spans: list[dict], + *, + source: str, + kind: str, + items: list[dict], + color_of, + band_color: str | None = None, +) -> None: + """Append annotation/equipment-event records to overlay_rows and collect + markArea/markLine spans (consumed below to decorate the owning series).""" + for it in items: + t_start = it["start"] + t_end = it["end"] or t_start + ref = len(overlay_rows) + 1 + color = band_color or color_of(it) + overlay_rows.append( + { + "ref": ref, "kind": kind, "source": source, + "category": it.get("category", "") or "", + "title": it.get("title", "") or "", + "start": t_start, "end": it["end"] or "", + "comment": it.get("comment", "") or "", + } + ) + spans.append({"ref": ref, "start": t_start, "end": t_end, "color": color}) + + +def build_scalar_echarts_option( + active_channels: list[int], + channel_meta: dict[int, dict], + mode: str, + active_series: list[int] | None = None, + series_meta: dict[int, dict] | None = None, +) -> tuple[dict, list[dict], list[dict]]: + """Build the ECharts ``option`` for the scalar view. + + Returns ``(option, series_index_map, overlay_rows)``: + * ``series_index_map[i]`` describes the i-th ECharts series — its kind + ("sensor"/"lab"), stream id, and a per-dataIndex list of + ``{"x","y","obs_id"}`` so a brush dataIndex maps straight to an + observation. + * ``overlay_rows`` feeds the annotations & equipment-events summary table. + """ + active_series = active_series or [] + series_meta = series_meta or {} + + echarts_series: list[dict] = [] + series_index_map: list[dict] = [] + overlay_rows: list[dict] = [] + y_labels: list[str] = [] + seen_labels: set[str] = set() + drawn_eq: set = set() + + def _track_label(lbl: str) -> None: + if lbl not in seen_labels: + seen_labels.add(lbl) + y_labels.append(lbl) + + # --- Sensor channels: line+markers --- + for idx, ch_id in enumerate(active_channels): + data = _load_timeseries(ch_id) + if not data or not data.get("data"): + continue + rows = data["data"] + ts_list = [r.get("timestamp") for r in rows] + v_list = [r.get("value") for r in rows] + qc_list = [r.get("quality_code") for r in rows] + ts_to_obs = {r.get("timestamp"): r.get("observation_id") for r in rows} + + if mode == "viz": + ts_list, v_list = lttb(ts_list, v_list, VIZ_MAX_POINTS) + qc_list = qc_list[: len(ts_list)] + obs_list = [ts_to_obs.get(ts) for ts in ts_list] + + meta = channel_meta.get(ch_id, {}) + _track_label(_series_value_label(data, meta)) + label = f"CH-{ch_id}: {meta.get('equipment_identifier', '?')} / {meta.get('parameter_name', '?')}" + color = SENSOR_PALETTE[idx % len(SENSOR_PALETTE)] + + points = [ + {"x": x, "y": y, "obs_id": o} for x, y, o in zip(ts_list, v_list, obs_list) + ] + ec_data = [ + {"value": [x, y], "itemStyle": {"color": _qc_color(qc)}} + for x, y, qc in zip(ts_list, v_list, qc_list) + ] + + spans: list[dict] = [] + _overlay_markers( + overlay_rows, spans, source=f"CH-{ch_id}", kind="Annotation", + items=[ + {"start": a.get("start_time"), "end": a.get("end_time"), + "category": (a.get("type") or {}).get("name", ""), + "title": a.get("title"), "comment": a.get("comment"), + "color": (a.get("type") or {}).get("color") or "#888888"} + for a in _load_annotations(ch_id) + ], + color_of=lambda it: it["color"], + ) + eq_id = meta.get("equipment_id") + if eq_id is not None and eq_id not in drawn_eq: + drawn_eq.add(eq_id) + eq_label = meta.get("equipment_identifier") or f"EQ-{eq_id}" + _overlay_markers( + overlay_rows, spans, source=eq_label, kind="Equipment Event", + items=[ + {"start": ev.get("start_datetime"), "end": ev.get("end_datetime"), + "category": ev.get("event_type_name", ""), "title": ev.get("notes")} + for ev in _load_equipment_events(eq_id) + ], + color_of=lambda it: EVENT_BAND, band_color=EVENT_BAND, + ) + + # ECharts line series have no brushSelector — the toolbox brush cannot + # point-select them. So the line is a decorative (non-selectable) layer + # with its symbols hidden, and a companion scatter draws the markers and + # is the brushable layer that carries point identity. The two share a + # legend name so they toggle as one. The decorative line keeps a + # placeholder map entry so seriesIndex stays 1:1 with series_index_map. + line = _line_series(label, color, ec_data, spans) + line["showSymbol"] = False + echarts_series.append(line) + series_index_map.append({"kind": "decor"}) + + echarts_series.append(_sensor_marker_series(label, color, ec_data)) + series_index_map.append({"kind": "sensor", "id": ch_id, "points": points}) + + # --- Lab AnalysisSeries: scatter (diamonds) --- + for idx, s_id in enumerate(active_series): + data = _load_series_timeseries(s_id) + if not data or not data.get("data"): + continue + rows = data["data"] + ts_list = [r.get("timestamp") for r in rows] + v_list = [r.get("value") for r in rows] + qc_list = [r.get("quality_code") for r in rows] + obs_list = [r.get("observation_id") for r in rows] + + smeta = series_meta.get(s_id, {}) + _track_label(_series_value_label(data, smeta)) + label = ( + f"LAB-{s_id}: {smeta.get('name') or smeta.get('parameter_name', '?')} " + f"@ {smeta.get('sampling_point_label', '?')}" + ) + outline = LAB_PALETTE[idx % len(LAB_PALETTE)] + + points = [ + {"x": x, "y": y, "obs_id": o} for x, y, o in zip(ts_list, v_list, obs_list) + ] + ec_data = [ + {"value": [x, y], + "itemStyle": {"color": _qc_color(qc), "borderColor": outline, "borderWidth": 1.5}} + for x, y, qc in zip(ts_list, v_list, qc_list) + ] + + spans = [] + _overlay_markers( + overlay_rows, spans, source=f"LAB-{s_id}", kind="Annotation", + items=[ + {"start": a.get("start_time"), "end": a.get("end_time"), + "category": (a.get("type") or {}).get("name", ""), + "title": a.get("title"), "comment": a.get("comment"), + "color": (a.get("type") or {}).get("color") or "#888888"} + for a in _load_series_annotations(s_id) + ], + color_of=lambda it: it["color"], + ) + + echarts_series.append( + _scatter_series(label, outline, ec_data, spans) + ) + series_index_map.append({"kind": "lab", "id": s_id, "points": points}) + + y_axis_title = " / ".join(y_labels) if y_labels else "Value" + + option = { + "tooltip": {"trigger": "item", "axisPointer": {"type": "cross"}}, + "legend": {"top": 0, "type": "scroll"}, + "grid": {"left": 76, "right": 24, "top": 48, "bottom": 80, "containLabel": True}, + "xAxis": { + "type": "time", "name": "Time", + "nameLocation": "middle", "nameGap": 28, + }, + "yAxis": { + "type": "value", "name": y_axis_title, "scale": True, + # Render the parameter (unit) as a proper rotated axis title, like the + # old Plotly yaxis_title — not the tiny default label at the axis top. + "nameLocation": "middle", "nameGap": 44, + "nameTextStyle": {"fontWeight": "bold"}, + }, + "dataZoom": [ + {"type": "inside", "xAxisIndex": 0}, + {"type": "slider", "xAxisIndex": 0, "bottom": 8}, + ], + "toolbox": {"feature": {"brush": {"type": ["rect", "polygon", "lineX", "clear"]}}}, + "brush": { + "xAxisIndex": 0, + "throttleType": "debounce", + "throttleDelay": 300, + "brushStyle": {"borderColor": "#5b8def", "color": "rgba(91,141,239,0.12)"}, + }, + "series": echarts_series, + } + return option, series_index_map, overlay_rows + + +def _markarea_markline(spans: list[dict]) -> dict: + """Return markArea + markLine config drawing the [ref]-labelled bands/lines.""" + extra: dict = {} + if not spans: + return extra + extra["markArea"] = { + "silent": True, + "data": [ + [ + {"xAxis": s["start"], "itemStyle": {"color": s["color"], "opacity": 0.08}}, + {"xAxis": s["end"]}, + ] + for s in spans + ], + } + extra["markLine"] = { + "symbol": "none", + "data": [ + { + "xAxis": s["start"], + "lineStyle": {"color": s["color"], "type": "dashed", "width": 1.5}, + "label": {"formatter": f"[{s['ref']}]", "color": s["color"]}, + } + for s in spans + ], + } + return extra + + +def _line_series(name: str, color: str, data: list[dict], spans: list[dict]) -> dict: + s = { + "name": name, "type": "line", "showSymbol": True, "symbolSize": 5, + "lineStyle": {"color": color, "width": 1.5}, "itemStyle": {"color": color}, + "emphasis": {"focus": "series"}, "data": data, + } + s.update(_markarea_markline(spans)) + return s + + +def _sensor_marker_series(name: str, color: str, data: list[dict]) -> dict: + """Brushable point layer sitting on the decorative sensor line. Same legend + name as the line so the two toggle together; per-point qc colours ride on + each data item's itemStyle (data is shared with the line).""" + return { + "name": name, "type": "scatter", "symbol": "circle", "symbolSize": 5, + "itemStyle": {"color": color}, "emphasis": {"focus": "series"}, + "data": data, + } + + +def _scatter_series(name: str, outline: str, data: list[dict], spans: list[dict]) -> dict: + s = { + "name": name, "type": "scatter", "symbol": "diamond", "symbolSize": 11, + "itemStyle": {"color": outline}, "emphasis": {"focus": "series"}, "data": data, + } + s.update(_markarea_markline(spans)) + return s + + +def resolve_brush_selection(payload, series_index_map: list[dict]) -> dict: + """Translate the brushSelected JS payload into the annotation workflow shape. + + ``payload`` is the list of ``{"seriesIndex", "dataIndex": [...]}`` returned + by BRUSH_SELECTED_JS (or None when nothing is selected / no browser). + Returns ``{"sensor_pts": [...], "lab_pts": [...]}`` where each point is + ``{"x","y","obs_id","id"}`` — matching what _render_scalar_view consumed + from the old Plotly customdata. + """ + out: dict[str, list[dict]] = {"sensor_pts": [], "lab_pts": []} + if not payload: + return out + for sel in payload: + si = sel.get("seriesIndex") + if si is None or si < 0 or si >= len(series_index_map): + continue + smap = series_index_map[si] + if smap["kind"] not in ("sensor", "lab"): + continue # decorative line layer — not a selectable identity series + bucket = "sensor_pts" if smap["kind"] == "sensor" else "lab_pts" + pts = smap["points"] + for di in sel.get("dataIndex") or []: + if 0 <= di < len(pts): + p = pts[di] + out[bucket].append( + {"x": p["x"], "y": p["y"], "obs_id": p["obs_id"], "id": smap["id"]} + ) + return out diff --git a/app/components/explore_export.py b/app/components/explore_export.py new file mode 100644 index 0000000..48dcec3 --- /dev/null +++ b/app/components/explore_export.py @@ -0,0 +1,244 @@ +"""Pure builder for the Data Explorer zip export. + +Turns a list of already-fetched stream entries into a single zip: one CSV per +stream (UTC timestamps, value, annotation + equipment-event overlay columns) and +a paired pedigree YAML. Image streams additionally embed their files under +images//. + +No Streamlit, no API calls — all I/O happens in explore.py, which assembles the +entries and calls build_export_zip. That keeps every non-trivial rule (overlay +matching, CSV shaping, filename safety) unit-testable in isolation. + +Entry contract (one dict per active stream):: + + { + "filename": "CH-5_TSS", # base name; sanitized here + "value_kind": 1, # VALUE_TYPE_* (explore_data) + "data": {"parameter": str, "unit": str, "data": [ {timestamp, ...} ]}, + "annotations": [ {kind, note, start, end} ], # normalized, UTC strings + "events": [ {kind, note, start, end} ], # normalized, UTC strings + "pedigree": { ... }, # /lineage/streams/{id}/pedigree + "images": { ts_str: bytes }, # image streams only + } + +Use overlay_from_annotation / overlay_from_event to normalize raw API dicts into +the {kind, note, start, end} overlay shape. +""" + +from __future__ import annotations + +import csv +import io +import re +import zipfile +from datetime import datetime, timezone + +import yaml + +from app.components.explore_data import VALUE_TYPE_IMAGE + + +def _to_utc_naive(ts) -> datetime | None: + """Parse an ISO timestamp to a naive-UTC datetime for safe comparison.""" + if not ts: + return None + try: + dt = datetime.fromisoformat(str(ts).replace("Z", "+00:00")) + except ValueError: + return None + if dt.tzinfo is not None: + dt = dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt + + +def _covers(item: dict, ts_dt: datetime | None) -> bool: + """True if a {start, end} overlay item covers the given timestamp. + + end=None means a point annotation: matches only the exact start timestamp + (avoids an open-ended range silently tagging every later row).""" + if ts_dt is None: + return False + start = _to_utc_naive(item.get("start")) + if start is None: + return False + end = _to_utc_naive(item.get("end")) + if end is None: + return ts_dt == start + return start <= ts_dt <= end + + +def _segment_covers(seg: dict, ts_dt: datetime | None) -> bool: + """True if a deployment segment was active at the timestamp. + + A segment with both bounds null is a fixed/unbounded context (a lab series' + inherent sampling point + campaign) and covers every row. valid_to null is an + open-ended deployment (still active), covering everything from valid_from on.""" + start = _to_utc_naive(seg.get("valid_from")) + end = _to_utc_naive(seg.get("valid_to")) + if start is None and end is None: + return True + if ts_dt is None: + return False + if start is not None and ts_dt < start: + return False + if end is not None and ts_dt > end: + return False + return True + + +def _segment_columns(ts, deployments: list[dict]) -> dict: + """Resolve the sampling location + campaign active at a row's timestamp from + the deployment timeline (the time-bound pedigree).""" + ts_dt = _to_utc_naive(ts) + hits = [d for d in deployments if _segment_covers(d, ts_dt)] + + def _name(seg: dict, key: str) -> str | None: + obj = seg.get(key) + return obj.get("name") if isinstance(obj, dict) else None + + return { + "sampling_location": "; ".join( + dict.fromkeys(n for s in hits if (n := _name(s, "sampling_location"))) + ), + "campaign": "; ".join( + dict.fromkeys(n for s in hits if (n := _name(s, "campaign"))) + ), + } + + +def _overlay_columns(ts, annotations: list[dict], events: list[dict]) -> dict: + ts_dt = _to_utc_naive(ts) + a_hits = [a for a in annotations if _covers(a, ts_dt)] + e_hits = [e for e in events if _covers(e, ts_dt)] + return { + "annotation_kind": "; ".join(a["kind"] for a in a_hits if a.get("kind")), + "annotation_note": "; ".join(a["note"] for a in a_hits if a.get("note")), + "event_kind": "; ".join(e["kind"] for e in e_hits if e.get("kind")), + "event_note": "; ".join(e["note"] for e in e_hits if e.get("note")), + } + + +def overlay_from_annotation(ann: dict) -> dict: + """Normalize a raw annotation (AnnotationResponse) to the overlay shape. + + The kind lives under the nested ``type.name`` on the API response; plain + ``kind`` is also accepted so callers can pass pre-flattened dicts.""" + type_obj = ann.get("type") + type_name = type_obj.get("name") if isinstance(type_obj, dict) else None + note = " — ".join(p for p in (ann.get("title"), ann.get("comment")) if p) + return { + "kind": ann.get("kind") or type_name or ann.get("annotation_kind") or ann.get("name"), + "note": note or None, + "start": ann.get("start_time") or ann.get("start"), + "end": ann.get("end_time") or ann.get("end"), + } + + +def overlay_from_event(ev: dict) -> dict: + """Normalize a raw equipment-event dict (lifecycle endpoint) to the overlay shape.""" + note = " — ".join( + p for p in (ev.get("notes"), ev.get("title"), ev.get("comment")) if p + ) + return { + "kind": ev.get("event_type_name") or ev.get("kind") or ev.get("event_kind") or ev.get("name"), + "note": note or None, + "start": ev.get("start_datetime") or ev.get("start"), + "end": ev.get("end_datetime") or ev.get("end"), + } + + +def _safe(name: str) -> str: + """Filesystem-safe basename for zip members.""" + cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", str(name)).strip("_") + return cleaned or "stream" + + +def _csv_bytes(rows: list[dict]) -> bytes: + if not rows: + return b"" + fieldnames: list[str] = [] + seen: set[str] = set() + for r in rows: + for k in r: + if k not in seen: + seen.add(k) + fieldnames.append(k) + buf = io.StringIO() + writer = csv.DictWriter(buf, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + return buf.getvalue().encode() + + +def _stream_rows( + entry: dict, base: str, zf: zipfile.ZipFile, quality_labels: dict +) -> list[dict]: + """Build the CSV rows for one stream (and embed image files as a side effect). + + Uniform across scalar/vector/matrix: timestamp_utc first, then the original + row fields (value, quality_code, bin_index/row/col, …), then parameter/unit, + then overlay columns. quality_code is rendered as its label, not the id. + Image streams add an image_file column pointing at the embedded file.""" + data = entry.get("data") or {} + parameter = data.get("parameter", "") + unit = data.get("unit", "") + annotations = entry.get("annotations") or [] + events = entry.get("events") or [] + deployments = (entry.get("pedigree") or {}).get("deployments") or [] + images = entry.get("images") or {} + is_image = entry.get("value_kind") == VALUE_TYPE_IMAGE + + rows: list[dict] = [] + for orig in data.get("data", []): + ts = orig.get("timestamp") + row: dict = {"timestamp_utc": ts} + for k, v in orig.items(): + if k == "timestamp": + continue + row[k] = quality_labels.get(v, v) if k == "quality_code" else v + row["parameter"] = parameter + row["unit"] = unit + row.update(_segment_columns(ts, deployments)) + if is_image: + img = images.get(ts) + if img is not None: + path = f"images/{base}/{_safe(str(ts))}.jpg" + zf.writestr(path, img) + row["image_file"] = path + else: + row["image_file"] = "" + row.update(_overlay_columns(ts, annotations, events)) + rows.append(row) + return rows + + +def build_export_zip( + entries: list[dict], quality_labels: dict | None = None +) -> bytes: + """Build the export zip: paired .csv + .yaml per stream, + plus embedded image files for image streams. ``quality_labels`` maps + QualityCode ids to their names so the CSV shows labels, not ids. Returns the + zip bytes.""" + quality_labels = quality_labels or {} + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + used: set[str] = set() + for entry in entries: + base = _safe(entry.get("filename", "stream")) + # Guard against duplicate basenames colliding in the archive. + unique = base + n = 2 + while unique in used: + unique = f"{base}_{n}" + n += 1 + used.add(unique) + + rows = _stream_rows(entry, unique, zf, quality_labels) + zf.writestr(f"{unique}.csv", _csv_bytes(rows)) + zf.writestr( + f"{unique}.yaml", + yaml.safe_dump( + entry.get("pedigree") or {}, sort_keys=False, allow_unicode=True + ), + ) + return buf.getvalue() diff --git a/app/components/form_specs.py b/app/components/form_specs.py index 13bfbef..3403b97 100644 --- a/app/components/form_specs.py +++ b/app/components/form_specs.py @@ -34,6 +34,27 @@ def _yaml( return lambda: load_table(table).build_form_fields(exclude=exclude, overrides=overrides) +def _channel_fields() -> list[dict]: + """Channel form: YAML-derived columns plus the current port. + + F3 dropped the denormalised Channel.SignalInterfacePort_ID column (the port + is now resolved from the active ChannelPortHistory row), but ChannelIn still + accepts ``signal_interface_port_id`` as the channel's current port — writes + route through ``channel_repository.set_channel_active_port``. The field is no + longer in the YAML, so re-add it explicitly to keep the form↔schema contract. + """ + fields = load_table("Channel").build_form_fields(exclude={"unit_id"}) + fields.append( + { + "name": "signal_interface_port_id", + "type": "select", + "required": False, + "options_fn": "list_signal_interface_port_lookup", + } + ) + return fields + + def _explicit(fields: list[dict]) -> Callable[[], list[dict]]: """For entities whose API request schema diverges too far from the YAML to derive (renamed date fields, anchor-derived ids, non-column list inputs). @@ -46,7 +67,8 @@ def _explicit(fields: list[dict]) -> Callable[[], list[dict]]: FORM_FIELD_BUILDERS: dict[str, Callable[[], list[dict]]] = { # --- name/description vocab (YAML already matches *In) ------------------- "campaign_kind": _yaml("CampaignKind"), - "equipment_event_kind": _yaml("EquipmentEventKind"), + "equipment_event_kind": _yaml("EventKind"), # renamed in PRD-2 S1; slug + API rename in S2 + "event_kind": _yaml("EventKind"), # PRD-2 S4: new slug for the generalised EventKind page "process_unit_kind": _yaml("ProcessUnitKind"), "procedure_kind": _yaml("ProcedureKind"), "sample_kind": _yaml("SampleKind"), @@ -74,14 +96,13 @@ def _explicit(fields: list[dict]) -> Callable[[], list[dict]]: ), # ChannelIn carries no unit (a channel's unit follows its parameter); # produced_by_step_id is included (manual link to a derived channel's step). - "channel": _yaml("Channel", exclude={"unit_id"}), + "channel": _channel_fields, # --- explicit: API schema diverges from YAML too far to derive ----------- # CampaignIn renames the YAML *DateTime columns to start_date/end_date. "campaign": _explicit( [ {"name": "name", "type": "text", "required": True}, {"name": "campaign_kind_id", "type": "select", "required": True}, - {"name": "site_id", "type": "select", "required": True}, {"name": "description", "type": "textarea", "required": False}, {"name": "start_date", "type": "date", "required": False}, {"name": "end_date", "type": "date", "required": False}, @@ -108,7 +129,7 @@ def _explicit(fields: list[dict]) -> Callable[[], list[dict]]: {"name": "title", "type": "text", "required": False}, {"name": "comment", "type": "textarea", "required": False}, {"name": "campaign_id", "type": "number", "required": False}, - {"name": "equipment_event_id", "type": "number", "required": False}, + {"name": "event_id", "type": "number", "required": False}, {"name": "author_person_id", "type": "number", "required": False}, ] ), @@ -140,8 +161,9 @@ def _explicit(fields: list[dict]) -> Callable[[], list[dict]]: "parameter": _yaml( "Parameter", exclude={"qudt_quantity_kind_iri", "value_kind_id"} ), - # ChannelIn carries no unit (a channel's unit follows its parameter). - "channel": _yaml("Channel", exclude={"unit_id"}), + # ChannelIn carries no unit (a channel's unit follows its parameter); the + # port is re-added by _channel_fields (dropped from YAML by F3). + "channel": _channel_fields, # --- need rename: API name diverges from YAML→snake --------------------- # EquipmentIn uses model_id; is_active/storage_location are managed via the # commission/decommission lifecycle endpoints, not the edit form. diff --git a/app/components/resolver.py b/app/components/resolver.py new file mode 100644 index 0000000..252110c --- /dev/null +++ b/app/components/resolver.py @@ -0,0 +1,167 @@ +"""Entity resolver: text → existing DB ID with fuzzy suggestion.""" +from __future__ import annotations + +import re +from difflib import get_close_matches +from typing import Any + +# The exclusive-arc Event target levels, smallest logical unit first. Maps a +# level label to the EventIn FK field it sets. +TARGET_LEVELS: dict[str, str] = { + "Equipment": "equipment_id", + "SamplingPoint": "sampling_point_id", + "ProcessUnit": "process_unit_id", + "Site": "site_id", + "Campaign": "campaign_id", +} + +# Cheap, deterministic level hints (PRD-4 S2) for rows the resolver could not +# match to a known entity — a *suggestion* the user confirms, never an +# auto-commit. An equipment-style tag (e.g. "P-100", "LDO-241") looks like +# Equipment; a handful of keywords hint at site-wide / process-unit scope. +_EQUIPMENT_TAG = re.compile(r"\b[A-Za-z]{1,4}-?\d{2,}\b") +_LEVEL_KEYWORDS: dict[str, tuple[str, ...]] = { + "Site": ("outage", "power", "panne", "électr", "electr", "building", "bâtiment", "site-wide"), + "ProcessUnit": ("plc", "automate", "scada", "ups", "onduleur"), +} + + +def guess_target_level(text: str) -> str | None: + """Suggest a target *level* (not an entity) from cheap text heuristics. + + Used to pre-fill the level picker for rows with no entity match. Returns a + key of :data:`TARGET_LEVELS` or None. Never resolves an entity on its own — + the user still confirms which entity at that level. + """ + raw = text or "" + low = raw.lower() + if not low.strip(): + return None + if _EQUIPMENT_TAG.search(raw): + return "Equipment" + for level, keywords in _LEVEL_KEYWORDS.items(): + if any(k in low for k in keywords): + return level + return None + + +# Label keys a lookup dict may carry, most specific first. Used by target +# resolution, which is key-agnostic across the different target lookups. +_NAME_KEYS = ("identifier", "name", "label", "tag") + + +def _candidate_name(candidate: dict) -> str: + """Return the first non-empty label a candidate lookup dict carries.""" + for key in _NAME_KEYS: + val = candidate.get(key) + if val: + return str(val) + return "" + + +def _best_match(query: str, candidates: list[dict], name_key: str, id_key: str) -> dict | None: + """Return the best fuzzy match dict or None.""" + names = [c[name_key] for c in candidates if c.get(name_key)] + matches = get_close_matches(query, names, n=1, cutoff=0.6) + if not matches: + return None + return next(c for c in candidates if c.get(name_key) == matches[0]) + + +class EntityResolver: + """Resolves text labels to existing DB IDs. Call .resolve_*() for each cell. + + Parameters + ---------- + units: + List of unit dicts with keys ``unit_id``, ``name``, and optionally ``symbol``. + parameters: + List of parameter dicts with keys ``parameter_id`` and ``name``. + sampling_points: + List of sampling-point dicts with keys ``sampling_point_id`` and ``name``. + """ + + def __init__( + self, + units: list[dict], + parameters: list[dict], + sampling_points: list[dict], + equipment: list[dict] | None = None, + sites: list[dict] | None = None, + process_units: list[dict] | None = None, + campaigns: list[dict] | None = None, + persons: list[dict] | None = None, + ) -> None: + self._units = units + self._parameters = parameters + self._sps = sampling_points + # Event-target candidate pools (PRD-4 logbook profile). Optional so the + # lab/sensor profiles construct the resolver unchanged. + self._equipment = equipment or [] + self._sites = sites or [] + self._process_units = process_units or [] + self._campaigns = campaigns or [] + self._persons = persons or [] + + def resolve_unit(self, text: str) -> dict | None: + """Match unit by symbol (exact, case-insensitive) then by name (fuzzy).""" + text_lower = text.strip().lower() + for u in self._units: + if (u.get("symbol") or "").lower() == text_lower: + return u + return _best_match(text, self._units, "name", "unit_id") + + def resolve_parameter(self, text: str) -> dict | None: + """Match parameter by short_name (exact, case-insensitive) then by name (fuzzy).""" + text_lower = text.strip().lower() + for p in self._parameters: + if (p.get("short_name") or "").lower() == text_lower: + return p + return _best_match(text, self._parameters, "name", "parameter_id") + + def resolve_sampling_point(self, text: str) -> dict | None: + """Match sampling point by name (fuzzy).""" + return _best_match(text, self._sps, "name", "sampling_point_id") + + def resolve_person(self, text: str) -> dict | None: + """Match a person by their display label (fuzzy).""" + return _best_match(text, self._persons, "label", "person_id") + + def resolve_target(self, text: str) -> dict | None: + """Resolve free text to the *smallest* logical Event target it names. + + Scans the text for any candidate label as a substring, smallest level + first (Equipment → SamplingPoint → ProcessUnit → Site → Campaign), and + returns the first level that hits. Within a level the longest matching + label wins (most specific). Returns a dict with ``arc_field`` (the + EventIn FK to set), ``level``, ``entity_id``, ``label`` — or None. + + ponytail: substring containment, not word-boundary aware ("P-100" + matches inside "P-1000"). S2 adds the disambiguation UX + tighter match. + """ + haystack = (text or "").strip().lower() + if not haystack: + return None + # (level label, candidate pool, EventIn arc-FK field, id key) + levels = [ + ("Equipment", self._equipment, "equipment_id", "equipment_id"), + ("SamplingPoint", self._sps, "sampling_point_id", "sampling_point_id"), + ("ProcessUnit", self._process_units, "process_unit_id", "id"), + ("Site", self._sites, "site_id", "site_id"), + ("Campaign", self._campaigns, "campaign_id", "campaign_id"), + ] + for level, pool, arc_field, id_key in levels: + hits = [ + c for c in pool + if (name := _candidate_name(c)) and len(name) >= 2 + and name.lower() in haystack + ] + if hits: + best = max(hits, key=lambda c: len(_candidate_name(c))) + return { + "arc_field": arc_field, + "level": level, + "entity_id": best.get(id_key), + "label": _candidate_name(best), + } + return None diff --git a/app/components/wizard_helpers.py b/app/components/wizard_helpers.py index 593e52b..398890d 100644 --- a/app/components/wizard_helpers.py +++ b/app/components/wizard_helpers.py @@ -67,6 +67,21 @@ def restore_snapshot(wiz_id: str, step: int) -> None: st.session_state[key] = val +def snapshot_get(wiz_id: str, step: int, key: str, default=None): + """Read a value a prior step captured in its snapshot. + + Streamlit drops a widget's session-state entry on any rerun where that + widget is not rendered. A later step that reads an earlier step's widget key + directly therefore sees ``None`` after the first in-step rerun (e.g. a + selectbox change), losing the earlier selection. The snapshot is a plain + dict that survives, so cross-step reads must come from it. Live state wins + when present (most up to date); fall back to the snapshot when dropped. + """ + if key in st.session_state: + return st.session_state[key] + return st.session_state.get(f"_{wiz_id}_snap_{step}", {}).get(key, default) + + def clear_wizard(wiz_id: str) -> None: """Remove all wizard state keys from session_state.""" prefix = f"{wiz_id}_" diff --git a/app/pages/campaign_story.py b/app/pages/campaign_story.py index 0fed040..fac9abf 100644 --- a/app/pages/campaign_story.py +++ b/app/pages/campaign_story.py @@ -133,7 +133,7 @@ def _status_badge(e: dict) -> tuple[str, str, bool]: ("Ongoing" if ongoing else "Ended", "ok" if ongoing else "grey", True), ], meta=[ - ("Site", camp.get("site_name") or "—"), + ("Site", ", ".join(camp.get("site_names") or []) or "—"), ("Watershed", ws.get("name") or "—"), ("Period", period), ("Lead", camp.get("responsible_person_name") or "—"), diff --git a/app/pages/campaign_wizard_page.py b/app/pages/campaign_wizard_page.py index bf0dfe0..f931e3b 100644 --- a/app/pages/campaign_wizard_page.py +++ b/app/pages/campaign_wizard_page.py @@ -22,6 +22,7 @@ render_wizard_result, resolve_id, restore_snapshot, + snapshot_get, ) _WIZ = "cmp_wiz" @@ -194,32 +195,59 @@ def _step_site_and_sls(lookups: dict) -> None: ) return - selected_site = st.selectbox("Site *", site_labels, key=f"{_WIZ}_s1_site") - - # Load sampling locations for the selected site - site_record = next((s for s in sites if s["name"] == selected_site), None) - site_id = site_record["site_id"] if site_record else None - sampling_locations: list[dict] = [] + # Campaigns are multi-site: one repeatable block per site, each with its own + # sampling-location multiselect. The campaign's sites are the union. + block_ids: list[int] = st.session_state.get(f"{_WIZ}_s1_block_ids") or [] + if not block_ids: + block_ids = [0] + st.session_state[f"{_WIZ}_s1_block_ids"] = block_ids + st.session_state[f"{_WIZ}_s1_next_block"] = 1 + + st.caption( + "Add each site this campaign covers and pick its sampling locations. " + "The campaign's sites are derived from everything selected here." + ) - if site_id is not None: - try: - sampling_locations = list_site_sampling_locations(site_id) - except APIError as e: - st.error(f"Failed to load sampling locations: {e.message}") + for b in block_ids: + with st.container(border=True): + head, rm = st.columns([6, 1]) + with head: + selected_site = st.selectbox( + "Site *", site_labels, key=f"{_WIZ}_s1_site_{b}" + ) + with rm: + st.write("") + if len(block_ids) > 1 and st.button( + "✖", key=f"{_WIZ}_s1_site_{b}_remove", help="Remove this site" + ): + st.session_state[f"{_WIZ}_s1_block_ids"] = [ + i for i in block_ids if i != b + ] + st.rerun() + + site_record = next((s for s in sites if s["name"] == selected_site), None) + site_id = site_record["site_id"] if site_record else None + sls: list[dict] = [] + if site_id is not None: + try: + sls = list_site_sampling_locations(site_id) + except APIError as e: + st.error(f"Failed to load sampling locations: {e.message}") + + if not sls: + st.info("This site has no sampling locations yet.") + else: + st.multiselect( + "Sampling locations in scope", + [sl["name"] for sl in sls], + key=f"{_WIZ}_s1_sls_{b}", + ) - if not sampling_locations: - st.info( - "This site has no sampling locations yet. " - "Use the **Site Setup Wizard** to add sampling locations, " - "or proceed to create a campaign without deployments." - ) - else: - sl_labels = [sl["name"] for sl in sampling_locations] - st.multiselect( - "Sampling locations in scope", - sl_labels, - key=f"{_WIZ}_s1_sl_selected", - ) + if st.button("➕ Add another site", key=f"{_WIZ}_s1_add_site"): + nxt = st.session_state.get(f"{_WIZ}_s1_next_block", len(block_ids)) + st.session_state[f"{_WIZ}_s1_block_ids"] = block_ids + [nxt] + st.session_state[f"{_WIZ}_s1_next_block"] = nxt + 1 + st.rerun() nav( wiz_id=_WIZ, @@ -231,23 +259,41 @@ def _step_site_and_sls(lookups: dict) -> None: ) -def _step_equipment_deployments(lookups: dict) -> None: - restore_snapshot(_WIZ, 2) +def _selected_sls(lookups: dict) -> list[dict]: + """Union of sampling locations selected across all site blocks, each + annotated with ``site_name``. + Reads from the step-1 snapshot so it survives later-step reruns. SL ids are + unique across sites; SL names may collide between sites, so resolution is + per block (within a single site, names are unique).""" sites = lookups.get("sites", []) - selected_site = st.session_state.get(f"{_WIZ}_s1_site") - site_record = next((s for s in sites if s["name"] == selected_site), None) - site_id = site_record["site_id"] if site_record else None - - sampling_locations: list[dict] = [] - if site_id is not None: + block_ids = snapshot_get(_WIZ, 1, f"{_WIZ}_s1_block_ids") or [0] + out: list[dict] = [] + seen: set[int] = set() + for b in block_ids: + site_name = snapshot_get(_WIZ, 1, f"{_WIZ}_s1_site_{b}") + site_record = next((s for s in sites if s["name"] == site_name), None) + if not site_record: + continue try: - sampling_locations = list_site_sampling_locations(site_id) + sls = list_site_sampling_locations(site_record["site_id"]) except APIError: - pass + sls = [] + chosen = snapshot_get(_WIZ, 1, f"{_WIZ}_s1_sls_{b}") or [] + for sl in sls: + if sl["name"] in chosen and sl["id"] not in seen: + seen.add(sl["id"]) + out.append({**sl, "site_name": site_record["name"]}) + return out + - selected_sl_labels: list[str] = st.session_state.get(f"{_WIZ}_s1_sl_selected") or [] - selected_sls = [sl for sl in sampling_locations if sl["name"] in selected_sl_labels] +def _step_equipment_deployments(lookups: dict) -> None: + restore_snapshot(_WIZ, 2) + + # Sampling locations come from the step-1 snapshot (its widgets aren't + # rendered here, so Streamlit drops their live keys on the rerun an + # equipment selectbox triggers, which otherwise blanks this step). + selected_sls = _selected_sls(lookups) equipment = lookups.get("equipment", []) eq_labels = [e["identifier"] for e in equipment] @@ -269,7 +315,7 @@ def _step_equipment_deployments(lookups: dict) -> None: ) for sl in selected_sls: st.selectbox( - f"Equipment at **{sl['name']}**", + f"Equipment at **{sl['name']}** ({sl['site_name']})", ["(none)"] + eq_labels, key=f"{_WIZ}_s2_sl_{sl['id']}_eq", ) @@ -285,13 +331,16 @@ def _step_equipment_deployments(lookups: dict) -> None: def _step_review(lookups: dict) -> None: + # Re-inject earlier steps' snapshots: their widgets aren't rendered here, so + # the live keys were dropped on the way to this step. + for s in range(3): + restore_snapshot(_WIZ, s) + name = st.session_state.get(f"{_WIZ}_s0_name", "") kind_label = st.session_state.get(f"{_WIZ}_s0_kind", "") start = st.session_state.get(f"{_WIZ}_s0_start_date") end = st.session_state.get(f"{_WIZ}_s0_end_date") description = st.session_state.get(f"{_WIZ}_s0_description", "") - selected_site = st.session_state.get(f"{_WIZ}_s1_site", "") - selected_sl_labels: list[str] = st.session_state.get(f"{_WIZ}_s1_sl_selected") or [] st.markdown("### Campaign") st.write(f"**Name:** {name}") @@ -309,23 +358,16 @@ def _step_review(lookups: dict) -> None: ln = st.session_state.get(f"{_WIZ}_s0_person_last_name", "") st.write(f"**Responsible person (new):** {fn} {ln}".strip()) - st.markdown("### Site") - st.write(f"**Site:** {selected_site}") - if selected_sl_labels: - st.write(f"**Sampling locations:** {', '.join(selected_sl_labels)}") + selected_sls = _selected_sls(lookups) + site_names = sorted({sl["site_name"] for sl in selected_sls}) - # Show deployments - sites = lookups.get("sites", []) - site_record = next((s for s in sites if s["name"] == selected_site), None) - site_id = site_record["site_id"] if site_record else None - sampling_locations: list[dict] = [] - if site_id: - try: - sampling_locations = list_site_sampling_locations(site_id) - except APIError: - pass + st.markdown("### Sites & Sampling Locations") + if site_names: + st.write(f"**Sites:** {', '.join(site_names)}") + for sl in selected_sls: + st.write(f"- {sl['name']} ({sl['site_name']})") - selected_sls = [sl for sl in sampling_locations if sl["name"] in selected_sl_labels] + # Show deployments equipment = lookups.get("equipment", []) deployments = [] for sl in selected_sls: @@ -337,7 +379,7 @@ def _step_review(lookups: dict) -> None: if deployments: st.markdown("### Equipment Deployments") for sl, eq in deployments: - st.write(f"- **{eq['identifier']}** at {sl['name']}") + st.write(f"- **{eq['identifier']}** at {sl['name']} ({sl['site_name']})") def on_next() -> list[str]: created, errors = _execute_creates(lookups) @@ -383,7 +425,6 @@ def _execute_creates(lookups: dict) -> tuple[list[dict], list[str]]: for k in lookups["campaign_kinds"] ] person_opts: list[dict] = lookups.get("persons", []) - sites = lookups.get("sites", []) equipment = lookups.get("equipment", []) eq_opts = [{"id": e["equipment_id"], "label": e["identifier"]} for e in equipment] @@ -439,34 +480,24 @@ def _execute_creates(lookups: dict) -> tuple[list[dict], list[str]]: errors.append(f"Campaign creation failed: {e.message}") return created, errors - # 3. Create deployments - selected_site = st.session_state.get(f"{_WIZ}_s1_site") - site_record = next((s for s in sites if s["name"] == selected_site), None) - site_id = site_record["site_id"] if site_record else None - selected_sl_labels: list[str] = st.session_state.get(f"{_WIZ}_s1_sl_selected") or [] - - if site_id and selected_sl_labels: - try: - sampling_locations = list_site_sampling_locations(site_id) - except APIError: - sampling_locations = [] - - selected_sls = [sl for sl in sampling_locations if sl["name"] in selected_sl_labels] - for sl in selected_sls: - eq_label = st.session_state.get(f"{_WIZ}_s2_sl_{sl['id']}_eq") or "(none)" - eq_id = resolve_id(eq_label, eq_opts) if eq_label != "(none)" else None - if eq_id is not None: - try: - create_campaign_deployment( - campaign_id, - { - "equipment_id": eq_id, - "sampling_point_id": sl["id"], - }, - ) - created.append({"label": f"Deployment at {sl['name']}", "detail": eq_label}) - except APIError as e: - errors.append(f"Deployment at '{sl['name']}': {e.message}") + # 3. Create deployments (across every site block's selected locations) + for sl in _selected_sls(lookups): + eq_label = st.session_state.get(f"{_WIZ}_s2_sl_{sl['id']}_eq") or "(none)" + eq_id = resolve_id(eq_label, eq_opts) if eq_label != "(none)" else None + if eq_id is not None: + try: + create_campaign_deployment( + campaign_id, + { + "equipment_id": eq_id, + "sampling_point_id": sl["id"], + }, + ) + created.append( + {"label": f"Deployment at {sl['name']} ({sl['site_name']})", "detail": eq_label} + ) + except APIError as e: + errors.append(f"Deployment at '{sl['name']}': {e.message}") return created, errors diff --git a/app/pages/campaigns.py b/app/pages/campaigns.py index 9be9fce..481af38 100644 --- a/app/pages/campaigns.py +++ b/app/pages/campaigns.py @@ -156,8 +156,6 @@ def add_deployment_dialog( "name": {"label": "Name", "help": "Human-readable name for the campaign"}, "campaign_kind_id": {"options": type_options, "label": "Campaign Kind", "help": "Kind of campaign (Experiment, Operations, Commissioning)"}, - "site_id": {"options": site_options, "label": "Site", - "help": "Site where the campaign is conducted"}, "description": {"label": "Description", "help": "Objectives and scope"}, "start_date": {"label": "Start Date", "help": "Date the campaign began"}, "end_date": {"label": "End Date", "help": "Date the campaign ended; blank if ongoing"}, @@ -189,7 +187,9 @@ def _campaign_fields() -> list[dict]: # Filter campaigns if site_id_filter is not None: - filtered_campaigns = [c for c in campaigns if c.get("site_id") == site_id_filter] + filtered_campaigns = [ + c for c in campaigns if site_id_filter in (c.get("site_ids") or []) + ] else: filtered_campaigns = campaigns diff --git a/app/pages/data_health.py b/app/pages/data_health.py new file mode 100644 index 0000000..9c8da3d --- /dev/null +++ b/app/pages/data_health.py @@ -0,0 +1,64 @@ +"""Data Health — broken-link reports (consistency audit F5, F11). + +Surfaces the reconciling views added in schema 2.3.0: + - Unlinked channels (F5): ingested channels carrying data but never wired to + equipment, so their observations resolve to no equipment / no location. + - Live references to deactivated parents (F11): active wiring rows still + pointing at a SignalInterface/SignalInterfacePort that was soft-deleted. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_project_root = str(Path(__file__).resolve().parent.parent.parent) +if _project_root not in sys.path: + sys.path.insert(0, _project_root) + +import pandas as pd +import streamlit as st + +from app.api_client import ( + APIError, + get_inactive_parent_references, + get_unlinked_channels, +) + +st.title("Data Health") +st.caption( + "Reconciling reports for broken links in the equipment/wiring model. " + "An empty section means that check is clean." +) + +try: + with st.spinner("Loading…"): + unlinked = get_unlinked_channels() + inactive_refs = get_inactive_parent_references() +except APIError as e: + st.error(f"Could not load data-health reports: {e.message}") + st.stop() + +# --- F5: channels that need wiring ----------------------------------------- +st.subheader("Channels needing wiring") +if unlinked: + st.warning( + f"{len(unlinked)} channel(s) carry observations but have no active " + "equipment wiring — their data resolves to no equipment and no sampling " + "point. Wire them via **Move a sensor** / the field-system wizard." + ) + st.dataframe(pd.DataFrame(unlinked), use_container_width=True) +else: + st.success("All channels with data are wired to equipment.") + +# --- F11: live references to deactivated parents --------------------------- +st.subheader("Live references to deactivated interfaces/ports") +if inactive_refs: + st.warning( + f"{len(inactive_refs)} active wiring row(s) still point at a " + "deactivated SignalInterface/SignalInterfacePort. They keep resolving " + "as if active — rewire the equipment or reactivate the parent." + ) + st.dataframe(pd.DataFrame(inactive_refs), use_container_width=True) +else: + st.success("No active wiring points at a deactivated interface or port.") diff --git a/app/pages/event_kinds.py b/app/pages/event_kinds.py new file mode 100644 index 0000000..e73ed4a --- /dev/null +++ b/app/pages/event_kinds.py @@ -0,0 +1,33 @@ +"""Event Kinds — vocabulary admin page (generalised EventKind, PRD-2 S4).""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_project_root = str(Path(__file__).resolve().parent.parent.parent) +if _project_root not in sys.path: + sys.path.insert(0, _project_root) + +from app.api_client import ( + create_event_kind, + delete_event_kind, + list_event_kinds, + update_event_kind, +) +from app.auth import require_auth +from app.components.form_specs import get_form_fields +from app.components.generic_crud import render_crud_page + +require_auth() + +render_crud_page( + title="Event Kinds", + pk_field="event_kind_id", + form_fields=get_form_fields("event_kind"), + list_fn=list_event_kinds, + create_fn=create_event_kind, + update_fn=update_event_kind, + delete_fn=delete_event_kind, + label_field="name", +) diff --git a/app/pages/events.py b/app/pages/events.py new file mode 100644 index 0000000..89db196 --- /dev/null +++ b/app/pages/events.py @@ -0,0 +1,168 @@ +"""Events — generalised Event CRUD page (PRD-2 S4). + +Displays all Events with list/create/delete. No inline edit form needed for +MVP — create + delete covers the acceptance criteria in issue #38. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_project_root = str(Path(__file__).resolve().parent.parent.parent) +if _project_root not in sys.path: + sys.path.insert(0, _project_root) + +import streamlit as st +import pandas as pd + +from app.api_client import ( + APIError, + create_event, + delete_event, + list_event_kinds_lookup, + list_events, +) +from app.auth import require_auth + +require_auth() + +st.title("Events") + +# --------------------------------------------------------------------------- +# Reference data +# --------------------------------------------------------------------------- + +_ARC_TARGETS = [ + "channel_id", + "equipment_id", + "signal_interface_id", + "data_acquisition_system_id", + "sampling_point_id", + "process_unit_id", + "site_id", + "campaign_id", +] + +try: + event_kinds = list_event_kinds_lookup() +except APIError as e: + st.error(f"Cannot load event kinds: {e.message}") + event_kinds = [] + +event_kind_options = {ek["name"]: ek["event_kind_id"] for ek in event_kinds} + +# --------------------------------------------------------------------------- +# Load events +# --------------------------------------------------------------------------- + +try: + events = list_events() +except APIError as e: + st.error(f"Cannot load events: {e.message}") + events = [] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _target_summary(row: dict) -> str: + """Return '=' for the single non-null arc FK, or '—'.""" + for field in _ARC_TARGETS: + val = row.get(field) + if val is not None: + return f"{field}={val}" + return "—" + + +# --------------------------------------------------------------------------- +# Create form (inside a dialog / expander) +# --------------------------------------------------------------------------- + +@st.dialog("Create Event") +def _create_dialog() -> None: + with st.form("create_event_form"): + kind_name = st.selectbox( + "Event kind *", + options=list(event_kind_options.keys()), + index=0 if event_kind_options else None, + ) + start_dt = st.text_input("Start datetime * (YYYY-MM-DD HH:MM:SS)") + end_dt = st.text_input("End datetime (optional, YYYY-MM-DD HH:MM:SS)") + notes = st.text_area("Notes") + + st.markdown("**Target** — pick exactly one arc FK") + target_type = st.selectbox("Target type *", options=_ARC_TARGETS) + target_id = st.number_input("Target ID *", min_value=1, step=1) + + submitted = st.form_submit_button("Create", type="primary") + + if submitted: + if not kind_name or not start_dt or not target_id: + st.error("Event kind, start datetime, and target ID are required.") + return + if kind_name not in event_kind_options: + st.error("Please select a valid event kind.") + return + payload: dict = { + "event_kind_id": event_kind_options[kind_name], + "start_datetime": start_dt, + target_type: int(target_id), + } + if end_dt: + payload["end_datetime"] = end_dt + if notes: + payload["notes"] = notes + try: + create_event(payload) + st.success("Event created.") + st.rerun() + except APIError as e: + st.error(f"Failed to create event: {e.message}") + + +# --------------------------------------------------------------------------- +# Page layout +# --------------------------------------------------------------------------- + +col1, _rest = st.columns([1, 9]) +with col1: + if st.button("➕ New", type="primary"): + _create_dialog() + +# --------------------------------------------------------------------------- +# Table +# --------------------------------------------------------------------------- + +if events: + df = pd.DataFrame(events) + # Add a human-readable target column + df["target"] = df.apply(_target_summary, axis=1) + + display_cols = [c for c in ["event_id", "event_kind_name", "target", "start_datetime", "notes"] + if c in df.columns] + display_df = df[display_cols] + + selected = st.dataframe( + display_df, + use_container_width=True, + on_select="rerun", + selection_mode="single-row", + ) + + selected_rows = selected.get("selection", {}).get("rows", []) if selected else [] + selected_item = events[selected_rows[0]] if selected_rows else None + + if selected_item: + event_id = selected_item["event_id"] + st.markdown(f"**Selected event ID:** {event_id}") + if st.button("🗑️ Delete selected event", type="secondary"): + try: + delete_event(event_id) + st.success(f"Event {event_id} deleted.") + st.rerun() + except APIError as e: + st.error(f"Failed to delete event: {e.message}") +else: + st.info("No events found. Click '➕ New' to create one.") diff --git a/app/pages/explore.py b/app/pages/explore.py index 7c3fded..2fa6f12 100644 --- a/app/pages/explore.py +++ b/app/pages/explore.py @@ -16,8 +16,6 @@ from __future__ import annotations -import io -import csv import sys from datetime import date, datetime, timedelta, timezone from pathlib import Path @@ -28,7 +26,6 @@ import pandas as pd -import plotly.graph_objects as go import streamlit as st from app.api_client import ( @@ -46,8 +43,17 @@ list_deployment_traces_lookup, get_analysis_series_thumbnail, get_stream_story, + get_stream_pedigree, + get_channel_timeseries, + get_analysis_series_timeseries, + get_equipment_events, + get_channel_image, + get_analysis_series_image, + get_campaign, + list_campaigns_lookup, ) from app.components import entity_story as story +from app.components import explore_export as export # Provenance inspector lives in its own module (Phase 5 split). Re-exported here # so the panel is callable as before and tests can reach the helpers via @@ -81,7 +87,14 @@ _build_matrix_slice_line, ) from app.components.explore_scalar import _build_scalar_figure # noqa: F401 +from app.components.explore_echarts import ( # noqa: F401 + BRUSH_SELECTED_JS, + CLICK_SELECTED_JS, + build_scalar_echarts_option, + resolve_brush_selection, +) from app.components.explore_image import _image_viewer_dialog # noqa: F401 +from streamlit_echarts import st_echarts # --------------------------------------------------------------------------- @@ -125,6 +138,13 @@ def _init_state() -> None: "explore_active_series": [], # list[int] analysis_series_id (lab Traces) "explore_series_meta": {}, # series_id -> AnalysisSeries dict "explore_series_stats": {}, # series_id -> stats dict (cached) + # Multi-plot workspace: streams stay in the canonical active_* lists; this + # layer just groups them across one or more scalar plots. + "explore_plots": [1], # ordered list of plot ids + "explore_next_plot_id": 2, # next id handed out by "+ Add plot" + "explore_plot_of": {}, # "ch:" / "s:" -> plot id + "explore_target_plot": 1, # plot new streams are added to + "explore_campaign_filter_id": None, # page-level campaign scope (None=off) "explore_start": date.today() - timedelta(days=30), "explore_end": date.today(), "explore_mode": "viz", @@ -132,7 +152,6 @@ def _init_state() -> None: "explore_annotations": {}, # channel_id → list[dict] "explore_series_annotations": {}, # analysis_series_id → list[dict] "explore_eq_events": {}, # equipment_id → list[dict] - "explore_selected_points": {}, # Plotly selection result "explore_channel_stats": {}, # channel_id -> stats dict (cached) "explore_selected_images": [], # list[str] timestamps "explore_image_detail_ch": None, @@ -173,6 +192,107 @@ def _channel_label(ch: dict) -> str: return f"CH-{ch['channel_id']}: {eq} / {param} [{vtype}]" +# --------------------------------------------------------------------------- +# Multi-plot workspace helpers (scalar plots only) +# --------------------------------------------------------------------------- + + +def _stream_key(kind: str, sid: int) -> str: + """Assignment-map key for a stream. kind is "ch" (sensor) or "s" (lab).""" + return f"{kind}:{sid}" + + +def _plot_of(kind: str, sid: int) -> int: + """Plot a stream is assigned to (defaults to the first plot).""" + plots = st.session_state.explore_plots + default = plots[0] if plots else 1 + return st.session_state.explore_plot_of.get(_stream_key(kind, sid), default) + + +def _assign_stream_to_plot(kind: str, sid: int, plot_id: int) -> None: + st.session_state.explore_plot_of[_stream_key(kind, sid)] = plot_id + + +def _add_plot() -> int: + """Append a new empty plot and make it the target for new streams.""" + pid = st.session_state.explore_next_plot_id + st.session_state.explore_plots.append(pid) + st.session_state.explore_next_plot_id = pid + 1 + st.session_state.explore_target_plot = pid + return pid + + +def _delete_plot(plot_id: int) -> None: + """Delete a plot. Its streams are not removed — they fall back to the first + remaining plot (each stream keeps its own ✕ for actual removal). Never + deletes the last plot.""" + plots = st.session_state.explore_plots + if plot_id not in plots or len(plots) <= 1: + return + plots.remove(plot_id) + fallback = plots[0] + for k, v in list(st.session_state.explore_plot_of.items()): + if v == plot_id: + st.session_state.explore_plot_of[k] = fallback + if st.session_state.explore_target_plot == plot_id: + st.session_state.explore_target_plot = fallback + + +def _render_plot_badges() -> None: + """Plot-management zone: one badge per plot (click to make it the target for + new streams; ✕ to delete it) plus an Add-plot control.""" + plots = st.session_state.explore_plots + target = st.session_state.explore_target_plot + st.caption("**Plots** — click a badge to send new streams there, ✕ to delete") + cols = st.columns(len(plots) + 1) + for i, pid in enumerate(plots): + is_target = pid == target + sel_col, del_col = cols[i].columns([3, 1]) + if sel_col.button( + f"{'🎯 ' if is_target else ''}Plot {pid}", + key=f"seltgt_{pid}", + type="primary" if is_target else "secondary", + help="Send newly added streams to this plot", + use_container_width=True, + ): + st.session_state.explore_target_plot = pid + st.rerun() + if del_col.button( + "✕", key=f"delplot_{pid}", disabled=len(plots) <= 1, + help=f"Delete Plot {pid}", + ): + _delete_plot(pid) + st.rerun() + if cols[-1].button("➕ Add plot", key="btn_add_plot", use_container_width=True): + _add_plot() + st.rerun() + + +def _streams_in_plot( + plot_id: int, active_channels: list[int], active_series: list[int] +) -> tuple[list[int], list[int]]: + chans = [c for c in active_channels if _plot_of("ch", c) == plot_id] + sers = [s for s in active_series if _plot_of("s", s) == plot_id] + return chans, sers + + +def _plot_move_control(col, kind: str, sid: int) -> None: + """Per-chip selectbox to move a stream to another plot (only when >1 plot).""" + plots = st.session_state.explore_plots + if len(plots) <= 1: + return + current = _plot_of(kind, sid) + labels = {f"Plot {p}": p for p in plots} + cur_label = next((l for l, v in labels.items() if v == current), list(labels)[0]) + sel = col.selectbox( + "Plot", list(labels), index=list(labels).index(cur_label), + key=f"move_{kind}_{sid}", label_visibility="collapsed", + ) + if labels[sel] != current: + _assign_stream_to_plot(kind, sid, labels[sel]) + st.rerun() + + # --------------------------------------------------------------------------- # Chart builders # --------------------------------------------------------------------------- @@ -352,6 +472,7 @@ def _equipment_event_dialog( end_time: str | None, equipment_options: list[dict], event_type_options: list[dict], + default_equipment_id: int | None = None, ) -> None: eq_map = { e.get("identifier", str(e["equipment_id"])): e["equipment_id"] @@ -359,7 +480,13 @@ def _equipment_event_dialog( } et_map = {et["event_type_name"]: et["event_type_id"] for et in event_type_options} - sel_eq = st.selectbox("Equipment *", list(eq_map.keys())) + eq_labels = list(eq_map.keys()) + eq_index = 0 + if default_equipment_id is not None: + eq_ids = list(eq_map.values()) + if default_equipment_id in eq_ids: + eq_index = eq_ids.index(default_equipment_id) + sel_eq = st.selectbox("Equipment *", eq_labels, index=eq_index) sel_et = st.selectbox("Event type *", list(et_map.keys())) col1, col2 = st.columns(2) @@ -390,74 +517,6 @@ def _equipment_event_dialog( st.error(f"Failed: {e.message}") -# --------------------------------------------------------------------------- -# Download helpers -# --------------------------------------------------------------------------- - - -def _make_csv(rows: list[dict]) -> bytes: - if not rows: - return b"" - buf = io.StringIO() - writer = csv.DictWriter(buf, fieldnames=list(rows[0].keys())) - writer.writeheader() - writer.writerows(rows) - return buf.getvalue().encode() - - -def _flat_scalar_rows(channel_ids: list[int], channel_meta: dict) -> list[dict]: - out = [] - for ch_id in channel_ids: - data = _load_timeseries(ch_id) - if data is None: - continue - meta = channel_meta.get(ch_id, {}) - label = f"CH-{ch_id} {meta.get('equipment_identifier', '')} {meta.get('parameter_name', '')}".strip() - unit = data.get("unit", "") - param = data.get("parameter", "") - for row in data.get("data", []): - out.append( - { - "channel_id": ch_id, - "channel_label": label, - "timestamp": row.get("timestamp"), - "value": row.get("value"), - "quality_code": row.get("quality_code"), - "parameter": param, - "unit": unit, - } - ) - return out - - -def _flat_series_scalar_rows(series_ids: list[int], series_meta: dict) -> list[dict]: - out = [] - for s_id in series_ids: - data = _load_series_timeseries(s_id) - if data is None: - continue - meta = series_meta.get(s_id, {}) - label = ( - f"LAB-{s_id} {meta.get('parameter_name', '')} " - f"@ {meta.get('sampling_point_label', '')}" - ).strip() - unit = data.get("unit", "") - param = data.get("parameter", "") - for row in data.get("data", []): - out.append( - { - "channel_id": f"LAB-{s_id}", - "channel_label": label, - "timestamp": row.get("timestamp"), - "value": row.get("value"), - "quality_code": row.get("quality_code"), - "parameter": param, - "unit": unit, - } - ) - return out - - # --------------------------------------------------------------------------- # Top bar (title + time range + mode toggle) # --------------------------------------------------------------------------- @@ -556,21 +615,23 @@ def _to_date(ts) -> date | None: _invalidate_data_cache() st.rerun() - def _on_range_change() -> None: - _invalidate_data_cache() - st.rerun() - + # On a real range change we must drop annotation/event caches (those are + # keyed by id, not range); the timeseries cache is range-keyed so it + # refreshes on its own. The callback ONLY clears the cache — it must not + # call st.rerun() (Streamlit reruns automatically after the widget + # change, and a rerun *inside* a callback races the widget commit and is + # what made the date snap back to its default). with from_col: st.date_input( "From", key="explore_start", - on_change=_on_range_change, + on_change=_invalidate_data_cache, ) with to_col: st.date_input( "To", key="explore_end", - on_change=_on_range_change, + on_change=_invalidate_data_cache, ) @@ -731,8 +792,11 @@ def _add_channel_to_plot(node: dict, *, rerun: bool = True) -> bool: return False active.append(ch_id) st.session_state.explore_channel_meta[ch_id] = node + _assign_stream_to_plot("ch", ch_id, st.session_state.explore_target_plot) _fetch_channel_stats(ch_id, node) - _invalidate_data_cache() + # No cache wipe: the data cache is keyed by (channel, start, end), so adding + # a stream can't stale the others. The new stream loads lazily on render; + # already-plotted streams stay cached (no full reload, no slow refetch). if rerun: st.rerun() return True @@ -747,8 +811,10 @@ def _add_series_to_plot(node: dict, *, rerun: bool = True) -> bool: return False active.append(s_id) st.session_state.explore_series_meta[s_id] = node + _assign_stream_to_plot("s", s_id, st.session_state.explore_target_plot) _fetch_series_stats(s_id) - _invalidate_data_cache() + # No cache wipe — see _add_channel_to_plot. The cache is range-keyed, so the + # new series loads lazily while already-plotted streams stay cached. if rerun: st.rerun() return True @@ -917,11 +983,14 @@ def _render_active_chips(channel_meta: dict[int, dict]) -> None: return to_remove: list[int] = [] + multi = len(st.session_state.explore_plots) > 1 - cols = st.columns([5, 2, 2, 1, 1]) + cols = st.columns([4, 2, 2, 2, 1, 1]) cols[0].caption("**Stream**") cols[1].caption("**First value**") cols[2].caption("**Last value**") + if multi: + cols[3].caption("**Plot**") for ch_id in active: meta = channel_meta.get(ch_id, {}) @@ -934,10 +1003,13 @@ def _render_active_chips(channel_meta: dict[int, dict]) -> None: max_str = str(max_ts)[:10] if max_ts else "—" with st.container(border=True): - name_col, min_col, max_col, insp_col, rm_col = st.columns([5, 2, 2, 1, 1]) + name_col, min_col, max_col, plot_col, insp_col, rm_col = st.columns( + [4, 2, 2, 2, 1, 1] + ) name_col.markdown(label) min_col.markdown(min_str) max_col.markdown(max_str) + _plot_move_control(plot_col, "ch", ch_id) if insp_col.button("🔬", key=f"insp_{ch_id}", help="Inspect provenance"): _inspect_stream("channel", ch_id) if rm_col.button("✕", key=f"rm_{ch_id}", help="Remove stream"): @@ -947,6 +1019,7 @@ def _render_active_chips(channel_meta: dict[int, dict]) -> None: st.session_state.explore_active_channels.remove(ch_id) st.session_state.explore_channel_meta.pop(ch_id, None) st.session_state.explore_channel_stats.pop(ch_id, None) + st.session_state.explore_plot_of.pop(_stream_key("ch", ch_id), None) _invalidate_data_cache() st.rerun() @@ -970,10 +1043,13 @@ def _render_series_chips(series_meta: dict[int, dict]) -> None: max_str = str(max_ts)[:10] if max_ts else "—" with st.container(border=True): - name_col, min_col, max_col, insp_col, rm_col = st.columns([5, 2, 2, 1, 1]) + name_col, min_col, max_col, plot_col, insp_col, rm_col = st.columns( + [4, 2, 2, 2, 1, 1] + ) name_col.markdown(label) min_col.markdown(min_str) max_col.markdown(max_str) + _plot_move_control(plot_col, "s", s_id) if insp_col.button("🔬", key=f"s_insp_{s_id}", help="Inspect provenance"): _inspect_stream("series", s_id) if rm_col.button("✕", key=f"s_rm_{s_id}", help="Remove series"): @@ -983,6 +1059,7 @@ def _render_series_chips(series_meta: dict[int, dict]) -> None: st.session_state.explore_active_series.remove(s_id) st.session_state.explore_series_meta.pop(s_id, None) st.session_state.explore_series_stats.pop(s_id, None) + st.session_state.explore_plot_of.pop(_stream_key("s", s_id), None) _invalidate_data_cache() st.rerun() @@ -1066,7 +1143,10 @@ def _render_scalar_view( event_types: list[dict], active_series: list[int] | None = None, series_meta: dict[int, dict] | None = None, + suffix: str = "", ) -> None: + # ``suffix`` namespaces every widget key so this view can be rendered once + # per plot (multi-plot workspace) without colliding Streamlit keys. active_series = active_series or [] series_meta = series_meta or {} scalar_channels = [ @@ -1092,138 +1172,123 @@ def _render_scalar_view( else: st.caption("Extraction mode — full raw data displayed.") - fig, overlay_rows = _build_scalar_figure( + option, series_index_map, overlay_rows = build_scalar_echarts_option( scalar_channels, channel_meta, mode, scalar_series, series_meta ) - selection = st.plotly_chart( - fig, - use_container_width=True, - on_select="rerun", - selection_mode=["points", "box", "lasso"], - key="scalar_chart", + # ECharts canvas chart: native dataZoom (slider + scroll/pinch) for zoom. + # Selection has two paths that both return the same (seriesIndex, dataIndex) + # shape: click a marker (single point — reliable + discoverable) or use the + # toolbox brush (rect / polygon / lineX) for multi-select. resolve_brush_selection + # maps either back to stream/observation identity. + brush_payload = st_echarts( + options=option, + events={"click": CLICK_SELECTED_JS, "brushSelected": BRUSH_SELECTED_JS}, + height="480px", + key=f"scalar_chart{suffix}", ) - st.session_state.explore_selected_points = selection - - # Split selected points into sensor vs lab using the customdata type tag. - # customdata format: ["sensor", ch_id, obs_id] or ["lab", s_id, obs_id] - selected = selection.get("selection", {}) if selection else {} - selected_pts = selected.get("points", []) - - def _cd(pt: dict) -> list: - return pt.get("customdata") or [] - - sensor_pts = [p for p in selected_pts if _cd(p) and _cd(p)[0] == "sensor"] - lab_pts = [p for p in selected_pts if _cd(p) and _cd(p)[0] == "lab"] - - if sensor_pts or lab_pts: - st.markdown( - f"**{len(selected_pts)} points selected** across " - f"{len({p.get('curve_number') for p in selected_pts})} streams." + sel = resolve_brush_selection(brush_payload, series_index_map) + sensor_pts = sel["sensor_pts"] + lab_pts = sel["lab_pts"] + selected_pts = sensor_pts + lab_pts + + # Identities derived from the live selection — what annotation / events target. + sensor_sel_ids = sorted({p["id"] for p in sensor_pts}) + lab_series_ids: list[int] = list(dict.fromkeys(p["id"] for p in lab_pts)) + sel_equipment: dict[int, str] = {} + for ch_id in sensor_sel_ids: + m = channel_meta.get(ch_id, {}) + eqid = m.get("equipment_id") + if eqid is not None: + sel_equipment[eqid] = m.get("equipment_identifier") or f"EQ-{eqid}" + all_sel_times = [p["x"] for p in selected_pts if p.get("x")] + + st.markdown("##### Annotate & flag") + if not selected_pts: + st.caption( + "Click a point — or use the brush tool (top-right of the chart) to " + "box / lasso-select several — to choose what gets annotated. " + "Annotations apply to the selected points, not the view range. " + "Scroll or drag the bottom slider to zoom." ) - if sensor_pts: - sel_times = [p.get("x") for p in sensor_pts if p.get("x")] - t_start_sel = min(sel_times) if sel_times else None - t_end_sel = max(sel_times) if sel_times else None - - # Single sensor point → pin to its exact observation - single_sensor_obs_id: int | None = None - single_sensor_val: float | None = None - if len(sensor_pts) == 1: - cd = _cd(sensor_pts[0]) - single_sensor_obs_id = cd[2] if len(cd) > 2 else None - single_sensor_val = sensor_pts[0].get("y") - - col1, col2 = st.columns(2) - with col1: - btn_label = "Create Annotation (point)" if single_sensor_obs_id else "Create Annotation" - if st.button(btn_label, type="primary", key="btn_sensor_ann"): + obs_col, eq_col = st.columns(2) + + # --- Left: selected observations + annotate buttons --- + with obs_col: + st.caption(f"**Selected observations** · {len(selected_pts)}") + if selected_pts: + sel_rows = [ + {"Stream": f"CH-{p['id']}", "Kind": "sensor", + "Timestamp": p["x"], "Value": p["y"], "Observation": p.get("obs_id")} + for p in sensor_pts + ] + [ + {"Stream": f"LAB-{p['id']}", "Kind": "lab", + "Timestamp": p["x"], "Value": p["y"], "Observation": p.get("obs_id")} + for p in lab_pts + ] + st.dataframe( + pd.DataFrame(sel_rows), use_container_width=True, hide_index=True + ) + else: + st.caption("— none —") + + if sensor_pts: + sel_times = [p["x"] for p in sensor_pts if p.get("x")] + t_start_sel = min(sel_times) if sel_times else None + t_end_sel = max(sel_times) if sel_times else None + single_sensor_obs_id = sensor_pts[0].get("obs_id") if len(sensor_pts) == 1 else None + single_sensor_val = sensor_pts[0].get("y") if len(sensor_pts) == 1 else None + btn_label = "Annotate selected point" if single_sensor_obs_id else "Annotate selected points" + if st.button(btn_label, type="primary", key=f"btn_sensor_ann{suffix}"): _annotation_dialog( - channel_ids=scalar_channels, + channel_ids=sensor_sel_ids, start_time=str(t_start_sel) if t_start_sel else None, end_time=str(t_end_sel) if t_end_sel else None, annotation_types=annotation_types, observation_id=single_sensor_obs_id, point_value=single_sensor_val, ) - with col2: - if st.button("Tag Equipment Event", key="btn_eq_event"): - st.session_state._show_event_dialog = True - st.session_state._ann_start = str(t_start_sel) - st.session_state._ann_end = str(t_end_sel) - st.rerun() # dialog check runs before visualization area in script order - - if lab_pts: - lab_times = [p.get("x") for p in lab_pts if p.get("x")] - t_lab_start = min(lab_times) if lab_times else None - t_lab_end = max(lab_times) if lab_times else None - - # Group by series to support multi-series box selections - lab_series_ids: list[int] = list(dict.fromkeys( - _cd(p)[1] for p in lab_pts if len(_cd(p)) > 1 - )) - - # Single lab point → pin to exact replicate observation - single_lab_obs_id: int | None = None - single_lab_val: float | None = None - if len(lab_pts) == 1: - cd = _cd(lab_pts[0]) - single_lab_obs_id = cd[2] if len(cd) > 2 else None - single_lab_val = lab_pts[0].get("y") - - lab_btn_label = ( - "Create Lab Annotation (point)" if single_lab_obs_id - else "Create Lab Annotation (range)" - ) - if st.button(lab_btn_label, type="primary", key="btn_lab_ann_pt"): - _annotation_dialog( - channel_ids=[], - series_ids=lab_series_ids, - start_time=str(t_lab_start) if t_lab_start else None, - end_time=str(t_lab_end) if (t_lab_end and not single_lab_obs_id) else None, - annotation_types=annotation_types, - observation_id=single_lab_obs_id, - point_value=single_lab_val, - ) - if not selected_pts: - st.caption("Use box or lasso selection on the chart to select points.") - - # Sensor channel annotation — range over the current view window. - if scalar_channels and annotation_types: - sel_ch = st.selectbox( - "Annotate sensor channel (range)", - options=scalar_channels, - format_func=lambda ch: f"CH-{ch}: " - f"{channel_meta.get(ch, {}).get('parameter_name', '?')} " - f"({channel_meta.get(ch, {}).get('equipment_identifier', '?')})", - key="sensor_ann_chan_sel", - ) - if st.button("Create Annotation (view range)", key="btn_sensor_ann_range"): - _annotation_dialog( - channel_ids=[sel_ch], - start_time=_local_to_utc_iso(st.session_state.explore_start), - end_time=_local_to_utc_iso(st.session_state.explore_end, end_of_day=True), - annotation_types=annotation_types, + if lab_pts: + lab_times = [p["x"] for p in lab_pts if p.get("x")] + t_lab_start = min(lab_times) if lab_times else None + t_lab_end = max(lab_times) if lab_times else None + single_lab_obs_id = lab_pts[0].get("obs_id") if len(lab_pts) == 1 else None + single_lab_val = lab_pts[0].get("y") if len(lab_pts) == 1 else None + lab_btn_label = ( + "Annotate selected lab point" if single_lab_obs_id + else "Annotate selected lab points" ) + if st.button(lab_btn_label, type="primary", key=f"btn_lab_ann_pt{suffix}"): + _annotation_dialog( + channel_ids=[], + series_ids=lab_series_ids, + start_time=str(t_lab_start) if t_lab_start else None, + end_time=str(t_lab_end) if (t_lab_end and not single_lab_obs_id) else None, + annotation_types=annotation_types, + observation_id=single_lab_obs_id, + point_value=single_lab_val, + ) - # Lab AnalysisSeries annotation — range over the current view window. - if scalar_series and annotation_types: - sel_lab = st.selectbox( - "Annotate lab series (range)", - options=scalar_series, - format_func=lambda s: f"LAB-{s}: " - f"{series_meta.get(s, {}).get('name') or series_meta.get(s, {}).get('parameter_name', '?')}", - key="lab_ann_series_sel", - ) - if st.button("Create Lab Annotation (view range)", key="btn_lab_ann"): - _annotation_dialog( - channel_ids=[], - series_ids=[sel_lab], - start_time=_local_to_utc_iso(st.session_state.explore_start), - end_time=_local_to_utc_iso(st.session_state.explore_end, end_of_day=True), - annotation_types=annotation_types, - ) + # --- Right: selected equipment + equipment-event button (always available) --- + with eq_col: + st.caption(f"**Selected equipment** · {len(sel_equipment)}") + if sel_equipment: + for ident in sel_equipment.values(): + st.markdown(f"- {ident}") + else: + st.caption("— equipment of any selected sensor points appears here —") + # Equipment events are equipment + time based (not tied to a point), so + # this is always available; the selected span pre-fills the dialog. + if st.button("Tag equipment event", key=f"btn_eq_event{suffix}"): + st.session_state._show_event_dialog = True + st.session_state._ann_start = str(min(all_sel_times)) if all_sel_times else None + st.session_state._ann_end = str(max(all_sel_times)) if all_sel_times else None + # Default the dialog to the selected sensor's equipment — otherwise it + # falls back to the first equipment in the full list and the event lands + # on the wrong equipment (never rendered on this channel's chart). + st.session_state._event_equipment_ids = list(sel_equipment.keys()) + st.rerun() # dialog check runs before visualization area in script order # Annotations & events summary table if overlay_rows: @@ -1244,18 +1309,8 @@ def _cd(pt: dict) -> list: ] st.dataframe(df_ov, use_container_width=True, hide_index=True) - # Download (sensor + lab) - st.divider() - all_rows = _flat_scalar_rows(scalar_channels, channel_meta) - all_rows += _flat_series_scalar_rows(scalar_series, series_meta) - if all_rows: - csv_bytes = _make_csv(all_rows) - st.download_button( - "Download CSV", - data=csv_bytes, - file_name="explore_scalar.csv", - mime="text/csv", - ) + # Bulk export of all active streams lives at the page level (see + # _render_export_section), not per value-type view. def _render_vector_view( @@ -1326,18 +1381,6 @@ def _render_vector_view( annotation_types=annotation_types, ) - st.divider() - df = pd.DataFrame(rows) - if data: - df["parameter"] = data.get("parameter", "") - df["unit"] = data.get("unit", "") - csv_bytes = df.to_csv(index=False).encode() - st.download_button( - "Download CSV", - data=csv_bytes, - file_name="explore_vector.csv", - mime="text/csv", - ) def _render_matrix_view( @@ -1402,17 +1445,6 @@ def _render_matrix_view( fig = _build_matrix_slice_line(data, "col", sel_col) st.plotly_chart(fig, use_container_width=True, key="matrix_col_chart") - st.divider() - if data: - df["parameter"] = data.get("parameter", "") - df["unit"] = data.get("unit", "") - csv_bytes = df.to_csv(index=False).encode() - st.download_button( - "Download CSV", - data=csv_bytes, - file_name="explore_matrix.csv", - mime="text/csv", - ) def _render_image_view( @@ -1525,17 +1557,6 @@ def _render_image_view( else: st.caption("Check image thumbnails above to select them for bulk actions.") - img_df = pd.DataFrame(rows) - if data: - img_df["parameter"] = data.get("parameter", "") - img_df["unit"] = data.get("unit", "") - csv_bytes = img_df.to_csv(index=False).encode() - st.download_button( - "Download image list CSV", - data=csv_bytes, - file_name="explore_images.csv", - mime="text/csv", - ) # --------------------------------------------------------------------------- @@ -1552,8 +1573,12 @@ def _render_visualization_area( active_series: list[int] | None = None, series_meta: dict[int, dict] | None = None, ) -> None: - """Show visualization tabs only for value types present across active Traces - (sensor channels + lab series). Sensor and lab overlay within each type.""" + """Render the visualization area. + + Scalar streams render in a multi-plot workspace — one ECharts chart per plot, + grouped by each stream's plot assignment (see _streams_in_plot). Vector / + matrix / image are single-stream pickers, so they render once over all active + streams (multi-plot adds nothing there).""" active_series = active_series or [] series_meta = series_meta or {} @@ -1564,24 +1589,40 @@ def _render_visualization_area( ) return - # Determine which value types are represented across both sources - types_present: list[int] = [] - for ch_id in active_channels: - vt = channel_meta.get(ch_id, {}).get("value_kind_id") - if vt is not None and vt not in types_present: - types_present.append(vt) - for s_id in active_series: - vt = series_meta.get(s_id, {}).get("value_kind_id") - if vt is not None and vt not in types_present: - types_present.append(vt) - # Preserve natural order scalar < vector < matrix < image - types_present.sort() + def _is_scalar(vt) -> bool: + return vt in (None, VALUE_TYPE_SCALAR) + + scalar_present = any( + _is_scalar(channel_meta.get(c, {}).get("value_kind_id")) for c in active_channels + ) or any( + _is_scalar(series_meta.get(s, {}).get("value_kind_id")) for s in active_series + ) + + plots = st.session_state.explore_plots + + # --- Scalar multi-plot workspace --- + if scalar_present or len(plots) > 1: + _render_plot_badges() + for plot_id in plots: + p_chans, p_sers = _streams_in_plot(plot_id, active_channels, active_series) + with st.container(border=True): + st.markdown(f"**Plot {plot_id}**") + _render_scalar_view( + p_chans, channel_meta, annotation_types, equipment, event_types, + p_sers, series_meta, suffix=f"_p{plot_id}", + ) + + # --- Non-scalar views (vector / matrix / image), once over all streams --- + non_scalar = [VALUE_TYPE_VECTOR, VALUE_TYPE_MATRIX, VALUE_TYPE_IMAGE] + types_present = [ + vt for vt in non_scalar + if any(channel_meta.get(c, {}).get("value_kind_id") == vt for c in active_channels) + or any(series_meta.get(s, {}).get("value_kind_id") == vt for s in active_series) + ] + if not types_present: + return render_map = { - VALUE_TYPE_SCALAR: lambda: _render_scalar_view( - active_channels, channel_meta, annotation_types, equipment, event_types, - active_series, series_meta, - ), VALUE_TYPE_VECTOR: lambda: _render_vector_view( active_channels, channel_meta, annotation_types, active_series, series_meta ), @@ -1594,18 +1635,10 @@ def _render_visualization_area( ), } - if len(types_present) == 0: - # Channel meta not fully loaded yet — fall back to trying all types - for fn in render_map.values(): - fn() - return - if len(types_present) == 1: - # Single type: no tabs needed render_map[types_present[0]]() return - # Multiple types: show only relevant tabs tab_names = [VALUE_TYPE_NAMES[vt] for vt in types_present] tabs = st.tabs(tab_names) for tab, vt in zip(tabs, types_present): @@ -1627,12 +1660,264 @@ def _render_sidebar_minimal() -> None: ) +# --------------------------------------------------------------------------- +# Bulk export: zip of per-stream CSV (UTC) + pedigree YAML +# --------------------------------------------------------------------------- + + +def _raw_annotations(path: str, start_iso: str, end_iso: str) -> list[dict]: + """List a stream's annotations over [start,end] WITHOUT the display-time + local conversion the cached loaders apply — the export keeps UTC throughout.""" + import httpx + from app.api_client import _get_client, _raise_for_status + + try: + with _get_client() as client: + r = client.get(path, params={"from": start_iso, "to": end_iso}) + _raise_for_status(r) + except (httpx.ConnectError, APIError): + return [] + return r.json().get("annotations", []) + + +def _stream_export_filename(prefix: str, sid: int, meta: dict) -> str: + parameter = meta.get("parameter_name") or "data" + loc = meta.get("sampling_point_label") or meta.get("equipment_identifier") or "" + base = f"{prefix}-{sid}_{parameter}" + return f"{base}_{loc}" if loc else base + + +def _stream_images(loader, stream_id: int, data: dict | None) -> dict: + """Fetch full-res image bytes per timestamp for an image stream.""" + images: dict = {} + if not data: + return images + for row in data.get("data", []): + ts = row.get("timestamp") + if not ts: + continue + try: + images[ts] = loader(stream_id, ts) + except APIError: + pass + return images + + +def _build_export_entries( + active_channels: list[int], + channel_meta: dict[int, dict], + active_series: list[int], + series_meta: dict[int, dict], +) -> list[dict]: + """Assemble one export-entry dict per active stream (raw UTC data + overlay + annotations/events + pedigree + images). Pure-builder input for + explore_export.build_export_zip.""" + start_iso = _local_to_utc_iso(st.session_state.explore_start) + end_iso = _local_to_utc_iso(st.session_state.explore_end, end_of_day=True) + entries: list[dict] = [] + + for ch_id in active_channels: + meta = channel_meta.get(ch_id, {}) + try: + data = get_channel_timeseries(ch_id, start=start_iso, end=end_iso) + except APIError: + continue + anns = [ + export.overlay_from_annotation(a) + for a in _raw_annotations(f"/timeseries/{ch_id}/annotations", start_iso, end_iso) + ] + events = [] + eq_id = meta.get("equipment_id") + if eq_id: + try: + events = [ + export.overlay_from_event(e) + for e in get_equipment_events(eq_id, from_dt=start_iso, to_dt=end_iso) + ] + except APIError: + events = [] + images = ( + _stream_images(get_channel_image, ch_id, data) + if meta.get("value_kind_id") == VALUE_TYPE_IMAGE else {} + ) + try: + pedigree = get_stream_pedigree(ch_id, start=start_iso, end=end_iso) + except APIError: + pedigree = {} + entries.append({ + "filename": _stream_export_filename("CH", ch_id, meta), + "value_kind": meta.get("value_kind_id"), + "data": data or {}, + "annotations": anns, + "events": events, + "pedigree": pedigree, + "images": images, + }) + + for s_id in active_series: + meta = series_meta.get(s_id, {}) + try: + data = get_analysis_series_timeseries(s_id, start=start_iso, end=end_iso) + except APIError: + continue + anns = [ + export.overlay_from_annotation(a) + for a in _raw_annotations(f"/analysis-series/{s_id}/annotations", start_iso, end_iso) + ] + images = ( + _stream_images(get_analysis_series_image, s_id, data) + if meta.get("value_kind_id") == VALUE_TYPE_IMAGE else {} + ) + try: + pedigree = get_stream_pedigree(s_id, start=start_iso, end=end_iso) + except APIError: + pedigree = {} + entries.append({ + "filename": _stream_export_filename("LAB", s_id, meta), + "value_kind": meta.get("value_kind_id"), + "data": data or {}, + "annotations": anns, + "events": [], # lab series have no equipment events + "pedigree": pedigree, + "images": images, + }) + + return entries + + +def _render_export_section( + active_channels: list[int], + channel_meta: dict[int, dict], + active_series: list[int], + series_meta: dict[int, dict], +) -> None: + """Two-step export: 'Generate export' builds the zip once into session_state, + then 'Download zip' serves it (avoids rebuilding on every rerun).""" + if not active_channels and not active_series: + return + st.divider() + st.subheader("⬇ Export data") + n = len(active_channels) + len(active_series) + st.caption( + f"Bundle all {n} active stream(s) as a zip: one CSV per stream " + "(UTC timestamps, plus annotation & equipment-event columns) and a " + "pedigree YAML for each." + ) + if st.button("Generate export", key="btn_generate_export"): + with st.spinner("Building export…"): + try: + quality_labels = { + q["quality_code_id"]: q.get("name") for q in list_quality_codes() + } + except APIError: + quality_labels = {} + entries = _build_export_entries( + active_channels, channel_meta, active_series, series_meta + ) + st.session_state.explore_export_zip = export.build_export_zip( + entries, quality_labels + ) + st.session_state.explore_export_count = len(entries) + + blob = st.session_state.get("explore_export_zip") + if blob: + st.download_button( + f"Download zip ({len(blob) // 1024} KB · " + f"{st.session_state.get('explore_export_count', 0)} stream(s))", + data=blob, + file_name="dateaubase_export.zip", + mime="application/zip", + key="btn_download_export", + ) + + +# --------------------------------------------------------------------------- +# Campaign filter: scope the whole explorer (plot + export) to one campaign +# --------------------------------------------------------------------------- + + +def _apply_campaign_window(campaign_id: int) -> None: + """Snap the active time range to a campaign's date span. Uses the pending-range + flag so the date-input widgets pick it up on the next run (they can't be set + after being drawn).""" + try: + campaign = get_campaign(campaign_id) + except APIError: + return + + def _to_date(value) -> date | None: + if not value: + return None + try: + return date.fromisoformat(str(value)[:10]) + except ValueError: + return None + + start = _to_date(campaign.get("start_date")) + end = _to_date(campaign.get("end_date")) or date.today() + if start: + st.session_state._tstrip_pending = (start, end) + + +def _scope_to_campaign( + deployment_traces: list[dict], + series_list: list[dict], + campaign_id: int | None, +) -> tuple[list[dict], list[dict]]: + """Restrict the pickable sensor traces + lab series to one campaign. With the + source lists scoped, the picker can only add that campaign's streams, so the + plot and the export stay within the campaign (its time window is snapped + separately).""" + if campaign_id is None: + return deployment_traces, series_list + return ( + [d for d in deployment_traces if d.get("campaign_id") == campaign_id], + [s for s in series_list if s.get("campaign_id") == campaign_id], + ) + + +def _render_campaign_filter(campaigns: list[dict]) -> None: + """Page-level campaign scope. Selecting a campaign snaps the time window to + its span and restricts the stream picker to that campaign — so the plot and + the export both cover only that campaign's data. '(all campaigns)' clears it.""" + opts: dict[str, int | None] = {"📂 All campaigns (no filter)": None} + for c in campaigns: + opts[c.get("name") or str(c.get("campaign_id"))] = c.get("campaign_id") + + current = st.session_state.explore_campaign_filter_id + labels = list(opts.keys()) + cur_label = next((l for l, v in opts.items() if v == current), labels[0]) + + sel = st.selectbox( + "Campaign filter", + labels, + index=labels.index(cur_label), + key="explore_campaign_filter_sel", + help="Scope the explorer (plot + download) to one campaign: snaps the time " + "range to the campaign span and limits the picker to its streams.", + ) + new_id = opts[sel] + if new_id != current: + st.session_state.explore_campaign_filter_id = new_id + if new_id is not None: + _apply_campaign_window(new_id) + _invalidate_data_cache() + st.rerun() + + if current is not None: + st.caption( + "Scoped to this campaign — the time range and the stream picker are " + "restricted, and exports cover only this span." + ) + + def _render_page_body( deployment_traces: list[dict], series_list: list[dict], equipment: list[dict], annotation_types: list[dict], event_types: list[dict], + campaigns: list[dict], ) -> None: """Render the main content area of the Explore page (picker, chips, time strip, and visualization). When a provenance trail is active, this is @@ -1642,6 +1927,9 @@ def _render_page_body( # --- Top bar: title + Viz/Extract toggle --- _render_top_bar() + # --- Page-level campaign filter (scopes plot + export) --- + _render_campaign_filter(campaigns) + st.divider() # --- Unified picker: sensor Deployment Traces + lab AnalysisSeries --- @@ -1689,11 +1977,13 @@ def _render_page_body( # --- Equipment event dialog (triggered from scalar view) --- if st.session_state._show_event_dialog: st.session_state._show_event_dialog = False + _sel_eq_ids = st.session_state.get("_event_equipment_ids") or [] _equipment_event_dialog( start_time=st.session_state._ann_start, end_time=st.session_state._ann_end, equipment_options=equipment, event_type_options=event_types, + default_equipment_id=_sel_eq_ids[0] if _sel_eq_ids else None, ) # --- Visualization area --- @@ -1702,6 +1992,9 @@ def _render_page_body( active_series, series_meta, ) + # --- Bulk export (all active streams → zip) --- + _render_export_section(active_channels, channel_meta, active_series, series_meta) + # --------------------------------------------------------------------------- # Main page @@ -1733,6 +2026,11 @@ def main() -> None: except APIError: series_list = [] + try: + campaigns = list_campaigns_lookup() + except APIError: + campaigns = [] + # Deployment traces filtered by the active time window from_dt = _local_to_utc_iso(st.session_state.explore_start) to_dt = _local_to_utc_iso(st.session_state.explore_end, end_of_day=True) @@ -1741,6 +2039,11 @@ def main() -> None: except APIError: deployment_traces = [] + # Page-level campaign scope: restrict the pickable streams to the campaign. + deployment_traces, series_list = _scope_to_campaign( + deployment_traces, series_list, st.session_state.explore_campaign_filter_id + ) + _render_sidebar_minimal() # --- Global page layout: provenance panel as a right-hand sidebar --- @@ -1748,13 +2051,15 @@ def main() -> None: body_col, prov_col = st.columns([7, 3]) with body_col: _render_page_body( - deployment_traces, series_list, equipment, annotation_types, event_types + deployment_traces, series_list, equipment, annotation_types, + event_types, campaigns, ) with prov_col: _render_provenance_panel(_add_node_to_plot, _inspect_stream) else: _render_page_body( - deployment_traces, series_list, equipment, annotation_types, event_types + deployment_traces, series_list, equipment, annotation_types, + event_types, campaigns, ) # Stream Story — additive narrative panel for the inspected stream. diff --git a/app/pages/maintenance_control_chart.py b/app/pages/maintenance_control_chart.py new file mode 100644 index 0000000..d9298ab --- /dev/null +++ b/app/pages/maintenance_control_chart.py @@ -0,0 +1,421 @@ +"""Maintenance Control Chart — PRD-2.5 S2. + +Plots a drift Channel's values over time with: +- Horizontal upper/lower limit lines (UI-only, not persisted) +- Out-of-limit points highlighted +- Quality-flagged points shown distinctly +- Source (raw) Channel values as a second series for context +""" + +from __future__ import annotations + +import sys +from datetime import date, timedelta +from pathlib import Path + +_project_root = str(Path(__file__).resolve().parent.parent.parent) +if _project_root not in sys.path: + sys.path.insert(0, _project_root) + +import pandas as pd +import streamlit as st + +from app.api_client import ( + APIError, + get_channel_timeseries, + get_event_maintenance_drift, + list_channels, + list_quality_codes, +) + + +# --------------------------------------------------------------------------- +# Session-state helpers +# --------------------------------------------------------------------------- + + +def _init_state() -> None: + defaults: dict = { + "mcc_drift_channel_id": None, + "mcc_source_channel_id": None, + "mcc_start": date.today() - timedelta(days=30), + "mcc_end": date.today(), + "mcc_upper_limit": 5.0, + "mcc_lower_limit": -5.0, + } + for k, v in defaults.items(): + if k not in st.session_state: + st.session_state[k] = v + + +# --------------------------------------------------------------------------- +# Data helpers +# --------------------------------------------------------------------------- + + +def _fetch_timeseries(channel_id: int, start: date, end: date) -> list[dict]: + """Fetch scalar timeseries rows for a channel. Returns [] on error.""" + start_iso = start.isoformat() + "T00:00:00Z" + end_iso = end.isoformat() + "T23:59:59Z" + try: + data = get_channel_timeseries(channel_id, start=start_iso, end=end_iso) + except APIError: + return [] + if not data: + return [] + return data.get("data", []) + + +def _build_dataframe(rows: list[dict]) -> pd.DataFrame: + """Convert timeseries rows to a DataFrame with timestamp, value, quality columns.""" + if not rows: + return pd.DataFrame(columns=["timestamp", "value", "quality_code_id"]) + df = pd.DataFrame(rows) + # Normalise expected columns + for col in ("timestamp", "value", "quality_code_id"): + if col not in df.columns: + df[col] = None + df["timestamp"] = pd.to_datetime(df["timestamp"], errors="coerce", utc=True) + df["value"] = pd.to_numeric(df["value"], errors="coerce") + df = df.dropna(subset=["timestamp", "value"]).sort_values("timestamp") + return df[["timestamp", "value", "quality_code_id"]] + + +# --------------------------------------------------------------------------- +# Chart builder +# --------------------------------------------------------------------------- + + +def _build_chart_option( + drift_df: pd.DataFrame, + source_df: pd.DataFrame, + upper_limit: float, + lower_limit: float, + quality_codes: dict[int, str], + drift_label: str = "Drift", + source_label: str = "Raw source", +) -> dict: + """Build an ECharts option dict for the control chart.""" + + def _row_to_point(row) -> dict: + ts = row["timestamp"] + # Convert to milliseconds epoch for ECharts + ts_ms = int(ts.timestamp() * 1000) + return {"value": [ts_ms, row["value"]]} + + # Separate drift rows into normal / out-of-limit / quality-flagged + normal_pts: list[dict] = [] + ool_pts: list[dict] = [] # out-of-limit + flagged_pts: list[dict] = [] # non-None quality_code_id + + for _, row in drift_df.iterrows(): + pt = _row_to_point(row) + qc = row.get("quality_code_id") + v = row["value"] + if qc is not None and str(qc) not in ("None", ""): + flagged_pts.append(pt) + elif v > upper_limit or v < lower_limit: + ool_pts.append(pt) + else: + normal_pts.append(pt) + + source_pts = [_row_to_point(r) for _, r in source_df.iterrows()] if not source_df.empty else [] + + series: list[dict] = [ + { + "name": drift_label, + "type": "line", + "data": normal_pts, + "showSymbol": len(normal_pts) <= 200, + "symbol": "circle", + "symbolSize": 5, + "lineStyle": {"color": "#1f77b4"}, + "itemStyle": {"color": "#1f77b4"}, + }, + { + "name": "Out of limit", + "type": "scatter", + "data": ool_pts, + "symbol": "triangle", + "symbolSize": 9, + "itemStyle": {"color": "#d62728"}, + }, + { + "name": "Quality-flagged", + "type": "scatter", + "data": flagged_pts, + "symbol": "diamond", + "symbolSize": 9, + "itemStyle": {"color": "#ff7f0e"}, + }, + ] + + if source_pts: + series.append({ + "name": source_label, + "type": "line", + "data": source_pts, + "showSymbol": False, + "lineStyle": {"color": "#aec7e8", "type": "dashed", "opacity": 0.6}, + "itemStyle": {"color": "#aec7e8"}, + }) + + # Limit line markers + mark_lines: list[dict] = [] + if upper_limit is not None: + mark_lines.append({"yAxis": upper_limit, "name": f"UCL ({upper_limit})", "lineStyle": {"color": "#d62728", "type": "dashed"}}) + if lower_limit is not None: + mark_lines.append({"yAxis": lower_limit, "name": f"LCL ({lower_limit})", "lineStyle": {"color": "#d62728", "type": "dashed"}}) + + # Attach mark lines to the first series + if mark_lines and series: + series[0]["markLine"] = { + "silent": True, + "data": [{"yAxis": ml["yAxis"]} for ml in mark_lines], + "lineStyle": {"color": "#d62728", "type": "dashed"}, + "label": { + "formatter": "{b}", + "position": "insideEndTop", + }, + } + + option = { + "tooltip": {"trigger": "axis"}, + "legend": {"data": [s["name"] for s in series]}, + "xAxis": { + "type": "time", + "axisLabel": {"formatter": "{yyyy}-{MM}-{dd}"}, + }, + "yAxis": {"type": "value"}, + "dataZoom": [ + {"type": "slider", "start": 0, "end": 100}, + {"type": "inside"}, + ], + "series": series, + } + return option + + +# --------------------------------------------------------------------------- +# Page renderers +# --------------------------------------------------------------------------- + + +def _render_channel_picker(all_channels: list[dict]) -> None: + """Sidebar: pick drift channel and (optional) source channel.""" + st.sidebar.header("Channel selection") + + channel_opts: dict[str, int | None] = {"— select —": None} + for ch in all_channels: + label = ( + f"CH-{ch['channel_id']}: " + f"{ch.get('parameter_name') or 'param-?'} " + f"[{ch.get('tag_name') or ch.get('identifier') or ''}]" + ) + channel_opts[label] = ch["channel_id"] + + labels = list(channel_opts.keys()) + + # Drift channel + current_drift = st.session_state.mcc_drift_channel_id + cur_drift_label = next((l for l, v in channel_opts.items() if v == current_drift), labels[0]) + sel_drift = st.sidebar.selectbox( + "Drift channel", + labels, + index=labels.index(cur_drift_label), + key="mcc_drift_sel", + help="Pick the maintenance-drift derived channel (MethodName='maintenance_drift').", + ) + st.session_state.mcc_drift_channel_id = channel_opts[sel_drift] + + # Source channel + current_source = st.session_state.mcc_source_channel_id + cur_source_label = next((l for l, v in channel_opts.items() if v == current_source), labels[0]) + sel_source = st.sidebar.selectbox( + "Source channel (optional)", + labels, + index=labels.index(cur_source_label), + key="mcc_source_sel", + help="Raw sensor channel whose drift is being monitored (shown as reference).", + ) + st.session_state.mcc_source_channel_id = channel_opts[sel_source] + + +def _render_controls() -> tuple[date, date, float, float]: + """Sidebar: time range and limit inputs. Returns (start, end, upper, lower).""" + st.sidebar.header("Time range") + start = st.sidebar.date_input("From", key="mcc_start") + end = st.sidebar.date_input("To", key="mcc_end") + + st.sidebar.header("Control limits") + upper = st.sidebar.number_input( + "Upper limit (UCL)", value=float(st.session_state.mcc_upper_limit), + step=0.1, format="%.2f", key="mcc_ucl", + ) + lower = st.sidebar.number_input( + "Lower limit (LCL)", value=float(st.session_state.mcc_lower_limit), + step=0.1, format="%.2f", key="mcc_lcl", + ) + return start, end, upper, lower + + +def _render_stats(drift_df: pd.DataFrame, upper: float, lower: float) -> None: + """Show a quick stat summary above the chart.""" + if drift_df.empty: + return + n_total = len(drift_df) + n_ool = int(((drift_df["value"] > upper) | (drift_df["value"] < lower)).sum()) + n_flagged = int(drift_df["quality_code_id"].notna().sum()) + + c1, c2, c3, c4 = st.columns(4) + c1.metric("Points", n_total) + c2.metric("Out of limit", n_ool, delta=None) + c3.metric("Quality-flagged", n_flagged) + c4.metric( + "In-limit %", + f"{100 * (n_total - n_ool) / n_total:.1f}%" if n_total else "—", + ) + + +def _render_out_of_limit_table(drift_df: pd.DataFrame, upper: float, lower: float) -> None: + """Show a table of out-of-limit rows.""" + ool = drift_df[(drift_df["value"] > upper) | (drift_df["value"] < lower)].copy() + if ool.empty: + st.caption("No out-of-limit points in this range.") + return + ool["timestamp"] = ool["timestamp"].dt.strftime("%Y-%m-%d %H:%M:%S") + ool = ool.rename(columns={"timestamp": "Timestamp", "value": "Value", "quality_code_id": "QC"}) + st.dataframe(ool, use_container_width=True, hide_index=True) + + +def main() -> None: + _init_state() + st.title("Maintenance Control Chart") + st.caption( + "Plot a drift channel over time with configurable upper/lower control limits. " + "Out-of-limit points are highlighted in red; quality-flagged points in orange." + ) + + # Load channel list + try: + channels_data = list_channels(page_size=500) + all_channels: list[dict] = channels_data.get("items", []) if channels_data else [] + except APIError as e: + st.error(f"Cannot load channels: {e.message}") + all_channels = [] + + _render_channel_picker(all_channels) + start, end, upper, lower = _render_controls() + + drift_id = st.session_state.mcc_drift_channel_id + source_id = st.session_state.mcc_source_channel_id + + if drift_id is None: + st.info("Select a drift channel in the sidebar to display the control chart.") + return + + # Fetch data + with st.spinner("Loading drift data…"): + drift_rows = _fetch_timeseries(drift_id, start, end) + drift_df = _build_dataframe(drift_rows) + + source_df = pd.DataFrame() + if source_id is not None and source_id != drift_id: + with st.spinner("Loading source data…"): + source_rows = _fetch_timeseries(source_id, start, end) + source_df = _build_dataframe(source_rows) + + if drift_df.empty: + st.warning("No data found for the selected drift channel and time range.") + return + + # Quality code labels for tooltip (best-effort) + try: + qc_list = list_quality_codes() + quality_codes = {q["quality_code_id"]: q.get("name", str(q["quality_code_id"])) for q in qc_list} + except APIError: + quality_codes = {} + + # Stats row + _render_stats(drift_df, upper, lower) + + # Chart + try: + from streamlit_echarts import st_echarts + + drift_meta = next((c for c in all_channels if c["channel_id"] == drift_id), {}) + source_meta = next((c for c in all_channels if c["channel_id"] == source_id), {}) + drift_label = ( + f"Drift — CH-{drift_id} {drift_meta.get('parameter_name') or ''}".strip() + ) + source_label = ( + f"Raw — CH-{source_id} {source_meta.get('parameter_name') or ''}".strip() + if source_id else "Raw source" + ) + + option = _build_chart_option(drift_df, source_df, upper, lower, quality_codes, drift_label, source_label) + st_echarts(options=option, height="480px", key="mcc_chart") + except ImportError: + # Fallback: plain st.line_chart when streamlit_echarts not available + chart_df = drift_df.set_index("timestamp")[["value"]].rename(columns={"value": drift_label}) + if not source_df.empty: + source_chart = source_df.set_index("timestamp")[["value"]].rename(columns={"value": source_label}) + chart_df = chart_df.join(source_chart, how="outer") + st.line_chart(chart_df) + + # Out-of-limit table + st.divider() + st.subheader("Out-of-limit points") + _render_out_of_limit_table(drift_df, upper, lower) + + st.divider() + _render_drift_readback() + + +def _render_drift_readback() -> None: + """PRD-4 S4: 'drift since last cleaning' for a maintenance Event. + + Enter a maintenance Event ID; shows the before/after readings derived from + the source stream around the event window, plus the % drift. + """ + st.subheader("Drift since last cleaning") + st.caption( + "Enter a maintenance Event ID to read back the before/after values " + "derived from the source stream around its window." + ) + event_id = st.number_input( + "Maintenance Event ID", min_value=0, value=0, step=1, key="mcc_event_id" + ) + if not event_id: + return + try: + rb = get_event_maintenance_drift(int(event_id)) + except APIError as e: + st.info(f"No drift read-back for Event {int(event_id)}: {e.message}") + return + + before = rb.get("before") + after = rb.get("after") + pct = rb.get("percent_diff") + c1, c2, c3 = st.columns(3) + c1.metric("Before (fouled)", f"{before['value']:.3g}" if before and before.get("value") is not None else "—") + c2.metric("After (clean)", f"{after['value']:.3g}" if after and after.get("value") is not None else "—") + c3.metric("Drift", f"{pct:+.1f}%" if pct is not None else "—") + st.caption( + f"Drift channel CH-{rb.get('drift_channel_id')} · source CH-{rb.get('source_channel_id')} · " + f"window {rb.get('window_start')} → {rb.get('window_end') or '(instantaneous)'}" + ) + if before is None or after is None: + st.warning("No source reading found on one side of the window — widen the data range.") + + +def _in_streamlit_run() -> bool: + try: + from streamlit.runtime.scriptrunner import get_script_run_ctx + return get_script_run_ctx() is not None + except Exception: + return True + + +if _in_streamlit_run(): + main() diff --git a/app/pages/mapper.py b/app/pages/mapper.py new file mode 100644 index 0000000..f86b83a --- /dev/null +++ b/app/pages/mapper.py @@ -0,0 +1,1866 @@ +"""Mapper Engine — S5: upload CSV/XLSX → pick sheet/header/range +→ tag column roles → save/load named config → preview → resolve entities (text → DB ID) +→ preview ingest → submit via the active profile's endpoint. + +PRD-3 S1: shell. PRD-3 S2: entity resolution. PRD-3 S3: lab end-to-end. +PRD-3 S4: save/reload named mapping config (PRD-5-compatible shape). +PRD-3 S5: Sensor-CSV profile — thin profile reusing the same engine, +mapping timestamp/tag/parameter/unit/value columns to /ingest/sensor. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +_project_root = str(Path(__file__).resolve().parent.parent.parent) +if _project_root not in sys.path: + sys.path.insert(0, _project_root) + +import io +from datetime import datetime, timezone + +import pandas as pd +import streamlit as st + +import app.api_client as api +from app.components.resolver import ( + TARGET_LEVELS, + EntityResolver, + guess_target_level, +) + +# --------------------------------------------------------------------------- +# Profile stub — role vocabulary (S1: hardcoded; S2+ will load from saved profile) +# --------------------------------------------------------------------------- + +LAB_ROLES = [ + "(ignore)", + "sample_datetime", + "sampling_location", + "replicate", + "parameter_value", +] + +# Long-format (tidy) lab profile (S6). One row = one measurement; parameter and +# unit come from cells (not the header). Resolves into the same shape as the +# wide profile so the preview/submit pipeline is reused unchanged. +LONG_LAB_ROLES = [ + "(ignore)", + "sample_datetime", + "sampling_location", + "replicate", + "parameter", + "unit", + "value", +] + +# Sensor-CSV profile (S5). Each row is one timestamped reading; the tag, +# parameter, and unit columns identify (and auto-create) the channel. +SENSOR_ROLES = [ + "(ignore)", + "timestamp", + "tag", + "parameter", + "unit", + "value", +] + +# Logbook "Général" profile (PRD-4 S1). One flat journal row → one Event. +# Date(+Heure) → start; Commentaires → notes (+ derived title) and the target +# search source; Conductor → performed-by person. Target is resolved to the +# smallest logical unit by the shared resolver. +LOGBOOK_ROLES = [ + "(ignore)", + "event_date", + "event_time", + "notes", + "conductor", +] + +# Per-equipment maintenance-sheet profile (PRD-4 S3). One maintenance Event per +# row; the target is the *whole sheet's* equipment/channel (picked once from the +# sheet name). Before/after probe readings are NOT stored — the PRD-2.5 drift +# Channel derives them from the stream; manual zero-checks go to notes. +MAINTENANCE_ROLES = [ + "(ignore)", + "event_date", + "start_time", + "end_time", + "notes", +] + +# Profile registry — maps a profile key to its role vocabulary. The engine is +# profile-agnostic; profiles only supply the role set (and their resolve/submit). +PROFILES = { + "Lab (wide)": LAB_ROLES, + "Lab (long)": LONG_LAB_ROLES, + "Sensor CSV": SENSOR_ROLES, + "Logbook (Général)": LOGBOOK_ROLES, + "Logbook (Maintenance)": MAINTENANCE_ROLES, +} + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _read_dataframe( + file_bytes: bytes, + filename: str, + sheet_name: str | int = 0, + header_row: int = 0, +) -> pd.DataFrame: + """Parse uploaded file into a DataFrame using the given sheet and header.""" + if filename.endswith(".xlsx"): + return pd.read_excel( + io.BytesIO(file_bytes), + sheet_name=sheet_name, + header=header_row, + ) + # CSV — sheet_name is ignored + return pd.read_csv(io.BytesIO(file_bytes), header=header_row) + + +def _parse_param_header(header: str) -> tuple[str, str]: + """Split 'COD (mg/L)' → ('COD', 'mg/L'). Returns (header, '') on no parens.""" + if " (" in header and header.endswith(")"): + param_part, unit_part = header.split(" (", 1) + return param_part.strip(), unit_part.rstrip(")").strip() + return header.strip(), "" + + +def _resolve_all( + resolver: EntityResolver, + role_map: dict[str, str], + df_data: pd.DataFrame, +) -> dict: + """Run full entity resolution for column headers and each row's sampling location. + + Returns a dict with: + - ``col_resolutions``: {col_name → {param, unit, resolved, error}} + - ``row_resolutions``: list of per-row dicts + - ``n_resolved``, ``n_unresolved`` counts + """ + param_value_cols = [c for c, r in role_map.items() if r == "parameter_value"] + datetime_cols = [c for c, r in role_map.items() if r == "sample_datetime"] + location_cols = [c for c, r in role_map.items() if r == "sampling_location"] + replicate_cols = [c for c, r in role_map.items() if r == "replicate"] + + # Resolve column-level entities (parameter + unit per header) + col_resolutions: dict[str, dict] = {} + for col in param_value_cols: + param_text, unit_text = _parse_param_header(col) + param = resolver.resolve_parameter(param_text) + unit = resolver.resolve_unit(unit_text) if unit_text else None + errors = [] + if param is None: + errors.append(f"parameter '{param_text}' not found") + if not unit_text: + errors.append("no unit in header — use 'Name (unit)' format") + elif unit is None: + errors.append(f"unit '{unit_text}' not found") + col_resolutions[col] = { + "param": param, + "unit": unit, + "resolved": param is not None and unit is not None, + "errors": errors, + } + + # Resolve row-level entities + row_resolutions = [] + n_resolved = 0 + n_unresolved = 0 + + for idx, row in df_data.iterrows(): + errors: list[str] = [] + + # sample_datetime + dt_val = None + if datetime_cols: + raw_dt = row[datetime_cols[0]] + try: + dt_val = pd.to_datetime(raw_dt) + if dt_val.tzinfo is None: + dt_val = dt_val.replace(tzinfo=timezone.utc) + except Exception: + errors.append(f"cannot parse datetime '{raw_dt}'") + + # sampling_location → sampling_point_id + sp = None + sp_text = "" + if location_cols: + sp_text = str(row[location_cols[0]]) + sp = resolver.resolve_sampling_point(sp_text) + if sp is None: + errors.append(f"sampling point '{sp_text}' not found") + + # replicate + replicate = 1 + if replicate_cols: + try: + replicate = int(row[replicate_cols[0]]) + except Exception: + pass # default to 1 silently + + # Per-column values + values = [] + col_errors: list[str] = [] + for col in param_value_cols: + cr = col_resolutions[col] + if not cr["resolved"]: + col_errors.append(f"column '{col}' unresolved") + continue + raw_val = row.get(col) + try: + float_val = float(raw_val) + except (TypeError, ValueError): + col_errors.append(f"value '{raw_val}' in column '{col}' is not numeric") + continue + values.append({ + "col": col, + "parameter_id": cr["param"]["parameter_id"], + "parameter_name": cr["param"]["name"], + "unit_id": cr["unit"]["unit_id"], + "unit_symbol": cr["unit"].get("symbol", cr["unit"].get("name", "")), + "value": float_val, + }) + + errors.extend(col_errors) + + row_ok = len(errors) == 0 and dt_val is not None and sp is not None and len(values) > 0 + + if row_ok: + n_resolved += 1 + else: + n_unresolved += 1 + + row_resolutions.append({ + "row_index": int(idx), + "datetime": dt_val, + "sampling_point": sp, + "sp_text": sp_text, + "replicate": replicate, + "values": values, + "errors": errors, + "ok": row_ok, + }) + + return { + "col_resolutions": col_resolutions, + "row_resolutions": row_resolutions, + "n_resolved": n_resolved, + "n_unresolved": n_unresolved, + } + + +def _resolve_long( + resolver: EntityResolver, + role_map: dict[str, str], + df_data: pd.DataFrame, +) -> dict: + """Resolve a long-format (tidy) lab table into the SAME shape as + :func:`_resolve_all`, so the wide-lab preview + submit pipeline is reused + unchanged. Each row is one measurement: ``parameter`` and ``unit`` come from + cells (not the header); ``sample_datetime`` / ``sampling_location`` are + per-row. Resolved (parameter, unit) pairs are registered as synthetic + "columns" keyed ``" ()"`` so ``col_resolutions`` matches the + wide shape consumed downstream. + """ + datetime_cols = [c for c, r in role_map.items() if r == "sample_datetime"] + location_cols = [c for c, r in role_map.items() if r == "sampling_location"] + replicate_cols = [c for c, r in role_map.items() if r == "replicate"] + parameter_cols = [c for c, r in role_map.items() if r == "parameter"] + unit_cols = [c for c, r in role_map.items() if r == "unit"] + value_cols = [c for c, r in role_map.items() if r == "value"] + + col_resolutions: dict[str, dict] = {} + row_resolutions = [] + n_resolved = 0 + n_unresolved = 0 + + for idx, row in df_data.iterrows(): + errors: list[str] = [] + + # sample_datetime + dt_val = None + if datetime_cols: + raw_dt = row[datetime_cols[0]] + try: + dt_val = pd.to_datetime(raw_dt) + if dt_val.tzinfo is None: + dt_val = dt_val.replace(tzinfo=timezone.utc) + except Exception: + errors.append(f"cannot parse datetime '{raw_dt}'") + else: + errors.append("no sample_datetime column tagged") + + # sampling_location → sampling_point_id + sp = None + sp_text = "" + if location_cols: + sp_text = str(row[location_cols[0]]) + sp = resolver.resolve_sampling_point(sp_text) + if sp is None: + errors.append(f"sampling point '{sp_text}' not found") + else: + errors.append("no sampling_location column tagged") + + # replicate + replicate = 1 + if replicate_cols: + try: + replicate = int(row[replicate_cols[0]]) + except Exception: + pass # default to 1 silently + + # parameter → parameter entity + param = None + param_text = "" + if parameter_cols: + param_text = str(row[parameter_cols[0]]).strip() + param = resolver.resolve_parameter(param_text) + if param is None: + errors.append(f"parameter '{param_text}' not found") + else: + errors.append("no parameter column tagged") + + # unit → unit entity + unit = None + unit_text = "" + if unit_cols: + unit_text = str(row[unit_cols[0]]).strip() + unit = resolver.resolve_unit(unit_text) + if unit is None: + errors.append(f"unit '{unit_text}' not found") + else: + errors.append("no unit column tagged") + + # value → numeric + value = None + if value_cols: + raw_val = row[value_cols[0]] + try: + value = float(raw_val) + except (TypeError, ValueError): + errors.append(f"value '{raw_val}' is not numeric") + else: + errors.append("no value column tagged") + + values = [] + if param is not None and unit is not None and value is not None: + unit_symbol = unit.get("symbol") or unit.get("name", "") + col_key = f"{param['name']} ({unit_symbol})" + col_resolutions.setdefault(col_key, { + "param": param, + "unit": unit, + "resolved": True, + "errors": [], + }) + values.append({ + "col": col_key, + "parameter_id": param["parameter_id"], + "parameter_name": param["name"], + "unit_id": unit["unit_id"], + "unit_symbol": unit_symbol, + "value": value, + }) + + row_ok = ( + len(errors) == 0 + and dt_val is not None + and sp is not None + and len(values) > 0 + ) + if row_ok: + n_resolved += 1 + else: + n_unresolved += 1 + + row_resolutions.append({ + "row_index": int(idx), + "datetime": dt_val, + "sampling_point": sp, + "sp_text": sp_text, + "replicate": replicate, + "values": values, + "errors": errors, + "ok": row_ok, + }) + + return { + "col_resolutions": col_resolutions, + "row_resolutions": row_resolutions, + "n_resolved": n_resolved, + "n_unresolved": n_unresolved, + } + + +def _resolve_sensor( + resolver: EntityResolver, + role_map: dict[str, str], + df_data: pd.DataFrame, +) -> dict: + """Resolve a Sensor-CSV table (thin profile, reuses the shared engine). + + Each row is one timestamped reading. The ``parameter`` and ``unit`` cells + resolve to existing DB entities; the ``tag`` cell is passed through to the + tagged sensor-ingest endpoint (channels are auto-created server-side). + + Returns the same shape as :func:`_resolve_all`: + ``row_resolutions`` (per-row dicts), ``n_resolved``, ``n_unresolved``. + """ + timestamp_cols = [c for c, r in role_map.items() if r == "timestamp"] + tag_cols = [c for c, r in role_map.items() if r == "tag"] + parameter_cols = [c for c, r in role_map.items() if r == "parameter"] + unit_cols = [c for c, r in role_map.items() if r == "unit"] + value_cols = [c for c, r in role_map.items() if r == "value"] + + row_resolutions = [] + n_resolved = 0 + n_unresolved = 0 + + for idx, row in df_data.iterrows(): + errors: list[str] = [] + + # timestamp + ts_val = None + if timestamp_cols: + raw_ts = row[timestamp_cols[0]] + try: + ts_val = pd.to_datetime(raw_ts) + if ts_val.tzinfo is None: + ts_val = ts_val.replace(tzinfo=timezone.utc) + except Exception: + errors.append(f"cannot parse timestamp '{raw_ts}'") + else: + errors.append("no timestamp column tagged") + + # tag — free text, passed through (resolved server-side against DAS tags) + tag = "" + if tag_cols: + tag = str(row[tag_cols[0]]).strip() + if not tag: + errors.append("missing tag") + + # parameter → parameter entity + param = None + param_text = "" + if parameter_cols: + param_text = str(row[parameter_cols[0]]).strip() + param = resolver.resolve_parameter(param_text) + if param is None: + errors.append(f"parameter '{param_text}' not found") + else: + errors.append("no parameter column tagged") + + # unit → unit entity + unit = None + unit_text = "" + if unit_cols: + unit_text = str(row[unit_cols[0]]).strip() + unit = resolver.resolve_unit(unit_text) + if unit is None: + errors.append(f"unit '{unit_text}' not found") + else: + errors.append("no unit column tagged") + + # value → numeric + value = None + if value_cols: + raw_val = row[value_cols[0]] + try: + value = float(raw_val) + except (TypeError, ValueError): + errors.append(f"value '{raw_val}' is not numeric") + else: + errors.append("no value column tagged") + + row_ok = len(errors) == 0 + + if row_ok: + n_resolved += 1 + else: + n_unresolved += 1 + + row_resolutions.append({ + "row_index": int(idx), + "timestamp": ts_val, + "tag": tag, + "parameter": param, + "param_text": param_text, + "unit": unit, + "unit_text": unit_text, + "value": value, + "errors": errors, + "ok": row_ok, + }) + + return { + "row_resolutions": row_resolutions, + "n_resolved": n_resolved, + "n_unresolved": n_unresolved, + } + + +def _build_sensor_payloads( + row_resolutions: list[dict], das_name: str = "" +) -> list[dict]: + """Group resolved sensor rows into tagged /ingest/sensor payloads. + + Rows sharing the same (tag, parameter, unit) form one channel; their + timestamped values are batched into a single payload. Only ``ok`` rows + are included — unresolved rows are surfaced upstream, never submitted. + ``das_name`` names the source data-acquisition system (the tag lives under it). + """ + grouped: dict[tuple, dict] = {} + for rr in row_resolutions: + if not rr["ok"]: + continue + key = (rr["tag"], rr["parameter"]["parameter_id"], rr["unit"]["unit_id"]) + payload = grouped.get(key) + if payload is None: + payload = { + "das_name": das_name, + "tag": rr["tag"], + "channel_kind": "value", + "parameter_name": rr["parameter"]["name"], + "unit_name": rr["unit"].get("symbol") or rr["unit"].get("name", ""), + "strict": False, + "values": [], + } + grouped[key] = payload + payload["values"].append({ + "timestamp": rr["timestamp"].isoformat(), + "value": rr["value"], + "quality_code": None, + }) + return list(grouped.values()) + + +def _derive_title(notes: str, limit: int = 60) -> str: + """Derive a short Event title from the comment's first line.""" + first = (notes or "").strip().splitlines()[0] if notes and notes.strip() else "" + return first[:limit].strip() + + +def _resolve_logbook( + resolver: EntityResolver, + role_map: dict[str, str], + df_data: pd.DataFrame, +) -> dict: + """Resolve a "Général" logbook journal (PRD-4 S1) — one Event per row. + + ``event_date`` (+ optional ``event_time``) → start datetime; ``notes`` → + Event notes (and the text scanned for the target); ``conductor`` → the + performed-by person. The target is resolved to the *smallest* logical unit + found in the comment. Rows with no parseable date or no resolvable target + are flagged (never auto-guessed into a wrong target). + + Returns ``row_resolutions`` / ``n_resolved`` / ``n_unresolved``. + """ + date_cols = [c for c, r in role_map.items() if r == "event_date"] + time_cols = [c for c, r in role_map.items() if r == "event_time"] + notes_cols = [c for c, r in role_map.items() if r == "notes"] + conductor_cols = [c for c, r in role_map.items() if r == "conductor"] + + row_resolutions = [] + n_resolved = 0 + n_unresolved = 0 + + for idx, row in df_data.iterrows(): + errors: list[str] = [] + + # start datetime — date (required) combined with optional time + start_dt = None + if date_cols: + raw_d = row[date_cols[0]] + try: + start_dt = pd.to_datetime(raw_d) + if time_cols: + raw_t = row[time_cols[0]] + if pd.notna(raw_t) and str(raw_t).strip(): + try: + start_dt = pd.to_datetime(f"{start_dt.date()} {raw_t}") + except Exception: + errors.append(f"cannot parse time '{raw_t}' — using date only") + if start_dt.tzinfo is None: + start_dt = start_dt.replace(tzinfo=timezone.utc) + except Exception: + start_dt = None + errors.append(f"cannot parse date '{raw_d}'") + else: + errors.append("no event_date column tagged") + + # notes + derived title + notes = "" + if notes_cols: + raw_notes = row[notes_cols[0]] + notes = "" if pd.isna(raw_notes) else str(raw_notes).strip() + title = _derive_title(notes) + + # conductor → person (optional FK; unresolved warns, never blocks) + person = None + person_text = "" + if conductor_cols: + raw_c = row[conductor_cols[0]] + person_text = "" if pd.isna(raw_c) else str(raw_c).strip() + if person_text: + person = resolver.resolve_person(person_text) + if person is None: + errors.append(f"conductor '{person_text}' not matched — left unset") + + # target → smallest logical unit named in the comment + target = resolver.resolve_target(notes) + if target is None: + errors.append("no target resolved from comment — pick one before submit") + + # A row submits when it has a start time and a target. Unmatched + # conductor is a soft warning (person FK is optional), so it does not + # block — but it is still surfaced in errors above. + blocking = (start_dt is None) or (target is None) + row_ok = not blocking + + if row_ok: + n_resolved += 1 + else: + n_unresolved += 1 + + row_resolutions.append({ + "row_index": int(idx), + "start_datetime": start_dt, + "notes": notes, + "title": title, + "person": person, + "person_text": person_text, + "target": target, + "errors": errors, + "ok": row_ok, + }) + + return { + "row_resolutions": row_resolutions, + "n_resolved": n_resolved, + "n_unresolved": n_unresolved, + } + + +def _build_event_payloads( + row_resolutions: list[dict], event_kind_id: int +) -> list[dict]: + """Build one EventIn payload per resolved logbook row. + + Each payload sets exactly one exclusive-arc target FK (from the resolved + target's ``arc_field``), so it satisfies EventIn's one-target invariant. + Only ``ok`` rows are included — flagged rows are surfaced, never submitted. + """ + payloads = [] + for rr in row_resolutions: + if not rr["ok"]: + continue + target = rr["target"] + payload = { + "event_kind_id": event_kind_id, + "is_instantaneous": True, + "start_datetime": rr["start_datetime"].isoformat(), + "notes": rr["notes"] or None, + target["arc_field"]: target["entity_id"], + } + if rr["person"]: + payload["performed_by_person_id"] = rr["person"]["person_id"] + payloads.append(payload) + return payloads + + +# --------------------------------------------------------------------------- +# Target-confirmation UX (PRD-4 S2) — heuristic hint + user-confirmed overrides +# --------------------------------------------------------------------------- + + +def _build_target_pools( + equipment: list[dict], + sampling_points: list[dict], + process_units: list[dict], + sites: list[dict], + campaigns: list[dict], +) -> dict[str, list[dict]]: + """Normalize each level's lookup into ``{id, label}`` options for pickers.""" + from app.components.resolver import _candidate_name # local import: shared labeler + + def opts(pool: list[dict], id_key: str) -> list[dict]: + return [ + {"id": c.get(id_key), "label": _candidate_name(c)} + for c in pool + if c.get(id_key) is not None and _candidate_name(c) + ] + + return { + "Equipment": opts(equipment, "equipment_id"), + "SamplingPoint": opts(sampling_points, "sampling_point_id"), + "ProcessUnit": opts(process_units, "id"), + "Site": opts(sites, "site_id"), + "Campaign": opts(campaigns, "campaign_id"), + } + + +def _apply_target_override(rr: dict, target: dict | None) -> dict: + """Return a copy of *rr* with a user-confirmed target, ``ok`` recomputed. + + A row is submittable when it has a start datetime and a target. Setting a + target clears the stale "no target resolved" flag. + """ + rr = {**rr, "target": target} + if target is not None: + rr["errors"] = [e for e in rr["errors"] if "no target resolved" not in e] + rr["ok"] = rr["start_datetime"] is not None and target is not None + return rr + + +def _override_targets( + row_resolutions: list[dict], overrides: dict[int, dict] +) -> tuple[list[dict], int, int]: + """Apply ``{row_index: target}`` overrides; return (rows, n_resolved, n_unresolved).""" + out = [] + n_resolved = 0 + for rr in row_resolutions: + ov = overrides.get(rr["row_index"]) + if ov is not None: + rr = _apply_target_override(rr, ov) + if rr["ok"]: + n_resolved += 1 + out.append(rr) + return out, n_resolved, len(out) - n_resolved + + +def _make_target(level: str, option: dict) -> dict: + """Build a target dict from a confirmed (level, {id,label}) pick.""" + return { + "arc_field": TARGET_LEVELS[level], + "level": level, + "entity_id": option["id"], + "label": option["label"], + } + + +# --------------------------------------------------------------------------- +# Per-equipment maintenance-sheet profile (PRD-4 S3) +# --------------------------------------------------------------------------- + + +def _default_kind_index(event_kinds: list[dict], keyword: str = "maintenance") -> int: + """Index of the first kind whose name contains *keyword* (else 0).""" + for i, k in enumerate(event_kinds): + if keyword.lower() in (k.get("name") or "").lower(): + return i + return 0 + + +def _combine_dt(raw_date, raw_time) -> "pd.Timestamp | None": + """Combine a date cell with an optional time cell into a tz-aware timestamp.""" + if pd.isna(raw_date) or not str(raw_date).strip(): + return None + try: + dt = pd.to_datetime(raw_date) + except Exception: + return None + if pd.isna(dt): # e.g. unparseable string → NaT (no exception raised) + return None + if raw_time is not None and pd.notna(raw_time) and str(raw_time).strip(): + try: + dt = pd.to_datetime(f"{dt.date()} {raw_time}") + except Exception: + pass # keep the date-only value + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def _resolve_maintenance( + role_map: dict[str, str], + df_data: pd.DataFrame, + sheet_target: dict, +) -> dict: + """Resolve a per-equipment maintenance sheet (PRD-4 S3) — one Event per row. + + The target is the *whole sheet's* equipment/channel (``sheet_target``), + not per-row. Each row maps ``event_date`` + ``start_time`` → start and + ``end_time`` → end (a spanning, non-instantaneous Event); ``notes`` carries + manual zero-checks. Before/after readings are intentionally not captured. + """ + date_cols = [c for c, r in role_map.items() if r == "event_date"] + start_cols = [c for c, r in role_map.items() if r == "start_time"] + end_cols = [c for c, r in role_map.items() if r == "end_time"] + notes_cols = [c for c, r in role_map.items() if r == "notes"] + + row_resolutions = [] + n_resolved = 0 + n_unresolved = 0 + + for idx, row in df_data.iterrows(): + errors: list[str] = [] + + raw_date = row[date_cols[0]] if date_cols else None + start_dt = _combine_dt(raw_date, row[start_cols[0]] if start_cols else None) + if start_dt is None: + errors.append("no parseable start date/time") + + end_dt = None + if end_cols: + end_dt = _combine_dt(raw_date, row[end_cols[0]]) + + notes = "" + if notes_cols: + raw_notes = row[notes_cols[0]] + notes = "" if pd.isna(raw_notes) else str(raw_notes).strip() + + row_ok = start_dt is not None + if row_ok: + n_resolved += 1 + else: + n_unresolved += 1 + + row_resolutions.append({ + "row_index": int(idx), + "start_datetime": start_dt, + "end_datetime": end_dt, + "notes": notes, + "title": _derive_title(notes), + "target": sheet_target, + "errors": errors, + "ok": row_ok, + }) + + return { + "row_resolutions": row_resolutions, + "n_resolved": n_resolved, + "n_unresolved": n_unresolved, + } + + +def _build_maintenance_payloads( + row_resolutions: list[dict], event_kind_id: int +) -> list[dict]: + """Build one maintenance EventIn payload per resolved row (spanning Event).""" + payloads = [] + for rr in row_resolutions: + if not rr["ok"]: + continue + target = rr["target"] + payload = { + "event_kind_id": event_kind_id, + "is_instantaneous": False, + "start_datetime": rr["start_datetime"].isoformat(), + "notes": rr["notes"] or None, + target["arc_field"]: target["entity_id"], + } + if rr["end_datetime"] is not None: + payload["end_datetime"] = rr["end_datetime"].isoformat() + payloads.append(payload) + return payloads + + +# --------------------------------------------------------------------------- +# Config persistence (S4) — PRD-5-compatible shape +# --------------------------------------------------------------------------- + +_CONFIG_VERSION = 1 + + +def _config_dir() -> Path: + """Return (and create) the directory where named configs are stored.""" + d = Path.home() / ".dateaubase_mapper_configs" + d.mkdir(parents=True, exist_ok=True) + return d + + +def _build_config( + name: str, + header_row: int, + data_start_row: int, + role_map: dict[str, str], + sheet_name: str | int | None = None, + profile: str = "Lab (wide)", +) -> dict: + """Assemble a config dict in PRD-5-compatible shape.""" + cfg: dict = { + "name": name, + "version": _CONFIG_VERSION, + "profile": profile, + "header_row": header_row, + "data_start_row": data_start_row, + "role_map": role_map, + } + if sheet_name is not None: + cfg["sheet_name"] = sheet_name + return cfg + + +def _save_config(cfg: dict) -> Path: + """Persist *cfg* to ~/.dateaubase_mapper_configs/.json. Returns the path.""" + safe_name = "".join(c if c.isalnum() or c in "._- " else "_" for c in cfg["name"]).strip() + if not safe_name: + safe_name = "unnamed" + dest = _config_dir() / f"{safe_name}.json" + dest.write_text(json.dumps(cfg, indent=2), encoding="utf-8") + return dest + + +def _list_configs() -> list[Path]: + """Return all saved config files, sorted by name.""" + return sorted(_config_dir().glob("*.json")) + + +def _load_config(path: Path) -> dict: + """Read and return a config dict from *path*.""" + return json.loads(path.read_text(encoding="utf-8")) + + +# --------------------------------------------------------------------------- +# Page +# --------------------------------------------------------------------------- + + +def mapper_page() -> None: + st.header("Import Data (Mapper)") + st.caption("PRD-3 S5 — upload → pick profile → tag roles → resolve → preview → submit") + + profile = st.selectbox( + "Profile", + options=list(PROFILES.keys()), + index=0, + key="mapper_profile", + help="Lab (wide): rows are samples, columns are parameters. " + "Lab (long): each row is one measurement (parameter/unit in cells). " + "Sensor CSV: each row is one timestamped reading. " + "Logbook (Général): each row is one operational Event. " + "Logbook (Maintenance): per-equipment sheet → one maintenance Event per row.", + ) + + uploaded = st.file_uploader( + "Upload a spreadsheet", + type=["csv", "xlsx"], + help="CSV or Excel files are supported.", + ) + + if uploaded is None: + st.info("Upload a CSV or XLSX file to get started.") + return + + active_roles = PROFILES.get(profile, LAB_ROLES) + + file_bytes = uploaded.read() + filename = uploaded.name + + # ------------------------------------------------------------------ + # Sheet picker (XLSX only) + # ------------------------------------------------------------------ + sheet_name: str | int = 0 + if filename.endswith(".xlsx"): + try: + xl = pd.ExcelFile(io.BytesIO(file_bytes)) + sheet_names = xl.sheet_names + except Exception as exc: + st.error(f"Could not open Excel file: {exc}") + return + + if len(sheet_names) > 1: + sheet_name = st.selectbox("Sheet", options=sheet_names, index=0, key="mapper_sheet") + else: + sheet_name = sheet_names[0] + st.info(f"Sheet: **{sheet_name}**") + + # ------------------------------------------------------------------ + # Header row / data start row + # ------------------------------------------------------------------ + col_h, col_d = st.columns(2) + with col_h: + header_row = st.number_input( + "Header row (0-indexed)", + min_value=0, + value=0, + step=1, + key="mapper_header_row", + help="Row number containing column names (0 = first row).", + ) + with col_d: + data_start_row = st.number_input( + "Data start row (0-indexed)", + min_value=0, + value=1, + step=1, + key="mapper_data_start", + help="First row of data (must be > header row).", + ) + + # ------------------------------------------------------------------ + # Parse + # ------------------------------------------------------------------ + try: + df = _read_dataframe(file_bytes, filename, sheet_name=sheet_name, header_row=int(header_row)) + except Exception as exc: + st.error(f"Could not parse file: {exc}") + return + + if df.empty: + st.warning("The parsed table is empty. Check header row and sheet settings.") + return + + # Slice to data_start_row + data_start = int(data_start_row) + if data_start > 0: + # header_row already consumed by pd.read_excel/csv; data_start_row is relative + # to the full file, so offset by header_row + 1 (already stripped by pandas). + skip = max(0, data_start - int(header_row) - 1) + df_data = df.iloc[skip:].reset_index(drop=True) + else: + df_data = df.copy() + + columns = list(df.columns.astype(str)) + st.markdown(f"**{len(columns)} columns** detected") + + # ------------------------------------------------------------------ + # Role tagging — one selectbox per column + # ------------------------------------------------------------------ + st.subheader("Tag column roles") + st.caption("Assign a role to each column. Columns tagged '(ignore)' are excluded from the preview.") + + role_map: dict[str, str] = {} + n_cols = min(len(columns), 4) + grid_rows = [columns[i : i + n_cols] for i in range(0, len(columns), n_cols)] + + for row_cols in grid_rows: + grid = st.columns(len(row_cols)) + for col_widget, col_name in zip(grid, row_cols): + with col_widget: + role = st.selectbox( + col_name, + options=active_roles, + index=0, + key=f"mapper_role_{col_name}", + ) + role_map[col_name] = role + + # ------------------------------------------------------------------ + # S4: Save / Load mapping config + # ------------------------------------------------------------------ + with st.expander("💾 Save config", expanded=False): + cfg_name = st.text_input( + "Config name", + value="My Lab Sheet v1", + key="mapper_cfg_name", + help="A descriptive name — saved as a JSON file in ~/.dateaubase_mapper_configs/", + ) + if st.button("Save", key="mapper_cfg_save"): + if not cfg_name.strip(): + st.error("Please enter a config name before saving.") + else: + cfg = _build_config( + name=cfg_name.strip(), + header_row=int(header_row), + data_start_row=int(data_start_row), + role_map=role_map, + sheet_name=sheet_name if filename.endswith(".xlsx") else None, + profile=profile, + ) + saved_path = _save_config(cfg) + st.success(f"Config saved to `{saved_path}`") + + with st.expander("📂 Load config", expanded=False): + config_files = _list_configs() + if not config_files: + st.info("No saved configs yet. Save one above first.") + else: + selected_cfg_path = st.selectbox( + "Saved configs", + options=config_files, + format_func=lambda p: p.stem, + key="mapper_cfg_select", + ) + if st.button("Load", key="mapper_cfg_load") and selected_cfg_path is not None: + try: + loaded = _load_config(selected_cfg_path) + # Apply layout settings via session_state + st.session_state["mapper_header_row"] = loaded.get("header_row", 0) + st.session_state["mapper_data_start"] = loaded.get("data_start_row", 1) + # Apply role assignments per column + loaded_profile = loaded.get("profile") + if loaded_profile in PROFILES: + st.session_state["mapper_profile"] = loaded_profile + valid_roles = PROFILES.get(loaded_profile, active_roles) + for col_name, role in loaded.get("role_map", {}).items(): + key = f"mapper_role_{col_name}" + if role in valid_roles: + st.session_state[key] = role + st.success( + f"Config **{loaded.get('name', selected_cfg_path.stem)}** loaded. " + "Roles have been applied — scroll up to review." + ) + st.rerun() + except Exception as exc: + st.error(f"Failed to load config: {exc}") + + # ------------------------------------------------------------------ + # Dumb preview — first 5 rows, role-tagged columns only + # ------------------------------------------------------------------ + st.subheader("Preview") + active_cols = {col: role for col, role in role_map.items() if role != "(ignore)"} + + if not active_cols: + st.info("Tag at least one column with a role to see the preview.") + return + + preview_df = df_data[list(active_cols.keys())].head(5).copy() + preview_df.columns = [f"{col} [{role}]" for col, role in active_cols.items()] + st.dataframe(preview_df, use_container_width=True) + st.caption("Preview shows the first 5 data rows with role labels as column headers.") + + # ------------------------------------------------------------------ + # S5: Sensor-CSV profile — thin profile branch (reuses the same engine) + # ------------------------------------------------------------------ + if profile == "Sensor CSV": + _sensor_resolve_and_submit(role_map, active_cols, df_data) + return + + if profile == "Logbook (Général)": + _logbook_resolve_and_submit(role_map, active_cols, df_data) + return + + if profile == "Logbook (Maintenance)": + # The sheet's subject (its name, or the file stem for CSV) seeds the + # single per-sheet target — e.g. "Solitax R240" → that equipment. + subject = str(sheet_name) if filename.endswith(".xlsx") else Path(filename).stem + _maintenance_resolve_and_submit(role_map, active_cols, df_data, subject) + return + + # ------------------------------------------------------------------ + # S2 / S3: Entity resolution (wide lab) — or S6 long-format lab. + # Long format resolves into the same shape, so the preview + submit + # pipeline below is shared verbatim. + # ------------------------------------------------------------------ + is_long = profile == "Lab (long)" + if is_long: + needed = {"sample_datetime", "sampling_location", "parameter", "unit", "value"} + missing = needed - set(active_cols.values()) + if missing: + st.info( + "Tag one column for each of: sample_datetime, sampling_location, " + f"parameter, unit, value. Still missing: {', '.join(sorted(missing))}." + ) + return + else: + param_value_cols = [col for col, role in active_cols.items() if role == "parameter_value"] + if not param_value_cols: + st.info("Tag at least one column as **parameter_value** to enable entity resolution and ingest.") + return + + st.subheader("Resolve entities") + st.caption( + "Match row cells to database entities using fuzzy text matching. " + + ( + "Each row is one measurement (parameter and unit from cells). " + if is_long + else "Parameter column headers should follow the format **Name (unit)** e.g. *COD (mg/L)*. " + ) + + "Unresolved columns/rows are listed — they are never silently submitted." + ) + + if st.button("Resolve entities", key="mapper_resolve"): + try: + units_list = api.list_units_lookup() + params_list = api.list_parameters_lookup() + sps_list = api.list_sampling_points_lookup() + except Exception as exc: + st.error(f"Could not load lookup data from API: {exc}") + return + + resolver = EntityResolver( + units=units_list, + parameters=params_list, + sampling_points=sps_list, + ) + resolution = ( + _resolve_long(resolver, role_map, df_data) + if is_long + else _resolve_all(resolver, role_map, df_data) + ) + st.session_state["mapper_resolution"] = resolution + + # ------------------------------------------------------------------ + # Show resolution results (persisted in session state) + # ------------------------------------------------------------------ + resolution = st.session_state.get("mapper_resolution") + if resolution is None: + return + + col_resolutions: dict = resolution["col_resolutions"] + row_resolutions: list = resolution["row_resolutions"] + n_resolved: int = resolution["n_resolved"] + n_unresolved: int = resolution["n_unresolved"] + + # Column resolution table + col_rows = [] + for col, cr in col_resolutions.items(): + param_name = cr["param"]["name"] if cr["param"] else "— unresolved —" + unit_display = ( + cr["unit"].get("symbol") or cr["unit"].get("name", "— unresolved —") + if cr["unit"] + else "— unresolved —" + ) + col_rows.append({ + "Column header": col, + "Matched parameter": param_name, + "Matched unit": unit_display, + "Status": "resolved" if cr["resolved"] else "unresolved", + }) + col_df = pd.DataFrame(col_rows) + st.markdown("**Column resolution**") + st.dataframe(col_df, use_container_width=True) + + unresolved_cols = [c for c, cr in col_resolutions.items() if not cr["resolved"]] + if unresolved_cols: + for col in unresolved_cols: + st.warning( + f"Column **{col}**: " + "; ".join(col_resolutions[col]["errors"]) + + ". Review column names or add missing entities." + ) + else: + st.success("All parameter_value columns resolved.") + + # ------------------------------------------------------------------ + # Preview ingest table + # ------------------------------------------------------------------ + st.subheader("Preview ingest") + n_total = len(row_resolutions) + st.caption( + f"{n_resolved} of {n_total} rows resolved — " + f"{n_unresolved} row(s) have errors and will NOT be submitted." + ) + + # Build preview table + preview_rows = [] + for rr in row_resolutions: + dt_str = rr["datetime"].isoformat() if rr["datetime"] else "— missing —" + sp_name = rr["sampling_point"]["name"] if rr["sampling_point"] else f"— {rr['sp_text']} not found —" + val_summary = ", ".join( + f"{v['parameter_name']}={v['value']} {v['unit_symbol']}" + for v in rr["values"] + ) + row_dict = { + "row#": rr["row_index"], + "datetime": dt_str, + "sampling_point": sp_name, + "replicate": rr["replicate"], + "values": val_summary if val_summary else "—", + "status": "ok" if rr["ok"] else ("error: " + "; ".join(rr["errors"])), + } + preview_rows.append(row_dict) + + preview_ingest_df = pd.DataFrame(preview_rows) + st.dataframe(preview_ingest_df, use_container_width=True) + + if n_unresolved > 0: + st.warning( + f"{n_unresolved} row(s) have unresolved entities or missing values and will be skipped. " + "Fix the data or add missing parameters/locations before submitting." + ) + + if n_resolved == 0: + st.error("No rows can be submitted — all rows have errors.") + return + + # ------------------------------------------------------------------ + # Submit to /ingest/lab + # ------------------------------------------------------------------ + st.subheader("Submit to /ingest/lab") + + exp_name = st.text_input( + "Experiment name", + value=f"Mapper import {datetime.now(tz=timezone.utc).strftime('%Y-%m-%d %H:%M')}", + key="mapper_exp_name", + ) + exp_datetime = st.text_input( + "Experiment datetime (ISO, UTC)", + value=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+00:00"), + key="mapper_exp_datetime", + ) + + col_submit, col_info = st.columns([1, 3]) + with col_info: + st.info( + f"Will submit **{n_resolved}** row(s) × {len([c for c in col_resolutions if col_resolutions[c]['resolved']])} " + f"parameter(s) = up to **{n_resolved * len([c for c in col_resolutions if col_resolutions[c]['resolved']])}** measurement(s). " + f"{n_unresolved} row(s) will be skipped." + ) + + if col_submit.button("Submit to /ingest/lab", key="mapper_submit", type="primary"): + _do_submit(row_resolutions, col_resolutions, exp_name, exp_datetime) + + +def _do_submit( + row_resolutions: list[dict], + col_resolutions: dict, + exp_name: str, + exp_datetime_str: str, +) -> None: + """Build payloads per row and call POST /ingest/lab. Reports per-row results.""" + resolved_cols = { + col: cr for col, cr in col_resolutions.items() if cr["resolved"] + } + if not resolved_cols: + st.error("No resolved parameter columns — cannot submit.") + return + + try: + exp_dt = datetime.fromisoformat(exp_datetime_str) + except Exception: + st.error(f"Invalid experiment datetime: '{exp_datetime_str}'. Use ISO format.") + return + + ok_rows = [rr for rr in row_resolutions if rr["ok"]] + if not ok_rows: + st.error("No resolved rows to submit.") + return + + results = [] + progress = st.progress(0, text="Submitting rows…") + + for i, rr in enumerate(ok_rows): + # Create a sample for this row + sample_datetime = rr["datetime"] + sp_id = rr["sampling_point"]["sampling_point_id"] + try: + sample_resp = api.create_sample({ + "sampling_point_id": sp_id, + "sample_datetime_start": sample_datetime.isoformat(), + }) + sample_id = sample_resp["sample_id"] + except Exception as exc: + results.append({"row": rr["row_index"], "status": "error", "detail": f"create_sample failed: {exc}"}) + progress.progress((i + 1) / len(ok_rows), text=f"Row {rr['row_index']}: sample creation error") + continue + + # Build measurements list + measurements = [] + for v in rr["values"]: + col_name = v["col"] + param = col_resolutions[col_name]["param"] + unit = col_resolutions[col_name]["unit"] + measurements.append({ + "parameter_id": v["parameter_id"], + "sampling_point_id": sp_id, + "unit_id": v["unit_id"], + "value_kind_id": 1, + "series_name": f"{param['name']}@{rr['sampling_point']['name']}", + "sample_id": sample_id, + "value": v["value"], + "replicate": rr["replicate"], + }) + + payload = { + "name": exp_name, + "experiment_datetime": exp_dt.isoformat(), + "measurements": measurements, + } + + try: + resp = api.ingest_lab(payload) + results.append({ + "row": rr["row_index"], + "status": "ok", + "detail": f"lab_experiment_id={resp.get('lab_experiment_id')}, rows_written={resp.get('rows_written')}", + }) + except Exception as exc: + results.append({"row": rr["row_index"], "status": "error", "detail": str(exc)}) + + progress.progress((i + 1) / len(ok_rows), text=f"Row {rr['row_index']} done") + + progress.empty() + + # Report + n_ok = sum(1 for r in results if r["status"] == "ok") + n_err = sum(1 for r in results if r["status"] == "error") + if n_err == 0: + st.success(f"All {n_ok} row(s) submitted successfully.") + else: + st.warning(f"{n_ok} row(s) submitted; {n_err} row(s) failed.") + + st.dataframe(pd.DataFrame(results), use_container_width=True) + + +# --------------------------------------------------------------------------- +# Sensor-CSV profile UI (S5) — thin profile over the shared engine +# --------------------------------------------------------------------------- + + +def _sensor_resolve_and_submit( + role_map: dict[str, str], + active_cols: dict[str, str], + df_data: pd.DataFrame, +) -> None: + """Resolve, preview, and submit a Sensor-CSV table to /ingest/sensor.""" + needed = {"timestamp", "tag", "parameter", "unit", "value"} + tagged = set(active_cols.values()) + missing = needed - tagged + if missing: + st.info( + "Tag one column for each of: timestamp, tag, parameter, unit, value. " + f"Still missing: {', '.join(sorted(missing))}." + ) + return + + st.subheader("Resolve entities") + st.caption( + "Parameter and unit cells are matched to database entities by fuzzy text. " + "The tag is passed through to the tagged sensor endpoint (channels are " + "auto-created). Unresolved rows are listed — never silently submitted." + ) + + if st.button("Resolve entities", key="mapper_sensor_resolve"): + try: + units_list = api.list_units_lookup() + params_list = api.list_parameters_lookup() + sps_list = api.list_sampling_points_lookup() + except Exception as exc: + st.error(f"Could not load lookup data from API: {exc}") + return + resolver = EntityResolver( + units=units_list, + parameters=params_list, + sampling_points=sps_list, + ) + st.session_state["mapper_sensor_resolution"] = _resolve_sensor( + resolver, role_map, df_data + ) + + resolution = st.session_state.get("mapper_sensor_resolution") + if resolution is None: + return + + row_resolutions = resolution["row_resolutions"] + n_resolved = resolution["n_resolved"] + n_unresolved = resolution["n_unresolved"] + n_total = len(row_resolutions) + + st.subheader("Preview ingest") + st.caption( + f"{n_resolved} of {n_total} rows resolved — " + f"{n_unresolved} row(s) have errors and will NOT be submitted." + ) + + preview_rows = [] + for rr in row_resolutions: + ts_str = rr["timestamp"].isoformat() if rr["timestamp"] else "— missing —" + param_name = rr["parameter"]["name"] if rr["parameter"] else f"— {rr['param_text']} not found —" + unit_disp = ( + (rr["unit"].get("symbol") or rr["unit"].get("name", "")) + if rr["unit"] + else f"— {rr['unit_text']} not found —" + ) + preview_rows.append({ + "row#": rr["row_index"], + "timestamp": ts_str, + "tag": rr["tag"], + "parameter": param_name, + "unit": unit_disp, + "value": rr["value"] if rr["value"] is not None else "—", + "status": "ok" if rr["ok"] else ("error: " + "; ".join(rr["errors"])), + }) + st.dataframe(pd.DataFrame(preview_rows), use_container_width=True) + + if n_unresolved > 0: + st.warning( + f"{n_unresolved} row(s) have unresolved entities or invalid values and will be skipped." + ) + if n_resolved == 0: + st.error("No rows can be submitted — all rows have errors.") + return + + st.subheader("Submit to /ingest/sensor") + das_name = st.text_input( + "Data-acquisition system (DAS)", + key="mapper_sensor_das", + help="The source system the tags belong to; auto-created if it doesn't exist.", + ).strip() + payloads = _build_sensor_payloads(row_resolutions, das_name) + st.info( + f"Will submit **{n_resolved}** reading(s) grouped into **{len(payloads)}** " + f"channel(s) (one tagged /ingest/sensor call each). {n_unresolved} row(s) skipped." + ) + + submit_disabled = not das_name + if submit_disabled: + st.caption("Enter a DAS name to enable submit.") + if st.button( + "Submit to /ingest/sensor", + key="mapper_sensor_submit", + type="primary", + disabled=submit_disabled, + ): + _do_sensor_submit(payloads) + + +def _do_sensor_submit(payloads: list[dict]) -> None: + """Call POST /ingest/sensor for each (tag, parameter, unit) channel payload.""" + if not payloads: + st.error("No resolved readings to submit.") + return + + results = [] + progress = st.progress(0, text="Submitting channels…") + for i, payload in enumerate(payloads): + try: + resp = api.ingest_sensor(payload) + results.append({ + "tag": payload["tag"], + "parameter": payload["parameter_name"], + "rows": len(payload["values"]), + "status": "ok", + "detail": f"channel_id={resp.get('channel_id')}, rows_written={resp.get('rows_written')}", + }) + except Exception as exc: + results.append({ + "tag": payload["tag"], + "parameter": payload["parameter_name"], + "rows": len(payload["values"]), + "status": "error", + "detail": str(exc), + }) + progress.progress((i + 1) / len(payloads), text=f"Channel '{payload['tag']}' done") + progress.empty() + + n_ok = sum(1 for r in results if r["status"] == "ok") + n_err = sum(1 for r in results if r["status"] == "error") + if n_err == 0: + st.success(f"All {n_ok} channel(s) submitted successfully.") + else: + st.warning(f"{n_ok} channel(s) submitted; {n_err} failed.") + st.dataframe(pd.DataFrame(results), use_container_width=True) + + +# --------------------------------------------------------------------------- +# Logbook "Général" profile UI (PRD-4 S1) — map → resolve target → create Events +# --------------------------------------------------------------------------- + + +def _logbook_resolve_and_submit( + role_map: dict[str, str], + active_cols: dict[str, str], + df_data: pd.DataFrame, +) -> None: + """Resolve, preview, and create Events from a "Général" logbook journal.""" + tagged = set(active_cols.values()) + if "event_date" not in tagged: + st.info("Tag the date column as **event_date** (and optionally a time column).") + return + if "notes" not in tagged: + st.info("Tag the comments column as **notes** — it carries the entry text and target.") + return + + st.subheader("Resolve targets") + st.caption( + "Each row becomes one Event. The comment is scanned for the smallest " + "logical target it names (Equipment → SamplingPoint → ProcessUnit → " + "Site → Campaign). Rows with no parseable date or no resolved target " + "are flagged — never auto-guessed into a wrong target." + ) + + # Event kind isn't in the sheet — the user picks a default for all rows. + try: + event_kinds = api.list_event_kinds_lookup() + except Exception as exc: + st.error(f"Could not load event kinds: {exc}") + return + if not event_kinds: + st.warning("No event kinds defined yet — create one before importing the logbook.") + return + kind_label = st.selectbox( + "Default event kind", + options=[k["name"] for k in event_kinds], + key="mapper_logbook_kind", + help="Applied to every imported row (the Général sheet has no kind column).", + ) + event_kind_id = next(k["event_kind_id"] for k in event_kinds if k["name"] == kind_label) + + if st.button("Resolve targets", key="mapper_logbook_resolve"): + try: + equipment = api.list_equipment_lookup() + sampling_points = api.list_sampling_points_lookup() + process_units = api.list_process_units_lookup() + sites = api.list_sites_lookup() + campaigns = api.list_campaigns_lookup() + resolver = EntityResolver( + units=[], + parameters=[], + sampling_points=sampling_points, + equipment=equipment, + sites=sites, + process_units=process_units, + campaigns=campaigns, + persons=api.list_persons_lookup(), + ) + except Exception as exc: + st.error(f"Could not load lookup data from API: {exc}") + return + st.session_state["mapper_logbook_resolution"] = _resolve_logbook( + resolver, role_map, df_data + ) + # Stash the picker pools for the S2 confirm UX; reset prior overrides. + st.session_state["mapper_logbook_pools"] = _build_target_pools( + equipment, sampling_points, process_units, sites, campaigns + ) + st.session_state["mapper_logbook_overrides"] = {} + + resolution = st.session_state.get("mapper_logbook_resolution") + if resolution is None: + return + + base_rows = resolution["row_resolutions"] + pools = st.session_state.get("mapper_logbook_pools", {}) + overrides = st.session_state.get("mapper_logbook_overrides", {}) + + # ------------------------------------------------------------------ + # S2: confirm / override ambiguous targets (never auto-committed). + # Only rows that have a date but no resolved target are fixable here. + # ------------------------------------------------------------------ + fixable = [ + rr for rr in base_rows + if rr["start_datetime"] is not None + and rr["row_index"] not in overrides + and rr["target"] is None + ] + if fixable and pools: + with st.expander(f"🎯 Confirm targets ({len(fixable)} need a pick)", expanded=True): + st.caption( + "These rows name no known entity. Pick the right target — nothing " + "is submitted until you confirm. The level is pre-filled from a " + "heuristic guess; it is only a hint." + ) + level_names = list(TARGET_LEVELS.keys()) + for rr in fixable: + ri = rr["row_index"] + st.markdown(f"**Row {ri}** — {rr['title'] or '(no title)'}") + guess = guess_target_level(rr["notes"]) + lvl_col, ent_col = st.columns(2) + with lvl_col: + level = st.selectbox( + "Level", + options=level_names, + index=level_names.index(guess) if guess in level_names else 0, + key=f"mapper_logbook_lvl_{ri}", + ) + opts = pools.get(level, []) + with ent_col: + choice = st.selectbox( + "Target", + options=["— pick —"] + [o["label"] for o in opts], + key=f"mapper_logbook_ent_{ri}", + ) + col_apply, col_bulk = st.columns(2) + picked = next((o for o in opts if o["label"] == choice), None) + if col_apply.button("Apply", key=f"mapper_logbook_apply_{ri}", disabled=picked is None): + overrides[ri] = _make_target(level, picked) + st.session_state["mapper_logbook_overrides"] = overrides + st.rerun() + if col_bulk.button( + "Apply to all flagged", + key=f"mapper_logbook_bulk_{ri}", + disabled=picked is None, + help="Set this same target on every still-unresolved dated row.", + ): + for other in fixable: + overrides[other["row_index"]] = _make_target(level, picked) + st.session_state["mapper_logbook_overrides"] = overrides + st.rerun() + + row_resolutions, n_resolved, n_unresolved = _override_targets(base_rows, overrides) + n_total = len(row_resolutions) + + st.subheader("Preview events") + st.caption( + f"{n_resolved} of {n_total} rows resolved — " + f"{n_unresolved} row(s) have errors and will NOT be submitted." + ) + + preview_rows = [] + for rr in row_resolutions: + ts_str = rr["start_datetime"].isoformat() if rr["start_datetime"] else "— missing —" + tgt = rr["target"] + target_disp = f"{tgt['level']}: {tgt['label']}" if tgt else "— unresolved —" + preview_rows.append({ + "row#": rr["row_index"], + "start": ts_str, + "title": rr["title"] or "—", + "target": target_disp, + "conductor": (rr["person"]["label"] if rr["person"] else (rr["person_text"] or "—")), + "status": "ok" if rr["ok"] else ("error: " + "; ".join(rr["errors"])), + }) + st.dataframe(pd.DataFrame(preview_rows), use_container_width=True) + + if n_unresolved > 0: + st.warning( + f"{n_unresolved} row(s) have no date or no resolved target and will be skipped." + ) + if n_resolved == 0: + st.error("No rows can be submitted — confirm a target above, or fix the dates.") + return + + payloads = _build_event_payloads(row_resolutions, event_kind_id) + st.subheader("Create events") + st.info(f"Will create **{len(payloads)}** Event(s). {n_unresolved} row(s) skipped.") + if st.button("Create events", key="mapper_logbook_submit", type="primary"): + _do_event_submit(payloads, row_resolutions) + + +def _do_event_submit(payloads: list[dict], row_resolutions: list[dict]) -> None: + """Call POST /events for each resolved logbook row. Reports per-row results.""" + if not payloads: + st.error("No resolved rows to submit.") + return + + # One ok row per payload, in the same order, for the result table. + ok_rows = [rr for rr in row_resolutions if rr["ok"]] + results = [] + progress = st.progress(0, text="Creating events…") + for i, (payload, rr) in enumerate(zip(payloads, ok_rows)): + tgt = rr["target"] + try: + resp = api.create_event(payload) + results.append({ + "row": rr["row_index"], + "target": f"{tgt['level']}: {tgt['label']}", + "status": "ok", + "detail": f"event_id={resp.get('event_id')}", + }) + except Exception as exc: + results.append({ + "row": rr["row_index"], + "target": f"{tgt['level']}: {tgt['label']}", + "status": "error", + "detail": str(exc), + }) + progress.progress((i + 1) / len(payloads), text=f"Row {rr['row_index']} done") + progress.empty() + + n_ok = sum(1 for r in results if r["status"] == "ok") + n_err = sum(1 for r in results if r["status"] == "error") + if n_err == 0: + st.success(f"All {n_ok} event(s) created successfully.") + else: + st.warning(f"{n_ok} event(s) created; {n_err} failed.") + st.dataframe(pd.DataFrame(results), use_container_width=True) + + +# --------------------------------------------------------------------------- +# Maintenance-sheet profile UI (PRD-4 S3) — single sheet target → maintenance Events +# --------------------------------------------------------------------------- + + +def _maintenance_resolve_and_submit( + role_map: dict[str, str], + active_cols: dict[str, str], + df_data: pd.DataFrame, + subject: str, +) -> None: + """Resolve, preview, and create maintenance Events from a per-equipment sheet.""" + tagged = set(active_cols.values()) + if "event_date" not in tagged or "start_time" not in tagged: + st.info("Tag a date column as **event_date** and a **start_time** column (end_time optional).") + return + + st.subheader("Sheet target") + st.caption( + "One maintenance Event per row, all targeting this sheet's " + "equipment/channel. Confirm the target — pre-filled from the sheet name. " + "Before/after readings are not stored (the drift Channel derives them)." + ) + try: + equipment = api.list_equipment_lookup() + sampling_points = api.list_sampling_points_lookup() + process_units = api.list_process_units_lookup() + sites = api.list_sites_lookup() + campaigns = api.list_campaigns_lookup() + event_kinds = api.list_event_kinds_lookup() + except Exception as exc: + st.error(f"Could not load lookup data from API: {exc}") + return + if not event_kinds: + st.warning("No event kinds defined yet — create a 'maintenance' kind first.") + return + + pools = _build_target_pools(equipment, sampling_points, process_units, sites, campaigns) + resolver = EntityResolver( + units=[], parameters=[], sampling_points=sampling_points, + equipment=equipment, sites=sites, process_units=process_units, campaigns=campaigns, + ) + guess = resolver.resolve_target(subject or "") + + level_names = list(TARGET_LEVELS.keys()) + default_level = guess["level"] if guess else "Equipment" + lvl_col, ent_col = st.columns(2) + with lvl_col: + level = st.selectbox( + "Target level", level_names, + index=level_names.index(default_level), + key="mapper_maint_level", + ) + opts = pools.get(level, []) + labels = [o["label"] for o in opts] + if not labels: + st.warning(f"No {level} entities available — pick another level.") + return + default_ix = labels.index(guess["label"]) if guess and guess["level"] == level and guess["label"] in labels else 0 + with ent_col: + choice = st.selectbox("Target", labels, index=default_ix, key="mapper_maint_entity") + picked = next(o for o in opts if o["label"] == choice) + sheet_target = _make_target(level, picked) + st.caption(f"Subject **{subject or '(none)'}** → **{level}: {picked['label']}**") + + kind_label = st.selectbox( + "Event kind", + options=[k["name"] for k in event_kinds], + index=_default_kind_index(event_kinds), + key="mapper_maint_kind", + help="Defaults to a 'maintenance' kind when one exists.", + ) + event_kind_id = next(k["event_kind_id"] for k in event_kinds if k["name"] == kind_label) + + if st.button("Resolve rows", key="mapper_maint_resolve"): + st.session_state["mapper_maint_resolution"] = _resolve_maintenance( + role_map, df_data, sheet_target + ) + + resolution = st.session_state.get("mapper_maint_resolution") + if resolution is None: + return + + rows = resolution["row_resolutions"] + n_resolved = resolution["n_resolved"] + n_unresolved = resolution["n_unresolved"] + + st.subheader("Preview events") + st.caption(f"{n_resolved} of {len(rows)} rows resolved — {n_unresolved} row(s) skipped.") + preview = [] + for rr in rows: + tgt = rr["target"] + preview.append({ + "row#": rr["row_index"], + "start": rr["start_datetime"].isoformat() if rr["start_datetime"] else "— missing —", + "end": rr["end_datetime"].isoformat() if rr["end_datetime"] else "—", + "target": f"{tgt['level']}: {tgt['label']}", + "notes": rr["title"] or "—", + "status": "ok" if rr["ok"] else ("error: " + "; ".join(rr["errors"])), + }) + st.dataframe(pd.DataFrame(preview), use_container_width=True) + if n_resolved == 0: + st.error("No rows can be submitted — check the date/time columns.") + return + + payloads = _build_maintenance_payloads(rows, event_kind_id) + st.subheader("Create events") + st.info(f"Will create **{len(payloads)}** maintenance Event(s). {n_unresolved} row(s) skipped.") + if st.button("Create events", key="mapper_maint_submit", type="primary"): + _do_event_submit(payloads, rows) + + +mapper_page() diff --git a/docs/adr/0006-unified-event-polymorphic-target.md b/docs/adr/0006-unified-event-polymorphic-target.md new file mode 100644 index 0000000..fc4d8a2 --- /dev/null +++ b/docs/adr/0006-unified-event-polymorphic-target.md @@ -0,0 +1,82 @@ +# Unified Event with exclusive-arc polymorphic target + +## Status + +accepted (extends [ADR-0003](0003-exclusive-arc-for-sensor-lab-polymorphism.md)) + +## Context + +The plant logbook records operational occurrences at every level of the +physical hierarchy: a single sensor channel, a piece of equipment, a sampling +location, a process unit, the whole site, or a campaign. The schema only had +`EquipmentEvent` (target = Equipment) and `Annotation` (target = a measurement +Stream). Site-wide and process-unit-wide entries — power outages, ventilation +failures, PLC crashes — had nowhere to land without being forced, lossily, onto +a single equipment or a single stream. + +## Decision + +Generalize `EquipmentEvent` into a single `Event` table whose target is an +**exclusive arc**: nullable FKs to every node of the two operational hierarchies +plus the Campaign cross-cut — spatial `Site → ProcessUnit → SamplingPoint` and +acquisition `DataAcquisitionSystem → SignalInterface → Equipment → Channel` — +**eight FKs**, with a CHECK enforcing exactly one non-NULL. DataAcquisitionSystem +and SignalInterface are included because logbook entries like *"Plantage du PLC"* +are DAS/interface-scope and otherwise have nowhere to land. (The anchor entity is +`SamplingPoint`; "SamplingLocation" is not a table.) `EquipmentEventKind` becomes +`EventKind`. `Annotation`'s `EquipmentEvent_ID` FK is repointed to `Event_ID`. +This mirrors the exclusive-arc pattern already chosen in ADR-0003 for the +sensor/lab Stream polymorphism. + +Done now, during pre-release v2.0: per the project's migration policy no data +migration script is required — the change is a YAML dictionary edit plus DDL +regeneration. Post-release the same refactor would be expensive. + +## Consequences + +- Logbook entries map to their *smallest logical unit* instead of being dumped + into one bucket. This is the data model the logbook mapper targets. +- **Event and Annotation stay separate tables, by design.** IWA *Metadata + Collection* Ch3 §3.4.3.2 names the field's central unsolved problem: people + conflate *causes* (deliberate actions, causal occurrences) with *effects* + (observed symptoms in the signal). `Event` is the cause side (a cleaning, a + calibration, a power outage); `Annotation` is the effect side (an outlier, a + drift, a noise burst anchored to a Stream). The repointed `Annotation.Event_ID` + FK **is** the "causal annotation" the book prescribes (Ch3 Fig 3.9: *"OUR drops + **because** influent pump P100 is down"*) — a symptom optionally linked to the + event that explains it, without merging the two tables. +- **A cross-stream symptom is carried by the shared Event, not by a + many-to-many Annotation↔Stream join.** Ch3 D3.36 wants one symptom to span + several signals (e.g. a control failure across reactors). Rather than fan an + Annotation out to N streams, we anchor one `Event` (cause) at the **lowest + common ancestor** in the arc (ProcessUnit/Site) and let the per-stream + `Annotation`s (effects) — each still single-Stream — share that `Event_ID`. + The cause is the unifier. This preserves the cause/effect separation above and + keeps `Annotation.Stream_ID` a simple non-null FK; the obvious alternative (a + join table) was rejected because it re-smears one symptom across many targets, + the very conflation §3.4.3.2 warns against. +- Maintenance before/after readings are **not stored** as columns. The drift + series is instead a **derived `Channel`** produced by a `ProcessingStep` (the + schema already distinguishes raw channels from `ProducedByStep_ID` derived + ones): one `%diff` point per maintenance Event, between the last source sample + before `EventDateTimeStart` and the first after `EventDateTimeEnd`. This makes + drift a first-class Stream — selectable, plottable, downloadable, and + annotatable — and its points carry `QualityCode`s like any Channel, so a delta + taken across a transient is simply quality-flagged (no bespoke "confidence" + field). Manual reference readings with no backing stream (zero-checks) go to + `Notes`. +- **The drift Channel measures the sensor's *own signal change* at the event, not + trueness.** Ch5 §5.4.2 endorses deriving before/after from the stream for + **cleaning under steady conditions** (sensor-vs-itself, same medium). But Ch5 + §5.5.2 is emphatic that any *accuracy/trueness* claim needs a **controlled + reference** (the bucket procedure, A) absent from the live stream — the process + variable itself drifts on the order of minutes, so a reading "20 min after" can + straddle a real process change. Therefore the drift Channel's quality meaning is + scoped to `Cleaning` (and the at-calibration signal jump), and is **never** a + calibration accuracy result. +- **Calibration proper** — slope/intercept, single- vs multi-point, the + Channel↔reference relationship and operator metadata — is a distinct model + deferred to its own PRD and ADR (see `.tasks/prd_6_calibration_NOTES.md`). Ch3 + §3.2.3.4 is clear that, unlike maintenance readings, calibration *curves* must + be **stored as append-only history**, not derived. Maintenance drift (above) + is not calibration. diff --git a/docs/assets/erd_interactive.html b/docs/assets/erd_interactive.html index aabf164..8e9fc6d 100644 --- a/docs/assets/erd_interactive.html +++ b/docs/assets/erd_interactive.html @@ -583,13 +583,13 @@ "description": "Campaign this annotation is associated with, if any" }, { - "name": "EquipmentEvent_ID", + "name": "Event_ID", "sql_type": "INT", "is_pk": false, "is_fk": true, "is_required": false, - "fk_target": "EquipmentEvent.EquipmentEvent_ID", - "description": "Equipment event that caused this annotation, if any" + "fk_target": "Event.Event_ID", + "description": "Event that caused this annotation, if any (causal link: Event=cause, Annotation=effect)" }, { "name": "Title", @@ -800,7 +800,7 @@ { "id": "Campaign", "label": "Campaign", - "description": "A named collection of measurement activities at a site, classified by type (Experiment, Operations, Commissioning). Supersedes Project (defunct table) for all organisational grouping.", + "description": "A named collection of measurement activities, classified by type (Experiment, Operations, Commissioning). Campaigns are multi-site: their sites are derived from sampling-location membership (CampaignSamplingLocation to SamplingPoint.Site), not stored. Supersedes Project (defunct table) for all organisational grouping.", "fields": [ { "name": "Campaign_ID", @@ -820,15 +820,6 @@ "fk_target": "CampaignKind.CampaignKind_ID", "description": "Kind of campaign (See CampaignKind table. E.g., Experiment, Monitoring, Facility Commissioning)." }, - { - "name": "Site_ID", - "sql_type": "INT", - "is_pk": false, - "is_fk": true, - "is_required": true, - "fk_target": "Site.Site_ID", - "description": "Site where the campaign is conducted." - }, { "name": "Name", "sql_type": "NVARCHAR(200)", @@ -993,7 +984,7 @@ { "id": "Channel", "label": "Channel", - "description": "Invariant descriptor for a measurement stream (sensor channel). Channel is the sensor subtype of Stream (table-per-type inheritance): it shares Stream_ID as its own primary key, which is simultaneously a foreign key to Stream.Stream_ID. Each row is identified by a unique (SignalInterface, TagName, Parameter, DataProvenance, ProducedByStep) combination. A Channel is created once and never changes \u2014 equipment swaps and sensor relocations are tracked on the physical Equipment via EquipmentWiringHistory and EquipmentLocationHistory, leaving Stream_ID stable. The specific SignalInterfacePort carrying the stream is optional at ingest time and can be backfilled later via ChannelPortHistory (and the denormalised SignalInterfacePort_ID below). Lab sample results are stored in LabAnalysis + LabValue (not in Channel).\nRaw/ingested channels have SignalInterface_ID NOT NULL and ProducedByStep_ID NULL. Derived/processed channels have SignalInterface_ID NULL and ProducedByStep_ID pointing to the ProcessingStep that produced them. Accumulated processing operations applied to a Channel live in the ChannelTrait junction (to be added in a later slice).\n", + "description": "Invariant descriptor for a measurement stream (sensor channel). Channel is the sensor subtype of Stream (table-per-type inheritance): it shares Stream_ID as its own primary key, which is simultaneously a foreign key to Stream.Stream_ID. Each row is identified by a unique (SignalInterface, TagName, Parameter, DataProvenance, ProducedByStep) combination. A Channel is created once and never changes \u2014 equipment swaps and sensor relocations are tracked on the physical Equipment via EquipmentWiringHistory and EquipmentLocationHistory, leaving Stream_ID stable. The specific SignalInterfacePort carrying the stream is optional at ingest time and is recorded over time in ChannelPortHistory (the active row is the current port). Queries that need the current port resolve it through the vw_ChannelResolved view, so there is a single source of truth and no denormalised column to drift. Lab sample results are stored in LabAnalysis + LabValue (not in Channel).\nRaw/ingested channels have SignalInterface_ID NOT NULL and ProducedByStep_ID NULL. Derived/processed channels have SignalInterface_ID NULL and ProducedByStep_ID pointing to the ProcessingStep that produced them. Accumulated processing operations applied to a Channel live in the ChannelTrait junction (to be added in a later slice).\n", "fields": [ { "name": "Stream_ID", @@ -1022,15 +1013,6 @@ "fk_target": null, "description": "Tag string as published by the SignalInterface (case-preserved; lookups are case-insensitive trimmed). For direct-connect interfaces a synthetic tag such as \"{equipment_identifier}/{parameter_name}\" is auto-generated.\n" }, - { - "name": "SignalInterfacePort_ID", - "sql_type": "INT", - "is_pk": false, - "is_fk": true, - "is_required": false, - "fk_target": "SignalInterfacePort.SignalInterfacePort_ID", - "description": "Current physical port (if known) this Channel is gated through. Denormalised from the active ChannelPortHistory row for query convenience. NULL when the wiring has not yet been traced.\n" - }, { "name": "ParentChannel_ID", "sql_type": "INT", @@ -1916,134 +1898,6 @@ "group_color": "#059669", "group_key": "equipment" }, - { - "id": "EquipmentEvent", - "label": "EquipmentEvent", - "description": "Records a discrete lifecycle event (calibration, maintenance, failure, etc.) that occurred on a specific piece of equipment.", - "fields": [ - { - "name": "EquipmentEvent_ID", - "sql_type": "INT", - "is_pk": true, - "is_fk": false, - "is_required": true, - "fk_target": null, - "description": "Surrogate primary key" - }, - { - "name": "Equipment_ID", - "sql_type": "INT", - "is_pk": false, - "is_fk": true, - "is_required": true, - "fk_target": "Equipment.Equipment_ID", - "description": "Equipment on which the event occurred" - }, - { - "name": "EquipmentEventKind_ID", - "sql_type": "INT", - "is_pk": false, - "is_fk": true, - "is_required": true, - "fk_target": "EquipmentEventKind.EquipmentEventKind_ID", - "description": "Kind of lifecycle event" - }, - { - "name": "EventDateTimeStart", - "sql_type": "DATETIME2(7)", - "is_pk": false, - "is_fk": false, - "is_required": true, - "fk_target": null, - "description": "Date and time the event began (UTC)" - }, - { - "name": "IsInstantaneous", - "sql_type": "BIT", - "is_pk": false, - "is_fk": false, - "is_required": true, - "fk_target": null, - "description": "True if the event occurred at a single point in time. When true, EventDateTimeEnd must be NULL. When false and EventDateTimeEnd is NULL, the event is ongoing." - }, - { - "name": "EventDateTimeEnd", - "sql_type": "DATETIME2(7)", - "is_pk": false, - "is_fk": false, - "is_required": false, - "fk_target": null, - "description": "Date and time the event ended (UTC). NULL when IsInstantaneous=1 (point-in-time) or when the event is still ongoing (IsInstantaneous=0)." - }, - { - "name": "PerformedByPerson_ID", - "sql_type": "INT", - "is_pk": false, - "is_fk": true, - "is_required": false, - "fk_target": "Person.Person_ID", - "description": "Person who physically performed the event (e.g. technician on site)" - }, - { - "name": "RecordedByPerson_ID", - "sql_type": "INT", - "is_pk": false, - "is_fk": true, - "is_required": false, - "fk_target": "Person.Person_ID", - "description": "Person who entered this record into the system (may differ from PerformedByPerson_ID)" - }, - { - "name": "Notes", - "sql_type": "NVARCHAR(MAX)", - "is_pk": false, - "is_fk": false, - "is_required": false, - "fk_target": null, - "description": "Free-text notes about the event" - } - ], - "group_name": "Field System & Equipment", - "group_color": "#059669", - "group_key": "equipment" - }, - { - "id": "EquipmentEventKind", - "label": "EquipmentEventKind", - "description": "Controlled vocabulary classifying the kind of lifecycle event that occurred on a piece of equipment (Calibration, Maintenance, etc.)", - "fields": [ - { - "name": "EquipmentEventKind_ID", - "sql_type": "INT", - "is_pk": true, - "is_fk": false, - "is_required": true, - "fk_target": null, - "description": "Surrogate primary key" - }, - { - "name": "Name", - "sql_type": "NVARCHAR(100)", - "is_pk": false, - "is_fk": false, - "is_required": true, - "fk_target": null, - "description": "Name of the equipment event kind" - }, - { - "name": "Description", - "sql_type": "NVARCHAR(300)", - "is_pk": false, - "is_fk": false, - "is_required": false, - "fk_target": null, - "description": "Explanation of what this kind of equipment event involves" - } - ], - "group_name": "Field System & Equipment", - "group_color": "#059669", - "group_key": "equipment" - }, { "id": "EquipmentLocationHistory", "label": "EquipmentLocationHistory", @@ -2311,139 +2165,330 @@ "group_key": "equipment" }, { - "id": "HydrologicalCharacteristics", - "label": "HydrologicalCharacteristics", - "description": "Stores the hydrological land use percentages (e.g., forest, wetlands, cropland, grassland) within the watershed", + "id": "Event", + "label": "Event", + "description": "Records a discrete, time-stamped operational occurrence (calibration, cleaning, power outage, PLC crash, site visit, \u2026) at any node of the physical hierarchy. Each Event attaches to exactly one target via an exclusive arc of eight nullable FKs \u2014 a CHECK constraint enforces that exactly one is non-NULL. Generalises the former EquipmentEvent (which was restricted to Equipment).\n", "fields": [ { - "name": "Watershed_ID", + "name": "Event_ID", "sql_type": "INT", "is_pk": true, - "is_fk": true, + "is_fk": false, "is_required": true, - "fk_target": "Watershed.Watershed_ID", - "description": "Linked to the Watershed table" + "fk_target": null, + "description": "Surrogate primary key" }, { - "name": "UrbanArea", - "sql_type": "REAL", + "name": "Channel_ID", + "sql_type": "INT", "is_pk": false, - "is_fk": false, + "is_fk": true, "is_required": false, - "fk_target": null, - "description": "Percentage [%] of urban areas" + "fk_target": "Channel.Stream_ID", + "description": "Leaf target: the specific sensor Channel the event concerns (references Channel.Stream_ID)" }, { - "name": "Forest", - "sql_type": "REAL", + "name": "Equipment_ID", + "sql_type": "INT", "is_pk": false, - "is_fk": false, + "is_fk": true, "is_required": false, - "fk_target": null, - "description": "Percentage [%] of forest areas" + "fk_target": "Equipment.Equipment_ID", + "description": "Target: the Equipment the event concerns (sensor, actuator, \u2026)" }, { - "name": "Wetlands", - "sql_type": "REAL", + "name": "SignalInterface_ID", + "sql_type": "INT", "is_pk": false, - "is_fk": false, + "is_fk": true, "is_required": false, - "fk_target": null, - "description": "Percentage [%] of wetlands" + "fk_target": "SignalInterface.SignalInterface_ID", + "description": "Target: the SignalInterface (field bus, port block) the event concerns" }, { - "name": "Cropland", - "sql_type": "REAL", + "name": "DataAcquisitionSystem_ID", + "sql_type": "INT", "is_pk": false, - "is_fk": false, + "is_fk": true, "is_required": false, - "fk_target": null, - "description": "Percentage [%] of croplands" + "fk_target": "DataAcquisitionSystem.DataAcquisitionSystem_ID", + "description": "Target: the DataAcquisitionSystem (PLC, logger) the event concerns" }, { - "name": "Meadow", - "sql_type": "REAL", + "name": "SamplingPoint_ID", + "sql_type": "INT", "is_pk": false, - "is_fk": false, + "is_fk": true, "is_required": false, - "fk_target": null, - "description": "Percentage [%] of meadow areas" + "fk_target": "SamplingPoint.SamplingPoint_ID", + "description": "Target: the SamplingPoint the event concerns" }, { - "name": "Grassland", - "sql_type": "REAL", + "name": "ProcessUnit_ID", + "sql_type": "INT", "is_pk": false, - "is_fk": false, + "is_fk": true, "is_required": false, - "fk_target": null, - "description": "Percentage [%] of grasslands" - } - ], - "group_name": "Site & Facility", - "group_color": "#2563eb", - "group_key": "site_facility" - }, - { - "id": "LabAnalysis", - "label": "LabAnalysis", - "description": "One analytical run on a discrete physical sample \u2014 the event-record sitting at the intersection of two orthogonal grouping axes: LabExperiment (the session: who/when) and AnalysisSeries (the stream identity: parameter / location / kind / processing / unit). The measurement itself lives in Observation (via LabAnalysis_ID) and is routed to a payload table (Value / ValueVector / ValueMatrix / ValueImage) so lab data is no longer scalar-only. Point-level review state (ReviewStatus_ID, ReviewedByPerson_ID, ReviewDateTime) records the institutional approval of each individual measurement, parallel to QualityCode_ID; AuditLog covers amendment history.\n", - "fields": [ + "fk_target": "ProcessUnit.ProcessUnit_ID", + "description": "Target: the ProcessUnit the event concerns" + }, { - "name": "LabAnalysis_ID", + "name": "Site_ID", "sql_type": "INT", - "is_pk": true, - "is_fk": false, - "is_required": true, - "fk_target": null, - "description": "Surrogate primary key" + "is_pk": false, + "is_fk": true, + "is_required": false, + "fk_target": "Site.Site_ID", + "description": "Target: the Site the event concerns (site-wide power outage, etc.)" }, { - "name": "LabExperiment_ID", + "name": "Campaign_ID", "sql_type": "INT", "is_pk": false, "is_fk": true, - "is_required": true, - "fk_target": "LabExperiment.LabExperiment_ID", - "description": "The lab session this analysis was part of" + "is_required": false, + "fk_target": "Campaign.Campaign_ID", + "description": "Target: the Campaign the event concerns" }, { - "name": "AnalysisSeries_ID", + "name": "EventKind_ID", "sql_type": "INT", "is_pk": false, "is_fk": true, "is_required": true, - "fk_target": "AnalysisSeries.Stream_ID", - "description": "The measurement stream (parameter / location / value kind) this analysis belongs to" + "fk_target": "EventKind.EventKind_ID", + "description": "Kind of event (calibration, cleaning, power outage, \u2026)" }, { - "name": "Sample_ID", - "sql_type": "INT", + "name": "EventDateTimeStart", + "sql_type": "DATETIME2(7)", "is_pk": false, - "is_fk": true, + "is_fk": false, "is_required": true, - "fk_target": "Sample.Sample_ID", - "description": "The physical sample that was analysed" + "fk_target": null, + "description": "Date and time the event began (UTC)" }, { - "name": "Replicate", - "sql_type": "INT", + "name": "IsInstantaneous", + "sql_type": "BIT", "is_pk": false, "is_fk": false, "is_required": true, "fk_target": null, - "description": "Replicate number (1 = primary measurement, 2+ = duplicates)" + "description": "True if the event occurred at a single point in time. When true, EventDateTimeEnd must be NULL. When false and EventDateTimeEnd is NULL, the event is ongoing.\n" }, { - "name": "QualityCode_ID", + "name": "EventDateTimeEnd", + "sql_type": "DATETIME2(7)", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Date and time the event ended (UTC). NULL when IsInstantaneous=1 (point-in-time) or when the event is still ongoing (IsInstantaneous=0).\n" + }, + { + "name": "PerformedByPerson_ID", "sql_type": "INT", "is_pk": false, "is_fk": true, "is_required": false, - "fk_target": "QualityCode.QualityCode_ID", - "description": "Optional quality flag. NULL means no quality assessment has been recorded." + "fk_target": "Person.Person_ID", + "description": "Person who physically performed the event (e.g. technician on site)" }, { - "name": "ReviewStatus_ID", + "name": "RecordedByPerson_ID", + "sql_type": "INT", + "is_pk": false, + "is_fk": true, + "is_required": false, + "fk_target": "Person.Person_ID", + "description": "Person who entered this record into the system (may differ from PerformedByPerson_ID)" + }, + { + "name": "Notes", + "sql_type": "NVARCHAR(MAX)", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Free-text notes about the event" + } + ], + "group_name": "Uncategorized", + "group_color": "#6b7280", + "group_key": null + }, + { + "id": "EventKind", + "label": "EventKind", + "description": "Controlled vocabulary classifying the kind of operational event (Calibration, Cleaning, PowerOutage, ControllerCrash, \u2026). Generalises the former EquipmentEventKind to cover all targets in the exclusive-arc Event table.\n", + "fields": [ + { + "name": "EventKind_ID", + "sql_type": "INT", + "is_pk": true, + "is_fk": false, + "is_required": true, + "fk_target": null, + "description": "Surrogate primary key" + }, + { + "name": "Name", + "sql_type": "NVARCHAR(100)", + "is_pk": false, + "is_fk": false, + "is_required": true, + "fk_target": null, + "description": "Name of the event kind" + }, + { + "name": "Description", + "sql_type": "NVARCHAR(300)", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Explanation of what this kind of event involves" + } + ], + "group_name": "Uncategorized", + "group_color": "#6b7280", + "group_key": null + }, + { + "id": "HydrologicalCharacteristics", + "label": "HydrologicalCharacteristics", + "description": "Stores the hydrological land use percentages (e.g., forest, wetlands, cropland, grassland) within the watershed", + "fields": [ + { + "name": "Watershed_ID", + "sql_type": "INT", + "is_pk": true, + "is_fk": true, + "is_required": true, + "fk_target": "Watershed.Watershed_ID", + "description": "Linked to the Watershed table" + }, + { + "name": "UrbanArea", + "sql_type": "REAL", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Percentage [%] of urban areas" + }, + { + "name": "Forest", + "sql_type": "REAL", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Percentage [%] of forest areas" + }, + { + "name": "Wetlands", + "sql_type": "REAL", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Percentage [%] of wetlands" + }, + { + "name": "Cropland", + "sql_type": "REAL", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Percentage [%] of croplands" + }, + { + "name": "Meadow", + "sql_type": "REAL", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Percentage [%] of meadow areas" + }, + { + "name": "Grassland", + "sql_type": "REAL", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Percentage [%] of grasslands" + } + ], + "group_name": "Site & Facility", + "group_color": "#2563eb", + "group_key": "site_facility" + }, + { + "id": "LabAnalysis", + "label": "LabAnalysis", + "description": "One analytical run on a discrete physical sample \u2014 the event-record sitting at the intersection of two orthogonal grouping axes: LabExperiment (the session: who/when) and AnalysisSeries (the stream identity: parameter / location / kind / processing / unit). The measurement itself lives in Observation (via LabAnalysis_ID) and is routed to a payload table (Value / ValueVector / ValueMatrix / ValueImage) so lab data is no longer scalar-only. Point-level review state (ReviewStatus_ID, ReviewedByPerson_ID, ReviewDateTime) records the institutional approval of each individual measurement, parallel to QualityCode_ID; AuditLog covers amendment history.\n", + "fields": [ + { + "name": "LabAnalysis_ID", + "sql_type": "INT", + "is_pk": true, + "is_fk": false, + "is_required": true, + "fk_target": null, + "description": "Surrogate primary key" + }, + { + "name": "LabExperiment_ID", + "sql_type": "INT", + "is_pk": false, + "is_fk": true, + "is_required": true, + "fk_target": "LabExperiment.LabExperiment_ID", + "description": "The lab session this analysis was part of" + }, + { + "name": "AnalysisSeries_ID", + "sql_type": "INT", + "is_pk": false, + "is_fk": true, + "is_required": true, + "fk_target": "AnalysisSeries.Stream_ID", + "description": "The measurement stream (parameter / location / value kind) this analysis belongs to" + }, + { + "name": "Sample_ID", + "sql_type": "INT", + "is_pk": false, + "is_fk": true, + "is_required": true, + "fk_target": "Sample.Sample_ID", + "description": "The physical sample that was analysed" + }, + { + "name": "Replicate", + "sql_type": "INT", + "is_pk": false, + "is_fk": false, + "is_required": true, + "fk_target": null, + "description": "Replicate number (1 = primary measurement, 2+ = duplicates)" + }, + { + "name": "QualityCode_ID", + "sql_type": "INT", + "is_pk": false, + "is_fk": true, + "is_required": false, + "fk_target": "QualityCode.QualityCode_ID", + "description": "Optional quality flag. NULL means no quality assessment has been recorded." + }, + { + "name": "ReviewStatus_ID", "sql_type": "INT", "is_pk": false, "is_fk": true, @@ -4895,7 +4940,7 @@ "id": "vw_ChannelEquipmentAtTime", "label": "vw_ChannelEquipmentAtTime", "description": "Resolves, for every Observation, which physical Equipment was wired to the Channel at the time of the observation. Walks EquipmentWiringHistory by matching SignalInterface_ID (and, when the Channel's port is known, SignalInterfacePort_ID) and bracketing (ValidFrom, ValidTo]. When a Channel has no port recorded and multiple Equipment share the interface at the same time, EquipmentID is NULL and Resolution = 'ambiguous'.\n", - "view_definition": "WITH channel_wiring AS (\n SELECT\n o.[Observation_ID] AS ObservationID,\n c.[Stream_ID] AS ChannelID,\n o.[Timestamp] AS Timestamp,\n c.[SignalInterface_ID],\n c.[SignalInterfacePort_ID],\n ewh.[Equipment_ID] AS EquipmentID,\n ROW_NUMBER() OVER (\n PARTITION BY o.[Observation_ID]\n ORDER BY\n CASE WHEN ewh.[SignalInterfacePort_ID] IS NOT NULL THEN 0 ELSE 1 END,\n ewh.[ValidFrom] DESC\n ) AS rn,\n COUNT(*) OVER (PARTITION BY o.[Observation_ID]) AS match_count\n FROM [dbo].[Observation] o\n JOIN [dbo].[Channel] c ON c.[Stream_ID] = o.[Channel_ID]\n LEFT JOIN [dbo].[EquipmentWiringHistory] ewh ON ewh.[SignalInterface_ID] = c.[SignalInterface_ID]\n AND (\n ewh.[SignalInterfacePort_ID] = c.[SignalInterfacePort_ID]\n OR (ewh.[SignalInterfacePort_ID] IS NULL AND c.[SignalInterfacePort_ID] IS NULL)\n OR c.[SignalInterfacePort_ID] IS NULL\n )\n AND ewh.[ValidFrom] <= o.[Timestamp]\n AND (ewh.[ValidTo] IS NULL OR ewh.[ValidTo] > o.[Timestamp])\n)\nSELECT\n cw.ObservationID,\n cw.ChannelID,\n cw.Timestamp,\n CASE WHEN cw.match_count > 1 AND cw.[SignalInterfacePort_ID] IS NULL THEN NULL ELSE cw.EquipmentID END AS EquipmentID,\n e.[Identifier] AS EquipmentName,\n CASE\n WHEN cw.EquipmentID IS NULL AND cw.match_count = 0 THEN N'unlinked'\n WHEN cw.match_count > 1 AND cw.[SignalInterfacePort_ID] IS NULL THEN N'ambiguous'\n ELSE N'resolved'\n END AS Resolution\nFROM channel_wiring cw\nLEFT JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = cw.EquipmentID\nWHERE cw.rn = 1\n", + "view_definition": "WITH channel_wiring AS (\n SELECT\n o.[Observation_ID] AS ObservationID,\n c.[Stream_ID] AS ChannelID,\n o.[Timestamp] AS Timestamp,\n c.[SignalInterface_ID],\n c.[SignalInterfacePort_ID],\n ewh.[Equipment_ID] AS EquipmentID,\n ROW_NUMBER() OVER (\n PARTITION BY o.[Observation_ID]\n ORDER BY\n CASE WHEN ewh.[SignalInterfacePort_ID] IS NOT NULL THEN 0 ELSE 1 END,\n ewh.[ValidFrom] DESC\n ) AS rn,\n COUNT(*) OVER (PARTITION BY o.[Observation_ID]) AS match_count\n FROM [dbo].[Observation] o\n JOIN [dbo].[vw_ChannelResolved] c ON c.[Stream_ID] = o.[Channel_ID]\n LEFT JOIN [dbo].[EquipmentWiringHistory] ewh ON ewh.[SignalInterface_ID] = c.[SignalInterface_ID]\n AND (\n ewh.[SignalInterfacePort_ID] = c.[SignalInterfacePort_ID]\n OR (ewh.[SignalInterfacePort_ID] IS NULL AND c.[SignalInterfacePort_ID] IS NULL)\n OR c.[SignalInterfacePort_ID] IS NULL\n )\n AND ewh.[ValidFrom] <= o.[Timestamp]\n AND (ewh.[ValidTo] IS NULL OR ewh.[ValidTo] > o.[Timestamp])\n)\nSELECT\n cw.ObservationID,\n cw.ChannelID,\n cw.Timestamp,\n CASE WHEN cw.match_count > 1 AND cw.[SignalInterfacePort_ID] IS NULL THEN NULL ELSE cw.EquipmentID END AS EquipmentID,\n e.[Identifier] AS EquipmentName,\n CASE\n WHEN cw.EquipmentID IS NULL AND cw.match_count = 0 THEN N'unlinked'\n WHEN cw.match_count > 1 AND cw.[SignalInterfacePort_ID] IS NULL THEN N'ambiguous'\n ELSE N'resolved'\n END AS Resolution\nFROM channel_wiring cw\nLEFT JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = cw.EquipmentID\nWHERE cw.rn = 1\n", "columns": [ { "name": "ObservationID", @@ -4958,8 +5003,8 @@ { "id": "vw_ChannelLocationAtTime", "label": "vw_ChannelLocationAtTime", - "description": "Resolves the SamplingPoint a Channel was sampling at the time of each Observation, by composing vw_ChannelEquipmentAtTime with EquipmentLocationHistory. When equipment cannot be resolved (ambiguous or unlinked wiring), SamplingPointID is NULL.\n", - "view_definition": "SELECT\n cea.ObservationID,\n cea.ChannelID,\n cea.Timestamp,\n cea.EquipmentID,\n elh.[SamplingPoint_ID] AS SamplingPointID,\n sp.[SamplingPoint] AS SamplingPointName\nFROM [dbo].[vw_ChannelEquipmentAtTime] cea\nLEFT JOIN [dbo].[EquipmentLocationHistory] elh ON elh.[Equipment_ID] = cea.EquipmentID\n AND elh.[ValidFrom] <= cea.Timestamp\n AND (elh.[ValidTo] IS NULL OR elh.[ValidTo] > cea.Timestamp)\nLEFT JOIN [dbo].[SamplingPoint] sp ON sp.[SamplingPoint_ID] = elh.[SamplingPoint_ID]\n", + "description": "Resolves the SamplingPoint a Channel was sampling at the time of each Observation, by composing vw_ChannelEquipmentAtTime with EquipmentLocationHistory. SamplingPointID is NULL whenever the chain is broken; the Resolution (equipment leg) and LocationResolution (location leg) discriminators say *why*, so NULL-because-broken is distinguishable from NULL-because-genuinely-absent (consistency audit F6).\n", + "view_definition": "SELECT\n cea.ObservationID,\n cea.ChannelID,\n cea.Timestamp,\n cea.EquipmentID,\n cea.Resolution,\n elh.[SamplingPoint_ID] AS SamplingPointID,\n sp.[SamplingPoint] AS SamplingPointName,\n CASE\n WHEN cea.EquipmentID IS NULL THEN N'no-equipment'\n WHEN elh.[SamplingPoint_ID] IS NOT NULL THEN N'resolved'\n ELSE N'no-location'\n END AS LocationResolution\nFROM [dbo].[vw_ChannelEquipmentAtTime] cea\nLEFT JOIN [dbo].[EquipmentLocationHistory] elh ON elh.[Equipment_ID] = cea.EquipmentID\n AND elh.[ValidFrom] <= cea.Timestamp\n AND (elh.[ValidTo] IS NULL OR elh.[ValidTo] > cea.Timestamp)\nLEFT JOIN [dbo].[SamplingPoint] sp ON sp.[SamplingPoint_ID] = elh.[SamplingPoint_ID]\n", "columns": [ { "name": "ObservationID", @@ -4997,6 +5042,15 @@ "fk_target": null, "description": "Equipment resolved at this time (NULL if unresolved)" }, + { + "name": "Resolution", + "sql_type": "NVARCHAR(20)", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Equipment leg, passed through from vw_ChannelEquipmentAtTime: 'resolved' | 'unlinked' | 'ambiguous'" + }, { "name": "SamplingPointID", "sql_type": "INT", @@ -5014,127 +5068,190 @@ "is_required": false, "fk_target": null, "description": "Name of the SamplingPoint" + }, + { + "name": "LocationResolution", + "sql_type": "NVARCHAR(20)", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Location leg: 'resolved' (active ELH row) | 'no-location' (equipment resolved but no covering ELH row) | 'no-equipment' (equipment leg broken upstream, see Resolution)" } ], "group_name": "Views", "group_color": "#0284c7" }, { - "id": "vw_ChannelStatus", - "label": "vw_ChannelStatus", - "description": "Per-channel sensor status view. A status channel is a Channel whose ChannelRole = Status and whose ParentChannel_ID points at the measured value Channel. The equipment behind the value channel is resolved via the new EquipmentWiringHistory table (v4.0.0).\n", - "view_definition": "SELECT\n statusC.[Stream_ID] AS StatusChannelID,\n valueC.[Stream_ID] AS MeasurementChannelID,\n e.[Equipment_ID] AS EquipmentID,\n e.[Identifier] AS EquipmentName,\n p.[Parameter] AS MeasurementParameter,\n o.[Timestamp],\n CAST(v.[Value] AS INT) AS StatusCodeID\nFROM [dbo].[Value] v\nJOIN [dbo].[Observation] o ON o.[Observation_ID] = v.[Observation_ID]\nJOIN [dbo].[Channel] statusC ON statusC.[Stream_ID] = o.[Channel_ID]\nJOIN [dbo].[ChannelKind] role ON role.[ChannelKind_ID] = statusC.[ChannelKind_ID]\nJOIN [dbo].[Channel] valueC ON valueC.[Stream_ID] = statusC.[ParentChannel_ID]\nJOIN [dbo].[Parameter] p ON p.[Parameter_ID] = valueC.[Parameter_ID]\nLEFT JOIN [dbo].[EquipmentWiringHistory] ewh\n ON ewh.[SignalInterface_ID] = valueC.[SignalInterface_ID]\n AND (\n ewh.[SignalInterfacePort_ID] = valueC.[SignalInterfacePort_ID]\n OR valueC.[SignalInterfacePort_ID] IS NULL\n )\n AND ewh.[ValidTo] IS NULL\nLEFT JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = ewh.[Equipment_ID]\nWHERE role.[Name] = N'Status'\n AND statusC.[ParentChannel_ID] IS NOT NULL\n", + "id": "vw_ChannelResolved", + "label": "vw_ChannelResolved", + "description": "Channel with its current SignalInterfacePort resolved from the active ChannelPortHistory row (ValidTo IS NULL). Replaces the former denormalised Channel.SignalInterfacePort_ID column: there is exactly one source of truth (ChannelPortHistory) so the port can never drift. Every read that needs the current port selects FROM this view instead of FROM the Channel base table; the equipment-resolution join predicates are otherwise unchanged. The filtered unique index UQ_ChannelPortHistory_ActiveRow guarantees at most one active row per channel, so this view returns exactly one row per Channel.\n", + "view_definition": "SELECT\n c.[Stream_ID],\n c.[SignalInterface_ID],\n c.[TagName],\n cph.[SignalInterfacePort_ID],\n c.[ParentChannel_ID],\n c.[ChannelKind_ID],\n c.[Parameter_ID],\n c.[DataProvenanceKind_ID],\n c.[ProducedByStep_ID],\n c.[ValueKind_ID],\n c.[Unit_ID]\nFROM [dbo].[Channel] c\nLEFT JOIN [dbo].[ChannelPortHistory] cph\n ON cph.[Channel_ID] = c.[Stream_ID]\n AND cph.[ValidTo] IS NULL\n", "columns": [ { - "name": "StatusChannelID", + "name": "Stream_ID", "sql_type": "INT", "is_pk": false, "is_fk": false, "is_required": false, "fk_target": null, - "description": "Stream_ID of the status time series (Channel PK is now Stream_ID)" + "description": "Channel primary key (shared with Stream)" }, { - "name": "MeasurementChannelID", + "name": "SignalInterface_ID", "sql_type": "INT", "is_pk": false, "is_fk": false, "is_required": false, "fk_target": null, - "description": "Stream_ID of the measurement channel this status describes" + "description": "Publishing SignalInterface (NULL for derived channels)" }, { - "name": "EquipmentID", - "sql_type": "INT", + "name": "TagName", + "sql_type": "NVARCHAR(200)", "is_pk": false, "is_fk": false, "is_required": false, "fk_target": null, - "description": "Equipment ID currently wired to the measurement channel (NULL if unresolved)" + "description": "Published tag string" }, { - "name": "EquipmentName", - "sql_type": "NVARCHAR(200)", + "name": "SignalInterfacePort_ID", + "sql_type": "INT", "is_pk": false, "is_fk": false, "is_required": false, "fk_target": null, - "description": "Identifier of the currently-linked equipment" + "description": "Current port from the active ChannelPortHistory row (NULL if untraced)" }, { - "name": "MeasurementParameter", - "sql_type": "NVARCHAR(100)", + "name": "ParentChannel_ID", + "sql_type": "INT", "is_pk": false, "is_fk": false, "is_required": false, "fk_target": null, - "description": "Name of the measured parameter (TSS, pH, etc.)" + "description": "Parent value channel for sub-signals (Status/Alarm/Uncertainty)" }, { - "name": "Timestamp", - "sql_type": "DATETIME2(7)", + "name": "ChannelKind_ID", + "sql_type": "INT", "is_pk": false, "is_fk": false, "is_required": false, "fk_target": null, - "description": "Timestamp of the status observation" + "description": "1=Value, 2=Status, 3=Alarm, 4=Uncertainty" }, { - "name": "StatusCodeID", + "name": "Parameter_ID", "sql_type": "INT", "is_pk": false, "is_fk": false, "is_required": false, "fk_target": null, - "description": "Raw integer status code stored in the status Channel's Value rows" - } - ], - "group_name": "Views", - "group_color": "#0284c7" - }, - { - "id": "vw_DeviceStatus", - "label": "vw_DeviceStatus", - "description": "Device-level status view. Finds status channels through the v4.0.0 SignalInterface + EquipmentWiringHistory chain: a status Channel (ChannelRole = Status) inherits its equipment from the parent value Channel's current wiring. Replaces the port-centric v3.0.0 join.\n", - "view_definition": "SELECT\n statusC.[Stream_ID] AS StatusChannelID,\n e.[Equipment_ID] AS EquipmentID,\n e.[Identifier] AS EquipmentName,\n o.[Timestamp],\n CAST(v.[Value] AS INT) AS StatusCodeID\nFROM [dbo].[Value] v\nJOIN [dbo].[Observation] o ON o.[Observation_ID] = v.[Observation_ID]\nJOIN [dbo].[Channel] statusC ON statusC.[Stream_ID] = o.[Channel_ID]\nJOIN [dbo].[ChannelKind] role ON role.[ChannelKind_ID] = statusC.[ChannelKind_ID]\nJOIN [dbo].[Channel] valueC ON valueC.[Stream_ID] = statusC.[ParentChannel_ID]\nJOIN [dbo].[EquipmentWiringHistory] ewh\n ON ewh.[SignalInterface_ID] = valueC.[SignalInterface_ID]\n AND (\n ewh.[SignalInterfacePort_ID] = valueC.[SignalInterfacePort_ID]\n OR valueC.[SignalInterfacePort_ID] IS NULL\n )\n AND ewh.[ValidTo] IS NULL\nJOIN [dbo].[Equipment] e ON e.[Equipment_ID] = ewh.[Equipment_ID]\nWHERE role.[Name] = N'Status'\n", - "columns": [ + "description": "Measured analyte or parameter" + }, { - "name": "StatusChannelID", + "name": "DataProvenanceKind_ID", "sql_type": "INT", "is_pk": false, "is_fk": false, "is_required": false, "fk_target": null, - "description": "Stream_ID of the status time series (Channel PK is now Stream_ID)" + "description": "How the data was produced" }, { - "name": "EquipmentID", + "name": "ProducedByStep_ID", "sql_type": "INT", "is_pk": false, "is_fk": false, "is_required": false, "fk_target": null, - "description": "Equipment ID this status describes" + "description": "ProcessingStep that produced a derived channel (NULL for raw)" }, { - "name": "EquipmentName", - "sql_type": "NVARCHAR(200)", + "name": "ValueKind_ID", + "sql_type": "INT", "is_pk": false, "is_fk": false, "is_required": false, "fk_target": null, - "description": "Identifier of the equipment" + "description": "Shape of stored values (1=Scalar, ...)" }, { - "name": "Timestamp", - "sql_type": "DATETIME2(7)", + "name": "Unit_ID", + "sql_type": "INT", "is_pk": false, "is_fk": false, "is_required": false, "fk_target": null, - "description": "Timestamp of the status observation" - }, - { + "description": "Unit of measurement" + } + ], + "group_name": "Views", + "group_color": "#0284c7" + }, + { + "id": "vw_ChannelStatus", + "label": "vw_ChannelStatus", + "description": "Per-channel sensor status view. A status channel is a Channel whose ChannelRole = Status and whose ParentChannel_ID points at the measured value Channel. The equipment behind the value channel is resolved via the new EquipmentWiringHistory table (v4.0.0).\n", + "view_definition": "SELECT\n statusC.[Stream_ID] AS StatusChannelID,\n valueC.[Stream_ID] AS MeasurementChannelID,\n e.[Equipment_ID] AS EquipmentID,\n e.[Identifier] AS EquipmentName,\n p.[Parameter] AS MeasurementParameter,\n o.[Timestamp],\n CAST(v.[Value] AS INT) AS StatusCodeID\nFROM [dbo].[Value] v\nJOIN [dbo].[Observation] o ON o.[Observation_ID] = v.[Observation_ID]\nJOIN [dbo].[Channel] statusC ON statusC.[Stream_ID] = o.[Channel_ID]\nJOIN [dbo].[ChannelKind] role ON role.[ChannelKind_ID] = statusC.[ChannelKind_ID]\nJOIN [dbo].[vw_ChannelResolved] valueC ON valueC.[Stream_ID] = statusC.[ParentChannel_ID]\nJOIN [dbo].[Parameter] p ON p.[Parameter_ID] = valueC.[Parameter_ID]\nLEFT JOIN [dbo].[EquipmentWiringHistory] ewh\n ON ewh.[SignalInterface_ID] = valueC.[SignalInterface_ID]\n AND (\n ewh.[SignalInterfacePort_ID] = valueC.[SignalInterfacePort_ID]\n OR valueC.[SignalInterfacePort_ID] IS NULL\n )\n AND ewh.[ValidTo] IS NULL\nLEFT JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = ewh.[Equipment_ID]\nWHERE role.[Name] = N'Status'\n AND statusC.[ParentChannel_ID] IS NOT NULL\n", + "columns": [ + { + "name": "StatusChannelID", + "sql_type": "INT", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Stream_ID of the status time series (Channel PK is now Stream_ID)" + }, + { + "name": "MeasurementChannelID", + "sql_type": "INT", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Stream_ID of the measurement channel this status describes" + }, + { + "name": "EquipmentID", + "sql_type": "INT", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Equipment ID currently wired to the measurement channel (NULL if unresolved)" + }, + { + "name": "EquipmentName", + "sql_type": "NVARCHAR(200)", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Identifier of the currently-linked equipment" + }, + { + "name": "MeasurementParameter", + "sql_type": "NVARCHAR(100)", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Name of the measured parameter (TSS, pH, etc.)" + }, + { + "name": "Timestamp", + "sql_type": "DATETIME2(7)", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Timestamp of the status observation" + }, + { "name": "StatusCodeID", "sql_type": "INT", "is_pk": false, @@ -5146,6 +5263,289 @@ ], "group_name": "Views", "group_color": "#0284c7" + }, + { + "id": "vw_DeploymentCoherence", + "label": "vw_DeploymentCoherence", + "description": "Surfaces deployment drift (consistency audit F1): equipment whose active location sits at a Site different from the Site where its connected Data Acquisition System is currently deployed. A DAS is deployed to a Site (DASLocationHistory); equipment is wired to that DAS's SignalInterfaces (EquipmentWiringHistory) and physically placed at a SamplingPoint (EquipmentLocationHistory), and every SamplingPoint belongs to a Site. When a DAS is moved to a new Site, the equipment it feeds does not move with it automatically \u2014 this view lists each such stranded equipment so the drift can be corrected (relocate the equipment, or move the DAS back). Only active rows (ValidTo IS NULL) on both histories are considered; a row appears here only while the mismatch is live.\n", + "view_definition": "SELECT\n dlh.[DataAcquisitionSystem_ID] AS DAS_ID,\n das.[Name] AS DASName,\n dlh.[Site_ID] AS DASSite_ID,\n dsite.[Name] AS DASSiteName,\n e.[Equipment_ID] AS Equipment_ID,\n e.[Identifier] AS EquipmentName,\n sp.[Site_ID] AS EquipmentSite_ID,\n esite.[Name] AS EquipmentSiteName,\n sp.[SamplingPoint_ID] AS SamplingPoint_ID,\n sp.[SamplingPoint] AS SamplingPointName\nFROM [dbo].[DASLocationHistory] dlh\nJOIN [dbo].[DataAcquisitionSystem] das ON das.[DataAcquisitionSystem_ID] = dlh.[DataAcquisitionSystem_ID]\nJOIN [dbo].[SignalInterface] si ON si.[DataAcquisitionSystem_ID] = dlh.[DataAcquisitionSystem_ID]\nJOIN [dbo].[EquipmentWiringHistory] ewh ON ewh.[SignalInterface_ID] = si.[SignalInterface_ID]\n AND ewh.[ValidTo] IS NULL\nJOIN [dbo].[Equipment] e ON e.[Equipment_ID] = ewh.[Equipment_ID]\nJOIN [dbo].[EquipmentLocationHistory] elh ON elh.[Equipment_ID] = e.[Equipment_ID]\n AND elh.[ValidTo] IS NULL\nJOIN [dbo].[SamplingPoint] sp ON sp.[SamplingPoint_ID] = elh.[SamplingPoint_ID]\nLEFT JOIN [dbo].[Site] dsite ON dsite.[Site_ID] = dlh.[Site_ID]\nLEFT JOIN [dbo].[Site] esite ON esite.[Site_ID] = sp.[Site_ID]\nWHERE dlh.[ValidTo] IS NULL\n AND sp.[Site_ID] <> dlh.[Site_ID]\n", + "columns": [ + { + "name": "DAS_ID", + "sql_type": "INT", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "The deployed Data Acquisition System" + }, + { + "name": "DASName", + "sql_type": "NVARCHAR(200)", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "DAS name" + }, + { + "name": "DASSite_ID", + "sql_type": "INT", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Site the DAS is currently deployed to" + }, + { + "name": "DASSiteName", + "sql_type": "NVARCHAR(200)", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Name of the DAS's Site" + }, + { + "name": "Equipment_ID", + "sql_type": "INT", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Equipment wired to the DAS but located elsewhere" + }, + { + "name": "EquipmentName", + "sql_type": "NVARCHAR(200)", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Equipment identifier" + }, + { + "name": "EquipmentSite_ID", + "sql_type": "INT", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Site of the equipment's current SamplingPoint (the drift)" + }, + { + "name": "EquipmentSiteName", + "sql_type": "NVARCHAR(200)", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Name of the equipment's current Site" + }, + { + "name": "SamplingPoint_ID", + "sql_type": "INT", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Equipment's current SamplingPoint" + }, + { + "name": "SamplingPointName", + "sql_type": "NVARCHAR(200)", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Name of the equipment's current SamplingPoint" + } + ], + "group_name": "Views", + "group_color": "#0284c7" + }, + { + "id": "vw_DeviceStatus", + "label": "vw_DeviceStatus", + "description": "Device-level status view. Finds status channels through the v4.0.0 SignalInterface + EquipmentWiringHistory chain: a status Channel (ChannelRole = Status) inherits its equipment from the parent value Channel's current wiring. Replaces the port-centric v3.0.0 join.\n", + "view_definition": "SELECT\n statusC.[Stream_ID] AS StatusChannelID,\n e.[Equipment_ID] AS EquipmentID,\n e.[Identifier] AS EquipmentName,\n o.[Timestamp],\n CAST(v.[Value] AS INT) AS StatusCodeID\nFROM [dbo].[Value] v\nJOIN [dbo].[Observation] o ON o.[Observation_ID] = v.[Observation_ID]\nJOIN [dbo].[Channel] statusC ON statusC.[Stream_ID] = o.[Channel_ID]\nJOIN [dbo].[ChannelKind] role ON role.[ChannelKind_ID] = statusC.[ChannelKind_ID]\nJOIN [dbo].[vw_ChannelResolved] valueC ON valueC.[Stream_ID] = statusC.[ParentChannel_ID]\nJOIN [dbo].[EquipmentWiringHistory] ewh\n ON ewh.[SignalInterface_ID] = valueC.[SignalInterface_ID]\n AND (\n ewh.[SignalInterfacePort_ID] = valueC.[SignalInterfacePort_ID]\n OR valueC.[SignalInterfacePort_ID] IS NULL\n )\n AND ewh.[ValidTo] IS NULL\nJOIN [dbo].[Equipment] e ON e.[Equipment_ID] = ewh.[Equipment_ID]\nWHERE role.[Name] = N'Status'\n", + "columns": [ + { + "name": "StatusChannelID", + "sql_type": "INT", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Stream_ID of the status time series (Channel PK is now Stream_ID)" + }, + { + "name": "EquipmentID", + "sql_type": "INT", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Equipment ID this status describes" + }, + { + "name": "EquipmentName", + "sql_type": "NVARCHAR(200)", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Identifier of the equipment" + }, + { + "name": "Timestamp", + "sql_type": "DATETIME2(7)", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Timestamp of the status observation" + }, + { + "name": "StatusCodeID", + "sql_type": "INT", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Raw integer status code stored in the status Channel's Value rows" + } + ], + "group_name": "Views", + "group_color": "#0284c7" + }, + { + "id": "vw_InactiveParentReferences", + "label": "vw_InactiveParentReferences", + "description": "Health view (consistency audit F11): live references to soft-deleted parents. The model leans on IsActive soft-deletes for SignalInterface and SignalInterfacePort, but setting IsActive=0 does nothing to the active EquipmentWiringHistory rows still pointing at that interface/port \u2014 they keep resolving as if active. This view lists each active wiring row (ValidTo IS NULL) whose referenced SignalInterface or SignalInterfacePort is inactive, so the app can warn before deactivating a parent that still has live children (and operators can reconcile existing orphans). One row per dangling reference; ReferenceType says which leg is stale.\n", + "view_definition": "SELECT\n N'active-wiring->interface' AS ReferenceType,\n ewh.[EquipmentWiringHistory_ID] AS WiringHistoryID,\n ewh.[Equipment_ID] AS EquipmentID,\n si.[SignalInterface_ID] AS ParentID,\n si.[Name] AS ParentLabel\nFROM [dbo].[EquipmentWiringHistory] ewh\nJOIN [dbo].[SignalInterface] si ON si.[SignalInterface_ID] = ewh.[SignalInterface_ID]\nWHERE ewh.[ValidTo] IS NULL\n AND si.[IsActive] = 0\nUNION ALL\nSELECT\n N'active-wiring->port' AS ReferenceType,\n ewh.[EquipmentWiringHistory_ID] AS WiringHistoryID,\n ewh.[Equipment_ID] AS EquipmentID,\n sip.[SignalInterfacePort_ID] AS ParentID,\n sip.[PortIdentifier] AS ParentLabel\nFROM [dbo].[EquipmentWiringHistory] ewh\nJOIN [dbo].[SignalInterfacePort] sip ON sip.[SignalInterfacePort_ID] = ewh.[SignalInterfacePort_ID]\nWHERE ewh.[ValidTo] IS NULL\n AND sip.[IsActive] = 0\n", + "columns": [ + { + "name": "ReferenceType", + "sql_type": "NVARCHAR(40)", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "'active-wiring->interface' | 'active-wiring->port'" + }, + { + "name": "WiringHistoryID", + "sql_type": "INT", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "The active EquipmentWiringHistory row holding the stale reference" + }, + { + "name": "EquipmentID", + "sql_type": "INT", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Equipment wired by that row" + }, + { + "name": "ParentID", + "sql_type": "INT", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "SignalInterface_ID or SignalInterfacePort_ID that is inactive" + }, + { + "name": "ParentLabel", + "sql_type": "NVARCHAR(200)", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Inactive parent's name (interface) or port identifier" + } + ], + "group_name": "Views", + "group_color": "#0284c7" + }, + { + "id": "vw_UnlinkedChannels", + "label": "vw_UnlinkedChannels", + "description": "Health view (consistency audit F5): raw/ingested channels that carry Observations but have no active EquipmentWiringHistory on their SignalInterface, so every observation resolves as 'unlinked' (see vw_ChannelEquipmentAtTime) with no equipment and no SamplingPoint. These are channels that were ingested but never linked to physical equipment \u2014 \"N channels need wiring\". Only raw channels are considered: derived/ processed channels have SignalInterface_ID NULL by design and are deliberately unwired, not forgotten, so they are excluded. A channel drops off this list the moment an active wiring row exists for its interface.\n", + "view_definition": "SELECT\n c.[Stream_ID] AS ChannelID,\n c.[TagName] AS TagName,\n c.[SignalInterface_ID] AS SignalInterfaceID,\n si.[Name] AS SignalInterfaceName,\n COUNT(o.[Observation_ID]) AS ObservationCount,\n MIN(o.[Timestamp]) AS FirstObservation,\n MAX(o.[Timestamp]) AS LastObservation\nFROM [dbo].[Channel] c\nJOIN [dbo].[Observation] o ON o.[Channel_ID] = c.[Stream_ID]\nLEFT JOIN [dbo].[SignalInterface] si ON si.[SignalInterface_ID] = c.[SignalInterface_ID]\nWHERE c.[SignalInterface_ID] IS NOT NULL\n AND NOT EXISTS (\n SELECT 1\n FROM [dbo].[EquipmentWiringHistory] ewh\n WHERE ewh.[SignalInterface_ID] = c.[SignalInterface_ID]\n AND ewh.[ValidTo] IS NULL\n )\nGROUP BY c.[Stream_ID], c.[TagName], c.[SignalInterface_ID], si.[Name]\n", + "columns": [ + { + "name": "ChannelID", + "sql_type": "INT", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Channel (Stream_ID) lacking active wiring" + }, + { + "name": "TagName", + "sql_type": "NVARCHAR(200)", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Channel tag name" + }, + { + "name": "SignalInterfaceID", + "sql_type": "INT", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "The channel's SignalInterface (has no active wiring row)" + }, + { + "name": "SignalInterfaceName", + "sql_type": "NVARCHAR(200)", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Name of the SignalInterface" + }, + { + "name": "ObservationCount", + "sql_type": "INT", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "How many observations are stranded on this unlinked channel" + }, + { + "name": "FirstObservation", + "sql_type": "DATETIME2(7)", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Earliest stranded observation timestamp" + }, + { + "name": "LastObservation", + "sql_type": "DATETIME2(7)", + "is_pk": false, + "is_fk": false, + "is_required": false, + "fk_target": null, + "description": "Latest stranded observation timestamp" + } + ], + "group_name": "Views", + "group_color": "#0284c7" } ], "relationships": [ @@ -5235,9 +5635,9 @@ }, { "from_table": "Annotation", - "to_table": "EquipmentEvent", - "from_field": "EquipmentEvent_ID", - "to_field": "EquipmentEvent_ID", + "to_table": "Event", + "from_field": "Event_ID", + "to_field": "Event_ID", "relationship_type": null }, { @@ -5261,13 +5661,6 @@ "to_field": "CampaignKind_ID", "relationship_type": null }, - { - "from_table": "Campaign", - "to_table": "Site", - "from_field": "Site_ID", - "to_field": "Site_ID", - "relationship_type": null - }, { "from_table": "Campaign", "to_table": "Person", @@ -5317,13 +5710,6 @@ "to_field": "SignalInterface_ID", "relationship_type": null }, - { - "from_table": "Channel", - "to_table": "SignalInterfacePort", - "from_field": "SignalInterfacePort_ID", - "to_field": "SignalInterfacePort_ID", - "relationship_type": null - }, { "from_table": "Channel", "to_table": "Channel", @@ -5527,34 +5913,6 @@ "to_field": "EquipmentModel_ID", "relationship_type": null }, - { - "from_table": "EquipmentEvent", - "to_table": "Equipment", - "from_field": "Equipment_ID", - "to_field": "Equipment_ID", - "relationship_type": null - }, - { - "from_table": "EquipmentEvent", - "to_table": "EquipmentEventKind", - "from_field": "EquipmentEventKind_ID", - "to_field": "EquipmentEventKind_ID", - "relationship_type": null - }, - { - "from_table": "EquipmentEvent", - "to_table": "Person", - "from_field": "PerformedByPerson_ID", - "to_field": "Person_ID", - "relationship_type": null - }, - { - "from_table": "EquipmentEvent", - "to_table": "Person", - "from_field": "RecordedByPerson_ID", - "to_field": "Person_ID", - "relationship_type": null - }, { "from_table": "EquipmentLocationHistory", "to_table": "Equipment", @@ -5625,6 +5983,83 @@ "to_field": "SignalInterfacePort_ID", "relationship_type": null }, + { + "from_table": "Event", + "to_table": "Channel", + "from_field": "Channel_ID", + "to_field": "Stream_ID", + "relationship_type": null + }, + { + "from_table": "Event", + "to_table": "Equipment", + "from_field": "Equipment_ID", + "to_field": "Equipment_ID", + "relationship_type": null + }, + { + "from_table": "Event", + "to_table": "SignalInterface", + "from_field": "SignalInterface_ID", + "to_field": "SignalInterface_ID", + "relationship_type": null + }, + { + "from_table": "Event", + "to_table": "DataAcquisitionSystem", + "from_field": "DataAcquisitionSystem_ID", + "to_field": "DataAcquisitionSystem_ID", + "relationship_type": null + }, + { + "from_table": "Event", + "to_table": "SamplingPoint", + "from_field": "SamplingPoint_ID", + "to_field": "SamplingPoint_ID", + "relationship_type": null + }, + { + "from_table": "Event", + "to_table": "ProcessUnit", + "from_field": "ProcessUnit_ID", + "to_field": "ProcessUnit_ID", + "relationship_type": null + }, + { + "from_table": "Event", + "to_table": "Site", + "from_field": "Site_ID", + "to_field": "Site_ID", + "relationship_type": null + }, + { + "from_table": "Event", + "to_table": "Campaign", + "from_field": "Campaign_ID", + "to_field": "Campaign_ID", + "relationship_type": null + }, + { + "from_table": "Event", + "to_table": "EventKind", + "from_field": "EventKind_ID", + "to_field": "EventKind_ID", + "relationship_type": null + }, + { + "from_table": "Event", + "to_table": "Person", + "from_field": "PerformedByPerson_ID", + "to_field": "Person_ID", + "relationship_type": null + }, + { + "from_table": "Event", + "to_table": "Person", + "from_field": "RecordedByPerson_ID", + "to_field": "Person_ID", + "relationship_type": null + }, { "from_table": "HydrologicalCharacteristics", "to_table": "Watershed", diff --git a/docs/reference/api/openapi.json b/docs/reference/api/openapi.json index 1cfcf1a..7cd4809 100644 --- a/docs/reference/api/openapi.json +++ b/docs/reference/api/openapi.json @@ -391,7 +391,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/SamplingLocationOut" + "$ref": "#/components/schemas/api__v1__schemas__metadata__SamplingLocationOut" }, "title": "Response List All Sampling Locations Api V1 Sites Sampling Locations Get" } @@ -462,7 +462,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SamplingLocationOut" + "$ref": "#/components/schemas/api__v1__schemas__metadata__SamplingLocationOut" } } } @@ -1213,7 +1213,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/SamplingLocationOut" + "$ref": "#/components/schemas/api__v1__schemas__metadata__SamplingLocationOut" }, "title": "Response List Sampling Locations Api V1 Sites Site Id Sampling Locations Get" } @@ -1282,7 +1282,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SamplingLocationOut" + "$ref": "#/components/schemas/api__v1__schemas__metadata__SamplingLocationOut" } } } @@ -2100,7 +2100,7 @@ "channels" ], "summary": "Open Channel Port History", - "description": "Open a ChannelPortHistory row linking a channel to a port for a time period.", + "description": "Open a ChannelPortHistory row linking a channel to a port for a time period.\n\nRoutes through ``set_channel_active_port``, the single writer of the CPH\nactive-row invariant: it closes the previous active row before opening the\nnew one, so a second post no longer collides with the\nUQ_ChannelPortHistory_ActiveRow filtered unique index (previously a 500).", "operationId": "open_channel_port_history_api_v1_channels__channel_id__port_history_post", "parameters": [ { @@ -6553,6 +6553,65 @@ } } }, + "/api/v1/equipment/{equipment_id}/active-campaign": { + "get": { + "tags": [ + "equipment-move" + ], + "summary": "Get Active Campaign Endpoint", + "description": "Return the still-running campaign whose deployment placed this equipment.\n\nReconfiguring (relocate/rewire) equipment placed by a campaign that has not\nended will close that campaign's deployment, since physical configuration is\nshared across campaigns. The move UIs call this to warn before acting. All\nfields are None when no open campaign row exists.", + "operationId": "get_active_campaign_endpoint_api_v1_equipment__equipment_id__active_campaign_get", + "parameters": [ + { + "name": "equipment_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Equipment Id" + } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActiveCampaignDeploymentResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/das/{das_id}/deploy": { "post": { "tags": [ @@ -6749,6 +6808,74 @@ } } }, + "/api/v1/das/{das_id}/move-conflicts": { + "get": { + "tags": [ + "das-move" + ], + "summary": "Move Conflicts Endpoint", + "description": "Equipment that a pending move of this DAS to ``site_id`` would strand.\n\nLists equipment currently wired to this DAS whose active location is at a\nSamplingPoint in a *different* Site than ``site_id``. Empty list = the move\nis coherent. The wizard surfaces this so the user can relocate those\nequipment too rather than leaving a silent location/DAS mismatch (which\n``vw_DeploymentCoherence`` would then report).", + "operationId": "move_conflicts_endpoint_api_v1_das__das_id__move_conflicts_get", + "parameters": [ + { + "name": "das_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Das Id" + } + }, + { + "name": "site_id", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "title": "Site Id" + } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DASMoveConflictsResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/lineage/steps": { "post": { "tags": [ @@ -7100,6 +7227,99 @@ } } }, + "/api/v1/lineage/streams/{stream_id}/pedigree": { + "get": { + "tags": [ + "lineage" + ], + "summary": "Get Stream Pedigree", + "description": "Read-only stream pedigree: time-invariant identity plus a time-bound\ndeployment timeline (sampling location, process unit, site, campaign,\nresponsible person per deployment). Optional from/to restrict the timeline to\nsegments overlapping that window (the exported range). Distinct from\n/provenance (the processing DAG). Powers the data-export metadata YAML.", + "operationId": "get_stream_pedigree_api_v1_lineage_streams__stream_id__pedigree_get", + "parameters": [ + { + "name": "stream_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Stream Id" + } + }, + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "To" + } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StreamPedigreeOut" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/ingest/lookup/units": { "get": { "tags": [ @@ -8022,7 +8242,7 @@ "ingestion" ], "summary": "Ingest Sensor", - "description": "Ingest raw sensor measurements.\n\nResolves (or creates) the Channel via the UNIQUE stream identity:\n(das_name, tag, parameter_name, data_provenance_kind_id, processing_degree).\nDAS and SignalPort are auto-created with a warning on first encounter.\nUnrecognised parameter_name or unit_name returns 422 before any DB write.", + "description": "Ingest raw sensor measurements.\n\nResolves (or creates) the Channel via the UNIQUE stream identity:\n(das_name, tag, parameter_name, data_provenance_kind_id, processing_degree).\nDAS and SignalInterface are auto-created with a warning on first encounter.\nUnrecognised parameter_name or unit_name returns 422 before any DB write.", "operationId": "ingest_sensor_api_v1_ingest_sensor_post", "parameters": [ { @@ -8082,7 +8302,7 @@ "ingestion" ], "summary": "Ingest Sensor Tagless", - "description": "Ingest raw sensor measurements from a direct-connect station (no SCADA tag).\n\nA synthetic SignalPort tag is auto-generated as\n``\"{equipment_name}/{parameter_name}\"`` (lowercased, trimmed) \u2014 deterministic\nand stable across repeated runs.\n\nOn first ingest a SignalPortEquipmentHistory row is opened immediately so\nprovenance is recorded from the start. Subsequent ingests for the same\n(DAS, equipment_name, parameter_name) are idempotent.\n\nUnrecognised equipment name produces a warning and auto-creates the\nEquipment record. Unrecognised parameter_name or unit_name returns 422 before\nany DB write.", + "description": "Ingest raw sensor measurements from a direct-connect station (no SCADA tag).\n\nA synthetic SignalInterface tag is auto-generated as\n``\"{equipment_name}/{parameter_name}\"`` (lowercased, trimmed) \u2014 deterministic\nand stable across repeated runs.\n\nOn first ingest an EquipmentWiringHistory row is opened immediately so\nprovenance is recorded from the start. Subsequent ingests for the same\n(DAS, equipment_name, parameter_name) are idempotent.\n\nUnrecognised equipment name produces a warning and auto-creates the\nEquipment record. Unrecognised parameter_name or unit_name returns 422 before\nany DB write.", "operationId": "ingest_sensor_tagless_api_v1_ingest_sensor_tagless_post", "parameters": [ { @@ -16192,40 +16412,246 @@ } } }, - "/": { + "/api/v1/data-health/unlinked-channels": { "get": { "tags": [ - "root" + "data-health" + ], + "summary": "Unlinked Channels", + "description": "Raw channels that carry observations but have no active wiring (F5).", + "operationId": "unlinked_channels_api_v1_data_health_unlinked_channels_get", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } ], - "summary": "Root", - "description": "API root \u2014 links to documentation and health check.", - "operationId": "root__get", "responses": { "200": { "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "$ref": "#/components/schemas/UnlinkedChannelsResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } } } } } } - } - }, - "components": { - "schemas": { - "AnalysisSeriesCreateRequest": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "parameter_id": { - "type": "integer", - "title": "Parameter Id" - }, - "sampling_point_id": { + }, + "/api/v1/data-health/inactive-parent-references": { + "get": { + "tags": [ + "data-health" + ], + "summary": "Inactive Parent References", + "description": "Active wiring rows still pointing at a soft-deleted interface/port (F11).\n\nOptionally filter to a single parent \u2014 the deactivation flow passes the\ninterface/port about to be set inactive to ask \"does this still have live\nchildren?\" before committing.", + "operationId": "inactive_parent_references_api_v1_data_health_inactive_parent_references_get", + "parameters": [ + { + "name": "signal_interface_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Signal Interface Id" + } + }, + { + "name": "signal_interface_port_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Signal Interface Port Id" + } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InactiveParentReferencesResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/": { + "get": { + "tags": [ + "root" + ], + "summary": "Root", + "description": "API root \u2014 links to documentation and health check.", + "operationId": "root__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ActiveCampaignDeploymentResponse": { + "properties": { + "equipment_id": { + "type": "integer", + "title": "Equipment Id" + }, + "campaign_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Campaign Id" + }, + "campaign_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Campaign Name" + }, + "equipment_location_history_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Equipment Location History Id" + }, + "sampling_point_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Sampling Point Id" + }, + "sampling_point_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sampling Point Name" + } + }, + "type": "object", + "required": [ + "equipment_id", + "campaign_id", + "campaign_name", + "equipment_location_history_id", + "sampling_point_id", + "sampling_point_name" + ], + "title": "ActiveCampaignDeploymentResponse", + "description": "The still-running campaign whose deployment placed this equipment, if any\n(consistency audit F13). ``campaign_id`` is None when no open campaign row\nexists \u2014 reconfiguring then closes no running campaign's deployment." + }, + "AnalysisSeriesCreateRequest": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "parameter_id": { + "type": "integer", + "title": "Parameter Id" + }, + "sampling_point_id": { "type": "integer", "title": "Sampling Point Id" }, @@ -17932,6 +18358,65 @@ "title": "CampaignPatch", "description": "Partial update schema for Campaign - all fields optional." }, + "CampaignPedigreeOut": { + "properties": { + "campaign_id": { + "type": "integer", + "title": "Campaign Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Kind" + }, + "start": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Start" + }, + "end": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "End" + } + }, + "type": "object", + "required": [ + "campaign_id" + ], + "title": "CampaignPedigreeOut" + }, "ChannelDerivedIn": { "properties": { "source_channel_id": { @@ -19290,6 +19775,33 @@ ], "title": "DASDeployResponse" }, + "DASMoveConflictsResponse": { + "properties": { + "das_id": { + "type": "integer", + "title": "Das Id" + }, + "site_id": { + "type": "integer", + "title": "Site Id" + }, + "stranded_equipment": { + "items": { + "$ref": "#/components/schemas/StrandedEquipment" + }, + "type": "array", + "title": "Stranded Equipment" + } + }, + "type": "object", + "required": [ + "das_id", + "site_id", + "stranded_equipment" + ], + "title": "DASMoveConflictsResponse", + "description": "Equipment wired to this DAS whose active location is at a Site other than\n``site_id`` \u2014 i.e. would be silently stranded if the DAS moves there." + }, "DasCreateIn": { "properties": { "name": { @@ -19690,12 +20202,104 @@ "title": "DeploymentOut", "description": "A deployment pairs equipment with a sampling point for a campaign." }, - "DeploymentTraceLookupItem": { + "DeploymentSegmentOut": { "properties": { - "equipment_location_history_id": { + "valid_from": { "anyOf": [ { - "type": "integer" + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Valid From" + }, + "valid_to": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Valid To" + }, + "equipment_identifier": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Equipment Identifier" + }, + "sampling_location": { + "anyOf": [ + { + "$ref": "#/components/schemas/api__v1__schemas__lineage__SamplingLocationOut" + }, + { + "type": "null" + } + ] + }, + "process_unit": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProcessUnitPedigreeOut" + }, + { + "type": "null" + } + ] + }, + "site": { + "anyOf": [ + { + "$ref": "#/components/schemas/SitePedigreeOut" + }, + { + "type": "null" + } + ] + }, + "campaign": { + "anyOf": [ + { + "$ref": "#/components/schemas/CampaignPedigreeOut" + }, + { + "type": "null" + } + ] + }, + "responsible_person": { + "anyOf": [ + { + "$ref": "#/components/schemas/ResponsiblePersonOut" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "title": "DeploymentSegmentOut", + "description": "One slice of a stream's life with a stable location + campaign. Sensor\nstreams have one per EquipmentLocationHistory they spanned; a lab series has\na single open segment (valid_from/valid_to null)." + }, + "DeploymentTraceLookupItem": { + "properties": { + "equipment_location_history_id": { + "anyOf": [ + { + "type": "integer" }, { "type": "null" @@ -21101,6 +21705,68 @@ ], "title": "ImageIngestResponse" }, + "InactiveParentReference": { + "properties": { + "reference_type": { + "type": "string", + "title": "Reference Type" + }, + "wiring_history_id": { + "type": "integer", + "title": "Wiring History Id" + }, + "equipment_id": { + "type": "integer", + "title": "Equipment Id" + }, + "parent_id": { + "type": "integer", + "title": "Parent Id" + }, + "parent_label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Parent Label" + } + }, + "type": "object", + "required": [ + "reference_type", + "wiring_history_id", + "equipment_id", + "parent_id", + "parent_label" + ], + "title": "InactiveParentReference", + "description": "An active wiring row pointing at a soft-deleted interface/port (F11)." + }, + "InactiveParentReferencesResponse": { + "properties": { + "count": { + "type": "integer", + "title": "Count" + }, + "references": { + "items": { + "$ref": "#/components/schemas/InactiveParentReference" + }, + "type": "array", + "title": "References" + } + }, + "type": "object", + "required": [ + "count", + "references" + ], + "title": "InactiveParentReferencesResponse" + }, "IngestResponse": { "properties": { "channel_id": { @@ -23255,6 +23921,52 @@ "type": "object", "title": "ProcessUnitPatch" }, + "ProcessUnitPedigreeOut": { + "properties": { + "process_unit_id": { + "type": "integer", + "title": "Process Unit Id" + }, + "tag": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tag" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Kind" + } + }, + "type": "object", + "required": [ + "process_unit_id" + ], + "title": "ProcessUnitPedigreeOut" + }, "ProcessUnitTreeOut": { "properties": { "id": { @@ -23918,6 +24630,63 @@ ], "title": "QualityCodePatch" }, + "ResponsiblePersonOut": { + "properties": { + "person_id": { + "type": "integer", + "title": "Person Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Email" + }, + "role": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Role" + }, + "company": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Company" + } + }, + "type": "object", + "required": [ + "person_id" + ], + "title": "ResponsiblePersonOut" + }, "RetuneRequest": { "properties": { "start_time": { @@ -24262,17 +25031,22 @@ ], "title": "SamplingLocationIn" }, - "SamplingLocationOut": { + "SensorChannelResolveRequest": { "properties": { - "id": { - "type": "integer", - "title": "Id" + "das_name": { + "type": "string", + "title": "Das Name" }, - "name": { + "tag": { "type": "string", - "title": "Name" + "title": "Tag" }, - "description": { + "channel_kind": { + "type": "string", + "title": "Channel Kind", + "default": "value" + }, + "parent_tag": { "anyOf": [ { "type": "string" @@ -24281,112 +25055,7 @@ "type": "null" } ], - "title": "Description" - }, - "latitude": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Latitude" - }, - "longitude": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Longitude" - }, - "site_id": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Site Id" - }, - "site_name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Site Name" - }, - "process_unit_id": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Process Unit Id" - }, - "picture_path": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Picture Path" - } - }, - "type": "object", - "required": [ - "id", - "name", - "description", - "latitude", - "longitude", - "site_id", - "site_name" - ], - "title": "SamplingLocationOut" - }, - "SensorChannelResolveRequest": { - "properties": { - "das_name": { - "type": "string", - "title": "Das Name" - }, - "tag": { - "type": "string", - "title": "Tag" - }, - "channel_kind": { - "type": "string", - "title": "Channel Kind", - "default": "value" - }, - "parent_tag": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Parent Tag" + "title": "Parent Tag" }, "parameter_name": { "type": "string", @@ -25373,6 +26042,63 @@ "title": "SitePatch", "description": "Partial update schema for Site - all fields optional." }, + "SitePedigreeOut": { + "properties": { + "site_id": { + "type": "integer", + "title": "Site Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "city": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "City" + }, + "province": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Province" + }, + "country": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Country" + } + }, + "type": "object", + "required": [ + "site_id" + ], + "title": "SitePedigreeOut" + }, "StatusCodeListResponse": { "properties": { "status_codes": { @@ -25483,6 +26209,151 @@ "title": "StatusTransition", "description": "A single status transition event." }, + "StrandedEquipment": { + "properties": { + "equipment_id": { + "type": "integer", + "title": "Equipment Id" + }, + "equipment_identifier": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Equipment Identifier" + }, + "sampling_point_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Sampling Point Id" + }, + "sampling_point_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sampling Point Name" + }, + "current_site_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Current Site Id" + }, + "current_site_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Current Site Name" + } + }, + "type": "object", + "required": [ + "equipment_id", + "equipment_identifier", + "sampling_point_id", + "sampling_point_name", + "current_site_id", + "current_site_name" + ], + "title": "StrandedEquipment", + "description": "Equipment a pending DAS move would strand (consistency audit F1)." + }, + "StreamPedigreeOut": { + "properties": { + "stream_id": { + "type": "integer", + "title": "Stream Id" + }, + "kind": { + "type": "string", + "title": "Kind" + }, + "parameter": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Parameter" + }, + "unit": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Unit" + }, + "value_kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Value Kind" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Label" + }, + "deployments": { + "items": { + "$ref": "#/components/schemas/DeploymentSegmentOut" + }, + "type": "array", + "title": "Deployments", + "default": [] + } + }, + "type": "object", + "required": [ + "stream_id", + "kind" + ], + "title": "StreamPedigreeOut", + "description": "Organizational/spatial pedigree of a stream (see\nchannel_repository.get_stream_pedigree). Distinct from provenance: the\nwho/where/why, not the processing how. Identity is time-invariant; location,\ncampaign and responsible person are a time-bound deployment timeline. Powers\nthe data-export metadata YAML." + }, "StreamStoryOut": { "properties": { "stream_id": { @@ -26037,6 +26908,108 @@ ], "title": "UnitOut" }, + "UnlinkedChannel": { + "properties": { + "channel_id": { + "type": "integer", + "title": "Channel Id" + }, + "tag_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tag Name" + }, + "signal_interface_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Signal Interface Id" + }, + "signal_interface_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Signal Interface Name" + }, + "observation_count": { + "type": "integer", + "title": "Observation Count" + }, + "first_observation": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "First Observation" + }, + "last_observation": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Last Observation" + } + }, + "type": "object", + "required": [ + "channel_id", + "tag_name", + "signal_interface_id", + "signal_interface_name", + "observation_count", + "first_observation", + "last_observation" + ], + "title": "UnlinkedChannel", + "description": "A raw channel with observations but no active wiring (F5)." + }, + "UnlinkedChannelsResponse": { + "properties": { + "count": { + "type": "integer", + "title": "Count" + }, + "channels": { + "items": { + "$ref": "#/components/schemas/UnlinkedChannel" + }, + "type": "array", + "title": "Channels" + } + }, + "type": "object", + "required": [ + "count", + "channels" + ], + "title": "UnlinkedChannelsResponse" + }, "UserOut": { "properties": { "user_id": { @@ -26883,6 +27856,152 @@ "valid_to" ], "title": "WiringAtTimeResponse" + }, + "api__v1__schemas__lineage__SamplingLocationOut": { + "properties": { + "sampling_point_id": { + "type": "integer", + "title": "Sampling Point Id" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "latitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Latitude" + }, + "longitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Longitude" + } + }, + "type": "object", + "required": [ + "sampling_point_id" + ], + "title": "SamplingLocationOut" + }, + "api__v1__schemas__metadata__SamplingLocationOut": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "latitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Latitude" + }, + "longitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Longitude" + }, + "site_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Site Id" + }, + "site_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Site Name" + }, + "process_unit_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Process Unit Id" + }, + "picture_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Picture Path" + } + }, + "type": "object", + "required": [ + "id", + "name", + "description", + "latitude", + "longitude", + "site_id", + "site_name" + ], + "title": "SamplingLocationOut" } } } diff --git a/docs/reference/erd.md b/docs/reference/erd.md index eabd650..98c3a5d 100644 --- a/docs/reference/erd.md +++ b/docs/reference/erd.md @@ -42,5 +42,5 @@ Relationships use standard crow's foot notation: The current schema contains: - **75** tables -- **4** views -- **129** relationships +- **8** views +- **134** relationships diff --git a/docs/reference/tables.md b/docs/reference/tables.md index f54994c..8d30c87 100644 --- a/docs/reference/tables.md +++ b/docs/reference/tables.md @@ -67,7 +67,7 @@ Human-authored annotations on time series data. Each annotation anchors to a sin | EndTime | DATETIME2(7) | - | | End of the annotated range. NULL = point annotation or ongoing | - | | AuthorPerson_ID | INT | - | | Person who created this annotation | FK → [Person.Person_ID](#Person) | | Campaign_ID | INT | - | | Campaign this annotation is associated with, if any | FK → [Campaign.Campaign_ID](#Campaign) | -| EquipmentEvent_ID | INT | - | | Equipment event that caused this annotation, if any | FK → [EquipmentEvent.EquipmentEvent_ID](#EquipmentEvent) | +| Event_ID | INT | - | | Event that caused this annotation, if any (causal link: Event=cause, Annotation=effect) | FK → [Event.Event_ID](#Event) | | Title | NVARCHAR(200) | - | | Short title for the annotation | - | | Comment | NVARCHAR(MAX) | - | | Detailed free-text comment | - | | CreatedDateTime | DATETIME2(7) | - | ✓ | When this annotation was created | Default: `CURRENT_TIMESTAMP` | @@ -131,7 +131,7 @@ Controlled vocabulary defining how bins on a ValueBinningAxis are specified. Eac ### Campaign -A named collection of measurement activities at a site, classified by type (Experiment, Operations, Commissioning). Supersedes Project (defunct table) for all organisational grouping. +A named collection of measurement activities, classified by type (Experiment, Operations, Commissioning). Campaigns are multi-site: their sites are derived from sampling-location membership (CampaignSamplingLocation to SamplingPoint.Site), not stored. Supersedes Project (defunct table) for all organisational grouping. #### Fields @@ -140,7 +140,6 @@ A named collection of measurement activities at a site, classified by type (Expe |-------|----------|-----------|----------|-------------|-------------| | Campaign_ID | INT **(PK)** | - | ✓ | Surrogate primary key. | - | | CampaignKind_ID | INT | - | ✓ | Kind of campaign (See CampaignKind table. E.g., Experiment, Monitoring, Facility Commissioning). | FK → [CampaignKind.CampaignKind_ID](#CampaignKind) | -| Site_ID | INT | - | ✓ | Site where the campaign is conducted. | FK → [Site.Site_ID](#Site) | | Name | NVARCHAR(200) | - | ✓ | Human-readable name for the campaign. | - | | Description | NVARCHAR(2000) | - | | Detailed description of the campaign objectives and scope. | - | | CampaignStartDateTime | DATETIME2(7) | - | | Date and time the campaign began (UTC). | - | @@ -196,7 +195,7 @@ Junction table: sampling locations actively monitored during a campaign. ### Channel -Invariant descriptor for a measurement stream (sensor channel). Channel is the sensor subtype of Stream (table-per-type inheritance): it shares Stream_ID as its own primary key, which is simultaneously a foreign key to Stream.Stream_ID. Each row is identified by a unique (SignalInterface, TagName, Parameter, DataProvenance, ProducedByStep) combination. A Channel is created once and never changes — equipment swaps and sensor relocations are tracked on the physical Equipment via EquipmentWiringHistory and EquipmentLocationHistory, leaving Stream_ID stable. The specific SignalInterfacePort carrying the stream is optional at ingest time and can be backfilled later via ChannelPortHistory (and the denormalised SignalInterfacePort_ID below). Lab sample results are stored in LabAnalysis + LabValue (not in Channel). +Invariant descriptor for a measurement stream (sensor channel). Channel is the sensor subtype of Stream (table-per-type inheritance): it shares Stream_ID as its own primary key, which is simultaneously a foreign key to Stream.Stream_ID. Each row is identified by a unique (SignalInterface, TagName, Parameter, DataProvenance, ProducedByStep) combination. A Channel is created once and never changes — equipment swaps and sensor relocations are tracked on the physical Equipment via EquipmentWiringHistory and EquipmentLocationHistory, leaving Stream_ID stable. The specific SignalInterfacePort carrying the stream is optional at ingest time and is recorded over time in ChannelPortHistory (the active row is the current port). Queries that need the current port resolve it through the vw_ChannelResolved view, so there is a single source of truth and no denormalised column to drift. Lab sample results are stored in LabAnalysis + LabValue (not in Channel). Raw/ingested channels have SignalInterface_ID NOT NULL and ProducedByStep_ID NULL. Derived/processed channels have SignalInterface_ID NULL and ProducedByStep_ID pointing to the ProcessingStep that produced them. Accumulated processing operations applied to a Channel live in the ChannelTrait junction (to be added in a later slice). @@ -211,8 +210,6 @@ Raw/ingested channels have SignalInterface_ID NOT NULL and ProducedByStep_ID NUL | FK → [SignalInterface.SignalInterface_ID](#SignalInterface) | | TagName | NVARCHAR(200) | - | ✓ | Tag string as published by the SignalInterface (case-preserved; lookups are case-insensitive trimmed). For direct-connect interfaces a synthetic tag such as "{equipment_identifier}/{parameter_name}" is auto-generated. | - | -| SignalInterfacePort_ID | INT | - | | Current physical port (if known) this Channel is gated through. Denormalised from the active ChannelPortHistory row for query convenience. NULL when the wiring has not yet been traced. - | FK → [SignalInterfacePort.SignalInterfacePort_ID](#SignalInterfacePort) | | ParentChannel_ID | INT | - | | For sub-signal Channels (Status, Alarm, Uncertainty), points to the parent value Channel's Stream_ID. NULL for primary value channels and unlinked channels. Self-FK. | FK → [Channel.Stream_ID](#Channel) | | ChannelKind_ID | INT | - | ✓ | Kind of information this Channel carries (1=Value, 2=Status, 3=Alarm, 4=Uncertainty). @@ -510,42 +507,6 @@ Stores information about a specific physical piece of equipment (e.g., serial nu | IsActive | BIT | - | ✓ | Whether this equipment is currently in service. Set to false when decommissioned. Decommissioning should also be recorded as an EquipmentEvent for auditability. | Default: `True` | - - -### EquipmentEvent - -Records a discrete lifecycle event (calibration, maintenance, failure, etc.) that occurred on a specific piece of equipment. - - -#### Fields - -| Field | SQL Type | Value Set | Required | Description | Constraints | -|-------|----------|-----------|----------|-------------|-------------| -| EquipmentEvent_ID | INT **(PK)** | - | ✓ | Surrogate primary key | - | -| Equipment_ID | INT | - | ✓ | Equipment on which the event occurred | FK → [Equipment.Equipment_ID](#Equipment) | -| EquipmentEventKind_ID | INT | - | ✓ | Kind of lifecycle event | FK → [EquipmentEventKind.EquipmentEventKind_ID](#EquipmentEventKind) | -| EventDateTimeStart | DATETIME2(7) | - | ✓ | Date and time the event began (UTC) | - | -| IsInstantaneous | BIT | - | ✓ | True if the event occurred at a single point in time. When true, EventDateTimeEnd must be NULL. When false and EventDateTimeEnd is NULL, the event is ongoing. | Default: `False` | -| EventDateTimeEnd | DATETIME2(7) | - | | Date and time the event ended (UTC). NULL when IsInstantaneous=1 (point-in-time) or when the event is still ongoing (IsInstantaneous=0). | - | -| PerformedByPerson_ID | INT | - | | Person who physically performed the event (e.g. technician on site) | FK → [Person.Person_ID](#Person) | -| RecordedByPerson_ID | INT | - | | Person who entered this record into the system (may differ from PerformedByPerson_ID) | FK → [Person.Person_ID](#Person) | -| Notes | NVARCHAR(MAX) | - | | Free-text notes about the event | - | - - - -### EquipmentEventKind - -Controlled vocabulary classifying the kind of lifecycle event that occurred on a piece of equipment (Calibration, Maintenance, etc.) - - -#### Fields - -| Field | SQL Type | Value Set | Required | Description | Constraints | -|-------|----------|-----------|----------|-------------|-------------| -| EquipmentEventKind_ID | INT **(PK)** | - | ✓ | Surrogate primary key | - | -| Name | NVARCHAR(100) | - | ✓ | Name of the equipment event kind | - | -| Description | NVARCHAR(300) | - | | Explanation of what this kind of equipment event involves | - | - ### EquipmentLocationHistory @@ -632,6 +593,53 @@ Temporal record of how a piece of Equipment is wired to a SignalInterface (and o | ValidTo | DATETIME2(7) | - | | UTC datetime when this wiring ended. NULL = currently wired. | - | | Note | NVARCHAR(MAX) | - | | Free-text notes (reason for rewire, calibration context) | - | + + +### Event + +Records a discrete, time-stamped operational occurrence (calibration, cleaning, power outage, PLC crash, site visit, …) at any node of the physical hierarchy. Each Event attaches to exactly one target via an exclusive arc of eight nullable FKs — a CHECK constraint enforces that exactly one is non-NULL. Generalises the former EquipmentEvent (which was restricted to Equipment). + + + +#### Fields + +| Field | SQL Type | Value Set | Required | Description | Constraints | +|-------|----------|-----------|----------|-------------|-------------| +| Event_ID | INT **(PK)** | - | ✓ | Surrogate primary key | - | +| Channel_ID | INT | - | | Leaf target: the specific sensor Channel the event concerns (references Channel.Stream_ID) | FK → [Channel.Stream_ID](#Channel) | +| Equipment_ID | INT | - | | Target: the Equipment the event concerns (sensor, actuator, …) | FK → [Equipment.Equipment_ID](#Equipment) | +| SignalInterface_ID | INT | - | | Target: the SignalInterface (field bus, port block) the event concerns | FK → [SignalInterface.SignalInterface_ID](#SignalInterface) | +| DataAcquisitionSystem_ID | INT | - | | Target: the DataAcquisitionSystem (PLC, logger) the event concerns | FK → [DataAcquisitionSystem.DataAcquisitionSystem_ID](#DataAcquisitionSystem) | +| SamplingPoint_ID | INT | - | | Target: the SamplingPoint the event concerns | FK → [SamplingPoint.SamplingPoint_ID](#SamplingPoint) | +| ProcessUnit_ID | INT | - | | Target: the ProcessUnit the event concerns | FK → [ProcessUnit.ProcessUnit_ID](#ProcessUnit) | +| Site_ID | INT | - | | Target: the Site the event concerns (site-wide power outage, etc.) | FK → [Site.Site_ID](#Site) | +| Campaign_ID | INT | - | | Target: the Campaign the event concerns | FK → [Campaign.Campaign_ID](#Campaign) | +| EventKind_ID | INT | - | ✓ | Kind of event (calibration, cleaning, power outage, …) | FK → [EventKind.EventKind_ID](#EventKind) | +| EventDateTimeStart | DATETIME2(7) | - | ✓ | Date and time the event began (UTC) | - | +| IsInstantaneous | BIT | - | ✓ | True if the event occurred at a single point in time. When true, EventDateTimeEnd must be NULL. When false and EventDateTimeEnd is NULL, the event is ongoing. + | Default: `False` | +| EventDateTimeEnd | DATETIME2(7) | - | | Date and time the event ended (UTC). NULL when IsInstantaneous=1 (point-in-time) or when the event is still ongoing (IsInstantaneous=0). + | - | +| PerformedByPerson_ID | INT | - | | Person who physically performed the event (e.g. technician on site) | FK → [Person.Person_ID](#Person) | +| RecordedByPerson_ID | INT | - | | Person who entered this record into the system (may differ from PerformedByPerson_ID) | FK → [Person.Person_ID](#Person) | +| Notes | NVARCHAR(MAX) | - | | Free-text notes about the event | - | + + + +### EventKind + +Controlled vocabulary classifying the kind of operational event (Calibration, Cleaning, PowerOutage, ControllerCrash, …). Generalises the former EquipmentEventKind to cover all targets in the exclusive-arc Event table. + + + +#### Fields + +| Field | SQL Type | Value Set | Required | Description | Constraints | +|-------|----------|-----------|----------|-------------|-------------| +| EventKind_ID | INT **(PK)** | - | ✓ | Surrogate primary key | - | +| Name | NVARCHAR(100) | - | ✓ | Name of the event kind | - | +| Description | NVARCHAR(300) | - | | Explanation of what this kind of event involves | - | + ### HydrologicalCharacteristics diff --git a/docs/reference/views.md b/docs/reference/views.md index 21f4ff2..a344c57 100644 --- a/docs/reference/views.md +++ b/docs/reference/views.md @@ -30,7 +30,7 @@ WITH channel_wiring AS ( ) AS rn, COUNT(*) OVER (PARTITION BY o.[Observation_ID]) AS match_count FROM [dbo].[Observation] o - JOIN [dbo].[Channel] c ON c.[Stream_ID] = o.[Channel_ID] + JOIN [dbo].[vw_ChannelResolved] c ON c.[Stream_ID] = o.[Channel_ID] LEFT JOIN [dbo].[EquipmentWiringHistory] ewh ON ewh.[SignalInterface_ID] = c.[SignalInterface_ID] AND ( ewh.[SignalInterfacePort_ID] = c.[SignalInterfacePort_ID] @@ -73,7 +73,7 @@ WHERE cw.rn = 1 ## vw_ChannelLocationAtTime -Resolves the SamplingPoint a Channel was sampling at the time of each Observation, by composing vw_ChannelEquipmentAtTime with EquipmentLocationHistory. When equipment cannot be resolved (ambiguous or unlinked wiring), SamplingPointID is NULL. +Resolves the SamplingPoint a Channel was sampling at the time of each Observation, by composing vw_ChannelEquipmentAtTime with EquipmentLocationHistory. SamplingPointID is NULL whenever the chain is broken; the Resolution (equipment leg) and LocationResolution (location leg) discriminators say *why*, so NULL-because-broken is distinguishable from NULL-because-genuinely-absent (consistency audit F6). @@ -85,8 +85,14 @@ SELECT cea.ChannelID, cea.Timestamp, cea.EquipmentID, + cea.Resolution, elh.[SamplingPoint_ID] AS SamplingPointID, - sp.[SamplingPoint] AS SamplingPointName + sp.[SamplingPoint] AS SamplingPointName, + CASE + WHEN cea.EquipmentID IS NULL THEN N'no-equipment' + WHEN elh.[SamplingPoint_ID] IS NOT NULL THEN N'resolved' + ELSE N'no-location' + END AS LocationResolution FROM [dbo].[vw_ChannelEquipmentAtTime] cea LEFT JOIN [dbo].[EquipmentLocationHistory] elh ON elh.[Equipment_ID] = cea.EquipmentID AND elh.[ValidFrom] <= cea.Timestamp @@ -104,8 +110,57 @@ LEFT JOIN [dbo].[SamplingPoint] sp ON sp.[SamplingPoint_ID] = elh.[SamplingPoint | ChannelID | INT | `ChannelID` | Channel this observation belongs to | | Timestamp | DATETIME2(7) | `Timestamp` | Observation timestamp used for resolution | | EquipmentID | INT | `EquipmentID` | Equipment resolved at this time (NULL if unresolved) | +| Resolution | NVARCHAR(20) | `Resolution` | Equipment leg, passed through from vw_ChannelEquipmentAtTime: 'resolved' | 'unlinked' | 'ambiguous' | | SamplingPointID | INT | `SamplingPointID` | SamplingPoint where the equipment was installed at this time | | SamplingPointName | NVARCHAR(200) | `SamplingPointName` | Name of the SamplingPoint | +| LocationResolution | NVARCHAR(20) | `LocationResolution` | Location leg: 'resolved' (active ELH row) | 'no-location' (equipment resolved but no covering ELH row) | 'no-equipment' (equipment leg broken upstream, see Resolution) | + + + +## vw_ChannelResolved + +Channel with its current SignalInterfacePort resolved from the active ChannelPortHistory row (ValidTo IS NULL). Replaces the former denormalised Channel.SignalInterfacePort_ID column: there is exactly one source of truth (ChannelPortHistory) so the port can never drift. Every read that needs the current port selects FROM this view instead of FROM the Channel base table; the equipment-resolution join predicates are otherwise unchanged. The filtered unique index UQ_ChannelPortHistory_ActiveRow guarantees at most one active row per channel, so this view returns exactly one row per Channel. + + + +**View Definition:** + +```sql +SELECT + c.[Stream_ID], + c.[SignalInterface_ID], + c.[TagName], + cph.[SignalInterfacePort_ID], + c.[ParentChannel_ID], + c.[ChannelKind_ID], + c.[Parameter_ID], + c.[DataProvenanceKind_ID], + c.[ProducedByStep_ID], + c.[ValueKind_ID], + c.[Unit_ID] +FROM [dbo].[Channel] c +LEFT JOIN [dbo].[ChannelPortHistory] cph + ON cph.[Channel_ID] = c.[Stream_ID] + AND cph.[ValidTo] IS NULL + +``` + + +#### Columns + +| Column | SQL Type | Source Field | Description | +|--------|----------|--------------|-------------| +| Stream_ID | INT | `Stream_ID` | Channel primary key (shared with Stream) | +| SignalInterface_ID | INT | `SignalInterface_ID` | Publishing SignalInterface (NULL for derived channels) | +| TagName | NVARCHAR(200) | `TagName` | Published tag string | +| SignalInterfacePort_ID | INT | `SignalInterfacePort_ID` | Current port from the active ChannelPortHistory row (NULL if untraced) | +| ParentChannel_ID | INT | `ParentChannel_ID` | Parent value channel for sub-signals (Status/Alarm/Uncertainty) | +| ChannelKind_ID | INT | `ChannelKind_ID` | 1=Value, 2=Status, 3=Alarm, 4=Uncertainty | +| Parameter_ID | INT | `Parameter_ID` | Measured analyte or parameter | +| DataProvenanceKind_ID | INT | `DataProvenanceKind_ID` | How the data was produced | +| ProducedByStep_ID | INT | `ProducedByStep_ID` | ProcessingStep that produced a derived channel (NULL for raw) | +| ValueKind_ID | INT | `ValueKind_ID` | Shape of stored values (1=Scalar, ...) | +| Unit_ID | INT | `Unit_ID` | Unit of measurement | @@ -130,7 +185,7 @@ FROM [dbo].[Value] v JOIN [dbo].[Observation] o ON o.[Observation_ID] = v.[Observation_ID] JOIN [dbo].[Channel] statusC ON statusC.[Stream_ID] = o.[Channel_ID] JOIN [dbo].[ChannelKind] role ON role.[ChannelKind_ID] = statusC.[ChannelKind_ID] -JOIN [dbo].[Channel] valueC ON valueC.[Stream_ID] = statusC.[ParentChannel_ID] +JOIN [dbo].[vw_ChannelResolved] valueC ON valueC.[Stream_ID] = statusC.[ParentChannel_ID] JOIN [dbo].[Parameter] p ON p.[Parameter_ID] = valueC.[Parameter_ID] LEFT JOIN [dbo].[EquipmentWiringHistory] ewh ON ewh.[SignalInterface_ID] = valueC.[SignalInterface_ID] @@ -158,6 +213,60 @@ WHERE role.[Name] = N'Status' | Timestamp | DATETIME2(7) | `Timestamp` | Timestamp of the status observation | | StatusCodeID | INT | `StatusCodeID` | Raw integer status code stored in the status Channel's Value rows | + + +## vw_DeploymentCoherence + +Surfaces deployment drift (consistency audit F1): equipment whose active location sits at a Site different from the Site where its connected Data Acquisition System is currently deployed. A DAS is deployed to a Site (DASLocationHistory); equipment is wired to that DAS's SignalInterfaces (EquipmentWiringHistory) and physically placed at a SamplingPoint (EquipmentLocationHistory), and every SamplingPoint belongs to a Site. When a DAS is moved to a new Site, the equipment it feeds does not move with it automatically — this view lists each such stranded equipment so the drift can be corrected (relocate the equipment, or move the DAS back). Only active rows (ValidTo IS NULL) on both histories are considered; a row appears here only while the mismatch is live. + + + +**View Definition:** + +```sql +SELECT + dlh.[DataAcquisitionSystem_ID] AS DAS_ID, + das.[Name] AS DASName, + dlh.[Site_ID] AS DASSite_ID, + dsite.[Name] AS DASSiteName, + e.[Equipment_ID] AS Equipment_ID, + e.[Identifier] AS EquipmentName, + sp.[Site_ID] AS EquipmentSite_ID, + esite.[Name] AS EquipmentSiteName, + sp.[SamplingPoint_ID] AS SamplingPoint_ID, + sp.[SamplingPoint] AS SamplingPointName +FROM [dbo].[DASLocationHistory] dlh +JOIN [dbo].[DataAcquisitionSystem] das ON das.[DataAcquisitionSystem_ID] = dlh.[DataAcquisitionSystem_ID] +JOIN [dbo].[SignalInterface] si ON si.[DataAcquisitionSystem_ID] = dlh.[DataAcquisitionSystem_ID] +JOIN [dbo].[EquipmentWiringHistory] ewh ON ewh.[SignalInterface_ID] = si.[SignalInterface_ID] + AND ewh.[ValidTo] IS NULL +JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = ewh.[Equipment_ID] +JOIN [dbo].[EquipmentLocationHistory] elh ON elh.[Equipment_ID] = e.[Equipment_ID] + AND elh.[ValidTo] IS NULL +JOIN [dbo].[SamplingPoint] sp ON sp.[SamplingPoint_ID] = elh.[SamplingPoint_ID] +LEFT JOIN [dbo].[Site] dsite ON dsite.[Site_ID] = dlh.[Site_ID] +LEFT JOIN [dbo].[Site] esite ON esite.[Site_ID] = sp.[Site_ID] +WHERE dlh.[ValidTo] IS NULL + AND sp.[Site_ID] <> dlh.[Site_ID] + +``` + + +#### Columns + +| Column | SQL Type | Source Field | Description | +|--------|----------|--------------|-------------| +| DAS_ID | INT | `DAS_ID` | The deployed Data Acquisition System | +| DASName | NVARCHAR(200) | `DASName` | DAS name | +| DASSite_ID | INT | `DASSite_ID` | Site the DAS is currently deployed to | +| DASSiteName | NVARCHAR(200) | `DASSiteName` | Name of the DAS's Site | +| Equipment_ID | INT | `Equipment_ID` | Equipment wired to the DAS but located elsewhere | +| EquipmentName | NVARCHAR(200) | `EquipmentName` | Equipment identifier | +| EquipmentSite_ID | INT | `EquipmentSite_ID` | Site of the equipment's current SamplingPoint (the drift) | +| EquipmentSiteName | NVARCHAR(200) | `EquipmentSiteName` | Name of the equipment's current Site | +| SamplingPoint_ID | INT | `SamplingPoint_ID` | Equipment's current SamplingPoint | +| SamplingPointName | NVARCHAR(200) | `SamplingPointName` | Name of the equipment's current SamplingPoint | + ## vw_DeviceStatus @@ -179,7 +288,7 @@ FROM [dbo].[Value] v JOIN [dbo].[Observation] o ON o.[Observation_ID] = v.[Observation_ID] JOIN [dbo].[Channel] statusC ON statusC.[Stream_ID] = o.[Channel_ID] JOIN [dbo].[ChannelKind] role ON role.[ChannelKind_ID] = statusC.[ChannelKind_ID] -JOIN [dbo].[Channel] valueC ON valueC.[Stream_ID] = statusC.[ParentChannel_ID] +JOIN [dbo].[vw_ChannelResolved] valueC ON valueC.[Stream_ID] = statusC.[ParentChannel_ID] JOIN [dbo].[EquipmentWiringHistory] ewh ON ewh.[SignalInterface_ID] = valueC.[SignalInterface_ID] AND ( @@ -201,4 +310,96 @@ WHERE role.[Name] = N'Status' | EquipmentID | INT | `EquipmentID` | Equipment ID this status describes | | EquipmentName | NVARCHAR(200) | `EquipmentName` | Identifier of the equipment | | Timestamp | DATETIME2(7) | `Timestamp` | Timestamp of the status observation | -| StatusCodeID | INT | `StatusCodeID` | Raw integer status code stored in the status Channel's Value rows | \ No newline at end of file +| StatusCodeID | INT | `StatusCodeID` | Raw integer status code stored in the status Channel's Value rows | + + + +## vw_InactiveParentReferences + +Health view (consistency audit F11): live references to soft-deleted parents. The model leans on IsActive soft-deletes for SignalInterface and SignalInterfacePort, but setting IsActive=0 does nothing to the active EquipmentWiringHistory rows still pointing at that interface/port — they keep resolving as if active. This view lists each active wiring row (ValidTo IS NULL) whose referenced SignalInterface or SignalInterfacePort is inactive, so the app can warn before deactivating a parent that still has live children (and operators can reconcile existing orphans). One row per dangling reference; ReferenceType says which leg is stale. + + + +**View Definition:** + +```sql +SELECT + N'active-wiring->interface' AS ReferenceType, + ewh.[EquipmentWiringHistory_ID] AS WiringHistoryID, + ewh.[Equipment_ID] AS EquipmentID, + si.[SignalInterface_ID] AS ParentID, + si.[Name] AS ParentLabel +FROM [dbo].[EquipmentWiringHistory] ewh +JOIN [dbo].[SignalInterface] si ON si.[SignalInterface_ID] = ewh.[SignalInterface_ID] +WHERE ewh.[ValidTo] IS NULL + AND si.[IsActive] = 0 +UNION ALL +SELECT + N'active-wiring->port' AS ReferenceType, + ewh.[EquipmentWiringHistory_ID] AS WiringHistoryID, + ewh.[Equipment_ID] AS EquipmentID, + sip.[SignalInterfacePort_ID] AS ParentID, + sip.[PortIdentifier] AS ParentLabel +FROM [dbo].[EquipmentWiringHistory] ewh +JOIN [dbo].[SignalInterfacePort] sip ON sip.[SignalInterfacePort_ID] = ewh.[SignalInterfacePort_ID] +WHERE ewh.[ValidTo] IS NULL + AND sip.[IsActive] = 0 + +``` + + +#### Columns + +| Column | SQL Type | Source Field | Description | +|--------|----------|--------------|-------------| +| ReferenceType | NVARCHAR(40) | `ReferenceType` | 'active-wiring->interface' | 'active-wiring->port' | +| WiringHistoryID | INT | `WiringHistoryID` | The active EquipmentWiringHistory row holding the stale reference | +| EquipmentID | INT | `EquipmentID` | Equipment wired by that row | +| ParentID | INT | `ParentID` | SignalInterface_ID or SignalInterfacePort_ID that is inactive | +| ParentLabel | NVARCHAR(200) | `ParentLabel` | Inactive parent's name (interface) or port identifier | + + + +## vw_UnlinkedChannels + +Health view (consistency audit F5): raw/ingested channels that carry Observations but have no active EquipmentWiringHistory on their SignalInterface, so every observation resolves as 'unlinked' (see vw_ChannelEquipmentAtTime) with no equipment and no SamplingPoint. These are channels that were ingested but never linked to physical equipment — "N channels need wiring". Only raw channels are considered: derived/ processed channels have SignalInterface_ID NULL by design and are deliberately unwired, not forgotten, so they are excluded. A channel drops off this list the moment an active wiring row exists for its interface. + + + +**View Definition:** + +```sql +SELECT + c.[Stream_ID] AS ChannelID, + c.[TagName] AS TagName, + c.[SignalInterface_ID] AS SignalInterfaceID, + si.[Name] AS SignalInterfaceName, + COUNT(o.[Observation_ID]) AS ObservationCount, + MIN(o.[Timestamp]) AS FirstObservation, + MAX(o.[Timestamp]) AS LastObservation +FROM [dbo].[Channel] c +JOIN [dbo].[Observation] o ON o.[Channel_ID] = c.[Stream_ID] +LEFT JOIN [dbo].[SignalInterface] si ON si.[SignalInterface_ID] = c.[SignalInterface_ID] +WHERE c.[SignalInterface_ID] IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM [dbo].[EquipmentWiringHistory] ewh + WHERE ewh.[SignalInterface_ID] = c.[SignalInterface_ID] + AND ewh.[ValidTo] IS NULL + ) +GROUP BY c.[Stream_ID], c.[TagName], c.[SignalInterface_ID], si.[Name] + +``` + + +#### Columns + +| Column | SQL Type | Source Field | Description | +|--------|----------|--------------|-------------| +| ChannelID | INT | `ChannelID` | Channel (Stream_ID) lacking active wiring | +| TagName | NVARCHAR(200) | `TagName` | Channel tag name | +| SignalInterfaceID | INT | `SignalInterfaceID` | The channel's SignalInterface (has no active wiring row) | +| SignalInterfaceName | NVARCHAR(200) | `SignalInterfaceName` | Name of the SignalInterface | +| ObservationCount | INT | `ObservationCount` | How many observations are stranded on this unlinked channel | +| FirstObservation | DATETIME2(7) | `FirstObservation` | Earliest stranded observation timestamp | +| LastObservation | DATETIME2(7) | `LastObservation` | Latest stranded observation timestamp | \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index b08ab5d..d05d86d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,9 @@ app = [ "streamlit>=1.40", "httpx>=0.28", "plotly>=5.24", + # Pinned to the classic components.v1 release: 0.7.x switched to the + # bleeding-edge st.components.v2 API and fails to import on Streamlit 1.55. + "streamlit-echarts==0.4.0", "folium>=0.17", "streamlit-folium>=0.22", "streamlit-searchbox>=0.1.24", diff --git a/schema_dictionary/tables/EquipmentEvent.yaml b/schema_dictionary/deprecated/EquipmentEvent.yaml similarity index 100% rename from schema_dictionary/tables/EquipmentEvent.yaml rename to schema_dictionary/deprecated/EquipmentEvent.yaml diff --git a/schema_dictionary/tables/EquipmentEventKind.yaml b/schema_dictionary/deprecated/EquipmentEventKind.yaml similarity index 100% rename from schema_dictionary/tables/EquipmentEventKind.yaml rename to schema_dictionary/deprecated/EquipmentEventKind.yaml diff --git a/schema_dictionary/tables/Annotation.yaml b/schema_dictionary/tables/Annotation.yaml index 8830f21..5468154 100644 --- a/schema_dictionary/tables/Annotation.yaml +++ b/schema_dictionary/tables/Annotation.yaml @@ -57,13 +57,13 @@ table: foreign_key: table: Campaign column: Campaign_ID - - name: EquipmentEvent_ID + - name: Event_ID logical_type: integer nullable: true - description: "Equipment event that caused this annotation, if any" + description: "Event that caused this annotation, if any (causal link: Event=cause, Annotation=effect)" foreign_key: - table: EquipmentEvent - column: EquipmentEvent_ID + table: Event + column: Event_ID - name: Title logical_type: string max_length: 200 diff --git a/schema_dictionary/tables/Campaign.yaml b/schema_dictionary/tables/Campaign.yaml index 40b515f..c22dc78 100644 --- a/schema_dictionary/tables/Campaign.yaml +++ b/schema_dictionary/tables/Campaign.yaml @@ -2,7 +2,7 @@ _format_version: "1.0" table: name: Campaign schema: dbo - description: "A named collection of measurement activities at a site, classified by type (Experiment, Operations, Commissioning). Supersedes Project (defunct table) for all organisational grouping." + description: "A named collection of measurement activities, classified by type (Experiment, Operations, Commissioning). Campaigns are multi-site: their sites are derived from sampling-location membership (CampaignSamplingLocation to SamplingPoint.Site), not stored. Supersedes Project (defunct table) for all organisational grouping." columns: - name: Campaign_ID @@ -17,13 +17,6 @@ table: foreign_key: table: CampaignKind column: CampaignKind_ID - - name: Site_ID - logical_type: integer - nullable: false - description: "Site where the campaign is conducted." - foreign_key: - table: Site - column: Site_ID - name: Name logical_type: string max_length: 200 diff --git a/schema_dictionary/tables/Channel.yaml b/schema_dictionary/tables/Channel.yaml index fafe2f1..2a2200c 100644 --- a/schema_dictionary/tables/Channel.yaml +++ b/schema_dictionary/tables/Channel.yaml @@ -12,8 +12,10 @@ table: swaps and sensor relocations are tracked on the physical Equipment via EquipmentWiringHistory and EquipmentLocationHistory, leaving Stream_ID stable. The specific SignalInterfacePort carrying the stream is - optional at ingest time and can be backfilled later via - ChannelPortHistory (and the denormalised SignalInterfacePort_ID below). + optional at ingest time and is recorded over time in ChannelPortHistory + (the active row is the current port). Queries that need the current port + resolve it through the vw_ChannelResolved view, so there is a single + source of truth and no denormalised column to drift. Lab sample results are stored in LabAnalysis + LabValue (not in Channel). Raw/ingested channels have SignalInterface_ID NOT NULL and ProducedByStep_ID NULL. @@ -56,17 +58,6 @@ table: direct-connect interfaces a synthetic tag such as "{equipment_identifier}/{parameter_name}" is auto-generated. - - name: SignalInterfacePort_ID - logical_type: integer - nullable: true - description: > - Current physical port (if known) this Channel is gated through. - Denormalised from the active ChannelPortHistory row for query - convenience. NULL when the wiring has not yet been traced. - foreign_key: - table: SignalInterfacePort - column: SignalInterfacePort_ID - - name: ParentChannel_ID logical_type: integer nullable: true diff --git a/schema_dictionary/tables/Event.yaml b/schema_dictionary/tables/Event.yaml new file mode 100644 index 0000000..7995cbe --- /dev/null +++ b/schema_dictionary/tables/Event.yaml @@ -0,0 +1,168 @@ +_format_version: "1.0" +table: + name: Event + schema: dbo + description: > + Records a discrete, time-stamped operational occurrence (calibration, cleaning, + power outage, PLC crash, site visit, …) at any node of the physical hierarchy. + Each Event attaches to exactly one target via an exclusive arc of eight nullable + FKs — a CHECK constraint enforces that exactly one is non-NULL. + Generalises the former EquipmentEvent (which was restricted to Equipment). + + columns: + - name: Event_ID + logical_type: integer + nullable: false + identity: true + description: "Surrogate primary key" + + # ── Exclusive-arc target (exactly one non-NULL enforced by CK_Event_ExclusiveArc) ── + - name: Channel_ID + logical_type: integer + nullable: true + description: "Leaf target: the specific sensor Channel the event concerns (references Channel.Stream_ID)" + foreign_key: + table: Channel + column: Stream_ID + + - name: Equipment_ID + logical_type: integer + nullable: true + description: "Target: the Equipment the event concerns (sensor, actuator, …)" + foreign_key: + table: Equipment + column: Equipment_ID + + - name: SignalInterface_ID + logical_type: integer + nullable: true + description: "Target: the SignalInterface (field bus, port block) the event concerns" + foreign_key: + table: SignalInterface + column: SignalInterface_ID + + - name: DataAcquisitionSystem_ID + logical_type: integer + nullable: true + description: "Target: the DataAcquisitionSystem (PLC, logger) the event concerns" + foreign_key: + table: DataAcquisitionSystem + column: DataAcquisitionSystem_ID + + - name: SamplingPoint_ID + logical_type: integer + nullable: true + description: "Target: the SamplingPoint the event concerns" + foreign_key: + table: SamplingPoint + column: SamplingPoint_ID + + - name: ProcessUnit_ID + logical_type: integer + nullable: true + description: "Target: the ProcessUnit the event concerns" + foreign_key: + table: ProcessUnit + column: ProcessUnit_ID + + - name: Site_ID + logical_type: integer + nullable: true + description: "Target: the Site the event concerns (site-wide power outage, etc.)" + foreign_key: + table: Site + column: Site_ID + + - name: Campaign_ID + logical_type: integer + nullable: true + description: "Target: the Campaign the event concerns" + foreign_key: + table: Campaign + column: Campaign_ID + + # ── Classification and timing ─────────────────────────────────────────── + - name: EventKind_ID + logical_type: integer + nullable: false + description: "Kind of event (calibration, cleaning, power outage, …)" + foreign_key: + table: EventKind + column: EventKind_ID + + - name: EventDateTimeStart + logical_type: timestamp + precision: 7 + nullable: false + description: "Date and time the event began (UTC)" + + - name: IsInstantaneous + logical_type: boolean + nullable: false + default: false + description: > + True if the event occurred at a single point in time. When true, + EventDateTimeEnd must be NULL. When false and EventDateTimeEnd is NULL, + the event is ongoing. + + - name: EventDateTimeEnd + logical_type: timestamp + precision: 7 + nullable: true + description: > + Date and time the event ended (UTC). NULL when IsInstantaneous=1 + (point-in-time) or when the event is still ongoing (IsInstantaneous=0). + + - name: PerformedByPerson_ID + logical_type: integer + nullable: true + description: "Person who physically performed the event (e.g. technician on site)" + foreign_key: + table: Person + column: Person_ID + + - name: RecordedByPerson_ID + logical_type: integer + nullable: true + description: "Person who entered this record into the system (may differ from PerformedByPerson_ID)" + foreign_key: + table: Person + column: Person_ID + + - name: Notes + logical_type: string + max_length: max + nullable: true + description: "Free-text notes about the event" + + primary_key: [Event_ID] + + check_constraints: + - name: CK_Event_ExclusiveArc + expression: >- + (CASE WHEN [Channel_ID] IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN [Equipment_ID] IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN [SignalInterface_ID] IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN [DataAcquisitionSystem_ID] IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN [SamplingPoint_ID] IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN [ProcessUnit_ID] IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN [Site_ID] IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN [Campaign_ID] IS NOT NULL THEN 1 ELSE 0 END) = 1 + + indexes: + - name: IX_Event_Equipment_Start + columns: [Equipment_ID, EventDateTimeStart] + unique: false + description: "Supports efficient retrieval of event history for a specific piece of equipment ordered by time" + - name: IX_Event_Channel_Start + columns: [Channel_ID, EventDateTimeStart] + unique: false + description: "Supports efficient retrieval of event history for a specific channel ordered by time" + - name: IX_Event_Site_Start + columns: [Site_ID, EventDateTimeStart] + unique: false + description: "Supports efficient retrieval of site-level events (power outages, visits) ordered by time" + - name: IX_Event_Start + columns: [EventDateTimeStart] + unique: false + description: "Supports time-range queries across all events" diff --git a/schema_dictionary/tables/EventKind.yaml b/schema_dictionary/tables/EventKind.yaml new file mode 100644 index 0000000..ab57144 --- /dev/null +++ b/schema_dictionary/tables/EventKind.yaml @@ -0,0 +1,46 @@ +_format_version: "1.0" +table: + name: EventKind + schema: dbo + description: > + Controlled vocabulary classifying the kind of operational event (Calibration, + Cleaning, PowerOutage, ControllerCrash, …). Generalises the former + EquipmentEventKind to cover all targets in the exclusive-arc Event table. + + columns: + - name: EventKind_ID + logical_type: integer + nullable: false + identity: true + description: "Surrogate primary key" + + - name: Name + logical_type: string + max_length: 100 + nullable: false + description: "Name of the event kind" + + - name: Description + logical_type: string + max_length: 300 + nullable: true + description: "Explanation of what this kind of event involves" + + primary_key: [EventKind_ID] + + seed_data: + - { EventKind_ID: 1, Name: "Calibration", Description: "Adjustment of sensor output to match a known reference standard" } + - { EventKind_ID: 2, Name: "Cleaning", Description: "Physical cleaning or flushing of a sensor or sampling point to restore signal quality" } + - { EventKind_ID: 3, Name: "Repair", Description: "Corrective action performed following a recorded failure" } + - { EventKind_ID: 4, Name: "PartReplacement", Description: "Replacement of a sub-component (membrane, electrode, probe tip) without swapping the full unit" } + - { EventKind_ID: 5, Name: "Replacement", Description: "Full swap of a sensor or equipment unit" } + - { EventKind_ID: 6, Name: "SoftwareUpdate", Description: "Update to embedded firmware, driver, or control software of a device" } + - { EventKind_ID: 7, Name: "Validation", Description: "Formal check confirming that sensor outputs meet defined acceptance criteria" } + - { EventKind_ID: 8, Name: "Verification", Description: "Comparison of sensor reading against a reference under controlled conditions (in-situ or bench)" } + - { EventKind_ID: 9, Name: "VisualInspection", Description: "Non-destructive observation of equipment condition without intervention" } + - { EventKind_ID: 10, Name: "Commissioning", Description: "Formal activation of equipment or a system node into operational service" } + - { EventKind_ID: 11, Name: "Decommissioning", Description: "Formal retirement of equipment or a system node from operational service" } + - { EventKind_ID: 12, Name: "OutOfService", Description: "Planned or unplanned removal from service (shutdown, isolation) without full decommissioning" } + - { EventKind_ID: 13, Name: "PowerOutage", Description: "Loss of electrical power affecting a device, interface, or site" } + - { EventKind_ID: 14, Name: "ControllerCrash", Description: "Unplanned software or hardware fault causing a controller or DAS to stop functioning" } + - { EventKind_ID: 15, Name: "OperationalChange", Description: "Any deliberate change in operational configuration, set-point, or procedure not covered by a more specific kind" } diff --git a/schema_dictionary/version.yaml b/schema_dictionary/version.yaml index 430d560..b8c6895 100644 --- a/schema_dictionary/version.yaml +++ b/schema_dictionary/version.yaml @@ -1,6 +1,8 @@ -schema_version: "2.0.0" +schema_version: "2.5.0" description: > - Initial public release. Complete redesign of the signal interface, - annotation model, lab observation model, and deployment trace concept. - Breaking change from v1.x — no migration provided; fresh install only. -date: "2026-06-11" + PRD-2 S1 — Rename EquipmentEvent to Event with 8-FK exclusive arc (CHECK + exactly-one non-NULL: Channel, Equipment, SignalInterface, DAS, SamplingPoint, + ProcessUnit, Site, Campaign). Rename EquipmentEventKind to EventKind; expand + seed vocab (Calibration/Cleaning/Repair/…15 kinds). Annotation FK repointed + to Event. Pre-release, fresh install only — no migration. +date: "2026-06-30" diff --git a/schema_dictionary/views/vw_ChannelEquipmentAtTime.yaml b/schema_dictionary/views/vw_ChannelEquipmentAtTime.yaml index 3613687..1316889 100644 --- a/schema_dictionary/views/vw_ChannelEquipmentAtTime.yaml +++ b/schema_dictionary/views/vw_ChannelEquipmentAtTime.yaml @@ -46,7 +46,7 @@ view: ) AS rn, COUNT(*) OVER (PARTITION BY o.[Observation_ID]) AS match_count FROM [dbo].[Observation] o - JOIN [dbo].[Channel] c ON c.[Stream_ID] = o.[Channel_ID] + JOIN [dbo].[vw_ChannelResolved] c ON c.[Stream_ID] = o.[Channel_ID] LEFT JOIN [dbo].[EquipmentWiringHistory] ewh ON ewh.[SignalInterface_ID] = c.[SignalInterface_ID] AND ( ewh.[SignalInterfacePort_ID] = c.[SignalInterfacePort_ID] diff --git a/schema_dictionary/views/vw_ChannelLocationAtTime.yaml b/schema_dictionary/views/vw_ChannelLocationAtTime.yaml index e5c01d2..e1c765c 100644 --- a/schema_dictionary/views/vw_ChannelLocationAtTime.yaml +++ b/schema_dictionary/views/vw_ChannelLocationAtTime.yaml @@ -5,8 +5,10 @@ view: description: > Resolves the SamplingPoint a Channel was sampling at the time of each Observation, by composing vw_ChannelEquipmentAtTime with - EquipmentLocationHistory. When equipment cannot be resolved (ambiguous - or unlinked wiring), SamplingPointID is NULL. + EquipmentLocationHistory. SamplingPointID is NULL whenever the chain is + broken; the Resolution (equipment leg) and LocationResolution (location + leg) discriminators say *why*, so NULL-because-broken is distinguishable + from NULL-because-genuinely-absent (consistency audit F6). columns: - name: ObservationID sql_data_type: BIGINT @@ -20,20 +22,32 @@ view: - name: EquipmentID sql_data_type: INT description: "Equipment resolved at this time (NULL if unresolved)" + - name: Resolution + sql_data_type: NVARCHAR(20) + description: "Equipment leg, passed through from vw_ChannelEquipmentAtTime: 'resolved' | 'unlinked' | 'ambiguous'" - name: SamplingPointID sql_data_type: INT description: "SamplingPoint where the equipment was installed at this time" - name: SamplingPointName sql_data_type: NVARCHAR(200) description: "Name of the SamplingPoint" + - name: LocationResolution + sql_data_type: NVARCHAR(20) + description: "Location leg: 'resolved' (active ELH row) | 'no-location' (equipment resolved but no covering ELH row) | 'no-equipment' (equipment leg broken upstream, see Resolution)" view_definition: | SELECT cea.ObservationID, cea.ChannelID, cea.Timestamp, cea.EquipmentID, + cea.Resolution, elh.[SamplingPoint_ID] AS SamplingPointID, - sp.[SamplingPoint] AS SamplingPointName + sp.[SamplingPoint] AS SamplingPointName, + CASE + WHEN cea.EquipmentID IS NULL THEN N'no-equipment' + WHEN elh.[SamplingPoint_ID] IS NOT NULL THEN N'resolved' + ELSE N'no-location' + END AS LocationResolution FROM [dbo].[vw_ChannelEquipmentAtTime] cea LEFT JOIN [dbo].[EquipmentLocationHistory] elh ON elh.[Equipment_ID] = cea.EquipmentID AND elh.[ValidFrom] <= cea.Timestamp diff --git a/schema_dictionary/views/vw_ChannelResolved.yaml b/schema_dictionary/views/vw_ChannelResolved.yaml new file mode 100644 index 0000000..5031666 --- /dev/null +++ b/schema_dictionary/views/vw_ChannelResolved.yaml @@ -0,0 +1,64 @@ +_format_version: "1.0" +view: + name: vw_ChannelResolved + schema: dbo + description: > + Channel with its current SignalInterfacePort resolved from the active + ChannelPortHistory row (ValidTo IS NULL). Replaces the former denormalised + Channel.SignalInterfacePort_ID column: there is exactly one source of truth + (ChannelPortHistory) so the port can never drift. Every read that needs the + current port selects FROM this view instead of FROM the Channel base table; + the equipment-resolution join predicates are otherwise unchanged. The + filtered unique index UQ_ChannelPortHistory_ActiveRow guarantees at most one + active row per channel, so this view returns exactly one row per Channel. + columns: + - name: Stream_ID + sql_data_type: INT + description: "Channel primary key (shared with Stream)" + - name: SignalInterface_ID + sql_data_type: INT + description: "Publishing SignalInterface (NULL for derived channels)" + - name: TagName + sql_data_type: NVARCHAR(200) + description: "Published tag string" + - name: SignalInterfacePort_ID + sql_data_type: INT + description: "Current port from the active ChannelPortHistory row (NULL if untraced)" + - name: ParentChannel_ID + sql_data_type: INT + description: "Parent value channel for sub-signals (Status/Alarm/Uncertainty)" + - name: ChannelKind_ID + sql_data_type: INT + description: "1=Value, 2=Status, 3=Alarm, 4=Uncertainty" + - name: Parameter_ID + sql_data_type: INT + description: "Measured analyte or parameter" + - name: DataProvenanceKind_ID + sql_data_type: INT + description: "How the data was produced" + - name: ProducedByStep_ID + sql_data_type: INT + description: "ProcessingStep that produced a derived channel (NULL for raw)" + - name: ValueKind_ID + sql_data_type: INT + description: "Shape of stored values (1=Scalar, ...)" + - name: Unit_ID + sql_data_type: INT + description: "Unit of measurement" + view_definition: | + SELECT + c.[Stream_ID], + c.[SignalInterface_ID], + c.[TagName], + cph.[SignalInterfacePort_ID], + c.[ParentChannel_ID], + c.[ChannelKind_ID], + c.[Parameter_ID], + c.[DataProvenanceKind_ID], + c.[ProducedByStep_ID], + c.[ValueKind_ID], + c.[Unit_ID] + FROM [dbo].[Channel] c + LEFT JOIN [dbo].[ChannelPortHistory] cph + ON cph.[Channel_ID] = c.[Stream_ID] + AND cph.[ValidTo] IS NULL diff --git a/schema_dictionary/views/vw_ChannelStatus.yaml b/schema_dictionary/views/vw_ChannelStatus.yaml index 5f4cd9e..e604957 100644 --- a/schema_dictionary/views/vw_ChannelStatus.yaml +++ b/schema_dictionary/views/vw_ChannelStatus.yaml @@ -42,7 +42,7 @@ view: JOIN [dbo].[Observation] o ON o.[Observation_ID] = v.[Observation_ID] JOIN [dbo].[Channel] statusC ON statusC.[Stream_ID] = o.[Channel_ID] JOIN [dbo].[ChannelKind] role ON role.[ChannelKind_ID] = statusC.[ChannelKind_ID] - JOIN [dbo].[Channel] valueC ON valueC.[Stream_ID] = statusC.[ParentChannel_ID] + JOIN [dbo].[vw_ChannelResolved] valueC ON valueC.[Stream_ID] = statusC.[ParentChannel_ID] JOIN [dbo].[Parameter] p ON p.[Parameter_ID] = valueC.[Parameter_ID] LEFT JOIN [dbo].[EquipmentWiringHistory] ewh ON ewh.[SignalInterface_ID] = valueC.[SignalInterface_ID] diff --git a/schema_dictionary/views/vw_DeploymentCoherence.yaml b/schema_dictionary/views/vw_DeploymentCoherence.yaml new file mode 100644 index 0000000..e834d39 --- /dev/null +++ b/schema_dictionary/views/vw_DeploymentCoherence.yaml @@ -0,0 +1,72 @@ +_format_version: "1.0" +view: + name: vw_DeploymentCoherence + schema: dbo + description: > + Surfaces deployment drift (consistency audit F1): equipment whose active + location sits at a Site different from the Site where its connected Data + Acquisition System is currently deployed. A DAS is deployed to a Site + (DASLocationHistory); equipment is wired to that DAS's SignalInterfaces + (EquipmentWiringHistory) and physically placed at a SamplingPoint + (EquipmentLocationHistory), and every SamplingPoint belongs to a Site. When + a DAS is moved to a new Site, the equipment it feeds does not move with it + automatically — this view lists each such stranded equipment so the drift + can be corrected (relocate the equipment, or move the DAS back). Only active + rows (ValidTo IS NULL) on both histories are considered; a row appears here + only while the mismatch is live. + columns: + - name: DAS_ID + sql_data_type: INT + description: "The deployed Data Acquisition System" + - name: DASName + sql_data_type: NVARCHAR(200) + description: "DAS name" + - name: DASSite_ID + sql_data_type: INT + description: "Site the DAS is currently deployed to" + - name: DASSiteName + sql_data_type: NVARCHAR(200) + description: "Name of the DAS's Site" + - name: Equipment_ID + sql_data_type: INT + description: "Equipment wired to the DAS but located elsewhere" + - name: EquipmentName + sql_data_type: NVARCHAR(200) + description: "Equipment identifier" + - name: EquipmentSite_ID + sql_data_type: INT + description: "Site of the equipment's current SamplingPoint (the drift)" + - name: EquipmentSiteName + sql_data_type: NVARCHAR(200) + description: "Name of the equipment's current Site" + - name: SamplingPoint_ID + sql_data_type: INT + description: "Equipment's current SamplingPoint" + - name: SamplingPointName + sql_data_type: NVARCHAR(200) + description: "Name of the equipment's current SamplingPoint" + view_definition: | + SELECT + dlh.[DataAcquisitionSystem_ID] AS DAS_ID, + das.[Name] AS DASName, + dlh.[Site_ID] AS DASSite_ID, + dsite.[Name] AS DASSiteName, + e.[Equipment_ID] AS Equipment_ID, + e.[Identifier] AS EquipmentName, + sp.[Site_ID] AS EquipmentSite_ID, + esite.[Name] AS EquipmentSiteName, + sp.[SamplingPoint_ID] AS SamplingPoint_ID, + sp.[SamplingPoint] AS SamplingPointName + FROM [dbo].[DASLocationHistory] dlh + JOIN [dbo].[DataAcquisitionSystem] das ON das.[DataAcquisitionSystem_ID] = dlh.[DataAcquisitionSystem_ID] + JOIN [dbo].[SignalInterface] si ON si.[DataAcquisitionSystem_ID] = dlh.[DataAcquisitionSystem_ID] + JOIN [dbo].[EquipmentWiringHistory] ewh ON ewh.[SignalInterface_ID] = si.[SignalInterface_ID] + AND ewh.[ValidTo] IS NULL + JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = ewh.[Equipment_ID] + JOIN [dbo].[EquipmentLocationHistory] elh ON elh.[Equipment_ID] = e.[Equipment_ID] + AND elh.[ValidTo] IS NULL + JOIN [dbo].[SamplingPoint] sp ON sp.[SamplingPoint_ID] = elh.[SamplingPoint_ID] + LEFT JOIN [dbo].[Site] dsite ON dsite.[Site_ID] = dlh.[Site_ID] + LEFT JOIN [dbo].[Site] esite ON esite.[Site_ID] = sp.[Site_ID] + WHERE dlh.[ValidTo] IS NULL + AND sp.[Site_ID] <> dlh.[Site_ID] diff --git a/schema_dictionary/views/vw_DeviceStatus.yaml b/schema_dictionary/views/vw_DeviceStatus.yaml index f344aaa..4d90e63 100644 --- a/schema_dictionary/views/vw_DeviceStatus.yaml +++ b/schema_dictionary/views/vw_DeviceStatus.yaml @@ -34,7 +34,7 @@ view: JOIN [dbo].[Observation] o ON o.[Observation_ID] = v.[Observation_ID] JOIN [dbo].[Channel] statusC ON statusC.[Stream_ID] = o.[Channel_ID] JOIN [dbo].[ChannelKind] role ON role.[ChannelKind_ID] = statusC.[ChannelKind_ID] - JOIN [dbo].[Channel] valueC ON valueC.[Stream_ID] = statusC.[ParentChannel_ID] + JOIN [dbo].[vw_ChannelResolved] valueC ON valueC.[Stream_ID] = statusC.[ParentChannel_ID] JOIN [dbo].[EquipmentWiringHistory] ewh ON ewh.[SignalInterface_ID] = valueC.[SignalInterface_ID] AND ( diff --git a/schema_dictionary/views/vw_InactiveParentReferences.yaml b/schema_dictionary/views/vw_InactiveParentReferences.yaml new file mode 100644 index 0000000..10a1143 --- /dev/null +++ b/schema_dictionary/views/vw_InactiveParentReferences.yaml @@ -0,0 +1,52 @@ +_format_version: "1.0" +view: + name: vw_InactiveParentReferences + schema: dbo + description: > + Health view (consistency audit F11): live references to soft-deleted + parents. The model leans on IsActive soft-deletes for SignalInterface and + SignalInterfacePort, but setting IsActive=0 does nothing to the active + EquipmentWiringHistory rows still pointing at that interface/port — they + keep resolving as if active. This view lists each active wiring row + (ValidTo IS NULL) whose referenced SignalInterface or SignalInterfacePort + is inactive, so the app can warn before deactivating a parent that still + has live children (and operators can reconcile existing orphans). One row + per dangling reference; ReferenceType says which leg is stale. + columns: + - name: ReferenceType + sql_data_type: NVARCHAR(40) + description: "'active-wiring->interface' | 'active-wiring->port'" + - name: WiringHistoryID + sql_data_type: INT + description: "The active EquipmentWiringHistory row holding the stale reference" + - name: EquipmentID + sql_data_type: INT + description: "Equipment wired by that row" + - name: ParentID + sql_data_type: INT + description: "SignalInterface_ID or SignalInterfacePort_ID that is inactive" + - name: ParentLabel + sql_data_type: NVARCHAR(200) + description: "Inactive parent's name (interface) or port identifier" + view_definition: | + SELECT + N'active-wiring->interface' AS ReferenceType, + ewh.[EquipmentWiringHistory_ID] AS WiringHistoryID, + ewh.[Equipment_ID] AS EquipmentID, + si.[SignalInterface_ID] AS ParentID, + si.[Name] AS ParentLabel + FROM [dbo].[EquipmentWiringHistory] ewh + JOIN [dbo].[SignalInterface] si ON si.[SignalInterface_ID] = ewh.[SignalInterface_ID] + WHERE ewh.[ValidTo] IS NULL + AND si.[IsActive] = 0 + UNION ALL + SELECT + N'active-wiring->port' AS ReferenceType, + ewh.[EquipmentWiringHistory_ID] AS WiringHistoryID, + ewh.[Equipment_ID] AS EquipmentID, + sip.[SignalInterfacePort_ID] AS ParentID, + sip.[PortIdentifier] AS ParentLabel + FROM [dbo].[EquipmentWiringHistory] ewh + JOIN [dbo].[SignalInterfacePort] sip ON sip.[SignalInterfacePort_ID] = ewh.[SignalInterfacePort_ID] + WHERE ewh.[ValidTo] IS NULL + AND sip.[IsActive] = 0 diff --git a/schema_dictionary/views/vw_UnlinkedChannels.yaml b/schema_dictionary/views/vw_UnlinkedChannels.yaml new file mode 100644 index 0000000..466c61f --- /dev/null +++ b/schema_dictionary/views/vw_UnlinkedChannels.yaml @@ -0,0 +1,56 @@ +_format_version: "1.0" +view: + name: vw_UnlinkedChannels + schema: dbo + description: > + Health view (consistency audit F5): raw/ingested channels that carry + Observations but have no active EquipmentWiringHistory on their + SignalInterface, so every observation resolves as 'unlinked' (see + vw_ChannelEquipmentAtTime) with no equipment and no SamplingPoint. These + are channels that were ingested but never linked to physical equipment — + "N channels need wiring". Only raw channels are considered: derived/ + processed channels have SignalInterface_ID NULL by design and are + deliberately unwired, not forgotten, so they are excluded. A channel drops + off this list the moment an active wiring row exists for its interface. + columns: + - name: ChannelID + sql_data_type: INT + description: "Channel (Stream_ID) lacking active wiring" + - name: TagName + sql_data_type: NVARCHAR(200) + description: "Channel tag name" + - name: SignalInterfaceID + sql_data_type: INT + description: "The channel's SignalInterface (has no active wiring row)" + - name: SignalInterfaceName + sql_data_type: NVARCHAR(200) + description: "Name of the SignalInterface" + - name: ObservationCount + sql_data_type: INT + description: "How many observations are stranded on this unlinked channel" + - name: FirstObservation + sql_data_type: DATETIME2(7) + description: "Earliest stranded observation timestamp" + - name: LastObservation + sql_data_type: DATETIME2(7) + description: "Latest stranded observation timestamp" + view_definition: | + SELECT + c.[Stream_ID] AS ChannelID, + c.[TagName] AS TagName, + c.[SignalInterface_ID] AS SignalInterfaceID, + si.[Name] AS SignalInterfaceName, + COUNT(o.[Observation_ID]) AS ObservationCount, + MIN(o.[Timestamp]) AS FirstObservation, + MAX(o.[Timestamp]) AS LastObservation + FROM [dbo].[Channel] c + JOIN [dbo].[Observation] o ON o.[Channel_ID] = c.[Stream_ID] + LEFT JOIN [dbo].[SignalInterface] si ON si.[SignalInterface_ID] = c.[SignalInterface_ID] + WHERE c.[SignalInterface_ID] IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM [dbo].[EquipmentWiringHistory] ewh + WHERE ewh.[SignalInterface_ID] = c.[SignalInterface_ID] + AND ewh.[ValidTo] IS NULL + ) + GROUP BY c.[Stream_ID], c.[TagName], c.[SignalInterface_ID], si.[Name] diff --git a/scripts/generate_sql.py b/scripts/generate_sql.py index 985149d..7513f8e 100644 --- a/scripts/generate_sql.py +++ b/scripts/generate_sql.py @@ -11,6 +11,7 @@ import sys import json +import re from pathlib import Path from datetime import datetime from importlib.metadata import version @@ -162,6 +163,34 @@ def parse_parts_json(json_path): return data +def _ordered_views(views: dict) -> list[str]: + """View ids in creation order: a view that references another is emitted after it. + + Dependencies are read straight from each view's SQL text (whole-word match on + the other view ids), so no manual ``depends_on`` bookkeeping is needed. Kahn's + algorithm with an alphabetical tiebreak keeps the generated script stable. + """ + ids = list(views) + deps = { + vid: { + other + for other in ids + if other != vid + and re.search(rf"\b{re.escape(other)}\b", views[vid]["view_definition"]) + } + for vid in ids + } + ordered: list[str] = [] + remaining = set(ids) + while remaining: + ready = sorted(v for v in remaining if deps[v] <= set(ordered)) + if not ready: # a dependency cycle — surface it rather than emit broken SQL + raise ValueError(f"Cyclic view dependencies among: {sorted(remaining)}") + ordered.extend(ready) + remaining -= set(ready) + return ordered + + def generate_sql_schemas(parts_data, output_path, db_list): """Generate SQL schemas for multiple database types.""" for target_db in db_list: @@ -236,10 +265,14 @@ def generate_sql_schema(data, target_db="mssql", include_timestamp=True): if fk_sql: sql.append(fk_sql) - # Third pass: Create views + # Third pass: Create views, dependency-ordered. + # SQL Server resolves view references at CREATE time (no deferral), so a view + # that selects from another view must be emitted after it. Kahn's algorithm, + # alphabetical tiebreak for a deterministic script. if "views" in data and data["views"]: sql.append("\n-- Views\n") - for view_id, view_info in sorted(data["views"].items()): + for view_id in _ordered_views(data["views"]): + view_info = data["views"][view_id] sql.append(f"\n-- {view_info['description']}") sql.append(f"CREATE VIEW {db_config['quote'](view_id)} AS") sql.append(f"{view_info['view_definition']};") diff --git a/sql/init.sql b/sql/init.sql index f2df480..4becff1 100644 --- a/sql/init.sql +++ b/sql/init.sql @@ -8,15 +8,15 @@ USE open_dateaubase; GO -- Full schema — generated from schema_dictionary/tables/*.yaml via `uv run mkdocs build` -:r /sql_generation_scripts/v2.0.0_create_mssql.sql +:r /sql_generation_scripts/v2.5.0_create_mssql.sql GO -- Vocabulary seed (auto-generated from YAML seed_data fields): -- ValueKind, ChannelKind, DataProvenanceKind, ProcessingKind, -- SignalInterfaceKind, SignalInterfacePortKind, QualityCode, AnnotationKind, --- BinKind, CampaignKind, EquipmentEventKind, ControlLoopPortKind, +-- BinKind, CampaignKind, EventKind, ControlLoopPortKind, -- ProcessUnitKind, SampleKind, SampleCollectionKind, Unit, Parameter, ParameterHasUnit. -:r /sql_generation_scripts/v2.0.0_seed_mssql.sql +:r /sql_generation_scripts/v2.5.0_seed_mssql.sql GO -- Demo seed: TEST_ site, process units, sampling points, persons, campaigns, analysis diff --git a/sql/seed_demo.sql b/sql/seed_demo.sql index c312a17..58f6403 100644 --- a/sql/seed_demo.sql +++ b/sql/seed_demo.sql @@ -164,12 +164,11 @@ SET @SP_BR4 = SCOPE_IDENTITY(); DECLARE @CampOpsID INT, @CampExpID INT; INSERT INTO [dbo].[Campaign] ( - [CampaignKind_ID], [Site_ID], [Name], [Description], + [CampaignKind_ID], [Name], [Description], [CampaignStartDateTime], [ResponsiblePerson_ID] ) VALUES ( 2, -- Operations - @SiteID, N'TEST_ Routine Operations 2026', N'TEST campaign — ongoing routine monitoring of the pilot WWTP', '2026-01-01T00:00:00', @@ -178,12 +177,11 @@ VALUES ( SET @CampOpsID = SCOPE_IDENTITY(); INSERT INTO [dbo].[Campaign] ( - [CampaignKind_ID], [Site_ID], [Name], [Description], + [CampaignKind_ID], [Name], [Description], [CampaignStartDateTime], [CampaignEndDateTime], [ResponsiblePerson_ID] ) VALUES ( 1, -- Experiment - @SiteID, N'TEST_ Bioaugmentation Experiment Spring 2026', N'TEST campaign — evaluating the effect of bioaugmentation on nitrogen removal', '2026-04-01T00:00:00', diff --git a/sql_generation_scripts/v2.1.0_create_mssql.sql b/sql_generation_scripts/v2.1.0_create_mssql.sql new file mode 100644 index 0000000..ed9f4e1 --- /dev/null +++ b/sql_generation_scripts/v2.1.0_create_mssql.sql @@ -0,0 +1,1093 @@ +-- Baseline CREATE script for schema v2.1.0 +-- Platform: mssql +-- Generated: 2026-06-29 12:00:55 UTC + +CREATE TABLE [dbo].[AnnotationKind] ( + [AnnotationKind_ID] INT NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(500), + [Color] NVARCHAR(7), + CONSTRAINT [PK_AnnotationKind] PRIMARY KEY ([AnnotationKind_ID]) +); + +CREATE TABLE [dbo].[BinKind] ( + [BinKind_ID] INT NOT NULL, + [Name] NVARCHAR(30) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_BinKind] PRIMARY KEY ([BinKind_ID]) +); + +CREATE TABLE [dbo].[CampaignKind] ( + [CampaignKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_CampaignKind] PRIMARY KEY ([CampaignKind_ID]) +); + +CREATE TABLE [dbo].[ChannelKind] ( + [ChannelKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_ChannelKind] PRIMARY KEY ([ChannelKind_ID]) +); + +CREATE TABLE [dbo].[ControlLoopPortKind] ( + [ControlLoopPortKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_ControlLoopPortKind] PRIMARY KEY ([ControlLoopPortKind_ID]) +); + +CREATE TABLE [dbo].[ControllerKind] ( + [ControllerKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(500), + CONSTRAINT [PK_ControllerKind] PRIMARY KEY ([ControllerKind_ID]) +); + +CREATE TABLE [dbo].[DataAcquisitionSystemKind] ( + [DataAcquisitionSystemKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(500), + CONSTRAINT [PK_DataAcquisitionSystemKind] PRIMARY KEY ([DataAcquisitionSystemKind_ID]) +); + +CREATE TABLE [dbo].[DataProvenanceKind] ( + [DataProvenanceKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_DataProvenanceKind] PRIMARY KEY ([DataProvenanceKind_ID]) +); + +CREATE TABLE [dbo].[EquipmentEventKind] ( + [EquipmentEventKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_EquipmentEventKind] PRIMARY KEY ([EquipmentEventKind_ID]) +); + +CREATE TABLE [dbo].[EquipmentModel] ( + [EquipmentModel_ID] INT IDENTITY(1,1) NOT NULL, + [EquipmentModel] NVARCHAR(100), + [Method] NVARCHAR(100), + [Functions] NVARCHAR(MAX), + [Manufacturer] NVARCHAR(100), + [ManualLocation] NVARCHAR(1000), + CONSTRAINT [PK_EquipmentModel] PRIMARY KEY ([EquipmentModel_ID]) +); + +CREATE TABLE [dbo].[OperationKind] ( + [OperationKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_OperationKind] PRIMARY KEY ([OperationKind_ID]) +); + +CREATE TABLE [dbo].[Person] ( + [Person_ID] INT IDENTITY(1,1) NOT NULL, + [LastName] NVARCHAR(100), + [FirstName] NVARCHAR(255), + [Company] NVARCHAR(MAX), + [Role] NVARCHAR(255), + [AssignedFunctions] NVARCHAR(MAX), + [Email] NVARCHAR(100), + [Phone] NVARCHAR(100), + [Linkedin] NVARCHAR(100), + [Website] NVARCHAR(60), + CONSTRAINT [PK_Person] PRIMARY KEY ([Person_ID]) +); + +CREATE TABLE [dbo].[ProcedureKind] ( + [ProcedureKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_ProcedureKind] PRIMARY KEY ([ProcedureKind_ID]) +); + +CREATE TABLE [dbo].[ProcessUnitKind] ( + [ProcessUnitKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_ProcessUnitKind] PRIMARY KEY ([ProcessUnitKind_ID]), + CONSTRAINT [UQ_ProcessUnitKind_Name] UNIQUE ([Name]) +); + +CREATE TABLE [dbo].[QualityCode] ( + [QualityCode_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + [IsUsable] BIT NOT NULL DEFAULT 1, + CONSTRAINT [PK_QualityCode] PRIMARY KEY ([QualityCode_ID]) +); + +CREATE TABLE [dbo].[ReviewStatus] ( + [ReviewStatus_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_ReviewStatus] PRIMARY KEY ([ReviewStatus_ID]) +); + +CREATE TABLE [dbo].[SampleCollectionKind] ( + [SampleCollectionKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_SampleCollectionKind] PRIMARY KEY ([SampleCollectionKind_ID]) +); + +CREATE TABLE [dbo].[SampleKind] ( + [SampleKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_SampleKind] PRIMARY KEY ([SampleKind_ID]) +); + +CREATE TABLE [dbo].[SchemaVersion] ( + [VersionID] INT IDENTITY(1,1) NOT NULL, + [Version] NVARCHAR(20) NOT NULL, + [AppliedDateTime] DATETIME2(7) NOT NULL DEFAULT CURRENT_TIMESTAMP, + [Description] NVARCHAR(500), + [MigrationScript] NVARCHAR(200), + CONSTRAINT [PK_SchemaVersion] PRIMARY KEY ([VersionID]) +); + +CREATE TABLE [dbo].[SiteKind] ( + [SiteKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_SiteKind] PRIMARY KEY ([SiteKind_ID]) +); + +CREATE TABLE [dbo].[StreamKind] ( + [StreamKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_StreamKind] PRIMARY KEY ([StreamKind_ID]) +); + +CREATE TABLE [dbo].[Unit] ( + [Unit_ID] INT IDENTITY(1,1) NOT NULL, + [Unit] NVARCHAR(100), + [QUDT_IRI] NVARCHAR(256), + [UnitVector] NVARCHAR(64), + [SI_Multiplier] FLOAT, + [SI_Offset] FLOAT, + CONSTRAINT [PK_Unit] PRIMARY KEY ([Unit_ID]) +); + +CREATE TABLE [dbo].[UserAccount] ( + [UserAccount_ID] INT IDENTITY(1,1) NOT NULL, + [Email] NVARCHAR(255) NOT NULL, + [FullName] NVARCHAR(255) NOT NULL, + [PasswordHash] NVARCHAR(255) NOT NULL, + [IsActive] BIT NOT NULL DEFAULT 1, + [IsVerified] BIT NOT NULL DEFAULT 1, + [CreatedAt] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + [UpdatedAt] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + CONSTRAINT [PK_UserAccount] PRIMARY KEY ([UserAccount_ID]), + CONSTRAINT [UQ_UserAccount_Email] UNIQUE ([Email]) +); + +CREATE TABLE [dbo].[ValueKind] ( + [ValueKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_ValueKind] PRIMARY KEY ([ValueKind_ID]) +); + +CREATE TABLE [dbo].[AnalysisSeries] ( + [Stream_ID] INT NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Parameter_ID] INT NOT NULL, + [SamplingPoint_ID] INT NOT NULL, + [ValueKind_ID] INT NOT NULL DEFAULT 1, + [Unit_ID] INT NOT NULL, + [Campaign_ID] INT, + [Description] NVARCHAR(MAX), + CONSTRAINT [PK_AnalysisSeries] PRIMARY KEY ([Stream_ID]), + CONSTRAINT [UQ_AnalysisSeries_Identity] UNIQUE ([Parameter_ID], [SamplingPoint_ID], [ValueKind_ID]) +); + +CREATE TABLE [dbo].[AnalysisSeriesAxis] ( + [AnalysisSeries_ID] INT NOT NULL, + [AxisRole] INT NOT NULL, + [ValueBinningAxis_ID] INT NOT NULL, + CONSTRAINT [PK_AnalysisSeriesAxis] PRIMARY KEY ([AnalysisSeries_ID], [AxisRole]), + CONSTRAINT [CK_AnalysisSeriesAxis_AxisRole] CHECK (AxisRole IN (0, 1)) +); + +CREATE TABLE [dbo].[Annotation] ( + [Annotation_ID] INT IDENTITY(1,1) NOT NULL, + [Stream_ID] INT NOT NULL, + [AnnotationKind_ID] INT NOT NULL, + [StartTime] DATETIME2(7) NOT NULL, + [EndTime] DATETIME2(7), + [AuthorPerson_ID] INT, + [Campaign_ID] INT, + [EquipmentEvent_ID] INT, + [Title] NVARCHAR(200), + [Comment] NVARCHAR(MAX), + [CreatedDateTime] DATETIME2(7) NOT NULL DEFAULT CURRENT_TIMESTAMP, + [ModifiedDateTime] DATETIME2(7), + [Observation_ID] INT, + CONSTRAINT [PK_Annotation] PRIMARY KEY ([Annotation_ID]) +); + +CREATE TABLE [dbo].[AuditLog] ( + [AuditLog_ID] BIGINT IDENTITY(1,1) NOT NULL, + [UserAccount_ID] INT, + [Action] NVARCHAR(50) NOT NULL, + [ResourceType] NVARCHAR(100) NOT NULL, + [ResourceID] NVARCHAR(255), + [Details] NVARCHAR(MAX), + [Timestamp] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + CONSTRAINT [PK_AuditLog] PRIMARY KEY ([AuditLog_ID]) +); + +CREATE TABLE [dbo].[Campaign] ( + [Campaign_ID] INT IDENTITY(1,1) NOT NULL, + [CampaignKind_ID] INT NOT NULL, + [Site_ID] INT NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(2000), + [CampaignStartDateTime] DATETIME2(7), + [CampaignEndDateTime] DATETIME2(7), + [ResponsiblePerson_ID] INT, + CONSTRAINT [PK_Campaign] PRIMARY KEY ([Campaign_ID]) +); + +CREATE TABLE [dbo].[CampaignEquipment] ( + [Campaign_ID] INT NOT NULL, + [Equipment_ID] INT NOT NULL, + [Role] NVARCHAR(100), + CONSTRAINT [PK_CampaignEquipment] PRIMARY KEY ([Campaign_ID], [Equipment_ID]) +); + +CREATE TABLE [dbo].[CampaignSamplingLocation] ( + [Campaign_ID] INT NOT NULL, + [SamplingPoint_ID] INT NOT NULL, + [Role] NVARCHAR(100), + CONSTRAINT [PK_CampaignSamplingLocation] PRIMARY KEY ([Campaign_ID], [SamplingPoint_ID]) +); + +CREATE TABLE [dbo].[Channel] ( + [Stream_ID] INT NOT NULL, + [SignalInterface_ID] INT, + [TagName] NVARCHAR(200) NOT NULL, + [ParentChannel_ID] INT, + [ChannelKind_ID] INT NOT NULL DEFAULT 1, + [Parameter_ID] INT, + [DataProvenanceKind_ID] INT, + [ProducedByStep_ID] INT, + [ValueKind_ID] INT NOT NULL DEFAULT 1, + [Unit_ID] INT, + CONSTRAINT [PK_Channel] PRIMARY KEY ([Stream_ID]) +); + +CREATE TABLE [dbo].[ChannelAxis] ( + [Channel_ID] INT NOT NULL, + [AxisRole] INT NOT NULL, + [ValueBinningAxis_ID] INT NOT NULL, + CONSTRAINT [PK_ChannelAxis] PRIMARY KEY ([Channel_ID], [AxisRole]), + CONSTRAINT [CK_MetaDataAxis_AxisRole] CHECK (AxisRole IN (0, 1)) +); + +CREATE TABLE [dbo].[ChannelPortHistory] ( + [ChannelPortHistory_ID] INT IDENTITY(1,1) NOT NULL, + [Channel_ID] INT NOT NULL, + [SignalInterfacePort_ID] INT, + [ValidFrom] DATETIME2(7) NOT NULL, + [ValidTo] DATETIME2(7), + [GatingNote] NVARCHAR(MAX), + CONSTRAINT [PK_ChannelPortHistory] PRIMARY KEY ([ChannelPortHistory_ID]) +); + +CREATE TABLE [dbo].[ChannelTrait] ( + [Stream_ID] INT NOT NULL, + [OperationKind_ID] INT NOT NULL, + CONSTRAINT [PK_ChannelTrait] PRIMARY KEY ([Stream_ID], [OperationKind_ID]) +); + +CREATE TABLE [dbo].[ControlLoop] ( + [ControlLoop_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [ControllerKind_ID] INT NOT NULL, + [FallbackControlLoop_ID] INT, + [AlgorithmReference] NVARCHAR(500), + [Description] NVARCHAR(MAX), + CONSTRAINT [PK_ControlLoop] PRIMARY KEY ([ControlLoop_ID]) +); + +CREATE TABLE [dbo].[ControlLoopApplication] ( + [ControlLoopApplication_ID] INT IDENTITY(1,1) NOT NULL, + [ControlLoop_ID] INT NOT NULL, + [StartTime] DATETIME2(7) NOT NULL, + [EndTime] DATETIME2(7), + [Parameters] NVARCHAR(MAX), + [AppliedByPerson_ID] INT, + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_ControlLoopApplication] PRIMARY KEY ([ControlLoopApplication_ID]) +); + +CREATE TABLE [dbo].[ControlLoopPort] ( + [ControlLoopPort_ID] INT IDENTITY(1,1) NOT NULL, + [ControlLoop_ID] INT NOT NULL, + [Channel_ID] INT NOT NULL, + [ControlLoopPortKind_ID] INT NOT NULL, + CONSTRAINT [PK_ControlLoopPort] PRIMARY KEY ([ControlLoopPort_ID]) +); + +CREATE TABLE [dbo].[DASLocationHistory] ( + [DASLocationHistory_ID] INT IDENTITY(1,1) NOT NULL, + [DataAcquisitionSystem_ID] INT NOT NULL, + [Site_ID] INT NOT NULL, + [Campaign_ID] INT, + [ValidFrom] DATETIME2(7) NOT NULL, + [ValidTo] DATETIME2(7), + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_DASLocationHistory] PRIMARY KEY ([DASLocationHistory_ID]) +); + +CREATE TABLE [dbo].[DataAcquisitionSystem] ( + [DataAcquisitionSystem_ID] INT IDENTITY(1,1) NOT NULL, + [ParentSystem_ID] INT, + [Name] NVARCHAR(200) NOT NULL, + [DataAcquisitionSystemKind_ID] INT, + [Manufacturer] NVARCHAR(100), + [Model] NVARCHAR(100), + [Description] NVARCHAR(MAX), + CONSTRAINT [PK_DataAcquisitionSystem] PRIMARY KEY ([DataAcquisitionSystem_ID]) +); + +CREATE TABLE [dbo].[Dataset] ( + [Dataset_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(2000), + [Purpose] NVARCHAR(500), + [CreatedOn] DATETIME2(7) NOT NULL DEFAULT CURRENT_TIMESTAMP, + [CreatedByPerson_ID] INT, + CONSTRAINT [PK_Dataset] PRIMARY KEY ([Dataset_ID]) +); + +CREATE TABLE [dbo].[DatasetChannel] ( + [Dataset_ID] INT NOT NULL, + [Channel_ID] INT NOT NULL, + CONSTRAINT [PK_DatasetChannel] PRIMARY KEY ([Dataset_ID], [Channel_ID]) +); + +CREATE TABLE [dbo].[Equipment] ( + [Equipment_ID] INT IDENTITY(1,1) NOT NULL, + [EquipmentModel_ID] INT, + [Identifier] NVARCHAR(100), + [SerialNumber] NVARCHAR(100), + [Owner] NVARCHAR(MAX), + [StorageLocation] NVARCHAR(100), + [PurchaseDate] DATE, + [IsActive] BIT NOT NULL DEFAULT 1, + CONSTRAINT [PK_Equipment] PRIMARY KEY ([Equipment_ID]) +); + +CREATE TABLE [dbo].[EquipmentEvent] ( + [EquipmentEvent_ID] INT IDENTITY(1,1) NOT NULL, + [Equipment_ID] INT NOT NULL, + [EquipmentEventKind_ID] INT NOT NULL, + [EventDateTimeStart] DATETIME2(7) NOT NULL, + [IsInstantaneous] BIT NOT NULL DEFAULT 0, + [EventDateTimeEnd] DATETIME2(7), + [PerformedByPerson_ID] INT, + [RecordedByPerson_ID] INT, + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_EquipmentEvent] PRIMARY KEY ([EquipmentEvent_ID]) +); + +CREATE TABLE [dbo].[EquipmentLocationHistory] ( + [EquipmentLocationHistory_ID] INT IDENTITY(1,1) NOT NULL, + [Equipment_ID] INT NOT NULL, + [SamplingPoint_ID] INT NOT NULL, + [ValidFrom] DATETIME2(7) NOT NULL, + [ValidTo] DATETIME2(7), + [Campaign_ID] INT, + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_EquipmentLocationHistory] PRIMARY KEY ([EquipmentLocationHistory_ID]) +); + +CREATE TABLE [dbo].[EquipmentModelHasParameter] ( + [EquipmentModel_ID] INT NOT NULL, + [Parameter_ID] INT NOT NULL, + CONSTRAINT [PK_EquipmentModelHasParameter] PRIMARY KEY ([EquipmentModel_ID], [Parameter_ID]) +); + +CREATE TABLE [dbo].[EquipmentModelHasProcedures] ( + [EquipmentModel_ID] INT NOT NULL, + [Procedure_ID] INT NOT NULL, + CONSTRAINT [PK_EquipmentModelHasProcedures] PRIMARY KEY ([EquipmentModel_ID], [Procedure_ID]) +); + +CREATE TABLE [dbo].[EquipmentWiringHistory] ( + [EquipmentWiringHistory_ID] INT IDENTITY(1,1) NOT NULL, + [Equipment_ID] INT NOT NULL, + [SignalInterface_ID] INT NOT NULL, + [SignalInterfacePort_ID] INT, + [ValidFrom] DATETIME2(7) NOT NULL, + [ValidTo] DATETIME2(7), + [Note] NVARCHAR(MAX), + CONSTRAINT [PK_EquipmentWiringHistory] PRIMARY KEY ([EquipmentWiringHistory_ID]) +); + +CREATE TABLE [dbo].[HydrologicalCharacteristics] ( + [Watershed_ID] INT NOT NULL, + [UrbanArea] REAL, + [Forest] REAL, + [Wetlands] REAL, + [Cropland] REAL, + [Meadow] REAL, + [Grassland] REAL, + CONSTRAINT [PK_HydrologicalCharacteristics] PRIMARY KEY ([Watershed_ID]) +); + +CREATE TABLE [dbo].[LabAnalysis] ( + [LabAnalysis_ID] INT IDENTITY(1,1) NOT NULL, + [LabExperiment_ID] INT NOT NULL, + [AnalysisSeries_ID] INT NOT NULL, + [Sample_ID] INT NOT NULL, + [Replicate] INT NOT NULL DEFAULT 1, + [QualityCode_ID] INT, + [ReviewStatus_ID] INT NOT NULL DEFAULT 1, + [ReviewedByPerson_ID] INT, + [ReviewDateTime] DATETIME2(7), + [Laboratory_ID] INT, + [AnalystPerson_ID] INT, + [Procedure_ID] INT, + [AnalysisDateTime] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_LabAnalysis] PRIMARY KEY ([LabAnalysis_ID]), + CONSTRAINT [UQ_LabAnalysis_Identity] UNIQUE ([LabExperiment_ID], [AnalysisSeries_ID], [Sample_ID], [Replicate]) +); + +CREATE TABLE [dbo].[LabExperiment] ( + [LabExperiment_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Campaign_ID] INT, + [ExperimentDateTime] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + [Description] NVARCHAR(MAX), + [CreatedByPerson_ID] INT, + [LabPanel_ID] INT, + CONSTRAINT [PK_LabExperiment] PRIMARY KEY ([LabExperiment_ID]) +); + +CREATE TABLE [dbo].[LabPanel] ( + [LabPanel_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(MAX), + [CreatedByPerson_ID] INT, + [DefaultSampleCollectionKind_ID] INT, + [DefaultSampleEquipment_ID] INT, + [CreatedAt] DATETIME2(7) NOT NULL DEFAULT GETUTCDATE(), + CONSTRAINT [PK_LabPanel] PRIMARY KEY ([LabPanel_ID]) +); + +CREATE TABLE [dbo].[LabPanelSeries] ( + [LabPanel_ID] INT NOT NULL, + [AnalysisSeries_ID] INT NOT NULL, + CONSTRAINT [PK_LabPanelSeries] PRIMARY KEY ([LabPanel_ID], [AnalysisSeries_ID]) +); + +CREATE TABLE [dbo].[Laboratory] ( + [Laboratory_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Site_ID] INT, + [Description] NVARCHAR(500), + CONSTRAINT [PK_Laboratory] PRIMARY KEY ([Laboratory_ID]) +); + +CREATE TABLE [dbo].[LandUse] ( + [Watershed_ID] INT NOT NULL, + [Commercial] REAL, + [GreenSpaces] REAL, + [Industrial] REAL, + [Institutional] REAL, + [Residential] REAL, + [Agricultural] REAL, + [Recreational] REAL, + CONSTRAINT [PK_LandUse] PRIMARY KEY ([Watershed_ID]) +); + +CREATE TABLE [dbo].[Observation] ( + [Observation_ID] INT IDENTITY(1,1) NOT NULL, + [Channel_ID] INT, + [LabAnalysis_ID] INT, + [Timestamp] DATETIME2(7) NOT NULL, + [ValueKind_ID] INT NOT NULL, + CONSTRAINT [PK_Observation] PRIMARY KEY ([Observation_ID]), + CONSTRAINT [CK_Observation_Source] CHECK ((Channel_ID IS NOT NULL AND LabAnalysis_ID IS NULL) OR (Channel_ID IS NULL AND LabAnalysis_ID IS NOT NULL)) +); + +CREATE TABLE [dbo].[Parameter] ( + [Parameter_ID] INT IDENTITY(1,1) NOT NULL, + [Parameter] NVARCHAR(100), + [Description] NVARCHAR(MAX), + [ENVO_IRI] NVARCHAR(256), + [ValueKind_ID] INT NOT NULL DEFAULT 1, + [QUDT_QuantityKind_IRI] NVARCHAR(256), + CONSTRAINT [PK_Parameter] PRIMARY KEY ([Parameter_ID]) +); + +CREATE TABLE [dbo].[ParameterHasUnit] ( + [Parameter_ID] INT NOT NULL, + [Unit_ID] INT NOT NULL, + CONSTRAINT [PK_ParameterHasUnit] PRIMARY KEY ([Parameter_ID], [Unit_ID]) +); + +CREATE TABLE [dbo].[Procedures] ( + [Procedure_ID] INT IDENTITY(1,1) NOT NULL, + [ProcedureName] NVARCHAR(100), + [ProcedureKind_ID] INT, + [Description] NVARCHAR(MAX), + [ProcedureLocation] NVARCHAR(100), + CONSTRAINT [PK_Procedures] PRIMARY KEY ([Procedure_ID]) +); + +CREATE TABLE [dbo].[ProcessUnit] ( + [ProcessUnit_ID] INT IDENTITY(1,1) NOT NULL, + [Site_ID] INT NOT NULL, + [Tag] NVARCHAR(100) NOT NULL, + [Name] NVARCHAR(255) NOT NULL, + [Description] NVARCHAR(MAX), + [ProcessUnitKind_ID] INT, + [Parent_ID] INT, + CONSTRAINT [PK_ProcessUnit] PRIMARY KEY ([ProcessUnit_ID]), + CONSTRAINT [UQ_ProcessUnit_SiteTag] UNIQUE ([Site_ID], [Tag]) +); + +CREATE TABLE [dbo].[ProcessingLineage] ( + [ProcessingLineage_ID] INT IDENTITY(1,1) NOT NULL, + [ProcessingStep_ID] INT NOT NULL, + [Stream_ID] INT NOT NULL, + [StartTime] DATETIME2(7), + [EndTime] DATETIME2(7), + CONSTRAINT [PK_ProcessingLineage] PRIMARY KEY ([ProcessingLineage_ID]) +); + +CREATE TABLE [dbo].[ProcessingStep] ( + [ProcessingStep_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(MAX), + [MethodName] NVARCHAR(200), + [MethodVersion] NVARCHAR(100), + [OperationKind_ID] INT, + [MethodParameters] NVARCHAR(MAX), + [ExecutedDateTime] DATETIME2(7), + [ExecutedByPerson_ID] INT, + [Dataset_ID] INT, + CONSTRAINT [PK_ProcessingStep] PRIMARY KEY ([ProcessingStep_ID]) +); + +CREATE TABLE [dbo].[Sample] ( + [Sample_ID] INT IDENTITY(1,1) NOT NULL, + [ParentSample_ID] INT, + [SampleKind_ID] INT, + [SamplingPoint_ID] INT NOT NULL, + [SampledByPerson_ID] INT, + [Campaign_ID] INT, + [SampleDateTimeStart] DATETIME2(7) NOT NULL, + [SampleDateTimeEnd] DATETIME2(7), + [SampleCollectionKind_ID] INT, + [SampleEquipment_ID] INT, + [Description] NVARCHAR(500), + CONSTRAINT [PK_Sample] PRIMARY KEY ([Sample_ID]) +); + +CREATE TABLE [dbo].[SamplingPoint] ( + [SamplingPoint_ID] INT IDENTITY(1,1) NOT NULL, + [Site_ID] INT NOT NULL, + [SamplingPoint] NVARCHAR(100) NOT NULL, + [LatitudeWGS84] FLOAT, + [LongitudeWGS84] FLOAT, + [Description] NVARCHAR(MAX), + [PicturePath] NVARCHAR(500), + [ValidFrom] DATETIME2(7), + [ValidTo] DATETIME2(7), + [ProcessUnit_ID] INT, + [CreatedByCampaign_ID] INT, + CONSTRAINT [PK_SamplingPoint] PRIMARY KEY ([SamplingPoint_ID]) +); + +CREATE TABLE [dbo].[SignalInterface] ( + [SignalInterface_ID] INT IDENTITY(1,1) NOT NULL, + [DataAcquisitionSystem_ID] INT NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Manufacturer] NVARCHAR(100), + [Model] NVARCHAR(100), + [SerialNumber] NVARCHAR(100), + [Description] NVARCHAR(MAX), + [IsActive] BIT NOT NULL DEFAULT 1, + CONSTRAINT [PK_SignalInterface] PRIMARY KEY ([SignalInterface_ID]) +); + +CREATE TABLE [dbo].[SignalInterfacePort] ( + [SignalInterfacePort_ID] INT IDENTITY(1,1) NOT NULL, + [SignalInterface_ID] INT NOT NULL, + [PortIdentifier] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(MAX), + [IsActive] BIT NOT NULL DEFAULT 1, + CONSTRAINT [PK_SignalInterfacePort] PRIMARY KEY ([SignalInterfacePort_ID]) +); + +CREATE TABLE [dbo].[Site] ( + [Site_ID] INT IDENTITY(1,1) NOT NULL, + [Watershed_ID] INT, + [Name] NVARCHAR(100), + [SiteKind_ID] INT, + [Description] NVARCHAR(MAX), + [LatitudeWGS84] FLOAT, + [LongitudeWGS84] FLOAT, + [StreetNumber] NVARCHAR(100), + [StreetName] NVARCHAR(100), + [City] NVARCHAR(255), + [PostCode] NVARCHAR(100), + [Province] NVARCHAR(255), + [Country] NVARCHAR(255), + CONSTRAINT [PK_Site] PRIMARY KEY ([Site_ID]) +); + +CREATE TABLE [dbo].[Stream] ( + [Stream_ID] INT IDENTITY(1,1) NOT NULL, + [StreamKind_ID] INT NOT NULL, + CONSTRAINT [PK_Stream] PRIMARY KEY ([Stream_ID]) +); + +CREATE TABLE [dbo].[Value] ( + [Observation_ID] INT NOT NULL, + [Value] FLOAT, + [QualityCode] INT, + CONSTRAINT [PK_Value] PRIMARY KEY ([Observation_ID]) +); + +CREATE TABLE [dbo].[ValueBin] ( + [ValueBin_ID] INT IDENTITY(1,1) NOT NULL, + [ValueBinningAxis_ID] INT NOT NULL, + [BinIndex] INT NOT NULL, + [LowerBound] FLOAT, + [UpperBound] FLOAT, + [NominalValue] FLOAT, + CONSTRAINT [PK_ValueBin] PRIMARY KEY ([ValueBin_ID]), + CONSTRAINT [UQ_ValueBin_AxisIndex] UNIQUE ([ValueBinningAxis_ID], [BinIndex]), + CONSTRAINT [CK_ValueBin_BinValues] CHECK (((LowerBound IS NULL AND UpperBound IS NULL) OR (LowerBound IS NOT NULL AND UpperBound IS NOT NULL)) AND (LowerBound IS NULL OR UpperBound > LowerBound) AND (NominalValue IS NOT NULL OR LowerBound IS NOT NULL) +) +); + +CREATE TABLE [dbo].[ValueBinningAxis] ( + [ValueBinningAxis_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(500), + [NumberOfBins] INT NOT NULL, + [Unit_ID] INT NOT NULL, + [BinKind_ID] INT NOT NULL DEFAULT 1, + CONSTRAINT [PK_ValueBinningAxis] PRIMARY KEY ([ValueBinningAxis_ID]) +); + +CREATE TABLE [dbo].[ValueImage] ( + [Observation_ID] INT NOT NULL, + [ImageWidth] INT NOT NULL, + [ImageHeight] INT NOT NULL, + [NumberOfChannels] INT NOT NULL DEFAULT 3, + [ImageFormat] NVARCHAR(20) NOT NULL, + [FileSizeBytes] BIGINT, + [StorageBackend] NVARCHAR(50) NOT NULL DEFAULT 'FileSystem', + [StoragePath] NVARCHAR(1000) NOT NULL, + [Thumbnail] VARBINARY(MAX), + [QualityCode] INT, + CONSTRAINT [PK_ValueImage] PRIMARY KEY ([Observation_ID]) +); + +CREATE TABLE [dbo].[ValueMatrix] ( + [Observation_ID] INT NOT NULL, + [RowValueBin_ID] INT NOT NULL, + [ColValueBin_ID] INT NOT NULL, + [Value] FLOAT, + [QualityCode] INT, + CONSTRAINT [PK_ValueMatrix] PRIMARY KEY ([Observation_ID], [RowValueBin_ID], [ColValueBin_ID]) +); + +CREATE TABLE [dbo].[ValueVector] ( + [Observation_ID] INT NOT NULL, + [ValueBin_ID] INT NOT NULL, + [Value] FLOAT, + [QualityCode] INT, + CONSTRAINT [PK_ValueVector] PRIMARY KEY ([Observation_ID], [ValueBin_ID]) +); + +CREATE TABLE [dbo].[Watershed] ( + [Watershed_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100), + [Description] NVARCHAR(MAX), + [SurfaceArea] REAL, + [ConcentrationTime] INT, + [ImperviousSurface] REAL, + [ParentWatershed_ID] INT, + [GeometryGeoJSON] NVARCHAR(MAX), + CONSTRAINT [PK_Watershed] PRIMARY KEY ([Watershed_ID]) +); + + + + + + + + + + + + + + + + + + + + + + + +CREATE INDEX [IX_UserAccount_Email] ON [dbo].[UserAccount] ([Email]); + + + + +CREATE INDEX [IX_Annotation_Stream_Time] ON [dbo].[Annotation] ([Stream_ID], [StartTime], [EndTime]); +CREATE INDEX [IX_Annotation_Author] ON [dbo].[Annotation] ([AuthorPerson_ID], [CreatedDateTime]); + +CREATE INDEX [IX_AuditLog_UserAccount_ID] ON [dbo].[AuditLog] ([UserAccount_ID]); +CREATE INDEX [IX_AuditLog_Timestamp] ON [dbo].[AuditLog] ([Timestamp]); +CREATE INDEX [IX_AuditLog_ResourceType] ON [dbo].[AuditLog] ([ResourceType]); + + + + +CREATE UNIQUE INDEX [UQ_Channel_SignalStream] ON [dbo].[Channel] ([SignalInterface_ID], [TagName], [Parameter_ID], [DataProvenanceKind_ID], [ProducedByStep_ID]); +CREATE INDEX [IX_Channel_ParentChannel] ON [dbo].[Channel] ([ParentChannel_ID]); + + +CREATE UNIQUE INDEX [UQ_ChannelPortHistory_ActiveRow] ON [dbo].[ChannelPortHistory] ([Channel_ID]) WHERE [ValidTo] IS NULL; +CREATE INDEX [IX_ChannelPortHistory_Port] ON [dbo].[ChannelPortHistory] ([SignalInterfacePort_ID], [ValidFrom]); + +CREATE INDEX [IX_ChannelTrait_Stream] ON [dbo].[ChannelTrait] ([Stream_ID]); + + +CREATE UNIQUE INDEX [UQ_ControlLoopApplication_ActiveRow] ON [dbo].[ControlLoopApplication] ([ControlLoop_ID]) WHERE [EndTime] IS NULL; + +CREATE UNIQUE INDEX [UQ_ControlLoopPort_LoopChannel] ON [dbo].[ControlLoopPort] ([ControlLoop_ID], [Channel_ID]); + +CREATE UNIQUE INDEX [UQ_DASLocationHistory_ActivePerDAS] ON [dbo].[DASLocationHistory] ([DataAcquisitionSystem_ID]) WHERE [ValidTo] IS NULL; +CREATE INDEX [IX_DASLocationHistory_Site_ValidFrom] ON [dbo].[DASLocationHistory] ([Site_ID], [ValidFrom]); + + + + + +CREATE INDEX [IX_EquipmentEvent_Equipment_Start] ON [dbo].[EquipmentEvent] ([Equipment_ID], [EventDateTimeStart]); + +CREATE UNIQUE INDEX [UQ_EquipmentLocationHistory_ActiveRow] ON [dbo].[EquipmentLocationHistory] ([Equipment_ID]) WHERE [ValidTo] IS NULL; +CREATE INDEX [IX_EquipmentLocationHistory_SamplingPoint] ON [dbo].[EquipmentLocationHistory] ([SamplingPoint_ID], [ValidFrom]); + + + +CREATE UNIQUE INDEX [UQ_EquipmentWiringHistory_ActiveRow] ON [dbo].[EquipmentWiringHistory] ([Equipment_ID]) WHERE [ValidTo] IS NULL; +CREATE INDEX [IX_EquipmentWiringHistory_Interface] ON [dbo].[EquipmentWiringHistory] ([SignalInterface_ID], [ValidFrom]); + + + + + + + + +CREATE UNIQUE INDEX [UQ_Obs_Channel] ON [dbo].[Observation] ([Channel_ID], [Timestamp], [ValueKind_ID]) WHERE Channel_ID IS NOT NULL; +CREATE UNIQUE INDEX [UQ_Obs_Lab] ON [dbo].[Observation] ([LabAnalysis_ID]) WHERE LabAnalysis_ID IS NOT NULL; + + + + + +CREATE INDEX [IX_ProcessingLineage_Stream] ON [dbo].[ProcessingLineage] ([Stream_ID]); +CREATE INDEX [IX_Lineage_Step] ON [dbo].[ProcessingLineage] ([ProcessingStep_ID]); + + + + +CREATE UNIQUE INDEX [UQ_SignalInterface_DAS_Name] ON [dbo].[SignalInterface] ([DataAcquisitionSystem_ID], [Name]); + +CREATE UNIQUE INDEX [UQ_SignalInterfacePort_Interface_PortId] ON [dbo].[SignalInterfacePort] ([SignalInterface_ID], [PortIdentifier]); + + + + + + + + + + +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_Parameter_ID] FOREIGN KEY ([Parameter_ID]) REFERENCES [dbo].[Parameter] ([Parameter_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_SamplingPoint_ID] FOREIGN KEY ([SamplingPoint_ID]) REFERENCES [dbo].[SamplingPoint] ([SamplingPoint_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_ValueKind_ID] FOREIGN KEY ([ValueKind_ID]) REFERENCES [dbo].[ValueKind] ([ValueKind_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_Unit_ID] FOREIGN KEY ([Unit_ID]) REFERENCES [dbo].[Unit] ([Unit_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[AnalysisSeriesAxis] ADD CONSTRAINT [FK_AnalysisSeriesAxis_AnalysisSeries_ID] FOREIGN KEY ([AnalysisSeries_ID]) REFERENCES [dbo].[AnalysisSeries] ([Stream_ID]); +ALTER TABLE [dbo].[AnalysisSeriesAxis] ADD CONSTRAINT [FK_AnalysisSeriesAxis_ValueBinningAxis_ID] FOREIGN KEY ([ValueBinningAxis_ID]) REFERENCES [dbo].[ValueBinningAxis] ([ValueBinningAxis_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_AnnotationKind_ID] FOREIGN KEY ([AnnotationKind_ID]) REFERENCES [dbo].[AnnotationKind] ([AnnotationKind_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_AuthorPerson_ID] FOREIGN KEY ([AuthorPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_EquipmentEvent_ID] FOREIGN KEY ([EquipmentEvent_ID]) REFERENCES [dbo].[EquipmentEvent] ([EquipmentEvent_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[AuditLog] ADD CONSTRAINT [FK_AuditLog_UserAccount_ID] FOREIGN KEY ([UserAccount_ID]) REFERENCES [dbo].[UserAccount] ([UserAccount_ID]); +ALTER TABLE [dbo].[Campaign] ADD CONSTRAINT [FK_Campaign_CampaignKind_ID] FOREIGN KEY ([CampaignKind_ID]) REFERENCES [dbo].[CampaignKind] ([CampaignKind_ID]); +ALTER TABLE [dbo].[Campaign] ADD CONSTRAINT [FK_Campaign_Site_ID] FOREIGN KEY ([Site_ID]) REFERENCES [dbo].[Site] ([Site_ID]); +ALTER TABLE [dbo].[Campaign] ADD CONSTRAINT [FK_Campaign_ResponsiblePerson_ID] FOREIGN KEY ([ResponsiblePerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[CampaignEquipment] ADD CONSTRAINT [FK_CampaignEquipment_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[CampaignEquipment] ADD CONSTRAINT [FK_CampaignEquipment_Equipment_ID] FOREIGN KEY ([Equipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[CampaignSamplingLocation] ADD CONSTRAINT [FK_CampaignSamplingLocation_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[CampaignSamplingLocation] ADD CONSTRAINT [FK_CampaignSamplingLocation_SamplingPoint_ID] FOREIGN KEY ([SamplingPoint_ID]) REFERENCES [dbo].[SamplingPoint] ([SamplingPoint_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_SignalInterface_ID] FOREIGN KEY ([SignalInterface_ID]) REFERENCES [dbo].[SignalInterface] ([SignalInterface_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_ParentChannel_ID] FOREIGN KEY ([ParentChannel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_ChannelKind_ID] FOREIGN KEY ([ChannelKind_ID]) REFERENCES [dbo].[ChannelKind] ([ChannelKind_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_Parameter_ID] FOREIGN KEY ([Parameter_ID]) REFERENCES [dbo].[Parameter] ([Parameter_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_DataProvenanceKind_ID] FOREIGN KEY ([DataProvenanceKind_ID]) REFERENCES [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_ProducedByStep_ID] FOREIGN KEY ([ProducedByStep_ID]) REFERENCES [dbo].[ProcessingStep] ([ProcessingStep_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_ValueKind_ID] FOREIGN KEY ([ValueKind_ID]) REFERENCES [dbo].[ValueKind] ([ValueKind_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_Unit_ID] FOREIGN KEY ([Unit_ID]) REFERENCES [dbo].[Unit] ([Unit_ID]); +ALTER TABLE [dbo].[ChannelAxis] ADD CONSTRAINT [FK_ChannelAxis_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[ChannelAxis] ADD CONSTRAINT [FK_ChannelAxis_ValueBinningAxis_ID] FOREIGN KEY ([ValueBinningAxis_ID]) REFERENCES [dbo].[ValueBinningAxis] ([ValueBinningAxis_ID]); +ALTER TABLE [dbo].[ChannelPortHistory] ADD CONSTRAINT [FK_ChannelPortHistory_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[ChannelPortHistory] ADD CONSTRAINT [FK_ChannelPortHistory_SignalInterfacePort_ID] FOREIGN KEY ([SignalInterfacePort_ID]) REFERENCES [dbo].[SignalInterfacePort] ([SignalInterfacePort_ID]); +ALTER TABLE [dbo].[ChannelTrait] ADD CONSTRAINT [FK_ChannelTrait_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[ChannelTrait] ADD CONSTRAINT [FK_ChannelTrait_OperationKind_ID] FOREIGN KEY ([OperationKind_ID]) REFERENCES [dbo].[OperationKind] ([OperationKind_ID]); +ALTER TABLE [dbo].[ControlLoop] ADD CONSTRAINT [FK_ControlLoop_ControllerKind_ID] FOREIGN KEY ([ControllerKind_ID]) REFERENCES [dbo].[ControllerKind] ([ControllerKind_ID]); +ALTER TABLE [dbo].[ControlLoop] ADD CONSTRAINT [FK_ControlLoop_FallbackControlLoop_ID] FOREIGN KEY ([FallbackControlLoop_ID]) REFERENCES [dbo].[ControlLoop] ([ControlLoop_ID]); +ALTER TABLE [dbo].[ControlLoopApplication] ADD CONSTRAINT [FK_ControlLoopApplication_ControlLoop_ID] FOREIGN KEY ([ControlLoop_ID]) REFERENCES [dbo].[ControlLoop] ([ControlLoop_ID]); +ALTER TABLE [dbo].[ControlLoopApplication] ADD CONSTRAINT [FK_ControlLoopApplication_AppliedByPerson_ID] FOREIGN KEY ([AppliedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[ControlLoopPort] ADD CONSTRAINT [FK_ControlLoopPort_ControlLoop_ID] FOREIGN KEY ([ControlLoop_ID]) REFERENCES [dbo].[ControlLoop] ([ControlLoop_ID]); +ALTER TABLE [dbo].[ControlLoopPort] ADD CONSTRAINT [FK_ControlLoopPort_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[ControlLoopPort] ADD CONSTRAINT [FK_ControlLoopPort_ControlLoopPortKind_ID] FOREIGN KEY ([ControlLoopPortKind_ID]) REFERENCES [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID]); +ALTER TABLE [dbo].[DASLocationHistory] ADD CONSTRAINT [FK_DASLocationHistory_DataAcquisitionSystem_ID] FOREIGN KEY ([DataAcquisitionSystem_ID]) REFERENCES [dbo].[DataAcquisitionSystem] ([DataAcquisitionSystem_ID]); +ALTER TABLE [dbo].[DASLocationHistory] ADD CONSTRAINT [FK_DASLocationHistory_Site_ID] FOREIGN KEY ([Site_ID]) REFERENCES [dbo].[Site] ([Site_ID]); +ALTER TABLE [dbo].[DASLocationHistory] ADD CONSTRAINT [FK_DASLocationHistory_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[DataAcquisitionSystem] ADD CONSTRAINT [FK_DataAcquisitionSystem_ParentSystem_ID] FOREIGN KEY ([ParentSystem_ID]) REFERENCES [dbo].[DataAcquisitionSystem] ([DataAcquisitionSystem_ID]); +ALTER TABLE [dbo].[DataAcquisitionSystem] ADD CONSTRAINT [FK_DataAcquisitionSystem_DataAcquisitionSystemKind_ID] FOREIGN KEY ([DataAcquisitionSystemKind_ID]) REFERENCES [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID]); +ALTER TABLE [dbo].[Dataset] ADD CONSTRAINT [FK_Dataset_CreatedByPerson_ID] FOREIGN KEY ([CreatedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[DatasetChannel] ADD CONSTRAINT [FK_DatasetChannel_Dataset_ID] FOREIGN KEY ([Dataset_ID]) REFERENCES [dbo].[Dataset] ([Dataset_ID]); +ALTER TABLE [dbo].[DatasetChannel] ADD CONSTRAINT [FK_DatasetChannel_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[Equipment] ADD CONSTRAINT [FK_Equipment_EquipmentModel_ID] FOREIGN KEY ([EquipmentModel_ID]) REFERENCES [dbo].[EquipmentModel] ([EquipmentModel_ID]); +ALTER TABLE [dbo].[EquipmentEvent] ADD CONSTRAINT [FK_EquipmentEvent_Equipment_ID] FOREIGN KEY ([Equipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[EquipmentEvent] ADD CONSTRAINT [FK_EquipmentEvent_EquipmentEventKind_ID] FOREIGN KEY ([EquipmentEventKind_ID]) REFERENCES [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID]); +ALTER TABLE [dbo].[EquipmentEvent] ADD CONSTRAINT [FK_EquipmentEvent_PerformedByPerson_ID] FOREIGN KEY ([PerformedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[EquipmentEvent] ADD CONSTRAINT [FK_EquipmentEvent_RecordedByPerson_ID] FOREIGN KEY ([RecordedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[EquipmentLocationHistory] ADD CONSTRAINT [FK_EquipmentLocationHistory_Equipment_ID] FOREIGN KEY ([Equipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[EquipmentLocationHistory] ADD CONSTRAINT [FK_EquipmentLocationHistory_SamplingPoint_ID] FOREIGN KEY ([SamplingPoint_ID]) REFERENCES [dbo].[SamplingPoint] ([SamplingPoint_ID]); +ALTER TABLE [dbo].[EquipmentLocationHistory] ADD CONSTRAINT [FK_EquipmentLocationHistory_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[EquipmentModelHasParameter] ADD CONSTRAINT [FK_EquipmentModelHasParameter_EquipmentModel_ID] FOREIGN KEY ([EquipmentModel_ID]) REFERENCES [dbo].[EquipmentModel] ([EquipmentModel_ID]); +ALTER TABLE [dbo].[EquipmentModelHasParameter] ADD CONSTRAINT [FK_EquipmentModelHasParameter_Parameter_ID] FOREIGN KEY ([Parameter_ID]) REFERENCES [dbo].[Parameter] ([Parameter_ID]); +ALTER TABLE [dbo].[EquipmentModelHasProcedures] ADD CONSTRAINT [FK_EquipmentModelHasProcedures_EquipmentModel_ID] FOREIGN KEY ([EquipmentModel_ID]) REFERENCES [dbo].[EquipmentModel] ([EquipmentModel_ID]); +ALTER TABLE [dbo].[EquipmentModelHasProcedures] ADD CONSTRAINT [FK_EquipmentModelHasProcedures_Procedure_ID] FOREIGN KEY ([Procedure_ID]) REFERENCES [dbo].[Procedures] ([Procedure_ID]); +ALTER TABLE [dbo].[EquipmentWiringHistory] ADD CONSTRAINT [FK_EquipmentWiringHistory_Equipment_ID] FOREIGN KEY ([Equipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[EquipmentWiringHistory] ADD CONSTRAINT [FK_EquipmentWiringHistory_SignalInterface_ID] FOREIGN KEY ([SignalInterface_ID]) REFERENCES [dbo].[SignalInterface] ([SignalInterface_ID]); +ALTER TABLE [dbo].[EquipmentWiringHistory] ADD CONSTRAINT [FK_EquipmentWiringHistory_SignalInterfacePort_ID] FOREIGN KEY ([SignalInterfacePort_ID]) REFERENCES [dbo].[SignalInterfacePort] ([SignalInterfacePort_ID]); +ALTER TABLE [dbo].[HydrologicalCharacteristics] ADD CONSTRAINT [FK_HydrologicalCharacteristics_Watershed_ID] FOREIGN KEY ([Watershed_ID]) REFERENCES [dbo].[Watershed] ([Watershed_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_LabExperiment_ID] FOREIGN KEY ([LabExperiment_ID]) REFERENCES [dbo].[LabExperiment] ([LabExperiment_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_AnalysisSeries_ID] FOREIGN KEY ([AnalysisSeries_ID]) REFERENCES [dbo].[AnalysisSeries] ([Stream_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_Sample_ID] FOREIGN KEY ([Sample_ID]) REFERENCES [dbo].[Sample] ([Sample_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_QualityCode_ID] FOREIGN KEY ([QualityCode_ID]) REFERENCES [dbo].[QualityCode] ([QualityCode_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_ReviewStatus_ID] FOREIGN KEY ([ReviewStatus_ID]) REFERENCES [dbo].[ReviewStatus] ([ReviewStatus_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_ReviewedByPerson_ID] FOREIGN KEY ([ReviewedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_Laboratory_ID] FOREIGN KEY ([Laboratory_ID]) REFERENCES [dbo].[Laboratory] ([Laboratory_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_AnalystPerson_ID] FOREIGN KEY ([AnalystPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_Procedure_ID] FOREIGN KEY ([Procedure_ID]) REFERENCES [dbo].[Procedures] ([Procedure_ID]); +ALTER TABLE [dbo].[LabExperiment] ADD CONSTRAINT [FK_LabExperiment_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[LabExperiment] ADD CONSTRAINT [FK_LabExperiment_CreatedByPerson_ID] FOREIGN KEY ([CreatedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[LabExperiment] ADD CONSTRAINT [FK_LabExperiment_LabPanel_ID] FOREIGN KEY ([LabPanel_ID]) REFERENCES [dbo].[LabPanel] ([LabPanel_ID]); +ALTER TABLE [dbo].[LabPanel] ADD CONSTRAINT [FK_LabPanel_CreatedByPerson_ID] FOREIGN KEY ([CreatedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[LabPanel] ADD CONSTRAINT [FK_LabPanel_DefaultSampleCollectionKind_ID] FOREIGN KEY ([DefaultSampleCollectionKind_ID]) REFERENCES [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID]); +ALTER TABLE [dbo].[LabPanel] ADD CONSTRAINT [FK_LabPanel_DefaultSampleEquipment_ID] FOREIGN KEY ([DefaultSampleEquipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[LabPanelSeries] ADD CONSTRAINT [FK_LabPanelSeries_LabPanel_ID] FOREIGN KEY ([LabPanel_ID]) REFERENCES [dbo].[LabPanel] ([LabPanel_ID]); +ALTER TABLE [dbo].[LabPanelSeries] ADD CONSTRAINT [FK_LabPanelSeries_AnalysisSeries_ID] FOREIGN KEY ([AnalysisSeries_ID]) REFERENCES [dbo].[AnalysisSeries] ([Stream_ID]); +ALTER TABLE [dbo].[Laboratory] ADD CONSTRAINT [FK_Laboratory_Site_ID] FOREIGN KEY ([Site_ID]) REFERENCES [dbo].[Site] ([Site_ID]); +ALTER TABLE [dbo].[LandUse] ADD CONSTRAINT [FK_LandUse_Watershed_ID] FOREIGN KEY ([Watershed_ID]) REFERENCES [dbo].[Watershed] ([Watershed_ID]); +ALTER TABLE [dbo].[Observation] ADD CONSTRAINT [FK_Observation_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[Observation] ADD CONSTRAINT [FK_Observation_LabAnalysis_ID] FOREIGN KEY ([LabAnalysis_ID]) REFERENCES [dbo].[LabAnalysis] ([LabAnalysis_ID]); +ALTER TABLE [dbo].[Observation] ADD CONSTRAINT [FK_Observation_ValueKind_ID] FOREIGN KEY ([ValueKind_ID]) REFERENCES [dbo].[ValueKind] ([ValueKind_ID]); +ALTER TABLE [dbo].[Parameter] ADD CONSTRAINT [FK_Parameter_ValueKind_ID] FOREIGN KEY ([ValueKind_ID]) REFERENCES [dbo].[ValueKind] ([ValueKind_ID]); +ALTER TABLE [dbo].[ParameterHasUnit] ADD CONSTRAINT [FK_ParameterHasUnit_Parameter_ID] FOREIGN KEY ([Parameter_ID]) REFERENCES [dbo].[Parameter] ([Parameter_ID]); +ALTER TABLE [dbo].[ParameterHasUnit] ADD CONSTRAINT [FK_ParameterHasUnit_Unit_ID] FOREIGN KEY ([Unit_ID]) REFERENCES [dbo].[Unit] ([Unit_ID]); +ALTER TABLE [dbo].[Procedures] ADD CONSTRAINT [FK_Procedures_ProcedureKind_ID] FOREIGN KEY ([ProcedureKind_ID]) REFERENCES [dbo].[ProcedureKind] ([ProcedureKind_ID]); +ALTER TABLE [dbo].[ProcessUnit] ADD CONSTRAINT [FK_ProcessUnit_Site_ID] FOREIGN KEY ([Site_ID]) REFERENCES [dbo].[Site] ([Site_ID]); +ALTER TABLE [dbo].[ProcessUnit] ADD CONSTRAINT [FK_ProcessUnit_ProcessUnitKind_ID] FOREIGN KEY ([ProcessUnitKind_ID]) REFERENCES [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID]); +ALTER TABLE [dbo].[ProcessUnit] ADD CONSTRAINT [FK_ProcessUnit_Parent_ID] FOREIGN KEY ([Parent_ID]) REFERENCES [dbo].[ProcessUnit] ([ProcessUnit_ID]); +ALTER TABLE [dbo].[ProcessingLineage] ADD CONSTRAINT [FK_ProcessingLineage_ProcessingStep_ID] FOREIGN KEY ([ProcessingStep_ID]) REFERENCES [dbo].[ProcessingStep] ([ProcessingStep_ID]); +ALTER TABLE [dbo].[ProcessingLineage] ADD CONSTRAINT [FK_ProcessingLineage_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[ProcessingStep] ADD CONSTRAINT [FK_ProcessingStep_OperationKind_ID] FOREIGN KEY ([OperationKind_ID]) REFERENCES [dbo].[OperationKind] ([OperationKind_ID]); +ALTER TABLE [dbo].[ProcessingStep] ADD CONSTRAINT [FK_ProcessingStep_ExecutedByPerson_ID] FOREIGN KEY ([ExecutedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[ProcessingStep] ADD CONSTRAINT [FK_ProcessingStep_Dataset_ID] FOREIGN KEY ([Dataset_ID]) REFERENCES [dbo].[Dataset] ([Dataset_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_ParentSample_ID] FOREIGN KEY ([ParentSample_ID]) REFERENCES [dbo].[Sample] ([Sample_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SampleKind_ID] FOREIGN KEY ([SampleKind_ID]) REFERENCES [dbo].[SampleKind] ([SampleKind_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SamplingPoint_ID] FOREIGN KEY ([SamplingPoint_ID]) REFERENCES [dbo].[SamplingPoint] ([SamplingPoint_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SampledByPerson_ID] FOREIGN KEY ([SampledByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SampleCollectionKind_ID] FOREIGN KEY ([SampleCollectionKind_ID]) REFERENCES [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SampleEquipment_ID] FOREIGN KEY ([SampleEquipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[SamplingPoint] ADD CONSTRAINT [FK_SamplingPoint_Site_ID] FOREIGN KEY ([Site_ID]) REFERENCES [dbo].[Site] ([Site_ID]); +ALTER TABLE [dbo].[SamplingPoint] ADD CONSTRAINT [FK_SamplingPoint_ProcessUnit_ID] FOREIGN KEY ([ProcessUnit_ID]) REFERENCES [dbo].[ProcessUnit] ([ProcessUnit_ID]); +ALTER TABLE [dbo].[SamplingPoint] ADD CONSTRAINT [FK_SamplingPoint_CreatedByCampaign_ID] FOREIGN KEY ([CreatedByCampaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[SignalInterface] ADD CONSTRAINT [FK_SignalInterface_DataAcquisitionSystem_ID] FOREIGN KEY ([DataAcquisitionSystem_ID]) REFERENCES [dbo].[DataAcquisitionSystem] ([DataAcquisitionSystem_ID]); +ALTER TABLE [dbo].[SignalInterfacePort] ADD CONSTRAINT [FK_SignalInterfacePort_SignalInterface_ID] FOREIGN KEY ([SignalInterface_ID]) REFERENCES [dbo].[SignalInterface] ([SignalInterface_ID]); +ALTER TABLE [dbo].[Site] ADD CONSTRAINT [FK_Site_Watershed_ID] FOREIGN KEY ([Watershed_ID]) REFERENCES [dbo].[Watershed] ([Watershed_ID]); +ALTER TABLE [dbo].[Site] ADD CONSTRAINT [FK_Site_SiteKind_ID] FOREIGN KEY ([SiteKind_ID]) REFERENCES [dbo].[SiteKind] ([SiteKind_ID]); +ALTER TABLE [dbo].[Stream] ADD CONSTRAINT [FK_Stream_StreamKind_ID] FOREIGN KEY ([StreamKind_ID]) REFERENCES [dbo].[StreamKind] ([StreamKind_ID]); +ALTER TABLE [dbo].[Value] ADD CONSTRAINT [FK_Value_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[ValueBin] ADD CONSTRAINT [FK_ValueBin_ValueBinningAxis_ID] FOREIGN KEY ([ValueBinningAxis_ID]) REFERENCES [dbo].[ValueBinningAxis] ([ValueBinningAxis_ID]); +ALTER TABLE [dbo].[ValueBinningAxis] ADD CONSTRAINT [FK_ValueBinningAxis_Unit_ID] FOREIGN KEY ([Unit_ID]) REFERENCES [dbo].[Unit] ([Unit_ID]); +ALTER TABLE [dbo].[ValueBinningAxis] ADD CONSTRAINT [FK_ValueBinningAxis_BinKind_ID] FOREIGN KEY ([BinKind_ID]) REFERENCES [dbo].[BinKind] ([BinKind_ID]); +ALTER TABLE [dbo].[ValueImage] ADD CONSTRAINT [FK_ValueImage_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[ValueMatrix] ADD CONSTRAINT [FK_ValueMatrix_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[ValueMatrix] ADD CONSTRAINT [FK_ValueMatrix_RowValueBin] FOREIGN KEY ([RowValueBin_ID]) REFERENCES [dbo].[ValueBin] ([ValueBin_ID]); +ALTER TABLE [dbo].[ValueMatrix] ADD CONSTRAINT [FK_ValueMatrix_ColValueBin] FOREIGN KEY ([ColValueBin_ID]) REFERENCES [dbo].[ValueBin] ([ValueBin_ID]); +ALTER TABLE [dbo].[ValueVector] ADD CONSTRAINT [FK_ValueVector_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[ValueVector] ADD CONSTRAINT [FK_ValueVector_ValueBin_ID] FOREIGN KEY ([ValueBin_ID]) REFERENCES [dbo].[ValueBin] ([ValueBin_ID]); +ALTER TABLE [dbo].[Watershed] ADD CONSTRAINT [FK_Watershed_ParentWatershed_ID] FOREIGN KEY ([ParentWatershed_ID]) REFERENCES [dbo].[Watershed] ([Watershed_ID]); + +-- Views +GO +CREATE OR ALTER VIEW [dbo].[vw_ChannelEquipmentAtTime] AS +WITH channel_wiring AS ( + SELECT + o.[Observation_ID] AS ObservationID, + c.[Stream_ID] AS ChannelID, + o.[Timestamp] AS Timestamp, + c.[SignalInterface_ID], + c.[SignalInterfacePort_ID], + ewh.[Equipment_ID] AS EquipmentID, + ROW_NUMBER() OVER ( + PARTITION BY o.[Observation_ID] + ORDER BY + CASE WHEN ewh.[SignalInterfacePort_ID] IS NOT NULL THEN 0 ELSE 1 END, + ewh.[ValidFrom] DESC + ) AS rn, + COUNT(*) OVER (PARTITION BY o.[Observation_ID]) AS match_count + FROM [dbo].[Observation] o + JOIN [dbo].[vw_ChannelResolved] c ON c.[Stream_ID] = o.[Channel_ID] + LEFT JOIN [dbo].[EquipmentWiringHistory] ewh ON ewh.[SignalInterface_ID] = c.[SignalInterface_ID] + AND ( + ewh.[SignalInterfacePort_ID] = c.[SignalInterfacePort_ID] + OR (ewh.[SignalInterfacePort_ID] IS NULL AND c.[SignalInterfacePort_ID] IS NULL) + OR c.[SignalInterfacePort_ID] IS NULL + ) + AND ewh.[ValidFrom] <= o.[Timestamp] + AND (ewh.[ValidTo] IS NULL OR ewh.[ValidTo] > o.[Timestamp]) +) +SELECT + cw.ObservationID, + cw.ChannelID, + cw.Timestamp, + CASE WHEN cw.match_count > 1 AND cw.[SignalInterfacePort_ID] IS NULL THEN NULL ELSE cw.EquipmentID END AS EquipmentID, + e.[Identifier] AS EquipmentName, + CASE + WHEN cw.EquipmentID IS NULL AND cw.match_count = 0 THEN N'unlinked' + WHEN cw.match_count > 1 AND cw.[SignalInterfacePort_ID] IS NULL THEN N'ambiguous' + ELSE N'resolved' + END AS Resolution +FROM channel_wiring cw +LEFT JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = cw.EquipmentID +WHERE cw.rn = 1; + +GO +CREATE OR ALTER VIEW [dbo].[vw_ChannelLocationAtTime] AS +SELECT + cea.ObservationID, + cea.ChannelID, + cea.Timestamp, + cea.EquipmentID, + elh.[SamplingPoint_ID] AS SamplingPointID, + sp.[SamplingPoint] AS SamplingPointName +FROM [dbo].[vw_ChannelEquipmentAtTime] cea +LEFT JOIN [dbo].[EquipmentLocationHistory] elh ON elh.[Equipment_ID] = cea.EquipmentID + AND elh.[ValidFrom] <= cea.Timestamp + AND (elh.[ValidTo] IS NULL OR elh.[ValidTo] > cea.Timestamp) +LEFT JOIN [dbo].[SamplingPoint] sp ON sp.[SamplingPoint_ID] = elh.[SamplingPoint_ID]; + +GO +CREATE OR ALTER VIEW [dbo].[vw_ChannelResolved] AS +SELECT + c.[Stream_ID], + c.[SignalInterface_ID], + c.[TagName], + cph.[SignalInterfacePort_ID], + c.[ParentChannel_ID], + c.[ChannelKind_ID], + c.[Parameter_ID], + c.[DataProvenanceKind_ID], + c.[ProducedByStep_ID], + c.[ValueKind_ID], + c.[Unit_ID] +FROM [dbo].[Channel] c +LEFT JOIN [dbo].[ChannelPortHistory] cph + ON cph.[Channel_ID] = c.[Stream_ID] + AND cph.[ValidTo] IS NULL; + +GO +CREATE OR ALTER VIEW [dbo].[vw_ChannelStatus] AS +SELECT + statusC.[Stream_ID] AS StatusChannelID, + valueC.[Stream_ID] AS MeasurementChannelID, + e.[Equipment_ID] AS EquipmentID, + e.[Identifier] AS EquipmentName, + p.[Parameter] AS MeasurementParameter, + o.[Timestamp], + CAST(v.[Value] AS INT) AS StatusCodeID +FROM [dbo].[Value] v +JOIN [dbo].[Observation] o ON o.[Observation_ID] = v.[Observation_ID] +JOIN [dbo].[Channel] statusC ON statusC.[Stream_ID] = o.[Channel_ID] +JOIN [dbo].[ChannelKind] role ON role.[ChannelKind_ID] = statusC.[ChannelKind_ID] +JOIN [dbo].[vw_ChannelResolved] valueC ON valueC.[Stream_ID] = statusC.[ParentChannel_ID] +JOIN [dbo].[Parameter] p ON p.[Parameter_ID] = valueC.[Parameter_ID] +LEFT JOIN [dbo].[EquipmentWiringHistory] ewh + ON ewh.[SignalInterface_ID] = valueC.[SignalInterface_ID] + AND ( + ewh.[SignalInterfacePort_ID] = valueC.[SignalInterfacePort_ID] + OR valueC.[SignalInterfacePort_ID] IS NULL + ) + AND ewh.[ValidTo] IS NULL +LEFT JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = ewh.[Equipment_ID] +WHERE role.[Name] = N'Status' + AND statusC.[ParentChannel_ID] IS NOT NULL; + +GO +CREATE OR ALTER VIEW [dbo].[vw_DeviceStatus] AS +SELECT + statusC.[Stream_ID] AS StatusChannelID, + e.[Equipment_ID] AS EquipmentID, + e.[Identifier] AS EquipmentName, + o.[Timestamp], + CAST(v.[Value] AS INT) AS StatusCodeID +FROM [dbo].[Value] v +JOIN [dbo].[Observation] o ON o.[Observation_ID] = v.[Observation_ID] +JOIN [dbo].[Channel] statusC ON statusC.[Stream_ID] = o.[Channel_ID] +JOIN [dbo].[ChannelKind] role ON role.[ChannelKind_ID] = statusC.[ChannelKind_ID] +JOIN [dbo].[vw_ChannelResolved] valueC ON valueC.[Stream_ID] = statusC.[ParentChannel_ID] +JOIN [dbo].[EquipmentWiringHistory] ewh + ON ewh.[SignalInterface_ID] = valueC.[SignalInterface_ID] + AND ( + ewh.[SignalInterfacePort_ID] = valueC.[SignalInterfacePort_ID] + OR valueC.[SignalInterfacePort_ID] IS NULL + ) + AND ewh.[ValidTo] IS NULL +JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = ewh.[Equipment_ID] +WHERE role.[Name] = N'Status'; + + +GO +-- Schema version stamp (from schema_dictionary/version.yaml) +INSERT INTO [dbo].[SchemaVersion] ([Version], [Description]) +VALUES (N'2.1.0', N'Consistency hardening. Drops the denormalised Channel.SignalInterfacePort_ID column (F3): the current port is now resolved from the active ChannelPortHistory row via the new vw_ChannelResolved view, giving a single source of truth that cannot drift. Pre-release, fresh install only — no migration provided.'); diff --git a/sql_generation_scripts/v2.1.0_seed_mssql.sql b/sql_generation_scripts/v2.1.0_seed_mssql.sql new file mode 100644 index 0000000..4b9cd9a --- /dev/null +++ b/sql_generation_scripts/v2.1.0_seed_mssql.sql @@ -0,0 +1,228 @@ +-- Seed data for schema v2.1.0 +-- Platform: mssql +-- Generated: 2026-06-29 12:00:55 UTC +-- AnnotationKind +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (1, N'Fault', N'Sensor or process fault', N'#FF4444'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (2, N'Maintenance', N'Sensor under maintenance', N'#FFA500'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (3, N'Calibration Period', N'Data during calibration — may be invalid', N'#FFD700'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (4, N'Anomaly', N'Unexpected behavior, needs investigation', N'#FF69B4'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (5, N'Experiment', N'Data collected for a specific experiment', N'#4488FF'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (6, N'Process Event', N'Known process event (storm, dosing, etc.)', N'#44BB44'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (7, N'Data Quality', N'Suspect data quality (drift, fouling)', N'#AA44FF'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (8, N'Note', N'General commentary', N'#888888'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (9, N'Exclusion', N'Data should be excluded from analysis', N'#CC0000'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (10, N'Confirmed', N'Data has been reviewed and accepted as valid', N'#00AA00'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (11, N'Equipment Relocation', N'Equipment was physically moved to a new location', N'#8888FF'); +-- BinKind +INSERT INTO [dbo].[BinKind] ([BinKind_ID], [Name], [Description]) VALUES (1, N'interval', N'Bins defined by lower and upper bounds only'); +INSERT INTO [dbo].[BinKind] ([BinKind_ID], [Name], [Description]) VALUES (2, N'interval_with_nominal', N'Bins defined by bounds plus a nominal center value (e.g., comes from a table with columns showing the mean settling velocity, but the bin in fact collects data between a min and max value (not a point value)).'); +INSERT INTO [dbo].[BinKind] ([BinKind_ID], [Name], [Description]) VALUES (3, N'nominal', N'Bins defined by a single nominal (exact) value only (e.g., absorbance at exactly 200 nm).'); +-- CampaignKind +SET IDENTITY_INSERT [dbo].[CampaignKind] ON; +INSERT INTO [dbo].[CampaignKind] ([CampaignKind_ID], [Name], [Description]) VALUES (1, N'Experiment', N'Planned scientific investigation under controlled or semi-controlled conditions'); +INSERT INTO [dbo].[CampaignKind] ([CampaignKind_ID], [Name], [Description]) VALUES (2, N'Regular operation', N'Routine monitoring or operational run of the monitored process'); +INSERT INTO [dbo].[CampaignKind] ([CampaignKind_ID], [Name], [Description]) VALUES (3, N'Commissioning', N'Initial setup, calibration, and qualification of equipment or a process'); +SET IDENTITY_INSERT [dbo].[CampaignKind] OFF; +-- ChannelKind +INSERT INTO [dbo].[ChannelKind] ([ChannelKind_ID], [Name], [Description]) VALUES (1, N'Value', N'Primary measurement or output value'); +INSERT INTO [dbo].[ChannelKind] ([ChannelKind_ID], [Name], [Description]) VALUES (2, N'Status', N'Device or measurement status flag'); +INSERT INTO [dbo].[ChannelKind] ([ChannelKind_ID], [Name], [Description]) VALUES (3, N'Alarm', N'Alarm or alert indicator'); +INSERT INTO [dbo].[ChannelKind] ([ChannelKind_ID], [Name], [Description]) VALUES (4, N'Uncertainty', N'Measurement uncertainty estimate'); +-- ControlLoopPortKind +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (1, N'MeasuredVariable', N'The controlled or observed process variable'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (2, N'ManipulatedVariable', N'The actuator or output adjusted by the controller'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (3, N'SetPoint', N'Target value supplied to the controller'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (4, N'Disturbance', N'Measured input that affects the process; not manipulated'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (5, N'PredictedOutput', N'Model-predicted value of the controlled variable'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (6, N'Other', N'Escape hatch for novel kinds; describe in ControlLoop.Description'); +-- ControllerKind +SET IDENTITY_INSERT [dbo].[ControllerKind] ON; +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (1, N'PID', N'Proportional-Integral-Derivative controller'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (2, N'Feedforward', N'Open-loop controller that acts on predicted disturbances'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (3, N'MPC', N'Model Predictive Controller using an internal process model'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (4, N'On-Off', N'Bang-bang (on/off) controller with fixed setpoint'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (5, N'Manual', N'Operator-driven manual control with no automated loop'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (6, N'Other', N'Controller type not covered by the other categories'); +SET IDENTITY_INSERT [dbo].[ControllerKind] OFF; +-- DataAcquisitionSystemKind +SET IDENTITY_INSERT [dbo].[DataAcquisitionSystemKind] ON; +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (1, N'SCADA', N'Supervisory Control and Data Acquisition system.'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (2, N'PLC', N'Programmable Logic Controller.'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (3, N'Field monitoring station', N'Deployable measurement station capable of hosting multiple devices and recording their data streams.'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (4, N'IoT Gateway', N'Internet-of-Things gateway aggregating sensor streams.'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (5, N'Manual entry', N'Data entered manually by an operator (spreadsheet, form).'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (6, N'Other', N'System type not covered by the other categories.'); +SET IDENTITY_INSERT [dbo].[DataAcquisitionSystemKind] OFF; +-- DataProvenanceKind +SET IDENTITY_INSERT [dbo].[DataProvenanceKind] ON; +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (1, N'Sensor', N'Value acquired directly from an instrument or sensor in the field'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (2, N'Laboratory', N'Value determined by laboratory chemical or physical analysis'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (3, N'Controller Output', N'Value generated by a control algorithm.'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (4, N'Model Output', N'Value generated by a simulation, model, or prediction algorithm not involved in control.'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (5, N'External Source', N'Value imported from an external dataset or third-party system'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (6, N'Forecast', N'Future-dated value produced by a forecasting model'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (7, N'Derived', N'Value produced by applying a data-processing algorithm to one or more existing channels.'); +SET IDENTITY_INSERT [dbo].[DataProvenanceKind] OFF; +-- EquipmentEventKind +SET IDENTITY_INSERT [dbo].[EquipmentEventKind] ON; +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (1, N'Calibration', N'Adjustment of sensor output to match a known reference standard'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (2, N'Commissioning', N'Formal activation of equipment into operational service'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (3, N'Maintenance', N'Physical cleaning, inspection, or servicing of equipment'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (4, N'Installation', N'First-time mounting or connection of equipment at its deployment site'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (5, N'Removal', N'Decommissioning or retrieval of equipment from its deployment site'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (6, N'Firmware Update', N'Update to the embedded software or firmware of the device'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (7, N'Failure', N'Unplanned malfunction or breakdown requiring corrective action'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (8, N'Repair', N'Corrective action performed following a recorded failure'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (9, N'Decommissioning', N'Formal retirement of equipment from operational service'); +SET IDENTITY_INSERT [dbo].[EquipmentEventKind] OFF; +-- OperationKind +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (1, N'Unprocessed', N'No operations applied — used for the raw channel trait only'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (2, N'OutlierRemoval', N'Spikes and statistical outliers removed or flagged'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (3, N'DriftCorrection', N'Sensor drift or baseline shift corrected'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (4, N'FaultRemoval', N'Instrument faults and implausible values removed'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (5, N'Smoothing', N'Noise reduced by a smoothing or averaging algorithm'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (6, N'Interpolation', N'Missing values filled by interpolation or reconstruction'); +-- ProcedureKind +SET IDENTITY_INSERT [dbo].[ProcedureKind] ON; +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (1, N'Maintenance and Cleaning Protocol', N'Procedures for routine maintenance, cleaning, and upkeep of equipment'); +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (2, N'Calibration Protocol', N'Step-by-step instructions for calibrating instruments or sensors'); +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (3, N'Validation Protocol', N'Procedures for validating measurements, methods, or models'); +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (4, N'Laboratory Method Protocol', N'Standardised laboratory analytical methods (e.g. ISO, ASTM, APHA)'); +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (5, N'Software Manual', N'User or operational manuals for software tools used in data acquisition or processing'); +SET IDENTITY_INSERT [dbo].[ProcedureKind] OFF; +-- ProcessUnitKind +SET IDENTITY_INSERT [dbo].[ProcessUnitKind] ON; +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (1, N'Area', N'Broad spatial zone (e.g. biological treatment area)'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (2, N'Zone', N'Defined functional sub-zone within a process area'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (3, N'Tank', N'Enclosed vessel for liquid storage or treatment'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (4, N'Reactor', N'Vessel designed for controlled biological or chemical reactions'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (5, N'Pipe', N'Conduit transporting liquid between process units'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (6, N'Pump', N'Mechanical device for moving liquid'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (7, N'Valve', N'Flow control device regulating liquid passage'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (8, N'Clarifier', N'Gravity settling vessel separating solids from liquid'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (9, N'Basin', N'Open or partially open liquid containment structure'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (10, N'Blower', N'Mechanical device for supplying air or gas'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (11, N'Other', N'Process unit kind not covered by the standard vocabulary'); +SET IDENTITY_INSERT [dbo].[ProcessUnitKind] OFF; +-- QualityCode +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (1, N'Accepted', N'Measurement meets quality criteria and is fit for use', 1); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (2, N'Suspect', N'Measurement may be unreliable; flagged for manual review', 1); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (3, N'Rejected', N'Measurement is invalid and must not be used', 0); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (4, N'BelowLoD', N'Result is below the method''s limit of detection', 0); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (5, N'AboveLoQ', N'Result exceeds the limit of quantification (instrument saturated)', 0); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (6, N'Outlier', N'Statistical outlier; not automatically invalid but requires review', 1); +-- ReviewStatus +INSERT INTO [dbo].[ReviewStatus] ([ReviewStatus_ID], [Name], [Description]) VALUES (1, N'Pending', N'Measurement recorded but not yet reviewed/approved'); +INSERT INTO [dbo].[ReviewStatus] ([ReviewStatus_ID], [Name], [Description]) VALUES (2, N'Approved', N'Measurement reviewed and approved by a designated reviewer'); +INSERT INTO [dbo].[ReviewStatus] ([ReviewStatus_ID], [Name], [Description]) VALUES (3, N'Rejected', N'Measurement reviewed and rejected'); +-- SampleCollectionKind +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (1, N'Grab', N'Single instantaneous sample collected at one point in time'); +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (2, N'Composite24h', N'Flow- or time-proportional composite over a 24-hour period'); +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (3, N'Composite8h', N'Flow- or time-proportional composite over an 8-hour period'); +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (4, N'Passive', N'Passive sampler deployed over an extended exposure period'); +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (5, N'Other', N'Collection kind not covered by the standard vocabulary'); +-- SampleKind +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (1, N'Field', N'Sample collected from a real-world site or process'); +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (2, N'Synthetic', N'Laboratory-prepared sample with known composition'); +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (3, N'Master Standard', N'Reference standard used to prepare derived standards'); +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (4, N'Derived Standard', N'Dilution or aliquot derived from a master standard'); +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (5, N'Blank', N'Blank sample used to detect contamination or baseline'); +-- SiteKind +SET IDENTITY_INSERT [dbo].[SiteKind] ON; +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (1, N'Municipal Wastewater Treatment Plant', N'Municipal or industrial facility treating wastewater before discharge'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (2, N'Combined Sewer Overflow', N'Point where combined sewer system discharges during high-flow events'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (3, N'River / Stream', N'Natural flowing surface water body'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (4, N'Lake / Reservoir', N'Natural or artificial standing body of water'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (5, N'Groundwater / Well', N'Subsurface water source accessed via a well or borehole'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (6, N'Drinking Water Distribution Network Access Point', N'Monitoring point within a potable water distribution network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (7, N'Canal', N'Artificial waterway for water transport or drainage'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (8, N'Wastewater Pumping Station', N'Facility that pumps wastewater through the collection network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (9, N'Combined Drainage Network Access Point', N'Monitoring point within a combined stormwater and wastewater network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (10, N'Rainwater Drainage Network Access Point', N'Monitoring point within a stormwater-only drainage network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (11, N'Wastewater Drainage Network Access Point', N'Monitoring point within a sanitary sewer network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (12, N'Experimental Wastewater Treatment Plant', N'Small-scale experimental treatment or process facility'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (13, N'Other', N'Site kind not covered by the standard vocabulary'); +SET IDENTITY_INSERT [dbo].[SiteKind] OFF; +-- StreamKind +INSERT INTO [dbo].[StreamKind] ([StreamKind_ID], [Name], [Description]) VALUES (1, N'Sensor', N'A sensor measurement stream (Channel subtype of Stream)'); +INSERT INTO [dbo].[StreamKind] ([StreamKind_ID], [Name], [Description]) VALUES (2, N'Lab', N'A laboratory measurement stream (AnalysisSeries subtype of Stream)'); +-- Unit +SET IDENTITY_INSERT [dbo].[Unit] ON; +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (1, N'mg/L', N'https://qudt.org/vocab/unit/MilliGM-PER-L', N'0,1,-3,0,0,0,0', 0.001, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (2, N'NTU', N'https://qudt.org/vocab/unit/NTU', N'0,0,0,0,0,0,0', NULL, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (3, N'pH units', N'https://qudt.org/vocab/unit/PH', N'0,0,0,0,0,0,0', NULL, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (4, N'°C', N'https://qudt.org/vocab/unit/DEG_C', N'0,0,0,0,1,0,0', 1.0, 273.15); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (5, N'mS/cm', N'https://qudt.org/vocab/unit/MilliS-PER-CentiM', N'-3,-1,3,2,0,0,0', 0.1, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (6, N'nm', N'https://qudt.org/vocab/unit/NanoM', N'1,0,0,0,0,0,0', 1e-09, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (7, N'µm', N'https://qudt.org/vocab/unit/MicroM', N'1,0,0,0,0,0,0', 1e-06, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (8, N'm/s', N'https://qudt.org/vocab/unit/M-PER-SEC', N'1,0,-1,0,0,0,0', 1.0, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (9, N'Status Code', NULL, NULL, NULL, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (10, N'AU', N'https://qudt.org/vocab/unit/ABSORBANCE_UNIT', N'0,0,0,0,0,0,0', NULL, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (11, N'-', N'https://qudt.org/vocab/unit/UNITLESS', N'0,0,0,0,0,0,0', 1.0, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (12, N'm³/h', N'https://qudt.org/vocab/unit/M3-PER-HR', N'3,0,-1,0,0,0,0', 0.000277778, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (13, N'm', N'https://qudt.org/vocab/unit/M', N'1,0,0,0,0,0,0', 1.0, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (14, N'Nm³/h', NULL, N'3,0,-1,0,0,0,0', 0.000277778, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (15, N'%', N'https://qudt.org/vocab/unit/PERCENT', N'0,0,0,0,0,0,0', 0.01, NULL); +SET IDENTITY_INSERT [dbo].[Unit] OFF; +-- ValueKind +SET IDENTITY_INSERT [dbo].[ValueKind] ON; +INSERT INTO [dbo].[ValueKind] ([ValueKind_ID], [Name], [Description]) VALUES (1, N'Scalar', N'A single numeric measurement value (e.g. temperature, concentration)'); +INSERT INTO [dbo].[ValueKind] ([ValueKind_ID], [Name], [Description]) VALUES (2, N'Vector', N'An ordered sequence of numeric values (e.g. particle size distribution)'); +INSERT INTO [dbo].[ValueKind] ([ValueKind_ID], [Name], [Description]) VALUES (3, N'Matrix', N'A two-dimensional array of values (e.g. excitation-emission matrix)'); +INSERT INTO [dbo].[ValueKind] ([ValueKind_ID], [Name], [Description]) VALUES (4, N'Image', N'A raster image stored as a binary file'); +SET IDENTITY_INSERT [dbo].[ValueKind] OFF; +-- Parameter +SET IDENTITY_INSERT [dbo].[Parameter] ON; +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'TSS concentration', 1, N'Total suspended solids', N'http://purl.obolibrary.org/obo/ENVO_01001502', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'COD concentration', 2, N'Chemical oxygen demand', N'http://purl.obolibrary.org/obo/ENVO_01000632', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'pH', 3, N'Hydrogen ion concentration', N'http://purl.obolibrary.org/obo/ENVO_09200019', 1, N'http://qudt.org/vocab/quantitykind/PH'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Temperature', 4, N'Water temperature', N'http://purl.obolibrary.org/obo/ENVO_01001501', 1, N'http://qudt.org/vocab/quantitykind/Temperature'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Conductivity', 5, N'Electrical conductivity', N'http://purl.obolibrary.org/obo/ENVO_09200010', 1, N'http://qudt.org/vocab/quantitykind/ElectricConductivity'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Sensor Status', 6, N'Per-channel operational status code', NULL, 1, NULL); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Device Status', 7, N'Overall equipment health status code', NULL, 1, NULL); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Dissolved oxygen concentration', 8, N'Dissolved oxygen concentration in water', N'http://purl.obolibrary.org/obo/ENVO_01001111', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Turbidity', 9, N'Water turbidity measured by nephelometry', N'http://purl.obolibrary.org/obo/ENVO_01001573', 1, N'http://qudt.org/vocab/quantitykind/Turbidity'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Absorbance spectrum', 10, N'UV-Vis spectral absorbance (per-wavelength vector, unit AU)', NULL, 2, NULL); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Ammonium-N concentration', 11, N'Ammonium nitrogen concentration (NH4-N)', N'http://purl.obolibrary.org/obo/CHEBI_49786', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Nitrate-N concentration', 12, N'Nitrate nitrogen concentration as NO3-N equivalent', N'http://purl.obolibrary.org/obo/CHEBI_17632', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'COD filtered concentration', 13, N'Filtered COD (CODf) — soluble fraction of chemical oxygen demand', NULL, 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Flow', 14, N'Volumetric flow rate', N'http://purl.obolibrary.org/obo/ENVO_01001020', 1, N'http://qudt.org/vocab/quantitykind/VolumeFlowRate'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Level', 15, N'Water level / depth', NULL, 1, N'http://qudt.org/vocab/quantitykind/Length'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'floc_morphology', 16, N'Activated sludge floc morphology image from inline microscope', NULL, 4, NULL); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Potassium concentration', 17, N'Potassium concentration (K)', NULL, 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Light Absorbance', 18, N'Scalar light absorbance measurement', NULL, 1, N'http://qudt.org/vocab/quantitykind/Absorbance'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Nitrite-N concentration', 19, N'Nitrite nitrogen concentration (NO2-N)', NULL, 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'NOx-N concentration', 20, N'Total oxidized nitrogen (NO3-N + NO2-N)', NULL, 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Air flow', 21, N'Volumetric air/gas flow rate', NULL, 1, N'http://qudt.org/vocab/quantitykind/VolumeFlowRate'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Valve position', 22, N'Control valve analog output position (0-100%)', NULL, 1, NULL); +SET IDENTITY_INSERT [dbo].[Parameter] OFF; +-- Procedures +SET IDENTITY_INSERT [dbo].[Procedures] ON; +INSERT INTO [dbo].[Procedures] ([Procedure_ID], [ProcedureName], [Description], [ProcedureLocation]) VALUES (1, N'Grab sampling', N'Manual grab sample collected at water surface', N'/procedures/grab_sampling.pdf'); +INSERT INTO [dbo].[Procedures] ([Procedure_ID], [ProcedureName], [Description], [ProcedureLocation]) VALUES (2, N'24h composite', N'Time-weighted 24-hour composite sample via autosampler', N'/procedures/composite_24h.pdf'); +INSERT INTO [dbo].[Procedures] ([Procedure_ID], [ProcedureName], [Description], [ProcedureLocation]) VALUES (3, N'Online continuous', N'Continuous in-situ measurement with data logging', N'/procedures/online_continuous.pdf'); +SET IDENTITY_INSERT [dbo].[Procedures] OFF; + +-- ParameterHasUnit (generated by ontology_query.py) +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (1, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (2, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (3, 3); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (4, 4); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (5, 5); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (8, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (9, 2); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (10, 10); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (11, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (12, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (13, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (14, 12); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (15, 6); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (15, 7); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (15, 13); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (17, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (18, 10); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (19, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (20, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (21, 12); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (21, 14); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (22, 15); diff --git a/sql_generation_scripts/v2.2.0_create_mssql.sql b/sql_generation_scripts/v2.2.0_create_mssql.sql new file mode 100644 index 0000000..aad338c --- /dev/null +++ b/sql_generation_scripts/v2.2.0_create_mssql.sql @@ -0,0 +1,1120 @@ +-- Baseline CREATE script for schema v2.2.0 +-- Platform: mssql +-- Generated: 2026-06-29 17:02:27 UTC + +CREATE TABLE [dbo].[AnnotationKind] ( + [AnnotationKind_ID] INT NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(500), + [Color] NVARCHAR(7), + CONSTRAINT [PK_AnnotationKind] PRIMARY KEY ([AnnotationKind_ID]) +); + +CREATE TABLE [dbo].[BinKind] ( + [BinKind_ID] INT NOT NULL, + [Name] NVARCHAR(30) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_BinKind] PRIMARY KEY ([BinKind_ID]) +); + +CREATE TABLE [dbo].[CampaignKind] ( + [CampaignKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_CampaignKind] PRIMARY KEY ([CampaignKind_ID]) +); + +CREATE TABLE [dbo].[ChannelKind] ( + [ChannelKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_ChannelKind] PRIMARY KEY ([ChannelKind_ID]) +); + +CREATE TABLE [dbo].[ControlLoopPortKind] ( + [ControlLoopPortKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_ControlLoopPortKind] PRIMARY KEY ([ControlLoopPortKind_ID]) +); + +CREATE TABLE [dbo].[ControllerKind] ( + [ControllerKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(500), + CONSTRAINT [PK_ControllerKind] PRIMARY KEY ([ControllerKind_ID]) +); + +CREATE TABLE [dbo].[DataAcquisitionSystemKind] ( + [DataAcquisitionSystemKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(500), + CONSTRAINT [PK_DataAcquisitionSystemKind] PRIMARY KEY ([DataAcquisitionSystemKind_ID]) +); + +CREATE TABLE [dbo].[DataProvenanceKind] ( + [DataProvenanceKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_DataProvenanceKind] PRIMARY KEY ([DataProvenanceKind_ID]) +); + +CREATE TABLE [dbo].[EquipmentEventKind] ( + [EquipmentEventKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_EquipmentEventKind] PRIMARY KEY ([EquipmentEventKind_ID]) +); + +CREATE TABLE [dbo].[EquipmentModel] ( + [EquipmentModel_ID] INT IDENTITY(1,1) NOT NULL, + [EquipmentModel] NVARCHAR(100), + [Method] NVARCHAR(100), + [Functions] NVARCHAR(MAX), + [Manufacturer] NVARCHAR(100), + [ManualLocation] NVARCHAR(1000), + CONSTRAINT [PK_EquipmentModel] PRIMARY KEY ([EquipmentModel_ID]) +); + +CREATE TABLE [dbo].[OperationKind] ( + [OperationKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_OperationKind] PRIMARY KEY ([OperationKind_ID]) +); + +CREATE TABLE [dbo].[Person] ( + [Person_ID] INT IDENTITY(1,1) NOT NULL, + [LastName] NVARCHAR(100), + [FirstName] NVARCHAR(255), + [Company] NVARCHAR(MAX), + [Role] NVARCHAR(255), + [AssignedFunctions] NVARCHAR(MAX), + [Email] NVARCHAR(100), + [Phone] NVARCHAR(100), + [Linkedin] NVARCHAR(100), + [Website] NVARCHAR(60), + CONSTRAINT [PK_Person] PRIMARY KEY ([Person_ID]) +); + +CREATE TABLE [dbo].[ProcedureKind] ( + [ProcedureKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_ProcedureKind] PRIMARY KEY ([ProcedureKind_ID]) +); + +CREATE TABLE [dbo].[ProcessUnitKind] ( + [ProcessUnitKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_ProcessUnitKind] PRIMARY KEY ([ProcessUnitKind_ID]), + CONSTRAINT [UQ_ProcessUnitKind_Name] UNIQUE ([Name]) +); + +CREATE TABLE [dbo].[QualityCode] ( + [QualityCode_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + [IsUsable] BIT NOT NULL DEFAULT 1, + CONSTRAINT [PK_QualityCode] PRIMARY KEY ([QualityCode_ID]) +); + +CREATE TABLE [dbo].[ReviewStatus] ( + [ReviewStatus_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_ReviewStatus] PRIMARY KEY ([ReviewStatus_ID]) +); + +CREATE TABLE [dbo].[SampleCollectionKind] ( + [SampleCollectionKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_SampleCollectionKind] PRIMARY KEY ([SampleCollectionKind_ID]) +); + +CREATE TABLE [dbo].[SampleKind] ( + [SampleKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_SampleKind] PRIMARY KEY ([SampleKind_ID]) +); + +CREATE TABLE [dbo].[SchemaVersion] ( + [VersionID] INT IDENTITY(1,1) NOT NULL, + [Version] NVARCHAR(20) NOT NULL, + [AppliedDateTime] DATETIME2(7) NOT NULL DEFAULT CURRENT_TIMESTAMP, + [Description] NVARCHAR(500), + [MigrationScript] NVARCHAR(200), + CONSTRAINT [PK_SchemaVersion] PRIMARY KEY ([VersionID]) +); + +CREATE TABLE [dbo].[SiteKind] ( + [SiteKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_SiteKind] PRIMARY KEY ([SiteKind_ID]) +); + +CREATE TABLE [dbo].[StreamKind] ( + [StreamKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_StreamKind] PRIMARY KEY ([StreamKind_ID]) +); + +CREATE TABLE [dbo].[Unit] ( + [Unit_ID] INT IDENTITY(1,1) NOT NULL, + [Unit] NVARCHAR(100), + [QUDT_IRI] NVARCHAR(256), + [UnitVector] NVARCHAR(64), + [SI_Multiplier] FLOAT, + [SI_Offset] FLOAT, + CONSTRAINT [PK_Unit] PRIMARY KEY ([Unit_ID]) +); + +CREATE TABLE [dbo].[UserAccount] ( + [UserAccount_ID] INT IDENTITY(1,1) NOT NULL, + [Email] NVARCHAR(255) NOT NULL, + [FullName] NVARCHAR(255) NOT NULL, + [PasswordHash] NVARCHAR(255) NOT NULL, + [IsActive] BIT NOT NULL DEFAULT 1, + [IsVerified] BIT NOT NULL DEFAULT 1, + [CreatedAt] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + [UpdatedAt] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + CONSTRAINT [PK_UserAccount] PRIMARY KEY ([UserAccount_ID]), + CONSTRAINT [UQ_UserAccount_Email] UNIQUE ([Email]) +); + +CREATE TABLE [dbo].[ValueKind] ( + [ValueKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_ValueKind] PRIMARY KEY ([ValueKind_ID]) +); + +CREATE TABLE [dbo].[AnalysisSeries] ( + [Stream_ID] INT NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Parameter_ID] INT NOT NULL, + [SamplingPoint_ID] INT NOT NULL, + [ValueKind_ID] INT NOT NULL DEFAULT 1, + [Unit_ID] INT NOT NULL, + [Campaign_ID] INT, + [Description] NVARCHAR(MAX), + CONSTRAINT [PK_AnalysisSeries] PRIMARY KEY ([Stream_ID]), + CONSTRAINT [UQ_AnalysisSeries_Identity] UNIQUE ([Parameter_ID], [SamplingPoint_ID], [ValueKind_ID]) +); + +CREATE TABLE [dbo].[AnalysisSeriesAxis] ( + [AnalysisSeries_ID] INT NOT NULL, + [AxisRole] INT NOT NULL, + [ValueBinningAxis_ID] INT NOT NULL, + CONSTRAINT [PK_AnalysisSeriesAxis] PRIMARY KEY ([AnalysisSeries_ID], [AxisRole]), + CONSTRAINT [CK_AnalysisSeriesAxis_AxisRole] CHECK (AxisRole IN (0, 1)) +); + +CREATE TABLE [dbo].[Annotation] ( + [Annotation_ID] INT IDENTITY(1,1) NOT NULL, + [Stream_ID] INT NOT NULL, + [AnnotationKind_ID] INT NOT NULL, + [StartTime] DATETIME2(7) NOT NULL, + [EndTime] DATETIME2(7), + [AuthorPerson_ID] INT, + [Campaign_ID] INT, + [EquipmentEvent_ID] INT, + [Title] NVARCHAR(200), + [Comment] NVARCHAR(MAX), + [CreatedDateTime] DATETIME2(7) NOT NULL DEFAULT CURRENT_TIMESTAMP, + [ModifiedDateTime] DATETIME2(7), + [Observation_ID] INT, + CONSTRAINT [PK_Annotation] PRIMARY KEY ([Annotation_ID]) +); + +CREATE TABLE [dbo].[AuditLog] ( + [AuditLog_ID] BIGINT IDENTITY(1,1) NOT NULL, + [UserAccount_ID] INT, + [Action] NVARCHAR(50) NOT NULL, + [ResourceType] NVARCHAR(100) NOT NULL, + [ResourceID] NVARCHAR(255), + [Details] NVARCHAR(MAX), + [Timestamp] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + CONSTRAINT [PK_AuditLog] PRIMARY KEY ([AuditLog_ID]) +); + +CREATE TABLE [dbo].[Campaign] ( + [Campaign_ID] INT IDENTITY(1,1) NOT NULL, + [CampaignKind_ID] INT NOT NULL, + [Site_ID] INT NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(2000), + [CampaignStartDateTime] DATETIME2(7), + [CampaignEndDateTime] DATETIME2(7), + [ResponsiblePerson_ID] INT, + CONSTRAINT [PK_Campaign] PRIMARY KEY ([Campaign_ID]) +); + +CREATE TABLE [dbo].[CampaignEquipment] ( + [Campaign_ID] INT NOT NULL, + [Equipment_ID] INT NOT NULL, + [Role] NVARCHAR(100), + CONSTRAINT [PK_CampaignEquipment] PRIMARY KEY ([Campaign_ID], [Equipment_ID]) +); + +CREATE TABLE [dbo].[CampaignSamplingLocation] ( + [Campaign_ID] INT NOT NULL, + [SamplingPoint_ID] INT NOT NULL, + [Role] NVARCHAR(100), + CONSTRAINT [PK_CampaignSamplingLocation] PRIMARY KEY ([Campaign_ID], [SamplingPoint_ID]) +); + +CREATE TABLE [dbo].[Channel] ( + [Stream_ID] INT NOT NULL, + [SignalInterface_ID] INT, + [TagName] NVARCHAR(200) NOT NULL, + [ParentChannel_ID] INT, + [ChannelKind_ID] INT NOT NULL DEFAULT 1, + [Parameter_ID] INT, + [DataProvenanceKind_ID] INT, + [ProducedByStep_ID] INT, + [ValueKind_ID] INT NOT NULL DEFAULT 1, + [Unit_ID] INT, + CONSTRAINT [PK_Channel] PRIMARY KEY ([Stream_ID]) +); + +CREATE TABLE [dbo].[ChannelAxis] ( + [Channel_ID] INT NOT NULL, + [AxisRole] INT NOT NULL, + [ValueBinningAxis_ID] INT NOT NULL, + CONSTRAINT [PK_ChannelAxis] PRIMARY KEY ([Channel_ID], [AxisRole]), + CONSTRAINT [CK_MetaDataAxis_AxisRole] CHECK (AxisRole IN (0, 1)) +); + +CREATE TABLE [dbo].[ChannelPortHistory] ( + [ChannelPortHistory_ID] INT IDENTITY(1,1) NOT NULL, + [Channel_ID] INT NOT NULL, + [SignalInterfacePort_ID] INT, + [ValidFrom] DATETIME2(7) NOT NULL, + [ValidTo] DATETIME2(7), + [GatingNote] NVARCHAR(MAX), + CONSTRAINT [PK_ChannelPortHistory] PRIMARY KEY ([ChannelPortHistory_ID]) +); + +CREATE TABLE [dbo].[ChannelTrait] ( + [Stream_ID] INT NOT NULL, + [OperationKind_ID] INT NOT NULL, + CONSTRAINT [PK_ChannelTrait] PRIMARY KEY ([Stream_ID], [OperationKind_ID]) +); + +CREATE TABLE [dbo].[ControlLoop] ( + [ControlLoop_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [ControllerKind_ID] INT NOT NULL, + [FallbackControlLoop_ID] INT, + [AlgorithmReference] NVARCHAR(500), + [Description] NVARCHAR(MAX), + CONSTRAINT [PK_ControlLoop] PRIMARY KEY ([ControlLoop_ID]) +); + +CREATE TABLE [dbo].[ControlLoopApplication] ( + [ControlLoopApplication_ID] INT IDENTITY(1,1) NOT NULL, + [ControlLoop_ID] INT NOT NULL, + [StartTime] DATETIME2(7) NOT NULL, + [EndTime] DATETIME2(7), + [Parameters] NVARCHAR(MAX), + [AppliedByPerson_ID] INT, + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_ControlLoopApplication] PRIMARY KEY ([ControlLoopApplication_ID]) +); + +CREATE TABLE [dbo].[ControlLoopPort] ( + [ControlLoopPort_ID] INT IDENTITY(1,1) NOT NULL, + [ControlLoop_ID] INT NOT NULL, + [Channel_ID] INT NOT NULL, + [ControlLoopPortKind_ID] INT NOT NULL, + CONSTRAINT [PK_ControlLoopPort] PRIMARY KEY ([ControlLoopPort_ID]) +); + +CREATE TABLE [dbo].[DASLocationHistory] ( + [DASLocationHistory_ID] INT IDENTITY(1,1) NOT NULL, + [DataAcquisitionSystem_ID] INT NOT NULL, + [Site_ID] INT NOT NULL, + [Campaign_ID] INT, + [ValidFrom] DATETIME2(7) NOT NULL, + [ValidTo] DATETIME2(7), + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_DASLocationHistory] PRIMARY KEY ([DASLocationHistory_ID]) +); + +CREATE TABLE [dbo].[DataAcquisitionSystem] ( + [DataAcquisitionSystem_ID] INT IDENTITY(1,1) NOT NULL, + [ParentSystem_ID] INT, + [Name] NVARCHAR(200) NOT NULL, + [DataAcquisitionSystemKind_ID] INT, + [Manufacturer] NVARCHAR(100), + [Model] NVARCHAR(100), + [Description] NVARCHAR(MAX), + CONSTRAINT [PK_DataAcquisitionSystem] PRIMARY KEY ([DataAcquisitionSystem_ID]) +); + +CREATE TABLE [dbo].[Dataset] ( + [Dataset_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(2000), + [Purpose] NVARCHAR(500), + [CreatedOn] DATETIME2(7) NOT NULL DEFAULT CURRENT_TIMESTAMP, + [CreatedByPerson_ID] INT, + CONSTRAINT [PK_Dataset] PRIMARY KEY ([Dataset_ID]) +); + +CREATE TABLE [dbo].[DatasetChannel] ( + [Dataset_ID] INT NOT NULL, + [Channel_ID] INT NOT NULL, + CONSTRAINT [PK_DatasetChannel] PRIMARY KEY ([Dataset_ID], [Channel_ID]) +); + +CREATE TABLE [dbo].[Equipment] ( + [Equipment_ID] INT IDENTITY(1,1) NOT NULL, + [EquipmentModel_ID] INT, + [Identifier] NVARCHAR(100), + [SerialNumber] NVARCHAR(100), + [Owner] NVARCHAR(MAX), + [StorageLocation] NVARCHAR(100), + [PurchaseDate] DATE, + [IsActive] BIT NOT NULL DEFAULT 1, + CONSTRAINT [PK_Equipment] PRIMARY KEY ([Equipment_ID]) +); + +CREATE TABLE [dbo].[EquipmentEvent] ( + [EquipmentEvent_ID] INT IDENTITY(1,1) NOT NULL, + [Equipment_ID] INT NOT NULL, + [EquipmentEventKind_ID] INT NOT NULL, + [EventDateTimeStart] DATETIME2(7) NOT NULL, + [IsInstantaneous] BIT NOT NULL DEFAULT 0, + [EventDateTimeEnd] DATETIME2(7), + [PerformedByPerson_ID] INT, + [RecordedByPerson_ID] INT, + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_EquipmentEvent] PRIMARY KEY ([EquipmentEvent_ID]) +); + +CREATE TABLE [dbo].[EquipmentLocationHistory] ( + [EquipmentLocationHistory_ID] INT IDENTITY(1,1) NOT NULL, + [Equipment_ID] INT NOT NULL, + [SamplingPoint_ID] INT NOT NULL, + [ValidFrom] DATETIME2(7) NOT NULL, + [ValidTo] DATETIME2(7), + [Campaign_ID] INT, + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_EquipmentLocationHistory] PRIMARY KEY ([EquipmentLocationHistory_ID]) +); + +CREATE TABLE [dbo].[EquipmentModelHasParameter] ( + [EquipmentModel_ID] INT NOT NULL, + [Parameter_ID] INT NOT NULL, + CONSTRAINT [PK_EquipmentModelHasParameter] PRIMARY KEY ([EquipmentModel_ID], [Parameter_ID]) +); + +CREATE TABLE [dbo].[EquipmentModelHasProcedures] ( + [EquipmentModel_ID] INT NOT NULL, + [Procedure_ID] INT NOT NULL, + CONSTRAINT [PK_EquipmentModelHasProcedures] PRIMARY KEY ([EquipmentModel_ID], [Procedure_ID]) +); + +CREATE TABLE [dbo].[EquipmentWiringHistory] ( + [EquipmentWiringHistory_ID] INT IDENTITY(1,1) NOT NULL, + [Equipment_ID] INT NOT NULL, + [SignalInterface_ID] INT NOT NULL, + [SignalInterfacePort_ID] INT, + [ValidFrom] DATETIME2(7) NOT NULL, + [ValidTo] DATETIME2(7), + [Note] NVARCHAR(MAX), + CONSTRAINT [PK_EquipmentWiringHistory] PRIMARY KEY ([EquipmentWiringHistory_ID]) +); + +CREATE TABLE [dbo].[HydrologicalCharacteristics] ( + [Watershed_ID] INT NOT NULL, + [UrbanArea] REAL, + [Forest] REAL, + [Wetlands] REAL, + [Cropland] REAL, + [Meadow] REAL, + [Grassland] REAL, + CONSTRAINT [PK_HydrologicalCharacteristics] PRIMARY KEY ([Watershed_ID]) +); + +CREATE TABLE [dbo].[LabAnalysis] ( + [LabAnalysis_ID] INT IDENTITY(1,1) NOT NULL, + [LabExperiment_ID] INT NOT NULL, + [AnalysisSeries_ID] INT NOT NULL, + [Sample_ID] INT NOT NULL, + [Replicate] INT NOT NULL DEFAULT 1, + [QualityCode_ID] INT, + [ReviewStatus_ID] INT NOT NULL DEFAULT 1, + [ReviewedByPerson_ID] INT, + [ReviewDateTime] DATETIME2(7), + [Laboratory_ID] INT, + [AnalystPerson_ID] INT, + [Procedure_ID] INT, + [AnalysisDateTime] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_LabAnalysis] PRIMARY KEY ([LabAnalysis_ID]), + CONSTRAINT [UQ_LabAnalysis_Identity] UNIQUE ([LabExperiment_ID], [AnalysisSeries_ID], [Sample_ID], [Replicate]) +); + +CREATE TABLE [dbo].[LabExperiment] ( + [LabExperiment_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Campaign_ID] INT, + [ExperimentDateTime] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + [Description] NVARCHAR(MAX), + [CreatedByPerson_ID] INT, + [LabPanel_ID] INT, + CONSTRAINT [PK_LabExperiment] PRIMARY KEY ([LabExperiment_ID]) +); + +CREATE TABLE [dbo].[LabPanel] ( + [LabPanel_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(MAX), + [CreatedByPerson_ID] INT, + [DefaultSampleCollectionKind_ID] INT, + [DefaultSampleEquipment_ID] INT, + [CreatedAt] DATETIME2(7) NOT NULL DEFAULT GETUTCDATE(), + CONSTRAINT [PK_LabPanel] PRIMARY KEY ([LabPanel_ID]) +); + +CREATE TABLE [dbo].[LabPanelSeries] ( + [LabPanel_ID] INT NOT NULL, + [AnalysisSeries_ID] INT NOT NULL, + CONSTRAINT [PK_LabPanelSeries] PRIMARY KEY ([LabPanel_ID], [AnalysisSeries_ID]) +); + +CREATE TABLE [dbo].[Laboratory] ( + [Laboratory_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Site_ID] INT, + [Description] NVARCHAR(500), + CONSTRAINT [PK_Laboratory] PRIMARY KEY ([Laboratory_ID]) +); + +CREATE TABLE [dbo].[LandUse] ( + [Watershed_ID] INT NOT NULL, + [Commercial] REAL, + [GreenSpaces] REAL, + [Industrial] REAL, + [Institutional] REAL, + [Residential] REAL, + [Agricultural] REAL, + [Recreational] REAL, + CONSTRAINT [PK_LandUse] PRIMARY KEY ([Watershed_ID]) +); + +CREATE TABLE [dbo].[Observation] ( + [Observation_ID] INT IDENTITY(1,1) NOT NULL, + [Channel_ID] INT, + [LabAnalysis_ID] INT, + [Timestamp] DATETIME2(7) NOT NULL, + [ValueKind_ID] INT NOT NULL, + CONSTRAINT [PK_Observation] PRIMARY KEY ([Observation_ID]), + CONSTRAINT [CK_Observation_Source] CHECK ((Channel_ID IS NOT NULL AND LabAnalysis_ID IS NULL) OR (Channel_ID IS NULL AND LabAnalysis_ID IS NOT NULL)) +); + +CREATE TABLE [dbo].[Parameter] ( + [Parameter_ID] INT IDENTITY(1,1) NOT NULL, + [Parameter] NVARCHAR(100), + [Description] NVARCHAR(MAX), + [ENVO_IRI] NVARCHAR(256), + [ValueKind_ID] INT NOT NULL DEFAULT 1, + [QUDT_QuantityKind_IRI] NVARCHAR(256), + CONSTRAINT [PK_Parameter] PRIMARY KEY ([Parameter_ID]) +); + +CREATE TABLE [dbo].[ParameterHasUnit] ( + [Parameter_ID] INT NOT NULL, + [Unit_ID] INT NOT NULL, + CONSTRAINT [PK_ParameterHasUnit] PRIMARY KEY ([Parameter_ID], [Unit_ID]) +); + +CREATE TABLE [dbo].[Procedures] ( + [Procedure_ID] INT IDENTITY(1,1) NOT NULL, + [ProcedureName] NVARCHAR(100), + [ProcedureKind_ID] INT, + [Description] NVARCHAR(MAX), + [ProcedureLocation] NVARCHAR(100), + CONSTRAINT [PK_Procedures] PRIMARY KEY ([Procedure_ID]) +); + +CREATE TABLE [dbo].[ProcessUnit] ( + [ProcessUnit_ID] INT IDENTITY(1,1) NOT NULL, + [Site_ID] INT NOT NULL, + [Tag] NVARCHAR(100) NOT NULL, + [Name] NVARCHAR(255) NOT NULL, + [Description] NVARCHAR(MAX), + [ProcessUnitKind_ID] INT, + [Parent_ID] INT, + CONSTRAINT [PK_ProcessUnit] PRIMARY KEY ([ProcessUnit_ID]), + CONSTRAINT [UQ_ProcessUnit_SiteTag] UNIQUE ([Site_ID], [Tag]) +); + +CREATE TABLE [dbo].[ProcessingLineage] ( + [ProcessingLineage_ID] INT IDENTITY(1,1) NOT NULL, + [ProcessingStep_ID] INT NOT NULL, + [Stream_ID] INT NOT NULL, + [StartTime] DATETIME2(7), + [EndTime] DATETIME2(7), + CONSTRAINT [PK_ProcessingLineage] PRIMARY KEY ([ProcessingLineage_ID]) +); + +CREATE TABLE [dbo].[ProcessingStep] ( + [ProcessingStep_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(MAX), + [MethodName] NVARCHAR(200), + [MethodVersion] NVARCHAR(100), + [OperationKind_ID] INT, + [MethodParameters] NVARCHAR(MAX), + [ExecutedDateTime] DATETIME2(7), + [ExecutedByPerson_ID] INT, + [Dataset_ID] INT, + CONSTRAINT [PK_ProcessingStep] PRIMARY KEY ([ProcessingStep_ID]) +); + +CREATE TABLE [dbo].[Sample] ( + [Sample_ID] INT IDENTITY(1,1) NOT NULL, + [ParentSample_ID] INT, + [SampleKind_ID] INT, + [SamplingPoint_ID] INT NOT NULL, + [SampledByPerson_ID] INT, + [Campaign_ID] INT, + [SampleDateTimeStart] DATETIME2(7) NOT NULL, + [SampleDateTimeEnd] DATETIME2(7), + [SampleCollectionKind_ID] INT, + [SampleEquipment_ID] INT, + [Description] NVARCHAR(500), + CONSTRAINT [PK_Sample] PRIMARY KEY ([Sample_ID]) +); + +CREATE TABLE [dbo].[SamplingPoint] ( + [SamplingPoint_ID] INT IDENTITY(1,1) NOT NULL, + [Site_ID] INT NOT NULL, + [SamplingPoint] NVARCHAR(100) NOT NULL, + [LatitudeWGS84] FLOAT, + [LongitudeWGS84] FLOAT, + [Description] NVARCHAR(MAX), + [PicturePath] NVARCHAR(500), + [ValidFrom] DATETIME2(7), + [ValidTo] DATETIME2(7), + [ProcessUnit_ID] INT, + [CreatedByCampaign_ID] INT, + CONSTRAINT [PK_SamplingPoint] PRIMARY KEY ([SamplingPoint_ID]) +); + +CREATE TABLE [dbo].[SignalInterface] ( + [SignalInterface_ID] INT IDENTITY(1,1) NOT NULL, + [DataAcquisitionSystem_ID] INT NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Manufacturer] NVARCHAR(100), + [Model] NVARCHAR(100), + [SerialNumber] NVARCHAR(100), + [Description] NVARCHAR(MAX), + [IsActive] BIT NOT NULL DEFAULT 1, + CONSTRAINT [PK_SignalInterface] PRIMARY KEY ([SignalInterface_ID]) +); + +CREATE TABLE [dbo].[SignalInterfacePort] ( + [SignalInterfacePort_ID] INT IDENTITY(1,1) NOT NULL, + [SignalInterface_ID] INT NOT NULL, + [PortIdentifier] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(MAX), + [IsActive] BIT NOT NULL DEFAULT 1, + CONSTRAINT [PK_SignalInterfacePort] PRIMARY KEY ([SignalInterfacePort_ID]) +); + +CREATE TABLE [dbo].[Site] ( + [Site_ID] INT IDENTITY(1,1) NOT NULL, + [Watershed_ID] INT, + [Name] NVARCHAR(100), + [SiteKind_ID] INT, + [Description] NVARCHAR(MAX), + [LatitudeWGS84] FLOAT, + [LongitudeWGS84] FLOAT, + [StreetNumber] NVARCHAR(100), + [StreetName] NVARCHAR(100), + [City] NVARCHAR(255), + [PostCode] NVARCHAR(100), + [Province] NVARCHAR(255), + [Country] NVARCHAR(255), + CONSTRAINT [PK_Site] PRIMARY KEY ([Site_ID]) +); + +CREATE TABLE [dbo].[Stream] ( + [Stream_ID] INT IDENTITY(1,1) NOT NULL, + [StreamKind_ID] INT NOT NULL, + CONSTRAINT [PK_Stream] PRIMARY KEY ([Stream_ID]) +); + +CREATE TABLE [dbo].[Value] ( + [Observation_ID] INT NOT NULL, + [Value] FLOAT, + [QualityCode] INT, + CONSTRAINT [PK_Value] PRIMARY KEY ([Observation_ID]) +); + +CREATE TABLE [dbo].[ValueBin] ( + [ValueBin_ID] INT IDENTITY(1,1) NOT NULL, + [ValueBinningAxis_ID] INT NOT NULL, + [BinIndex] INT NOT NULL, + [LowerBound] FLOAT, + [UpperBound] FLOAT, + [NominalValue] FLOAT, + CONSTRAINT [PK_ValueBin] PRIMARY KEY ([ValueBin_ID]), + CONSTRAINT [UQ_ValueBin_AxisIndex] UNIQUE ([ValueBinningAxis_ID], [BinIndex]), + CONSTRAINT [CK_ValueBin_BinValues] CHECK (((LowerBound IS NULL AND UpperBound IS NULL) OR (LowerBound IS NOT NULL AND UpperBound IS NOT NULL)) AND (LowerBound IS NULL OR UpperBound > LowerBound) AND (NominalValue IS NOT NULL OR LowerBound IS NOT NULL) +) +); + +CREATE TABLE [dbo].[ValueBinningAxis] ( + [ValueBinningAxis_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(500), + [NumberOfBins] INT NOT NULL, + [Unit_ID] INT NOT NULL, + [BinKind_ID] INT NOT NULL DEFAULT 1, + CONSTRAINT [PK_ValueBinningAxis] PRIMARY KEY ([ValueBinningAxis_ID]) +); + +CREATE TABLE [dbo].[ValueImage] ( + [Observation_ID] INT NOT NULL, + [ImageWidth] INT NOT NULL, + [ImageHeight] INT NOT NULL, + [NumberOfChannels] INT NOT NULL DEFAULT 3, + [ImageFormat] NVARCHAR(20) NOT NULL, + [FileSizeBytes] BIGINT, + [StorageBackend] NVARCHAR(50) NOT NULL DEFAULT 'FileSystem', + [StoragePath] NVARCHAR(1000) NOT NULL, + [Thumbnail] VARBINARY(MAX), + [QualityCode] INT, + CONSTRAINT [PK_ValueImage] PRIMARY KEY ([Observation_ID]) +); + +CREATE TABLE [dbo].[ValueMatrix] ( + [Observation_ID] INT NOT NULL, + [RowValueBin_ID] INT NOT NULL, + [ColValueBin_ID] INT NOT NULL, + [Value] FLOAT, + [QualityCode] INT, + CONSTRAINT [PK_ValueMatrix] PRIMARY KEY ([Observation_ID], [RowValueBin_ID], [ColValueBin_ID]) +); + +CREATE TABLE [dbo].[ValueVector] ( + [Observation_ID] INT NOT NULL, + [ValueBin_ID] INT NOT NULL, + [Value] FLOAT, + [QualityCode] INT, + CONSTRAINT [PK_ValueVector] PRIMARY KEY ([Observation_ID], [ValueBin_ID]) +); + +CREATE TABLE [dbo].[Watershed] ( + [Watershed_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100), + [Description] NVARCHAR(MAX), + [SurfaceArea] REAL, + [ConcentrationTime] INT, + [ImperviousSurface] REAL, + [ParentWatershed_ID] INT, + [GeometryGeoJSON] NVARCHAR(MAX), + CONSTRAINT [PK_Watershed] PRIMARY KEY ([Watershed_ID]) +); + + + + + + + + + + + + + + + + + + + + + + + +CREATE INDEX [IX_UserAccount_Email] ON [dbo].[UserAccount] ([Email]); + + + + +CREATE INDEX [IX_Annotation_Stream_Time] ON [dbo].[Annotation] ([Stream_ID], [StartTime], [EndTime]); +CREATE INDEX [IX_Annotation_Author] ON [dbo].[Annotation] ([AuthorPerson_ID], [CreatedDateTime]); + +CREATE INDEX [IX_AuditLog_UserAccount_ID] ON [dbo].[AuditLog] ([UserAccount_ID]); +CREATE INDEX [IX_AuditLog_Timestamp] ON [dbo].[AuditLog] ([Timestamp]); +CREATE INDEX [IX_AuditLog_ResourceType] ON [dbo].[AuditLog] ([ResourceType]); + + + + +CREATE UNIQUE INDEX [UQ_Channel_SignalStream] ON [dbo].[Channel] ([SignalInterface_ID], [TagName], [Parameter_ID], [DataProvenanceKind_ID], [ProducedByStep_ID]); +CREATE INDEX [IX_Channel_ParentChannel] ON [dbo].[Channel] ([ParentChannel_ID]); + + +CREATE UNIQUE INDEX [UQ_ChannelPortHistory_ActiveRow] ON [dbo].[ChannelPortHistory] ([Channel_ID]) WHERE [ValidTo] IS NULL; +CREATE INDEX [IX_ChannelPortHistory_Port] ON [dbo].[ChannelPortHistory] ([SignalInterfacePort_ID], [ValidFrom]); + +CREATE INDEX [IX_ChannelTrait_Stream] ON [dbo].[ChannelTrait] ([Stream_ID]); + + +CREATE UNIQUE INDEX [UQ_ControlLoopApplication_ActiveRow] ON [dbo].[ControlLoopApplication] ([ControlLoop_ID]) WHERE [EndTime] IS NULL; + +CREATE UNIQUE INDEX [UQ_ControlLoopPort_LoopChannel] ON [dbo].[ControlLoopPort] ([ControlLoop_ID], [Channel_ID]); + +CREATE UNIQUE INDEX [UQ_DASLocationHistory_ActivePerDAS] ON [dbo].[DASLocationHistory] ([DataAcquisitionSystem_ID]) WHERE [ValidTo] IS NULL; +CREATE INDEX [IX_DASLocationHistory_Site_ValidFrom] ON [dbo].[DASLocationHistory] ([Site_ID], [ValidFrom]); + + + + + +CREATE INDEX [IX_EquipmentEvent_Equipment_Start] ON [dbo].[EquipmentEvent] ([Equipment_ID], [EventDateTimeStart]); + +CREATE UNIQUE INDEX [UQ_EquipmentLocationHistory_ActiveRow] ON [dbo].[EquipmentLocationHistory] ([Equipment_ID]) WHERE [ValidTo] IS NULL; +CREATE INDEX [IX_EquipmentLocationHistory_SamplingPoint] ON [dbo].[EquipmentLocationHistory] ([SamplingPoint_ID], [ValidFrom]); + + + +CREATE UNIQUE INDEX [UQ_EquipmentWiringHistory_ActiveRow] ON [dbo].[EquipmentWiringHistory] ([Equipment_ID]) WHERE [ValidTo] IS NULL; +CREATE INDEX [IX_EquipmentWiringHistory_Interface] ON [dbo].[EquipmentWiringHistory] ([SignalInterface_ID], [ValidFrom]); + + + + + + + + +CREATE UNIQUE INDEX [UQ_Obs_Channel] ON [dbo].[Observation] ([Channel_ID], [Timestamp], [ValueKind_ID]) WHERE Channel_ID IS NOT NULL; +CREATE UNIQUE INDEX [UQ_Obs_Lab] ON [dbo].[Observation] ([LabAnalysis_ID]) WHERE LabAnalysis_ID IS NOT NULL; + + + + + +CREATE INDEX [IX_ProcessingLineage_Stream] ON [dbo].[ProcessingLineage] ([Stream_ID]); +CREATE INDEX [IX_Lineage_Step] ON [dbo].[ProcessingLineage] ([ProcessingStep_ID]); + + + + +CREATE UNIQUE INDEX [UQ_SignalInterface_DAS_Name] ON [dbo].[SignalInterface] ([DataAcquisitionSystem_ID], [Name]); + +CREATE UNIQUE INDEX [UQ_SignalInterfacePort_Interface_PortId] ON [dbo].[SignalInterfacePort] ([SignalInterface_ID], [PortIdentifier]); + + + + + + + + + + +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_Parameter_ID] FOREIGN KEY ([Parameter_ID]) REFERENCES [dbo].[Parameter] ([Parameter_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_SamplingPoint_ID] FOREIGN KEY ([SamplingPoint_ID]) REFERENCES [dbo].[SamplingPoint] ([SamplingPoint_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_ValueKind_ID] FOREIGN KEY ([ValueKind_ID]) REFERENCES [dbo].[ValueKind] ([ValueKind_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_Unit_ID] FOREIGN KEY ([Unit_ID]) REFERENCES [dbo].[Unit] ([Unit_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[AnalysisSeriesAxis] ADD CONSTRAINT [FK_AnalysisSeriesAxis_AnalysisSeries_ID] FOREIGN KEY ([AnalysisSeries_ID]) REFERENCES [dbo].[AnalysisSeries] ([Stream_ID]); +ALTER TABLE [dbo].[AnalysisSeriesAxis] ADD CONSTRAINT [FK_AnalysisSeriesAxis_ValueBinningAxis_ID] FOREIGN KEY ([ValueBinningAxis_ID]) REFERENCES [dbo].[ValueBinningAxis] ([ValueBinningAxis_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_AnnotationKind_ID] FOREIGN KEY ([AnnotationKind_ID]) REFERENCES [dbo].[AnnotationKind] ([AnnotationKind_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_AuthorPerson_ID] FOREIGN KEY ([AuthorPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_EquipmentEvent_ID] FOREIGN KEY ([EquipmentEvent_ID]) REFERENCES [dbo].[EquipmentEvent] ([EquipmentEvent_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[AuditLog] ADD CONSTRAINT [FK_AuditLog_UserAccount_ID] FOREIGN KEY ([UserAccount_ID]) REFERENCES [dbo].[UserAccount] ([UserAccount_ID]); +ALTER TABLE [dbo].[Campaign] ADD CONSTRAINT [FK_Campaign_CampaignKind_ID] FOREIGN KEY ([CampaignKind_ID]) REFERENCES [dbo].[CampaignKind] ([CampaignKind_ID]); +ALTER TABLE [dbo].[Campaign] ADD CONSTRAINT [FK_Campaign_Site_ID] FOREIGN KEY ([Site_ID]) REFERENCES [dbo].[Site] ([Site_ID]); +ALTER TABLE [dbo].[Campaign] ADD CONSTRAINT [FK_Campaign_ResponsiblePerson_ID] FOREIGN KEY ([ResponsiblePerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[CampaignEquipment] ADD CONSTRAINT [FK_CampaignEquipment_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[CampaignEquipment] ADD CONSTRAINT [FK_CampaignEquipment_Equipment_ID] FOREIGN KEY ([Equipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[CampaignSamplingLocation] ADD CONSTRAINT [FK_CampaignSamplingLocation_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[CampaignSamplingLocation] ADD CONSTRAINT [FK_CampaignSamplingLocation_SamplingPoint_ID] FOREIGN KEY ([SamplingPoint_ID]) REFERENCES [dbo].[SamplingPoint] ([SamplingPoint_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_SignalInterface_ID] FOREIGN KEY ([SignalInterface_ID]) REFERENCES [dbo].[SignalInterface] ([SignalInterface_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_ParentChannel_ID] FOREIGN KEY ([ParentChannel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_ChannelKind_ID] FOREIGN KEY ([ChannelKind_ID]) REFERENCES [dbo].[ChannelKind] ([ChannelKind_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_Parameter_ID] FOREIGN KEY ([Parameter_ID]) REFERENCES [dbo].[Parameter] ([Parameter_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_DataProvenanceKind_ID] FOREIGN KEY ([DataProvenanceKind_ID]) REFERENCES [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_ProducedByStep_ID] FOREIGN KEY ([ProducedByStep_ID]) REFERENCES [dbo].[ProcessingStep] ([ProcessingStep_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_ValueKind_ID] FOREIGN KEY ([ValueKind_ID]) REFERENCES [dbo].[ValueKind] ([ValueKind_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_Unit_ID] FOREIGN KEY ([Unit_ID]) REFERENCES [dbo].[Unit] ([Unit_ID]); +ALTER TABLE [dbo].[ChannelAxis] ADD CONSTRAINT [FK_ChannelAxis_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[ChannelAxis] ADD CONSTRAINT [FK_ChannelAxis_ValueBinningAxis_ID] FOREIGN KEY ([ValueBinningAxis_ID]) REFERENCES [dbo].[ValueBinningAxis] ([ValueBinningAxis_ID]); +ALTER TABLE [dbo].[ChannelPortHistory] ADD CONSTRAINT [FK_ChannelPortHistory_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[ChannelPortHistory] ADD CONSTRAINT [FK_ChannelPortHistory_SignalInterfacePort_ID] FOREIGN KEY ([SignalInterfacePort_ID]) REFERENCES [dbo].[SignalInterfacePort] ([SignalInterfacePort_ID]); +ALTER TABLE [dbo].[ChannelTrait] ADD CONSTRAINT [FK_ChannelTrait_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[ChannelTrait] ADD CONSTRAINT [FK_ChannelTrait_OperationKind_ID] FOREIGN KEY ([OperationKind_ID]) REFERENCES [dbo].[OperationKind] ([OperationKind_ID]); +ALTER TABLE [dbo].[ControlLoop] ADD CONSTRAINT [FK_ControlLoop_ControllerKind_ID] FOREIGN KEY ([ControllerKind_ID]) REFERENCES [dbo].[ControllerKind] ([ControllerKind_ID]); +ALTER TABLE [dbo].[ControlLoop] ADD CONSTRAINT [FK_ControlLoop_FallbackControlLoop_ID] FOREIGN KEY ([FallbackControlLoop_ID]) REFERENCES [dbo].[ControlLoop] ([ControlLoop_ID]); +ALTER TABLE [dbo].[ControlLoopApplication] ADD CONSTRAINT [FK_ControlLoopApplication_ControlLoop_ID] FOREIGN KEY ([ControlLoop_ID]) REFERENCES [dbo].[ControlLoop] ([ControlLoop_ID]); +ALTER TABLE [dbo].[ControlLoopApplication] ADD CONSTRAINT [FK_ControlLoopApplication_AppliedByPerson_ID] FOREIGN KEY ([AppliedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[ControlLoopPort] ADD CONSTRAINT [FK_ControlLoopPort_ControlLoop_ID] FOREIGN KEY ([ControlLoop_ID]) REFERENCES [dbo].[ControlLoop] ([ControlLoop_ID]); +ALTER TABLE [dbo].[ControlLoopPort] ADD CONSTRAINT [FK_ControlLoopPort_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[ControlLoopPort] ADD CONSTRAINT [FK_ControlLoopPort_ControlLoopPortKind_ID] FOREIGN KEY ([ControlLoopPortKind_ID]) REFERENCES [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID]); +ALTER TABLE [dbo].[DASLocationHistory] ADD CONSTRAINT [FK_DASLocationHistory_DataAcquisitionSystem_ID] FOREIGN KEY ([DataAcquisitionSystem_ID]) REFERENCES [dbo].[DataAcquisitionSystem] ([DataAcquisitionSystem_ID]); +ALTER TABLE [dbo].[DASLocationHistory] ADD CONSTRAINT [FK_DASLocationHistory_Site_ID] FOREIGN KEY ([Site_ID]) REFERENCES [dbo].[Site] ([Site_ID]); +ALTER TABLE [dbo].[DASLocationHistory] ADD CONSTRAINT [FK_DASLocationHistory_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[DataAcquisitionSystem] ADD CONSTRAINT [FK_DataAcquisitionSystem_ParentSystem_ID] FOREIGN KEY ([ParentSystem_ID]) REFERENCES [dbo].[DataAcquisitionSystem] ([DataAcquisitionSystem_ID]); +ALTER TABLE [dbo].[DataAcquisitionSystem] ADD CONSTRAINT [FK_DataAcquisitionSystem_DataAcquisitionSystemKind_ID] FOREIGN KEY ([DataAcquisitionSystemKind_ID]) REFERENCES [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID]); +ALTER TABLE [dbo].[Dataset] ADD CONSTRAINT [FK_Dataset_CreatedByPerson_ID] FOREIGN KEY ([CreatedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[DatasetChannel] ADD CONSTRAINT [FK_DatasetChannel_Dataset_ID] FOREIGN KEY ([Dataset_ID]) REFERENCES [dbo].[Dataset] ([Dataset_ID]); +ALTER TABLE [dbo].[DatasetChannel] ADD CONSTRAINT [FK_DatasetChannel_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[Equipment] ADD CONSTRAINT [FK_Equipment_EquipmentModel_ID] FOREIGN KEY ([EquipmentModel_ID]) REFERENCES [dbo].[EquipmentModel] ([EquipmentModel_ID]); +ALTER TABLE [dbo].[EquipmentEvent] ADD CONSTRAINT [FK_EquipmentEvent_Equipment_ID] FOREIGN KEY ([Equipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[EquipmentEvent] ADD CONSTRAINT [FK_EquipmentEvent_EquipmentEventKind_ID] FOREIGN KEY ([EquipmentEventKind_ID]) REFERENCES [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID]); +ALTER TABLE [dbo].[EquipmentEvent] ADD CONSTRAINT [FK_EquipmentEvent_PerformedByPerson_ID] FOREIGN KEY ([PerformedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[EquipmentEvent] ADD CONSTRAINT [FK_EquipmentEvent_RecordedByPerson_ID] FOREIGN KEY ([RecordedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[EquipmentLocationHistory] ADD CONSTRAINT [FK_EquipmentLocationHistory_Equipment_ID] FOREIGN KEY ([Equipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[EquipmentLocationHistory] ADD CONSTRAINT [FK_EquipmentLocationHistory_SamplingPoint_ID] FOREIGN KEY ([SamplingPoint_ID]) REFERENCES [dbo].[SamplingPoint] ([SamplingPoint_ID]); +ALTER TABLE [dbo].[EquipmentLocationHistory] ADD CONSTRAINT [FK_EquipmentLocationHistory_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[EquipmentModelHasParameter] ADD CONSTRAINT [FK_EquipmentModelHasParameter_EquipmentModel_ID] FOREIGN KEY ([EquipmentModel_ID]) REFERENCES [dbo].[EquipmentModel] ([EquipmentModel_ID]); +ALTER TABLE [dbo].[EquipmentModelHasParameter] ADD CONSTRAINT [FK_EquipmentModelHasParameter_Parameter_ID] FOREIGN KEY ([Parameter_ID]) REFERENCES [dbo].[Parameter] ([Parameter_ID]); +ALTER TABLE [dbo].[EquipmentModelHasProcedures] ADD CONSTRAINT [FK_EquipmentModelHasProcedures_EquipmentModel_ID] FOREIGN KEY ([EquipmentModel_ID]) REFERENCES [dbo].[EquipmentModel] ([EquipmentModel_ID]); +ALTER TABLE [dbo].[EquipmentModelHasProcedures] ADD CONSTRAINT [FK_EquipmentModelHasProcedures_Procedure_ID] FOREIGN KEY ([Procedure_ID]) REFERENCES [dbo].[Procedures] ([Procedure_ID]); +ALTER TABLE [dbo].[EquipmentWiringHistory] ADD CONSTRAINT [FK_EquipmentWiringHistory_Equipment_ID] FOREIGN KEY ([Equipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[EquipmentWiringHistory] ADD CONSTRAINT [FK_EquipmentWiringHistory_SignalInterface_ID] FOREIGN KEY ([SignalInterface_ID]) REFERENCES [dbo].[SignalInterface] ([SignalInterface_ID]); +ALTER TABLE [dbo].[EquipmentWiringHistory] ADD CONSTRAINT [FK_EquipmentWiringHistory_SignalInterfacePort_ID] FOREIGN KEY ([SignalInterfacePort_ID]) REFERENCES [dbo].[SignalInterfacePort] ([SignalInterfacePort_ID]); +ALTER TABLE [dbo].[HydrologicalCharacteristics] ADD CONSTRAINT [FK_HydrologicalCharacteristics_Watershed_ID] FOREIGN KEY ([Watershed_ID]) REFERENCES [dbo].[Watershed] ([Watershed_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_LabExperiment_ID] FOREIGN KEY ([LabExperiment_ID]) REFERENCES [dbo].[LabExperiment] ([LabExperiment_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_AnalysisSeries_ID] FOREIGN KEY ([AnalysisSeries_ID]) REFERENCES [dbo].[AnalysisSeries] ([Stream_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_Sample_ID] FOREIGN KEY ([Sample_ID]) REFERENCES [dbo].[Sample] ([Sample_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_QualityCode_ID] FOREIGN KEY ([QualityCode_ID]) REFERENCES [dbo].[QualityCode] ([QualityCode_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_ReviewStatus_ID] FOREIGN KEY ([ReviewStatus_ID]) REFERENCES [dbo].[ReviewStatus] ([ReviewStatus_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_ReviewedByPerson_ID] FOREIGN KEY ([ReviewedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_Laboratory_ID] FOREIGN KEY ([Laboratory_ID]) REFERENCES [dbo].[Laboratory] ([Laboratory_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_AnalystPerson_ID] FOREIGN KEY ([AnalystPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_Procedure_ID] FOREIGN KEY ([Procedure_ID]) REFERENCES [dbo].[Procedures] ([Procedure_ID]); +ALTER TABLE [dbo].[LabExperiment] ADD CONSTRAINT [FK_LabExperiment_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[LabExperiment] ADD CONSTRAINT [FK_LabExperiment_CreatedByPerson_ID] FOREIGN KEY ([CreatedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[LabExperiment] ADD CONSTRAINT [FK_LabExperiment_LabPanel_ID] FOREIGN KEY ([LabPanel_ID]) REFERENCES [dbo].[LabPanel] ([LabPanel_ID]); +ALTER TABLE [dbo].[LabPanel] ADD CONSTRAINT [FK_LabPanel_CreatedByPerson_ID] FOREIGN KEY ([CreatedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[LabPanel] ADD CONSTRAINT [FK_LabPanel_DefaultSampleCollectionKind_ID] FOREIGN KEY ([DefaultSampleCollectionKind_ID]) REFERENCES [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID]); +ALTER TABLE [dbo].[LabPanel] ADD CONSTRAINT [FK_LabPanel_DefaultSampleEquipment_ID] FOREIGN KEY ([DefaultSampleEquipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[LabPanelSeries] ADD CONSTRAINT [FK_LabPanelSeries_LabPanel_ID] FOREIGN KEY ([LabPanel_ID]) REFERENCES [dbo].[LabPanel] ([LabPanel_ID]); +ALTER TABLE [dbo].[LabPanelSeries] ADD CONSTRAINT [FK_LabPanelSeries_AnalysisSeries_ID] FOREIGN KEY ([AnalysisSeries_ID]) REFERENCES [dbo].[AnalysisSeries] ([Stream_ID]); +ALTER TABLE [dbo].[Laboratory] ADD CONSTRAINT [FK_Laboratory_Site_ID] FOREIGN KEY ([Site_ID]) REFERENCES [dbo].[Site] ([Site_ID]); +ALTER TABLE [dbo].[LandUse] ADD CONSTRAINT [FK_LandUse_Watershed_ID] FOREIGN KEY ([Watershed_ID]) REFERENCES [dbo].[Watershed] ([Watershed_ID]); +ALTER TABLE [dbo].[Observation] ADD CONSTRAINT [FK_Observation_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[Observation] ADD CONSTRAINT [FK_Observation_LabAnalysis_ID] FOREIGN KEY ([LabAnalysis_ID]) REFERENCES [dbo].[LabAnalysis] ([LabAnalysis_ID]); +ALTER TABLE [dbo].[Observation] ADD CONSTRAINT [FK_Observation_ValueKind_ID] FOREIGN KEY ([ValueKind_ID]) REFERENCES [dbo].[ValueKind] ([ValueKind_ID]); +ALTER TABLE [dbo].[Parameter] ADD CONSTRAINT [FK_Parameter_ValueKind_ID] FOREIGN KEY ([ValueKind_ID]) REFERENCES [dbo].[ValueKind] ([ValueKind_ID]); +ALTER TABLE [dbo].[ParameterHasUnit] ADD CONSTRAINT [FK_ParameterHasUnit_Parameter_ID] FOREIGN KEY ([Parameter_ID]) REFERENCES [dbo].[Parameter] ([Parameter_ID]); +ALTER TABLE [dbo].[ParameterHasUnit] ADD CONSTRAINT [FK_ParameterHasUnit_Unit_ID] FOREIGN KEY ([Unit_ID]) REFERENCES [dbo].[Unit] ([Unit_ID]); +ALTER TABLE [dbo].[Procedures] ADD CONSTRAINT [FK_Procedures_ProcedureKind_ID] FOREIGN KEY ([ProcedureKind_ID]) REFERENCES [dbo].[ProcedureKind] ([ProcedureKind_ID]); +ALTER TABLE [dbo].[ProcessUnit] ADD CONSTRAINT [FK_ProcessUnit_Site_ID] FOREIGN KEY ([Site_ID]) REFERENCES [dbo].[Site] ([Site_ID]); +ALTER TABLE [dbo].[ProcessUnit] ADD CONSTRAINT [FK_ProcessUnit_ProcessUnitKind_ID] FOREIGN KEY ([ProcessUnitKind_ID]) REFERENCES [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID]); +ALTER TABLE [dbo].[ProcessUnit] ADD CONSTRAINT [FK_ProcessUnit_Parent_ID] FOREIGN KEY ([Parent_ID]) REFERENCES [dbo].[ProcessUnit] ([ProcessUnit_ID]); +ALTER TABLE [dbo].[ProcessingLineage] ADD CONSTRAINT [FK_ProcessingLineage_ProcessingStep_ID] FOREIGN KEY ([ProcessingStep_ID]) REFERENCES [dbo].[ProcessingStep] ([ProcessingStep_ID]); +ALTER TABLE [dbo].[ProcessingLineage] ADD CONSTRAINT [FK_ProcessingLineage_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[ProcessingStep] ADD CONSTRAINT [FK_ProcessingStep_OperationKind_ID] FOREIGN KEY ([OperationKind_ID]) REFERENCES [dbo].[OperationKind] ([OperationKind_ID]); +ALTER TABLE [dbo].[ProcessingStep] ADD CONSTRAINT [FK_ProcessingStep_ExecutedByPerson_ID] FOREIGN KEY ([ExecutedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[ProcessingStep] ADD CONSTRAINT [FK_ProcessingStep_Dataset_ID] FOREIGN KEY ([Dataset_ID]) REFERENCES [dbo].[Dataset] ([Dataset_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_ParentSample_ID] FOREIGN KEY ([ParentSample_ID]) REFERENCES [dbo].[Sample] ([Sample_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SampleKind_ID] FOREIGN KEY ([SampleKind_ID]) REFERENCES [dbo].[SampleKind] ([SampleKind_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SamplingPoint_ID] FOREIGN KEY ([SamplingPoint_ID]) REFERENCES [dbo].[SamplingPoint] ([SamplingPoint_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SampledByPerson_ID] FOREIGN KEY ([SampledByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SampleCollectionKind_ID] FOREIGN KEY ([SampleCollectionKind_ID]) REFERENCES [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SampleEquipment_ID] FOREIGN KEY ([SampleEquipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[SamplingPoint] ADD CONSTRAINT [FK_SamplingPoint_Site_ID] FOREIGN KEY ([Site_ID]) REFERENCES [dbo].[Site] ([Site_ID]); +ALTER TABLE [dbo].[SamplingPoint] ADD CONSTRAINT [FK_SamplingPoint_ProcessUnit_ID] FOREIGN KEY ([ProcessUnit_ID]) REFERENCES [dbo].[ProcessUnit] ([ProcessUnit_ID]); +ALTER TABLE [dbo].[SamplingPoint] ADD CONSTRAINT [FK_SamplingPoint_CreatedByCampaign_ID] FOREIGN KEY ([CreatedByCampaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[SignalInterface] ADD CONSTRAINT [FK_SignalInterface_DataAcquisitionSystem_ID] FOREIGN KEY ([DataAcquisitionSystem_ID]) REFERENCES [dbo].[DataAcquisitionSystem] ([DataAcquisitionSystem_ID]); +ALTER TABLE [dbo].[SignalInterfacePort] ADD CONSTRAINT [FK_SignalInterfacePort_SignalInterface_ID] FOREIGN KEY ([SignalInterface_ID]) REFERENCES [dbo].[SignalInterface] ([SignalInterface_ID]); +ALTER TABLE [dbo].[Site] ADD CONSTRAINT [FK_Site_Watershed_ID] FOREIGN KEY ([Watershed_ID]) REFERENCES [dbo].[Watershed] ([Watershed_ID]); +ALTER TABLE [dbo].[Site] ADD CONSTRAINT [FK_Site_SiteKind_ID] FOREIGN KEY ([SiteKind_ID]) REFERENCES [dbo].[SiteKind] ([SiteKind_ID]); +ALTER TABLE [dbo].[Stream] ADD CONSTRAINT [FK_Stream_StreamKind_ID] FOREIGN KEY ([StreamKind_ID]) REFERENCES [dbo].[StreamKind] ([StreamKind_ID]); +ALTER TABLE [dbo].[Value] ADD CONSTRAINT [FK_Value_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[ValueBin] ADD CONSTRAINT [FK_ValueBin_ValueBinningAxis_ID] FOREIGN KEY ([ValueBinningAxis_ID]) REFERENCES [dbo].[ValueBinningAxis] ([ValueBinningAxis_ID]); +ALTER TABLE [dbo].[ValueBinningAxis] ADD CONSTRAINT [FK_ValueBinningAxis_Unit_ID] FOREIGN KEY ([Unit_ID]) REFERENCES [dbo].[Unit] ([Unit_ID]); +ALTER TABLE [dbo].[ValueBinningAxis] ADD CONSTRAINT [FK_ValueBinningAxis_BinKind_ID] FOREIGN KEY ([BinKind_ID]) REFERENCES [dbo].[BinKind] ([BinKind_ID]); +ALTER TABLE [dbo].[ValueImage] ADD CONSTRAINT [FK_ValueImage_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[ValueMatrix] ADD CONSTRAINT [FK_ValueMatrix_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[ValueMatrix] ADD CONSTRAINT [FK_ValueMatrix_RowValueBin] FOREIGN KEY ([RowValueBin_ID]) REFERENCES [dbo].[ValueBin] ([ValueBin_ID]); +ALTER TABLE [dbo].[ValueMatrix] ADD CONSTRAINT [FK_ValueMatrix_ColValueBin] FOREIGN KEY ([ColValueBin_ID]) REFERENCES [dbo].[ValueBin] ([ValueBin_ID]); +ALTER TABLE [dbo].[ValueVector] ADD CONSTRAINT [FK_ValueVector_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[ValueVector] ADD CONSTRAINT [FK_ValueVector_ValueBin_ID] FOREIGN KEY ([ValueBin_ID]) REFERENCES [dbo].[ValueBin] ([ValueBin_ID]); +ALTER TABLE [dbo].[Watershed] ADD CONSTRAINT [FK_Watershed_ParentWatershed_ID] FOREIGN KEY ([ParentWatershed_ID]) REFERENCES [dbo].[Watershed] ([Watershed_ID]); + +-- Views +GO +CREATE OR ALTER VIEW [dbo].[vw_ChannelResolved] AS +SELECT + c.[Stream_ID], + c.[SignalInterface_ID], + c.[TagName], + cph.[SignalInterfacePort_ID], + c.[ParentChannel_ID], + c.[ChannelKind_ID], + c.[Parameter_ID], + c.[DataProvenanceKind_ID], + c.[ProducedByStep_ID], + c.[ValueKind_ID], + c.[Unit_ID] +FROM [dbo].[Channel] c +LEFT JOIN [dbo].[ChannelPortHistory] cph + ON cph.[Channel_ID] = c.[Stream_ID] + AND cph.[ValidTo] IS NULL; + +GO +CREATE OR ALTER VIEW [dbo].[vw_DeploymentCoherence] AS +SELECT + dlh.[DataAcquisitionSystem_ID] AS DAS_ID, + das.[Name] AS DASName, + dlh.[Site_ID] AS DASSite_ID, + dsite.[Name] AS DASSiteName, + e.[Equipment_ID] AS Equipment_ID, + e.[Identifier] AS EquipmentName, + sp.[Site_ID] AS EquipmentSite_ID, + esite.[Name] AS EquipmentSiteName, + sp.[SamplingPoint_ID] AS SamplingPoint_ID, + sp.[SamplingPoint] AS SamplingPointName +FROM [dbo].[DASLocationHistory] dlh +JOIN [dbo].[DataAcquisitionSystem] das ON das.[DataAcquisitionSystem_ID] = dlh.[DataAcquisitionSystem_ID] +JOIN [dbo].[SignalInterface] si ON si.[DataAcquisitionSystem_ID] = dlh.[DataAcquisitionSystem_ID] +JOIN [dbo].[EquipmentWiringHistory] ewh ON ewh.[SignalInterface_ID] = si.[SignalInterface_ID] + AND ewh.[ValidTo] IS NULL +JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = ewh.[Equipment_ID] +JOIN [dbo].[EquipmentLocationHistory] elh ON elh.[Equipment_ID] = e.[Equipment_ID] + AND elh.[ValidTo] IS NULL +JOIN [dbo].[SamplingPoint] sp ON sp.[SamplingPoint_ID] = elh.[SamplingPoint_ID] +LEFT JOIN [dbo].[Site] dsite ON dsite.[Site_ID] = dlh.[Site_ID] +LEFT JOIN [dbo].[Site] esite ON esite.[Site_ID] = sp.[Site_ID] +WHERE dlh.[ValidTo] IS NULL + AND sp.[Site_ID] <> dlh.[Site_ID]; + +GO +CREATE OR ALTER VIEW [dbo].[vw_ChannelEquipmentAtTime] AS +WITH channel_wiring AS ( + SELECT + o.[Observation_ID] AS ObservationID, + c.[Stream_ID] AS ChannelID, + o.[Timestamp] AS Timestamp, + c.[SignalInterface_ID], + c.[SignalInterfacePort_ID], + ewh.[Equipment_ID] AS EquipmentID, + ROW_NUMBER() OVER ( + PARTITION BY o.[Observation_ID] + ORDER BY + CASE WHEN ewh.[SignalInterfacePort_ID] IS NOT NULL THEN 0 ELSE 1 END, + ewh.[ValidFrom] DESC + ) AS rn, + COUNT(*) OVER (PARTITION BY o.[Observation_ID]) AS match_count + FROM [dbo].[Observation] o + JOIN [dbo].[vw_ChannelResolved] c ON c.[Stream_ID] = o.[Channel_ID] + LEFT JOIN [dbo].[EquipmentWiringHistory] ewh ON ewh.[SignalInterface_ID] = c.[SignalInterface_ID] + AND ( + ewh.[SignalInterfacePort_ID] = c.[SignalInterfacePort_ID] + OR (ewh.[SignalInterfacePort_ID] IS NULL AND c.[SignalInterfacePort_ID] IS NULL) + OR c.[SignalInterfacePort_ID] IS NULL + ) + AND ewh.[ValidFrom] <= o.[Timestamp] + AND (ewh.[ValidTo] IS NULL OR ewh.[ValidTo] > o.[Timestamp]) +) +SELECT + cw.ObservationID, + cw.ChannelID, + cw.Timestamp, + CASE WHEN cw.match_count > 1 AND cw.[SignalInterfacePort_ID] IS NULL THEN NULL ELSE cw.EquipmentID END AS EquipmentID, + e.[Identifier] AS EquipmentName, + CASE + WHEN cw.EquipmentID IS NULL AND cw.match_count = 0 THEN N'unlinked' + WHEN cw.match_count > 1 AND cw.[SignalInterfacePort_ID] IS NULL THEN N'ambiguous' + ELSE N'resolved' + END AS Resolution +FROM channel_wiring cw +LEFT JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = cw.EquipmentID +WHERE cw.rn = 1; + +GO +CREATE OR ALTER VIEW [dbo].[vw_ChannelStatus] AS +SELECT + statusC.[Stream_ID] AS StatusChannelID, + valueC.[Stream_ID] AS MeasurementChannelID, + e.[Equipment_ID] AS EquipmentID, + e.[Identifier] AS EquipmentName, + p.[Parameter] AS MeasurementParameter, + o.[Timestamp], + CAST(v.[Value] AS INT) AS StatusCodeID +FROM [dbo].[Value] v +JOIN [dbo].[Observation] o ON o.[Observation_ID] = v.[Observation_ID] +JOIN [dbo].[Channel] statusC ON statusC.[Stream_ID] = o.[Channel_ID] +JOIN [dbo].[ChannelKind] role ON role.[ChannelKind_ID] = statusC.[ChannelKind_ID] +JOIN [dbo].[vw_ChannelResolved] valueC ON valueC.[Stream_ID] = statusC.[ParentChannel_ID] +JOIN [dbo].[Parameter] p ON p.[Parameter_ID] = valueC.[Parameter_ID] +LEFT JOIN [dbo].[EquipmentWiringHistory] ewh + ON ewh.[SignalInterface_ID] = valueC.[SignalInterface_ID] + AND ( + ewh.[SignalInterfacePort_ID] = valueC.[SignalInterfacePort_ID] + OR valueC.[SignalInterfacePort_ID] IS NULL + ) + AND ewh.[ValidTo] IS NULL +LEFT JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = ewh.[Equipment_ID] +WHERE role.[Name] = N'Status' + AND statusC.[ParentChannel_ID] IS NOT NULL; + +GO +CREATE OR ALTER VIEW [dbo].[vw_DeviceStatus] AS +SELECT + statusC.[Stream_ID] AS StatusChannelID, + e.[Equipment_ID] AS EquipmentID, + e.[Identifier] AS EquipmentName, + o.[Timestamp], + CAST(v.[Value] AS INT) AS StatusCodeID +FROM [dbo].[Value] v +JOIN [dbo].[Observation] o ON o.[Observation_ID] = v.[Observation_ID] +JOIN [dbo].[Channel] statusC ON statusC.[Stream_ID] = o.[Channel_ID] +JOIN [dbo].[ChannelKind] role ON role.[ChannelKind_ID] = statusC.[ChannelKind_ID] +JOIN [dbo].[vw_ChannelResolved] valueC ON valueC.[Stream_ID] = statusC.[ParentChannel_ID] +JOIN [dbo].[EquipmentWiringHistory] ewh + ON ewh.[SignalInterface_ID] = valueC.[SignalInterface_ID] + AND ( + ewh.[SignalInterfacePort_ID] = valueC.[SignalInterfacePort_ID] + OR valueC.[SignalInterfacePort_ID] IS NULL + ) + AND ewh.[ValidTo] IS NULL +JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = ewh.[Equipment_ID] +WHERE role.[Name] = N'Status'; + +GO +CREATE OR ALTER VIEW [dbo].[vw_ChannelLocationAtTime] AS +SELECT + cea.ObservationID, + cea.ChannelID, + cea.Timestamp, + cea.EquipmentID, + elh.[SamplingPoint_ID] AS SamplingPointID, + sp.[SamplingPoint] AS SamplingPointName +FROM [dbo].[vw_ChannelEquipmentAtTime] cea +LEFT JOIN [dbo].[EquipmentLocationHistory] elh ON elh.[Equipment_ID] = cea.EquipmentID + AND elh.[ValidFrom] <= cea.Timestamp + AND (elh.[ValidTo] IS NULL OR elh.[ValidTo] > cea.Timestamp) +LEFT JOIN [dbo].[SamplingPoint] sp ON sp.[SamplingPoint_ID] = elh.[SamplingPoint_ID]; + + +GO +-- Schema version stamp (from schema_dictionary/version.yaml) +INSERT INTO [dbo].[SchemaVersion] ([Version], [Description]) +VALUES (N'2.2.0', N'Consistency hardening, part 2. Adds vw_DeploymentCoherence (F1): surfaces equipment whose active location Site differs from the Site its connected DAS is currently deployed to, so a DAS move no longer silently strands equipment. Builds on 2.1.0 (dropped the denormalised Channel.SignalInterfacePort_ID, resolved via vw_ChannelResolved). Pre-release, fresh install only — no migration provided.'); diff --git a/sql_generation_scripts/v2.2.0_seed_mssql.sql b/sql_generation_scripts/v2.2.0_seed_mssql.sql new file mode 100644 index 0000000..e2cb64c --- /dev/null +++ b/sql_generation_scripts/v2.2.0_seed_mssql.sql @@ -0,0 +1,228 @@ +-- Seed data for schema v2.2.0 +-- Platform: mssql +-- Generated: 2026-06-29 13:05:36 UTC +-- AnnotationKind +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (1, N'Fault', N'Sensor or process fault', N'#FF4444'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (2, N'Maintenance', N'Sensor under maintenance', N'#FFA500'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (3, N'Calibration Period', N'Data during calibration — may be invalid', N'#FFD700'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (4, N'Anomaly', N'Unexpected behavior, needs investigation', N'#FF69B4'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (5, N'Experiment', N'Data collected for a specific experiment', N'#4488FF'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (6, N'Process Event', N'Known process event (storm, dosing, etc.)', N'#44BB44'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (7, N'Data Quality', N'Suspect data quality (drift, fouling)', N'#AA44FF'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (8, N'Note', N'General commentary', N'#888888'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (9, N'Exclusion', N'Data should be excluded from analysis', N'#CC0000'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (10, N'Confirmed', N'Data has been reviewed and accepted as valid', N'#00AA00'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (11, N'Equipment Relocation', N'Equipment was physically moved to a new location', N'#8888FF'); +-- BinKind +INSERT INTO [dbo].[BinKind] ([BinKind_ID], [Name], [Description]) VALUES (1, N'interval', N'Bins defined by lower and upper bounds only'); +INSERT INTO [dbo].[BinKind] ([BinKind_ID], [Name], [Description]) VALUES (2, N'interval_with_nominal', N'Bins defined by bounds plus a nominal center value (e.g., comes from a table with columns showing the mean settling velocity, but the bin in fact collects data between a min and max value (not a point value)).'); +INSERT INTO [dbo].[BinKind] ([BinKind_ID], [Name], [Description]) VALUES (3, N'nominal', N'Bins defined by a single nominal (exact) value only (e.g., absorbance at exactly 200 nm).'); +-- CampaignKind +SET IDENTITY_INSERT [dbo].[CampaignKind] ON; +INSERT INTO [dbo].[CampaignKind] ([CampaignKind_ID], [Name], [Description]) VALUES (1, N'Experiment', N'Planned scientific investigation under controlled or semi-controlled conditions'); +INSERT INTO [dbo].[CampaignKind] ([CampaignKind_ID], [Name], [Description]) VALUES (2, N'Regular operation', N'Routine monitoring or operational run of the monitored process'); +INSERT INTO [dbo].[CampaignKind] ([CampaignKind_ID], [Name], [Description]) VALUES (3, N'Commissioning', N'Initial setup, calibration, and qualification of equipment or a process'); +SET IDENTITY_INSERT [dbo].[CampaignKind] OFF; +-- ChannelKind +INSERT INTO [dbo].[ChannelKind] ([ChannelKind_ID], [Name], [Description]) VALUES (1, N'Value', N'Primary measurement or output value'); +INSERT INTO [dbo].[ChannelKind] ([ChannelKind_ID], [Name], [Description]) VALUES (2, N'Status', N'Device or measurement status flag'); +INSERT INTO [dbo].[ChannelKind] ([ChannelKind_ID], [Name], [Description]) VALUES (3, N'Alarm', N'Alarm or alert indicator'); +INSERT INTO [dbo].[ChannelKind] ([ChannelKind_ID], [Name], [Description]) VALUES (4, N'Uncertainty', N'Measurement uncertainty estimate'); +-- ControlLoopPortKind +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (1, N'MeasuredVariable', N'The controlled or observed process variable'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (2, N'ManipulatedVariable', N'The actuator or output adjusted by the controller'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (3, N'SetPoint', N'Target value supplied to the controller'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (4, N'Disturbance', N'Measured input that affects the process; not manipulated'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (5, N'PredictedOutput', N'Model-predicted value of the controlled variable'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (6, N'Other', N'Escape hatch for novel kinds; describe in ControlLoop.Description'); +-- ControllerKind +SET IDENTITY_INSERT [dbo].[ControllerKind] ON; +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (1, N'PID', N'Proportional-Integral-Derivative controller'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (2, N'Feedforward', N'Open-loop controller that acts on predicted disturbances'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (3, N'MPC', N'Model Predictive Controller using an internal process model'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (4, N'On-Off', N'Bang-bang (on/off) controller with fixed setpoint'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (5, N'Manual', N'Operator-driven manual control with no automated loop'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (6, N'Other', N'Controller type not covered by the other categories'); +SET IDENTITY_INSERT [dbo].[ControllerKind] OFF; +-- DataAcquisitionSystemKind +SET IDENTITY_INSERT [dbo].[DataAcquisitionSystemKind] ON; +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (1, N'SCADA', N'Supervisory Control and Data Acquisition system.'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (2, N'PLC', N'Programmable Logic Controller.'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (3, N'Field monitoring station', N'Deployable measurement station capable of hosting multiple devices and recording their data streams.'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (4, N'IoT Gateway', N'Internet-of-Things gateway aggregating sensor streams.'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (5, N'Manual entry', N'Data entered manually by an operator (spreadsheet, form).'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (6, N'Other', N'System type not covered by the other categories.'); +SET IDENTITY_INSERT [dbo].[DataAcquisitionSystemKind] OFF; +-- DataProvenanceKind +SET IDENTITY_INSERT [dbo].[DataProvenanceKind] ON; +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (1, N'Sensor', N'Value acquired directly from an instrument or sensor in the field'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (2, N'Laboratory', N'Value determined by laboratory chemical or physical analysis'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (3, N'Controller Output', N'Value generated by a control algorithm.'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (4, N'Model Output', N'Value generated by a simulation, model, or prediction algorithm not involved in control.'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (5, N'External Source', N'Value imported from an external dataset or third-party system'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (6, N'Forecast', N'Future-dated value produced by a forecasting model'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (7, N'Derived', N'Value produced by applying a data-processing algorithm to one or more existing channels.'); +SET IDENTITY_INSERT [dbo].[DataProvenanceKind] OFF; +-- EquipmentEventKind +SET IDENTITY_INSERT [dbo].[EquipmentEventKind] ON; +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (1, N'Calibration', N'Adjustment of sensor output to match a known reference standard'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (2, N'Commissioning', N'Formal activation of equipment into operational service'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (3, N'Maintenance', N'Physical cleaning, inspection, or servicing of equipment'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (4, N'Installation', N'First-time mounting or connection of equipment at its deployment site'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (5, N'Removal', N'Decommissioning or retrieval of equipment from its deployment site'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (6, N'Firmware Update', N'Update to the embedded software or firmware of the device'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (7, N'Failure', N'Unplanned malfunction or breakdown requiring corrective action'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (8, N'Repair', N'Corrective action performed following a recorded failure'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (9, N'Decommissioning', N'Formal retirement of equipment from operational service'); +SET IDENTITY_INSERT [dbo].[EquipmentEventKind] OFF; +-- OperationKind +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (1, N'Unprocessed', N'No operations applied — used for the raw channel trait only'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (2, N'OutlierRemoval', N'Spikes and statistical outliers removed or flagged'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (3, N'DriftCorrection', N'Sensor drift or baseline shift corrected'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (4, N'FaultRemoval', N'Instrument faults and implausible values removed'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (5, N'Smoothing', N'Noise reduced by a smoothing or averaging algorithm'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (6, N'Interpolation', N'Missing values filled by interpolation or reconstruction'); +-- ProcedureKind +SET IDENTITY_INSERT [dbo].[ProcedureKind] ON; +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (1, N'Maintenance and Cleaning Protocol', N'Procedures for routine maintenance, cleaning, and upkeep of equipment'); +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (2, N'Calibration Protocol', N'Step-by-step instructions for calibrating instruments or sensors'); +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (3, N'Validation Protocol', N'Procedures for validating measurements, methods, or models'); +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (4, N'Laboratory Method Protocol', N'Standardised laboratory analytical methods (e.g. ISO, ASTM, APHA)'); +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (5, N'Software Manual', N'User or operational manuals for software tools used in data acquisition or processing'); +SET IDENTITY_INSERT [dbo].[ProcedureKind] OFF; +-- ProcessUnitKind +SET IDENTITY_INSERT [dbo].[ProcessUnitKind] ON; +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (1, N'Area', N'Broad spatial zone (e.g. biological treatment area)'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (2, N'Zone', N'Defined functional sub-zone within a process area'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (3, N'Tank', N'Enclosed vessel for liquid storage or treatment'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (4, N'Reactor', N'Vessel designed for controlled biological or chemical reactions'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (5, N'Pipe', N'Conduit transporting liquid between process units'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (6, N'Pump', N'Mechanical device for moving liquid'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (7, N'Valve', N'Flow control device regulating liquid passage'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (8, N'Clarifier', N'Gravity settling vessel separating solids from liquid'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (9, N'Basin', N'Open or partially open liquid containment structure'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (10, N'Blower', N'Mechanical device for supplying air or gas'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (11, N'Other', N'Process unit kind not covered by the standard vocabulary'); +SET IDENTITY_INSERT [dbo].[ProcessUnitKind] OFF; +-- QualityCode +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (1, N'Accepted', N'Measurement meets quality criteria and is fit for use', 1); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (2, N'Suspect', N'Measurement may be unreliable; flagged for manual review', 1); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (3, N'Rejected', N'Measurement is invalid and must not be used', 0); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (4, N'BelowLoD', N'Result is below the method''s limit of detection', 0); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (5, N'AboveLoQ', N'Result exceeds the limit of quantification (instrument saturated)', 0); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (6, N'Outlier', N'Statistical outlier; not automatically invalid but requires review', 1); +-- ReviewStatus +INSERT INTO [dbo].[ReviewStatus] ([ReviewStatus_ID], [Name], [Description]) VALUES (1, N'Pending', N'Measurement recorded but not yet reviewed/approved'); +INSERT INTO [dbo].[ReviewStatus] ([ReviewStatus_ID], [Name], [Description]) VALUES (2, N'Approved', N'Measurement reviewed and approved by a designated reviewer'); +INSERT INTO [dbo].[ReviewStatus] ([ReviewStatus_ID], [Name], [Description]) VALUES (3, N'Rejected', N'Measurement reviewed and rejected'); +-- SampleCollectionKind +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (1, N'Grab', N'Single instantaneous sample collected at one point in time'); +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (2, N'Composite24h', N'Flow- or time-proportional composite over a 24-hour period'); +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (3, N'Composite8h', N'Flow- or time-proportional composite over an 8-hour period'); +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (4, N'Passive', N'Passive sampler deployed over an extended exposure period'); +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (5, N'Other', N'Collection kind not covered by the standard vocabulary'); +-- SampleKind +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (1, N'Field', N'Sample collected from a real-world site or process'); +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (2, N'Synthetic', N'Laboratory-prepared sample with known composition'); +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (3, N'Master Standard', N'Reference standard used to prepare derived standards'); +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (4, N'Derived Standard', N'Dilution or aliquot derived from a master standard'); +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (5, N'Blank', N'Blank sample used to detect contamination or baseline'); +-- SiteKind +SET IDENTITY_INSERT [dbo].[SiteKind] ON; +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (1, N'Municipal Wastewater Treatment Plant', N'Municipal or industrial facility treating wastewater before discharge'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (2, N'Combined Sewer Overflow', N'Point where combined sewer system discharges during high-flow events'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (3, N'River / Stream', N'Natural flowing surface water body'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (4, N'Lake / Reservoir', N'Natural or artificial standing body of water'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (5, N'Groundwater / Well', N'Subsurface water source accessed via a well or borehole'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (6, N'Drinking Water Distribution Network Access Point', N'Monitoring point within a potable water distribution network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (7, N'Canal', N'Artificial waterway for water transport or drainage'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (8, N'Wastewater Pumping Station', N'Facility that pumps wastewater through the collection network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (9, N'Combined Drainage Network Access Point', N'Monitoring point within a combined stormwater and wastewater network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (10, N'Rainwater Drainage Network Access Point', N'Monitoring point within a stormwater-only drainage network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (11, N'Wastewater Drainage Network Access Point', N'Monitoring point within a sanitary sewer network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (12, N'Experimental Wastewater Treatment Plant', N'Small-scale experimental treatment or process facility'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (13, N'Other', N'Site kind not covered by the standard vocabulary'); +SET IDENTITY_INSERT [dbo].[SiteKind] OFF; +-- StreamKind +INSERT INTO [dbo].[StreamKind] ([StreamKind_ID], [Name], [Description]) VALUES (1, N'Sensor', N'A sensor measurement stream (Channel subtype of Stream)'); +INSERT INTO [dbo].[StreamKind] ([StreamKind_ID], [Name], [Description]) VALUES (2, N'Lab', N'A laboratory measurement stream (AnalysisSeries subtype of Stream)'); +-- Unit +SET IDENTITY_INSERT [dbo].[Unit] ON; +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (1, N'mg/L', N'https://qudt.org/vocab/unit/MilliGM-PER-L', N'0,1,-3,0,0,0,0', 0.001, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (2, N'NTU', N'https://qudt.org/vocab/unit/NTU', N'0,0,0,0,0,0,0', NULL, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (3, N'pH units', N'https://qudt.org/vocab/unit/PH', N'0,0,0,0,0,0,0', NULL, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (4, N'°C', N'https://qudt.org/vocab/unit/DEG_C', N'0,0,0,0,1,0,0', 1.0, 273.15); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (5, N'mS/cm', N'https://qudt.org/vocab/unit/MilliS-PER-CentiM', N'-3,-1,3,2,0,0,0', 0.1, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (6, N'nm', N'https://qudt.org/vocab/unit/NanoM', N'1,0,0,0,0,0,0', 1e-09, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (7, N'µm', N'https://qudt.org/vocab/unit/MicroM', N'1,0,0,0,0,0,0', 1e-06, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (8, N'm/s', N'https://qudt.org/vocab/unit/M-PER-SEC', N'1,0,-1,0,0,0,0', 1.0, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (9, N'Status Code', NULL, NULL, NULL, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (10, N'AU', N'https://qudt.org/vocab/unit/ABSORBANCE_UNIT', N'0,0,0,0,0,0,0', NULL, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (11, N'-', N'https://qudt.org/vocab/unit/UNITLESS', N'0,0,0,0,0,0,0', 1.0, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (12, N'm³/h', N'https://qudt.org/vocab/unit/M3-PER-HR', N'3,0,-1,0,0,0,0', 0.000277778, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (13, N'm', N'https://qudt.org/vocab/unit/M', N'1,0,0,0,0,0,0', 1.0, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (14, N'Nm³/h', NULL, N'3,0,-1,0,0,0,0', 0.000277778, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (15, N'%', N'https://qudt.org/vocab/unit/PERCENT', N'0,0,0,0,0,0,0', 0.01, NULL); +SET IDENTITY_INSERT [dbo].[Unit] OFF; +-- ValueKind +SET IDENTITY_INSERT [dbo].[ValueKind] ON; +INSERT INTO [dbo].[ValueKind] ([ValueKind_ID], [Name], [Description]) VALUES (1, N'Scalar', N'A single numeric measurement value (e.g. temperature, concentration)'); +INSERT INTO [dbo].[ValueKind] ([ValueKind_ID], [Name], [Description]) VALUES (2, N'Vector', N'An ordered sequence of numeric values (e.g. particle size distribution)'); +INSERT INTO [dbo].[ValueKind] ([ValueKind_ID], [Name], [Description]) VALUES (3, N'Matrix', N'A two-dimensional array of values (e.g. excitation-emission matrix)'); +INSERT INTO [dbo].[ValueKind] ([ValueKind_ID], [Name], [Description]) VALUES (4, N'Image', N'A raster image stored as a binary file'); +SET IDENTITY_INSERT [dbo].[ValueKind] OFF; +-- Parameter +SET IDENTITY_INSERT [dbo].[Parameter] ON; +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'TSS concentration', 1, N'Total suspended solids', N'http://purl.obolibrary.org/obo/ENVO_01001502', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'COD concentration', 2, N'Chemical oxygen demand', N'http://purl.obolibrary.org/obo/ENVO_01000632', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'pH', 3, N'Hydrogen ion concentration', N'http://purl.obolibrary.org/obo/ENVO_09200019', 1, N'http://qudt.org/vocab/quantitykind/PH'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Temperature', 4, N'Water temperature', N'http://purl.obolibrary.org/obo/ENVO_01001501', 1, N'http://qudt.org/vocab/quantitykind/Temperature'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Conductivity', 5, N'Electrical conductivity', N'http://purl.obolibrary.org/obo/ENVO_09200010', 1, N'http://qudt.org/vocab/quantitykind/ElectricConductivity'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Sensor Status', 6, N'Per-channel operational status code', NULL, 1, NULL); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Device Status', 7, N'Overall equipment health status code', NULL, 1, NULL); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Dissolved oxygen concentration', 8, N'Dissolved oxygen concentration in water', N'http://purl.obolibrary.org/obo/ENVO_01001111', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Turbidity', 9, N'Water turbidity measured by nephelometry', N'http://purl.obolibrary.org/obo/ENVO_01001573', 1, N'http://qudt.org/vocab/quantitykind/Turbidity'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Absorbance spectrum', 10, N'UV-Vis spectral absorbance (per-wavelength vector, unit AU)', NULL, 2, NULL); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Ammonium-N concentration', 11, N'Ammonium nitrogen concentration (NH4-N)', N'http://purl.obolibrary.org/obo/CHEBI_49786', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Nitrate-N concentration', 12, N'Nitrate nitrogen concentration as NO3-N equivalent', N'http://purl.obolibrary.org/obo/CHEBI_17632', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'COD filtered concentration', 13, N'Filtered COD (CODf) — soluble fraction of chemical oxygen demand', NULL, 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Flow', 14, N'Volumetric flow rate', N'http://purl.obolibrary.org/obo/ENVO_01001020', 1, N'http://qudt.org/vocab/quantitykind/VolumeFlowRate'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Level', 15, N'Water level / depth', NULL, 1, N'http://qudt.org/vocab/quantitykind/Length'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'floc_morphology', 16, N'Activated sludge floc morphology image from inline microscope', NULL, 4, NULL); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Potassium concentration', 17, N'Potassium concentration (K)', NULL, 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Light Absorbance', 18, N'Scalar light absorbance measurement', NULL, 1, N'http://qudt.org/vocab/quantitykind/Absorbance'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Nitrite-N concentration', 19, N'Nitrite nitrogen concentration (NO2-N)', NULL, 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'NOx-N concentration', 20, N'Total oxidized nitrogen (NO3-N + NO2-N)', NULL, 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Air flow', 21, N'Volumetric air/gas flow rate', NULL, 1, N'http://qudt.org/vocab/quantitykind/VolumeFlowRate'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Valve position', 22, N'Control valve analog output position (0-100%)', NULL, 1, NULL); +SET IDENTITY_INSERT [dbo].[Parameter] OFF; +-- Procedures +SET IDENTITY_INSERT [dbo].[Procedures] ON; +INSERT INTO [dbo].[Procedures] ([Procedure_ID], [ProcedureName], [Description], [ProcedureLocation]) VALUES (1, N'Grab sampling', N'Manual grab sample collected at water surface', N'/procedures/grab_sampling.pdf'); +INSERT INTO [dbo].[Procedures] ([Procedure_ID], [ProcedureName], [Description], [ProcedureLocation]) VALUES (2, N'24h composite', N'Time-weighted 24-hour composite sample via autosampler', N'/procedures/composite_24h.pdf'); +INSERT INTO [dbo].[Procedures] ([Procedure_ID], [ProcedureName], [Description], [ProcedureLocation]) VALUES (3, N'Online continuous', N'Continuous in-situ measurement with data logging', N'/procedures/online_continuous.pdf'); +SET IDENTITY_INSERT [dbo].[Procedures] OFF; + +-- ParameterHasUnit (generated by ontology_query.py) +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (1, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (2, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (3, 3); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (4, 4); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (5, 5); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (8, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (9, 2); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (10, 10); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (11, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (12, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (13, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (14, 12); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (15, 6); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (15, 7); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (15, 13); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (17, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (18, 10); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (19, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (20, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (21, 12); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (21, 14); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (22, 15); diff --git a/sql_generation_scripts/v2.3.0_create_mssql.sql b/sql_generation_scripts/v2.3.0_create_mssql.sql new file mode 100644 index 0000000..d5cbfb3 --- /dev/null +++ b/sql_generation_scripts/v2.3.0_create_mssql.sql @@ -0,0 +1,1172 @@ +-- Baseline CREATE script for schema v2.3.0 +-- Platform: mssql +-- Generated: 2026-06-29 17:15:56 UTC + +CREATE TABLE [dbo].[AnnotationKind] ( + [AnnotationKind_ID] INT NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(500), + [Color] NVARCHAR(7), + CONSTRAINT [PK_AnnotationKind] PRIMARY KEY ([AnnotationKind_ID]) +); + +CREATE TABLE [dbo].[BinKind] ( + [BinKind_ID] INT NOT NULL, + [Name] NVARCHAR(30) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_BinKind] PRIMARY KEY ([BinKind_ID]) +); + +CREATE TABLE [dbo].[CampaignKind] ( + [CampaignKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_CampaignKind] PRIMARY KEY ([CampaignKind_ID]) +); + +CREATE TABLE [dbo].[ChannelKind] ( + [ChannelKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_ChannelKind] PRIMARY KEY ([ChannelKind_ID]) +); + +CREATE TABLE [dbo].[ControlLoopPortKind] ( + [ControlLoopPortKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_ControlLoopPortKind] PRIMARY KEY ([ControlLoopPortKind_ID]) +); + +CREATE TABLE [dbo].[ControllerKind] ( + [ControllerKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(500), + CONSTRAINT [PK_ControllerKind] PRIMARY KEY ([ControllerKind_ID]) +); + +CREATE TABLE [dbo].[DataAcquisitionSystemKind] ( + [DataAcquisitionSystemKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(500), + CONSTRAINT [PK_DataAcquisitionSystemKind] PRIMARY KEY ([DataAcquisitionSystemKind_ID]) +); + +CREATE TABLE [dbo].[DataProvenanceKind] ( + [DataProvenanceKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_DataProvenanceKind] PRIMARY KEY ([DataProvenanceKind_ID]) +); + +CREATE TABLE [dbo].[EquipmentEventKind] ( + [EquipmentEventKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_EquipmentEventKind] PRIMARY KEY ([EquipmentEventKind_ID]) +); + +CREATE TABLE [dbo].[EquipmentModel] ( + [EquipmentModel_ID] INT IDENTITY(1,1) NOT NULL, + [EquipmentModel] NVARCHAR(100), + [Method] NVARCHAR(100), + [Functions] NVARCHAR(MAX), + [Manufacturer] NVARCHAR(100), + [ManualLocation] NVARCHAR(1000), + CONSTRAINT [PK_EquipmentModel] PRIMARY KEY ([EquipmentModel_ID]) +); + +CREATE TABLE [dbo].[OperationKind] ( + [OperationKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_OperationKind] PRIMARY KEY ([OperationKind_ID]) +); + +CREATE TABLE [dbo].[Person] ( + [Person_ID] INT IDENTITY(1,1) NOT NULL, + [LastName] NVARCHAR(100), + [FirstName] NVARCHAR(255), + [Company] NVARCHAR(MAX), + [Role] NVARCHAR(255), + [AssignedFunctions] NVARCHAR(MAX), + [Email] NVARCHAR(100), + [Phone] NVARCHAR(100), + [Linkedin] NVARCHAR(100), + [Website] NVARCHAR(60), + CONSTRAINT [PK_Person] PRIMARY KEY ([Person_ID]) +); + +CREATE TABLE [dbo].[ProcedureKind] ( + [ProcedureKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_ProcedureKind] PRIMARY KEY ([ProcedureKind_ID]) +); + +CREATE TABLE [dbo].[ProcessUnitKind] ( + [ProcessUnitKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_ProcessUnitKind] PRIMARY KEY ([ProcessUnitKind_ID]), + CONSTRAINT [UQ_ProcessUnitKind_Name] UNIQUE ([Name]) +); + +CREATE TABLE [dbo].[QualityCode] ( + [QualityCode_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + [IsUsable] BIT NOT NULL DEFAULT 1, + CONSTRAINT [PK_QualityCode] PRIMARY KEY ([QualityCode_ID]) +); + +CREATE TABLE [dbo].[ReviewStatus] ( + [ReviewStatus_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_ReviewStatus] PRIMARY KEY ([ReviewStatus_ID]) +); + +CREATE TABLE [dbo].[SampleCollectionKind] ( + [SampleCollectionKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_SampleCollectionKind] PRIMARY KEY ([SampleCollectionKind_ID]) +); + +CREATE TABLE [dbo].[SampleKind] ( + [SampleKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_SampleKind] PRIMARY KEY ([SampleKind_ID]) +); + +CREATE TABLE [dbo].[SchemaVersion] ( + [VersionID] INT IDENTITY(1,1) NOT NULL, + [Version] NVARCHAR(20) NOT NULL, + [AppliedDateTime] DATETIME2(7) NOT NULL DEFAULT CURRENT_TIMESTAMP, + [Description] NVARCHAR(500), + [MigrationScript] NVARCHAR(200), + CONSTRAINT [PK_SchemaVersion] PRIMARY KEY ([VersionID]) +); + +CREATE TABLE [dbo].[SiteKind] ( + [SiteKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_SiteKind] PRIMARY KEY ([SiteKind_ID]) +); + +CREATE TABLE [dbo].[StreamKind] ( + [StreamKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_StreamKind] PRIMARY KEY ([StreamKind_ID]) +); + +CREATE TABLE [dbo].[Unit] ( + [Unit_ID] INT IDENTITY(1,1) NOT NULL, + [Unit] NVARCHAR(100), + [QUDT_IRI] NVARCHAR(256), + [UnitVector] NVARCHAR(64), + [SI_Multiplier] FLOAT, + [SI_Offset] FLOAT, + CONSTRAINT [PK_Unit] PRIMARY KEY ([Unit_ID]) +); + +CREATE TABLE [dbo].[UserAccount] ( + [UserAccount_ID] INT IDENTITY(1,1) NOT NULL, + [Email] NVARCHAR(255) NOT NULL, + [FullName] NVARCHAR(255) NOT NULL, + [PasswordHash] NVARCHAR(255) NOT NULL, + [IsActive] BIT NOT NULL DEFAULT 1, + [IsVerified] BIT NOT NULL DEFAULT 1, + [CreatedAt] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + [UpdatedAt] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + CONSTRAINT [PK_UserAccount] PRIMARY KEY ([UserAccount_ID]), + CONSTRAINT [UQ_UserAccount_Email] UNIQUE ([Email]) +); + +CREATE TABLE [dbo].[ValueKind] ( + [ValueKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_ValueKind] PRIMARY KEY ([ValueKind_ID]) +); + +CREATE TABLE [dbo].[AnalysisSeries] ( + [Stream_ID] INT NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Parameter_ID] INT NOT NULL, + [SamplingPoint_ID] INT NOT NULL, + [ValueKind_ID] INT NOT NULL DEFAULT 1, + [Unit_ID] INT NOT NULL, + [Campaign_ID] INT, + [Description] NVARCHAR(MAX), + CONSTRAINT [PK_AnalysisSeries] PRIMARY KEY ([Stream_ID]), + CONSTRAINT [UQ_AnalysisSeries_Identity] UNIQUE ([Parameter_ID], [SamplingPoint_ID], [ValueKind_ID]) +); + +CREATE TABLE [dbo].[AnalysisSeriesAxis] ( + [AnalysisSeries_ID] INT NOT NULL, + [AxisRole] INT NOT NULL, + [ValueBinningAxis_ID] INT NOT NULL, + CONSTRAINT [PK_AnalysisSeriesAxis] PRIMARY KEY ([AnalysisSeries_ID], [AxisRole]), + CONSTRAINT [CK_AnalysisSeriesAxis_AxisRole] CHECK (AxisRole IN (0, 1)) +); + +CREATE TABLE [dbo].[Annotation] ( + [Annotation_ID] INT IDENTITY(1,1) NOT NULL, + [Stream_ID] INT NOT NULL, + [AnnotationKind_ID] INT NOT NULL, + [StartTime] DATETIME2(7) NOT NULL, + [EndTime] DATETIME2(7), + [AuthorPerson_ID] INT, + [Campaign_ID] INT, + [EquipmentEvent_ID] INT, + [Title] NVARCHAR(200), + [Comment] NVARCHAR(MAX), + [CreatedDateTime] DATETIME2(7) NOT NULL DEFAULT CURRENT_TIMESTAMP, + [ModifiedDateTime] DATETIME2(7), + [Observation_ID] INT, + CONSTRAINT [PK_Annotation] PRIMARY KEY ([Annotation_ID]) +); + +CREATE TABLE [dbo].[AuditLog] ( + [AuditLog_ID] BIGINT IDENTITY(1,1) NOT NULL, + [UserAccount_ID] INT, + [Action] NVARCHAR(50) NOT NULL, + [ResourceType] NVARCHAR(100) NOT NULL, + [ResourceID] NVARCHAR(255), + [Details] NVARCHAR(MAX), + [Timestamp] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + CONSTRAINT [PK_AuditLog] PRIMARY KEY ([AuditLog_ID]) +); + +CREATE TABLE [dbo].[Campaign] ( + [Campaign_ID] INT IDENTITY(1,1) NOT NULL, + [CampaignKind_ID] INT NOT NULL, + [Site_ID] INT NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(2000), + [CampaignStartDateTime] DATETIME2(7), + [CampaignEndDateTime] DATETIME2(7), + [ResponsiblePerson_ID] INT, + CONSTRAINT [PK_Campaign] PRIMARY KEY ([Campaign_ID]) +); + +CREATE TABLE [dbo].[CampaignEquipment] ( + [Campaign_ID] INT NOT NULL, + [Equipment_ID] INT NOT NULL, + [Role] NVARCHAR(100), + CONSTRAINT [PK_CampaignEquipment] PRIMARY KEY ([Campaign_ID], [Equipment_ID]) +); + +CREATE TABLE [dbo].[CampaignSamplingLocation] ( + [Campaign_ID] INT NOT NULL, + [SamplingPoint_ID] INT NOT NULL, + [Role] NVARCHAR(100), + CONSTRAINT [PK_CampaignSamplingLocation] PRIMARY KEY ([Campaign_ID], [SamplingPoint_ID]) +); + +CREATE TABLE [dbo].[Channel] ( + [Stream_ID] INT NOT NULL, + [SignalInterface_ID] INT, + [TagName] NVARCHAR(200) NOT NULL, + [ParentChannel_ID] INT, + [ChannelKind_ID] INT NOT NULL DEFAULT 1, + [Parameter_ID] INT, + [DataProvenanceKind_ID] INT, + [ProducedByStep_ID] INT, + [ValueKind_ID] INT NOT NULL DEFAULT 1, + [Unit_ID] INT, + CONSTRAINT [PK_Channel] PRIMARY KEY ([Stream_ID]) +); + +CREATE TABLE [dbo].[ChannelAxis] ( + [Channel_ID] INT NOT NULL, + [AxisRole] INT NOT NULL, + [ValueBinningAxis_ID] INT NOT NULL, + CONSTRAINT [PK_ChannelAxis] PRIMARY KEY ([Channel_ID], [AxisRole]), + CONSTRAINT [CK_MetaDataAxis_AxisRole] CHECK (AxisRole IN (0, 1)) +); + +CREATE TABLE [dbo].[ChannelPortHistory] ( + [ChannelPortHistory_ID] INT IDENTITY(1,1) NOT NULL, + [Channel_ID] INT NOT NULL, + [SignalInterfacePort_ID] INT, + [ValidFrom] DATETIME2(7) NOT NULL, + [ValidTo] DATETIME2(7), + [GatingNote] NVARCHAR(MAX), + CONSTRAINT [PK_ChannelPortHistory] PRIMARY KEY ([ChannelPortHistory_ID]) +); + +CREATE TABLE [dbo].[ChannelTrait] ( + [Stream_ID] INT NOT NULL, + [OperationKind_ID] INT NOT NULL, + CONSTRAINT [PK_ChannelTrait] PRIMARY KEY ([Stream_ID], [OperationKind_ID]) +); + +CREATE TABLE [dbo].[ControlLoop] ( + [ControlLoop_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [ControllerKind_ID] INT NOT NULL, + [FallbackControlLoop_ID] INT, + [AlgorithmReference] NVARCHAR(500), + [Description] NVARCHAR(MAX), + CONSTRAINT [PK_ControlLoop] PRIMARY KEY ([ControlLoop_ID]) +); + +CREATE TABLE [dbo].[ControlLoopApplication] ( + [ControlLoopApplication_ID] INT IDENTITY(1,1) NOT NULL, + [ControlLoop_ID] INT NOT NULL, + [StartTime] DATETIME2(7) NOT NULL, + [EndTime] DATETIME2(7), + [Parameters] NVARCHAR(MAX), + [AppliedByPerson_ID] INT, + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_ControlLoopApplication] PRIMARY KEY ([ControlLoopApplication_ID]) +); + +CREATE TABLE [dbo].[ControlLoopPort] ( + [ControlLoopPort_ID] INT IDENTITY(1,1) NOT NULL, + [ControlLoop_ID] INT NOT NULL, + [Channel_ID] INT NOT NULL, + [ControlLoopPortKind_ID] INT NOT NULL, + CONSTRAINT [PK_ControlLoopPort] PRIMARY KEY ([ControlLoopPort_ID]) +); + +CREATE TABLE [dbo].[DASLocationHistory] ( + [DASLocationHistory_ID] INT IDENTITY(1,1) NOT NULL, + [DataAcquisitionSystem_ID] INT NOT NULL, + [Site_ID] INT NOT NULL, + [Campaign_ID] INT, + [ValidFrom] DATETIME2(7) NOT NULL, + [ValidTo] DATETIME2(7), + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_DASLocationHistory] PRIMARY KEY ([DASLocationHistory_ID]) +); + +CREATE TABLE [dbo].[DataAcquisitionSystem] ( + [DataAcquisitionSystem_ID] INT IDENTITY(1,1) NOT NULL, + [ParentSystem_ID] INT, + [Name] NVARCHAR(200) NOT NULL, + [DataAcquisitionSystemKind_ID] INT, + [Manufacturer] NVARCHAR(100), + [Model] NVARCHAR(100), + [Description] NVARCHAR(MAX), + CONSTRAINT [PK_DataAcquisitionSystem] PRIMARY KEY ([DataAcquisitionSystem_ID]) +); + +CREATE TABLE [dbo].[Dataset] ( + [Dataset_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(2000), + [Purpose] NVARCHAR(500), + [CreatedOn] DATETIME2(7) NOT NULL DEFAULT CURRENT_TIMESTAMP, + [CreatedByPerson_ID] INT, + CONSTRAINT [PK_Dataset] PRIMARY KEY ([Dataset_ID]) +); + +CREATE TABLE [dbo].[DatasetChannel] ( + [Dataset_ID] INT NOT NULL, + [Channel_ID] INT NOT NULL, + CONSTRAINT [PK_DatasetChannel] PRIMARY KEY ([Dataset_ID], [Channel_ID]) +); + +CREATE TABLE [dbo].[Equipment] ( + [Equipment_ID] INT IDENTITY(1,1) NOT NULL, + [EquipmentModel_ID] INT, + [Identifier] NVARCHAR(100), + [SerialNumber] NVARCHAR(100), + [Owner] NVARCHAR(MAX), + [StorageLocation] NVARCHAR(100), + [PurchaseDate] DATE, + [IsActive] BIT NOT NULL DEFAULT 1, + CONSTRAINT [PK_Equipment] PRIMARY KEY ([Equipment_ID]) +); + +CREATE TABLE [dbo].[EquipmentEvent] ( + [EquipmentEvent_ID] INT IDENTITY(1,1) NOT NULL, + [Equipment_ID] INT NOT NULL, + [EquipmentEventKind_ID] INT NOT NULL, + [EventDateTimeStart] DATETIME2(7) NOT NULL, + [IsInstantaneous] BIT NOT NULL DEFAULT 0, + [EventDateTimeEnd] DATETIME2(7), + [PerformedByPerson_ID] INT, + [RecordedByPerson_ID] INT, + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_EquipmentEvent] PRIMARY KEY ([EquipmentEvent_ID]) +); + +CREATE TABLE [dbo].[EquipmentLocationHistory] ( + [EquipmentLocationHistory_ID] INT IDENTITY(1,1) NOT NULL, + [Equipment_ID] INT NOT NULL, + [SamplingPoint_ID] INT NOT NULL, + [ValidFrom] DATETIME2(7) NOT NULL, + [ValidTo] DATETIME2(7), + [Campaign_ID] INT, + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_EquipmentLocationHistory] PRIMARY KEY ([EquipmentLocationHistory_ID]) +); + +CREATE TABLE [dbo].[EquipmentModelHasParameter] ( + [EquipmentModel_ID] INT NOT NULL, + [Parameter_ID] INT NOT NULL, + CONSTRAINT [PK_EquipmentModelHasParameter] PRIMARY KEY ([EquipmentModel_ID], [Parameter_ID]) +); + +CREATE TABLE [dbo].[EquipmentModelHasProcedures] ( + [EquipmentModel_ID] INT NOT NULL, + [Procedure_ID] INT NOT NULL, + CONSTRAINT [PK_EquipmentModelHasProcedures] PRIMARY KEY ([EquipmentModel_ID], [Procedure_ID]) +); + +CREATE TABLE [dbo].[EquipmentWiringHistory] ( + [EquipmentWiringHistory_ID] INT IDENTITY(1,1) NOT NULL, + [Equipment_ID] INT NOT NULL, + [SignalInterface_ID] INT NOT NULL, + [SignalInterfacePort_ID] INT, + [ValidFrom] DATETIME2(7) NOT NULL, + [ValidTo] DATETIME2(7), + [Note] NVARCHAR(MAX), + CONSTRAINT [PK_EquipmentWiringHistory] PRIMARY KEY ([EquipmentWiringHistory_ID]) +); + +CREATE TABLE [dbo].[HydrologicalCharacteristics] ( + [Watershed_ID] INT NOT NULL, + [UrbanArea] REAL, + [Forest] REAL, + [Wetlands] REAL, + [Cropland] REAL, + [Meadow] REAL, + [Grassland] REAL, + CONSTRAINT [PK_HydrologicalCharacteristics] PRIMARY KEY ([Watershed_ID]) +); + +CREATE TABLE [dbo].[LabAnalysis] ( + [LabAnalysis_ID] INT IDENTITY(1,1) NOT NULL, + [LabExperiment_ID] INT NOT NULL, + [AnalysisSeries_ID] INT NOT NULL, + [Sample_ID] INT NOT NULL, + [Replicate] INT NOT NULL DEFAULT 1, + [QualityCode_ID] INT, + [ReviewStatus_ID] INT NOT NULL DEFAULT 1, + [ReviewedByPerson_ID] INT, + [ReviewDateTime] DATETIME2(7), + [Laboratory_ID] INT, + [AnalystPerson_ID] INT, + [Procedure_ID] INT, + [AnalysisDateTime] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_LabAnalysis] PRIMARY KEY ([LabAnalysis_ID]), + CONSTRAINT [UQ_LabAnalysis_Identity] UNIQUE ([LabExperiment_ID], [AnalysisSeries_ID], [Sample_ID], [Replicate]) +); + +CREATE TABLE [dbo].[LabExperiment] ( + [LabExperiment_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Campaign_ID] INT, + [ExperimentDateTime] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + [Description] NVARCHAR(MAX), + [CreatedByPerson_ID] INT, + [LabPanel_ID] INT, + CONSTRAINT [PK_LabExperiment] PRIMARY KEY ([LabExperiment_ID]) +); + +CREATE TABLE [dbo].[LabPanel] ( + [LabPanel_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(MAX), + [CreatedByPerson_ID] INT, + [DefaultSampleCollectionKind_ID] INT, + [DefaultSampleEquipment_ID] INT, + [CreatedAt] DATETIME2(7) NOT NULL DEFAULT GETUTCDATE(), + CONSTRAINT [PK_LabPanel] PRIMARY KEY ([LabPanel_ID]) +); + +CREATE TABLE [dbo].[LabPanelSeries] ( + [LabPanel_ID] INT NOT NULL, + [AnalysisSeries_ID] INT NOT NULL, + CONSTRAINT [PK_LabPanelSeries] PRIMARY KEY ([LabPanel_ID], [AnalysisSeries_ID]) +); + +CREATE TABLE [dbo].[Laboratory] ( + [Laboratory_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Site_ID] INT, + [Description] NVARCHAR(500), + CONSTRAINT [PK_Laboratory] PRIMARY KEY ([Laboratory_ID]) +); + +CREATE TABLE [dbo].[LandUse] ( + [Watershed_ID] INT NOT NULL, + [Commercial] REAL, + [GreenSpaces] REAL, + [Industrial] REAL, + [Institutional] REAL, + [Residential] REAL, + [Agricultural] REAL, + [Recreational] REAL, + CONSTRAINT [PK_LandUse] PRIMARY KEY ([Watershed_ID]) +); + +CREATE TABLE [dbo].[Observation] ( + [Observation_ID] INT IDENTITY(1,1) NOT NULL, + [Channel_ID] INT, + [LabAnalysis_ID] INT, + [Timestamp] DATETIME2(7) NOT NULL, + [ValueKind_ID] INT NOT NULL, + CONSTRAINT [PK_Observation] PRIMARY KEY ([Observation_ID]), + CONSTRAINT [CK_Observation_Source] CHECK ((Channel_ID IS NOT NULL AND LabAnalysis_ID IS NULL) OR (Channel_ID IS NULL AND LabAnalysis_ID IS NOT NULL)) +); + +CREATE TABLE [dbo].[Parameter] ( + [Parameter_ID] INT IDENTITY(1,1) NOT NULL, + [Parameter] NVARCHAR(100), + [Description] NVARCHAR(MAX), + [ENVO_IRI] NVARCHAR(256), + [ValueKind_ID] INT NOT NULL DEFAULT 1, + [QUDT_QuantityKind_IRI] NVARCHAR(256), + CONSTRAINT [PK_Parameter] PRIMARY KEY ([Parameter_ID]) +); + +CREATE TABLE [dbo].[ParameterHasUnit] ( + [Parameter_ID] INT NOT NULL, + [Unit_ID] INT NOT NULL, + CONSTRAINT [PK_ParameterHasUnit] PRIMARY KEY ([Parameter_ID], [Unit_ID]) +); + +CREATE TABLE [dbo].[Procedures] ( + [Procedure_ID] INT IDENTITY(1,1) NOT NULL, + [ProcedureName] NVARCHAR(100), + [ProcedureKind_ID] INT, + [Description] NVARCHAR(MAX), + [ProcedureLocation] NVARCHAR(100), + CONSTRAINT [PK_Procedures] PRIMARY KEY ([Procedure_ID]) +); + +CREATE TABLE [dbo].[ProcessUnit] ( + [ProcessUnit_ID] INT IDENTITY(1,1) NOT NULL, + [Site_ID] INT NOT NULL, + [Tag] NVARCHAR(100) NOT NULL, + [Name] NVARCHAR(255) NOT NULL, + [Description] NVARCHAR(MAX), + [ProcessUnitKind_ID] INT, + [Parent_ID] INT, + CONSTRAINT [PK_ProcessUnit] PRIMARY KEY ([ProcessUnit_ID]), + CONSTRAINT [UQ_ProcessUnit_SiteTag] UNIQUE ([Site_ID], [Tag]) +); + +CREATE TABLE [dbo].[ProcessingLineage] ( + [ProcessingLineage_ID] INT IDENTITY(1,1) NOT NULL, + [ProcessingStep_ID] INT NOT NULL, + [Stream_ID] INT NOT NULL, + [StartTime] DATETIME2(7), + [EndTime] DATETIME2(7), + CONSTRAINT [PK_ProcessingLineage] PRIMARY KEY ([ProcessingLineage_ID]) +); + +CREATE TABLE [dbo].[ProcessingStep] ( + [ProcessingStep_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(MAX), + [MethodName] NVARCHAR(200), + [MethodVersion] NVARCHAR(100), + [OperationKind_ID] INT, + [MethodParameters] NVARCHAR(MAX), + [ExecutedDateTime] DATETIME2(7), + [ExecutedByPerson_ID] INT, + [Dataset_ID] INT, + CONSTRAINT [PK_ProcessingStep] PRIMARY KEY ([ProcessingStep_ID]) +); + +CREATE TABLE [dbo].[Sample] ( + [Sample_ID] INT IDENTITY(1,1) NOT NULL, + [ParentSample_ID] INT, + [SampleKind_ID] INT, + [SamplingPoint_ID] INT NOT NULL, + [SampledByPerson_ID] INT, + [Campaign_ID] INT, + [SampleDateTimeStart] DATETIME2(7) NOT NULL, + [SampleDateTimeEnd] DATETIME2(7), + [SampleCollectionKind_ID] INT, + [SampleEquipment_ID] INT, + [Description] NVARCHAR(500), + CONSTRAINT [PK_Sample] PRIMARY KEY ([Sample_ID]) +); + +CREATE TABLE [dbo].[SamplingPoint] ( + [SamplingPoint_ID] INT IDENTITY(1,1) NOT NULL, + [Site_ID] INT NOT NULL, + [SamplingPoint] NVARCHAR(100) NOT NULL, + [LatitudeWGS84] FLOAT, + [LongitudeWGS84] FLOAT, + [Description] NVARCHAR(MAX), + [PicturePath] NVARCHAR(500), + [ValidFrom] DATETIME2(7), + [ValidTo] DATETIME2(7), + [ProcessUnit_ID] INT, + [CreatedByCampaign_ID] INT, + CONSTRAINT [PK_SamplingPoint] PRIMARY KEY ([SamplingPoint_ID]) +); + +CREATE TABLE [dbo].[SignalInterface] ( + [SignalInterface_ID] INT IDENTITY(1,1) NOT NULL, + [DataAcquisitionSystem_ID] INT NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Manufacturer] NVARCHAR(100), + [Model] NVARCHAR(100), + [SerialNumber] NVARCHAR(100), + [Description] NVARCHAR(MAX), + [IsActive] BIT NOT NULL DEFAULT 1, + CONSTRAINT [PK_SignalInterface] PRIMARY KEY ([SignalInterface_ID]) +); + +CREATE TABLE [dbo].[SignalInterfacePort] ( + [SignalInterfacePort_ID] INT IDENTITY(1,1) NOT NULL, + [SignalInterface_ID] INT NOT NULL, + [PortIdentifier] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(MAX), + [IsActive] BIT NOT NULL DEFAULT 1, + CONSTRAINT [PK_SignalInterfacePort] PRIMARY KEY ([SignalInterfacePort_ID]) +); + +CREATE TABLE [dbo].[Site] ( + [Site_ID] INT IDENTITY(1,1) NOT NULL, + [Watershed_ID] INT, + [Name] NVARCHAR(100), + [SiteKind_ID] INT, + [Description] NVARCHAR(MAX), + [LatitudeWGS84] FLOAT, + [LongitudeWGS84] FLOAT, + [StreetNumber] NVARCHAR(100), + [StreetName] NVARCHAR(100), + [City] NVARCHAR(255), + [PostCode] NVARCHAR(100), + [Province] NVARCHAR(255), + [Country] NVARCHAR(255), + CONSTRAINT [PK_Site] PRIMARY KEY ([Site_ID]) +); + +CREATE TABLE [dbo].[Stream] ( + [Stream_ID] INT IDENTITY(1,1) NOT NULL, + [StreamKind_ID] INT NOT NULL, + CONSTRAINT [PK_Stream] PRIMARY KEY ([Stream_ID]) +); + +CREATE TABLE [dbo].[Value] ( + [Observation_ID] INT NOT NULL, + [Value] FLOAT, + [QualityCode] INT, + CONSTRAINT [PK_Value] PRIMARY KEY ([Observation_ID]) +); + +CREATE TABLE [dbo].[ValueBin] ( + [ValueBin_ID] INT IDENTITY(1,1) NOT NULL, + [ValueBinningAxis_ID] INT NOT NULL, + [BinIndex] INT NOT NULL, + [LowerBound] FLOAT, + [UpperBound] FLOAT, + [NominalValue] FLOAT, + CONSTRAINT [PK_ValueBin] PRIMARY KEY ([ValueBin_ID]), + CONSTRAINT [UQ_ValueBin_AxisIndex] UNIQUE ([ValueBinningAxis_ID], [BinIndex]), + CONSTRAINT [CK_ValueBin_BinValues] CHECK (((LowerBound IS NULL AND UpperBound IS NULL) OR (LowerBound IS NOT NULL AND UpperBound IS NOT NULL)) AND (LowerBound IS NULL OR UpperBound > LowerBound) AND (NominalValue IS NOT NULL OR LowerBound IS NOT NULL) +) +); + +CREATE TABLE [dbo].[ValueBinningAxis] ( + [ValueBinningAxis_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(500), + [NumberOfBins] INT NOT NULL, + [Unit_ID] INT NOT NULL, + [BinKind_ID] INT NOT NULL DEFAULT 1, + CONSTRAINT [PK_ValueBinningAxis] PRIMARY KEY ([ValueBinningAxis_ID]) +); + +CREATE TABLE [dbo].[ValueImage] ( + [Observation_ID] INT NOT NULL, + [ImageWidth] INT NOT NULL, + [ImageHeight] INT NOT NULL, + [NumberOfChannels] INT NOT NULL DEFAULT 3, + [ImageFormat] NVARCHAR(20) NOT NULL, + [FileSizeBytes] BIGINT, + [StorageBackend] NVARCHAR(50) NOT NULL DEFAULT 'FileSystem', + [StoragePath] NVARCHAR(1000) NOT NULL, + [Thumbnail] VARBINARY(MAX), + [QualityCode] INT, + CONSTRAINT [PK_ValueImage] PRIMARY KEY ([Observation_ID]) +); + +CREATE TABLE [dbo].[ValueMatrix] ( + [Observation_ID] INT NOT NULL, + [RowValueBin_ID] INT NOT NULL, + [ColValueBin_ID] INT NOT NULL, + [Value] FLOAT, + [QualityCode] INT, + CONSTRAINT [PK_ValueMatrix] PRIMARY KEY ([Observation_ID], [RowValueBin_ID], [ColValueBin_ID]) +); + +CREATE TABLE [dbo].[ValueVector] ( + [Observation_ID] INT NOT NULL, + [ValueBin_ID] INT NOT NULL, + [Value] FLOAT, + [QualityCode] INT, + CONSTRAINT [PK_ValueVector] PRIMARY KEY ([Observation_ID], [ValueBin_ID]) +); + +CREATE TABLE [dbo].[Watershed] ( + [Watershed_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100), + [Description] NVARCHAR(MAX), + [SurfaceArea] REAL, + [ConcentrationTime] INT, + [ImperviousSurface] REAL, + [ParentWatershed_ID] INT, + [GeometryGeoJSON] NVARCHAR(MAX), + CONSTRAINT [PK_Watershed] PRIMARY KEY ([Watershed_ID]) +); + + + + + + + + + + + + + + + + + + + + + + + +CREATE INDEX [IX_UserAccount_Email] ON [dbo].[UserAccount] ([Email]); + + + + +CREATE INDEX [IX_Annotation_Stream_Time] ON [dbo].[Annotation] ([Stream_ID], [StartTime], [EndTime]); +CREATE INDEX [IX_Annotation_Author] ON [dbo].[Annotation] ([AuthorPerson_ID], [CreatedDateTime]); + +CREATE INDEX [IX_AuditLog_UserAccount_ID] ON [dbo].[AuditLog] ([UserAccount_ID]); +CREATE INDEX [IX_AuditLog_Timestamp] ON [dbo].[AuditLog] ([Timestamp]); +CREATE INDEX [IX_AuditLog_ResourceType] ON [dbo].[AuditLog] ([ResourceType]); + + + + +CREATE UNIQUE INDEX [UQ_Channel_SignalStream] ON [dbo].[Channel] ([SignalInterface_ID], [TagName], [Parameter_ID], [DataProvenanceKind_ID], [ProducedByStep_ID]); +CREATE INDEX [IX_Channel_ParentChannel] ON [dbo].[Channel] ([ParentChannel_ID]); + + +CREATE UNIQUE INDEX [UQ_ChannelPortHistory_ActiveRow] ON [dbo].[ChannelPortHistory] ([Channel_ID]) WHERE [ValidTo] IS NULL; +CREATE INDEX [IX_ChannelPortHistory_Port] ON [dbo].[ChannelPortHistory] ([SignalInterfacePort_ID], [ValidFrom]); + +CREATE INDEX [IX_ChannelTrait_Stream] ON [dbo].[ChannelTrait] ([Stream_ID]); + + +CREATE UNIQUE INDEX [UQ_ControlLoopApplication_ActiveRow] ON [dbo].[ControlLoopApplication] ([ControlLoop_ID]) WHERE [EndTime] IS NULL; + +CREATE UNIQUE INDEX [UQ_ControlLoopPort_LoopChannel] ON [dbo].[ControlLoopPort] ([ControlLoop_ID], [Channel_ID]); + +CREATE UNIQUE INDEX [UQ_DASLocationHistory_ActivePerDAS] ON [dbo].[DASLocationHistory] ([DataAcquisitionSystem_ID]) WHERE [ValidTo] IS NULL; +CREATE INDEX [IX_DASLocationHistory_Site_ValidFrom] ON [dbo].[DASLocationHistory] ([Site_ID], [ValidFrom]); + + + + + +CREATE INDEX [IX_EquipmentEvent_Equipment_Start] ON [dbo].[EquipmentEvent] ([Equipment_ID], [EventDateTimeStart]); + +CREATE UNIQUE INDEX [UQ_EquipmentLocationHistory_ActiveRow] ON [dbo].[EquipmentLocationHistory] ([Equipment_ID]) WHERE [ValidTo] IS NULL; +CREATE INDEX [IX_EquipmentLocationHistory_SamplingPoint] ON [dbo].[EquipmentLocationHistory] ([SamplingPoint_ID], [ValidFrom]); + + + +CREATE UNIQUE INDEX [UQ_EquipmentWiringHistory_ActiveRow] ON [dbo].[EquipmentWiringHistory] ([Equipment_ID]) WHERE [ValidTo] IS NULL; +CREATE INDEX [IX_EquipmentWiringHistory_Interface] ON [dbo].[EquipmentWiringHistory] ([SignalInterface_ID], [ValidFrom]); + + + + + + + + +CREATE UNIQUE INDEX [UQ_Obs_Channel] ON [dbo].[Observation] ([Channel_ID], [Timestamp], [ValueKind_ID]) WHERE Channel_ID IS NOT NULL; +CREATE UNIQUE INDEX [UQ_Obs_Lab] ON [dbo].[Observation] ([LabAnalysis_ID]) WHERE LabAnalysis_ID IS NOT NULL; + + + + + +CREATE INDEX [IX_ProcessingLineage_Stream] ON [dbo].[ProcessingLineage] ([Stream_ID]); +CREATE INDEX [IX_Lineage_Step] ON [dbo].[ProcessingLineage] ([ProcessingStep_ID]); + + + + +CREATE UNIQUE INDEX [UQ_SignalInterface_DAS_Name] ON [dbo].[SignalInterface] ([DataAcquisitionSystem_ID], [Name]); + +CREATE UNIQUE INDEX [UQ_SignalInterfacePort_Interface_PortId] ON [dbo].[SignalInterfacePort] ([SignalInterface_ID], [PortIdentifier]); + + + + + + + + + + +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_Parameter_ID] FOREIGN KEY ([Parameter_ID]) REFERENCES [dbo].[Parameter] ([Parameter_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_SamplingPoint_ID] FOREIGN KEY ([SamplingPoint_ID]) REFERENCES [dbo].[SamplingPoint] ([SamplingPoint_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_ValueKind_ID] FOREIGN KEY ([ValueKind_ID]) REFERENCES [dbo].[ValueKind] ([ValueKind_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_Unit_ID] FOREIGN KEY ([Unit_ID]) REFERENCES [dbo].[Unit] ([Unit_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[AnalysisSeriesAxis] ADD CONSTRAINT [FK_AnalysisSeriesAxis_AnalysisSeries_ID] FOREIGN KEY ([AnalysisSeries_ID]) REFERENCES [dbo].[AnalysisSeries] ([Stream_ID]); +ALTER TABLE [dbo].[AnalysisSeriesAxis] ADD CONSTRAINT [FK_AnalysisSeriesAxis_ValueBinningAxis_ID] FOREIGN KEY ([ValueBinningAxis_ID]) REFERENCES [dbo].[ValueBinningAxis] ([ValueBinningAxis_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_AnnotationKind_ID] FOREIGN KEY ([AnnotationKind_ID]) REFERENCES [dbo].[AnnotationKind] ([AnnotationKind_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_AuthorPerson_ID] FOREIGN KEY ([AuthorPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_EquipmentEvent_ID] FOREIGN KEY ([EquipmentEvent_ID]) REFERENCES [dbo].[EquipmentEvent] ([EquipmentEvent_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[AuditLog] ADD CONSTRAINT [FK_AuditLog_UserAccount_ID] FOREIGN KEY ([UserAccount_ID]) REFERENCES [dbo].[UserAccount] ([UserAccount_ID]); +ALTER TABLE [dbo].[Campaign] ADD CONSTRAINT [FK_Campaign_CampaignKind_ID] FOREIGN KEY ([CampaignKind_ID]) REFERENCES [dbo].[CampaignKind] ([CampaignKind_ID]); +ALTER TABLE [dbo].[Campaign] ADD CONSTRAINT [FK_Campaign_Site_ID] FOREIGN KEY ([Site_ID]) REFERENCES [dbo].[Site] ([Site_ID]); +ALTER TABLE [dbo].[Campaign] ADD CONSTRAINT [FK_Campaign_ResponsiblePerson_ID] FOREIGN KEY ([ResponsiblePerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[CampaignEquipment] ADD CONSTRAINT [FK_CampaignEquipment_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[CampaignEquipment] ADD CONSTRAINT [FK_CampaignEquipment_Equipment_ID] FOREIGN KEY ([Equipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[CampaignSamplingLocation] ADD CONSTRAINT [FK_CampaignSamplingLocation_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[CampaignSamplingLocation] ADD CONSTRAINT [FK_CampaignSamplingLocation_SamplingPoint_ID] FOREIGN KEY ([SamplingPoint_ID]) REFERENCES [dbo].[SamplingPoint] ([SamplingPoint_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_SignalInterface_ID] FOREIGN KEY ([SignalInterface_ID]) REFERENCES [dbo].[SignalInterface] ([SignalInterface_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_ParentChannel_ID] FOREIGN KEY ([ParentChannel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_ChannelKind_ID] FOREIGN KEY ([ChannelKind_ID]) REFERENCES [dbo].[ChannelKind] ([ChannelKind_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_Parameter_ID] FOREIGN KEY ([Parameter_ID]) REFERENCES [dbo].[Parameter] ([Parameter_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_DataProvenanceKind_ID] FOREIGN KEY ([DataProvenanceKind_ID]) REFERENCES [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_ProducedByStep_ID] FOREIGN KEY ([ProducedByStep_ID]) REFERENCES [dbo].[ProcessingStep] ([ProcessingStep_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_ValueKind_ID] FOREIGN KEY ([ValueKind_ID]) REFERENCES [dbo].[ValueKind] ([ValueKind_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_Unit_ID] FOREIGN KEY ([Unit_ID]) REFERENCES [dbo].[Unit] ([Unit_ID]); +ALTER TABLE [dbo].[ChannelAxis] ADD CONSTRAINT [FK_ChannelAxis_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[ChannelAxis] ADD CONSTRAINT [FK_ChannelAxis_ValueBinningAxis_ID] FOREIGN KEY ([ValueBinningAxis_ID]) REFERENCES [dbo].[ValueBinningAxis] ([ValueBinningAxis_ID]); +ALTER TABLE [dbo].[ChannelPortHistory] ADD CONSTRAINT [FK_ChannelPortHistory_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[ChannelPortHistory] ADD CONSTRAINT [FK_ChannelPortHistory_SignalInterfacePort_ID] FOREIGN KEY ([SignalInterfacePort_ID]) REFERENCES [dbo].[SignalInterfacePort] ([SignalInterfacePort_ID]); +ALTER TABLE [dbo].[ChannelTrait] ADD CONSTRAINT [FK_ChannelTrait_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[ChannelTrait] ADD CONSTRAINT [FK_ChannelTrait_OperationKind_ID] FOREIGN KEY ([OperationKind_ID]) REFERENCES [dbo].[OperationKind] ([OperationKind_ID]); +ALTER TABLE [dbo].[ControlLoop] ADD CONSTRAINT [FK_ControlLoop_ControllerKind_ID] FOREIGN KEY ([ControllerKind_ID]) REFERENCES [dbo].[ControllerKind] ([ControllerKind_ID]); +ALTER TABLE [dbo].[ControlLoop] ADD CONSTRAINT [FK_ControlLoop_FallbackControlLoop_ID] FOREIGN KEY ([FallbackControlLoop_ID]) REFERENCES [dbo].[ControlLoop] ([ControlLoop_ID]); +ALTER TABLE [dbo].[ControlLoopApplication] ADD CONSTRAINT [FK_ControlLoopApplication_ControlLoop_ID] FOREIGN KEY ([ControlLoop_ID]) REFERENCES [dbo].[ControlLoop] ([ControlLoop_ID]); +ALTER TABLE [dbo].[ControlLoopApplication] ADD CONSTRAINT [FK_ControlLoopApplication_AppliedByPerson_ID] FOREIGN KEY ([AppliedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[ControlLoopPort] ADD CONSTRAINT [FK_ControlLoopPort_ControlLoop_ID] FOREIGN KEY ([ControlLoop_ID]) REFERENCES [dbo].[ControlLoop] ([ControlLoop_ID]); +ALTER TABLE [dbo].[ControlLoopPort] ADD CONSTRAINT [FK_ControlLoopPort_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[ControlLoopPort] ADD CONSTRAINT [FK_ControlLoopPort_ControlLoopPortKind_ID] FOREIGN KEY ([ControlLoopPortKind_ID]) REFERENCES [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID]); +ALTER TABLE [dbo].[DASLocationHistory] ADD CONSTRAINT [FK_DASLocationHistory_DataAcquisitionSystem_ID] FOREIGN KEY ([DataAcquisitionSystem_ID]) REFERENCES [dbo].[DataAcquisitionSystem] ([DataAcquisitionSystem_ID]); +ALTER TABLE [dbo].[DASLocationHistory] ADD CONSTRAINT [FK_DASLocationHistory_Site_ID] FOREIGN KEY ([Site_ID]) REFERENCES [dbo].[Site] ([Site_ID]); +ALTER TABLE [dbo].[DASLocationHistory] ADD CONSTRAINT [FK_DASLocationHistory_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[DataAcquisitionSystem] ADD CONSTRAINT [FK_DataAcquisitionSystem_ParentSystem_ID] FOREIGN KEY ([ParentSystem_ID]) REFERENCES [dbo].[DataAcquisitionSystem] ([DataAcquisitionSystem_ID]); +ALTER TABLE [dbo].[DataAcquisitionSystem] ADD CONSTRAINT [FK_DataAcquisitionSystem_DataAcquisitionSystemKind_ID] FOREIGN KEY ([DataAcquisitionSystemKind_ID]) REFERENCES [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID]); +ALTER TABLE [dbo].[Dataset] ADD CONSTRAINT [FK_Dataset_CreatedByPerson_ID] FOREIGN KEY ([CreatedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[DatasetChannel] ADD CONSTRAINT [FK_DatasetChannel_Dataset_ID] FOREIGN KEY ([Dataset_ID]) REFERENCES [dbo].[Dataset] ([Dataset_ID]); +ALTER TABLE [dbo].[DatasetChannel] ADD CONSTRAINT [FK_DatasetChannel_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[Equipment] ADD CONSTRAINT [FK_Equipment_EquipmentModel_ID] FOREIGN KEY ([EquipmentModel_ID]) REFERENCES [dbo].[EquipmentModel] ([EquipmentModel_ID]); +ALTER TABLE [dbo].[EquipmentEvent] ADD CONSTRAINT [FK_EquipmentEvent_Equipment_ID] FOREIGN KEY ([Equipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[EquipmentEvent] ADD CONSTRAINT [FK_EquipmentEvent_EquipmentEventKind_ID] FOREIGN KEY ([EquipmentEventKind_ID]) REFERENCES [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID]); +ALTER TABLE [dbo].[EquipmentEvent] ADD CONSTRAINT [FK_EquipmentEvent_PerformedByPerson_ID] FOREIGN KEY ([PerformedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[EquipmentEvent] ADD CONSTRAINT [FK_EquipmentEvent_RecordedByPerson_ID] FOREIGN KEY ([RecordedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[EquipmentLocationHistory] ADD CONSTRAINT [FK_EquipmentLocationHistory_Equipment_ID] FOREIGN KEY ([Equipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[EquipmentLocationHistory] ADD CONSTRAINT [FK_EquipmentLocationHistory_SamplingPoint_ID] FOREIGN KEY ([SamplingPoint_ID]) REFERENCES [dbo].[SamplingPoint] ([SamplingPoint_ID]); +ALTER TABLE [dbo].[EquipmentLocationHistory] ADD CONSTRAINT [FK_EquipmentLocationHistory_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[EquipmentModelHasParameter] ADD CONSTRAINT [FK_EquipmentModelHasParameter_EquipmentModel_ID] FOREIGN KEY ([EquipmentModel_ID]) REFERENCES [dbo].[EquipmentModel] ([EquipmentModel_ID]); +ALTER TABLE [dbo].[EquipmentModelHasParameter] ADD CONSTRAINT [FK_EquipmentModelHasParameter_Parameter_ID] FOREIGN KEY ([Parameter_ID]) REFERENCES [dbo].[Parameter] ([Parameter_ID]); +ALTER TABLE [dbo].[EquipmentModelHasProcedures] ADD CONSTRAINT [FK_EquipmentModelHasProcedures_EquipmentModel_ID] FOREIGN KEY ([EquipmentModel_ID]) REFERENCES [dbo].[EquipmentModel] ([EquipmentModel_ID]); +ALTER TABLE [dbo].[EquipmentModelHasProcedures] ADD CONSTRAINT [FK_EquipmentModelHasProcedures_Procedure_ID] FOREIGN KEY ([Procedure_ID]) REFERENCES [dbo].[Procedures] ([Procedure_ID]); +ALTER TABLE [dbo].[EquipmentWiringHistory] ADD CONSTRAINT [FK_EquipmentWiringHistory_Equipment_ID] FOREIGN KEY ([Equipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[EquipmentWiringHistory] ADD CONSTRAINT [FK_EquipmentWiringHistory_SignalInterface_ID] FOREIGN KEY ([SignalInterface_ID]) REFERENCES [dbo].[SignalInterface] ([SignalInterface_ID]); +ALTER TABLE [dbo].[EquipmentWiringHistory] ADD CONSTRAINT [FK_EquipmentWiringHistory_SignalInterfacePort_ID] FOREIGN KEY ([SignalInterfacePort_ID]) REFERENCES [dbo].[SignalInterfacePort] ([SignalInterfacePort_ID]); +ALTER TABLE [dbo].[HydrologicalCharacteristics] ADD CONSTRAINT [FK_HydrologicalCharacteristics_Watershed_ID] FOREIGN KEY ([Watershed_ID]) REFERENCES [dbo].[Watershed] ([Watershed_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_LabExperiment_ID] FOREIGN KEY ([LabExperiment_ID]) REFERENCES [dbo].[LabExperiment] ([LabExperiment_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_AnalysisSeries_ID] FOREIGN KEY ([AnalysisSeries_ID]) REFERENCES [dbo].[AnalysisSeries] ([Stream_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_Sample_ID] FOREIGN KEY ([Sample_ID]) REFERENCES [dbo].[Sample] ([Sample_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_QualityCode_ID] FOREIGN KEY ([QualityCode_ID]) REFERENCES [dbo].[QualityCode] ([QualityCode_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_ReviewStatus_ID] FOREIGN KEY ([ReviewStatus_ID]) REFERENCES [dbo].[ReviewStatus] ([ReviewStatus_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_ReviewedByPerson_ID] FOREIGN KEY ([ReviewedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_Laboratory_ID] FOREIGN KEY ([Laboratory_ID]) REFERENCES [dbo].[Laboratory] ([Laboratory_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_AnalystPerson_ID] FOREIGN KEY ([AnalystPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_Procedure_ID] FOREIGN KEY ([Procedure_ID]) REFERENCES [dbo].[Procedures] ([Procedure_ID]); +ALTER TABLE [dbo].[LabExperiment] ADD CONSTRAINT [FK_LabExperiment_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[LabExperiment] ADD CONSTRAINT [FK_LabExperiment_CreatedByPerson_ID] FOREIGN KEY ([CreatedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[LabExperiment] ADD CONSTRAINT [FK_LabExperiment_LabPanel_ID] FOREIGN KEY ([LabPanel_ID]) REFERENCES [dbo].[LabPanel] ([LabPanel_ID]); +ALTER TABLE [dbo].[LabPanel] ADD CONSTRAINT [FK_LabPanel_CreatedByPerson_ID] FOREIGN KEY ([CreatedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[LabPanel] ADD CONSTRAINT [FK_LabPanel_DefaultSampleCollectionKind_ID] FOREIGN KEY ([DefaultSampleCollectionKind_ID]) REFERENCES [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID]); +ALTER TABLE [dbo].[LabPanel] ADD CONSTRAINT [FK_LabPanel_DefaultSampleEquipment_ID] FOREIGN KEY ([DefaultSampleEquipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[LabPanelSeries] ADD CONSTRAINT [FK_LabPanelSeries_LabPanel_ID] FOREIGN KEY ([LabPanel_ID]) REFERENCES [dbo].[LabPanel] ([LabPanel_ID]); +ALTER TABLE [dbo].[LabPanelSeries] ADD CONSTRAINT [FK_LabPanelSeries_AnalysisSeries_ID] FOREIGN KEY ([AnalysisSeries_ID]) REFERENCES [dbo].[AnalysisSeries] ([Stream_ID]); +ALTER TABLE [dbo].[Laboratory] ADD CONSTRAINT [FK_Laboratory_Site_ID] FOREIGN KEY ([Site_ID]) REFERENCES [dbo].[Site] ([Site_ID]); +ALTER TABLE [dbo].[LandUse] ADD CONSTRAINT [FK_LandUse_Watershed_ID] FOREIGN KEY ([Watershed_ID]) REFERENCES [dbo].[Watershed] ([Watershed_ID]); +ALTER TABLE [dbo].[Observation] ADD CONSTRAINT [FK_Observation_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[Observation] ADD CONSTRAINT [FK_Observation_LabAnalysis_ID] FOREIGN KEY ([LabAnalysis_ID]) REFERENCES [dbo].[LabAnalysis] ([LabAnalysis_ID]); +ALTER TABLE [dbo].[Observation] ADD CONSTRAINT [FK_Observation_ValueKind_ID] FOREIGN KEY ([ValueKind_ID]) REFERENCES [dbo].[ValueKind] ([ValueKind_ID]); +ALTER TABLE [dbo].[Parameter] ADD CONSTRAINT [FK_Parameter_ValueKind_ID] FOREIGN KEY ([ValueKind_ID]) REFERENCES [dbo].[ValueKind] ([ValueKind_ID]); +ALTER TABLE [dbo].[ParameterHasUnit] ADD CONSTRAINT [FK_ParameterHasUnit_Parameter_ID] FOREIGN KEY ([Parameter_ID]) REFERENCES [dbo].[Parameter] ([Parameter_ID]); +ALTER TABLE [dbo].[ParameterHasUnit] ADD CONSTRAINT [FK_ParameterHasUnit_Unit_ID] FOREIGN KEY ([Unit_ID]) REFERENCES [dbo].[Unit] ([Unit_ID]); +ALTER TABLE [dbo].[Procedures] ADD CONSTRAINT [FK_Procedures_ProcedureKind_ID] FOREIGN KEY ([ProcedureKind_ID]) REFERENCES [dbo].[ProcedureKind] ([ProcedureKind_ID]); +ALTER TABLE [dbo].[ProcessUnit] ADD CONSTRAINT [FK_ProcessUnit_Site_ID] FOREIGN KEY ([Site_ID]) REFERENCES [dbo].[Site] ([Site_ID]); +ALTER TABLE [dbo].[ProcessUnit] ADD CONSTRAINT [FK_ProcessUnit_ProcessUnitKind_ID] FOREIGN KEY ([ProcessUnitKind_ID]) REFERENCES [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID]); +ALTER TABLE [dbo].[ProcessUnit] ADD CONSTRAINT [FK_ProcessUnit_Parent_ID] FOREIGN KEY ([Parent_ID]) REFERENCES [dbo].[ProcessUnit] ([ProcessUnit_ID]); +ALTER TABLE [dbo].[ProcessingLineage] ADD CONSTRAINT [FK_ProcessingLineage_ProcessingStep_ID] FOREIGN KEY ([ProcessingStep_ID]) REFERENCES [dbo].[ProcessingStep] ([ProcessingStep_ID]); +ALTER TABLE [dbo].[ProcessingLineage] ADD CONSTRAINT [FK_ProcessingLineage_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[ProcessingStep] ADD CONSTRAINT [FK_ProcessingStep_OperationKind_ID] FOREIGN KEY ([OperationKind_ID]) REFERENCES [dbo].[OperationKind] ([OperationKind_ID]); +ALTER TABLE [dbo].[ProcessingStep] ADD CONSTRAINT [FK_ProcessingStep_ExecutedByPerson_ID] FOREIGN KEY ([ExecutedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[ProcessingStep] ADD CONSTRAINT [FK_ProcessingStep_Dataset_ID] FOREIGN KEY ([Dataset_ID]) REFERENCES [dbo].[Dataset] ([Dataset_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_ParentSample_ID] FOREIGN KEY ([ParentSample_ID]) REFERENCES [dbo].[Sample] ([Sample_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SampleKind_ID] FOREIGN KEY ([SampleKind_ID]) REFERENCES [dbo].[SampleKind] ([SampleKind_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SamplingPoint_ID] FOREIGN KEY ([SamplingPoint_ID]) REFERENCES [dbo].[SamplingPoint] ([SamplingPoint_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SampledByPerson_ID] FOREIGN KEY ([SampledByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SampleCollectionKind_ID] FOREIGN KEY ([SampleCollectionKind_ID]) REFERENCES [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SampleEquipment_ID] FOREIGN KEY ([SampleEquipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[SamplingPoint] ADD CONSTRAINT [FK_SamplingPoint_Site_ID] FOREIGN KEY ([Site_ID]) REFERENCES [dbo].[Site] ([Site_ID]); +ALTER TABLE [dbo].[SamplingPoint] ADD CONSTRAINT [FK_SamplingPoint_ProcessUnit_ID] FOREIGN KEY ([ProcessUnit_ID]) REFERENCES [dbo].[ProcessUnit] ([ProcessUnit_ID]); +ALTER TABLE [dbo].[SamplingPoint] ADD CONSTRAINT [FK_SamplingPoint_CreatedByCampaign_ID] FOREIGN KEY ([CreatedByCampaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[SignalInterface] ADD CONSTRAINT [FK_SignalInterface_DataAcquisitionSystem_ID] FOREIGN KEY ([DataAcquisitionSystem_ID]) REFERENCES [dbo].[DataAcquisitionSystem] ([DataAcquisitionSystem_ID]); +ALTER TABLE [dbo].[SignalInterfacePort] ADD CONSTRAINT [FK_SignalInterfacePort_SignalInterface_ID] FOREIGN KEY ([SignalInterface_ID]) REFERENCES [dbo].[SignalInterface] ([SignalInterface_ID]); +ALTER TABLE [dbo].[Site] ADD CONSTRAINT [FK_Site_Watershed_ID] FOREIGN KEY ([Watershed_ID]) REFERENCES [dbo].[Watershed] ([Watershed_ID]); +ALTER TABLE [dbo].[Site] ADD CONSTRAINT [FK_Site_SiteKind_ID] FOREIGN KEY ([SiteKind_ID]) REFERENCES [dbo].[SiteKind] ([SiteKind_ID]); +ALTER TABLE [dbo].[Stream] ADD CONSTRAINT [FK_Stream_StreamKind_ID] FOREIGN KEY ([StreamKind_ID]) REFERENCES [dbo].[StreamKind] ([StreamKind_ID]); +ALTER TABLE [dbo].[Value] ADD CONSTRAINT [FK_Value_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[ValueBin] ADD CONSTRAINT [FK_ValueBin_ValueBinningAxis_ID] FOREIGN KEY ([ValueBinningAxis_ID]) REFERENCES [dbo].[ValueBinningAxis] ([ValueBinningAxis_ID]); +ALTER TABLE [dbo].[ValueBinningAxis] ADD CONSTRAINT [FK_ValueBinningAxis_Unit_ID] FOREIGN KEY ([Unit_ID]) REFERENCES [dbo].[Unit] ([Unit_ID]); +ALTER TABLE [dbo].[ValueBinningAxis] ADD CONSTRAINT [FK_ValueBinningAxis_BinKind_ID] FOREIGN KEY ([BinKind_ID]) REFERENCES [dbo].[BinKind] ([BinKind_ID]); +ALTER TABLE [dbo].[ValueImage] ADD CONSTRAINT [FK_ValueImage_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[ValueMatrix] ADD CONSTRAINT [FK_ValueMatrix_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[ValueMatrix] ADD CONSTRAINT [FK_ValueMatrix_RowValueBin] FOREIGN KEY ([RowValueBin_ID]) REFERENCES [dbo].[ValueBin] ([ValueBin_ID]); +ALTER TABLE [dbo].[ValueMatrix] ADD CONSTRAINT [FK_ValueMatrix_ColValueBin] FOREIGN KEY ([ColValueBin_ID]) REFERENCES [dbo].[ValueBin] ([ValueBin_ID]); +ALTER TABLE [dbo].[ValueVector] ADD CONSTRAINT [FK_ValueVector_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[ValueVector] ADD CONSTRAINT [FK_ValueVector_ValueBin_ID] FOREIGN KEY ([ValueBin_ID]) REFERENCES [dbo].[ValueBin] ([ValueBin_ID]); +ALTER TABLE [dbo].[Watershed] ADD CONSTRAINT [FK_Watershed_ParentWatershed_ID] FOREIGN KEY ([ParentWatershed_ID]) REFERENCES [dbo].[Watershed] ([Watershed_ID]); + +-- Views +GO +CREATE OR ALTER VIEW [dbo].[vw_ChannelResolved] AS +SELECT + c.[Stream_ID], + c.[SignalInterface_ID], + c.[TagName], + cph.[SignalInterfacePort_ID], + c.[ParentChannel_ID], + c.[ChannelKind_ID], + c.[Parameter_ID], + c.[DataProvenanceKind_ID], + c.[ProducedByStep_ID], + c.[ValueKind_ID], + c.[Unit_ID] +FROM [dbo].[Channel] c +LEFT JOIN [dbo].[ChannelPortHistory] cph + ON cph.[Channel_ID] = c.[Stream_ID] + AND cph.[ValidTo] IS NULL; + +GO +CREATE OR ALTER VIEW [dbo].[vw_DeploymentCoherence] AS +SELECT + dlh.[DataAcquisitionSystem_ID] AS DAS_ID, + das.[Name] AS DASName, + dlh.[Site_ID] AS DASSite_ID, + dsite.[Name] AS DASSiteName, + e.[Equipment_ID] AS Equipment_ID, + e.[Identifier] AS EquipmentName, + sp.[Site_ID] AS EquipmentSite_ID, + esite.[Name] AS EquipmentSiteName, + sp.[SamplingPoint_ID] AS SamplingPoint_ID, + sp.[SamplingPoint] AS SamplingPointName +FROM [dbo].[DASLocationHistory] dlh +JOIN [dbo].[DataAcquisitionSystem] das ON das.[DataAcquisitionSystem_ID] = dlh.[DataAcquisitionSystem_ID] +JOIN [dbo].[SignalInterface] si ON si.[DataAcquisitionSystem_ID] = dlh.[DataAcquisitionSystem_ID] +JOIN [dbo].[EquipmentWiringHistory] ewh ON ewh.[SignalInterface_ID] = si.[SignalInterface_ID] + AND ewh.[ValidTo] IS NULL +JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = ewh.[Equipment_ID] +JOIN [dbo].[EquipmentLocationHistory] elh ON elh.[Equipment_ID] = e.[Equipment_ID] + AND elh.[ValidTo] IS NULL +JOIN [dbo].[SamplingPoint] sp ON sp.[SamplingPoint_ID] = elh.[SamplingPoint_ID] +LEFT JOIN [dbo].[Site] dsite ON dsite.[Site_ID] = dlh.[Site_ID] +LEFT JOIN [dbo].[Site] esite ON esite.[Site_ID] = sp.[Site_ID] +WHERE dlh.[ValidTo] IS NULL + AND sp.[Site_ID] <> dlh.[Site_ID]; + +GO +CREATE OR ALTER VIEW [dbo].[vw_InactiveParentReferences] AS +SELECT + N'active-wiring->interface' AS ReferenceType, + ewh.[EquipmentWiringHistory_ID] AS WiringHistoryID, + ewh.[Equipment_ID] AS EquipmentID, + si.[SignalInterface_ID] AS ParentID, + si.[Name] AS ParentLabel +FROM [dbo].[EquipmentWiringHistory] ewh +JOIN [dbo].[SignalInterface] si ON si.[SignalInterface_ID] = ewh.[SignalInterface_ID] +WHERE ewh.[ValidTo] IS NULL + AND si.[IsActive] = 0 +UNION ALL +SELECT + N'active-wiring->port' AS ReferenceType, + ewh.[EquipmentWiringHistory_ID] AS WiringHistoryID, + ewh.[Equipment_ID] AS EquipmentID, + sip.[SignalInterfacePort_ID] AS ParentID, + sip.[PortIdentifier] AS ParentLabel +FROM [dbo].[EquipmentWiringHistory] ewh +JOIN [dbo].[SignalInterfacePort] sip ON sip.[SignalInterfacePort_ID] = ewh.[SignalInterfacePort_ID] +WHERE ewh.[ValidTo] IS NULL + AND sip.[IsActive] = 0; + +GO +CREATE OR ALTER VIEW [dbo].[vw_UnlinkedChannels] AS +SELECT + c.[Stream_ID] AS ChannelID, + c.[TagName] AS TagName, + c.[SignalInterface_ID] AS SignalInterfaceID, + si.[Name] AS SignalInterfaceName, + COUNT(o.[Observation_ID]) AS ObservationCount, + MIN(o.[Timestamp]) AS FirstObservation, + MAX(o.[Timestamp]) AS LastObservation +FROM [dbo].[Channel] c +JOIN [dbo].[Observation] o ON o.[Channel_ID] = c.[Stream_ID] +LEFT JOIN [dbo].[SignalInterface] si ON si.[SignalInterface_ID] = c.[SignalInterface_ID] +WHERE c.[SignalInterface_ID] IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM [dbo].[EquipmentWiringHistory] ewh + WHERE ewh.[SignalInterface_ID] = c.[SignalInterface_ID] + AND ewh.[ValidTo] IS NULL + ) +GROUP BY c.[Stream_ID], c.[TagName], c.[SignalInterface_ID], si.[Name]; + +GO +CREATE OR ALTER VIEW [dbo].[vw_ChannelEquipmentAtTime] AS +WITH channel_wiring AS ( + SELECT + o.[Observation_ID] AS ObservationID, + c.[Stream_ID] AS ChannelID, + o.[Timestamp] AS Timestamp, + c.[SignalInterface_ID], + c.[SignalInterfacePort_ID], + ewh.[Equipment_ID] AS EquipmentID, + ROW_NUMBER() OVER ( + PARTITION BY o.[Observation_ID] + ORDER BY + CASE WHEN ewh.[SignalInterfacePort_ID] IS NOT NULL THEN 0 ELSE 1 END, + ewh.[ValidFrom] DESC + ) AS rn, + COUNT(*) OVER (PARTITION BY o.[Observation_ID]) AS match_count + FROM [dbo].[Observation] o + JOIN [dbo].[vw_ChannelResolved] c ON c.[Stream_ID] = o.[Channel_ID] + LEFT JOIN [dbo].[EquipmentWiringHistory] ewh ON ewh.[SignalInterface_ID] = c.[SignalInterface_ID] + AND ( + ewh.[SignalInterfacePort_ID] = c.[SignalInterfacePort_ID] + OR (ewh.[SignalInterfacePort_ID] IS NULL AND c.[SignalInterfacePort_ID] IS NULL) + OR c.[SignalInterfacePort_ID] IS NULL + ) + AND ewh.[ValidFrom] <= o.[Timestamp] + AND (ewh.[ValidTo] IS NULL OR ewh.[ValidTo] > o.[Timestamp]) +) +SELECT + cw.ObservationID, + cw.ChannelID, + cw.Timestamp, + CASE WHEN cw.match_count > 1 AND cw.[SignalInterfacePort_ID] IS NULL THEN NULL ELSE cw.EquipmentID END AS EquipmentID, + e.[Identifier] AS EquipmentName, + CASE + WHEN cw.EquipmentID IS NULL AND cw.match_count = 0 THEN N'unlinked' + WHEN cw.match_count > 1 AND cw.[SignalInterfacePort_ID] IS NULL THEN N'ambiguous' + ELSE N'resolved' + END AS Resolution +FROM channel_wiring cw +LEFT JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = cw.EquipmentID +WHERE cw.rn = 1; + +GO +CREATE OR ALTER VIEW [dbo].[vw_ChannelStatus] AS +SELECT + statusC.[Stream_ID] AS StatusChannelID, + valueC.[Stream_ID] AS MeasurementChannelID, + e.[Equipment_ID] AS EquipmentID, + e.[Identifier] AS EquipmentName, + p.[Parameter] AS MeasurementParameter, + o.[Timestamp], + CAST(v.[Value] AS INT) AS StatusCodeID +FROM [dbo].[Value] v +JOIN [dbo].[Observation] o ON o.[Observation_ID] = v.[Observation_ID] +JOIN [dbo].[Channel] statusC ON statusC.[Stream_ID] = o.[Channel_ID] +JOIN [dbo].[ChannelKind] role ON role.[ChannelKind_ID] = statusC.[ChannelKind_ID] +JOIN [dbo].[vw_ChannelResolved] valueC ON valueC.[Stream_ID] = statusC.[ParentChannel_ID] +JOIN [dbo].[Parameter] p ON p.[Parameter_ID] = valueC.[Parameter_ID] +LEFT JOIN [dbo].[EquipmentWiringHistory] ewh + ON ewh.[SignalInterface_ID] = valueC.[SignalInterface_ID] + AND ( + ewh.[SignalInterfacePort_ID] = valueC.[SignalInterfacePort_ID] + OR valueC.[SignalInterfacePort_ID] IS NULL + ) + AND ewh.[ValidTo] IS NULL +LEFT JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = ewh.[Equipment_ID] +WHERE role.[Name] = N'Status' + AND statusC.[ParentChannel_ID] IS NOT NULL; + +GO +CREATE OR ALTER VIEW [dbo].[vw_DeviceStatus] AS +SELECT + statusC.[Stream_ID] AS StatusChannelID, + e.[Equipment_ID] AS EquipmentID, + e.[Identifier] AS EquipmentName, + o.[Timestamp], + CAST(v.[Value] AS INT) AS StatusCodeID +FROM [dbo].[Value] v +JOIN [dbo].[Observation] o ON o.[Observation_ID] = v.[Observation_ID] +JOIN [dbo].[Channel] statusC ON statusC.[Stream_ID] = o.[Channel_ID] +JOIN [dbo].[ChannelKind] role ON role.[ChannelKind_ID] = statusC.[ChannelKind_ID] +JOIN [dbo].[vw_ChannelResolved] valueC ON valueC.[Stream_ID] = statusC.[ParentChannel_ID] +JOIN [dbo].[EquipmentWiringHistory] ewh + ON ewh.[SignalInterface_ID] = valueC.[SignalInterface_ID] + AND ( + ewh.[SignalInterfacePort_ID] = valueC.[SignalInterfacePort_ID] + OR valueC.[SignalInterfacePort_ID] IS NULL + ) + AND ewh.[ValidTo] IS NULL +JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = ewh.[Equipment_ID] +WHERE role.[Name] = N'Status'; + +GO +CREATE OR ALTER VIEW [dbo].[vw_ChannelLocationAtTime] AS +SELECT + cea.ObservationID, + cea.ChannelID, + cea.Timestamp, + cea.EquipmentID, + cea.Resolution, + elh.[SamplingPoint_ID] AS SamplingPointID, + sp.[SamplingPoint] AS SamplingPointName, + CASE + WHEN cea.EquipmentID IS NULL THEN N'no-equipment' + WHEN elh.[SamplingPoint_ID] IS NOT NULL THEN N'resolved' + ELSE N'no-location' + END AS LocationResolution +FROM [dbo].[vw_ChannelEquipmentAtTime] cea +LEFT JOIN [dbo].[EquipmentLocationHistory] elh ON elh.[Equipment_ID] = cea.EquipmentID + AND elh.[ValidFrom] <= cea.Timestamp + AND (elh.[ValidTo] IS NULL OR elh.[ValidTo] > cea.Timestamp) +LEFT JOIN [dbo].[SamplingPoint] sp ON sp.[SamplingPoint_ID] = elh.[SamplingPoint_ID]; + + +GO +-- Schema version stamp (from schema_dictionary/version.yaml) +INSERT INTO [dbo].[SchemaVersion] ([Version], [Description]) +VALUES (N'2.3.0', N'Consistency hardening part 3 — broken-link visibility (Batch 3). F6: vw_ChannelLocationAtTime gains Resolution + LocationResolution discriminators (broken-chain NULL vs genuinely-absent). F5: vw_UnlinkedChannels (raw channels with observations but no active wiring). F11: vw_InactiveParentReferences (active wiring pointing at soft-deleted interfaces/ports). Pre-release, fresh install only — no migration.'); diff --git a/sql_generation_scripts/v2.3.0_seed_mssql.sql b/sql_generation_scripts/v2.3.0_seed_mssql.sql new file mode 100644 index 0000000..b8a79be --- /dev/null +++ b/sql_generation_scripts/v2.3.0_seed_mssql.sql @@ -0,0 +1,228 @@ +-- Seed data for schema v2.3.0 +-- Platform: mssql +-- Generated: 2026-06-29 17:15:56 UTC +-- AnnotationKind +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (1, N'Fault', N'Sensor or process fault', N'#FF4444'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (2, N'Maintenance', N'Sensor under maintenance', N'#FFA500'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (3, N'Calibration Period', N'Data during calibration — may be invalid', N'#FFD700'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (4, N'Anomaly', N'Unexpected behavior, needs investigation', N'#FF69B4'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (5, N'Experiment', N'Data collected for a specific experiment', N'#4488FF'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (6, N'Process Event', N'Known process event (storm, dosing, etc.)', N'#44BB44'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (7, N'Data Quality', N'Suspect data quality (drift, fouling)', N'#AA44FF'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (8, N'Note', N'General commentary', N'#888888'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (9, N'Exclusion', N'Data should be excluded from analysis', N'#CC0000'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (10, N'Confirmed', N'Data has been reviewed and accepted as valid', N'#00AA00'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (11, N'Equipment Relocation', N'Equipment was physically moved to a new location', N'#8888FF'); +-- BinKind +INSERT INTO [dbo].[BinKind] ([BinKind_ID], [Name], [Description]) VALUES (1, N'interval', N'Bins defined by lower and upper bounds only'); +INSERT INTO [dbo].[BinKind] ([BinKind_ID], [Name], [Description]) VALUES (2, N'interval_with_nominal', N'Bins defined by bounds plus a nominal center value (e.g., comes from a table with columns showing the mean settling velocity, but the bin in fact collects data between a min and max value (not a point value)).'); +INSERT INTO [dbo].[BinKind] ([BinKind_ID], [Name], [Description]) VALUES (3, N'nominal', N'Bins defined by a single nominal (exact) value only (e.g., absorbance at exactly 200 nm).'); +-- CampaignKind +SET IDENTITY_INSERT [dbo].[CampaignKind] ON; +INSERT INTO [dbo].[CampaignKind] ([CampaignKind_ID], [Name], [Description]) VALUES (1, N'Experiment', N'Planned scientific investigation under controlled or semi-controlled conditions'); +INSERT INTO [dbo].[CampaignKind] ([CampaignKind_ID], [Name], [Description]) VALUES (2, N'Regular operation', N'Routine monitoring or operational run of the monitored process'); +INSERT INTO [dbo].[CampaignKind] ([CampaignKind_ID], [Name], [Description]) VALUES (3, N'Commissioning', N'Initial setup, calibration, and qualification of equipment or a process'); +SET IDENTITY_INSERT [dbo].[CampaignKind] OFF; +-- ChannelKind +INSERT INTO [dbo].[ChannelKind] ([ChannelKind_ID], [Name], [Description]) VALUES (1, N'Value', N'Primary measurement or output value'); +INSERT INTO [dbo].[ChannelKind] ([ChannelKind_ID], [Name], [Description]) VALUES (2, N'Status', N'Device or measurement status flag'); +INSERT INTO [dbo].[ChannelKind] ([ChannelKind_ID], [Name], [Description]) VALUES (3, N'Alarm', N'Alarm or alert indicator'); +INSERT INTO [dbo].[ChannelKind] ([ChannelKind_ID], [Name], [Description]) VALUES (4, N'Uncertainty', N'Measurement uncertainty estimate'); +-- ControlLoopPortKind +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (1, N'MeasuredVariable', N'The controlled or observed process variable'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (2, N'ManipulatedVariable', N'The actuator or output adjusted by the controller'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (3, N'SetPoint', N'Target value supplied to the controller'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (4, N'Disturbance', N'Measured input that affects the process; not manipulated'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (5, N'PredictedOutput', N'Model-predicted value of the controlled variable'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (6, N'Other', N'Escape hatch for novel kinds; describe in ControlLoop.Description'); +-- ControllerKind +SET IDENTITY_INSERT [dbo].[ControllerKind] ON; +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (1, N'PID', N'Proportional-Integral-Derivative controller'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (2, N'Feedforward', N'Open-loop controller that acts on predicted disturbances'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (3, N'MPC', N'Model Predictive Controller using an internal process model'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (4, N'On-Off', N'Bang-bang (on/off) controller with fixed setpoint'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (5, N'Manual', N'Operator-driven manual control with no automated loop'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (6, N'Other', N'Controller type not covered by the other categories'); +SET IDENTITY_INSERT [dbo].[ControllerKind] OFF; +-- DataAcquisitionSystemKind +SET IDENTITY_INSERT [dbo].[DataAcquisitionSystemKind] ON; +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (1, N'SCADA', N'Supervisory Control and Data Acquisition system.'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (2, N'PLC', N'Programmable Logic Controller.'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (3, N'Field monitoring station', N'Deployable measurement station capable of hosting multiple devices and recording their data streams.'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (4, N'IoT Gateway', N'Internet-of-Things gateway aggregating sensor streams.'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (5, N'Manual entry', N'Data entered manually by an operator (spreadsheet, form).'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (6, N'Other', N'System type not covered by the other categories.'); +SET IDENTITY_INSERT [dbo].[DataAcquisitionSystemKind] OFF; +-- DataProvenanceKind +SET IDENTITY_INSERT [dbo].[DataProvenanceKind] ON; +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (1, N'Sensor', N'Value acquired directly from an instrument or sensor in the field'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (2, N'Laboratory', N'Value determined by laboratory chemical or physical analysis'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (3, N'Controller Output', N'Value generated by a control algorithm.'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (4, N'Model Output', N'Value generated by a simulation, model, or prediction algorithm not involved in control.'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (5, N'External Source', N'Value imported from an external dataset or third-party system'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (6, N'Forecast', N'Future-dated value produced by a forecasting model'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (7, N'Derived', N'Value produced by applying a data-processing algorithm to one or more existing channels.'); +SET IDENTITY_INSERT [dbo].[DataProvenanceKind] OFF; +-- EquipmentEventKind +SET IDENTITY_INSERT [dbo].[EquipmentEventKind] ON; +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (1, N'Calibration', N'Adjustment of sensor output to match a known reference standard'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (2, N'Commissioning', N'Formal activation of equipment into operational service'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (3, N'Maintenance', N'Physical cleaning, inspection, or servicing of equipment'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (4, N'Installation', N'First-time mounting or connection of equipment at its deployment site'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (5, N'Removal', N'Decommissioning or retrieval of equipment from its deployment site'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (6, N'Firmware Update', N'Update to the embedded software or firmware of the device'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (7, N'Failure', N'Unplanned malfunction or breakdown requiring corrective action'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (8, N'Repair', N'Corrective action performed following a recorded failure'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (9, N'Decommissioning', N'Formal retirement of equipment from operational service'); +SET IDENTITY_INSERT [dbo].[EquipmentEventKind] OFF; +-- OperationKind +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (1, N'Unprocessed', N'No operations applied — used for the raw channel trait only'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (2, N'OutlierRemoval', N'Spikes and statistical outliers removed or flagged'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (3, N'DriftCorrection', N'Sensor drift or baseline shift corrected'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (4, N'FaultRemoval', N'Instrument faults and implausible values removed'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (5, N'Smoothing', N'Noise reduced by a smoothing or averaging algorithm'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (6, N'Interpolation', N'Missing values filled by interpolation or reconstruction'); +-- ProcedureKind +SET IDENTITY_INSERT [dbo].[ProcedureKind] ON; +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (1, N'Maintenance and Cleaning Protocol', N'Procedures for routine maintenance, cleaning, and upkeep of equipment'); +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (2, N'Calibration Protocol', N'Step-by-step instructions for calibrating instruments or sensors'); +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (3, N'Validation Protocol', N'Procedures for validating measurements, methods, or models'); +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (4, N'Laboratory Method Protocol', N'Standardised laboratory analytical methods (e.g. ISO, ASTM, APHA)'); +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (5, N'Software Manual', N'User or operational manuals for software tools used in data acquisition or processing'); +SET IDENTITY_INSERT [dbo].[ProcedureKind] OFF; +-- ProcessUnitKind +SET IDENTITY_INSERT [dbo].[ProcessUnitKind] ON; +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (1, N'Area', N'Broad spatial zone (e.g. biological treatment area)'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (2, N'Zone', N'Defined functional sub-zone within a process area'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (3, N'Tank', N'Enclosed vessel for liquid storage or treatment'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (4, N'Reactor', N'Vessel designed for controlled biological or chemical reactions'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (5, N'Pipe', N'Conduit transporting liquid between process units'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (6, N'Pump', N'Mechanical device for moving liquid'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (7, N'Valve', N'Flow control device regulating liquid passage'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (8, N'Clarifier', N'Gravity settling vessel separating solids from liquid'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (9, N'Basin', N'Open or partially open liquid containment structure'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (10, N'Blower', N'Mechanical device for supplying air or gas'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (11, N'Other', N'Process unit kind not covered by the standard vocabulary'); +SET IDENTITY_INSERT [dbo].[ProcessUnitKind] OFF; +-- QualityCode +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (1, N'Accepted', N'Measurement meets quality criteria and is fit for use', 1); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (2, N'Suspect', N'Measurement may be unreliable; flagged for manual review', 1); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (3, N'Rejected', N'Measurement is invalid and must not be used', 0); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (4, N'BelowLoD', N'Result is below the method''s limit of detection', 0); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (5, N'AboveLoQ', N'Result exceeds the limit of quantification (instrument saturated)', 0); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (6, N'Outlier', N'Statistical outlier; not automatically invalid but requires review', 1); +-- ReviewStatus +INSERT INTO [dbo].[ReviewStatus] ([ReviewStatus_ID], [Name], [Description]) VALUES (1, N'Pending', N'Measurement recorded but not yet reviewed/approved'); +INSERT INTO [dbo].[ReviewStatus] ([ReviewStatus_ID], [Name], [Description]) VALUES (2, N'Approved', N'Measurement reviewed and approved by a designated reviewer'); +INSERT INTO [dbo].[ReviewStatus] ([ReviewStatus_ID], [Name], [Description]) VALUES (3, N'Rejected', N'Measurement reviewed and rejected'); +-- SampleCollectionKind +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (1, N'Grab', N'Single instantaneous sample collected at one point in time'); +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (2, N'Composite24h', N'Flow- or time-proportional composite over a 24-hour period'); +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (3, N'Composite8h', N'Flow- or time-proportional composite over an 8-hour period'); +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (4, N'Passive', N'Passive sampler deployed over an extended exposure period'); +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (5, N'Other', N'Collection kind not covered by the standard vocabulary'); +-- SampleKind +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (1, N'Field', N'Sample collected from a real-world site or process'); +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (2, N'Synthetic', N'Laboratory-prepared sample with known composition'); +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (3, N'Master Standard', N'Reference standard used to prepare derived standards'); +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (4, N'Derived Standard', N'Dilution or aliquot derived from a master standard'); +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (5, N'Blank', N'Blank sample used to detect contamination or baseline'); +-- SiteKind +SET IDENTITY_INSERT [dbo].[SiteKind] ON; +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (1, N'Municipal Wastewater Treatment Plant', N'Municipal or industrial facility treating wastewater before discharge'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (2, N'Combined Sewer Overflow', N'Point where combined sewer system discharges during high-flow events'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (3, N'River / Stream', N'Natural flowing surface water body'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (4, N'Lake / Reservoir', N'Natural or artificial standing body of water'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (5, N'Groundwater / Well', N'Subsurface water source accessed via a well or borehole'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (6, N'Drinking Water Distribution Network Access Point', N'Monitoring point within a potable water distribution network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (7, N'Canal', N'Artificial waterway for water transport or drainage'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (8, N'Wastewater Pumping Station', N'Facility that pumps wastewater through the collection network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (9, N'Combined Drainage Network Access Point', N'Monitoring point within a combined stormwater and wastewater network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (10, N'Rainwater Drainage Network Access Point', N'Monitoring point within a stormwater-only drainage network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (11, N'Wastewater Drainage Network Access Point', N'Monitoring point within a sanitary sewer network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (12, N'Experimental Wastewater Treatment Plant', N'Small-scale experimental treatment or process facility'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (13, N'Other', N'Site kind not covered by the standard vocabulary'); +SET IDENTITY_INSERT [dbo].[SiteKind] OFF; +-- StreamKind +INSERT INTO [dbo].[StreamKind] ([StreamKind_ID], [Name], [Description]) VALUES (1, N'Sensor', N'A sensor measurement stream (Channel subtype of Stream)'); +INSERT INTO [dbo].[StreamKind] ([StreamKind_ID], [Name], [Description]) VALUES (2, N'Lab', N'A laboratory measurement stream (AnalysisSeries subtype of Stream)'); +-- Unit +SET IDENTITY_INSERT [dbo].[Unit] ON; +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (1, N'mg/L', N'https://qudt.org/vocab/unit/MilliGM-PER-L', N'0,1,-3,0,0,0,0', 0.001, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (2, N'NTU', N'https://qudt.org/vocab/unit/NTU', N'0,0,0,0,0,0,0', NULL, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (3, N'pH units', N'https://qudt.org/vocab/unit/PH', N'0,0,0,0,0,0,0', NULL, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (4, N'°C', N'https://qudt.org/vocab/unit/DEG_C', N'0,0,0,0,1,0,0', 1.0, 273.15); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (5, N'mS/cm', N'https://qudt.org/vocab/unit/MilliS-PER-CentiM', N'-3,-1,3,2,0,0,0', 0.1, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (6, N'nm', N'https://qudt.org/vocab/unit/NanoM', N'1,0,0,0,0,0,0', 1e-09, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (7, N'µm', N'https://qudt.org/vocab/unit/MicroM', N'1,0,0,0,0,0,0', 1e-06, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (8, N'm/s', N'https://qudt.org/vocab/unit/M-PER-SEC', N'1,0,-1,0,0,0,0', 1.0, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (9, N'Status Code', NULL, NULL, NULL, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (10, N'AU', N'https://qudt.org/vocab/unit/ABSORBANCE_UNIT', N'0,0,0,0,0,0,0', NULL, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (11, N'-', N'https://qudt.org/vocab/unit/UNITLESS', N'0,0,0,0,0,0,0', 1.0, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (12, N'm³/h', N'https://qudt.org/vocab/unit/M3-PER-HR', N'3,0,-1,0,0,0,0', 0.000277778, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (13, N'm', N'https://qudt.org/vocab/unit/M', N'1,0,0,0,0,0,0', 1.0, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (14, N'Nm³/h', NULL, N'3,0,-1,0,0,0,0', 0.000277778, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (15, N'%', N'https://qudt.org/vocab/unit/PERCENT', N'0,0,0,0,0,0,0', 0.01, NULL); +SET IDENTITY_INSERT [dbo].[Unit] OFF; +-- ValueKind +SET IDENTITY_INSERT [dbo].[ValueKind] ON; +INSERT INTO [dbo].[ValueKind] ([ValueKind_ID], [Name], [Description]) VALUES (1, N'Scalar', N'A single numeric measurement value (e.g. temperature, concentration)'); +INSERT INTO [dbo].[ValueKind] ([ValueKind_ID], [Name], [Description]) VALUES (2, N'Vector', N'An ordered sequence of numeric values (e.g. particle size distribution)'); +INSERT INTO [dbo].[ValueKind] ([ValueKind_ID], [Name], [Description]) VALUES (3, N'Matrix', N'A two-dimensional array of values (e.g. excitation-emission matrix)'); +INSERT INTO [dbo].[ValueKind] ([ValueKind_ID], [Name], [Description]) VALUES (4, N'Image', N'A raster image stored as a binary file'); +SET IDENTITY_INSERT [dbo].[ValueKind] OFF; +-- Parameter +SET IDENTITY_INSERT [dbo].[Parameter] ON; +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'TSS concentration', 1, N'Total suspended solids', N'http://purl.obolibrary.org/obo/ENVO_01001502', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'COD concentration', 2, N'Chemical oxygen demand', N'http://purl.obolibrary.org/obo/ENVO_01000632', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'pH', 3, N'Hydrogen ion concentration', N'http://purl.obolibrary.org/obo/ENVO_09200019', 1, N'http://qudt.org/vocab/quantitykind/PH'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Temperature', 4, N'Water temperature', N'http://purl.obolibrary.org/obo/ENVO_01001501', 1, N'http://qudt.org/vocab/quantitykind/Temperature'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Conductivity', 5, N'Electrical conductivity', N'http://purl.obolibrary.org/obo/ENVO_09200010', 1, N'http://qudt.org/vocab/quantitykind/ElectricConductivity'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Sensor Status', 6, N'Per-channel operational status code', NULL, 1, NULL); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Device Status', 7, N'Overall equipment health status code', NULL, 1, NULL); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Dissolved oxygen concentration', 8, N'Dissolved oxygen concentration in water', N'http://purl.obolibrary.org/obo/ENVO_01001111', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Turbidity', 9, N'Water turbidity measured by nephelometry', N'http://purl.obolibrary.org/obo/ENVO_01001573', 1, N'http://qudt.org/vocab/quantitykind/Turbidity'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Absorbance spectrum', 10, N'UV-Vis spectral absorbance (per-wavelength vector, unit AU)', NULL, 2, NULL); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Ammonium-N concentration', 11, N'Ammonium nitrogen concentration (NH4-N)', N'http://purl.obolibrary.org/obo/CHEBI_49786', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Nitrate-N concentration', 12, N'Nitrate nitrogen concentration as NO3-N equivalent', N'http://purl.obolibrary.org/obo/CHEBI_17632', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'COD filtered concentration', 13, N'Filtered COD (CODf) — soluble fraction of chemical oxygen demand', NULL, 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Flow', 14, N'Volumetric flow rate', N'http://purl.obolibrary.org/obo/ENVO_01001020', 1, N'http://qudt.org/vocab/quantitykind/VolumeFlowRate'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Level', 15, N'Water level / depth', NULL, 1, N'http://qudt.org/vocab/quantitykind/Length'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'floc_morphology', 16, N'Activated sludge floc morphology image from inline microscope', NULL, 4, NULL); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Potassium concentration', 17, N'Potassium concentration (K)', NULL, 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Light Absorbance', 18, N'Scalar light absorbance measurement', NULL, 1, N'http://qudt.org/vocab/quantitykind/Absorbance'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Nitrite-N concentration', 19, N'Nitrite nitrogen concentration (NO2-N)', NULL, 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'NOx-N concentration', 20, N'Total oxidized nitrogen (NO3-N + NO2-N)', NULL, 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Air flow', 21, N'Volumetric air/gas flow rate', NULL, 1, N'http://qudt.org/vocab/quantitykind/VolumeFlowRate'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Valve position', 22, N'Control valve analog output position (0-100%)', NULL, 1, NULL); +SET IDENTITY_INSERT [dbo].[Parameter] OFF; +-- Procedures +SET IDENTITY_INSERT [dbo].[Procedures] ON; +INSERT INTO [dbo].[Procedures] ([Procedure_ID], [ProcedureName], [Description], [ProcedureLocation]) VALUES (1, N'Grab sampling', N'Manual grab sample collected at water surface', N'/procedures/grab_sampling.pdf'); +INSERT INTO [dbo].[Procedures] ([Procedure_ID], [ProcedureName], [Description], [ProcedureLocation]) VALUES (2, N'24h composite', N'Time-weighted 24-hour composite sample via autosampler', N'/procedures/composite_24h.pdf'); +INSERT INTO [dbo].[Procedures] ([Procedure_ID], [ProcedureName], [Description], [ProcedureLocation]) VALUES (3, N'Online continuous', N'Continuous in-situ measurement with data logging', N'/procedures/online_continuous.pdf'); +SET IDENTITY_INSERT [dbo].[Procedures] OFF; + +-- ParameterHasUnit (generated by ontology_query.py) +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (1, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (2, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (3, 3); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (4, 4); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (5, 5); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (8, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (9, 2); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (10, 10); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (11, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (12, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (13, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (14, 12); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (15, 6); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (15, 7); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (15, 13); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (17, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (18, 10); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (19, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (20, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (21, 12); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (21, 14); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (22, 15); diff --git a/sql_generation_scripts/v2.4.0_create_mssql.sql b/sql_generation_scripts/v2.4.0_create_mssql.sql new file mode 100644 index 0000000..a5fb3c5 --- /dev/null +++ b/sql_generation_scripts/v2.4.0_create_mssql.sql @@ -0,0 +1,1170 @@ +-- Baseline CREATE script for schema v2.4.0 +-- Platform: mssql +-- Generated: 2026-06-29 17:56:40 UTC + +CREATE TABLE [dbo].[AnnotationKind] ( + [AnnotationKind_ID] INT NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(500), + [Color] NVARCHAR(7), + CONSTRAINT [PK_AnnotationKind] PRIMARY KEY ([AnnotationKind_ID]) +); + +CREATE TABLE [dbo].[BinKind] ( + [BinKind_ID] INT NOT NULL, + [Name] NVARCHAR(30) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_BinKind] PRIMARY KEY ([BinKind_ID]) +); + +CREATE TABLE [dbo].[CampaignKind] ( + [CampaignKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_CampaignKind] PRIMARY KEY ([CampaignKind_ID]) +); + +CREATE TABLE [dbo].[ChannelKind] ( + [ChannelKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_ChannelKind] PRIMARY KEY ([ChannelKind_ID]) +); + +CREATE TABLE [dbo].[ControlLoopPortKind] ( + [ControlLoopPortKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_ControlLoopPortKind] PRIMARY KEY ([ControlLoopPortKind_ID]) +); + +CREATE TABLE [dbo].[ControllerKind] ( + [ControllerKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(500), + CONSTRAINT [PK_ControllerKind] PRIMARY KEY ([ControllerKind_ID]) +); + +CREATE TABLE [dbo].[DataAcquisitionSystemKind] ( + [DataAcquisitionSystemKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(500), + CONSTRAINT [PK_DataAcquisitionSystemKind] PRIMARY KEY ([DataAcquisitionSystemKind_ID]) +); + +CREATE TABLE [dbo].[DataProvenanceKind] ( + [DataProvenanceKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_DataProvenanceKind] PRIMARY KEY ([DataProvenanceKind_ID]) +); + +CREATE TABLE [dbo].[EquipmentEventKind] ( + [EquipmentEventKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_EquipmentEventKind] PRIMARY KEY ([EquipmentEventKind_ID]) +); + +CREATE TABLE [dbo].[EquipmentModel] ( + [EquipmentModel_ID] INT IDENTITY(1,1) NOT NULL, + [EquipmentModel] NVARCHAR(100), + [Method] NVARCHAR(100), + [Functions] NVARCHAR(MAX), + [Manufacturer] NVARCHAR(100), + [ManualLocation] NVARCHAR(1000), + CONSTRAINT [PK_EquipmentModel] PRIMARY KEY ([EquipmentModel_ID]) +); + +CREATE TABLE [dbo].[OperationKind] ( + [OperationKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_OperationKind] PRIMARY KEY ([OperationKind_ID]) +); + +CREATE TABLE [dbo].[Person] ( + [Person_ID] INT IDENTITY(1,1) NOT NULL, + [LastName] NVARCHAR(100), + [FirstName] NVARCHAR(255), + [Company] NVARCHAR(MAX), + [Role] NVARCHAR(255), + [AssignedFunctions] NVARCHAR(MAX), + [Email] NVARCHAR(100), + [Phone] NVARCHAR(100), + [Linkedin] NVARCHAR(100), + [Website] NVARCHAR(60), + CONSTRAINT [PK_Person] PRIMARY KEY ([Person_ID]) +); + +CREATE TABLE [dbo].[ProcedureKind] ( + [ProcedureKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_ProcedureKind] PRIMARY KEY ([ProcedureKind_ID]) +); + +CREATE TABLE [dbo].[ProcessUnitKind] ( + [ProcessUnitKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_ProcessUnitKind] PRIMARY KEY ([ProcessUnitKind_ID]), + CONSTRAINT [UQ_ProcessUnitKind_Name] UNIQUE ([Name]) +); + +CREATE TABLE [dbo].[QualityCode] ( + [QualityCode_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + [IsUsable] BIT NOT NULL DEFAULT 1, + CONSTRAINT [PK_QualityCode] PRIMARY KEY ([QualityCode_ID]) +); + +CREATE TABLE [dbo].[ReviewStatus] ( + [ReviewStatus_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_ReviewStatus] PRIMARY KEY ([ReviewStatus_ID]) +); + +CREATE TABLE [dbo].[SampleCollectionKind] ( + [SampleCollectionKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_SampleCollectionKind] PRIMARY KEY ([SampleCollectionKind_ID]) +); + +CREATE TABLE [dbo].[SampleKind] ( + [SampleKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_SampleKind] PRIMARY KEY ([SampleKind_ID]) +); + +CREATE TABLE [dbo].[SchemaVersion] ( + [VersionID] INT IDENTITY(1,1) NOT NULL, + [Version] NVARCHAR(20) NOT NULL, + [AppliedDateTime] DATETIME2(7) NOT NULL DEFAULT CURRENT_TIMESTAMP, + [Description] NVARCHAR(500), + [MigrationScript] NVARCHAR(200), + CONSTRAINT [PK_SchemaVersion] PRIMARY KEY ([VersionID]) +); + +CREATE TABLE [dbo].[SiteKind] ( + [SiteKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_SiteKind] PRIMARY KEY ([SiteKind_ID]) +); + +CREATE TABLE [dbo].[StreamKind] ( + [StreamKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_StreamKind] PRIMARY KEY ([StreamKind_ID]) +); + +CREATE TABLE [dbo].[Unit] ( + [Unit_ID] INT IDENTITY(1,1) NOT NULL, + [Unit] NVARCHAR(100), + [QUDT_IRI] NVARCHAR(256), + [UnitVector] NVARCHAR(64), + [SI_Multiplier] FLOAT, + [SI_Offset] FLOAT, + CONSTRAINT [PK_Unit] PRIMARY KEY ([Unit_ID]) +); + +CREATE TABLE [dbo].[UserAccount] ( + [UserAccount_ID] INT IDENTITY(1,1) NOT NULL, + [Email] NVARCHAR(255) NOT NULL, + [FullName] NVARCHAR(255) NOT NULL, + [PasswordHash] NVARCHAR(255) NOT NULL, + [IsActive] BIT NOT NULL DEFAULT 1, + [IsVerified] BIT NOT NULL DEFAULT 1, + [CreatedAt] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + [UpdatedAt] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + CONSTRAINT [PK_UserAccount] PRIMARY KEY ([UserAccount_ID]), + CONSTRAINT [UQ_UserAccount_Email] UNIQUE ([Email]) +); + +CREATE TABLE [dbo].[ValueKind] ( + [ValueKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_ValueKind] PRIMARY KEY ([ValueKind_ID]) +); + +CREATE TABLE [dbo].[AnalysisSeries] ( + [Stream_ID] INT NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Parameter_ID] INT NOT NULL, + [SamplingPoint_ID] INT NOT NULL, + [ValueKind_ID] INT NOT NULL DEFAULT 1, + [Unit_ID] INT NOT NULL, + [Campaign_ID] INT, + [Description] NVARCHAR(MAX), + CONSTRAINT [PK_AnalysisSeries] PRIMARY KEY ([Stream_ID]), + CONSTRAINT [UQ_AnalysisSeries_Identity] UNIQUE ([Parameter_ID], [SamplingPoint_ID], [ValueKind_ID]) +); + +CREATE TABLE [dbo].[AnalysisSeriesAxis] ( + [AnalysisSeries_ID] INT NOT NULL, + [AxisRole] INT NOT NULL, + [ValueBinningAxis_ID] INT NOT NULL, + CONSTRAINT [PK_AnalysisSeriesAxis] PRIMARY KEY ([AnalysisSeries_ID], [AxisRole]), + CONSTRAINT [CK_AnalysisSeriesAxis_AxisRole] CHECK (AxisRole IN (0, 1)) +); + +CREATE TABLE [dbo].[Annotation] ( + [Annotation_ID] INT IDENTITY(1,1) NOT NULL, + [Stream_ID] INT NOT NULL, + [AnnotationKind_ID] INT NOT NULL, + [StartTime] DATETIME2(7) NOT NULL, + [EndTime] DATETIME2(7), + [AuthorPerson_ID] INT, + [Campaign_ID] INT, + [EquipmentEvent_ID] INT, + [Title] NVARCHAR(200), + [Comment] NVARCHAR(MAX), + [CreatedDateTime] DATETIME2(7) NOT NULL DEFAULT CURRENT_TIMESTAMP, + [ModifiedDateTime] DATETIME2(7), + [Observation_ID] INT, + CONSTRAINT [PK_Annotation] PRIMARY KEY ([Annotation_ID]) +); + +CREATE TABLE [dbo].[AuditLog] ( + [AuditLog_ID] BIGINT IDENTITY(1,1) NOT NULL, + [UserAccount_ID] INT, + [Action] NVARCHAR(50) NOT NULL, + [ResourceType] NVARCHAR(100) NOT NULL, + [ResourceID] NVARCHAR(255), + [Details] NVARCHAR(MAX), + [Timestamp] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + CONSTRAINT [PK_AuditLog] PRIMARY KEY ([AuditLog_ID]) +); + +CREATE TABLE [dbo].[Campaign] ( + [Campaign_ID] INT IDENTITY(1,1) NOT NULL, + [CampaignKind_ID] INT NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(2000), + [CampaignStartDateTime] DATETIME2(7), + [CampaignEndDateTime] DATETIME2(7), + [ResponsiblePerson_ID] INT, + CONSTRAINT [PK_Campaign] PRIMARY KEY ([Campaign_ID]) +); + +CREATE TABLE [dbo].[CampaignEquipment] ( + [Campaign_ID] INT NOT NULL, + [Equipment_ID] INT NOT NULL, + [Role] NVARCHAR(100), + CONSTRAINT [PK_CampaignEquipment] PRIMARY KEY ([Campaign_ID], [Equipment_ID]) +); + +CREATE TABLE [dbo].[CampaignSamplingLocation] ( + [Campaign_ID] INT NOT NULL, + [SamplingPoint_ID] INT NOT NULL, + [Role] NVARCHAR(100), + CONSTRAINT [PK_CampaignSamplingLocation] PRIMARY KEY ([Campaign_ID], [SamplingPoint_ID]) +); + +CREATE TABLE [dbo].[Channel] ( + [Stream_ID] INT NOT NULL, + [SignalInterface_ID] INT, + [TagName] NVARCHAR(200) NOT NULL, + [ParentChannel_ID] INT, + [ChannelKind_ID] INT NOT NULL DEFAULT 1, + [Parameter_ID] INT, + [DataProvenanceKind_ID] INT, + [ProducedByStep_ID] INT, + [ValueKind_ID] INT NOT NULL DEFAULT 1, + [Unit_ID] INT, + CONSTRAINT [PK_Channel] PRIMARY KEY ([Stream_ID]) +); + +CREATE TABLE [dbo].[ChannelAxis] ( + [Channel_ID] INT NOT NULL, + [AxisRole] INT NOT NULL, + [ValueBinningAxis_ID] INT NOT NULL, + CONSTRAINT [PK_ChannelAxis] PRIMARY KEY ([Channel_ID], [AxisRole]), + CONSTRAINT [CK_MetaDataAxis_AxisRole] CHECK (AxisRole IN (0, 1)) +); + +CREATE TABLE [dbo].[ChannelPortHistory] ( + [ChannelPortHistory_ID] INT IDENTITY(1,1) NOT NULL, + [Channel_ID] INT NOT NULL, + [SignalInterfacePort_ID] INT, + [ValidFrom] DATETIME2(7) NOT NULL, + [ValidTo] DATETIME2(7), + [GatingNote] NVARCHAR(MAX), + CONSTRAINT [PK_ChannelPortHistory] PRIMARY KEY ([ChannelPortHistory_ID]) +); + +CREATE TABLE [dbo].[ChannelTrait] ( + [Stream_ID] INT NOT NULL, + [OperationKind_ID] INT NOT NULL, + CONSTRAINT [PK_ChannelTrait] PRIMARY KEY ([Stream_ID], [OperationKind_ID]) +); + +CREATE TABLE [dbo].[ControlLoop] ( + [ControlLoop_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [ControllerKind_ID] INT NOT NULL, + [FallbackControlLoop_ID] INT, + [AlgorithmReference] NVARCHAR(500), + [Description] NVARCHAR(MAX), + CONSTRAINT [PK_ControlLoop] PRIMARY KEY ([ControlLoop_ID]) +); + +CREATE TABLE [dbo].[ControlLoopApplication] ( + [ControlLoopApplication_ID] INT IDENTITY(1,1) NOT NULL, + [ControlLoop_ID] INT NOT NULL, + [StartTime] DATETIME2(7) NOT NULL, + [EndTime] DATETIME2(7), + [Parameters] NVARCHAR(MAX), + [AppliedByPerson_ID] INT, + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_ControlLoopApplication] PRIMARY KEY ([ControlLoopApplication_ID]) +); + +CREATE TABLE [dbo].[ControlLoopPort] ( + [ControlLoopPort_ID] INT IDENTITY(1,1) NOT NULL, + [ControlLoop_ID] INT NOT NULL, + [Channel_ID] INT NOT NULL, + [ControlLoopPortKind_ID] INT NOT NULL, + CONSTRAINT [PK_ControlLoopPort] PRIMARY KEY ([ControlLoopPort_ID]) +); + +CREATE TABLE [dbo].[DASLocationHistory] ( + [DASLocationHistory_ID] INT IDENTITY(1,1) NOT NULL, + [DataAcquisitionSystem_ID] INT NOT NULL, + [Site_ID] INT NOT NULL, + [Campaign_ID] INT, + [ValidFrom] DATETIME2(7) NOT NULL, + [ValidTo] DATETIME2(7), + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_DASLocationHistory] PRIMARY KEY ([DASLocationHistory_ID]) +); + +CREATE TABLE [dbo].[DataAcquisitionSystem] ( + [DataAcquisitionSystem_ID] INT IDENTITY(1,1) NOT NULL, + [ParentSystem_ID] INT, + [Name] NVARCHAR(200) NOT NULL, + [DataAcquisitionSystemKind_ID] INT, + [Manufacturer] NVARCHAR(100), + [Model] NVARCHAR(100), + [Description] NVARCHAR(MAX), + CONSTRAINT [PK_DataAcquisitionSystem] PRIMARY KEY ([DataAcquisitionSystem_ID]) +); + +CREATE TABLE [dbo].[Dataset] ( + [Dataset_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(2000), + [Purpose] NVARCHAR(500), + [CreatedOn] DATETIME2(7) NOT NULL DEFAULT CURRENT_TIMESTAMP, + [CreatedByPerson_ID] INT, + CONSTRAINT [PK_Dataset] PRIMARY KEY ([Dataset_ID]) +); + +CREATE TABLE [dbo].[DatasetChannel] ( + [Dataset_ID] INT NOT NULL, + [Channel_ID] INT NOT NULL, + CONSTRAINT [PK_DatasetChannel] PRIMARY KEY ([Dataset_ID], [Channel_ID]) +); + +CREATE TABLE [dbo].[Equipment] ( + [Equipment_ID] INT IDENTITY(1,1) NOT NULL, + [EquipmentModel_ID] INT, + [Identifier] NVARCHAR(100), + [SerialNumber] NVARCHAR(100), + [Owner] NVARCHAR(MAX), + [StorageLocation] NVARCHAR(100), + [PurchaseDate] DATE, + [IsActive] BIT NOT NULL DEFAULT 1, + CONSTRAINT [PK_Equipment] PRIMARY KEY ([Equipment_ID]) +); + +CREATE TABLE [dbo].[EquipmentEvent] ( + [EquipmentEvent_ID] INT IDENTITY(1,1) NOT NULL, + [Equipment_ID] INT NOT NULL, + [EquipmentEventKind_ID] INT NOT NULL, + [EventDateTimeStart] DATETIME2(7) NOT NULL, + [IsInstantaneous] BIT NOT NULL DEFAULT 0, + [EventDateTimeEnd] DATETIME2(7), + [PerformedByPerson_ID] INT, + [RecordedByPerson_ID] INT, + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_EquipmentEvent] PRIMARY KEY ([EquipmentEvent_ID]) +); + +CREATE TABLE [dbo].[EquipmentLocationHistory] ( + [EquipmentLocationHistory_ID] INT IDENTITY(1,1) NOT NULL, + [Equipment_ID] INT NOT NULL, + [SamplingPoint_ID] INT NOT NULL, + [ValidFrom] DATETIME2(7) NOT NULL, + [ValidTo] DATETIME2(7), + [Campaign_ID] INT, + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_EquipmentLocationHistory] PRIMARY KEY ([EquipmentLocationHistory_ID]) +); + +CREATE TABLE [dbo].[EquipmentModelHasParameter] ( + [EquipmentModel_ID] INT NOT NULL, + [Parameter_ID] INT NOT NULL, + CONSTRAINT [PK_EquipmentModelHasParameter] PRIMARY KEY ([EquipmentModel_ID], [Parameter_ID]) +); + +CREATE TABLE [dbo].[EquipmentModelHasProcedures] ( + [EquipmentModel_ID] INT NOT NULL, + [Procedure_ID] INT NOT NULL, + CONSTRAINT [PK_EquipmentModelHasProcedures] PRIMARY KEY ([EquipmentModel_ID], [Procedure_ID]) +); + +CREATE TABLE [dbo].[EquipmentWiringHistory] ( + [EquipmentWiringHistory_ID] INT IDENTITY(1,1) NOT NULL, + [Equipment_ID] INT NOT NULL, + [SignalInterface_ID] INT NOT NULL, + [SignalInterfacePort_ID] INT, + [ValidFrom] DATETIME2(7) NOT NULL, + [ValidTo] DATETIME2(7), + [Note] NVARCHAR(MAX), + CONSTRAINT [PK_EquipmentWiringHistory] PRIMARY KEY ([EquipmentWiringHistory_ID]) +); + +CREATE TABLE [dbo].[HydrologicalCharacteristics] ( + [Watershed_ID] INT NOT NULL, + [UrbanArea] REAL, + [Forest] REAL, + [Wetlands] REAL, + [Cropland] REAL, + [Meadow] REAL, + [Grassland] REAL, + CONSTRAINT [PK_HydrologicalCharacteristics] PRIMARY KEY ([Watershed_ID]) +); + +CREATE TABLE [dbo].[LabAnalysis] ( + [LabAnalysis_ID] INT IDENTITY(1,1) NOT NULL, + [LabExperiment_ID] INT NOT NULL, + [AnalysisSeries_ID] INT NOT NULL, + [Sample_ID] INT NOT NULL, + [Replicate] INT NOT NULL DEFAULT 1, + [QualityCode_ID] INT, + [ReviewStatus_ID] INT NOT NULL DEFAULT 1, + [ReviewedByPerson_ID] INT, + [ReviewDateTime] DATETIME2(7), + [Laboratory_ID] INT, + [AnalystPerson_ID] INT, + [Procedure_ID] INT, + [AnalysisDateTime] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_LabAnalysis] PRIMARY KEY ([LabAnalysis_ID]), + CONSTRAINT [UQ_LabAnalysis_Identity] UNIQUE ([LabExperiment_ID], [AnalysisSeries_ID], [Sample_ID], [Replicate]) +); + +CREATE TABLE [dbo].[LabExperiment] ( + [LabExperiment_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Campaign_ID] INT, + [ExperimentDateTime] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + [Description] NVARCHAR(MAX), + [CreatedByPerson_ID] INT, + [LabPanel_ID] INT, + CONSTRAINT [PK_LabExperiment] PRIMARY KEY ([LabExperiment_ID]) +); + +CREATE TABLE [dbo].[LabPanel] ( + [LabPanel_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(MAX), + [CreatedByPerson_ID] INT, + [DefaultSampleCollectionKind_ID] INT, + [DefaultSampleEquipment_ID] INT, + [CreatedAt] DATETIME2(7) NOT NULL DEFAULT GETUTCDATE(), + CONSTRAINT [PK_LabPanel] PRIMARY KEY ([LabPanel_ID]) +); + +CREATE TABLE [dbo].[LabPanelSeries] ( + [LabPanel_ID] INT NOT NULL, + [AnalysisSeries_ID] INT NOT NULL, + CONSTRAINT [PK_LabPanelSeries] PRIMARY KEY ([LabPanel_ID], [AnalysisSeries_ID]) +); + +CREATE TABLE [dbo].[Laboratory] ( + [Laboratory_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Site_ID] INT, + [Description] NVARCHAR(500), + CONSTRAINT [PK_Laboratory] PRIMARY KEY ([Laboratory_ID]) +); + +CREATE TABLE [dbo].[LandUse] ( + [Watershed_ID] INT NOT NULL, + [Commercial] REAL, + [GreenSpaces] REAL, + [Industrial] REAL, + [Institutional] REAL, + [Residential] REAL, + [Agricultural] REAL, + [Recreational] REAL, + CONSTRAINT [PK_LandUse] PRIMARY KEY ([Watershed_ID]) +); + +CREATE TABLE [dbo].[Observation] ( + [Observation_ID] INT IDENTITY(1,1) NOT NULL, + [Channel_ID] INT, + [LabAnalysis_ID] INT, + [Timestamp] DATETIME2(7) NOT NULL, + [ValueKind_ID] INT NOT NULL, + CONSTRAINT [PK_Observation] PRIMARY KEY ([Observation_ID]), + CONSTRAINT [CK_Observation_Source] CHECK ((Channel_ID IS NOT NULL AND LabAnalysis_ID IS NULL) OR (Channel_ID IS NULL AND LabAnalysis_ID IS NOT NULL)) +); + +CREATE TABLE [dbo].[Parameter] ( + [Parameter_ID] INT IDENTITY(1,1) NOT NULL, + [Parameter] NVARCHAR(100), + [Description] NVARCHAR(MAX), + [ENVO_IRI] NVARCHAR(256), + [ValueKind_ID] INT NOT NULL DEFAULT 1, + [QUDT_QuantityKind_IRI] NVARCHAR(256), + CONSTRAINT [PK_Parameter] PRIMARY KEY ([Parameter_ID]) +); + +CREATE TABLE [dbo].[ParameterHasUnit] ( + [Parameter_ID] INT NOT NULL, + [Unit_ID] INT NOT NULL, + CONSTRAINT [PK_ParameterHasUnit] PRIMARY KEY ([Parameter_ID], [Unit_ID]) +); + +CREATE TABLE [dbo].[Procedures] ( + [Procedure_ID] INT IDENTITY(1,1) NOT NULL, + [ProcedureName] NVARCHAR(100), + [ProcedureKind_ID] INT, + [Description] NVARCHAR(MAX), + [ProcedureLocation] NVARCHAR(100), + CONSTRAINT [PK_Procedures] PRIMARY KEY ([Procedure_ID]) +); + +CREATE TABLE [dbo].[ProcessUnit] ( + [ProcessUnit_ID] INT IDENTITY(1,1) NOT NULL, + [Site_ID] INT NOT NULL, + [Tag] NVARCHAR(100) NOT NULL, + [Name] NVARCHAR(255) NOT NULL, + [Description] NVARCHAR(MAX), + [ProcessUnitKind_ID] INT, + [Parent_ID] INT, + CONSTRAINT [PK_ProcessUnit] PRIMARY KEY ([ProcessUnit_ID]), + CONSTRAINT [UQ_ProcessUnit_SiteTag] UNIQUE ([Site_ID], [Tag]) +); + +CREATE TABLE [dbo].[ProcessingLineage] ( + [ProcessingLineage_ID] INT IDENTITY(1,1) NOT NULL, + [ProcessingStep_ID] INT NOT NULL, + [Stream_ID] INT NOT NULL, + [StartTime] DATETIME2(7), + [EndTime] DATETIME2(7), + CONSTRAINT [PK_ProcessingLineage] PRIMARY KEY ([ProcessingLineage_ID]) +); + +CREATE TABLE [dbo].[ProcessingStep] ( + [ProcessingStep_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(MAX), + [MethodName] NVARCHAR(200), + [MethodVersion] NVARCHAR(100), + [OperationKind_ID] INT, + [MethodParameters] NVARCHAR(MAX), + [ExecutedDateTime] DATETIME2(7), + [ExecutedByPerson_ID] INT, + [Dataset_ID] INT, + CONSTRAINT [PK_ProcessingStep] PRIMARY KEY ([ProcessingStep_ID]) +); + +CREATE TABLE [dbo].[Sample] ( + [Sample_ID] INT IDENTITY(1,1) NOT NULL, + [ParentSample_ID] INT, + [SampleKind_ID] INT, + [SamplingPoint_ID] INT NOT NULL, + [SampledByPerson_ID] INT, + [Campaign_ID] INT, + [SampleDateTimeStart] DATETIME2(7) NOT NULL, + [SampleDateTimeEnd] DATETIME2(7), + [SampleCollectionKind_ID] INT, + [SampleEquipment_ID] INT, + [Description] NVARCHAR(500), + CONSTRAINT [PK_Sample] PRIMARY KEY ([Sample_ID]) +); + +CREATE TABLE [dbo].[SamplingPoint] ( + [SamplingPoint_ID] INT IDENTITY(1,1) NOT NULL, + [Site_ID] INT NOT NULL, + [SamplingPoint] NVARCHAR(100) NOT NULL, + [LatitudeWGS84] FLOAT, + [LongitudeWGS84] FLOAT, + [Description] NVARCHAR(MAX), + [PicturePath] NVARCHAR(500), + [ValidFrom] DATETIME2(7), + [ValidTo] DATETIME2(7), + [ProcessUnit_ID] INT, + [CreatedByCampaign_ID] INT, + CONSTRAINT [PK_SamplingPoint] PRIMARY KEY ([SamplingPoint_ID]) +); + +CREATE TABLE [dbo].[SignalInterface] ( + [SignalInterface_ID] INT IDENTITY(1,1) NOT NULL, + [DataAcquisitionSystem_ID] INT NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Manufacturer] NVARCHAR(100), + [Model] NVARCHAR(100), + [SerialNumber] NVARCHAR(100), + [Description] NVARCHAR(MAX), + [IsActive] BIT NOT NULL DEFAULT 1, + CONSTRAINT [PK_SignalInterface] PRIMARY KEY ([SignalInterface_ID]) +); + +CREATE TABLE [dbo].[SignalInterfacePort] ( + [SignalInterfacePort_ID] INT IDENTITY(1,1) NOT NULL, + [SignalInterface_ID] INT NOT NULL, + [PortIdentifier] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(MAX), + [IsActive] BIT NOT NULL DEFAULT 1, + CONSTRAINT [PK_SignalInterfacePort] PRIMARY KEY ([SignalInterfacePort_ID]) +); + +CREATE TABLE [dbo].[Site] ( + [Site_ID] INT IDENTITY(1,1) NOT NULL, + [Watershed_ID] INT, + [Name] NVARCHAR(100), + [SiteKind_ID] INT, + [Description] NVARCHAR(MAX), + [LatitudeWGS84] FLOAT, + [LongitudeWGS84] FLOAT, + [StreetNumber] NVARCHAR(100), + [StreetName] NVARCHAR(100), + [City] NVARCHAR(255), + [PostCode] NVARCHAR(100), + [Province] NVARCHAR(255), + [Country] NVARCHAR(255), + CONSTRAINT [PK_Site] PRIMARY KEY ([Site_ID]) +); + +CREATE TABLE [dbo].[Stream] ( + [Stream_ID] INT IDENTITY(1,1) NOT NULL, + [StreamKind_ID] INT NOT NULL, + CONSTRAINT [PK_Stream] PRIMARY KEY ([Stream_ID]) +); + +CREATE TABLE [dbo].[Value] ( + [Observation_ID] INT NOT NULL, + [Value] FLOAT, + [QualityCode] INT, + CONSTRAINT [PK_Value] PRIMARY KEY ([Observation_ID]) +); + +CREATE TABLE [dbo].[ValueBin] ( + [ValueBin_ID] INT IDENTITY(1,1) NOT NULL, + [ValueBinningAxis_ID] INT NOT NULL, + [BinIndex] INT NOT NULL, + [LowerBound] FLOAT, + [UpperBound] FLOAT, + [NominalValue] FLOAT, + CONSTRAINT [PK_ValueBin] PRIMARY KEY ([ValueBin_ID]), + CONSTRAINT [UQ_ValueBin_AxisIndex] UNIQUE ([ValueBinningAxis_ID], [BinIndex]), + CONSTRAINT [CK_ValueBin_BinValues] CHECK (((LowerBound IS NULL AND UpperBound IS NULL) OR (LowerBound IS NOT NULL AND UpperBound IS NOT NULL)) AND (LowerBound IS NULL OR UpperBound > LowerBound) AND (NominalValue IS NOT NULL OR LowerBound IS NOT NULL) +) +); + +CREATE TABLE [dbo].[ValueBinningAxis] ( + [ValueBinningAxis_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(500), + [NumberOfBins] INT NOT NULL, + [Unit_ID] INT NOT NULL, + [BinKind_ID] INT NOT NULL DEFAULT 1, + CONSTRAINT [PK_ValueBinningAxis] PRIMARY KEY ([ValueBinningAxis_ID]) +); + +CREATE TABLE [dbo].[ValueImage] ( + [Observation_ID] INT NOT NULL, + [ImageWidth] INT NOT NULL, + [ImageHeight] INT NOT NULL, + [NumberOfChannels] INT NOT NULL DEFAULT 3, + [ImageFormat] NVARCHAR(20) NOT NULL, + [FileSizeBytes] BIGINT, + [StorageBackend] NVARCHAR(50) NOT NULL DEFAULT 'FileSystem', + [StoragePath] NVARCHAR(1000) NOT NULL, + [Thumbnail] VARBINARY(MAX), + [QualityCode] INT, + CONSTRAINT [PK_ValueImage] PRIMARY KEY ([Observation_ID]) +); + +CREATE TABLE [dbo].[ValueMatrix] ( + [Observation_ID] INT NOT NULL, + [RowValueBin_ID] INT NOT NULL, + [ColValueBin_ID] INT NOT NULL, + [Value] FLOAT, + [QualityCode] INT, + CONSTRAINT [PK_ValueMatrix] PRIMARY KEY ([Observation_ID], [RowValueBin_ID], [ColValueBin_ID]) +); + +CREATE TABLE [dbo].[ValueVector] ( + [Observation_ID] INT NOT NULL, + [ValueBin_ID] INT NOT NULL, + [Value] FLOAT, + [QualityCode] INT, + CONSTRAINT [PK_ValueVector] PRIMARY KEY ([Observation_ID], [ValueBin_ID]) +); + +CREATE TABLE [dbo].[Watershed] ( + [Watershed_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100), + [Description] NVARCHAR(MAX), + [SurfaceArea] REAL, + [ConcentrationTime] INT, + [ImperviousSurface] REAL, + [ParentWatershed_ID] INT, + [GeometryGeoJSON] NVARCHAR(MAX), + CONSTRAINT [PK_Watershed] PRIMARY KEY ([Watershed_ID]) +); + + + + + + + + + + + + + + + + + + + + + + + +CREATE INDEX [IX_UserAccount_Email] ON [dbo].[UserAccount] ([Email]); + + + + +CREATE INDEX [IX_Annotation_Stream_Time] ON [dbo].[Annotation] ([Stream_ID], [StartTime], [EndTime]); +CREATE INDEX [IX_Annotation_Author] ON [dbo].[Annotation] ([AuthorPerson_ID], [CreatedDateTime]); + +CREATE INDEX [IX_AuditLog_UserAccount_ID] ON [dbo].[AuditLog] ([UserAccount_ID]); +CREATE INDEX [IX_AuditLog_Timestamp] ON [dbo].[AuditLog] ([Timestamp]); +CREATE INDEX [IX_AuditLog_ResourceType] ON [dbo].[AuditLog] ([ResourceType]); + + + + +CREATE UNIQUE INDEX [UQ_Channel_SignalStream] ON [dbo].[Channel] ([SignalInterface_ID], [TagName], [Parameter_ID], [DataProvenanceKind_ID], [ProducedByStep_ID]); +CREATE INDEX [IX_Channel_ParentChannel] ON [dbo].[Channel] ([ParentChannel_ID]); + + +CREATE UNIQUE INDEX [UQ_ChannelPortHistory_ActiveRow] ON [dbo].[ChannelPortHistory] ([Channel_ID]) WHERE [ValidTo] IS NULL; +CREATE INDEX [IX_ChannelPortHistory_Port] ON [dbo].[ChannelPortHistory] ([SignalInterfacePort_ID], [ValidFrom]); + +CREATE INDEX [IX_ChannelTrait_Stream] ON [dbo].[ChannelTrait] ([Stream_ID]); + + +CREATE UNIQUE INDEX [UQ_ControlLoopApplication_ActiveRow] ON [dbo].[ControlLoopApplication] ([ControlLoop_ID]) WHERE [EndTime] IS NULL; + +CREATE UNIQUE INDEX [UQ_ControlLoopPort_LoopChannel] ON [dbo].[ControlLoopPort] ([ControlLoop_ID], [Channel_ID]); + +CREATE UNIQUE INDEX [UQ_DASLocationHistory_ActivePerDAS] ON [dbo].[DASLocationHistory] ([DataAcquisitionSystem_ID]) WHERE [ValidTo] IS NULL; +CREATE INDEX [IX_DASLocationHistory_Site_ValidFrom] ON [dbo].[DASLocationHistory] ([Site_ID], [ValidFrom]); + + + + + +CREATE INDEX [IX_EquipmentEvent_Equipment_Start] ON [dbo].[EquipmentEvent] ([Equipment_ID], [EventDateTimeStart]); + +CREATE UNIQUE INDEX [UQ_EquipmentLocationHistory_ActiveRow] ON [dbo].[EquipmentLocationHistory] ([Equipment_ID]) WHERE [ValidTo] IS NULL; +CREATE INDEX [IX_EquipmentLocationHistory_SamplingPoint] ON [dbo].[EquipmentLocationHistory] ([SamplingPoint_ID], [ValidFrom]); + + + +CREATE UNIQUE INDEX [UQ_EquipmentWiringHistory_ActiveRow] ON [dbo].[EquipmentWiringHistory] ([Equipment_ID]) WHERE [ValidTo] IS NULL; +CREATE INDEX [IX_EquipmentWiringHistory_Interface] ON [dbo].[EquipmentWiringHistory] ([SignalInterface_ID], [ValidFrom]); + + + + + + + + +CREATE UNIQUE INDEX [UQ_Obs_Channel] ON [dbo].[Observation] ([Channel_ID], [Timestamp], [ValueKind_ID]) WHERE Channel_ID IS NOT NULL; +CREATE UNIQUE INDEX [UQ_Obs_Lab] ON [dbo].[Observation] ([LabAnalysis_ID]) WHERE LabAnalysis_ID IS NOT NULL; + + + + + +CREATE INDEX [IX_ProcessingLineage_Stream] ON [dbo].[ProcessingLineage] ([Stream_ID]); +CREATE INDEX [IX_Lineage_Step] ON [dbo].[ProcessingLineage] ([ProcessingStep_ID]); + + + + +CREATE UNIQUE INDEX [UQ_SignalInterface_DAS_Name] ON [dbo].[SignalInterface] ([DataAcquisitionSystem_ID], [Name]); + +CREATE UNIQUE INDEX [UQ_SignalInterfacePort_Interface_PortId] ON [dbo].[SignalInterfacePort] ([SignalInterface_ID], [PortIdentifier]); + + + + + + + + + + +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_Parameter_ID] FOREIGN KEY ([Parameter_ID]) REFERENCES [dbo].[Parameter] ([Parameter_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_SamplingPoint_ID] FOREIGN KEY ([SamplingPoint_ID]) REFERENCES [dbo].[SamplingPoint] ([SamplingPoint_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_ValueKind_ID] FOREIGN KEY ([ValueKind_ID]) REFERENCES [dbo].[ValueKind] ([ValueKind_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_Unit_ID] FOREIGN KEY ([Unit_ID]) REFERENCES [dbo].[Unit] ([Unit_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[AnalysisSeriesAxis] ADD CONSTRAINT [FK_AnalysisSeriesAxis_AnalysisSeries_ID] FOREIGN KEY ([AnalysisSeries_ID]) REFERENCES [dbo].[AnalysisSeries] ([Stream_ID]); +ALTER TABLE [dbo].[AnalysisSeriesAxis] ADD CONSTRAINT [FK_AnalysisSeriesAxis_ValueBinningAxis_ID] FOREIGN KEY ([ValueBinningAxis_ID]) REFERENCES [dbo].[ValueBinningAxis] ([ValueBinningAxis_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_AnnotationKind_ID] FOREIGN KEY ([AnnotationKind_ID]) REFERENCES [dbo].[AnnotationKind] ([AnnotationKind_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_AuthorPerson_ID] FOREIGN KEY ([AuthorPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_EquipmentEvent_ID] FOREIGN KEY ([EquipmentEvent_ID]) REFERENCES [dbo].[EquipmentEvent] ([EquipmentEvent_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[AuditLog] ADD CONSTRAINT [FK_AuditLog_UserAccount_ID] FOREIGN KEY ([UserAccount_ID]) REFERENCES [dbo].[UserAccount] ([UserAccount_ID]); +ALTER TABLE [dbo].[Campaign] ADD CONSTRAINT [FK_Campaign_CampaignKind_ID] FOREIGN KEY ([CampaignKind_ID]) REFERENCES [dbo].[CampaignKind] ([CampaignKind_ID]); +ALTER TABLE [dbo].[Campaign] ADD CONSTRAINT [FK_Campaign_ResponsiblePerson_ID] FOREIGN KEY ([ResponsiblePerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[CampaignEquipment] ADD CONSTRAINT [FK_CampaignEquipment_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[CampaignEquipment] ADD CONSTRAINT [FK_CampaignEquipment_Equipment_ID] FOREIGN KEY ([Equipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[CampaignSamplingLocation] ADD CONSTRAINT [FK_CampaignSamplingLocation_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[CampaignSamplingLocation] ADD CONSTRAINT [FK_CampaignSamplingLocation_SamplingPoint_ID] FOREIGN KEY ([SamplingPoint_ID]) REFERENCES [dbo].[SamplingPoint] ([SamplingPoint_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_SignalInterface_ID] FOREIGN KEY ([SignalInterface_ID]) REFERENCES [dbo].[SignalInterface] ([SignalInterface_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_ParentChannel_ID] FOREIGN KEY ([ParentChannel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_ChannelKind_ID] FOREIGN KEY ([ChannelKind_ID]) REFERENCES [dbo].[ChannelKind] ([ChannelKind_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_Parameter_ID] FOREIGN KEY ([Parameter_ID]) REFERENCES [dbo].[Parameter] ([Parameter_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_DataProvenanceKind_ID] FOREIGN KEY ([DataProvenanceKind_ID]) REFERENCES [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_ProducedByStep_ID] FOREIGN KEY ([ProducedByStep_ID]) REFERENCES [dbo].[ProcessingStep] ([ProcessingStep_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_ValueKind_ID] FOREIGN KEY ([ValueKind_ID]) REFERENCES [dbo].[ValueKind] ([ValueKind_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_Unit_ID] FOREIGN KEY ([Unit_ID]) REFERENCES [dbo].[Unit] ([Unit_ID]); +ALTER TABLE [dbo].[ChannelAxis] ADD CONSTRAINT [FK_ChannelAxis_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[ChannelAxis] ADD CONSTRAINT [FK_ChannelAxis_ValueBinningAxis_ID] FOREIGN KEY ([ValueBinningAxis_ID]) REFERENCES [dbo].[ValueBinningAxis] ([ValueBinningAxis_ID]); +ALTER TABLE [dbo].[ChannelPortHistory] ADD CONSTRAINT [FK_ChannelPortHistory_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[ChannelPortHistory] ADD CONSTRAINT [FK_ChannelPortHistory_SignalInterfacePort_ID] FOREIGN KEY ([SignalInterfacePort_ID]) REFERENCES [dbo].[SignalInterfacePort] ([SignalInterfacePort_ID]); +ALTER TABLE [dbo].[ChannelTrait] ADD CONSTRAINT [FK_ChannelTrait_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[ChannelTrait] ADD CONSTRAINT [FK_ChannelTrait_OperationKind_ID] FOREIGN KEY ([OperationKind_ID]) REFERENCES [dbo].[OperationKind] ([OperationKind_ID]); +ALTER TABLE [dbo].[ControlLoop] ADD CONSTRAINT [FK_ControlLoop_ControllerKind_ID] FOREIGN KEY ([ControllerKind_ID]) REFERENCES [dbo].[ControllerKind] ([ControllerKind_ID]); +ALTER TABLE [dbo].[ControlLoop] ADD CONSTRAINT [FK_ControlLoop_FallbackControlLoop_ID] FOREIGN KEY ([FallbackControlLoop_ID]) REFERENCES [dbo].[ControlLoop] ([ControlLoop_ID]); +ALTER TABLE [dbo].[ControlLoopApplication] ADD CONSTRAINT [FK_ControlLoopApplication_ControlLoop_ID] FOREIGN KEY ([ControlLoop_ID]) REFERENCES [dbo].[ControlLoop] ([ControlLoop_ID]); +ALTER TABLE [dbo].[ControlLoopApplication] ADD CONSTRAINT [FK_ControlLoopApplication_AppliedByPerson_ID] FOREIGN KEY ([AppliedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[ControlLoopPort] ADD CONSTRAINT [FK_ControlLoopPort_ControlLoop_ID] FOREIGN KEY ([ControlLoop_ID]) REFERENCES [dbo].[ControlLoop] ([ControlLoop_ID]); +ALTER TABLE [dbo].[ControlLoopPort] ADD CONSTRAINT [FK_ControlLoopPort_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[ControlLoopPort] ADD CONSTRAINT [FK_ControlLoopPort_ControlLoopPortKind_ID] FOREIGN KEY ([ControlLoopPortKind_ID]) REFERENCES [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID]); +ALTER TABLE [dbo].[DASLocationHistory] ADD CONSTRAINT [FK_DASLocationHistory_DataAcquisitionSystem_ID] FOREIGN KEY ([DataAcquisitionSystem_ID]) REFERENCES [dbo].[DataAcquisitionSystem] ([DataAcquisitionSystem_ID]); +ALTER TABLE [dbo].[DASLocationHistory] ADD CONSTRAINT [FK_DASLocationHistory_Site_ID] FOREIGN KEY ([Site_ID]) REFERENCES [dbo].[Site] ([Site_ID]); +ALTER TABLE [dbo].[DASLocationHistory] ADD CONSTRAINT [FK_DASLocationHistory_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[DataAcquisitionSystem] ADD CONSTRAINT [FK_DataAcquisitionSystem_ParentSystem_ID] FOREIGN KEY ([ParentSystem_ID]) REFERENCES [dbo].[DataAcquisitionSystem] ([DataAcquisitionSystem_ID]); +ALTER TABLE [dbo].[DataAcquisitionSystem] ADD CONSTRAINT [FK_DataAcquisitionSystem_DataAcquisitionSystemKind_ID] FOREIGN KEY ([DataAcquisitionSystemKind_ID]) REFERENCES [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID]); +ALTER TABLE [dbo].[Dataset] ADD CONSTRAINT [FK_Dataset_CreatedByPerson_ID] FOREIGN KEY ([CreatedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[DatasetChannel] ADD CONSTRAINT [FK_DatasetChannel_Dataset_ID] FOREIGN KEY ([Dataset_ID]) REFERENCES [dbo].[Dataset] ([Dataset_ID]); +ALTER TABLE [dbo].[DatasetChannel] ADD CONSTRAINT [FK_DatasetChannel_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[Equipment] ADD CONSTRAINT [FK_Equipment_EquipmentModel_ID] FOREIGN KEY ([EquipmentModel_ID]) REFERENCES [dbo].[EquipmentModel] ([EquipmentModel_ID]); +ALTER TABLE [dbo].[EquipmentEvent] ADD CONSTRAINT [FK_EquipmentEvent_Equipment_ID] FOREIGN KEY ([Equipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[EquipmentEvent] ADD CONSTRAINT [FK_EquipmentEvent_EquipmentEventKind_ID] FOREIGN KEY ([EquipmentEventKind_ID]) REFERENCES [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID]); +ALTER TABLE [dbo].[EquipmentEvent] ADD CONSTRAINT [FK_EquipmentEvent_PerformedByPerson_ID] FOREIGN KEY ([PerformedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[EquipmentEvent] ADD CONSTRAINT [FK_EquipmentEvent_RecordedByPerson_ID] FOREIGN KEY ([RecordedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[EquipmentLocationHistory] ADD CONSTRAINT [FK_EquipmentLocationHistory_Equipment_ID] FOREIGN KEY ([Equipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[EquipmentLocationHistory] ADD CONSTRAINT [FK_EquipmentLocationHistory_SamplingPoint_ID] FOREIGN KEY ([SamplingPoint_ID]) REFERENCES [dbo].[SamplingPoint] ([SamplingPoint_ID]); +ALTER TABLE [dbo].[EquipmentLocationHistory] ADD CONSTRAINT [FK_EquipmentLocationHistory_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[EquipmentModelHasParameter] ADD CONSTRAINT [FK_EquipmentModelHasParameter_EquipmentModel_ID] FOREIGN KEY ([EquipmentModel_ID]) REFERENCES [dbo].[EquipmentModel] ([EquipmentModel_ID]); +ALTER TABLE [dbo].[EquipmentModelHasParameter] ADD CONSTRAINT [FK_EquipmentModelHasParameter_Parameter_ID] FOREIGN KEY ([Parameter_ID]) REFERENCES [dbo].[Parameter] ([Parameter_ID]); +ALTER TABLE [dbo].[EquipmentModelHasProcedures] ADD CONSTRAINT [FK_EquipmentModelHasProcedures_EquipmentModel_ID] FOREIGN KEY ([EquipmentModel_ID]) REFERENCES [dbo].[EquipmentModel] ([EquipmentModel_ID]); +ALTER TABLE [dbo].[EquipmentModelHasProcedures] ADD CONSTRAINT [FK_EquipmentModelHasProcedures_Procedure_ID] FOREIGN KEY ([Procedure_ID]) REFERENCES [dbo].[Procedures] ([Procedure_ID]); +ALTER TABLE [dbo].[EquipmentWiringHistory] ADD CONSTRAINT [FK_EquipmentWiringHistory_Equipment_ID] FOREIGN KEY ([Equipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[EquipmentWiringHistory] ADD CONSTRAINT [FK_EquipmentWiringHistory_SignalInterface_ID] FOREIGN KEY ([SignalInterface_ID]) REFERENCES [dbo].[SignalInterface] ([SignalInterface_ID]); +ALTER TABLE [dbo].[EquipmentWiringHistory] ADD CONSTRAINT [FK_EquipmentWiringHistory_SignalInterfacePort_ID] FOREIGN KEY ([SignalInterfacePort_ID]) REFERENCES [dbo].[SignalInterfacePort] ([SignalInterfacePort_ID]); +ALTER TABLE [dbo].[HydrologicalCharacteristics] ADD CONSTRAINT [FK_HydrologicalCharacteristics_Watershed_ID] FOREIGN KEY ([Watershed_ID]) REFERENCES [dbo].[Watershed] ([Watershed_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_LabExperiment_ID] FOREIGN KEY ([LabExperiment_ID]) REFERENCES [dbo].[LabExperiment] ([LabExperiment_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_AnalysisSeries_ID] FOREIGN KEY ([AnalysisSeries_ID]) REFERENCES [dbo].[AnalysisSeries] ([Stream_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_Sample_ID] FOREIGN KEY ([Sample_ID]) REFERENCES [dbo].[Sample] ([Sample_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_QualityCode_ID] FOREIGN KEY ([QualityCode_ID]) REFERENCES [dbo].[QualityCode] ([QualityCode_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_ReviewStatus_ID] FOREIGN KEY ([ReviewStatus_ID]) REFERENCES [dbo].[ReviewStatus] ([ReviewStatus_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_ReviewedByPerson_ID] FOREIGN KEY ([ReviewedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_Laboratory_ID] FOREIGN KEY ([Laboratory_ID]) REFERENCES [dbo].[Laboratory] ([Laboratory_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_AnalystPerson_ID] FOREIGN KEY ([AnalystPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_Procedure_ID] FOREIGN KEY ([Procedure_ID]) REFERENCES [dbo].[Procedures] ([Procedure_ID]); +ALTER TABLE [dbo].[LabExperiment] ADD CONSTRAINT [FK_LabExperiment_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[LabExperiment] ADD CONSTRAINT [FK_LabExperiment_CreatedByPerson_ID] FOREIGN KEY ([CreatedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[LabExperiment] ADD CONSTRAINT [FK_LabExperiment_LabPanel_ID] FOREIGN KEY ([LabPanel_ID]) REFERENCES [dbo].[LabPanel] ([LabPanel_ID]); +ALTER TABLE [dbo].[LabPanel] ADD CONSTRAINT [FK_LabPanel_CreatedByPerson_ID] FOREIGN KEY ([CreatedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[LabPanel] ADD CONSTRAINT [FK_LabPanel_DefaultSampleCollectionKind_ID] FOREIGN KEY ([DefaultSampleCollectionKind_ID]) REFERENCES [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID]); +ALTER TABLE [dbo].[LabPanel] ADD CONSTRAINT [FK_LabPanel_DefaultSampleEquipment_ID] FOREIGN KEY ([DefaultSampleEquipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[LabPanelSeries] ADD CONSTRAINT [FK_LabPanelSeries_LabPanel_ID] FOREIGN KEY ([LabPanel_ID]) REFERENCES [dbo].[LabPanel] ([LabPanel_ID]); +ALTER TABLE [dbo].[LabPanelSeries] ADD CONSTRAINT [FK_LabPanelSeries_AnalysisSeries_ID] FOREIGN KEY ([AnalysisSeries_ID]) REFERENCES [dbo].[AnalysisSeries] ([Stream_ID]); +ALTER TABLE [dbo].[Laboratory] ADD CONSTRAINT [FK_Laboratory_Site_ID] FOREIGN KEY ([Site_ID]) REFERENCES [dbo].[Site] ([Site_ID]); +ALTER TABLE [dbo].[LandUse] ADD CONSTRAINT [FK_LandUse_Watershed_ID] FOREIGN KEY ([Watershed_ID]) REFERENCES [dbo].[Watershed] ([Watershed_ID]); +ALTER TABLE [dbo].[Observation] ADD CONSTRAINT [FK_Observation_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[Observation] ADD CONSTRAINT [FK_Observation_LabAnalysis_ID] FOREIGN KEY ([LabAnalysis_ID]) REFERENCES [dbo].[LabAnalysis] ([LabAnalysis_ID]); +ALTER TABLE [dbo].[Observation] ADD CONSTRAINT [FK_Observation_ValueKind_ID] FOREIGN KEY ([ValueKind_ID]) REFERENCES [dbo].[ValueKind] ([ValueKind_ID]); +ALTER TABLE [dbo].[Parameter] ADD CONSTRAINT [FK_Parameter_ValueKind_ID] FOREIGN KEY ([ValueKind_ID]) REFERENCES [dbo].[ValueKind] ([ValueKind_ID]); +ALTER TABLE [dbo].[ParameterHasUnit] ADD CONSTRAINT [FK_ParameterHasUnit_Parameter_ID] FOREIGN KEY ([Parameter_ID]) REFERENCES [dbo].[Parameter] ([Parameter_ID]); +ALTER TABLE [dbo].[ParameterHasUnit] ADD CONSTRAINT [FK_ParameterHasUnit_Unit_ID] FOREIGN KEY ([Unit_ID]) REFERENCES [dbo].[Unit] ([Unit_ID]); +ALTER TABLE [dbo].[Procedures] ADD CONSTRAINT [FK_Procedures_ProcedureKind_ID] FOREIGN KEY ([ProcedureKind_ID]) REFERENCES [dbo].[ProcedureKind] ([ProcedureKind_ID]); +ALTER TABLE [dbo].[ProcessUnit] ADD CONSTRAINT [FK_ProcessUnit_Site_ID] FOREIGN KEY ([Site_ID]) REFERENCES [dbo].[Site] ([Site_ID]); +ALTER TABLE [dbo].[ProcessUnit] ADD CONSTRAINT [FK_ProcessUnit_ProcessUnitKind_ID] FOREIGN KEY ([ProcessUnitKind_ID]) REFERENCES [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID]); +ALTER TABLE [dbo].[ProcessUnit] ADD CONSTRAINT [FK_ProcessUnit_Parent_ID] FOREIGN KEY ([Parent_ID]) REFERENCES [dbo].[ProcessUnit] ([ProcessUnit_ID]); +ALTER TABLE [dbo].[ProcessingLineage] ADD CONSTRAINT [FK_ProcessingLineage_ProcessingStep_ID] FOREIGN KEY ([ProcessingStep_ID]) REFERENCES [dbo].[ProcessingStep] ([ProcessingStep_ID]); +ALTER TABLE [dbo].[ProcessingLineage] ADD CONSTRAINT [FK_ProcessingLineage_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[ProcessingStep] ADD CONSTRAINT [FK_ProcessingStep_OperationKind_ID] FOREIGN KEY ([OperationKind_ID]) REFERENCES [dbo].[OperationKind] ([OperationKind_ID]); +ALTER TABLE [dbo].[ProcessingStep] ADD CONSTRAINT [FK_ProcessingStep_ExecutedByPerson_ID] FOREIGN KEY ([ExecutedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[ProcessingStep] ADD CONSTRAINT [FK_ProcessingStep_Dataset_ID] FOREIGN KEY ([Dataset_ID]) REFERENCES [dbo].[Dataset] ([Dataset_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_ParentSample_ID] FOREIGN KEY ([ParentSample_ID]) REFERENCES [dbo].[Sample] ([Sample_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SampleKind_ID] FOREIGN KEY ([SampleKind_ID]) REFERENCES [dbo].[SampleKind] ([SampleKind_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SamplingPoint_ID] FOREIGN KEY ([SamplingPoint_ID]) REFERENCES [dbo].[SamplingPoint] ([SamplingPoint_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SampledByPerson_ID] FOREIGN KEY ([SampledByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SampleCollectionKind_ID] FOREIGN KEY ([SampleCollectionKind_ID]) REFERENCES [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SampleEquipment_ID] FOREIGN KEY ([SampleEquipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[SamplingPoint] ADD CONSTRAINT [FK_SamplingPoint_Site_ID] FOREIGN KEY ([Site_ID]) REFERENCES [dbo].[Site] ([Site_ID]); +ALTER TABLE [dbo].[SamplingPoint] ADD CONSTRAINT [FK_SamplingPoint_ProcessUnit_ID] FOREIGN KEY ([ProcessUnit_ID]) REFERENCES [dbo].[ProcessUnit] ([ProcessUnit_ID]); +ALTER TABLE [dbo].[SamplingPoint] ADD CONSTRAINT [FK_SamplingPoint_CreatedByCampaign_ID] FOREIGN KEY ([CreatedByCampaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[SignalInterface] ADD CONSTRAINT [FK_SignalInterface_DataAcquisitionSystem_ID] FOREIGN KEY ([DataAcquisitionSystem_ID]) REFERENCES [dbo].[DataAcquisitionSystem] ([DataAcquisitionSystem_ID]); +ALTER TABLE [dbo].[SignalInterfacePort] ADD CONSTRAINT [FK_SignalInterfacePort_SignalInterface_ID] FOREIGN KEY ([SignalInterface_ID]) REFERENCES [dbo].[SignalInterface] ([SignalInterface_ID]); +ALTER TABLE [dbo].[Site] ADD CONSTRAINT [FK_Site_Watershed_ID] FOREIGN KEY ([Watershed_ID]) REFERENCES [dbo].[Watershed] ([Watershed_ID]); +ALTER TABLE [dbo].[Site] ADD CONSTRAINT [FK_Site_SiteKind_ID] FOREIGN KEY ([SiteKind_ID]) REFERENCES [dbo].[SiteKind] ([SiteKind_ID]); +ALTER TABLE [dbo].[Stream] ADD CONSTRAINT [FK_Stream_StreamKind_ID] FOREIGN KEY ([StreamKind_ID]) REFERENCES [dbo].[StreamKind] ([StreamKind_ID]); +ALTER TABLE [dbo].[Value] ADD CONSTRAINT [FK_Value_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[ValueBin] ADD CONSTRAINT [FK_ValueBin_ValueBinningAxis_ID] FOREIGN KEY ([ValueBinningAxis_ID]) REFERENCES [dbo].[ValueBinningAxis] ([ValueBinningAxis_ID]); +ALTER TABLE [dbo].[ValueBinningAxis] ADD CONSTRAINT [FK_ValueBinningAxis_Unit_ID] FOREIGN KEY ([Unit_ID]) REFERENCES [dbo].[Unit] ([Unit_ID]); +ALTER TABLE [dbo].[ValueBinningAxis] ADD CONSTRAINT [FK_ValueBinningAxis_BinKind_ID] FOREIGN KEY ([BinKind_ID]) REFERENCES [dbo].[BinKind] ([BinKind_ID]); +ALTER TABLE [dbo].[ValueImage] ADD CONSTRAINT [FK_ValueImage_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[ValueMatrix] ADD CONSTRAINT [FK_ValueMatrix_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[ValueMatrix] ADD CONSTRAINT [FK_ValueMatrix_RowValueBin] FOREIGN KEY ([RowValueBin_ID]) REFERENCES [dbo].[ValueBin] ([ValueBin_ID]); +ALTER TABLE [dbo].[ValueMatrix] ADD CONSTRAINT [FK_ValueMatrix_ColValueBin] FOREIGN KEY ([ColValueBin_ID]) REFERENCES [dbo].[ValueBin] ([ValueBin_ID]); +ALTER TABLE [dbo].[ValueVector] ADD CONSTRAINT [FK_ValueVector_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[ValueVector] ADD CONSTRAINT [FK_ValueVector_ValueBin_ID] FOREIGN KEY ([ValueBin_ID]) REFERENCES [dbo].[ValueBin] ([ValueBin_ID]); +ALTER TABLE [dbo].[Watershed] ADD CONSTRAINT [FK_Watershed_ParentWatershed_ID] FOREIGN KEY ([ParentWatershed_ID]) REFERENCES [dbo].[Watershed] ([Watershed_ID]); + +-- Views +GO +CREATE OR ALTER VIEW [dbo].[vw_ChannelResolved] AS +SELECT + c.[Stream_ID], + c.[SignalInterface_ID], + c.[TagName], + cph.[SignalInterfacePort_ID], + c.[ParentChannel_ID], + c.[ChannelKind_ID], + c.[Parameter_ID], + c.[DataProvenanceKind_ID], + c.[ProducedByStep_ID], + c.[ValueKind_ID], + c.[Unit_ID] +FROM [dbo].[Channel] c +LEFT JOIN [dbo].[ChannelPortHistory] cph + ON cph.[Channel_ID] = c.[Stream_ID] + AND cph.[ValidTo] IS NULL; + +GO +CREATE OR ALTER VIEW [dbo].[vw_DeploymentCoherence] AS +SELECT + dlh.[DataAcquisitionSystem_ID] AS DAS_ID, + das.[Name] AS DASName, + dlh.[Site_ID] AS DASSite_ID, + dsite.[Name] AS DASSiteName, + e.[Equipment_ID] AS Equipment_ID, + e.[Identifier] AS EquipmentName, + sp.[Site_ID] AS EquipmentSite_ID, + esite.[Name] AS EquipmentSiteName, + sp.[SamplingPoint_ID] AS SamplingPoint_ID, + sp.[SamplingPoint] AS SamplingPointName +FROM [dbo].[DASLocationHistory] dlh +JOIN [dbo].[DataAcquisitionSystem] das ON das.[DataAcquisitionSystem_ID] = dlh.[DataAcquisitionSystem_ID] +JOIN [dbo].[SignalInterface] si ON si.[DataAcquisitionSystem_ID] = dlh.[DataAcquisitionSystem_ID] +JOIN [dbo].[EquipmentWiringHistory] ewh ON ewh.[SignalInterface_ID] = si.[SignalInterface_ID] + AND ewh.[ValidTo] IS NULL +JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = ewh.[Equipment_ID] +JOIN [dbo].[EquipmentLocationHistory] elh ON elh.[Equipment_ID] = e.[Equipment_ID] + AND elh.[ValidTo] IS NULL +JOIN [dbo].[SamplingPoint] sp ON sp.[SamplingPoint_ID] = elh.[SamplingPoint_ID] +LEFT JOIN [dbo].[Site] dsite ON dsite.[Site_ID] = dlh.[Site_ID] +LEFT JOIN [dbo].[Site] esite ON esite.[Site_ID] = sp.[Site_ID] +WHERE dlh.[ValidTo] IS NULL + AND sp.[Site_ID] <> dlh.[Site_ID]; + +GO +CREATE OR ALTER VIEW [dbo].[vw_InactiveParentReferences] AS +SELECT + N'active-wiring->interface' AS ReferenceType, + ewh.[EquipmentWiringHistory_ID] AS WiringHistoryID, + ewh.[Equipment_ID] AS EquipmentID, + si.[SignalInterface_ID] AS ParentID, + si.[Name] AS ParentLabel +FROM [dbo].[EquipmentWiringHistory] ewh +JOIN [dbo].[SignalInterface] si ON si.[SignalInterface_ID] = ewh.[SignalInterface_ID] +WHERE ewh.[ValidTo] IS NULL + AND si.[IsActive] = 0 +UNION ALL +SELECT + N'active-wiring->port' AS ReferenceType, + ewh.[EquipmentWiringHistory_ID] AS WiringHistoryID, + ewh.[Equipment_ID] AS EquipmentID, + sip.[SignalInterfacePort_ID] AS ParentID, + sip.[PortIdentifier] AS ParentLabel +FROM [dbo].[EquipmentWiringHistory] ewh +JOIN [dbo].[SignalInterfacePort] sip ON sip.[SignalInterfacePort_ID] = ewh.[SignalInterfacePort_ID] +WHERE ewh.[ValidTo] IS NULL + AND sip.[IsActive] = 0; + +GO +CREATE OR ALTER VIEW [dbo].[vw_UnlinkedChannels] AS +SELECT + c.[Stream_ID] AS ChannelID, + c.[TagName] AS TagName, + c.[SignalInterface_ID] AS SignalInterfaceID, + si.[Name] AS SignalInterfaceName, + COUNT(o.[Observation_ID]) AS ObservationCount, + MIN(o.[Timestamp]) AS FirstObservation, + MAX(o.[Timestamp]) AS LastObservation +FROM [dbo].[Channel] c +JOIN [dbo].[Observation] o ON o.[Channel_ID] = c.[Stream_ID] +LEFT JOIN [dbo].[SignalInterface] si ON si.[SignalInterface_ID] = c.[SignalInterface_ID] +WHERE c.[SignalInterface_ID] IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM [dbo].[EquipmentWiringHistory] ewh + WHERE ewh.[SignalInterface_ID] = c.[SignalInterface_ID] + AND ewh.[ValidTo] IS NULL + ) +GROUP BY c.[Stream_ID], c.[TagName], c.[SignalInterface_ID], si.[Name]; + +GO +CREATE OR ALTER VIEW [dbo].[vw_ChannelEquipmentAtTime] AS +WITH channel_wiring AS ( + SELECT + o.[Observation_ID] AS ObservationID, + c.[Stream_ID] AS ChannelID, + o.[Timestamp] AS Timestamp, + c.[SignalInterface_ID], + c.[SignalInterfacePort_ID], + ewh.[Equipment_ID] AS EquipmentID, + ROW_NUMBER() OVER ( + PARTITION BY o.[Observation_ID] + ORDER BY + CASE WHEN ewh.[SignalInterfacePort_ID] IS NOT NULL THEN 0 ELSE 1 END, + ewh.[ValidFrom] DESC + ) AS rn, + COUNT(*) OVER (PARTITION BY o.[Observation_ID]) AS match_count + FROM [dbo].[Observation] o + JOIN [dbo].[vw_ChannelResolved] c ON c.[Stream_ID] = o.[Channel_ID] + LEFT JOIN [dbo].[EquipmentWiringHistory] ewh ON ewh.[SignalInterface_ID] = c.[SignalInterface_ID] + AND ( + ewh.[SignalInterfacePort_ID] = c.[SignalInterfacePort_ID] + OR (ewh.[SignalInterfacePort_ID] IS NULL AND c.[SignalInterfacePort_ID] IS NULL) + OR c.[SignalInterfacePort_ID] IS NULL + ) + AND ewh.[ValidFrom] <= o.[Timestamp] + AND (ewh.[ValidTo] IS NULL OR ewh.[ValidTo] > o.[Timestamp]) +) +SELECT + cw.ObservationID, + cw.ChannelID, + cw.Timestamp, + CASE WHEN cw.match_count > 1 AND cw.[SignalInterfacePort_ID] IS NULL THEN NULL ELSE cw.EquipmentID END AS EquipmentID, + e.[Identifier] AS EquipmentName, + CASE + WHEN cw.EquipmentID IS NULL AND cw.match_count = 0 THEN N'unlinked' + WHEN cw.match_count > 1 AND cw.[SignalInterfacePort_ID] IS NULL THEN N'ambiguous' + ELSE N'resolved' + END AS Resolution +FROM channel_wiring cw +LEFT JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = cw.EquipmentID +WHERE cw.rn = 1; + +GO +CREATE OR ALTER VIEW [dbo].[vw_ChannelStatus] AS +SELECT + statusC.[Stream_ID] AS StatusChannelID, + valueC.[Stream_ID] AS MeasurementChannelID, + e.[Equipment_ID] AS EquipmentID, + e.[Identifier] AS EquipmentName, + p.[Parameter] AS MeasurementParameter, + o.[Timestamp], + CAST(v.[Value] AS INT) AS StatusCodeID +FROM [dbo].[Value] v +JOIN [dbo].[Observation] o ON o.[Observation_ID] = v.[Observation_ID] +JOIN [dbo].[Channel] statusC ON statusC.[Stream_ID] = o.[Channel_ID] +JOIN [dbo].[ChannelKind] role ON role.[ChannelKind_ID] = statusC.[ChannelKind_ID] +JOIN [dbo].[vw_ChannelResolved] valueC ON valueC.[Stream_ID] = statusC.[ParentChannel_ID] +JOIN [dbo].[Parameter] p ON p.[Parameter_ID] = valueC.[Parameter_ID] +LEFT JOIN [dbo].[EquipmentWiringHistory] ewh + ON ewh.[SignalInterface_ID] = valueC.[SignalInterface_ID] + AND ( + ewh.[SignalInterfacePort_ID] = valueC.[SignalInterfacePort_ID] + OR valueC.[SignalInterfacePort_ID] IS NULL + ) + AND ewh.[ValidTo] IS NULL +LEFT JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = ewh.[Equipment_ID] +WHERE role.[Name] = N'Status' + AND statusC.[ParentChannel_ID] IS NOT NULL; + +GO +CREATE OR ALTER VIEW [dbo].[vw_DeviceStatus] AS +SELECT + statusC.[Stream_ID] AS StatusChannelID, + e.[Equipment_ID] AS EquipmentID, + e.[Identifier] AS EquipmentName, + o.[Timestamp], + CAST(v.[Value] AS INT) AS StatusCodeID +FROM [dbo].[Value] v +JOIN [dbo].[Observation] o ON o.[Observation_ID] = v.[Observation_ID] +JOIN [dbo].[Channel] statusC ON statusC.[Stream_ID] = o.[Channel_ID] +JOIN [dbo].[ChannelKind] role ON role.[ChannelKind_ID] = statusC.[ChannelKind_ID] +JOIN [dbo].[vw_ChannelResolved] valueC ON valueC.[Stream_ID] = statusC.[ParentChannel_ID] +JOIN [dbo].[EquipmentWiringHistory] ewh + ON ewh.[SignalInterface_ID] = valueC.[SignalInterface_ID] + AND ( + ewh.[SignalInterfacePort_ID] = valueC.[SignalInterfacePort_ID] + OR valueC.[SignalInterfacePort_ID] IS NULL + ) + AND ewh.[ValidTo] IS NULL +JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = ewh.[Equipment_ID] +WHERE role.[Name] = N'Status'; + +GO +CREATE OR ALTER VIEW [dbo].[vw_ChannelLocationAtTime] AS +SELECT + cea.ObservationID, + cea.ChannelID, + cea.Timestamp, + cea.EquipmentID, + cea.Resolution, + elh.[SamplingPoint_ID] AS SamplingPointID, + sp.[SamplingPoint] AS SamplingPointName, + CASE + WHEN cea.EquipmentID IS NULL THEN N'no-equipment' + WHEN elh.[SamplingPoint_ID] IS NOT NULL THEN N'resolved' + ELSE N'no-location' + END AS LocationResolution +FROM [dbo].[vw_ChannelEquipmentAtTime] cea +LEFT JOIN [dbo].[EquipmentLocationHistory] elh ON elh.[Equipment_ID] = cea.EquipmentID + AND elh.[ValidFrom] <= cea.Timestamp + AND (elh.[ValidTo] IS NULL OR elh.[ValidTo] > cea.Timestamp) +LEFT JOIN [dbo].[SamplingPoint] sp ON sp.[SamplingPoint_ID] = elh.[SamplingPoint_ID]; + + +GO +-- Schema version stamp (from schema_dictionary/version.yaml) +INSERT INTO [dbo].[SchemaVersion] ([Version], [Description]) +VALUES (N'2.4.0', N'Consistency hardening part 4 — multi-site campaigns (Batch 6). Drops Campaign.Site_ID; a campaign''s sites are now derived from its sampling-location membership (CampaignSamplingLocation to SamplingPoint.Site), so a campaign may span several sites. Readers (campaign list/overview, channel pedigree fallback, site filter) repointed to the derived set. Pre-release, fresh install only — no migration.'); diff --git a/sql_generation_scripts/v2.4.0_seed_mssql.sql b/sql_generation_scripts/v2.4.0_seed_mssql.sql new file mode 100644 index 0000000..99897b8 --- /dev/null +++ b/sql_generation_scripts/v2.4.0_seed_mssql.sql @@ -0,0 +1,228 @@ +-- Seed data for schema v2.4.0 +-- Platform: mssql +-- Generated: 2026-06-29 17:56:40 UTC +-- AnnotationKind +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (1, N'Fault', N'Sensor or process fault', N'#FF4444'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (2, N'Maintenance', N'Sensor under maintenance', N'#FFA500'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (3, N'Calibration Period', N'Data during calibration — may be invalid', N'#FFD700'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (4, N'Anomaly', N'Unexpected behavior, needs investigation', N'#FF69B4'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (5, N'Experiment', N'Data collected for a specific experiment', N'#4488FF'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (6, N'Process Event', N'Known process event (storm, dosing, etc.)', N'#44BB44'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (7, N'Data Quality', N'Suspect data quality (drift, fouling)', N'#AA44FF'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (8, N'Note', N'General commentary', N'#888888'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (9, N'Exclusion', N'Data should be excluded from analysis', N'#CC0000'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (10, N'Confirmed', N'Data has been reviewed and accepted as valid', N'#00AA00'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (11, N'Equipment Relocation', N'Equipment was physically moved to a new location', N'#8888FF'); +-- BinKind +INSERT INTO [dbo].[BinKind] ([BinKind_ID], [Name], [Description]) VALUES (1, N'interval', N'Bins defined by lower and upper bounds only'); +INSERT INTO [dbo].[BinKind] ([BinKind_ID], [Name], [Description]) VALUES (2, N'interval_with_nominal', N'Bins defined by bounds plus a nominal center value (e.g., comes from a table with columns showing the mean settling velocity, but the bin in fact collects data between a min and max value (not a point value)).'); +INSERT INTO [dbo].[BinKind] ([BinKind_ID], [Name], [Description]) VALUES (3, N'nominal', N'Bins defined by a single nominal (exact) value only (e.g., absorbance at exactly 200 nm).'); +-- CampaignKind +SET IDENTITY_INSERT [dbo].[CampaignKind] ON; +INSERT INTO [dbo].[CampaignKind] ([CampaignKind_ID], [Name], [Description]) VALUES (1, N'Experiment', N'Planned scientific investigation under controlled or semi-controlled conditions'); +INSERT INTO [dbo].[CampaignKind] ([CampaignKind_ID], [Name], [Description]) VALUES (2, N'Regular operation', N'Routine monitoring or operational run of the monitored process'); +INSERT INTO [dbo].[CampaignKind] ([CampaignKind_ID], [Name], [Description]) VALUES (3, N'Commissioning', N'Initial setup, calibration, and qualification of equipment or a process'); +SET IDENTITY_INSERT [dbo].[CampaignKind] OFF; +-- ChannelKind +INSERT INTO [dbo].[ChannelKind] ([ChannelKind_ID], [Name], [Description]) VALUES (1, N'Value', N'Primary measurement or output value'); +INSERT INTO [dbo].[ChannelKind] ([ChannelKind_ID], [Name], [Description]) VALUES (2, N'Status', N'Device or measurement status flag'); +INSERT INTO [dbo].[ChannelKind] ([ChannelKind_ID], [Name], [Description]) VALUES (3, N'Alarm', N'Alarm or alert indicator'); +INSERT INTO [dbo].[ChannelKind] ([ChannelKind_ID], [Name], [Description]) VALUES (4, N'Uncertainty', N'Measurement uncertainty estimate'); +-- ControlLoopPortKind +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (1, N'MeasuredVariable', N'The controlled or observed process variable'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (2, N'ManipulatedVariable', N'The actuator or output adjusted by the controller'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (3, N'SetPoint', N'Target value supplied to the controller'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (4, N'Disturbance', N'Measured input that affects the process; not manipulated'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (5, N'PredictedOutput', N'Model-predicted value of the controlled variable'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (6, N'Other', N'Escape hatch for novel kinds; describe in ControlLoop.Description'); +-- ControllerKind +SET IDENTITY_INSERT [dbo].[ControllerKind] ON; +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (1, N'PID', N'Proportional-Integral-Derivative controller'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (2, N'Feedforward', N'Open-loop controller that acts on predicted disturbances'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (3, N'MPC', N'Model Predictive Controller using an internal process model'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (4, N'On-Off', N'Bang-bang (on/off) controller with fixed setpoint'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (5, N'Manual', N'Operator-driven manual control with no automated loop'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (6, N'Other', N'Controller type not covered by the other categories'); +SET IDENTITY_INSERT [dbo].[ControllerKind] OFF; +-- DataAcquisitionSystemKind +SET IDENTITY_INSERT [dbo].[DataAcquisitionSystemKind] ON; +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (1, N'SCADA', N'Supervisory Control and Data Acquisition system.'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (2, N'PLC', N'Programmable Logic Controller.'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (3, N'Field monitoring station', N'Deployable measurement station capable of hosting multiple devices and recording their data streams.'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (4, N'IoT Gateway', N'Internet-of-Things gateway aggregating sensor streams.'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (5, N'Manual entry', N'Data entered manually by an operator (spreadsheet, form).'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (6, N'Other', N'System type not covered by the other categories.'); +SET IDENTITY_INSERT [dbo].[DataAcquisitionSystemKind] OFF; +-- DataProvenanceKind +SET IDENTITY_INSERT [dbo].[DataProvenanceKind] ON; +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (1, N'Sensor', N'Value acquired directly from an instrument or sensor in the field'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (2, N'Laboratory', N'Value determined by laboratory chemical or physical analysis'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (3, N'Controller Output', N'Value generated by a control algorithm.'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (4, N'Model Output', N'Value generated by a simulation, model, or prediction algorithm not involved in control.'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (5, N'External Source', N'Value imported from an external dataset or third-party system'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (6, N'Forecast', N'Future-dated value produced by a forecasting model'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (7, N'Derived', N'Value produced by applying a data-processing algorithm to one or more existing channels.'); +SET IDENTITY_INSERT [dbo].[DataProvenanceKind] OFF; +-- EquipmentEventKind +SET IDENTITY_INSERT [dbo].[EquipmentEventKind] ON; +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (1, N'Calibration', N'Adjustment of sensor output to match a known reference standard'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (2, N'Commissioning', N'Formal activation of equipment into operational service'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (3, N'Maintenance', N'Physical cleaning, inspection, or servicing of equipment'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (4, N'Installation', N'First-time mounting or connection of equipment at its deployment site'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (5, N'Removal', N'Decommissioning or retrieval of equipment from its deployment site'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (6, N'Firmware Update', N'Update to the embedded software or firmware of the device'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (7, N'Failure', N'Unplanned malfunction or breakdown requiring corrective action'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (8, N'Repair', N'Corrective action performed following a recorded failure'); +INSERT INTO [dbo].[EquipmentEventKind] ([EquipmentEventKind_ID], [Name], [Description]) VALUES (9, N'Decommissioning', N'Formal retirement of equipment from operational service'); +SET IDENTITY_INSERT [dbo].[EquipmentEventKind] OFF; +-- OperationKind +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (1, N'Unprocessed', N'No operations applied — used for the raw channel trait only'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (2, N'OutlierRemoval', N'Spikes and statistical outliers removed or flagged'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (3, N'DriftCorrection', N'Sensor drift or baseline shift corrected'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (4, N'FaultRemoval', N'Instrument faults and implausible values removed'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (5, N'Smoothing', N'Noise reduced by a smoothing or averaging algorithm'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (6, N'Interpolation', N'Missing values filled by interpolation or reconstruction'); +-- ProcedureKind +SET IDENTITY_INSERT [dbo].[ProcedureKind] ON; +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (1, N'Maintenance and Cleaning Protocol', N'Procedures for routine maintenance, cleaning, and upkeep of equipment'); +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (2, N'Calibration Protocol', N'Step-by-step instructions for calibrating instruments or sensors'); +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (3, N'Validation Protocol', N'Procedures for validating measurements, methods, or models'); +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (4, N'Laboratory Method Protocol', N'Standardised laboratory analytical methods (e.g. ISO, ASTM, APHA)'); +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (5, N'Software Manual', N'User or operational manuals for software tools used in data acquisition or processing'); +SET IDENTITY_INSERT [dbo].[ProcedureKind] OFF; +-- ProcessUnitKind +SET IDENTITY_INSERT [dbo].[ProcessUnitKind] ON; +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (1, N'Area', N'Broad spatial zone (e.g. biological treatment area)'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (2, N'Zone', N'Defined functional sub-zone within a process area'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (3, N'Tank', N'Enclosed vessel for liquid storage or treatment'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (4, N'Reactor', N'Vessel designed for controlled biological or chemical reactions'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (5, N'Pipe', N'Conduit transporting liquid between process units'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (6, N'Pump', N'Mechanical device for moving liquid'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (7, N'Valve', N'Flow control device regulating liquid passage'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (8, N'Clarifier', N'Gravity settling vessel separating solids from liquid'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (9, N'Basin', N'Open or partially open liquid containment structure'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (10, N'Blower', N'Mechanical device for supplying air or gas'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (11, N'Other', N'Process unit kind not covered by the standard vocabulary'); +SET IDENTITY_INSERT [dbo].[ProcessUnitKind] OFF; +-- QualityCode +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (1, N'Accepted', N'Measurement meets quality criteria and is fit for use', 1); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (2, N'Suspect', N'Measurement may be unreliable; flagged for manual review', 1); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (3, N'Rejected', N'Measurement is invalid and must not be used', 0); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (4, N'BelowLoD', N'Result is below the method''s limit of detection', 0); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (5, N'AboveLoQ', N'Result exceeds the limit of quantification (instrument saturated)', 0); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (6, N'Outlier', N'Statistical outlier; not automatically invalid but requires review', 1); +-- ReviewStatus +INSERT INTO [dbo].[ReviewStatus] ([ReviewStatus_ID], [Name], [Description]) VALUES (1, N'Pending', N'Measurement recorded but not yet reviewed/approved'); +INSERT INTO [dbo].[ReviewStatus] ([ReviewStatus_ID], [Name], [Description]) VALUES (2, N'Approved', N'Measurement reviewed and approved by a designated reviewer'); +INSERT INTO [dbo].[ReviewStatus] ([ReviewStatus_ID], [Name], [Description]) VALUES (3, N'Rejected', N'Measurement reviewed and rejected'); +-- SampleCollectionKind +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (1, N'Grab', N'Single instantaneous sample collected at one point in time'); +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (2, N'Composite24h', N'Flow- or time-proportional composite over a 24-hour period'); +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (3, N'Composite8h', N'Flow- or time-proportional composite over an 8-hour period'); +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (4, N'Passive', N'Passive sampler deployed over an extended exposure period'); +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (5, N'Other', N'Collection kind not covered by the standard vocabulary'); +-- SampleKind +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (1, N'Field', N'Sample collected from a real-world site or process'); +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (2, N'Synthetic', N'Laboratory-prepared sample with known composition'); +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (3, N'Master Standard', N'Reference standard used to prepare derived standards'); +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (4, N'Derived Standard', N'Dilution or aliquot derived from a master standard'); +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (5, N'Blank', N'Blank sample used to detect contamination or baseline'); +-- SiteKind +SET IDENTITY_INSERT [dbo].[SiteKind] ON; +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (1, N'Municipal Wastewater Treatment Plant', N'Municipal or industrial facility treating wastewater before discharge'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (2, N'Combined Sewer Overflow', N'Point where combined sewer system discharges during high-flow events'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (3, N'River / Stream', N'Natural flowing surface water body'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (4, N'Lake / Reservoir', N'Natural or artificial standing body of water'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (5, N'Groundwater / Well', N'Subsurface water source accessed via a well or borehole'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (6, N'Drinking Water Distribution Network Access Point', N'Monitoring point within a potable water distribution network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (7, N'Canal', N'Artificial waterway for water transport or drainage'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (8, N'Wastewater Pumping Station', N'Facility that pumps wastewater through the collection network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (9, N'Combined Drainage Network Access Point', N'Monitoring point within a combined stormwater and wastewater network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (10, N'Rainwater Drainage Network Access Point', N'Monitoring point within a stormwater-only drainage network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (11, N'Wastewater Drainage Network Access Point', N'Monitoring point within a sanitary sewer network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (12, N'Experimental Wastewater Treatment Plant', N'Small-scale experimental treatment or process facility'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (13, N'Other', N'Site kind not covered by the standard vocabulary'); +SET IDENTITY_INSERT [dbo].[SiteKind] OFF; +-- StreamKind +INSERT INTO [dbo].[StreamKind] ([StreamKind_ID], [Name], [Description]) VALUES (1, N'Sensor', N'A sensor measurement stream (Channel subtype of Stream)'); +INSERT INTO [dbo].[StreamKind] ([StreamKind_ID], [Name], [Description]) VALUES (2, N'Lab', N'A laboratory measurement stream (AnalysisSeries subtype of Stream)'); +-- Unit +SET IDENTITY_INSERT [dbo].[Unit] ON; +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (1, N'mg/L', N'https://qudt.org/vocab/unit/MilliGM-PER-L', N'0,1,-3,0,0,0,0', 0.001, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (2, N'NTU', N'https://qudt.org/vocab/unit/NTU', N'0,0,0,0,0,0,0', NULL, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (3, N'pH units', N'https://qudt.org/vocab/unit/PH', N'0,0,0,0,0,0,0', NULL, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (4, N'°C', N'https://qudt.org/vocab/unit/DEG_C', N'0,0,0,0,1,0,0', 1.0, 273.15); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (5, N'mS/cm', N'https://qudt.org/vocab/unit/MilliS-PER-CentiM', N'-3,-1,3,2,0,0,0', 0.1, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (6, N'nm', N'https://qudt.org/vocab/unit/NanoM', N'1,0,0,0,0,0,0', 1e-09, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (7, N'µm', N'https://qudt.org/vocab/unit/MicroM', N'1,0,0,0,0,0,0', 1e-06, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (8, N'm/s', N'https://qudt.org/vocab/unit/M-PER-SEC', N'1,0,-1,0,0,0,0', 1.0, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (9, N'Status Code', NULL, NULL, NULL, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (10, N'AU', N'https://qudt.org/vocab/unit/ABSORBANCE_UNIT', N'0,0,0,0,0,0,0', NULL, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (11, N'-', N'https://qudt.org/vocab/unit/UNITLESS', N'0,0,0,0,0,0,0', 1.0, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (12, N'm³/h', N'https://qudt.org/vocab/unit/M3-PER-HR', N'3,0,-1,0,0,0,0', 0.000277778, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (13, N'm', N'https://qudt.org/vocab/unit/M', N'1,0,0,0,0,0,0', 1.0, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (14, N'Nm³/h', NULL, N'3,0,-1,0,0,0,0', 0.000277778, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (15, N'%', N'https://qudt.org/vocab/unit/PERCENT', N'0,0,0,0,0,0,0', 0.01, NULL); +SET IDENTITY_INSERT [dbo].[Unit] OFF; +-- ValueKind +SET IDENTITY_INSERT [dbo].[ValueKind] ON; +INSERT INTO [dbo].[ValueKind] ([ValueKind_ID], [Name], [Description]) VALUES (1, N'Scalar', N'A single numeric measurement value (e.g. temperature, concentration)'); +INSERT INTO [dbo].[ValueKind] ([ValueKind_ID], [Name], [Description]) VALUES (2, N'Vector', N'An ordered sequence of numeric values (e.g. particle size distribution)'); +INSERT INTO [dbo].[ValueKind] ([ValueKind_ID], [Name], [Description]) VALUES (3, N'Matrix', N'A two-dimensional array of values (e.g. excitation-emission matrix)'); +INSERT INTO [dbo].[ValueKind] ([ValueKind_ID], [Name], [Description]) VALUES (4, N'Image', N'A raster image stored as a binary file'); +SET IDENTITY_INSERT [dbo].[ValueKind] OFF; +-- Parameter +SET IDENTITY_INSERT [dbo].[Parameter] ON; +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'TSS concentration', 1, N'Total suspended solids', N'http://purl.obolibrary.org/obo/ENVO_01001502', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'COD concentration', 2, N'Chemical oxygen demand', N'http://purl.obolibrary.org/obo/ENVO_01000632', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'pH', 3, N'Hydrogen ion concentration', N'http://purl.obolibrary.org/obo/ENVO_09200019', 1, N'http://qudt.org/vocab/quantitykind/PH'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Temperature', 4, N'Water temperature', N'http://purl.obolibrary.org/obo/ENVO_01001501', 1, N'http://qudt.org/vocab/quantitykind/Temperature'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Conductivity', 5, N'Electrical conductivity', N'http://purl.obolibrary.org/obo/ENVO_09200010', 1, N'http://qudt.org/vocab/quantitykind/ElectricConductivity'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Sensor Status', 6, N'Per-channel operational status code', NULL, 1, NULL); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Device Status', 7, N'Overall equipment health status code', NULL, 1, NULL); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Dissolved oxygen concentration', 8, N'Dissolved oxygen concentration in water', N'http://purl.obolibrary.org/obo/ENVO_01001111', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Turbidity', 9, N'Water turbidity measured by nephelometry', N'http://purl.obolibrary.org/obo/ENVO_01001573', 1, N'http://qudt.org/vocab/quantitykind/Turbidity'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Absorbance spectrum', 10, N'UV-Vis spectral absorbance (per-wavelength vector, unit AU)', NULL, 2, NULL); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Ammonium-N concentration', 11, N'Ammonium nitrogen concentration (NH4-N)', N'http://purl.obolibrary.org/obo/CHEBI_49786', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Nitrate-N concentration', 12, N'Nitrate nitrogen concentration as NO3-N equivalent', N'http://purl.obolibrary.org/obo/CHEBI_17632', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'COD filtered concentration', 13, N'Filtered COD (CODf) — soluble fraction of chemical oxygen demand', NULL, 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Flow', 14, N'Volumetric flow rate', N'http://purl.obolibrary.org/obo/ENVO_01001020', 1, N'http://qudt.org/vocab/quantitykind/VolumeFlowRate'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Level', 15, N'Water level / depth', NULL, 1, N'http://qudt.org/vocab/quantitykind/Length'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'floc_morphology', 16, N'Activated sludge floc morphology image from inline microscope', NULL, 4, NULL); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Potassium concentration', 17, N'Potassium concentration (K)', NULL, 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Light Absorbance', 18, N'Scalar light absorbance measurement', NULL, 1, N'http://qudt.org/vocab/quantitykind/Absorbance'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Nitrite-N concentration', 19, N'Nitrite nitrogen concentration (NO2-N)', NULL, 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'NOx-N concentration', 20, N'Total oxidized nitrogen (NO3-N + NO2-N)', NULL, 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Air flow', 21, N'Volumetric air/gas flow rate', NULL, 1, N'http://qudt.org/vocab/quantitykind/VolumeFlowRate'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Valve position', 22, N'Control valve analog output position (0-100%)', NULL, 1, NULL); +SET IDENTITY_INSERT [dbo].[Parameter] OFF; +-- Procedures +SET IDENTITY_INSERT [dbo].[Procedures] ON; +INSERT INTO [dbo].[Procedures] ([Procedure_ID], [ProcedureName], [Description], [ProcedureLocation]) VALUES (1, N'Grab sampling', N'Manual grab sample collected at water surface', N'/procedures/grab_sampling.pdf'); +INSERT INTO [dbo].[Procedures] ([Procedure_ID], [ProcedureName], [Description], [ProcedureLocation]) VALUES (2, N'24h composite', N'Time-weighted 24-hour composite sample via autosampler', N'/procedures/composite_24h.pdf'); +INSERT INTO [dbo].[Procedures] ([Procedure_ID], [ProcedureName], [Description], [ProcedureLocation]) VALUES (3, N'Online continuous', N'Continuous in-situ measurement with data logging', N'/procedures/online_continuous.pdf'); +SET IDENTITY_INSERT [dbo].[Procedures] OFF; + +-- ParameterHasUnit (generated by ontology_query.py) +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (1, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (2, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (3, 3); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (4, 4); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (5, 5); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (8, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (9, 2); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (10, 10); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (11, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (12, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (13, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (14, 12); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (15, 6); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (15, 7); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (15, 13); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (17, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (18, 10); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (19, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (20, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (21, 12); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (21, 14); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (22, 15); diff --git a/sql_generation_scripts/v2.5.0_create_mssql.sql b/sql_generation_scripts/v2.5.0_create_mssql.sql new file mode 100644 index 0000000..5248c4c --- /dev/null +++ b/sql_generation_scripts/v2.5.0_create_mssql.sql @@ -0,0 +1,1195 @@ +-- Baseline CREATE script for schema v2.5.0 +-- Platform: mssql +-- Generated: 2026-06-30 12:05:25 UTC + +CREATE TABLE [dbo].[AnnotationKind] ( + [AnnotationKind_ID] INT NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(500), + [Color] NVARCHAR(7), + CONSTRAINT [PK_AnnotationKind] PRIMARY KEY ([AnnotationKind_ID]) +); + +CREATE TABLE [dbo].[BinKind] ( + [BinKind_ID] INT NOT NULL, + [Name] NVARCHAR(30) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_BinKind] PRIMARY KEY ([BinKind_ID]) +); + +CREATE TABLE [dbo].[CampaignKind] ( + [CampaignKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_CampaignKind] PRIMARY KEY ([CampaignKind_ID]) +); + +CREATE TABLE [dbo].[ChannelKind] ( + [ChannelKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_ChannelKind] PRIMARY KEY ([ChannelKind_ID]) +); + +CREATE TABLE [dbo].[ControlLoopPortKind] ( + [ControlLoopPortKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_ControlLoopPortKind] PRIMARY KEY ([ControlLoopPortKind_ID]) +); + +CREATE TABLE [dbo].[ControllerKind] ( + [ControllerKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(500), + CONSTRAINT [PK_ControllerKind] PRIMARY KEY ([ControllerKind_ID]) +); + +CREATE TABLE [dbo].[DataAcquisitionSystemKind] ( + [DataAcquisitionSystemKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(500), + CONSTRAINT [PK_DataAcquisitionSystemKind] PRIMARY KEY ([DataAcquisitionSystemKind_ID]) +); + +CREATE TABLE [dbo].[DataProvenanceKind] ( + [DataProvenanceKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_DataProvenanceKind] PRIMARY KEY ([DataProvenanceKind_ID]) +); + +CREATE TABLE [dbo].[EquipmentModel] ( + [EquipmentModel_ID] INT IDENTITY(1,1) NOT NULL, + [EquipmentModel] NVARCHAR(100), + [Method] NVARCHAR(100), + [Functions] NVARCHAR(MAX), + [Manufacturer] NVARCHAR(100), + [ManualLocation] NVARCHAR(1000), + CONSTRAINT [PK_EquipmentModel] PRIMARY KEY ([EquipmentModel_ID]) +); + +CREATE TABLE [dbo].[EventKind] ( + [EventKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_EventKind] PRIMARY KEY ([EventKind_ID]) +); + +CREATE TABLE [dbo].[OperationKind] ( + [OperationKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_OperationKind] PRIMARY KEY ([OperationKind_ID]) +); + +CREATE TABLE [dbo].[Person] ( + [Person_ID] INT IDENTITY(1,1) NOT NULL, + [LastName] NVARCHAR(100), + [FirstName] NVARCHAR(255), + [Company] NVARCHAR(MAX), + [Role] NVARCHAR(255), + [AssignedFunctions] NVARCHAR(MAX), + [Email] NVARCHAR(100), + [Phone] NVARCHAR(100), + [Linkedin] NVARCHAR(100), + [Website] NVARCHAR(60), + CONSTRAINT [PK_Person] PRIMARY KEY ([Person_ID]) +); + +CREATE TABLE [dbo].[ProcedureKind] ( + [ProcedureKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_ProcedureKind] PRIMARY KEY ([ProcedureKind_ID]) +); + +CREATE TABLE [dbo].[ProcessUnitKind] ( + [ProcessUnitKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_ProcessUnitKind] PRIMARY KEY ([ProcessUnitKind_ID]), + CONSTRAINT [UQ_ProcessUnitKind_Name] UNIQUE ([Name]) +); + +CREATE TABLE [dbo].[QualityCode] ( + [QualityCode_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + [IsUsable] BIT NOT NULL DEFAULT 1, + CONSTRAINT [PK_QualityCode] PRIMARY KEY ([QualityCode_ID]) +); + +CREATE TABLE [dbo].[ReviewStatus] ( + [ReviewStatus_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_ReviewStatus] PRIMARY KEY ([ReviewStatus_ID]) +); + +CREATE TABLE [dbo].[SampleCollectionKind] ( + [SampleCollectionKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_SampleCollectionKind] PRIMARY KEY ([SampleCollectionKind_ID]) +); + +CREATE TABLE [dbo].[SampleKind] ( + [SampleKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_SampleKind] PRIMARY KEY ([SampleKind_ID]) +); + +CREATE TABLE [dbo].[SchemaVersion] ( + [VersionID] INT IDENTITY(1,1) NOT NULL, + [Version] NVARCHAR(20) NOT NULL, + [AppliedDateTime] DATETIME2(7) NOT NULL DEFAULT CURRENT_TIMESTAMP, + [Description] NVARCHAR(500), + [MigrationScript] NVARCHAR(200), + CONSTRAINT [PK_SchemaVersion] PRIMARY KEY ([VersionID]) +); + +CREATE TABLE [dbo].[SiteKind] ( + [SiteKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(300), + CONSTRAINT [PK_SiteKind] PRIMARY KEY ([SiteKind_ID]) +); + +CREATE TABLE [dbo].[StreamKind] ( + [StreamKind_ID] INT NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_StreamKind] PRIMARY KEY ([StreamKind_ID]) +); + +CREATE TABLE [dbo].[Unit] ( + [Unit_ID] INT IDENTITY(1,1) NOT NULL, + [Unit] NVARCHAR(100), + [QUDT_IRI] NVARCHAR(256), + [UnitVector] NVARCHAR(64), + [SI_Multiplier] FLOAT, + [SI_Offset] FLOAT, + CONSTRAINT [PK_Unit] PRIMARY KEY ([Unit_ID]) +); + +CREATE TABLE [dbo].[UserAccount] ( + [UserAccount_ID] INT IDENTITY(1,1) NOT NULL, + [Email] NVARCHAR(255) NOT NULL, + [FullName] NVARCHAR(255) NOT NULL, + [PasswordHash] NVARCHAR(255) NOT NULL, + [IsActive] BIT NOT NULL DEFAULT 1, + [IsVerified] BIT NOT NULL DEFAULT 1, + [CreatedAt] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + [UpdatedAt] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + CONSTRAINT [PK_UserAccount] PRIMARY KEY ([UserAccount_ID]), + CONSTRAINT [UQ_UserAccount_Email] UNIQUE ([Email]) +); + +CREATE TABLE [dbo].[ValueKind] ( + [ValueKind_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(50) NOT NULL, + [Description] NVARCHAR(200), + CONSTRAINT [PK_ValueKind] PRIMARY KEY ([ValueKind_ID]) +); + +CREATE TABLE [dbo].[AnalysisSeries] ( + [Stream_ID] INT NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Parameter_ID] INT NOT NULL, + [SamplingPoint_ID] INT NOT NULL, + [ValueKind_ID] INT NOT NULL DEFAULT 1, + [Unit_ID] INT NOT NULL, + [Campaign_ID] INT, + [Description] NVARCHAR(MAX), + CONSTRAINT [PK_AnalysisSeries] PRIMARY KEY ([Stream_ID]), + CONSTRAINT [UQ_AnalysisSeries_Identity] UNIQUE ([Parameter_ID], [SamplingPoint_ID], [ValueKind_ID]) +); + +CREATE TABLE [dbo].[AnalysisSeriesAxis] ( + [AnalysisSeries_ID] INT NOT NULL, + [AxisRole] INT NOT NULL, + [ValueBinningAxis_ID] INT NOT NULL, + CONSTRAINT [PK_AnalysisSeriesAxis] PRIMARY KEY ([AnalysisSeries_ID], [AxisRole]), + CONSTRAINT [CK_AnalysisSeriesAxis_AxisRole] CHECK (AxisRole IN (0, 1)) +); + +CREATE TABLE [dbo].[Annotation] ( + [Annotation_ID] INT IDENTITY(1,1) NOT NULL, + [Stream_ID] INT NOT NULL, + [AnnotationKind_ID] INT NOT NULL, + [StartTime] DATETIME2(7) NOT NULL, + [EndTime] DATETIME2(7), + [AuthorPerson_ID] INT, + [Campaign_ID] INT, + [Event_ID] INT, + [Title] NVARCHAR(200), + [Comment] NVARCHAR(MAX), + [CreatedDateTime] DATETIME2(7) NOT NULL DEFAULT CURRENT_TIMESTAMP, + [ModifiedDateTime] DATETIME2(7), + [Observation_ID] INT, + CONSTRAINT [PK_Annotation] PRIMARY KEY ([Annotation_ID]) +); + +CREATE TABLE [dbo].[AuditLog] ( + [AuditLog_ID] BIGINT IDENTITY(1,1) NOT NULL, + [UserAccount_ID] INT, + [Action] NVARCHAR(50) NOT NULL, + [ResourceType] NVARCHAR(100) NOT NULL, + [ResourceID] NVARCHAR(255), + [Details] NVARCHAR(MAX), + [Timestamp] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + CONSTRAINT [PK_AuditLog] PRIMARY KEY ([AuditLog_ID]) +); + +CREATE TABLE [dbo].[Campaign] ( + [Campaign_ID] INT IDENTITY(1,1) NOT NULL, + [CampaignKind_ID] INT NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(2000), + [CampaignStartDateTime] DATETIME2(7), + [CampaignEndDateTime] DATETIME2(7), + [ResponsiblePerson_ID] INT, + CONSTRAINT [PK_Campaign] PRIMARY KEY ([Campaign_ID]) +); + +CREATE TABLE [dbo].[CampaignEquipment] ( + [Campaign_ID] INT NOT NULL, + [Equipment_ID] INT NOT NULL, + [Role] NVARCHAR(100), + CONSTRAINT [PK_CampaignEquipment] PRIMARY KEY ([Campaign_ID], [Equipment_ID]) +); + +CREATE TABLE [dbo].[CampaignSamplingLocation] ( + [Campaign_ID] INT NOT NULL, + [SamplingPoint_ID] INT NOT NULL, + [Role] NVARCHAR(100), + CONSTRAINT [PK_CampaignSamplingLocation] PRIMARY KEY ([Campaign_ID], [SamplingPoint_ID]) +); + +CREATE TABLE [dbo].[Channel] ( + [Stream_ID] INT NOT NULL, + [SignalInterface_ID] INT, + [TagName] NVARCHAR(200) NOT NULL, + [ParentChannel_ID] INT, + [ChannelKind_ID] INT NOT NULL DEFAULT 1, + [Parameter_ID] INT, + [DataProvenanceKind_ID] INT, + [ProducedByStep_ID] INT, + [ValueKind_ID] INT NOT NULL DEFAULT 1, + [Unit_ID] INT, + CONSTRAINT [PK_Channel] PRIMARY KEY ([Stream_ID]) +); + +CREATE TABLE [dbo].[ChannelAxis] ( + [Channel_ID] INT NOT NULL, + [AxisRole] INT NOT NULL, + [ValueBinningAxis_ID] INT NOT NULL, + CONSTRAINT [PK_ChannelAxis] PRIMARY KEY ([Channel_ID], [AxisRole]), + CONSTRAINT [CK_MetaDataAxis_AxisRole] CHECK (AxisRole IN (0, 1)) +); + +CREATE TABLE [dbo].[ChannelPortHistory] ( + [ChannelPortHistory_ID] INT IDENTITY(1,1) NOT NULL, + [Channel_ID] INT NOT NULL, + [SignalInterfacePort_ID] INT, + [ValidFrom] DATETIME2(7) NOT NULL, + [ValidTo] DATETIME2(7), + [GatingNote] NVARCHAR(MAX), + CONSTRAINT [PK_ChannelPortHistory] PRIMARY KEY ([ChannelPortHistory_ID]) +); + +CREATE TABLE [dbo].[ChannelTrait] ( + [Stream_ID] INT NOT NULL, + [OperationKind_ID] INT NOT NULL, + CONSTRAINT [PK_ChannelTrait] PRIMARY KEY ([Stream_ID], [OperationKind_ID]) +); + +CREATE TABLE [dbo].[ControlLoop] ( + [ControlLoop_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [ControllerKind_ID] INT NOT NULL, + [FallbackControlLoop_ID] INT, + [AlgorithmReference] NVARCHAR(500), + [Description] NVARCHAR(MAX), + CONSTRAINT [PK_ControlLoop] PRIMARY KEY ([ControlLoop_ID]) +); + +CREATE TABLE [dbo].[ControlLoopApplication] ( + [ControlLoopApplication_ID] INT IDENTITY(1,1) NOT NULL, + [ControlLoop_ID] INT NOT NULL, + [StartTime] DATETIME2(7) NOT NULL, + [EndTime] DATETIME2(7), + [Parameters] NVARCHAR(MAX), + [AppliedByPerson_ID] INT, + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_ControlLoopApplication] PRIMARY KEY ([ControlLoopApplication_ID]) +); + +CREATE TABLE [dbo].[ControlLoopPort] ( + [ControlLoopPort_ID] INT IDENTITY(1,1) NOT NULL, + [ControlLoop_ID] INT NOT NULL, + [Channel_ID] INT NOT NULL, + [ControlLoopPortKind_ID] INT NOT NULL, + CONSTRAINT [PK_ControlLoopPort] PRIMARY KEY ([ControlLoopPort_ID]) +); + +CREATE TABLE [dbo].[DASLocationHistory] ( + [DASLocationHistory_ID] INT IDENTITY(1,1) NOT NULL, + [DataAcquisitionSystem_ID] INT NOT NULL, + [Site_ID] INT NOT NULL, + [Campaign_ID] INT, + [ValidFrom] DATETIME2(7) NOT NULL, + [ValidTo] DATETIME2(7), + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_DASLocationHistory] PRIMARY KEY ([DASLocationHistory_ID]) +); + +CREATE TABLE [dbo].[DataAcquisitionSystem] ( + [DataAcquisitionSystem_ID] INT IDENTITY(1,1) NOT NULL, + [ParentSystem_ID] INT, + [Name] NVARCHAR(200) NOT NULL, + [DataAcquisitionSystemKind_ID] INT, + [Manufacturer] NVARCHAR(100), + [Model] NVARCHAR(100), + [Description] NVARCHAR(MAX), + CONSTRAINT [PK_DataAcquisitionSystem] PRIMARY KEY ([DataAcquisitionSystem_ID]) +); + +CREATE TABLE [dbo].[Dataset] ( + [Dataset_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(2000), + [Purpose] NVARCHAR(500), + [CreatedOn] DATETIME2(7) NOT NULL DEFAULT CURRENT_TIMESTAMP, + [CreatedByPerson_ID] INT, + CONSTRAINT [PK_Dataset] PRIMARY KEY ([Dataset_ID]) +); + +CREATE TABLE [dbo].[DatasetChannel] ( + [Dataset_ID] INT NOT NULL, + [Channel_ID] INT NOT NULL, + CONSTRAINT [PK_DatasetChannel] PRIMARY KEY ([Dataset_ID], [Channel_ID]) +); + +CREATE TABLE [dbo].[Equipment] ( + [Equipment_ID] INT IDENTITY(1,1) NOT NULL, + [EquipmentModel_ID] INT, + [Identifier] NVARCHAR(100), + [SerialNumber] NVARCHAR(100), + [Owner] NVARCHAR(MAX), + [StorageLocation] NVARCHAR(100), + [PurchaseDate] DATE, + [IsActive] BIT NOT NULL DEFAULT 1, + CONSTRAINT [PK_Equipment] PRIMARY KEY ([Equipment_ID]) +); + +CREATE TABLE [dbo].[EquipmentLocationHistory] ( + [EquipmentLocationHistory_ID] INT IDENTITY(1,1) NOT NULL, + [Equipment_ID] INT NOT NULL, + [SamplingPoint_ID] INT NOT NULL, + [ValidFrom] DATETIME2(7) NOT NULL, + [ValidTo] DATETIME2(7), + [Campaign_ID] INT, + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_EquipmentLocationHistory] PRIMARY KEY ([EquipmentLocationHistory_ID]) +); + +CREATE TABLE [dbo].[EquipmentModelHasParameter] ( + [EquipmentModel_ID] INT NOT NULL, + [Parameter_ID] INT NOT NULL, + CONSTRAINT [PK_EquipmentModelHasParameter] PRIMARY KEY ([EquipmentModel_ID], [Parameter_ID]) +); + +CREATE TABLE [dbo].[EquipmentModelHasProcedures] ( + [EquipmentModel_ID] INT NOT NULL, + [Procedure_ID] INT NOT NULL, + CONSTRAINT [PK_EquipmentModelHasProcedures] PRIMARY KEY ([EquipmentModel_ID], [Procedure_ID]) +); + +CREATE TABLE [dbo].[EquipmentWiringHistory] ( + [EquipmentWiringHistory_ID] INT IDENTITY(1,1) NOT NULL, + [Equipment_ID] INT NOT NULL, + [SignalInterface_ID] INT NOT NULL, + [SignalInterfacePort_ID] INT, + [ValidFrom] DATETIME2(7) NOT NULL, + [ValidTo] DATETIME2(7), + [Note] NVARCHAR(MAX), + CONSTRAINT [PK_EquipmentWiringHistory] PRIMARY KEY ([EquipmentWiringHistory_ID]) +); + +CREATE TABLE [dbo].[Event] ( + [Event_ID] INT IDENTITY(1,1) NOT NULL, + [Channel_ID] INT, + [Equipment_ID] INT, + [SignalInterface_ID] INT, + [DataAcquisitionSystem_ID] INT, + [SamplingPoint_ID] INT, + [ProcessUnit_ID] INT, + [Site_ID] INT, + [Campaign_ID] INT, + [EventKind_ID] INT NOT NULL, + [EventDateTimeStart] DATETIME2(7) NOT NULL, + [IsInstantaneous] BIT NOT NULL DEFAULT 0, + [EventDateTimeEnd] DATETIME2(7), + [PerformedByPerson_ID] INT, + [RecordedByPerson_ID] INT, + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_Event] PRIMARY KEY ([Event_ID]), + CONSTRAINT [CK_Event_ExclusiveArc] CHECK ((CASE WHEN [Channel_ID] IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN [Equipment_ID] IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN [SignalInterface_ID] IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN [DataAcquisitionSystem_ID] IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN [SamplingPoint_ID] IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN [ProcessUnit_ID] IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN [Site_ID] IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN [Campaign_ID] IS NOT NULL THEN 1 ELSE 0 END) = 1) +); + +CREATE TABLE [dbo].[HydrologicalCharacteristics] ( + [Watershed_ID] INT NOT NULL, + [UrbanArea] REAL, + [Forest] REAL, + [Wetlands] REAL, + [Cropland] REAL, + [Meadow] REAL, + [Grassland] REAL, + CONSTRAINT [PK_HydrologicalCharacteristics] PRIMARY KEY ([Watershed_ID]) +); + +CREATE TABLE [dbo].[LabAnalysis] ( + [LabAnalysis_ID] INT IDENTITY(1,1) NOT NULL, + [LabExperiment_ID] INT NOT NULL, + [AnalysisSeries_ID] INT NOT NULL, + [Sample_ID] INT NOT NULL, + [Replicate] INT NOT NULL DEFAULT 1, + [QualityCode_ID] INT, + [ReviewStatus_ID] INT NOT NULL DEFAULT 1, + [ReviewedByPerson_ID] INT, + [ReviewDateTime] DATETIME2(7), + [Laboratory_ID] INT, + [AnalystPerson_ID] INT, + [Procedure_ID] INT, + [AnalysisDateTime] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + [Notes] NVARCHAR(MAX), + CONSTRAINT [PK_LabAnalysis] PRIMARY KEY ([LabAnalysis_ID]), + CONSTRAINT [UQ_LabAnalysis_Identity] UNIQUE ([LabExperiment_ID], [AnalysisSeries_ID], [Sample_ID], [Replicate]) +); + +CREATE TABLE [dbo].[LabExperiment] ( + [LabExperiment_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Campaign_ID] INT, + [ExperimentDateTime] DATETIME2(7) NOT NULL DEFAULT SYSUTCDATETIME(), + [Description] NVARCHAR(MAX), + [CreatedByPerson_ID] INT, + [LabPanel_ID] INT, + CONSTRAINT [PK_LabExperiment] PRIMARY KEY ([LabExperiment_ID]) +); + +CREATE TABLE [dbo].[LabPanel] ( + [LabPanel_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(MAX), + [CreatedByPerson_ID] INT, + [DefaultSampleCollectionKind_ID] INT, + [DefaultSampleEquipment_ID] INT, + [CreatedAt] DATETIME2(7) NOT NULL DEFAULT GETUTCDATE(), + CONSTRAINT [PK_LabPanel] PRIMARY KEY ([LabPanel_ID]) +); + +CREATE TABLE [dbo].[LabPanelSeries] ( + [LabPanel_ID] INT NOT NULL, + [AnalysisSeries_ID] INT NOT NULL, + CONSTRAINT [PK_LabPanelSeries] PRIMARY KEY ([LabPanel_ID], [AnalysisSeries_ID]) +); + +CREATE TABLE [dbo].[Laboratory] ( + [Laboratory_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Site_ID] INT, + [Description] NVARCHAR(500), + CONSTRAINT [PK_Laboratory] PRIMARY KEY ([Laboratory_ID]) +); + +CREATE TABLE [dbo].[LandUse] ( + [Watershed_ID] INT NOT NULL, + [Commercial] REAL, + [GreenSpaces] REAL, + [Industrial] REAL, + [Institutional] REAL, + [Residential] REAL, + [Agricultural] REAL, + [Recreational] REAL, + CONSTRAINT [PK_LandUse] PRIMARY KEY ([Watershed_ID]) +); + +CREATE TABLE [dbo].[Observation] ( + [Observation_ID] INT IDENTITY(1,1) NOT NULL, + [Channel_ID] INT, + [LabAnalysis_ID] INT, + [Timestamp] DATETIME2(7) NOT NULL, + [ValueKind_ID] INT NOT NULL, + CONSTRAINT [PK_Observation] PRIMARY KEY ([Observation_ID]), + CONSTRAINT [CK_Observation_Source] CHECK ((Channel_ID IS NOT NULL AND LabAnalysis_ID IS NULL) OR (Channel_ID IS NULL AND LabAnalysis_ID IS NOT NULL)) +); + +CREATE TABLE [dbo].[Parameter] ( + [Parameter_ID] INT IDENTITY(1,1) NOT NULL, + [Parameter] NVARCHAR(100), + [Description] NVARCHAR(MAX), + [ENVO_IRI] NVARCHAR(256), + [ValueKind_ID] INT NOT NULL DEFAULT 1, + [QUDT_QuantityKind_IRI] NVARCHAR(256), + CONSTRAINT [PK_Parameter] PRIMARY KEY ([Parameter_ID]) +); + +CREATE TABLE [dbo].[ParameterHasUnit] ( + [Parameter_ID] INT NOT NULL, + [Unit_ID] INT NOT NULL, + CONSTRAINT [PK_ParameterHasUnit] PRIMARY KEY ([Parameter_ID], [Unit_ID]) +); + +CREATE TABLE [dbo].[Procedures] ( + [Procedure_ID] INT IDENTITY(1,1) NOT NULL, + [ProcedureName] NVARCHAR(100), + [ProcedureKind_ID] INT, + [Description] NVARCHAR(MAX), + [ProcedureLocation] NVARCHAR(100), + CONSTRAINT [PK_Procedures] PRIMARY KEY ([Procedure_ID]) +); + +CREATE TABLE [dbo].[ProcessUnit] ( + [ProcessUnit_ID] INT IDENTITY(1,1) NOT NULL, + [Site_ID] INT NOT NULL, + [Tag] NVARCHAR(100) NOT NULL, + [Name] NVARCHAR(255) NOT NULL, + [Description] NVARCHAR(MAX), + [ProcessUnitKind_ID] INT, + [Parent_ID] INT, + CONSTRAINT [PK_ProcessUnit] PRIMARY KEY ([ProcessUnit_ID]), + CONSTRAINT [UQ_ProcessUnit_SiteTag] UNIQUE ([Site_ID], [Tag]) +); + +CREATE TABLE [dbo].[ProcessingLineage] ( + [ProcessingLineage_ID] INT IDENTITY(1,1) NOT NULL, + [ProcessingStep_ID] INT NOT NULL, + [Stream_ID] INT NOT NULL, + [StartTime] DATETIME2(7), + [EndTime] DATETIME2(7), + CONSTRAINT [PK_ProcessingLineage] PRIMARY KEY ([ProcessingLineage_ID]) +); + +CREATE TABLE [dbo].[ProcessingStep] ( + [ProcessingStep_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(MAX), + [MethodName] NVARCHAR(200), + [MethodVersion] NVARCHAR(100), + [OperationKind_ID] INT, + [MethodParameters] NVARCHAR(MAX), + [ExecutedDateTime] DATETIME2(7), + [ExecutedByPerson_ID] INT, + [Dataset_ID] INT, + CONSTRAINT [PK_ProcessingStep] PRIMARY KEY ([ProcessingStep_ID]) +); + +CREATE TABLE [dbo].[Sample] ( + [Sample_ID] INT IDENTITY(1,1) NOT NULL, + [ParentSample_ID] INT, + [SampleKind_ID] INT, + [SamplingPoint_ID] INT NOT NULL, + [SampledByPerson_ID] INT, + [Campaign_ID] INT, + [SampleDateTimeStart] DATETIME2(7) NOT NULL, + [SampleDateTimeEnd] DATETIME2(7), + [SampleCollectionKind_ID] INT, + [SampleEquipment_ID] INT, + [Description] NVARCHAR(500), + CONSTRAINT [PK_Sample] PRIMARY KEY ([Sample_ID]) +); + +CREATE TABLE [dbo].[SamplingPoint] ( + [SamplingPoint_ID] INT IDENTITY(1,1) NOT NULL, + [Site_ID] INT NOT NULL, + [SamplingPoint] NVARCHAR(100) NOT NULL, + [LatitudeWGS84] FLOAT, + [LongitudeWGS84] FLOAT, + [Description] NVARCHAR(MAX), + [PicturePath] NVARCHAR(500), + [ValidFrom] DATETIME2(7), + [ValidTo] DATETIME2(7), + [ProcessUnit_ID] INT, + [CreatedByCampaign_ID] INT, + CONSTRAINT [PK_SamplingPoint] PRIMARY KEY ([SamplingPoint_ID]) +); + +CREATE TABLE [dbo].[SignalInterface] ( + [SignalInterface_ID] INT IDENTITY(1,1) NOT NULL, + [DataAcquisitionSystem_ID] INT NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Manufacturer] NVARCHAR(100), + [Model] NVARCHAR(100), + [SerialNumber] NVARCHAR(100), + [Description] NVARCHAR(MAX), + [IsActive] BIT NOT NULL DEFAULT 1, + CONSTRAINT [PK_SignalInterface] PRIMARY KEY ([SignalInterface_ID]) +); + +CREATE TABLE [dbo].[SignalInterfacePort] ( + [SignalInterfacePort_ID] INT IDENTITY(1,1) NOT NULL, + [SignalInterface_ID] INT NOT NULL, + [PortIdentifier] NVARCHAR(100) NOT NULL, + [Description] NVARCHAR(MAX), + [IsActive] BIT NOT NULL DEFAULT 1, + CONSTRAINT [PK_SignalInterfacePort] PRIMARY KEY ([SignalInterfacePort_ID]) +); + +CREATE TABLE [dbo].[Site] ( + [Site_ID] INT IDENTITY(1,1) NOT NULL, + [Watershed_ID] INT, + [Name] NVARCHAR(100), + [SiteKind_ID] INT, + [Description] NVARCHAR(MAX), + [LatitudeWGS84] FLOAT, + [LongitudeWGS84] FLOAT, + [StreetNumber] NVARCHAR(100), + [StreetName] NVARCHAR(100), + [City] NVARCHAR(255), + [PostCode] NVARCHAR(100), + [Province] NVARCHAR(255), + [Country] NVARCHAR(255), + CONSTRAINT [PK_Site] PRIMARY KEY ([Site_ID]) +); + +CREATE TABLE [dbo].[Stream] ( + [Stream_ID] INT IDENTITY(1,1) NOT NULL, + [StreamKind_ID] INT NOT NULL, + CONSTRAINT [PK_Stream] PRIMARY KEY ([Stream_ID]) +); + +CREATE TABLE [dbo].[Value] ( + [Observation_ID] INT NOT NULL, + [Value] FLOAT, + [QualityCode] INT, + CONSTRAINT [PK_Value] PRIMARY KEY ([Observation_ID]) +); + +CREATE TABLE [dbo].[ValueBin] ( + [ValueBin_ID] INT IDENTITY(1,1) NOT NULL, + [ValueBinningAxis_ID] INT NOT NULL, + [BinIndex] INT NOT NULL, + [LowerBound] FLOAT, + [UpperBound] FLOAT, + [NominalValue] FLOAT, + CONSTRAINT [PK_ValueBin] PRIMARY KEY ([ValueBin_ID]), + CONSTRAINT [UQ_ValueBin_AxisIndex] UNIQUE ([ValueBinningAxis_ID], [BinIndex]), + CONSTRAINT [CK_ValueBin_BinValues] CHECK (((LowerBound IS NULL AND UpperBound IS NULL) OR (LowerBound IS NOT NULL AND UpperBound IS NOT NULL)) AND (LowerBound IS NULL OR UpperBound > LowerBound) AND (NominalValue IS NOT NULL OR LowerBound IS NOT NULL) +) +); + +CREATE TABLE [dbo].[ValueBinningAxis] ( + [ValueBinningAxis_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(200) NOT NULL, + [Description] NVARCHAR(500), + [NumberOfBins] INT NOT NULL, + [Unit_ID] INT NOT NULL, + [BinKind_ID] INT NOT NULL DEFAULT 1, + CONSTRAINT [PK_ValueBinningAxis] PRIMARY KEY ([ValueBinningAxis_ID]) +); + +CREATE TABLE [dbo].[ValueImage] ( + [Observation_ID] INT NOT NULL, + [ImageWidth] INT NOT NULL, + [ImageHeight] INT NOT NULL, + [NumberOfChannels] INT NOT NULL DEFAULT 3, + [ImageFormat] NVARCHAR(20) NOT NULL, + [FileSizeBytes] BIGINT, + [StorageBackend] NVARCHAR(50) NOT NULL DEFAULT 'FileSystem', + [StoragePath] NVARCHAR(1000) NOT NULL, + [Thumbnail] VARBINARY(MAX), + [QualityCode] INT, + CONSTRAINT [PK_ValueImage] PRIMARY KEY ([Observation_ID]) +); + +CREATE TABLE [dbo].[ValueMatrix] ( + [Observation_ID] INT NOT NULL, + [RowValueBin_ID] INT NOT NULL, + [ColValueBin_ID] INT NOT NULL, + [Value] FLOAT, + [QualityCode] INT, + CONSTRAINT [PK_ValueMatrix] PRIMARY KEY ([Observation_ID], [RowValueBin_ID], [ColValueBin_ID]) +); + +CREATE TABLE [dbo].[ValueVector] ( + [Observation_ID] INT NOT NULL, + [ValueBin_ID] INT NOT NULL, + [Value] FLOAT, + [QualityCode] INT, + CONSTRAINT [PK_ValueVector] PRIMARY KEY ([Observation_ID], [ValueBin_ID]) +); + +CREATE TABLE [dbo].[Watershed] ( + [Watershed_ID] INT IDENTITY(1,1) NOT NULL, + [Name] NVARCHAR(100), + [Description] NVARCHAR(MAX), + [SurfaceArea] REAL, + [ConcentrationTime] INT, + [ImperviousSurface] REAL, + [ParentWatershed_ID] INT, + [GeometryGeoJSON] NVARCHAR(MAX), + CONSTRAINT [PK_Watershed] PRIMARY KEY ([Watershed_ID]) +); + + + + + + + + + + + + + + + + + + + + + + + +CREATE INDEX [IX_UserAccount_Email] ON [dbo].[UserAccount] ([Email]); + + + + +CREATE INDEX [IX_Annotation_Stream_Time] ON [dbo].[Annotation] ([Stream_ID], [StartTime], [EndTime]); +CREATE INDEX [IX_Annotation_Author] ON [dbo].[Annotation] ([AuthorPerson_ID], [CreatedDateTime]); + +CREATE INDEX [IX_AuditLog_UserAccount_ID] ON [dbo].[AuditLog] ([UserAccount_ID]); +CREATE INDEX [IX_AuditLog_Timestamp] ON [dbo].[AuditLog] ([Timestamp]); +CREATE INDEX [IX_AuditLog_ResourceType] ON [dbo].[AuditLog] ([ResourceType]); + + + + +CREATE UNIQUE INDEX [UQ_Channel_SignalStream] ON [dbo].[Channel] ([SignalInterface_ID], [TagName], [Parameter_ID], [DataProvenanceKind_ID], [ProducedByStep_ID]); +CREATE INDEX [IX_Channel_ParentChannel] ON [dbo].[Channel] ([ParentChannel_ID]); + + +CREATE UNIQUE INDEX [UQ_ChannelPortHistory_ActiveRow] ON [dbo].[ChannelPortHistory] ([Channel_ID]) WHERE [ValidTo] IS NULL; +CREATE INDEX [IX_ChannelPortHistory_Port] ON [dbo].[ChannelPortHistory] ([SignalInterfacePort_ID], [ValidFrom]); + +CREATE INDEX [IX_ChannelTrait_Stream] ON [dbo].[ChannelTrait] ([Stream_ID]); + + +CREATE UNIQUE INDEX [UQ_ControlLoopApplication_ActiveRow] ON [dbo].[ControlLoopApplication] ([ControlLoop_ID]) WHERE [EndTime] IS NULL; + +CREATE UNIQUE INDEX [UQ_ControlLoopPort_LoopChannel] ON [dbo].[ControlLoopPort] ([ControlLoop_ID], [Channel_ID]); + +CREATE UNIQUE INDEX [UQ_DASLocationHistory_ActivePerDAS] ON [dbo].[DASLocationHistory] ([DataAcquisitionSystem_ID]) WHERE [ValidTo] IS NULL; +CREATE INDEX [IX_DASLocationHistory_Site_ValidFrom] ON [dbo].[DASLocationHistory] ([Site_ID], [ValidFrom]); + + + + + +CREATE UNIQUE INDEX [UQ_EquipmentLocationHistory_ActiveRow] ON [dbo].[EquipmentLocationHistory] ([Equipment_ID]) WHERE [ValidTo] IS NULL; +CREATE INDEX [IX_EquipmentLocationHistory_SamplingPoint] ON [dbo].[EquipmentLocationHistory] ([SamplingPoint_ID], [ValidFrom]); + + + +CREATE UNIQUE INDEX [UQ_EquipmentWiringHistory_ActiveRow] ON [dbo].[EquipmentWiringHistory] ([Equipment_ID]) WHERE [ValidTo] IS NULL; +CREATE INDEX [IX_EquipmentWiringHistory_Interface] ON [dbo].[EquipmentWiringHistory] ([SignalInterface_ID], [ValidFrom]); + +CREATE INDEX [IX_Event_Equipment_Start] ON [dbo].[Event] ([Equipment_ID], [EventDateTimeStart]); +CREATE INDEX [IX_Event_Channel_Start] ON [dbo].[Event] ([Channel_ID], [EventDateTimeStart]); +CREATE INDEX [IX_Event_Site_Start] ON [dbo].[Event] ([Site_ID], [EventDateTimeStart]); +CREATE INDEX [IX_Event_Start] ON [dbo].[Event] ([EventDateTimeStart]); + + + + + + + + +CREATE UNIQUE INDEX [UQ_Obs_Channel] ON [dbo].[Observation] ([Channel_ID], [Timestamp], [ValueKind_ID]) WHERE Channel_ID IS NOT NULL; +CREATE UNIQUE INDEX [UQ_Obs_Lab] ON [dbo].[Observation] ([LabAnalysis_ID]) WHERE LabAnalysis_ID IS NOT NULL; + + + + + +CREATE INDEX [IX_ProcessingLineage_Stream] ON [dbo].[ProcessingLineage] ([Stream_ID]); +CREATE INDEX [IX_Lineage_Step] ON [dbo].[ProcessingLineage] ([ProcessingStep_ID]); + + + + +CREATE UNIQUE INDEX [UQ_SignalInterface_DAS_Name] ON [dbo].[SignalInterface] ([DataAcquisitionSystem_ID], [Name]); + +CREATE UNIQUE INDEX [UQ_SignalInterfacePort_Interface_PortId] ON [dbo].[SignalInterfacePort] ([SignalInterface_ID], [PortIdentifier]); + + + + + + + + + + +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_Parameter_ID] FOREIGN KEY ([Parameter_ID]) REFERENCES [dbo].[Parameter] ([Parameter_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_SamplingPoint_ID] FOREIGN KEY ([SamplingPoint_ID]) REFERENCES [dbo].[SamplingPoint] ([SamplingPoint_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_ValueKind_ID] FOREIGN KEY ([ValueKind_ID]) REFERENCES [dbo].[ValueKind] ([ValueKind_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_Unit_ID] FOREIGN KEY ([Unit_ID]) REFERENCES [dbo].[Unit] ([Unit_ID]); +ALTER TABLE [dbo].[AnalysisSeries] ADD CONSTRAINT [FK_AnalysisSeries_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[AnalysisSeriesAxis] ADD CONSTRAINT [FK_AnalysisSeriesAxis_AnalysisSeries_ID] FOREIGN KEY ([AnalysisSeries_ID]) REFERENCES [dbo].[AnalysisSeries] ([Stream_ID]); +ALTER TABLE [dbo].[AnalysisSeriesAxis] ADD CONSTRAINT [FK_AnalysisSeriesAxis_ValueBinningAxis_ID] FOREIGN KEY ([ValueBinningAxis_ID]) REFERENCES [dbo].[ValueBinningAxis] ([ValueBinningAxis_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_AnnotationKind_ID] FOREIGN KEY ([AnnotationKind_ID]) REFERENCES [dbo].[AnnotationKind] ([AnnotationKind_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_AuthorPerson_ID] FOREIGN KEY ([AuthorPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_Event_ID] FOREIGN KEY ([Event_ID]) REFERENCES [dbo].[Event] ([Event_ID]); +ALTER TABLE [dbo].[Annotation] ADD CONSTRAINT [FK_Annotation_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[AuditLog] ADD CONSTRAINT [FK_AuditLog_UserAccount_ID] FOREIGN KEY ([UserAccount_ID]) REFERENCES [dbo].[UserAccount] ([UserAccount_ID]); +ALTER TABLE [dbo].[Campaign] ADD CONSTRAINT [FK_Campaign_CampaignKind_ID] FOREIGN KEY ([CampaignKind_ID]) REFERENCES [dbo].[CampaignKind] ([CampaignKind_ID]); +ALTER TABLE [dbo].[Campaign] ADD CONSTRAINT [FK_Campaign_ResponsiblePerson_ID] FOREIGN KEY ([ResponsiblePerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[CampaignEquipment] ADD CONSTRAINT [FK_CampaignEquipment_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[CampaignEquipment] ADD CONSTRAINT [FK_CampaignEquipment_Equipment_ID] FOREIGN KEY ([Equipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[CampaignSamplingLocation] ADD CONSTRAINT [FK_CampaignSamplingLocation_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[CampaignSamplingLocation] ADD CONSTRAINT [FK_CampaignSamplingLocation_SamplingPoint_ID] FOREIGN KEY ([SamplingPoint_ID]) REFERENCES [dbo].[SamplingPoint] ([SamplingPoint_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_SignalInterface_ID] FOREIGN KEY ([SignalInterface_ID]) REFERENCES [dbo].[SignalInterface] ([SignalInterface_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_ParentChannel_ID] FOREIGN KEY ([ParentChannel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_ChannelKind_ID] FOREIGN KEY ([ChannelKind_ID]) REFERENCES [dbo].[ChannelKind] ([ChannelKind_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_Parameter_ID] FOREIGN KEY ([Parameter_ID]) REFERENCES [dbo].[Parameter] ([Parameter_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_DataProvenanceKind_ID] FOREIGN KEY ([DataProvenanceKind_ID]) REFERENCES [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_ProducedByStep_ID] FOREIGN KEY ([ProducedByStep_ID]) REFERENCES [dbo].[ProcessingStep] ([ProcessingStep_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_ValueKind_ID] FOREIGN KEY ([ValueKind_ID]) REFERENCES [dbo].[ValueKind] ([ValueKind_ID]); +ALTER TABLE [dbo].[Channel] ADD CONSTRAINT [FK_Channel_Unit_ID] FOREIGN KEY ([Unit_ID]) REFERENCES [dbo].[Unit] ([Unit_ID]); +ALTER TABLE [dbo].[ChannelAxis] ADD CONSTRAINT [FK_ChannelAxis_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[ChannelAxis] ADD CONSTRAINT [FK_ChannelAxis_ValueBinningAxis_ID] FOREIGN KEY ([ValueBinningAxis_ID]) REFERENCES [dbo].[ValueBinningAxis] ([ValueBinningAxis_ID]); +ALTER TABLE [dbo].[ChannelPortHistory] ADD CONSTRAINT [FK_ChannelPortHistory_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[ChannelPortHistory] ADD CONSTRAINT [FK_ChannelPortHistory_SignalInterfacePort_ID] FOREIGN KEY ([SignalInterfacePort_ID]) REFERENCES [dbo].[SignalInterfacePort] ([SignalInterfacePort_ID]); +ALTER TABLE [dbo].[ChannelTrait] ADD CONSTRAINT [FK_ChannelTrait_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[ChannelTrait] ADD CONSTRAINT [FK_ChannelTrait_OperationKind_ID] FOREIGN KEY ([OperationKind_ID]) REFERENCES [dbo].[OperationKind] ([OperationKind_ID]); +ALTER TABLE [dbo].[ControlLoop] ADD CONSTRAINT [FK_ControlLoop_ControllerKind_ID] FOREIGN KEY ([ControllerKind_ID]) REFERENCES [dbo].[ControllerKind] ([ControllerKind_ID]); +ALTER TABLE [dbo].[ControlLoop] ADD CONSTRAINT [FK_ControlLoop_FallbackControlLoop_ID] FOREIGN KEY ([FallbackControlLoop_ID]) REFERENCES [dbo].[ControlLoop] ([ControlLoop_ID]); +ALTER TABLE [dbo].[ControlLoopApplication] ADD CONSTRAINT [FK_ControlLoopApplication_ControlLoop_ID] FOREIGN KEY ([ControlLoop_ID]) REFERENCES [dbo].[ControlLoop] ([ControlLoop_ID]); +ALTER TABLE [dbo].[ControlLoopApplication] ADD CONSTRAINT [FK_ControlLoopApplication_AppliedByPerson_ID] FOREIGN KEY ([AppliedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[ControlLoopPort] ADD CONSTRAINT [FK_ControlLoopPort_ControlLoop_ID] FOREIGN KEY ([ControlLoop_ID]) REFERENCES [dbo].[ControlLoop] ([ControlLoop_ID]); +ALTER TABLE [dbo].[ControlLoopPort] ADD CONSTRAINT [FK_ControlLoopPort_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[ControlLoopPort] ADD CONSTRAINT [FK_ControlLoopPort_ControlLoopPortKind_ID] FOREIGN KEY ([ControlLoopPortKind_ID]) REFERENCES [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID]); +ALTER TABLE [dbo].[DASLocationHistory] ADD CONSTRAINT [FK_DASLocationHistory_DataAcquisitionSystem_ID] FOREIGN KEY ([DataAcquisitionSystem_ID]) REFERENCES [dbo].[DataAcquisitionSystem] ([DataAcquisitionSystem_ID]); +ALTER TABLE [dbo].[DASLocationHistory] ADD CONSTRAINT [FK_DASLocationHistory_Site_ID] FOREIGN KEY ([Site_ID]) REFERENCES [dbo].[Site] ([Site_ID]); +ALTER TABLE [dbo].[DASLocationHistory] ADD CONSTRAINT [FK_DASLocationHistory_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[DataAcquisitionSystem] ADD CONSTRAINT [FK_DataAcquisitionSystem_ParentSystem_ID] FOREIGN KEY ([ParentSystem_ID]) REFERENCES [dbo].[DataAcquisitionSystem] ([DataAcquisitionSystem_ID]); +ALTER TABLE [dbo].[DataAcquisitionSystem] ADD CONSTRAINT [FK_DataAcquisitionSystem_DataAcquisitionSystemKind_ID] FOREIGN KEY ([DataAcquisitionSystemKind_ID]) REFERENCES [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID]); +ALTER TABLE [dbo].[Dataset] ADD CONSTRAINT [FK_Dataset_CreatedByPerson_ID] FOREIGN KEY ([CreatedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[DatasetChannel] ADD CONSTRAINT [FK_DatasetChannel_Dataset_ID] FOREIGN KEY ([Dataset_ID]) REFERENCES [dbo].[Dataset] ([Dataset_ID]); +ALTER TABLE [dbo].[DatasetChannel] ADD CONSTRAINT [FK_DatasetChannel_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[Equipment] ADD CONSTRAINT [FK_Equipment_EquipmentModel_ID] FOREIGN KEY ([EquipmentModel_ID]) REFERENCES [dbo].[EquipmentModel] ([EquipmentModel_ID]); +ALTER TABLE [dbo].[EquipmentLocationHistory] ADD CONSTRAINT [FK_EquipmentLocationHistory_Equipment_ID] FOREIGN KEY ([Equipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[EquipmentLocationHistory] ADD CONSTRAINT [FK_EquipmentLocationHistory_SamplingPoint_ID] FOREIGN KEY ([SamplingPoint_ID]) REFERENCES [dbo].[SamplingPoint] ([SamplingPoint_ID]); +ALTER TABLE [dbo].[EquipmentLocationHistory] ADD CONSTRAINT [FK_EquipmentLocationHistory_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[EquipmentModelHasParameter] ADD CONSTRAINT [FK_EquipmentModelHasParameter_EquipmentModel_ID] FOREIGN KEY ([EquipmentModel_ID]) REFERENCES [dbo].[EquipmentModel] ([EquipmentModel_ID]); +ALTER TABLE [dbo].[EquipmentModelHasParameter] ADD CONSTRAINT [FK_EquipmentModelHasParameter_Parameter_ID] FOREIGN KEY ([Parameter_ID]) REFERENCES [dbo].[Parameter] ([Parameter_ID]); +ALTER TABLE [dbo].[EquipmentModelHasProcedures] ADD CONSTRAINT [FK_EquipmentModelHasProcedures_EquipmentModel_ID] FOREIGN KEY ([EquipmentModel_ID]) REFERENCES [dbo].[EquipmentModel] ([EquipmentModel_ID]); +ALTER TABLE [dbo].[EquipmentModelHasProcedures] ADD CONSTRAINT [FK_EquipmentModelHasProcedures_Procedure_ID] FOREIGN KEY ([Procedure_ID]) REFERENCES [dbo].[Procedures] ([Procedure_ID]); +ALTER TABLE [dbo].[EquipmentWiringHistory] ADD CONSTRAINT [FK_EquipmentWiringHistory_Equipment_ID] FOREIGN KEY ([Equipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[EquipmentWiringHistory] ADD CONSTRAINT [FK_EquipmentWiringHistory_SignalInterface_ID] FOREIGN KEY ([SignalInterface_ID]) REFERENCES [dbo].[SignalInterface] ([SignalInterface_ID]); +ALTER TABLE [dbo].[EquipmentWiringHistory] ADD CONSTRAINT [FK_EquipmentWiringHistory_SignalInterfacePort_ID] FOREIGN KEY ([SignalInterfacePort_ID]) REFERENCES [dbo].[SignalInterfacePort] ([SignalInterfacePort_ID]); +ALTER TABLE [dbo].[Event] ADD CONSTRAINT [FK_Event_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[Event] ADD CONSTRAINT [FK_Event_Equipment_ID] FOREIGN KEY ([Equipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[Event] ADD CONSTRAINT [FK_Event_SignalInterface_ID] FOREIGN KEY ([SignalInterface_ID]) REFERENCES [dbo].[SignalInterface] ([SignalInterface_ID]); +ALTER TABLE [dbo].[Event] ADD CONSTRAINT [FK_Event_DataAcquisitionSystem_ID] FOREIGN KEY ([DataAcquisitionSystem_ID]) REFERENCES [dbo].[DataAcquisitionSystem] ([DataAcquisitionSystem_ID]); +ALTER TABLE [dbo].[Event] ADD CONSTRAINT [FK_Event_SamplingPoint_ID] FOREIGN KEY ([SamplingPoint_ID]) REFERENCES [dbo].[SamplingPoint] ([SamplingPoint_ID]); +ALTER TABLE [dbo].[Event] ADD CONSTRAINT [FK_Event_ProcessUnit_ID] FOREIGN KEY ([ProcessUnit_ID]) REFERENCES [dbo].[ProcessUnit] ([ProcessUnit_ID]); +ALTER TABLE [dbo].[Event] ADD CONSTRAINT [FK_Event_Site_ID] FOREIGN KEY ([Site_ID]) REFERENCES [dbo].[Site] ([Site_ID]); +ALTER TABLE [dbo].[Event] ADD CONSTRAINT [FK_Event_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[Event] ADD CONSTRAINT [FK_Event_EventKind_ID] FOREIGN KEY ([EventKind_ID]) REFERENCES [dbo].[EventKind] ([EventKind_ID]); +ALTER TABLE [dbo].[Event] ADD CONSTRAINT [FK_Event_PerformedByPerson_ID] FOREIGN KEY ([PerformedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[Event] ADD CONSTRAINT [FK_Event_RecordedByPerson_ID] FOREIGN KEY ([RecordedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[HydrologicalCharacteristics] ADD CONSTRAINT [FK_HydrologicalCharacteristics_Watershed_ID] FOREIGN KEY ([Watershed_ID]) REFERENCES [dbo].[Watershed] ([Watershed_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_LabExperiment_ID] FOREIGN KEY ([LabExperiment_ID]) REFERENCES [dbo].[LabExperiment] ([LabExperiment_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_AnalysisSeries_ID] FOREIGN KEY ([AnalysisSeries_ID]) REFERENCES [dbo].[AnalysisSeries] ([Stream_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_Sample_ID] FOREIGN KEY ([Sample_ID]) REFERENCES [dbo].[Sample] ([Sample_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_QualityCode_ID] FOREIGN KEY ([QualityCode_ID]) REFERENCES [dbo].[QualityCode] ([QualityCode_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_ReviewStatus_ID] FOREIGN KEY ([ReviewStatus_ID]) REFERENCES [dbo].[ReviewStatus] ([ReviewStatus_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_ReviewedByPerson_ID] FOREIGN KEY ([ReviewedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_Laboratory_ID] FOREIGN KEY ([Laboratory_ID]) REFERENCES [dbo].[Laboratory] ([Laboratory_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_AnalystPerson_ID] FOREIGN KEY ([AnalystPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[LabAnalysis] ADD CONSTRAINT [FK_LabAnalysis_Procedure_ID] FOREIGN KEY ([Procedure_ID]) REFERENCES [dbo].[Procedures] ([Procedure_ID]); +ALTER TABLE [dbo].[LabExperiment] ADD CONSTRAINT [FK_LabExperiment_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[LabExperiment] ADD CONSTRAINT [FK_LabExperiment_CreatedByPerson_ID] FOREIGN KEY ([CreatedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[LabExperiment] ADD CONSTRAINT [FK_LabExperiment_LabPanel_ID] FOREIGN KEY ([LabPanel_ID]) REFERENCES [dbo].[LabPanel] ([LabPanel_ID]); +ALTER TABLE [dbo].[LabPanel] ADD CONSTRAINT [FK_LabPanel_CreatedByPerson_ID] FOREIGN KEY ([CreatedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[LabPanel] ADD CONSTRAINT [FK_LabPanel_DefaultSampleCollectionKind_ID] FOREIGN KEY ([DefaultSampleCollectionKind_ID]) REFERENCES [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID]); +ALTER TABLE [dbo].[LabPanel] ADD CONSTRAINT [FK_LabPanel_DefaultSampleEquipment_ID] FOREIGN KEY ([DefaultSampleEquipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[LabPanelSeries] ADD CONSTRAINT [FK_LabPanelSeries_LabPanel_ID] FOREIGN KEY ([LabPanel_ID]) REFERENCES [dbo].[LabPanel] ([LabPanel_ID]); +ALTER TABLE [dbo].[LabPanelSeries] ADD CONSTRAINT [FK_LabPanelSeries_AnalysisSeries_ID] FOREIGN KEY ([AnalysisSeries_ID]) REFERENCES [dbo].[AnalysisSeries] ([Stream_ID]); +ALTER TABLE [dbo].[Laboratory] ADD CONSTRAINT [FK_Laboratory_Site_ID] FOREIGN KEY ([Site_ID]) REFERENCES [dbo].[Site] ([Site_ID]); +ALTER TABLE [dbo].[LandUse] ADD CONSTRAINT [FK_LandUse_Watershed_ID] FOREIGN KEY ([Watershed_ID]) REFERENCES [dbo].[Watershed] ([Watershed_ID]); +ALTER TABLE [dbo].[Observation] ADD CONSTRAINT [FK_Observation_Channel_ID] FOREIGN KEY ([Channel_ID]) REFERENCES [dbo].[Channel] ([Stream_ID]); +ALTER TABLE [dbo].[Observation] ADD CONSTRAINT [FK_Observation_LabAnalysis_ID] FOREIGN KEY ([LabAnalysis_ID]) REFERENCES [dbo].[LabAnalysis] ([LabAnalysis_ID]); +ALTER TABLE [dbo].[Observation] ADD CONSTRAINT [FK_Observation_ValueKind_ID] FOREIGN KEY ([ValueKind_ID]) REFERENCES [dbo].[ValueKind] ([ValueKind_ID]); +ALTER TABLE [dbo].[Parameter] ADD CONSTRAINT [FK_Parameter_ValueKind_ID] FOREIGN KEY ([ValueKind_ID]) REFERENCES [dbo].[ValueKind] ([ValueKind_ID]); +ALTER TABLE [dbo].[ParameterHasUnit] ADD CONSTRAINT [FK_ParameterHasUnit_Parameter_ID] FOREIGN KEY ([Parameter_ID]) REFERENCES [dbo].[Parameter] ([Parameter_ID]); +ALTER TABLE [dbo].[ParameterHasUnit] ADD CONSTRAINT [FK_ParameterHasUnit_Unit_ID] FOREIGN KEY ([Unit_ID]) REFERENCES [dbo].[Unit] ([Unit_ID]); +ALTER TABLE [dbo].[Procedures] ADD CONSTRAINT [FK_Procedures_ProcedureKind_ID] FOREIGN KEY ([ProcedureKind_ID]) REFERENCES [dbo].[ProcedureKind] ([ProcedureKind_ID]); +ALTER TABLE [dbo].[ProcessUnit] ADD CONSTRAINT [FK_ProcessUnit_Site_ID] FOREIGN KEY ([Site_ID]) REFERENCES [dbo].[Site] ([Site_ID]); +ALTER TABLE [dbo].[ProcessUnit] ADD CONSTRAINT [FK_ProcessUnit_ProcessUnitKind_ID] FOREIGN KEY ([ProcessUnitKind_ID]) REFERENCES [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID]); +ALTER TABLE [dbo].[ProcessUnit] ADD CONSTRAINT [FK_ProcessUnit_Parent_ID] FOREIGN KEY ([Parent_ID]) REFERENCES [dbo].[ProcessUnit] ([ProcessUnit_ID]); +ALTER TABLE [dbo].[ProcessingLineage] ADD CONSTRAINT [FK_ProcessingLineage_ProcessingStep_ID] FOREIGN KEY ([ProcessingStep_ID]) REFERENCES [dbo].[ProcessingStep] ([ProcessingStep_ID]); +ALTER TABLE [dbo].[ProcessingLineage] ADD CONSTRAINT [FK_ProcessingLineage_Stream_ID] FOREIGN KEY ([Stream_ID]) REFERENCES [dbo].[Stream] ([Stream_ID]); +ALTER TABLE [dbo].[ProcessingStep] ADD CONSTRAINT [FK_ProcessingStep_OperationKind_ID] FOREIGN KEY ([OperationKind_ID]) REFERENCES [dbo].[OperationKind] ([OperationKind_ID]); +ALTER TABLE [dbo].[ProcessingStep] ADD CONSTRAINT [FK_ProcessingStep_ExecutedByPerson_ID] FOREIGN KEY ([ExecutedByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[ProcessingStep] ADD CONSTRAINT [FK_ProcessingStep_Dataset_ID] FOREIGN KEY ([Dataset_ID]) REFERENCES [dbo].[Dataset] ([Dataset_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_ParentSample_ID] FOREIGN KEY ([ParentSample_ID]) REFERENCES [dbo].[Sample] ([Sample_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SampleKind_ID] FOREIGN KEY ([SampleKind_ID]) REFERENCES [dbo].[SampleKind] ([SampleKind_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SamplingPoint_ID] FOREIGN KEY ([SamplingPoint_ID]) REFERENCES [dbo].[SamplingPoint] ([SamplingPoint_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SampledByPerson_ID] FOREIGN KEY ([SampledByPerson_ID]) REFERENCES [dbo].[Person] ([Person_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_Campaign_ID] FOREIGN KEY ([Campaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SampleCollectionKind_ID] FOREIGN KEY ([SampleCollectionKind_ID]) REFERENCES [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID]); +ALTER TABLE [dbo].[Sample] ADD CONSTRAINT [FK_Sample_SampleEquipment_ID] FOREIGN KEY ([SampleEquipment_ID]) REFERENCES [dbo].[Equipment] ([Equipment_ID]); +ALTER TABLE [dbo].[SamplingPoint] ADD CONSTRAINT [FK_SamplingPoint_Site_ID] FOREIGN KEY ([Site_ID]) REFERENCES [dbo].[Site] ([Site_ID]); +ALTER TABLE [dbo].[SamplingPoint] ADD CONSTRAINT [FK_SamplingPoint_ProcessUnit_ID] FOREIGN KEY ([ProcessUnit_ID]) REFERENCES [dbo].[ProcessUnit] ([ProcessUnit_ID]); +ALTER TABLE [dbo].[SamplingPoint] ADD CONSTRAINT [FK_SamplingPoint_CreatedByCampaign_ID] FOREIGN KEY ([CreatedByCampaign_ID]) REFERENCES [dbo].[Campaign] ([Campaign_ID]); +ALTER TABLE [dbo].[SignalInterface] ADD CONSTRAINT [FK_SignalInterface_DataAcquisitionSystem_ID] FOREIGN KEY ([DataAcquisitionSystem_ID]) REFERENCES [dbo].[DataAcquisitionSystem] ([DataAcquisitionSystem_ID]); +ALTER TABLE [dbo].[SignalInterfacePort] ADD CONSTRAINT [FK_SignalInterfacePort_SignalInterface_ID] FOREIGN KEY ([SignalInterface_ID]) REFERENCES [dbo].[SignalInterface] ([SignalInterface_ID]); +ALTER TABLE [dbo].[Site] ADD CONSTRAINT [FK_Site_Watershed_ID] FOREIGN KEY ([Watershed_ID]) REFERENCES [dbo].[Watershed] ([Watershed_ID]); +ALTER TABLE [dbo].[Site] ADD CONSTRAINT [FK_Site_SiteKind_ID] FOREIGN KEY ([SiteKind_ID]) REFERENCES [dbo].[SiteKind] ([SiteKind_ID]); +ALTER TABLE [dbo].[Stream] ADD CONSTRAINT [FK_Stream_StreamKind_ID] FOREIGN KEY ([StreamKind_ID]) REFERENCES [dbo].[StreamKind] ([StreamKind_ID]); +ALTER TABLE [dbo].[Value] ADD CONSTRAINT [FK_Value_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[ValueBin] ADD CONSTRAINT [FK_ValueBin_ValueBinningAxis_ID] FOREIGN KEY ([ValueBinningAxis_ID]) REFERENCES [dbo].[ValueBinningAxis] ([ValueBinningAxis_ID]); +ALTER TABLE [dbo].[ValueBinningAxis] ADD CONSTRAINT [FK_ValueBinningAxis_Unit_ID] FOREIGN KEY ([Unit_ID]) REFERENCES [dbo].[Unit] ([Unit_ID]); +ALTER TABLE [dbo].[ValueBinningAxis] ADD CONSTRAINT [FK_ValueBinningAxis_BinKind_ID] FOREIGN KEY ([BinKind_ID]) REFERENCES [dbo].[BinKind] ([BinKind_ID]); +ALTER TABLE [dbo].[ValueImage] ADD CONSTRAINT [FK_ValueImage_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[ValueMatrix] ADD CONSTRAINT [FK_ValueMatrix_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[ValueMatrix] ADD CONSTRAINT [FK_ValueMatrix_RowValueBin] FOREIGN KEY ([RowValueBin_ID]) REFERENCES [dbo].[ValueBin] ([ValueBin_ID]); +ALTER TABLE [dbo].[ValueMatrix] ADD CONSTRAINT [FK_ValueMatrix_ColValueBin] FOREIGN KEY ([ColValueBin_ID]) REFERENCES [dbo].[ValueBin] ([ValueBin_ID]); +ALTER TABLE [dbo].[ValueVector] ADD CONSTRAINT [FK_ValueVector_Observation_ID] FOREIGN KEY ([Observation_ID]) REFERENCES [dbo].[Observation] ([Observation_ID]); +ALTER TABLE [dbo].[ValueVector] ADD CONSTRAINT [FK_ValueVector_ValueBin_ID] FOREIGN KEY ([ValueBin_ID]) REFERENCES [dbo].[ValueBin] ([ValueBin_ID]); +ALTER TABLE [dbo].[Watershed] ADD CONSTRAINT [FK_Watershed_ParentWatershed_ID] FOREIGN KEY ([ParentWatershed_ID]) REFERENCES [dbo].[Watershed] ([Watershed_ID]); + +-- Views +GO +CREATE OR ALTER VIEW [dbo].[vw_ChannelResolved] AS +SELECT + c.[Stream_ID], + c.[SignalInterface_ID], + c.[TagName], + cph.[SignalInterfacePort_ID], + c.[ParentChannel_ID], + c.[ChannelKind_ID], + c.[Parameter_ID], + c.[DataProvenanceKind_ID], + c.[ProducedByStep_ID], + c.[ValueKind_ID], + c.[Unit_ID] +FROM [dbo].[Channel] c +LEFT JOIN [dbo].[ChannelPortHistory] cph + ON cph.[Channel_ID] = c.[Stream_ID] + AND cph.[ValidTo] IS NULL; + +GO +CREATE OR ALTER VIEW [dbo].[vw_DeploymentCoherence] AS +SELECT + dlh.[DataAcquisitionSystem_ID] AS DAS_ID, + das.[Name] AS DASName, + dlh.[Site_ID] AS DASSite_ID, + dsite.[Name] AS DASSiteName, + e.[Equipment_ID] AS Equipment_ID, + e.[Identifier] AS EquipmentName, + sp.[Site_ID] AS EquipmentSite_ID, + esite.[Name] AS EquipmentSiteName, + sp.[SamplingPoint_ID] AS SamplingPoint_ID, + sp.[SamplingPoint] AS SamplingPointName +FROM [dbo].[DASLocationHistory] dlh +JOIN [dbo].[DataAcquisitionSystem] das ON das.[DataAcquisitionSystem_ID] = dlh.[DataAcquisitionSystem_ID] +JOIN [dbo].[SignalInterface] si ON si.[DataAcquisitionSystem_ID] = dlh.[DataAcquisitionSystem_ID] +JOIN [dbo].[EquipmentWiringHistory] ewh ON ewh.[SignalInterface_ID] = si.[SignalInterface_ID] + AND ewh.[ValidTo] IS NULL +JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = ewh.[Equipment_ID] +JOIN [dbo].[EquipmentLocationHistory] elh ON elh.[Equipment_ID] = e.[Equipment_ID] + AND elh.[ValidTo] IS NULL +JOIN [dbo].[SamplingPoint] sp ON sp.[SamplingPoint_ID] = elh.[SamplingPoint_ID] +LEFT JOIN [dbo].[Site] dsite ON dsite.[Site_ID] = dlh.[Site_ID] +LEFT JOIN [dbo].[Site] esite ON esite.[Site_ID] = sp.[Site_ID] +WHERE dlh.[ValidTo] IS NULL + AND sp.[Site_ID] <> dlh.[Site_ID]; + +GO +CREATE OR ALTER VIEW [dbo].[vw_InactiveParentReferences] AS +SELECT + N'active-wiring->interface' AS ReferenceType, + ewh.[EquipmentWiringHistory_ID] AS WiringHistoryID, + ewh.[Equipment_ID] AS EquipmentID, + si.[SignalInterface_ID] AS ParentID, + si.[Name] AS ParentLabel +FROM [dbo].[EquipmentWiringHistory] ewh +JOIN [dbo].[SignalInterface] si ON si.[SignalInterface_ID] = ewh.[SignalInterface_ID] +WHERE ewh.[ValidTo] IS NULL + AND si.[IsActive] = 0 +UNION ALL +SELECT + N'active-wiring->port' AS ReferenceType, + ewh.[EquipmentWiringHistory_ID] AS WiringHistoryID, + ewh.[Equipment_ID] AS EquipmentID, + sip.[SignalInterfacePort_ID] AS ParentID, + sip.[PortIdentifier] AS ParentLabel +FROM [dbo].[EquipmentWiringHistory] ewh +JOIN [dbo].[SignalInterfacePort] sip ON sip.[SignalInterfacePort_ID] = ewh.[SignalInterfacePort_ID] +WHERE ewh.[ValidTo] IS NULL + AND sip.[IsActive] = 0; + +GO +CREATE OR ALTER VIEW [dbo].[vw_UnlinkedChannels] AS +SELECT + c.[Stream_ID] AS ChannelID, + c.[TagName] AS TagName, + c.[SignalInterface_ID] AS SignalInterfaceID, + si.[Name] AS SignalInterfaceName, + COUNT(o.[Observation_ID]) AS ObservationCount, + MIN(o.[Timestamp]) AS FirstObservation, + MAX(o.[Timestamp]) AS LastObservation +FROM [dbo].[Channel] c +JOIN [dbo].[Observation] o ON o.[Channel_ID] = c.[Stream_ID] +LEFT JOIN [dbo].[SignalInterface] si ON si.[SignalInterface_ID] = c.[SignalInterface_ID] +WHERE c.[SignalInterface_ID] IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM [dbo].[EquipmentWiringHistory] ewh + WHERE ewh.[SignalInterface_ID] = c.[SignalInterface_ID] + AND ewh.[ValidTo] IS NULL + ) +GROUP BY c.[Stream_ID], c.[TagName], c.[SignalInterface_ID], si.[Name]; + +GO +CREATE OR ALTER VIEW [dbo].[vw_ChannelEquipmentAtTime] AS +WITH channel_wiring AS ( + SELECT + o.[Observation_ID] AS ObservationID, + c.[Stream_ID] AS ChannelID, + o.[Timestamp] AS Timestamp, + c.[SignalInterface_ID], + c.[SignalInterfacePort_ID], + ewh.[Equipment_ID] AS EquipmentID, + ROW_NUMBER() OVER ( + PARTITION BY o.[Observation_ID] + ORDER BY + CASE WHEN ewh.[SignalInterfacePort_ID] IS NOT NULL THEN 0 ELSE 1 END, + ewh.[ValidFrom] DESC + ) AS rn, + COUNT(*) OVER (PARTITION BY o.[Observation_ID]) AS match_count + FROM [dbo].[Observation] o + JOIN [dbo].[vw_ChannelResolved] c ON c.[Stream_ID] = o.[Channel_ID] + LEFT JOIN [dbo].[EquipmentWiringHistory] ewh ON ewh.[SignalInterface_ID] = c.[SignalInterface_ID] + AND ( + ewh.[SignalInterfacePort_ID] = c.[SignalInterfacePort_ID] + OR (ewh.[SignalInterfacePort_ID] IS NULL AND c.[SignalInterfacePort_ID] IS NULL) + OR c.[SignalInterfacePort_ID] IS NULL + ) + AND ewh.[ValidFrom] <= o.[Timestamp] + AND (ewh.[ValidTo] IS NULL OR ewh.[ValidTo] > o.[Timestamp]) +) +SELECT + cw.ObservationID, + cw.ChannelID, + cw.Timestamp, + CASE WHEN cw.match_count > 1 AND cw.[SignalInterfacePort_ID] IS NULL THEN NULL ELSE cw.EquipmentID END AS EquipmentID, + e.[Identifier] AS EquipmentName, + CASE + WHEN cw.EquipmentID IS NULL AND cw.match_count = 0 THEN N'unlinked' + WHEN cw.match_count > 1 AND cw.[SignalInterfacePort_ID] IS NULL THEN N'ambiguous' + ELSE N'resolved' + END AS Resolution +FROM channel_wiring cw +LEFT JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = cw.EquipmentID +WHERE cw.rn = 1; + +GO +CREATE OR ALTER VIEW [dbo].[vw_ChannelStatus] AS +SELECT + statusC.[Stream_ID] AS StatusChannelID, + valueC.[Stream_ID] AS MeasurementChannelID, + e.[Equipment_ID] AS EquipmentID, + e.[Identifier] AS EquipmentName, + p.[Parameter] AS MeasurementParameter, + o.[Timestamp], + CAST(v.[Value] AS INT) AS StatusCodeID +FROM [dbo].[Value] v +JOIN [dbo].[Observation] o ON o.[Observation_ID] = v.[Observation_ID] +JOIN [dbo].[Channel] statusC ON statusC.[Stream_ID] = o.[Channel_ID] +JOIN [dbo].[ChannelKind] role ON role.[ChannelKind_ID] = statusC.[ChannelKind_ID] +JOIN [dbo].[vw_ChannelResolved] valueC ON valueC.[Stream_ID] = statusC.[ParentChannel_ID] +JOIN [dbo].[Parameter] p ON p.[Parameter_ID] = valueC.[Parameter_ID] +LEFT JOIN [dbo].[EquipmentWiringHistory] ewh + ON ewh.[SignalInterface_ID] = valueC.[SignalInterface_ID] + AND ( + ewh.[SignalInterfacePort_ID] = valueC.[SignalInterfacePort_ID] + OR valueC.[SignalInterfacePort_ID] IS NULL + ) + AND ewh.[ValidTo] IS NULL +LEFT JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = ewh.[Equipment_ID] +WHERE role.[Name] = N'Status' + AND statusC.[ParentChannel_ID] IS NOT NULL; + +GO +CREATE OR ALTER VIEW [dbo].[vw_DeviceStatus] AS +SELECT + statusC.[Stream_ID] AS StatusChannelID, + e.[Equipment_ID] AS EquipmentID, + e.[Identifier] AS EquipmentName, + o.[Timestamp], + CAST(v.[Value] AS INT) AS StatusCodeID +FROM [dbo].[Value] v +JOIN [dbo].[Observation] o ON o.[Observation_ID] = v.[Observation_ID] +JOIN [dbo].[Channel] statusC ON statusC.[Stream_ID] = o.[Channel_ID] +JOIN [dbo].[ChannelKind] role ON role.[ChannelKind_ID] = statusC.[ChannelKind_ID] +JOIN [dbo].[vw_ChannelResolved] valueC ON valueC.[Stream_ID] = statusC.[ParentChannel_ID] +JOIN [dbo].[EquipmentWiringHistory] ewh + ON ewh.[SignalInterface_ID] = valueC.[SignalInterface_ID] + AND ( + ewh.[SignalInterfacePort_ID] = valueC.[SignalInterfacePort_ID] + OR valueC.[SignalInterfacePort_ID] IS NULL + ) + AND ewh.[ValidTo] IS NULL +JOIN [dbo].[Equipment] e ON e.[Equipment_ID] = ewh.[Equipment_ID] +WHERE role.[Name] = N'Status'; + +GO +CREATE OR ALTER VIEW [dbo].[vw_ChannelLocationAtTime] AS +SELECT + cea.ObservationID, + cea.ChannelID, + cea.Timestamp, + cea.EquipmentID, + cea.Resolution, + elh.[SamplingPoint_ID] AS SamplingPointID, + sp.[SamplingPoint] AS SamplingPointName, + CASE + WHEN cea.EquipmentID IS NULL THEN N'no-equipment' + WHEN elh.[SamplingPoint_ID] IS NOT NULL THEN N'resolved' + ELSE N'no-location' + END AS LocationResolution +FROM [dbo].[vw_ChannelEquipmentAtTime] cea +LEFT JOIN [dbo].[EquipmentLocationHistory] elh ON elh.[Equipment_ID] = cea.EquipmentID + AND elh.[ValidFrom] <= cea.Timestamp + AND (elh.[ValidTo] IS NULL OR elh.[ValidTo] > cea.Timestamp) +LEFT JOIN [dbo].[SamplingPoint] sp ON sp.[SamplingPoint_ID] = elh.[SamplingPoint_ID]; + + +GO +-- Schema version stamp (from schema_dictionary/version.yaml) +INSERT INTO [dbo].[SchemaVersion] ([Version], [Description]) +VALUES (N'2.5.0', N'PRD-2 S1 — Rename EquipmentEvent to Event with 8-FK exclusive arc (CHECK exactly-one non-NULL: Channel, Equipment, SignalInterface, DAS, SamplingPoint, ProcessUnit, Site, Campaign). Rename EquipmentEventKind to EventKind; expand seed vocab (Calibration/Cleaning/Repair/…15 kinds). Annotation FK repointed to Event. Pre-release, fresh install only — no migration.'); diff --git a/sql_generation_scripts/v2.5.0_seed_mssql.sql b/sql_generation_scripts/v2.5.0_seed_mssql.sql new file mode 100644 index 0000000..13ea3bb --- /dev/null +++ b/sql_generation_scripts/v2.5.0_seed_mssql.sql @@ -0,0 +1,234 @@ +-- Seed data for schema v2.5.0 +-- Platform: mssql +-- Generated: 2026-06-30 12:05:25 UTC +-- AnnotationKind +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (1, N'Fault', N'Sensor or process fault', N'#FF4444'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (2, N'Maintenance', N'Sensor under maintenance', N'#FFA500'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (3, N'Calibration Period', N'Data during calibration — may be invalid', N'#FFD700'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (4, N'Anomaly', N'Unexpected behavior, needs investigation', N'#FF69B4'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (5, N'Experiment', N'Data collected for a specific experiment', N'#4488FF'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (6, N'Process Event', N'Known process event (storm, dosing, etc.)', N'#44BB44'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (7, N'Data Quality', N'Suspect data quality (drift, fouling)', N'#AA44FF'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (8, N'Note', N'General commentary', N'#888888'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (9, N'Exclusion', N'Data should be excluded from analysis', N'#CC0000'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (10, N'Confirmed', N'Data has been reviewed and accepted as valid', N'#00AA00'); +INSERT INTO [dbo].[AnnotationKind] ([AnnotationKind_ID], [Name], [Description], [Color]) VALUES (11, N'Equipment Relocation', N'Equipment was physically moved to a new location', N'#8888FF'); +-- BinKind +INSERT INTO [dbo].[BinKind] ([BinKind_ID], [Name], [Description]) VALUES (1, N'interval', N'Bins defined by lower and upper bounds only'); +INSERT INTO [dbo].[BinKind] ([BinKind_ID], [Name], [Description]) VALUES (2, N'interval_with_nominal', N'Bins defined by bounds plus a nominal center value (e.g., comes from a table with columns showing the mean settling velocity, but the bin in fact collects data between a min and max value (not a point value)).'); +INSERT INTO [dbo].[BinKind] ([BinKind_ID], [Name], [Description]) VALUES (3, N'nominal', N'Bins defined by a single nominal (exact) value only (e.g., absorbance at exactly 200 nm).'); +-- CampaignKind +SET IDENTITY_INSERT [dbo].[CampaignKind] ON; +INSERT INTO [dbo].[CampaignKind] ([CampaignKind_ID], [Name], [Description]) VALUES (1, N'Experiment', N'Planned scientific investigation under controlled or semi-controlled conditions'); +INSERT INTO [dbo].[CampaignKind] ([CampaignKind_ID], [Name], [Description]) VALUES (2, N'Regular operation', N'Routine monitoring or operational run of the monitored process'); +INSERT INTO [dbo].[CampaignKind] ([CampaignKind_ID], [Name], [Description]) VALUES (3, N'Commissioning', N'Initial setup, calibration, and qualification of equipment or a process'); +SET IDENTITY_INSERT [dbo].[CampaignKind] OFF; +-- ChannelKind +INSERT INTO [dbo].[ChannelKind] ([ChannelKind_ID], [Name], [Description]) VALUES (1, N'Value', N'Primary measurement or output value'); +INSERT INTO [dbo].[ChannelKind] ([ChannelKind_ID], [Name], [Description]) VALUES (2, N'Status', N'Device or measurement status flag'); +INSERT INTO [dbo].[ChannelKind] ([ChannelKind_ID], [Name], [Description]) VALUES (3, N'Alarm', N'Alarm or alert indicator'); +INSERT INTO [dbo].[ChannelKind] ([ChannelKind_ID], [Name], [Description]) VALUES (4, N'Uncertainty', N'Measurement uncertainty estimate'); +-- ControlLoopPortKind +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (1, N'MeasuredVariable', N'The controlled or observed process variable'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (2, N'ManipulatedVariable', N'The actuator or output adjusted by the controller'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (3, N'SetPoint', N'Target value supplied to the controller'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (4, N'Disturbance', N'Measured input that affects the process; not manipulated'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (5, N'PredictedOutput', N'Model-predicted value of the controlled variable'); +INSERT INTO [dbo].[ControlLoopPortKind] ([ControlLoopPortKind_ID], [Name], [Description]) VALUES (6, N'Other', N'Escape hatch for novel kinds; describe in ControlLoop.Description'); +-- ControllerKind +SET IDENTITY_INSERT [dbo].[ControllerKind] ON; +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (1, N'PID', N'Proportional-Integral-Derivative controller'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (2, N'Feedforward', N'Open-loop controller that acts on predicted disturbances'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (3, N'MPC', N'Model Predictive Controller using an internal process model'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (4, N'On-Off', N'Bang-bang (on/off) controller with fixed setpoint'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (5, N'Manual', N'Operator-driven manual control with no automated loop'); +INSERT INTO [dbo].[ControllerKind] ([ControllerKind_ID], [Name], [Description]) VALUES (6, N'Other', N'Controller type not covered by the other categories'); +SET IDENTITY_INSERT [dbo].[ControllerKind] OFF; +-- DataAcquisitionSystemKind +SET IDENTITY_INSERT [dbo].[DataAcquisitionSystemKind] ON; +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (1, N'SCADA', N'Supervisory Control and Data Acquisition system.'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (2, N'PLC', N'Programmable Logic Controller.'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (3, N'Field monitoring station', N'Deployable measurement station capable of hosting multiple devices and recording their data streams.'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (4, N'IoT Gateway', N'Internet-of-Things gateway aggregating sensor streams.'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (5, N'Manual entry', N'Data entered manually by an operator (spreadsheet, form).'); +INSERT INTO [dbo].[DataAcquisitionSystemKind] ([DataAcquisitionSystemKind_ID], [Name], [Description]) VALUES (6, N'Other', N'System type not covered by the other categories.'); +SET IDENTITY_INSERT [dbo].[DataAcquisitionSystemKind] OFF; +-- DataProvenanceKind +SET IDENTITY_INSERT [dbo].[DataProvenanceKind] ON; +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (1, N'Sensor', N'Value acquired directly from an instrument or sensor in the field'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (2, N'Laboratory', N'Value determined by laboratory chemical or physical analysis'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (3, N'Controller Output', N'Value generated by a control algorithm.'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (4, N'Model Output', N'Value generated by a simulation, model, or prediction algorithm not involved in control.'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (5, N'External Source', N'Value imported from an external dataset or third-party system'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (6, N'Forecast', N'Future-dated value produced by a forecasting model'); +INSERT INTO [dbo].[DataProvenanceKind] ([DataProvenanceKind_ID], [Name], [Description]) VALUES (7, N'Derived', N'Value produced by applying a data-processing algorithm to one or more existing channels.'); +SET IDENTITY_INSERT [dbo].[DataProvenanceKind] OFF; +-- EventKind +SET IDENTITY_INSERT [dbo].[EventKind] ON; +INSERT INTO [dbo].[EventKind] ([EventKind_ID], [Name], [Description]) VALUES (1, N'Calibration', N'Adjustment of sensor output to match a known reference standard'); +INSERT INTO [dbo].[EventKind] ([EventKind_ID], [Name], [Description]) VALUES (2, N'Cleaning', N'Physical cleaning or flushing of a sensor or sampling point to restore signal quality'); +INSERT INTO [dbo].[EventKind] ([EventKind_ID], [Name], [Description]) VALUES (3, N'Repair', N'Corrective action performed following a recorded failure'); +INSERT INTO [dbo].[EventKind] ([EventKind_ID], [Name], [Description]) VALUES (4, N'PartReplacement', N'Replacement of a sub-component (membrane, electrode, probe tip) without swapping the full unit'); +INSERT INTO [dbo].[EventKind] ([EventKind_ID], [Name], [Description]) VALUES (5, N'Replacement', N'Full swap of a sensor or equipment unit'); +INSERT INTO [dbo].[EventKind] ([EventKind_ID], [Name], [Description]) VALUES (6, N'SoftwareUpdate', N'Update to embedded firmware, driver, or control software of a device'); +INSERT INTO [dbo].[EventKind] ([EventKind_ID], [Name], [Description]) VALUES (7, N'Validation', N'Formal check confirming that sensor outputs meet defined acceptance criteria'); +INSERT INTO [dbo].[EventKind] ([EventKind_ID], [Name], [Description]) VALUES (8, N'Verification', N'Comparison of sensor reading against a reference under controlled conditions (in-situ or bench)'); +INSERT INTO [dbo].[EventKind] ([EventKind_ID], [Name], [Description]) VALUES (9, N'VisualInspection', N'Non-destructive observation of equipment condition without intervention'); +INSERT INTO [dbo].[EventKind] ([EventKind_ID], [Name], [Description]) VALUES (10, N'Commissioning', N'Formal activation of equipment or a system node into operational service'); +INSERT INTO [dbo].[EventKind] ([EventKind_ID], [Name], [Description]) VALUES (11, N'Decommissioning', N'Formal retirement of equipment or a system node from operational service'); +INSERT INTO [dbo].[EventKind] ([EventKind_ID], [Name], [Description]) VALUES (12, N'OutOfService', N'Planned or unplanned removal from service (shutdown, isolation) without full decommissioning'); +INSERT INTO [dbo].[EventKind] ([EventKind_ID], [Name], [Description]) VALUES (13, N'PowerOutage', N'Loss of electrical power affecting a device, interface, or site'); +INSERT INTO [dbo].[EventKind] ([EventKind_ID], [Name], [Description]) VALUES (14, N'ControllerCrash', N'Unplanned software or hardware fault causing a controller or DAS to stop functioning'); +INSERT INTO [dbo].[EventKind] ([EventKind_ID], [Name], [Description]) VALUES (15, N'OperationalChange', N'Any deliberate change in operational configuration, set-point, or procedure not covered by a more specific kind'); +SET IDENTITY_INSERT [dbo].[EventKind] OFF; +-- OperationKind +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (1, N'Unprocessed', N'No operations applied — used for the raw channel trait only'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (2, N'OutlierRemoval', N'Spikes and statistical outliers removed or flagged'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (3, N'DriftCorrection', N'Sensor drift or baseline shift corrected'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (4, N'FaultRemoval', N'Instrument faults and implausible values removed'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (5, N'Smoothing', N'Noise reduced by a smoothing or averaging algorithm'); +INSERT INTO [dbo].[OperationKind] ([OperationKind_ID], [Name], [Description]) VALUES (6, N'Interpolation', N'Missing values filled by interpolation or reconstruction'); +-- ProcedureKind +SET IDENTITY_INSERT [dbo].[ProcedureKind] ON; +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (1, N'Maintenance and Cleaning Protocol', N'Procedures for routine maintenance, cleaning, and upkeep of equipment'); +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (2, N'Calibration Protocol', N'Step-by-step instructions for calibrating instruments or sensors'); +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (3, N'Validation Protocol', N'Procedures for validating measurements, methods, or models'); +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (4, N'Laboratory Method Protocol', N'Standardised laboratory analytical methods (e.g. ISO, ASTM, APHA)'); +INSERT INTO [dbo].[ProcedureKind] ([ProcedureKind_ID], [Name], [Description]) VALUES (5, N'Software Manual', N'User or operational manuals for software tools used in data acquisition or processing'); +SET IDENTITY_INSERT [dbo].[ProcedureKind] OFF; +-- ProcessUnitKind +SET IDENTITY_INSERT [dbo].[ProcessUnitKind] ON; +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (1, N'Area', N'Broad spatial zone (e.g. biological treatment area)'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (2, N'Zone', N'Defined functional sub-zone within a process area'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (3, N'Tank', N'Enclosed vessel for liquid storage or treatment'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (4, N'Reactor', N'Vessel designed for controlled biological or chemical reactions'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (5, N'Pipe', N'Conduit transporting liquid between process units'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (6, N'Pump', N'Mechanical device for moving liquid'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (7, N'Valve', N'Flow control device regulating liquid passage'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (8, N'Clarifier', N'Gravity settling vessel separating solids from liquid'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (9, N'Basin', N'Open or partially open liquid containment structure'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (10, N'Blower', N'Mechanical device for supplying air or gas'); +INSERT INTO [dbo].[ProcessUnitKind] ([ProcessUnitKind_ID], [Name], [Description]) VALUES (11, N'Other', N'Process unit kind not covered by the standard vocabulary'); +SET IDENTITY_INSERT [dbo].[ProcessUnitKind] OFF; +-- QualityCode +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (1, N'Accepted', N'Measurement meets quality criteria and is fit for use', 1); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (2, N'Suspect', N'Measurement may be unreliable; flagged for manual review', 1); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (3, N'Rejected', N'Measurement is invalid and must not be used', 0); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (4, N'BelowLoD', N'Result is below the method''s limit of detection', 0); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (5, N'AboveLoQ', N'Result exceeds the limit of quantification (instrument saturated)', 0); +INSERT INTO [dbo].[QualityCode] ([QualityCode_ID], [Name], [Description], [IsUsable]) VALUES (6, N'Outlier', N'Statistical outlier; not automatically invalid but requires review', 1); +-- ReviewStatus +INSERT INTO [dbo].[ReviewStatus] ([ReviewStatus_ID], [Name], [Description]) VALUES (1, N'Pending', N'Measurement recorded but not yet reviewed/approved'); +INSERT INTO [dbo].[ReviewStatus] ([ReviewStatus_ID], [Name], [Description]) VALUES (2, N'Approved', N'Measurement reviewed and approved by a designated reviewer'); +INSERT INTO [dbo].[ReviewStatus] ([ReviewStatus_ID], [Name], [Description]) VALUES (3, N'Rejected', N'Measurement reviewed and rejected'); +-- SampleCollectionKind +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (1, N'Grab', N'Single instantaneous sample collected at one point in time'); +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (2, N'Composite24h', N'Flow- or time-proportional composite over a 24-hour period'); +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (3, N'Composite8h', N'Flow- or time-proportional composite over an 8-hour period'); +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (4, N'Passive', N'Passive sampler deployed over an extended exposure period'); +INSERT INTO [dbo].[SampleCollectionKind] ([SampleCollectionKind_ID], [Name], [Description]) VALUES (5, N'Other', N'Collection kind not covered by the standard vocabulary'); +-- SampleKind +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (1, N'Field', N'Sample collected from a real-world site or process'); +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (2, N'Synthetic', N'Laboratory-prepared sample with known composition'); +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (3, N'Master Standard', N'Reference standard used to prepare derived standards'); +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (4, N'Derived Standard', N'Dilution or aliquot derived from a master standard'); +INSERT INTO [dbo].[SampleKind] ([SampleKind_ID], [Name], [Description]) VALUES (5, N'Blank', N'Blank sample used to detect contamination or baseline'); +-- SiteKind +SET IDENTITY_INSERT [dbo].[SiteKind] ON; +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (1, N'Municipal Wastewater Treatment Plant', N'Municipal or industrial facility treating wastewater before discharge'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (2, N'Combined Sewer Overflow', N'Point where combined sewer system discharges during high-flow events'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (3, N'River / Stream', N'Natural flowing surface water body'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (4, N'Lake / Reservoir', N'Natural or artificial standing body of water'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (5, N'Groundwater / Well', N'Subsurface water source accessed via a well or borehole'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (6, N'Drinking Water Distribution Network Access Point', N'Monitoring point within a potable water distribution network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (7, N'Canal', N'Artificial waterway for water transport or drainage'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (8, N'Wastewater Pumping Station', N'Facility that pumps wastewater through the collection network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (9, N'Combined Drainage Network Access Point', N'Monitoring point within a combined stormwater and wastewater network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (10, N'Rainwater Drainage Network Access Point', N'Monitoring point within a stormwater-only drainage network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (11, N'Wastewater Drainage Network Access Point', N'Monitoring point within a sanitary sewer network'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (12, N'Experimental Wastewater Treatment Plant', N'Small-scale experimental treatment or process facility'); +INSERT INTO [dbo].[SiteKind] ([SiteKind_ID], [Name], [Description]) VALUES (13, N'Other', N'Site kind not covered by the standard vocabulary'); +SET IDENTITY_INSERT [dbo].[SiteKind] OFF; +-- StreamKind +INSERT INTO [dbo].[StreamKind] ([StreamKind_ID], [Name], [Description]) VALUES (1, N'Sensor', N'A sensor measurement stream (Channel subtype of Stream)'); +INSERT INTO [dbo].[StreamKind] ([StreamKind_ID], [Name], [Description]) VALUES (2, N'Lab', N'A laboratory measurement stream (AnalysisSeries subtype of Stream)'); +-- Unit +SET IDENTITY_INSERT [dbo].[Unit] ON; +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (1, N'mg/L', N'https://qudt.org/vocab/unit/MilliGM-PER-L', N'0,1,-3,0,0,0,0', 0.001, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (2, N'NTU', N'https://qudt.org/vocab/unit/NTU', N'0,0,0,0,0,0,0', NULL, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (3, N'pH units', N'https://qudt.org/vocab/unit/PH', N'0,0,0,0,0,0,0', NULL, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (4, N'°C', N'https://qudt.org/vocab/unit/DEG_C', N'0,0,0,0,1,0,0', 1.0, 273.15); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (5, N'mS/cm', N'https://qudt.org/vocab/unit/MilliS-PER-CentiM', N'-3,-1,3,2,0,0,0', 0.1, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (6, N'nm', N'https://qudt.org/vocab/unit/NanoM', N'1,0,0,0,0,0,0', 1e-09, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (7, N'µm', N'https://qudt.org/vocab/unit/MicroM', N'1,0,0,0,0,0,0', 1e-06, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (8, N'm/s', N'https://qudt.org/vocab/unit/M-PER-SEC', N'1,0,-1,0,0,0,0', 1.0, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (9, N'Status Code', NULL, NULL, NULL, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (10, N'AU', N'https://qudt.org/vocab/unit/ABSORBANCE_UNIT', N'0,0,0,0,0,0,0', NULL, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (11, N'-', N'https://qudt.org/vocab/unit/UNITLESS', N'0,0,0,0,0,0,0', 1.0, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (12, N'm³/h', N'https://qudt.org/vocab/unit/M3-PER-HR', N'3,0,-1,0,0,0,0', 0.000277778, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (13, N'm', N'https://qudt.org/vocab/unit/M', N'1,0,0,0,0,0,0', 1.0, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (14, N'Nm³/h', NULL, N'3,0,-1,0,0,0,0', 0.000277778, NULL); +INSERT INTO [dbo].[Unit] ([Unit_ID], [Unit], [QUDT_IRI], [UnitVector], [SI_Multiplier], [SI_Offset]) VALUES (15, N'%', N'https://qudt.org/vocab/unit/PERCENT', N'0,0,0,0,0,0,0', 0.01, NULL); +SET IDENTITY_INSERT [dbo].[Unit] OFF; +-- ValueKind +SET IDENTITY_INSERT [dbo].[ValueKind] ON; +INSERT INTO [dbo].[ValueKind] ([ValueKind_ID], [Name], [Description]) VALUES (1, N'Scalar', N'A single numeric measurement value (e.g. temperature, concentration)'); +INSERT INTO [dbo].[ValueKind] ([ValueKind_ID], [Name], [Description]) VALUES (2, N'Vector', N'An ordered sequence of numeric values (e.g. particle size distribution)'); +INSERT INTO [dbo].[ValueKind] ([ValueKind_ID], [Name], [Description]) VALUES (3, N'Matrix', N'A two-dimensional array of values (e.g. excitation-emission matrix)'); +INSERT INTO [dbo].[ValueKind] ([ValueKind_ID], [Name], [Description]) VALUES (4, N'Image', N'A raster image stored as a binary file'); +SET IDENTITY_INSERT [dbo].[ValueKind] OFF; +-- Parameter +SET IDENTITY_INSERT [dbo].[Parameter] ON; +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'TSS concentration', 1, N'Total suspended solids', N'http://purl.obolibrary.org/obo/ENVO_01001502', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'COD concentration', 2, N'Chemical oxygen demand', N'http://purl.obolibrary.org/obo/ENVO_01000632', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'pH', 3, N'Hydrogen ion concentration', N'http://purl.obolibrary.org/obo/ENVO_09200019', 1, N'http://qudt.org/vocab/quantitykind/PH'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Temperature', 4, N'Water temperature', N'http://purl.obolibrary.org/obo/ENVO_01001501', 1, N'http://qudt.org/vocab/quantitykind/Temperature'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Conductivity', 5, N'Electrical conductivity', N'http://purl.obolibrary.org/obo/ENVO_09200010', 1, N'http://qudt.org/vocab/quantitykind/ElectricConductivity'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Sensor Status', 6, N'Per-channel operational status code', NULL, 1, NULL); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Device Status', 7, N'Overall equipment health status code', NULL, 1, NULL); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Dissolved oxygen concentration', 8, N'Dissolved oxygen concentration in water', N'http://purl.obolibrary.org/obo/ENVO_01001111', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Turbidity', 9, N'Water turbidity measured by nephelometry', N'http://purl.obolibrary.org/obo/ENVO_01001573', 1, N'http://qudt.org/vocab/quantitykind/Turbidity'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Absorbance spectrum', 10, N'UV-Vis spectral absorbance (per-wavelength vector, unit AU)', NULL, 2, NULL); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Ammonium-N concentration', 11, N'Ammonium nitrogen concentration (NH4-N)', N'http://purl.obolibrary.org/obo/CHEBI_49786', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Nitrate-N concentration', 12, N'Nitrate nitrogen concentration as NO3-N equivalent', N'http://purl.obolibrary.org/obo/CHEBI_17632', 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'COD filtered concentration', 13, N'Filtered COD (CODf) — soluble fraction of chemical oxygen demand', NULL, 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Flow', 14, N'Volumetric flow rate', N'http://purl.obolibrary.org/obo/ENVO_01001020', 1, N'http://qudt.org/vocab/quantitykind/VolumeFlowRate'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Level', 15, N'Water level / depth', NULL, 1, N'http://qudt.org/vocab/quantitykind/Length'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'floc_morphology', 16, N'Activated sludge floc morphology image from inline microscope', NULL, 4, NULL); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Potassium concentration', 17, N'Potassium concentration (K)', NULL, 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Light Absorbance', 18, N'Scalar light absorbance measurement', NULL, 1, N'http://qudt.org/vocab/quantitykind/Absorbance'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Nitrite-N concentration', 19, N'Nitrite nitrogen concentration (NO2-N)', NULL, 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'NOx-N concentration', 20, N'Total oxidized nitrogen (NO3-N + NO2-N)', NULL, 1, N'http://qudt.org/vocab/quantitykind/MassConcentration'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Air flow', 21, N'Volumetric air/gas flow rate', NULL, 1, N'http://qudt.org/vocab/quantitykind/VolumeFlowRate'); +INSERT INTO [dbo].[Parameter] ([Parameter], [Parameter_ID], [Description], [ENVO_IRI], [ValueKind_ID], [QUDT_QuantityKind_IRI]) VALUES (N'Valve position', 22, N'Control valve analog output position (0-100%)', NULL, 1, NULL); +SET IDENTITY_INSERT [dbo].[Parameter] OFF; +-- Procedures +SET IDENTITY_INSERT [dbo].[Procedures] ON; +INSERT INTO [dbo].[Procedures] ([Procedure_ID], [ProcedureName], [Description], [ProcedureLocation]) VALUES (1, N'Grab sampling', N'Manual grab sample collected at water surface', N'/procedures/grab_sampling.pdf'); +INSERT INTO [dbo].[Procedures] ([Procedure_ID], [ProcedureName], [Description], [ProcedureLocation]) VALUES (2, N'24h composite', N'Time-weighted 24-hour composite sample via autosampler', N'/procedures/composite_24h.pdf'); +INSERT INTO [dbo].[Procedures] ([Procedure_ID], [ProcedureName], [Description], [ProcedureLocation]) VALUES (3, N'Online continuous', N'Continuous in-situ measurement with data logging', N'/procedures/online_continuous.pdf'); +SET IDENTITY_INSERT [dbo].[Procedures] OFF; + +-- ParameterHasUnit (generated by ontology_query.py) +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (1, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (2, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (3, 3); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (4, 4); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (5, 5); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (8, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (9, 2); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (10, 10); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (11, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (12, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (13, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (14, 12); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (15, 6); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (15, 7); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (15, 13); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (17, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (18, 10); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (19, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (20, 1); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (21, 12); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (21, 14); +INSERT INTO [dbo].[ParameterHasUnit] ([Parameter_ID], [Unit_ID]) VALUES (22, 15); diff --git a/tests/api/contract/test_annotation_endpoints.py b/tests/api/contract/test_annotation_endpoints.py index 6315361..af767f6 100644 --- a/tests/api/contract/test_annotation_endpoints.py +++ b/tests/api/contract/test_annotation_endpoints.py @@ -81,7 +81,7 @@ def _mock_annotation(): "author": {"person_id": 5, "name": "Marie Dupont"}, "campaign_id": None, "campaign_name": None, - "equipment_event_id": None, + "event_id": None, "created_at": "2025-06-14T11:22:33", "modified_at": None, } @@ -807,7 +807,7 @@ def _feed_row(annotation_id, *, stream_id, stream_kind_id=1, Column layout must match _row_to_annotation + _feed_row in annotation_repository: 0=annotation_id, 1=stream_id, 2=annotation_kind_id, 3=name, 4=color, 5=start_time, 6=end_time, 7=title, 8=comment, 9=author_person_id, - 10=author_name, 11=campaign_id, 12=campaign_name, 13=equipment_event_id, + 10=author_name, 11=campaign_id, 12=campaign_name, 13=event_id, 14=created_datetime, 15=modified_datetime, 16=stream_kind_id, 17=observation_id, 18=location_name, 19=parameter_name. stream_kind_id=1 → sensor/channel, stream_kind_id=2 → lab/series. diff --git a/tests/api/contract/test_data_health_endpoints.py b/tests/api/contract/test_data_health_endpoints.py new file mode 100644 index 0000000..d76aec4 --- /dev/null +++ b/tests/api/contract/test_data_health_endpoints.py @@ -0,0 +1,78 @@ +"""Contract tests for the F5/F11 data-health endpoints. + + GET /data-health/unlinked-channels — channels needing wiring (F5) + GET /data-health/inactive-parent-references — live refs to dead parents (F11) +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient + +from api.database import get_db +from api.main import app + + +@pytest.fixture +def client_and_cursor(): + cursor = MagicMock() + conn = MagicMock() + conn.cursor.return_value = cursor + + def _override(): + yield conn + + app.dependency_overrides[get_db] = _override + with TestClient(app) as c: + yield c, cursor + app.dependency_overrides.clear() + + +def test_unlinked_channels_maps_rows_and_counts(client_and_cursor): + client, cursor = client_and_cursor + cursor.fetchall.return_value = [ + (7, "TSS-RAW", 3, "AI_01", 1680, "2026-04-01T00:00:00", "2026-06-01T00:00:00"), + ] + resp = client.get("/api/v1/data-health/unlinked-channels") + assert resp.status_code == 200 + body = resp.json() + assert body["count"] == 1 + ch = body["channels"][0] + assert ch["channel_id"] == 7 and ch["observation_count"] == 1680 + assert "vw_UnlinkedChannels" in cursor.execute.call_args.args[0] + + +def test_unlinked_channels_empty(client_and_cursor): + client, cursor = client_and_cursor + cursor.fetchall.return_value = [] + resp = client.get("/api/v1/data-health/unlinked-channels") + assert resp.json() == {"count": 0, "channels": []} + + +def test_inactive_parent_references_maps_rows(client_and_cursor): + client, cursor = client_and_cursor + cursor.fetchall.return_value = [ + ("active-wiring->interface", 12, 5, 3, "AI_01"), + ] + resp = client.get("/api/v1/data-health/inactive-parent-references") + assert resp.status_code == 200 + body = resp.json() + assert body["count"] == 1 + ref = body["references"][0] + assert ref["reference_type"] == "active-wiring->interface" + assert ref["parent_id"] == 3 + + +def test_inactive_parent_references_filters_by_interface(client_and_cursor): + client, cursor = client_and_cursor + cursor.fetchall.return_value = [] + client.get( + "/api/v1/data-health/inactive-parent-references", + params={"signal_interface_id": 3}, + ) + sql = cursor.execute.call_args.args[0] + assert "ParentID = ?" in sql + # the interface id is forwarded as a positional parameter + assert cursor.execute.call_args.args[1:] == (3,) diff --git a/tests/api/contract/test_deployment_coherence_endpoints.py b/tests/api/contract/test_deployment_coherence_endpoints.py new file mode 100644 index 0000000..5f0a2e5 --- /dev/null +++ b/tests/api/contract/test_deployment_coherence_endpoints.py @@ -0,0 +1,86 @@ +"""Contract tests for the F1/F13 detection endpoints. + + GET /das/{id}/move-conflicts?site_id= — equipment a DAS move would strand + GET /equipment/{id}/active-campaign — running campaign that placed equipment +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from api.database import get_db +from api.main import app + +_THR = "api.v1.repositories.temporal_history_repository" + + +@pytest.fixture +def client(): + conn = MagicMock() + conn.cursor.return_value = MagicMock() + + def _override(): + yield conn + + app.dependency_overrides[get_db] = _override + with TestClient(app) as c: + yield c + app.dependency_overrides.clear() + + +# --- F1: /das/{id}/move-conflicts ------------------------------------------ + +def test_move_conflicts_lists_stranded_equipment(client): + rows = [ + { + "equipment_id": 5, + "equipment_identifier": "pH-01", + "sampling_point_id": 7, + "sampling_point_name": "Influent", + "current_site_id": 2, + "current_site_name": "Plant A", + } + ] + with patch( + f"{_THR}.get_das_move_equipment_conflicts", return_value=rows + ) as m: + resp = client.get("/api/v1/das/3/move-conflicts", params={"site_id": 9}) + assert resp.status_code == 200 + body = resp.json() + assert body["das_id"] == 3 and body["site_id"] == 9 + assert body["stranded_equipment"][0]["equipment_identifier"] == "pH-01" + # site_id forwarded as new_site_id + assert m.call_args.kwargs == {"das_id": 3, "new_site_id": 9} + + +def test_move_conflicts_empty_is_coherent(client): + with patch(f"{_THR}.get_das_move_equipment_conflicts", return_value=[]): + resp = client.get("/api/v1/das/3/move-conflicts", params={"site_id": 9}) + assert resp.status_code == 200 + assert resp.json()["stranded_equipment"] == [] + + +# --- F13: /equipment/{id}/active-campaign ---------------------------------- + +def test_active_campaign_returns_running_campaign(client): + row = { + "campaign_id": 12, + "campaign_name": "Pilot 2026", + "equipment_location_history_id": 88, + "sampling_point_id": 7, + "sampling_point_name": "Influent", + } + with patch(f"{_THR}.get_active_campaign_deployment", return_value=row): + resp = client.get("/api/v1/equipment/5/active-campaign") + assert resp.status_code == 200 + assert resp.json()["campaign_name"] == "Pilot 2026" + + +def test_active_campaign_null_when_none(client): + with patch(f"{_THR}.get_active_campaign_deployment", return_value=None): + resp = client.get("/api/v1/equipment/5/active-campaign") + assert resp.status_code == 200 + assert resp.json()["campaign_id"] is None diff --git a/tests/api/contract/test_schema_contracts.py b/tests/api/contract/test_schema_contracts.py index cc7b6c9..130aa89 100644 --- a/tests/api/contract/test_schema_contracts.py +++ b/tests/api/contract/test_schema_contracts.py @@ -435,8 +435,9 @@ def test_full_context_endpoint_exists(self, patched_client): "campaign_id", "campaign_kind_id", "campaign_kind_name", - "site_id", - "site_name", + # Campaigns are multi-site: sites are derived, exposed as lists. + "site_ids", + "site_names", "name", "description", "start_date", @@ -448,7 +449,8 @@ def _mock_campaign(): return {f: None for f in REQUIRED_CAMPAIGN_FIELDS} | { "campaign_id": 1, "campaign_kind_id": 1, - "site_id": 1, + "site_ids": [1], + "site_names": ["Site A"], "name": "Ops 2025", } diff --git a/tests/api/contract/test_stream_pedigree_endpoint.py b/tests/api/contract/test_stream_pedigree_endpoint.py new file mode 100644 index 0000000..8e9ea10 --- /dev/null +++ b/tests/api/contract/test_stream_pedigree_endpoint.py @@ -0,0 +1,87 @@ +"""Contract test for GET /lineage/streams/{id}/pedigree (stream pedigree).""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from api.database import get_db +from api.main import app + +_REPO = "api.v1.endpoints.lineage.channel_repository" + + +@pytest.fixture +def client(): + conn = MagicMock() + conn.cursor.return_value = MagicMock() + + def _override(): + yield conn + + app.dependency_overrides[get_db] = _override + with TestClient(app) as c: + yield c + app.dependency_overrides.clear() + + +def _pedigree(): + return { + "stream_id": 42, + "kind": "sensor", + "parameter": "TSS", + "unit": "mg/L", + "value_kind": "Scalar", + "label": "CH-TSS", + "deployments": [ + { + "valid_from": "2026-01-01T00:00:00", "valid_to": "2026-04-01T00:00:00", + "equipment_identifier": "EQ5", + "sampling_location": {"sampling_point_id": 99, "name": "Effluent", + "latitude": 46.78, "longitude": -71.27}, + "process_unit": {"process_unit_id": 7, "tag": "R-210", + "name": "Bioreactor", "kind": "Tank"}, + "site": {"site_id": 3, "name": "pilEAU", "city": "Quebec", + "province": "QC", "country": "Canada"}, + "campaign": {"campaign_id": 5, "name": "Winter 2026", + "kind": "Experiment", "start": "2026-01-01T00:00:00", + "end": "2026-04-01T00:00:00"}, + "responsible_person": {"person_id": 11, "name": "Jean Tremblay", + "email": "jt@x.io", "role": "PI", + "company": "modelEAU"}, + } + ], + } + + +def test_pedigree_returns_deployment_timeline(client): + with patch(f"{_REPO}.get_stream_pedigree", return_value=_pedigree()): + resp = client.get("/api/v1/lineage/streams/42/pedigree") + assert resp.status_code == 200 + body = resp.json() + assert body["kind"] == "sensor" + seg = body["deployments"][0] + assert seg["sampling_location"]["name"] == "Effluent" + assert seg["process_unit"]["tag"] == "R-210" + assert seg["site"]["name"] == "pilEAU" + assert seg["campaign"]["name"] == "Winter 2026" + assert seg["responsible_person"]["name"] == "Jean Tremblay" + + +def test_pedigree_forwards_window_to_repo(client): + with patch(f"{_REPO}.get_stream_pedigree", return_value=_pedigree()) as m: + client.get( + "/api/v1/lineage/streams/42/pedigree", + params={"from": "2026-02-01T00:00:00", "to": "2026-03-01T00:00:00"}, + ) + _, kwargs = m.call_args + assert kwargs["from_dt"] is not None + assert kwargs["to_dt"] is not None + + +def test_pedigree_404(client): + with patch(f"{_REPO}.get_stream_pedigree", return_value=None): + resp = client.get("/api/v1/lineage/streams/999/pedigree") + assert resp.status_code == 404 diff --git a/tests/app/test_campaign_wizard.py b/tests/app/test_campaign_wizard.py index 46d7a32..3637737 100644 --- a/tests/app/test_campaign_wizard.py +++ b/tests/app/test_campaign_wizard.py @@ -339,6 +339,42 @@ def test_existing_das_advances(self, mocked_lookups): assert not _errors(at) assert at.session_state.wizard_step == 4 + def test_das_move_strands_equipment_warning(self, mocked_lookups): + """F1: picking a DAS active at another site lists the equipment it strands.""" + conflict = { + "conflicting_site_name": "Plant A", + "conflicting_site_id": 2, + "conflicting_campaign_name": "Old Study", + } + stranded = [ + { + "equipment_id": 5, + "equipment_identifier": "pH-01", + "sampling_point_name": "Influent", + "current_site_name": "Plant A", + } + ] + at = _at( + 3, + { + "wiz_s1_mode": "Use existing", + "wiz_s1_site_label": "Site A", + "wiz_das_ids": [0], + "wiz_das_next_id": 1, + "wiz_das_0_mode": "Existing", + "wiz_das_0_das_label": "DAS-001", + }, + ) + with patch(f"{MOD}.get_das_conflict", return_value=conflict), patch( + f"{MOD}.get_das_move_conflicts", return_value=stranded + ) as m: + at.run() + warnings = " ".join(w.value for w in at.warning) + assert "would strand 1 wired equipment" in warnings + assert "pH-01" in warnings + # queried with the resolved DAS id and the new (campaign) site id + assert m.call_args.args == (1, 1) + # --------------------------------------------------------------------------- # Step 4: Equipment & Tags @@ -484,8 +520,11 @@ def test_all_existing_uses_resolved_ids(self, mock_apis): at.button(key="wiz_next_5").click().run() payload = mock_apis["create_campaign"].call_args[0][0] - assert payload["site_id"] == 1 # "Site A" → id 1 + # Campaign is multi-site now: no site_id on the campaign; the resolved + # site flows downstream to the DAS deployment instead. + assert "site_id" not in payload assert payload["responsible_person_id"] == 1 # "Alice Smith" → person_id 1 + assert mock_apis["deploy_das"].call_args.kwargs["site_id"] == 1 # "Site A" → id 1 def test_new_equipment_new_model_creates_model_first(self, mock_apis): state = _all_new_state() @@ -530,8 +569,11 @@ def test_bug_3_site_id_resolved_from_store_key_only(self, mock_apis): at.button(key="wiz_next_5").click().run() assert not _errors(at), f"BUG-3 regression: {_errors(at)}" + # The campaign carries no site_id; proving the store-key site resolved + # means it reaches the downstream DAS deployment as site_id=1. payload = mock_apis["create_campaign"].call_args[0][0] - assert payload["site_id"] == 1 # "Site A" resolves to id=1 + assert "site_id" not in payload + assert mock_apis["deploy_das"].call_args.kwargs["site_id"] == 1 # "Site A" → id 1 def test_bug_4_sl_mode_resolved_from_store_key_only(self, mock_apis): """BUG-4: step 3 reported 'No sampling locations selected' because diff --git a/tests/app/test_campaign_wizard_page.py b/tests/app/test_campaign_wizard_page.py new file mode 100644 index 0000000..dfb66b1 --- /dev/null +++ b/tests/app/test_campaign_wizard_page.py @@ -0,0 +1,47 @@ +"""Campaign wizard page: multi-site sampling-location selection (Batch 6 UX). + +`_selected_sls` unions the locations picked across every site block. SL names +may collide between sites, so resolution must be per block (by that block's +site) — a campaign spanning two sites resolves both even when an SL name repeats. +""" + +from __future__ import annotations + +import streamlit as st + +from app.pages import campaign_wizard_page as cwp + +_SLS = { + 1: [{"id": 10, "name": "Inlet"}, {"id": 11, "name": "Outlet"}], # Site A + 2: [{"id": 20, "name": "Inlet"}], # Site B — same SL name as A +} + +_LOOKUPS = {"sites": [{"name": "Site A", "site_id": 1}, {"name": "Site B", "site_id": 2}]} + + +def _setup(monkeypatch, snap): + monkeypatch.setattr(st, "session_state", {"_cmp_wiz_snap_1": snap}) + monkeypatch.setattr(cwp, "list_site_sampling_locations", lambda sid: _SLS.get(sid, [])) + + +def test_union_across_sites_resolves_both_despite_name_collision(monkeypatch): + _setup( + monkeypatch, + { + "cmp_wiz_s1_block_ids": [0, 1], + "cmp_wiz_s1_site_0": "Site A", + "cmp_wiz_s1_sls_0": ["Inlet"], + "cmp_wiz_s1_site_1": "Site B", + "cmp_wiz_s1_sls_1": ["Inlet"], + }, + ) + out = cwp._selected_sls(_LOOKUPS) + assert [(sl["id"], sl["site_name"]) for sl in out] == [ + (10, "Site A"), + (20, "Site B"), + ] + + +def test_empty_blocks_yield_no_locations(monkeypatch): + _setup(monkeypatch, {"cmp_wiz_s1_block_ids": [0], "cmp_wiz_s1_site_0": "Site A"}) + assert cwp._selected_sls(_LOOKUPS) == [] diff --git a/tests/app/test_campaign_wizard_page_apptest.py b/tests/app/test_campaign_wizard_page_apptest.py new file mode 100644 index 0000000..7db0a35 --- /dev/null +++ b/tests/app/test_campaign_wizard_page_apptest.py @@ -0,0 +1,89 @@ +"""AppTest coverage for the campaign wizard page (Batch 6 multi-site UX + the +deployment-step rerun bug). + +The page does `from app.api_client import …`, and AppTest.from_file re-execs the +script per run, so the lookups are patched on `app.api_client.*`. +""" + +from __future__ import annotations + +from contextlib import ExitStack +from unittest.mock import patch + +from streamlit.testing.v1 import AppTest + +PAGE = "app/pages/campaign_wizard_page.py" +AC = "app.api_client" + +_SITES = [{"site_id": 1, "name": "Site A"}, {"site_id": 2, "name": "Site B"}] +_KINDS = [{"campaign_kind_id": 1, "name": "Experiment"}] +_PERSONS = [{"person_id": 1, "id": 1, "label": "Alice Smith"}] +_EQUIP = [{"equipment_id": 5, "identifier": "EQ5"}] +_SLS = { + 1: [{"id": 10, "name": "Inlet"}, {"id": 11, "name": "Outlet"}], + 2: [{"id": 20, "name": "Lab tap"}], +} + + +def _patches(stack: ExitStack) -> None: + stack.enter_context(patch(f"{AC}.list_sites_lookup", return_value=_SITES)) + stack.enter_context(patch(f"{AC}.list_campaign_kinds", return_value=_KINDS)) + stack.enter_context(patch(f"{AC}.list_persons_lookup", return_value=_PERSONS)) + stack.enter_context(patch(f"{AC}.list_equipment_lookup", return_value=_EQUIP)) + stack.enter_context( + patch(f"{AC}.list_site_sampling_locations", side_effect=lambda sid: _SLS.get(sid, [])) + ) + + +def test_add_another_site_renders_a_second_block(): + with ExitStack() as stack: + _patches(stack) + at = AppTest.from_file(PAGE) + at.session_state["cmp_wiz_step"] = 1 + at.run() + assert len(at.selectbox) == 1 # one site picker + at.button(key="cmp_wiz_s1_add_site").click().run() + assert len(at.selectbox) == 2 # second site block added + + +def test_equipment_selection_keeps_sampling_locations(): + """The reported bug: selecting an equipment reran the page and blanked the + sampling-location list ('No sampling locations selected').""" + with ExitStack() as stack: + _patches(stack) + at = AppTest.from_file(PAGE) + at.session_state["cmp_wiz_step"] = 2 + at.session_state["_cmp_wiz_snap_1"] = { + "cmp_wiz_s1_block_ids": [0], + "cmp_wiz_s1_site_0": "Site A", + "cmp_wiz_s1_sls_0": ["Inlet", "Outlet"], + } + at.run() + eq_selects = [s for s in at.selectbox if "Equipment at" in s.label] + assert len(eq_selects) == 2 + + # Select an equipment → triggers the rerun that used to blank the step. + eq_selects[0].select("EQ5").run() + + eq_after = [s for s in at.selectbox if "Equipment at" in s.label] + assert len(eq_after) == 2 + assert not any("No sampling locations selected" in i.value for i in at.info) + + +def test_multi_site_deployment_lists_both_sites(): + """Two site blocks → equipment dropdowns for SLs from both sites.""" + with ExitStack() as stack: + _patches(stack) + at = AppTest.from_file(PAGE) + at.session_state["cmp_wiz_step"] = 2 + at.session_state["_cmp_wiz_snap_1"] = { + "cmp_wiz_s1_block_ids": [0, 1], + "cmp_wiz_s1_site_0": "Site A", + "cmp_wiz_s1_sls_0": ["Inlet"], + "cmp_wiz_s1_site_1": "Site B", + "cmp_wiz_s1_sls_1": ["Lab tap"], + } + at.run() + labels = [s.label for s in at.selectbox if "Equipment at" in s.label] + assert any("Site A" in lab for lab in labels) + assert any("Site B" in lab for lab in labels) diff --git a/tests/app/test_data_health_page.py b/tests/app/test_data_health_page.py new file mode 100644 index 0000000..43704a8 --- /dev/null +++ b/tests/app/test_data_health_page.py @@ -0,0 +1,67 @@ +"""Data Health page coverage (consistency audit F5, F11) via AppTest. + +The page surfaces two reconciling reports; assert it warns when each has +findings and reports clean when empty. API calls are mocked. +""" +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from streamlit.testing.v1 import AppTest + +PAGE = str(Path(__file__).parent.parent.parent / "app" / "pages" / "data_health.py") +MOD = "app.pages.data_health" + +_UNLINKED = [ + { + "channel_id": 7, + "tag_name": "TSS-RAW", + "signal_interface_id": 3, + "signal_interface_name": "AI_01", + "observation_count": 1680, + "first_observation": "2026-04-01T00:00:00", + "last_observation": "2026-06-01T00:00:00", + } +] +_INACTIVE_REFS = [ + { + "reference_type": "active-wiring->interface", + "wiring_history_id": 12, + "equipment_id": 5, + "parent_id": 3, + "parent_label": "AI_01", + } +] + + +def _warnings(at) -> str: + return " ".join(w.value for w in at.warning) + + +def _successes(at) -> str: + return " ".join(s.value for s in at.success) + + +def test_warns_when_findings_present(): + with ( + patch("app.api_client.get_unlinked_channels", return_value=_UNLINKED), + patch("app.api_client.get_inactive_parent_references", return_value=_INACTIVE_REFS), + ): + at = AppTest.from_file(PAGE).run() + assert not at.exception + warns = _warnings(at) + assert "1 channel(s)" in warns and "no active equipment wiring" in warns + assert "1 active wiring row(s)" in warns + + +def test_reports_clean_when_empty(): + with ( + patch("app.api_client.get_unlinked_channels", return_value=[]), + patch("app.api_client.get_inactive_parent_references", return_value=[]), + ): + at = AppTest.from_file(PAGE).run() + assert not at.exception + successes = _successes(at) + assert "All channels with data are wired" in successes + assert "No active wiring points at a deactivated" in successes diff --git a/tests/app/test_explore_campaign_filter.py b/tests/app/test_explore_campaign_filter.py new file mode 100644 index 0000000..a3f5e5b --- /dev/null +++ b/tests/app/test_explore_campaign_filter.py @@ -0,0 +1,64 @@ +"""AppTest for the Explore page-level campaign filter. + +Selecting a campaign must scope the whole explorer: snap the time window to the +campaign's span and restrict the picker (picker_campaign_id) to that campaign, so +both the plot and the export inherit the scope. +""" +from __future__ import annotations + +from contextlib import ExitStack +from datetime import date +from pathlib import Path +from unittest.mock import patch + +from streamlit.testing.v1 import AppTest + +HARNESS = str(Path(__file__).parent / "explore_harness.py") +MOD = "app.pages.explore" +DATA = "app.components.explore_data" + +_CAMPAIGNS = [{"campaign_id": 1, "name": "Winter 2026"}] +_CAMPAIGN = {"campaign_id": 1, "name": "Winter 2026", + "start_date": "2026-01-01", "end_date": "2026-04-01"} + + +def _patches(stack: ExitStack) -> None: + stack.enter_context(patch(f"{MOD}.list_equipment_lookup", return_value=[])) + stack.enter_context(patch(f"{MOD}.list_annotation_kinds", return_value=[])) + stack.enter_context(patch(f"{MOD}.list_equipment_event_kinds", return_value=[])) + stack.enter_context(patch(f"{MOD}.list_analysis_series_lookup", return_value=[])) + stack.enter_context(patch(f"{MOD}.list_deployment_traces_lookup", return_value=[])) + stack.enter_context(patch(f"{MOD}.list_campaigns_lookup", return_value=_CAMPAIGNS)) + stack.enter_context(patch(f"{MOD}.get_campaign", return_value=_CAMPAIGN)) + + +def test_selecting_campaign_snaps_window_and_restricts_picker(): + with ExitStack() as stack: + _patches(stack) + at = AppTest.from_file(HARNESS) + at.session_state["explore_start"] = date(2026, 5, 1) + at.session_state["explore_end"] = date(2026, 5, 8) + at.run() + + at.selectbox(key="explore_campaign_filter_sel").set_value("Winter 2026").run() + + assert at.session_state["explore_campaign_filter_id"] == 1 + # Window snapped to the campaign span (applied via the pending-range flag). + assert at.session_state["explore_start"] == date(2026, 1, 1) + assert at.session_state["explore_end"] == date(2026, 4, 1) + assert not at.exception + + +def test_clearing_campaign_filter_removes_scope(): + with ExitStack() as stack: + _patches(stack) + at = AppTest.from_file(HARNESS) + at.session_state["explore_campaign_filter_id"] = 1 + at.run() + + at.selectbox(key="explore_campaign_filter_sel").set_value( + "📂 All campaigns (no filter)" + ).run() + + assert at.session_state["explore_campaign_filter_id"] is None + assert not at.exception diff --git a/tests/app/test_explore_export_page.py b/tests/app/test_explore_export_page.py new file mode 100644 index 0000000..29a805a --- /dev/null +++ b/tests/app/test_explore_export_page.py @@ -0,0 +1,99 @@ +"""AppTest for the Explore bulk-export section (two-step Generate → Download). + +Verifies the wiring: with active streams, clicking 'Generate export' builds a zip +into session_state from the raw-UTC data + pedigree, and a 'Download zip' button +then appears. Builder internals are covered in tests/unit/test_explore_export.py. +""" +from __future__ import annotations + +import io +import zipfile +from contextlib import ExitStack +from datetime import date +from pathlib import Path +from unittest.mock import patch + +from streamlit.testing.v1 import AppTest + +HARNESS = str(Path(__file__).parent / "explore_harness.py") +MOD = "app.pages.explore" +DATA = "app.components.explore_data" + +_EQUIPMENT = [{"equipment_id": 5, "identifier": "EQ5"}] +_DT5 = { + "equipment_location_history_id": 5, + "channel_id": 5, "equipment_id": 5, "equipment_identifier": "EQ5", + "sampling_point_id": 100, "sampling_point_label": "Effluent", + "parameter_id": 5, "parameter_name": "TSS", + "value_kind_id": 1, "campaign_id": 1, "campaign_name": "Campaign A", + "valid_from": "2026-01-01T00:00:00", "valid_to": None, +} +_TS = {"channel_id": 5, "parameter": "TSS", "unit": "mg/L", "row_count": 1, + "data": [{"timestamp": "2026-05-02T00:00:00", "value": 1.0, "quality_code": 1}]} +_STATS = {"min_timestamp": "2026-05-01T00:00:00", "max_timestamp": "2026-05-08T00:00:00", + "row_count": 1} +_PEDIGREE = { + "stream_id": 5, "kind": "sensor", "parameter": "TSS", "unit": "mg/L", + "value_kind": "Scalar", "label": "CH-TSS", + "deployments": [{ + "valid_from": "2026-01-01T00:00:00", "valid_to": None, + "equipment_identifier": "EQ5", + "sampling_location": {"name": "Effluent"}, + "campaign": {"name": "Campaign A"}, + "responsible_person": {"name": "Jean Tremblay"}, + }], +} + + +def _patches(stack: ExitStack) -> None: + stack.enter_context(patch(f"{MOD}.list_equipment_lookup", return_value=_EQUIPMENT)) + stack.enter_context(patch(f"{MOD}.list_annotation_kinds", return_value=[])) + stack.enter_context(patch(f"{MOD}.list_equipment_event_kinds", return_value=[])) + stack.enter_context(patch(f"{MOD}.list_analysis_series_lookup", return_value=[])) + stack.enter_context(patch(f"{MOD}.list_deployment_traces_lookup", return_value=[_DT5])) + stack.enter_context(patch(f"{DATA}.get_channel_timeseries", return_value=_TS)) + stack.enter_context( + patch(f"{MOD}.get_channel_stats", side_effect=lambda cid: {"channel_id": cid, **_STATS}) + ) + # Export-path calls (imported into the page module namespace). + stack.enter_context(patch(f"{MOD}.get_channel_timeseries", return_value=_TS)) + stack.enter_context(patch(f"{MOD}.get_equipment_events", return_value=[])) + stack.enter_context(patch(f"{MOD}._raw_annotations", return_value=[])) + stack.enter_context(patch(f"{MOD}.get_stream_pedigree", return_value=_PEDIGREE)) + + +def test_generate_then_download_builds_zip_with_csv_and_yaml(): + with ExitStack() as stack: + _patches(stack) + at = AppTest.from_file(HARNESS) + at.session_state["explore_start"] = date(2026, 5, 1) + at.session_state["explore_end"] = date(2026, 5, 8) + at.session_state["explore_active_channels"] = [5] + at.session_state["explore_channel_meta"] = {5: _DT5} + at.run() + + # Before generating, no zip and no download button. + assert "explore_export_zip" not in at.session_state + at.button(key="btn_generate_export").click().run() + assert not at.exception + + blob = at.session_state["explore_export_zip"] + assert isinstance(blob, (bytes, bytearray)) + names = zipfile.ZipFile(io.BytesIO(blob)).namelist() + assert any(n.endswith(".csv") for n in names) + assert any(n.endswith(".yaml") for n in names) + assert at.session_state["explore_export_count"] == 1 + # Download is served via st.download_button (a distinct widget type from + # st.button); its presence is guaranteed by the blob being set this run. + + +def test_no_export_section_without_active_streams(): + with ExitStack() as stack: + _patches(stack) + at = AppTest.from_file(HARNESS) + at.session_state["explore_start"] = date(2026, 5, 1) + at.session_state["explore_end"] = date(2026, 5, 8) + at.run() + assert not at.exception + # No Generate button when nothing is plotted. + assert "btn_generate_export" not in [b.key for b in at.button] diff --git a/tests/app/test_explore_lab.py b/tests/app/test_explore_lab.py index 3c9b90b..c3083ea 100644 --- a/tests/app/test_explore_lab.py +++ b/tests/app/test_explore_lab.py @@ -185,7 +185,6 @@ def test_lab_series_annotation_renders_overlay(): at.run() assert not at.exception - assert list(at.get("plotly_chart")), "no scalar chart rendered" # The overlay summary table lists the lab annotation anchored to LAB-1. overlay_tables = [ df.value for df in at.dataframe @@ -218,8 +217,11 @@ def test_lab_annotation_dialog_is_homogeneous_no_quality_flag_tab(): at = AppTest.from_file(HARNESS) at.session_state["explore_active_series"] = [1] at.session_state["explore_series_meta"] = {1: _SERIES[0]} + # Brush-select the lab point so the selection-based annotate button appears + # (annotations are now selection-driven, not view-range). + at.session_state["scalar_chart_p1"] = [{"seriesIndex": 0, "dataIndex": [0]}] at.run() - at.button(key="btn_lab_ann").click().run() + at.button(key="btn_lab_ann_pt_p1").click().run() assert not at.exception lab_tab_labels = [lbl for t in at.tabs for lbl in (t.label or "",)] assert "Quality Flag" not in lab_tab_labels, ( @@ -228,9 +230,19 @@ def test_lab_annotation_dialog_is_homogeneous_no_quality_flag_tab(): ) +def _scalar_view_rendered(at) -> bool: + """Proxy for 'the scalar ECharts view rendered'. The st_echarts component is + not introspectable as an AppTest element, so we key off the mode caption that + _render_scalar_view emits right above the chart.""" + return any( + (c.value or "").startswith(("Visualization mode", "Extraction mode")) + for c in at.caption + ) + + def test_page_renders_with_active_traces_of_both_sources(): - """With one sensor channel and one lab series active, the page renders the - scalar overlay without error (a plotly chart is produced).""" + """With one sensor channel and one lab series active, the scalar overlay + view renders without error.""" with ExitStack() as stack: _patches(stack) at = AppTest.from_file(HARNESS) @@ -241,7 +253,7 @@ def test_page_renders_with_active_traces_of_both_sources(): at.run() assert not at.exception - assert list(at.get("plotly_chart")), "no scalar chart rendered" + assert _scalar_view_rendered(at), "scalar view did not render" # --------------------------------------------------------------------------- @@ -258,18 +270,11 @@ def test_page_renders_with_active_traces_of_both_sources(): ], } -# Simulated Plotly chart selection event for a single lab marker click. -# customdata format: ["lab", series_id, observation_id] -_LAB_PT_SELECTION = { - "selection": { - "points": [{ - "curve_number": 0, - "x": "2026-05-01T00:00:00", - "y": 11.0, - "customdata": ["lab", 1, 42], - }] - } -} +# Simulated ECharts brushSelected return for a single lab marker brush. +# Shape: list of {seriesIndex, dataIndex[]} (what BRUSH_SELECTED_JS returns). +# With only the lab series active it is ECharts seriesIndex 0; dataIndex 0 is the +# first lab point (observation_id 42). resolve_brush_selection maps it back. +_LAB_PT_SELECTION = [{"seriesIndex": 0, "dataIndex": [0]}] def test_lab_point_selection_shows_pin_button(): @@ -292,12 +297,12 @@ def test_lab_point_selection_shows_pin_button(): at = AppTest.from_file(HARNESS) at.session_state["explore_active_series"] = [1] at.session_state["explore_series_meta"] = {1: _SERIES[0]} - at.session_state["scalar_chart"] = _LAB_PT_SELECTION + at.session_state["scalar_chart_p1"] = _LAB_PT_SELECTION at.run() assert not at.exception btn_keys = [b.key for b in at.button] - assert "btn_lab_ann_pt" in btn_keys, ( + assert "btn_lab_ann_pt_p1" in btn_keys, ( f"'Create Lab Annotation (point)' button (key=btn_lab_ann_pt) not found; " f"got buttons: {btn_keys}" ) @@ -322,10 +327,10 @@ def test_lab_point_pin_dialog_shows_observation_info(): at = AppTest.from_file(HARNESS) at.session_state["explore_active_series"] = [1] at.session_state["explore_series_meta"] = {1: _SERIES[0]} - at.session_state["scalar_chart"] = _LAB_PT_SELECTION + at.session_state["scalar_chart_p1"] = _LAB_PT_SELECTION at.run() # Click the point-pin button to open the dialog - at.button(key="btn_lab_ann_pt").click().run() + at.button(key="btn_lab_ann_pt_p1").click().run() assert not at.exception info_texts = [i.value for i in at.info] @@ -363,9 +368,9 @@ def _capture_dialog(**kwargs): at = AppTest.from_file(HARNESS) at.session_state["explore_active_series"] = [1] at.session_state["explore_series_meta"] = {1: _SERIES[0]} - at.session_state["scalar_chart"] = _LAB_PT_SELECTION + at.session_state["scalar_chart_p1"] = _LAB_PT_SELECTION at.run() - at.button(key="btn_lab_ann_pt").click().run() + at.button(key="btn_lab_ann_pt_p1").click().run() assert not at.exception assert captured, "expected _annotation_dialog to have been called" @@ -501,7 +506,7 @@ def test_provenance_panel_renders_in_global_layout(): at.run() assert not at.exception - assert list(at.get("plotly_chart")), "chart should still render in the global layout" + assert _scalar_view_rendered(at), "chart should still render in the global layout" headers = [h.value for h in at.subheader] assert any("Provenance" in h for h in headers), ( f"provenance panel header not found; got {headers}" diff --git a/tests/app/test_explore_multiplot.py b/tests/app/test_explore_multiplot.py new file mode 100644 index 0000000..60648bd --- /dev/null +++ b/tests/app/test_explore_multiplot.py @@ -0,0 +1,123 @@ +"""Multi-plot workspace coverage for the Data Explorer. + +Streams stay in the canonical active_* lists; a thin assignment layer groups +them across scalar plots. Verifies: new streams land in the target plot, "Add +plot" switches the target, and the per-chip move control reassigns a stream. +""" +from __future__ import annotations + +from contextlib import ExitStack +from datetime import date +from pathlib import Path +from unittest.mock import patch + +from streamlit.testing.v1 import AppTest + +HARNESS = str(Path(__file__).parent / "explore_harness.py") +MOD = "app.pages.explore" +DATA = "app.components.explore_data" + +_EQUIPMENT = [{"equipment_id": 5, "identifier": "EQ5"}] + + +def _trace(ch_id: int, eq: str, param: str) -> dict: + return { + "equipment_location_history_id": ch_id, + "channel_id": ch_id, "equipment_id": ch_id, "equipment_identifier": eq, + "sampling_point_id": 100, "sampling_point_label": "Effluent", + "parameter_id": ch_id, "parameter_name": param, + "value_kind_id": 1, "campaign_id": 1, "campaign_name": "Campaign A", + "valid_from": "2026-01-01T00:00:00", "valid_to": None, + } + + +_DT5 = _trace(5, "EQ5", "TSS") +_DT6 = _trace(6, "EQ6", "COD") +_TS = {"channel_id": 0, "parameter": "P", "unit": "mg/L", "row_count": 1, + "data": [{"timestamp": "2026-05-02T00:00:00", "value": 1.0, "quality_code": 1}]} +_STATS = {"min_timestamp": "2026-05-01T00:00:00", "max_timestamp": "2026-05-08T00:00:00", + "row_count": 1} + + +def _patches(stack: ExitStack) -> None: + stack.enter_context(patch(f"{MOD}.list_equipment_lookup", return_value=_EQUIPMENT)) + stack.enter_context(patch(f"{MOD}.list_annotation_kinds", return_value=[])) + stack.enter_context(patch(f"{MOD}.list_equipment_event_kinds", return_value=[])) + stack.enter_context(patch(f"{MOD}.list_analysis_series_lookup", return_value=[])) + stack.enter_context( + patch(f"{MOD}.list_deployment_traces_lookup", return_value=[_DT5, _DT6]) + ) + stack.enter_context(patch(f"{DATA}.get_channel_timeseries", return_value=_TS)) + stack.enter_context( + patch(f"{MOD}.get_channel_stats", side_effect=lambda cid: {"channel_id": cid, **_STATS}) + ) + + +def test_add_plot_switches_target_and_new_streams_land_there(): + with ExitStack() as stack: + _patches(stack) + at = AppTest.from_file(HARNESS) + at.session_state["explore_start"] = date(2026, 5, 1) + at.session_state["explore_end"] = date(2026, 5, 8) + at.run() + + # Add the first stream (default picker selection = CH-5) -> plot 1. + at.button(key="upicker_add_btn").click().run() + assert at.session_state["explore_plot_of"] == {"ch:5": 1} + assert at.session_state["explore_plots"] == [1] + + # Add a plot -> it becomes the target. + at.button(key="btn_add_plot").click().run() + assert at.session_state["explore_plots"] == [1, 2] + assert at.session_state["explore_target_plot"] == 2 + + # Add the second stream -> lands in plot 2. + at.selectbox(key="upicker_trace_select").set_value( + "Campaign A › Effluent / COD (EQ6)" + ).run() + at.button(key="upicker_add_btn").click().run() + assert at.session_state["explore_plot_of"]["ch:6"] == 2 + assert not at.exception + + +def test_per_chip_move_control_reassigns_stream(): + with ExitStack() as stack: + _patches(stack) + at = AppTest.from_file(HARNESS) + at.session_state["explore_start"] = date(2026, 5, 1) + at.session_state["explore_end"] = date(2026, 5, 8) + # Two channels, both in plot 1; a second (empty) plot exists. + at.session_state["explore_active_channels"] = [5, 6] + at.session_state["explore_channel_meta"] = {5: _DT5, 6: _DT6} + at.session_state["explore_plots"] = [1, 2] + at.session_state["explore_next_plot_id"] = 3 + at.session_state["explore_plot_of"] = {"ch:5": 1, "ch:6": 1} + at.run() + + # Move CH-6 to Plot 2 via its chip selectbox. + at.selectbox(key="move_ch_6").set_value("Plot 2").run() + assert at.session_state["explore_plot_of"]["ch:6"] == 2 + assert at.session_state["explore_plot_of"]["ch:5"] == 1 + assert not at.exception + + +def test_delete_plot_badge_reassigns_its_streams_to_first_plot(): + with ExitStack() as stack: + _patches(stack) + at = AppTest.from_file(HARNESS) + at.session_state["explore_start"] = date(2026, 5, 1) + at.session_state["explore_end"] = date(2026, 5, 8) + at.session_state["explore_active_channels"] = [5, 6] + at.session_state["explore_channel_meta"] = {5: _DT5, 6: _DT6} + at.session_state["explore_plots"] = [1, 2] + at.session_state["explore_next_plot_id"] = 3 + at.session_state["explore_plot_of"] = {"ch:5": 1, "ch:6": 2} + at.run() + + # Delete Plot 2 via its badge ✕ — the plot is removed and its stream + # (CH-6) falls back to the first remaining plot (not deleted). + at.button(key="delplot_2").click().run() + assert at.session_state["explore_plots"] == [1] + assert at.session_state["explore_plot_of"]["ch:6"] == 1 + assert 6 in at.session_state["explore_active_channels"] + assert not at.exception diff --git a/tests/app/test_explore_selection.py b/tests/app/test_explore_selection.py new file mode 100644 index 0000000..199e4dd --- /dev/null +++ b/tests/app/test_explore_selection.py @@ -0,0 +1,175 @@ +"""Selection-driven behavior on the Explore scalar view. + +Brushing points must (a) list them in a table and (b) drive annotation against +only the selected streams/points — never the plotted view range. +""" +from __future__ import annotations + +from contextlib import ExitStack +from datetime import date +from pathlib import Path +from unittest.mock import patch + +from streamlit.testing.v1 import AppTest + +HARNESS = str(Path(__file__).parent / "explore_harness.py") +MOD = "app.pages.explore" +DATA = "app.components.explore_data" + +_EQUIPMENT = [{"equipment_id": 5, "identifier": "EQ5"}] + + +def _meta(ch_id: int, eq: str, param: str) -> dict: + return { + "channel_id": ch_id, "equipment_id": ch_id, "equipment_identifier": eq, + "parameter_id": ch_id, "parameter_name": param, "unit_name": "mg/L", + "value_kind_id": 1, "sampling_point_label": "Effluent", + "campaign_name": "Campaign A", + } + + +_M5 = _meta(5, "EQ5", "TSS") +_M6 = _meta(6, "EQ6", "COD") + + +def _ts(ch_id: int, **_kw) -> dict: + return { + "channel_id": ch_id, "parameter": "P", "unit": "mg/L", "row_count": 2, + "data": [ + {"timestamp": "2026-05-02T00:00:00", "value": 10.0, "quality_code": 1, + "observation_id": ch_id * 100 + 1}, + {"timestamp": "2026-05-03T00:00:00", "value": 12.0, "quality_code": 1, + "observation_id": ch_id * 100 + 2}, + ], + } + + +def _patches(stack: ExitStack) -> None: + stack.enter_context(patch(f"{MOD}.list_equipment_lookup", return_value=_EQUIPMENT)) + stack.enter_context( + patch(f"{MOD}.list_annotation_kinds", + return_value=[{"id": 3, "name": "Fault", "color": "#FF0000"}]) + ) + stack.enter_context(patch(f"{MOD}.list_equipment_event_kinds", return_value=[])) + stack.enter_context(patch(f"{MOD}.list_analysis_series_lookup", return_value=[])) + stack.enter_context(patch(f"{MOD}.list_deployment_traces_lookup", return_value=[])) + stack.enter_context(patch(f"{DATA}.get_channel_timeseries", side_effect=_ts)) + stack.enter_context( + patch(f"{MOD}.get_channel_stats", + side_effect=lambda cid: {"channel_id": cid, + "min_timestamp": "2026-05-02T00:00:00", + "max_timestamp": "2026-05-03T00:00:00", + "row_count": 2}) + ) + stack.enter_context(patch(f"{DATA}._api_list_annotations_for_channel", return_value=[])) + stack.enter_context(patch(f"{DATA}.get_equipment_events", return_value=[])) + + +def test_selected_points_listed_in_a_table(): + with ExitStack() as stack: + _patches(stack) + at = AppTest.from_file(HARNESS) + at.session_state["explore_active_channels"] = [5] + at.session_state["explore_channel_meta"] = {5: _M5} + at.session_state["explore_start"] = date(2026, 5, 1) + at.session_state["explore_end"] = date(2026, 5, 8) + # Each sensor renders as a decorative line (seriesIndex 0) + a brushable + # marker scatter (seriesIndex 1) that carries identity. Brush the markers. + at.session_state["scalar_chart_p1"] = [{"seriesIndex": 1, "dataIndex": [0, 1]}] + at.run() + + assert not at.exception + sel_tables = [ + df.value for df in at.dataframe + if "Observation" in getattr(df.value, "columns", []) + ] + assert sel_tables, "selection table not rendered" + recs = sel_tables[0].to_dict("records") + assert {r["Observation"] for r in recs} == {501, 502} + assert all(r["Stream"] == "CH-5" for r in recs) + + +def test_equipment_event_button_always_available_and_lists_selected_equipment(): + """The equipment-event button is no longer gated behind a selection, and the + selected equipment is listed so it's clear what an event would target.""" + with ExitStack() as stack: + _patches(stack) + at = AppTest.from_file(HARNESS) + at.session_state["explore_active_channels"] = [5] + at.session_state["explore_channel_meta"] = {5: _M5} + at.session_state["explore_start"] = date(2026, 5, 1) + at.session_state["explore_end"] = date(2026, 5, 8) + at.run() + # No selection yet — the equipment-event button is still present. + assert "btn_eq_event_p1" in [b.key for b in at.button] + + # Select a CH-5 point; its equipment (EQ5) is now listed. Markers are the + # brushable scatter at seriesIndex 1 (seriesIndex 0 is the decorative line). + at.session_state["scalar_chart_p1"] = [{"seriesIndex": 1, "dataIndex": [0]}] + at.run() + assert "btn_eq_event_p1" in [b.key for b in at.button] + body = " ".join((m.value or "") for m in at.markdown) + assert "EQ5" in body, "selected equipment identifier not shown" + + +def test_annotation_scoped_to_selected_stream_only(): + """Two sensor channels active; brushing a point on CH-5 only must annotate + CH-5 alone — not every channel in the plot (the old view-range behavior).""" + captured: dict = {} + + with ExitStack() as stack: + _patches(stack) + stack.enter_context( + patch(f"{MOD}._annotation_dialog", side_effect=lambda **kw: captured.update(kw)) + ) + at = AppTest.from_file(HARNESS) + at.session_state["explore_active_channels"] = [5, 6] + at.session_state["explore_channel_meta"] = {5: _M5, 6: _M6} + at.session_state["explore_start"] = date(2026, 5, 1) + at.session_state["explore_end"] = date(2026, 5, 8) + # Per channel: decorative line + marker scatter. CH-5 markers are at + # seriesIndex 1 (0=CH-5 line, 1=CH-5 markers, 2=CH-6 line, 3=CH-6 markers). + at.session_state["scalar_chart_p1"] = [{"seriesIndex": 1, "dataIndex": [0]}] + at.run() + at.button(key="btn_sensor_ann_p1").click().run() + + assert not at.exception + assert captured, "annotation dialog not opened" + assert captured.get("channel_ids") == [5], ( + f"annotation should target only the selected stream CH-5; got {captured.get('channel_ids')}" + ) + # Single selected point pins to its exact observation. + assert captured.get("observation_id") == 501 + + +def test_equipment_event_defaults_to_selected_equipment(): + """Tagging an equipment event must default the dialog to the SELECTED sensor's + equipment, not the first equipment in the global list. The old behavior landed + the event on the wrong equipment, so it never rendered on this channel's chart.""" + captured: dict = {} + # First equipment in the list is NOT the selected sensor's (id 5). + two_equipment = [ + {"equipment_id": 9, "identifier": "Basestation"}, + {"equipment_id": 5, "identifier": "EQ5"}, + ] + with ExitStack() as stack: + _patches(stack) + stack.enter_context(patch(f"{MOD}.list_equipment_lookup", return_value=two_equipment)) + stack.enter_context( + patch(f"{MOD}._equipment_event_dialog", side_effect=lambda **kw: captured.update(kw)) + ) + at = AppTest.from_file(HARNESS) + at.session_state["explore_active_channels"] = [5] + at.session_state["explore_channel_meta"] = {5: _M5} + at.session_state["explore_start"] = date(2026, 5, 1) + at.session_state["explore_end"] = date(2026, 5, 8) + at.session_state["scalar_chart_p1"] = [{"seriesIndex": 1, "dataIndex": [0]}] + at.run() + at.button(key="btn_eq_event_p1").click().run() + + assert not at.exception + assert captured, "equipment event dialog not opened" + assert captured.get("default_equipment_id") == 5, ( + "equipment-event dialog should default to the selected sensor's equipment 5; " + f"got {captured.get('default_equipment_id')}" + ) diff --git a/tests/app/test_explore_state.py b/tests/app/test_explore_state.py new file mode 100644 index 0000000..214546c --- /dev/null +++ b/tests/app/test_explore_state.py @@ -0,0 +1,96 @@ +"""Reactive-state regressions for the Data Explorer (issue: state resets). + +Adding a stream to the plot must not wipe the range-keyed data cache: an +already-plotted stream must NOT be re-fetched when a second stream is added. +Before the fix, both add paths called _invalidate_data_cache(), forcing a full +reload of every active stream on each add (the "data is immediately requested, +which is slow" symptom). +""" +from __future__ import annotations + +from contextlib import ExitStack +from datetime import date +from pathlib import Path +from unittest.mock import patch + +from streamlit.testing.v1 import AppTest + +HARNESS = str(Path(__file__).parent / "explore_harness.py") +MOD = "app.pages.explore" +DATA = "app.components.explore_data" + +_EQUIPMENT = [{"equipment_id": 5, "identifier": "EQ5"}] + + +def _trace(ch_id: int, eq: str, param: str) -> dict: + return { + "equipment_location_history_id": ch_id, + "channel_id": ch_id, "equipment_id": ch_id, "equipment_identifier": eq, + "sampling_point_id": 100, "sampling_point_label": "Effluent", + "parameter_id": ch_id, "parameter_name": param, + "value_kind_id": 1, "campaign_id": 1, "campaign_name": "Campaign A", + "valid_from": "2026-01-01T00:00:00", "valid_to": None, + } + + +_DT5 = _trace(5, "EQ5", "TSS") +_DT6 = _trace(6, "EQ6", "COD") + + +def _ts_for(channel_id: int, **_kw) -> dict: + return { + "channel_id": channel_id, "parameter": "P", "unit": "mg/L", + "data_shape": "Scalar", "row_count": 1, + "data": [{"timestamp": "2026-03-05T00:00:00", "value": 1.0, "quality_code": 1}], + } + + +def _stats(channel_id: int) -> dict: + return {"channel_id": channel_id, "min_timestamp": "2026-03-02T00:00:00", + "max_timestamp": "2026-03-10T00:00:00", "row_count": 1} + + +def test_adding_a_stream_does_not_refetch_already_plotted_streams(): + calls: list[int] = [] + + def _track(channel_id, **kw): + calls.append(channel_id) + return _ts_for(channel_id) + + with ExitStack() as stack: + stack.enter_context(patch(f"{MOD}.list_equipment_lookup", return_value=_EQUIPMENT)) + stack.enter_context(patch(f"{MOD}.list_annotation_kinds", return_value=[])) + stack.enter_context(patch(f"{MOD}.list_equipment_event_kinds", return_value=[])) + stack.enter_context(patch(f"{MOD}.list_analysis_series_lookup", return_value=[])) + stack.enter_context( + patch(f"{MOD}.list_deployment_traces_lookup", return_value=[_DT5, _DT6]) + ) + stack.enter_context(patch(f"{DATA}.get_channel_timeseries", side_effect=_track)) + stack.enter_context( + patch(f"{MOD}.get_channel_stats", side_effect=lambda cid: _stats(cid)) + ) + + at = AppTest.from_file(HARNESS) + # Channel 5 already plotted; first run loads its data once. + at.session_state["explore_active_channels"] = [5] + at.session_state["explore_channel_meta"] = {5: _DT5} + at.session_state["explore_channel_stats"] = {5: _stats(5)} + at.session_state["explore_start"] = date(2026, 3, 1) + at.session_state["explore_end"] = date(2026, 3, 15) + at.run() + assert not at.exception + assert calls.count(5) == 1, f"ch5 not loaded exactly once on first run: {calls}" + + # Add channel 6 through the picker, then click "+ Add to plot". + at.selectbox(key="upicker_trace_select").set_value( + "Campaign A › Effluent / COD (EQ6)" + ).run() + at.button(key="upicker_add_btn").click().run() + assert not at.exception + + # ch6 was added and loaded; ch5 must NOT have been re-fetched. + assert 6 in at.session_state["explore_active_channels"] + assert calls.count(6) == 1, f"ch6 should load once: {calls}" + assert calls.count(5) == 1, ( + f"ch5 was re-fetched when ch6 was added (cache wiped): {calls}" + ) diff --git a/tests/app/test_wizard_snapshot.py b/tests/app/test_wizard_snapshot.py new file mode 100644 index 0000000..ce73364 --- /dev/null +++ b/tests/app/test_wizard_snapshot.py @@ -0,0 +1,37 @@ +"""snapshot_get must survive Streamlit dropping a prior step's widget keys. + +Regression for the campaign wizard's Equipment Deployments step: selecting an +equipment triggers a rerun on which the step-1 site/sampling-location widgets +are not rendered, so Streamlit drops their live session_state keys. Reading them +directly blanked the step ("No sampling locations selected"); reading from the +persistent snapshot fixes it. +""" + +from __future__ import annotations + +from app.components import wizard_helpers as wh + + +def test_snapshot_get_falls_back_when_live_key_dropped(monkeypatch): + monkeypatch.setattr(wh.st, "session_state", {}) + # Step 1 saved its selections into the snapshot; the live widget keys were + # then dropped by Streamlit on a later-step rerun. + wh.st.session_state["_cmp_wiz_snap_1"] = { + "cmp_wiz_s1_site": "Site A", + "cmp_wiz_s1_sl_selected": ["Inlet", "Outlet"], + } + + assert wh.snapshot_get("cmp_wiz", 1, "cmp_wiz_s1_site") == "Site A" + assert wh.snapshot_get("cmp_wiz", 1, "cmp_wiz_s1_sl_selected") == ["Inlet", "Outlet"] + + +def test_live_state_wins_over_snapshot(monkeypatch): + monkeypatch.setattr(wh.st, "session_state", {}) + wh.st.session_state["_cmp_wiz_snap_1"] = {"cmp_wiz_s1_site": "Site A"} + wh.st.session_state["cmp_wiz_s1_site"] = "Site B" # freshly changed, still rendered + assert wh.snapshot_get("cmp_wiz", 1, "cmp_wiz_s1_site") == "Site B" + + +def test_default_when_neither_present(monkeypatch): + monkeypatch.setattr(wh.st, "session_state", {}) + assert wh.snapshot_get("cmp_wiz", 1, "missing", default=[]) == [] diff --git a/tests/unit/test_annotation_s3.py b/tests/unit/test_annotation_s3.py new file mode 100644 index 0000000..9acbe34 --- /dev/null +++ b/tests/unit/test_annotation_s3.py @@ -0,0 +1,37 @@ +"""S3 rename tests: Annotation.EquipmentEvent_ID → Event_ID. + +Asserts: + 1. The YAML schema dictionary has a column named Event_ID (not EquipmentEvent_ID). + 2. AnnotationCreate accepts event_id as a keyword argument and stores it. +""" + +from datetime import datetime +from pathlib import Path + +from tools.schema_migrate.loader import load_schema + +from api.v1.schemas.annotations import AnnotationCreate + + +_TABLES_DIR = Path(__file__).parent.parent.parent / "schema_dictionary" / "tables" + + +def test_annotation_yaml_has_event_id_not_equipment_event_id(): + """The Annotation table YAML defines Event_ID, not EquipmentEvent_ID.""" + schema = load_schema(_TABLES_DIR) + annotation = schema["Annotation"] + column_names = [col["name"] for col in annotation["table"]["columns"]] + assert "Event_ID" in column_names, "Annotation table must have Event_ID column" + assert "EquipmentEvent_ID" not in column_names, ( + "EquipmentEvent_ID must not appear in Annotation table after S3 rename" + ) + + +def test_annotation_create_accepts_event_id(): + """AnnotationCreate accepts event_id and exposes it correctly.""" + obj = AnnotationCreate( + annotation_type="Fault", + start_time=datetime.now(), + event_id=42, + ) + assert obj.event_id == 42 diff --git a/tests/unit/test_annotation_series.py b/tests/unit/test_annotation_series.py index 4d8f85c..f64e95b 100644 --- a/tests/unit/test_annotation_series.py +++ b/tests/unit/test_annotation_series.py @@ -72,7 +72,7 @@ def _stream_row(stream_id: int = 7, stream_kind_id: int = LAB, observation_id=No None, # AuthorName None, # Campaign_ID None, # CampaignName - None, # EquipmentEvent_ID + None, # Event_ID FROM, # CreatedDateTime None, # ModifiedDateTime stream_kind_id, # StreamKind_ID @@ -97,7 +97,7 @@ def test_create_writes_single_stream_id(self): end_time=TO, author_person_id=None, campaign_id=None, - equipment_event_id=None, + event_id=None, title=None, comment=None, ) @@ -124,7 +124,7 @@ def test_create_sensor_and_lab_streams_use_same_insert(self): end_time=None, author_person_id=None, campaign_id=None, - equipment_event_id=None, + event_id=None, title=None, comment=None, ) @@ -141,7 +141,7 @@ def test_create_threads_observation_id_pin(self): end_time=None, author_person_id=None, campaign_id=None, - equipment_event_id=None, + event_id=None, title=None, comment=None, observation_id=61, @@ -549,7 +549,7 @@ def _feed_row( None, # AuthorName None, # Campaign_ID None, # CampaignName - None, # EquipmentEvent_ID + None, # Event_ID created, # CreatedDateTime None, # ModifiedDateTime stream_kind_id, # StreamKind_ID diff --git a/tests/unit/test_campaign_deployment_membership.py b/tests/unit/test_campaign_deployment_membership.py new file mode 100644 index 0000000..91c070c --- /dev/null +++ b/tests/unit/test_campaign_deployment_membership.py @@ -0,0 +1,64 @@ +"""Campaign membership is junction-authoritative (consistency audit F10, D2). + +delete_campaign_deployment must remove membership via the junction tables and +must not couple the CampaignSamplingLocation cleanup to ELH open/closed state. +Runs without a DB: we inspect the SQL the repo issues. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from api.v1.repositories import campaign_repository as cr + + +def _conn(): + conn = MagicMock() + cursor = MagicMock() + conn.cursor.return_value = cursor + return conn, cursor + + +def _sqls(cursor): + return [c.args[0] for c in cursor.execute.call_args_list] + + +def test_delete_removes_junctions_and_sp_link_decoupled_from_open_elh(): + conn, cursor = _conn() + cr.delete_campaign_deployment( + conn, campaign_id=5, equipment_id=7, sampling_point_id=3 + ) + sqls = _sqls(cursor) + + # 1. membership junction removed + assert any("DELETE FROM [dbo].[CampaignEquipment]" in s for s in sqls) + # 2. active physical placement removed; closed rows (no ValidTo IS NULL) untouched + assert any( + "DELETE FROM [dbo].[EquipmentLocationHistory]" in s and "[ValidTo] IS NULL" in s + for s in sqls + ) + # 3. SP-link removal is junction-driven and NOT gated on open-ELH state (the F10 fix) + sp_sql = next(s for s in sqls if "DELETE FROM [dbo].[CampaignSamplingLocation]" in s) + assert "CampaignEquipment" in sp_sql, "SP-link check must be junction-authoritative" + assert "ValidTo" not in sp_sql, "SP-link check must not depend on ELH open/closed state" + + conn.commit.assert_called_once() + + +def test_campaign_equipment_deleted_before_sp_link_check(): + """CE must be removed before the SP-link NOT EXISTS so the equipment being + deleted is excluded from 'does another member still use this SP?'.""" + conn, cursor = _conn() + cr.delete_campaign_deployment(conn, 5, 7, 3) + sqls = _sqls(cursor) + ce_idx = next(i for i, s in enumerate(sqls) if "DELETE FROM [dbo].[CampaignEquipment]" in s) + sp_idx = next(i for i, s in enumerate(sqls) if "CampaignSamplingLocation" in s) + assert ce_idx < sp_idx + + +def test_delete_without_sampling_point_skips_sp_link(): + conn, cursor = _conn() + cr.delete_campaign_deployment(conn, 5, 7, sampling_point_id=None) + sqls = _sqls(cursor) + assert not any("CampaignSamplingLocation" in s for s in sqls) + conn.commit.assert_called_once() diff --git a/tests/unit/test_campaign_multisite.py b/tests/unit/test_campaign_multisite.py new file mode 100644 index 0000000..6138d00 --- /dev/null +++ b/tests/unit/test_campaign_multisite.py @@ -0,0 +1,59 @@ +"""Campaigns are multi-site (consistency audit F2/F9, Batch 6). + +Campaign.Site_ID is dropped: a campaign's sites are derived from its +sampling-location membership (CampaignSamplingLocation -> SamplingPoint.Site). +These run without a DB — we pin the SQL the repo issues and the derived shape. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from api.v1.repositories import campaign_repository as cr + + +def _conn(fetchall=None, fetchone=None): + conn = MagicMock() + cursor = MagicMock() + cursor.fetchall.return_value = fetchall if fetchall is not None else [] + cursor.fetchone.return_value = fetchone + conn.cursor.return_value = cursor + return conn, cursor + + +def _sqls(cursor): + return [c.args[0] for c in cursor.execute.call_args_list] + + +def test_campaign_sites_derived_from_membership(): + conn, cursor = _conn(fetchall=[(4, "Site A"), (9, "Site B")]) + sites = cr._campaign_sites(conn, 5) + + sql = _sqls(cursor)[0] + assert "DISTINCT" in sql + assert "[CampaignSamplingLocation]" in sql + assert "[SamplingPoint]" in sql and "[Site]" in sql + assert sites == [ + {"site_id": 4, "site_name": "Site A"}, + {"site_id": 9, "site_name": "Site B"}, + ] + + +def test_list_filter_by_site_is_membership_not_column(): + conn, cursor = _conn(fetchall=[]) + cr.list_campaigns(conn, site_id=3) + sql = _sqls(cursor)[0] + # F2/F9 fix: filter is junction membership, not the dropped Campaign.Site_ID. + assert "EXISTS" in sql and "[CampaignSamplingLocation]" in sql + assert "c.[Site_ID]" not in sql + + +def test_insert_and_update_no_longer_write_site_id(): + # fetchone serves @@IDENTITY ([0]) and the get_campaign_by_id row (9 cols). + conn, cursor = _conn(fetchone=(1, 2, "kind", "name", None, None, None, None, "person")) + cr.insert_campaign(conn, {"name": "C", "campaign_kind_id": 2, "site_id": 7}) + cr.update_campaign(conn, 1, {"name": "C", "campaign_kind_id": 2, "site_id": 7}) + writes = [s for s in _sqls(cursor) if "INTO [dbo].[Campaign]" in s or "UPDATE [dbo].[Campaign]" in s] + assert writes, "expected an INSERT and an UPDATE against Campaign" + for sql in writes: + assert "[Site_ID]" not in sql diff --git a/tests/unit/test_channel_port_resolution.py b/tests/unit/test_channel_port_resolution.py new file mode 100644 index 0000000..d9c101f --- /dev/null +++ b/tests/unit/test_channel_port_resolution.py @@ -0,0 +1,82 @@ +"""F3 regression: Channel.SignalInterfacePort_ID denorm column is dropped. + +The current port is resolved from the active ChannelPortHistory row via the +vw_ChannelResolved view, and writes go through set_channel_active_port (the +single writer of the CPH active-row invariant). These pin: + * reads come FROM the view, not the base Channel table; + * the Channel INSERT no longer carries the dropped column; + * set_channel_active_port closes-then-opens, no-ops when unchanged, and + closes-only when cleared. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from api.v1.repositories import channel_repository + + +def test_channel_select_reads_from_resolved_view_not_base_table(): + sql = channel_repository._CHANNEL_SELECT + assert "[dbo].[vw_ChannelResolved] c" in sql + assert "FROM [dbo].[Channel] c" not in sql + + +def test_insert_channel_does_not_write_dropped_port_column(): + cursor = MagicMock() + # stream mint -> 42; set_channel_active_port SELECT active -> None; + # CPH INSERT -> 77; get_channel_by_id SELECT -> None + cursor.fetchone.side_effect = [(42,), None, (77,), None] + conn = MagicMock() + conn.cursor.return_value = cursor + + channel_repository.insert_channel( + conn, {"signal_interface_id": 3, "tag_name": "t", "signal_interface_port_id": 5} + ) + + statements = [c.args[0] for c in cursor.execute.call_args_list] + channel_insert = next(s for s in statements if "INSERT INTO [dbo].[Channel]" in s) + assert "SignalInterfacePort_ID" not in channel_insert + # The port is recorded as a ChannelPortHistory row instead. + assert any("INSERT INTO [dbo].[ChannelPortHistory]" in s for s in statements) + + +def test_insert_channel_without_port_opens_no_cph_row(): + cursor = MagicMock() + cursor.fetchone.side_effect = [(42,), None] # stream mint, get_channel_by_id + conn = MagicMock() + conn.cursor.return_value = cursor + + channel_repository.insert_channel(conn, {"signal_interface_id": 3, "tag_name": "t"}) + + statements = [c.args[0] for c in cursor.execute.call_args_list] + assert not any("ChannelPortHistory" in s for s in statements) + + +def test_set_active_port_unchanged_is_noop(): + cursor = MagicMock() + cursor.fetchone.return_value = (10, 5) # active row id=10, port=5 + new_id, closed_id = channel_repository.set_channel_active_port(cursor, 1, 5) + assert (new_id, closed_id) == (None, 10) + # Only the SELECT ran — no close, no open. + assert cursor.execute.call_count == 1 + + +def test_set_active_port_change_closes_then_opens(): + cursor = MagicMock() + cursor.fetchone.side_effect = [(10, 5), (10,), (11,)] # active, closed id, new id + new_id, closed_id = channel_repository.set_channel_active_port(cursor, 1, 7) + assert (new_id, closed_id) == (11, 10) + stmts = " ".join(c.args[0] for c in cursor.execute.call_args_list) + assert "UPDATE [dbo].[ChannelPortHistory]" in stmts + assert "INSERT INTO [dbo].[ChannelPortHistory]" in stmts + + +def test_set_active_port_clear_closes_without_opening(): + cursor = MagicMock() + cursor.fetchone.side_effect = [(10, 5), (10,)] # active row, then closed id + new_id, closed_id = channel_repository.set_channel_active_port(cursor, 1, None) + assert (new_id, closed_id) == (None, 10) + stmts = [c.args[0] for c in cursor.execute.call_args_list] + assert any("UPDATE [dbo].[ChannelPortHistory]" in s for s in stmts) + assert not any("INSERT INTO [dbo].[ChannelPortHistory]" in s for s in stmts) diff --git a/tests/unit/test_channel_stream_tpt_schema.py b/tests/unit/test_channel_stream_tpt_schema.py index 20e4f29..aa16fed 100644 --- a/tests/unit/test_channel_stream_tpt_schema.py +++ b/tests/unit/test_channel_stream_tpt_schema.py @@ -70,7 +70,6 @@ def test_subtype_columns_still_present(self, channel): for col in ( "SignalInterface_ID", "TagName", - "SignalInterfacePort_ID", "ChannelKind_ID", "Parameter_ID", "DataProvenanceKind_ID", @@ -80,6 +79,12 @@ def test_subtype_columns_still_present(self, channel): ): assert col in names + def test_denormalised_port_column_dropped(self, channel): + # F3: the denormalised port column is gone; the current port is resolved + # from the active ChannelPortHistory row via vw_ChannelResolved. + names = {c["name"] for c in channel["columns"]} + assert "SignalInterfacePort_ID" not in names + def test_uq_signal_stream_index_unchanged(self, channel): idx = {i["name"]: i for i in channel.get("indexes", [])} assert "UQ_Channel_SignalStream" in idx diff --git a/tests/unit/test_deployment_coherence.py b/tests/unit/test_deployment_coherence.py new file mode 100644 index 0000000..859e246 --- /dev/null +++ b/tests/unit/test_deployment_coherence.py @@ -0,0 +1,85 @@ +"""Batch 2 detection layer (consistency audit F1 + F13). + +These repo helpers feed the DAS-move / reconfigure warnings. They run without a +DB (MagicMock cursor): we pin the SQL shape and the row→dict mapping. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from api.v1.repositories import temporal_history_repository as thr + + +def _conn(fetchall=None, fetchone=None): + cursor = MagicMock() + cursor.fetchall.return_value = fetchall if fetchall is not None else [] + cursor.fetchone.return_value = fetchone # None unless a row is supplied + conn = MagicMock() + conn.cursor.return_value = cursor + return conn, cursor + + +# --- F1: equipment stranded by a DAS move ---------------------------------- + +def test_das_move_conflicts_query_keys_on_das_and_excludes_target_site(): + conn, cursor = _conn(fetchall=[]) + thr.get_das_move_equipment_conflicts(conn, das_id=3, new_site_id=9) + + sql = cursor.execute.call_args.args[0] + # walks DAS -> SignalInterface -> active wiring -> active location -> SP.Site + assert "si.[DataAcquisitionSystem_ID] = ?" in sql + assert "ewh.[ValidTo] IS NULL" in sql + assert "elh.[ValidTo] IS NULL" in sql + assert "sp.[Site_ID] <> ?" in sql + assert cursor.execute.call_args.args[1:] == (3, 9) + + +def test_das_move_conflicts_maps_rows(): + conn, _ = _conn(fetchall=[(5, "pH-01", 7, "Influent", 2, "Plant A")]) + rows = thr.get_das_move_equipment_conflicts(conn, das_id=3, new_site_id=9) + assert rows == [ + { + "equipment_id": 5, + "equipment_identifier": "pH-01", + "sampling_point_id": 7, + "sampling_point_name": "Influent", + "current_site_id": 2, + "current_site_name": "Plant A", + } + ] + + +def test_das_move_no_conflicts_returns_empty(): + conn, _ = _conn(fetchall=[]) + assert thr.get_das_move_equipment_conflicts(conn, 3, 9) == [] + + +# --- F13: equipment's still-active campaign deployment ---------------------- + +def test_active_campaign_deployment_filters_to_unfinished_campaigns(): + conn, cursor = _conn(fetchone=None) + thr.get_active_campaign_deployment(conn, equipment_id=5) + + sql = cursor.execute.call_args.args[0] + assert "elh.[ValidTo] IS NULL" in sql + # only campaigns still running (no end, or end in the future) + assert "c.[CampaignEndDateTime] IS NULL" in sql + assert "c.[CampaignEndDateTime] > SYSUTCDATETIME()" in sql + + +def test_active_campaign_deployment_maps_row(): + conn, _ = _conn(fetchone=(12, "Pilot 2026", 88, 7, "Influent")) + got = thr.get_active_campaign_deployment(conn, 5) + assert got == { + "campaign_id": 12, + "campaign_name": "Pilot 2026", + "equipment_location_history_id": 88, + "sampling_point_id": 7, + "sampling_point_name": "Influent", + } + + +def test_active_campaign_deployment_none_when_no_open_campaign_row(): + conn, _ = _conn(fetchone=None) + assert thr.get_active_campaign_deployment(conn, 5) is None diff --git a/tests/unit/test_equipment_installations_elh.py b/tests/unit/test_equipment_installations_elh.py new file mode 100644 index 0000000..5393541 --- /dev/null +++ b/tests/unit/test_equipment_installations_elh.py @@ -0,0 +1,68 @@ +"""Regression (F4): get_equipment_installations must read EquipmentLocationHistory. + +The EquipmentInstallation table was dropped in the v2 schema, but +get_equipment_installations still queried [dbo].[EquipmentInstallation], so +GET /equipment/{id}/lifecycle 500'd ('Invalid object name EquipmentInstallation'). +Location history now lives in EquipmentLocationHistory (ValidFrom/ValidTo rows). +These pin the source table and the dict mapping the InstallationOut schema expects. +""" + +from __future__ import annotations + +from datetime import datetime +from unittest.mock import MagicMock + +from api.v1.repositories import equipment_repository + + +def test_installations_query_targets_location_history_not_dropped_table(): + cursor = MagicMock() + cursor.fetchall.return_value = [] + conn = MagicMock() + conn.cursor.return_value = cursor + + equipment_repository.get_equipment_installations(conn, 5, None, None) + + executed = cursor.execute.call_args.args[0] + assert "[dbo].[EquipmentLocationHistory]" in executed + assert "EquipmentInstallation" not in executed + + +def test_installations_maps_elh_rows_to_installationout_shape(): + cursor = MagicMock() + installed = datetime(2024, 1, 1) + cursor.fetchall.return_value = [ + (7, 3, "Influent", installed, None, 2, "Baseline", "note"), + ] + conn = MagicMock() + conn.cursor.return_value = cursor + + rows = equipment_repository.get_equipment_installations(conn, 5, None, None) + + assert rows == [ + { + "installation_id": 7, + "sampling_location_id": 3, + "location_name": "Influent", + "installed_date": installed, + "removed_date": None, + "campaign_id": 2, + "campaign_name": "Baseline", + "notes": "note", + } + ] + + +def test_installations_date_filters_use_validfrom_validto(): + cursor = MagicMock() + cursor.fetchall.return_value = [] + conn = MagicMock() + conn.cursor.return_value = cursor + + equipment_repository.get_equipment_installations( + conn, 5, datetime(2024, 1, 1), datetime(2024, 12, 31) + ) + + executed = cursor.execute.call_args.args[0] + assert "elh.[ValidTo] IS NULL OR elh.[ValidTo] >= ?" in executed + assert "elh.[ValidFrom] <= ?" in executed diff --git a/tests/unit/test_event_api_s2.py b/tests/unit/test_event_api_s2.py new file mode 100644 index 0000000..c674d32 --- /dev/null +++ b/tests/unit/test_event_api_s2.py @@ -0,0 +1,170 @@ +"""Unit tests for PRD-2 S2: Event/EventKind schemas with exclusive-arc validator. + +Red→green: all assertions fail before EventIn is implemented (no validator, no schema). +After S2, each assertion passes without a DB connection. + +Coverage: +- Zero arc targets → ValidationError +- Two arc targets → ValidationError +- Each of the 8 arc levels with exactly one target → OK (8 separate assertions) +- EventKindIn / EventKindOut round-trip +- EventOut construction +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from pydantic import ValidationError + +from api.v1.schemas.events import EventIn, EventKindIn, EventKindOut, EventOut + +_NOW = datetime(2024, 3, 15, 10, 0, 0, tzinfo=timezone.utc) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _base(**extra) -> dict: + """Minimal valid EventIn kwargs with exactly-one arc target supplied via extra.""" + return {"event_kind_id": 1, "start_datetime": _NOW, **extra} + + +# --------------------------------------------------------------------------- +# Arc-validator: rejection cases +# --------------------------------------------------------------------------- + + +def test_event_in_zero_targets_raises() -> None: + """EventIn with no arc target must raise ValidationError.""" + with pytest.raises(ValidationError, match="exactly one target required"): + EventIn(**_base()) + + +def test_event_in_two_targets_raises() -> None: + """EventIn with two arc targets must raise ValidationError.""" + with pytest.raises(ValidationError, match="exactly one target required"): + EventIn(**_base(equipment_id=1, site_id=2)) + + +def test_event_in_eight_targets_raises() -> None: + """EventIn with all 8 arc targets must raise ValidationError.""" + with pytest.raises(ValidationError, match="exactly one target required"): + EventIn( + **_base( + channel_id=1, + equipment_id=2, + signal_interface_id=3, + data_acquisition_system_id=4, + sampling_point_id=5, + process_unit_id=6, + site_id=7, + campaign_id=8, + ) + ) + + +# --------------------------------------------------------------------------- +# Arc-validator: acceptance cases (one per arc level) +# --------------------------------------------------------------------------- + + +def test_event_in_channel_only_ok() -> None: + evt = EventIn(**_base(channel_id=5)) + assert evt.channel_id == 5 + assert evt.equipment_id is None + + +def test_event_in_equipment_only_ok() -> None: + evt = EventIn(**_base(equipment_id=10)) + assert evt.equipment_id == 10 + assert evt.channel_id is None + + +def test_event_in_signal_interface_only_ok() -> None: + evt = EventIn(**_base(signal_interface_id=3)) + assert evt.signal_interface_id == 3 + + +def test_event_in_das_only_ok() -> None: + evt = EventIn(**_base(data_acquisition_system_id=7)) + assert evt.data_acquisition_system_id == 7 + + +def test_event_in_sampling_point_only_ok() -> None: + evt = EventIn(**_base(sampling_point_id=2)) + assert evt.sampling_point_id == 2 + + +def test_event_in_process_unit_only_ok() -> None: + evt = EventIn(**_base(process_unit_id=4)) + assert evt.process_unit_id == 4 + + +def test_event_in_site_only_ok() -> None: + evt = EventIn(**_base(site_id=1)) + assert evt.site_id == 1 + + +def test_event_in_campaign_only_ok() -> None: + evt = EventIn(**_base(campaign_id=99)) + assert evt.campaign_id == 99 + + +# --------------------------------------------------------------------------- +# EventKindIn / EventKindOut +# --------------------------------------------------------------------------- + + +def test_event_kind_in_minimal() -> None: + k = EventKindIn(name="Calibration") + assert k.name == "Calibration" + assert k.description is None + + +def test_event_kind_in_with_description() -> None: + k = EventKindIn(name="Cleaning", description="Sensor flushed") + assert k.description == "Sensor flushed" + + +def test_event_kind_out_round_trip() -> None: + k = EventKindOut(event_kind_id=1, name="Calibration", description=None) + assert k.event_kind_id == 1 + assert k.name == "Calibration" + assert k.description is None + + +# --------------------------------------------------------------------------- +# EventOut construction +# --------------------------------------------------------------------------- + + +def test_event_out_with_equipment_target() -> None: + evt = EventOut( + event_id=42, + event_kind_id=1, + event_kind_name="Calibration", + is_instantaneous=False, + start_datetime=_NOW, + equipment_id=7, + ) + assert evt.event_id == 42 + assert evt.equipment_id == 7 + assert evt.channel_id is None + assert evt.site_id is None + + +def test_event_out_instantaneous() -> None: + evt = EventOut( + event_id=1, + event_kind_id=14, + event_kind_name="ControllerCrash", + is_instantaneous=True, + start_datetime=_NOW, + data_acquisition_system_id=3, + ) + assert evt.is_instantaneous is True + assert evt.end_datetime is None diff --git a/tests/unit/test_event_app_s4.py b/tests/unit/test_event_app_s4.py new file mode 100644 index 0000000..6e830da --- /dev/null +++ b/tests/unit/test_event_app_s4.py @@ -0,0 +1,32 @@ +"""PRD-2 S4 — App-layer smoke tests for Event CRUD page + EventKind page. + +Red→green: these fail until event_kind slug is added to form_specs and the +pages exist with correct imports. +""" + +from __future__ import annotations + +import importlib + + +def test_get_form_fields_event_kind_has_name_and_description() -> None: + """get_form_fields('event_kind') returns at least name and description.""" + from app.components.form_specs import get_form_fields + + fields = get_form_fields("event_kind") + field_names = {f["name"] for f in fields} + assert "name" in field_names, "'name' field missing from event_kind form spec" + assert "description" in field_names, "'description' field missing from event_kind form spec" + + +def test_event_kinds_page_imports() -> None: + """app.pages.event_kinds imports without error.""" + # Use importlib so Streamlit top-level calls are not executed + spec = importlib.util.find_spec("app.pages.event_kinds") + assert spec is not None, "app/pages/event_kinds.py not found" + + +def test_events_page_imports() -> None: + """app.pages.events imports without error.""" + spec = importlib.util.find_spec("app.pages.events") + assert spec is not None, "app/pages/events.py not found" diff --git a/tests/unit/test_event_schema_s1.py b/tests/unit/test_event_schema_s1.py new file mode 100644 index 0000000..3f6db3e --- /dev/null +++ b/tests/unit/test_event_schema_s1.py @@ -0,0 +1,199 @@ +"""Unit tests for PRD-2 S1: Event + EventKind YAML schema. + +Red→green: these tests fail on the old EquipmentEvent/EquipmentEventKind schema +and pass only after the rename + 8-FK exclusive-arc change. + +Validates: +- Event table exists (EquipmentEvent is gone) +- EventKind table exists (EquipmentEventKind is gone) +- Event has all 8 exclusive-arc FK columns (all nullable) +- Event carries a CHECK constraint CK_Event_ExclusiveArc +- EventKind seed vocab contains all PRD-required kinds +- Schema validator reports no errors (FK refs resolved) +- Generated DDL contains the Event CREATE TABLE and CHECK constraint +""" + +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).parent.parent.parent +TABLES_DIR = REPO_ROOT / "schema_dictionary" / "tables" +VIEWS_DIR = REPO_ROOT / "schema_dictionary" / "views" +VERSION_YAML = REPO_ROOT / "schema_dictionary" / "version.yaml" + +# 8 FK columns in the exclusive arc (per ADR-0006 and PRD-2) +ARC_COLUMNS = [ + "Channel_ID", + "Equipment_ID", + "SignalInterface_ID", + "DataAcquisitionSystem_ID", + "SamplingPoint_ID", + "ProcessUnit_ID", + "Site_ID", + "Campaign_ID", +] + +# EventKind vocab required by the PRD (subset assertion — users can add more) +REQUIRED_EVENT_KINDS = { + "Calibration", + "Cleaning", + "Repair", + "Validation", + "PowerOutage", + "ControllerCrash", + "OperationalChange", +} + + +def _load_schema() -> dict: + from tools.schema_migrate.loader import load_schema + + return load_schema(TABLES_DIR) + + +# --------------------------------------------------------------------------- +# Test 1: EquipmentEvent is gone; Event exists +# --------------------------------------------------------------------------- + + +def test_event_table_exists_equipment_event_gone() -> None: + """Event table must exist; EquipmentEvent must NOT exist after the rename.""" + schema = _load_schema() + assert "Event" in schema, "Event table not found in schema — rename not applied" + assert "EquipmentEvent" not in schema, ( + "EquipmentEvent still exists — old table was not removed" + ) + + +# --------------------------------------------------------------------------- +# Test 2: EventKind is gone → EventKind exists +# --------------------------------------------------------------------------- + + +def test_event_kind_table_exists_equipment_event_kind_gone() -> None: + """EventKind must exist; EquipmentEventKind must NOT exist after the rename.""" + schema = _load_schema() + assert "EventKind" in schema, "EventKind table not found — rename not applied" + assert "EquipmentEventKind" not in schema, ( + "EquipmentEventKind still exists — old table was not removed" + ) + + +# --------------------------------------------------------------------------- +# Test 3: Event has all 8 arc FK columns, all nullable +# --------------------------------------------------------------------------- + + +def test_event_has_all_eight_arc_columns() -> None: + """Event must carry all 8 exclusive-arc FK columns, all nullable.""" + schema = _load_schema() + tbl = schema["Event"]["table"] + col_map = {c["name"]: c for c in tbl["columns"]} + + missing = [col for col in ARC_COLUMNS if col not in col_map] + assert missing == [], f"Arc columns missing from Event: {missing}" + + non_nullable = [col for col in ARC_COLUMNS if not col_map[col].get("nullable", True)] + assert non_nullable == [], ( + f"Arc columns must be nullable (exactly-one enforced by CHECK, not NOT NULL): " + f"{non_nullable}" + ) + + +# --------------------------------------------------------------------------- +# Test 4: Event has the CK_Event_ExclusiveArc CHECK constraint +# --------------------------------------------------------------------------- + + +def test_event_has_exclusive_arc_check_constraint() -> None: + """Event must declare a check_constraint named CK_Event_ExclusiveArc.""" + schema = _load_schema() + tbl = schema["Event"]["table"] + check_constraints = tbl.get("check_constraints", []) or [] + names = [c.get("name") for c in check_constraints] + assert "CK_Event_ExclusiveArc" in names, ( + f"CK_Event_ExclusiveArc not found in Event.check_constraints; got: {names}" + ) + + # The expression must reference all 8 arc columns + expr_blob = " ".join( + c.get("expression", "") for c in check_constraints if c.get("name") == "CK_Event_ExclusiveArc" + ) + for col in ARC_COLUMNS: + assert col in expr_blob, ( + f"Arc column '{col}' not referenced in CK_Event_ExclusiveArc expression" + ) + + +# --------------------------------------------------------------------------- +# Test 5: EventKind seed vocab contains all PRD-required kinds +# --------------------------------------------------------------------------- + + +def test_event_kind_seed_contains_required_vocab() -> None: + """EventKind.seed_data must include all vocabulary entries required by PRD-2.""" + schema = _load_schema() + tbl = schema["EventKind"]["table"] + seed = tbl.get("seed_data", []) or [] + seeded_names = {row.get("Name") for row in seed} + + missing = REQUIRED_EVENT_KINDS - seeded_names + assert missing == set(), ( + f"EventKind seed is missing PRD-required kinds: {missing}" + ) + + +# --------------------------------------------------------------------------- +# Test 6: Schema validator clears (FK refs all resolved) +# --------------------------------------------------------------------------- + + +def test_schema_validates_cleanly_after_rename() -> None: + """validate_schema must return no errors — Annotation FK repoint must hold.""" + from tools.schema_migrate.loader import load_schema + from tools.schema_migrate.validate import validate_schema + + schema = load_schema(TABLES_DIR) + errs = validate_schema(schema) + assert errs == [], "Schema has validation errors after PRD-2 S1 rename:\n" + "\n".join(errs) + + +# --------------------------------------------------------------------------- +# Test 7: Generated DDL contains Event CREATE TABLE and CHECK constraint +# --------------------------------------------------------------------------- + + +def test_generated_ddl_contains_event_and_check(tmp_path: Path) -> None: + """Generated MSSQL DDL must contain CREATE TABLE [dbo].[Event] and the CHECK constraint.""" + import sys + import yaml + + sys.path.insert(0, str(REPO_ROOT / "scripts")) + from generate_from_yaml import generate_all_from_yaml + + with VERSION_YAML.open(encoding="utf-8") as fh: + version = str(yaml.safe_load(fh).get("schema_version", "0.1.0")) + + sql_dir = tmp_path / "sql" + generate_all_from_yaml( + tables_dir=TABLES_DIR, + views_dir=VIEWS_DIR, + docs_dir=tmp_path / "docs", + assets_dir=tmp_path / "assets", + sql_dir=sql_dir, + platform="mssql", + version=version, + ) + + sql = (sql_dir / f"v{version}_create_mssql.sql").read_text(encoding="utf-8") + + assert "CREATE TABLE [dbo].[Event]" in sql, "Event table missing from generated DDL" + assert "CK_Event_ExclusiveArc" in sql, "CHECK constraint missing from generated DDL" + assert "CREATE TABLE [dbo].[EquipmentEvent]" not in sql, ( + "EquipmentEvent still present in generated DDL — rename incomplete" + ) + + seed_sql = (sql_dir / f"v{version}_seed_mssql.sql").read_text(encoding="utf-8") + assert "INSERT INTO [dbo].[EventKind]" in seed_sql, "EventKind seed missing from generated seed SQL" + assert "EquipmentEventKind" not in seed_sql, "EquipmentEventKind still present in seed SQL" diff --git a/tests/unit/test_explore_campaign_scope.py b/tests/unit/test_explore_campaign_scope.py new file mode 100644 index 0000000..68a9434 --- /dev/null +++ b/tests/unit/test_explore_campaign_scope.py @@ -0,0 +1,37 @@ +"""Unit test for the Explore page-level campaign scoping helper. + +_scope_to_campaign restricts the pickable sensor traces + lab series to a single +campaign, so the plot and export can only contain that campaign's streams. +""" +from __future__ import annotations + +from app.pages import explore + + +_TRACES = [ + {"channel_id": 5, "campaign_id": 1, "parameter_name": "TSS"}, + {"channel_id": 6, "campaign_id": 2, "parameter_name": "COD"}, + {"channel_id": 7, "campaign_id": None, "parameter_name": "pH"}, # orphaned +] +_SERIES = [ + {"analysis_series_id": 10, "campaign_id": 1}, + {"analysis_series_id": 11, "campaign_id": 2}, +] + + +def test_none_returns_everything_unchanged(): + dts, sers = explore._scope_to_campaign(_TRACES, _SERIES, None) + assert dts == _TRACES + assert sers == _SERIES + + +def test_filters_both_lists_to_campaign(): + dts, sers = explore._scope_to_campaign(_TRACES, _SERIES, 1) + assert [d["channel_id"] for d in dts] == [5] + assert [s["analysis_series_id"] for s in sers] == [10] + + +def test_campaign_with_no_streams_yields_empty(): + dts, sers = explore._scope_to_campaign(_TRACES, _SERIES, 99) + assert dts == [] + assert sers == [] diff --git a/tests/unit/test_explore_csv_export.py b/tests/unit/test_explore_csv_export.py deleted file mode 100644 index 359b014..0000000 --- a/tests/unit/test_explore_csv_export.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Unit tests for CSV export helpers covering unit/parameter columns and timestamp conversion. - -Pure functions — no Streamlit runtime. Tests: - _flat_scalar_rows — includes parameter + unit columns from API response - _flat_series_scalar_rows — includes parameter + unit columns from API response -""" - -from __future__ import annotations - -from unittest.mock import patch - -from app.pages import explore - - -_CHANNEL_TS = { - "channel_id": 5, - "parameter": "TSS", - "unit": "mg/L", - "data_shape": "Scalar", - "from_timestamp": "2026-06-19T04:00:00", - "to_timestamp": "2026-06-19T08:00:00", - "row_count": 2, - "data": [ - {"timestamp": "2026-06-19T04:00:00", "value": 10.0, "quality_code": 1}, - {"timestamp": "2026-06-19T08:00:00", "value": 12.0, "quality_code": 1}, - ], -} - -_SERIES_TS = { - "analysis_series_id": 1, - "name": "TSS@Eff", - "parameter": "TSS", - "unit": "mg/L", - "sampling_point": "Effluent", - "data_shape": "Scalar", - "from_timestamp": "2026-05-01T00:00:00", - "to_timestamp": "2026-05-08T00:00:00", - "row_count": 2, - "data": [ - {"timestamp": "2026-05-01T00:00:00", "value": 11.0, "quality_code": 1}, - {"timestamp": "2026-05-08T00:00:00", "value": 13.0, "quality_code": 1}, - ], -} - -_CHANNEL_META = {5: {"equipment_identifier": "EQ5", "parameter_name": "TSS"}} - -_SERIES_META = {1: {"parameter_name": "TSS", "sampling_point_label": "Effluent"}} - - -class TestFlatScalarRows: - def test_includes_unit_and_parameter_columns(self): - with patch.object(explore, "_load_timeseries", return_value=_CHANNEL_TS): - rows = explore._flat_scalar_rows([5], _CHANNEL_META) - - assert len(rows) == 2 - for row in rows: - assert "parameter" in row, f"missing 'parameter' column, got: {list(row.keys())}" - assert "unit" in row, f"missing 'unit' column, got: {list(row.keys())}" - assert row["parameter"] == "TSS" - assert row["unit"] == "mg/L" - - def test_includes_channel_id_and_label(self): - with patch.object(explore, "_load_timeseries", return_value=_CHANNEL_TS): - rows = explore._flat_scalar_rows([5], _CHANNEL_META) - - row = rows[0] - assert row["channel_id"] == 5 - assert "EQ5" in row["channel_label"] - assert "TSS" in row["channel_label"] - - def test_timestamps_present(self): - with patch.object(explore, "_load_timeseries", return_value=_CHANNEL_TS): - rows = explore._flat_scalar_rows([5], _CHANNEL_META) - - assert rows[0]["timestamp"] == "2026-06-19T04:00:00" - assert rows[1]["timestamp"] == "2026-06-19T08:00:00" - - def test_no_data_returns_empty(self): - empty_ts = {**_CHANNEL_TS, "data": []} - with patch.object(explore, "_load_timeseries", return_value=empty_ts): - rows = explore._flat_scalar_rows([5], _CHANNEL_META) - assert rows == [] - - def test_missing_unit_param_columns_fall_back_to_empty(self): - no_meta_ts = {**_CHANNEL_TS} - no_meta_ts.pop("parameter", None) - no_meta_ts.pop("unit", None) - with patch.object(explore, "_load_timeseries", return_value=no_meta_ts): - rows = explore._flat_scalar_rows([5], _CHANNEL_META) - assert rows[0]["parameter"] == "" - assert rows[0]["unit"] == "" - - -class TestFlatSeriesScalarRows: - def test_includes_unit_and_parameter_columns(self): - with patch.object(explore, "_load_series_timeseries", return_value=_SERIES_TS): - rows = explore._flat_series_scalar_rows([1], _SERIES_META) - - assert len(rows) == 2 - for row in rows: - assert "parameter" in row, f"missing 'parameter' column, got: {list(row.keys())}" - assert "unit" in row, f"missing 'unit' column, got: {list(row.keys())}" - assert row["parameter"] == "TSS" - assert row["unit"] == "mg/L" - - def test_lab_prefixed_channel_id(self): - with patch.object(explore, "_load_series_timeseries", return_value=_SERIES_TS): - rows = explore._flat_series_scalar_rows([1], _SERIES_META) - - assert rows[0]["channel_id"] == "LAB-1" - - def test_no_data_returns_empty(self): - empty_ts = {**_SERIES_TS, "data": []} - with patch.object(explore, "_load_series_timeseries", return_value=empty_ts): - rows = explore._flat_series_scalar_rows([1], _SERIES_META) - assert rows == [] diff --git a/tests/unit/test_explore_echarts.py b/tests/unit/test_explore_echarts.py new file mode 100644 index 0000000..cbb8dc4 --- /dev/null +++ b/tests/unit/test_explore_echarts.py @@ -0,0 +1,140 @@ +"""Unit tests for the ECharts scalar builder + brush-selection resolver. + +These cover the decision-bearing logic of the Explore scalar rewrite. The +``st_echarts`` component round-trip itself needs a browser and is verified +separately; everything here is pure. +""" + +from __future__ import annotations + +from unittest.mock import patch + +from app.components import explore_echarts as ee + +_MOD = "app.components.explore_echarts" + +_CH_META = { + 5: {"value_kind_id": 1, "equipment_identifier": "EQ5", "parameter_name": "TSS", + "unit_name": "mg/L", "equipment_id": 5}, +} +_S_META = { + 1: {"analysis_series_id": 1, "name": "TSS@Eff", "parameter_name": "TSS", + "sampling_point_label": "Effluent", "unit_name": "mg/L", "value_kind_id": 1}, +} +_CH_TS = {"data": [ + {"timestamp": "2026-05-02T00:00:00", "value": 10.0, "quality_code": 1, "observation_id": 901}, + {"timestamp": "2026-05-03T00:00:00", "value": 12.0, "quality_code": 2, "observation_id": 902}, +]} +_S_TS = {"data": [ + {"timestamp": "2026-05-01T00:00:00", "value": 11.0, "quality_code": 1, "observation_id": 701}, + {"timestamp": "2026-05-08T00:00:00", "value": 13.0, "quality_code": 1, "observation_id": 702}, +]} + + +def _build(**overrides): + loaders = { + "_load_timeseries": _CH_TS, + "_load_series_timeseries": _S_TS, + "_load_annotations": [], + "_load_series_annotations": [], + "_load_equipment_events": [], + } + loaders.update(overrides) + with ( + patch(f"{_MOD}._load_timeseries", return_value=loaders["_load_timeseries"]), + patch(f"{_MOD}._load_series_timeseries", return_value=loaders["_load_series_timeseries"]), + patch(f"{_MOD}._load_annotations", return_value=loaders["_load_annotations"]), + patch(f"{_MOD}._load_series_annotations", return_value=loaders["_load_series_annotations"]), + patch(f"{_MOD}._load_equipment_events", return_value=loaders["_load_equipment_events"]), + ): + return ee.build_scalar_echarts_option( + [5], _CH_META, "extract", [1], _S_META + ) + + +def test_option_has_line_and_scatter_with_zoom_and_brush(): + option, smap, _ = _build() + types = [s["type"] for s in option["series"]] + # Sensor = decorative line (no symbols) + brushable marker scatter; lab = scatter. + # ECharts line series aren't brush-selectable, so the identity lives on scatter. + assert types == ["line", "scatter", "scatter"], types + assert option["series"][0]["showSymbol"] is False # line is decorative only + # dataZoom slider + inside, and a brush config (the UX wins) + assert {z["type"] for z in option["dataZoom"]} == {"inside", "slider"} + assert "brush" in option and option["xAxis"]["type"] == "time" + # series_index_map aligns 1:1 with series order; the decor line is skipped, + # sensor identity rides on the (brushable) marker scatter at index 1. + assert smap[0]["kind"] == "decor" + assert smap[1]["kind"] == "sensor" and smap[1]["id"] == 5 + assert smap[2]["kind"] == "lab" and smap[2]["id"] == 1 + assert [p["obs_id"] for p in smap[1]["points"]] == [901, 902] + assert [p["obs_id"] for p in smap[2]["points"]] == [701, 702] + + +def test_yaxis_labelled_with_parameter_and_unit_from_data(): + # Sensor meta (DeploymentTraceLookupItem) has no unit_name; the unit must come + # from the loaded time-series payload so the axis reads "parameter (unit)". + meta_no_unit = {5: {"value_kind_id": 1, "equipment_identifier": "EQ5", + "parameter_name": "TSS", "equipment_id": 5}} + ch_ts = {"parameter": "TSS", "unit": "mg/L", "data": _CH_TS["data"]} + with ( + patch(f"{_MOD}._load_timeseries", return_value=ch_ts), + patch(f"{_MOD}._load_series_timeseries", return_value={"data": []}), + patch(f"{_MOD}._load_annotations", return_value=[]), + patch(f"{_MOD}._load_series_annotations", return_value=[]), + patch(f"{_MOD}._load_equipment_events", return_value=[]), + ): + option, _, _ = ee.build_scalar_echarts_option([5], meta_no_unit, "extract", [], {}) + assert option["yAxis"]["name"] == "TSS (mg/L)" + # rendered as a proper centered axis title, not the tiny default + assert option["yAxis"]["nameLocation"] == "middle" + + +def test_click_handler_returns_brush_shape(): + # Click handler must emit the same {seriesIndex, dataIndex[]} shape the + # resolver consumes, so one resolver handles click + brush. + assert "seriesIndex" in ee.CLICK_SELECTED_JS + assert "dataIndex" in ee.CLICK_SELECTED_JS + + +def test_quality_code_drives_per_point_color(): + option, _, _ = _build() + line = option["series"][0] + # first point qc=1 (accepted/green), second qc=2 (suspect/orange) + assert line["data"][0]["itemStyle"]["color"] == ee.QUALITY_COLORS[1] + assert line["data"][1]["itemStyle"]["color"] == ee.QUALITY_COLORS[2] + + +def test_annotation_and_event_overlays_become_markareas_and_rows(): + ann = [{"start_time": "2026-05-02T00:00:00", "end_time": "2026-05-02T06:00:00", + "type": {"name": "Fault", "color": "#DC2626"}, "title": "Lamp", "comment": "x"}] + ev = [{"start_datetime": "2026-05-03T00:00:00", "end_datetime": None, + "event_type_name": "Calibration", "notes": "annual"}] + option, _, rows = _build(_load_annotations=ann, _load_equipment_events=ev) + line = option["series"][0] + # both overlays decorate the sensor series + assert len(line["markArea"]["data"]) == 2 + assert {r["kind"] for r in rows} == {"Annotation", "Equipment Event"} + assert any(r["category"] == "Fault" for r in rows) + assert any(r["category"] == "Calibration" for r in rows) + + +def test_resolve_brush_selection_maps_indices_to_observations(): + _, smap, _ = _build() + payload = [ + {"seriesIndex": 0, "dataIndex": [0, 1]}, # decor line — must be ignored + {"seriesIndex": 1, "dataIndex": [1]}, # sensor 2nd point -> obs 902 + {"seriesIndex": 2, "dataIndex": [0, 1]}, # both lab points + ] + sel = ee.resolve_brush_selection(payload, smap) + assert [p["obs_id"] for p in sel["sensor_pts"]] == [902] + assert sel["sensor_pts"][0]["id"] == 5 and sel["sensor_pts"][0]["y"] == 12.0 + assert [p["obs_id"] for p in sel["lab_pts"]] == [701, 702] + + +def test_resolve_brush_selection_handles_none_and_bad_indices(): + _, smap, _ = _build() + assert ee.resolve_brush_selection(None, smap) == {"sensor_pts": [], "lab_pts": []} + # out-of-range seriesIndex / dataIndex are ignored, not raised + bad = [{"seriesIndex": 99, "dataIndex": [0]}, {"seriesIndex": 0, "dataIndex": [50]}] + assert ee.resolve_brush_selection(bad, smap) == {"sensor_pts": [], "lab_pts": []} diff --git a/tests/unit/test_explore_export.py b/tests/unit/test_explore_export.py new file mode 100644 index 0000000..5a81741 --- /dev/null +++ b/tests/unit/test_explore_export.py @@ -0,0 +1,225 @@ +"""Unit tests for the pure zip-export builder (app.components.explore_export). + +Covers the non-trivial rules: annotation/event overlay matching (range + point), +UTC timestamp column, per-stream CSV+YAML pairing, image embedding, and basename +collision handling. +""" + +from __future__ import annotations + +import csv +import io +import zipfile + +import yaml + +from app.components import explore_export as ex + + +def _read_zip(blob: bytes) -> dict[str, bytes]: + with zipfile.ZipFile(io.BytesIO(blob)) as zf: + return {n: zf.read(n) for n in zf.namelist()} + + +def _rows(csv_bytes: bytes) -> list[dict]: + return list(csv.DictReader(io.StringIO(csv_bytes.decode()))) + + +# --- overlay matching ------------------------------------------------------- + +class TestOverlay: + def test_range_annotation_tags_covered_rows_only(self): + cols_in = ex._overlay_columns( + "2026-06-19T06:00:00", + [{"kind": "Outlier", "note": "fouling", + "start": "2026-06-19T05:00:00", "end": "2026-06-19T07:00:00"}], + [], + ) + cols_out = ex._overlay_columns( + "2026-06-19T08:00:00", + [{"kind": "Outlier", "note": "fouling", + "start": "2026-06-19T05:00:00", "end": "2026-06-19T07:00:00"}], + [], + ) + assert cols_in["annotation_kind"] == "Outlier" + assert cols_in["annotation_note"] == "fouling" + assert cols_out["annotation_kind"] == "" + + def test_point_annotation_matches_exact_timestamp(self): + ann = [{"kind": "Spike", "note": None, + "start": "2026-06-19T06:00:00", "end": None}] + assert ex._overlay_columns("2026-06-19T06:00:00", ann, [])["annotation_kind"] == "Spike" + assert ex._overlay_columns("2026-06-19T06:00:01", ann, [])["annotation_kind"] == "" + + def test_timezone_offsets_compared_correctly(self): + # 06:00Z == 02:00-04:00; both must fall in a 05:00Z–07:00Z range. + ann = [{"kind": "X", "note": None, + "start": "2026-06-19T05:00:00Z", "end": "2026-06-19T07:00:00Z"}] + assert ex._overlay_columns("2026-06-19T02:00:00-04:00", ann, [])["annotation_kind"] == "X" + + def test_multiple_overlaps_semicolon_joined(self): + anns = [ + {"kind": "A", "note": "n1", "start": "2026-06-19T05:00:00", "end": "2026-06-19T07:00:00"}, + {"kind": "B", "note": "n2", "start": "2026-06-19T05:30:00", "end": "2026-06-19T06:30:00"}, + ] + cols = ex._overlay_columns("2026-06-19T06:00:00", anns, []) + assert cols["annotation_kind"] == "A; B" + assert cols["annotation_note"] == "n1; n2" + + +def test_normalizers_map_raw_api_shapes(): + # AnnotationResponse: kind nested under type.name. + a = ex.overlay_from_annotation( + {"type": {"name": "Mask"}, "title": "Fault", "comment": "masked", + "start_time": "2026-06-01T00:00:00", "end_time": None} + ) + assert a == {"kind": "Mask", "note": "Fault — masked", + "start": "2026-06-01T00:00:00", "end": None} + # Equipment lifecycle event: kind under event_type_name, text under notes. + e = ex.overlay_from_event( + {"event_type_name": "Calibration", "notes": "zero check", + "start_datetime": "2026-06-19T08:00:00", "end_datetime": "2026-06-19T09:00:00"} + ) + assert e["kind"] == "Calibration" + assert e["note"] == "zero check" + assert e["start"] == "2026-06-19T08:00:00" + + +# --- full zip --------------------------------------------------------------- + +def test_scalar_zip_has_paired_csv_and_yaml_with_overlay(): + entry = { + "filename": "CH-5 TSS", # space -> sanitized + "value_kind": 1, + "data": { + "parameter": "TSS", "unit": "mg/L", + "data": [ + {"timestamp": "2026-06-19T04:00:00", "value": 10.0, "quality_code": 1}, + {"timestamp": "2026-06-19T06:00:00", "value": 11.0, "quality_code": 2}, + ], + }, + "annotations": [{"kind": "Outlier", "note": "fouling", + "start": "2026-06-19T06:00:00", "end": None}], + "events": [{"kind": "Calib", "note": "zero", + "start": "2026-06-19T04:00:00", "end": "2026-06-19T04:30:00"}], + "pedigree": {"stream_id": 5, "site": {"name": "pilEAU"}}, + } + files = _read_zip(ex.build_export_zip([entry])) + + assert "CH-5_TSS.csv" in files + assert "CH-5_TSS.yaml" in files + + rows = _rows(files["CH-5_TSS.csv"]) + assert rows[0]["timestamp_utc"] == "2026-06-19T04:00:00" + assert rows[0]["value"] == "10.0" + assert rows[0]["event_kind"] == "Calib" # covered by event range + assert rows[0]["annotation_kind"] == "" + assert rows[1]["annotation_kind"] == "Outlier" # point match at 06:00 + assert rows[1]["event_kind"] == "" + + ped = yaml.safe_load(files["CH-5_TSS.yaml"]) + assert ped["site"]["name"] == "pilEAU" + + +def test_quality_code_rendered_as_label_not_id(): + entry = { + "filename": "CH-5_TSS", "value_kind": 1, + "data": {"parameter": "TSS", "unit": "mg/L", "data": [ + {"timestamp": "2026-06-19T04:00:00", "value": 10.0, "quality_code": 1}, + {"timestamp": "2026-06-19T06:00:00", "value": 11.0, "quality_code": 2}, + ]}, + "annotations": [], "events": [], "pedigree": {}, + } + files = _read_zip(ex.build_export_zip([entry], {1: "Accepted", 2: "Suspect"})) + rows = _rows(files["CH-5_TSS.csv"]) + assert rows[0]["quality_code"] == "Accepted" + assert rows[1]["quality_code"] == "Suspect" + + +def test_unknown_quality_code_falls_back_to_raw_value(): + entry = { + "filename": "CH-5_TSS", "value_kind": 1, + "data": {"parameter": "TSS", "unit": "mg/L", "data": [ + {"timestamp": "2026-06-19T04:00:00", "value": 10.0, "quality_code": 9}, + ]}, + "annotations": [], "events": [], "pedigree": {}, + } + row = _rows(_read_zip(ex.build_export_zip([entry], {1: "Accepted"}))["CH-5_TSS.csv"])[0] + assert row["quality_code"] == "9" + + +def test_per_row_location_and_campaign_follow_deployment_timeline(): + """The spicy case: rows before/after an equipment move carry the location and + campaign that were active at their timestamp (resolved from the timeline).""" + entry = { + "filename": "CH-5_TSS", + "value_kind": 1, + "data": { + "parameter": "TSS", "unit": "mg/L", + "data": [ + {"timestamp": "2026-02-15T00:00:00", "value": 9.8, "quality_code": 1}, + {"timestamp": "2026-05-15T00:00:00", "value": 7.1, "quality_code": 1}, + ], + }, + "annotations": [], "events": [], + "pedigree": { + "stream_id": 5, + "deployments": [ + {"valid_from": "2026-01-01T00:00:00", "valid_to": "2026-04-01T00:00:00", + "sampling_location": {"name": "Inlet"}, + "campaign": {"name": "Winter 2026"}}, + {"valid_from": "2026-04-01T00:00:00", "valid_to": None, + "sampling_location": {"name": "Effluent"}, + "campaign": {"name": "Spring 2026"}}, + ], + }, + } + rows = _rows(_read_zip(ex.build_export_zip([entry]))["CH-5_TSS.csv"]) + assert rows[0]["sampling_location"] == "Inlet" + assert rows[0]["campaign"] == "Winter 2026" + assert rows[1]["sampling_location"] == "Effluent" # after the move + assert rows[1]["campaign"] == "Spring 2026" + + +def test_lab_fixed_segment_applies_to_all_rows(): + entry = { + "filename": "LAB-1_TSS", + "value_kind": 1, + "data": {"parameter": "TSS", "unit": "mg/L", + "data": [{"timestamp": "2026-05-15T00:00:00", "value": 7.1}]}, + "annotations": [], "events": [], + "pedigree": {"deployments": [ + {"valid_from": None, "valid_to": None, + "sampling_location": {"name": "Effluent"}, + "campaign": {"name": "Routine"}}]}, + } + row = _rows(_read_zip(ex.build_export_zip([entry]))["LAB-1_TSS.csv"])[0] + assert row["sampling_location"] == "Effluent" + assert row["campaign"] == "Routine" + + +def test_image_stream_embeds_files_and_references_them(): + entry = { + "filename": "CH-7_cam", + "value_kind": 4, + "data": {"parameter": "Image", "unit": "", + "data": [{"timestamp": "2026-06-19T04:00:00", "width": 640}]}, + "annotations": [], "events": [], + "pedigree": {"stream_id": 7}, + "images": {"2026-06-19T04:00:00": b"\xff\xd8\xff jpegbytes"}, + } + files = _read_zip(ex.build_export_zip([entry])) + img_path = "images/CH-7_cam/2026-06-19T04_00_00.jpg" + assert img_path in files + assert files[img_path] == b"\xff\xd8\xff jpegbytes" + row = _rows(files["CH-7_cam.csv"])[0] + assert row["image_file"] == img_path + + +def test_duplicate_basenames_do_not_collide(): + e = {"filename": "dup", "value_kind": 1, + "data": {"parameter": "p", "unit": "u", "data": []}, + "annotations": [], "events": [], "pedigree": {}} + files = _read_zip(ex.build_export_zip([dict(e), dict(e)])) + assert "dup.csv" in files + assert "dup_2.csv" in files diff --git a/tests/unit/test_form_coverage.py b/tests/unit/test_form_coverage.py index 596bdf9..d5b706a 100644 --- a/tests/unit/test_form_coverage.py +++ b/tests/unit/test_form_coverage.py @@ -32,6 +32,7 @@ from api.v1.schemas.channel import ChannelIn, ParameterIn, UnitIn from api.v1.schemas.control_loop import ControlLoopCreateRequest from api.v1.schemas.equipment import EquipmentIn, EquipmentModelIn +from api.v1.schemas.events import EventKindIn from api.v1.schemas.ingestion import LabPanelCreateRequest from api.v1.schemas.metadata import ( CampaignKindIn, @@ -53,6 +54,7 @@ ENTITY_SCHEMA = { "campaign_kind": CampaignKindIn, "equipment_event_kind": EquipmentEventKindIn, + "event_kind": EventKindIn, "process_unit_kind": ProcessUnitKindIn, "procedure_kind": ProcedureKindIn, "sample_kind": SampleKindIn, diff --git a/tests/unit/test_interval_integrity.py b/tests/unit/test_interval_integrity.py new file mode 100644 index 0000000..2cda96d --- /dev/null +++ b/tests/unit/test_interval_integrity.py @@ -0,0 +1,72 @@ +"""Interval integrity guard (consistency audit F7). + +The *History swap helpers reject a backdated ValidFrom that would overlap or +invert existing rows. These run without a DB (MagicMock cursor): we pin the +guard's SQL shape and that a conflict short-circuits before any write. +""" + +from __future__ import annotations + +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest + +from api.v1.repositories import temporal_history_repository as thr + +NOW = datetime(2026, 6, 29) + + +def test_guard_rejects_timestamp_inside_existing_interval(): + cursor = MagicMock() + # an existing closed interval straddling NOW + cursor.fetchone.return_value = (datetime(2025, 1, 1), datetime(2027, 1, 1)) + with pytest.raises(ValueError, match="overlap or invert"): + thr._assert_valid_from_ok( + cursor, "EquipmentWiringHistory", "Equipment_ID", 1, NOW + ) + + +def test_guard_allows_when_no_conflict(): + cursor = MagicMock() + cursor.fetchone.return_value = None + thr._assert_valid_from_ok( + cursor, "EquipmentLocationHistory", "Equipment_ID", 1, NOW + ) # must not raise + + +def test_guard_sql_shape_and_params(): + cursor = MagicMock() + cursor.fetchone.return_value = None + thr._assert_valid_from_ok( + cursor, "DASLocationHistory", "DataAcquisitionSystem_ID", 7, NOW + ) + sql = cursor.execute.call_args.args[0] + assert "[DataAcquisitionSystem_ID] = ?" in sql + assert "[ValidTo] IS NULL AND [ValidFrom] >= ?" in sql + assert "[ValidFrom] <= ? AND ? < [ValidTo]" in sql + assert cursor.execute.call_args.args[1:] == (7, NOW, NOW, NOW) + + +def test_relocate_rejects_backdated_overlap_before_any_write(): + """F8 passes (different SP) → F7 guard finds a conflict → no close/insert/commit.""" + with patch.object( + thr, "get_active_location_for_equipment", return_value={"sampling_point_id": 99} + ): + conn = MagicMock() + cursor = MagicMock() + cursor.fetchone.return_value = (datetime(2025, 1, 1), datetime(2027, 1, 1)) + conn.cursor.return_value = cursor + + with pytest.raises(ValueError, match="overlap or invert"): + thr.relocate_equipment( + conn, + equipment_id=1, + new_sampling_point_id=5, + start_time=NOW, + campaign_id=2, + ) + + # guard ran exactly one SELECT; no UPDATE/INSERT, no commit + assert cursor.execute.call_count == 1 + conn.commit.assert_not_called() diff --git a/tests/unit/test_lab_profile_s3.py b/tests/unit/test_lab_profile_s3.py new file mode 100644 index 0000000..eda3400 --- /dev/null +++ b/tests/unit/test_lab_profile_s3.py @@ -0,0 +1,110 @@ +"""Unit tests for PRD-3 S3 — Lab profile entity resolution and payload shape. + +Red→green tests that verify: +1. Parameter column headers tagged as parameter_value resolve to (parameter, unit) pairs. +2. Rows with unresolved entities surface errors instead of silently dropping. +3. A resolved row produces the correct /ingest/lab payload structure. +""" + +from __future__ import annotations + +import pandas as pd +import pytest + +from app.components.resolver import EntityResolver +from app.pages.mapper import _parse_param_header, _resolve_all + +_UNITS = [{"unit_id": 1, "name": "milligram per litre", "symbol": "mg/L"}] +_PARAMS = [ + {"parameter_id": 10, "name": "Chemical Oxygen Demand", "short_name": "COD"}, + {"parameter_id": 11, "name": "Total Suspended Solids", "short_name": "TSS"}, +] +_SPS = [{"sampling_point_id": 100, "name": "R240"}] + + +def test_lab_profile_resolves_parameter_columns(): + """Column headers tagged as parameter_value resolve to (parameter, unit) pairs.""" + r = EntityResolver(units=_UNITS, parameters=_PARAMS, sampling_points=_SPS) + # Simulate a column header like "COD (mg/L)" + header = "COD (mg/L)" + # naive split on space+paren + parts = header.split(" (") + param_text = parts[0] + unit_text = parts[1].rstrip(")") if len(parts) > 1 else "" + param = r.resolve_parameter(param_text) + unit = r.resolve_unit(unit_text) + assert param is not None + assert unit is not None + + +def test_lab_profile_unresolved_row_not_submitted(): + """Rows with unresolved entities produce errors, not silent drops.""" + r = EntityResolver(units=_UNITS, parameters=_PARAMS, sampling_points=_SPS) + sp = r.resolve_sampling_point("UNKNOWN_LOCATION") + assert sp is None # unresolved → must surface error, not submit + + +def test_wide_lab_row_builds_payload(): + """A resolved row produces the correct ingest payload structure.""" + row = { + "sample_datetime": "2024-01-15T10:00:00", + "sampling_point_id": 100, + "replicate": 1, + "values": [{"parameter_id": 10, "unit_id": 1, "value": 42.5}], + } + # Basic shape check — not a full API call + assert row["sampling_point_id"] == 100 + assert row["values"][0]["parameter_id"] == 10 + + +def test_parse_param_header_with_unit(): + """_parse_param_header correctly splits 'COD (mg/L)' into param and unit text.""" + param_text, unit_text = _parse_param_header("COD (mg/L)") + assert param_text == "COD" + assert unit_text == "mg/L" + + +def test_parse_param_header_no_unit(): + """_parse_param_header returns empty unit string when header has no parens.""" + param_text, unit_text = _parse_param_header("COD") + assert param_text == "COD" + assert unit_text == "" + + +def test_resolve_all_marks_unresolved_rows_as_not_ok(): + """_resolve_all marks rows with unknown sampling location as not ok.""" + r = EntityResolver(units=_UNITS, parameters=_PARAMS, sampling_points=_SPS) + role_map = { + "sample_datetime": "sample_datetime", + "location": "sampling_location", + "COD (mg/L)": "parameter_value", + } + df = pd.DataFrame([ + {"sample_datetime": "2024-01-15T10:00:00", "location": "UNKNOWN", "COD (mg/L)": 42.5} + ]) + result = _resolve_all(r, role_map, df) + assert result["n_unresolved"] == 1 + assert result["n_resolved"] == 0 + assert not result["row_resolutions"][0]["ok"] + + +def test_resolve_all_resolved_row_is_ok(): + """_resolve_all marks a fully resolved row as ok.""" + r = EntityResolver(units=_UNITS, parameters=_PARAMS, sampling_points=_SPS) + role_map = { + "sample_datetime": "sample_datetime", + "location": "sampling_location", + "COD (mg/L)": "parameter_value", + } + df = pd.DataFrame([ + {"sample_datetime": "2024-01-15T10:00:00", "location": "R240", "COD (mg/L)": 42.5} + ]) + result = _resolve_all(r, role_map, df) + assert result["n_resolved"] == 1 + assert result["n_unresolved"] == 0 + rr = result["row_resolutions"][0] + assert rr["ok"] is True + assert len(rr["values"]) == 1 + assert rr["values"][0]["parameter_id"] == 10 + assert rr["values"][0]["unit_id"] == 1 + assert rr["values"][0]["value"] == 42.5 diff --git a/tests/unit/test_logbook_profile_s1.py b/tests/unit/test_logbook_profile_s1.py new file mode 100644 index 0000000..8bfa77a --- /dev/null +++ b/tests/unit/test_logbook_profile_s1.py @@ -0,0 +1,110 @@ +"""Unit tests for PRD-4 S1 — Général-sheet logbook profile → Event. + +Acceptance (red→green): a power-outage row lands as a Site-targeted Event, a +pump row as an Equipment-targeted Event — resolved to the *smallest* logical +unit named in the comment, never auto-guessed into the wrong target. +""" + +from __future__ import annotations + +import pandas as pd + +from app.components.resolver import EntityResolver +from app.pages.mapper import ( + PROFILES, + LOGBOOK_ROLES, + _build_event_payloads, + _resolve_logbook, +) + +# Lookup pools in their production key shapes. +_EQUIPMENT = [{"equipment_id": 5, "identifier": "P-100"}] +_SITES = [{"site_id": 2, "name": "Pouliot"}] +_PROCESS_UNITS = [{"id": 9, "name": "Primary Clarifier", "tag": "PC-1"}] +_PERSONS = [{"person_id": 3, "label": "Marie Tremblay"}] + +_LOGBOOK_ROLE_MAP = { + "Date": "event_date", + "Heure": "event_time", + "Commentaires": "notes", + "Conductor": "conductor", +} + + +def _resolver() -> EntityResolver: + return EntityResolver( + units=[], + parameters=[], + sampling_points=[], + equipment=_EQUIPMENT, + sites=_SITES, + process_units=_PROCESS_UNITS, + persons=_PERSONS, + ) + + +def test_logbook_profile_registered(): + assert "Logbook (Général)" in PROFILES + assert PROFILES["Logbook (Général)"] == LOGBOOK_ROLES + assert {"event_date", "notes", "conductor"} <= set(LOGBOOK_ROLES) + + +def test_target_resolution_picks_smallest_unit(): + """A pump mention resolves to Equipment even when a Site is also present.""" + r = _resolver() + # Equipment is the smallest level, so it wins over Site in the same text. + eq = r.resolve_target("Cleaned pump P-100 at Pouliot") + assert eq["arc_field"] == "equipment_id" + assert eq["entity_id"] == 5 + # No equipment named → falls through to Site. + site = r.resolve_target("Building power outage at Pouliot") + assert site["arc_field"] == "site_id" + assert site["entity_id"] == 2 + # Nothing recognizable → no auto-guess. + assert r.resolve_target("misc note with no entity") is None + + +def test_resolve_logbook_pump_and_outage_rows(): + """The acceptance: pump row → Equipment Event, outage row → Site Event.""" + df = pd.DataFrame([ + {"Date": "2023-05-01", "Heure": "08:30", "Commentaires": "Cleaned pump P-100", "Conductor": "Marie Tremblay"}, + {"Date": "2023-05-02", "Heure": "14:00", "Commentaires": "Building power outage at Pouliot", "Conductor": "Unknown"}, + ]) + result = _resolve_logbook(_resolver(), _LOGBOOK_ROLE_MAP, df) + assert result["n_resolved"] == 2 + + rows = result["row_resolutions"] + assert rows[0]["target"]["arc_field"] == "equipment_id" + assert rows[0]["person"]["person_id"] == 3 # conductor matched + assert rows[1]["target"]["arc_field"] == "site_id" + assert rows[1]["person"] is None # unmatched conductor left unset, row still ok + + payloads = _build_event_payloads(rows, event_kind_id=7) + assert len(payloads) == 2 + # Pump → Equipment-targeted EventIn (exactly one arc FK + the kind + person). + p0 = payloads[0] + assert p0["equipment_id"] == 5 + assert p0["event_kind_id"] == 7 + assert p0["performed_by_person_id"] == 3 + assert "site_id" not in p0 + # Outage → Site-targeted, no person. + p1 = payloads[1] + assert p1["site_id"] == 2 + assert "equipment_id" not in p1 + assert "performed_by_person_id" not in p1 + # Date + time combined into the start datetime. + assert p0["start_datetime"].startswith("2023-05-01T08:30") + + +def test_resolve_logbook_unresolved_target_is_flagged(): + """A row whose comment names no known entity is flagged, not submitted.""" + df = pd.DataFrame([ + {"Date": "2023-05-03", "Heure": "09:00", "Commentaires": "general note", "Conductor": ""}, + ]) + result = _resolve_logbook(_resolver(), _LOGBOOK_ROLE_MAP, df) + assert result["n_resolved"] == 0 + assert result["n_unresolved"] == 1 + rr = result["row_resolutions"][0] + assert rr["ok"] is False + assert rr["target"] is None + assert _build_event_payloads(result["row_resolutions"], event_kind_id=1) == [] diff --git a/tests/unit/test_logbook_target_confirm_s2.py b/tests/unit/test_logbook_target_confirm_s2.py new file mode 100644 index 0000000..d0811bb --- /dev/null +++ b/tests/unit/test_logbook_target_confirm_s2.py @@ -0,0 +1,78 @@ +"""Unit tests for PRD-4 S2 — target-resolution confirm UX (deterministic core). + +Acceptance: ambiguous entries are never auto-guessed into the wrong target — +the user confirms. These cover the pure logic behind the confirm UI: the level +heuristic (a *hint* only) and the override application that turns a confirmed +pick into a submittable row. +""" + +from __future__ import annotations + +from app.components.resolver import TARGET_LEVELS, guess_target_level +from app.pages.mapper import ( + _apply_target_override, + _make_target, + _override_targets, +) + + +def test_guess_target_level_is_a_hint_not_a_resolution(): + # Equipment-style tag → Equipment. + assert guess_target_level("replaced P-205 seal") == "Equipment" + assert guess_target_level("LDO-241 drift") == "Equipment" + # Keyword → Site / ProcessUnit. + assert guess_target_level("building power outage") == "Site" + assert guess_target_level("PLC rebooted") == "ProcessUnit" + # No signal → no guess (so the UI defaults to nothing auto-applied). + assert guess_target_level("misc note") is None + assert guess_target_level("") is None + + +def _row(idx: int, *, ok: bool, has_date: bool = True) -> dict: + return { + "row_index": idx, + "start_datetime": object() if has_date else None, + "notes": "n", + "title": "t", + "person": None, + "person_text": "", + "target": None, + "errors": [] if ok else ["no target resolved from comment — pick one before submit"], + "ok": ok, + } + + +def test_apply_override_makes_row_submittable_and_clears_flag(): + rr = _row(0, ok=False) + target = _make_target("Equipment", {"id": 5, "label": "P-205"}) + out = _apply_target_override(rr, target) + assert out["ok"] is True + assert out["target"]["arc_field"] == "equipment_id" + assert out["target"]["entity_id"] == 5 + # The stale "no target resolved" flag is dropped once confirmed. + assert not any("no target resolved" in e for e in out["errors"]) + # Original row is untouched (pure function). + assert rr["ok"] is False + + +def test_override_without_date_stays_unsubmittable(): + rr = _row(1, ok=False, has_date=False) + out = _apply_target_override(rr, _make_target("Site", {"id": 2, "label": "Pouliot"})) + assert out["ok"] is False # no date → still not submittable + + +def test_override_targets_recomputes_counts(): + rows = [_row(0, ok=False), _row(1, ok=True), _row(2, ok=False)] + rows[1]["target"] = _make_target("Site", {"id": 2, "label": "Pouliot"}) + overrides = {0: _make_target("Equipment", {"id": 5, "label": "P-205"})} + out, n_resolved, n_unresolved = _override_targets(rows, overrides) + assert n_resolved == 2 # row 0 (overridden) + row 1 (already ok) + assert n_unresolved == 1 # row 2 still unconfirmed + assert out[0]["target"]["entity_id"] == 5 + + +def test_make_target_uses_the_arc_field_for_the_level(): + for level, arc in TARGET_LEVELS.items(): + t = _make_target(level, {"id": 1, "label": "x"}) + assert t["arc_field"] == arc + assert t["level"] == level diff --git a/tests/unit/test_long_lab_profile_s6.py b/tests/unit/test_long_lab_profile_s6.py new file mode 100644 index 0000000..c384905 --- /dev/null +++ b/tests/unit/test_long_lab_profile_s6.py @@ -0,0 +1,102 @@ +"""Unit tests for PRD-3 S6 — Long-format (tidy) lab profile. + +A long-format lab sheet has one measurement per row, with parameter and unit +in *cells* (not the column header). It must resolve into the SAME shape as the +wide-lab engine (``_resolve_all``) so the existing preview + ``/ingest/lab`` +submit pipeline is reused unchanged. +""" + +from __future__ import annotations + +import pandas as pd + +from app.components.resolver import EntityResolver +from app.pages.mapper import ( + PROFILES, + LONG_LAB_ROLES, + _resolve_all, + _resolve_long, +) + +_UNITS = [{"unit_id": 1, "name": "milligram per litre", "symbol": "mg/L"}] +_PARAMS = [ + {"parameter_id": 10, "name": "Chemical Oxygen Demand", "short_name": "COD"}, +] +_SPS = [{"sampling_point_id": 100, "name": "R240"}] + +_LONG_ROLE_MAP = { + "when": "sample_datetime", + "where": "sampling_location", + "param_col": "parameter", + "unit_col": "unit", + "val_col": "value", +} + + +def _resolver() -> EntityResolver: + return EntityResolver(units=_UNITS, parameters=_PARAMS, sampling_points=_SPS) + + +def test_long_profile_registered(): + """The Lab (long) profile is selectable and exposes its role vocabulary.""" + assert "Lab (long)" in PROFILES + assert PROFILES["Lab (long)"] == LONG_LAB_ROLES + assert {"sample_datetime", "sampling_location", "parameter", "unit", "value"} <= set( + LONG_LAB_ROLES + ) + + +def test_resolve_long_returns_wide_compatible_shape(): + """A resolved long row matches the wide engine's row/col shape exactly.""" + df = pd.DataFrame([ + { + "when": "2024-01-15T10:00:00", + "where": "R240", + "param_col": "COD", + "unit_col": "mg/L", + "val_col": 42.5, + } + ]) + result = _resolve_long(_resolver(), _LONG_ROLE_MAP, df) + + # Same top-level keys as _resolve_all (so the downstream pipeline is shared). + assert set(result.keys()) == set( + _resolve_all(_resolver(), {}, df.iloc[0:0]).keys() + ) + assert result["n_resolved"] == 1 + assert result["n_unresolved"] == 0 + + rr = result["row_resolutions"][0] + assert rr["ok"] is True + assert rr["datetime"] is not None + assert rr["sampling_point"]["sampling_point_id"] == 100 + assert rr["replicate"] == 1 + # One synthesized value entry, in the wide value shape (incl. its "col" key). + assert len(rr["values"]) == 1 + v = rr["values"][0] + assert v["parameter_id"] == 10 + assert v["unit_id"] == 1 + assert v["value"] == 42.5 + # The value's col key is registered in col_resolutions (used by _do_submit). + assert v["col"] in result["col_resolutions"] + assert result["col_resolutions"][v["col"]]["resolved"] is True + + +def test_resolve_long_unknown_unit_not_ok(): + """A row with an unresolvable unit surfaces an error, not a silent drop.""" + df = pd.DataFrame([ + { + "when": "2024-01-15T10:00:00", + "where": "R240", + "param_col": "COD", + "unit_col": "furlongs", + "val_col": 42.5, + } + ]) + result = _resolve_long(_resolver(), _LONG_ROLE_MAP, df) + assert result["n_resolved"] == 0 + assert result["n_unresolved"] == 1 + rr = result["row_resolutions"][0] + assert rr["ok"] is False + assert rr["values"] == [] + assert any("furlongs" in e for e in rr["errors"]) diff --git a/tests/unit/test_maintenance_chart_s2.py b/tests/unit/test_maintenance_chart_s2.py new file mode 100644 index 0000000..b5d9cc8 --- /dev/null +++ b/tests/unit/test_maintenance_chart_s2.py @@ -0,0 +1,151 @@ +"""Unit tests for PRD-2.5 S2 — maintenance control chart page. + +Red→green: these tests verify the page can be imported and that +the pure helper functions work correctly without a running API. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pandas as pd +import pytest + +# --------------------------------------------------------------------------- +# Module loader helpers +# --------------------------------------------------------------------------- + +_PAGE_PATH = ( + Path(__file__).resolve().parent.parent.parent + / "app" + / "pages" + / "maintenance_control_chart.py" +) + + +def _load_page_module(): + """Load maintenance_control_chart.py without running main() or hitting the API.""" + import unittest.mock as mock + + fake_st = mock.MagicMock() + # _in_streamlit_run() → False so main() is not called at import time + fake_st.runtime.scriptrunner.get_script_run_ctx.return_value = None + + with mock.patch.dict(sys.modules, {"streamlit": fake_st, "streamlit_echarts": mock.MagicMock()}): + spec = importlib.util.spec_from_file_location("mcc_s2", str(_PAGE_PATH)) + assert spec is not None + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) # type: ignore[union-attr] + + return mod + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_page_file_exists(): + """Fails immediately if the page file is missing.""" + assert _PAGE_PATH.exists(), f"maintenance_control_chart.py not found at {_PAGE_PATH}" + + +def test_page_importable(): + """Page module must import without errors when streamlit is mocked.""" + mod = _load_page_module() + assert mod is not None + + +def test_build_dataframe_empty(): + """_build_dataframe returns an empty DataFrame with correct columns for no rows.""" + mod = _load_page_module() + df = mod._build_dataframe([]) + assert list(df.columns) == ["timestamp", "value", "quality_code_id"] + assert len(df) == 0 + + +def test_build_dataframe_basic(): + """_build_dataframe converts rows to a sorted DataFrame with correct dtypes.""" + mod = _load_page_module() + rows = [ + {"timestamp": "2024-01-02T00:00:00Z", "value": 3.14, "quality_code_id": None}, + {"timestamp": "2024-01-01T00:00:00Z", "value": 2.71, "quality_code_id": 1}, + ] + df = mod._build_dataframe(rows) + assert len(df) == 2 + # Sorted by timestamp + assert df.iloc[0]["value"] == pytest.approx(2.71) + assert df.iloc[1]["value"] == pytest.approx(3.14) + # quality_code_id preserved + assert df.iloc[0]["quality_code_id"] == 1 + + +def test_build_chart_option_structure(): + """_build_chart_option returns a dict with required ECharts keys.""" + mod = _load_page_module() + + drift_rows = [ + {"timestamp": "2024-01-01T00:00:00Z", "value": 1.0, "quality_code_id": None}, + {"timestamp": "2024-01-02T00:00:00Z", "value": 10.0, "quality_code_id": None}, # out-of-limit + {"timestamp": "2024-01-03T00:00:00Z", "value": 2.0, "quality_code_id": 99}, # flagged + ] + drift_df = mod._build_dataframe(drift_rows) + source_df = pd.DataFrame(columns=["timestamp", "value", "quality_code_id"]) + + option = mod._build_chart_option(drift_df, source_df, upper_limit=5.0, lower_limit=-5.0, quality_codes={}) + + assert "series" in option + assert "xAxis" in option + assert "yAxis" in option + assert "dataZoom" in option + + series_names = [s["name"] for s in option["series"]] + assert "Drift" in series_names + assert "Out of limit" in series_names + assert "Quality-flagged" in series_names + + +def test_out_of_limit_points_separated(): + """Points outside [lower, upper] must end up in the 'Out of limit' series.""" + mod = _load_page_module() + + rows = [ + {"timestamp": "2024-01-01T00:00:00Z", "value": 0.0, "quality_code_id": None}, # in-limit + {"timestamp": "2024-01-02T00:00:00Z", "value": 8.0, "quality_code_id": None}, # above UCL=5 + {"timestamp": "2024-01-03T00:00:00Z", "value": -8.0, "quality_code_id": None}, # below LCL=-5 + ] + df = mod._build_dataframe(rows) + source_df = pd.DataFrame(columns=["timestamp", "value", "quality_code_id"]) + option = mod._build_chart_option(df, source_df, upper_limit=5.0, lower_limit=-5.0, quality_codes={}) + + ool_series = next(s for s in option["series"] if s["name"] == "Out of limit") + assert len(ool_series["data"]) == 2 + + normal_series = next(s for s in option["series"] if s["name"] == "Drift") + assert len(normal_series["data"]) == 1 + + +def test_source_series_included_when_provided(): + """A non-empty source_df adds a 'Raw source' series to the chart.""" + mod = _load_page_module() + + drift_rows = [{"timestamp": "2024-01-01T00:00:00Z", "value": 1.0, "quality_code_id": None}] + source_rows = [{"timestamp": "2024-01-01T00:00:00Z", "value": 100.0, "quality_code_id": None}] + drift_df = mod._build_dataframe(drift_rows) + source_df = mod._build_dataframe(source_rows) + + option = mod._build_chart_option(drift_df, source_df, upper_limit=5.0, lower_limit=-5.0, quality_codes={}) + series_names = [s["name"] for s in option["series"]] + assert "Raw source" in series_names + + +def test_navigation_includes_maintenance_chart(): + """Home.py navigation must reference maintenance_control_chart.py.""" + home_path = Path(__file__).resolve().parent.parent.parent / "app" / "Home.py" + content = home_path.read_text() + assert "maintenance_control_chart.py" in content, ( + "Home.py does not reference maintenance_control_chart.py — page not wired into navigation" + ) diff --git a/tests/unit/test_maintenance_drift_readback_s4.py b/tests/unit/test_maintenance_drift_readback_s4.py new file mode 100644 index 0000000..b4b4a83 --- /dev/null +++ b/tests/unit/test_maintenance_drift_readback_s4.py @@ -0,0 +1,65 @@ +"""Unit tests for PRD-4 S4 — drift read-back derivation (before/after + %diff). + +Acceptance: the drift Channel shows before/after derived from the *source* +stream around the event window. These cover the pure derivation: last reading +before the window is "before" (fouled), first after is "after" (clean), with a +guarded percent-drift. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from api.v1.repositories.maintenance_drift_repository import derive_before_after + + +def _dt(day: int, hour: int = 0) -> datetime: + return datetime(2023, 6, day, hour, tzinfo=timezone.utc) + + +def _row(day: int, value, hour: int = 0) -> dict: + return {"timestamp": _dt(day, hour), "value": value} + + +def test_before_is_last_pre_window_after_is_first_post_window(): + rows = [ + _row(1, 10.0), # before (older) + _row(4, 12.0), # before (nearest before start) → picked + _row(5, 99.0), # inside window (start=5, end=6) → ignored + _row(7, 6.0), # after (nearest after end) → picked + _row(9, 5.0), # after (later) + ] + result = derive_before_after(rows, window_start=_dt(5), window_end=_dt(6)) + assert result["before"]["value"] == 12.0 + assert result["after"]["value"] == 6.0 + # Drift: (6 - 12) / 12 * 100 = -50% + assert result["percent_diff"] == -50.0 + + +def test_instantaneous_event_uses_start_for_both_sides(): + rows = [_row(4, 8.0), _row(5, 4.0)] # window is instantaneous at day 5 + result = derive_before_after(rows, window_start=_dt(5), window_end=None) + assert result["before"]["value"] == 8.0 + assert result["after"]["value"] == 4.0 + assert result["percent_diff"] == -50.0 + + +def test_missing_side_yields_none_and_no_percent(): + rows = [_row(4, 8.0)] # nothing after the window + result = derive_before_after(rows, window_start=_dt(5), window_end=_dt(6)) + assert result["before"]["value"] == 8.0 + assert result["after"] is None + assert result["percent_diff"] is None + + +def test_zero_baseline_guards_percent(): + rows = [_row(4, 0.0), _row(7, 3.0)] + result = derive_before_after(rows, window_start=_dt(5), window_end=_dt(6)) + assert result["before"]["value"] == 0.0 + assert result["after"]["value"] == 3.0 + assert result["percent_diff"] is None # no divide-by-zero + + +def test_empty_rows_are_all_none(): + result = derive_before_after([], window_start=_dt(5), window_end=_dt(6)) + assert result == {"before": None, "after": None, "percent_diff": None} diff --git a/tests/unit/test_maintenance_drift_s1.py b/tests/unit/test_maintenance_drift_s1.py new file mode 100644 index 0000000..3cfde76 --- /dev/null +++ b/tests/unit/test_maintenance_drift_s1.py @@ -0,0 +1,103 @@ +"""Unit tests for PRD-2.5 S1 — maintenance-drift schema (no DB required). + +Red→green: these tests verify the Pydantic models for MaintenanceDriftIn / +MaintenanceDriftOut are correct. They fail if the models don't exist or if +the validation logic is missing. +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from api.v1.schemas.maintenance_drift import MaintenanceDriftIn, MaintenanceDriftOut + + +# --------------------------------------------------------------------------- +# MaintenanceDriftIn — valid construction +# --------------------------------------------------------------------------- + + +def test_maintenance_drift_in_valid_minimal() -> None: + """Minimal valid payload: source + at least one event.""" + obj = MaintenanceDriftIn( + source_channel_id=1, + event_ids=[10, 20], + name="NH4 drift 2024-Q1", + ) + assert obj.source_channel_id == 1 + assert obj.event_ids == [10, 20] + assert obj.name == "NH4 drift 2024-Q1" + assert obj.performed_by_person_id is None + + +def test_maintenance_drift_in_with_person() -> None: + """Optional person ID accepted.""" + obj = MaintenanceDriftIn( + source_channel_id=5, + event_ids=[1], + name="drift-ch5", + performed_by_person_id=99, + ) + assert obj.performed_by_person_id == 99 + + +# --------------------------------------------------------------------------- +# MaintenanceDriftIn — validation errors +# --------------------------------------------------------------------------- + + +def test_maintenance_drift_in_rejects_zero_source_channel() -> None: + """source_channel_id = 0 must be rejected.""" + with pytest.raises(ValidationError) as exc_info: + MaintenanceDriftIn(source_channel_id=0, event_ids=[1], name="x") + assert "source_channel_id" in str(exc_info.value) + + +def test_maintenance_drift_in_rejects_negative_source_channel() -> None: + """Negative source_channel_id must be rejected.""" + with pytest.raises(ValidationError) as exc_info: + MaintenanceDriftIn(source_channel_id=-3, event_ids=[1], name="x") + assert "source_channel_id" in str(exc_info.value) + + +def test_maintenance_drift_in_rejects_empty_event_ids() -> None: + """Empty event_ids list must be rejected.""" + with pytest.raises(ValidationError) as exc_info: + MaintenanceDriftIn(source_channel_id=1, event_ids=[], name="x") + assert "event_ids" in str(exc_info.value) + + +def test_maintenance_drift_in_rejects_missing_name() -> None: + """Missing name field must be rejected.""" + with pytest.raises(ValidationError): + MaintenanceDriftIn(source_channel_id=1, event_ids=[1]) # type: ignore[call-arg] + + +# --------------------------------------------------------------------------- +# MaintenanceDriftOut — field presence +# --------------------------------------------------------------------------- + + +def test_maintenance_drift_out_has_expected_fields() -> None: + """MaintenanceDriftOut must expose channel_id, name, produced_by_step_id.""" + obj = MaintenanceDriftOut( + channel_id=42, + name="NH4 drift channel", + produced_by_step_id=7, + ) + assert obj.channel_id == 42 + assert obj.name == "NH4 drift channel" + assert obj.produced_by_step_id == 7 + + +def test_maintenance_drift_out_rejects_missing_channel_id() -> None: + """channel_id is required.""" + with pytest.raises(ValidationError): + MaintenanceDriftOut(name="x", produced_by_step_id=1) # type: ignore[call-arg] + + +def test_maintenance_drift_out_rejects_missing_produced_by_step_id() -> None: + """produced_by_step_id is required.""" + with pytest.raises(ValidationError): + MaintenanceDriftOut(channel_id=1, name="x") # type: ignore[call-arg] diff --git a/tests/unit/test_maintenance_profile_s3.py b/tests/unit/test_maintenance_profile_s3.py new file mode 100644 index 0000000..9fee610 --- /dev/null +++ b/tests/unit/test_maintenance_profile_s3.py @@ -0,0 +1,81 @@ +"""Unit tests for PRD-4 S3 — per-equipment maintenance-sheet profile. + +Acceptance: a `Solitax R240` row imports as a maintenance Event on the right +target. One spanning Event per row; the target is the *whole sheet's* +equipment/channel (not per-row); before/after readings are not stored. +""" + +from __future__ import annotations + +import pandas as pd + +from app.components.resolver import EntityResolver +from app.pages.mapper import ( + PROFILES, + MAINTENANCE_ROLES, + _build_maintenance_payloads, + _default_kind_index, + _make_target, + _resolve_maintenance, +) + +_MAINT_ROLE_MAP = { + "Date": "event_date", + "Start": "start_time", + "End": "end_time", + "Comment": "notes", +} + + +def test_maintenance_profile_registered(): + assert "Logbook (Maintenance)" in PROFILES + assert PROFILES["Logbook (Maintenance)"] == MAINTENANCE_ROLES + assert {"event_date", "start_time", "end_time", "notes"} <= set(MAINTENANCE_ROLES) + + +def test_sheet_name_resolves_to_equipment_target(): + """'Solitax R240' sheet → its equipment, the single target for every row.""" + resolver = EntityResolver( + units=[], parameters=[], sampling_points=[], + equipment=[{"equipment_id": 12, "identifier": "Solitax R240"}], + ) + tgt = resolver.resolve_target("Solitax R240") + assert tgt["arc_field"] == "equipment_id" + assert tgt["entity_id"] == 12 + + +def test_resolve_maintenance_spans_and_targets_the_sheet(): + sheet_target = _make_target("Equipment", {"id": 12, "label": "Solitax R240"}) + df = pd.DataFrame([ + {"Date": "2023-06-01", "Start": "09:00", "End": "09:30", "Comment": "zero check ok"}, + {"Date": "2023-06-15", "Start": "10:00", "End": "10:20", "Comment": ""}, + ]) + result = _resolve_maintenance(_MAINT_ROLE_MAP, df, sheet_target) + assert result["n_resolved"] == 2 + + payloads = _build_maintenance_payloads(result["row_resolutions"], event_kind_id=4) + assert len(payloads) == 2 + p = payloads[0] + assert p["equipment_id"] == 12 # every row targets the sheet equipment + assert p["event_kind_id"] == 4 + assert p["is_instantaneous"] is False # spanning maintenance Event + assert p["start_datetime"].startswith("2023-06-01T09:00") + assert p["end_datetime"].startswith("2023-06-01T09:30") + assert p["notes"] == "zero check ok" # manual zero-check kept as notes + # No before/after value fields leak into the payload. + assert "value" not in p and "before" not in p and "after" not in p + + +def test_resolve_maintenance_flags_unparseable_date(): + sheet_target = _make_target("Equipment", {"id": 12, "label": "Solitax R240"}) + df = pd.DataFrame([{"Date": "", "Start": "", "End": "", "Comment": "no date"}]) + result = _resolve_maintenance(_MAINT_ROLE_MAP, df, sheet_target) + assert result["n_resolved"] == 0 + assert result["row_resolutions"][0]["ok"] is False + assert _build_maintenance_payloads(result["row_resolutions"], event_kind_id=1) == [] + + +def test_default_kind_index_prefers_maintenance(): + kinds = [{"name": "general"}, {"name": "Probe maintenance"}, {"name": "calibration"}] + assert _default_kind_index(kinds) == 1 + assert _default_kind_index([{"name": "general"}]) == 0 # falls back to 0 diff --git a/tests/unit/test_mapper_config_s4.py b/tests/unit/test_mapper_config_s4.py new file mode 100644 index 0000000..40b5aab --- /dev/null +++ b/tests/unit/test_mapper_config_s4.py @@ -0,0 +1,163 @@ +"""Tests for PRD-3 S4 — save/reload named mapping config. + +Verifies: +- Config dict serializes/deserializes correctly (pure logic, no Streamlit). +- Round-trip save → load produces identical role_map. +- _build_config produces the expected PRD-5-compatible shape. +- _list_configs returns saved files. +""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from unittest import mock + +import pytest + +# --------------------------------------------------------------------------- +# Module-level load (once per process) — avoids the "numpy loaded twice" error +# when multiple tests each call _load_mapper_module() independently. +# --------------------------------------------------------------------------- + +_MAPPER_PATH = Path(__file__).resolve().parent.parent.parent / "app" / "pages" / "mapper.py" + +_fake_st = mock.MagicMock() +_fake_st.file_uploader.return_value = None + +with mock.patch.dict(sys.modules, {"streamlit": _fake_st}): + _spec = importlib.util.spec_from_file_location("mapper_s4_cfg", str(_MAPPER_PATH)) + assert _spec is not None + _mapper_mod = importlib.util.module_from_spec(_spec) + assert _spec.loader is not None + _spec.loader.exec_module(_mapper_mod) # type: ignore[union-attr] + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_build_config_shape(): + """_build_config returns a dict with the PRD-5-compatible keys.""" + role_map = {"sample_datetime": "sample_datetime", "COD (mg/L)": "parameter_value"} + cfg = _mapper_mod._build_config( + name="Test Config", + header_row=0, + data_start_row=1, + role_map=role_map, + sheet_name="Sheet1", + ) + assert cfg["name"] == "Test Config" + assert cfg["version"] == 1 + assert cfg["header_row"] == 0 + assert cfg["data_start_row"] == 1 + assert cfg["role_map"] == role_map + assert cfg["sheet_name"] == "Sheet1" + + +def test_build_config_no_sheet(): + """_build_config omits sheet_name when None (CSV case).""" + cfg = _mapper_mod._build_config( + name="CSV Config", + header_row=0, + data_start_row=1, + role_map={"dt": "sample_datetime"}, + sheet_name=None, + ) + assert "sheet_name" not in cfg + + +def test_config_json_roundtrip(): + """Config dict survives JSON serialization/deserialization without loss.""" + role_map = { + "sample_datetime": "sample_datetime", + "Location": "sampling_location", + "Replicate": "replicate", + "COD (mg/L)": "parameter_value", + "TSS (mg/L)": "parameter_value", + } + cfg = _mapper_mod._build_config( + name="Lab Sheet v1", + header_row=0, + data_start_row=1, + role_map=role_map, + sheet_name="Data", + ) + + serialized = json.dumps(cfg) + deserialized = json.loads(serialized) + + assert deserialized["name"] == cfg["name"] + assert deserialized["version"] == cfg["version"] + assert deserialized["header_row"] == cfg["header_row"] + assert deserialized["data_start_row"] == cfg["data_start_row"] + assert deserialized["role_map"] == cfg["role_map"] + assert deserialized["sheet_name"] == cfg["sheet_name"] + + +def test_save_and_load_roundtrip(tmp_path, monkeypatch): + """Round-trip save → load produces identical role_map.""" + monkeypatch.setattr(_mapper_mod, "_config_dir", lambda: tmp_path) + + role_map = { + "sample_datetime": "sample_datetime", + "Loc": "sampling_location", + "COD (mg/L)": "parameter_value", + } + cfg = _mapper_mod._build_config( + name="Roundtrip Test", + header_row=2, + data_start_row=3, + role_map=role_map, + ) + + saved_path = _mapper_mod._save_config(cfg) + assert saved_path.exists() + + loaded = _mapper_mod._load_config(saved_path) + + assert loaded["role_map"] == role_map + assert loaded["header_row"] == 2 + assert loaded["data_start_row"] == 3 + assert loaded["name"] == "Roundtrip Test" + assert loaded["version"] == 1 + + +def test_list_configs(tmp_path, monkeypatch): + """_list_configs returns all saved JSON files.""" + monkeypatch.setattr(_mapper_mod, "_config_dir", lambda: tmp_path) + + # Initially empty + assert _mapper_mod._list_configs() == [] + + # Save two configs + cfg_a = _mapper_mod._build_config("Alpha", 0, 1, {"dt": "sample_datetime"}) + cfg_b = _mapper_mod._build_config("Beta", 0, 1, {"loc": "sampling_location"}) + _mapper_mod._save_config(cfg_a) + _mapper_mod._save_config(cfg_b) + + configs = _mapper_mod._list_configs() + assert len(configs) == 2 + stems = {p.stem for p in configs} + assert "Alpha" in stems + assert "Beta" in stems + + +def test_save_config_sanitizes_name(tmp_path, monkeypatch): + """_save_config creates a valid filename even for names with special chars.""" + monkeypatch.setattr(_mapper_mod, "_config_dir", lambda: tmp_path) + + cfg = _mapper_mod._build_config( + name="My Config: 2024/01 (v2)", + header_row=0, + data_start_row=1, + role_map={}, + ) + saved_path = _mapper_mod._save_config(cfg) + assert saved_path.exists() + # Name should be a valid filename (no slashes or colons) + assert "/" not in saved_path.name + assert ":" not in saved_path.name diff --git a/tests/unit/test_mapper_shell_s1.py b/tests/unit/test_mapper_shell_s1.py new file mode 100644 index 0000000..5e1c427 --- /dev/null +++ b/tests/unit/test_mapper_shell_s1.py @@ -0,0 +1,102 @@ +"""Tests for PRD-3 S1 — mapper engine shell. + +Goals: +- Fail immediately if app/pages/mapper.py is missing. +- Verify the LAB_ROLES constant is defined and contains required role names. +- Verify _read_dataframe helper parses a minimal CSV correctly. + +We intentionally avoid AppTest here because the page calls mapper_page() at +module level (Streamlit pattern) and AppTest would require a running Streamlit +context; the module-level side-effects make import-time execution unreliable +without a harness. The import + constant tests are sufficient for S1. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +import io + +import pandas as pd +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_MAPPER_PATH = Path(__file__).resolve().parent.parent.parent / "app" / "pages" / "mapper.py" + + +def _load_mapper_module(): + """Load mapper.py without executing the top-level mapper_page() call. + + We monkey-patch streamlit so the module-level `mapper_page()` call is a + no-op during import. + """ + import unittest.mock as mock + + # Provide a fake streamlit module so the import doesn't fail or open a UI. + fake_st = mock.MagicMock() + # make file_uploader return None (no upload) so the page exits early + fake_st.file_uploader.return_value = None + + with mock.patch.dict(sys.modules, {"streamlit": fake_st}): + spec = importlib.util.spec_from_file_location("mapper_s1", str(_MAPPER_PATH)) + assert spec is not None + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(mod) # type: ignore[union-attr] + + return mod + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_mapper_file_exists(): + """Fails if app/pages/mapper.py is missing.""" + assert _MAPPER_PATH.exists(), f"mapper.py not found at {_MAPPER_PATH}" + + +def test_lab_roles_defined(): + """LAB_ROLES constant must exist and contain required role names.""" + mod = _load_mapper_module() + roles = mod.LAB_ROLES + assert isinstance(roles, list), "LAB_ROLES must be a list" + assert "(ignore)" in roles + assert "sample_datetime" in roles + assert "sampling_location" in roles + assert "replicate" in roles + assert "parameter_value" in roles + + +def test_read_dataframe_csv(): + """_read_dataframe parses a minimal CSV correctly.""" + mod = _load_mapper_module() + csv_bytes = b"col_a,col_b\n1,x\n2,y\n" + df = mod._read_dataframe(csv_bytes, "test.csv", header_row=0) + assert list(df.columns) == ["col_a", "col_b"] + assert len(df) == 2 + + +@pytest.mark.skipif( + not importlib.util.find_spec("openpyxl"), + reason="openpyxl not installed — skip XLSX parsing test", +) +def test_read_dataframe_xlsx(): + """_read_dataframe parses a minimal XLSX correctly (requires openpyxl).""" + mod = _load_mapper_module() + + # Build a minimal xlsx in memory + buf = io.BytesIO() + pd.DataFrame({"alpha": [1, 2], "beta": ["x", "y"]}).to_excel(buf, index=False) + xlsx_bytes = buf.getvalue() + + df = mod._read_dataframe(xlsx_bytes, "test.xlsx", sheet_name=0, header_row=0) + assert "alpha" in df.columns + assert "beta" in df.columns + assert len(df) == 2 diff --git a/tests/unit/test_onboarding_panel_s1.py b/tests/unit/test_onboarding_panel_s1.py new file mode 100644 index 0000000..24672a0 --- /dev/null +++ b/tests/unit/test_onboarding_panel_s1.py @@ -0,0 +1,101 @@ +from unittest.mock import patch +from streamlit.testing.v1 import AppTest + +APP = "app/Home.py" + +_FAKE_USER = {"full_name": "Test User", "user_id": 1} + + +def _run( + mock_sps, + *, + data_type: str = "Both", + sites=None, + persons=None, + channels=None, + analysis_series=None, + das=None, + signal_interfaces=None, + laboratories=None, +): + ch_resp = {"items": channels or [], "total": len(channels or [])} + with patch("app.api_client.list_sampling_points_lookup", return_value=mock_sps), \ + patch("app.api_client.list_sites_lookup", return_value=sites or []), \ + patch("app.api_client.list_persons_lookup", return_value=persons or []), \ + patch("app.api_client.list_channels", return_value=ch_resp), \ + patch("app.api_client.list_analysis_series_lookup", return_value=analysis_series or []), \ + patch("app.api_client.list_das_lookup", return_value=das or []), \ + patch("app.api_client.list_signal_interfaces_lookup", return_value=signal_interfaces or []), \ + patch("app.api_client.list_laboratories_lookup", return_value=laboratories or []), \ + patch("app.api_client.get_health", return_value={"api_version": "test", "db": "ok"}), \ + patch("app.auth.get_current_user", return_value=_FAKE_USER): + at = AppTest.from_file(APP) + at.session_state["onboarding_data_type"] = data_type + at.run() + return at + + +def _collect_text(at) -> str: + """Join all visible text (markdown, info, success, warning, error, subheader, title).""" + parts = [] + for el in at.get("markdown") + at.get("text") + at.get("info"): + parts.append(el.value) + return " ".join(parts) + + +def test_panel_shown_when_no_sampling_points(): + at = _run([]) + text = _collect_text(at) + assert "Get started" in text or "sampling location" in text.lower() + + +def test_panel_shown_when_sps_exist_but_no_streams(): + """S2: sampling points alone no longer hide the panel; streams are required.""" + at = _run([{"sampling_point_id": 1, "name": "SP-1"}]) + text = _collect_text(at) + assert "Get started" in text + + +def test_panel_hidden_when_stream_exists(): + """Auto-hides when at least one ingestable Stream (Channel) exists.""" + at = _run([], channels=[{"stream_id": 1}]) + text = _collect_text(at) + assert "Get started" not in text + + +def test_panel_hidden_when_analysis_series_exists(): + """Auto-hides when at least one AnalysisSeries exists.""" + at = _run([], analysis_series=[{"analysis_series_id": 1}]) + text = _collect_text(at) + assert "Get started" not in text + + +def test_ticks_appear_for_completed_steps(): + """Steps show ✓ when the list is non-empty.""" + at = _run([], sites=[{"site_id": 1}]) + text = _collect_text(at) + assert "✓" in text + assert "Get started" in text + + +def test_lab_only_hides_sensor_steps(): + """Lab selection: no DAS/SignalInterface/Channel steps.""" + at = _run([], data_type="Lab") + text = _collect_text(at) + assert "field_system" not in text.lower() and "signal interface" not in text.lower() + + +def test_sensor_only_hides_lab_steps(): + """Sensor selection: no Laboratory steps.""" + at = _run([], data_type="Sensor") + text = _collect_text(at) + assert "laborator" not in text.lower() + + +def test_panel_dismissed_hides_panel(): + """Dismiss flag in session_state hides the panel for the rest of the session.""" + at = _run([]) + at.session_state["onboarding_dismissed"] = True + at.run() + text = _collect_text(at) + assert "Get started" not in text diff --git a/tests/unit/test_resolver_s2.py b/tests/unit/test_resolver_s2.py new file mode 100644 index 0000000..5a1463d --- /dev/null +++ b/tests/unit/test_resolver_s2.py @@ -0,0 +1,67 @@ +"""Tests for PRD-3 S2: EntityResolver — text → DB ID with fuzzy matching.""" +from app.components.resolver import EntityResolver + +_UNITS = [ + {"unit_id": 1, "name": "milligram per litre", "symbol": "mg/L"}, + {"unit_id": 2, "name": "gram per litre", "symbol": "g/L"}, +] +_PARAMS = [ + {"parameter_id": 10, "name": "Chemical Oxygen Demand"}, + {"parameter_id": 11, "name": "Total Suspended Solids"}, +] +_SPS = [ + {"sampling_point_id": 100, "name": "R240"}, + {"sampling_point_id": 101, "name": "R450"}, +] + + +def _r() -> EntityResolver: + return EntityResolver(units=_UNITS, parameters=_PARAMS, sampling_points=_SPS) + + +def test_resolve_unit_by_symbol(): + assert _r().resolve_unit("mg/L")["unit_id"] == 1 + + +def test_resolve_unit_by_name_fuzzy(): + # "milligram per liter" vs "milligram per litre" — close enough for difflib at 0.6 + assert _r().resolve_unit("milligram per liter")["unit_id"] == 1 + + +def test_resolve_parameter_cod(): + r = _r() + result = r.resolve_parameter("COD") + # COD is an abbreviation — fuzzy may not match; accept None or correct id + assert result is None or result["parameter_id"] == 10 + + +def test_resolve_parameter_exact_fuzzy(): + assert _r().resolve_parameter("Chemical Oxygen Demand")["parameter_id"] == 10 + + +def test_resolve_sampling_point(): + assert _r().resolve_sampling_point("R240")["sampling_point_id"] == 100 + + +def test_no_match_returns_none(): + assert _r().resolve_unit("parsec") is None + + +def test_resolve_unit_symbol_case_insensitive(): + assert _r().resolve_unit("MG/L")["unit_id"] == 1 + + +def test_resolve_second_unit_by_symbol(): + assert _r().resolve_unit("g/L")["unit_id"] == 2 + + +def test_resolve_second_sampling_point(): + assert _r().resolve_sampling_point("R450")["sampling_point_id"] == 101 + + +def test_resolve_parameter_no_match_returns_none(): + assert _r().resolve_parameter("xyzzy_nonexistent") is None + + +def test_resolve_sampling_point_no_match_returns_none(): + assert _r().resolve_sampling_point("Z999") is None diff --git a/tests/unit/test_schema_ci.py b/tests/unit/test_schema_ci.py index 8f57079..cc10695 100644 --- a/tests/unit/test_schema_ci.py +++ b/tests/unit/test_schema_ci.py @@ -131,6 +131,21 @@ def _latest_migration_target_version() -> str | None: return ".".join(str(x) for x in max(versions)) +def test_version_description_fits_schemaversion_column() -> None: + """version.yaml description must fit SchemaVersion.Description, or db-init's + INSERT truncates and fails (Msg 2628). Guards against discovering this only + in docker.""" + desc = (yaml.safe_load(VERSION_YAML.read_text(encoding="utf-8")).get("description") or "").strip() + sv = yaml.safe_load((TABLES_DIR / "SchemaVersion.yaml").read_text(encoding="utf-8")) + max_len = next( + c["max_length"] for c in sv["table"]["columns"] if c["name"] == "Description" + ) + assert len(desc) <= max_len, ( + f"version.yaml description is {len(desc)} chars but SchemaVersion." + f"Description holds {max_len}. Shorten it or the stamp INSERT truncates." + ) + + def test_version_yaml_matches_latest_migration_target() -> None: """schema_dictionary/version.yaml schema_version should equal the highest migration target.""" latest = _latest_migration_target_version() diff --git a/tests/unit/test_sensor_profile_s5.py b/tests/unit/test_sensor_profile_s5.py new file mode 100644 index 0000000..b3e7224 --- /dev/null +++ b/tests/unit/test_sensor_profile_s5.py @@ -0,0 +1,124 @@ +"""Unit tests for PRD-3 S5 — Sensor-CSV profile (thin profile over the engine). + +Red→green tests that verify a sensor CSV imports through the *same* mapper +engine: timestamp/tag/parameter/unit/value columns resolve to existing +entities and group into tagged /ingest/sensor payloads. +""" + +from __future__ import annotations + +import pandas as pd + +from app.components.resolver import EntityResolver +from app.pages.mapper import ( + PROFILES, + SENSOR_ROLES, + _build_sensor_payloads, + _resolve_sensor, +) + +_UNITS = [{"unit_id": 1, "name": "milligram per litre", "symbol": "mg/L"}] +_PARAMS = [ + {"parameter_id": 10, "name": "Chemical Oxygen Demand", "short_name": "COD"}, +] +_SPS = [{"sampling_point_id": 100, "name": "R240"}] + +_SENSOR_ROLE_MAP = { + "ts": "timestamp", + "tag_col": "tag", + "param_col": "parameter", + "unit_col": "unit", + "val_col": "value", +} + + +def _resolver() -> EntityResolver: + return EntityResolver(units=_UNITS, parameters=_PARAMS, sampling_points=_SPS) + + +def test_sensor_profile_registered(): + """The Sensor-CSV profile is selectable and exposes its role vocabulary.""" + assert "Sensor CSV" in PROFILES + assert PROFILES["Sensor CSV"] == SENSOR_ROLES + assert {"timestamp", "tag", "parameter", "unit", "value"} <= set(SENSOR_ROLES) + + +def test_resolve_sensor_resolved_row_is_ok(): + """A fully resolved sensor row is marked ok with resolved entities + value.""" + df = pd.DataFrame([ + { + "ts": "2024-01-15T10:00:00", + "tag_col": "PLC1.COD", + "param_col": "COD", + "unit_col": "mg/L", + "val_col": 42.5, + } + ]) + result = _resolve_sensor(_resolver(), _SENSOR_ROLE_MAP, df) + assert result["n_resolved"] == 1 + assert result["n_unresolved"] == 0 + rr = result["row_resolutions"][0] + assert rr["ok"] is True + assert rr["parameter"]["parameter_id"] == 10 + assert rr["unit"]["unit_id"] == 1 + assert rr["value"] == 42.5 + assert rr["tag"] == "PLC1.COD" + + +def test_resolve_sensor_unknown_unit_not_ok(): + """A row with an unresolvable unit surfaces an error, not a silent drop.""" + df = pd.DataFrame([ + { + "ts": "2024-01-15T10:00:00", + "tag_col": "PLC1.COD", + "param_col": "COD", + "unit_col": "furlongs", + "val_col": 42.5, + } + ]) + result = _resolve_sensor(_resolver(), _SENSOR_ROLE_MAP, df) + assert result["n_resolved"] == 0 + assert result["n_unresolved"] == 1 + rr = result["row_resolutions"][0] + assert rr["ok"] is False + assert any("furlongs" in e for e in rr["errors"]) + + +def test_build_sensor_payloads_groups_by_channel(): + """Resolved rows group into tagged /ingest/sensor payloads keyed by channel.""" + df = pd.DataFrame([ + {"ts": "2024-01-15T10:00:00", "tag_col": "PLC1.COD", "param_col": "COD", "unit_col": "mg/L", "val_col": 1.0}, + {"ts": "2024-01-15T10:05:00", "tag_col": "PLC1.COD", "param_col": "COD", "unit_col": "mg/L", "val_col": 2.0}, + ]) + result = _resolve_sensor(_resolver(), _SENSOR_ROLE_MAP, df) + payloads = _build_sensor_payloads(result["row_resolutions"]) + assert len(payloads) == 1 # both rows share one channel + p = payloads[0] + assert p["tag"] == "PLC1.COD" + assert p["parameter_name"] == "Chemical Oxygen Demand" + assert p["unit_name"] == "mg/L" + assert p["strict"] is False + assert len(p["values"]) == 2 + assert p["values"][0]["value"] == 1.0 + assert "timestamp" in p["values"][0] + + +def test_build_sensor_payloads_threads_das_name(): + """The user-supplied DAS name reaches the payload (not a silent empty).""" + df = pd.DataFrame([ + {"ts": "2024-01-15T10:00:00", "tag_col": "PLC1.COD", "param_col": "COD", "unit_col": "mg/L", "val_col": 1.0}, + ]) + result = _resolve_sensor(_resolver(), _SENSOR_ROLE_MAP, df) + payloads = _build_sensor_payloads(result["row_resolutions"], das_name="PLC1") + assert payloads[0]["das_name"] == "PLC1" + + +def test_build_sensor_payloads_excludes_unresolved_rows(): + """Unresolved rows never make it into a submit payload.""" + df = pd.DataFrame([ + {"ts": "2024-01-15T10:00:00", "tag_col": "PLC1.COD", "param_col": "COD", "unit_col": "mg/L", "val_col": 1.0}, + {"ts": "2024-01-15T10:05:00", "tag_col": "PLC1.COD", "param_col": "UNKNOWN", "unit_col": "mg/L", "val_col": 2.0}, + ]) + result = _resolve_sensor(_resolver(), _SENSOR_ROLE_MAP, df) + payloads = _build_sensor_payloads(result["row_resolutions"]) + assert sum(len(p["values"]) for p in payloads) == 1 # only the resolved row diff --git a/tests/unit/test_stream_pedigree.py b/tests/unit/test_stream_pedigree.py new file mode 100644 index 0000000..4b925b0 --- /dev/null +++ b/tests/unit/test_stream_pedigree.py @@ -0,0 +1,115 @@ +"""Unit tests for channel_repository.get_stream_pedigree. + +Pedigree = the organizational/spatial context of a stream, as a time-bound +deployment timeline (sampling location, process unit, site, campaign, +responsible person per deployment) — distinct from its processing provenance. +See CONTEXT.md. A sensor channel can span several deployments over its life +(equipment moves); a lab series has one fixed open segment. +""" + +from __future__ import annotations + +from datetime import datetime +from unittest.mock import MagicMock + +from api.v1.repositories import channel_repository + + +def _conn(fetchone_seq, fetchall_seq=()): + cursor = MagicMock() + cursor.fetchone.side_effect = list(fetchone_seq) + cursor.fetchall.side_effect = list(fetchall_seq) + conn = MagicMock() + conn.cursor.return_value = cursor + return conn, cursor + + +# Column orders mirror _pedigree_sampling_point / _pedigree_campaign SELECTs. +_SP_INLET = ("Inlet", 46.7, -71.2, 7, "R-210", "Bioreactor", "Tank", + 3, "pilEAU", "Quebec", "QC", "Canada") +_SP_EFF = ("Effluent", 46.8, -71.3, 8, "R-310", "Clarifier", "Tank", + 3, "pilEAU", "Quebec", "QC", "Canada") +# Campaign SELECT no longer carries site columns; sites are a separate query. +_CAMP_WINTER = ("Winter 2026", "Experiment", "2026-01-01", "2026-04-01", + 11, "Jean", "Tremblay", "jt@x.io", "PI", "modelEAU") +_CAMP_SPRING = ("Spring 2026", "Experiment", "2026-04-01", "2026-07-01", + 12, "Marie", "Roy", "mr@x.io", "Eng", "modelEAU") +# Derived single-site fallback row (site_id, name, city, province, country). +_CAMP_SITE = (3, "pilEAU", "Quebec", "QC", "Canada") + + +def test_sensor_spanning_two_deployments_yields_a_timeline(): + """The spicy case: a channel whose data crosses an equipment move resolves to + two segments, each with its own location + campaign + responsible person.""" + identity = ("TSS", "mg/L", "Scalar", "CH-TSS") + seg1 = (101, "2026-01-01", "2026-04-01", "EQ5", 21, 5) + seg2 = (102, "2026-04-01", None, "EQ5", 22, 6) # open-ended (still active) + conn, _ = _conn( + fetchone_seq=[identity, _SP_INLET, _CAMP_WINTER, _SP_EFF, _CAMP_SPRING], + # segments, then derived sites per campaign (Winter, Spring). + fetchall_seq=[[seg1, seg2], [_CAMP_SITE], [_CAMP_SITE]], + ) + + ped = channel_repository.get_stream_pedigree(conn, 42) + + assert ped["kind"] == "sensor" + assert ped["parameter"] == "TSS" + assert len(ped["deployments"]) == 2 + + d0, d1 = ped["deployments"] + assert d0["valid_from"] == "2026-01-01" + assert d0["valid_to"] == "2026-04-01" + assert d0["sampling_location"] == { + "sampling_point_id": 21, "name": "Inlet", "latitude": 46.7, "longitude": -71.2} + assert d0["process_unit"]["tag"] == "R-210" + assert d0["campaign"]["name"] == "Winter 2026" + assert d0["responsible_person"]["name"] == "Jean Tremblay" + + assert d1["valid_to"] is None # open deployment + assert d1["sampling_location"]["name"] == "Effluent" + assert d1["campaign"]["name"] == "Spring 2026" + assert d1["responsible_person"]["name"] == "Marie Roy" + + +def test_sensor_window_filters_segments_in_sql(): + identity = ("pH", "-", "Scalar", "CH-pH") + conn, cursor = _conn(fetchone_seq=[identity], fetchall_seq=[[]]) + + channel_repository.get_stream_pedigree( + conn, 8, from_dt=datetime(2026, 5, 1), to_dt=datetime(2026, 6, 1) + ) + + seg_call = next( + c for c in cursor.execute.call_args_list + if "EquipmentLocationHistory" in c.args[0] and "vw_ChannelResolved" in c.args[0] + ) + sql = seg_call.args[0] + assert "elh.[ValidFrom] <= ?" in sql + assert "elh.[ValidTo] IS NULL OR elh.[ValidTo] >= ?" in sql + assert datetime(2026, 6, 1) in seg_call.args # to_dt bound + assert datetime(2026, 5, 1) in seg_call.args # from_dt bound + + +def test_lab_series_single_open_segment(): + lab_identity = ("TSS", "mg/L", "Scalar", "TSS@Eff", 21, 5) + # sensor identity misses -> lab branch; then one segment's lookups. + conn, cursor = _conn( + fetchone_seq=[None, lab_identity, _SP_INLET, _CAMP_WINTER], + fetchall_seq=[[_CAMP_SITE]], # derived sites for the campaign + ) + + ped = channel_repository.get_stream_pedigree(conn, 7) + + assert ped["kind"] == "lab" + assert len(ped["deployments"]) == 1 + seg = ped["deployments"][0] + assert seg["valid_from"] is None and seg["valid_to"] is None + assert seg["equipment_identifier"] is None + assert seg["sampling_location"]["name"] == "Inlet" + assert seg["campaign"]["name"] == "Winter 2026" + assert any("AnalysisSeries" in c.args[0] for c in cursor.execute.call_args_list) + + +def test_unknown_stream_returns_none(): + conn, _ = _conn(fetchone_seq=[None, None]) + assert channel_repository.get_stream_pedigree(conn, 123) is None diff --git a/tests/unit/test_temporal_history_noop_guard.py b/tests/unit/test_temporal_history_noop_guard.py new file mode 100644 index 0000000..4ebb2fd --- /dev/null +++ b/tests/unit/test_temporal_history_noop_guard.py @@ -0,0 +1,86 @@ +"""F8 regression: movers must reject a move to the destination already active. + +relocate_equipment / rewire_equipment / deploy_das previously closed the active +row and opened an identical one when asked to move equipment to where it already +was, producing churn rows (and spurious annotations downstream). Each now raises +ValueError before touching the DB. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from api.v1.repositories import temporal_history_repository as thr + +NOW = datetime(2026, 6, 1, tzinfo=timezone.utc) + + +def _conn_that_must_not_execute(monkeypatch, helper_name, active_row): + """Patch the active-row helper and return a conn whose .cursor() blows up if + the guard fails to short-circuit (proving no DB writes happen).""" + monkeypatch.setattr(thr, helper_name, lambda *a, **k: active_row) + + class _Boom: + def cursor(self): + raise AssertionError("guard did not short-circuit; DB was touched") + + def commit(self): + raise AssertionError("guard did not short-circuit; commit was called") + + return _Boom() + + +def test_relocate_to_current_sampling_point_raises(monkeypatch): + conn = _conn_that_must_not_execute( + monkeypatch, + "get_active_location_for_equipment", + {"sampling_point_id": 8}, + ) + with pytest.raises(ValueError, match="already located"): + thr.relocate_equipment(conn, equipment_id=1, new_sampling_point_id=8, + start_time=NOW, campaign_id=2) + + +def test_rewire_to_current_interface_and_port_raises(monkeypatch): + conn = _conn_that_must_not_execute( + monkeypatch, + "get_active_wiring_for_equipment", + {"signal_interface_id": 5, "signal_interface_port_id": 3}, + ) + with pytest.raises(ValueError, match="already wired"): + thr.rewire_equipment(conn, equipment_id=1, new_signal_interface_id=5, + new_signal_interface_port_id=3, swap_time=NOW) + + +def test_redeploy_das_to_current_site_raises(monkeypatch): + conn = _conn_that_must_not_execute( + monkeypatch, + "get_active_das_deployment", + {"site_id": 4}, + ) + with pytest.raises(ValueError, match="already deployed"): + thr.deploy_das(conn, das_id=1, site_id=4, valid_from=NOW) + + +def test_rewire_to_different_port_same_interface_is_allowed(monkeypatch): + """Port-only change must NOT be treated as a no-op (mux re-patch is real).""" + monkeypatch.setattr( + thr, "get_active_wiring_for_equipment", + lambda *a, **k: {"signal_interface_id": 5, "signal_interface_port_id": 3}, + ) + # A different port → guard passes → cursor is used. Minimal mock for the path. + from unittest.mock import MagicMock + + conn = MagicMock() + cursor = MagicMock() + # F7 interval guard (no conflict), then: no row closed, new id. + cursor.fetchone.side_effect = [None, None, (99,)] + conn.cursor.return_value = cursor + + new_id, closed_id = thr.rewire_equipment( + conn, equipment_id=1, new_signal_interface_id=5, + new_signal_interface_port_id=4, swap_time=NOW, + ) + assert new_id == 99 and closed_id is None diff --git a/tests/unit/test_temporal_history_repository.py b/tests/unit/test_temporal_history_repository.py index 57af4bd..1a3a00e 100644 --- a/tests/unit/test_temporal_history_repository.py +++ b/tests/unit/test_temporal_history_repository.py @@ -20,8 +20,9 @@ def _conn(self, closed_row_id: int | None, new_row_id: int = 7): UPDATE OUTPUT and new_row_id from the INSERT OUTPUT.""" conn = MagicMock() cursor = MagicMock() - # The UPDATE/INSERT both go through fetchone(); side_effect drives them in order. + # fetchone() order: F7 interval guard SELECT (no conflict), close UPDATE, open INSERT. fetchone_returns: list[tuple[int] | None] = [ + None, (closed_row_id,) if closed_row_id is not None else None, (new_row_id,), ] @@ -45,10 +46,10 @@ def test_closes_active_row_and_opens_new_with_campaign_id(self): assert new_id == 7 assert closed_id == 3 - # Two cursor.execute calls: close UPDATE, then open INSERT. - assert cursor.execute.call_count == 2 - update_sql = cursor.execute.call_args_list[0][0][0] - insert_sql = cursor.execute.call_args_list[1][0][0] + # Three cursor.execute calls: F7 guard SELECT, close UPDATE, open INSERT. + assert cursor.execute.call_count == 3 + update_sql = cursor.execute.call_args_list[1][0][0] + insert_sql = cursor.execute.call_args_list[2][0][0] assert "UPDATE" in update_sql and "EquipmentLocationHistory" in update_sql assert "ValidTo" in update_sql @@ -91,8 +92,8 @@ def test_insert_args_carry_campaign_id_and_start_time(self): notes=None, ) - # Second call is the INSERT; args after the SQL string are the bound parameters. - insert_args = cursor.execute.call_args_list[1][0][1:] + # Third call is the INSERT (after F7 guard + close); args after the SQL are params. + insert_args = cursor.execute.call_args_list[2][0][1:] assert 42 in insert_args # equipment_id assert 8 in insert_args # sampling_point_id assert start in insert_args # start_time diff --git a/tests/unit/test_yaml_generation_ci.py b/tests/unit/test_yaml_generation_ci.py index 8031ce5..3e306a3 100644 --- a/tests/unit/test_yaml_generation_ci.py +++ b/tests/unit/test_yaml_generation_ci.py @@ -8,6 +8,7 @@ 5. load_views returns an empty dict when views_dir is empty (graceful). """ +import re from pathlib import Path import pytest @@ -178,6 +179,47 @@ def test_view_yaml_files_validate_cleanly() -> None: assert errors == [], "\n".join(errors) +def test_channel_location_view_exposes_resolution_discriminators() -> None: + """F6: vw_ChannelLocationAtTime must carry Resolution + LocationResolution so a + NULL SamplingPoint from a broken chain is distinguishable from a genuine absence.""" + import yaml as _yaml + + doc = _yaml.safe_load( + (VIEWS_DIR / "vw_ChannelLocationAtTime.yaml").read_text(encoding="utf-8") + )["view"] + col_names = {c["name"] for c in doc["columns"]} + assert {"Resolution", "LocationResolution"} <= col_names, col_names + definition = doc["view_definition"] + # the location leg discriminates the three broken/absent/resolved cases + for token in ("no-equipment", "no-location", "LocationResolution"): + assert token in definition, f"missing {token!r} in view definition" + + +def test_views_emitted_after_views_they_reference() -> None: + """A view that selects from another must be CREATEd after it. + + SQL Server resolves view references at CREATE time, so an alphabetical emit + order broke db-init once vw_ChannelEquipmentAtTime started selecting from + vw_ChannelResolved. Guard the dependency-ordered emit. + """ + from tools.schema_migrate.loader import load_schema, load_views + from tools.schema_migrate.render import render_create_script_with_views + + views = load_views(VIEWS_DIR) + schema = load_schema(TABLES_DIR) + sql = render_create_script_with_views(schema, views, "test", "mssql") + + # Position of each "CREATE ... VIEW [dbo].[name]" in the script. + pos = {name: sql.index(f"[dbo].[{name}]") for name in views} + for name, vdoc in views.items(): + definition = vdoc.get("view", {}).get("view_definition") or "" + for other in views: + if other != name and re.search(rf"\b{re.escape(other)}\b", definition): + assert pos[other] < pos[name], ( + f"{name} references {other} but is created before it" + ) + + # --------------------------------------------------------------------------- # Test 5: load_views is graceful when views_dir is empty or missing # --------------------------------------------------------------------------- diff --git a/tools/schema_migrate/render.py b/tools/schema_migrate/render.py index af00f07..3c83887 100644 --- a/tools/schema_migrate/render.py +++ b/tools/schema_migrate/render.py @@ -1,5 +1,6 @@ """SQL migration script renderer for MSSQL and PostgreSQL.""" +import re from datetime import datetime, timezone from .diff import SchemaDiff @@ -577,6 +578,40 @@ def render_drop_view(view_name: str, view_dict: dict, platform: str) -> str: return f"DROP VIEW {full_view};" +def _order_views_by_dependency(views: dict[str, dict]) -> list[str]: + """View names in creation order: a view that selects from another comes after it. + + SQL Server resolves view references at CREATE (OR ALTER) time — there is no + deferral across views — so emitting them alphabetically breaks whenever one + view sits on top of another (e.g. vw_ChannelEquipmentAtTime on + vw_ChannelResolved). Dependencies are read straight from each view's SQL text + (whole-word match on the other view names), so no manual bookkeeping is + needed. Kahn's algorithm with an alphabetical tiebreak keeps output stable. + """ + names = list(views) + + def _definition(name: str) -> str: + return (views[name].get("view", {}).get("view_definition") or "") + + deps = { + name: { + other + for other in names + if other != name and re.search(rf"\b{re.escape(other)}\b", _definition(name)) + } + for name in names + } + ordered: list[str] = [] + remaining = set(names) + while remaining: + ready = sorted(n for n in remaining if deps[n] <= set(ordered)) + if not ready: # a dependency cycle — surface it rather than emit broken SQL + raise ValueError(f"Cyclic view dependencies among: {sorted(remaining)}") + ordered.extend(ready) + remaining -= set(ready) + return ordered + + def render_create_script_with_views( schema: dict[str, dict], views: dict[str, dict], @@ -600,7 +635,7 @@ def render_create_script_with_views( if views: view_lines: list[str] = ["", "-- Views"] - for view_name in sorted(views.keys()): + for view_name in _order_views_by_dependency(views): if platform == "mssql": view_lines.append("GO") view_lines.append(render_create_view(view_name, views[view_name], platform))