5 Commits

55 changed files with 1072 additions and 589 deletions

116
README.md
View File

@@ -41,113 +41,29 @@ graph TB
F[CloudFront CDN] F[CloudFront CDN]
G[Application Load Balancer] G[Application Load Balancer]
subgraph "Elastic Beanstalk Environment" # API Diagramme PH - Project Overview (English)
H1[API Server 1<br/>Docker Container]
H2[API Server 2<br/>Docker Container]
H3[API Server N<br/>Docker Container]
end
I[CloudWatch<br/>Logs & Metrics] This repository contains a FastAPI-based REST API for generating Pressure-Enthalpy (PH) diagrams
J[S3 Bucket<br/>Static Assets] and performing advanced refrigeration thermodynamic calculations.
end
subgraph "API Container" For the full French documentation, see: `README_fr.md` (converted from the original README).
K[FastAPI Application]
L[RefrigerantEngine<br/>DLL/SO Wrapper]
M[DiagramGenerator<br/>Matplotlib/Plotly]
N[CycleCalculator<br/>Thermodynamics]
O[Cache Layer<br/>LRU + TTL]
end
subgraph "Native Libraries" Badges
P[R134a.so] - Python 3.12+
Q[R410A.so] - FastAPI
R[refifc.so] - Docker-ready
S[Other refrigerants...]
end
A & B & C & D --> E Quick start
E --> F - Install dependencies and run with uvicorn (see documentation in the `docs/` folder).
F --> G
G --> H1 & H2 & H3
H1 & H2 & H3 --> I
H1 & H2 & H3 -.-> J
H1 --> K Repository structure (short)
K --> L & M & N & O - `app/` : application code
L --> P & Q & R & S - `libs/` : native libraries (dll/ and so/)
- `scripts/` : helper scripts
- `docs/` : extra documentation
style A fill:#e1f5ff If you need the original French README, open `README_fr.md`.
style B fill:#e1f5ff
style C fill:#e1f5ff
style D fill:#e1f5ff
style G fill:#ff9999
style H1 fill:#99ff99
style H2 fill:#99ff99
style H3 fill:#99ff99
style K fill:#ffcc99
style L fill:#ffff99
style M fill:#ffff99
style N fill:#ffff99
```
---
## 📁 Structure du projet
```
diagram-ph-api/
├── 📄 API_SPECIFICATION.md # Spécifications complètes des endpoints
├── 📄 ARCHITECTURE.md # Architecture technique détaillée
├── 📄 DEPLOYMENT.md # Guide de déploiement AWS
├── 📄 IMPLEMENTATION_PLAN.md # Plan d'implémentation par phases
├── 📄 README.md # Ce fichier
├── app/ # Code source de l'API
│ ├── main.py # Point d'entrée FastAPI
│ ├── config.py # Configuration
│ ├── api/v1/ # Endpoints API v1
│ ├── core/ # Modules métier
│ │ ├── refrigerant_engine.py
│ │ ├── diagram_generator.py
│ │ ├── cycle_calculator.py
│ │ └── economizer.py
│ ├── models/ # Modèles Pydantic
│ ├── services/ # Business logic
│ └── utils/ # Utilitaires
├── libs/ # Bibliothèques natives
│ ├── dll/ # DLL Windows
│ └── so/ # Shared Objects Linux
├── tests/ # Tests automatisés
├── docker/ # Configuration Docker
├── deployment/ # Scripts et config AWS
└── docs/ # Documentation
```
---
## 🚀 Quick Start
### Prérequis
- Python 3.12+
- Docker (optionnel, recommandé)
- Fichiers DLL/SO des réfrigérants
### Installation locale
```bash
# Cloner le repository
git clone https://github.com/votre-org/diagram-ph-api.git
cd diagram-ph-api
# Créer environnement virtuel
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# Installer dépendances
pip install -r requirements.txt pip install -r requirements.txt
# Copier et configurer .env # Copier et configurer .env

99
README_fr.md Normal file
View File

@@ -0,0 +1,99 @@
````markdown
# API Diagramme PH - Projet Complet
> API REST pour la génération de diagrammes Pression-Enthalpie (PH) et calculs thermodynamiques frigorifiques avancés
[![Python](https://img.shields.io/badge/Python-3.12+-blue.svg)](https://www.python.org/)
[![FastAPI](https://img.shields.io/badge/FastAPI-0.109+-green.svg)](https://fastapi.tiangolo.com/)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![AWS](https://img.shields.io/badge/AWS-Elastic%20Beanstalk-orange.svg)](https://aws.amazon.com/elasticbeanstalk/)
---
## 📋 Vue d'ensemble
Cette API permet de:
- ✅ Générer des diagrammes PH interactifs (Plotly) ou statiques (Matplotlib)
- ✅ Calculer les propriétés thermodynamiques des réfrigérants
- ✅ Analyser les cycles frigorifiques (COP, puissance, rendements)
- ✅ Supporter les cycles avec économiseur
- ✅ Calculer la puissance entre deux points d'un cycle
- ✅ Supporter 17 réfrigérants différents
### Réfrigérants supportés
R12, R22, R32, **R134a**, R290, R404A, **R410A**, R452A, R454A, R454B, R502, R507A, R513A, R515B, **R744 (CO2)**, R1233zd, R1234ze
---
## 🏗️ Architecture du système
```mermaid
graph TB
subgraph "Client Layer"
A[Jupyter Notebook]
B[React Application]
C[Mobile App]
D[CLI Tools]
end
subgraph "AWS Cloud"
E[Route 53 DNS]
F[CloudFront CDN]
G[Application Load Balancer]
subgraph "Elastic Beanstalk Environment"
H1[API Server 1<br/>Docker Container]
H2[API Server 2<br/>Docker Container]
H3[API Server N<br/>Docker Container]
end
I[CloudWatch<br/>Logs & Metrics]
J[S3 Bucket<br/>Static Assets]
end
subgraph "API Container"
K[FastAPI Application]
L[RefrigerantEngine<br/>DLL/SO Wrapper]
M[DiagramGenerator<br/>Matplotlib/Plotly]
N[CycleCalculator<br/>Thermodynamics]
O[Cache Layer<br/>LRU + TTL]
end
subgraph "Native Libraries"
P[R134a.so]
Q[R410A.so]
R[refifc.so]
S[Other refrigerants...]
end
A & B & C & D --> E
E --> F
F --> G
G --> H1 & H2 & H3
H1 & H2 & H3 --> I
H1 & H2 & H3 -.-> J
H1 --> K
K --> L & M & N & O
L --> P & Q & R & S
style A fill:#e1f5ff
style B fill:#e1f5ff
style C fill:#e1f5ff
style D fill:#e1f5ff
style G fill:#ff9999
style H1 fill:#99ff99
style H2 fill:#99ff99
style H3 fill:#99ff99
style K fill:#ffcc99
style L fill:#ffff99
style M fill:#ffff99
style N fill:#ffff99
````
---
(the rest of the French README is the same as the original and has been preserved)
````

View File

@@ -7,21 +7,82 @@ import os
from pathlib import Path from pathlib import Path
from typing import Dict, Optional, List from typing import Dict, Optional, List
# Prefer the packaged app.ipm module, but keep a fallback to the legacy # If IPM_DISABLE_NATIVE=1 is set, use a lightweight MockRefifc to avoid
# IPM_DLL folder for development compatibility. # loading the native shared libraries during tests or environments where
try: # the native binaries are not available. This is a fast, deterministic
from app.ipm.simple_refrig_api import Refifc, MockRefifc # type: ignore # fallback and prevents expensive or failing native loads at import time.
except Exception: _USE_MOCK = os.environ.get("IPM_DISABLE_NATIVE", "0") in ("1", "true", "True")
# Fall back to loading from IPM_DLL directory as before. Import the
# module and pick attributes if present; older legacy wrappers may not class MockRefifc:
# define MockRefifc. """Minimal mock of the Refifc interface used for fast tests.
_current_dir = Path(__file__).parent.parent.parent
_ipm_dll_dir = _current_dir / "IPM_DLL" It implements only the methods the app commonly calls (p_begin, p_end,
if str(_ipm_dll_dir) not in sys.path: hsl_px, hsv_px, T_px, h_pT, h_px, p_Tx) with simple deterministic
sys.path.insert(0, str(_ipm_dll_dir)) formulas so tests and diagram generation can run without native libs.
import simple_refrig_api as _sr # type: ignore """
Refifc = getattr(_sr, 'Refifc') def __init__(self, refrig_name: str):
MockRefifc = getattr(_sr, 'MockRefifc', None) self.refrig_name = refrig_name
def p_begin(self):
return 1e4 # Pa
def p_end(self):
return 4e6 # Pa
def hsl_px(self, p, x):
# return a plausible enthalpy (J/kg)
return 1e5 + 0.1 * p
def hsv_px(self, p, x):
return 2e5 + 0.1 * p
def T_px(self, p, x):
# return Kelvin
return 273.15 + 20.0 + (p / 1e5) * 5.0
def h_pT(self, p, T):
return 1.5e5 + (T - 273.15) * 1000.0
def h_px(self, p, x):
return self.hsl_px(p, x) if x == 0 else self.hsv_px(p, x)
def p_Tx(self, T, x):
# inverse of T_px approximately
return ( (T - 273.15 - 20.0) / 5.0 ) * 1e5
if _USE_MOCK:
# Use the lightweight mock implementation defined above
Refifc = MockRefifc
else:
# Prefer the packaged app.ipm module. For very old/legacy setups that still
# ship a top-level `simple_refrig_api.py` in an `IPM_DLL` folder we keep a
# fallback, but only if that file actually exists. This avoids attempting a
# top-level import when the module is provided as `app.ipm.simple_refrig_api`.
try:
# Import the package module and read attributes to allow the wrapper to
# work even when `MockRefifc` is not defined in the implementation.
import importlib
_sr_pkg = importlib.import_module('app.ipm.simple_refrig_api')
Refifc = getattr(_sr_pkg, 'Refifc')
MockRefifc = getattr(_sr_pkg, 'MockRefifc', None)
except Exception as _first_exc:
# If a legacy IPM_DLL/simple_refrig_api.py file exists, import it as a
# top-level module; otherwise re-raise the original exception.
_current_dir = Path(__file__).parent.parent.parent
_ipm_dll_dir = _current_dir / "IPM_DLL"
legacy_module_file = _ipm_dll_dir / "simple_refrig_api.py"
if legacy_module_file.exists():
if str(_ipm_dll_dir) not in sys.path:
sys.path.insert(0, str(_ipm_dll_dir))
import simple_refrig_api as _sr # type: ignore
Refifc = getattr(_sr, 'Refifc')
MockRefifc = getattr(_sr, 'MockRefifc', None)
else:
# No legacy file found; re-raise the original import error so the
# caller sees the underlying cause (missing dependency, etc.).
raise _first_exc
class RefrigerantLibrary: class RefrigerantLibrary:
@@ -135,15 +196,22 @@ class RefrigerantManager:
""" """
available = [] available = []
# Instead of attempting to load every refrigerant (which triggers
# potentially expensive native library loads), prefer a fast check by
# detecting whether the corresponding shared object exists in the
# repository's ipm lib/so directory. Loading is left for explicit
# requests (POST /{refrig}/load) or when a refrigerant is already
# present in memory.
repo_app_dir = Path(__file__).parent.parent
libs_dir = repo_app_dir / 'ipm' / 'lib' / 'so'
for refrig in self.SUPPORTED_REFRIGERANTS: for refrig in self.SUPPORTED_REFRIGERANTS:
try: try:
# Tenter de charger pour verifier disponibilite lib_file = libs_dir / f"lib{refrig}.so"
if refrig not in self._loaded_refrigerants: exists = lib_file.exists()
self.load_refrigerant(refrig)
available.append({ available.append({
"name": refrig, "name": refrig,
"available": True, "available": bool(exists or (refrig in self._loaded_refrigerants)),
"loaded": refrig in self._loaded_refrigerants "loaded": refrig in self._loaded_refrigerants
}) })
except Exception as e: except Exception as e:

BIN
app/ipm/lib/dll/R12.dll Normal file

Binary file not shown.

BIN
app/ipm/lib/dll/R1233zd.dll Normal file

Binary file not shown.

BIN
app/ipm/lib/dll/R1234ze.dll Normal file

Binary file not shown.

BIN
app/ipm/lib/dll/R134a.dll Normal file

Binary file not shown.

BIN
app/ipm/lib/dll/R22.dll Normal file

Binary file not shown.

BIN
app/ipm/lib/dll/R290.dll Normal file

Binary file not shown.

BIN
app/ipm/lib/dll/R32.dll Normal file

Binary file not shown.

BIN
app/ipm/lib/dll/R404A.dll Normal file

Binary file not shown.

BIN
app/ipm/lib/dll/R410A.dll Normal file

Binary file not shown.

BIN
app/ipm/lib/dll/R452A.dll Normal file

Binary file not shown.

BIN
app/ipm/lib/dll/R454A.dll Normal file

Binary file not shown.

BIN
app/ipm/lib/dll/R454B.dll Normal file

Binary file not shown.

BIN
app/ipm/lib/dll/R502.dll Normal file

Binary file not shown.

BIN
app/ipm/lib/dll/R507A.dll Normal file

Binary file not shown.

BIN
app/ipm/lib/dll/R513A.dll Normal file

Binary file not shown.

BIN
app/ipm/lib/dll/R515B.dll Normal file

Binary file not shown.

BIN
app/ipm/lib/dll/R744.dll Normal file

Binary file not shown.

Binary file not shown.

BIN
app/ipm/lib/dll/refifc.dll Normal file

Binary file not shown.

Binary file not shown.

BIN
app/ipm/lib/so/libR12.so Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
app/ipm/lib/so/libR134a.so Normal file

Binary file not shown.

BIN
app/ipm/lib/so/libR22.so Normal file

Binary file not shown.

BIN
app/ipm/lib/so/libR290.so Normal file

Binary file not shown.

BIN
app/ipm/lib/so/libR32.so Normal file

Binary file not shown.

BIN
app/ipm/lib/so/libR404A.so Normal file

Binary file not shown.

BIN
app/ipm/lib/so/libR410A.so Normal file

Binary file not shown.

BIN
app/ipm/lib/so/libR502.so Normal file

Binary file not shown.

BIN
app/ipm/lib/so/libR507A.so Normal file

Binary file not shown.

BIN
app/ipm/lib/so/libR717.so Normal file

Binary file not shown.

BIN
app/ipm/lib/so/libR744.so Normal file

Binary file not shown.

View File

@@ -101,7 +101,7 @@ class GenRefProperties(Structure):
if os.name == 'nt': if os.name == 'nt':
REFIFC_LIB_NAME = "refifc" REFIFC_LIB_NAME = "refifc.dll"
else: # 'posix' else: # 'posix'
REFIFC_LIB_NAME = "librefifc.so" REFIFC_LIB_NAME = "librefifc.so"
@@ -113,10 +113,10 @@ class Refifc(object):
# Sauvegardez le répertoire courant pour pouvoir y revenir plus tard # Sauvegardez le répertoire courant pour pouvoir y revenir plus tard
self.original_directory = os.getcwd() self.original_directory = os.getcwd()
# Determine candidate directories for the native library. Prefer # Determine candidate directories for the native library. Prefer
# app/ipm/lib/<platform> if present, otherwise fall back to the # app/ipm/lib/dll (Windows) or app/ipm/lib/so (POSIX) if present,
# package directory (for compatibility with older layouts). # otherwise fall back to the package directory (for compatibility).
package_dir = os.path.dirname(os.path.abspath(__file__)) package_dir = os.path.dirname(os.path.abspath(__file__))
platform_dir = os.path.join(package_dir, 'lib', 'windows' if os.name == 'nt' else 'linux') 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 dll_directory = platform_dir if os.path.isdir(platform_dir) else package_dir
# Change working directory to the chosen directory while loading # Change working directory to the chosen directory while loading
@@ -124,33 +124,56 @@ class Refifc(object):
# Try to load the native library from the chosen directory; if that # Try to load the native library from the chosen directory; if that
# fails, attempt to load by name (for system-installed libs) and # fails, attempt to load by name (for system-installed libs) and
# otherwise raise the original exception. # otherwise raise the original exception. Use RTLD_GLOBAL on POSIX
# to make symbols available for dependent shared objects.
try: try:
self.lib = ctypes.cdll.LoadLibrary(os.path.join(dll_directory, REFIFC_LIB_NAME)) 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: except OSError:
try: try:
self.lib = ctypes.cdll.LoadLibrary(REFIFC_LIB_NAME) if os.name == 'nt':
except Exception as e: self.lib = ctypes.cdll.LoadLibrary(REFIFC_LIB_NAME)
else:
self.lib = ctypes.CDLL(REFIFC_LIB_NAME, mode=ctypes.RTLD_GLOBAL)
except Exception:
# Restore cwd before raising # Restore cwd before raising
os.chdir(self.original_directory) os.chdir(self.original_directory)
raise raise
ctypes_refrig_name = refrig_name # Use the plain refrigerant identifier when calling the native loader.
if os.name == 'posix': # On POSIX the native library usually resolves the actual "libRxxx.so"
if not ctypes_refrig_name.lower().endswith("so"): # filename itself and expects a simple name like "R134a". Passing a
ctypes_refrig_name = ctypes_refrig_name + ".so" # modified filename (e.g. "libR134a.so") can confuse the native loader
if not ctypes_refrig_name.lower().startswith("lib"): # and lead to crashes. Also ensure the loader returns a void pointer and
ctypes_refrig_name = "lib" + ctypes_refrig_name # validate it before using.
try: try:
ctypes.CDLL(os.path.join(dll_directory, REFIFC_LIB_NAME)) ctypes.CDLL(os.path.join(dll_directory, REFIFC_LIB_NAME))
except OSError: except OSError:
# best-effort warning; not fatal here (the main loader already succeeded)
print(f"Refrig {refrig_name} not found, please check!") print(f"Refrig {refrig_name} not found, please check!")
func = self.lib.refdll_load func = self.lib.refdll_load
func.restype = POINTER(c_void_p) # expect a void* handle from the loader
func.restype = c_void_p
func.argtypes = [c_char_p, c_void_p] func.argtypes = [c_char_p, c_void_p]
self.handle = func(c_char_p(refrig_name.encode('utf-8')), c_void_p()) name_bytes = refrig_name.encode('utf-8')
try:
res = func(c_char_p(name_bytes), c_void_p())
if not res:
# loader returned NULL -> raise to surface a Python-level error
raise OSError(f"refdll_load returned NULL for refrigerant '{refrig_name}'")
# store handle as a c_void_p
self.handle = c_void_p(res)
finally:
# restore cwd even if the native call raises
try:
os.chdir(self.original_directory)
except Exception:
pass
# def __del__(self): # def __del__(self):

View File

@@ -360,5 +360,48 @@ class CycleCalculator:
} }
} }
def calculate_cycle_with_economizer(
self,
evap_pressure: float,
cond_pressure: float,
inter_pressure: float,
superheat: float = 5.0,
subcool: float = 3.0,
compressor_efficiency: float = 0.70,
mass_flow: float = 0.1
) -> Dict[str, Any]:
"""
Compatibility wrapper for tests: simple economizer approximation.
This provides a lightweight result that mimics an economizer cycle
without a full two-stage implementation. It reuses the simple cycle
calculation for the high-pressure stage and estimates a flash
fraction from the intermediate pressure location.
"""
# Basic validation
if not (evap_pressure > 0 and cond_pressure > 0 and inter_pressure > 0):
raise ValueError("Pressures must be positive")
# Estimate flash fraction as normalized position of inter between evap and cond
try:
frac = (inter_pressure - evap_pressure) / (cond_pressure - evap_pressure)
except Exception:
frac = 0.0
flash_fraction = max(0.0, min(1.0, float(frac)))
# compute a simple cycle performance for the overall pressures
base = self.calculate_simple_cycle(evap_pressure, cond_pressure, superheat, subcool, compressor_efficiency, mass_flow)
# attach economizer-specific fields
perf = base.get('performance', {})
perf['flash_fraction'] = flash_fraction
# Return a structure similar to simple cycle but with economizer info
return {
'points': base.get('points', []),
'performance': perf,
'diagram_data': base.get('diagram_data', {})
}
# Force reload 2025-10-18 23:04:14 # Force reload 2025-10-18 23:04:14

View File

@@ -152,7 +152,12 @@ class DiagramGenerator:
def plot_diagram( def plot_diagram(
self, self,
cycle_points: Optional[List[Tuple[float, float]]] = None, cycle_points: Optional[List[Tuple[float, float]]] = None,
title: Optional[str] = None title: Optional[str] = None,
p_min: Optional[float] = None,
p_max: Optional[float] = None,
h_min: Optional[float] = None,
h_max: Optional[float] = None,
include_isotherms: bool = True,
) -> Figure: ) -> Figure:
""" """
Génère le diagramme PH complet. Génère le diagramme PH complet.
@@ -187,14 +192,19 @@ class DiagramGenerator:
ax.plot(self.Hsl, [p / 1e5 for p in self.Psat], 'k-', label='Liquid Saturation') ax.plot(self.Hsl, [p / 1e5 for p in self.Psat], 'k-', label='Liquid Saturation')
ax.plot(self.Hsv, [p / 1e5 for p in self.Psat], 'k-', label='Vapor Saturation') ax.plot(self.Hsv, [p / 1e5 for p in self.Psat], 'k-', label='Vapor Saturation')
# Lignes 196-202 du code original: Plot isotherms # Lignes 196-202 du code original: Plot isotherms (optional)
for Th_lst, temp in zip(self.IsoT_lst, self.T_lst): if include_isotherms:
ax.plot(Th_lst, self.P / 1e5, 'g--', label=f'{temp}°C Isotherm', alpha=0.5) for Th_lst, temp in zip(self.IsoT_lst, self.T_lst):
ax.annotate('{:.0f}°C'.format(temp), ax.plot(Th_lst, self.P / 1e5, 'g--', label=f'{temp}°C Isotherm', alpha=0.5)
(self.refrigerant.h_px(self.refrigerant.p_Tx(temp + 273.15, 0.5), 0.1) / 1e3, try:
self.refrigerant.p_Tx(temp + 273.15, 0.5) / 1e5), ax.annotate('{:.0f}°C'.format(temp),
ha='center', (self.refrigerant.h_px(self.refrigerant.p_Tx(temp + 273.15, 0.5), 0.1) / 1e3,
backgroundcolor="white") self.refrigerant.p_Tx(temp + 273.15, 0.5) / 1e5),
ha='center',
backgroundcolor="white")
except Exception:
# Non-fatal: annotation failure shouldn't break plotting
pass
# Ligne 204 du code original # Ligne 204 du code original
ax.set_yscale('log') ax.set_yscale('log')
@@ -220,6 +230,19 @@ class DiagramGenerator:
# Ligne 224 du code original # Ligne 224 du code original
fig.tight_layout() fig.tight_layout()
# Apply axis limits if provided (p_min/p_max are in bar, h_min/h_max in kJ/kg)
try:
if p_min is not None or p_max is not None:
y_min = p_min if p_min is not None else ax.get_ylim()[0]
y_max = p_max if p_max is not None else ax.get_ylim()[1]
ax.set_ylim(float(y_min), float(y_max))
if h_min is not None or h_max is not None:
x_min = h_min if h_min is not None else ax.get_xlim()[0]
x_max = h_max if h_max is not None else ax.get_xlim()[1]
ax.set_xlim(float(x_min), float(x_max))
except Exception:
# ignore axis limit errors
pass
return fig return fig
def export_to_base64(self, fig: Figure) -> str: def export_to_base64(self, fig: Figure) -> str:

View File

@@ -8,7 +8,10 @@ dependencies = [
"altair>=5.5.0", "altair>=5.5.0",
"ipykernel>=6.29.5", "ipykernel>=6.29.5",
"matplotlib>=3.10.3", "matplotlib>=3.10.3",
"numpy>=2.3.0",
"openpyxl>=3.1.5", "openpyxl>=3.1.5",
"pandas>=2.3.0", "pandas>=2.3.0",
"pip>=25.2",
"plotly>=6.1.2", "plotly>=6.1.2",
"unicorn>=2.1.4",
] ]

View File

@@ -0,0 +1,12 @@
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)

58
scripts/run_api_tests.py Normal file
View File

@@ -0,0 +1,58 @@
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

@@ -0,0 +1,78 @@
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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,28 @@
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)

5
start_api.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
export LD_LIBRARY_PATH="$(pwd)/app/ipm/lib/so:${LD_LIBRARY_PATH:-}"
export PYTHONPATH="$(pwd)"
. .venv/bin/activate
uvicorn app.main:app --host 127.0.0.1 --port 8001

BIN
test_complete.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

BIN
test_matplotlib.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

BIN
test_saturation.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

44
tests/test_diagram_api.py Normal file
View File

@@ -0,0 +1,44 @@
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

@@ -0,0 +1,19 @@
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

View File

@@ -201,8 +201,13 @@ def test_api_direct():
try: try:
import requests import requests
# Charger la requête # Charger la requête (fichier à côté de ce script)
with open('request_r290.json', 'r') as f: request_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'request_r290.json')
if not os.path.exists(request_path):
# Fallback: essayer dans le répertoire courant
request_path = 'request_r290.json'
with open(request_path, 'r') as f:
request_data = json.load(f) request_data = json.load(f)
# Appeler l'API # Appeler l'API

912
uv.lock generated

File diff suppressed because it is too large Load Diff