feat: instrument model
This commit is contained in:
@@ -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", "")
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user