Spaces:
Paused
Paused
Commit
·
aa49145
1
Parent(s):
091edc3
New Embedded Version
Browse files- app.py +216 -239
- app_old.py +239 -0
- em_embedded.py +0 -0
- pages/em_page.py +30 -57
- pages/em_page_subprocess.py +70 -0
- pages/qlbm_page.py +32 -117
- pages/qlbm_page_subprocess.py +128 -0
- qlbm_embedded.py +1472 -0
app.py
CHANGED
|
@@ -1,239 +1,216 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
import
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
from
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
#
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 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 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 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
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
return
|
| 34 |
|
| 35 |
-
#
|
| 36 |
-
|
|
|
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 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 |
-
|
| 53 |
-
|
| 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 |
-
"""
|
| 2 |
-
|
| 3 |
-
multi-page app only needs `python app.py`.
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 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
|
| 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 |
-
|
| 45 |
-
|
| 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 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 58 |
-
|
| 59 |
-
|
| 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
|
| 108 |
-
style="
|
| 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 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 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 |
+
|