Who Runs Your Simulation When You're Not a Simulation Engineer?
tl;dr — Type “run a 6 m, 10 s storm at 12 m/s and plot pitch,” and an LLM turns that sentence into a tool call, runs the real FMU, plots the result, and tells you whether the mooring held — on a free-tier model, no API bill.
High-fidelity digital twins are usually locked behind expert scripts. Someone who knows the co-simulation stack writes a notebook, hard-codes the sea state, and emails around a PNG. Anyone else with a question — “what if the wave is steeper,” “how does pitch change if wind picks up” — waits for that person to be available again. In Part 3, I remove that bottleneck: I wrap the existing FOWT co-simulation in three tools, hand them to an LLM, and let plain language drive the model.
Why an Agent
A dashboard is the right tool when you know the questions ahead of time: fix the sliders, lay out the plots, and anyone can drive it. The questions here are open-ended. One person wants pitch RMS at a steeper wave; the next wants two runs compared side by side; the next wants mooring tension at the fairlead. That is where an agent earns its place — the user types the question in plain language, and the agent decides which simulation to run and which signal to plot.
It also lowers the bar to drive the model. The co-simulation underneath is expert-grade — an FMU, a JONSWAP wave spectrum, a Kaimal wind field, mooring constraint checks — but the person asking the question does not have to operate any of it. They phrase the question; the agent translates it into the right tool calls and reads the result back. The simulation engineering stays in the tools, where it belongs, instead of in the user’s lap.
The OC4 DeepCwind semi-submersible the agent is simulating underneath every chat response — the same platform and three-line catenary mooring the FMU model is built on.
What the Numbers Mean
Before trusting an agent’s answer, it helps to see what the four input numbers actually produce.
A JONSWAP sea state at Hs = 6 m, Tp = 10 s — a “6 m / 10 s” request from the user becomes this irregular surface elevation trace, not a single 6 m wave.
A Kaimal wind field at U_hub = 12 m/s, TI = 15 % — the “12 m/s / 15 %” the user types becomes this gusting trace around a 12 m/s mean, not a steady breeze.
Every number the agent reports downstream — surge RMS, pitch RMS, mooring tension — is driven by time series that look like these, not by a single static input.
The Tool Chain
A user sentence (“run a 6 m storm, plot surge”) becomes a user message and goes to run_agent, which calls client.chat.completions.create(model, messages, tools). If the model responds with tool_calls, the loop dispatches each one: maybe run_simulation to drive the FMU, maybe plot_results to draw signals from a run already in memory, maybe compare_runs to put two runs side by side. Each tool’s result is appended back to the message history, and the loop calls the model again. This repeats until the model returns plain text instead of a tool call — at that point the text and any pending Plotly figures go to the Streamlit UI.
It helps to be precise about who decides what. The model is the only part that interprets the sentence: it reads “run a 6 m storm” and decides that means run_simulation with Hs=6, filling in the arguments itself. That decision arrives in a structured tool_calls field — a list of function names and JSON arguments. It is data, not logic: it carries the model’s choice but interprets nothing on its own. The loop only reads that field, parses the arguments, and runs the matching Python function. The model thinks, tool_calls carries the decision, the loop executes it.
No step in that chain is a guess. The LLM never touches the FMU directly; it only ever picks a function name and arguments from a fixed registry.
Three Tools, Nothing Else
I gave the agent exactly three tools — that is its entire vocabulary:
| Tool | What it does |
|---|---|
run_simulation(Hs, Tp, U_hub, TI, mooring_model="catenary", duration=60.0) | Runs the FOWT co-simulation, stores the result, returns arun_id plus a summary (surge RMS, pitch RMS in degrees, max mooring tension in MN, constraint-OK boolean). |
plot_results(run_id, signals) | Draws the requested signals from a stored run as a Plotly figure. |
compare_runs(run_ids, metric) | Pulls one summary metric out of two or more stored runs for side-by-side comparison. |
Triggering one is a dictionary lookup. The model never calls these functions; it only names one in tool_calls, and the loop runs it. dispatch takes that name, finds the matching function in a fixed TOOL_FUNCS registry, and calls it as func(store, **args) — the parsed JSON arguments spread straight into the Python signature. The store is injected by the loop, never by the model, so the model picks what to run but never reaches the shared state itself. A name that is not in the registry, or arguments the function rejects, come back as an {"error": ...} dict the model can read — a bad call is a message, not a crash.
All three tools share the same in-memory RunStore — no database, no persistence beyond the session. Restart the app and the runs are gone.
The agent loop itself is supervisory: it dispatches whatever tool calls the model produces and never actuates anything on its own. The actual control logic — running the FMU, checking constraints, drawing a plot — lives in deterministic Python functions, not in the model’s output. That separation is why new tools can later be bolted onto this exact registry without touching the loop that drives it.
Two Scenarios, One Agent
The same agent code runs two different ways, switched entirely through three environment variables read in agent/llm.py: LLM_BASE_URL, LLM_API_KEY, LLM_MODEL.
Local: LLM_BASE_URL=http://localhost:11434/v1, a local Ollama model (qwen2.5), and the Windows-built FOWT.fmu. Data never leaves the machine, and runs can go the full 200 s.
Cloud: LLM_BASE_URL=https://api.groq.com/openai/v1, Groq’s free-tier llama-3.3-70b-versatile, and the Linux-built FOWT_linux.fmu. Public, zero-cost, with demo runs kept short by practice.
Both backends speak the same OpenAI-compatible function-calling protocol, so the client code and the tool schemas above are identical either way — the only thing that changes is which server answers chat.completions.create.
Porting the FMU to Linux
The FMU is not portable source — it is a platform-compiled binary, originally built and tested on Windows against the FOWT Modelica model. Streamlit Community Cloud runs Linux. A win64 FMU does not load there.
I fixed this in two parts: I compiled a linux64 build of the same model (FOWT_linux.fmu, produced from the FOWT Modelica source in an OpenModelica Linux container) and added a small runtime check in the co-simulation runner that picks FOWT.fmu or FOWT_linux.fmu depending on sys.platform. Win64 and linux64 FMUs come from different OMC builds with different internal GUIDs — they cannot share one file, so both binaries ship in the repo and the platform check chooses between them at run time.
A Live Run
A user asks for a sea state in plain language; the agent calls run_simulation, then reports surge RMS, pitch RMS, and max mooring tension with a constraint-OK verdict — no manual parameter entry. A follow-up question drives compare_runs across two runs already sitting in the RunStore, putting the requested metric side by side without re-running the simulation.
Deploying for Free
The cloud deployment runs on Streamlit Community Cloud’s free tier: 1 GB RAM, no GPU, a public repo, the app sleeping when idle, and a temporary filesystem that is wiped on every restart — which is exactly what the in-memory RunStore already assumes.
Two settings keep the simulation within those limits. The co-simulation time step is fixed at dt = 0.001; a larger step triggers a known OMC pitch-actuation NaN divergence (a known OMC limitation), so step size is not something I can freely tune. Runtime is controlled through duration instead — a full local run covers roughly 200 s, while on the public cloud demo I keep requested durations to 30-60 s to keep the spinner responsive and stay inside the 1 GB tier.
The LLM side costs nothing either: Groq’s free tier serves llama-3.3-70b-versatile for free. Local Ollama and cloud Groq together put the total cost of running this demo at zero.
What You Can Ask For (and What You Can’t)
The agent will accept almost any phrasing, but the underlying FMU only trusts a specific input envelope — the range the OC4 model and NREL 5 MW turbine are actually validated over.
| Input | Range | Note |
|---|---|---|
Hs (significant wave height) | ~1-10 m | Operational seas to storm conditions |
Tp (peak wave period) | ~6-16 s | Typical JONSWAP peak periods for this site class |
U_hub (hub-height wind speed) | ~4-25 m/s | NREL 5 MW turbine’s operating envelope; above ~25 m/s the turbine would be shut down, outside this model’s scope |
TI (turbulence intensity) | ~0.06-0.20 | Typical offshore turbulence intensities |
A few more boundaries worth knowing:
mooring_modelis one of exactly two options:catenaryoronnx(the Part 1/2 analytical and data-driven mooring models). There is no third choice.durationis free-form but bounded by purpose: ~200 s for local full runs, 30-60 s for the public cloud demo.- Plottable signals are fixed:
surge,sway,heave,roll,pitch,yaw,tension1,tension2,tension3. - The geometry is fixed and singular: OC4 DeepCwind semi-submersible, NREL 5 MW turbine, three-line catenary mooring at 0°/120°/240°. There is no way to ask for a different platform.
- The simulation step
dtis fixed at 0.001 s and is not user-configurable (see Deploying for Free). - The agent is supervisory, not a real-time controller or a safety system. It should never be used as one.
One subtlety on repeatability: asking for “another 6 m storm” twice gives the same wave and wind both times. The environment seed is fixed at 42 and no tool argument exposes it — deliberately, since the randomization (numpy.random.default_rng(seed)) belongs in Python, not in numbers the LLM free-types into a tool call. A future tool argument could expose seed for a fresh realization on demand, while keeping the randomization itself in Python’s hands.
Wrap
Three tools, one supervisory loop, two interchangeable backends. Adding more capability later means appending new tools to this same registry — the chain and the registry above don’t change; the registry just grows.
Code: vedat-s/Automation · part3_agentic_fowt/






