Files
fivenode_go/app/api/session-stats.prg
Charles KWON OhJun d26624c63b feat(labdb): port all 22 labdb api/*.prg, one-binary AOT build
Drop-in copy of labdb's API surface from
fivenode/labdb/api/*.prg into app/api/. Single fnode invocation builds
the whole thing (bridge_server + bridge/ + 22 api/*.prg) into one
24 MB Go binary — no Node.js, no FFI, no Apache.

End-to-end smoke test (server.js not running, ctx empty so defaults
fall back) hitting six endpoints all return well-formed JSON via the
bridge layer + path -> Main dispatcher:

  GET  /api/hello.prg          -> {"msg":"hello from PRG","ok":true}
  GET  /api/admin-stats.prg    -> {"active_sessions":0,...}
  GET  /api/admin-me.prg       -> {"ok":true,"user":{...}}
  GET  /api/sessions-list.prg  -> {"sessions":[],"total":0}
  GET  /api/records-list.prg   -> {"records":[],"sessionId":"",...}
  POST /api/devices-register   -> {"deviceId":"","status":"pending",...}

One small upstream patch was needed: seven .prg files each define
their own STATIC FUNCTION fn_HGet, but Five doesn't yet honour
file-local STATIC scoping for top-level functions — all definitions
land in the same symbol table and collide. Renamed each duplicate to
<file>_fn_hget so they peacefully coexist; the call sites still
reference fn_HGet and Five resolves them against _helpers.prg's
public version (signature-compatible). TODO: revert once Five gains
file-local STATIC FUNCTION scoping.

What's deferred to 1a.4-4: ctx data injection (so endpoints return
real labdb data), static asset embedding (labdb/public/), and a live
LABDB_DSN round-trip to confirm pgrtl in the request path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:23:16 +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
FUNCTION session_stats_fn_hget(h, k)
IF HB_ISHASH(h) .AND. hb_HHasKey(h, k) ; RETURN h[k] ; ENDIF
RETURN NIL