From 11c6af9f3e70f5e214d23425af6af27b07df6915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Tue, 5 May 2026 06:54:28 +0200 Subject: [PATCH 01/19] Update AGENTS guidance for Julia process handling --- Project.toml | 4 + frontend/src/App.tsx | 148 +++++++- frontend/src/styles.css | 96 +++++ frontend/src/types.ts | 35 ++ lib/PlantSimEngineGraphEditor/Project.toml | 16 + .../src/PlantSimEngineGraphEditor.jl | 285 +++++++++++++++ src/PlantSimEngine.jl | 8 +- src/model_discovery.jl | 242 +++++++++++++ src/visualization/dependency_graph_view.jl | 332 +++++++++++++++--- test/test-dependency-graph-view.jl | 88 +++++ 10 files changed, 1205 insertions(+), 49 deletions(-) create mode 100644 lib/PlantSimEngineGraphEditor/Project.toml create mode 100644 lib/PlantSimEngineGraphEditor/src/PlantSimEngineGraphEditor.jl create mode 100644 src/model_discovery.jl diff --git a/Project.toml b/Project.toml index 7db8b106..a4d1a192 100644 --- a/Project.toml +++ b/Project.toml @@ -10,6 +10,8 @@ DataAPI = "9a962f9c-6df0-11e9-0e5d-c546b8b5ee8a" DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" FLoops = "cc61a311-1640-44b5-9fba-1b764f453329" +InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240" +JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" Markdown = "d6f4376e-aef5-505a-96c1-9c027394607a" MultiScaleTreeGraph = "dd4a991b-8a45-4075-bede-262ee62d5583" PlantMeteo = "4630fe09-e0fb-4da5-a846-781cb73437b6" @@ -25,6 +27,8 @@ DataAPI = "1.15" DataFrames = "1" Dates = "1.10" FLoops = "0.2" +InteractiveUtils = "1.10" +JSON = "1" Markdown = "1.10" MultiScaleTreeGraph = "0.15.1" PlantMeteo = "0.8.2" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5f8bc7d9..351bdcd5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -30,7 +30,7 @@ import { DependencyEdge } from "./DependencyEdge"; import { ModelNode } from "./ModelNode"; import { layoutGraph, type LayoutMode } from "./layout"; import { sampleGraph } from "./sampleGraph"; -import type { DependencyGraphView, GraphEdgeData, GraphNodeData, GraphPort, RuntimeGraphNodeData } from "./types"; +import type { DependencyGraphView, GraphEdgeData, GraphEditorState, GraphNodeData, GraphPort, ModelDescriptor, RuntimeGraphNodeData } from "./types"; import "./styles.css"; type EdgeFilterKey = "dataFlow" | "mapped" | "callStack"; @@ -102,7 +102,12 @@ const layoutLabels: Record = { }; export default function App() { - const [graph] = useState(loadInitialGraph()); + const [graph, setGraph] = useState(loadInitialGraph()); + const [editorModels, setEditorModels] = useState([]); + const [editorSocket, setEditorSocket] = useState(null); + const [editorConnected, setEditorConnected] = useState(false); + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); const [selected, setSelected] = useState(null); const [activePort, setActivePort] = useState(null); const [showRequiredPanel, setShowRequiredPanel] = useState(false); @@ -142,6 +147,29 @@ export default function App() { ); const focus = useMemo(() => pinnedFocus?.active ? pinnedFocus : traversalFocus, [pinnedFocus, traversalFocus]); + useEffect(() => { + const config = loadEditorConfig(); + if (!config?.websocketUrl) return; + + const socket = new WebSocket(config.websocketUrl); + setEditorSocket(socket); + socket.addEventListener("open", () => setEditorConnected(true)); + socket.addEventListener("close", () => setEditorConnected(false)); + socket.addEventListener("message", (event) => { + const payload = JSON.parse(event.data) as GraphEditorState; + if (payload.graph) setGraph(payload.graph); + if (payload.models) setEditorModels(payload.models); + setCanUndo(Boolean(payload.canUndo)); + setCanRedo(Boolean(payload.canRedo)); + }); + return () => socket.close(); + }, []); + + const sendEditorCommand = useCallback((command: Record) => { + if (!editorSocket || editorSocket.readyState !== WebSocket.OPEN) return; + editorSocket.send(JSON.stringify(command)); + }, [editorSocket]); + useEffect(() => { const nextNodes = visibleNodeData.map((node) => ({ id: node.id, @@ -377,6 +405,13 @@ export default function App() { + {editorSocket && ( +
+ {editorConnected ? "live" : "offline"} + + +
+ )} @@ -447,6 +482,12 @@ export default function App() {

Diagnostics

{graph.diagnostics.length > 0 ? graph.diagnostics.map((item) =>
{item}
) :
No diagnostics.
} + {editorModels.length > 0 && ( + <> +

Available Models

+ + + )} ); @@ -706,6 +747,103 @@ function Row({ label, value }: { label: string; value: string }) { return
{label}{value}
; } +function ModelBrowser({ + models, + scales, + onCommand, + disabled, +}: { + models: ModelDescriptor[]; + scales: string[]; + onCommand: (command: Record) => void; + disabled: boolean; +}) { + const [modelType, setModelType] = useState(models[0]?.type ?? ""); + const [scale, setScale] = useState(scales[0] ?? "Default"); + const selected = models.find((model) => model.type === modelType) ?? models[0]; + + useEffect(() => { + if (!models.some((model) => model.type === modelType)) setModelType(models[0]?.type ?? ""); + }, [modelType, models]); + + useEffect(() => { + if (!scales.includes(scale)) setScale(scales[0] ?? "Default"); + }, [scale, scales]); + + if (!selected) return
No model type is available.
; + return ( +
+ + + +
+ ); +} + +function ModelParameterForm({ + model, + scale, + disabled, + onCommand, +}: { + model: ModelDescriptor; + scale: string; + disabled: boolean; + onCommand: (command: Record) => void; +}) { + const initialValues = useMemo(() => Object.fromEntries(model.constructor.fields.map((field) => [field.name, parameterDefaultValue(field.default)])), [model]); + const initialTypes = useMemo(() => Object.fromEntries(model.constructor.fields.map((field) => [field.name, field.inferredChoice])), [model]); + const [values, setValues] = useState>(initialValues); + const [types, setTypes] = useState>(initialTypes); + + const setSharedType = useCallback((fieldName: string, nextType: string) => { + const field = model.constructor.fields.find((item) => item.name === fieldName); + const group = field?.typeParameter ? model.constructor.parameterGroups[field.typeParameter] ?? [fieldName] : [fieldName]; + setTypes((current) => ({ ...current, ...Object.fromEntries(group.map((name) => [name, nextType])) })); + }, [model]); + + const addModel = useCallback(() => { + const parameters = Object.fromEntries(model.constructor.fields.map((field) => [ + field.name, + { type: types[field.name] ?? field.inferredChoice, value: values[field.name] ?? "" }, + ])); + onCommand({ action: "edit", kind: "add_model", scale, modelType: model.type, parameters }); + }, [model, onCommand, scale, types, values]); + + return ( +
+ {model.name} + {model.process ?? "unknown process"} + {model.constructor.fields.map((field) => ( +
+ + setValues((current) => ({ ...current, [field.name]: event.target.value }))} /> + +
+ ))} + +
+ ); +} + +function parameterDefaultValue(value: unknown) { + if (value === null || typeof value === "undefined") return ""; + if (typeof value === "string" && value.startsWith(":")) return value.slice(1); + return String(value); +} + function loadInitialGraph() { const embedded = document.getElementById("pse-graph-data"); if (embedded?.textContent) return JSON.parse(embedded.textContent) as DependencyGraphView; @@ -713,6 +851,12 @@ function loadInitialGraph() { return fromWindow ?? sampleGraph; } +function loadEditorConfig() { + const embedded = document.getElementById("pse-editor-config"); + if (!embedded?.textContent) return null; + return JSON.parse(embedded.textContent) as { websocketUrl?: string }; +} + function runtimeNodeData( node: GraphNodeData, options: { diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 432e7e0b..9f46cf42 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1193,6 +1193,102 @@ h1 { font-size: 11px; } +.live-session { + border-left: 1px solid rgba(183, 166, 150, 0.36); + padding-left: 10px; +} + +.live-pill { + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 9px; + border: 1px solid rgba(191, 106, 84, 0.38); + border-radius: 999px; + color: var(--clay); + background: rgba(191, 106, 84, 0.08); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} + +.live-pill.connected { + border-color: rgba(31, 122, 83, 0.34); + color: var(--accent); + background: rgba(31, 122, 83, 0.09); +} + +.metric-button:disabled { + cursor: not-allowed; + opacity: 0.42; +} + +.model-browser { + display: grid; + gap: 7px; +} + +.model-browser-control { + display: grid; + gap: 4px; +} + +.model-browser-control span, +.parameter-row label { + color: var(--muted); + font-size: 11px; + font-weight: 700; +} + +.model-browser-control select, +.parameter-row input, +.parameter-row select { + min-width: 0; + border: 1px solid rgba(183, 166, 150, 0.44); + border-radius: 8px; + background: rgba(255, 255, 255, 0.78); + color: var(--ink); + font: inherit; +} + +.model-browser-control select { + height: 32px; + padding: 0 8px; +} + +.model-browser-item { + display: grid; + gap: 7px; + padding: 8px 9px; + border: 1px solid rgba(183, 166, 150, 0.34); + border-radius: 10px; + background: rgba(255, 255, 255, 0.62); +} + +.model-browser-item strong { + overflow-wrap: anywhere; + font-size: 12px; +} + +.model-browser-item span { + color: var(--muted); + font-size: 11px; +} + +.parameter-row { + display: grid; + grid-template-columns: minmax(0, 0.75fr) minmax(0, 1fr) minmax(78px, 0.8fr); + gap: 6px; + align-items: center; +} + +.parameter-row input, +.parameter-row select { + height: 28px; + padding: 0 7px; + font-size: 12px; +} + @media (max-width: 900px) { .app-shell { grid-template-columns: 1fr; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 3f43f240..7fa36e82 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -54,5 +54,40 @@ export type DependencyGraphView = { scales: string[]; cyclic: boolean; cycleNodes: string[]; + cycleEdges?: string[]; diagnostics: string[]; }; + +export type ModelConstructorField = { + name: string; + declaredType: string; + hasDefault: boolean; + default: unknown; + defaultType: string | null; + typeParameter: string | null; + inferredChoice: string; + choices: string[]; +}; + +export type ModelDescriptor = { + type: string; + name: string; + process: string | null; + processType: string | null; + inputs: Record; + outputs: Record; + constructor: { + fields: ModelConstructorField[]; + parameterGroups: Record; + hasZeroArgConstructor: boolean; + }; +}; + +export type GraphEditorState = { + ok: boolean; + graph: DependencyGraphView; + models: ModelDescriptor[]; + canUndo: boolean; + canRedo: boolean; + url: string; +}; diff --git a/lib/PlantSimEngineGraphEditor/Project.toml b/lib/PlantSimEngineGraphEditor/Project.toml new file mode 100644 index 00000000..749b7ad6 --- /dev/null +++ b/lib/PlantSimEngineGraphEditor/Project.toml @@ -0,0 +1,16 @@ +name = "PlantSimEngineGraphEditor" +uuid = "7cf0a04e-a9b1-4d1f-a906-d86a8c54d709" +version = "0.1.0" +authors = ["Rémi Vezy "] + +[deps] +HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" +JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" +PlantSimEngine = "9a576370-710b-4269-adf9-4f603a9c6423" + +[sources] +PlantSimEngine = {path = "../.."} + +[compat] +HTTP = "1" +JSON = "1" diff --git a/lib/PlantSimEngineGraphEditor/src/PlantSimEngineGraphEditor.jl b/lib/PlantSimEngineGraphEditor/src/PlantSimEngineGraphEditor.jl new file mode 100644 index 00000000..62811d49 --- /dev/null +++ b/lib/PlantSimEngineGraphEditor/src/PlantSimEngineGraphEditor.jl @@ -0,0 +1,285 @@ +module PlantSimEngineGraphEditor + +import HTTP +import JSON +import PlantSimEngine + +export GraphEditorSession, edit_graph, current_mapping, apply_edit!, undo!, redo!, close + +mutable struct GraphEditorSession{M,G,S} + mapping::M + mtg::G + history::Vector{M} + future::Vector{M} + server::S + host::String + port::Int + url::String +end + +current_mapping(session::GraphEditorSession) = session.mapping +Base.close(session::GraphEditorSession) = close(session.server) + +""" + edit_graph(mapping; mtg=nothing, host="127.0.0.1", port=8765) + +Start a local graph editor session. The returned session owns the current +`ModelMapping`; call `current_mapping(session)` to recover the edited mapping. +""" +function edit_graph(mapping; mtg=nothing, host::AbstractString="127.0.0.1", port::Integer=8765) + session_ref = Ref{Any}() + handler = http -> _handle_http(session_ref[], http) + server = HTTP.listen!(handler, host, port; listenany=true, verbose=false) + actual_port = HTTP.port(server) + session = GraphEditorSession( + mapping, + mtg, + typeof(mapping)[], + typeof(mapping)[], + server, + String(host), + actual_port, + "http://$(host):$(actual_port)", + ) + session_ref[] = session + return session +end + +function apply_edit!(session::GraphEditorSession, edit::PlantSimEngine.AbstractGraphEdit) + push!(session.history, session.mapping) + empty!(session.future) + session.mapping = PlantSimEngine.apply_graph_edit(session.mapping, edit) + return session.mapping +end + +function undo!(session::GraphEditorSession) + isempty(session.history) && return session.mapping + push!(session.future, session.mapping) + session.mapping = pop!(session.history) + return session.mapping +end + +function redo!(session::GraphEditorSession) + isempty(session.future) && return session.mapping + push!(session.history, session.mapping) + session.mapping = pop!(session.future) + return session.mapping +end + +function _handle_http(session::GraphEditorSession, http::HTTP.Stream) + if HTTP.WebSockets.isupgrade(http.message) + return HTTP.WebSockets.upgrade(http) do ws + _handle_websocket(session, ws) + end + end + + req = http.message + path = HTTP.URI(req.target).path + response = if path == "/" || path == "/index.html" + (200, ["Content-Type" => "text/html; charset=utf-8"], _editor_html(session)) + elseif path == "/state" + (200, ["Content-Type" => "application/json"], _state_json(session)) + else + (404, ["Content-Type" => "text/plain; charset=utf-8"], "Not found") + end + status, headers, body = response + HTTP.setstatus(http, status) + for header in headers + HTTP.setheader(http, header) + end + HTTP.setheader(http, "Content-Length" => string(sizeof(body))) + HTTP.startwrite(http) + write(http, body) + return nothing +end + +function _handle_websocket(session::GraphEditorSession, ws) + HTTP.WebSockets.send(ws, _state_json(session)) + try + for message in ws + command = JSON.parse(String(message)) + response = _handle_command!(session, command) + HTTP.WebSockets.send(ws, JSON.json(response)) + end + catch err + HTTP.WebSockets.send(ws, JSON.json(_error_payload(err))) + end +end + +function _handle_command!(session::GraphEditorSession, command) + action = get(command, "action", "") + try + if action == "undo" + undo!(session) + elseif action == "redo" + redo!(session) + elseif action == "edit" + edit = _edit_from_command(command) + apply_edit!(session, edit) + else + error("Unsupported graph editor command action `$action`.") + end + return _state_payload(session; ok=true) + catch err + return _state_payload(session; ok=false, diagnostics=[sprint(showerror, err)]) + end +end + +function _edit_from_command(command) + kind = get(command, "kind", "") + kind == "mark_previous_timestep" && return PlantSimEngine.MarkPreviousTimeStep( + Symbol(command["scale"]), + Symbol(command["process"]), + Symbol(command["variable"]), + ) + kind == "unmark_previous_timestep" && return PlantSimEngine.UnmarkPreviousTimeStep( + Symbol(command["scale"]), + Symbol(command["process"]), + Symbol(command["variable"]), + ) + kind == "remove_model" && return PlantSimEngine.RemoveModel( + Symbol(command["scale"]), + Symbol(command["process"]), + ) + kind == "set_mapped_variable" && return PlantSimEngine.SetMappedVariable( + Symbol(command["scale"]), + Symbol(command["process"]), + Symbol(command["variable"]), + Symbol(command["sourceScale"]), + Symbol(command["sourceVariable"]), + Symbol(get(command, "mode", "single")), + ) + if kind in ("add_model", "replace_model") + model_type = _resolve_model_type(command["modelType"]) + parameters = _parameters_from_command(get(command, "parameters", Dict())) + if kind == "add_model" + return PlantSimEngine.AddModel(Symbol(command["scale"]), model_type, parameters) + end + return PlantSimEngine.ReplaceModel(Symbol(command["scale"]), Symbol(command["process"]), model_type, parameters) + end + error("Unsupported graph edit kind `$kind`.") +end + +function _resolve_model_type(label) + for model_type in PlantSimEngine.available_models() + string(model_type) == label && return model_type + string(nameof(model_type)) == label && return model_type + end + error("No loaded PlantSimEngine model type matches `$label`. Load the package that defines it first.") +end + +function _parameters_from_command(parameters) + pairs = Pair{Symbol,Any}[] + for (key, value) in parameters + push!(pairs, Symbol(key) => _parse_parameter_value(value)) + end + return (; pairs...) +end + +function _parse_parameter_value(value) + value isa AbstractDict || return value + choice = Symbol(get(value, "type", "julia")) + raw = get(value, "value", nothing) + choice == :float && return Float64(raw) + choice == :integer && return Int(raw) + choice == :boolean && return Bool(raw) + choice == :symbol && return Symbol(raw) + choice == :string && return String(raw) + choice == :nothing && return nothing + choice == :julia && return Core.eval(Main, Meta.parse(String(raw))) + return raw +end + +function _state_payload(session::GraphEditorSession; ok::Bool=true, diagnostics::Vector{String}=String[]) + graph = JSON.parse(PlantSimEngine.graph_view_json(session.mapping)) + append!(graph["diagnostics"], diagnostics) + return Dict( + "ok" => ok, + "graph" => graph, + "models" => [PlantSimEngine.model_descriptor(T) for T in PlantSimEngine.available_models()], + "canUndo" => !isempty(session.history), + "canRedo" => !isempty(session.future), + "url" => session.url, + ) +end + +_state_json(session::GraphEditorSession) = JSON.json(_state_payload(session)) +_error_payload(err) = Dict("ok" => false, "diagnostics" => [sprint(showerror, err)]) + +function _editor_html(session::GraphEditorSession) + react_html = _react_editor_html(session) + isnothing(react_html) || return react_html + + graph_json = PlantSimEngine.graph_view_json(session.mapping) + config_json = JSON.json(Dict("websocketUrl" => "ws://$(session.host):$(session.port)/ws")) + return """ + + + + + +PlantSimEngine Graph Editor + + + + +
+

PlantSimEngine Graph Editor

+

This live session is running. The React editor can connect to ws://$(session.host):$(session.port)/ws.

+

Current graph state is available at /state.

+

+
+ + + +""" +end + +function _react_editor_html(session::GraphEditorSession) + assets_dir = _frontend_dist_dir() + manifest_path = joinpath(assets_dir, ".vite", "manifest.json") + isfile(manifest_path) || return nothing + + manifest = JSON.parse(read(manifest_path, String)) + entry = nothing + for value in values(manifest) + if get(value, "isEntry", false) == true + entry = value + break + end + end + isnothing(entry) && (entry = get(manifest, "index.html", nothing)) + isnothing(entry) && return nothing + + js_file = get(entry, "file", nothing) + isnothing(js_file) && return nothing + css_files = get(entry, "css", Any[]) + js = read(joinpath(assets_dir, js_file), String) + css = join([read(joinpath(assets_dir, css_file), String) for css_file in css_files], "\n") + graph_json = PlantSimEngine.graph_view_json(session.mapping) + config_json = replace(JSON.json(Dict("websocketUrl" => "ws://$(session.host):$(session.port)/ws")), " "<\\/") + + return """ + + + + + +PlantSimEngine Graph Editor + + + + + +
+ + + +""" +end + +_frontend_dist_dir() = normpath(joinpath(@__DIR__, "..", "..", "..", "frontend", "dist")) + +end diff --git a/src/PlantSimEngine.jl b/src/PlantSimEngine.jl index f3d1a81c..e12f7fb7 100644 --- a/src/PlantSimEngine.jl +++ b/src/PlantSimEngine.jl @@ -5,8 +5,10 @@ import DataFrames import Tables import DataAPI import Dates +import InteractiveUtils import CSV # For reading csv files with variables() +import JSON # For graph JSON serialization # For graph dependency: import AbstractTrees @@ -94,6 +96,7 @@ include("processes/model_initialisation.jl") include("processes/models_inputs_outputs.jl") include("processes/process_generation.jl") include("checks/dimensions.jl") +include("model_discovery.jl") # Multi-rate runtime: include("time/runtime/clocks.jl") @@ -142,8 +145,9 @@ export input_bindings, meteo_bindings, meteo_window, output_routing, model_scope export run! export fit export GraphPort, GraphNode, GraphEdge, DependencyGraphView -export graph_view, graph_view_json, write_graph_view -export AbstractGraphEdit, MarkPreviousTimeStep, apply_graph_edit +export graph_view, graph_view_json, write_graph_view, compile_graph_view +export available_processes, available_models, model_descriptor, model_constructor_descriptor +export AbstractGraphEdit, AddModel, RemoveModel, ReplaceModel, SetMappedVariable, MarkPreviousTimeStep, UnmarkPreviousTimeStep, apply_graph_edit # Re-exporting PlantMeteo main functions: export Atmosphere, TimeStepTable, Constants, Weather diff --git a/src/model_discovery.jl b/src/model_discovery.jl new file mode 100644 index 00000000..1be00446 --- /dev/null +++ b/src/model_discovery.jl @@ -0,0 +1,242 @@ +const _MODEL_PARAMETER_TYPE_CHOICES = ( + :float, + :integer, + :boolean, + :symbol, + :string, + :nothing, + :julia, +) + +const _MODEL_DISCOVERY_EXCLUDED_NAMES = Set{Symbol}([ + :MultiScaleModel, + :ModelSpec, +]) + +""" + available_processes() + +Return process abstract model types visible in the current Julia session. + +Packages become discoverable after the user loads them with `using PackageName`. +""" +function available_processes() + processes = Type[] + for T in _abstract_model_subtypes() + _is_process_type(T) || continue + push!(processes, T) + end + return sort!(unique(processes); by=T -> string(process_(T))) +end + +""" + available_models() + available_models(process::Symbol) + available_models(process_type::Type{<:AbstractModel}) + +Return model implementation types visible in the current Julia session. +""" +function available_models() + models = Type[] + for T in _abstract_model_subtypes() + _is_available_model_type(T) || continue + push!(models, T) + end + return sort!(unique(models); by=T -> string(_process_name_for_type(T), ".", nameof(T))) +end + +function available_models(process_name::Symbol) + filter(T -> _process_name_for_type(T) == process_name, available_models()) +end + +function available_models(process_type::Type{<:AbstractModel}) + process_name = _is_process_type(process_type) ? process_(process_type) : _process_name_for_type(process_type) + return available_models(process_name) +end + +""" + model_descriptor(::Type{<:AbstractModel}) + +Return renderer-friendly metadata for one model implementation type. +""" +function model_descriptor(::Type{T}) where {T<:AbstractModel} + process_type = _process_type_for_model(T) + process_name = isnothing(process_type) ? nothing : process_(process_type) + constructor = model_constructor_descriptor(T) + return Dict{String,Any}( + "type" => string(T), + "name" => string(nameof(T)), + "process" => isnothing(process_name) ? nothing : string(process_name), + "processType" => isnothing(process_type) ? nothing : string(process_type), + "inputs" => _model_var_descriptor(T, inputs_), + "outputs" => _model_var_descriptor(T, outputs_), + "timespec" => _safe_string_trait(T, timespec), + "outputPolicy" => _safe_string_trait(T, output_policy), + "timestepHint" => _safe_string_trait(T, timestep_hint), + "meteoHint" => _safe_string_trait(T, meteo_hint), + "constructor" => constructor, + ) +end + +""" + model_constructor_descriptor(::Type{<:AbstractModel}) + +Return best-effort constructor metadata inferred from struct fields and an +optional zero-argument constructor. +""" +function model_constructor_descriptor(::Type{T}) where {T<:AbstractModel} + unwrapped_type = Base.unwrap_unionall(T) + names = collect(fieldnames(unwrapped_type)) + declared_types = collect(fieldtypes(unwrapped_type)) + default_instance = _try_zero_arg_model(T) + has_defaults = !isnothing(default_instance) + + fields = Dict{String,Any}[] + parameter_groups = Dict{String,Vector{String}}() + for (i, name) in pairs(names) + declared = declared_types[i] + default = has_defaults ? getfield(default_instance, name) : nothing + default_type = has_defaults ? typeof(default) : nothing + parameter_key = _field_type_parameter_key(declared) + inferred_choice = _parameter_choice(default_type, declared) + field_name = string(name) + if !isnothing(parameter_key) + push!(get!(parameter_groups, parameter_key, String[]), field_name) + end + + push!(fields, Dict{String,Any}( + "name" => field_name, + "declaredType" => string(declared), + "hasDefault" => has_defaults, + "default" => has_defaults ? _jsonable_value(default) : nothing, + "defaultType" => isnothing(default_type) ? nothing : string(default_type), + "typeParameter" => parameter_key, + "inferredChoice" => string(inferred_choice), + "choices" => string.(_MODEL_PARAMETER_TYPE_CHOICES), + )) + end + + return Dict{String,Any}( + "type" => string(T), + "name" => string(nameof(T)), + "fields" => fields, + "parameterGroups" => parameter_groups, + "hasZeroArgConstructor" => has_defaults, + "constructible" => true, + "positional" => true, + "keyword" => false, + ) +end + +function _abstract_model_subtypes(root::Type=AbstractModel) + found = Type[] + for child in InteractiveUtils.subtypes(root) + push!(found, child) + append!(found, _abstract_model_subtypes(child)) + end + return found +end + +function _is_process_type(T::Type) + isabstracttype(T) || return false + T === AbstractModel && return false + try + process_(T) + return true + catch + return false + end +end + +function _is_available_model_type(T::Type) + isabstracttype(T) && return false + nameof(T) in _MODEL_DISCOVERY_EXCLUDED_NAMES && return false + isnothing(_process_type_for_model(T)) && return false + return true +end + +function _process_type_for_model(T::Type) + current = T + while current !== Any && current !== AbstractModel + _is_process_type(current) && return current + current = supertype(current) + end + return nothing +end + +function _process_name_for_type(T::Type) + process_type = _process_type_for_model(T) + isnothing(process_type) && return Symbol(nameof(T)) + return process_(process_type) +end + +function _model_var_descriptor(::Type{T}, accessor) where {T<:AbstractModel} + instance = _try_zero_arg_model(T) + isnothing(instance) && return Dict{String,Any}() + vars = try + accessor(instance) + catch err + return Dict{String,Any}("_error" => sprint(showerror, err)) + end + return Dict(string(k) => _jsonable_value(v) for (k, v) in pairs(vars)) +end + +function _safe_string_trait(::Type{T}, trait) where {T<:AbstractModel} + value = try + trait(T) + catch + instance = _try_zero_arg_model(T) + isnothing(instance) && return nothing + try + trait(instance) + catch err + return string("error: ", sprint(showerror, err)) + end + end + return string(value) +end + +function _try_zero_arg_model(::Type{T}) where {T<:AbstractModel} + try + return T() + catch + return nothing + end +end + +function _field_type_parameter_key(field_type) + field_type isa TypeVar && return string(field_type.name) + return nothing +end + +function _parameter_choice(default_type, declared_type) + !isnothing(default_type) && return _parameter_choice_from_type(default_type) + return _parameter_choice_from_type(declared_type) +end + +function _parameter_choice_from_type(T) + try + T === Nothing && return :nothing + T === Any && return :float + T isa TypeVar && return :float + T === Bool && return :boolean + T <: Integer && return :integer + T <: AbstractFloat && return :float + T <: Real && return :float + T <: Symbol && return :symbol + T <: AbstractString && return :string + catch + return :float + end + return :julia +end + +function _jsonable_value(value) + value === nothing && return nothing + value isa Bool && return value + value isa Real && isfinite(value) && return value + value isa Symbol && return string(":", value) + value isa AbstractString && return value + value isa AbstractArray && return string(typeof(value), " length ", length(value)) + return string(value) +end diff --git a/src/visualization/dependency_graph_view.jl b/src/visualization/dependency_graph_view.jl index da63cb78..5fdb2485 100644 --- a/src/visualization/dependency_graph_view.jl +++ b/src/visualization/dependency_graph_view.jl @@ -63,11 +63,39 @@ struct DependencyGraphView scales::Vector{Symbol} cyclic::Bool cycle_nodes::Vector{String} + cycle_edges::Vector{String} diagnostics::Vector{String} end abstract type AbstractGraphEdit end +struct AddModel{P} <: AbstractGraphEdit + scale::Symbol + model_type::Type + parameters::P +end + +struct RemoveModel <: AbstractGraphEdit + scale::Symbol + process::Symbol +end + +struct ReplaceModel{P} <: AbstractGraphEdit + scale::Symbol + process::Symbol + model_type::Type + parameters::P +end + +struct SetMappedVariable <: AbstractGraphEdit + scale::Symbol + process::Symbol + variable::Symbol + source_scale::Symbol + source_variable::Symbol + mode::Symbol +end + """ MarkPreviousTimeStep(scale, process, variable) @@ -80,25 +108,53 @@ struct MarkPreviousTimeStep <: AbstractGraphEdit variable::Symbol end +struct UnmarkPreviousTimeStep <: AbstractGraphEdit + scale::Symbol + process::Symbol + variable::Symbol +end + """ + compile_graph_view(mapping; strict=false) graph_view(mapping) graph_view(sim::GraphSimulation) Build a renderer-independent view of a dependency graph. """ -function graph_view(mapping::ModelMapping; verbose::Bool=false) +function compile_graph_view(mapping::ModelMapping; verbose::Bool=false, strict::Bool=false) diagnostics = String[] - graph = try + graph, diagnostics = try dep(mapping; verbose=verbose) catch err + strict && rethrow() msg = sprint(showerror, err) push!(diagnostics, msg) - return _graph_view_from_mapping_only(mapping, diagnostics) - end + graph_for_view = _dependency_graph_for_view(mapping, diagnostics) + isnothing(graph_for_view) && return _graph_view_from_mapping_only(mapping, diagnostics) + graph_for_view + end, diagnostics return graph_view(graph, mapping; diagnostics=diagnostics) end +function graph_view(mapping::ModelMapping; kwargs...) + return compile_graph_view(mapping; kwargs...) +end + +function _dependency_graph_for_view(mapping::ModelMapping{MultiScale}, diagnostics) + try + soft_dep_graphs_roots, hard_dep_dict = hard_dependencies(mapping; verbose=false) + mapped_vars = mapped_variables(mapping, soft_dep_graphs_roots, verbose=false) + reverse_multiscale_mapping = reverse_mapping(mapped_vars, all=false) + return soft_dependencies_multiscale(soft_dep_graphs_roots, reverse_multiscale_mapping, hard_dep_dict) + catch err + push!(diagnostics, sprint(showerror, err)) + return nothing + end +end + +_dependency_graph_for_view(::ModelMapping, diagnostics) = nothing + function graph_view(sim::GraphSimulation; diagnostics::Vector{String}=String[]) return graph_view(sim.dependency_graph, sim; diagnostics=diagnostics) end @@ -149,8 +205,23 @@ function graph_view(graph::DependencyGraph, context=nothing; diagnostics::Vector cyclic, cycle_vec = is_graph_cyclic(graph; warn=false) cycle_nodes = cyclic ? [_model_node_id(last(pair), process(first(pair))) for pair in cycle_vec] : String[] + cycle_edges = cyclic ? _cycle_edge_ids(edges, cycle_nodes) : String[] scales = sort!(unique([node.scale for node in nodes]); by=string) - return DependencyGraphView(nodes, edges, scales, cyclic, cycle_nodes, diagnostics) + return DependencyGraphView(nodes, edges, scales, cyclic, cycle_nodes, cycle_edges, diagnostics) +end + +function _cycle_edge_ids(edges::Vector{GraphEdge}, cycle_nodes::Vector{String}) + ids = String[] + length(cycle_nodes) < 2 && return ids + for i in 1:(length(cycle_nodes)-1) + source = cycle_nodes[i + 1] + target = cycle_nodes[i] + for edge in edges + edge.source == source && edge.target == target || continue + push!(ids, edge.id) + end + end + return unique(ids) end function _push_edge!(edges::Vector{GraphEdge}, edge_ids::Set{String}, edge::GraphEdge) @@ -361,10 +432,181 @@ function apply_graph_edit(mapping::ModelMapping{MultiScale}, edit::MarkPreviousT return ModelMapping(data; check=true, type_promotion=type_promotion(mapping)) end +function apply_graph_edit(mapping::ModelMapping{MultiScale}, edit::UnmarkPreviousTimeStep) + haskey(mapping, edit.scale) || error("Cannot unmark `$(edit.variable)` as previous timestep: scale `$(edit.scale)` is not present in the `ModelMapping`.") + + found = Ref(false) + data = Dict{Symbol,Any}() + for (scale, entry) in pairs(mapping) + data[scale] = scale == edit.scale ? _unmark_previous_timestep_entry(entry, edit, found) : entry + end + + found[] || error("Cannot unmark `$(edit.variable)` as previous timestep: process `$(edit.process)` was not found at scale `$(edit.scale)`.") + return ModelMapping(data; check=true, type_promotion=type_promotion(mapping)) +end + +function apply_graph_edit(mapping::ModelMapping{MultiScale}, edit::AddModel) + haskey(mapping, edit.scale) || error("Cannot add model: scale `$(edit.scale)` is not present in the `ModelMapping`.") + model = _construct_graph_edit_model(edit.model_type, edit.parameters) + process_name = process(model) + any(existing -> process(existing) == process_name, get_models(mapping[edit.scale])) && error( + "Cannot add `$(typeof(model))` at scale `$(edit.scale)`: process `$process_name` already exists at this scale." + ) + + data = Dict{Symbol,Any}() + for (scale, entry) in pairs(mapping) + data[scale] = scale == edit.scale ? _insert_model_entry(entry, model) : entry + end + return ModelMapping(data; check=true, type_promotion=type_promotion(mapping)) +end + +function apply_graph_edit(mapping::ModelMapping{MultiScale}, edit::RemoveModel) + haskey(mapping, edit.scale) || error("Cannot remove model: scale `$(edit.scale)` is not present in the `ModelMapping`.") + found = Ref(false) + data = Dict{Symbol,Any}() + for (scale, entry) in pairs(mapping) + data[scale] = scale == edit.scale ? _remove_model_entry(entry, edit.process, found) : entry + end + found[] || error("Cannot remove model: process `$(edit.process)` was not found at scale `$(edit.scale)`.") + return ModelMapping(data; check=true, type_promotion=type_promotion(mapping)) +end + +function apply_graph_edit(mapping::ModelMapping{MultiScale}, edit::ReplaceModel) + haskey(mapping, edit.scale) || error("Cannot replace model: scale `$(edit.scale)` is not present in the `ModelMapping`.") + model = _construct_graph_edit_model(edit.model_type, edit.parameters) + process(model) == edit.process || error( + "Cannot replace process `$(edit.process)` with `$(typeof(model))`: replacement model implements process `$(process(model))`." + ) + found = Ref(false) + data = Dict{Symbol,Any}() + for (scale, entry) in pairs(mapping) + data[scale] = scale == edit.scale ? _replace_model_entry(entry, edit.process, model, found) : entry + end + found[] || error("Cannot replace model: process `$(edit.process)` was not found at scale `$(edit.scale)`.") + return ModelMapping(data; check=true, type_promotion=type_promotion(mapping)) +end + +function apply_graph_edit(mapping::ModelMapping{MultiScale}, edit::SetMappedVariable) + haskey(mapping, edit.scale) || error("Cannot set mapping: scale `$(edit.scale)` is not present in the `ModelMapping`.") + found = Ref(false) + data = Dict{Symbol,Any}() + for (scale, entry) in pairs(mapping) + data[scale] = scale == edit.scale ? _set_mapped_variable_entry(entry, edit, found) : entry + end + found[] || error("Cannot set mapping for `$(edit.variable)`: process `$(edit.process)` was not found at scale `$(edit.scale)`.") + return ModelMapping(data; check=true, type_promotion=type_promotion(mapping)) +end + function apply_graph_edit(mapping::ModelMapping, edit::AbstractGraphEdit) error("Graph edit `$(typeof(edit))` is not supported for `$(typeof(mapping))`.") end +function _construct_graph_edit_model(model_type::Type, parameters::NamedTuple) + names = fieldnames(model_type) + if isempty(parameters) + try + return model_type() + catch + end + end + isempty(names) && isempty(parameters) && return model_type() + values = Any[] + for name in names + haskey(parameters, name) || error("Missing constructor parameter `$name` for model `$(model_type)`.") + push!(values, parameters[name]) + end + return model_type(values...) +end + +_construct_graph_edit_model(model_type::Type, parameters::AbstractDict) = + _construct_graph_edit_model(model_type, (; (Symbol(k) => v for (k, v) in pairs(parameters))...)) + +function _insert_model_entry(entry::Tuple, model) + items = Any[] + inserted = false + for item in entry + if !inserted && item isa Status + push!(items, model) + inserted = true + end + push!(items, item) + end + inserted || push!(items, model) + return tuple(items...) +end + +_insert_model_entry(entry, model) = _insert_model_entry((entry,), model) + +function _remove_model_entry(entry::Tuple, process_name::Symbol, found::Base.RefValue{Bool}) + items = Any[] + for item in entry + if !(item isa Status) && process(model_(as_model_spec(item))) == process_name + found[] = true + continue + end + push!(items, item) + end + return tuple(items...) +end + +_remove_model_entry(entry, process_name::Symbol, found::Base.RefValue{Bool}) = + _remove_model_entry((entry,), process_name, found) + +function _replace_model_entry(entry::Tuple, process_name::Symbol, model, found::Base.RefValue{Bool}) + return tuple((_replace_model_item(item, process_name, model, found) for item in entry)...) +end + +_replace_model_entry(entry, process_name::Symbol, model, found::Base.RefValue{Bool}) = + _replace_model_item(entry, process_name, model, found) + +function _replace_model_item(item::Status, ::Symbol, model, ::Base.RefValue{Bool}) + return item +end + +function _replace_model_item(item, process_name::Symbol, model, found::Base.RefValue{Bool}) + spec = as_model_spec(item) + process(model_(spec)) == process_name || return item + found[] = true + return ModelSpec(spec; model=model) +end + +function _set_mapped_variable_entry(entry::Tuple, edit::SetMappedVariable, found::Base.RefValue{Bool}) + return tuple((_set_mapped_variable_item(item, edit, found) for item in entry)...) +end + +_set_mapped_variable_entry(entry, edit::SetMappedVariable, found::Base.RefValue{Bool}) = + _set_mapped_variable_item(entry, edit, found) + +_set_mapped_variable_item(item::Status, ::SetMappedVariable, ::Base.RefValue{Bool}) = item + +function _set_mapped_variable_item(item, edit::SetMappedVariable, found::Base.RefValue{Bool}) + spec = as_model_spec(item) + process(model_(spec)) == edit.process || return item + edit.variable in keys(variables(model_(spec))) || error( + "Cannot map `$(edit.variable)` for process `$(edit.process)` at scale `$(edit.scale)`: ", + "the variable is not declared as an input or output of `$(typeof(model_(spec)))`." + ) + found[] = true + source = edit.mode in (:multi, :multi_node, :vector) ? + [edit.source_scale => edit.source_variable] : + edit.source_scale => edit.source_variable + return ModelSpec(spec; multiscale=_set_mapping_item(spec.multiscale, edit.variable, source)) +end + +function _set_mapping_item(mapping, variable::Symbol, source) + mapped = isnothing(mapping) ? Any[] : Any[collect(mapping)...] + for i in eachindex(mapped) + item = mapped[i] + lhs = item isa Pair ? first(item) : item + lhs_var = lhs isa PreviousTimeStep ? lhs.variable : lhs + lhs_var == variable || continue + mapped[i] = lhs isa PreviousTimeStep ? PreviousTimeStep(variable) => source : variable => source + return mapped + end + push!(mapped, variable => source) + return mapped +end + function _mark_previous_timestep_entry(entry::Tuple, edit::MarkPreviousTimeStep, found::Base.RefValue{Bool}) return tuple((_mark_previous_timestep_item(item, edit, found) for item in entry)...) end @@ -411,6 +653,41 @@ function _mark_previous_timestep_mapping(mapping, variable::Symbol) return mapped end +function _unmark_previous_timestep_entry(entry::Tuple, edit::UnmarkPreviousTimeStep, found::Base.RefValue{Bool}) + return tuple((_unmark_previous_timestep_item(item, edit, found) for item in entry)...) +end + +_unmark_previous_timestep_entry(entry, edit::UnmarkPreviousTimeStep, found::Base.RefValue{Bool}) = + _unmark_previous_timestep_item(entry, edit, found) + +_unmark_previous_timestep_item(item::Status, ::UnmarkPreviousTimeStep, ::Base.RefValue{Bool}) = item + +function _unmark_previous_timestep_item(item, edit::UnmarkPreviousTimeStep, found::Base.RefValue{Bool}) + spec = as_model_spec(item) + process(model_(spec)) == edit.process || return item + found[] = true + return ModelSpec(spec; multiscale=_unmark_previous_timestep_mapping(spec.multiscale, edit.variable)) +end + +function _unmark_previous_timestep_mapping(mapping, variable::Symbol) + isnothing(mapping) && return mapping + mapped = Any[] + for item in mapping + if item isa Pair && first(item) isa PreviousTimeStep && first(item).variable == variable + source = last(item) + if source == (Symbol("") => variable) + continue + end + push!(mapped, variable => source) + elseif item isa PreviousTimeStep && item.variable == variable + continue + else + push!(mapped, item) + end + end + return mapped +end + function _graph_view_from_mapping_only(mapping::ModelMapping, diagnostics) nodes = GraphNode[] for (scale, entry) in pairs(mapping) @@ -434,7 +711,9 @@ function _graph_view_from_mapping_only(mapping::ModelMapping, diagnostics) end end scales = sort!(unique([node.scale for node in nodes]); by=string) - return DependencyGraphView(nodes, GraphEdge[], scales, any(occursin.("Cyclic", diagnostics)), String[], diagnostics) + cyclic = any(occursin.("Cyclic", diagnostics)) + cycle_nodes = cyclic ? [node.id for node in nodes] : String[] + return DependencyGraphView(nodes, GraphEdge[], scales, cyclic, cycle_nodes, String[], diagnostics) end function _graph_node(node::AbstractDependencyNode, id::String, context, node_ids) @@ -633,6 +912,7 @@ function _graph_view_dict(view::DependencyGraphView) "scales" => string.(view.scales), "cyclic" => view.cyclic, "cycleNodes" => view.cycle_nodes, + "cycleEdges" => view.cycle_edges, "diagnostics" => view.diagnostics, ) end @@ -682,45 +962,7 @@ function _edge_dict(edge::GraphEdge) ) end -function _json(value) - io = IOBuffer() - _write_json(io, value) - return String(take!(io)) -end - -function _write_json(io, value::AbstractDict) - print(io, "{") - first_item = true - for (key, val) in value - first_item || print(io, ",") - first_item = false - _write_json(io, string(key)) - print(io, ":") - _write_json(io, val) - end - print(io, "}") -end - -function _write_json(io, value::AbstractVector) - print(io, "[") - for (i, val) in pairs(value) - i == firstindex(value) || print(io, ",") - _write_json(io, val) - end - print(io, "]") -end - -_write_json(io, value::Nothing) = print(io, "null") -_write_json(io, value::Bool) = print(io, value ? "true" : "false") -_write_json(io, value::Real) = isfinite(value) ? print(io, value) : _write_json(io, string(value)) -_write_json(io, value::Symbol) = _write_json(io, string(value)) -_write_json(io, value::AbstractString) = print(io, "\"", _escape_json(value), "\"") -_write_json(io, value) = _write_json(io, string(value)) - -function _escape_json(s::AbstractString) - escaped = replace(s, "\\" => "\\\\", "\"" => "\\\"", "\n" => "\\n", "\r" => "\\r", "\t" => "\\t") - return replace(escaped, " "<\\/") -end +_json(value) = replace(JSON.json(value), " "<\\/") function _graph_view_html(view::DependencyGraphView; renderer::Symbol=:react) if renderer == :react diff --git a/test/test-dependency-graph-view.jl b/test/test-dependency-graph-view.jl index da25a7dd..3431d1c8 100644 --- a/test/test-dependency-graph-view.jl +++ b/test/test-dependency-graph-view.jl @@ -1,10 +1,16 @@ abstract type AbstractGraphViewPlantAgeModel <: PlantSimEngine.AbstractModel end abstract type AbstractGraphViewPhytomerEmissionModel <: PlantSimEngine.AbstractModel end abstract type AbstractGraphViewInitiationAgeModel <: PlantSimEngine.AbstractModel end +abstract type AbstractGraphViewParamModel <: PlantSimEngine.AbstractModel end +abstract type AbstractGraphViewCyclePlantModel <: PlantSimEngine.AbstractModel end +abstract type AbstractGraphViewCycleLeafModel <: PlantSimEngine.AbstractModel end PlantSimEngine.process_(::Type{AbstractGraphViewPlantAgeModel}) = :graph_view_plant_age PlantSimEngine.process_(::Type{AbstractGraphViewPhytomerEmissionModel}) = :graph_view_phytomer_emission PlantSimEngine.process_(::Type{AbstractGraphViewInitiationAgeModel}) = :graph_view_initiation_age +PlantSimEngine.process_(::Type{AbstractGraphViewParamModel}) = :graph_view_param +PlantSimEngine.process_(::Type{AbstractGraphViewCyclePlantModel}) = :graph_view_cycle_plant +PlantSimEngine.process_(::Type{AbstractGraphViewCycleLeafModel}) = :graph_view_cycle_leaf struct GraphViewPlantAgeModel <: AbstractGraphViewPlantAgeModel end @@ -25,6 +31,35 @@ end PlantSimEngine.inputs_(::GraphViewInitiationAgeModel) = (plant_age=-Inf,) PlantSimEngine.outputs_(::GraphViewInitiationAgeModel) = (initiation_age=-Inf,) +struct GraphViewParamModel{T} <: AbstractGraphViewParamModel + a::T + b::T +end + +PlantSimEngine.inputs_(::GraphViewParamModel) = (x=-Inf,) +PlantSimEngine.outputs_(::GraphViewParamModel) = (y=-Inf,) + +struct GraphViewDefaultParamModel <: AbstractGraphViewParamModel + alpha::Float64 + mode::Symbol +end + +GraphViewDefaultParamModel() = GraphViewDefaultParamModel(1.5, :fast) +PlantSimEngine.inputs_(::GraphViewDefaultParamModel) = (x=-Inf,) +PlantSimEngine.outputs_(::GraphViewDefaultParamModel) = (z=-Inf,) + +struct GraphViewCyclePlantModel <: AbstractGraphViewCyclePlantModel +end + +PlantSimEngine.inputs_(::GraphViewCyclePlantModel) = (y=-Inf,) +PlantSimEngine.outputs_(::GraphViewCyclePlantModel) = (x=-Inf,) + +struct GraphViewCycleLeafModel <: AbstractGraphViewCycleLeafModel +end + +PlantSimEngine.inputs_(::GraphViewCycleLeafModel) = (x=-Inf,) +PlantSimEngine.outputs_(::GraphViewCycleLeafModel) = (y=-Inf,) + @testset "Dependency graph view" begin mapping = ModelMapping( ToyLAIModel(), @@ -118,4 +153,57 @@ PlantSimEngine.outputs_(::GraphViewInitiationAgeModel) = (initiation_age=-Inf,) @test any(edge -> edge.kind == :mapped_variable && edge.source_variable == :plant_age && edge.target_variable == :plant_age, plant_age_edges) @test !any(edge -> edge.source_variable == :last_phytomer, plant_age_edges) @test any(edge -> edge.kind == :hard_dependency && isnothing(edge.source_port) && isnothing(edge.target_port), hard_mapped_view.edges) + + @test AbstractGraphViewParamModel in available_processes() + @test GraphViewParamModel in available_models(:graph_view_param) + descriptor = model_constructor_descriptor(GraphViewParamModel) + fields = descriptor["fields"] + @test length(fields) == 2 + @test fields[1]["typeParameter"] == "T" + @test fields[2]["typeParameter"] == "T" + @test descriptor["parameterGroups"]["T"] == ["a", "b"] + @test fields[1]["inferredChoice"] == "float" + + default_descriptor = model_constructor_descriptor(GraphViewDefaultParamModel) + default_fields = default_descriptor["fields"] + @test default_descriptor["hasZeroArgConstructor"] + @test default_fields[1]["default"] == 1.5 + @test default_fields[1]["inferredChoice"] == "float" + @test default_fields[2]["default"] == ":fast" + @test default_fields[2]["inferredChoice"] == "symbol" + + add_mapping = ModelMapping(:Plant => (GraphViewPlantAgeModel(), Status(day=1.0))) + added_mapping = apply_graph_edit(add_mapping, AddModel(:Plant, GraphViewPhytomerEmissionModel, NamedTuple())) + @test any(m -> process(m) == :graph_view_phytomer_emission, PlantSimEngine.get_models(added_mapping[:Plant])) + @test_throws "already exists" apply_graph_edit(added_mapping, AddModel(:Plant, GraphViewPhytomerEmissionModel, NamedTuple())) + removed_mapping = apply_graph_edit(added_mapping, RemoveModel(:Plant, :graph_view_phytomer_emission)) + @test !any(m -> process(m) == :graph_view_phytomer_emission, PlantSimEngine.get_models(removed_mapping[:Plant])) + replaced_mapping = apply_graph_edit(add_mapping, ReplaceModel(:Plant, :graph_view_plant_age, GraphViewPlantAgeModel, NamedTuple())) + @test only(PlantSimEngine.get_models(replaced_mapping[:Plant])) isa GraphViewPlantAgeModel + + mapped_edit_mapping = apply_graph_edit( + hard_mapped_mapping, + SetMappedVariable(:Phytomer, :graph_view_initiation_age, :plant_age, :Plant, :plant_age, :single), + ) + mapped_spec = PlantSimEngine.parse_model_specs(mapped_edit_mapping[:Phytomer])[:graph_view_initiation_age] + @test first(PlantSimEngine.mapped_variables_(mapped_spec)) == (:plant_age => (:Plant => :plant_age)) + + unmarked_mapping = apply_graph_edit(edited_mapping, UnmarkPreviousTimeStep(:Leaf, :carbon_assimilation, :soil_water_content)) + unmarked_view = graph_view(unmarked_mapping) + @test any( + edge -> edge.source_variable == :soil_water_content && + edge.target_variable == :soil_water_content && + edge.source != edge.target, + unmarked_view.edges, + ) + + cyclic_mapping = ModelMapping( + :Plant => MultiScaleModel(GraphViewCyclePlantModel(), [:y => [:Leaf]]), + :Leaf => MultiScaleModel(GraphViewCycleLeafModel(), [:x => :Plant]), + ) + @test_throws "Cyclic dependency detected" dep(cyclic_mapping) + cyclic_view = graph_view(cyclic_mapping) + @test cyclic_view.cyclic + @test !isempty(cyclic_view.cycle_nodes) + @test occursin("Cyclic dependency detected", join(cyclic_view.diagnostics, "\n")) end From 4a13d75c51d1d26b1f11b687a9d7bdb2f875b830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Tue, 5 May 2026 07:27:07 +0200 Subject: [PATCH 02/19] Move graph editor into a PlantSimEngine extension --- Project.toml | 7 ++++++ .../PlantSimEngineGraphEditorExt.jl | 14 ++++++----- lib/PlantSimEngineGraphEditor/Project.toml | 16 ------------ src/PlantSimEngine.jl | 2 ++ src/visualization/graph_editor_api.jl | 25 +++++++++++++++++++ 5 files changed, 42 insertions(+), 22 deletions(-) rename lib/PlantSimEngineGraphEditor/src/PlantSimEngineGraphEditor.jl => ext/PlantSimEngineGraphEditorExt.jl (94%) delete mode 100644 lib/PlantSimEngineGraphEditor/Project.toml create mode 100644 src/visualization/graph_editor_api.jl diff --git a/Project.toml b/Project.toml index a4d1a192..84eae66b 100644 --- a/Project.toml +++ b/Project.toml @@ -20,6 +20,12 @@ Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" Tables = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" Term = "22787eb5-b846-44ae-b979-8e399b8463ab" +[weakdeps] +HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" + +[extensions] +PlantSimEngineGraphEditorExt = "HTTP" + [compat] AbstractTrees = "0.4" CSV = "0.10" @@ -27,6 +33,7 @@ DataAPI = "1.15" DataFrames = "1" Dates = "1.10" FLoops = "0.2" +HTTP = "1" InteractiveUtils = "1.10" JSON = "1" Markdown = "1.10" diff --git a/lib/PlantSimEngineGraphEditor/src/PlantSimEngineGraphEditor.jl b/ext/PlantSimEngineGraphEditorExt.jl similarity index 94% rename from lib/PlantSimEngineGraphEditor/src/PlantSimEngineGraphEditor.jl rename to ext/PlantSimEngineGraphEditorExt.jl index 62811d49..fa29a9b2 100644 --- a/lib/PlantSimEngineGraphEditor/src/PlantSimEngineGraphEditor.jl +++ b/ext/PlantSimEngineGraphEditorExt.jl @@ -1,12 +1,11 @@ -module PlantSimEngineGraphEditor +module PlantSimEngineGraphEditorExt import HTTP import JSON import PlantSimEngine +import PlantSimEngine: edit_graph, current_mapping, apply_edit!, undo!, redo! -export GraphEditorSession, edit_graph, current_mapping, apply_edit!, undo!, redo!, close - -mutable struct GraphEditorSession{M,G,S} +mutable struct GraphEditorSession{M,G,S} <: PlantSimEngine.AbstractGraphEditorSession mapping::M mtg::G history::Vector{M} @@ -25,8 +24,11 @@ Base.close(session::GraphEditorSession) = close(session.server) Start a local graph editor session. The returned session owns the current `ModelMapping`; call `current_mapping(session)` to recover the edited mapping. + +This method is provided by the `PlantSimEngineGraphEditorExt` package extension. +Load `HTTP` in the active session to make it available. """ -function edit_graph(mapping; mtg=nothing, host::AbstractString="127.0.0.1", port::Integer=8765) +function edit_graph(mapping::PlantSimEngine.ModelMapping; mtg=nothing, host::AbstractString="127.0.0.1", port::Integer=8765) session_ref = Ref{Any}() handler = http -> _handle_http(session_ref[], http) server = HTTP.listen!(handler, host, port; listenany=true, verbose=false) @@ -280,6 +282,6 @@ function _react_editor_html(session::GraphEditorSession) """ end -_frontend_dist_dir() = normpath(joinpath(@__DIR__, "..", "..", "..", "frontend", "dist")) +_frontend_dist_dir() = normpath(joinpath(@__DIR__, "..", "frontend", "dist")) end diff --git a/lib/PlantSimEngineGraphEditor/Project.toml b/lib/PlantSimEngineGraphEditor/Project.toml deleted file mode 100644 index 749b7ad6..00000000 --- a/lib/PlantSimEngineGraphEditor/Project.toml +++ /dev/null @@ -1,16 +0,0 @@ -name = "PlantSimEngineGraphEditor" -uuid = "7cf0a04e-a9b1-4d1f-a906-d86a8c54d709" -version = "0.1.0" -authors = ["Rémi Vezy "] - -[deps] -HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" -JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" -PlantSimEngine = "9a576370-710b-4269-adf9-4f603a9c6423" - -[sources] -PlantSimEngine = {path = "../.."} - -[compat] -HTTP = "1" -JSON = "1" diff --git a/src/PlantSimEngine.jl b/src/PlantSimEngine.jl index e12f7fb7..198c9b63 100644 --- a/src/PlantSimEngine.jl +++ b/src/PlantSimEngine.jl @@ -112,6 +112,7 @@ include("run.jl") # Dependency graph visualisation: include("visualization/dependency_graph_view.jl") +include("visualization/graph_editor_api.jl") # Fitting include("evaluation/fit.jl") @@ -146,6 +147,7 @@ export run! export fit export GraphPort, GraphNode, GraphEdge, DependencyGraphView export graph_view, graph_view_json, write_graph_view, compile_graph_view +export AbstractGraphEditorSession, edit_graph, current_mapping, apply_edit!, undo!, redo! export available_processes, available_models, model_descriptor, model_constructor_descriptor export AbstractGraphEdit, AddModel, RemoveModel, ReplaceModel, SetMappedVariable, MarkPreviousTimeStep, UnmarkPreviousTimeStep, apply_graph_edit diff --git a/src/visualization/graph_editor_api.jl b/src/visualization/graph_editor_api.jl new file mode 100644 index 00000000..b1e85f38 --- /dev/null +++ b/src/visualization/graph_editor_api.jl @@ -0,0 +1,25 @@ +abstract type AbstractGraphEditorSession end + +function _graph_editor_missing_http() + throw(ArgumentError("Interactive graph editing requires HTTP.jl. Load it with `using HTTP` before calling `edit_graph`.")) +end + +function edit_graph(args...; kwargs...) + _graph_editor_missing_http() +end + +function current_mapping(session::AbstractGraphEditorSession) + _graph_editor_missing_http() +end + +function apply_edit!(session::AbstractGraphEditorSession, edit::AbstractGraphEdit) + _graph_editor_missing_http() +end + +function undo!(session::AbstractGraphEditorSession) + _graph_editor_missing_http() +end + +function redo!(session::AbstractGraphEditorSession) + _graph_editor_missing_http() +end From 936c5be02370af45cd531d79fc940a04a4f4d314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Tue, 5 May 2026 10:28:36 +0200 Subject: [PATCH 03/19] Add docs and more tests for graph editor --- docs/make.jl | 1 + .../graph_visualization_editor.md | 51 ++++++++++++++++++ ext/PlantSimEngineGraphEditorExt.jl | 6 +-- src/visualization/dependency_graph_view.jl | 9 ++++ src/visualization/graph_editor_api.jl | 40 ++++++++++++++ test/Project.toml | 1 + test/runtests.jl | 4 ++ test/test-graph-editor-extension.jl | 52 +++++++++++++++++++ 8 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 docs/src/step_by_step/graph_visualization_editor.md create mode 100644 test/test-graph-editor-extension.jl diff --git a/docs/make.jl b/docs/make.jl index 57639391..b7f72f37 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -34,6 +34,7 @@ makedocs(; "Detailed first simulation" => "./step_by_step/detailed_first_example.md", "Coupling" => "./step_by_step/simple_model_coupling.md", "Model Switching" => "./step_by_step/model_switching.md", + "Graph visualization and editing" => "./step_by_step/graph_visualization_editor.md", "Quick examples" => "./step_by_step/quick_and_dirty_examples.md", "Implementing a process" => "./step_by_step/implement_a_process.md", "Implementing a model" => "./step_by_step/implement_a_model.md", diff --git a/docs/src/step_by_step/graph_visualization_editor.md b/docs/src/step_by_step/graph_visualization_editor.md new file mode 100644 index 00000000..9aeaabc8 --- /dev/null +++ b/docs/src/step_by_step/graph_visualization_editor.md @@ -0,0 +1,51 @@ +# Graph visualization and editing + +`PlantSimEngine` can export a dependency graph view from a [`ModelMapping`](@ref). The static viewer is available from the core package and does not require any web server dependency. + +```julia +using PlantSimEngine +using PlantSimEngine.Examples + +mapping = ModelMapping( + ToyLAIModel(), + Beer(0.5); + status=(TT_cu=1.0:200.0,), +) + +write_graph_view("dependency_graph.html", mapping) +``` + +The same serialization path is used by the interactive editor. The editor is implemented as a Julia package extension, so the HTTP/WebSocket stack is loaded only when [`HTTP.jl`](https://github.com/JuliaWeb/HTTP.jl) is available and loaded in the active session. + +```julia +using PlantSimEngine +using PlantSimEngine.Examples +using HTTP + +mapping = ModelMapping( + ToyLAIModel(), + Beer(0.5); + status=(TT_cu=1.0:200.0,), +) + +session = edit_graph(mapping) +session.url +``` + +Open `session.url` in a browser to use the live editor. The browser sends edit commands to Julia over a WebSocket. Julia remains the source of truth: it applies the edit, rebuilds the [`ModelMapping`](@ref), recompiles graph diagnostics, and sends the updated graph back to the browser. + +Use [`current_mapping`](@ref) to recover the latest mapping from the session: + +```julia +edited_mapping = current_mapping(session) +close(session) +``` + +The editor extension currently supports the same edit operations as the Julia API: + +- add, remove, and replace a model at a scale; +- set a mapped input variable; +- mark or unmark a variable as [`PreviousTimeStep`](@ref); +- undo and redo edits inside the live session. + +If `HTTP` is not loaded, `edit_graph(mapping)` throws an error explaining that the interactive editor requires `using HTTP`. Static graph visualization through [`write_graph_view`](@ref), `graph_view`, and [`graph_view_json`](@ref) remains available without loading `HTTP`. diff --git a/ext/PlantSimEngineGraphEditorExt.jl b/ext/PlantSimEngineGraphEditorExt.jl index fa29a9b2..8bf6b641 100644 --- a/ext/PlantSimEngineGraphEditorExt.jl +++ b/ext/PlantSimEngineGraphEditorExt.jl @@ -182,9 +182,9 @@ function _parse_parameter_value(value) value isa AbstractDict || return value choice = Symbol(get(value, "type", "julia")) raw = get(value, "value", nothing) - choice == :float && return Float64(raw) - choice == :integer && return Int(raw) - choice == :boolean && return Bool(raw) + choice == :float && return parse(Float64, raw) + choice == :integer && return parse(Int, raw) + choice == :boolean && return parse(Bool, raw) choice == :symbol && return Symbol(raw) choice == :string && return String(raw) choice == :nothing && return nothing diff --git a/src/visualization/dependency_graph_view.jl b/src/visualization/dependency_graph_view.jl index 5fdb2485..b9423a61 100644 --- a/src/visualization/dependency_graph_view.jl +++ b/src/visualization/dependency_graph_view.jl @@ -67,6 +67,15 @@ struct DependencyGraphView diagnostics::Vector{String} end +""" + AbstractGraphEdit + +Abstract supertype for declarative dependency-graph editor commands. + +Concrete edits such as `AddModel`, `RemoveModel`, `ReplaceModel`, +`SetMappedVariable`, `MarkPreviousTimeStep`, and `UnmarkPreviousTimeStep` are +applied with `apply_graph_edit`. +""" abstract type AbstractGraphEdit end struct AddModel{P} <: AbstractGraphEdit diff --git a/src/visualization/graph_editor_api.jl b/src/visualization/graph_editor_api.jl index b1e85f38..cfb0d67f 100644 --- a/src/visualization/graph_editor_api.jl +++ b/src/visualization/graph_editor_api.jl @@ -4,22 +4,62 @@ function _graph_editor_missing_http() throw(ArgumentError("Interactive graph editing requires HTTP.jl. Load it with `using HTTP` before calling `edit_graph`.")) end +""" + edit_graph(mapping; kwargs...) + +Start an interactive graph editor session for a [`ModelMapping`](@ref). + +The HTTP-backed method is provided by the `PlantSimEngineGraphEditorExt` +package extension. Load `HTTP` in the active Julia session before calling this +function: + +```julia +using PlantSimEngine +using HTTP + +session = edit_graph(mapping) +``` +""" function edit_graph(args...; kwargs...) _graph_editor_missing_http() end +""" + current_mapping(session) + +Return the current [`ModelMapping`](@ref) stored by an interactive graph editor +session. +""" function current_mapping(session::AbstractGraphEditorSession) _graph_editor_missing_http() end +""" + apply_edit!(session, edit) + +Apply an [`AbstractGraphEdit`](@ref) to an interactive graph editor session and +return the rebuilt [`ModelMapping`](@ref). +""" function apply_edit!(session::AbstractGraphEditorSession, edit::AbstractGraphEdit) _graph_editor_missing_http() end +""" + undo!(session) + +Undo the latest edit in an interactive graph editor session and return the +current [`ModelMapping`](@ref). +""" function undo!(session::AbstractGraphEditorSession) _graph_editor_missing_http() end +""" + redo!(session) + +Redo the latest undone edit in an interactive graph editor session and return +the current [`ModelMapping`](@ref). +""" function redo!(session::AbstractGraphEditorSession) _graph_editor_missing_http() end diff --git a/test/Project.toml b/test/Project.toml index f806effc..dde1bb0e 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -5,6 +5,7 @@ CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" +HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" MultiScaleTreeGraph = "dd4a991b-8a45-4075-bede-262ee62d5583" PlantMeteo = "4630fe09-e0fb-4da5-a846-781cb73437b6" PlantSimEngine = "9a576370-710b-4269-adf9-4f603a9c6423" diff --git a/test/runtests.jl b/test/runtests.jl index df596d70..39c43df0 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -74,6 +74,10 @@ include("helper-functions.jl") include("test-dependency-graph-view.jl") end + @testset "Dependency graph editor extension" begin + include("test-graph-editor-extension.jl") + end + @testset "MTG with multiscale mapping" begin include("test-mtg-multiscale.jl") include("test-mtg-dynamic.jl") diff --git a/test/test-graph-editor-extension.jl b/test/test-graph-editor-extension.jl new file mode 100644 index 00000000..97e6adfc --- /dev/null +++ b/test/test-graph-editor-extension.jl @@ -0,0 +1,52 @@ +using HTTP + +@testset "HTTP extension loading and WebSocket edits" begin + @test Base.get_extension(PlantSimEngine, :PlantSimEngineGraphEditorExt) !== nothing + + mapping = ModelMapping( + :Leaf => ( + ToyLAIModel(), + Beer(0.5), + Status(TT_cu=1.0, LAI=2.0), + ), + ) + session = edit_graph(mapping; port=0) + + try + @test session isa AbstractGraphEditorSession + @test startswith(session.url, "http://127.0.0.1:") + @test current_mapping(session) === mapping + + state_response = HTTP.get(string(session.url, "/state")) + @test state_response.status == 200 + state = PlantSimEngine.JSON.parse(String(state_response.body)) + @test state["ok"] + @test haskey(state, "graph") + @test haskey(state, "models") + + websocket_url = replace(session.url, "http://" => "ws://") * "/ws" + HTTP.WebSockets.open(websocket_url) do ws + initial = PlantSimEngine.JSON.parse(String(HTTP.WebSockets.receive(ws))) + @test initial["ok"] + @test haskey(initial, "graph") + + command = PlantSimEngine.JSON.json(Dict( + "action" => "edit", + "kind" => "remove_model", + "scale" => "Leaf", + "process" => "light_interception", + )) + HTTP.WebSockets.send(ws, command) + + response = PlantSimEngine.JSON.parse(String(HTTP.WebSockets.receive(ws))) + @test response["ok"] + @test response["canUndo"] + @test !any( + model -> process(model) == :light_interception, + PlantSimEngine.get_models(current_mapping(session)[:Leaf]), + ) + end + finally + close(session) + end +end From 8ca4159508f37580288da7d6fc614d40b04d5b16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Vezy?= Date: Tue, 5 May 2026 13:27:39 +0200 Subject: [PATCH 04/19] Add browser-opening graph editor session UI --- .../graph_visualization_editor.md | 17 +- ext/PlantSimEngineGraphEditorExt.jl | 156 +++++++++++++- frontend/src/App.tsx | 201 +++++++++++++++--- frontend/src/styles.css | 71 ++++++- frontend/src/types.ts | 3 + src/visualization/dependency_graph_view.jl | 16 +- src/visualization/graph_editor_api.jl | 2 + test/test-graph-editor-extension.jl | 66 +++++- 8 files changed, 488 insertions(+), 44 deletions(-) diff --git a/docs/src/step_by_step/graph_visualization_editor.md b/docs/src/step_by_step/graph_visualization_editor.md index 9aeaabc8..ba0c959c 100644 --- a/docs/src/step_by_step/graph_visualization_editor.md +++ b/docs/src/step_by_step/graph_visualization_editor.md @@ -30,9 +30,22 @@ mapping = ModelMapping( session = edit_graph(mapping) session.url +session ``` -Open `session.url` in a browser to use the live editor. The browser sends edit commands to Julia over a WebSocket. Julia remains the source of truth: it applies the edit, rebuilds the [`ModelMapping`](@ref), recompiles graph diagnostics, and sends the updated graph back to the browser. +By default, `edit_graph` opens `session.url` in the system default browser. Pass `open_browser=false` to keep the session headless, for example in scripts or tests: + +```julia +session = edit_graph(mapping; open_browser=false) +``` + +The browser sends edit commands to Julia over a WebSocket. Julia remains the source of truth: it applies the edit, rebuilds the [`ModelMapping`](@ref), recompiles graph diagnostics, and sends the updated graph back to the browser. + +To stop the HTTP/WebSocket session, run: + +```julia +close(session) +``` Use [`current_mapping`](@ref) to recover the latest mapping from the session: @@ -41,6 +54,8 @@ edited_mapping = current_mapping(session) close(session) ``` +The web editor also exposes a dedicated "Mapping code" panel. It shows the current [`ModelMapping`](@ref) as Julia code, and can write that code to a `.jl` file so it can be copied/pasted or reused in scripts. + The editor extension currently supports the same edit operations as the Julia API: - add, remove, and replace a model at a scale; diff --git a/ext/PlantSimEngineGraphEditorExt.jl b/ext/PlantSimEngineGraphEditorExt.jl index 8bf6b641..ac2c3fb8 100644 --- a/ext/PlantSimEngineGraphEditorExt.jl +++ b/ext/PlantSimEngineGraphEditorExt.jl @@ -14,21 +14,53 @@ mutable struct GraphEditorSession{M,G,S} <: PlantSimEngine.AbstractGraphEditorSe host::String port::Int url::String + last_saved_path::Union{Nothing,String} end current_mapping(session::GraphEditorSession) = session.mapping -Base.close(session::GraphEditorSession) = close(session.server) +function Base.close(session::GraphEditorSession) + isopen(session.server) || return nothing + return HTTP.forceclose(session.server) +end + +function Base.show(io::IO, session::GraphEditorSession) + print(io, "GraphEditorSession(url=\"$(session.url)\", host=\"$(session.host)\", port=$(session.port))") +end + +function Base.show(io::IO, ::MIME"text/plain", session::GraphEditorSession) + println(io, "PlantSimEngineGraphEditorExt.GraphEditorSession") + println(io, " Open in browser: $(session.url)") + println(io, " Local state JSON: $(session.url)/state") + println(io, " Quit session: close(session)") + println(io, " Current mapping: current_mapping(session)") + println(io, " Save mapping code: use the \"Mapping code\" panel in the web editor") +end + +current_mapping_code(session::GraphEditorSession) = _model_mapping_to_julia(session.mapping) """ - edit_graph(mapping; mtg=nothing, host="127.0.0.1", port=8765) + edit_graph(mapping; mtg=nothing, host="127.0.0.1", port=8765, open_browser=true) Start a local graph editor session. The returned session owns the current `ModelMapping`; call `current_mapping(session)` to recover the edited mapping. +Single-scale mappings are automatically normalized to multiscale form at the :Default scale. +By default, the session URL is opened with the system default browser. Pass +`open_browser=false` to disable this, for example in scripts or tests. + This method is provided by the `PlantSimEngineGraphEditorExt` package extension. Load `HTTP` in the active session to make it available. """ -function edit_graph(mapping::PlantSimEngine.ModelMapping; mtg=nothing, host::AbstractString="127.0.0.1", port::Integer=8765) +function edit_graph( + mapping::PlantSimEngine.ModelMapping; + mtg=nothing, + host::AbstractString="127.0.0.1", + port::Integer=8765, + open_browser::Bool=true, +) + # Normalize single-scale to multiscale form for uniform handling downstream + mapping = _normalize_to_multiscale(mapping) + session_ref = Ref{Any}() handler = http -> _handle_http(session_ref[], http) server = HTTP.listen!(handler, host, port; listenany=true, verbose=false) @@ -42,11 +74,32 @@ function edit_graph(mapping::PlantSimEngine.ModelMapping; mtg=nothing, host::Abs String(host), actual_port, "http://$(host):$(actual_port)", + nothing, ) session_ref[] = session + open_browser && _open_in_default_browser(session.url) return session end +function _open_in_default_browser(url::AbstractString) + try + if Sys.isapple() + run(`open $url`) + elseif Sys.iswindows() + run(`cmd /c start "" $url`) + elseif !isnothing(Sys.which("xdg-open")) + run(`xdg-open $url`) + else + @warn "Could not open graph editor automatically because no supported default-browser command was found." url + return false + end + return true + catch err + @warn "Could not open graph editor automatically. Open the session URL manually." url exception = (err, catch_backtrace()) + return false + end +end + function apply_edit!(session::GraphEditorSession, edit::PlantSimEngine.AbstractGraphEdit) push!(session.history, session.mapping) empty!(session.future) @@ -95,6 +148,22 @@ function _handle_http(session::GraphEditorSession, http::HTTP.Stream) return nothing end +""" + _normalize_to_multiscale(mapping::PlantSimEngine.ModelMapping{PlantSimEngine.SingleScale}) + +Convert a single-scale ModelMapping to multiscale form at the :Default scale. +This ensures all downstream logic only deals with MultiScale mappings. +""" +function _normalize_to_multiscale(mapping::PlantSimEngine.ModelMapping{PlantSimEngine.SingleScale}) + entry = mapping[:Default] # Returns tuple of (models..., status) + return PlantSimEngine.ModelMapping(:Default => entry; check=true, type_promotion=PlantSimEngine.type_promotion(mapping)) +end + +function _normalize_to_multiscale(mapping::PlantSimEngine.ModelMapping{PlantSimEngine.MultiScale}) + # Already multiscale, return as is + return mapping +end + function _handle_websocket(session::GraphEditorSession, ws) HTTP.WebSockets.send(ws, _state_json(session)) try @@ -118,6 +187,9 @@ function _handle_command!(session::GraphEditorSession, command) elseif action == "edit" edit = _edit_from_command(command) apply_edit!(session, edit) + elseif action == "write_mapping_code" + raw_path = get(command, "path", "") + _write_mapping_code!(session, String(raw_path)) else error("Unsupported graph editor command action `$action`.") end @@ -197,11 +269,14 @@ function _state_payload(session::GraphEditorSession; ok::Bool=true, diagnostics: append!(graph["diagnostics"], diagnostics) return Dict( "ok" => ok, + "diagnostics" => diagnostics, "graph" => graph, "models" => [PlantSimEngine.model_descriptor(T) for T in PlantSimEngine.available_models()], "canUndo" => !isempty(session.history), "canRedo" => !isempty(session.future), "url" => session.url, + "mappingCode" => current_mapping_code(session), + "lastSavedPath" => session.last_saved_path, ) end @@ -284,4 +359,79 @@ end _frontend_dist_dir() = normpath(joinpath(@__DIR__, "..", "frontend", "dist")) +function _write_mapping_code!(session::GraphEditorSession, raw_path::AbstractString) + path = strip(String(raw_path)) + isempty(path) && error("The output path is empty. Provide a .jl file path.") + full_path = isabspath(path) ? normpath(path) : normpath(joinpath(pwd(), path)) + mkpath(dirname(full_path)) + write(full_path, current_mapping_code(session) * "\n") + session.last_saved_path = full_path + return full_path +end + +function _model_mapping_to_julia(mapping::PlantSimEngine.ModelMapping) + io = IOBuffer() + println(io, "mapping = ModelMapping(") + for scale in keys(mapping) + println(io, " :$(scale) => (") + for item in _scale_items(mapping[scale]) + println(io, " $(_mapping_item_to_code(item)),") + end + println(io, " ),") + end + print(io, ")") + return String(take!(io)) +end + +_scale_items(entry) = entry isa Tuple ? entry : (entry,) + +function _mapping_item_to_code(item) + if item isa PlantSimEngine.MultiScaleModel + model_code = repr(PlantSimEngine.model_(item)) + mapped_code = _mapped_variables_to_code(PlantSimEngine.mapped_variables_(item)) + return "MultiScaleModel(model=$(model_code), mapped_variables=$(mapped_code))" + end + return repr(item) +end + +function _mapped_variables_to_code(mapped_variables) + isempty(mapped_variables) && return "[]" + return "[" * join((_mapped_variable_to_code(i) for i in mapped_variables), ", ") * "]" +end + +function _mapped_variable_to_code(mapping) + lhs = first(mapping) + rhs = last(mapping) + lhs_code = _mapped_lhs_to_code(lhs) + variable = _mapped_variable_symbol(lhs) + rhs_code = _mapped_rhs_to_code(rhs, variable) + return "$(lhs_code) => $(rhs_code)" +end + +_mapped_variable_symbol(variable::Symbol) = variable +_mapped_variable_symbol(variable::PlantSimEngine.PreviousTimeStep) = variable.variable + +_mapped_lhs_to_code(variable::Symbol) = string(":", variable) +_mapped_lhs_to_code(variable::PlantSimEngine.PreviousTimeStep) = "PreviousTimeStep(:$(variable.variable))" + +function _mapped_rhs_to_code(rhs::Pair{Symbol,Symbol}, variable::Symbol) + source_scale = first(rhs) + source_variable = last(rhs) + if source_scale == Symbol("") + return "(Symbol(\"\") => :$(source_variable))" + end + if source_variable == variable + return ":$(source_scale)" + end + return "(:$(source_scale) => :$(source_variable))" +end + +function _mapped_rhs_to_code(rhs::AbstractVector{<:Pair{Symbol,Symbol}}, variable::Symbol) + compact = all(last(i) == variable for i in rhs) + if compact + return "[" * join((":" * string(first(i)) for i in rhs), ", ") * "]" + end + return "[" * join(("(:$(first(i)) => :$(last(i)))" for i in rhs), ", ") * "]" +end + end diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 351bdcd5..5d7257c9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -36,6 +36,7 @@ import "./styles.css"; type EdgeFilterKey = "dataFlow" | "mapped" | "callStack"; type EdgeFilters = Record; type FocusMode = "none" | "upstream" | "downstream" | "neighborhood"; +type SidePanel = "inspector" | "add_model" | "mapping_code" | null; type SearchResult = { id: string; @@ -108,6 +109,12 @@ export default function App() { const [editorConnected, setEditorConnected] = useState(false); const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); + const [activePanel, setActivePanel] = useState("inspector"); + const [mappingCode, setMappingCode] = useState(""); + const [lastSavedPath, setLastSavedPath] = useState(null); + const [editorFeedback, setEditorFeedback] = useState<{ kind: "error" | "info"; text: string } | null>(null); + const [savePath, setSavePath] = useState("mapping.generated.jl"); + const [customScales, setCustomScales] = useState([]); const [selected, setSelected] = useState(null); const [activePort, setActivePort] = useState(null); const [showRequiredPanel, setShowRequiredPanel] = useState(false); @@ -134,6 +141,10 @@ export default function App() { const actionableWarningItems = useMemo(() => warningItems.filter((item) => item.severity !== "info"), [warningItems]); const searchResults = useMemo(() => deriveSearchResults(graph, searchQuery), [graph, searchQuery]); const visibleNodeData = useMemo(() => graph.nodes.filter((node) => !collapsedScales.has(node.scale)), [collapsedScales, graph.nodes]); + const editorScales = useMemo(() => { + const merged = [...graph.scales, ...customScales]; + return [...new Set(merged)]; + }, [customScales, graph.scales]); const visibleNodeIds = useMemo(() => new Set(visibleNodeData.map((node) => node.id)), [visibleNodeData]); const visibleEdgeData = useMemo(() => graph.edges.filter((edge) => ( edgeMatchesFilters(edge, edgeFilters) && @@ -153,23 +164,52 @@ export default function App() { const socket = new WebSocket(config.websocketUrl); setEditorSocket(socket); - socket.addEventListener("open", () => setEditorConnected(true)); - socket.addEventListener("close", () => setEditorConnected(false)); + socket.addEventListener("open", () => { + setEditorConnected(true); + setEditorFeedback(null); + }); + socket.addEventListener("close", () => { + setEditorConnected(false); + setEditorFeedback({ kind: "error", text: "Graph editor connection closed. Refresh the page or restart the Julia session." }); + }); socket.addEventListener("message", (event) => { const payload = JSON.parse(event.data) as GraphEditorState; if (payload.graph) setGraph(payload.graph); if (payload.models) setEditorModels(payload.models); + if (typeof payload.mappingCode === "string") setMappingCode(payload.mappingCode); + if (typeof payload.lastSavedPath === "string") setLastSavedPath(payload.lastSavedPath); setCanUndo(Boolean(payload.canUndo)); setCanRedo(Boolean(payload.canRedo)); + if (payload.ok === false) { + const message = payload.diagnostics?.[0] ?? "Graph editor command failed."; + setEditorFeedback({ kind: "error", text: message }); + } else if (payload.diagnostics?.length) { + setEditorFeedback({ kind: "info", text: payload.diagnostics[0] }); + } else { + setEditorFeedback(null); + } }); return () => socket.close(); }, []); const sendEditorCommand = useCallback((command: Record) => { - if (!editorSocket || editorSocket.readyState !== WebSocket.OPEN) return; + if (!editorSocket || editorSocket.readyState !== WebSocket.OPEN) { + setEditorFeedback({ kind: "error", text: "Graph editor is offline; command was not sent." }); + return; + } editorSocket.send(JSON.stringify(command)); }, [editorSocket]); + const togglePanel = useCallback((panel: Exclude) => { + setActivePanel((current) => current === panel ? null : panel); + }, []); + + const addCustomScale = useCallback((rawScale: string) => { + const scale = rawScale.trim(); + if (!scale) return; + setCustomScales((current) => current.includes(scale) || graph.scales.includes(scale) ? current : [...current, scale]); + }, [graph.scales]); + useEffect(() => { const nextNodes = visibleNodeData.map((node) => ({ id: node.id, @@ -405,6 +445,13 @@ export default function App() { + +
+ + + +
+ {editorSocket && (
{editorConnected ? "live" : "offline"} @@ -414,6 +461,12 @@ export default function App() { )}
+ {editorFeedback && ( +
+ {editorFeedback.text} +
+ )} + @@ -462,33 +515,67 @@ export default function App() { - + {activePanel && ( + + )} ); } @@ -747,19 +834,57 @@ function Row({ label, value }: { label: string; value: string }) { return
{label}{value}
; } +function MappingCodePanel({ + code, + savePath, + lastSavedPath, + onSavePathChange, + onSave, +}: { + code: string; + savePath: string; + lastSavedPath: string | null; + onSavePathChange: (path: string) => void; + onSave: () => void; +}) { + const copyCode = useCallback(async () => { + if (!code) return; + await navigator.clipboard.writeText(code); + }, [code]); + + return ( +
+
+ Current Julia mapping + +
+