223 lines
6.3 KiB
Go
223 lines
6.3 KiB
Go
package platform
|
|
|
|
import (
|
|
collector "aquacontrolai/internal/engine/collector"
|
|
pg "aquacontrolai/internal/repository/postgres"
|
|
td "aquacontrolai/internal/repository/tdengine"
|
|
"context"
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"github.com/google/uuid"
|
|
"math"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
type History struct {
|
|
PG *pg.Store
|
|
TD *td.Store
|
|
Collector interface {
|
|
Latest(uuid.UUID) *collector.LatestValue
|
|
}
|
|
}
|
|
type Series struct {
|
|
PointID uuid.UUID `json:"point_id"`
|
|
PointName string `json:"point_name"`
|
|
DataType string `json:"data_type"`
|
|
Unit *string `json:"unit"`
|
|
Sampled bool `json:"sampled"`
|
|
RawCount int `json:"raw_count"`
|
|
SampleCount int `json:"sample_count"`
|
|
Data []td.Sample `json:"data"`
|
|
}
|
|
|
|
func (h *History) Tree(ctx context.Context) ([]map[string]any, error) {
|
|
points, e := h.PG.ListPoints(ctx, "collection", "", true)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
deletedIDs, e := h.PG.ListDeletedCollectionPointIDs(ctx)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
deviceActive, e := h.PG.ListDeviceActivity(ctx)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
deleted := make(map[uuid.UUID]bool, len(deletedIDs))
|
|
for _, id := range deletedIDs {
|
|
deleted[id] = true
|
|
}
|
|
groups := map[string][]map[string]any{}
|
|
for _, p := range points {
|
|
active := p.Enabled && p.StoreHistory && deviceActive[p.DeviceID]
|
|
has := h.TD.HasData(ctx, p.ID)
|
|
if !active && !has {
|
|
continue
|
|
}
|
|
life := "active"
|
|
if !active {
|
|
life = "archived"
|
|
}
|
|
var latest any
|
|
if h.Collector != nil && life == "active" {
|
|
latest = h.Collector.Latest(p.ID)
|
|
}
|
|
groups[p.GroupName] = append(groups[p.GroupName], map[string]any{"id": p.ID, "name": p.Name, "type": "collection", "data_type": p.DataType, "unit": p.Unit, "history_interval": p.HistoryInterval, "device_id": p.DeviceID, "device_name": p.DeviceName, "group_name": p.GroupName, "lifecycle_status": life, "has_history_data": has, "can_cleanup": deleted[p.ID], "latest_value": latest})
|
|
}
|
|
names := make([]string, 0, len(groups))
|
|
for n := range groups {
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
tree := []map[string]any{}
|
|
for _, n := range names {
|
|
hash := sha256.Sum256([]byte(n))
|
|
tree = append(tree, map[string]any{"id": fmt.Sprintf("group_%x", hash[:8]), "name": n, "type": "group", "children": groups[n]})
|
|
}
|
|
tree = append(tree, map[string]any{"id": "internal-data", "name": "内部数据", "type": "reserved", "children": []map[string]any{{"id": "placeholder", "name": "暂无数据", "type": "placeholder", "disabled": true}}})
|
|
return tree, nil
|
|
}
|
|
|
|
// CleanupDeletedArchives drops TDengine history tables only for collection
|
|
// points that have been logically deleted (or belong to a deleted device).
|
|
func (h *History) CleanupDeletedArchives(ctx context.Context) (int, error) {
|
|
ids, e := h.PG.ListDeletedCollectionPointIDs(ctx)
|
|
if e != nil {
|
|
return 0, e
|
|
}
|
|
return h.TD.DropTables(ctx, ids)
|
|
}
|
|
func (h *History) Query(ctx context.Context, ids []uuid.UUID, start, end time.Time, max int) ([]Series, error) {
|
|
meta, e := h.PG.ListPoints(ctx, "collection", "", true)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
byID := map[uuid.UUID]pg.PointRow{}
|
|
for _, p := range meta {
|
|
byID[p.ID] = p
|
|
}
|
|
out := make([]Series, 0, len(ids))
|
|
for _, id := range ids {
|
|
p, ok := byID[id]
|
|
if !ok {
|
|
return nil, fmt.Errorf("点位元数据不存在: %s", id)
|
|
}
|
|
data, e := h.TD.Query(ctx, id, start, end)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
raw := len(data)
|
|
if raw > max {
|
|
data = minMax(data, max)
|
|
}
|
|
out = append(out, Series{id, p.Name, p.DataType, p.Unit, raw > len(data), raw, len(data), data})
|
|
}
|
|
return out, nil
|
|
}
|
|
func minMax(data []td.Sample, max int) []td.Sample {
|
|
if len(data) <= max {
|
|
return data
|
|
}
|
|
keep := map[int]bool{0: true, len(data) - 1: true}
|
|
bucket := float64(len(data)) / float64(max/2)
|
|
for b := 0; b < max/2; b++ {
|
|
lo, hi := int(float64(b)*bucket), int(float64(b+1)*bucket)
|
|
if hi > len(data) {
|
|
hi = len(data)
|
|
}
|
|
minI, maxI := -1, -1
|
|
for i := lo; i < hi; i++ {
|
|
if data[i].Value == nil {
|
|
keep[i] = true
|
|
continue
|
|
}
|
|
if minI < 0 || *data[i].Value < *data[minI].Value {
|
|
minI = i
|
|
}
|
|
if maxI < 0 || *data[i].Value > *data[maxI].Value {
|
|
maxI = i
|
|
}
|
|
if i > 0 && data[i].Quality != data[i-1].Quality {
|
|
keep[i-1] = true
|
|
keep[i] = true
|
|
}
|
|
}
|
|
if minI >= 0 {
|
|
keep[minI] = true
|
|
keep[maxI] = true
|
|
}
|
|
}
|
|
idx := make([]int, 0, len(keep))
|
|
for i := range keep {
|
|
idx = append(idx, i)
|
|
}
|
|
sort.Ints(idx)
|
|
if len(idx) > max {
|
|
idx = idx[:max]
|
|
}
|
|
out := make([]td.Sample, 0, len(idx))
|
|
for _, i := range idx {
|
|
out = append(out, data[i])
|
|
}
|
|
return out
|
|
}
|
|
|
|
type TableValue struct {
|
|
Value *float64 `json:"value"`
|
|
Quality string `json:"quality"`
|
|
QualityReason *string `json:"quality_reason"`
|
|
MatchedTS *time.Time `json:"matched_ts"`
|
|
}
|
|
type TableColumn struct {
|
|
PointID uuid.UUID `json:"point_id"`
|
|
PointName string `json:"point_name"`
|
|
Unit *string `json:"unit"`
|
|
Data []TableValue `json:"data"`
|
|
}
|
|
type TableResult struct {
|
|
TimeColumn []time.Time `json:"time_column"`
|
|
Columns []TableColumn `json:"columns"`
|
|
}
|
|
|
|
func (h *History) QueryTable(ctx context.Context, ids []uuid.UUID, start, end time.Time, minutes int) (TableResult, error) {
|
|
shanghai := time.FixedZone("Asia/Shanghai", 8*60*60)
|
|
start = start.In(shanghai)
|
|
end = end.In(shanghai)
|
|
step := time.Duration(minutes) * time.Minute
|
|
times := []time.Time{}
|
|
for t := start; !t.After(end); t = t.Add(step) {
|
|
times = append(times, t)
|
|
}
|
|
series, e := h.Query(ctx, ids, start.Add(-step/2), end.Add(step/2), 10000)
|
|
if e != nil {
|
|
return TableResult{}, e
|
|
}
|
|
res := TableResult{TimeColumn: times, Columns: []TableColumn{}}
|
|
for _, s := range series {
|
|
col := TableColumn{s.PointID, s.PointName, s.Unit, make([]TableValue, 0, len(times))}
|
|
for _, target := range times {
|
|
best := -1
|
|
bestDist := time.Duration(math.MaxInt64)
|
|
for i, x := range s.Data {
|
|
d := x.TS.Sub(target)
|
|
if d < 0 {
|
|
d = -d
|
|
}
|
|
if d <= step/2 && (d < bestDist || (d == bestDist && best >= 0 && x.TS.Before(s.Data[best].TS))) {
|
|
best, bestDist = i, d
|
|
}
|
|
}
|
|
if best < 0 {
|
|
col.Data = append(col.Data, TableValue{nil, "none", nil, nil})
|
|
} else {
|
|
x := s.Data[best]
|
|
ts := x.TS
|
|
col.Data = append(col.Data, TableValue{x.Value, x.Quality, x.QualityReason, &ts})
|
|
}
|
|
}
|
|
res.Columns = append(res.Columns, col)
|
|
}
|
|
return res, nil
|
|
}
|