Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
9f2f5e6
fix(app): stop Explore wiping data cache on add-stream and date edits
jeandavidt Jun 28, 2026
aa5d58f
feat(app): replace Explore scalar Plotly chart with ECharts (faster, …
jeandavidt Jun 28, 2026
5c84207
feat(app): multi-plot workspace for the Explore scalar view
jeandavidt Jun 28, 2026
52600cb
feat(app): deletable plot badges, selection table, selection-scoped a…
jeandavidt Jun 28, 2026
335ed64
fix(api): consistency Batch 0 quick wins (F4/F8/F12)
jeandavidt Jun 28, 2026
86bd74f
refactor(schema): drop denormalised Channel.SignalInterfacePort_ID (F3)
jeandavidt Jun 29, 2026
6648ff3
feat(schema): DAS-move coherence detection (F1, F13) — Batch 2 part 1
jeandavidt Jun 29, 2026
3e83162
feat(api): stream pedigree endpoint with deployment timeline
jeandavidt Jun 29, 2026
97824e3
feat(app): Explore zip export + campaign filter
jeandavidt Jun 29, 2026
39b3dec
docs(context): add glossary pinning pedigree vs provenance
jeandavidt Jun 29, 2026
0db041d
feat(api): expose F1/F13 deployment-coherence endpoints + wizard surf…
jeandavidt Jun 29, 2026
e7a8832
fix(schema): emit views in dependency order so db-init succeeds
jeandavidt Jun 29, 2026
ba0e8ff
feat(schema): broken-link visibility — F6/F5/F11 (Batch 3, schema 2.3.0)
jeandavidt Jun 29, 2026
4de5263
feat(api): reject overlapping/backdated history intervals (F7, Batch 4)
jeandavidt Jun 29, 2026
13e1c16
fix(api): junction-authoritative campaign membership on deployment de…
jeandavidt Jun 29, 2026
b126062
feat(schema): drop Campaign.Site_ID — campaigns are multi-site (F2/F9…
jeandavidt Jun 29, 2026
fdee9d4
build(db): regenerate v2.4.0 DDL/docs without Campaign.Site_ID
jeandavidt Jun 29, 2026
b077a9b
refactor(api): derive campaign sites from membership (Batch 6)
jeandavidt Jun 29, 2026
9aff634
refactor(app): repoint campaign UI to derived multi-site model (Batch 6)
jeandavidt Jun 29, 2026
bff4958
test: cover multi-site campaign derivation (Batch 6)
jeandavidt Jun 29, 2026
2cb5535
fix(app): keep campaign-wizard SL selections across the deployment st…
jeandavidt Jun 29, 2026
346c301
fix(app): export quality codes as labels, not ids
jeandavidt Jun 29, 2026
e5c47b4
feat(app): multi-site campaign wizard — per-site repeatable SL blocks
jeandavidt Jun 29, 2026
3cbe12f
feat(app): restore annotation/event access, click-to-select, live sel…
jeandavidt Jun 29, 2026
040b23e
docs(event): add ADR-0006 unified Event + CONTEXT glossary terms
jeandavidt Jun 30, 2026
fd94367
feat(schema): PRD-2 S1 — rename EquipmentEvent→Event, 8-FK exclusive …
jeandavidt Jun 30, 2026
1bc032a
feat(api): PRD-2 S2 — Event/EventKind models, repo, /events endpoints…
jeandavidt Jun 30, 2026
3db97a0
feat(app): PRD-1 S1 — Home empty-state onboarding panel with foundati…
jeandavidt Jun 30, 2026
9b1a24e
fix(schema): remove EquipmentEvent/EventKind from tables/ (were only …
jeandavidt Jun 30, 2026
d3cac70
feat(schema): PRD-2 S3 — rename Annotation.EquipmentEvent_ID → Event_…
jeandavidt Jun 30, 2026
d722fc0
feat(app): PRD-1 S2 — count-derived ticks + auto-hide at first ingest…
jeandavidt Jun 30, 2026
763d370
feat(app): PRD-2 S4 — Event CRUD page + EventKind page; wire into nav…
jeandavidt Jun 30, 2026
a306aaa
feat(app): PRD-3 S1 — mapper engine shell (upload → column-role taggi…
jeandavidt Jun 30, 2026
8e258d5
feat(app): PRD-1 S3 — data-type branch (Lab/Sensor/Both) in onboardin…
jeandavidt Jun 30, 2026
4316c57
feat(app): PRD-3 S2 — entity resolution service (text→ID fuzzy match,…
jeandavidt Jun 30, 2026
c75ce4a
feat(app): PRD-1 S4 — session dismiss + polish + campaign as optional…
jeandavidt Jun 30, 2026
32d959a
feat(api): PRD-2.5 S1 — maintenance-drift derived Channel via Process…
jeandavidt Jun 30, 2026
c270a8d
feat(app): PRD-3 S3 — lab profile end-to-end → preview ingest + submi…
jeandavidt Jun 30, 2026
d8ee5b3
feat(app): PRD-2.5 S2 — maintenance control chart (#40)
jeandavidt Jun 30, 2026
4e93219
feat(app): PRD-3 S5 — Sensor-CSV profile over the mapper engine (#45)
jeandavidt Jun 30, 2026
a2fd842
feat(app): PRD-3 S6 — long-format lab profile reusing the wide resolv…
jeandavidt Jun 30, 2026
9e08919
feat(app): PRD-4 S1 — Général logbook profile → Event (resolve smalle…
jeandavidt Jun 30, 2026
dba6b37
feat(app): PRD-4 S2 — logbook target confirm UX: level heuristic + us…
jeandavidt Jun 30, 2026
7e3397b
feat(app): PRD-4 S3 — per-equipment maintenance-sheet profile → spann…
jeandavidt Jun 30, 2026
2935418
feat(api,app): PRD-4 S4 — drift read-back next to maintenance Event (…
jeandavidt Jul 1, 2026
211272b
echart point selection bugs fixes
jeandavidt Jul 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -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
29 changes: 19 additions & 10 deletions api/v1/endpoints/channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
28 changes: 28 additions & 0 deletions api/v1/endpoints/das_move.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
DASConflictResponse,
DASDeployRequest,
DASDeployResponse,
DASMoveConflictsResponse,
StrandedEquipment,
)

router = APIRouter()
Expand Down Expand Up @@ -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],
)
94 changes: 94 additions & 0 deletions api/v1/endpoints/data_health.py
Original file line number Diff line number Diff line change
@@ -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)
35 changes: 35 additions & 0 deletions api/v1/endpoints/equipment_move.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
temporal_history_repository,
)
from ..schemas.equipment_move import (
ActiveCampaignDeploymentResponse,
EquipmentRegisterInterfaceRequest,
EquipmentRegisterInterfaceResponse,
EquipmentRelocateRequest,
Expand Down Expand Up @@ -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"),
)
Loading
Loading