Initial commit of akmon project
This commit is contained in:
442
scripts/training-event-simulator/simulate-training-events.mjs
Normal file
442
scripts/training-event-simulator/simulate-training-events.mjs
Normal file
@@ -0,0 +1,442 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
function loadEnv(envFile) {
|
||||
if (envFile) {
|
||||
const resolved = path.isAbsolute(envFile) ? envFile : path.resolve(process.cwd(), envFile)
|
||||
if (!fs.existsSync(resolved)) {
|
||||
console.warn(`[warn] .env file not found at ${resolved}, skipping explicit load`)
|
||||
} else {
|
||||
dotenv.config({ path: resolved })
|
||||
return
|
||||
}
|
||||
}
|
||||
const defaultEnv = path.resolve(process.cwd(), '.env')
|
||||
if (fs.existsSync(defaultEnv)) {
|
||||
dotenv.config({ path: defaultEnv })
|
||||
return
|
||||
}
|
||||
const rootEnv = path.resolve(__dirname, '..', '..', '.env')
|
||||
if (fs.existsSync(rootEnv)) {
|
||||
dotenv.config({ path: rootEnv })
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
help: false,
|
||||
classId: null,
|
||||
trainingId: null,
|
||||
rosterPath: null,
|
||||
studentsInline: null,
|
||||
intervalSeconds: 5,
|
||||
cycles: 12,
|
||||
includeAck: true,
|
||||
dryRun: false,
|
||||
verbose: false,
|
||||
supabaseUrl: null,
|
||||
supabaseKey: null,
|
||||
ingestSource: 'simulator',
|
||||
ingestNote: null,
|
||||
envFile: null
|
||||
}
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const token = argv[i]
|
||||
if (!token.startsWith('--')) continue
|
||||
const [rawKey, rawValue] = token.split('=')
|
||||
const key = rawKey.replace(/^--/, '')
|
||||
const nextValue = rawValue !== undefined ? rawValue : argv[i + 1]
|
||||
|
||||
const consumeNext = rawValue === undefined && nextValue !== undefined && !nextValue.startsWith('--')
|
||||
|
||||
switch (key) {
|
||||
case 'help':
|
||||
case 'h':
|
||||
args.help = true
|
||||
break
|
||||
case 'class-id':
|
||||
case 'classId':
|
||||
args.classId = consumeNext ? argv[++i] : nextValue
|
||||
break
|
||||
case 'training-id':
|
||||
case 'trainingId':
|
||||
args.trainingId = consumeNext ? argv[++i] : nextValue
|
||||
break
|
||||
case 'roster':
|
||||
case 'roster-path':
|
||||
args.rosterPath = consumeNext ? argv[++i] : nextValue
|
||||
break
|
||||
case 'students':
|
||||
args.studentsInline = consumeNext ? argv[++i] : nextValue
|
||||
break
|
||||
case 'interval':
|
||||
case 'interval-seconds': {
|
||||
const val = consumeNext ? argv[++i] : nextValue
|
||||
args.intervalSeconds = parseFloat(val)
|
||||
break
|
||||
}
|
||||
case 'cycles':
|
||||
case 'rounds': {
|
||||
const val = consumeNext ? argv[++i] : nextValue
|
||||
args.cycles = parseInt(val, 10)
|
||||
break
|
||||
}
|
||||
case 'no-ack':
|
||||
args.includeAck = false
|
||||
break
|
||||
case 'ack':
|
||||
args.includeAck = true
|
||||
break
|
||||
case 'dry-run':
|
||||
args.dryRun = true
|
||||
break
|
||||
case 'verbose':
|
||||
case 'v':
|
||||
args.verbose = true
|
||||
break
|
||||
case 'supabase-url':
|
||||
args.supabaseUrl = consumeNext ? argv[++i] : nextValue
|
||||
break
|
||||
case 'supabase-key':
|
||||
args.supabaseKey = consumeNext ? argv[++i] : nextValue
|
||||
break
|
||||
case 'source':
|
||||
case 'ingest-source':
|
||||
args.ingestSource = consumeNext ? argv[++i] : nextValue
|
||||
break
|
||||
case 'note':
|
||||
case 'ingest-note':
|
||||
args.ingestNote = consumeNext ? argv[++i] : nextValue
|
||||
break
|
||||
case 'env':
|
||||
case 'env-file':
|
||||
args.envFile = consumeNext ? argv[++i] : nextValue
|
||||
break
|
||||
default:
|
||||
console.warn(`[warn] Unknown option ${token}`)
|
||||
break
|
||||
}
|
||||
|
||||
if (consumeNext && rawValue === undefined) {
|
||||
// already consumed
|
||||
}
|
||||
}
|
||||
|
||||
if (Number.isNaN(args.intervalSeconds) || args.intervalSeconds <= 0) {
|
||||
args.intervalSeconds = 5
|
||||
}
|
||||
if (!Number.isInteger(args.cycles) || args.cycles <= 0) {
|
||||
args.cycles = 12
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
const usage = `simulate-training-events
|
||||
|
||||
Usage:
|
||||
node simulate-training-events.mjs --class-id <uuid> --roster ./roster.json [options]
|
||||
|
||||
Options:
|
||||
--class-id <uuid> 班级 ID(必填)
|
||||
--training-id <uuid> 训练会话 ID,缺省自动生成
|
||||
--roster <path>|--students 学生清单,支持 JSON/CSV 文件或内联 "student:device" 列表
|
||||
--interval <seconds> 指标上报间隔(秒),默认 5
|
||||
--cycles <n> 指标上报轮数,默认 12 (约 1 分钟)
|
||||
--no-ack 跳过应答事件
|
||||
--dry-run 仅打印,不写入数据库
|
||||
--verbose 输出更详细的日志
|
||||
--supabase-url <url> 覆盖环境变量中的 Supabase URL
|
||||
--supabase-key <key> 覆盖环境变量中的 Supabase Key(建议使用 service role key)
|
||||
--source <label> 设置 ingest_source 字段,默认 simulator
|
||||
--note <text> 设置 ingest_note 字段
|
||||
--env <path> 指定 .env 文件路径
|
||||
--help 显示帮助并退出
|
||||
|
||||
环境变量:
|
||||
SUPABASE_URL
|
||||
SUPABASE_SERVICE_ROLE_KEY (推荐)
|
||||
SUPABASE_ANON_KEY (仅限具有插入权限的角色)
|
||||
`
|
||||
console.log(usage)
|
||||
}
|
||||
|
||||
function readRoster(rosterPath, studentsInline) {
|
||||
if (rosterPath) {
|
||||
const resolved = path.isAbsolute(rosterPath) ? rosterPath : path.resolve(process.cwd(), rosterPath)
|
||||
if (!fs.existsSync(resolved)) {
|
||||
throw new Error(`Roster file not found: ${resolved}`)
|
||||
}
|
||||
const raw = fs.readFileSync(resolved, 'utf-8').trim()
|
||||
if (!raw) {
|
||||
throw new Error('Roster file is empty')
|
||||
}
|
||||
if (resolved.endsWith('.json')) {
|
||||
const data = JSON.parse(raw)
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('Roster JSON must be an array of records')
|
||||
}
|
||||
return data.map((entry, idx) => normalizeRosterEntry(entry, idx))
|
||||
}
|
||||
return raw.split(/\r?\n/).filter(Boolean).map((line, idx) => normalizeRosterEntry(parseRosterLine(line), idx))
|
||||
}
|
||||
if (studentsInline) {
|
||||
return studentsInline.split(',').map((token, idx) => normalizeRosterEntry(parseRosterLine(token), idx))
|
||||
}
|
||||
throw new Error('Roster is required. Provide --roster <file> or --students stuA:devA,stuB:devB')
|
||||
}
|
||||
|
||||
function parseRosterLine(text) {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) throw new Error('Empty roster entry encountered')
|
||||
if (trimmed.includes(',')) {
|
||||
const parts = trimmed.split(',').map((s) => s.trim())
|
||||
const [student_id, device_id, name] = parts
|
||||
return { student_id, device_id, name }
|
||||
}
|
||||
if (trimmed.includes(':')) {
|
||||
const [student_id, device_id] = trimmed.split(':').map((s) => s.trim())
|
||||
return { student_id, device_id, name: null }
|
||||
}
|
||||
return { student_id: trimmed, device_id: null, name: null }
|
||||
}
|
||||
|
||||
function normalizeRosterEntry(entry, index) {
|
||||
const studentId = entry.student_id || entry.studentId
|
||||
if (!studentId) {
|
||||
throw new Error(`Roster entry #${index + 1} is missing student_id`)
|
||||
}
|
||||
return {
|
||||
student_id: studentId,
|
||||
device_id: entry.device_id || entry.deviceId || null,
|
||||
name: entry.name || entry.display_name || null
|
||||
}
|
||||
}
|
||||
|
||||
function createRosterState(roster) {
|
||||
return roster.map((entry, idx) => ({
|
||||
...entry,
|
||||
battery: 80 - Math.floor(Math.random() * 10) - idx,
|
||||
steps: Math.floor(Math.random() * 50),
|
||||
hr: 95 + Math.floor(Math.random() * 20),
|
||||
spo2: 97,
|
||||
temp: 36.5 + Math.random() * 0.3,
|
||||
status: 'online'
|
||||
}))
|
||||
}
|
||||
|
||||
function randomDrift(base, { min, max }) {
|
||||
const delta = Math.random() * (max - min) + min
|
||||
return base + delta
|
||||
}
|
||||
|
||||
function updateTelemetryState(state) {
|
||||
const next = { ...state }
|
||||
next.hr = Math.max(70, Math.round(randomDrift(next.hr, { min: -3, max: 5 })))
|
||||
next.spo2 = Math.min(100, Math.max(93, Math.round(randomDrift(next.spo2, { min: -1.5, max: 1.5 }))))
|
||||
next.temp = Math.min(39, Math.max(36, randomDrift(next.temp, { min: -0.1, max: 0.12 })))
|
||||
next.steps += Math.floor(Math.random() * 8)
|
||||
next.battery = Math.max(5, next.battery - (Math.random() < 0.4 ? 1 : 0))
|
||||
if (Math.random() < 0.02) {
|
||||
next.status = 'offline'
|
||||
} else if (next.status === 'offline' && Math.random() < 0.5) {
|
||||
next.status = 'online'
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function createSupabaseClient(url, key) {
|
||||
return createClient(url, key, {
|
||||
auth: {
|
||||
persistSession: false,
|
||||
autoRefreshToken: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function insertEvents(client, rows, { dryRun, verbose }) {
|
||||
if (!rows.length) return
|
||||
if (dryRun) {
|
||||
console.log('[dry-run] would insert rows:', JSON.stringify(rows, null, 2))
|
||||
return
|
||||
}
|
||||
const { error } = await client.from('training_stream_events').insert(rows)
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
if (verbose) {
|
||||
console.log(`[info] inserted ${rows.length} events`)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendAckPhase(client, rosterState, options) {
|
||||
if (!options.includeAck) return
|
||||
const now = new Date()
|
||||
const rows = rosterState.map((entry) => ({
|
||||
training_id: options.trainingId,
|
||||
class_id: options.classId,
|
||||
event_type: 'ack',
|
||||
student_id: entry.student_id,
|
||||
device_id: entry.device_id,
|
||||
ack: true,
|
||||
status: entry.status === 'online' ? 'ack_ok' : 'ack_pending',
|
||||
metrics: null,
|
||||
payload: {
|
||||
display_name: entry.name,
|
||||
note: 'simulated ack'
|
||||
},
|
||||
ingest_source: options.ingestSource,
|
||||
ingest_note: options.ingestNote,
|
||||
recorded_at: now.toISOString()
|
||||
}))
|
||||
await insertEvents(client, rows, options)
|
||||
}
|
||||
|
||||
function buildTelemetryRows(rosterState, options, tick) {
|
||||
const timestamp = new Date(Date.now() + tick * options.intervalSeconds * 1000)
|
||||
return rosterState.map((entry) => ({
|
||||
training_id: options.trainingId,
|
||||
class_id: options.classId,
|
||||
event_type: 'telemetry',
|
||||
student_id: entry.student_id,
|
||||
device_id: entry.device_id,
|
||||
status: entry.status,
|
||||
ack: null,
|
||||
metrics: {
|
||||
hr: entry.hr,
|
||||
spo2: entry.spo2,
|
||||
temp: parseFloat(entry.temp.toFixed(2)),
|
||||
battery: entry.battery,
|
||||
steps: entry.steps
|
||||
},
|
||||
payload: {
|
||||
cycle: tick,
|
||||
display_name: entry.name
|
||||
},
|
||||
ingest_source: options.ingestSource,
|
||||
ingest_note: options.ingestNote,
|
||||
recorded_at: timestamp.toISOString()
|
||||
}))
|
||||
}
|
||||
|
||||
function buildSummaryRow(rosterState, options) {
|
||||
const maxHr = rosterState.reduce((acc, entry) => Math.max(acc, entry.hr), 0)
|
||||
const avgHr = rosterState.reduce((acc, entry) => acc + entry.hr, 0) / rosterState.length
|
||||
const avgSpo2 = rosterState.reduce((acc, entry) => acc + entry.spo2, 0) / rosterState.length
|
||||
const offline = rosterState.filter((entry) => entry.status !== 'online').length
|
||||
return {
|
||||
training_id: options.trainingId,
|
||||
class_id: options.classId,
|
||||
event_type: 'summary',
|
||||
student_id: null,
|
||||
device_id: null,
|
||||
status: offline > 0 ? 'has_alerts' : 'normal',
|
||||
ack: null,
|
||||
metrics: {
|
||||
max_hr: maxHr,
|
||||
avg_hr: Math.round(avgHr),
|
||||
avg_spo2: Math.round(avgSpo2),
|
||||
offline_count: offline
|
||||
},
|
||||
payload: {
|
||||
roster_size: rosterState.length,
|
||||
duration_seconds: options.cycles * options.intervalSeconds
|
||||
},
|
||||
ingest_source: options.ingestSource,
|
||||
ingest_note: options.ingestNote,
|
||||
recorded_at: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
async function delay(ms) {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
if (args.help) {
|
||||
printHelp()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
try {
|
||||
loadEnv(args.envFile)
|
||||
|
||||
if (!args.classId) {
|
||||
throw new Error('Missing required --class-id <uuid>')
|
||||
}
|
||||
|
||||
const roster = readRoster(args.rosterPath, args.studentsInline)
|
||||
if (roster.length === 0) {
|
||||
throw new Error('Roster cannot be empty')
|
||||
}
|
||||
|
||||
const trainingId = args.trainingId || randomUUID()
|
||||
const supabaseUrl = args.supabaseUrl || process.env.SUPABASE_URL
|
||||
const supabaseKey = args.supabaseKey || process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_ANON_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseKey) {
|
||||
throw new Error('Supabase credentials missing. Provide SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY environment variables or CLI overrides.')
|
||||
}
|
||||
|
||||
const options = {
|
||||
classId: args.classId,
|
||||
trainingId,
|
||||
intervalSeconds: args.intervalSeconds,
|
||||
cycles: args.cycles,
|
||||
includeAck: args.includeAck,
|
||||
dryRun: args.dryRun,
|
||||
verbose: args.verbose,
|
||||
ingestSource: args.ingestSource,
|
||||
ingestNote: args.ingestNote
|
||||
}
|
||||
|
||||
const rosterState = createRosterState(roster)
|
||||
|
||||
const supabase = createSupabaseClient(supabaseUrl, supabaseKey)
|
||||
|
||||
console.log(`[info] Training ID: ${trainingId}`)
|
||||
console.log(`[info] Roster size: ${rosterState.length}`)
|
||||
console.log(`[info] Interval: ${options.intervalSeconds}s, cycles: ${options.cycles}`)
|
||||
if (options.dryRun) {
|
||||
console.log('[info] DRY RUN mode - events will not be written to Supabase')
|
||||
}
|
||||
|
||||
await sendAckPhase(supabase, rosterState, options)
|
||||
|
||||
for (let tick = 0; tick < options.cycles; tick++) {
|
||||
for (let i = 0; i < rosterState.length; i++) {
|
||||
rosterState[i] = updateTelemetryState(rosterState[i])
|
||||
}
|
||||
const rows = buildTelemetryRows(rosterState, options, tick)
|
||||
await insertEvents(supabase, rows, options)
|
||||
if (tick < options.cycles - 1) {
|
||||
await delay(options.intervalSeconds * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const summaryRow = buildSummaryRow(rosterState, options)
|
||||
await insertEvents(supabase, [summaryRow], options)
|
||||
|
||||
console.log('[info] Simulation complete')
|
||||
} catch (err) {
|
||||
console.error('[error]', err.message || err)
|
||||
if (process.env.DEBUG) {
|
||||
console.error(err)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
Reference in New Issue
Block a user