diff --git a/nurse/models.go b/nurse/models.go index d14b406..f13c236 100644 --- a/nurse/models.go +++ b/nurse/models.go @@ -7,15 +7,16 @@ import ( // MaintenanceOrderEvent represents the input from Digital Twin type MaintenanceOrderEvent struct { - EquipmentID string `json:"equipmentId" validate:"required"` - FunctionalLocation string `json:"functionalLocation,omitempty"` - Plant string `json:"plant" validate:"required"` - Description string `json:"description" validate:"required"` - Priority string `json:"priority,omitempty"` - MaintenanceOrderType string `json:"maintenanceOrderType,omitempty"` - PlannedStartTime *time.Time `json:"plannedStartTime,omitempty"` - PlannedEndTime *time.Time `json:"plannedEndTime,omitempty"` - Operations []MaintenanceOperation `json:"operations,omitempty"` + EquipmentID string `json:"equipmentId" validate:"required"` + FunctionalLocation string `json:"functionalLocation,omitempty"` // human-readable FL tag + FunctionalLocationIRI string `json:"functionalLocationIri,omitempty"` // FL IRI used by the SAP adaptor to link the work request in the STEP graph + Plant string `json:"plant" validate:"required"` + Description string `json:"description" validate:"required"` + Priority string `json:"priority,omitempty"` + MaintenanceOrderType string `json:"maintenanceOrderType,omitempty"` + PlannedStartTime *time.Time `json:"plannedStartTime,omitempty"` + PlannedEndTime *time.Time `json:"plannedEndTime,omitempty"` + Operations []MaintenanceOperation `json:"operations,omitempty"` } // MaintenanceOperation represents a single operation within a maintenance order diff --git a/nurse/thing.go b/nurse/thing.go index 8f7b7fa..c1ed7da 100644 --- a/nurse/thing.go +++ b/nurse/thing.go @@ -45,7 +45,8 @@ type SignalT struct { TOverCount map[string]int `json:"-"` // consecutive out-of-range count per source node WorkRequested map[string]bool `json:"-"` // pending maintenance order per source node Operational bool `json:"-"` // false when any node has a pending order - ValveTagByNode map[string]string `json:"-"` // node → actuator FL tag resolved from GraphDB + ValveTagByNode map[string]string `json:"-"` // node → actuator FL tag resolved from GraphDB (human-readable) + ValveIRIByNode map[string]string `json:"-"` // node → actuator FL IRI (used to link the work request in the STEP graph) UnresolvableNodes map[string]bool `json:"-"` // nodes whose actuator could not be resolved; skipped } @@ -124,6 +125,7 @@ func newResource(configuredAsset usecases.ConfigurableAsset, sys *components.Sys t.Signals[i].TOverCount = make(map[string]int) t.Signals[i].WorkRequested = make(map[string]bool) t.Signals[i].ValveTagByNode = make(map[string]string) + t.Signals[i].ValveIRIByNode = make(map[string]string) t.Signals[i].UnresolvableNodes = make(map[string]bool) } @@ -295,16 +297,18 @@ func (t *Traits) sigMon(name string, period time.Duration) error { sig.WorkRequested[node] = true log.Printf("Signal %s/%s non-operational, requesting maintenance\n", name, node) equipmentID := assetNameFromURL(ni.URL) - // The actuator FL tag resolved from GraphDB is the SAP - // dispatch target — the sensor only diagnoses the fault. - location := sig.ValveTagByNode[node] - orderID := t.requestMaintenanceOrder(sig, equipmentID, location) + // The actuator FL tag (human label) and IRI (graph linkage) + // both come from the GraphDB resolution. The sensor only + // diagnoses the fault; the work order targets the valve. + locationTag := sig.ValveTagByNode[node] + locationIRI := sig.ValveIRIByNode[node] + orderID := t.requestMaintenanceOrder(sig, equipmentID, locationTag, locationIRI) if orderID != "" { t.pendingOrders[orderID] = name log.Printf("Maintenance order %s created for signal %s/%s\n", orderID, name, node) reason := fmt.Sprintf("Signal %s out of range [%.2f, %.2f]", sig.Name, sig.LowerThreshold, sig.UpperThreshold) - go t.pushOrderContext(orderID, equipmentID, location, reason) + go t.pushOrderContext(orderID, equipmentID, locationTag, reason) } else { log.Printf("SAP order failed for signal %s/%s; monitoring paused until system restart\n", name, node) } @@ -500,24 +504,27 @@ func (t *Traits) resolveActuators(sig *SignalT, nodes map[string][]components.No sig.UnresolvableNodes[node] = true continue } - tag, err := resolveActuatorTag(t.GraphDB_URL, sensorName, 5*time.Second) + tag, iri, err := resolveActuator(t.GraphDB_URL, sensorName, 5*time.Second) if err != nil { log.Printf("nurse: cannot resolve actuator for sensor %s on node %s: %v", sensorName, node, err) sig.UnresolvableNodes[node] = true continue } - log.Printf("nurse: sensor %s on node %s diagnoses actuator at %s", - sensorName, node, tag) + log.Printf("nurse: sensor %s on node %s diagnoses actuator %s (FL IRI %s)", + sensorName, node, tag, iri) sig.ValveTagByNode[node] = tag + sig.ValveIRIByNode[node] = iri } } -// resolveActuatorTag asks GraphDB for the FL tag of the actuator a sensor -// diagnoses. Returns the tag on success, or an error if the sensor has no +// resolveActuator asks GraphDB for the actuator a sensor diagnoses and +// returns both its functional-location tag (human-readable, e.g. +// "827-PV2708-200") and the FL's IRI (used to link the work order to the +// physical asset in the STEP graph). Returns an error if the sensor has no // diagnosesActuator triple, has more than one match (ambiguous), or the // endpoint is unreachable. -func resolveActuatorTag(endpoint, sensorName string, timeout time.Duration) (string, error) { +func resolveActuator(endpoint, sensorName string, timeout time.Duration) (tag, iri string, err error) { // afo: is the producer's namespace — the same URI the kgrapher emits and // the same URI under which the sensor's triples live in the remote graph. // arrowhead: is the upstream (Skoghall) namespace under which the valve's @@ -526,7 +533,7 @@ func resolveActuatorTag(endpoint, sensorName string, timeout time.Duration) (str // bridge them at reasoning time. query := fmt.Sprintf(`PREFIX afo: PREFIX arrowhead: -SELECT ?valveTag WHERE { +SELECT ?valveFL ?valveTag WHERE { ?sensor afo:hasName %q . ?sensor afo:diagnosesActuator ?valveFL . ?valveFL arrowhead:functionalLocation ?valveTag . @@ -535,22 +542,22 @@ SELECT ?valveTag WHERE { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(query)) - if err != nil { - return "", fmt.Errorf("build request: %w", err) + req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(query)) + if reqErr != nil { + return "", "", fmt.Errorf("build request: %w", reqErr) } req.Header.Set("Content-Type", "application/sparql-query") req.Header.Set("Accept", "application/sparql-results+json") - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", err + resp, doErr := http.DefaultClient.Do(req) + if doErr != nil { + return "", "", doErr } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("HTTP %s: %s", resp.Status, strings.TrimSpace(string(body))) + return "", "", fmt.Errorf("HTTP %s: %s", resp.Status, strings.TrimSpace(string(body))) } var result struct { @@ -560,22 +567,26 @@ SELECT ?valveTag WHERE { } `json:"bindings"` } `json:"results"` } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", fmt.Errorf("decode response: %w", err) + if decErr := json.NewDecoder(resp.Body).Decode(&result); decErr != nil { + return "", "", fmt.Errorf("decode response: %w", decErr) } bs := result.Results.Bindings switch len(bs) { case 0: - return "", fmt.Errorf("no diagnosesActuator relationship for sensor %q", sensorName) + return "", "", fmt.Errorf("no diagnosesActuator relationship for sensor %q", sensorName) case 1: - tag := bs[0]["valveTag"].Value + tag = bs[0]["valveTag"].Value + iri = bs[0]["valveFL"].Value if tag == "" { - return "", fmt.Errorf("empty valveTag binding for sensor %q", sensorName) + return "", "", fmt.Errorf("empty valveTag binding for sensor %q", sensorName) + } + if iri == "" { + return "", "", fmt.Errorf("empty valveFL binding for sensor %q", sensorName) } - return tag, nil + return tag, iri, nil default: - return "", fmt.Errorf("ambiguous diagnosesActuator for sensor %q (%d matches)", + return "", "", fmt.Errorf("ambiguous diagnosesActuator for sensor %q (%d matches)", sensorName, len(bs)) } } @@ -628,19 +639,23 @@ func assetNameFromURL(rawURL string) string { // requestMaintenanceOrder posts a maintenance order to the SAP system for the given signal // and returns the SAP order ID on success, or an empty string on failure. -func (t *Traits) requestMaintenanceOrder(sig *SignalT, equipmentID string, location string) string { +// locationTag is the human-readable FL tag (e.g. "827-PV2708-200"); locationIRI is +// the FL's IRI in the STEP graph, used by the SAP side to link the work request to +// the physical asset. The IRI may be empty for consumers that haven't resolved it. +func (t *Traits) requestMaintenanceOrder(sig *SignalT, equipmentID, locationTag, locationIRI string) string { start := time.Now().Add(24 * time.Hour) end := start.Add(8 * time.Hour) payload := MaintenanceOrderEvent{ - EquipmentID: equipmentID, - FunctionalLocation: location, - Plant: "1000", - Description: fmt.Sprintf("Signal %s out of range [%.2f, %.2f]", sig.Name, sig.LowerThreshold, sig.UpperThreshold), - Priority: "3", - MaintenanceOrderType: "PM01", - PlannedStartTime: &start, - PlannedEndTime: &end, + EquipmentID: equipmentID, + FunctionalLocation: locationTag, + FunctionalLocationIRI: locationIRI, + Plant: "1000", + Description: fmt.Sprintf("Signal %s out of range [%.2f, %.2f]", sig.Name, sig.LowerThreshold, sig.UpperThreshold), + Priority: "3", + MaintenanceOrderType: "PM01", + PlannedStartTime: &start, + PlannedEndTime: &end, Operations: []MaintenanceOperation{ { OperationID: "0010", diff --git a/sapper/models.go b/sapper/models.go index 442223a..3411b5e 100644 --- a/sapper/models.go +++ b/sapper/models.go @@ -23,15 +23,16 @@ import ( // OrderRequest is the body sent by the nurse (or any consumer) to create a maintenance order. // Field names match the nurse's MaintenanceOrderEvent JSON tags so no translation is needed. type OrderRequest struct { - EquipmentID string `json:"equipmentId"` - FunctionalLocation string `json:"functionalLocation,omitempty"` - Plant string `json:"plant"` - Description string `json:"description"` - Priority string `json:"priority,omitempty"` - MaintenanceOrderType string `json:"maintenanceOrderType,omitempty"` - PlannedStartTime *time.Time `json:"plannedStartTime,omitempty"` - PlannedEndTime *time.Time `json:"plannedEndTime,omitempty"` - Operations []OrderOperation `json:"operations,omitempty"` + EquipmentID string `json:"equipmentId"` + FunctionalLocation string `json:"functionalLocation,omitempty"` // human-readable FL tag + FunctionalLocationIRI string `json:"functionalLocationIri,omitempty"` // FL IRI used to link the work request to the FL in the STEP graph + Plant string `json:"plant"` + Description string `json:"description"` + Priority string `json:"priority,omitempty"` + MaintenanceOrderType string `json:"maintenanceOrderType,omitempty"` + PlannedStartTime *time.Time `json:"plannedStartTime,omitempty"` + PlannedEndTime *time.Time `json:"plannedEndTime,omitempty"` + Operations []OrderOperation `json:"operations,omitempty"` } // OrderOperation is a single work step within an order. diff --git a/sapper/thing.go b/sapper/thing.go index e3a9d4d..ee9451e 100644 --- a/sapper/thing.go +++ b/sapper/thing.go @@ -18,7 +18,6 @@ package main import ( "bytes" "context" - "crypto/md5" "encoding/json" "fmt" "html/template" @@ -364,8 +363,8 @@ func (t *Traits) primeCounterFromGraph() { // success, (0, true) when the graph is empty, (0, false) on any error. func (t *Traits) peekMaxOrderID() (int64, bool) { const query = `PREFIX step: -PREFIX identifier: -PREFIX workorder: +PREFIX identifier: +PREFIX workorder: SELECT (MAX(?id) AS ?max) WHERE { ?wo a step:WorkOrder ; workorder:Id ?idNode . @@ -558,23 +557,36 @@ func (t *Traits) notifyConsumer(o *Order) { //-------------------------------------GraphDB SPARQL insert // buildSPARQL constructs the SPARQL UPDATE statement for a newly created order. +// The WorkRequestAssignment block — which links the order to a functional +// location in the STEP graph — is only emitted when the consumer supplied an +// IRI. Orders raised by consumers that don't resolve an FL IRI still get a +// WorkOrder and WorkRequest, just no graph-side asset linkage. func (t *Traits) buildSPARQL(o *Order) string { orderURI := "https://sinetiq.se/sap/MaintenanceOrder/" + o.ID notifURI := "https://sinetiq.se/sap/MaintenanceNotification/" + o.Notification - equipHash := fmt.Sprintf("%x", md5.Sum([]byte(o.Request.EquipmentID))) createdAt := o.CreatedAt.UTC().Format(time.RFC3339Nano) desc := strings.ReplaceAll(o.Request.Description, `"`, `\"`) // escape any quotes + wraBlock := "" + if o.Request.FunctionalLocationIRI != "" { + wraBlock = fmt.Sprintf(` + # step:WorkRequestAssignment + <%s-WRA> a step:WorkRequestAssignment ; + workrequestassignment:AssignedTo <%s> ; + workrequestassignment:AssignedWorkRequest <%s> .`, + notifURI, o.Request.FunctionalLocationIRI, notifURI) + } + return fmt.Sprintf(`PREFIX rdfs: PREFIX ex: PREFIX schema: PREFIX dcterms: PREFIX xsd: PREFIX step: -PREFIX workorder: -PREFIX workrequest: -PREFIX workrequestassignment: -PREFIX identifier: +PREFIX workorder: +PREFIX workrequest: +PREFIX workrequestassignment: +PREFIX identifier: INSERT { GRAPH @@ -603,12 +615,7 @@ INSERT identifier:Id "%s" . <%s-description> a step:LocalizedString ; - rdfs:label "%s"@en . - - # step:WorkRequestAssignment - <%s-WRA> a step:WorkRequestAssignment ; - workrequestassignment:AssignedTo ; - workrequestassignment:AssignedWorkRequest <%s> . + rdfs:label "%s"@en .%s } } where @@ -627,28 +634,34 @@ where notifURI, o.Notification, // WorkRequest-description notifURI, desc, - // WorkRequestAssignment - notifURI, equipHash, notifURI, + // Optional WorkRequestAssignment + wraBlock, ) } -// buildRELInsertSPARQL records the planner's release of an order: the new REL -// status triple and the release timestamp. The enrichment JSON itself is not -// stored as a literal in the graph — it ships to the nurse over the -// EnrichmentNotification path and is captured in the nurse's log; embedding -// it as a SPARQL string literal couples graph state to textarea encoding and -// produces malformed queries on CRLF input. +// buildRELInsertSPARQL records the planner's release of an order. The status +// triple must replace (not append to) the previous CRTD status — the UI +// shows the "current" status by reading a single ex:status triple, so an +// additive update would leave the order looking still-CRTD even after release. +// dcterms:modified is set so the UI can show a last-modified timestamp. func (t *Traits) buildRELInsertSPARQL(o *Order) string { orderURI := "https://sinetiq.se/sap/MaintenanceOrder/" + o.ID releasedAt := o.ReleasedAt.UTC().Format(time.RFC3339Nano) return fmt.Sprintf(`PREFIX ex: PREFIX xsd: -INSERT DATA { - GRAPH { - <%s> ex:status "REL" ; - ex:releasedAt "%s"^^xsd:dateTime . - } -}`, orderURI, releasedAt) +PREFIX dcterms: + +DELETE { GRAPH { + <%s> ex:status ?old . +}} WHERE { GRAPH { + <%s> ex:status ?old . +}}; + +INSERT DATA { GRAPH { + <%s> ex:status "REL" ; + ex:releasedAt "%s"^^xsd:dateTime ; + dcterms:modified "%s"^^xsd:dateTime . +}}`, orderURI, orderURI, orderURI, releasedAt, releasedAt) } // insertReleaseToGraphDB pushes the REL transition to GraphDB. @@ -704,10 +717,11 @@ func (t *Traits) notifyEnrichment(o *Order) { log.Printf("← enrichment %s body=%s\n", resp.Status, string(msg)) } -// buildTECOInsertSPARQL constructs the SPARQL UPDATE statement that records an -// order's transition to TECO. It appends new triples (status, completion time, -// actual work hours) to the existing Order IRI created by buildSPARQL — the -// graph retains both the CRTD and TECO status triples as an audit trail. +// buildTECOInsertSPARQL records an order's transition to TECO. As with REL, +// the status triple replaces the previous one (REL or CRTD, whichever the +// order was sitting on) so the UI sees the current state. The completion +// timestamp, modified timestamp, and actual work hours are additive — they +// don't conflict with any prior triples on the order. func (t *Traits) buildTECOInsertSPARQL(o *Order) string { orderURI := "https://sinetiq.se/sap/MaintenanceOrder/" + o.ID completedAt := time.Now().UTC().Format(time.RFC3339Nano) @@ -715,13 +729,20 @@ func (t *Traits) buildTECOInsertSPARQL(o *Order) string { return fmt.Sprintf(`PREFIX ex: PREFIX xsd: -INSERT DATA { - GRAPH { - <%s> ex:status "TECO" ; - ex:completedAt "%s"^^xsd:dateTime ; - ex:actualWorkHours "%g"^^xsd:decimal . - } -}`, orderURI, completedAt, actualHours) +PREFIX dcterms: + +DELETE { GRAPH { + <%s> ex:status ?old . +}} WHERE { GRAPH { + <%s> ex:status ?old . +}}; + +INSERT DATA { GRAPH { + <%s> ex:status "TECO" ; + ex:completedAt "%s"^^xsd:dateTime ; + dcterms:modified "%s"^^xsd:dateTime ; + ex:actualWorkHours "%g"^^xsd:decimal . +}}`, orderURI, orderURI, orderURI, completedAt, completedAt, actualHours) } // updateEndpoint returns the SPARQL update URL by appending /statements to the