Compare commits
4 Commits
0855a064c9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fdeec1bf2e | |||
| 96548e60ea | |||
| 290c4fada2 | |||
| 56ed80b83a |
54
.gitea/workflows/build-and-push.yml
Normal file
54
.gitea/workflows/build-and-push.yml
Normal file
@@ -0,0 +1,54 @@
|
||||
name: Build and Deploy Docker Image
|
||||
|
||||
# Controls when the workflow will run. Here, it runs on every push to the 'main' branch.
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
|
||||
# Environment variables used across the workflow
|
||||
env:
|
||||
# The URL of your Gitea instance (without http:// or https://)
|
||||
GITEA_INSTANCE_URL: git.deowl.ru
|
||||
# The full name of your image (e.g., 'myusername/myproject')
|
||||
IMAGE_NAME: vkrb/client
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
# Runs the job on a runner with the 'ubuntu-latest' label.
|
||||
runs-on: ubuntu-latest
|
||||
# Optional but recommended: specifies the container image to use for the job.
|
||||
# This ensures a consistent environment with Docker tools pre-installed.
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
|
||||
steps:
|
||||
# 1. Check out your repository code so the workflow can access it.
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 2. Set up Docker Buildx, which is needed for building images.
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# 3. Log in to your Gitea instance's Container Registry.
|
||||
# It uses secrets you must define in your repository settings.
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GITEA_INSTANCE_URL }}
|
||||
username: ${{ gitea.repository_owner }}
|
||||
# Use a secret for the password/token. See Step 3 for setup.
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
# 4. Build the Docker image from your 'dockerfile_build' directory
|
||||
# and push it to the Gitea registry.
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
# The path to the directory containing your Dockerfile
|
||||
context: ./Dockerfile_build
|
||||
push: true
|
||||
# Tag the image with the Gitea instance, image name, and the git commit SHA.
|
||||
tags: |
|
||||
${{ env.GITEA_INSTANCE_URL }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.GITEA_INSTANCE_URL }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,7 +1,10 @@
|
||||
.env
|
||||
localStorage
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[codz]
|
||||
*$py.class
|
||||
.sesskey
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
9
Dockerfile_build/Dockerfile
Normal file
9
Dockerfile_build/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM pennylaneai/pennylane:v0.45.0-lightning-qubit
|
||||
WORKDIR /app
|
||||
COPY ./requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY ./source ./source
|
||||
|
||||
|
||||
ENTRYPOINT ["python", "./source/main.py"]
|
||||
9
Dockerfile_build/requirements.txt
Normal file
9
Dockerfile_build/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
aio-pika==9.5.8
|
||||
python-fasthtml==0.14.0
|
||||
pydantic==2.13.1
|
||||
psutil==7.2.2
|
||||
python-keycloak==7.1.1
|
||||
uvicorn==0.46.0
|
||||
basis-set-exchange==0.12
|
||||
openfermionpyscf==0.5
|
||||
jax==0.10.0
|
||||
86
Dockerfile_build/source/connections/keycloak.py
Normal file
86
Dockerfile_build/source/connections/keycloak.py
Normal file
@@ -0,0 +1,86 @@
|
||||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
from connections.local_files import (
|
||||
delete_client_info,
|
||||
delete_token,
|
||||
delete_verifier,
|
||||
load_token,
|
||||
save_token,
|
||||
)
|
||||
from fastcore.xtras import datetime
|
||||
|
||||
from keycloak import KeycloakOpenID
|
||||
|
||||
KEYCLOAK_CONFIG = {
|
||||
"server_url": os.environ["KEYCLOAK_URL"],
|
||||
"realm_name": os.environ["KEYCLOAK_REALM_NAME"],
|
||||
"client_id": os.environ["KEACLOAK_CLIENT_ID"],
|
||||
}
|
||||
|
||||
# Инициализация клиента Keycloak
|
||||
keycloak_openid = KeycloakOpenID(
|
||||
server_url=KEYCLOAK_CONFIG["server_url"],
|
||||
realm_name=KEYCLOAK_CONFIG["realm_name"],
|
||||
client_id=KEYCLOAK_CONFIG["client_id"],
|
||||
)
|
||||
|
||||
|
||||
def refresh_access_token() -> dict | None:
|
||||
"""Refresh the access token using refresh token"""
|
||||
token_data = load_token()
|
||||
if not token_data or not token_data.get("refresh_token"):
|
||||
return None
|
||||
|
||||
try:
|
||||
# Use Keycloak's refresh token endpoint
|
||||
refresh_token = token_data["refresh_token"]
|
||||
|
||||
# Get new token pair using refresh token
|
||||
new_tokens = keycloak_openid.refresh_token(refresh_token)
|
||||
|
||||
# Calculate new expiration time
|
||||
expires_in = new_tokens.get("expires_in", 3600)
|
||||
expires_at = datetime.now() + timedelta(seconds=expires_in)
|
||||
|
||||
# Update token data
|
||||
updated_token_data = {
|
||||
"access_token": new_tokens["access_token"],
|
||||
"refresh_token": new_tokens.get(
|
||||
"refresh_token", refresh_token
|
||||
), # Some providers return new refresh token
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"id_token": new_tokens.get("id_token", token_data.get("id_token")),
|
||||
"user_info": token_data.get("user_info"), # Preserve user info
|
||||
}
|
||||
|
||||
# Save updated tokens
|
||||
save_token(updated_token_data)
|
||||
|
||||
return updated_token_data
|
||||
|
||||
except Exception as e:
|
||||
# If refresh fails, clear all tokens and force re-login
|
||||
delete_token()
|
||||
delete_client_info()
|
||||
delete_verifier()
|
||||
return None
|
||||
|
||||
|
||||
def get_valid_access_token() -> str | None:
|
||||
"""Get a valid access token, refreshing if necessary"""
|
||||
token_data = load_token()
|
||||
|
||||
if not token_data or not token_data.get("expires_at"):
|
||||
return None
|
||||
|
||||
# Check if token is expired or about to expire (within 60 seconds)
|
||||
expires_at = datetime.fromisoformat(str(token_data.get("expires_at")))
|
||||
if expires_at <= datetime.now() + timedelta(seconds=60):
|
||||
# Token is expired or about to expire, try to refresh
|
||||
new_token_data = refresh_access_token()
|
||||
if new_token_data:
|
||||
return new_token_data["access_token"]
|
||||
return None
|
||||
|
||||
return token_data["access_token"]
|
||||
69
Dockerfile_build/source/connections/local_files.py
Normal file
69
Dockerfile_build/source/connections/local_files.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Локальные файлы для хранения данных
|
||||
TOKEN_FILE = Path(os.environ["STORAGE_PATH"] + "/token.json")
|
||||
VERIFIER_FILE = Path(os.environ["STORAGE_PATH"] + "/pkce_verifier.json")
|
||||
CLIENT_INFO_FILE = Path(os.environ["STORAGE_PATH"] + "/client_info.json")
|
||||
|
||||
|
||||
def save_token(token_data: dict):
|
||||
"""Сохранить токен в файл"""
|
||||
with open(TOKEN_FILE, "w") as f:
|
||||
json.dump(token_data, f, indent=2)
|
||||
|
||||
|
||||
def load_token() -> dict | None:
|
||||
"""Загрузить токен из файла"""
|
||||
if not TOKEN_FILE.exists():
|
||||
return None
|
||||
with open(TOKEN_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def delete_token():
|
||||
"""Удалить файл с токеном"""
|
||||
if TOKEN_FILE.exists():
|
||||
TOKEN_FILE.unlink()
|
||||
|
||||
|
||||
def save_verifier(verifier: str):
|
||||
"""Сохранить PKCE верификатор в файл"""
|
||||
with open(VERIFIER_FILE, "w") as f:
|
||||
json.dump({"verifier": verifier}, f)
|
||||
|
||||
|
||||
def load_verifier() -> str | None:
|
||||
"""Загрузить PKCE верификатор из файла"""
|
||||
if not VERIFIER_FILE.exists():
|
||||
return None
|
||||
with open(VERIFIER_FILE, "r") as f:
|
||||
data = json.load(f)
|
||||
return data.get("verifier")
|
||||
|
||||
|
||||
def delete_verifier():
|
||||
"""Удалить файл с PKCE верификатором"""
|
||||
if VERIFIER_FILE.exists():
|
||||
VERIFIER_FILE.unlink()
|
||||
|
||||
|
||||
def save_client_info(client_info: dict):
|
||||
"""Сохранить информацию о клиенте локально"""
|
||||
with open(CLIENT_INFO_FILE, "w") as f:
|
||||
json.dump(client_info, f, indent=2)
|
||||
|
||||
|
||||
def load_client_info() -> dict | None:
|
||||
"""Загрузить информацию о клиенте"""
|
||||
if not CLIENT_INFO_FILE.exists():
|
||||
return None
|
||||
with open(CLIENT_INFO_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def delete_client_info():
|
||||
"""Удалить файл с информацией о клиенте"""
|
||||
if CLIENT_INFO_FILE.exists():
|
||||
CLIENT_INFO_FILE.unlink()
|
||||
159
Dockerfile_build/source/connections/quantum_backend.py
Normal file
159
Dockerfile_build/source/connections/quantum_backend.py
Normal file
@@ -0,0 +1,159 @@
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
import requests
|
||||
|
||||
# Global var from env
|
||||
QUANTUM_BACKEND_URL = os.getenv(
|
||||
"QUANTUM_BACKEND_URL", os.environ["QUANTUM_BACKEND_URL"]
|
||||
)
|
||||
|
||||
|
||||
def get_or_create_device_by_name(
|
||||
system_name: str, max_qubits: int, access_token
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get or create a device by name.
|
||||
|
||||
Args:
|
||||
system_name: Name of the system
|
||||
max_qubits: Maximum number of qubits
|
||||
|
||||
Returns:
|
||||
Device data as dictionary
|
||||
|
||||
Raises:
|
||||
requests.RequestException: If the request fails
|
||||
"""
|
||||
|
||||
url = f"{QUANTUM_BACKEND_URL}/machine"
|
||||
payload = {"system_name": system_name, "max_qubits": max_qubits}
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "*/*",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Connection": "keep-alive",
|
||||
"Referer": url,
|
||||
"Host": "quantum-backend.deowl.ru",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, json=payload, headers=headers)
|
||||
print(response.content)
|
||||
print(response.headers)
|
||||
print(headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
print(f"Failed to get or create device: {e}")
|
||||
raise Exception(f"Failed to get or create device: {e}") from e
|
||||
|
||||
|
||||
def get_device_by_id(system_id: int, access_token) -> Dict[str, Any]:
|
||||
"""
|
||||
Get device by ID.
|
||||
|
||||
Args:
|
||||
system_id: ID of the system
|
||||
|
||||
Returns:
|
||||
Device data as dictionary
|
||||
|
||||
Raises:
|
||||
requests.RequestException: If the request fails
|
||||
"""
|
||||
url = f"{QUANTUM_BACKEND_URL}/machine/system"
|
||||
params = {"system_id": system_id}
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "*/*",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Connection": "keep-alive",
|
||||
"Referer": url,
|
||||
"Host": "quantum-backend.deowl.ru",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(url, params=params, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.RequestException as e:
|
||||
raise Exception(f"Failed to get device by ID {system_id}: {e}") from e
|
||||
|
||||
|
||||
def update_device_data(
|
||||
system_id: int,
|
||||
system_name: str,
|
||||
system_description: str,
|
||||
max_qubits: int,
|
||||
access_token,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update device data.
|
||||
|
||||
Args:
|
||||
system_id: ID of the system
|
||||
system_name: New system name
|
||||
system_description: New system description
|
||||
max_qubits: New maximum qubits
|
||||
|
||||
Returns:
|
||||
Updated device data as dictionary
|
||||
|
||||
Raises:
|
||||
requests.RequestException: If the request fails
|
||||
"""
|
||||
url = f"{QUANTUM_BACKEND_URL}/machine"
|
||||
payload = {
|
||||
"system_id": system_id,
|
||||
"system_name": system_name,
|
||||
"system_description": system_description,
|
||||
"max_qubits": max_qubits,
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "*/*",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Connection": "keep-alive",
|
||||
"Referer": url,
|
||||
"Host": "quantum-backend.deowl.ru",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.put(url, json=payload, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.RequestException as e:
|
||||
raise Exception(f"Failed to update device data for ID {system_id}: {e}") from e
|
||||
|
||||
|
||||
def get_comp_system_file(experiment_type_id: int) -> bytes:
|
||||
"""
|
||||
Get comp_system file as a string/blob.
|
||||
|
||||
Args:
|
||||
experiment_type_id: ID of the experiment type
|
||||
|
||||
Returns:
|
||||
Python module content as string
|
||||
|
||||
Raises:
|
||||
requests.RequestException: If the request fails
|
||||
"""
|
||||
url = f"{QUANTUM_BACKEND_URL}/experiment/types/comp-system"
|
||||
params = {"experiment_type_id": experiment_type_id}
|
||||
|
||||
try:
|
||||
response = requests.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
except requests.RequestException as e:
|
||||
raise Exception(
|
||||
f"Failed to get comp_system file for experiment_type_id {experiment_type_id}: {e}"
|
||||
) from e
|
||||
98
Dockerfile_build/source/connections/rabbitmq.py
Normal file
98
Dockerfile_build/source/connections/rabbitmq.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import aio_pika
|
||||
from aio_pika.abc import (
|
||||
AbstractChannel,
|
||||
AbstractRobustConnection,
|
||||
)
|
||||
from connections.keycloak import (
|
||||
get_valid_access_token,
|
||||
)
|
||||
from connections.local_files import (
|
||||
load_client_info,
|
||||
)
|
||||
|
||||
|
||||
class RabbitMQManager:
|
||||
"""Singleton manager for RabbitMQ connection and channels."""
|
||||
|
||||
_instance: Optional["RabbitMQManager"] = None
|
||||
_connection: Optional[AbstractRobustConnection] = None
|
||||
_consumer_channel: Optional[AbstractChannel] = None
|
||||
_publisher_channel: Optional[AbstractChannel] = None
|
||||
_heartbeat_channel: Optional[AbstractChannel] = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
async def connect(self):
|
||||
"""Establish the main connection if not already connected."""
|
||||
client_info = load_client_info()
|
||||
if not client_info:
|
||||
return None
|
||||
access_token = get_valid_access_token()
|
||||
if not access_token:
|
||||
return None
|
||||
if self._connection is None or self._connection.is_closed:
|
||||
print("Creating new RabbitMQ connection...")
|
||||
self._connection = await aio_pika.connect_robust(
|
||||
host=os.environ["RABBITMQ_HOST"],
|
||||
port=int(os.environ["RABBITMQ_PORT"]),
|
||||
login=str(client_info["system_id"]),
|
||||
password=access_token,
|
||||
virtualhost="/",
|
||||
)
|
||||
print("RabbitMQ connection established")
|
||||
return self._connection
|
||||
|
||||
async def get_consumer_channel(self):
|
||||
"""Get channel for consuming messages."""
|
||||
if self._connection:
|
||||
if self._consumer_channel is None or self._consumer_channel.is_closed:
|
||||
self._consumer_channel = await self._connection.channel()
|
||||
await self._consumer_channel.set_qos(prefetch_count=1, global_=True)
|
||||
print("Consumer channel created")
|
||||
return self._consumer_channel
|
||||
return self._consumer_channel
|
||||
return None
|
||||
|
||||
async def get_publisher_channel(self):
|
||||
"""Get channel for publishing regular messages."""
|
||||
if self._connection:
|
||||
if self._publisher_channel is None or self._publisher_channel.is_closed:
|
||||
self._publisher_channel = await self._connection.channel()
|
||||
print("Publisher channel created")
|
||||
return self._publisher_channel
|
||||
return None
|
||||
|
||||
async def get_heartbeat_channel(self):
|
||||
"""Get channel for heartbeat publishing."""
|
||||
if self._connection:
|
||||
if self._heartbeat_channel is None or self._heartbeat_channel.is_closed:
|
||||
self._heartbeat_channel = await self._connection.channel()
|
||||
print("Heartbeat channel created")
|
||||
return self._heartbeat_channel
|
||||
return None
|
||||
|
||||
async def close(self):
|
||||
"""Close all channels and the main connection gracefully."""
|
||||
print("Closing RabbitMQ channels and connection...")
|
||||
|
||||
for channel in [
|
||||
self._consumer_channel,
|
||||
self._publisher_channel,
|
||||
self._heartbeat_channel,
|
||||
]:
|
||||
if channel and not channel.is_closed:
|
||||
await channel.close()
|
||||
|
||||
if self._connection and not self._connection.is_closed:
|
||||
await self._connection.close()
|
||||
print("RabbitMQ connection closed")
|
||||
|
||||
|
||||
# Create global singleton instance
|
||||
rabbitmq_manager = RabbitMQManager()
|
||||
74
Dockerfile_build/source/main.py
Normal file
74
Dockerfile_build/source/main.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import asyncio
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import uvicorn
|
||||
from connections.rabbitmq import rabbitmq_manager
|
||||
from fasthtml.core import FastHTML, Mount
|
||||
from modules.fasthtml import app as fasthtml_app
|
||||
from modules.rabbitmq import consume_messages_topic, publish_heartbeat
|
||||
|
||||
|
||||
async def start_heartbeat():
|
||||
"""Start heartbeat with auto-reconnect"""
|
||||
while True:
|
||||
try:
|
||||
await publish_heartbeat()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Heartbeat failed: {e}")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
async def start_consumer():
|
||||
"""Start consumer with auto-reconnect"""
|
||||
while True:
|
||||
try:
|
||||
await consume_messages_topic()
|
||||
await asyncio.Future()
|
||||
except Exception as e:
|
||||
print(f"Consumer failed: {e}")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
async def connect_with_retry():
|
||||
"""Retry RabbitMQ connection until successful"""
|
||||
while True:
|
||||
try:
|
||||
await rabbitmq_manager.connect()
|
||||
if (
|
||||
rabbitmq_manager._connection
|
||||
and not rabbitmq_manager._connection.is_closed
|
||||
):
|
||||
print("RabbitMQ connected")
|
||||
asyncio.create_task(start_heartbeat())
|
||||
asyncio.create_task(start_consumer())
|
||||
return
|
||||
else:
|
||||
await asyncio.sleep(5)
|
||||
except Exception as e:
|
||||
print(f"RabbitMQ connection failed: {e}, retrying in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
print("Starting up...")
|
||||
|
||||
asyncio.create_task(connect_with_retry())
|
||||
|
||||
yield
|
||||
|
||||
print("Shutting down...")
|
||||
for task in asyncio.all_tasks():
|
||||
if task is not asyncio.current_task():
|
||||
task.cancel()
|
||||
|
||||
await rabbitmq_manager.close()
|
||||
|
||||
|
||||
app = FastHTML(routes=[Mount("", fasthtml_app, name="FastHTML")])
|
||||
app.set_lifespan(lifespan)
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=int(os.environ["PORT"]), reload=True)
|
||||
520
Dockerfile_build/source/modules/fasthtml.py
Normal file
520
Dockerfile_build/source/modules/fasthtml.py
Normal file
@@ -0,0 +1,520 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import psutil
|
||||
from connections.keycloak import get_valid_access_token, keycloak_openid
|
||||
from connections.local_files import (
|
||||
delete_client_info,
|
||||
delete_token,
|
||||
delete_verifier,
|
||||
load_client_info,
|
||||
load_token,
|
||||
load_verifier,
|
||||
save_client_info,
|
||||
save_token,
|
||||
save_verifier,
|
||||
)
|
||||
from connections.quantum_backend import get_device_by_id, get_or_create_device_by_name
|
||||
from connections.rabbitmq import (
|
||||
rabbitmq_manager,
|
||||
)
|
||||
from fasthtml.common import (
|
||||
H1,
|
||||
H2,
|
||||
H3,
|
||||
A,
|
||||
Button,
|
||||
Div,
|
||||
FastHTML,
|
||||
Form,
|
||||
Input,
|
||||
Meta,
|
||||
P,
|
||||
RedirectResponse,
|
||||
Script,
|
||||
Span,
|
||||
Strong,
|
||||
Titled,
|
||||
)
|
||||
from fasthtml.pico import Card, Container, picolink
|
||||
from fasthtml.xtend import Style
|
||||
from keycloak.pkce_utils import generate_code_challenge, generate_code_verifier
|
||||
from modules.rabbitmq import consume_messages_topic
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
REDIRECT_URI = f"http://localhost:{os.environ['PORT']}/callback"
|
||||
|
||||
css = Style(":root {--pico-font-size:90%,--pico-font-family: Pacifico, cursive;}")
|
||||
app = FastHTML(hdrs=(picolink, css))
|
||||
|
||||
|
||||
@app.route("/", methods="get")
|
||||
async def main_page():
|
||||
# Проверяем наличие валидного токена
|
||||
token_data = load_token()
|
||||
client_info = load_client_info()
|
||||
|
||||
if token_data:
|
||||
expires_at = datetime.fromisoformat(token_data.get("expires_at", "2000-01-01"))
|
||||
if expires_at > datetime.now():
|
||||
# Если есть токен, но нет информации о клиенте, перенаправляем на регистрацию
|
||||
if not client_info:
|
||||
return RedirectResponse("/register-client", status_code=303)
|
||||
return RedirectResponse("/dashboard", status_code=303)
|
||||
else:
|
||||
delete_token()
|
||||
delete_client_info()
|
||||
|
||||
return Titled(
|
||||
"Главная",
|
||||
H1("Добро пожаловать в Квантовый Симулятор"),
|
||||
P("Пожалуйста, войдите для продолжения"),
|
||||
A("Войти через Keycloak", href="/login", cls="button"),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/login", methods="get")
|
||||
async def login():
|
||||
# Генерация PKCE кода верификатора и запроса
|
||||
code_verifier = generate_code_verifier()
|
||||
code_challenge, code_challenge_method = generate_code_challenge(
|
||||
code_verifier, method="S256"
|
||||
)
|
||||
|
||||
# Сохраняем верификатор локально
|
||||
save_verifier(code_verifier)
|
||||
|
||||
# Формирование URL авторизации
|
||||
auth_url = keycloak_openid.auth_url(
|
||||
redirect_uri=REDIRECT_URI,
|
||||
scope="openid profile email",
|
||||
code_challenge=code_challenge,
|
||||
code_challenge_method=code_challenge_method,
|
||||
)
|
||||
|
||||
return RedirectResponse(auth_url, status_code=303)
|
||||
|
||||
|
||||
@app.route("/callback", methods="get")
|
||||
async def callback(request):
|
||||
code = request.query_params.get("code")
|
||||
|
||||
if not code:
|
||||
return Titled(
|
||||
"Ошибка", P("Код не найден"), A("Попробовать снова", href="/login")
|
||||
)
|
||||
|
||||
# Загружаем верификатор
|
||||
code_verifier = load_verifier()
|
||||
if not code_verifier:
|
||||
return Titled(
|
||||
"Ошибка",
|
||||
P("PKCE верификатор не найден"),
|
||||
A("Попробовать снова", href="/login"),
|
||||
)
|
||||
|
||||
# Очищаем верификатор сразу после использования
|
||||
delete_verifier()
|
||||
|
||||
try:
|
||||
# Обмен кода на токен
|
||||
token = keycloak_openid.token(
|
||||
grant_type="authorization_code",
|
||||
code=code,
|
||||
redirect_uri=REDIRECT_URI,
|
||||
code_verifier=code_verifier,
|
||||
)
|
||||
|
||||
# Расчет времени истечения
|
||||
expires_in = token.get("expires_in", 3600)
|
||||
expires_at = datetime.now() + timedelta(seconds=expires_in)
|
||||
|
||||
# Получение информации о пользователе
|
||||
access_token = token.get("access_token")
|
||||
if not access_token:
|
||||
raise Exception
|
||||
user_info = keycloak_openid.userinfo(access_token)
|
||||
|
||||
# Сохраняем токен
|
||||
token_data_to_save = {
|
||||
"access_token": access_token,
|
||||
"refresh_token": token.get("refresh_token"),
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"id_token": token.get("id_token"),
|
||||
"user_info": user_info,
|
||||
}
|
||||
|
||||
save_token(token_data_to_save)
|
||||
|
||||
# Перенаправление на регистрацию клиента (проверит, зарегистрирован ли уже)
|
||||
return RedirectResponse("/register-client", status_code=303)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обмене токена: {e}")
|
||||
return Titled("Ошибка", P(str(e)), A("Попробовать снова", href="/login"))
|
||||
|
||||
|
||||
@app.route("/register-client", methods="get")
|
||||
async def register_client(request):
|
||||
"""Страница регистрации клиента в центральном микросервисе"""
|
||||
token_data = load_token()
|
||||
|
||||
if not token_data:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
# Проверяем, не зарегистрирован ли уже
|
||||
client_info = load_client_info()
|
||||
if client_info:
|
||||
return RedirectResponse("/dashboard", status_code=303)
|
||||
|
||||
# Проверяем истечение токена
|
||||
expires_at = datetime.fromisoformat(str(token_data.get("expires_at")))
|
||||
if expires_at <= datetime.now():
|
||||
delete_token()
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
user_info = token_data.get("user_info", {})
|
||||
username = user_info.get(
|
||||
"preferred_username", user_info.get("name", "Пользователь")
|
||||
)
|
||||
|
||||
return Titled(
|
||||
"Регистрация клиента",
|
||||
Container(
|
||||
Card(
|
||||
H2("Добро пожаловать в Квантовый Симулятор!"),
|
||||
P(f"Здравствуйте, {username}!"),
|
||||
P("Это устройство должно быть зарегистрировано в центральном сервисе."),
|
||||
P(
|
||||
"Пожалуйста, укажите уникальное имя и количество кубит для этого клиента:"
|
||||
),
|
||||
Form(
|
||||
Input(
|
||||
type="text",
|
||||
name="client_name",
|
||||
id="client_name",
|
||||
placeholder="например: рабочий-ноутбук, домашний-пк, raspberry-pi-01",
|
||||
required=True,
|
||||
style="width: 100%; padding: 10px; margin: 10px 0;",
|
||||
),
|
||||
Input(
|
||||
type="number",
|
||||
name="number_qubits",
|
||||
id="number_qubits",
|
||||
placeholder=0,
|
||||
required=True,
|
||||
style="width: 100%; padding: 10px; margin: 10px 0;",
|
||||
),
|
||||
Div(
|
||||
Button(
|
||||
"Зарегистрировать клиент",
|
||||
type="submit",
|
||||
cls="button",
|
||||
style="background-color: #4CAF50;",
|
||||
),
|
||||
style="margin-top: 10px;",
|
||||
),
|
||||
hx_post="/register-client",
|
||||
hx_target="#registration-result",
|
||||
hx_swap="innerHTML",
|
||||
style="margin-top: 20px;",
|
||||
),
|
||||
Div(id="registration-result"),
|
||||
),
|
||||
Div(
|
||||
Button(
|
||||
"Выйти",
|
||||
onclick="window.location.href='/logout'",
|
||||
cls="button",
|
||||
style="background-color: #f44336; margin-top: 20px;",
|
||||
),
|
||||
style="text-align: center;",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/register-client", methods="post")
|
||||
async def register_client_post(request):
|
||||
"""Обработка POST запроса регистрации клиента"""
|
||||
form_data = await request.form()
|
||||
client_name = form_data.get("client_name")
|
||||
number_qubits = form_data.get("number_qubits")
|
||||
|
||||
if not number_qubits:
|
||||
return Div(
|
||||
P("❌ Обязательно необходимо указать колчество кубит", style="color: red;"),
|
||||
)
|
||||
|
||||
if not client_name:
|
||||
return Div(
|
||||
P("❌ Имя клиента обязательно", style="color: red;"),
|
||||
)
|
||||
|
||||
# Проверка формата имени клиента
|
||||
import re
|
||||
|
||||
if not re.match(r"^[a-zA-Z0-9_-]+$", client_name):
|
||||
return Div(
|
||||
P(
|
||||
"❌ Имя клиента может содержать только буквы, цифры, дефисы и подчеркивания",
|
||||
style="color: red;",
|
||||
),
|
||||
Button("Попробовать снова", onclick="location.reload()", cls="button"),
|
||||
)
|
||||
|
||||
token_data = load_token()
|
||||
if not token_data:
|
||||
return Div(
|
||||
P("❌ Сессия истекла. Пожалуйста, войдите снова.", style="color: red;"),
|
||||
A("Войти", href="/login", cls="button"),
|
||||
)
|
||||
|
||||
access_token = token_data.get("access_token")
|
||||
|
||||
# Регистрация в центральном микросервисе
|
||||
try:
|
||||
response = get_or_create_device_by_name(
|
||||
client_name, number_qubits, access_token
|
||||
)
|
||||
save_client_info(
|
||||
{
|
||||
"system_id": response["system_id"],
|
||||
}
|
||||
)
|
||||
|
||||
return Div(
|
||||
P(
|
||||
"✅ Клиент успешно зарегистрирован!",
|
||||
style="color: green; font-weight: bold;",
|
||||
),
|
||||
P(f"Имя клиента: {client_name}"),
|
||||
P("Перенаправление на панель управления..."),
|
||||
Meta(http_equiv="refresh", content="2;url=/dashboard"),
|
||||
Script("setTimeout(() => { window.location.href = '/dashboard'; }, 2000);"),
|
||||
)
|
||||
except Exception:
|
||||
return Div(
|
||||
P("❌ Ошибка регистрации", style="color: red;"),
|
||||
Button("Попробовать снова", onclick="location.reload()", cls="button"),
|
||||
P(
|
||||
"Если проблема повторяется, обратитесь к администратору.",
|
||||
style="font-size: 12px; margin-top: 10px;",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/dashboard", methods="get")
|
||||
async def dashboard():
|
||||
access_token = get_valid_access_token()
|
||||
if rabbitmq_manager._connection and access_token:
|
||||
await rabbitmq_manager._connection.update_secret(
|
||||
access_token, reason="Token expired"
|
||||
)
|
||||
if not access_token:
|
||||
# Token refresh failed, redirect to login
|
||||
return RedirectResponse("/", status_code=303)
|
||||
token_data = load_token()
|
||||
client_info = load_client_info()
|
||||
|
||||
if not token_data or not client_info:
|
||||
return RedirectResponse("/", status_code=303)
|
||||
|
||||
user_info = token_data.get("user_info", {})
|
||||
username = user_info.get(
|
||||
"preferred_username", user_info.get("name", "Пользователь")
|
||||
)
|
||||
system_id = client_info.get("system_id", None)
|
||||
|
||||
if not token_data:
|
||||
return Div(
|
||||
P("❌ Сессия истекла. Пожалуйста, войдите снова.", style="color: red;"),
|
||||
A("Войти", href="/login", cls="button"),
|
||||
)
|
||||
try:
|
||||
if system_id:
|
||||
device_data = get_device_by_id(system_id, access_token)
|
||||
device_name = device_data["system"]["system_name"]
|
||||
max_qubits = device_data["system"]["max_qubits"]
|
||||
created_at = device_data["system"]["created_at"]
|
||||
else:
|
||||
device_name = "error"
|
||||
max_qubits = "error"
|
||||
created_at = "error"
|
||||
except Exception as a:
|
||||
device_name = "error"
|
||||
max_qubits = "error"
|
||||
created_at = "error"
|
||||
|
||||
return Titled(
|
||||
"Панель управления",
|
||||
Container(
|
||||
Card(
|
||||
P(Strong("Имя пользователя: "), username),
|
||||
P(Strong("Email: "), user_info.get("email", "Не указан")),
|
||||
P(Strong("Устройство зарегестрировано: "), created_at),
|
||||
Div(style="border: 1px solid black"),
|
||||
P(Strong("Имя вычислительной системы: "), device_name),
|
||||
P(Strong("Макс. количество кубит: "), max_qubits),
|
||||
Div(Button("Изменить", style="background-color: blue;")),
|
||||
Div(
|
||||
id="client-status",
|
||||
hx_get="/client-status",
|
||||
hx_trigger="load, every 15s",
|
||||
),
|
||||
),
|
||||
Div(id="memory-stats", hx_get="/memory-stats", hx_trigger="load, every 5s"),
|
||||
A(
|
||||
Button(
|
||||
"Выйти",
|
||||
style="background-color: #f44336;",
|
||||
),
|
||||
href="/logout",
|
||||
style="margin: 0px;color: inherit;text-decoration: inherit;",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/client-status", methods="get")
|
||||
async def status():
|
||||
"""Получить статус клиента из центрального микросервиса"""
|
||||
access_token = get_valid_access_token()
|
||||
client_info = load_client_info()
|
||||
|
||||
if not access_token or not client_info:
|
||||
return P("Клиент не зарегистрирован", style="color: orange;")
|
||||
|
||||
if rabbitmq_manager._connection:
|
||||
# TODO: FIX
|
||||
status = (
|
||||
rabbitmq_manager._connection.connected
|
||||
) # get_client_status(client_name, access_token)
|
||||
|
||||
if status:
|
||||
await consume_messages_topic()
|
||||
|
||||
return Div(
|
||||
P(
|
||||
Strong("Статус клиента: "),
|
||||
Span("✅ Активен", style="color: green;"),
|
||||
),
|
||||
style="padding: 10px; border-radius: 5px; margin: 10px 0;",
|
||||
)
|
||||
else:
|
||||
return Div(
|
||||
P(
|
||||
Strong("Статус клиента: "),
|
||||
Span("Не подключен", style="color: orange;"),
|
||||
),
|
||||
P(
|
||||
"Не удалось связаться с центральным сервисом или данному клиенту отказано в подключении"
|
||||
),
|
||||
style="padding: 10px; border-radius: 5px; margin: 10px 0;",
|
||||
)
|
||||
else:
|
||||
return Div(
|
||||
P(
|
||||
Strong("Статус клиента: "),
|
||||
Span("Не подключен", style="color: orange;"),
|
||||
),
|
||||
P(
|
||||
"Не удалось связаться с центральным сервисом или данному клиенту отказано в подключении"
|
||||
),
|
||||
style="padding: 10px; border-radius: 5px; margin: 10px 0;",
|
||||
)
|
||||
|
||||
|
||||
@app.route("/memory-stats", methods="get")
|
||||
async def memory_stats():
|
||||
"""Endpoint, возвращающий карточку использования памяти (для обновления через HTMX)"""
|
||||
process = psutil.Process(os.getpid())
|
||||
memory_info = process.memory_info()
|
||||
current_time = datetime.now()
|
||||
timestamp = current_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def format_bytes(bytes):
|
||||
for unit in ["Б", "КБ", "МБ", "ГБ"]:
|
||||
if bytes < 1024.0:
|
||||
return f"{bytes:.1f} {unit}"
|
||||
bytes /= 1024.0
|
||||
return f"{bytes:.1f} ГБ"
|
||||
|
||||
mem = 0
|
||||
if os.path.isfile("/sys/fs/cgroup/memory.max"):
|
||||
with open("/sys/fs/cgroup/memory.max") as limit:
|
||||
try:
|
||||
mem = int(limit.read())
|
||||
except Exception:
|
||||
mem = 0
|
||||
# resource.setrlimit(resource.RLIMIT_AS, (mem, mem))
|
||||
|
||||
rss_memory = format_bytes(memory_info.rss)
|
||||
if mem > 0:
|
||||
memory_percent = (memory_info.rss / mem) * 100
|
||||
else:
|
||||
memory_percent = process.memory_percent()
|
||||
|
||||
bar_color = (
|
||||
"#4CAF50"
|
||||
if memory_percent < 1
|
||||
else "#FFC107"
|
||||
if memory_percent < 5
|
||||
else "#F44336"
|
||||
)
|
||||
|
||||
return Div(
|
||||
H2("Использование памяти"),
|
||||
Card(
|
||||
Div(
|
||||
H3(rss_memory),
|
||||
style="width: 100%;display:flex;flex-direction:column;align-items:center",
|
||||
),
|
||||
Div(
|
||||
Div(
|
||||
style=f"width: {min(memory_percent, 100)}%; background-color: {bar_color}; height: 20px; border-radius: 10px;"
|
||||
),
|
||||
style="width: 100%; background-color: #e0e0e0; border-radius: 10px; overflow: hidden; margin: 10px 0;",
|
||||
),
|
||||
P(
|
||||
f"{memory_percent:.2f}% от системной памяти",
|
||||
style="text-align: center; font-size: 12px;",
|
||||
),
|
||||
P(
|
||||
Strong("Обновлено: "),
|
||||
timestamp,
|
||||
style="text-align: center; font-size: 11px; color: #666;",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/logout", methods="get")
|
||||
async def logout():
|
||||
token_data = load_token()
|
||||
|
||||
if token_data and token_data.get("refresh_token"):
|
||||
keycloak_openid.logout(token_data["refresh_token"])
|
||||
# Очищаем локальные файлы
|
||||
delete_token()
|
||||
delete_client_info()
|
||||
delete_verifier()
|
||||
|
||||
return Titled(
|
||||
"Выход выполнен",
|
||||
H2("До свидания!"),
|
||||
P("Вы успешно вышли из системы."),
|
||||
P("Ваш клиент был отменен в центральном сервисе."),
|
||||
A("Войти снова", href="/", cls="button"),
|
||||
)
|
||||
|
||||
|
||||
# Опционально: endpoint для проверки работоспособности для центрального сервиса
|
||||
@app.route("/health", methods="get")
|
||||
async def health():
|
||||
return {"status": "healthy", "timestamp": datetime.now().isoformat()}
|
||||
410
Dockerfile_build/source/modules/rabbitmq.py
Normal file
410
Dockerfile_build/source/modules/rabbitmq.py
Normal file
@@ -0,0 +1,410 @@
|
||||
import asyncio
|
||||
import json
|
||||
from multiprocessing import Pipe, Process
|
||||
from multiprocessing.connection import Connection
|
||||
from time import sleep
|
||||
|
||||
import aio_pika
|
||||
import pennylane
|
||||
from aio_pika.abc import AbstractIncomingMessage
|
||||
from connections.keycloak import (
|
||||
get_valid_access_token,
|
||||
)
|
||||
from connections.local_files import load_client_info
|
||||
from connections.quantum_backend import (
|
||||
get_device_by_id,
|
||||
)
|
||||
from connections.rabbitmq import rabbitmq_manager
|
||||
from fastcore.xtras import datetime
|
||||
from modules.vqe import prepare_data, run_computation
|
||||
from pennylane.devices import Device
|
||||
from uvicorn.main import logger
|
||||
|
||||
HEARTBEAT_EXCHANGE = "heartbeat"
|
||||
HEARTBEAT_INTERVAL = 5
|
||||
EXCHANGE_NAME = "progress_report"
|
||||
|
||||
# Global flag to track if VQE is busy
|
||||
vqe_busy = False
|
||||
vqe_busy_lock = asyncio.Lock()
|
||||
|
||||
|
||||
# --- RabbitMQ Consumer Logic ---
|
||||
async def process_message(message: AbstractIncomingMessage):
|
||||
"""Your business logic for handling a message."""
|
||||
global vqe_busy
|
||||
|
||||
async with message.process():
|
||||
body = message.body.decode()
|
||||
print(f"Received and processing: {body}")
|
||||
|
||||
# Parse the JSON message
|
||||
data = json.loads(body)
|
||||
task_id = data.get("task_id")
|
||||
qubits_needed = data.get("qubits_needed")
|
||||
message_data = data.get("data", {})
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Extract molecular information from the text
|
||||
dev = pennylane.device("lightning.qubit", wires=qubits_needed)
|
||||
|
||||
sleep(1)
|
||||
|
||||
await publish_message(task_id, "", "IN SYSTEM")
|
||||
|
||||
data = prepare_data(message_data)
|
||||
|
||||
parent_conn, child_conn = Pipe()
|
||||
|
||||
# Set busy flag before starting VQE
|
||||
async with vqe_busy_lock:
|
||||
vqe_busy = True
|
||||
|
||||
try:
|
||||
# Run VQE in a separate process - will exit automatically when done
|
||||
vqe_process = Process(
|
||||
target=run_vqe_process,
|
||||
args=(child_conn, dev, data),
|
||||
)
|
||||
vqe_process.start()
|
||||
|
||||
# Monitor pipe in a separate thread to avoid blocking the event loop
|
||||
def monitor_pipe_thread():
|
||||
while vqe_process.is_alive():
|
||||
if parent_conn.poll():
|
||||
logger.info("polling success")
|
||||
result = parent_conn.recv()
|
||||
|
||||
if isinstance(result, dict) and result.get("status") == "ERROR":
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
publish_message(task_id, json.dumps(result), "ERROR"),
|
||||
loop,
|
||||
)
|
||||
break
|
||||
|
||||
# Check for sentinel
|
||||
if result is None:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
publish_message(task_id, "", "COMPLETE"), loop
|
||||
)
|
||||
break
|
||||
|
||||
# Drain to get latest result
|
||||
while parent_conn.poll():
|
||||
result = parent_conn.recv()
|
||||
if result is None:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
publish_message(task_id, "", "COMPLETE"), loop
|
||||
)
|
||||
break
|
||||
|
||||
if result is not None:
|
||||
logger.info(f"publishing {result}")
|
||||
# Run async publish in the event loop
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
publish_message(task_id, json.dumps(result)), loop
|
||||
)
|
||||
else:
|
||||
import time
|
||||
|
||||
time.sleep(5)
|
||||
else:
|
||||
if vqe_process.exitcode == 0:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
publish_message(task_id, "", "COMPLETE"), loop
|
||||
)
|
||||
else:
|
||||
if parent_conn.poll():
|
||||
logger.info("polling success")
|
||||
result = parent_conn.recv()
|
||||
|
||||
if (
|
||||
isinstance(result, dict)
|
||||
and result.get("status") == "ERROR"
|
||||
):
|
||||
print(f"ERROR ERROR ERROR ERROR ERROR ERROR")
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
publish_message(
|
||||
task_id, json.dumps(result), "ERROR"
|
||||
),
|
||||
loop,
|
||||
)
|
||||
|
||||
# Run the monitor in a thread
|
||||
import threading
|
||||
|
||||
monitor_thread = threading.Thread(target=monitor_pipe_thread, daemon=True)
|
||||
monitor_thread.start()
|
||||
|
||||
# Wait for the process to finish
|
||||
await asyncio.to_thread(vqe_process.join)
|
||||
|
||||
# Wait for monitor thread to finish
|
||||
monitor_thread.join(timeout=1)
|
||||
|
||||
print(f"Completed task {task_id}")
|
||||
|
||||
finally:
|
||||
# Clear busy flag after VQE completes (success or failure)
|
||||
async with vqe_busy_lock:
|
||||
vqe_busy = False
|
||||
|
||||
|
||||
def run_vqe_process(conn: Connection, dev: Device, data: dict):
|
||||
"""Run VQE in a separate process - process exits when this function returns."""
|
||||
try:
|
||||
# Run VQE
|
||||
run_computation(conn=conn, dev1=dev, data=data)
|
||||
|
||||
# Send sentinel to indicate completion
|
||||
conn.send(None) # Signal completion
|
||||
conn.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"Error in VQE: {e}")
|
||||
import traceback
|
||||
|
||||
# Send error information through the pipe BEFORE sending None
|
||||
error_msg = {
|
||||
"error": str(e),
|
||||
"traceback": traceback.format_exc(),
|
||||
"status": "ERROR",
|
||||
}
|
||||
conn.send(error_msg) # Send error details
|
||||
conn.send(None) # Signal completion
|
||||
import time
|
||||
|
||||
time.sleep(5)
|
||||
conn.close()
|
||||
|
||||
|
||||
# Global variable to track current subscriptions
|
||||
_current_subscriptions = {} # Format: {queue_name: {"team_id": team_id, "qubits": qubits, "consumer": consumer, "queue": queue}}
|
||||
|
||||
|
||||
async def consume_messages_topic():
|
||||
"""
|
||||
Subscribe to team-specific qubit queues.
|
||||
Multiple systems subscribe to the SAME queue: team_{team_id}.qubits_{N}
|
||||
where N is the number of qubits this system can handle (1 to max_qubits).
|
||||
|
||||
This enables round-robin task distribution among systems with sufficient qubits.
|
||||
|
||||
Dynamically manages subscriptions: adds new queues and removes obsolete ones.
|
||||
"""
|
||||
channel = await rabbitmq_manager.get_consumer_channel()
|
||||
if not channel:
|
||||
raise Exception("Failed to get consumer channel")
|
||||
|
||||
client_info = load_client_info()
|
||||
if not client_info:
|
||||
raise Exception("Failed to load client info")
|
||||
|
||||
system_id = client_info["system_id"]
|
||||
access_token = get_valid_access_token()
|
||||
device_data = get_device_by_id(system_id, access_token)
|
||||
teams = device_data["teams"]
|
||||
|
||||
if not teams:
|
||||
print(f"System {system_id} is not part of any team. No queues to subscribe.")
|
||||
# Unsubscribe from all existing queues if no teams
|
||||
await _unsubscribe_all()
|
||||
return
|
||||
|
||||
# Build expected queues from current teams
|
||||
expected_queues = set()
|
||||
for team in teams:
|
||||
for qubits in range(1, team["num_qubits"] + 1):
|
||||
queue_name = f"team_{team['team']['team_id']}.qubits_{qubits}"
|
||||
expected_queues.add(queue_name)
|
||||
|
||||
# Get current subscriptions
|
||||
current_queues = set(_current_subscriptions.keys())
|
||||
|
||||
# Queues to add (in expected but not current)
|
||||
queues_to_add = expected_queues - current_queues
|
||||
|
||||
# Queues to remove (in current but not expected)
|
||||
queues_to_remove = current_queues - expected_queues
|
||||
|
||||
# Unsubscribe from queues that no longer exist
|
||||
for queue_name in queues_to_remove:
|
||||
subscription_info = _current_subscriptions[queue_name]
|
||||
queue = subscription_info["queue"]
|
||||
cons = subscription_info["consumer"]
|
||||
try:
|
||||
# Cancel the consumer directly on the consumer object
|
||||
await queue.cancel(consumer_tag=cons)
|
||||
print(f"System {system_id} unsubscribed from queue: {queue_name}")
|
||||
del _current_subscriptions[queue_name]
|
||||
except Exception as e:
|
||||
print(f"Error unsubscribing from {queue_name}: {e}")
|
||||
|
||||
# Subscribe to new queues
|
||||
for queue_name in queues_to_add:
|
||||
# Extract qubits from queue name
|
||||
qubits = int(queue_name.split(".qubits_")[1])
|
||||
team_id = queue_name.split(".qubits_")[0].replace("team_", "")
|
||||
|
||||
# Declare the queue (durable, shared among multiple consumers)
|
||||
queue = await channel.declare_queue(
|
||||
queue_name,
|
||||
durable=True,
|
||||
arguments={
|
||||
"x-max-priority": 100, # Allow priorities 0-10
|
||||
},
|
||||
)
|
||||
|
||||
# Start consuming from this queue
|
||||
# In aio-pika, queue.consume returns a Consumer object
|
||||
consumer = await queue.consume(
|
||||
process_message,
|
||||
arguments={"x-priority": qubits, "x-priority-max": 10},
|
||||
)
|
||||
|
||||
# Store subscription info with consumer object
|
||||
_current_subscriptions[queue_name] = {
|
||||
"team_id": team_id,
|
||||
"qubits": qubits,
|
||||
"consumer": consumer,
|
||||
"queue": queue,
|
||||
}
|
||||
|
||||
print(f"System {system_id} subscribed to queue: {queue_name}")
|
||||
|
||||
# Print summary
|
||||
if queues_to_add or queues_to_remove:
|
||||
print(
|
||||
f"System {system_id} subscription update - Active: {len(_current_subscriptions)} queues, "
|
||||
f"Added: {len(queues_to_add)}, Removed: {len(queues_to_remove)}"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"System {system_id} subscriptions unchanged - Active: {len(_current_subscriptions)} queues"
|
||||
)
|
||||
|
||||
|
||||
async def _unsubscribe_all():
|
||||
"""
|
||||
Helper function to unsubscribe from all queues.
|
||||
"""
|
||||
for queue_name, subscription_info in list(_current_subscriptions.items()):
|
||||
consumer = subscription_info["consumer"]
|
||||
try:
|
||||
# Cancel the consumer directly on the consumer object
|
||||
await consumer.cancel()
|
||||
print(f"Unsubscribed from queue: {queue_name}")
|
||||
del _current_subscriptions[queue_name]
|
||||
except Exception as e:
|
||||
print(f"Error unsubscribing from {queue_name}: {e}")
|
||||
|
||||
|
||||
def get_current_subscriptions():
|
||||
"""
|
||||
Helper function to get current subscriptions for debugging.
|
||||
"""
|
||||
# Return a serializable version without the consumer and queue objects
|
||||
return {
|
||||
queue_name: {
|
||||
"team_id": info["team_id"],
|
||||
"qubits": info["qubits"],
|
||||
"consumer_tag": info["consumer"].consumer_tag if info["consumer"] else None,
|
||||
}
|
||||
for queue_name, info in _current_subscriptions.items()
|
||||
}
|
||||
|
||||
|
||||
# --- Heartbeat Publisher Logic ---
|
||||
async def publish_heartbeat():
|
||||
"""Continuously publish 'alive' or 'busy' messages using the shared connection."""
|
||||
global vqe_busy
|
||||
|
||||
channel = await rabbitmq_manager.get_heartbeat_channel()
|
||||
if not channel:
|
||||
raise Exception("Failed to get heartbeat channel")
|
||||
|
||||
exchange = await channel.declare_exchange(
|
||||
HEARTBEAT_EXCHANGE, type=aio_pika.ExchangeType.FANOUT, durable=True
|
||||
)
|
||||
print(
|
||||
f"Heartbeat publisher started, sending status every {HEARTBEAT_INTERVAL} seconds..."
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
client_info = load_client_info()
|
||||
if not client_info:
|
||||
raise Exception("Failed to load client info")
|
||||
|
||||
system_id = client_info["system_id"]
|
||||
|
||||
# Check current busy status
|
||||
async with vqe_busy_lock:
|
||||
current_status = "BUSY" if vqe_busy else "ONLINE"
|
||||
|
||||
# Add timestamp for better monitoring
|
||||
heartbeat_message = {
|
||||
"device_id": system_id,
|
||||
"status": current_status,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
message_body = json.dumps(heartbeat_message)
|
||||
await exchange.publish(
|
||||
aio_pika.Message(
|
||||
body=message_body.encode(),
|
||||
delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
|
||||
),
|
||||
routing_key="",
|
||||
)
|
||||
print(
|
||||
f"Heartbeat sent: {current_status} at {asyncio.get_event_loop().time():.2f}"
|
||||
)
|
||||
await asyncio.sleep(HEARTBEAT_INTERVAL)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error publishing heartbeat: {e}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def publish_message(task_id: int, message: str, status: str = "PROCESSING"):
|
||||
"""Publish a message to the exchange using shared connection."""
|
||||
try:
|
||||
channel = await rabbitmq_manager.get_publisher_channel()
|
||||
if not channel:
|
||||
raise Exception("Failed to get publisher channel")
|
||||
|
||||
# Declare exchange instead of queue
|
||||
exchange = await channel.declare_exchange(
|
||||
EXCHANGE_NAME, # Using QUEUE_NAME as exchange name
|
||||
type=aio_pika.ExchangeType.DIRECT, # or TOPIC/FANOUT based on your needs
|
||||
durable=True,
|
||||
)
|
||||
|
||||
client_info = load_client_info()
|
||||
if not client_info:
|
||||
raise Exception("Failed to load client info")
|
||||
|
||||
system_id = client_info["system_id"]
|
||||
|
||||
# Publish directly to the exchange
|
||||
await exchange.publish(
|
||||
aio_pika.Message(
|
||||
body=message.encode(),
|
||||
headers={
|
||||
"task_id": str(task_id),
|
||||
"status": status, # "PROCESSING" or "COMPLETE"
|
||||
"system_id": str(system_id),
|
||||
},
|
||||
content_type="application/json",
|
||||
delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
|
||||
),
|
||||
routing_key="", # Empty routing key for direct exchange, or use task_id as routing key
|
||||
)
|
||||
print(f"Message published to exchange {EXCHANGE_NAME}: {message}")
|
||||
except Exception as e:
|
||||
print(f"Error publishing message: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
173
Dockerfile_build/source/modules/vqe.py
Normal file
173
Dockerfile_build/source/modules/vqe.py
Normal file
@@ -0,0 +1,173 @@
|
||||
import os
|
||||
from multiprocessing.connection import Connection
|
||||
from typing import List
|
||||
|
||||
import jax
|
||||
import pennylane as qml
|
||||
import pennylane.numpy as np
|
||||
from jax import numpy as jnp
|
||||
from pennylane import qchem
|
||||
from pennylane.devices import Device
|
||||
from pennylane.optimize import GradientDescentOptimizer
|
||||
|
||||
jax.config.update("jax_enable_x64", True)
|
||||
|
||||
os.environ["OMP_NUM_THREADS"] = "16"
|
||||
|
||||
|
||||
def parse_xyz_from_text(text: str):
|
||||
"""Parse XYZ format from text content."""
|
||||
lines = text.strip().split("\n")
|
||||
|
||||
# First line: number of atoms
|
||||
num_atoms = int(lines[0].strip())
|
||||
|
||||
# Second line: Charge/Multiplicity/Electrons/Orbitals (optional)
|
||||
# Skip or parse as needed
|
||||
|
||||
symbols = []
|
||||
coordinates = []
|
||||
|
||||
# Parse atom lines (after the second line)
|
||||
for line in lines[2 : 2 + num_atoms]:
|
||||
parts = line.strip().split()
|
||||
if len(parts) >= 4:
|
||||
symbol = parts[0]
|
||||
x, y, z = float(parts[1]), float(parts[2]), float(parts[3])
|
||||
symbols.append(symbol)
|
||||
coordinates.append([x, y, z])
|
||||
|
||||
return symbols, coordinates
|
||||
|
||||
|
||||
def extract_electron_info(text: str):
|
||||
"""Extract electron and orbital counts from the second line."""
|
||||
lines = text.strip().split("\n")
|
||||
if len(lines) >= 2:
|
||||
second_line = lines[1]
|
||||
# Parse "Charge=0 Multiplicity=1 Electrons=3 Orbitals=3"
|
||||
electrons = 3 # default
|
||||
orbitals = 3 # default
|
||||
charge = 0 # default
|
||||
multiplicity = 1 # default
|
||||
|
||||
for part in second_line.split():
|
||||
if "Electrons=" in part:
|
||||
electrons = int(part.split("=")[1])
|
||||
elif "Orbitals=" in part:
|
||||
orbitals = int(part.split("=")[1])
|
||||
elif "Charge=" in part:
|
||||
charge = int(part.split("=")[1])
|
||||
elif "Multiplicity=" in part:
|
||||
multiplicity = int(part.split("=")[1])
|
||||
|
||||
return electrons, orbitals, charge, multiplicity
|
||||
|
||||
return 3, 3, 0, 1 # fallback defaults
|
||||
|
||||
|
||||
def prepare_data(data):
|
||||
text_content = data.get("text", "")
|
||||
|
||||
# Parse the molecular data (assuming it's in XYZ format)
|
||||
symbols, coordinates = parse_xyz_from_text(text_content)
|
||||
|
||||
# Extract electron/orbital info (from the Charge/Multiplicity line)
|
||||
# "Charge=0 Multiplicity=1 Electrons=3 Orbitals=3"
|
||||
electrons, orbitals, charge, multiplicity = extract_electron_info(text_content)
|
||||
|
||||
return {
|
||||
"symbols": symbols,
|
||||
"coordinates": coordinates,
|
||||
"charge": charge,
|
||||
"multiplicity": multiplicity,
|
||||
"active_electrons": electrons,
|
||||
"active_orbitals": orbitals,
|
||||
"max_iterations": data.get("max_iterations", 200),
|
||||
"conv_tol": data.get("conv_tol", 1e-6),
|
||||
"step_size": data.get("step_size", 0.05),
|
||||
}
|
||||
|
||||
|
||||
def run_computation(conn: Connection, dev1: Device, data: dict):
|
||||
coordinates = jnp.array(data.get("coordinates"))
|
||||
charge = int(data.get("charge"))
|
||||
multiplicity = int(data.get("multiplicity"))
|
||||
|
||||
molecule = qchem.Molecule(
|
||||
data.get("symbols"),
|
||||
coordinates,
|
||||
charge=charge,
|
||||
mult=multiplicity,
|
||||
)
|
||||
|
||||
active_electrons = int(data.get("active_electrons"))
|
||||
active_orbitals = int(data.get("active_orbitals"))
|
||||
|
||||
max_iterations = int(data.get("max_iterations", 200))
|
||||
step_size = float(data.get("step_size", 0.05))
|
||||
conv_tol = float(data.get("conv_tol", 1e-6))
|
||||
|
||||
H, qubits = qchem.molecular_hamiltonian(
|
||||
molecule,
|
||||
active_electrons=active_electrons,
|
||||
active_orbitals=active_orbitals,
|
||||
method="openfermion",
|
||||
) # type: ignore
|
||||
|
||||
singles, doubles = qml.qchem.excitations(active_electrons, qubits)
|
||||
|
||||
if False:
|
||||
params = np.array(last_state, requires_grad=True)
|
||||
else:
|
||||
params = np.array(np.zeros(len(singles) + len(doubles)), requires_grad=True)
|
||||
|
||||
conn.send(
|
||||
{
|
||||
"iter_num": 0,
|
||||
"energy": None,
|
||||
"conv": None,
|
||||
"params": params.tolist() if hasattr(params, "tolist") else list(params),
|
||||
}
|
||||
)
|
||||
|
||||
@qml.qnode(dev1)
|
||||
def circuit(param, wires):
|
||||
# Map excitations to the wires the UCCSD circuit will act on
|
||||
s_wires, d_wires = qml.qchem.excitations_to_wires(singles, doubles)
|
||||
qml.UCCSD(
|
||||
param,
|
||||
wires,
|
||||
s_wires=s_wires,
|
||||
d_wires=d_wires,
|
||||
init_state=qml.qchem.hf_state(active_electrons, qubits),
|
||||
)
|
||||
return qml.expval(H)
|
||||
|
||||
def cost_fn(param):
|
||||
return circuit(param, wires=range(qubits))
|
||||
|
||||
opt = GradientDescentOptimizer(stepsize=step_size)
|
||||
|
||||
for n in range(max_iterations):
|
||||
# Take step
|
||||
params, prev_energy = opt.step_and_cost(cost_fn, params)
|
||||
|
||||
energy = cost_fn(params)
|
||||
|
||||
# Calculate difference between new and old energies
|
||||
conv = np.abs(energy - prev_energy)
|
||||
|
||||
conn.send(
|
||||
{
|
||||
"iter_num": n,
|
||||
"energy": float(energy),
|
||||
"conv": float(conv),
|
||||
"params": params.tolist()
|
||||
if hasattr(params, "tolist")
|
||||
else list(params),
|
||||
}
|
||||
)
|
||||
|
||||
if conv <= conv_tol:
|
||||
break
|
||||
@@ -1,10 +1,11 @@
|
||||
services:
|
||||
local_quantum_app:
|
||||
build:
|
||||
context: .
|
||||
env_file: .env
|
||||
image: git.deowl.ru/vkrb/client:0.1.0
|
||||
container_name: quantum-client
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
- 5001:5001
|
||||
environment:
|
||||
MQTT_HOST: mqtt.deowl.ru
|
||||
MQTT_PORT: 1883
|
||||
- ${PORT}:${PORT}
|
||||
volumes:
|
||||
- ./localStorage:${STORAGE_PATH}
|
||||
|
||||
103
main.py
103
main.py
@@ -1,103 +0,0 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
from ast import Lambda
|
||||
from multiprocessing import Process, Queue
|
||||
from socket import timeout
|
||||
from time import sleep, time
|
||||
|
||||
import aio_pika
|
||||
import source.vqe as vqe
|
||||
from fasthtml.common import Div, P, fast_app, serve
|
||||
|
||||
logger = logging.basicConfig(level=logging.INFO)
|
||||
app, rt = fast_app()
|
||||
|
||||
|
||||
async def rabbit_worker():
|
||||
broker_address = os.environ.get("MQTT_HOST") # Example public broker
|
||||
broker_port = os.environ.get("MQTT_PORT")
|
||||
|
||||
if not broker_address or not broker_port:
|
||||
if not broker_address:
|
||||
logging.fatal("Not Found Environment Variable: MQTT_HOST")
|
||||
if not broker_port:
|
||||
logging.fatal("Not Found Environment Variable: MQTT_PORT")
|
||||
logging.info("Shutting down")
|
||||
return
|
||||
logging.info(f"starting connection: {broker_address}, {broker_port}")
|
||||
connection = await aio_pika.connect(
|
||||
host=broker_address, port=int(broker_port), timeout=5
|
||||
)
|
||||
logging.info("debug")
|
||||
channel = await connection.channel()
|
||||
queue = await channel.declare_queue("my_queue", durable=True)
|
||||
|
||||
async with queue.iterator() as queue_iter:
|
||||
async for message in queue_iter:
|
||||
async with message.process():
|
||||
asyncio.create_task(handle_message(message.body))
|
||||
|
||||
|
||||
async def handle_message(body):
|
||||
print("Processing:", body)
|
||||
await asyncio.sleep(2) # simulate work
|
||||
|
||||
|
||||
def handle_done(body):
|
||||
logging.info(f"done, {body}")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
global rabbit_task
|
||||
rabbit_task = asyncio.create_task(rabbit_worker())
|
||||
rabbit_task.add_done_callback(handle_done)
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown():
|
||||
rabbit_task.cancel()
|
||||
|
||||
|
||||
@rt("/")
|
||||
def get():
|
||||
return Div(P("Hello World!"), hx_get="/change")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve()
|
||||
|
||||
"""
|
||||
# GLOBAL SYSTEM PARAMETERS
|
||||
active_electrons = 2
|
||||
active_orbitals = 2
|
||||
max_iterations = 500
|
||||
conv_tol = 1e-04
|
||||
step_size = 0.05
|
||||
|
||||
q = Queue()
|
||||
p = Process(
|
||||
target=vqe.run_vqe,
|
||||
args=(
|
||||
q,
|
||||
symbols,
|
||||
coordinates,
|
||||
active_electrons,
|
||||
active_orbitals,
|
||||
max_iterations,
|
||||
conv_tol,
|
||||
step_size,
|
||||
),
|
||||
)
|
||||
p.start()
|
||||
while p.is_alive():
|
||||
try:
|
||||
print(q.get_nowait(), datetime.datetime.now())
|
||||
except Exception as e:
|
||||
print("no_data_to_get", e)
|
||||
sleep(0.1)
|
||||
|
||||
# client.loop_stop() # Stop the background loop thread if used
|
||||
# client.disconnect()"""
|
||||
@@ -1,2 +0,0 @@
|
||||
aio-pika==9.5.8
|
||||
python-fasthtml==0.12.41
|
||||
@@ -1,46 +0,0 @@
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
|
||||
import pika
|
||||
|
||||
context = ssl.create_default_context()
|
||||
context.verify_mode = ssl.CERT_REQUIRED
|
||||
|
||||
ssl_options = pika.SSLOptions(context=context, server_hostname="rabbitmq.deowl.ru")
|
||||
|
||||
credential = pika.PlainCredentials("test", "test")
|
||||
|
||||
|
||||
def main():
|
||||
connection = pika.BlockingConnection(
|
||||
pika.ConnectionParameters(
|
||||
"rabbitmq.deowl.ru",
|
||||
virtual_host="/",
|
||||
port=5671,
|
||||
ssl_options=ssl_options,
|
||||
credentials=credential,
|
||||
)
|
||||
)
|
||||
channel = connection.channel()
|
||||
|
||||
channel.queue_declare(queue="hello")
|
||||
|
||||
def callback(ch, method, properties, body):
|
||||
print(f" [x] Received {body}")
|
||||
|
||||
channel.basic_consume(queue="hello", on_message_callback=callback, auto_ack=True)
|
||||
|
||||
print(" [*] Waiting for messages. To exit press CTRL+C")
|
||||
channel.start_consuming()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("Interrupted")
|
||||
try:
|
||||
sys.exit(0)
|
||||
except SystemExit:
|
||||
os._exit(0)
|
||||
@@ -1,63 +0,0 @@
|
||||
import os
|
||||
from multiprocessing import Queue
|
||||
|
||||
import pennylane as qml
|
||||
from pennylane import numpy as np
|
||||
|
||||
os.environ["OMP_NUM_THREADS"] = "16"
|
||||
|
||||
|
||||
def get_sctructure_from_xyz_path(path: str):
|
||||
return qml.qchem.read_structure("methane.xyz")
|
||||
|
||||
|
||||
def run_vqe(
|
||||
queue_callback: Queue,
|
||||
symbols,
|
||||
coordinates,
|
||||
active_electrons,
|
||||
active_orbitals,
|
||||
max_iterations,
|
||||
conv_tol,
|
||||
step_size,
|
||||
):
|
||||
molecule = qml.qchem.Molecule(symbols, coordinates, load_data=True)
|
||||
H, qubits = qml.qchem.molecular_hamiltonian(
|
||||
molecule, active_electrons=active_electrons, active_orbitals=active_orbitals
|
||||
)
|
||||
dev = qml.device("lightning.qubit", wires=qubits)
|
||||
|
||||
singles, doubles = qml.qchem.excitations(active_electrons, qubits)
|
||||
params = np.array(np.zeros(len(singles) + len(doubles)), requires_grad=True)
|
||||
|
||||
@qml.qnode(dev)
|
||||
def circuit(param, wires):
|
||||
# Map excitations to the wires the UCCSD circuit will act on
|
||||
s_wires, d_wires = qml.qchem.excitations_to_wires(singles, doubles)
|
||||
qml.UCCSD(
|
||||
param,
|
||||
wires,
|
||||
s_wires=s_wires,
|
||||
d_wires=d_wires,
|
||||
init_state=qml.qchem.hf_state(active_electrons, qubits),
|
||||
)
|
||||
return qml.expval(H)
|
||||
|
||||
def cost_fn(param):
|
||||
return circuit(param, wires=range(qubits))
|
||||
|
||||
opt = qml.GradientDescentOptimizer(stepsize=step_size)
|
||||
|
||||
for n in range(max_iterations):
|
||||
# Take step
|
||||
params, prev_energy = opt.step_and_cost(cost_fn, params)
|
||||
|
||||
energy = cost_fn(params)
|
||||
|
||||
# Calculate difference between new and old energies
|
||||
conv = np.abs(energy - prev_energy)
|
||||
|
||||
queue_callback.put([n, energy, params])
|
||||
|
||||
if conv <= conv_tol:
|
||||
break
|
||||
@@ -1,4 +0,0 @@
|
||||
2
|
||||
Hydrogen molecule
|
||||
H 0.000 0.000 0.000
|
||||
H 0.740 0.000 0.000
|
||||
Reference in New Issue
Block a user