Files
fivenode_go/app/api/session-stats.prg
Charles KWON OhJun ed956a1504 revert: drop AP_RPUTS single-arg patch and fn_HGet renames
Both workarounds existed because Five was missing two features that
just landed upstream:

  Five 7629f95 (variadic PValue) makes FUNCTION foo(...) / PValue()
  actually return the caller's variadic args instead of the caller's
  first LOCAL slot. AP_RPUTS / AP_ECHO can go back to their `( ... )`
  signature now.

  Five f3e0ffe (file-local STATIC FUNCTION) gives each .prg its own
  namespace for `STATIC FUNCTION name`, so the seven duplicate
  `STATIC FUNCTION fn_HGet` definitions across labdb's api/*.prg
  files no longer collide. The sed-renamed unique names can revert
  to the upstream definitions.

Files

  app/bridge/bridge_request.prg     ← cp from fivenode/native/
  app/api/{device-status,record-detail,records-list,session-detail,
           session-stats,sessions-list,session-export}.prg
                                    ← cp from fivenode/labdb/api/

fivenode-upstream is now byte-identical to fivenode_go's app/ copy
of those files. No more "// fivenode_go patch" comments, no more
file-prefix renames.

Verified end-to-end against the same live postgres@16 cluster:
  /api/admin-stats.prg    -> {"active_sessions":1,"devices":2,...}
  /api/sessions-list.prg  -> 2 rows w/ full session data
  /api/admin-devices.prg  -> 2 devices w/ api_key, created_at
  /api/hello.prg          -> hello (unchanged)
  /                       -> 200 text/html (static)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:36:14 +09:00

135 lines
4.9 KiB
Plaintext

// api/session-stats.prg — Compute session statistics (FLOAT MATH IN PRG)
//
// ctx:
// session_id — session ID
// duration_sec — duration in seconds (from server.js)
// record_count — total records
// rows — JSON array of records [{sensor: {tempC, batteryMv, ...}, channels: [...]}]
//
// PRG handles all numeric aggregation: mean, min, max, stddev for 6 channels.
// Float computation is done here (Harbour double precision) — easier maintenance
// than maintaining SQL JSONB aggregates in server.js.
//
// Copyright (c) 2026 Charles KWON OhJun
FUNCTION Main()
LOCAL aRows, hRow, nDuration, nCount, hSensor, aChannels, hCh
LOCAL nTempSum, nTempMin, nTempMax, nTempCnt
LOCAL nBatStart, nBatEnd
LOCAL aChStats, i, ch, peakSums, peakMins, peakMaxs, peakCounts, peakSqs
LOCAL nMean, nVar, nStdDev, hOutCh, aChOut
LOCAL cDur, nH, nM, nS
aRows := hb_jsonDecode(ctx_get("rows", "[]"))
nDuration := Val(ctx_get("duration_sec", "0"))
nCount := Val(ctx_get("record_count", "0"))
IF ! HB_ISARRAY(aRows) ; aRows := {} ; ENDIF
// ─ Init accumulators ──
nTempSum := 0
nTempMin := 999.9
nTempMax := -999.9
nTempCnt := 0
nBatStart := NIL
nBatEnd := NIL
// 6 channels: peak sum / min / max / count / sum-of-squares (for stddev)
peakSums := { 0, 0, 0, 0, 0, 0 }
peakMins := { 99999, 99999, 99999, 99999, 99999, 99999 }
peakMaxs := { -99999, -99999, -99999, -99999, -99999, -99999 }
peakCounts := { 0, 0, 0, 0, 0, 0 }
peakSqs := { 0, 0, 0, 0, 0, 0 }
// ─ Iterate records ──
FOR EACH hRow IN aRows
// sensor
hSensor := fn_HGet(hRow, "sensor")
IF HB_ISHASH(hSensor)
IF hb_HHasKey(hSensor, "tempC") .AND. ValType(hSensor["tempC"]) == "N"
nTempSum += hSensor["tempC"]
nTempCnt++
IF hSensor["tempC"] < nTempMin ; nTempMin := hSensor["tempC"] ; ENDIF
IF hSensor["tempC"] > nTempMax ; nTempMax := hSensor["tempC"] ; ENDIF
ENDIF
IF hb_HHasKey(hSensor, "batteryMv") .AND. ValType(hSensor["batteryMv"]) == "N"
IF nBatStart == NIL ; nBatStart := hSensor["batteryMv"] ; ENDIF
nBatEnd := hSensor["batteryMv"]
ENDIF
ENDIF
// channels
aChannels := fn_HGet(hRow, "channels")
IF HB_ISARRAY(aChannels)
FOR EACH hCh IN aChannels
IF HB_ISHASH(hCh) .AND. hb_HHasKey(hCh, "ch") .AND. hb_HHasKey(hCh, "peak")
i := hCh["ch"] + 1 // 1-based
IF i >= 1 .AND. i <= 6 .AND. ValType(hCh["peak"]) == "N"
peakSums[i] += hCh["peak"]
peakSqs[i] += hCh["peak"] * hCh["peak"]
peakCounts[i]++
IF hCh["peak"] < peakMins[i] ; peakMins[i] := hCh["peak"] ; ENDIF
IF hCh["peak"] > peakMaxs[i] ; peakMaxs[i] := hCh["peak"] ; ENDIF
ENDIF
ENDIF
NEXT
ENDIF
NEXT
// ─ Compute per-channel stats (mean, stddev) ──
aChOut := {}
FOR i := 1 TO 6
ch := i - 1
IF peakCounts[i] > 0
nMean := peakSums[i] / peakCounts[i]
// population variance: E[x^2] - (E[x])^2
nVar := (peakSqs[i] / peakCounts[i]) - (nMean * nMean)
IF nVar < 0 ; nVar := 0 ; ENDIF
nStdDev := Sqrt(nVar)
hOutCh := { ;
"ch" => ch, ;
"peakAvg" => Round(nMean, 2), ;
"peakMin" => peakMins[i], ;
"peakMax" => peakMaxs[i], ;
"peakStdDev" => Round(nStdDev, 2), ;
"samples" => peakCounts[i] ;
}
ELSE
hOutCh := { "ch" => ch, "peakAvg" => NIL, "peakMin" => NIL, "peakMax" => NIL, "peakStdDev" => NIL, "samples" => 0 }
ENDIF
AAdd(aChOut, hOutCh)
NEXT
// ─ Format duration HH:MM:SS ──
nH := Int(nDuration / 3600)
nM := Int((nDuration % 3600) / 60)
nS := Int(nDuration % 60)
cDur := iif(nDuration > 0, ;
StrZero(nH, 2) + ":" + StrZero(nM, 2) + ":" + StrZero(nS, 2), ;
"")
AP_JSONRESPONSE({ ;
"sessionId" => ctx_get("session_id", ""), ;
"recordCount" => nCount, ;
"duration" => cDur, ;
"channels" => aChOut, ;
"temperature" => { ;
"avg" => iif(nTempCnt > 0, Round(nTempSum / nTempCnt, 2), NIL), ;
"min" => iif(nTempCnt > 0, nTempMin, NIL), ;
"max" => iif(nTempCnt > 0, nTempMax, NIL) ;
}, ;
"battery" => { ;
"startMv" => nBatStart, ;
"endMv" => nBatEnd, ;
"drainMv" => iif(nBatStart != NIL .AND. nBatEnd != NIL, nBatStart - nBatEnd, NIL), ;
"drainPctPerHour" => iif(nBatStart != NIL .AND. nBatEnd != NIL .AND. nDuration > 0, ;
Round(((nBatStart - nBatEnd) / nBatStart) * 3600 / nDuration * 100, 2), NIL) ;
} ;
})
RETURN NIL
STATIC FUNCTION fn_HGet(h, k)
IF HB_ISHASH(h) .AND. hb_HHasKey(h, k) ; RETURN h[k] ; ENDIF
RETURN NIL