feat: instrument model

This commit is contained in:
OpenSquared
2026-07-03 09:43:39 +02:00
parent d9e9d30889
commit 980c797f53
2 changed files with 206 additions and 35 deletions

View File

@@ -758,8 +758,10 @@ def _build_inputs(
ov = overrides.get(nid)
if ntype == "input_event":
# Events sont déjà en pips → pas de saturation (déjà non-linéaire via lifecycle)
inputs[nid] = float(ov["value"]) if ov else ev_by_cat.get(node.get("event_category", ""), 0.0)
# Baseline (niveau structurel user) + surprise event (lifecycle) → additif
base = float(ov["value"]) if ov else 0.0
events = ev_by_cat.get(node.get("event_category", ""), 0.0)
inputs[nid] = base + events
elif ntype == "input_manual":
coeff = float(node.get("coefficient_to_pips", 1.0))
@@ -953,9 +955,13 @@ def get_model_state(conn, instrument: str, at_date: Optional[str] = None) -> Opt
st["pip_contribution"] = val
if ntype == "input_event":
cat = node.get("event_category", "")
st["source"] = "manual" if ov else ("events" if val != 0.0 else "neutral")
st["raw_value"] = ov["value"] if ov else round(ev_by_cat.get(cat, 0.0), 1)
cat = node.get("event_category", "")
event_surprise = round(ev_by_cat.get(cat, 0.0), 1)
baseline = float(ov["value"]) if ov else 0.0
st["raw_value"] = baseline
st["baseline_value"] = baseline
st["event_surprise"] = event_surprise
st["source"] = "manual" if ov else ("events" if event_surprise != 0.0 else "neutral")
if ov:
st["override_note"] = ov.get("note", "")
st["override_set_at"] = ov.get("set_at", "")

View File

@@ -41,6 +41,9 @@ interface ModelNode {
pip_saturated?: number
saturation_pct?: number
regime_weight?: number
// Phase 3 — event nodes: baseline (structurel) + surprise (événement)
baseline_value?: number
event_surprise?: number
}
interface RegimeInfo {
@@ -347,7 +350,7 @@ function NodeEditModal({ node, instrument, onClose, onSaved }: {
Valeur ({node.unit})
{node.node_type === 'input_manual' && coeff !== 0 &&
<span className="ml-1.5 text-slate-500">× {coeff} = <span className={pipColor(preview)}>{fmt(preview)} pips</span></span>}
{node.node_type === 'input_event' && <span className="ml-1.5 text-slate-500">(pips directs)</span>}
{node.node_type === 'input_event' && <span className="ml-1.5 text-slate-500">niveau structurel (les events s'y ajoutent)</span>}
</label>
<input
type="number" step="any" value={val} onChange={e => setVal(e.target.value)}
@@ -444,22 +447,35 @@ function NodeCard({ node, onEdit, eventDetails }: {
)}
</div>
) : node.node_type === 'input_event' ? (() => {
const cat = node.event_category || ''
const evList = eventDetails?.[cat] || []
const maxLf = evList.length > 0 ? Math.max(...evList.map(e => e.lifecycle_factor)) : 0
const barW = Math.round(maxLf * 100)
const cat = node.event_category || ''
const evList = eventDetails?.[cat] || []
const maxLf = evList.length > 0 ? Math.max(...evList.map(e => e.lifecycle_factor)) : 0
const barW = Math.round(maxLf * 100)
const baseline = node.baseline_value ?? 0
const surprise = node.event_surprise ?? 0
const hasBaseline = baseline !== 0
return (
<div className="mt-1">
<div className="flex items-center gap-2">
<span className={clsx('text-xs font-bold font-mono', pipColor(v))}>{fmt(v)} pips</span>
{evList.length > 0 && (
<span className="text-xs text-slate-600">{evList.length} ev</span>
)}
</div>
{hasBaseline ? (
<div className="space-y-0.5">
<div className="flex flex-wrap gap-x-2 gap-y-0">
<span className="text-xs text-violet-300/80 font-mono">base:{fmt(baseline)}p</span>
{surprise !== 0 && (
<span className={clsx('text-xs font-mono', pipColor(surprise))}>surp:{fmt(surprise)}p</span>
)}
</div>
<span className={clsx('text-xs font-bold font-mono', pipColor(v))}>={fmt(v)}p</span>
</div>
) : (
<div className="flex items-center gap-2">
<span className={clsx('text-xs font-bold font-mono', pipColor(v))}>{fmt(v)} pips</span>
{evList.length > 0 && <span className="text-xs text-slate-600">{evList.length} ev</span>}
</div>
)}
{evList.length > 0 && (
<div className="mt-0.5 flex items-center gap-1.5">
<div className="flex-1 h-0.5 rounded-full bg-slate-700/60 overflow-hidden">
<div className={clsx('h-full rounded-full', pipBg(v))} style={{width:`${barW}%`}}/>
<div className={clsx('h-full rounded-full', pipBg(surprise !== 0 ? surprise : v))} style={{width:`${barW}%`}}/>
</div>
<span className="text-slate-600 text-xs font-mono">{barW}%</span>
</div>
@@ -498,38 +514,174 @@ function NodeCard({ node, onEdit, eventDetails }: {
)
}
interface EdgeDraw {
x1: number; y1: number; x2: number; y2: number
coeff: number; fromId: string; toId: string; key: string
fromType: NodeType
}
function DagView({ nodes, instrument, onEdit, eventDetails }: {
nodes: ModelNode[]; instrument: string; onEdit: (n: ModelNode) => void
eventDetails: Record<string, EventDetail[]>
}) {
const wrapperRef = useRef<HTMLDivElement>(null)
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map())
const [svgEdges, setSvgEdges] = useState<EdgeDraw[]>([])
const [hoveredId, setHoveredId] = useState<string | null>(null)
const [svgH, setSvgH] = useState(600)
const cols: ModelNode[][] = [[], [], [], []]
for (const n of nodes) cols[Math.min(n.display_col ?? 0, 3)].push(n)
const nodeTypeMap = useMemo(() => {
const m = new Map<string, NodeType>()
for (const n of nodes) m.set(n.id, n.node_type)
return m
}, [nodes])
// Parse directed connections from intermediate/output formula strings
const connections = useMemo(() => {
const result: { from: string; to: string; coeff: number }[] = []
for (const node of nodes) {
if (!node.formula) continue
for (const term of node.formula.split('+')) {
const t = term.trim()
const m1 = t.match(/^([\d.]+)\s*\*\s*(\w+)$/)
if (m1) {
result.push({ from: m1[2], to: node.id, coeff: parseFloat(m1[1]) })
} else if (/^\w+$/.test(t)) {
result.push({ from: t, to: node.id, coeff: 1.0 })
}
}
}
return result
}, [nodes])
const recompute = useCallback(() => {
const wrapper = wrapperRef.current
if (!wrapper) return
const rect0 = wrapper.getBoundingClientRect()
const sL = wrapper.scrollLeft
const sT = wrapper.scrollTop
const edges: EdgeDraw[] = []
for (const conn of connections) {
const fromEl = nodeRefs.current.get(conn.from)
const toEl = nodeRefs.current.get(conn.to)
if (!fromEl || !toEl) continue
const fr = fromEl.getBoundingClientRect()
const tr = toEl.getBoundingClientRect()
edges.push({
x1: fr.right - rect0.left + sL,
y1: fr.top + fr.height / 2 - rect0.top + sT,
x2: tr.left - rect0.left + sL,
y2: tr.top + tr.height / 2 - rect0.top + sT,
coeff: conn.coeff,
fromId: conn.from,
toId: conn.to,
key: `${conn.from}->${conn.to}`,
fromType: nodeTypeMap.get(conn.from) ?? 'input_event',
})
}
setSvgEdges(edges)
setSvgH(wrapper.scrollHeight)
}, [connections, nodeTypeMap])
useEffect(() => {
const t = setTimeout(recompute, 100)
const ro = new ResizeObserver(() => setTimeout(recompute, 80))
if (wrapperRef.current) ro.observe(wrapperRef.current)
return () => { clearTimeout(t); ro.disconnect() }
}, [recompute])
function edgeStroke(e: EdgeDraw): string {
if (e.fromType === 'input_event') return '#38bdf8' // sky — event source
if (e.fromType === 'input_manual') return '#a78bfa' // violet — manual source
return '#f59e0b' // amber — intermediate→output
}
return (
<div className="overflow-x-auto">
<div className="grid grid-cols-4 gap-3 min-w-[780px]">
<div ref={wrapperRef} className="relative overflow-x-auto" onScroll={recompute}>
{/* SVG arêtes superposées */}
<svg
className="absolute top-0 left-0 pointer-events-none"
style={{ width: '100%', height: svgH, zIndex: 5, overflow: 'visible' }}
aria-hidden="true"
>
{svgEdges.map(e => {
const isHov = hoveredId === e.fromId || hoveredId === e.toId
const opacity = isHov ? 0.80 : 0.13
const stroke = edgeStroke(e)
const dx = Math.min(50, (e.x2 - e.x1) * 0.38)
return (
<g key={e.key} opacity={opacity}>
<path
d={`M ${e.x1} ${e.y1} C ${e.x1 + dx} ${e.y1}, ${e.x2 - dx} ${e.y2}, ${e.x2} ${e.y2}`}
fill="none" stroke={stroke}
strokeWidth={e.coeff > 1.05 ? 2 : 1}
/>
{/* Arrowhead */}
<polygon
points={`${e.x2},${e.y2} ${e.x2 - 5},${e.y2 - 3} ${e.x2 - 5},${e.y2 + 3}`}
fill={stroke} fillOpacity={0.7}
/>
{/* Coefficient label on hover */}
{isHov && e.coeff !== 1.0 && (
<text
x={(e.x1 + e.x2) / 2} y={(e.y1 + e.y2) / 2 - 4}
fill={stroke} fontSize="9" textAnchor="middle"
style={{ userSelect: 'none', fontFamily: 'monospace' }}
>
×{e.coeff.toFixed(2)}
</text>
)}
</g>
)
})}
</svg>
{/* Grille 4 colonnes */}
<div className="grid grid-cols-4 gap-3 min-w-[780px]" style={{ position: 'relative', zIndex: 10 }}>
{COL_LABELS.map((lbl, ci) => (
<div key={ci}>
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 pb-1 border-b border-slate-700/30">
{lbl}
<span className="ml-1.5 text-slate-600 font-normal normal-case">({cols[ci].length})</span>
</div>
{cols[ci].map(n => <NodeCard key={n.id} node={n} onEdit={onEdit} eventDetails={eventDetails}/>)}
{cols[ci].map(n => (
<div
key={n.id}
ref={el => { if (el) nodeRefs.current.set(n.id, el); else nodeRefs.current.delete(n.id) }}
onMouseEnter={() => { setHoveredId(n.id); recompute() }}
onMouseLeave={() => setHoveredId(null)}
>
<NodeCard node={n} onEdit={onEdit} eventDetails={eventDetails}/>
</div>
))}
</div>
))}
</div>
{/* Legend */}
{/* Légende */}
<div className="flex items-center gap-4 mt-4 pt-3 border-t border-slate-700/20 flex-wrap">
{(Object.entries(SOURCE_META) as [string, { dot: string; label: string }][]).map(([src, m]) => (
<div key={src} className="flex items-center gap-1.5 text-xs text-slate-500">
<span className={clsx('w-2 h-2 rounded-full', m.dot)}/>
{m.label}
</div>
))}
<div className="flex items-center gap-1.5 text-xs text-slate-500 ml-2">
<Edit3 className="w-3 h-3"/>
Cliquer pour modifier les inputs events / manuels
<div className="flex items-center gap-1.5 text-xs text-sky-400">
<span className="w-3 h-px bg-sky-400 inline-block rounded"/> Events → couches
</div>
<div className="flex items-center gap-1.5 text-xs text-violet-400">
<span className="w-3 h-px bg-violet-400 inline-block rounded"/> Manuel → couches
</div>
<div className="flex items-center gap-1.5 text-xs text-amber-400">
<span className="w-3 h-px bg-amber-400 inline-block rounded"/> Couches → output
</div>
<span className="text-xs text-slate-600 ml-1">· survol = highlight</span>
<div className="ml-2 flex gap-3">
{(Object.entries(SOURCE_META) as [string, { dot: string; label: string }][]).map(([src, m]) => (
<div key={src} className="flex items-center gap-1.5 text-xs text-slate-500">
<span className={clsx('w-2 h-2 rounded-full', m.dot)}/>{m.label}
</div>
))}
</div>
<div className="flex items-center gap-1.5 text-xs text-slate-500 ml-auto">
<Edit3 className="w-3 h-3"/> Cliquer pour modifier
</div>
</div>
</div>
@@ -742,6 +894,7 @@ function TimelineView({ instrument }: { instrument: string }) {
const [whatifLoading, setWhatifLoading] = useState(false)
const [whatifData, setWhatifData] = useState<TimelinePoint[] | null>(null)
const [importingCal, setImportingCal] = useState(false)
const [calMsg, setCalMsg] = useState<string | null>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const activeData = whatifData ?? data
@@ -1073,10 +1226,17 @@ function TimelineView({ instrument }: { instrument: string }) {
plateau_days: ev.calibration.plateau_days,
decay_type: ev.calibration.decay_type,
}))
setVirtuals(prev => {
const existingIds = new Set(prev.map(v => v.id))
return [...prev, ...imported.filter(x => !existingIds.has(x.id))]
})
if (imported.length === 0) {
setCalMsg('Aucun event analysé pour cet instrument utilisez CausalLab d\'abord')
} else {
setVirtuals(prev => {
const existingIds = new Set(prev.map(v => v.id))
const added = imported.filter(x => !existingIds.has(x.id))
setCalMsg(`${added.length} event${added.length > 1 ? 's' : ''} importé${added.length > 1 ? 's' : ''}`)
return [...prev, ...added]
})
}
setTimeout(() => setCalMsg(null), 3500)
} finally { setImportingCal(false) }
}}
className="flex items-center gap-1 text-xs text-sky-400 hover:text-sky-300 border border-sky-700/40 rounded px-2 py-0.5 transition-colors disabled:opacity-40">
@@ -1088,7 +1248,12 @@ function TimelineView({ instrument }: { instrument: string }) {
</button>
</div>
</div>
{virtuals.length === 0 && (
{calMsg && (
<div className={clsx('text-xs px-2 py-1 rounded', calMsg.startsWith('Aucun') ? 'text-amber-400 bg-amber-900/20' : 'text-emerald-400 bg-emerald-900/20')}>
{calMsg}
</div>
)}
{virtuals.length === 0 && !calMsg && (
<div className="text-xs text-slate-500 text-center py-2">
Ajoute des events hypothétiques pour simuler leur impact sur la courbe synthétique.
</div>