From 980c797f537e6076a2900ba7dbdd5a1ed81ac5af Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Fri, 3 Jul 2026 09:43:39 +0200 Subject: [PATCH] feat: instrument model --- backend/services/instrument_models.py | 16 +- frontend/src/pages/InstrumentModels.tsx | 225 ++++++++++++++++++++---- 2 files changed, 206 insertions(+), 35 deletions(-) diff --git a/backend/services/instrument_models.py b/backend/services/instrument_models.py index 36438dd..4eac947 100644 --- a/backend/services/instrument_models.py +++ b/backend/services/instrument_models.py @@ -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", "") diff --git a/frontend/src/pages/InstrumentModels.tsx b/frontend/src/pages/InstrumentModels.tsx index e9197b6..75324d1 100644 --- a/frontend/src/pages/InstrumentModels.tsx +++ b/frontend/src/pages/InstrumentModels.tsx @@ -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 && × {coeff} = {fmt(preview)} pips} - {node.node_type === 'input_event' && (pips directs)} + {node.node_type === 'input_event' && niveau structurel (les events s'y ajoutent)} setVal(e.target.value)} @@ -444,22 +447,35 @@ function NodeCard({ node, onEdit, eventDetails }: { )} ) : 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 (
-
- {fmt(v)} pips - {evList.length > 0 && ( - {evList.length} ev - )} -
+ {hasBaseline ? ( +
+
+ base:{fmt(baseline)}p + {surprise !== 0 && ( + surp:{fmt(surprise)}p + )} +
+ ={fmt(v)}p +
+ ) : ( +
+ {fmt(v)} pips + {evList.length > 0 && {evList.length} ev} +
+ )} {evList.length > 0 && (
-
+
{barW}%
@@ -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 }) { + const wrapperRef = useRef(null) + const nodeRefs = useRef>(new Map()) + const [svgEdges, setSvgEdges] = useState([]) + const [hoveredId, setHoveredId] = useState(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() + 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 ( -
-
+
+ {/* SVG arêtes superposées */} + + + {/* Grille 4 colonnes */} +
{COL_LABELS.map((lbl, ci) => (
{lbl} ({cols[ci].length})
- {cols[ci].map(n => )} + {cols[ci].map(n => ( +
{ if (el) nodeRefs.current.set(n.id, el); else nodeRefs.current.delete(n.id) }} + onMouseEnter={() => { setHoveredId(n.id); recompute() }} + onMouseLeave={() => setHoveredId(null)} + > + +
+ ))}
))}
- {/* Legend */} + {/* Légende */}
- {(Object.entries(SOURCE_META) as [string, { dot: string; label: string }][]).map(([src, m]) => ( -
- - {m.label} -
- ))} -
- - Cliquer pour modifier les inputs events / manuels +
+ Events → couches +
+
+ Manuel → couches +
+
+ Couches → output +
+ · survol = highlight +
+ {(Object.entries(SOURCE_META) as [string, { dot: string; label: string }][]).map(([src, m]) => ( +
+ {m.label} +
+ ))} +
+
+ Cliquer pour modifier
@@ -742,6 +894,7 @@ function TimelineView({ instrument }: { instrument: string }) { const [whatifLoading, setWhatifLoading] = useState(false) const [whatifData, setWhatifData] = useState(null) const [importingCal, setImportingCal] = useState(false) + const [calMsg, setCalMsg] = useState(null) const canvasRef = useRef(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 }) {
- {virtuals.length === 0 && ( + {calMsg && ( +
+ {calMsg} +
+ )} + {virtuals.length === 0 && !calMsg && (
Ajoute des events hypothétiques pour simuler leur impact sur la courbe synthétique.