cfdpilot 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cfdpilot/__init__.py +0 -0
- cfdpilot/agent.py +299 -0
- cfdpilot/auth.py +57 -0
- cfdpilot/case_detector.py +90 -0
- cfdpilot/client.py +51 -0
- cfdpilot/display.py +71 -0
- cfdpilot/main.py +47 -0
- cfdpilot/tools/__init__.py +0 -0
- cfdpilot/tools/filesystem.py +45 -0
- cfdpilot/tools/log_parser.py +94 -0
- cfdpilot/tools/patcher.py +42 -0
- cfdpilot/tools/runner.py +159 -0
- cfdpilot-0.1.1.dist-info/METADATA +57 -0
- cfdpilot-0.1.1.dist-info/RECORD +16 -0
- cfdpilot-0.1.1.dist-info/WHEEL +4 -0
- cfdpilot-0.1.1.dist-info/entry_points.txt +2 -0
cfdpilot/__init__.py
ADDED
|
File without changes
|
cfdpilot/agent.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from cfdpilot.client import CFDPilotClient
|
|
3
|
+
from cfdpilot.tools.filesystem import read_file, list_dir
|
|
4
|
+
from cfdpilot.tools.log_parser import parse_log
|
|
5
|
+
from cfdpilot.tools.patcher import patch_dict, generate_diff
|
|
6
|
+
from cfdpilot.tools.runner import run_command, run_solver
|
|
7
|
+
|
|
8
|
+
MAX_ATTEMPTS = 3
|
|
9
|
+
|
|
10
|
+
TOOL_SCHEMAS = [
|
|
11
|
+
{
|
|
12
|
+
"name": "read_file",
|
|
13
|
+
"description": "Read a file from the OpenFOAM case directory",
|
|
14
|
+
"input_schema": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"properties": {"path": {"type": "string"}},
|
|
17
|
+
"required": ["path"]
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"name": "list_dir",
|
|
22
|
+
"description": "List files in a directory",
|
|
23
|
+
"input_schema": {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"properties": {"path": {"type": "string", "default": "."}},
|
|
26
|
+
"required": []
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "parse_log",
|
|
31
|
+
"description": "Parse an OpenFOAM log file. Returns structured JSON with residuals, Co number, divergence info.",
|
|
32
|
+
"input_schema": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"properties": {"log_path": {"type": "string", "description": "Path to log file, or omit to auto-detect"}},
|
|
35
|
+
"required": []
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "run_command",
|
|
40
|
+
"description": "Run an OpenFOAM command (blockMesh, checkMesh, foamDictionary, etc.). NOT for the main solver.",
|
|
41
|
+
"input_schema": {
|
|
42
|
+
"type": "object",
|
|
43
|
+
"properties": {"cmd": {"type": "string"}},
|
|
44
|
+
"required": ["cmd"]
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"name": "run_solver",
|
|
49
|
+
"description": "Launch the OpenFOAM solver and stream residuals. Auto-stops on convergence or divergence.",
|
|
50
|
+
"input_schema": {
|
|
51
|
+
"type": "object",
|
|
52
|
+
"properties": {
|
|
53
|
+
"solver": {"type": "string"},
|
|
54
|
+
"max_iterations": {"type": "integer"}
|
|
55
|
+
},
|
|
56
|
+
"required": ["solver"]
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"name": "show_diff",
|
|
61
|
+
"description": "Show a colored diff of proposed changes to a file. ALWAYS call this before patch_dict.",
|
|
62
|
+
"input_schema": {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"properties": {
|
|
65
|
+
"file_path": {"type": "string"},
|
|
66
|
+
"proposed_content": {"type": "string"}
|
|
67
|
+
},
|
|
68
|
+
"required": ["file_path", "proposed_content"]
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"name": "patch_dict",
|
|
73
|
+
"description": "Apply confirmed changes to an OpenFOAM dict file. Creates .bak backup automatically.",
|
|
74
|
+
"input_schema": {
|
|
75
|
+
"type": "object",
|
|
76
|
+
"properties": {
|
|
77
|
+
"file_path": {"type": "string"},
|
|
78
|
+
"changes": {"type": "object", "description": "key-value pairs to update"}
|
|
79
|
+
},
|
|
80
|
+
"required": ["file_path", "changes"]
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"name": "search_knowledge",
|
|
85
|
+
"description": "Search the CFDpilot knowledge base: 1,000+ CFD-Online threads + OpenFOAM user guide. Use when you need community experience or documentation on a specific OF topic.",
|
|
86
|
+
"input_schema": {
|
|
87
|
+
"type": "object",
|
|
88
|
+
"properties": {"query": {"type": "string", "description": "What to search for, e.g. 'GAMG divergence kOmegaSST' or 'nOuterCorrectors pimpleFoam'"}},
|
|
89
|
+
"required": ["query"]
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _dispatch_tool(name: str, inputs: dict, cwd: str, display, client=None) -> str:
|
|
96
|
+
"""Execute a tool call and return result as JSON string."""
|
|
97
|
+
try:
|
|
98
|
+
if name == "read_file":
|
|
99
|
+
result = read_file(inputs["path"], cwd=cwd)
|
|
100
|
+
return json.dumps(result)
|
|
101
|
+
|
|
102
|
+
elif name == "list_dir":
|
|
103
|
+
result = list_dir(inputs.get("path", "."), cwd=cwd)
|
|
104
|
+
return json.dumps(result)
|
|
105
|
+
|
|
106
|
+
elif name == "parse_log":
|
|
107
|
+
result = parse_log(inputs.get("log_path"), cwd=cwd)
|
|
108
|
+
return json.dumps(result)
|
|
109
|
+
|
|
110
|
+
elif name == "run_command":
|
|
111
|
+
display.print_action(f"Running: {inputs['cmd']}")
|
|
112
|
+
result = run_command(inputs["cmd"], cwd=cwd)
|
|
113
|
+
return json.dumps(result)
|
|
114
|
+
|
|
115
|
+
elif name == "run_solver":
|
|
116
|
+
display.print_action(f"Launching {inputs['solver']}...")
|
|
117
|
+
result = run_solver(
|
|
118
|
+
inputs["solver"],
|
|
119
|
+
cwd=cwd,
|
|
120
|
+
max_iterations=inputs.get("max_iterations"),
|
|
121
|
+
on_line=display.print_solver_line
|
|
122
|
+
)
|
|
123
|
+
return json.dumps(result)
|
|
124
|
+
|
|
125
|
+
elif name == "show_diff":
|
|
126
|
+
diff = generate_diff(inputs["file_path"], inputs["proposed_content"])
|
|
127
|
+
display.print_diff(diff)
|
|
128
|
+
confirmed = display.confirm("Apply changes?")
|
|
129
|
+
return json.dumps({"diff": diff, "confirmed": confirmed})
|
|
130
|
+
|
|
131
|
+
elif name == "patch_dict":
|
|
132
|
+
display.print_action(f"Patching {inputs['file_path']}")
|
|
133
|
+
try:
|
|
134
|
+
result = patch_dict(inputs["file_path"], inputs["changes"])
|
|
135
|
+
display.print_action(f"Backup saved → {result['backup']}")
|
|
136
|
+
return json.dumps(result)
|
|
137
|
+
except FileNotFoundError as e:
|
|
138
|
+
return json.dumps({"success": False, "error": str(e)})
|
|
139
|
+
|
|
140
|
+
elif name == "search_knowledge":
|
|
141
|
+
query = inputs["query"]
|
|
142
|
+
display.print_action(f"Searching knowledge base: {query}")
|
|
143
|
+
results = client.search(query) if client else []
|
|
144
|
+
return json.dumps({"results": results, "count": len(results)})
|
|
145
|
+
|
|
146
|
+
return json.dumps({"error": f"Unknown tool: {name}"})
|
|
147
|
+
|
|
148
|
+
except KeyError as e:
|
|
149
|
+
return json.dumps({"error": f"Missing required input: {e}"})
|
|
150
|
+
except Exception as e:
|
|
151
|
+
return json.dumps({"error": f"Tool error: {type(e).__name__}: {e}"})
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
SESSION_TOKEN_CAP = 100_000
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _build_case_context(cwd: str, case_info: dict) -> str:
|
|
158
|
+
"""Build a context block injected as the first user message."""
|
|
159
|
+
import os
|
|
160
|
+
lines = [f"Working directory: {cwd}"]
|
|
161
|
+
if case_info.get("flat_structure"):
|
|
162
|
+
files = ", ".join(case_info.get("fields", [])) or "unknown"
|
|
163
|
+
lines.append("Structure: FLAT (files dumped in one directory — no 0/constant/system hierarchy)")
|
|
164
|
+
lines.append(f"Files present: {files}")
|
|
165
|
+
lines.append("Note: read files directly by name (e.g. read_file('U'), read_file('fvSchemes'))")
|
|
166
|
+
else:
|
|
167
|
+
lines.append("Structure: standard OpenFOAM (0/ constant/ system/)")
|
|
168
|
+
if case_info.get("solver") and case_info["solver"] != "unknown":
|
|
169
|
+
lines.append(f"Solver: {case_info['solver']}")
|
|
170
|
+
if case_info.get("turbulence_model") and case_info["turbulence_model"] != "unknown":
|
|
171
|
+
lines.append(f"Turbulence: {case_info['turbulence_model']}")
|
|
172
|
+
if case_info.get("log_file"):
|
|
173
|
+
lines.append(f"Log file: {case_info['log_file']}")
|
|
174
|
+
else:
|
|
175
|
+
lines.append("Log file: none found")
|
|
176
|
+
return "\n".join(lines)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def run_agent_loop(token: str, cwd: str, display, case_info: dict = None) -> None:
|
|
180
|
+
"""Main conversation loop. Runs until user exits."""
|
|
181
|
+
client = CFDPilotClient(token)
|
|
182
|
+
messages = []
|
|
183
|
+
session_tokens = 0
|
|
184
|
+
|
|
185
|
+
# Inject case context as a silent system message (not shown to user)
|
|
186
|
+
if case_info:
|
|
187
|
+
ctx = _build_case_context(cwd, case_info)
|
|
188
|
+
messages.append({
|
|
189
|
+
"role": "user",
|
|
190
|
+
"content": f"[CASE CONTEXT — read this before answering anything]\n{ctx}"
|
|
191
|
+
})
|
|
192
|
+
messages.append({
|
|
193
|
+
"role": "assistant",
|
|
194
|
+
"content": [{"type": "text", "text": "Case context noted. Ready."}]
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
display.print_welcome()
|
|
198
|
+
|
|
199
|
+
while True:
|
|
200
|
+
try:
|
|
201
|
+
user_input = display.get_input()
|
|
202
|
+
except (KeyboardInterrupt, EOFError):
|
|
203
|
+
display.print_goodbye()
|
|
204
|
+
break
|
|
205
|
+
|
|
206
|
+
if user_input.lower() in ("exit", "quit", "q"):
|
|
207
|
+
display.print_goodbye()
|
|
208
|
+
break
|
|
209
|
+
|
|
210
|
+
if not user_input.strip():
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
messages.append({"role": "user", "content": user_input})
|
|
214
|
+
attempt_count = 0 # Reset per user turn
|
|
215
|
+
|
|
216
|
+
# Agentic inner loop: keep going until no more tool calls
|
|
217
|
+
while True:
|
|
218
|
+
if session_tokens >= SESSION_TOKEN_CAP:
|
|
219
|
+
display.print_error(
|
|
220
|
+
f"Session limit reached ({session_tokens:,} tokens). "
|
|
221
|
+
"Start a new session: cfdpilot"
|
|
222
|
+
)
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
response = client.chat(messages, TOOL_SCHEMAS)
|
|
227
|
+
except RuntimeError as e:
|
|
228
|
+
display.print_error(str(e))
|
|
229
|
+
break
|
|
230
|
+
|
|
231
|
+
usage = response.get("usage", {})
|
|
232
|
+
session_tokens += (
|
|
233
|
+
usage.get("input_tokens", 0)
|
|
234
|
+
+ usage.get("output_tokens", 0)
|
|
235
|
+
+ usage.get("cache_read_input_tokens", 0)
|
|
236
|
+
+ usage.get("cache_creation_input_tokens", 0)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
content = response.get("content", [])
|
|
240
|
+
stop_reason = response.get("stop_reason", "end_turn")
|
|
241
|
+
|
|
242
|
+
# Display text blocks
|
|
243
|
+
for block in content:
|
|
244
|
+
if block.get("type") == "text":
|
|
245
|
+
display.print_text(block["text"])
|
|
246
|
+
|
|
247
|
+
# Add assistant turn to history
|
|
248
|
+
messages.append({"role": "assistant", "content": content})
|
|
249
|
+
|
|
250
|
+
# No tool calls → turn done
|
|
251
|
+
if stop_reason != "tool_use":
|
|
252
|
+
break
|
|
253
|
+
|
|
254
|
+
# Extract and execute tool calls
|
|
255
|
+
tool_results = []
|
|
256
|
+
stuck = False
|
|
257
|
+
for block in content:
|
|
258
|
+
if block.get("type") != "tool_use":
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
tool_name = block["name"]
|
|
262
|
+
tool_inputs = block.get("input", {})
|
|
263
|
+
tool_id = block["id"]
|
|
264
|
+
|
|
265
|
+
display.print_action(f"● {tool_name}...")
|
|
266
|
+
|
|
267
|
+
# STUCK tracking
|
|
268
|
+
if tool_name == "patch_dict":
|
|
269
|
+
attempt_count += 1
|
|
270
|
+
if attempt_count > MAX_ATTEMPTS:
|
|
271
|
+
display.print_stuck(attempt_count)
|
|
272
|
+
tool_results.append({
|
|
273
|
+
"type": "tool_result",
|
|
274
|
+
"tool_use_id": tool_id,
|
|
275
|
+
"content": json.dumps({"error": "MAX_ATTEMPTS_REACHED"})
|
|
276
|
+
})
|
|
277
|
+
stuck = True
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
result = _dispatch_tool(tool_name, tool_inputs, cwd, display, client)
|
|
281
|
+
tool_results.append({
|
|
282
|
+
"type": "tool_result",
|
|
283
|
+
"tool_use_id": tool_id,
|
|
284
|
+
"content": result # full result sent to model this turn
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
# Truncate tool results before storing in history to prevent context bloat.
|
|
288
|
+
# The model already processed the full content this turn.
|
|
289
|
+
MAX_TOOL_RESULT_CHARS = 1500
|
|
290
|
+
history_tool_results = []
|
|
291
|
+
for tr in tool_results:
|
|
292
|
+
content = tr["content"]
|
|
293
|
+
if len(content) > MAX_TOOL_RESULT_CHARS:
|
|
294
|
+
content = content[:MAX_TOOL_RESULT_CHARS] + "…[truncated]"
|
|
295
|
+
history_tool_results.append({**tr, "content": content})
|
|
296
|
+
messages.append({"role": "user", "content": history_tool_results})
|
|
297
|
+
|
|
298
|
+
if stuck:
|
|
299
|
+
break
|
cfdpilot/auth.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
import httpx
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
TOKEN_PATH = Path.home() / ".cfdpilot" / "config"
|
|
7
|
+
API_BASE = "https://cfdpilot.com"
|
|
8
|
+
POLL_INTERVAL = 2
|
|
9
|
+
POLL_TIMEOUT = 600
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def save_token(token: str) -> None:
|
|
13
|
+
TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
TOKEN_PATH.write_text(json.dumps({"token": token}))
|
|
15
|
+
TOKEN_PATH.chmod(0o600)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_token() -> str | None:
|
|
19
|
+
if not TOKEN_PATH.exists():
|
|
20
|
+
return None
|
|
21
|
+
try:
|
|
22
|
+
return json.loads(TOKEN_PATH.read_text()).get("token")
|
|
23
|
+
except Exception:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def device_login() -> str:
|
|
28
|
+
"""Run device flow. Blocks until authorized or timeout."""
|
|
29
|
+
try:
|
|
30
|
+
with httpx.Client(timeout=10) as client:
|
|
31
|
+
resp = client.post(f"{API_BASE}/device/code")
|
|
32
|
+
resp.raise_for_status()
|
|
33
|
+
data = resp.json()
|
|
34
|
+
except Exception as e:
|
|
35
|
+
raise ConnectionError(f"Could not reach cfdpilot.com: {e}") from e
|
|
36
|
+
|
|
37
|
+
code = data["code"]
|
|
38
|
+
url = data["url"]
|
|
39
|
+
print(f"\n Open this URL in your browser:\n {url}\n")
|
|
40
|
+
print(" Waiting for authentication", end="", flush=True)
|
|
41
|
+
|
|
42
|
+
deadline = time.time() + POLL_TIMEOUT
|
|
43
|
+
with httpx.Client() as client:
|
|
44
|
+
while time.time() < deadline:
|
|
45
|
+
time.sleep(POLL_INTERVAL)
|
|
46
|
+
print(".", end="", flush=True)
|
|
47
|
+
try:
|
|
48
|
+
resp = client.get(f"{API_BASE}/device/poll/{code}")
|
|
49
|
+
if resp.status_code == 200:
|
|
50
|
+
data = resp.json()
|
|
51
|
+
if data["status"] == "authorized":
|
|
52
|
+
print(" ✓")
|
|
53
|
+
return data["token"]
|
|
54
|
+
except Exception:
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
raise TimeoutError("Authentication timed out. Run `cfdpilot login` again.")
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from cfdpilot.tools.log_parser import _find_log
|
|
4
|
+
|
|
5
|
+
SOLVER_PATTERN = re.compile(r"application\s+(\w+);")
|
|
6
|
+
TURBULENCE_PATTERN = re.compile(r"RASModel\s+(\w+);|LESModel\s+(\w+);|model\s+(\w+);")
|
|
7
|
+
DELTA_T_PATTERN = re.compile(r"deltaT\s+([\d.eE+-]+);")
|
|
8
|
+
END_TIME_PATTERN = re.compile(r"endTime\s+([\d.eE+-]+);")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_OF_FLAT_MARKERS = {"controlDict", "fvSchemes", "fvSolution", "turbulenceProperties", "momentumTransport"}
|
|
12
|
+
_OF_BC_FILES = {"U", "p", "T", "k", "omega", "epsilon", "nut", "alphat", "nuTilda", "p_rgh"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def detect_case(cwd: str) -> dict:
|
|
16
|
+
base = Path(cwd)
|
|
17
|
+
required = [base / "system" / "controlDict", base / "constant", base / "0"]
|
|
18
|
+
|
|
19
|
+
# Standard OF structure
|
|
20
|
+
if all(p.exists() for p in required):
|
|
21
|
+
pass # proceed below
|
|
22
|
+
else:
|
|
23
|
+
# Flat structure: files dumped in one directory (forum thread, shared files)
|
|
24
|
+
flat_files = {f.name for f in base.iterdir() if f.is_file()}
|
|
25
|
+
has_markers = bool(flat_files & _OF_FLAT_MARKERS)
|
|
26
|
+
has_bc = bool(flat_files & _OF_BC_FILES)
|
|
27
|
+
if not (has_markers or has_bc):
|
|
28
|
+
return {"is_of_case": False}
|
|
29
|
+
# Flat case detected — build result directly
|
|
30
|
+
result = {"is_of_case": True, "cwd": cwd, "flat_structure": True}
|
|
31
|
+
ctrl_path = base / "controlDict"
|
|
32
|
+
if ctrl_path.exists():
|
|
33
|
+
ctrl = ctrl_path.read_text(errors="replace")
|
|
34
|
+
m = SOLVER_PATTERN.search(ctrl)
|
|
35
|
+
result["solver"] = m.group(1) if m else "unknown"
|
|
36
|
+
m = DELTA_T_PATTERN.search(ctrl)
|
|
37
|
+
result["deltaT"] = float(m.group(1)) if m else None
|
|
38
|
+
m = END_TIME_PATTERN.search(ctrl)
|
|
39
|
+
result["endTime"] = float(m.group(1)) if m else None
|
|
40
|
+
else:
|
|
41
|
+
result["solver"] = "unknown"
|
|
42
|
+
for turb_name in ("turbulenceProperties", "momentumTransport"):
|
|
43
|
+
turb_path = base / turb_name
|
|
44
|
+
if turb_path.exists():
|
|
45
|
+
turb = turb_path.read_text(errors="replace")
|
|
46
|
+
m = TURBULENCE_PATTERN.search(turb)
|
|
47
|
+
result["turbulence_model"] = next((g for g in m.groups() if g), "unknown") if m else "unknown"
|
|
48
|
+
result["simulation_type"] = "LES" if "LESModel" in turb else "RAS"
|
|
49
|
+
break
|
|
50
|
+
else:
|
|
51
|
+
result["turbulence_model"] = "unknown"
|
|
52
|
+
result["mesh_type"] = "unknown"
|
|
53
|
+
result["log_file"] = None
|
|
54
|
+
result["fields"] = sorted(flat_files & _OF_BC_FILES)
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
result = {"is_of_case": True, "cwd": cwd}
|
|
58
|
+
|
|
59
|
+
ctrl = (base / "system" / "controlDict").read_text(errors="replace")
|
|
60
|
+
m = SOLVER_PATTERN.search(ctrl)
|
|
61
|
+
result["solver"] = m.group(1) if m else "unknown"
|
|
62
|
+
|
|
63
|
+
m = DELTA_T_PATTERN.search(ctrl)
|
|
64
|
+
result["deltaT"] = float(m.group(1)) if m else None
|
|
65
|
+
|
|
66
|
+
m = END_TIME_PATTERN.search(ctrl)
|
|
67
|
+
result["endTime"] = float(m.group(1)) if m else None
|
|
68
|
+
|
|
69
|
+
turb_file = base / "constant" / "turbulenceProperties"
|
|
70
|
+
if not turb_file.exists():
|
|
71
|
+
turb_file = base / "constant" / "momentumTransport"
|
|
72
|
+
result["turbulence_model"] = "unknown"
|
|
73
|
+
if turb_file.exists():
|
|
74
|
+
turb = turb_file.read_text(errors="replace")
|
|
75
|
+
m = TURBULENCE_PATTERN.search(turb)
|
|
76
|
+
if m:
|
|
77
|
+
result["turbulence_model"] = next(g for g in m.groups() if g)
|
|
78
|
+
result["simulation_type"] = "LES" if "LESModel" in turb else "RAS"
|
|
79
|
+
|
|
80
|
+
result["mesh_type"] = "blockMesh"
|
|
81
|
+
if (base / "system" / "snappyHexMeshDict").exists():
|
|
82
|
+
result["mesh_type"] = "snappyHexMesh"
|
|
83
|
+
|
|
84
|
+
log = _find_log(cwd)
|
|
85
|
+
result["log_file"] = str(log) if log else None
|
|
86
|
+
|
|
87
|
+
fields = list((base / "0").iterdir())
|
|
88
|
+
result["fields"] = [f.name for f in fields if f.is_file()]
|
|
89
|
+
|
|
90
|
+
return result
|
cfdpilot/client.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import uuid
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
API_BASE = "https://cfdpilot.com"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CFDPilotClient:
|
|
9
|
+
def __init__(self, token: str):
|
|
10
|
+
self.token = token
|
|
11
|
+
self.session_id = str(uuid.uuid4())
|
|
12
|
+
self.headers = {
|
|
13
|
+
"Authorization": f"Bearer {token}",
|
|
14
|
+
"Content-Type": "application/json",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
def search(self, query: str) -> list[dict]:
|
|
18
|
+
"""GET /agent/search — query the RAG knowledge base."""
|
|
19
|
+
try:
|
|
20
|
+
with httpx.Client(timeout=30) as client:
|
|
21
|
+
response = client.get(
|
|
22
|
+
f"{API_BASE}/agent/search",
|
|
23
|
+
headers=self.headers,
|
|
24
|
+
params={"query": query},
|
|
25
|
+
)
|
|
26
|
+
response.raise_for_status()
|
|
27
|
+
return response.json().get("results", [])
|
|
28
|
+
except Exception:
|
|
29
|
+
return []
|
|
30
|
+
|
|
31
|
+
def chat(self, messages: list[dict], tools: list[dict]) -> dict:
|
|
32
|
+
"""POST to /agent/chat, return the response dict."""
|
|
33
|
+
try:
|
|
34
|
+
with httpx.Client(timeout=120) as client:
|
|
35
|
+
response = client.post(
|
|
36
|
+
f"{API_BASE}/agent/chat",
|
|
37
|
+
headers=self.headers,
|
|
38
|
+
json={"messages": messages, "tools": tools, "session_id": self.session_id},
|
|
39
|
+
)
|
|
40
|
+
if response.status_code == 429:
|
|
41
|
+
raise RuntimeError("Session token limit reached. Start a new cfdpilot session.")
|
|
42
|
+
if response.status_code == 401:
|
|
43
|
+
raise RuntimeError("Invalid token. Run: cfdpilot login")
|
|
44
|
+
response.raise_for_status()
|
|
45
|
+
return response.json()
|
|
46
|
+
except httpx.TimeoutException:
|
|
47
|
+
raise RuntimeError("Request timed out. The backend may be overloaded.")
|
|
48
|
+
except httpx.HTTPStatusError as e:
|
|
49
|
+
raise RuntimeError(f"Backend error: HTTP {e.response.status_code}")
|
|
50
|
+
except httpx.NetworkError as e:
|
|
51
|
+
raise RuntimeError(f"Network error: {e}")
|
cfdpilot/display.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from rich.console import Console
|
|
2
|
+
from rich.syntax import Syntax
|
|
3
|
+
from rich.panel import Panel
|
|
4
|
+
|
|
5
|
+
console = Console()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Display:
|
|
9
|
+
def __init__(self, case_info: dict):
|
|
10
|
+
self.case_info = case_info
|
|
11
|
+
|
|
12
|
+
def print_welcome(self):
|
|
13
|
+
info = self.case_info
|
|
14
|
+
console.print()
|
|
15
|
+
console.print("[bold]CFDPilot Agent v0.1[/bold]")
|
|
16
|
+
console.rule()
|
|
17
|
+
if info.get("is_of_case"):
|
|
18
|
+
solver = info.get("solver", "unknown")
|
|
19
|
+
turb = info.get("turbulence_model", "unknown")
|
|
20
|
+
mesh = info.get("mesh_type", "unknown")
|
|
21
|
+
log = info.get("log_file")
|
|
22
|
+
console.print(
|
|
23
|
+
f"Case: [cyan]{solver}[/cyan] · [cyan]{turb}[/cyan] · [cyan]{mesh}[/cyan]"
|
|
24
|
+
)
|
|
25
|
+
if log:
|
|
26
|
+
console.print(f"Log: [dim]{log}[/dim]")
|
|
27
|
+
else:
|
|
28
|
+
console.print("[yellow]No OpenFOAM case detected in this directory[/yellow]")
|
|
29
|
+
console.rule()
|
|
30
|
+
console.print()
|
|
31
|
+
|
|
32
|
+
def get_input(self) -> str:
|
|
33
|
+
return console.input("[bold cyan]>[/bold cyan] ").strip()
|
|
34
|
+
|
|
35
|
+
def print_text(self, text: str):
|
|
36
|
+
console.print(f" {text}")
|
|
37
|
+
|
|
38
|
+
def print_action(self, msg: str):
|
|
39
|
+
console.print(f"[dim]● {msg}[/dim]")
|
|
40
|
+
|
|
41
|
+
def print_solver_line(self, line: str):
|
|
42
|
+
if "Solving for" in line or "Courant" in line or "Time = " in line:
|
|
43
|
+
console.print(f" [dim]{line}[/dim]")
|
|
44
|
+
|
|
45
|
+
def print_diff(self, diff: str):
|
|
46
|
+
console.print()
|
|
47
|
+
syntax = Syntax(diff, "diff", theme="monokai", line_numbers=False)
|
|
48
|
+
console.print(Panel(syntax, title="[bold]Proposed changes[/bold]", border_style="yellow"))
|
|
49
|
+
|
|
50
|
+
def confirm(self, prompt: str) -> bool:
|
|
51
|
+
try:
|
|
52
|
+
answer = console.input(f"\n {prompt} [Y/n] ").strip().lower()
|
|
53
|
+
return answer in ("", "y", "yes")
|
|
54
|
+
except (KeyboardInterrupt, EOFError):
|
|
55
|
+
console.print("\n[dim](cancelled)[/dim]")
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
def print_error(self, msg: str):
|
|
59
|
+
console.print(f"\n[red]Error: {msg}[/red]")
|
|
60
|
+
|
|
61
|
+
def print_stuck(self, attempts: int):
|
|
62
|
+
console.print(Panel(
|
|
63
|
+
f"[yellow]I have made {attempts} fix attempts without success.[/yellow]\n"
|
|
64
|
+
"The remaining issue likely requires manual inspection.\n"
|
|
65
|
+
"Run [cyan]checkMesh[/cyan] if you suspect a mesh quality problem.",
|
|
66
|
+
title="[bold yellow]Stuck[/bold yellow]",
|
|
67
|
+
border_style="yellow"
|
|
68
|
+
))
|
|
69
|
+
|
|
70
|
+
def print_goodbye(self):
|
|
71
|
+
console.print("\n[dim]Session ended.[/dim]")
|
cfdpilot/main.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import typer
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from cfdpilot.auth import device_login, save_token, load_token
|
|
5
|
+
from cfdpilot.case_detector import detect_case
|
|
6
|
+
from cfdpilot.display import Display
|
|
7
|
+
from cfdpilot.agent import run_agent_loop
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="CFDPilot Agent — AI engineer for your OpenFOAM cases")
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command("login")
|
|
14
|
+
def login():
|
|
15
|
+
"""Authenticate with your CFDpilot account."""
|
|
16
|
+
console.print("\n[bold]CFDPilot Agent[/bold] — Login\n")
|
|
17
|
+
try:
|
|
18
|
+
token = device_login()
|
|
19
|
+
save_token(token)
|
|
20
|
+
console.print("[green]Logged in successfully.[/green]")
|
|
21
|
+
except (TimeoutError, ConnectionError) as e:
|
|
22
|
+
console.print(f"[red]{e}[/red]")
|
|
23
|
+
raise typer.Exit(1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command("setup")
|
|
27
|
+
def setup(token: str = typer.Argument(..., help="Your CFDpilot access token")):
|
|
28
|
+
"""Activate your CFDpilot access token."""
|
|
29
|
+
save_token(token)
|
|
30
|
+
console.print("[green]Token saved. You're ready — run: cfdpilot[/green]")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.callback(invoke_without_command=True)
|
|
34
|
+
def main(ctx: typer.Context):
|
|
35
|
+
"""Run the CFDPilot Agent in the current OpenFOAM case directory."""
|
|
36
|
+
if ctx.invoked_subcommand is not None:
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
token = load_token()
|
|
40
|
+
if not token:
|
|
41
|
+
console.print("[red]Not logged in. Run:[/red] cfdpilot login")
|
|
42
|
+
raise typer.Exit(1)
|
|
43
|
+
|
|
44
|
+
cwd = os.getcwd()
|
|
45
|
+
case_info = detect_case(cwd)
|
|
46
|
+
display = Display(case_info)
|
|
47
|
+
run_agent_loop(token=token, cwd=cwd, display=display, case_info=case_info)
|
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _safe_path(path: str, cwd: str) -> Path | None:
|
|
6
|
+
base = Path(cwd).resolve()
|
|
7
|
+
target = (base / path).resolve()
|
|
8
|
+
if target != base and not str(target).startswith(str(base) + os.sep):
|
|
9
|
+
return None
|
|
10
|
+
return target
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
MAX_FILE_CHARS = 50_000 # ~12k tokens — prevents log files from blowing up the context
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def read_file(path: str, cwd: str) -> dict:
|
|
17
|
+
safe = _safe_path(path, cwd)
|
|
18
|
+
if safe is None:
|
|
19
|
+
return {"content": None, "error": "Access denied: path outside case directory"}
|
|
20
|
+
if not safe.exists():
|
|
21
|
+
return {"content": None, "error": f"File not found: {path}"}
|
|
22
|
+
try:
|
|
23
|
+
content = safe.read_text(errors="replace")
|
|
24
|
+
truncated = len(content) > MAX_FILE_CHARS
|
|
25
|
+
if truncated:
|
|
26
|
+
content = content[:MAX_FILE_CHARS] + f"\n\n[TRUNCATED — file is {len(content)} chars, showing first {MAX_FILE_CHARS}. Use parse_log for log files.]"
|
|
27
|
+
return {"content": content, "error": None, "size": len(content), "truncated": truncated}
|
|
28
|
+
except Exception as e:
|
|
29
|
+
return {"content": None, "error": str(e)}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def list_dir(path: str, cwd: str) -> dict:
|
|
33
|
+
safe = _safe_path(path, cwd)
|
|
34
|
+
if safe is None:
|
|
35
|
+
return {"entries": [], "error": "Access denied"}
|
|
36
|
+
if not safe.exists():
|
|
37
|
+
return {"entries": [], "error": f"Directory not found: {path}"}
|
|
38
|
+
entries = []
|
|
39
|
+
for item in sorted(safe.iterdir()):
|
|
40
|
+
entries.append({
|
|
41
|
+
"name": item.name,
|
|
42
|
+
"type": "dir" if item.is_dir() else "file",
|
|
43
|
+
"size": item.stat().st_size if item.is_file() else None
|
|
44
|
+
})
|
|
45
|
+
return {"entries": [e["name"] for e in entries], "details": entries, "error": None}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
SOLVER_PATTERN = re.compile(r"^Exec\s+:\s+(\w+)", re.MULTILINE)
|
|
5
|
+
VERSION_PATTERN = re.compile(r"Version:\s+([\w.]+)")
|
|
6
|
+
BUILD_PATTERN = re.compile(r"^Build\s+:\s+([\w.]+)", re.MULTILINE)
|
|
7
|
+
CO_PATTERN = re.compile(r"Courant Number mean:\s+[\d.]+\s+max:\s+([\d.]+)")
|
|
8
|
+
RESIDUAL_PATTERN = re.compile(
|
|
9
|
+
r"Solving for (\w+),\s+Initial residual = ([\d.eE+-]+),\s+Final residual = ([\d.eE+-]+)"
|
|
10
|
+
)
|
|
11
|
+
CONVERGED_PATTERN = re.compile(r"(SIMPLE solution converged|End)", re.IGNORECASE)
|
|
12
|
+
FATAL_PATTERN = re.compile(r"FOAM FATAL|Floating point exception|divergence detected", re.IGNORECASE)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_UTILITY_LOGS = {
|
|
16
|
+
"blockMesh", "checkMesh", "snappyHexMesh", "decomposePar", "reconstructPar",
|
|
17
|
+
"reconstructParMesh", "surfaceFeatures", "surfaceFeatureExtract", "renumberMesh",
|
|
18
|
+
"setFields", "extrudeMesh", "topoSet", "createPatch", "transformPoints",
|
|
19
|
+
"mergeMeshes", "mirrorMesh", "splitMeshRegions", "mapFields", "changeDictionary",
|
|
20
|
+
"createBaffles", "refineMesh", "moveDynamicMesh", "postProcess", "foamToVTK",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _find_log(cwd: str) -> Path | None:
|
|
25
|
+
"""Return the solver log, not a meshing/utility log.
|
|
26
|
+
|
|
27
|
+
Auto-detect previously grabbed the first log.* alphabetically (e.g. log.blockMesh),
|
|
28
|
+
a meshing step rather than the solver run. Exclude utility logs and prefer the most
|
|
29
|
+
recently modified remaining log (the solver runs after meshing/setup).
|
|
30
|
+
"""
|
|
31
|
+
cwd_path = Path(cwd)
|
|
32
|
+
logs = [f for f in cwd_path.glob("log.*") if f.is_file()]
|
|
33
|
+
if not logs:
|
|
34
|
+
return None
|
|
35
|
+
solver_logs = [f for f in logs if f.name[len("log."):] not in _UTILITY_LOGS]
|
|
36
|
+
pool = solver_logs or logs
|
|
37
|
+
return max(pool, key=lambda f: f.stat().st_mtime)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_log(log_path: str | None, cwd: str = ".") -> dict:
|
|
41
|
+
if log_path is None:
|
|
42
|
+
found = _find_log(cwd)
|
|
43
|
+
if not found:
|
|
44
|
+
return {"error": "No log file found. Run your solver first."}
|
|
45
|
+
log_path = str(found)
|
|
46
|
+
|
|
47
|
+
path = Path(log_path)
|
|
48
|
+
if not path.exists():
|
|
49
|
+
return {"error": f"Log file not found: {log_path}"}
|
|
50
|
+
|
|
51
|
+
text = path.read_text(errors="replace")
|
|
52
|
+
lines = text.splitlines()
|
|
53
|
+
|
|
54
|
+
solver_match = SOLVER_PATTERN.search(text)
|
|
55
|
+
solver = solver_match.group(1) if solver_match else "unknown"
|
|
56
|
+
|
|
57
|
+
ver_match = VERSION_PATTERN.search(text) or BUILD_PATTERN.search(text)
|
|
58
|
+
of_version = ver_match.group(1) if ver_match else "unknown"
|
|
59
|
+
|
|
60
|
+
residuals: dict[str, list[float]] = {}
|
|
61
|
+
for match in RESIDUAL_PATTERN.finditer(text):
|
|
62
|
+
field, init_res = match.group(1), float(match.group(2))
|
|
63
|
+
residuals.setdefault(field, []).append(init_res)
|
|
64
|
+
|
|
65
|
+
co_numbers = [float(m.group(1)) for m in CO_PATTERN.finditer(text)]
|
|
66
|
+
co_max = max(co_numbers) if co_numbers else None
|
|
67
|
+
|
|
68
|
+
fatal_match = FATAL_PATTERN.search(text)
|
|
69
|
+
diverged = fatal_match is not None
|
|
70
|
+
divergence_at = None
|
|
71
|
+
if diverged:
|
|
72
|
+
for i, line in enumerate(lines):
|
|
73
|
+
if FATAL_PATTERN.search(line):
|
|
74
|
+
divergence_at = i + 1
|
|
75
|
+
break
|
|
76
|
+
|
|
77
|
+
converged = bool(CONVERGED_PATTERN.search(text)) and not diverged
|
|
78
|
+
|
|
79
|
+
warnings = [l.strip() for l in lines if "WARNING" in l.upper() or "WARN:" in l]
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
"solver": solver,
|
|
83
|
+
"of_version": of_version,
|
|
84
|
+
"log_path": str(log_path),
|
|
85
|
+
"iterations": len(residuals.get(next(iter(residuals), "p"), [])),
|
|
86
|
+
"residuals": {k: v[-5:] for k, v in residuals.items()},
|
|
87
|
+
"Co_max": co_max,
|
|
88
|
+
"diverged": diverged,
|
|
89
|
+
"divergence_at": divergence_at,
|
|
90
|
+
"converged": converged,
|
|
91
|
+
"warnings": warnings[:10],
|
|
92
|
+
"errors": [l.strip() for l in lines if "FATAL" in l or "Floating point" in l],
|
|
93
|
+
"error": None
|
|
94
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import shutil
|
|
3
|
+
import difflib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def generate_diff(file_path: str, proposed_content: str) -> str:
|
|
8
|
+
path = Path(file_path)
|
|
9
|
+
if not path.exists():
|
|
10
|
+
return f"(new file)\n{proposed_content}"
|
|
11
|
+
current = path.read_text().splitlines(keepends=True)
|
|
12
|
+
proposed = proposed_content.splitlines(keepends=True)
|
|
13
|
+
diff = difflib.unified_diff(
|
|
14
|
+
current, proposed,
|
|
15
|
+
fromfile=f"{path.name} (current)",
|
|
16
|
+
tofile=f"{path.name} (proposed)",
|
|
17
|
+
lineterm=""
|
|
18
|
+
)
|
|
19
|
+
return "\n".join(diff)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def patch_dict(file_path: str, changes: dict) -> dict:
|
|
23
|
+
path = Path(file_path)
|
|
24
|
+
if not path.exists():
|
|
25
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
26
|
+
|
|
27
|
+
content = path.read_text()
|
|
28
|
+
backup = Path(str(path) + ".bak")
|
|
29
|
+
shutil.copy(path, backup)
|
|
30
|
+
|
|
31
|
+
for key, new_value in changes.items():
|
|
32
|
+
simple_key = key.split(".")[-1]
|
|
33
|
+
pattern = rf"(\b{re.escape(simple_key)}\s+)[^\n;]+;"
|
|
34
|
+
replacement = rf"\g<1>{new_value};"
|
|
35
|
+
new_content = re.sub(pattern, replacement, content, count=1)
|
|
36
|
+
if new_content == content:
|
|
37
|
+
pattern = rf"({re.escape(simple_key)}\s+)[^\n;]+;"
|
|
38
|
+
new_content = re.sub(pattern, replacement, content, count=1)
|
|
39
|
+
content = new_content
|
|
40
|
+
|
|
41
|
+
path.write_text(content)
|
|
42
|
+
return {"success": True, "backup": str(backup)}
|
cfdpilot/tools/runner.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import subprocess
|
|
4
|
+
import shlex
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
DIVERGENCE_SIGNALS = [
|
|
9
|
+
"FOAM FATAL",
|
|
10
|
+
"Floating point exception",
|
|
11
|
+
"divergence detected",
|
|
12
|
+
"solution singularity",
|
|
13
|
+
]
|
|
14
|
+
CONVERGENCE_SIGNALS = [
|
|
15
|
+
"SIMPLE solution converged",
|
|
16
|
+
"solution converged",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
_HOME = str(Path.home())
|
|
20
|
+
_DOCKER_WORKDIR = "/home/ofuser/workingDir"
|
|
21
|
+
# Configurable — was hardcoded "of_v11", which broke for any other container name.
|
|
22
|
+
_DOCKER_BASHRC = os.environ.get("CFDPILOT_OF_BASHRC", "/opt/openfoam11/etc/bashrc")
|
|
23
|
+
_CONTAINER_CACHE = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _docker_container() -> str:
|
|
27
|
+
"""Resolve the OpenFOAM Docker container name.
|
|
28
|
+
|
|
29
|
+
Order: CFDPILOT_DOCKER_CONTAINER env var → first running OpenFOAM-ish
|
|
30
|
+
container (via `docker ps`) → 'of_v11' fallback. Result is cached.
|
|
31
|
+
"""
|
|
32
|
+
global _CONTAINER_CACHE
|
|
33
|
+
if _CONTAINER_CACHE:
|
|
34
|
+
return _CONTAINER_CACHE
|
|
35
|
+
name = os.environ.get("CFDPILOT_DOCKER_CONTAINER")
|
|
36
|
+
if not name:
|
|
37
|
+
try:
|
|
38
|
+
out = subprocess.run(
|
|
39
|
+
["docker", "ps", "--format", "{{.Names}}"],
|
|
40
|
+
capture_output=True, text=True, timeout=10
|
|
41
|
+
).stdout
|
|
42
|
+
name = next((n for n in out.split() if re.search(r"foam|of[_-]?v?\d", n, re.I)), None)
|
|
43
|
+
except Exception:
|
|
44
|
+
name = None
|
|
45
|
+
_CONTAINER_CACHE = name or "of_v11"
|
|
46
|
+
return _CONTAINER_CACHE
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _to_docker_path(mac_path: str) -> str:
|
|
50
|
+
abs_path = str(Path(mac_path).resolve())
|
|
51
|
+
if abs_path.startswith(_HOME):
|
|
52
|
+
return _DOCKER_WORKDIR + abs_path[len(_HOME):]
|
|
53
|
+
return abs_path
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _wrap_docker(cmd: str, cwd: str) -> list:
|
|
57
|
+
"""Return a docker exec command list that runs cmd inside the OF container with OF sourced."""
|
|
58
|
+
docker_cwd = _to_docker_path(cwd)
|
|
59
|
+
# Source the configured bashrc if present, else glob common Foundation/ESI locations.
|
|
60
|
+
source = (
|
|
61
|
+
f'if [ -f "{_DOCKER_BASHRC}" ]; then source "{_DOCKER_BASHRC}"; '
|
|
62
|
+
f'else source $(ls /opt/openfoam*/etc/bashrc /usr/lib/openfoam/openfoam*/etc/bashrc 2>/dev/null | head -1); fi'
|
|
63
|
+
)
|
|
64
|
+
inner = f"{source} && cd {shlex.quote(docker_cwd)} && {cmd}"
|
|
65
|
+
return ["docker", "exec", _docker_container(), "bash", "-c", inner]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run_command(cmd: str, cwd: str = ".", timeout: int = 300) -> dict:
|
|
69
|
+
try:
|
|
70
|
+
result = subprocess.run(
|
|
71
|
+
cmd, shell=True, cwd=cwd,
|
|
72
|
+
capture_output=True, text=True, timeout=timeout
|
|
73
|
+
)
|
|
74
|
+
if result.returncode == 127:
|
|
75
|
+
# Command not found in PATH — retry inside Docker container
|
|
76
|
+
result = subprocess.run(
|
|
77
|
+
_wrap_docker(cmd, cwd), cwd=cwd,
|
|
78
|
+
capture_output=True, text=True, timeout=timeout
|
|
79
|
+
)
|
|
80
|
+
return {
|
|
81
|
+
"stdout": result.stdout,
|
|
82
|
+
"stderr": result.stderr,
|
|
83
|
+
"exit_code": result.returncode,
|
|
84
|
+
"error": None
|
|
85
|
+
}
|
|
86
|
+
except subprocess.TimeoutExpired:
|
|
87
|
+
return {"stdout": "", "stderr": "", "exit_code": -1, "error": "timeout"}
|
|
88
|
+
except Exception as e:
|
|
89
|
+
return {"stdout": "", "stderr": "", "exit_code": -1, "error": str(e)}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def run_solver(
|
|
93
|
+
solver: str,
|
|
94
|
+
cwd: str = ".",
|
|
95
|
+
max_iterations: int | None = None,
|
|
96
|
+
on_line: Callable[[str], None] | None = None,
|
|
97
|
+
) -> dict:
|
|
98
|
+
try:
|
|
99
|
+
proc = subprocess.Popen(
|
|
100
|
+
[solver], cwd=cwd,
|
|
101
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
102
|
+
text=True, bufsize=1
|
|
103
|
+
)
|
|
104
|
+
except FileNotFoundError:
|
|
105
|
+
# Solver not in PATH — run inside Docker container
|
|
106
|
+
try:
|
|
107
|
+
proc = subprocess.Popen(
|
|
108
|
+
_wrap_docker(solver, cwd), cwd=cwd,
|
|
109
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
110
|
+
text=True, bufsize=1
|
|
111
|
+
)
|
|
112
|
+
except Exception as e:
|
|
113
|
+
return {
|
|
114
|
+
"diverged": False, "converged": False, "iterations": 0, "log_lines": [],
|
|
115
|
+
"exit_code": -1,
|
|
116
|
+
"error": f"Could not start solver '{solver}'. Is your OpenFOAM Docker container running? Start it (e.g. `docker start {_docker_container()}`), or set CFDPILOT_DOCKER_CONTAINER / CFDPILOT_OF_BASHRC. Error: {e}"
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
lines = []
|
|
120
|
+
diverged = False
|
|
121
|
+
converged = False
|
|
122
|
+
iteration = 0
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
for line in proc.stdout:
|
|
126
|
+
line = line.rstrip()
|
|
127
|
+
lines.append(line)
|
|
128
|
+
if on_line:
|
|
129
|
+
on_line(line)
|
|
130
|
+
|
|
131
|
+
if any(sig in line for sig in DIVERGENCE_SIGNALS):
|
|
132
|
+
diverged = True
|
|
133
|
+
proc.terminate()
|
|
134
|
+
break
|
|
135
|
+
if any(sig in line for sig in CONVERGENCE_SIGNALS):
|
|
136
|
+
converged = True
|
|
137
|
+
proc.terminate()
|
|
138
|
+
break
|
|
139
|
+
if "Time = " in line:
|
|
140
|
+
iteration += 1
|
|
141
|
+
if max_iterations and iteration >= max_iterations:
|
|
142
|
+
proc.terminate()
|
|
143
|
+
break
|
|
144
|
+
|
|
145
|
+
proc.wait(timeout=5)
|
|
146
|
+
except Exception:
|
|
147
|
+
try:
|
|
148
|
+
proc.kill()
|
|
149
|
+
proc.wait(timeout=2)
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
"diverged": diverged,
|
|
155
|
+
"converged": converged,
|
|
156
|
+
"iterations": iteration,
|
|
157
|
+
"log_lines": lines[-50:],
|
|
158
|
+
"exit_code": proc.returncode if proc.returncode is not None else -1,
|
|
159
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cfdpilot
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: AI agent for OpenFOAM — diagnose, fix, and relaunch cases from your terminal
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: httpx>=0.27
|
|
7
|
+
Requires-Dist: python-dotenv>=1.0
|
|
8
|
+
Requires-Dist: rich>=13
|
|
9
|
+
Requires-Dist: typer>=0.12
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: httpx; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# CFDpilot
|
|
17
|
+
|
|
18
|
+
**An AI agent for OpenFOAM — in your terminal.**
|
|
19
|
+
|
|
20
|
+
CFDpilot reads your OpenFOAM case directly from disk, diagnoses why it diverges or
|
|
21
|
+
gives wrong physics, proposes the exact fix as a colored diff, and — once you confirm —
|
|
22
|
+
patches the files and reruns the solver. No ZIP upload, no browser. It runs where you
|
|
23
|
+
already work: your laptop, your workstation, or an HPC node over SSH.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install cfdpilot
|
|
27
|
+
cfdpilot login # authenticate once
|
|
28
|
+
cd path/to/your/case && cfdpilot
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then just ask, in plain English:
|
|
32
|
+
|
|
33
|
+
> *"Why won't my rhoSimpleFoam case converge?"*
|
|
34
|
+
|
|
35
|
+
The agent maps your case, reads `fvSchemes`, `fvSolution`, your boundary conditions and
|
|
36
|
+
solver log, reasons from the actual numbers (Courant number, residuals, turbulence wall
|
|
37
|
+
treatment, scheme/switch consistency), and reports the bugs ranked by impact — with the
|
|
38
|
+
exact fix for each. Nothing is changed without showing you a diff first, and every patch
|
|
39
|
+
makes a `.bak` backup.
|
|
40
|
+
|
|
41
|
+
## What it checks
|
|
42
|
+
|
|
43
|
+
Boundary-condition consistency, turbulence wall treatment (low-Re vs high-Re), Courant
|
|
44
|
+
number and time-step control, relaxation factors, discretisation schemes, solver-mode
|
|
45
|
+
switches (e.g. `transonic`), and solver/turbulence-model compatibility — across
|
|
46
|
+
incompressible, compressible, multiphase (VoF) and heat-transfer solvers.
|
|
47
|
+
|
|
48
|
+
## Requirements
|
|
49
|
+
|
|
50
|
+
- Python 3.10+
|
|
51
|
+
- An OpenFOAM case directory (Foundation or ESI). OpenFOAM itself can run natively or in
|
|
52
|
+
Docker; the agent will use it to run the solver when you ask.
|
|
53
|
+
|
|
54
|
+
## Access
|
|
55
|
+
|
|
56
|
+
CFDpilot is in **free early access** for the first engineers. Run `cfdpilot login` to get
|
|
57
|
+
started, or request access at [cfdpilot.com](https://cfdpilot.com).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
cfdpilot/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
cfdpilot/agent.py,sha256=1i48rTot82I5BOMsZ-MruUSubJ4LDY01yB6ycbmpjgA,11243
|
|
3
|
+
cfdpilot/auth.py,sha256=B2V1ZFKcYls39EXG0z40-l7yVl9liLs7BagHGNtRKOk,1748
|
|
4
|
+
cfdpilot/case_detector.py,sha256=J6ME-FFwmwNvI51iqnYbSsBl7O7ibZrVZwf7DSqGFr4,3695
|
|
5
|
+
cfdpilot/client.py,sha256=mPS7BZWx2dBF6WL5ILTmCa3CXx749FX1lPcfQJPepG8,1994
|
|
6
|
+
cfdpilot/display.py,sha256=r4XbmSq5phQ30_Lq1lz9GvQ4kzPzg6Fe7dT6POs46NE,2522
|
|
7
|
+
cfdpilot/main.py,sha256=uny0TSRk5Wn9Go1F0PTLWHBm7sFSQaeMmPnZZ-5Cj68,1516
|
|
8
|
+
cfdpilot/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
cfdpilot/tools/filesystem.py,sha256=5JAELASlu_KLXC3MnAvx8N-UjtcqxGVlW1IYAXK_jgE,1703
|
|
10
|
+
cfdpilot/tools/log_parser.py,sha256=VEESE6AMsl5oF5gQJeDak6o3_ZUpFXnF3hxnq2RVSzc,3669
|
|
11
|
+
cfdpilot/tools/patcher.py,sha256=CM06A-CGjp2sZr4AiAGExJee3vA2smHUa-hzcJaq7Xw,1361
|
|
12
|
+
cfdpilot/tools/runner.py,sha256=hhsMd8NcdgSEKugpKvfh1fBrej8mlktIveIup-G82wc,5291
|
|
13
|
+
cfdpilot-0.1.1.dist-info/METADATA,sha256=pMGLjtVGwhwkZvbOul6JQoKzS61i6q_55vAwpEGJMvU,2141
|
|
14
|
+
cfdpilot-0.1.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
15
|
+
cfdpilot-0.1.1.dist-info/entry_points.txt,sha256=kWaVnnh6PvfLHuTfpuFLA4y1XIlCj_dWE-8JhAPtmgA,47
|
|
16
|
+
cfdpilot-0.1.1.dist-info/RECORD,,
|