mirror of
https://github.com/ruvnet/RuView
synced 2026-07-25 17:51:48 +00:00
feat: happiness scoring pipeline + ESP32 swarm with Cognitum Seed (#285)
* feat: happiness scoring pipeline with ESP32 swarm + Cognitum Seed coordinator ADR-065: Hotel guest happiness scoring from WiFi CSI physiological proxies. ADR-066: ESP32 swarm with Cognitum Seed as coordinator for multi-zone analytics. Firmware: - swarm_bridge.c/h: FreeRTOS task on Core 0, HTTP client with Bearer auth, registers with Seed, sends heartbeats (30s) and happiness vectors (5s) - nvs_config: seed_url, seed_token, zone_name, swarm intervals - provision.py: --seed-url, --seed-token, --zone CLI args - esp32-hello-world: capability discovery firmware for 4MB ESP32-S3 variant WASM edge modules: - exo_happiness_score.rs: 8-dim happiness vector from gait speed, stride regularity, movement fluidity, breathing calm, posture, dwell time (events 690-694, 11 tests, ESP32-optimized buffers + event decimation) - ghost_hunter.rs standalone binary: 5.7 KB WASM, feature-gated default pipeline RuView Live: - --mode happiness dashboard with bar visualization - --seed flag for Cognitum Seed bridge (urllib, background POST) - HappinessScorer + SeedBridge classes (stdlib only, no deps) Examples: - seed_query.py: CLI tool (status, search, witness, monitor, report) - provision_swarm.sh: batch provisioning for multi-node deployment - happiness_vector_schema.json: 8-dim vector format documentation Verified live: ESP32 on COM5 (4MB flash) registered with Seed at 10.1.10.236, vectors flowing, witness chain growing (epoch 455, chain 1108). Co-Authored-By: claude-flow <ruv@ruv.net> * ci: raise firmware binary size gate to 1100 KB for HTTP client stack The swarm bridge (ADR-066) adds esp_http_client for Seed communication, which pulls in the HTTP/TLS stack (~150 KB). Binary grew from ~978 KB to ~1077 KB. Raise the gate from 950 KB to 1100 KB. Still fits comfortably in both 4MB (1856 KB OTA slot, 43% free) and 8MB flash variants. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Happiness Vector",
|
||||
"description": "8-dimensional happiness feature vector for Cognitum Seed ingestion (ADR-065). Each dimension is normalized to [0, 1] where higher values indicate more positive affect.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"vectors": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"prefixItems": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Vector ID: node_id * 1000000 + type_offset + timestamp_component. Type offsets: 0=registration, 100000=heartbeat, 200000=happiness."
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"minItems": 8,
|
||||
"maxItems": 8,
|
||||
"description": "8-dim happiness vector: [happiness_score, gait_speed, stride_regularity, movement_fluidity, breathing_calm, posture_score, dwell_factor, social_energy]"
|
||||
}
|
||||
],
|
||||
"minItems": 2,
|
||||
"maxItems": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["vectors"],
|
||||
|
||||
"$defs": {
|
||||
"dimensions": {
|
||||
"type": "object",
|
||||
"description": "Happiness vector dimension definitions",
|
||||
"properties": {
|
||||
"dim_0_happiness_score": {
|
||||
"description": "Composite happiness [0=sad, 0.5=neutral, 1=happy]. Weighted sum of dims 1-6.",
|
||||
"weights": "gait=0.25, stride=0.15, fluidity=0.20, calm=0.20, posture=0.10, dwell=0.10"
|
||||
},
|
||||
"dim_1_gait_speed": {
|
||||
"description": "Walking speed from CSI phase rate-of-change. Happy people walk ~12% faster.",
|
||||
"source": "Phase Doppler shift",
|
||||
"units": "normalized phase delta / MAX_GAIT_SPEED"
|
||||
},
|
||||
"dim_2_stride_regularity": {
|
||||
"description": "Step interval consistency. Regular strides indicate confidence/positive affect.",
|
||||
"source": "Variance coefficient of step intervals (inverted)",
|
||||
"interpretation": "1.0=perfectly regular, 0.0=erratic/stumbling"
|
||||
},
|
||||
"dim_3_movement_fluidity": {
|
||||
"description": "Smoothness of body movement trajectory. Jerky motion indicates anxiety.",
|
||||
"source": "Phase second derivative (acceleration), inverted",
|
||||
"interpretation": "1.0=smooth/flowing, 0.0=jerky/hesitant"
|
||||
},
|
||||
"dim_4_breathing_calm": {
|
||||
"description": "Breathing rate mapped to calmness. Slow deep breathing = relaxed.",
|
||||
"source": "0.15-0.5 Hz phase oscillation (breathing proxy)",
|
||||
"interpretation": "1.0=calm (6-14 BPM), 0.0=rapid/stressed (>22 BPM)"
|
||||
},
|
||||
"dim_5_posture_score": {
|
||||
"description": "Upright vs slouched posture from RF scattering cross-section.",
|
||||
"source": "Amplitude coefficient of variation across subcarrier groups",
|
||||
"interpretation": "1.0=upright (wide spread), 0.0=slouched (narrow spread)"
|
||||
},
|
||||
"dim_6_dwell_factor": {
|
||||
"description": "How long the person stays in the sensing zone.",
|
||||
"source": "Fraction of recent frames with presence detected",
|
||||
"interpretation": "1.0=lingering (happy guests browse), 0.0=rushing through"
|
||||
},
|
||||
"dim_7_social_energy": {
|
||||
"description": "Group animation and interaction level.",
|
||||
"source": "Motion energy + dwell + heart rate proxy",
|
||||
"interpretation": "1.0=animated group interaction, 0.0=solitary/withdrawn"
|
||||
}
|
||||
}
|
||||
},
|
||||
"event_ids": {
|
||||
"type": "object",
|
||||
"description": "WASM edge module event IDs (690-694)",
|
||||
"properties": {
|
||||
"690_HAPPINESS_SCORE": "Composite happiness [0, 1] — emitted every frame",
|
||||
"691_GAIT_ENERGY": "Gait speed + stride regularity composite — emitted every 4th frame",
|
||||
"692_AFFECT_VALENCE": "Breathing calm + fluidity + posture composite — emitted every 4th frame",
|
||||
"693_SOCIAL_ENERGY": "Group animation level — emitted every 4th frame",
|
||||
"694_TRANSIT_DIRECTION": "1.0=entering, 0.0=exiting — emitted every 4th frame"
|
||||
}
|
||||
},
|
||||
"seed_id_scheme": {
|
||||
"type": "object",
|
||||
"description": "Vector ID encoding for Cognitum Seed",
|
||||
"properties": {
|
||||
"format": "node_id * 1000000 + type_offset + timestamp_component",
|
||||
"registration": "offset 0 (e.g. node 1 = 1000000)",
|
||||
"heartbeat": "offset 100000 + uptime_sec % 100000 (e.g. 1100042)",
|
||||
"happiness": "offset 200000 + ms_timestamp / 1000 % 100000 (e.g. 1212345)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/bin/bash
|
||||
# ESP32 Swarm Provisioning — ADR-065/066
|
||||
#
|
||||
# Provisions multiple ESP32-S3 nodes for a hotel happiness sensing deployment.
|
||||
# Each node gets WiFi credentials, a unique node_id, zone name, and Seed token.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - ESP-IDF Python venv with esptool and nvs_partition_gen
|
||||
# - Firmware already flashed to each ESP32
|
||||
# - Seed paired (obtain token via: curl -X POST http://169.254.42.1/api/v1/pair)
|
||||
#
|
||||
# Usage:
|
||||
# bash provision_swarm.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---- Configuration ----
|
||||
SSID="RedCloverWifi"
|
||||
PASSWORD="redclover2.4"
|
||||
SEED_URL="http://10.1.10.236"
|
||||
SEED_TOKEN="hyHVY4Ux6uBAh8FaQzF_9OwWCWMFB-YuM2OJ3Dcwdm8" # Replace with your token
|
||||
|
||||
PROVISION="../../firmware/esp32-csi-node/provision.py"
|
||||
|
||||
# ---- Node definitions: PORT NODE_ID ZONE ----
|
||||
NODES=(
|
||||
"COM5 1 lobby"
|
||||
"COM6 2 hallway"
|
||||
"COM8 3 restaurant"
|
||||
"COM9 4 pool"
|
||||
"COM10 5 conference"
|
||||
)
|
||||
|
||||
echo "========================================"
|
||||
echo " ESP32 Swarm Provisioning"
|
||||
echo " Seed: $SEED_URL"
|
||||
echo " WiFi: $SSID"
|
||||
echo " Nodes: ${#NODES[@]}"
|
||||
echo "========================================"
|
||||
echo
|
||||
|
||||
for entry in "${NODES[@]}"; do
|
||||
read -r port node_id zone <<< "$entry"
|
||||
echo "--- Node $node_id: $zone ($port) ---"
|
||||
python "$PROVISION" \
|
||||
--port "$port" \
|
||||
--ssid "$SSID" \
|
||||
--password "$PASSWORD" \
|
||||
--node-id "$node_id" \
|
||||
--seed-url "$SEED_URL" \
|
||||
--seed-token "$SEED_TOKEN" \
|
||||
--zone "$zone" \
|
||||
&& echo " OK" || echo " FAILED (device not connected?)"
|
||||
echo
|
||||
done
|
||||
|
||||
echo "========================================"
|
||||
echo " Provisioning complete."
|
||||
echo " Monitor with: python seed_query.py monitor --seed $SEED_URL --token $SEED_TOKEN"
|
||||
echo "========================================"
|
||||
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Cognitum Seed — Happiness Vector Query Tool
|
||||
|
||||
Query the Seed's vector store for happiness patterns across ESP32 swarm nodes.
|
||||
Demonstrates kNN search, drift monitoring, and witness chain verification.
|
||||
|
||||
Usage:
|
||||
python seed_query.py --seed http://10.1.10.236 --token <bearer_token>
|
||||
python seed_query.py --seed http://169.254.42.1 # USB link-local (no token needed)
|
||||
|
||||
Requirements:
|
||||
Python 3.7+ (stdlib only, no dependencies)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
|
||||
def api(base, path, token=None, method="GET", data=None):
|
||||
"""Make an API request to the Seed."""
|
||||
url = f"{base}{path}"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
body = json.dumps(data).encode() if data else None
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
return {"error": f"HTTP {e.code}", "detail": e.read().decode()[:200]}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def print_header(title):
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" {title}")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
|
||||
def cmd_status(args):
|
||||
"""Show Seed and swarm status."""
|
||||
print_header("Seed Status")
|
||||
s = api(args.seed, "/api/v1/status", args.token)
|
||||
if "error" in s:
|
||||
print(f" Error: {s['error']}")
|
||||
return
|
||||
print(f" Device: {s['device_id'][:8]}...")
|
||||
print(f" Vectors: {s['total_vectors']} (dim={s['dimension']})")
|
||||
print(f" Epoch: {s['epoch']}")
|
||||
print(f" Store: {s['file_size_bytes'] / 1024:.1f} KB")
|
||||
print(f" Uptime: {s['uptime_secs'] // 3600}h {(s['uptime_secs'] % 3600) // 60}m")
|
||||
print(f" Witness: {s['witness_chain_length']} entries")
|
||||
|
||||
print_header("Drift Detection")
|
||||
d = api(args.seed, "/api/v1/sensor/drift/status", args.token)
|
||||
if "error" not in d:
|
||||
print(f" Drifting: {d.get('drifting', False)}")
|
||||
print(f" Score: {d.get('current_drift_score', 0):.4f}")
|
||||
print(f" Detectors: {d.get('detectors_active', 0)} active")
|
||||
print(f" Total: {d.get('detections_total', 0)} detections")
|
||||
|
||||
|
||||
def cmd_search(args):
|
||||
"""Search for similar happiness vectors."""
|
||||
print_header("Happiness kNN Search")
|
||||
|
||||
# Reference vectors for common moods
|
||||
refs = {
|
||||
"happy": [0.8, 0.7, 0.9, 0.8, 0.6, 0.7, 0.9, 0.5],
|
||||
"neutral": [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5],
|
||||
"stressed":[0.2, 0.3, 0.2, 0.2, 0.3, 0.3, 0.2, 0.7],
|
||||
}
|
||||
|
||||
query = refs.get(args.mood, refs["happy"])
|
||||
print(f" Query mood: {args.mood}")
|
||||
print(f" Vector: [{', '.join(f'{v:.1f}' for v in query)}]")
|
||||
print(f" k: {args.k}")
|
||||
print()
|
||||
|
||||
result = api(args.seed, "/api/v1/store/search", args.token,
|
||||
method="POST", data={"vector": query, "k": args.k})
|
||||
|
||||
if "error" in result:
|
||||
print(f" Error: {result['error']}")
|
||||
return
|
||||
|
||||
neighbors = result.get("neighbors", result.get("results", []))
|
||||
if not neighbors:
|
||||
print(" No results found.")
|
||||
return
|
||||
|
||||
print(f" {'ID':>10} {'Distance':>10} {'Vector'}")
|
||||
print(f" {'-'*10} {'-'*10} {'-'*40}")
|
||||
for n in neighbors:
|
||||
vid = n.get("id", "?")
|
||||
dist = n.get("distance", n.get("dist", 0))
|
||||
vec = n.get("vector", n.get("values", []))
|
||||
vec_str = "[" + ", ".join(f"{v:.2f}" for v in vec[:4]) + ", ...]" if len(vec) > 4 else str(vec)
|
||||
print(f" {vid:>10} {dist:>10.4f} {vec_str}")
|
||||
|
||||
|
||||
def cmd_witness(args):
|
||||
"""Show the witness chain for audit trail."""
|
||||
print_header("Witness Chain (Audit Trail)")
|
||||
|
||||
epoch = api(args.seed, "/api/v1/custody/epoch", args.token)
|
||||
if "error" not in epoch:
|
||||
print(f" Current epoch: {epoch.get('epoch', '?')}")
|
||||
head = epoch.get("witness_head", "?")
|
||||
print(f" Chain head: {head[:16]}..." if len(head) > 16 else f" Chain head: {head}")
|
||||
|
||||
chain = api(args.seed, "/api/v1/cognitive/status", args.token)
|
||||
if "error" not in chain:
|
||||
cv = chain.get("chain_valid", {})
|
||||
print(f" Chain valid: {cv.get('valid', '?')}")
|
||||
print(f" Chain length: {cv.get('chain_length', '?')}")
|
||||
print(f" Epoch range: {cv.get('first_epoch', '?')} - {cv.get('last_epoch', '?')}")
|
||||
|
||||
|
||||
def cmd_monitor(args):
|
||||
"""Live monitor happiness vectors flowing into the Seed."""
|
||||
print_header("Live Happiness Monitor")
|
||||
print(f" Polling every {args.interval}s (Ctrl+C to stop)")
|
||||
print()
|
||||
|
||||
prev_epoch = 0
|
||||
prev_vectors = 0
|
||||
|
||||
try:
|
||||
while True:
|
||||
s = api(args.seed, "/api/v1/status", args.token)
|
||||
if "error" in s:
|
||||
print(f" [{time.strftime('%H:%M:%S')}] Error: {s['error']}")
|
||||
time.sleep(args.interval)
|
||||
continue
|
||||
|
||||
epoch = s["epoch"]
|
||||
vectors = s["total_vectors"]
|
||||
new_v = vectors - prev_vectors if prev_vectors > 0 else 0
|
||||
new_e = epoch - prev_epoch if prev_epoch > 0 else 0
|
||||
|
||||
d = api(args.seed, "/api/v1/sensor/drift/status", args.token)
|
||||
drift = d.get("current_drift_score", 0) if "error" not in d else 0
|
||||
drifting = d.get("drifting", False) if "error" not in d else False
|
||||
|
||||
ts = time.strftime("%H:%M:%S")
|
||||
drift_str = f" DRIFT!" if drifting else ""
|
||||
print(f" [{ts}] epoch={epoch} vectors={vectors} (+{new_v}) "
|
||||
f"drift={drift:.4f} chain={s['witness_chain_length']}{drift_str}")
|
||||
|
||||
prev_epoch = epoch
|
||||
prev_vectors = vectors
|
||||
time.sleep(args.interval)
|
||||
except KeyboardInterrupt:
|
||||
print("\n Stopped.")
|
||||
|
||||
|
||||
def cmd_happiness_report(args):
|
||||
"""Generate a happiness report from stored vectors."""
|
||||
print_header("Happiness Report")
|
||||
|
||||
s = api(args.seed, "/api/v1/status", args.token)
|
||||
if "error" in s:
|
||||
print(f" Error: {s['error']}")
|
||||
return
|
||||
|
||||
print(f" Total vectors: {s['total_vectors']}")
|
||||
print(f" Store epoch: {s['epoch']}")
|
||||
print()
|
||||
|
||||
# Search for happiest and saddest vectors
|
||||
happy_ref = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.5]
|
||||
sad_ref = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5]
|
||||
|
||||
print(" Happiest moments (closest to ideal happy):")
|
||||
happy = api(args.seed, "/api/v1/store/search", args.token,
|
||||
method="POST", data={"vector": happy_ref, "k": 3})
|
||||
for n in happy.get("neighbors", happy.get("results", [])):
|
||||
dist = n.get("distance", n.get("dist", 0))
|
||||
vec = n.get("vector", n.get("values", []))
|
||||
score = vec[0] if vec else 0
|
||||
print(f" id={n.get('id','?'):>10} happiness={score:.2f} dist={dist:.4f}")
|
||||
|
||||
print()
|
||||
print(" Most stressed moments (closest to stressed reference):")
|
||||
sad = api(args.seed, "/api/v1/store/search", args.token,
|
||||
method="POST", data={"vector": sad_ref, "k": 3})
|
||||
for n in sad.get("neighbors", sad.get("results", [])):
|
||||
dist = n.get("distance", n.get("dist", 0))
|
||||
vec = n.get("vector", n.get("values", []))
|
||||
score = vec[0] if vec else 0
|
||||
print(f" id={n.get('id','?'):>10} happiness={score:.2f} dist={dist:.4f}")
|
||||
|
||||
# Drift status
|
||||
print()
|
||||
d = api(args.seed, "/api/v1/sensor/drift/status", args.token)
|
||||
if "error" not in d:
|
||||
if d.get("drifting"):
|
||||
print(f" WARNING: Mood drift detected (score={d['current_drift_score']:.4f})")
|
||||
print(f" This may indicate a change in guest satisfaction.")
|
||||
else:
|
||||
print(f" Mood stable (drift score={d.get('current_drift_score', 0):.4f})")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Happiness Vector Query Tool for Cognitum Seed",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s status --seed http://169.254.42.1
|
||||
%(prog)s search --seed http://10.1.10.236 --token TOKEN --mood happy
|
||||
%(prog)s monitor --seed http://10.1.10.236 --token TOKEN
|
||||
%(prog)s report --seed http://10.1.10.236 --token TOKEN
|
||||
%(prog)s witness --seed http://10.1.10.236 --token TOKEN
|
||||
"""
|
||||
)
|
||||
parser.add_argument("--seed", default="http://169.254.42.1",
|
||||
help="Seed base URL (default: USB link-local)")
|
||||
parser.add_argument("--token", default=None,
|
||||
help="Bearer token for WiFi access (not needed for USB)")
|
||||
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
sub.add_parser("status", help="Show Seed and swarm status")
|
||||
sub.add_parser("witness", help="Show witness chain audit trail")
|
||||
|
||||
p_search = sub.add_parser("search", help="kNN search for mood patterns")
|
||||
p_search.add_argument("--mood", default="happy",
|
||||
choices=["happy", "neutral", "stressed"])
|
||||
p_search.add_argument("--k", type=int, default=5)
|
||||
|
||||
p_monitor = sub.add_parser("monitor", help="Live monitor incoming vectors")
|
||||
p_monitor.add_argument("--interval", type=int, default=5)
|
||||
|
||||
sub.add_parser("report", help="Generate happiness report")
|
||||
|
||||
args = parser.parse_args()
|
||||
if not args.command:
|
||||
args.command = "status"
|
||||
|
||||
cmds = {
|
||||
"status": cmd_status,
|
||||
"search": cmd_search,
|
||||
"witness": cmd_witness,
|
||||
"monitor": cmd_monitor,
|
||||
"report": cmd_happiness_report,
|
||||
}
|
||||
cmds[args.command](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+259
-28
@@ -17,12 +17,15 @@ Usage:
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import serial
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
@@ -224,12 +227,153 @@ class BPEstimator:
|
||||
return round(max(80, min(200, sbp))), round(max(50, min(130, dbp)))
|
||||
|
||||
|
||||
class HappinessScorer:
|
||||
"""Multimodal happiness estimator fusing gait, breathing, and social signals."""
|
||||
|
||||
def __init__(self):
|
||||
self.gait_speed = WelfordStats()
|
||||
self.stride_regularity = WelfordStats()
|
||||
self.movement_fluidity = 0.5
|
||||
self.breathing_calm = 0.5
|
||||
self.posture_score = 0.5
|
||||
self.dwell_frames = 0
|
||||
self._prev_motion = 0.0
|
||||
self._motion_deltas = collections.deque(maxlen=30)
|
||||
self._br_baseline = WelfordStats()
|
||||
self._rssi_baseline = WelfordStats()
|
||||
|
||||
def update(self, motion_energy, br, hr, rssi):
|
||||
# Gait speed proxy from motion energy
|
||||
self.gait_speed.update(motion_energy)
|
||||
|
||||
# Stride regularity from motion delta consistency
|
||||
delta = abs(motion_energy - self._prev_motion)
|
||||
self._motion_deltas.append(delta)
|
||||
self._prev_motion = motion_energy
|
||||
if len(self._motion_deltas) >= 5:
|
||||
deltas = list(self._motion_deltas)
|
||||
mean_d = sum(deltas) / len(deltas)
|
||||
var_d = sum((x - mean_d) ** 2 for x in deltas) / len(deltas)
|
||||
self.stride_regularity.update(1.0 / (1.0 + math.sqrt(var_d)))
|
||||
|
||||
# Movement fluidity — smooth transitions score higher
|
||||
if len(self._motion_deltas) >= 3:
|
||||
recent = list(self._motion_deltas)[-3:]
|
||||
jerk = abs(recent[-1] - recent[-2]) - abs(recent[-2] - recent[-3]) if len(recent) == 3 else 0
|
||||
self.movement_fluidity = 0.9 * self.movement_fluidity + 0.1 * (1.0 / (1.0 + abs(jerk)))
|
||||
|
||||
# Breathing calm — low BR variance means relaxed
|
||||
if br > 0:
|
||||
self._br_baseline.update(br)
|
||||
if self._br_baseline.count >= 5:
|
||||
br_z = self._br_baseline.z_score(br)
|
||||
self.breathing_calm = 0.9 * self.breathing_calm + 0.1 * max(0.0, 1.0 - br_z / 3.0)
|
||||
|
||||
# Posture proxy from RSSI stability
|
||||
if rssi != 0:
|
||||
self._rssi_baseline.update(rssi)
|
||||
if self._rssi_baseline.count >= 5:
|
||||
rssi_z = self._rssi_baseline.z_score(rssi)
|
||||
self.posture_score = 0.9 * self.posture_score + 0.1 * max(0.0, 1.0 - rssi_z / 3.0)
|
||||
|
||||
# Dwell — presence accumulation
|
||||
if motion_energy > 0.01 or br > 0:
|
||||
self.dwell_frames += 1
|
||||
|
||||
def compute(self):
|
||||
# Normalize gait energy to 0-1 range
|
||||
gait_e = min(1.0, self.gait_speed.mean / 5.0) if self.gait_speed.count > 0 else 0.0
|
||||
|
||||
# Stride regularity average
|
||||
stride_r = min(1.0, self.stride_regularity.mean) if self.stride_regularity.count > 0 else 0.5
|
||||
|
||||
# Dwell factor — saturates after ~300 frames (~5 min at 1 Hz)
|
||||
dwell_factor = min(1.0, self.dwell_frames / 300.0)
|
||||
|
||||
# Weighted happiness score
|
||||
happiness = (
|
||||
0.25 * gait_e
|
||||
+ 0.15 * stride_r
|
||||
+ 0.20 * self.movement_fluidity
|
||||
+ 0.20 * self.breathing_calm
|
||||
+ 0.10 * self.posture_score
|
||||
+ 0.10 * dwell_factor
|
||||
)
|
||||
happiness = max(0.0, min(1.0, happiness))
|
||||
|
||||
# Affect valence: breathing_calm and fluidity dominant
|
||||
affect_valence = 0.5 * self.breathing_calm + 0.3 * self.movement_fluidity + 0.2 * stride_r
|
||||
|
||||
# Social energy: gait + dwell
|
||||
social_energy = 0.6 * gait_e + 0.4 * dwell_factor
|
||||
|
||||
vector = [
|
||||
happiness, gait_e, stride_r, self.movement_fluidity,
|
||||
self.breathing_calm, self.posture_score, dwell_factor, affect_valence,
|
||||
]
|
||||
|
||||
return {
|
||||
"happiness": happiness,
|
||||
"gait_energy": gait_e,
|
||||
"affect_valence": affect_valence,
|
||||
"social_energy": social_energy,
|
||||
"vector": vector,
|
||||
}
|
||||
|
||||
|
||||
class SeedBridge:
|
||||
"""HTTP bridge to Cognitum Seed for happiness vector ingestion."""
|
||||
|
||||
def __init__(self, base_url):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self._last_drift = None
|
||||
self._drift_lock = threading.Lock()
|
||||
|
||||
def ingest(self, vector, metadata=None):
|
||||
"""POST happiness vector to Seed in a background thread."""
|
||||
payload = json.dumps({"vector": vector, "metadata": metadata or {}}).encode()
|
||||
|
||||
def _post():
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{self.base_url}/api/v1/store/ingest",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
except Exception:
|
||||
pass # silently ignore connection errors
|
||||
|
||||
threading.Thread(target=_post, daemon=True).start()
|
||||
|
||||
def get_drift(self):
|
||||
"""GET drift status from Seed. Returns dict or None."""
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{self.base_url}/api/v1/sensor/drift/status",
|
||||
method="GET",
|
||||
)
|
||||
resp = urllib.request.urlopen(req, timeout=3)
|
||||
data = json.loads(resp.read().decode())
|
||||
with self._drift_lock:
|
||||
self._last_drift = data
|
||||
return data
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@property
|
||||
def last_drift(self):
|
||||
with self._drift_lock:
|
||||
return self._last_drift
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# Sensor Hub
|
||||
# ====================================================================
|
||||
|
||||
class SensorHub:
|
||||
def __init__(self):
|
||||
def __init__(self, seed_url=None):
|
||||
self.lock = threading.Lock()
|
||||
self.mw_hr = 0.0
|
||||
self.mw_br = 0.0
|
||||
@@ -254,6 +398,10 @@ class SensorHub:
|
||||
self.coherence_mw = CoherenceScorer()
|
||||
self.coherence_csi = CoherenceScorer()
|
||||
self.bp = BPEstimator()
|
||||
# Happiness + Seed
|
||||
self.happiness = HappinessScorer()
|
||||
self.seed = SeedBridge(seed_url) if seed_url else None
|
||||
self._last_seed_ingest = 0.0
|
||||
|
||||
def update_mw(self, **kw):
|
||||
with self.lock:
|
||||
@@ -283,6 +431,13 @@ class SensorHub:
|
||||
if rssi != 0:
|
||||
self.longitudinal.observe("rssi", rssi)
|
||||
self.coherence_csi.update(min(1.0, max(0.0, (rssi + 90) / 50)))
|
||||
# Feed happiness scorer
|
||||
self.happiness.update(
|
||||
motion_energy=kw.get("motion", self.csi_motion),
|
||||
br=kw.get("br", self.csi_br),
|
||||
hr=kw.get("hr", self.csi_hr),
|
||||
rssi=rssi,
|
||||
)
|
||||
|
||||
def add_event(self, msg):
|
||||
with self.lock:
|
||||
@@ -337,6 +492,18 @@ class SensorHub:
|
||||
if d:
|
||||
drifts.append(d)
|
||||
|
||||
# Happiness
|
||||
happy = self.happiness.compute()
|
||||
|
||||
# Seed ingestion every 5 seconds
|
||||
now = time.time()
|
||||
if self.seed and now - self._last_seed_ingest >= 5.0:
|
||||
self._last_seed_ingest = now
|
||||
self.seed.ingest(happy["vector"], {
|
||||
"hr": fused_hr, "br": fused_br, "rssi": self.csi_rssi,
|
||||
"presence": self.mw_presence or self.csi_presence,
|
||||
})
|
||||
|
||||
return {
|
||||
"hr": fused_hr, "hr_src": hr_src,
|
||||
"br": fused_br, "sbp": sbp, "dbp": dbp,
|
||||
@@ -350,6 +517,11 @@ class SensorHub:
|
||||
"fall": self.csi_fall, "drifts": drifts,
|
||||
"events": list(self.events),
|
||||
"longitudinal": self.longitudinal.summary(),
|
||||
"happiness": happy["happiness"],
|
||||
"gait_energy": happy["gait_energy"],
|
||||
"affect_valence": happy["affect_valence"],
|
||||
"social_energy": happy["social_energy"],
|
||||
"happiness_vector": happy["vector"],
|
||||
}
|
||||
|
||||
|
||||
@@ -426,21 +598,40 @@ def reader_csi(port, baud, hub, stop):
|
||||
# Display
|
||||
# ====================================================================
|
||||
|
||||
def run_display(hub, duration, interval):
|
||||
def _happiness_bar(value, width=10):
|
||||
"""Render a bar like [====------] 0.62"""
|
||||
filled = int(round(value * width))
|
||||
return "[" + "=" * filled + "-" * (width - filled) + "]"
|
||||
|
||||
|
||||
def run_display(hub, duration, interval, mode="vitals"):
|
||||
start = time.time()
|
||||
last = 0
|
||||
|
||||
print()
|
||||
print("=" * 80)
|
||||
print(" RuView Live — Ambient Intelligence + RuVector Signal Processing")
|
||||
if mode == "happiness":
|
||||
print(" RuView Live — Happiness + Cognitum Seed Dashboard")
|
||||
else:
|
||||
print(" RuView Live — Ambient Intelligence + RuVector Signal Processing")
|
||||
print("=" * 80)
|
||||
print()
|
||||
hdr = (f"{'s':>4} {'HR':>4} {'BR':>3} {'BP':>7} {'Stress':>8} "
|
||||
f"{'SDNN':>5} {'RMSSD':>5} {'LF/HF':>5} "
|
||||
f"{'Pres':>4} {'Dist':>5} {'Lux':>5} {'RSSI':>5} "
|
||||
f"{'Coh':>4} {'CSI#':>5}")
|
||||
print(hdr)
|
||||
print("-" * 80)
|
||||
|
||||
if mode == "happiness":
|
||||
hdr = (f"{'s':>4} {'Happy':>16} {'Gait':>5} {'Calm':>5} "
|
||||
f"{'Social':>6} {'Pres':>4} {'RSSI':>5} {'Seed':>6} {'CSI#':>5}")
|
||||
print(hdr)
|
||||
print("-" * 80)
|
||||
else:
|
||||
hdr = (f"{'s':>4} {'HR':>4} {'BR':>3} {'BP':>7} {'Stress':>8} "
|
||||
f"{'SDNN':>5} {'RMSSD':>5} {'LF/HF':>5} "
|
||||
f"{'Pres':>4} {'Dist':>5} {'Lux':>5} {'RSSI':>5} "
|
||||
f"{'Coh':>4} {'CSI#':>5}")
|
||||
print(hdr)
|
||||
print("-" * 80)
|
||||
|
||||
# Periodic Seed drift check (every 15s)
|
||||
_last_drift_check = 0.0
|
||||
|
||||
while time.time() - start < duration:
|
||||
time.sleep(0.5)
|
||||
@@ -451,23 +642,52 @@ def run_display(hub, duration, interval):
|
||||
|
||||
d = hub.compute()
|
||||
|
||||
hr_s = f"{d['hr']:>4.0f}" if d["hr"] > 0 else " —"
|
||||
br_s = f"{d['br']:>3.0f}" if d["br"] > 0 else " —"
|
||||
bp_s = f"{d['sbp']:>3}/{d['dbp']:<3}" if d["sbp"] > 0 else " —/— "
|
||||
sdnn_s = f"{d['sdnn']:>5.0f}" if d["sdnn"] > 0 else " — "
|
||||
rmssd_s = f"{d['rmssd']:>5.0f}" if d["rmssd"] > 0 else " — "
|
||||
lfhf_s = f"{d['lf_hf']:>5.2f}" if d["sdnn"] > 0 else " — "
|
||||
pres_s = "YES" if d["presence"] else " no"
|
||||
dist_s = f"{d['distance']:>4.0f}cm" if d["distance"] > 0 else " — "
|
||||
lux_s = f"{d['lux']:>5.1f}" if d["lux"] > 0 else " — "
|
||||
rssi_s = f"{d['rssi']:>5}" if d["rssi"] != 0 else " — "
|
||||
coh = max(d["coh_mw"], d["coh_csi"])
|
||||
coh_s = f"{coh:>.2f}"
|
||||
if mode == "happiness":
|
||||
h = d["happiness"]
|
||||
bar = _happiness_bar(h)
|
||||
gait_s = f"{d['gait_energy']:>5.2f}"
|
||||
calm_s = f"{d['affect_valence']:>5.2f}"
|
||||
social_s = f"{d['social_energy']:>6.2f}"
|
||||
pres_s = "YES" if d["presence"] else " no"
|
||||
rssi_s = f"{d['rssi']:>5}" if d["rssi"] != 0 else " — "
|
||||
|
||||
print(f"{elapsed:>3}s {hr_s} {br_s} {bp_s} {d['stress']:>8} "
|
||||
f"{sdnn_s} {rmssd_s} {lfhf_s} "
|
||||
f"{pres_s:>4} {dist_s} {lux_s} {rssi_s} "
|
||||
f"{coh_s:>4} {d['csi_frames']:>5}")
|
||||
# Seed status
|
||||
seed_s = " — "
|
||||
if hub.seed:
|
||||
now = time.time()
|
||||
if now - _last_drift_check >= 15.0:
|
||||
_last_drift_check = now
|
||||
hub.seed.get_drift()
|
||||
drift = hub.seed.last_drift
|
||||
if drift:
|
||||
seed_s = f"{'OK' if not drift.get('drifting') else 'DRIFT':>6}"
|
||||
else:
|
||||
seed_s = " conn?"
|
||||
|
||||
print(f"{elapsed:>3}s {bar} {h:.2f} {gait_s} {calm_s} "
|
||||
f"{social_s} {pres_s:>4} {rssi_s} {seed_s} {d['csi_frames']:>5}")
|
||||
|
||||
# Show drift detail if drifting
|
||||
if hub.seed and hub.seed.last_drift and hub.seed.last_drift.get("drifting"):
|
||||
print(f" SEED DRIFT: {hub.seed.last_drift.get('message', 'unknown')}")
|
||||
else:
|
||||
hr_s = f"{d['hr']:>4.0f}" if d["hr"] > 0 else " —"
|
||||
br_s = f"{d['br']:>3.0f}" if d["br"] > 0 else " —"
|
||||
bp_s = f"{d['sbp']:>3}/{d['dbp']:<3}" if d["sbp"] > 0 else " —/— "
|
||||
sdnn_s = f"{d['sdnn']:>5.0f}" if d["sdnn"] > 0 else " — "
|
||||
rmssd_s = f"{d['rmssd']:>5.0f}" if d["rmssd"] > 0 else " — "
|
||||
lfhf_s = f"{d['lf_hf']:>5.2f}" if d["sdnn"] > 0 else " — "
|
||||
pres_s = "YES" if d["presence"] else " no"
|
||||
dist_s = f"{d['distance']:>4.0f}cm" if d["distance"] > 0 else " — "
|
||||
lux_s = f"{d['lux']:>5.1f}" if d["lux"] > 0 else " — "
|
||||
rssi_s = f"{d['rssi']:>5}" if d["rssi"] != 0 else " — "
|
||||
coh = max(d["coh_mw"], d["coh_csi"])
|
||||
coh_s = f"{coh:>.2f}"
|
||||
|
||||
print(f"{elapsed:>3}s {hr_s} {br_s} {bp_s} {d['stress']:>8} "
|
||||
f"{sdnn_s} {rmssd_s} {lfhf_s} "
|
||||
f"{pres_s:>4} {dist_s} {lux_s} {rssi_s} "
|
||||
f"{coh_s:>4} {d['csi_frames']:>5}")
|
||||
|
||||
for drift in d["drifts"]:
|
||||
print(f" DRIFT: {drift}")
|
||||
@@ -506,6 +726,9 @@ def run_display(hub, duration, interval):
|
||||
print(f" Baselines ({len(longi)} metrics tracked):")
|
||||
for name, stats in sorted(longi.items()):
|
||||
print(f" {name}: mean={stats['mean']:.1f} std={stats['std']:.1f} n={stats['n']}")
|
||||
# Happiness
|
||||
if d.get("happiness", 0) > 0:
|
||||
print(f" Happiness: {d['happiness']:.2f} (gait={d['gait_energy']:.2f} affect={d['affect_valence']:.2f} social={d['social_energy']:.2f})")
|
||||
# Signal coherence
|
||||
print(f" Coherence: mmWave={d['coh_mw']:.2f} CSI={d['coh_csi']:.2f}")
|
||||
events = d["events"]
|
||||
@@ -518,13 +741,21 @@ def run_display(hub, duration, interval):
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="RuView Live + RuVector Analysis")
|
||||
parser.add_argument("--csi", default="COM7", help="CSI port (or 'none')")
|
||||
parser.add_argument("--csi", default=None, help="CSI port (or 'none'); defaults to COM5 for happiness mode, COM7 otherwise")
|
||||
parser.add_argument("--mmwave", default="COM4", help="mmWave port (or 'none')")
|
||||
parser.add_argument("--duration", type=int, default=120)
|
||||
parser.add_argument("--interval", type=int, default=3)
|
||||
parser.add_argument("--seed", default="none", help="Cognitum Seed HTTP base URL (e.g. 'http://169.254.42.1')")
|
||||
parser.add_argument("--mode", default="vitals", choices=["vitals", "happiness"],
|
||||
help="Dashboard mode: vitals (default) or happiness")
|
||||
args = parser.parse_args()
|
||||
|
||||
hub = SensorHub()
|
||||
# Default CSI port depends on mode
|
||||
if args.csi is None:
|
||||
args.csi = "COM5" if args.mode == "happiness" else "COM7"
|
||||
|
||||
seed_url = args.seed if args.seed.lower() != "none" else None
|
||||
hub = SensorHub(seed_url=seed_url)
|
||||
stop = threading.Event()
|
||||
|
||||
if args.mmwave.lower() != "none":
|
||||
@@ -535,7 +766,7 @@ def main():
|
||||
time.sleep(2)
|
||||
|
||||
try:
|
||||
run_display(hub, args.duration, args.interval)
|
||||
run_display(hub, args.duration, args.interval, mode=args.mode)
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopping...")
|
||||
stop.set()
|
||||
|
||||
Reference in New Issue
Block a user