1 Commits

Author SHA1 Message Date
Repo Bot
7d5005ce4e snapshot: capture local workspace state (20251019-132103) 2025-10-19 13:21:07 +02:00
50 changed files with 345 additions and 319 deletions

View File

@@ -30,14 +30,15 @@ COPY requirements.txt /app/requirements.txt
RUN python -m pip install --upgrade pip setuptools wheel && \
python -m pip install -r /app/requirements.txt
# Copy project
COPY . /app
# Copy only app directory and libs/so (native .so libraries for Linux)
COPY app /app/app
COPY libs/so /app/libs/so
# Ensure Python and dynamic linker will find the native libs if mounted
ENV PYTHONPATH="/app:/app/IPM_SO:/app/IPM_DLL"
ENV LD_LIBRARY_PATH="/app/IPM_SO:/app/IPM_DLL"
# Set environment variables to use libs/so for native libraries
ENV PYTHONPATH="/app"
ENV LD_LIBRARY_PATH="/app/libs/so"
EXPOSE 8001
# Default command runs uvicorn (use docker-compose override for development)
# Default command runs uvicorn
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001"]

View File

@@ -0,0 +1,21 @@
# Frontend .dockerignore
node_modules
.next
out
build
.turbo
dist
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.git
.gitignore
README.md
.vscode
.idea
*.swp
*.swo

View File

@@ -0,0 +1,50 @@
# Frontend Dockerfile
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build the Next.js app
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]

View File

@@ -0,0 +1,179 @@
# DiagramPH - Docker Deployment Guide
## 🐳 Docker Compose Setup
Ce projet utilise Docker Compose pour déployer le backend (FastAPI) et le frontend (Next.js) ensemble.
### Architecture
```
┌─────────────────┐ ┌──────────────────┐
│ Frontend │─────▶│ Backend │
│ (Next.js) │ │ (FastAPI) │
│ Port: 3000 │ │ Port: 8001 │
└─────────────────┘ └──────────────────┘
│ │
└────────────────────────┘
Docker Network
```
## 🚀 Démarrage Rapide
### Production
```bash
# Build et démarrer tous les services
docker-compose up --build
# En mode détaché (background)
docker-compose up -d --build
# Arrêter les services
docker-compose down
# Arrêter et supprimer les volumes
docker-compose down -v
```
Accès:
- **Frontend**: http://localhost:3000
- **Backend API**: http://localhost:8001
- **API Docs**: http://localhost:8001/docs
### Développement
Pour le développement avec hot-reload:
```bash
# Démarrer en mode développement
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up
# Rebuild si nécessaire
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up --build
```
## 📦 Services
### Backend (Python FastAPI)
- **Port**: 8001
- **Base Image**: python:3.12-slim
- **Hot Reload**: Activé avec `--reload` en dev
- **Healthcheck**: GET /api/v1/refrigerants/
### Frontend (Next.js)
- **Port**: 3000
- **Base Image**: node:20-alpine
- **Build**: Standalone output optimisé
- **Healthcheck**: HTTP GET sur port 3000
## 🔧 Configuration
### Variables d'Environnement
#### Backend
```env
PYTHONUNBUFFERED=1
PYTHONPATH=/app:/app/IPM_SO:/app/IPM_DLL
LD_LIBRARY_PATH=/app/IPM_SO:/app/IPM_DLL
```
#### Frontend
```env
NODE_ENV=production
NEXT_PUBLIC_API_URL=http://backend:8001/api/v1
```
### Volumes
Les volumes sont montés pour permettre le hot-reload en développement:
- `./app` → Backend code
- `./Frontend` → Frontend code
- `./IPM_SO` et `./IPM_DLL` → Native libraries
## 📝 Commandes Utiles
```bash
# Voir les logs
docker-compose logs -f
# Logs d'un service spécifique
docker-compose logs -f backend
docker-compose logs -f frontend
# Reconstruire un service
docker-compose build backend
docker-compose build frontend
# Exécuter une commande dans un conteneur
docker-compose exec backend python -m pytest
docker-compose exec frontend npm test
# Voir l'état des services
docker-compose ps
# Redémarrer un service
docker-compose restart backend
docker-compose restart frontend
```
## 🐛 Troubleshooting
### Le frontend ne peut pas se connecter au backend
Vérifiez que:
1. Le backend est en cours d'exécution: `docker-compose ps`
2. Le healthcheck du backend est OK: `docker-compose logs backend`
3. Les deux services sont sur le même réseau Docker
### Erreur de build frontend
```bash
# Nettoyer le cache et rebuild
docker-compose build --no-cache frontend
```
### Erreur de permissions
```bash
# Sur Linux/Mac, ajuster les permissions
sudo chown -R $USER:$USER Frontend/.next
```
## 📚 Structure des Fichiers
```
.
├── docker-compose.yml # Configuration production
├── docker-compose.dev.yml # Override pour développement
├── Dockerfile # Backend Dockerfile
├── Frontend/
│ ├── Dockerfile # Frontend Dockerfile
│ ├── .dockerignore
│ └── next.config.js
├── app/ # Code backend
└── README-DOCKER.md # Ce fichier
```
## 🔒 Production Deployment
Pour un déploiement en production:
1. **Désactiver le mode debug**:
- Retirer `--reload` du backend
- Utiliser `NODE_ENV=production`
2. **Utiliser des secrets**:
```yaml
environment:
- DATABASE_URL=${DATABASE_URL}
```
3. **Configurer un reverse proxy** (Nginx/Traefik)
4. **Ajouter SSL/TLS** avec Let's Encrypt
## 📄 License
MIT

View File

@@ -7,13 +7,18 @@ import os
from pathlib import Path
from typing import Dict, Optional, List
# Prefer the packaged app.ipm module, but keep a fallback to the legacy
# IPM_DLL folder for development compatibility.
# Prefer the packaged app.ipm module, but fall back to the legacy IPM_DLL
# folder only if the packaged module cannot be imported. Import the module
# object and use getattr to avoid ImportError when optional symbols like
# `MockRefifc` are missing.
try:
from app.ipm.simple_refrig_api import Refifc, MockRefifc # type: ignore
import importlib
_sr = importlib.import_module('app.ipm.simple_refrig_api')
Refifc = getattr(_sr, 'Refifc')
MockRefifc = getattr(_sr, 'MockRefifc', None)
except Exception:
# Fall back to loading from IPM_DLL directory as before. Import the
# module and pick attributes if present; older legacy wrappers may not
# legacy module and pick attributes if present; older wrappers may not
# define MockRefifc.
_current_dir = Path(__file__).parent.parent.parent
_ipm_dll_dir = _current_dir / "IPM_DLL"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -101,7 +101,7 @@ class GenRefProperties(Structure):
if os.name == 'nt':
REFIFC_LIB_NAME = "refifc.dll"
REFIFC_LIB_NAME = "refifc"
else: # 'posix'
REFIFC_LIB_NAME = "librefifc.so"
@@ -112,37 +112,55 @@ class Refifc(object):
# Sauvegardez le répertoire courant pour pouvoir y revenir plus tard
self.original_directory = os.getcwd()
# Determine candidate directories for the native library. Prefer
# app/ipm/lib/dll (Windows) or app/ipm/lib/so (POSIX) if present,
# otherwise fall back to the package directory (for compatibility).
# Determine candidate directories for the native library.
# Prefer central repo-level folders:
# <repo_root>/libs/dll (Windows)
# <repo_root>/libs/so (Linux)
# Fall back to package-local `app/ipm/lib/<platform>` or the package dir.
package_dir = os.path.dirname(os.path.abspath(__file__))
platform_dir = os.path.join(package_dir, 'lib', 'dll' if os.name == 'nt' else 'so')
dll_directory = platform_dir if os.path.isdir(platform_dir) else package_dir
# repo root is two levels above app/ipm
repo_root = os.path.abspath(os.path.join(package_dir, '..', '..'))
preferred_windows = os.path.join(repo_root, 'libs', 'dll')
preferred_linux = os.path.join(repo_root, 'libs', 'so')
# Change working directory to the chosen directory while loading
os.chdir(dll_directory)
if os.name == 'nt':
candidate_dirs = [preferred_windows]
else:
candidate_dirs = [preferred_linux]
# Try to load the native library from the chosen directory; if that
# fails, attempt to load by name (for system-installed libs) and
# otherwise raise the original exception. Use RTLD_GLOBAL on POSIX
# to make symbols available for dependent shared objects.
try:
full_lib_path = os.path.join(dll_directory, REFIFC_LIB_NAME)
if os.name == 'nt':
self.lib = ctypes.cdll.LoadLibrary(full_lib_path)
else:
# Use RTLD_GLOBAL so dependent .so files can resolve symbols
self.lib = ctypes.CDLL(full_lib_path, mode=ctypes.RTLD_GLOBAL)
except OSError:
# also consider the package-local lib/<platform> layout as fallback
candidate_dirs.append(os.path.join(package_dir, 'lib', 'windows' if os.name == 'nt' else 'linux'))
candidate_dirs.append(package_dir)
# Try each candidate directory in order until we can load the library.
load_exc = None
for dll_directory in candidate_dirs:
if not dll_directory:
continue
try:
if os.name == 'nt':
self.lib = ctypes.cdll.LoadLibrary(REFIFC_LIB_NAME)
if os.path.isdir(dll_directory):
# temporarily change cwd to help some LoadLibrary behaviors
os.chdir(dll_directory)
self.lib = ctypes.cdll.LoadLibrary(os.path.join(dll_directory, REFIFC_LIB_NAME))
else:
self.lib = ctypes.CDLL(REFIFC_LIB_NAME, mode=ctypes.RTLD_GLOBAL)
# attempt to load directly by path anyway
self.lib = ctypes.cdll.LoadLibrary(os.path.join(dll_directory, REFIFC_LIB_NAME))
load_exc = None
break
except OSError as e:
load_exc = e
# try next candidate
continue
# if we failed to load from candidates, try loading by name (system path)
if load_exc is not None:
try:
self.lib = ctypes.cdll.LoadLibrary(REFIFC_LIB_NAME)
load_exc = None
except Exception:
# Restore cwd before raising
# restore cwd before raising
os.chdir(self.original_directory)
raise
raise load_exc
ctypes_refrig_name = refrig_name
if os.name == 'posix':
@@ -154,24 +172,13 @@ class Refifc(object):
try:
ctypes.CDLL(os.path.join(dll_directory, REFIFC_LIB_NAME))
except OSError:
# best-effort warning; not fatal here (the main loader already succeeded)
print(f"Refrig {refrig_name} not found, please check!")
# don't raise here; letting the subsequent load/check handle missing refrigerant libs
print(f"Refrig {refrig_name} not found in {dll_directory}, please check!")
func = self.lib.refdll_load
func.restype = POINTER(c_void_p)
func.argtypes = [c_char_p, c_void_p]
# On POSIX the native loader often expects the full SO filename
# (e.g. "libR290.so"). We built `ctypes_refrig_name` above to match
# that convention; use it when calling the native loader.
name_to_pass = ctypes_refrig_name if ctypes_refrig_name else refrig_name
try:
self.handle = func(c_char_p(name_to_pass.encode('utf-8')), c_void_p())
finally:
# restore cwd even if the native call raises
try:
os.chdir(self.original_directory)
except Exception:
pass
self.handle = func(c_char_p(refrig_name.encode('utf-8')), c_void_p())
# def __del__(self):

View File

@@ -0,0 +1,30 @@
version: '3.8'
# Development override for docker-compose.yml
# Usage: docker-compose -f docker-compose.yml -f docker-compose.dev.yml up
services:
backend:
volumes:
- ./app:/app/app:cached
- ./IPM_SO:/app/IPM_SO:cached
- ./IPM_DLL:/app/IPM_DLL:cached
- ./tests:/app/tests:cached
command: uvicorn app.main:app --host 0.0.0.0 --port 8001 --reload
environment:
- DEBUG=1
frontend:
build:
context: ./Frontend
target: deps
volumes:
- ./Frontend:/app:cached
- /app/node_modules
- /app/.next
command: npm run dev
environment:
- NODE_ENV=development
- NEXT_PUBLIC_API_URL=http://localhost:8001/api/v1
ports:
- "3000:3000"

View File

@@ -1,5 +1,3 @@
version: '3.8'
services:
# Backend API Service
backend:
@@ -11,12 +9,11 @@ services:
- "8001:8001"
volumes:
- ./app:/app/app:cached
- ./IPM_SO:/app/IPM_SO:cached
- ./IPM_DLL:/app/IPM_DLL:cached
- ./libs/so:/app/libs/so:cached
environment:
- PYTHONUNBUFFERED=1
- PYTHONPATH=/app:/app/IPM_SO:/app/IPM_DLL
- LD_LIBRARY_PATH=/app/IPM_SO:/app/IPM_DLL
- PYTHONPATH=/app
- LD_LIBRARY_PATH=/app/libs/so
command: uvicorn app.main:app --host 0.0.0.0 --port 8001 --reload
networks:
- diagramph-network

View File

@@ -1,12 +0,0 @@
import traceback, sys
try:
from app.core.refrigerant_loader import RefrigerantLibrary
r = RefrigerantLibrary('R290')
print('Loaded Refifc OK')
try:
print('pbegin', r.p_begin())
except Exception as e:
print('p_begin failed:', e)
except Exception:
traceback.print_exc()
sys.exit(1)

View File

@@ -1,58 +0,0 @@
import requests, json, base64, os
base = 'http://127.0.0.1:8001'
print('Health ->', requests.get(base + '/api/v1/health').json())
# Diagram JSON
body = {
'refrigerant': 'R290',
'pressure_range': {'min': 0.1, 'max': 10.0},
'format': 'json',
'include_isotherms': True,
'width': 800,
'height': 600,
'dpi': 100
}
print('\nRequesting diagram JSON...')
r = requests.post(base + '/api/v1/diagrams/ph', json=body, timeout=60)
print('Status', r.status_code)
try:
j = r.json()
print('Keys in response:', list(j.keys()))
if 'data' in j:
print('Saturation curve length:', len(j['data'].get('saturation_curve', [])))
except Exception as e:
print('Failed to parse JSON:', e, r.text[:200])
# Diagram PNG
body['format'] = 'png'
print('\nRequesting diagram PNG...')
r2 = requests.post(base + '/api/v1/diagrams/ph', json=body, timeout=60)
print('Status', r2.status_code)
try:
j2 = r2.json()
print('Keys in response:', list(j2.keys()))
if 'image' in j2:
img_b64 = j2['image']
os.makedirs('test_outputs', exist_ok=True)
path = os.path.join('test_outputs','sample_diagram.png')
with open(path, 'wb') as f:
f.write(base64.b64decode(img_b64))
print('Saved PNG to', path)
except ValueError:
print('Response is not JSON, printing text length', len(r2.text))
# Simple cycle (pressure mode)
print('\nRequesting simple cycle...')
cycle_body = {
'refrigerant': 'R290',
'evap_pressure': 0.2, # bar
'cond_pressure': 6.0, # bar
'superheat': 5.0,
'subcool': 2.0,
'mass_flow': 0.1
}
r3 = requests.post(base + '/api/v1/cycles/simple', json=cycle_body, timeout=60)
print('Status', r3.status_code)
try:
print('Simple cycle keys:', list(r3.json().keys()))
except Exception as e:
print('Failed to parse cycle response:', e, r3.text[:200])

View File

@@ -1,78 +0,0 @@
import requests, json, base64, os
base = 'http://127.0.0.1:8002'
print('Health ->', requests.get(base + '/api/v1/health').json())
# Diagram JSON
body = {
'refrigerant': 'R290',
'pressure_range': {'min': 0.1, 'max': 10.0},
'format': 'json',
'include_isotherms': True,
'width': 800,
'height': 600,
'dpi': 100
}
print('\nRequesting diagram JSON...')
r = requests.post(base + '/api/v1/diagrams/ph', json=body, timeout=120)
print('Status', r.status_code)
try:
j = r.json()
print('Keys in response:', list(j.keys()))
if 'data' in j:
print('Saturation curve length:', len(j['data'].get('saturation_curve', [])))
except Exception as e:
print('Failed to parse JSON:', e, r.text[:200])
# Diagram PNG
body['format'] = 'png'
print('\nRequesting diagram PNG...')
r2 = requests.post(base + '/api/v1/diagrams/ph', json=body, timeout=120)
print('Status', r2.status_code)
try:
j2 = r2.json()
print('Keys in response:', list(j2.keys()))
if 'image' in j2:
img_b64 = j2['image']
os.makedirs('test_outputs', exist_ok=True)
path = os.path.join('test_outputs','docker_sample_diagram.png')
with open(path, 'wb') as f:
f.write(base64.b64decode(img_b64))
print('Saved PNG to', path)
except Exception as e:
print('Failed to parse PNG response:', e, r2.text[:200])
# Simple cycle (pressure mode)
print('\nRequesting simple cycle (pressure mode)...')
cycle_body = {
'refrigerant': 'R290',
'evap_pressure': 0.2, # bar
'cond_pressure': 6.0, # bar
'superheat': 5.0,
'subcool': 2.0,
'mass_flow': 0.1
}
r3 = requests.post(base + '/api/v1/cycles/simple', json=cycle_body, timeout=120)
print('Status', r3.status_code)
try:
print('Simple cycle keys:', list(r3.json().keys()))
except Exception as e:
print('Failed to parse cycle response:', e, r3.text[:200])
# Simple cycle (temperature mode)
print('\nRequesting simple cycle (temperature mode)...')
cycle_body2 = {
'refrigerant': 'R290',
'evap_temperature': -10.0,
'cond_temperature': 40.0,
'superheat': 5.0,
'subcool': 2.0,
'mass_flow': 0.1
}
r4 = requests.post(base + '/api/v1/cycles/simple', json=cycle_body2, timeout=120)
print('Status', r4.status_code)
try:
j4 = r4.json()
print('Keys:', list(j4.keys()))
if 'performance' in j4:
print('COP:', j4['performance'].get('cop'))
except Exception as e:
print('Failed to parse cycle temp response:', e, r4.text[:200])

View File

@@ -1,25 +0,0 @@
import requests, json
base = 'http://127.0.0.1:8001'
print('Health ->', requests.get(base + '/api/v1/health').json())
body = {
'refrigerant': 'R290',
'evap_temperature': -10.0, # °C
'cond_temperature': 40.0, # °C
'superheat': 5.0,
'subcool': 2.0,
'mass_flow': 0.1
}
print('\nRequesting simple cycle (temperature mode)...')
r = requests.post(base + '/api/v1/cycles/simple', json=body, timeout=60)
print('Status', r.status_code)
try:
j = r.json()
print('Keys:', list(j.keys()))
if 'performance' in j:
print('COP:', j['performance'].get('cop'))
print('Compressor efficiency:', j['performance'].get('compressor_efficiency'))
if 'diagram_data' in j:
print('Diagram cycle points count:', len(j['diagram_data'].get('cycle_points', [])))
except Exception as e:
print('Failed to parse JSON:', e, r.text[:200])

View File

@@ -1,28 +0,0 @@
import os
import sys
# Ensure project root is on sys.path so 'app' package is importable when running scripts
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from app.core.refrigerant_loader import RefrigerantLibrary
from app.services.cycle_calculator import CycleCalculator
# Create refrigerant and calculator
refrig = RefrigerantLibrary('R290')
calc = CycleCalculator(refrig)
# Typical pressures in Pa (convert from bar)
evap = 0.2 * 1e5
cond = 6.0 * 1e5
inter = 2.0 * 1e5
res = calc.calculate_cycle_with_economizer(evap, cond, inter, superheat=5.0, subcool=3.0, mass_flow=0.1)
print('Economizer result keys:', res.keys())
print('Flash fraction:', res['performance'].get('flash_fraction'))
print('COP:', res['performance'].get('cop'))
print('Points:')
for p in res['points']:
print(' -', p)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

View File

@@ -1,44 +0,0 @@
import base64
import pytest
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_diagram_json_no_image():
body = {
'refrigerant': 'R290',
'pressure_range': {'min': 0.1, 'max': 10.0},
'format': 'json',
'include_isotherms': True,
'width': 800,
'height': 600,
'dpi': 100
}
r = client.post('/api/v1/diagrams/ph', json=body)
assert r.status_code == 200
j = r.json()
# image key should not be present for json-only
assert 'image' not in j
assert 'data' in j
def test_diagram_png_includes_image():
body = {
'refrigerant': 'R290',
'pressure_range': {'min': 0.1, 'max': 10.0},
'format': 'png',
'include_isotherms': True,
'width': 800,
'height': 600,
'dpi': 100
}
r = client.post('/api/v1/diagrams/ph', json=body)
assert r.status_code == 200
j = r.json()
assert 'image' in j
# Validate base64 decodes
decoded = base64.b64decode(j['image'])
assert len(decoded) > 10

View File

@@ -1,19 +0,0 @@
import sys, os
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from app.core.refrigerant_loader import RefrigerantLibrary
from app.services.cycle_calculator import CycleCalculator
def test_economizer_runs():
refrigerant = RefrigerantLibrary('R290')
calc = CycleCalculator(refrigerant)
evap = 0.2 * 1e5
cond = 6.0 * 1e5
inter = 2.0 * 1e5
res = calc.calculate_cycle_with_economizer(evap, cond, inter, mass_flow=0.1)
assert 'performance' in res
assert 'flash_fraction' in res['performance']
assert 0.0 <= res['performance']['flash_fraction'] <= 1.0