harishaseebat92 commited on
Commit
aa49145
·
1 Parent(s): 091edc3

New Embedded Version

Browse files
app.py CHANGED
@@ -1,239 +1,216 @@
1
- import os
2
- # Ensure OMP_NUM_THREADS is set to avoid libgomp errors
3
- os.environ["OMP_NUM_THREADS"] = "1"
4
-
5
- from trame.app import get_server
6
- from trame_vuetify.ui.vuetify3 import SinglePageLayout
7
- from trame_vuetify.widgets import vuetify3
8
- from trame.widgets import html as trame_html
9
- import threading
10
- from concurrent.futures import ThreadPoolExecutor
11
- import time
12
-
13
- # Import embedded page wrappers (lazy load inside tabs)
14
- from importlib import import_module
15
- em_page = None
16
- qlbm_page = None
17
-
18
- # Force embedded mode for nested pages (avoid iframes/secondary servers)
19
- os.environ.setdefault("TRAME_EMBEDDED", "1")
20
- os.environ.setdefault("TRAME_DISABLE_BROWSER", "1")
21
- os.environ.setdefault("EM_APP_EMBEDDED", "1")
22
- os.environ.setdefault("DISABLE_EM_STANDALONE", "1")
23
-
24
- # Create a single server for the multipage app
25
- server = get_server()
26
- state, ctrl = server.state, server.controller
27
-
28
- # App state: landing chooser -> specific experience
29
- state.current_page = None # "EM" or "QLBM" once selected
30
-
31
- # -------------------------------------------------------------------------
32
- # Thread pool for long-running jobs to avoid UI freezing by HF timeout
33
- # -------------------------------------------------------------------------
34
- MAX_WORKERS = int(os.environ.get("MAX_WORKERS", "4"))
35
- executor = ThreadPoolExecutor(max_workers=MAX_WORKERS)
36
-
37
- def submit_background(fn, *args, **kwargs):
38
- """
39
- Run a CPU-heavy / long job without blocking Trame's event loop.
40
-
41
- Usage from pages:
42
- from trame.app import get_server
43
- server = get_server()
44
- server.submit_background(long_fn, arg1, arg2, kw1=...)
45
- """
46
- return executor.submit(fn, *args, **kwargs)
47
-
48
- # Expose helper on the server so pages.em_page / pages.qlbm_page can use it
49
- server.submit_background = submit_background
50
-
51
- # Load Synopsys/Ansys logo as data URI for main toolbar
52
- import base64
53
-
54
- def _load_logo_data_uri():
55
- base_dir = os.path.dirname(__file__)
56
- candidates = [
57
- os.path.join(base_dir, "ansys-part-of-synopsys-logo.svg"),
58
- os.path.join(base_dir, "synopsys-logo-color-rgb.svg"),
59
- os.path.join(base_dir, "synopsys-logo-color-rgb.png"),
60
- os.path.join(base_dir, "synopsys-logo-color-rgb.jpg"),
61
- ]
62
- for p in candidates:
63
- if os.path.exists(p):
64
- ext = os.path.splitext(p)[1].lower()
65
- mime = "image/svg+xml" if ext == ".svg" else ("image/png" if ext == ".png" else "image/jpeg")
66
- with open(p, "rb") as f:
67
- b64 = base64.b64encode(f.read()).decode("ascii")
68
- return f"data:{mime};base64,{b64}"
69
- return None
70
-
71
- def _stop_subapp(module_name: str, attr_name: str):
72
- module = globals().get(attr_name)
73
- if module is None:
74
- try:
75
- module = import_module(module_name)
76
- globals()[attr_name] = module
77
- except Exception:
78
- return
79
- stop_fn = getattr(module, "stop", None)
80
- if callable(stop_fn):
81
- try:
82
- stop_fn()
83
- except Exception:
84
- pass
85
-
86
-
87
- # Safely initialize logo in state (trame state isn't a dict; avoid .get())
88
- try:
89
- if not hasattr(state, "logo_src") or state.logo_src in (None, ""):
90
- state.logo_src = _load_logo_data_uri()
91
- except Exception:
92
- state.logo_src = _load_logo_data_uri()
93
-
94
-
95
- @state.change("current_page")
96
- def _handle_page_change(current_page, **_):
97
- """Stop inactive subprocesses as users navigate between experiences."""
98
- if current_page == "EM":
99
- _stop_subapp("pages.qlbm_page", "qlbm_page")
100
- elif current_page == "QLBM":
101
- _stop_subapp("pages.em_page", "em_page")
102
- else:
103
- _stop_subapp("pages.em_page", "em_page")
104
- _stop_subapp("pages.qlbm_page", "qlbm_page")
105
-
106
- with SinglePageLayout(server) as layout:
107
- layout.title.set_text("Quantum Applications")
108
- layout.toolbar.classes = "pl-2 pr-1 py-1 elevation-0"
109
- layout.toolbar.style = "background-color: #ffffff; border-bottom: 3px solid #5f259f;"
110
-
111
- with layout.toolbar:
112
- vuetify3.VSpacer()
113
- vuetify3.VBtn(
114
- v_if="current_page",
115
- text="Main Page",
116
- variant="text",
117
- color="primary",
118
- prepend_icon="mdi-arrow-left",
119
- click="current_page = null",
120
- classes="mr-2",
121
- )
122
- vuetify3.VChip(
123
- v_if="current_page",
124
- label=True,
125
- color="primary",
126
- text_color="white",
127
- children=["{{ current_page === 'EM' ? 'Electromagnetic Scattering' : 'Quantum LBM' }}"],
128
- classes="mr-2",
129
- )
130
- vuetify3.VImg(
131
- v_if="logo_src",
132
- src=("logo_src", None),
133
- style="height: 40px; width: auto;",
134
- classes="ml-2",
135
- )
136
-
137
- with layout.content:
138
- # Landing screen
139
- with vuetify3.VContainer(
140
- v_if="!current_page",
141
- fluid=True,
142
- classes="fill-height d-flex align-center justify-center pa-6",
143
- ):
144
- with vuetify3.VSheet(
145
- elevation=6,
146
- rounded=True,
147
- style="max-width: 1080px; width: 100%; background: linear-gradient(135deg, #fdfbff, #f3ecff);",
148
- classes="pa-8",
149
- ):
150
- vuetify3.VCardTitle(
151
- "Pick a quantum experience",
152
- classes="text-h4 text-primary font-weight-bold mb-2 text-center",
153
- )
154
- vuetify3.VCardSubtitle(
155
- "Choose one workflow. We'll spin up only that server until you switch back.",
156
- classes="text-body-1 text-center mb-6",
157
- )
158
- with vuetify3.VRow(justify="center", align="stretch", class_="text-left"):
159
- with vuetify3.VCol(cols=12, md=5, class_="d-flex"):
160
- with vuetify3.VCard(elevation=4, classes="pa-6 flex-grow-1"):
161
- vuetify3.VIcon("mdi-radar", size=52, color="primary", classes="mb-4")
162
- vuetify3.VCardTitle("Electromagnetic Scattering", classes="text-h5 mb-2")
163
- vuetify3.VCardText(
164
- "Placeholder",
165
- classes="text-body-2 mb-6",
166
- )
167
- vuetify3.VBtn(
168
- text="Launch EM",
169
- color="primary",
170
- block=True,
171
- prepend_icon="mdi-play-circle",
172
- size="large",
173
- click="current_page = 'EM'",
174
- )
175
- with vuetify3.VCol(cols=12, md=5, class_="d-flex"):
176
- with vuetify3.VCard(elevation=4, classes="pa-6 flex-grow-1"):
177
- vuetify3.VIcon("mdi-water", size=52, color="secondary", classes="mb-4")
178
- vuetify3.VCardTitle("Fluids", classes="text-h5 mb-2")
179
- vuetify3.VCardText(
180
- "Placeholder",
181
- classes="text-body-2 mb-6",
182
- )
183
- vuetify3.VBtn(
184
- text="Launch QLBM",
185
- color="secondary",
186
- block=True,
187
- prepend_icon="mdi-play-circle",
188
- size="large",
189
- click="current_page = 'QLBM'",
190
- )
191
-
192
- # EM experience
193
- with vuetify3.VContainer(v_if="current_page === 'EM'", fluid=True, classes="pa-0 fill-height"):
194
- try:
195
- if not globals().get("em_page"):
196
- globals()["em_page"] = import_module("pages.em_page")
197
- em_page.build(server)
198
- except Exception as e:
199
- trame_html.Div(f"EM embed failed: {e}", style="padding:8px;color:#b00020;")
200
-
201
- # QLBM experience
202
- with vuetify3.VContainer(v_if="current_page === 'QLBM'", fluid=True, classes="pa-0 fill-height"):
203
- try:
204
- if not globals().get("qlbm_page"):
205
- globals()["qlbm_page"] = import_module("pages.qlbm_page")
206
- qlbm_page.build(server)
207
- except Exception as e:
208
- trame_html.Div(f"QLBM embed failed: {e}", style="padding:8px;color:#b00020;")
209
-
210
- # -------------------------------------------------------------------------
211
- # Heartbeat: keep HuggingFace WebSocket alive during idle periods
212
- # -------------------------------------------------------------------------
213
- def _start_hf_heartbeat_thread(interval_s: int = 5):
214
- """
215
- Start a background thread that periodically flushes the server,
216
- keeping the WebSocket "active" in the eyes of Hugging Face.
217
- """
218
- def _loop():
219
- while True:
220
- time.sleep(interval_s)
221
- try:
222
- server.controller.flush()
223
- except Exception:
224
- # If server is shutting down or flush fails, exit the thread
225
- break
226
-
227
- t = threading.Thread(target=_loop, daemon=True)
228
- t.start()
229
-
230
-
231
- if __name__ == "__main__":
232
-
233
- # Start HF heartbeat to prevent timeout
234
- _start_hf_heartbeat_thread(interval_s=5)
235
-
236
- # Allow reverse-proxy setups to pin the internal host/port independently from the public PORT
237
- port = int(os.environ.get("APP_PORT") or os.environ.get("PORT", 7860))
238
- host = os.environ.get("APP_HOST", "0.0.0.0")
239
- server.start(host=host, port=port, open_browser=False)
 
1
+ """
2
+ Quantum Applications - Unified Single-Server App
3
+
4
+ This app provides both EM Scattering and QLBM experiences in a single Trame server,
5
+ avoiding the multi-server/iframe approach that causes issues on HuggingFace Spaces.
6
+ """
7
+ import os
8
+ import errno
9
+ os.environ["OMP_NUM_THREADS"] = "1"
10
+
11
+ from trame.app import get_server
12
+ from trame_vuetify.ui.vuetify3 import SinglePageLayout
13
+ from trame_vuetify.widgets import vuetify3
14
+ from trame.widgets import html as trame_html
15
+ import threading
16
+ import time
17
+ import base64
18
+
19
+ # Create a single server for the entire app
20
+ server = get_server()
21
+ state, ctrl = server.state, server.controller
22
+
23
+ # App state
24
+ state.current_page = None # None = landing, "EM" or "QLBM"
25
+
26
+ # --- Logo Loading ---
27
+ def _load_logo_data_uri():
28
+ base_dir = os.path.dirname(__file__)
29
+ candidates = [
30
+ os.path.join(base_dir, "ansys-part-of-synopsys-logo.svg"),
31
+ os.path.join(base_dir, "synopsys-logo-color-rgb.svg"),
32
+ os.path.join(base_dir, "synopsys-logo-color-rgb.png"),
33
+ os.path.join(base_dir, "synopsys-logo-color-rgb.jpg"),
34
+ ]
35
+ for p in candidates:
36
+ if os.path.exists(p):
37
+ ext = os.path.splitext(p)[1].lower()
38
+ mime = "image/svg+xml" if ext == ".svg" else ("image/png" if ext == ".png" else "image/jpeg")
39
+ with open(p, "rb") as f:
40
+ b64 = base64.b64encode(f.read()).decode("ascii")
41
+ return f"data:{mime};base64,{b64}"
42
+ return None
43
+
44
+ state.logo_src = _load_logo_data_uri()
45
+
46
+ # --- Import Embedded Modules ---
47
+ # These are lightweight modules that build UI without creating their own servers
48
+ import qlbm_embedded
49
+ import em_embedded
50
+
51
+ # Set the shared server on both modules
52
+ qlbm_embedded.set_server(server)
53
+ em_embedded.set_server(server)
54
+
55
+ # Initialize state for both modules
56
+ qlbm_embedded.init_state()
57
+ em_embedded.init_state()
58
+
59
+ # --- Build the Layout ---
60
+ with SinglePageLayout(server) as layout:
61
+ layout.title.set_text("Quantum Applications")
62
+ layout.toolbar.classes = "pl-2 pr-1 py-1 elevation-0"
63
+ layout.toolbar.style = "background-color: #ffffff; border-bottom: 3px solid #5f259f;"
64
+
65
+ # Custom CSS
66
+ trame_html.Style("""
67
+ :root { --v-theme-primary: 95, 37, 159; }
68
+ .landing-card:hover { transform: translateY(-4px); transition: transform 0.2s ease; }
69
+ """)
70
+
71
+ with layout.toolbar:
72
+ vuetify3.VSpacer()
73
+
74
+ # Back button (shown when in a sub-app)
75
+ vuetify3.VBtn(
76
+ v_if="current_page",
77
+ text="Main Page",
78
+ variant="text",
79
+ color="primary",
80
+ prepend_icon="mdi-arrow-left",
81
+ click="current_page = null",
82
+ classes="mr-2",
83
+ )
84
+
85
+ # Current page indicator
86
+ vuetify3.VChip(
87
+ v_if="current_page",
88
+ label=True,
89
+ color="primary",
90
+ text_color="white",
91
+ children=["{{ current_page === 'EM' ? 'Electromagnetic Scattering' : 'Quantum LBM' }}"],
92
+ classes="mr-2",
93
+ )
94
+
95
+ # Logo
96
+ vuetify3.VImg(
97
+ v_if="logo_src",
98
+ src=("logo_src", None),
99
+ style="height: 40px; width: auto;",
100
+ classes="ml-2",
101
+ )
102
+
103
+ with layout.content:
104
+ # === Landing Page ===
105
+ with vuetify3.VContainer(
106
+ v_if="!current_page",
107
+ fluid=True,
108
+ classes="fill-height d-flex align-center justify-center pa-6",
109
+ ):
110
+ with vuetify3.VSheet(
111
+ elevation=6,
112
+ rounded=True,
113
+ style="max-width: 1080px; width: 100%; background: linear-gradient(135deg, #fdfbff, #f3ecff);",
114
+ classes="pa-8",
115
+ ):
116
+ vuetify3.VCardTitle(
117
+ "Quantum Simulation Hub",
118
+ classes="text-h4 text-primary font-weight-bold mb-2 text-center",
119
+ )
120
+ vuetify3.VCardSubtitle(
121
+ "Choose a quantum simulation experience. Both run on the same server for optimal performance.",
122
+ classes="text-body-1 text-center mb-6",
123
+ )
124
+
125
+ with vuetify3.VRow(justify="center", align="stretch", class_="text-left"):
126
+ # EM Card
127
+ with vuetify3.VCol(cols=12, md=5, class_="d-flex"):
128
+ with vuetify3.VCard(elevation=4, classes="pa-6 flex-grow-1 landing-card"):
129
+ vuetify3.VIcon("mdi-radar", size=52, color="primary", classes="mb-4")
130
+ vuetify3.VCardTitle("Electromagnetic Scattering", classes="text-h5 mb-2")
131
+ vuetify3.VCardText(
132
+ "Simulate electromagnetic wave scattering using quantum algorithms. "
133
+ "Configure geometry, excitation, and visualize field propagation.",
134
+ classes="text-body-2 mb-6",
135
+ )
136
+ vuetify3.VBtn(
137
+ text="Launch EM",
138
+ color="primary",
139
+ block=True,
140
+ prepend_icon="mdi-play-circle",
141
+ size="large",
142
+ click="current_page = 'EM'",
143
+ )
144
+
145
+ # QLBM Card
146
+ with vuetify3.VCol(cols=12, md=5, class_="d-flex"):
147
+ with vuetify3.VCard(elevation=4, classes="pa-6 flex-grow-1 landing-card"):
148
+ vuetify3.VIcon("mdi-water", size=52, color="secondary", classes="mb-4")
149
+ vuetify3.VCardTitle("Quantum Lattice Boltzmann", classes="text-h5 mb-2")
150
+ vuetify3.VCardText(
151
+ "3D fluid simulation using Quantum Lattice Boltzmann Method. "
152
+ "Explore advection-diffusion with quantum-enhanced computation.",
153
+ classes="text-body-2 mb-6",
154
+ )
155
+ vuetify3.VBtn(
156
+ text="Launch QLBM",
157
+ color="secondary",
158
+ block=True,
159
+ prepend_icon="mdi-play-circle",
160
+ size="large",
161
+ click="current_page = 'QLBM'",
162
+ )
163
+
164
+ # === EM Experience ===
165
+ with vuetify3.VContainer(
166
+ v_if="current_page === 'EM'",
167
+ fluid=True,
168
+ classes="pa-0 fill-height",
169
+ ):
170
+ em_embedded.build_ui()
171
+
172
+ # === QLBM Experience ===
173
+ with vuetify3.VContainer(
174
+ v_if="current_page === 'QLBM'",
175
+ fluid=True,
176
+ classes="pa-0 fill-height",
177
+ ):
178
+ qlbm_embedded.build_ui()
179
+
180
+ # --- Heartbeat for HuggingFace ---
181
+ def _start_hf_heartbeat_thread(interval_s: int = 5):
182
+ """Keep the WebSocket alive for HuggingFace Spaces."""
183
+ def _loop():
184
+ while True:
185
+ time.sleep(interval_s)
186
+ try:
187
+ server.controller.flush()
188
+ except Exception:
189
+ break
190
+
191
+ t = threading.Thread(target=_loop, daemon=True)
192
+ t.start()
193
+
194
+ # --- Entry Point ---
195
+ if __name__ == "__main__":
196
+ # Start heartbeat
197
+ _start_hf_heartbeat_thread(interval_s=5)
198
+
199
+ # Get port from environment (HuggingFace) or use default
200
+ base_port = int(os.environ.get("APP_PORT") or os.environ.get("PORT", 7860))
201
+ host = os.environ.get("APP_HOST", "0.0.0.0")
202
+ max_attempts = 10
203
+
204
+ print(f"Starting Quantum Applications server on {host}:{base_port}")
205
+ print("This is a SINGLE SERVER serving both EM and QLBM experiences.")
206
+
207
+ for attempt in range(max_attempts):
208
+ port = base_port + attempt
209
+ try:
210
+ server.start(host=host, port=port, open_browser=False)
211
+ break
212
+ except OSError as exc:
213
+ if getattr(exc, "errno", None) == errno.EADDRINUSE and attempt < max_attempts - 1:
214
+ print(f"Port {port} busy, retrying on port {port + 1}...")
215
+ continue
216
+ raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app_old.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ # Ensure OMP_NUM_THREADS is set to avoid libgomp errors
3
+ os.environ["OMP_NUM_THREADS"] = "1"
4
+
5
+ from trame.app import get_server
6
+ from trame_vuetify.ui.vuetify3 import SinglePageLayout
7
+ from trame_vuetify.widgets import vuetify3
8
+ from trame.widgets import html as trame_html
9
+ import threading
10
+ from concurrent.futures import ThreadPoolExecutor
11
+ import time
12
+
13
+ # Import embedded page wrappers (lazy load inside tabs)
14
+ from importlib import import_module
15
+ em_page = None
16
+ qlbm_page = None
17
+
18
+ # Force embedded mode for nested pages (avoid iframes/secondary servers)
19
+ os.environ.setdefault("TRAME_EMBEDDED", "1")
20
+ os.environ.setdefault("TRAME_DISABLE_BROWSER", "1")
21
+ os.environ.setdefault("EM_APP_EMBEDDED", "1")
22
+ os.environ.setdefault("DISABLE_EM_STANDALONE", "1")
23
+
24
+ # Create a single server for the multipage app
25
+ server = get_server()
26
+ state, ctrl = server.state, server.controller
27
+
28
+ # App state: landing chooser -> specific experience
29
+ state.current_page = None # "EM" or "QLBM" once selected
30
+
31
+ # -------------------------------------------------------------------------
32
+ # Thread pool for long-running jobs to avoid UI freezing by HF timeout
33
+ # -------------------------------------------------------------------------
34
+ MAX_WORKERS = int(os.environ.get("MAX_WORKERS", "4"))
35
+ executor = ThreadPoolExecutor(max_workers=MAX_WORKERS)
36
+
37
+ def submit_background(fn, *args, **kwargs):
38
+ """
39
+ Run a CPU-heavy / long job without blocking Trame's event loop.
40
+
41
+ Usage from pages:
42
+ from trame.app import get_server
43
+ server = get_server()
44
+ server.submit_background(long_fn, arg1, arg2, kw1=...)
45
+ """
46
+ return executor.submit(fn, *args, **kwargs)
47
+
48
+ # Expose helper on the server so pages.em_page / pages.qlbm_page can use it
49
+ server.submit_background = submit_background
50
+
51
+ # Load Synopsys/Ansys logo as data URI for main toolbar
52
+ import base64
53
+
54
+ def _load_logo_data_uri():
55
+ base_dir = os.path.dirname(__file__)
56
+ candidates = [
57
+ os.path.join(base_dir, "ansys-part-of-synopsys-logo.svg"),
58
+ os.path.join(base_dir, "synopsys-logo-color-rgb.svg"),
59
+ os.path.join(base_dir, "synopsys-logo-color-rgb.png"),
60
+ os.path.join(base_dir, "synopsys-logo-color-rgb.jpg"),
61
+ ]
62
+ for p in candidates:
63
+ if os.path.exists(p):
64
+ ext = os.path.splitext(p)[1].lower()
65
+ mime = "image/svg+xml" if ext == ".svg" else ("image/png" if ext == ".png" else "image/jpeg")
66
+ with open(p, "rb") as f:
67
+ b64 = base64.b64encode(f.read()).decode("ascii")
68
+ return f"data:{mime};base64,{b64}"
69
+ return None
70
+
71
+ def _stop_subapp(module_name: str, attr_name: str):
72
+ module = globals().get(attr_name)
73
+ if module is None:
74
+ try:
75
+ module = import_module(module_name)
76
+ globals()[attr_name] = module
77
+ except Exception:
78
+ return
79
+ stop_fn = getattr(module, "stop", None)
80
+ if callable(stop_fn):
81
+ try:
82
+ stop_fn()
83
+ except Exception:
84
+ pass
85
+
86
+
87
+ # Safely initialize logo in state (trame state isn't a dict; avoid .get())
88
+ try:
89
+ if not hasattr(state, "logo_src") or state.logo_src in (None, ""):
90
+ state.logo_src = _load_logo_data_uri()
91
+ except Exception:
92
+ state.logo_src = _load_logo_data_uri()
93
+
94
+
95
+ @state.change("current_page")
96
+ def _handle_page_change(current_page, **_):
97
+ """Stop inactive subprocesses as users navigate between experiences."""
98
+ if current_page == "EM":
99
+ _stop_subapp("pages.qlbm_page", "qlbm_page")
100
+ elif current_page == "QLBM":
101
+ _stop_subapp("pages.em_page", "em_page")
102
+ else:
103
+ _stop_subapp("pages.em_page", "em_page")
104
+ _stop_subapp("pages.qlbm_page", "qlbm_page")
105
+
106
+ with SinglePageLayout(server) as layout:
107
+ layout.title.set_text("Quantum Applications")
108
+ layout.toolbar.classes = "pl-2 pr-1 py-1 elevation-0"
109
+ layout.toolbar.style = "background-color: #ffffff; border-bottom: 3px solid #5f259f;"
110
+
111
+ with layout.toolbar:
112
+ vuetify3.VSpacer()
113
+ vuetify3.VBtn(
114
+ v_if="current_page",
115
+ text="Main Page",
116
+ variant="text",
117
+ color="primary",
118
+ prepend_icon="mdi-arrow-left",
119
+ click="current_page = null",
120
+ classes="mr-2",
121
+ )
122
+ vuetify3.VChip(
123
+ v_if="current_page",
124
+ label=True,
125
+ color="primary",
126
+ text_color="white",
127
+ children=["{{ current_page === 'EM' ? 'Electromagnetic Scattering' : 'Quantum LBM' }}"],
128
+ classes="mr-2",
129
+ )
130
+ vuetify3.VImg(
131
+ v_if="logo_src",
132
+ src=("logo_src", None),
133
+ style="height: 40px; width: auto;",
134
+ classes="ml-2",
135
+ )
136
+
137
+ with layout.content:
138
+ # Landing screen
139
+ with vuetify3.VContainer(
140
+ v_if="!current_page",
141
+ fluid=True,
142
+ classes="fill-height d-flex align-center justify-center pa-6",
143
+ ):
144
+ with vuetify3.VSheet(
145
+ elevation=6,
146
+ rounded=True,
147
+ style="max-width: 1080px; width: 100%; background: linear-gradient(135deg, #fdfbff, #f3ecff);",
148
+ classes="pa-8",
149
+ ):
150
+ vuetify3.VCardTitle(
151
+ "Pick a quantum experience",
152
+ classes="text-h4 text-primary font-weight-bold mb-2 text-center",
153
+ )
154
+ vuetify3.VCardSubtitle(
155
+ "Choose one workflow. We'll spin up only that server until you switch back.",
156
+ classes="text-body-1 text-center mb-6",
157
+ )
158
+ with vuetify3.VRow(justify="center", align="stretch", class_="text-left"):
159
+ with vuetify3.VCol(cols=12, md=5, class_="d-flex"):
160
+ with vuetify3.VCard(elevation=4, classes="pa-6 flex-grow-1"):
161
+ vuetify3.VIcon("mdi-radar", size=52, color="primary", classes="mb-4")
162
+ vuetify3.VCardTitle("Electromagnetic Scattering", classes="text-h5 mb-2")
163
+ vuetify3.VCardText(
164
+ "Placeholder",
165
+ classes="text-body-2 mb-6",
166
+ )
167
+ vuetify3.VBtn(
168
+ text="Launch EM",
169
+ color="primary",
170
+ block=True,
171
+ prepend_icon="mdi-play-circle",
172
+ size="large",
173
+ click="current_page = 'EM'",
174
+ )
175
+ with vuetify3.VCol(cols=12, md=5, class_="d-flex"):
176
+ with vuetify3.VCard(elevation=4, classes="pa-6 flex-grow-1"):
177
+ vuetify3.VIcon("mdi-water", size=52, color="secondary", classes="mb-4")
178
+ vuetify3.VCardTitle("Fluids", classes="text-h5 mb-2")
179
+ vuetify3.VCardText(
180
+ "Placeholder",
181
+ classes="text-body-2 mb-6",
182
+ )
183
+ vuetify3.VBtn(
184
+ text="Launch QLBM",
185
+ color="secondary",
186
+ block=True,
187
+ prepend_icon="mdi-play-circle",
188
+ size="large",
189
+ click="current_page = 'QLBM'",
190
+ )
191
+
192
+ # EM experience
193
+ with vuetify3.VContainer(v_if="current_page === 'EM'", fluid=True, classes="pa-0 fill-height"):
194
+ try:
195
+ if not globals().get("em_page"):
196
+ globals()["em_page"] = import_module("pages.em_page")
197
+ em_page.build(server)
198
+ except Exception as e:
199
+ trame_html.Div(f"EM embed failed: {e}", style="padding:8px;color:#b00020;")
200
+
201
+ # QLBM experience
202
+ with vuetify3.VContainer(v_if="current_page === 'QLBM'", fluid=True, classes="pa-0 fill-height"):
203
+ try:
204
+ if not globals().get("qlbm_page"):
205
+ globals()["qlbm_page"] = import_module("pages.qlbm_page")
206
+ qlbm_page.build(server)
207
+ except Exception as e:
208
+ trame_html.Div(f"QLBM embed failed: {e}", style="padding:8px;color:#b00020;")
209
+
210
+ # -------------------------------------------------------------------------
211
+ # Heartbeat: keep HuggingFace WebSocket alive during idle periods
212
+ # -------------------------------------------------------------------------
213
+ def _start_hf_heartbeat_thread(interval_s: int = 5):
214
+ """
215
+ Start a background thread that periodically flushes the server,
216
+ keeping the WebSocket "active" in the eyes of Hugging Face.
217
+ """
218
+ def _loop():
219
+ while True:
220
+ time.sleep(interval_s)
221
+ try:
222
+ server.controller.flush()
223
+ except Exception:
224
+ # If server is shutting down or flush fails, exit the thread
225
+ break
226
+
227
+ t = threading.Thread(target=_loop, daemon=True)
228
+ t.start()
229
+
230
+
231
+ if __name__ == "__main__":
232
+
233
+ # Start HF heartbeat to prevent timeout
234
+ _start_hf_heartbeat_thread(interval_s=5)
235
+
236
+ # Allow reverse-proxy setups to pin the internal host/port independently from the public PORT
237
+ port = int(os.environ.get("APP_PORT") or os.environ.get("PORT", 7860))
238
+ host = os.environ.get("APP_HOST", "0.0.0.0")
239
+ server.start(host=host, port=port, open_browser=False)
em_embedded.py ADDED
The diff for this file is too large to render. See raw diff
 
pages/em_page.py CHANGED
@@ -1,70 +1,43 @@
 
 
 
 
 
 
1
  from trame_vuetify.widgets import vuetify3
2
  from trame.widgets import html as trame_html
3
  import os
4
- import subprocess
5
  import sys
6
- import atexit
7
-
8
- # Keep a single child process for the EM app
9
- _em_proc = None
10
- _EM_HOST = os.environ.get("EM_HOST", "127.0.0.1")
11
- _EM_IFRAME_SRC = os.environ.get("EM_IFRAME_SRC", "").strip()
12
 
 
 
13
 
14
- def _kill_em_process():
15
- """Ensure any running EM process is terminated."""
16
- global _em_proc
17
- if _em_proc and _em_proc.poll() is None:
18
- try:
19
- _em_proc.terminate()
20
- _em_proc.wait(timeout=2)
21
- except Exception:
22
- try:
23
- _em_proc.kill()
24
- except Exception:
25
- pass
26
- _em_proc = None
27
 
28
 
29
- def _ensure_em_process_started():
30
- global _em_proc
31
- # Check if process is still running
32
- if (_em_proc and _em_proc.poll() is None):
 
 
 
 
33
  return
34
 
35
- # Kill any stale process first
36
- _kill_em_process()
 
37
 
38
- base_dir = os.path.dirname(os.path.dirname(__file__))
39
- em_path = os.path.join(base_dir, "em_trame.py")
40
- env = os.environ.copy()
41
- # Prevent hosted platforms from forcing the subprocess to bind to $PORT
42
- env.pop("PORT", None)
43
- env.pop("HF_PORT", None)
44
- # Port used by iframe
45
- env.setdefault("EM_APP_PORT", env.get("PORT_EM", "8701"))
46
- env.setdefault("EM_HOST", _EM_HOST)
47
- # Start em_trame.py in a separate process
48
- python_exe = sys.executable or "python"
49
- _em_proc = subprocess.Popen([python_exe, em_path], cwd=base_dir, env=env)
50
 
51
 
52
- # Register cleanup on exit
53
- atexit.register(_kill_em_process)
54
-
55
-
56
- def build(server):
57
- """Render the EM app via iframe and ensure its process is running."""
58
- _ensure_em_process_started()
59
- port = os.environ.get("EM_APP_PORT", os.environ.get("PORT_EM", "8701"))
60
- host = os.environ.get("EM_HOST", _EM_HOST)
61
- iframe_src = _EM_IFRAME_SRC or f"http://{host}:{port}/"
62
- with vuetify3.VContainer(fluid=True, classes="pa-0 fill-height"):
63
- trame_html.Iframe(
64
- src=("em_iframe_src", iframe_src),
65
- style="border:0; width:100%; height: calc(100vh - 64px);",
66
- )
67
- trame_html.Div(
68
- "If the EM page is blank, wait a few seconds for the subprocess to start.",
69
- style="color: rgba(0,0,0,.6); padding: 6px;",
70
- )
 
1
+ """
2
+ EM Page - Embedded Mode Wrapper
3
+
4
+ This module provides the EM experience for the unified app.
5
+ It uses the embedded module instead of spawning a subprocess.
6
+ """
7
  from trame_vuetify.widgets import vuetify3
8
  from trame.widgets import html as trame_html
9
  import os
 
10
  import sys
 
 
 
 
 
 
11
 
12
+ # Add parent directory to path for imports
13
+ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
14
 
15
+ try:
16
+ import em_embedded
17
+ _EM_AVAILABLE = True
18
+ except ImportError as e:
19
+ _EM_AVAILABLE = False
20
+ _EM_ERROR = str(e)
 
 
 
 
 
 
 
21
 
22
 
23
+ def build(server):
24
+ """Build the EM UI using the embedded module."""
25
+ if not _EM_AVAILABLE:
26
+ with vuetify3.VContainer(fluid=True, classes="pa-4"):
27
+ trame_html.Div(
28
+ f"EM module failed to load: {_EM_ERROR}",
29
+ style="color: #b00020; padding: 12px;"
30
+ )
31
  return
32
 
33
+ # Set the server and initialize state
34
+ em_embedded.set_server(server)
35
+ em_embedded.init_state()
36
 
37
+ # Build the UI
38
+ em_embedded.build_ui()
 
 
 
 
 
 
 
 
 
 
39
 
40
 
41
+ def stop():
42
+ """Stop any running EM processes (no-op in embedded mode)."""
43
+ pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pages/em_page_subprocess.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from trame_vuetify.widgets import vuetify3
2
+ from trame.widgets import html as trame_html
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ import atexit
7
+
8
+ # Keep a single child process for the EM app
9
+ _em_proc = None
10
+ _EM_HOST = os.environ.get("EM_HOST", "127.0.0.1")
11
+ _EM_IFRAME_SRC = os.environ.get("EM_IFRAME_SRC", "").strip()
12
+
13
+
14
+ def _kill_em_process():
15
+ """Ensure any running EM process is terminated."""
16
+ global _em_proc
17
+ if _em_proc and _em_proc.poll() is None:
18
+ try:
19
+ _em_proc.terminate()
20
+ _em_proc.wait(timeout=2)
21
+ except Exception:
22
+ try:
23
+ _em_proc.kill()
24
+ except Exception:
25
+ pass
26
+ _em_proc = None
27
+
28
+
29
+ def _ensure_em_process_started():
30
+ global _em_proc
31
+ # Check if process is still running
32
+ if (_em_proc and _em_proc.poll() is None):
33
+ return
34
+
35
+ # Kill any stale process first
36
+ _kill_em_process()
37
+
38
+ base_dir = os.path.dirname(os.path.dirname(__file__))
39
+ em_path = os.path.join(base_dir, "em_trame.py")
40
+ env = os.environ.copy()
41
+ # Prevent hosted platforms from forcing the subprocess to bind to $PORT
42
+ env.pop("PORT", None)
43
+ env.pop("HF_PORT", None)
44
+ # Port used by iframe
45
+ env.setdefault("EM_APP_PORT", env.get("PORT_EM", "8701"))
46
+ env.setdefault("EM_HOST", _EM_HOST)
47
+ # Start em_trame.py in a separate process
48
+ python_exe = sys.executable or "python"
49
+ _em_proc = subprocess.Popen([python_exe, em_path], cwd=base_dir, env=env)
50
+
51
+
52
+ # Register cleanup on exit
53
+ atexit.register(_kill_em_process)
54
+
55
+
56
+ def build(server):
57
+ """Render the EM app via iframe and ensure its process is running."""
58
+ _ensure_em_process_started()
59
+ port = os.environ.get("EM_APP_PORT", os.environ.get("PORT_EM", "8701"))
60
+ host = os.environ.get("EM_HOST", _EM_HOST)
61
+ iframe_src = _EM_IFRAME_SRC or f"http://{host}:{port}/"
62
+ with vuetify3.VContainer(fluid=True, classes="pa-0 fill-height"):
63
+ trame_html.Iframe(
64
+ src=("em_iframe_src", iframe_src),
65
+ style="border:0; width:100%; height: calc(100vh - 64px);",
66
+ )
67
+ trame_html.Div(
68
+ "If the EM page is blank, wait a few seconds for the subprocess to start.",
69
+ style="color: rgba(0,0,0,.6); padding: 6px;",
70
+ )
pages/qlbm_page.py CHANGED
@@ -1,128 +1,43 @@
1
- """Embedded QLBM fluids page wrapper.
2
- Starts the standalone qlbm.py server in a background subprocess so the main
3
- multi-page app only needs `python app.py`.
4
 
5
- Environment variables:
6
- QLBM_APP_PORT / PORT_QLBM -> port (default 8702)
7
- QLBM_HOST -> host interface (default 127.0.0.1)
8
  """
9
- from __future__ import annotations
10
- import os, sys, subprocess, atexit
11
  from trame_vuetify.widgets import vuetify3
12
  from trame.widgets import html as trame_html
13
- import shutil
14
-
15
- _gpu_status = {
16
- "checked": False,
17
- "available": False,
18
- "reason": ""
19
- }
20
-
21
-
22
- def _detect_gpu_availability(force_recheck=False):
23
- """Best-effort CUDA GPU detection for hosted environments."""
24
- global _gpu_status
25
- if _gpu_status["checked"] and not force_recheck:
26
- return _gpu_status["available"], _gpu_status["reason"]
27
-
28
- allow_missing = os.environ.get("QLBM_IGNORE_GPU_CHECK", "0") == "1"
29
- if allow_missing:
30
- _gpu_status.update({"checked": True, "available": True, "reason": ""})
31
- return True, ""
32
-
33
- def _has_devices(val: str | None) -> bool:
34
- if not val:
35
- return False
36
- lowered = val.strip().lower()
37
- return lowered not in ("", "none", "nodevfiles", "-1")
38
-
39
- nvidia_devices = os.environ.get("NVIDIA_VISIBLE_DEVICES")
40
- cuda_devices = os.environ.get("CUDA_VISIBLE_DEVICES")
41
- saw_env_nvidia = _has_devices(nvidia_devices)
42
- saw_env_cuda = _has_devices(cuda_devices)
43
 
44
- # Heuristics: presence of device files or nvidia-smi
45
- has_proc_entry = os.path.exists("/proc/driver/nvidia/version")
46
- has_nvidia_smi = shutil.which("nvidia-smi") is not None
47
- has_gpu = (saw_env_nvidia or saw_env_cuda or has_proc_entry or has_nvidia_smi)
48
 
49
- if has_gpu:
50
- _gpu_status.update({"checked": True, "available": True, "reason": ""})
51
- return True, ""
 
 
 
52
 
53
- reason = "No NVIDIA GPU detected (CUDA_VISIBLE_DEVICES/NVIDIA_VISIBLE_DEVICES unset and no nvidia drivers present)."
54
- _gpu_status.update({"checked": True, "available": False, "reason": reason})
55
- return False, reason
56
 
57
- _qlbm_proc = None
58
- _QLBM_HOST = os.environ.get("QLBM_HOST", "127.0.0.1")
59
- _QLBM_IFRAME_SRC = os.environ.get("QLBM_IFRAME_SRC", "").strip()
60
-
61
-
62
- def _kill_qlbm_process():
63
- global _qlbm_proc
64
- if _qlbm_proc and _qlbm_proc.poll() is None:
65
- try:
66
- _qlbm_proc.terminate()
67
- _qlbm_proc.wait(timeout=2)
68
- except Exception:
69
- try:
70
- _qlbm_proc.kill()
71
- except Exception:
72
- pass
73
- _qlbm_proc = None
74
-
75
-
76
- def _ensure_qlbm_process_started():
77
- global _qlbm_proc
78
- if _qlbm_proc and _qlbm_proc.poll() is None:
79
- return
80
- _kill_qlbm_process()
81
- base_dir = os.path.dirname(os.path.dirname(__file__))
82
- qlbm_path = os.path.join(base_dir, "qlbm.py")
83
- env = os.environ.copy()
84
- env.pop("PORT", None)
85
- env.pop("HF_PORT", None)
86
- env.setdefault("QLBM_APP_PORT", env.get("PORT_QLBM", "8702"))
87
- env.setdefault("QLBM_HOST", _QLBM_HOST)
88
- py = sys.executable or "python"
89
- _qlbm_proc = subprocess.Popen([py, qlbm_path], cwd=base_dir, env=env)
90
-
91
-
92
- atexit.register(_kill_qlbm_process)
93
-
94
-
95
- def build(server): # signature matches app.py expectation
96
- if os.environ.get("DISABLE_SUBAPPS", "").strip() == "1":
97
- with vuetify3.VContainer(fluid=True, classes="pa-0 fill-height"):
98
- trame_html.Div(
99
- "This tab is disabled in single-port environments. Please run locally to enable the QLBM view.",
100
- style="padding:12px;color:#555;",
101
- )
102
- return
103
- gpu_ok, gpu_reason = _detect_gpu_availability()
104
- if not gpu_ok:
105
- with vuetify3.VContainer(fluid=True, classes="pa-0 fill-height"):
106
  trame_html.Div(
107
- "QLBM requires an NVIDIA CUDA-capable GPU, which is not available in this environment.",
108
- style="padding:12px;color:#b00020;font-weight:600;",
109
- )
110
- trame_html.Div(
111
- f"Details: {gpu_reason} Run the FLUIDS tab locally with a GPU or set QLBM_IGNORE_GPU_CHECK=1 to bypass this guard.",
112
- style="padding:8px 12px;color:#555;",
113
  )
114
  return
115
- _ensure_qlbm_process_started()
116
- port = os.environ.get("QLBM_APP_PORT", os.environ.get("PORT_QLBM", "8702"))
117
- host = os.environ.get("QLBM_HOST", _QLBM_HOST)
118
- iframe_src = _QLBM_IFRAME_SRC or f"http://{host}:{port}/"
119
- with vuetify3.VContainer(fluid=True, classes="pa-0 fill-height"):
120
- trame_html.Iframe(
121
- src=("qlbm_iframe_src", iframe_src),
122
- style="border:0;width:100%;height:100%;min-height:0;",
123
- )
124
- trame_html.Div(
125
- "If the QLBM view is blank, wait a few seconds for the subprocess to start.",
126
- style="color:rgba(0,0,0,.6);padding:6px;",
127
- )
128
-
 
1
+ """
2
+ QLBM Page - Embedded Mode Wrapper
 
3
 
4
+ This module provides the QLBM experience for the unified app.
5
+ It uses the embedded module instead of spawning a subprocess.
 
6
  """
 
 
7
  from trame_vuetify.widgets import vuetify3
8
  from trame.widgets import html as trame_html
9
+ import os
10
+ import sys
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
+ # Add parent directory to path for imports
13
+ sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
 
 
14
 
15
+ try:
16
+ import qlbm_embedded
17
+ _QLBM_AVAILABLE = True
18
+ except ImportError as e:
19
+ _QLBM_AVAILABLE = False
20
+ _QLBM_ERROR = str(e)
21
 
 
 
 
22
 
23
+ def build(server):
24
+ """Build the QLBM UI using the embedded module."""
25
+ if not _QLBM_AVAILABLE:
26
+ with vuetify3.VContainer(fluid=True, classes="pa-4"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  trame_html.Div(
28
+ f"QLBM module failed to load: {_QLBM_ERROR}",
29
+ style="color: #b00020; padding: 12px;"
 
 
 
 
30
  )
31
  return
32
+
33
+ # Set the server and initialize state
34
+ qlbm_embedded.set_server(server)
35
+ qlbm_embedded.init_state()
36
+
37
+ # Build the UI
38
+ qlbm_embedded.build_ui()
39
+
40
+
41
+ def stop():
42
+ """Stop any running QLBM processes (no-op in embedded mode)."""
43
+ pass
 
 
pages/qlbm_page_subprocess.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Embedded QLBM fluids page wrapper.
2
+ Starts the standalone qlbm.py server in a background subprocess so the main
3
+ multi-page app only needs `python app.py`.
4
+
5
+ Environment variables:
6
+ QLBM_APP_PORT / PORT_QLBM -> port (default 8702)
7
+ QLBM_HOST -> host interface (default 127.0.0.1)
8
+ """
9
+ from __future__ import annotations
10
+ import os, sys, subprocess, atexit
11
+ from trame_vuetify.widgets import vuetify3
12
+ from trame.widgets import html as trame_html
13
+ import shutil
14
+
15
+ _gpu_status = {
16
+ "checked": False,
17
+ "available": False,
18
+ "reason": ""
19
+ }
20
+
21
+
22
+ def _detect_gpu_availability(force_recheck=False):
23
+ """Best-effort CUDA GPU detection for hosted environments."""
24
+ global _gpu_status
25
+ if _gpu_status["checked"] and not force_recheck:
26
+ return _gpu_status["available"], _gpu_status["reason"]
27
+
28
+ allow_missing = os.environ.get("QLBM_IGNORE_GPU_CHECK", "0") == "1"
29
+ if allow_missing:
30
+ _gpu_status.update({"checked": True, "available": True, "reason": ""})
31
+ return True, ""
32
+
33
+ def _has_devices(val: str | None) -> bool:
34
+ if not val:
35
+ return False
36
+ lowered = val.strip().lower()
37
+ return lowered not in ("", "none", "nodevfiles", "-1")
38
+
39
+ nvidia_devices = os.environ.get("NVIDIA_VISIBLE_DEVICES")
40
+ cuda_devices = os.environ.get("CUDA_VISIBLE_DEVICES")
41
+ saw_env_nvidia = _has_devices(nvidia_devices)
42
+ saw_env_cuda = _has_devices(cuda_devices)
43
+
44
+ # Heuristics: presence of device files or nvidia-smi
45
+ has_proc_entry = os.path.exists("/proc/driver/nvidia/version")
46
+ has_nvidia_smi = shutil.which("nvidia-smi") is not None
47
+ has_gpu = (saw_env_nvidia or saw_env_cuda or has_proc_entry or has_nvidia_smi)
48
+
49
+ if has_gpu:
50
+ _gpu_status.update({"checked": True, "available": True, "reason": ""})
51
+ return True, ""
52
+
53
+ reason = "No NVIDIA GPU detected (CUDA_VISIBLE_DEVICES/NVIDIA_VISIBLE_DEVICES unset and no nvidia drivers present)."
54
+ _gpu_status.update({"checked": True, "available": False, "reason": reason})
55
+ return False, reason
56
+
57
+ _qlbm_proc = None
58
+ _QLBM_HOST = os.environ.get("QLBM_HOST", "127.0.0.1")
59
+ _QLBM_IFRAME_SRC = os.environ.get("QLBM_IFRAME_SRC", "").strip()
60
+
61
+
62
+ def _kill_qlbm_process():
63
+ global _qlbm_proc
64
+ if _qlbm_proc and _qlbm_proc.poll() is None:
65
+ try:
66
+ _qlbm_proc.terminate()
67
+ _qlbm_proc.wait(timeout=2)
68
+ except Exception:
69
+ try:
70
+ _qlbm_proc.kill()
71
+ except Exception:
72
+ pass
73
+ _qlbm_proc = None
74
+
75
+
76
+ def _ensure_qlbm_process_started():
77
+ global _qlbm_proc
78
+ if _qlbm_proc and _qlbm_proc.poll() is None:
79
+ return
80
+ _kill_qlbm_process()
81
+ base_dir = os.path.dirname(os.path.dirname(__file__))
82
+ qlbm_path = os.path.join(base_dir, "qlbm.py")
83
+ env = os.environ.copy()
84
+ env.pop("PORT", None)
85
+ env.pop("HF_PORT", None)
86
+ env.setdefault("QLBM_APP_PORT", env.get("PORT_QLBM", "8702"))
87
+ env.setdefault("QLBM_HOST", _QLBM_HOST)
88
+ py = sys.executable or "python"
89
+ _qlbm_proc = subprocess.Popen([py, qlbm_path], cwd=base_dir, env=env)
90
+
91
+
92
+ atexit.register(_kill_qlbm_process)
93
+
94
+
95
+ def build(server): # signature matches app.py expectation
96
+ if os.environ.get("DISABLE_SUBAPPS", "").strip() == "1":
97
+ with vuetify3.VContainer(fluid=True, classes="pa-0 fill-height"):
98
+ trame_html.Div(
99
+ "This tab is disabled in single-port environments. Please run locally to enable the QLBM view.",
100
+ style="padding:12px;color:#555;",
101
+ )
102
+ return
103
+ gpu_ok, gpu_reason = _detect_gpu_availability()
104
+ if not gpu_ok:
105
+ with vuetify3.VContainer(fluid=True, classes="pa-0 fill-height"):
106
+ trame_html.Div(
107
+ "QLBM requires an NVIDIA CUDA-capable GPU, which is not available in this environment.",
108
+ style="padding:12px;color:#b00020;font-weight:600;",
109
+ )
110
+ trame_html.Div(
111
+ f"Details: {gpu_reason} Run the FLUIDS tab locally with a GPU or set QLBM_IGNORE_GPU_CHECK=1 to bypass this guard.",
112
+ style="padding:8px 12px;color:#555;",
113
+ )
114
+ return
115
+ _ensure_qlbm_process_started()
116
+ port = os.environ.get("QLBM_APP_PORT", os.environ.get("PORT_QLBM", "8702"))
117
+ host = os.environ.get("QLBM_HOST", _QLBM_HOST)
118
+ iframe_src = _QLBM_IFRAME_SRC or f"http://{host}:{port}/"
119
+ with vuetify3.VContainer(fluid=True, classes="pa-0 fill-height"):
120
+ trame_html.Iframe(
121
+ src=("qlbm_iframe_src", iframe_src),
122
+ style="border:0;width:100%;height:100%;min-height:0;",
123
+ )
124
+ trame_html.Div(
125
+ "If the QLBM view is blank, wait a few seconds for the subprocess to start.",
126
+ style="color:rgba(0,0,0,.6);padding:6px;",
127
+ )
128
+
qlbm_embedded.py ADDED
@@ -0,0 +1,1472 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ QLBM Embedded Mode Module
3
+
4
+ This module provides functions to build the QLBM UI into an existing Trame server,
5
+ enabling single-server architecture for the unified app.
6
+
7
+ Contains ALL features from qlbm.py but designed for embedded use.
8
+ """
9
+ import os
10
+ import numpy as np
11
+ import math
12
+ import pyvista as pv
13
+ import plotly.graph_objects as go
14
+ import tempfile
15
+ from pathlib import Path
16
+ from datetime import datetime
17
+ from trame_vuetify.widgets import vuetify3
18
+ from trame.widgets import html
19
+ from trame_plotly.widgets import plotly as plotly_widgets
20
+ from pyvista.trame.ui import plotter_ui
21
+
22
+ # Set offscreen before pyvista usage
23
+ pv.OFF_SCREEN = True
24
+
25
+ # --- Backend Detection ---
26
+ def _env_flag(name: str) -> bool:
27
+ return os.environ.get(name, "").strip().lower() in ("1", "true", "yes")
28
+
29
+ def _should_disable_quantum_backend() -> str | None:
30
+ """Return a reason string if quantum backend should be disabled, else None."""
31
+ if _env_flag("FORCE_CPU_DEMO"):
32
+ return "FORCE_CPU_DEMO environment variable is set"
33
+ if _env_flag("HUGGINGFACE_SPACE") or os.environ.get("SPACE_ID"):
34
+ return "Hugging Face Spaces detected (no GPU runtime)"
35
+ return None
36
+
37
+ _disable_reason = _should_disable_quantum_backend()
38
+ simulate_qlbm_3D_and_animate = None
39
+
40
+ if _disable_reason:
41
+ _SIMULATION_BACKEND_READY = False
42
+ _SIMULATION_BACKEND_NOTE = f"CPU demo mode active ({_disable_reason}). Results are approximate."
43
+ _SIMULATION_MODE_LABEL = "CPU demo backend"
44
+ _SIMULATION_DISABLED_REASON = _disable_reason
45
+ else:
46
+ try:
47
+ from fluid3d_pyvista import simulate_qlbm_3D_and_animate
48
+ _SIMULATION_BACKEND_READY = True
49
+ _SIMULATION_BACKEND_NOTE = ""
50
+ _SIMULATION_MODE_LABEL = "Quantum CUDA-Q backend"
51
+ _SIMULATION_DISABLED_REASON = None
52
+ except Exception as exc:
53
+ simulate_qlbm_3D_and_animate = None
54
+ _SIMULATION_BACKEND_READY = False
55
+ _SIMULATION_BACKEND_NOTE = f"CPU demo mode active (import error: {exc}). Results are approximate."
56
+ _SIMULATION_MODE_LABEL = "CPU demo backend"
57
+ _SIMULATION_DISABLED_REASON = str(exc)
58
+
59
+ _SIMULATION_CAN_RUN = True # CPU demo is always available
60
+ _CPU_DEMO_MAX_GRID = 48
61
+
62
+ # Module-level state
63
+ _server = None
64
+ _state = None
65
+ _ctrl = None
66
+ _plotter = None
67
+ _initialized = False
68
+
69
+ # Global simulation data
70
+ simulation_data_frames = []
71
+ simulation_times = []
72
+ current_grid_object = None
73
+
74
+ GRID_SIZES = [8, 16, 32, 64, 128, 256]
75
+ _WORKFLOW_BASE_STYLE = "font-size: 0.8rem; border: 1px solid transparent; transition: box-shadow 0.2s ease;"
76
+ _WORKFLOW_HIGHLIGHT_STYLE = "font-size: 0.8rem; box-shadow: 0 0 0 2px #6200ea;"
77
+ _WORKFLOW_CARD_KEYS = ["overview_card_style", "distribution_card_style", "advect_card_style", "meshing_card_style", "backend_card_style"]
78
+
79
+ _PROBLEM_GEOMETRY_MAP = {
80
+ "Scalar advection-diffusion in a box": "Cube",
81
+ "Laminar flow & heat transfer for a heated body in water.": "Rectangular domain with a heated box (3D)",
82
+ }
83
+
84
+
85
+ def set_server(server):
86
+ """Set the server for embedded mode."""
87
+ global _server, _state, _ctrl
88
+ _server = server
89
+ _state = server.state
90
+ _ctrl = server.controller
91
+
92
+
93
+ def init_state():
94
+ """Initialize QLBM state variables with all features from qlbm.py."""
95
+ global _initialized
96
+ if _initialized or _state is None:
97
+ return
98
+
99
+ _state.update({
100
+ # Console & Status
101
+ "qlbm_console_output": "QLBM Console initialized.\n",
102
+ "qlbm_status_visible": True,
103
+ "qlbm_status_message": "Ready",
104
+ "qlbm_status_type": "info",
105
+ "qlbm_simulation_progress": 0,
106
+ "qlbm_show_progress": False,
107
+
108
+ # Distribution
109
+ "qlbm_dist_modes": ["Sinusoidal", "Gaussian"],
110
+ "qlbm_dist_type": None,
111
+ "qlbm_nx": 32,
112
+ "qlbm_show_edges": False,
113
+ "qlbm_custom_dist_params": False,
114
+
115
+ # Sinusoidal params
116
+ "qlbm_sine_k_x": 1.0,
117
+ "qlbm_sine_k_y": 1.0,
118
+ "qlbm_sine_k_z": 1.0,
119
+
120
+ # Gaussian params
121
+ "qlbm_gauss_cx": 16.0,
122
+ "qlbm_gauss_cy": 16.0,
123
+ "qlbm_gauss_cz": 16.0,
124
+ "qlbm_gauss_sigma": 6.0,
125
+
126
+ # Problem & Geometry
127
+ "qlbm_qlbm_problems": [
128
+ "Scalar advection-diffusion in a box",
129
+ "Laminar flow & heat transfer for a heated body in water.",
130
+ ],
131
+ "qlbm_problems_selection": None,
132
+ "qlbm_geometry_selection": None,
133
+ "qlbm_domain_L": 1.0,
134
+ "qlbm_domain_W": 1.0,
135
+ "qlbm_domain_H": 1.0,
136
+
137
+ # Boundary conditions
138
+ "qlbm_boundary_condition": "Periodic",
139
+
140
+ # Advecting fields
141
+ "qlbm_advecting_field": None,
142
+ "qlbm_show_advect_params": False,
143
+ "qlbm_vx_expr": "0.2",
144
+ "qlbm_vy_expr": "-0.15",
145
+ "qlbm_vz_expr": "0.3",
146
+
147
+ # Meshing
148
+ "qlbm_grid_index": 2, # Index into GRID_SIZES
149
+ "qlbm_grid_size": 32,
150
+ "qlbm_time_steps": 10,
151
+
152
+ # Backend
153
+ "qlbm_backend_type": None,
154
+ "qlbm_selected_simulator": "IBM Qiskit simulator",
155
+ "qlbm_selected_qpu": "IBM QPU",
156
+
157
+ # Simulation state
158
+ "qlbm_is_running": False,
159
+ "qlbm_run_error": "",
160
+ "qlbm_simulation_has_run": False,
161
+ "qlbm_time_val": 0,
162
+ "qlbm_max_time_step": 0,
163
+ "qlbm_time_slider_labels": [],
164
+ "qlbm_current_time_label": "0.0",
165
+
166
+ # Qubit info
167
+ "qlbm_qubit_grid_info": "Grid Size: 32 × 32 × 32",
168
+ "qlbm_qubit_warning": "",
169
+
170
+ # Backend info
171
+ "qlbm_simulation_backend_ready": _SIMULATION_CAN_RUN,
172
+ "qlbm_simulation_backend_note": _SIMULATION_BACKEND_NOTE,
173
+ "qlbm_simulation_backend_mode": _SIMULATION_MODE_LABEL,
174
+
175
+ # Workflow highlighting
176
+ "qlbm_workflow_step": 0,
177
+ "qlbm_overview_card_style": _WORKFLOW_BASE_STYLE,
178
+ "qlbm_distribution_card_style": _WORKFLOW_BASE_STYLE,
179
+ "qlbm_advect_card_style": _WORKFLOW_BASE_STYLE,
180
+ "qlbm_meshing_card_style": _WORKFLOW_BASE_STYLE,
181
+ "qlbm_backend_card_style": _WORKFLOW_BASE_STYLE,
182
+
183
+ # Pick point text
184
+ "qlbm_pick_text": "",
185
+ })
186
+ _initialized = True
187
+
188
+
189
+ def log_to_console(message):
190
+ """Log a message to the QLBM console."""
191
+ if _state is None:
192
+ return
193
+ timestamp = datetime.now().strftime("%H:%M:%S")
194
+ new_line = f"[{timestamp}] {message}\n"
195
+ _state.qlbm_console_output = (_state.qlbm_console_output or "") + new_line
196
+
197
+
198
+ def _set_pick_text(text):
199
+ """Set the pick text for point picking."""
200
+ if _state is not None:
201
+ _state.qlbm_pick_text = text
202
+
203
+
204
+ def _create_plotter():
205
+ """Create and return the PyVista plotter."""
206
+ global _plotter
207
+ if _plotter is None:
208
+ pv.OFF_SCREEN = True
209
+ _plotter = pv.Plotter()
210
+ return _plotter
211
+
212
+
213
+ def _ensure_point_picking(callback):
214
+ """Enable point picking on the plotter."""
215
+ global _plotter
216
+ if _plotter is None:
217
+ return
218
+ try:
219
+ _plotter.enable_point_picking(
220
+ callback=callback,
221
+ show_message=False,
222
+ use_picker=True,
223
+ pickable_window=False,
224
+ show_point=True,
225
+ point_size=12,
226
+ color="red",
227
+ )
228
+ except Exception:
229
+ pass
230
+
231
+
232
+ # --- Workflow Highlighting ---
233
+ def _determine_workflow_step():
234
+ """Determine current workflow step based on state."""
235
+ if _state is None:
236
+ return 0
237
+ if not _state.qlbm_problems_selection:
238
+ return 0
239
+ if not _state.qlbm_dist_type:
240
+ return 1
241
+ if not _state.qlbm_advecting_field:
242
+ return 2
243
+ if not _state.qlbm_backend_type:
244
+ return 4
245
+ return 5
246
+
247
+
248
+ def _apply_workflow_highlights(step):
249
+ """Apply highlighting to the current workflow step card."""
250
+ if _state is None:
251
+ return
252
+ for i, key in enumerate(_WORKFLOW_CARD_KEYS):
253
+ attr = f"qlbm_{key}"
254
+ if hasattr(_state, attr):
255
+ setattr(_state, attr, _WORKFLOW_HIGHLIGHT_STYLE if i == step else _WORKFLOW_BASE_STYLE)
256
+
257
+
258
+ # --- Qubit Info ---
259
+ def update_qubit_3D_info(grid_size: int):
260
+ """Generate qubit requirement plot and info strings."""
261
+ try:
262
+ num_reg_qubits = int(math.log2(grid_size)) if grid_size > 0 else 3
263
+ x = np.array([16, 32, 64, 128, 256])
264
+ y = np.log2(x).astype(int)
265
+ fig = go.Figure()
266
+ fig.add_trace(go.Scatter(x=x, y=y, mode='lines', name='Qubits/Direction', line=dict(color='#7A3DB5', width=3)))
267
+ fig.add_trace(go.Scatter(x=[grid_size], y=[num_reg_qubits], mode='markers',
268
+ marker=dict(size=12, color='red'), name='Current Selection'))
269
+ fig.update_layout(
270
+ xaxis_title="Grid Size (Points/Direction)",
271
+ yaxis_title="Qubits/Direction",
272
+ width=616,
273
+ height=320,
274
+ margin=dict(l=40, r=20, t=20, b=40)
275
+ )
276
+ grid_display = f"Grid Size: {grid_size} × {grid_size} × {grid_size}"
277
+ warning = ""
278
+ if grid_size > 64:
279
+ warning = "⚠️ Warning: Grid sizes > 64 may exceed simulator/memory limits!"
280
+ elif grid_size > 16 and _state and _state.qlbm_selected_qpu == "IBM QPU" and _state.qlbm_backend_type == "QPU":
281
+ warning = "⚠️ Warning: Grid size > 16 may exceed IBM QPU capacity!"
282
+ return fig, grid_display, warning
283
+ except Exception:
284
+ return go.Figure(), "Grid Size: N/A", ""
285
+
286
+
287
+ # --- Velocity Presets ---
288
+ def set_velocity_preset(preset_name):
289
+ """Map velocity preset buttons to expression triplets."""
290
+ if _state is None:
291
+ return
292
+ mapping = {
293
+ "Uniform": ("0.2", "-0.15", "0.3"),
294
+ "Swirl": ("0.3*sin(-2*pi*z)", "0.2", "0.3*sin(2*pi*x)"),
295
+ "Shear": ("abs(z-0.5)*1.2-0.3", "0", "0"),
296
+ "TGV": ("0.15*cos(2*pi*x)*sin(2*pi*y)*sin(2*pi*z)", "-0.3*sin(2*pi*x)*cos(2*pi*y)*sin(2*pi*z)", "0.15*sin(2*pi*x)*sin(2*pi*y)*cos(2*pi*z)"),
297
+ }
298
+ vx, vy, vz = mapping.get(preset_name, mapping["Uniform"])
299
+ _state.qlbm_advecting_field = preset_name
300
+ _state.qlbm_vx_expr = vx
301
+ _state.qlbm_vy_expr = vy
302
+ _state.qlbm_vz_expr = vz
303
+
304
+
305
+ def make_velocity_func(expr):
306
+ """Convert a string expression into a function of (x, y, z)."""
307
+ def func(x, y, z):
308
+ context = {
309
+ "x": x, "y": y, "z": z,
310
+ "sin": np.sin, "cos": np.cos, "tan": np.tan,
311
+ "pi": np.pi, "abs": np.abs, "exp": np.exp, "sqrt": np.sqrt
312
+ }
313
+ try:
314
+ return eval(str(expr), {"__builtins__": {}}, context)
315
+ except Exception as e:
316
+ print(f"Error evaluating velocity expression '{expr}': {e}")
317
+ return np.zeros_like(x) if isinstance(x, np.ndarray) else 0.0
318
+ return func
319
+
320
+
321
+ def _safe_velocity_sample(func) -> float:
322
+ try:
323
+ val = func(0.5, 0.5, 0.5)
324
+ if isinstance(val, np.ndarray):
325
+ val = float(np.mean(val))
326
+ return float(val)
327
+ except Exception:
328
+ return 0.0
329
+
330
+
331
+ def build_ui():
332
+ """Build the QLBM UI into the current Trame context."""
333
+ if _state is None:
334
+ raise RuntimeError("Server not set. Call set_server() first.")
335
+
336
+ init_state()
337
+ plotter = _create_plotter()
338
+
339
+ # Register state change handlers
340
+ _register_handlers()
341
+
342
+ # Apply initial CSS
343
+ html.Style("""
344
+ :root{ --v-theme-primary:95,37,159; }
345
+ .example-img{ max-width:100%; border-radius:4px; }
346
+ .warn-text{ color:#b71c1c; font-size:0.85rem; }
347
+ """)
348
+
349
+ # Build the UI
350
+ with vuetify3.VContainer(fluid=True, classes="pa-0 fill-height"):
351
+ with vuetify3.VRow(no_gutters=True, classes="fill-height"):
352
+ # Left Column: Controls
353
+ with vuetify3.VCol(cols=5, classes="pa-2 d-flex flex-column", style="overflow-y: auto; max-height: 200vh;"):
354
+ _build_control_panels(plotter)
355
+
356
+ # Right Column: Visualization
357
+ with vuetify3.VCol(cols=7, classes="pa-1 d-flex flex-column"):
358
+ _build_visualization_panel(plotter)
359
+
360
+ # Floating status window
361
+ _build_status_window()
362
+
363
+
364
+ # --- Distribution Figure Functions ---
365
+ def get_initial_distribution_figure(distribution_type, N, show_edges=False):
366
+ """Generate a 3D Plotly figure for the initial distribution."""
367
+ if _state is None:
368
+ return go.Figure()
369
+
370
+ if distribution_type == "Sinusoidal":
371
+ kx = max(1.0, round(float(_state.qlbm_sine_k_x))) if hasattr(_state, "qlbm_sine_k_x") else 1.0
372
+ ky = max(1.0, round(float(_state.qlbm_sine_k_y))) if hasattr(_state, "qlbm_sine_k_y") else 1.0
373
+ kz = max(1.0, round(float(_state.qlbm_sine_k_z))) if hasattr(_state, "qlbm_sine_k_z") else 1.0
374
+ selected_func = lambda x, y, z: \
375
+ np.sin(x * 2 * np.pi * kx / N) * \
376
+ np.sin(y * 2 * np.pi * ky / N) * \
377
+ np.sin(z * 2 * np.pi * kz / N) + 1
378
+ title = f"Sinusoidal Distribution (N={N})"
379
+
380
+ elif distribution_type == "Gaussian":
381
+ cx = _state.qlbm_gauss_cx if hasattr(_state, "qlbm_gauss_cx") else N/2
382
+ cy = _state.qlbm_gauss_cy if hasattr(_state, "qlbm_gauss_cy") else N/2
383
+ cz = _state.qlbm_gauss_cz if hasattr(_state, "qlbm_gauss_cz") else N/2
384
+ sigma = _state.qlbm_gauss_sigma if hasattr(_state, "qlbm_gauss_sigma") and _state.qlbm_gauss_sigma > 0 else 0.1
385
+
386
+ selected_func = lambda x, y, z: \
387
+ np.exp(-((x - cx)**2 / (2 * sigma**2) +
388
+ (y - cy)**2 / (2 * sigma**2) +
389
+ (z - cz)**2 / (2 * sigma**2))) * 1.8 + 0.2
390
+ title = f"Gaussian Distribution (N={N})"
391
+
392
+ else:
393
+ return go.Figure()
394
+
395
+ # Create 3D grid
396
+ x_indices = np.linspace(0, 1, N)
397
+ y_indices = np.linspace(0, 1, N)
398
+ z_indices = np.linspace(0, 1, N)
399
+ X, Y, Z = np.meshgrid(x_indices, y_indices, z_indices, indexing='ij')
400
+
401
+ # Calculate distribution values
402
+ xi = np.arange(0, N)
403
+ yi = np.arange(0, N)
404
+ zi = np.arange(0, N)
405
+ Xi, Yi, Zi = np.meshgrid(xi, yi, zi, indexing='ij')
406
+ values = selected_func(Xi, Yi, Zi)
407
+
408
+ # Create Plotly visualization
409
+ isomin = np.min(values)
410
+ isomax = np.max(values)
411
+ surface_count = 5
412
+
413
+ if distribution_type == "Sinusoidal":
414
+ isomin = 0.1
415
+ isomax = 1.9
416
+ surface_count = 4
417
+
418
+ data = [go.Isosurface(
419
+ x=X.flatten(),
420
+ y=Y.flatten(),
421
+ z=Z.flatten(),
422
+ value=values.flatten(),
423
+ isomin=isomin,
424
+ isomax=isomax,
425
+ surface_count=surface_count,
426
+ colorscale='Blues',
427
+ opacity=0.35,
428
+ caps=dict(x_show=False, y_show=False, z_show=False)
429
+ )]
430
+
431
+ if show_edges:
432
+ # Create grid lines
433
+ Y_yz, Z_yz = np.meshgrid(y_indices, z_indices, indexing='ij')
434
+ Y_flat, Z_flat = Y_yz.flatten(), Z_yz.flatten()
435
+ num_lines = len(Y_flat)
436
+
437
+ xe = np.full(num_lines * 3, np.nan)
438
+ xe[0::3], xe[1::3] = 0, 1
439
+ ye = np.full(num_lines * 3, np.nan)
440
+ ye[0::3] = ye[1::3] = Y_flat
441
+ ze = np.full(num_lines * 3, np.nan)
442
+ ze[0::3] = ze[1::3] = Z_flat
443
+
444
+ X_xz, Z_xz = np.meshgrid(x_indices, z_indices, indexing='ij')
445
+ X_flat, Z_flat = X_xz.flatten(), Z_xz.flatten()
446
+ num_lines = len(X_flat)
447
+
448
+ xe_y = np.full(num_lines * 3, np.nan)
449
+ xe_y[0::3] = xe_y[1::3] = X_flat
450
+ ye_y = np.full(num_lines * 3, np.nan)
451
+ ye_y[0::3], ye_y[1::3] = 0, 1
452
+ ze_y = np.full(num_lines * 3, np.nan)
453
+ ze_y[0::3] = ze_y[1::3] = Z_flat
454
+
455
+ X_xy, Y_xy = np.meshgrid(x_indices, y_indices, indexing='ij')
456
+ X_flat, Y_flat = X_xy.flatten(), Y_xy.flatten()
457
+ num_lines = len(X_flat)
458
+
459
+ xe_z = np.full(num_lines * 3, np.nan)
460
+ xe_z[0::3] = xe_z[1::3] = X_flat
461
+ ye_z = np.full(num_lines * 3, np.nan)
462
+ ye_z[0::3] = ye_z[1::3] = Y_flat
463
+ ze_z = np.full(num_lines * 3, np.nan)
464
+ ze_z[0::3], ze_z[1::3] = 0, 1
465
+
466
+ x_all = np.concatenate([xe, xe_y, xe_z])
467
+ y_all = np.concatenate([ye, ye_y, ye_z])
468
+ z_all = np.concatenate([ze, ze_y, ze_z])
469
+
470
+ data.append(go.Scatter3d(
471
+ x=x_all, y=y_all, z=z_all,
472
+ mode='lines',
473
+ line=dict(color='black', width=1),
474
+ opacity=0.22,
475
+ name='Grid Edges'
476
+ ))
477
+
478
+ fig = go.Figure(data=data)
479
+
480
+ fig.update_layout(
481
+ title=title,
482
+ scene=dict(
483
+ xaxis=dict(backgroundcolor="white", showbackground=True, gridcolor="lightgrey", zerolinecolor="lightgrey", title='X'),
484
+ yaxis=dict(backgroundcolor="white", showbackground=True, gridcolor="lightgrey", zerolinecolor="lightgrey", title='Y'),
485
+ zaxis=dict(backgroundcolor="white", showbackground=True, gridcolor="lightgrey", zerolinecolor="lightgrey", title='Z'),
486
+ ),
487
+ margin=dict(l=0, r=0, b=0, t=40),
488
+ width=800,
489
+ height=700
490
+ )
491
+ return fig
492
+
493
+
494
+ def update_view():
495
+ """Update the preview visualization."""
496
+ global current_grid_object
497
+
498
+ if _state is None:
499
+ return
500
+
501
+ # If simulation has run, don't update the preview
502
+ if _state.qlbm_simulation_has_run:
503
+ return
504
+
505
+ try:
506
+ N = int(_state.qlbm_nx)
507
+ distribution_type = _state.qlbm_dist_type
508
+
509
+ show_edges = _state.qlbm_show_edges
510
+ fig = get_initial_distribution_figure(distribution_type, N, show_edges)
511
+ if hasattr(_ctrl, "qlbm_preview_update"):
512
+ _ctrl.qlbm_preview_update(fig)
513
+
514
+ except Exception as e:
515
+ print(f"Error updating view: {e}")
516
+
517
+
518
+ def on_pick_point(point, *_) -> None:
519
+ """Handle point picking on the 3D visualization."""
520
+ global current_grid_object
521
+ if point is None or current_grid_object is None:
522
+ return
523
+ closest_id = current_grid_object.find_closest_point(point)
524
+ if closest_id == -1:
525
+ return
526
+ values = current_grid_object.point_data.get('scalars')
527
+ if values is None:
528
+ return
529
+
530
+ coords = current_grid_object.points[closest_id]
531
+ val = float(values[closest_id])
532
+
533
+ x, y, z = coords
534
+ _set_pick_text(f"Position: ({x:.3f}, {y:.3f}, {z:.3f})\nValue: {val:.4g}")
535
+ if hasattr(_ctrl, "qlbm_view_update"):
536
+ _ctrl.qlbm_view_update()
537
+
538
+
539
+ # --- Geometry Figure ---
540
+ def get_geometry_figure():
541
+ """Generates a 3D Plotly figure for the selected geometry."""
542
+ if _state is None:
543
+ return go.Figure()
544
+
545
+ geom = _state.qlbm_geometry_selection
546
+
547
+ if geom == "Cube":
548
+ fig = _create_box_figure(1, 1, 1, "Cube")
549
+
550
+ elif geom == "Rectangular domain with a heated box (3D)":
551
+ try:
552
+ L = float(_state.qlbm_domain_L)
553
+ W = float(_state.qlbm_domain_W)
554
+ H = float(_state.qlbm_domain_H)
555
+ except:
556
+ L, W, H = 1.0, 1.0, 1.0
557
+
558
+ max_dim = max(L, W, H)
559
+ if max_dim > 0:
560
+ L /= max_dim
561
+ W /= max_dim
562
+ H /= max_dim
563
+
564
+ fig = _create_box_figure(L, W, H, "Rectangular Domain")
565
+
566
+ else:
567
+ fig = go.Figure()
568
+ fig.update_layout(
569
+ scene=dict(xaxis=dict(visible=False), yaxis=dict(visible=False), zaxis=dict(visible=False)),
570
+ margin=dict(l=0, r=0, b=0, t=0),
571
+ )
572
+ return fig
573
+
574
+ fig.update_layout(
575
+ scene=dict(
576
+ xaxis=dict(visible=False),
577
+ yaxis=dict(visible=False),
578
+ zaxis=dict(visible=False),
579
+ aspectmode='data'
580
+ ),
581
+ margin=dict(l=0, r=0, b=0, t=30),
582
+ )
583
+ return fig
584
+
585
+
586
+ def _create_box_figure(lx, ly, lz, title):
587
+ """Create a 3D box figure."""
588
+ x = [0, lx, lx, 0, 0, lx, lx, 0]
589
+ y = [0, 0, ly, ly, 0, 0, ly, ly]
590
+ z = [0, 0, 0, 0, lz, lz, lz, lz]
591
+
592
+ fig = go.Figure()
593
+
594
+ fig.add_trace(go.Mesh3d(
595
+ x=x, y=y, z=z,
596
+ i=[7, 0, 0, 0, 4, 4, 6, 6, 4, 0, 3, 2],
597
+ j=[3, 4, 1, 2, 5, 6, 5, 2, 0, 1, 6, 3],
598
+ k=[0, 7, 2, 3, 6, 7, 1, 1, 5, 5, 7, 6],
599
+ opacity=0.2,
600
+ color='blue',
601
+ flatshading=True,
602
+ name=title,
603
+ showscale=False
604
+ ))
605
+
606
+ xe = [0, lx, lx, 0, 0, None, 0, lx, lx, 0, 0, None, 0, 0, None, lx, lx, None, lx, lx, None, 0, 0]
607
+ ye = [0, 0, ly, ly, 0, None, 0, 0, ly, ly, 0, None, 0, 0, None, 0, 0, None, ly, ly, None, ly, ly]
608
+ ze = [0, 0, 0, 0, 0, None, lz, lz, lz, lz, lz, None, 0, lz, None, 0, lz, None, 0, lz, None, 0, lz]
609
+
610
+ fig.add_trace(go.Scatter3d(
611
+ x=xe, y=ye, z=ze,
612
+ mode='lines',
613
+ line=dict(color='black', width=3),
614
+ showlegend=False
615
+ ))
616
+
617
+ fig.update_layout(title=title)
618
+ return fig
619
+
620
+
621
+ def update_geometry_view():
622
+ """Update the geometry visualization."""
623
+ try:
624
+ fig = get_geometry_figure()
625
+ if hasattr(_ctrl, "qlbm_geometry_plot_update"):
626
+ _ctrl.qlbm_geometry_plot_update(fig)
627
+ except Exception as e:
628
+ print(f"Error updating geometry view: {e}")
629
+
630
+
631
+ # --- CPU Demo Simulation ---
632
+ def _cpu_distribution_field(distribution_type: str, Xi, Yi, Zi, grid_size: int, drift, phase_fraction: float):
633
+ """Generate the distribution field for CPU demo simulation."""
634
+ if _state is None:
635
+ return np.ones_like(Xi)
636
+
637
+ if distribution_type == "Sinusoidal":
638
+ kx = max(1.0, round(float(_state.qlbm_sine_k_x))) if hasattr(_state, "qlbm_sine_k_x") else 1.0
639
+ ky = max(1.0, round(float(_state.qlbm_sine_k_y))) if hasattr(_state, "qlbm_sine_k_y") else 1.0
640
+ kz = max(1.0, round(float(_state.qlbm_sine_k_z))) if hasattr(_state, "qlbm_sine_k_z") else 1.0
641
+ x_term = np.sin((np.mod(Xi + drift[0], grid_size)) * 2 * np.pi * kx / grid_size)
642
+ y_term = np.sin((np.mod(Yi + drift[1], grid_size)) * 2 * np.pi * ky / grid_size)
643
+ z_term = np.sin((np.mod(Zi + drift[2], grid_size)) * 2 * np.pi * kz / grid_size)
644
+ field = x_term * y_term * z_term + 1.0
645
+ else:
646
+ # Gaussian
647
+ nx_val = max(1.0, float(_state.qlbm_nx)) if hasattr(_state, "qlbm_nx") else float(grid_size)
648
+ cx = float(_state.qlbm_gauss_cx) if hasattr(_state, "qlbm_gauss_cx") else nx_val / 2
649
+ cy = float(_state.qlbm_gauss_cy) if hasattr(_state, "qlbm_gauss_cy") else nx_val / 2
650
+ cz = float(_state.qlbm_gauss_cz) if hasattr(_state, "qlbm_gauss_cz") else nx_val / 2
651
+ sigma = float(_state.qlbm_gauss_sigma) if hasattr(_state, "qlbm_gauss_sigma") else nx_val / 6
652
+ scale = (grid_size - 1) / nx_val if nx_val else 1.0
653
+ cx = cx * scale + drift[0]
654
+ cy = cy * scale + drift[1]
655
+ cz = cz * scale + drift[2]
656
+ sigma = max(1.0, sigma * scale)
657
+ field = np.exp(-(((Xi - cx) ** 2 + (Yi - cy) ** 2 + (Zi - cz) ** 2) / (2 * sigma ** 2))) * 1.8 + 0.2
658
+
659
+ modulation = 0.15 * np.sin(2 * np.pi * phase_fraction + (Xi + Yi + Zi) * np.pi / max(1, grid_size))
660
+ return field + modulation
661
+
662
+
663
+ def _run_cpu_demo_simulation(grid_size: int, T: int, distribution_type: str, vx_func, vy_func, vz_func, progress_callback=None):
664
+ """Run CPU demo simulation."""
665
+ grid_size = int(max(8, min(grid_size, _CPU_DEMO_MAX_GRID)))
666
+ idx_coords = np.linspace(0, grid_size - 1, grid_size, dtype=np.float32)
667
+ Xi, Yi, Zi = np.meshgrid(idx_coords, idx_coords, idx_coords, indexing='ij')
668
+ geom_coords = np.linspace(0, 1, grid_size, dtype=np.float32)
669
+ Xg, Yg, Zg = np.meshgrid(geom_coords, geom_coords, geom_coords, indexing='ij')
670
+
671
+ if T <= 0:
672
+ target = 1.0
673
+ else:
674
+ target = float(T)
675
+ num_frames = min(30, max(2, int(min(target, 20)) + 1))
676
+ timeline = list(np.linspace(0.0, target, num_frames))
677
+ if len(timeline) < 2:
678
+ timeline.append(target)
679
+
680
+ vx = _safe_velocity_sample(vx_func)
681
+ vy = _safe_velocity_sample(vy_func)
682
+ vz = _safe_velocity_sample(vz_func)
683
+ drift_scale = 0.25 * grid_size
684
+
685
+ frames = []
686
+ for idx, t_val in enumerate(timeline):
687
+ phase_fraction = idx / (len(timeline) - 1) if len(timeline) > 1 else 0.0
688
+ drift = (
689
+ vx * phase_fraction * drift_scale,
690
+ vy * phase_fraction * drift_scale,
691
+ vz * phase_fraction * drift_scale,
692
+ )
693
+ field = _cpu_distribution_field(distribution_type, Xi, Yi, Zi, grid_size, drift, phase_fraction)
694
+ frames.append(field.astype(np.float32))
695
+
696
+ if progress_callback:
697
+ percent = int(((idx + 1) / len(timeline)) * 100)
698
+ progress_callback(percent)
699
+
700
+ grid = pv.StructuredGrid()
701
+ grid.points = np.column_stack((Xg.ravel(), Yg.ravel(), Zg.ravel()))
702
+ grid.dimensions = [grid_size, grid_size, grid_size]
703
+ grid["scalars"] = frames[0].ravel()
704
+
705
+ times = [float(t) for t in timeline]
706
+ return frames, times, grid
707
+
708
+
709
+ # --- Export Functions ---
710
+ def export_simulation_vtk():
711
+ """Download the current simulation volume as a VTK file."""
712
+ global current_grid_object
713
+
714
+ if not _state.qlbm_simulation_has_run or current_grid_object is None:
715
+ log_to_console("VTK export unavailable: run a simulation first.")
716
+ return
717
+
718
+ temp_path = None
719
+ try:
720
+ suffix = datetime.now().strftime("%Y%m%d_%H%M%S")
721
+ grid_size = int(_state.qlbm_grid_size or 0)
722
+ filename = f"qlbm_volume_n{grid_size}_{suffix}.vts"
723
+
724
+ tmp = tempfile.NamedTemporaryFile(suffix=".vts", delete=False)
725
+ tmp.close()
726
+ temp_path = Path(tmp.name)
727
+
728
+ current_grid_object.save(str(temp_path))
729
+ _server.controller.download_file(temp_path.read_bytes(), filename)
730
+ log_to_console(f"Exported VTK to {filename}")
731
+ except Exception as exc:
732
+ log_to_console(f"VTK export failed: {exc}")
733
+ finally:
734
+ if temp_path and temp_path.exists():
735
+ try:
736
+ temp_path.unlink()
737
+ except Exception:
738
+ pass
739
+
740
+
741
+ def export_simulation_mp4():
742
+ """Render the simulation frames to an MP4 animation for download."""
743
+ global simulation_data_frames, current_grid_object
744
+
745
+ if not _state.qlbm_simulation_has_run or not simulation_data_frames:
746
+ log_to_console("MP4 export unavailable: run a simulation first.")
747
+ return
748
+ if current_grid_object is None:
749
+ log_to_console("MP4 export failed: missing grid data.")
750
+ return
751
+
752
+ temp_path = None
753
+ movie_plotter = None
754
+ try:
755
+ suffix = datetime.now().strftime("%Y%m%d_%H%M%S")
756
+ grid_size = int(_state.qlbm_grid_size or 0)
757
+ filename = f"qlbm_animation_n{grid_size}_{suffix}.mp4"
758
+
759
+ tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
760
+ tmp.close()
761
+ temp_path = Path(tmp.name)
762
+
763
+ movie_plotter = pv.Plotter(off_screen=True, window_size=(1280, 720))
764
+ try:
765
+ camera_position = _plotter.camera_position if _plotter and _plotter.camera_position else None
766
+ except Exception:
767
+ camera_position = None
768
+
769
+ base_grid = current_grid_object.copy()
770
+ movie_plotter.open_movie(str(temp_path), framerate=15)
771
+
772
+ for frame_data in simulation_data_frames:
773
+ base_grid["scalars"] = np.asarray(frame_data).ravel()
774
+ iso_mesh = base_grid.contour(isosurfaces=7, scalars="scalars")
775
+ movie_plotter.clear()
776
+ movie_plotter.add_mesh(
777
+ iso_mesh,
778
+ cmap="Blues",
779
+ opacity=0.35,
780
+ show_scalar_bar=False,
781
+ )
782
+ movie_plotter.add_axes()
783
+ if camera_position:
784
+ try:
785
+ movie_plotter.camera_position = camera_position
786
+ except Exception:
787
+ pass
788
+ else:
789
+ movie_plotter.view_isometric()
790
+ movie_plotter.render()
791
+ movie_plotter.write_frame()
792
+
793
+ movie_plotter.close()
794
+ movie_plotter = None
795
+
796
+ _server.controller.download_file(temp_path.read_bytes(), filename)
797
+ log_to_console(f"Exported MP4 to {filename}")
798
+ except Exception as exc:
799
+ log_to_console(f"MP4 export failed: {exc}")
800
+ finally:
801
+ if movie_plotter is not None:
802
+ try:
803
+ movie_plotter.close()
804
+ except Exception:
805
+ pass
806
+ if temp_path and temp_path.exists():
807
+ try:
808
+ temp_path.unlink()
809
+ except Exception:
810
+ pass
811
+
812
+
813
+ # --- Main Simulation ---
814
+ def run_simulation():
815
+ """Run the QLBM simulation."""
816
+ global simulation_data_frames, simulation_times, current_grid_object, _plotter
817
+
818
+ if not _SIMULATION_CAN_RUN:
819
+ msg = _SIMULATION_DISABLED_REASON or "Simulation backend is not available on this platform."
820
+ _state.qlbm_run_error = msg
821
+ log_to_console(f"Error: {msg}")
822
+ _state.qlbm_status_message = "Error: Backend unavailable"
823
+ _state.qlbm_status_type = "error"
824
+ return
825
+
826
+ _state.qlbm_is_running = True
827
+ _state.qlbm_run_error = ""
828
+ _state.qlbm_simulation_has_run = False
829
+ _state.qlbm_show_progress = True
830
+ _state.qlbm_simulation_progress = 0
831
+ _state.qlbm_status_message = "Running simulation..."
832
+ _state.qlbm_status_type = "info"
833
+
834
+ # Log initial configuration
835
+ config_lines = [
836
+ "Job Initiated",
837
+ f" Grid Size: {_state.qlbm_grid_size} × {_state.qlbm_grid_size} × {_state.qlbm_grid_size}",
838
+ f" Time Steps: {_state.qlbm_time_steps}",
839
+ f" Distribution: {_state.qlbm_dist_type}",
840
+ f" Boundary: {_state.qlbm_boundary_condition}",
841
+ f" Backend: {_state.qlbm_backend_type}",
842
+ f" Velocity: vx={_state.qlbm_vx_expr}, vy={_state.qlbm_vy_expr}, vz={_state.qlbm_vz_expr}",
843
+ ]
844
+ for line in config_lines:
845
+ log_to_console(line)
846
+
847
+ last_logged_percent = 0
848
+ def _progress_callback(percent):
849
+ nonlocal last_logged_percent
850
+ _state.qlbm_simulation_progress = percent
851
+ if percent - last_logged_percent >= 10:
852
+ log_to_console(f"Simulation progress: {int(percent)}%")
853
+ last_logged_percent = percent
854
+
855
+ try:
856
+ grid_size = int(_state.qlbm_grid_size)
857
+ num_reg_qubits = int(math.log2(grid_size)) if grid_size > 0 else 3
858
+ T = int(_state.qlbm_time_steps)
859
+ distribution_type = _state.qlbm_dist_type
860
+ boundary_condition = _state.qlbm_boundary_condition
861
+
862
+ vx_func = make_velocity_func(_state.qlbm_vx_expr)
863
+ vy_func = make_velocity_func(_state.qlbm_vy_expr)
864
+ vz_func = make_velocity_func(_state.qlbm_vz_expr)
865
+
866
+ _progress_callback(0)
867
+
868
+ if simulate_qlbm_3D_and_animate is not None:
869
+ log_to_console("Running CUDA-Q Simulation...")
870
+ _plotter.clear()
871
+ _, frames, times, grid_obj = simulate_qlbm_3D_and_animate(
872
+ num_reg_qubits=num_reg_qubits,
873
+ T=T,
874
+ distribution_type=distribution_type,
875
+ vx_input=vx_func,
876
+ vy_input=vy_func,
877
+ vz_input=vz_func,
878
+ boundary_condition=boundary_condition,
879
+ plotter=_plotter,
880
+ add_slider=False,
881
+ progress_callback=_progress_callback
882
+ )
883
+ else:
884
+ log_to_console("Running CPU Demo Simulation...")
885
+ frames, times, grid_obj = _run_cpu_demo_simulation(
886
+ grid_size=grid_size,
887
+ T=T,
888
+ distribution_type=distribution_type or "Sinusoidal",
889
+ vx_func=vx_func,
890
+ vy_func=vy_func,
891
+ vz_func=vz_func,
892
+ progress_callback=_progress_callback
893
+ )
894
+
895
+ _progress_callback(100)
896
+
897
+ # Update plotter with results
898
+ if grid_obj:
899
+ _plotter.clear()
900
+ isosurfaces = grid_obj.contour(isosurfaces=7, scalars="scalars")
901
+ _plotter.add_mesh(isosurfaces, cmap="Blues", opacity=0.3, show_scalar_bar=True)
902
+ _plotter.add_axes()
903
+ _plotter.show_grid()
904
+
905
+ # Store Results
906
+ if frames and len(frames) > 0:
907
+ simulation_data_frames = frames
908
+ simulation_times = times
909
+ current_grid_object = grid_obj
910
+
911
+ _state.qlbm_max_time_step = len(frames) - 1
912
+ _state.qlbm_time_val = 0
913
+ _state.qlbm_time_slider_labels = [f"{t:.1f}" for t in times] if times else [str(i) for i in range(len(frames))]
914
+ _state.qlbm_simulation_has_run = True
915
+
916
+ _ensure_point_picking(on_pick_point)
917
+
918
+ if hasattr(_ctrl, "qlbm_view_update"):
919
+ _ctrl.qlbm_view_update()
920
+ log_to_console("Simulation completed successfully.")
921
+ _state.qlbm_status_message = "Simulation completed successfully."
922
+ _state.qlbm_status_type = "success"
923
+ _state.qlbm_simulation_progress = 100
924
+ else:
925
+ _state.qlbm_run_error = "Simulation produced no data."
926
+ log_to_console("Error: Simulation produced no data.")
927
+ _state.qlbm_status_message = "Error: No data produced"
928
+ _state.qlbm_status_type = "error"
929
+
930
+ except Exception as e:
931
+ _state.qlbm_run_error = f"Simulation failed: {str(e)}"
932
+ log_to_console(f"Simulation Error: {e}")
933
+ print(f"Simulation Error: {e}")
934
+ _state.qlbm_status_message = "Simulation failed"
935
+ _state.qlbm_status_type = "error"
936
+ finally:
937
+ _state.qlbm_is_running = False
938
+ if _state.qlbm_status_type != "success":
939
+ _state.qlbm_show_progress = False
940
+
941
+
942
+ def stop_simulation():
943
+ """Stop the running simulation."""
944
+ if _state is None:
945
+ return
946
+ _state.qlbm_is_running = False
947
+ log_to_console("Simulation stopped by user")
948
+
949
+
950
+ def reset_simulation():
951
+ """Reset the simulation state."""
952
+ global _plotter
953
+ if _state is None:
954
+ return
955
+ _state.qlbm_is_running = False
956
+ _state.qlbm_run_error = ""
957
+ _state.qlbm_simulation_has_run = False
958
+ _state.qlbm_dist_type = None
959
+ _state.qlbm_show_edges = False
960
+ _state.qlbm_problems_selection = None
961
+ _state.qlbm_geometry_selection = None
962
+ _state.qlbm_backend_type = None
963
+ _state.qlbm_advecting_field = None
964
+ _state.qlbm_show_advect_params = False
965
+ if _plotter:
966
+ _plotter.clear()
967
+ if hasattr(_ctrl, "qlbm_view_update"):
968
+ _ctrl.qlbm_view_update()
969
+ _apply_workflow_highlights(_determine_workflow_step())
970
+ log_to_console("Simulation reset")
971
+
972
+
973
+ def _register_handlers():
974
+ """Register state change handlers."""
975
+
976
+ @_state.change("qlbm_advecting_field")
977
+ def _on_advect_dropdown_change(qlbm_advecting_field, **_):
978
+ if qlbm_advecting_field:
979
+ set_velocity_preset(qlbm_advecting_field)
980
+ _apply_workflow_highlights(_determine_workflow_step())
981
+
982
+ @_state.change("qlbm_grid_index")
983
+ def _on_grid_index_change(qlbm_grid_index, **_):
984
+ """Map discrete slider index to allowed grid sizes."""
985
+ try:
986
+ if qlbm_grid_index is None:
987
+ return
988
+ if isinstance(qlbm_grid_index, (int, float)):
989
+ idx = int(qlbm_grid_index)
990
+ idx = max(0, min(idx, len(GRID_SIZES) - 1))
991
+ val = GRID_SIZES[idx]
992
+
993
+ if _state.qlbm_grid_size != val:
994
+ _state.qlbm_grid_size = val
995
+ fig, info, warn = update_qubit_3D_info(val)
996
+ _state.qlbm_qubit_grid_info = info
997
+ _state.qlbm_qubit_warning = warn
998
+ if hasattr(_ctrl, "qlbm_qubit_plot_update"):
999
+ _ctrl.qlbm_qubit_plot_update(fig)
1000
+
1001
+ if _state.qlbm_nx != val:
1002
+ _state.qlbm_nx = val
1003
+ _state.qlbm_gauss_cx = val / 2
1004
+ _state.qlbm_gauss_cy = val / 2
1005
+ _state.qlbm_gauss_cz = val / 2
1006
+ _state.qlbm_show_edges = True
1007
+ update_view()
1008
+
1009
+ except Exception:
1010
+ pass
1011
+ finally:
1012
+ _apply_workflow_highlights(_determine_workflow_step())
1013
+
1014
+ @_state.change("qlbm_problems_selection")
1015
+ def _on_problem_selection_change(qlbm_problems_selection, **_):
1016
+ """Auto-select geometry based on the chosen problem."""
1017
+ try:
1018
+ if not qlbm_problems_selection:
1019
+ _state.qlbm_geometry_selection = None
1020
+ return
1021
+
1022
+ if isinstance(qlbm_problems_selection, str):
1023
+ normalized = qlbm_problems_selection.strip()
1024
+ _state.qlbm_geometry_selection = _PROBLEM_GEOMETRY_MAP.get(normalized)
1025
+ else:
1026
+ _state.qlbm_geometry_selection = None
1027
+ except Exception:
1028
+ _state.qlbm_geometry_selection = None
1029
+ finally:
1030
+ _apply_workflow_highlights(_determine_workflow_step())
1031
+
1032
+ @_state.change("qlbm_dist_type")
1033
+ def _on_dist_type_change(qlbm_dist_type, **_):
1034
+ if _state.qlbm_show_edges:
1035
+ _state.qlbm_show_edges = False
1036
+ update_view()
1037
+ _apply_workflow_highlights(_determine_workflow_step())
1038
+
1039
+ @_state.change("qlbm_show_edges", "qlbm_sine_k_x", "qlbm_sine_k_y", "qlbm_sine_k_z",
1040
+ "qlbm_gauss_cx", "qlbm_gauss_cy", "qlbm_gauss_cz", "qlbm_gauss_sigma")
1041
+ def on_param_change(**kwargs):
1042
+ update_view()
1043
+ _apply_workflow_highlights(_determine_workflow_step())
1044
+
1045
+ @_state.change("qlbm_geometry_selection", "qlbm_domain_L", "qlbm_domain_W", "qlbm_domain_H")
1046
+ def _on_geometry_selection_change(**_):
1047
+ update_geometry_view()
1048
+ _apply_workflow_highlights(_determine_workflow_step())
1049
+
1050
+ @_state.change("qlbm_backend_type")
1051
+ def _on_backend_type_change(**_):
1052
+ _apply_workflow_highlights(_determine_workflow_step())
1053
+
1054
+ @_state.change("qlbm_time_val")
1055
+ def update_time_frame(qlbm_time_val, **_):
1056
+ """Update the plotter with the frame corresponding to time_val."""
1057
+ global simulation_data_frames, simulation_times, current_grid_object, _plotter
1058
+
1059
+ if not _state.qlbm_simulation_has_run or not simulation_data_frames or current_grid_object is None:
1060
+ return
1061
+
1062
+ try:
1063
+ idx = int(qlbm_time_val)
1064
+ if 0 <= idx < len(simulation_data_frames):
1065
+ current_grid_object["scalars"] = simulation_data_frames[idx].flatten()
1066
+ isosurfaces = current_grid_object.contour(isosurfaces=7, scalars="scalars")
1067
+
1068
+ _plotter.clear()
1069
+ _plotter.add_mesh(isosurfaces, cmap="Blues", opacity=0.3, show_scalar_bar=True)
1070
+ _plotter.add_axes()
1071
+ _plotter.show_grid()
1072
+
1073
+ t_val = simulation_times[idx] if idx < len(simulation_times) else idx
1074
+ _state.qlbm_current_time_label = f"{t_val:.2f}" if isinstance(t_val, float) else str(t_val)
1075
+ _plotter.add_text(f"Time: {t_val:.2f}" if isinstance(t_val, float) else f"Time: {t_val}",
1076
+ name="time_label", position="upper_right")
1077
+
1078
+ _ensure_point_picking(on_pick_point)
1079
+
1080
+ if hasattr(_ctrl, "qlbm_view_update"):
1081
+ _ctrl.qlbm_view_update()
1082
+ except Exception as e:
1083
+ print(f"Error updating time frame: {e}")
1084
+
1085
+
1086
+ def _build_control_panels(plotter):
1087
+ """Build the left control panel cards."""
1088
+
1089
+ # Overview card
1090
+ with vuetify3.VCard(classes="mb-2", style=("qlbm_overview_card_style", _WORKFLOW_BASE_STYLE)):
1091
+ vuetify3.VCardTitle("Overview", classes="text-subtitle-2 font-weight-bold text-primary")
1092
+ with vuetify3.VCardText():
1093
+ vuetify3.VDivider(classes="my-2")
1094
+ vuetify3.VCardSubtitle("Problems", classes="text-caption font-weight-bold mt-2")
1095
+ vuetify3.VSelect(
1096
+ key="qlbm_overview_problems",
1097
+ label="Select a problem",
1098
+ v_model=("qlbm_problems_selection", None),
1099
+ items=(
1100
+ "qlbm_qlbm_problems",
1101
+ [
1102
+ "Scalar advection-diffusion in a box",
1103
+ "Laminar flow & heat transfer for a heated body in water.",
1104
+ ],
1105
+ ),
1106
+ placeholder="Select",
1107
+ density="compact",
1108
+ hide_details=True,
1109
+ color="primary",
1110
+ classes="mb-2"
1111
+ )
1112
+ vuetify3.VCardSubtitle("Governing Equations", classes="text-caption font-weight-bold mt-2")
1113
+ vuetify3.VListItemTitle("Laminar Navier-Stokes including energy", classes="text-caption")
1114
+ vuetify3.VCardSubtitle("Inputs", classes="text-caption font-weight-bold mt-2")
1115
+ vuetify3.VListItemTitle("Geometry, Boundary conditions - temperature and flow", classes="text-caption")
1116
+ vuetify3.VCardSubtitle("Outputs", classes="text-caption font-weight-bold mt-2")
1117
+ vuetify3.VListItemTitle("Surface plots on sections OR sampling through a line in 3D domain", classes="text-caption")
1118
+
1119
+ # Geometry card
1120
+ with vuetify3.VCard(classes="mb-2"):
1121
+ vuetify3.VCardTitle("Geometry", classes="text-subtitle-2 font-weight-bold text-primary")
1122
+ with vuetify3.VCardText():
1123
+ vuetify3.VAlert(
1124
+ v_if="qlbm_geometry_selection",
1125
+ type="info",
1126
+ variant="tonal",
1127
+ density="compact",
1128
+ color="primary",
1129
+ children=["Selected Geometry: ", "{{ qlbm_geometry_selection }}"],
1130
+ classes="mb-2"
1131
+ )
1132
+ vuetify3.VAlert(
1133
+ v_if="!qlbm_geometry_selection",
1134
+ type="info",
1135
+ variant="tonal",
1136
+ density="compact",
1137
+ color="primary",
1138
+ children=["No geometry selected. Choose a problem to auto-set."],
1139
+ classes="mb-2"
1140
+ )
1141
+ with vuetify3.VContainer(v_if="qlbm_geometry_selection === 'Rectangular domain with a heated box (3D)'", classes="pa-0 mt-2"):
1142
+ vuetify3.VCardSubtitle("Domain dimensions", classes="text-caption font-weight-bold mb-2")
1143
+ with vuetify3.VRow(dense=True):
1144
+ with vuetify3.VCol():
1145
+ vuetify3.VTextField(label="Length (L)", v_model=("qlbm_domain_L", 1.0), type="number", step="0.1", density="compact", hide_details=True, color="primary")
1146
+ with vuetify3.VCol():
1147
+ vuetify3.VTextField(label="Width (W)", v_model=("qlbm_domain_W", 1.0), type="number", step="0.1", density="compact", hide_details=True, color="primary")
1148
+ with vuetify3.VCol():
1149
+ vuetify3.VTextField(label="Height (H)", v_model=("qlbm_domain_H", 1.0), type="number", step="0.1", density="compact", hide_details=True, color="primary")
1150
+
1151
+ # Initial Distribution card
1152
+ with vuetify3.VCard(classes="mb-2", style=("qlbm_distribution_card_style", _WORKFLOW_BASE_STYLE)):
1153
+ vuetify3.VCardTitle("Initial Distribution", classes="text-subtitle-2 font-weight-bold text-primary")
1154
+ with vuetify3.VCardText():
1155
+ with vuetify3.VRow(classes="d-flex align-center mb-2", no_gutters=True):
1156
+ with vuetify3.VCol(cols="auto", classes="flex-grow-1"):
1157
+ vuetify3.VSelect(
1158
+ label="Initial Distribution",
1159
+ v_model=("qlbm_dist_type", None),
1160
+ items=("qlbm_dist_modes",),
1161
+ density="compact",
1162
+ hide_details=True
1163
+ )
1164
+ with vuetify3.VCol(cols="auto", classes="ml-2"):
1165
+ with vuetify3.VBtn(
1166
+ icon=True, density="compact", variant="text",
1167
+ click="qlbm_custom_dist_params = !qlbm_custom_dist_params"
1168
+ ):
1169
+ vuetify3.VIcon("mdi-cog", color=("qlbm_custom_dist_params ? 'primary' : 'grey'",))
1170
+
1171
+ # Sinusoidal controls
1172
+ with vuetify3.VCard(classes="mb-2", v_if="qlbm_custom_dist_params && qlbm_dist_type === 'Sinusoidal'"):
1173
+ vuetify3.VCardTitle("Sinusoidal Frequencies")
1174
+ with vuetify3.VCardText():
1175
+ for axis in ['x', 'y', 'z']:
1176
+ vuetify3.VSlider(
1177
+ label=f"Freq {axis.upper()}",
1178
+ v_model=(f"qlbm_sine_k_{axis}", 1.0),
1179
+ min=1, max=5, step=1,
1180
+ thumb_label="always", density="compact"
1181
+ )
1182
+
1183
+ # Gaussian controls
1184
+ with vuetify3.VCard(classes="mb-2", v_if="qlbm_custom_dist_params && qlbm_dist_type === 'Gaussian'"):
1185
+ vuetify3.VCardTitle("Gaussian Parameters")
1186
+ with vuetify3.VCardText():
1187
+ for axis in ['x', 'y', 'z']:
1188
+ vuetify3.VSlider(
1189
+ label=f"Center {axis.upper()}",
1190
+ v_model=(f"qlbm_gauss_c{axis}", 16),
1191
+ min=0, max=("qlbm_nx", 32), step=1,
1192
+ thumb_label="always", density="compact"
1193
+ )
1194
+ vuetify3.VSlider(
1195
+ label="Width (Sigma)",
1196
+ v_model=("qlbm_gauss_sigma", 6.0),
1197
+ min=1.0, max=20.0, step=0.5,
1198
+ thumb_label="always", density="compact"
1199
+ )
1200
+
1201
+ # Boundary Conditions
1202
+ with vuetify3.VCard(classes="mb-2"):
1203
+ vuetify3.VCardTitle("Boundary Conditions", classes="text-subtitle-2 font-weight-bold text-primary")
1204
+ with vuetify3.VCardText():
1205
+ vuetify3.VSelect(label="Boundary Condition", v_model=("qlbm_boundary_condition", "Periodic"),
1206
+ items=("['Periodic']",), density="compact", hide_details=True, color="primary")
1207
+
1208
+ # Advecting Fields
1209
+ with vuetify3.VCard(classes="mb-2", style=("qlbm_advect_card_style", _WORKFLOW_BASE_STYLE)):
1210
+ vuetify3.VCardTitle("Advecting Fields", classes="text-subtitle-2 font-weight-bold text-primary")
1211
+ with vuetify3.VCardText():
1212
+ with vuetify3.VRow(classes="d-flex align-center mb-2", no_gutters=True):
1213
+ with vuetify3.VCol(cols="auto", classes="flex-grow-1"):
1214
+ vuetify3.VSelect(
1215
+ label="Select Advecting field",
1216
+ v_model=("qlbm_advecting_field", None),
1217
+ items=("['Uniform', 'Swirl', 'Shear', 'TGV']",),
1218
+ density="compact",
1219
+ hide_details=True,
1220
+ color="primary",
1221
+ placeholder="Select",
1222
+ )
1223
+ with vuetify3.VCol(cols="auto", classes="ml-2"):
1224
+ with vuetify3.VBtn(
1225
+ icon=True, density="compact", variant="text",
1226
+ click="qlbm_show_advect_params = !qlbm_show_advect_params"
1227
+ ):
1228
+ vuetify3.VIcon("mdi-cog", color=("qlbm_show_advect_params ? 'primary' : 'grey'",))
1229
+ with vuetify3.VContainer(v_if="qlbm_show_advect_params", classes="pa-0 mt-2"):
1230
+ html.Div("Velocity Components", classes="text-caption mb-1")
1231
+ vuetify3.VTextField(label="Velocity vx", v_model=("qlbm_vx_expr", "0.2"), density="compact", hide_details=True, color="primary", classes="mb-1")
1232
+ vuetify3.VTextField(label="Velocity vy", v_model=("qlbm_vy_expr", "-0.15"), density="compact", hide_details=True, color="primary", classes="mb-1")
1233
+ vuetify3.VTextField(label="Velocity vz", v_model=("qlbm_vz_expr", "0.3"), density="compact", hide_details=True, color="primary")
1234
+
1235
+ # Meshing
1236
+ with vuetify3.VCard(classes="mb-2", style=("qlbm_meshing_card_style", _WORKFLOW_BASE_STYLE)):
1237
+ vuetify3.VCardTitle("Meshing", classes="text-subtitle-2 font-weight-bold text-primary")
1238
+ with vuetify3.VCardText():
1239
+ with vuetify3.VMenu(open_on_hover=True, close_on_content_click=False, location="end"):
1240
+ with vuetify3.Template(v_slot_activator="{ props }"):
1241
+ with vuetify3.VSlider(
1242
+ v_bind="props",
1243
+ label="Number of Points / Direction",
1244
+ v_model=("qlbm_grid_index", 2),
1245
+ min=0, max=5, step=1,
1246
+ thumb_label="always",
1247
+ show_ticks="always",
1248
+ color="primary",
1249
+ density="compact",
1250
+ hide_details=True
1251
+ ):
1252
+ vuetify3.Template(v_slot_thumb_label="{ modelValue }", children=["{{ ['8','16','32','64','128','256'][modelValue] }}"])
1253
+ with vuetify3.VSheet(classes="pa-2", elevation=6, rounded=True, style="width: 700px;"):
1254
+ with vuetify3.VContainer(fluid=True, classes="pa-0"):
1255
+ qubit_fig = plotly_widgets.Figure(figure=go.Figure(), style="width: 616px; height: 320px; min-height: 320px;", responsive=True)
1256
+ _ctrl.qlbm_qubit_plot_update = qubit_fig.update
1257
+ html.Div("{{ qlbm_qubit_grid_info }}", classes="mt-2 text-caption")
1258
+ html.Div("{{ qlbm_qubit_warning }}", classes="warn-text")
1259
+ vuetify3.VAlert(v_if="qlbm_grid_size > 32", type="warning", variant="tonal", density="compact",
1260
+ children=["Warning: High grid size may impact performance."], classes="mt-2")
1261
+
1262
+ # Time
1263
+ with vuetify3.VCard(classes="mb-2"):
1264
+ vuetify3.VCardTitle("Time", classes="text-subtitle-2 font-weight-bold text-primary")
1265
+ with vuetify3.VCardText():
1266
+ vuetify3.VSlider(label="Total Time", v_model=("qlbm_time_steps", 10), min=0, max=2000, step=10,
1267
+ thumb_label="always", show_ticks="always", color="primary", density="compact", hide_details=True)
1268
+ vuetify3.VAlert(v_if="qlbm_time_steps > 100", type="warning", variant="tonal", density="compact",
1269
+ children=["Warning: High time steps may increase runtime."], classes="mt-2")
1270
+
1271
+ # Backends
1272
+ with vuetify3.VCard(classes="mb-2", style=("qlbm_backend_card_style", _WORKFLOW_BASE_STYLE)):
1273
+ vuetify3.VCardTitle("Backends", classes="text-subtitle-2 font-weight-bold text-primary")
1274
+ with vuetify3.VCardText():
1275
+ with vuetify3.VRow(dense=True, classes="mb-2"):
1276
+ with vuetify3.VCol():
1277
+ vuetify3.VAlert(
1278
+ type="info",
1279
+ color="primary",
1280
+ variant="tonal",
1281
+ density="compact",
1282
+ children=[
1283
+ "Selected: ",
1284
+ "{{ qlbm_backend_type || '—' }}",
1285
+ " - ",
1286
+ "{{ qlbm_backend_type === 'Simulator' ? qlbm_selected_simulator : (qlbm_backend_type === 'QPU' ? qlbm_selected_qpu : '—') }}",
1287
+ ],
1288
+ )
1289
+ with vuetify3.VMenu(open_on_hover=True, close_on_content_click=True, location="end"):
1290
+ with vuetify3.Template(v_slot_activator="{ props }"):
1291
+ vuetify3.VBtn(v_bind="props", text="Choose Backend", color="primary", variant="tonal", block=True)
1292
+ with vuetify3.VList(density="compact"):
1293
+ with vuetify3.VMenu(open_on_hover=True, close_on_content_click=True, location="end", offset=8):
1294
+ with vuetify3.Template(v_slot_activator="{ props }"):
1295
+ vuetify3.VListItem(v_bind="props", title="Simulator", prepend_icon="mdi-robot-outline", append_icon="mdi-chevron-right")
1296
+ with vuetify3.VList(density="compact"):
1297
+ vuetify3.VListItem(title="IBM Qiskit simulator", click="qlbm_backend_type = 'Simulator'; qlbm_selected_simulator = 'IBM Qiskit simulator'")
1298
+ with vuetify3.VMenu(open_on_hover=True, close_on_content_click=True, location="end", offset=8):
1299
+ with vuetify3.Template(v_slot_activator="{ props }"):
1300
+ vuetify3.VListItem(v_bind="props", title="QPU", prepend_icon="mdi-chip", append_icon="mdi-chevron-right")
1301
+ with vuetify3.VList(density="compact"):
1302
+ vuetify3.VListItem(title="IBM QPU", click="qlbm_backend_type = 'QPU'; qlbm_selected_qpu = 'IBM QPU'")
1303
+ vuetify3.VListItem(title="IonQ QPU", click="qlbm_backend_type = 'QPU'; qlbm_selected_qpu = 'IonQ QPU'")
1304
+
1305
+ # IBM QPU Warning for grid > 16
1306
+ vuetify3.VAlert(
1307
+ v_if="qlbm_backend_type === 'QPU' && qlbm_selected_qpu === 'IBM QPU' && qlbm_grid_size > 16",
1308
+ type="warning",
1309
+ variant="tonal",
1310
+ density="compact",
1311
+ children=["⚠️ Grid size > 16 may exceed IBM QPU capacity!"],
1312
+ classes="mt-2"
1313
+ )
1314
+
1315
+ vuetify3.VDivider(classes="my-3")
1316
+ vuetify3.VBtn(
1317
+ text="Run",
1318
+ color="primary",
1319
+ block=True,
1320
+ disabled=("qlbm_is_running || !qlbm_simulation_backend_ready", False),
1321
+ click=run_simulation,
1322
+ style=("qlbm_is_running ? '' : 'background-color:#87CEFA;'", ""),
1323
+ )
1324
+ html.Div("Backend: {{ qlbm_simulation_backend_mode }}", classes="text-caption text-medium-emphasis mt-2")
1325
+ vuetify3.VAlert(
1326
+ v_if="qlbm_simulation_backend_note",
1327
+ type="info",
1328
+ variant="tonal",
1329
+ density="compact",
1330
+ children=["{{ qlbm_simulation_backend_note }}"],
1331
+ classes="mt-2",
1332
+ )
1333
+ with vuetify3.VRow(dense=True, classes="mt-2"):
1334
+ with vuetify3.VCol(cols=6):
1335
+ vuetify3.VBtn(
1336
+ text="Reset",
1337
+ color="#8BC34A",
1338
+ variant="tonal",
1339
+ block=True,
1340
+ disabled=("qlbm_is_running", False),
1341
+ click=reset_simulation,
1342
+ )
1343
+ with vuetify3.VCol(cols=6):
1344
+ vuetify3.VBtn(
1345
+ text="STOP",
1346
+ color="#FF7043",
1347
+ variant="tonal",
1348
+ block=True,
1349
+ click=stop_simulation,
1350
+ disabled=("!qlbm_is_running", True),
1351
+ )
1352
+
1353
+
1354
+ def _build_visualization_panel(plotter):
1355
+ """Build the right visualization panel."""
1356
+
1357
+ # Main Plot Card
1358
+ with vuetify3.VCard(classes="mb-1 flex-grow-1 d-flex flex-column", elevation=2, style="min-height: 0;"):
1359
+
1360
+ # Geometry Preview (Plotly)
1361
+ with vuetify3.VContainer(v_if="!qlbm_simulation_has_run && !qlbm_dist_type && qlbm_geometry_selection",
1362
+ fluid=True, classes="pa-0 flex-grow-1", style="width: 100%; height: 100%;"):
1363
+ geom_fig = plotly_widgets.Figure(figure=go.Figure(), style="width: 100%; height: 100%;", responsive=True)
1364
+ _ctrl.qlbm_geometry_plot_update = geom_fig.update
1365
+
1366
+ # Distribution Preview (Plotly)
1367
+ with vuetify3.VContainer(v_if="!qlbm_simulation_has_run && qlbm_dist_type",
1368
+ fluid=True, classes="pa-0 flex-grow-1", style="width: 100%; height: 100%;"):
1369
+ preview_fig = plotly_widgets.Figure(figure=go.Figure(), style="width:100%; height:100%;", responsive=True)
1370
+ _ctrl.qlbm_preview_update = preview_fig.update
1371
+
1372
+ # Download controls
1373
+ with vuetify3.VContainer(v_if="qlbm_simulation_has_run", classes="px-4 pt-3 pb-1 d-flex justify-end",
1374
+ style="width: 100%; flex: 0 0 auto;"):
1375
+ with vuetify3.VMenu(location="bottom end"):
1376
+ with vuetify3.Template(v_slot_activator="{ props }"):
1377
+ vuetify3.VBtn(
1378
+ v_bind="props",
1379
+ text="Download",
1380
+ color="primary",
1381
+ variant="tonal",
1382
+ prepend_icon="mdi-download"
1383
+ )
1384
+ with vuetify3.VList(density="compact"):
1385
+ vuetify3.VListItem(
1386
+ title="Export as VTK",
1387
+ prepend_icon="mdi-content-save",
1388
+ click=export_simulation_vtk
1389
+ )
1390
+ vuetify3.VListItem(
1391
+ title="Export as MP4",
1392
+ prepend_icon="mdi-movie",
1393
+ click=export_simulation_mp4
1394
+ )
1395
+
1396
+ # Simulation Result (PyVista)
1397
+ with vuetify3.VContainer(v_if="qlbm_simulation_has_run", fluid=True, classes="pa-0 flex-grow-1",
1398
+ style="width: 100%; height: 100%;"):
1399
+ view = plotter_ui(plotter)
1400
+ _ctrl.qlbm_view_update = view.update
1401
+
1402
+ # Time Slider
1403
+ with vuetify3.VContainer(v_if="qlbm_simulation_has_run", classes="px-4 pb-4", style="width: 90%; flex: 0 0 auto;"):
1404
+ with vuetify3.VSlider(
1405
+ v_model=("qlbm_time_val", 0),
1406
+ min=0,
1407
+ max=("qlbm_max_time_step", 10),
1408
+ step=1,
1409
+ label="Time",
1410
+ thumb_label="always",
1411
+ density="compact",
1412
+ hide_details=True,
1413
+ color="primary"
1414
+ ):
1415
+ vuetify3.Template(
1416
+ v_slot_thumb_label="{ modelValue }",
1417
+ children=["{{ qlbm_time_slider_labels[modelValue] || modelValue }}"]
1418
+ )
1419
+
1420
+ # Console Window
1421
+ with vuetify3.VCard(classes="mt-1", style="font-size: 0.8rem; flex: 0 0 auto;"):
1422
+ vuetify3.VCardTitle("Status", classes="text-subtitle-1 text-primary", style="font-size: 0.9rem; padding: 6px 10px;")
1423
+ with vuetify3.VCardText(classes="py-1 px-2", style="height: 150px; overflow-y: auto; background-color: #f5f5f5; font-family: monospace;"):
1424
+ vuetify3.VTextarea(
1425
+ v_model=("qlbm_console_output", ""),
1426
+ readonly=True,
1427
+ auto_grow=False,
1428
+ rows=6,
1429
+ variant="plain",
1430
+ hide_details=True,
1431
+ style="font-family: monospace; width: 100%; height: 100%;"
1432
+ )
1433
+
1434
+
1435
+ def _build_status_window():
1436
+ """Build the floating status window."""
1437
+ with vuetify3.VCard(
1438
+ v_if="qlbm_status_visible",
1439
+ style="position: fixed; bottom: 16px; right: 16px; z-index: 1000; min-width: 320px; max-width: 450px;",
1440
+ elevation=8
1441
+ ):
1442
+ with vuetify3.VCardTitle(classes="d-flex align-center", style="font-size: 0.95rem; padding: 8px 12px;"):
1443
+ vuetify3.VIcon("mdi-information-outline", size="small", classes="mr-2")
1444
+ html.Span("Simulation Status")
1445
+ vuetify3.VSpacer()
1446
+ vuetify3.VBtn(
1447
+ icon="mdi-close",
1448
+ size="x-small",
1449
+ variant="text",
1450
+ click="qlbm_status_visible = false"
1451
+ )
1452
+ vuetify3.VDivider()
1453
+ with vuetify3.VCardText(classes="py-2 px-3"):
1454
+ vuetify3.VAlert(
1455
+ type=("qlbm_status_type", "info"),
1456
+ variant="tonal",
1457
+ density="compact",
1458
+ children=["{{ qlbm_status_message }}"]
1459
+ )
1460
+ with vuetify3.VContainer(v_if="qlbm_show_progress", classes="pa-0 mt-2"):
1461
+ vuetify3.VProgressLinear(
1462
+ model_value=("qlbm_simulation_progress", 0),
1463
+ color="primary",
1464
+ height=6,
1465
+ striped=True
1466
+ )
1467
+ html.Div(
1468
+ "{{ qlbm_simulation_progress }}% complete",
1469
+ classes="text-caption text-center mt-1",
1470
+ style="font-size: 0.75rem;"
1471
+ )
1472
+