ci: commit workspace changes from notebook and backend fixes (excludes test_env, Frontend)

This commit is contained in:
Repo Bot
2025-10-19 09:25:12 +02:00
commit 92e9b05393
80 changed files with 11653 additions and 0 deletions

80
app/README_API.md Normal file
View File

@@ -0,0 +1,80 @@
## 🚀 Démarrage rapide - API Diagramme PH
### Installation
```bash
# Installer les dépendances
pip install -r requirements.txt
```
### Configuration
```bash
# Copier le fichier d'exemple
cp ../.env.example ../.env
# Éditer .env si nécessaire (optionnel)
```
### Lancement de l'API
```bash
# Option 1 : Depuis le répertoire racine
python -m app.main
# Option 2 : Avec uvicorn directement
uvicorn app.main:app --reload --port 8000
# Option 3 : En mode production (sans reload)
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
```
### Test de l'API
Une fois lancée, visitez :
- **Documentation interactive** : http://localhost:8000/docs
- **Documentation alternative** : http://localhost:8000/redoc
- **Page d'accueil** : http://localhost:8000/
- **Health check** : http://localhost:8000/api/v1/health
### Test avec cURL
```bash
# Test health
curl http://localhost:8000/api/v1/health
# Résultat attendu:
# {
# "status": "healthy",
# "service": "Diagram PH API",
# "version": "1.0.0",
# "environment": "development"
# }
```
### Structure du projet
```
app/
├── __init__.py
├── main.py # Point d'entrée FastAPI
├── config.py # Configuration
├── requirements.txt # Dépendances
├── api/ # Endpoints API
│ └── v1/
│ └── endpoints/
├── core/ # Logique métier
├── models/ # Modèles Pydantic
├── services/ # Services
└── utils/ # Utilitaires
```
### Prochaines étapes
Voir TACHES_IMPLEMENTATION.md pour les tâches suivantes :
- Phase 2 : Intégration bibliothèques .so
- Phase 3 : Calculs thermodynamiques
- Phase 4 : Génération diagrammes

6
app/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""
API Diagramme PH
Application principale pour génération de diagrammes PH et calculs frigorifiques
"""
__version__ = "1.0.0"

1
app/api/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""API routes"""

1
app/api/v1/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""API v1 routes"""

View File

@@ -0,0 +1 @@
"""API v1 endpoints"""

View File

@@ -0,0 +1,335 @@
"""
Endpoints API pour les calculs de cycles frigorifiques.
Ce module fournit les endpoints pour:
- Calcul de cycles simples
- Calcul de cycles avec économiseur
- Liste des types de cycles disponibles
"""
from fastapi import APIRouter, HTTPException, status
from typing import List
import logging
from app.models.cycle import (
SimpleCycleRequest,
SimpleCycleResponse,
CyclePerformance,
CyclePoint,
CycleError
)
from app.core.refrigerant_loader import RefrigerantLibrary
from app.services.cycle_calculator import CycleCalculator
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get(
"/cycles/types",
response_model=List[str],
summary="Liste des types de cycles",
description="Retourne la liste des types de cycles frigorifiques disponibles"
)
async def get_cycle_types():
"""
Retourne les types de cycles disponibles.
Returns:
Liste des types de cycles
"""
return [
"simple",
"economizer"
]
@router.post(
"/cycles/simple",
response_model=SimpleCycleResponse,
summary="Calcul d'un cycle frigorifique simple",
description="Calcule les performances d'un cycle frigorifique simple à 4 points",
status_code=status.HTTP_200_OK
)
async def calculate_simple_cycle(request: SimpleCycleRequest):
"""
Calcule un cycle frigorifique simple.
Le cycle simple est composé de 4 points:
- Point 1: Sortie évaporateur (aspiration compresseur)
- Point 2: Refoulement compresseur
- Point 3: Sortie condenseur
- Point 4: Sortie détendeur
Notes:
- L'API accepte des pressions en bar. En interne nous utilisons Pa.
- Cette fonction convertit les pressions entrantes en Pa avant le calcul
et reconvertit les pressions renvoyées en bar pour la réponse.
"""
try:
logger.info(f"Calcul cycle simple pour {request.refrigerant}")
# Charger le réfrigérant
try:
refrigerant = RefrigerantLibrary(request.refrigerant)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Refrigerant '{request.refrigerant}' not found: {str(e)}"
)
# Créer le calculateur
calculator = CycleCalculator(refrigerant)
# Déterminer les pressions (soit fournies en bar, soit calculées depuis les températures)
if request.evap_pressure is not None:
evap_pressure_pa = request.evap_pressure * 1e5 # bar -> Pa
logger.info(f"Evap pressure provided: {request.evap_pressure:.3f} bar -> {evap_pressure_pa:.0f} Pa")
else:
# Calculer la pression depuis la température (retourne Pa)
evap_pressure_pa = calculator.get_pressure_from_saturation_temperature(
request.evap_temperature, quality=1.0 # Vapeur saturée
)
logger.info(f"Pression d'évaporation calculée: {evap_pressure_pa/1e5:.3f} bar pour T={request.evap_temperature}°C")
if request.cond_pressure is not None:
cond_pressure_pa = request.cond_pressure * 1e5 # bar -> Pa
logger.info(f"Cond pressure provided: {request.cond_pressure:.3f} bar -> {cond_pressure_pa:.0f} Pa")
else:
# Calculer la pression depuis la température (retourne Pa)
cond_pressure_pa = calculator.get_pressure_from_saturation_temperature(
request.cond_temperature, quality=0.0 # Liquide saturé
)
logger.info(f"Pression de condensation calculée: {cond_pressure_pa/1e5:.3f} bar pour T={request.cond_temperature}°C")
# Calculer le rapport de pression (unité indépendante)
pressure_ratio = cond_pressure_pa / evap_pressure_pa
# Déterminer le rendement du compresseur (soit fourni, soit calculé)
if request.compressor_efficiency is not None:
compressor_efficiency = request.compressor_efficiency
logger.info(f"Rendement compresseur fourni: {compressor_efficiency:.3f}")
else:
# Calculer automatiquement depuis le rapport de pression
compressor_efficiency = calculator.calculate_compressor_efficiency(pressure_ratio)
logger.info(f"Rendement compresseur calculé: {compressor_efficiency:.3f} (PR={pressure_ratio:.2f})")
# Calculer le cycle (toutes les pressions passées en Pa)
result = calculator.calculate_simple_cycle(
evap_pressure=evap_pressure_pa,
cond_pressure=cond_pressure_pa,
superheat=request.superheat,
subcool=request.subcool,
compressor_efficiency=compressor_efficiency,
mass_flow=request.mass_flow
)
# Construire la réponse : convertir les pressions internes (Pa) en bar pour l'API
cycle_points = [
CyclePoint(
point_id=pt["point_id"],
description=pt["description"],
pressure=(pt["pressure"] / 1e5) if pt.get("pressure") is not None else None,
temperature=pt.get("temperature"),
enthalpy=pt.get("enthalpy"),
entropy=pt.get("entropy"),
quality=pt.get("quality")
)
for pt in result["points"]
]
# Convertir pressures dans diagram_data si présent
diagram_data = result.get("diagram_data")
if diagram_data and "cycle_points" in diagram_data:
diagram_data["cycle_points"] = [
{"enthalpy": cp["enthalpy"], "pressure": (cp["pressure"] / 1e5)}
for cp in diagram_data["cycle_points"]
]
performance = CyclePerformance(
cop=result["performance"]["cop"],
cooling_capacity=result["performance"]["cooling_capacity"],
heating_capacity=result["performance"]["heating_capacity"],
compressor_power=result["performance"]["compressor_power"],
compressor_efficiency=result["performance"]["compressor_efficiency"],
mass_flow=result["performance"]["mass_flow"],
volumetric_flow=result["performance"]["volumetric_flow"],
compression_ratio=result["performance"]["compression_ratio"],
discharge_temperature=result["performance"]["discharge_temperature"]
)
return SimpleCycleResponse(
refrigerant=request.refrigerant,
cycle_type="simple",
points=cycle_points,
performance=performance,
diagram_data=diagram_data
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid calculation parameters: {str(e)}"
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Cycle calculation error: {str(e)}"
)
@router.post(
"/cycles/simple/validate",
response_model=dict,
summary="Validation des paramètres de cycle",
description="Valide les paramètres d'un cycle sans effectuer le calcul complet"
)
async def validate_cycle_parameters(request: SimpleCycleRequest):
"""
Valide les paramètres d'un cycle frigorifique.
Vérifie:
- Existence du réfrigérant
- Cohérence des pressions (P_cond > P_evap)
- Plages de valeurs acceptables
Args:
request: Paramètres à valider
Returns:
État de validation et messages
"""
issues = []
# Vérifier le réfrigérant
try:
refrigerant = RefrigerantLibrary(request.refrigerant)
calculator = CycleCalculator(refrigerant)
except Exception:
issues.append(f"Refrigerant '{request.refrigerant}' not found")
calculator = None
# Déterminer les pressions
if request.evap_pressure is not None:
evap_pressure = request.evap_pressure
else:
if calculator:
evap_pressure = calculator.get_pressure_from_saturation_temperature(
request.evap_temperature, quality=1.0
)
else:
evap_pressure = None
if request.cond_pressure is not None:
cond_pressure = request.cond_pressure
else:
if calculator:
cond_pressure = calculator.get_pressure_from_saturation_temperature(
request.cond_temperature, quality=0.0
)
else:
cond_pressure = None
# Vérifier les pressions
if evap_pressure and cond_pressure and cond_pressure <= evap_pressure:
issues.append(
f"Condensing pressure ({cond_pressure:.2f} bar) must be "
f"greater than evaporating pressure ({evap_pressure:.2f} bar)"
)
# Vérifier le rendement (seulement s'il est fourni)
if request.compressor_efficiency is not None:
if not 0.4 <= request.compressor_efficiency <= 1.0:
issues.append(
f"Compressor efficiency ({request.compressor_efficiency}) should be "
f"between 0.4 and 1.0"
)
# Vérifier le débit
if request.mass_flow <= 0:
issues.append(
f"Mass flow rate ({request.mass_flow}) must be positive"
)
# Vérifier surchauffe et sous-refroidissement
if request.superheat < 0:
issues.append(f"Superheat ({request.superheat}) cannot be negative")
if request.subcool < 0:
issues.append(f"Subcooling ({request.subcool}) cannot be negative")
is_valid = len(issues) == 0
return {
"valid": is_valid,
"issues": issues if not is_valid else [],
"message": "Parameters are valid" if is_valid else "Parameters validation failed"
}
@router.get(
"/cycles/info",
summary="Informations sur les cycles",
description="Retourne des informations détaillées sur les différents types de cycles"
)
async def get_cycles_info():
"""
Retourne des informations sur les cycles disponibles.
Returns:
Descriptions des types de cycles
"""
return {
"cycles": [
{
"type": "simple",
"name": "Cycle frigorifique simple",
"description": "Cycle de base à compression simple avec 4 points",
"components": [
"Evaporateur",
"Compresseur",
"Condenseur",
"Détendeur"
],
"points": [
{
"id": "1",
"name": "Sortie évaporateur (aspiration)",
"state": "Vapeur surchauffée"
},
{
"id": "2",
"name": "Refoulement compresseur",
"state": "Vapeur haute pression"
},
{
"id": "3",
"name": "Sortie condenseur",
"state": "Liquide sous-refroidi"
},
{
"id": "4",
"name": "Sortie détendeur",
"state": "Mélange liquide-vapeur"
}
],
"typical_cop_range": [2.5, 4.5]
},
{
"type": "economizer",
"name": "Cycle avec économiseur",
"description": "Cycle à double étage avec séparateur intermédiaire",
"components": [
"Evaporateur",
"Compresseur BP",
"Economiseur",
"Compresseur HP",
"Condenseur",
"Détendeur principal",
"Détendeur secondaire"
],
"status": "À implémenter",
"typical_cop_range": [3.0, 5.5]
}
]
}

View File

@@ -0,0 +1,187 @@
"""
Endpoints API pour la génération de diagrammes PH.
"""
from fastapi import APIRouter, HTTPException, status
from typing import Dict, Any
from app.models.diagram import (
DiagramRequest,
DiagramResponse,
DiagramError
)
from app.services.diagram_generator import DiagramGenerator
from app.core.refrigerant_loader import get_refrigerant
from collections import OrderedDict
import json
router = APIRouter(prefix="/diagrams", tags=["diagrams"])
@router.post(
"/ph",
response_model=DiagramResponse,
summary="Générer un diagramme Pression-Enthalpie",
description="Génère un diagramme PH avec courbe de saturation et isothermes",
status_code=status.HTTP_200_OK
)
async def generate_ph_diagram(request: DiagramRequest) -> DiagramResponse:
"""
Génère un diagramme Pression-Enthalpie.
Args:
request: Paramètres du diagramme
Returns:
DiagramResponse avec image et/ou données
Raises:
HTTPException: Si erreur lors de la génération
"""
try:
# Charger le réfrigérant
refrigerant = get_refrigerant(request.refrigerant)
if refrigerant is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Réfrigérant '{request.refrigerant}' non disponible"
)
# Créer le générateur
generator = DiagramGenerator(refrigerant)
# Configurer dimensions si PNG
if request.format in ["png", "both"]:
generator.fig_width = request.width / 100
generator.fig_height = request.height / 100
generator.dpi = request.dpi
# Convertir cycle_points en tuples
cycle_points_tuples = None
if request.cycle_points:
cycle_points_tuples = [
(pt["enthalpy"], pt["pressure"])
for pt in request.cycle_points
]
# Simple in-memory LRU cache for diagram JSON results (keyed by request body)
# Cache only JSON responses to avoid storing large PNG blobs in memory
if not hasattr(generate_ph_diagram, "_diagram_cache"):
generate_ph_diagram._diagram_cache = OrderedDict()
generate_ph_diagram._cache_size = 50
def _make_cache_key(req: DiagramRequest) -> str:
# Use a stable JSON string of important fields as cache key
def _serialize(v):
# Pydantic BaseModel expose model_dump in v2; fall back to dict
try:
if hasattr(v, 'model_dump'):
return v.model_dump()
elif hasattr(v, 'dict'):
return v.dict()
except Exception:
pass
return v
key_obj = {
"refrigerant": req.refrigerant,
"pressure_range": _serialize(req.pressure_range),
"include_isotherms": req.include_isotherms,
"cycle_points": _serialize(req.cycle_points),
"format": req.format,
}
return json.dumps(key_obj, sort_keys=True, separators=(",", ":"))
cache_key = _make_cache_key(request)
# If client requested JSON or both, try cache
cached_result = None
if request.format in ["json", "both"]:
cached_result = generate_ph_diagram._diagram_cache.get(cache_key)
if cached_result is not None:
# move to end (most recently used)
generate_ph_diagram._diagram_cache.move_to_end(cache_key)
result = cached_result
generation_time = 0.0
else:
import time
start_time = time.time()
result = generator.generate_complete_diagram(
cycle_points=cycle_points_tuples,
title=request.title,
export_format=request.format
)
generation_time = (time.time() - start_time) * 1000 # ms
# store JSON part in cache if present and format includes json
if request.format in ["json", "both"] and "data" in result:
# store only the data dictionary to keep cache small
cache_entry = {"data": result.get("data")}
generate_ph_diagram._diagram_cache[cache_key] = cache_entry
# enforce cache size
if len(generate_ph_diagram._diagram_cache) > generate_ph_diagram._cache_size:
generate_ph_diagram._diagram_cache.popitem(last=False)
# Construire la réponse
response_data = {
"success": True,
"metadata": {
"generation_time_ms": round(generation_time, 2),
"refrigerant": request.refrigerant,
"export_format": request.format
},
"message": f"Diagramme PH généré pour {request.refrigerant}"
}
# Ajouter l'image si générée
if "image_base64" in result:
response_data["image"] = result["image_base64"]
response_data["metadata"]["image_format"] = "png"
response_data["metadata"]["image_width"] = request.width
response_data["metadata"]["image_height"] = request.height
# Ajouter les données si générées
if "data" in result:
response_data["data"] = result["data"]
return DiagramResponse(**response_data)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Paramètres invalides: {str(e)}"
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Erreur génération diagramme: {str(e)}"
)
@router.get(
"/",
summary="Types de diagrammes disponibles",
response_model=Dict[str, Any]
)
async def list_diagram_types():
"""Liste les types de diagrammes disponibles."""
return {
"diagram_types": [
{
"type": "ph",
"name": "Pression-Enthalpie",
"description": "Diagramme log(P) vs h avec courbe de saturation et isothermes",
"endpoint": "/api/v1/diagrams/ph"
}
],
"export_formats": ["png", "json", "both"],
"supported_refrigerants": [
"R12", "R22", "R32", "R134a", "R290", "R404A", "R410A",
"R452A", "R454A", "R454B", "R502", "R507A", "R513A",
"R515B", "R744", "R1233zd", "R1234ze"
]
}

View File

@@ -0,0 +1,225 @@
"""
Endpoints pour les calculs de proprietes thermodynamiques
"""
from fastapi import APIRouter, HTTPException, status
from app.models.properties import (
PropertyCalculationRequest,
PropertyCalculationResponse,
SaturationRequest,
SaturationResponse
)
from app.services.thermodynamics import get_thermodynamics_service
router = APIRouter()
@router.post(
"/calculate",
response_model=PropertyCalculationResponse,
summary="Calculer les proprietes thermodynamiques",
description="Calcule les proprietes thermodynamiques d'un refrigerant selon differents parametres d'entree"
)
async def calculate_properties(request: PropertyCalculationRequest):
"""
Calcule les proprietes thermodynamiques d'un refrigerant.
Types de calcul disponibles:
- **px**: A partir de pression et qualite
- **pT**: A partir de pression et temperature
- **ph**: A partir de pression et enthalpie
- **Tx**: A partir de temperature et qualite
Args:
request: Parametres du calcul
Returns:
PropertyCalculationResponse: Proprietes calculees
Raises:
400: Parametres invalides
404: Refrigerant non trouve
500: Erreur de calcul
"""
try:
service = get_thermodynamics_service()
if request.calculation_type == "px":
if request.pressure is None or request.quality is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Pression et qualite requises pour le calcul px"
)
result = service.calculate_from_px(
request.refrigerant,
request.pressure,
request.quality
)
elif request.calculation_type == "pT":
if request.pressure is None or request.temperature is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Pression et temperature requises pour le calcul pT"
)
result = service.calculate_from_pT(
request.refrigerant,
request.pressure,
request.temperature
)
elif request.calculation_type == "ph":
if request.pressure is None or request.enthalpy is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Pression et enthalpie requises pour le calcul ph"
)
result = service.calculate_from_ph(
request.refrigerant,
request.pressure,
request.enthalpy
)
elif request.calculation_type == "Tx":
if request.temperature is None or request.quality is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Temperature et qualite requises pour le calcul Tx"
)
result = service.calculate_from_Tx(
request.refrigerant,
request.temperature,
request.quality
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Type de calcul invalide: {request.calculation_type}"
)
return result
except HTTPException:
raise
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Erreur lors du calcul des proprietes: {str(e)}"
)
@router.post(
"/saturation",
response_model=SaturationResponse,
summary="Proprietes de saturation",
description="Obtient les proprietes de saturation (liquide et vapeur) a une pression donnee"
)
async def get_saturation_properties(request: SaturationRequest):
"""
Obtient les proprietes de saturation d'un refrigerant.
Retourne les proprietes du liquide sature et de la vapeur saturee
a la pression specifiee, ainsi que la temperature de saturation
et la chaleur latente de vaporisation.
Args:
request: Refrigerant et pression
Returns:
SaturationResponse: Proprietes de saturation
Raises:
404: Refrigerant non trouve
500: Erreur de calcul
"""
try:
service = get_thermodynamics_service()
result = service.get_saturation_properties(
request.refrigerant,
request.pressure
)
return result
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Erreur lors du calcul des proprietes de saturation: {str(e)}"
)
@router.get(
"/",
summary="Information sur les endpoints de proprietes",
description="Retourne la liste des endpoints disponibles pour les calculs de proprietes"
)
async def properties_info():
"""
Retourne les informations sur les endpoints disponibles.
Returns:
dict: Liste des endpoints et leurs descriptions
"""
return {
"endpoints": [
{
"path": "/api/v1/properties/calculate",
"method": "POST",
"description": "Calcule les proprietes thermodynamiques",
"calculation_types": [
{
"type": "px",
"description": "A partir de pression et qualite",
"required": ["pressure", "quality"]
},
{
"type": "pT",
"description": "A partir de pression et temperature",
"required": ["pressure", "temperature"]
},
{
"type": "ph",
"description": "A partir de pression et enthalpie",
"required": ["pressure", "enthalpy"]
},
{
"type": "Tx",
"description": "A partir de temperature et qualite",
"required": ["temperature", "quality"]
}
]
},
{
"path": "/api/v1/properties/saturation",
"method": "POST",
"description": "Obtient les proprietes de saturation",
"required": ["refrigerant", "pressure"]
}
],
"units": {
"pressure": "Pa (Pascal)",
"temperature": "K (Kelvin)",
"enthalpy": "J/kg",
"entropy": "J/kg.K",
"density": "kg/m3",
"quality": "0-1 (sans dimension)"
},
"example": {
"refrigerant": "R134a",
"calculation_type": "px",
"pressure": 500000,
"quality": 0.5
}
}

View File

@@ -0,0 +1,150 @@
"""
Endpoints pour les refrigerants
"""
from fastapi import APIRouter, HTTPException, status
from typing import List
from app.models.refrigerant import (
RefrigerantInfo,
RefrigerantsListResponse
)
from app.core.refrigerant_loader import get_manager
router = APIRouter()
@router.get(
"/",
response_model=RefrigerantsListResponse,
summary="Liste des refrigerants disponibles",
description="Retourne la liste de tous les refrigerants supportes avec leur disponibilite"
)
async def list_refrigerants():
"""
Liste tous les refrigerants disponibles dans le systeme.
Retourne pour chaque refrigerant:
- name: Nom du refrigerant (ex: R134a, R410A)
- available: Si la bibliotheque est disponible
- loaded: Si le refrigerant est actuellement charge en memoire
- error: Message d'erreur si indisponible
Returns:
RefrigerantsListResponse: Liste des refrigerants avec statistiques
"""
try:
manager = get_manager()
refrigerants = manager.get_available_refrigerants()
available_count = sum(1 for r in refrigerants if r.get("available", False))
return RefrigerantsListResponse(
refrigerants=refrigerants,
total=len(refrigerants),
available_count=available_count
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Erreur lors de la recuperation des refrigerants: {str(e)}"
)
@router.get(
"/{refrigerant_name}",
response_model=RefrigerantInfo,
summary="Informations sur un refrigerant specifique",
description="Retourne les informations detaillees d'un refrigerant"
)
async def get_refrigerant_info(refrigerant_name: str):
"""
Obtient les informations d'un refrigerant specifique.
Args:
refrigerant_name: Nom du refrigerant (ex: R134a, R410A)
Returns:
RefrigerantInfo: Informations du refrigerant
Raises:
404: Si le refrigerant n'existe pas
500: En cas d'erreur interne
"""
try:
manager = get_manager()
refrigerants = manager.get_available_refrigerants()
# Rechercher le refrigerant
refrig_info = next(
(r for r in refrigerants if r["name"] == refrigerant_name),
None
)
if refrig_info is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Refrigerant '{refrigerant_name}' non trouve. "
f"Refrigerants disponibles: {', '.join(r['name'] for r in refrigerants)}"
)
return RefrigerantInfo(**refrig_info)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Erreur lors de la recuperation du refrigerant: {str(e)}"
)
@router.post(
"/{refrigerant_name}/load",
response_model=RefrigerantInfo,
summary="Charger un refrigerant en memoire",
description="Charge explicitement un refrigerant en memoire pour accelerer les calculs"
)
async def load_refrigerant(refrigerant_name: str):
"""
Charge un refrigerant en memoire.
Utile pour precharger les refrigerants frequemment utilises
et ameliorer les performances.
Args:
refrigerant_name: Nom du refrigerant a charger
Returns:
RefrigerantInfo: Informations du refrigerant charge
Raises:
404: Si le refrigerant n'existe pas
500: Si le chargement echoue
"""
try:
manager = get_manager()
# Tenter de charger le refrigerant
lib = manager.load_refrigerant(refrigerant_name)
return RefrigerantInfo(
name=refrigerant_name,
available=True,
loaded=True
)
except ValueError as e:
# Refrigerant non supporte
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
except Exception as e:
# Erreur de chargement
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Erreur lors du chargement de {refrigerant_name}: {str(e)}"
)

42
app/config.py Normal file
View File

@@ -0,0 +1,42 @@
"""
Configuration settings for the Diagram PH API
"""
from pydantic_settings import BaseSettings
from typing import List
class Settings(BaseSettings):
"""Application settings"""
# Application
APP_NAME: str = "Diagram PH API"
VERSION: str = "1.0.0"
ENV: str = "development"
LOG_LEVEL: str = "DEBUG"
# Server
HOST: str = "0.0.0.0"
PORT: int = 8001
# CORS
CORS_ORIGINS: List[str] = [
"http://localhost:3000",
"http://localhost:3001",
"http://localhost:8000",
"http://localhost:5173", # Vite dev server
]
# Cache
CACHE_TTL_SECONDS: int = 3600
CACHE_MAX_SIZE: int = 10000
# Rate limiting
RATE_LIMIT_PER_MINUTE: int = 100
class Config:
env_file = ".env"
case_sensitive = True
# Create global settings instance
settings = Settings()

1
app/core/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Core business logic modules"""

View File

@@ -0,0 +1,223 @@
"""
Module de chargement des refrigerants - Utilise directement IPM_DLL/simple_refrig_api.py
"""
import sys
import os
from pathlib import Path
from typing import Dict, Optional, List
# Ajouter le repertoire IPM_DLL au path pour importer simple_refrig_api
_current_dir = Path(__file__).parent.parent.parent
_ipm_dll_dir = _current_dir / "IPM_DLL"
if str(_ipm_dll_dir) not in sys.path:
sys.path.insert(0, str(_ipm_dll_dir))
from simple_refrig_api import Refifc
class RefrigerantLibrary:
"""
Wrapper autour de Refifc pour compatibilite avec l'API.
Utilise directement la classe Refifc qui fonctionne dans le code original.
"""
def __init__(self, refrig_name: str, libs_dir: Optional[Path] = None):
"""
Initialise le chargement du refrigerant.
Args:
refrig_name: Nom du refrigerant (ex: "R134a", "R290")
libs_dir: Repertoire contenant les bibliotheques (optionnel, non utilise car Refifc gere ca)
"""
self.refrig_name = refrig_name
# Utiliser Refifc directement - c'est la classe qui fonctionne dans le code original
self._refifc = Refifc(refrig_name)
# Exposer toutes les methodes de Refifc avec la meme signature
def T_px(self, p: float, x: float) -> float:
"""Température à partir de pression (Pa) et qualité (retourne K).
Note: les méthodes de bas niveau attendent et retournent des unités SI
(pression en Pa, température en K, enthalpie en J/kg, entropie en J/kg.K).
"""
return self._refifc.T_px(p, x)
def h_px(self, p: float, x: float) -> float:
"""Enthalpie à partir de pression (Pa) et qualité (retourne J/kg)"""
return self._refifc.h_px(p, x)
def h_pT(self, p: float, T: float) -> float:
"""Enthalpie à partir de pression (Pa) et température (K) (retourne J/kg)"""
return self._refifc.h_pT(p, T)
def x_ph(self, p: float, h: float) -> float:
"""Qualité à partir de pression (Pa) et enthalpie (J/kg)"""
return self._refifc.x_ph(p, h)
def p_Tx(self, T: float, x: float) -> float:
"""Pression à partir de température (K) et qualité (retourne Pa)."""
return self._refifc.p_Tx(T, x)
def Ts_px(self, p: float, x: float) -> float:
"""Température de saturation à partir de pression (Pa) et qualité (retourne K)"""
return self._refifc.Ts_px(p, x)
def rho_px(self, p: float, x: float) -> float:
"""Densité à partir de pression (Pa) et qualité"""
return self._refifc.rho_px(p, x)
def s_px(self, p: float, x: float) -> float:
"""Entropie à partir de pression (Pa) et qualité (retourne J/kg.K)"""
return self._refifc.s_px(p, x)
def hsl_px(self, p: float, x: float) -> float:
"""Enthalpie liquide saturée (retourne J/kg)"""
return self._refifc.hsl_px(p, x)
def hsv_px(self, p: float, x: float) -> float:
"""Enthalpie vapeur saturée (retourne J/kg)"""
return self._refifc.hsv_px(p, x)
def rhosl_px(self, p: float, x: float) -> float:
"""Densité liquide saturée"""
return self._refifc.rhosl_px(p, x)
def rhosv_px(self, p: float, x: float) -> float:
"""Densité vapeur saturée"""
return self._refifc.rhosv_px(p, x)
def p_begin(self) -> float:
"""Pression minimale du refrigerant (Pa)"""
return self._refifc.p_begin()
def p_end(self) -> float:
"""Pression maximale du refrigerant (Pa)"""
return self._refifc.p_end()
class RefrigerantManager:
"""Gestionnaire central pour tous les refrigerants disponibles"""
# Liste des refrigerants supportes
SUPPORTED_REFRIGERANTS = [
"R12", "R22", "R32", "R134a", "R290", "R404A", "R410A",
"R452A", "R454A", "R454B", "R502", "R507A", "R513A",
"R515B", "R717", "R744", "R1233zd", "R1234ze"
]
def __init__(self, libs_dir: Optional[Path] = None):
"""
Initialise le gestionnaire
Args:
libs_dir: Repertoire contenant les bibliotheques
"""
self.libs_dir = libs_dir
self._loaded_refrigerants: Dict[str, RefrigerantLibrary] = {}
def get_available_refrigerants(self) -> List[Dict[str, str]]:
"""
Retourne la liste des refrigerants disponibles
Returns:
Liste de dictionnaires avec nom et disponibilite
"""
available = []
for refrig in self.SUPPORTED_REFRIGERANTS:
try:
# Tenter de charger pour verifier disponibilite
if refrig not in self._loaded_refrigerants:
self.load_refrigerant(refrig)
available.append({
"name": refrig,
"available": True,
"loaded": refrig in self._loaded_refrigerants
})
except Exception as e:
available.append({
"name": refrig,
"available": False,
"error": str(e)
})
return available
def load_refrigerant(self, refrig_name: str) -> RefrigerantLibrary:
"""
Charge un refrigerant specifique
Args:
refrig_name: Nom du refrigerant
Returns:
Instance RefrigerantLibrary
Raises:
ValueError: Si le refrigerant n'est pas supporte
RuntimeError: Si le chargement echoue
"""
if refrig_name not in self.SUPPORTED_REFRIGERANTS:
raise ValueError(
f"Refrigerant non supporte: {refrig_name}. "
f"Supportes: {', '.join(self.SUPPORTED_REFRIGERANTS)}"
)
if refrig_name in self._loaded_refrigerants:
return self._loaded_refrigerants[refrig_name]
try:
lib = RefrigerantLibrary(refrig_name, self.libs_dir)
self._loaded_refrigerants[refrig_name] = lib
return lib
except Exception as e:
raise RuntimeError(
f"Erreur chargement {refrig_name}: {e}"
)
def get_refrigerant(self, refrig_name: str) -> RefrigerantLibrary:
"""
Obtient un refrigerant (le charge si necessaire)
Args:
refrig_name: Nom du refrigerant
Returns:
Instance RefrigerantLibrary
"""
if refrig_name not in self._loaded_refrigerants:
return self.load_refrigerant(refrig_name)
return self._loaded_refrigerants[refrig_name]
def unload_all(self):
"""Decharge tous les refrigerants"""
self._loaded_refrigerants.clear()
# Instance globale du gestionnaire
_manager: Optional[RefrigerantManager] = None
def get_manager() -> RefrigerantManager:
"""Obtient l'instance globale du gestionnaire"""
global _manager
if _manager is None:
_manager = RefrigerantManager()
return _manager
def get_refrigerant(name: str) -> RefrigerantLibrary:
"""
Fonction helper pour obtenir un refrigerant
Args:
name: Nom du refrigerant
Returns:
Instance RefrigerantLibrary
"""
return get_manager().get_refrigerant(name)

112
app/main.py Normal file
View File

@@ -0,0 +1,112 @@
"""
Main FastAPI application for Diagram PH API
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from app.config import settings
from app.api.v1.endpoints import refrigerants, properties, diagrams, cycles
import logging
# Configure logging
logging.basicConfig(
level=getattr(logging, settings.LOG_LEVEL),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Create FastAPI application
app = FastAPI(
title=settings.APP_NAME,
description="API REST pour génération de diagrammes PH et calculs frigorifiques",
version=settings.VERSION,
docs_url="/docs",
redoc_url="/redoc",
)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Add Gzip compression
app.add_middleware(GZipMiddleware, minimum_size=1000)
# Include routers
app.include_router(
refrigerants.router,
prefix="/api/v1/refrigerants",
tags=["Refrigerants"]
)
app.include_router(
properties.router,
prefix="/api/v1/properties",
tags=["Properties"]
)
app.include_router(
diagrams.router,
prefix="/api/v1",
tags=["Diagrams"]
)
app.include_router(
cycles.router,
prefix="/api/v1",
tags=["Cycles"]
)
@app.on_event("startup")
async def startup_event():
"""Actions to perform on application startup"""
logger.info(f"🚀 Starting {settings.APP_NAME} v{settings.VERSION}")
logger.info(f"📝 Environment: {settings.ENV}")
logger.info(f"📊 Log level: {settings.LOG_LEVEL}")
logger.info(f"🌐 CORS origins: {settings.CORS_ORIGINS}")
@app.on_event("shutdown")
async def shutdown_event():
"""Actions to perform on application shutdown"""
logger.info(f"🛑 Shutting down {settings.APP_NAME}")
@app.get("/", tags=["Root"])
def root():
"""Root endpoint - API information"""
return {
"message": f"Welcome to {settings.APP_NAME}",
"version": settings.VERSION,
"status": "running",
"docs": "/docs",
"redoc": "/redoc",
"health": "/api/v1/health"
}
@app.get("/api/v1/health", tags=["Health"])
def health_check():
"""Health check endpoint"""
return {
"status": "healthy",
"service": settings.APP_NAME,
"version": settings.VERSION,
"environment": settings.ENV
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host=settings.HOST,
port=settings.PORT,
reload=(settings.ENV == "development"),
log_level=settings.LOG_LEVEL.lower()
)

1
app/models/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Pydantic models for requests and responses"""

223
app/models/cycle.py Normal file
View File

@@ -0,0 +1,223 @@
"""
Modèles Pydantic pour les calculs de cycles frigorifiques.
"""
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field, field_validator
class CyclePoint(BaseModel):
"""Point d'un cycle frigorifique."""
point_id: str = Field(
...,
description="Identifiant du point (1, 2, 3, 4, etc.)",
examples=["1", "2", "3", "4"]
)
pressure: float = Field(
...,
description="Pression (bar)",
gt=0
)
temperature: Optional[float] = Field(
None,
description="Température (°C)"
)
enthalpy: Optional[float] = Field(
None,
description="Enthalpie (kJ/kg)"
)
entropy: Optional[float] = Field(
None,
description="Entropie (kJ/kg.K)"
)
quality: Optional[float] = Field(
None,
description="Titre vapeur (0-1)",
ge=0,
le=1
)
description: Optional[str] = Field(
None,
description="Description du point"
)
class SimpleCycleRequest(BaseModel):
"""
Requête pour calcul de cycle frigorifique simple (4 points).
Vous pouvez spécifier soit les pressions, soit les températures de saturation :
- evap_pressure OU evap_temperature (température d'évaporation)
- cond_pressure OU cond_temperature (température de condensation)
"""
refrigerant: str = Field(
...,
description="Code du réfrigérant",
examples=["R134a", "R410A"]
)
# Évaporation : Pression OU Température
evap_pressure: Optional[float] = Field(
None,
description="Pression d'évaporation (bar). Utiliser soit evap_pressure, soit evap_temperature",
gt=0,
examples=[2.9]
)
evap_temperature: Optional[float] = Field(
None,
description="Température d'évaporation (°C). Utiliser soit evap_pressure, soit evap_temperature",
examples=[-10.0]
)
# Condensation : Pression OU Température
cond_pressure: Optional[float] = Field(
None,
description="Pression de condensation (bar). Utiliser soit cond_pressure, soit cond_temperature",
gt=0,
examples=[12.0]
)
cond_temperature: Optional[float] = Field(
None,
description="Température de condensation (°C). Utiliser soit cond_pressure, soit cond_temperature",
examples=[40.0]
)
superheat: float = Field(
5.0,
description="Surchauffe à l'évaporateur (°C)",
ge=0,
examples=[5.0]
)
subcool: float = Field(
3.0,
description="Sous-refroidissement au condenseur (°C)",
ge=0,
examples=[3.0]
)
compressor_efficiency: Optional[float] = Field(
None,
description="Rendement isentropique du compresseur (0-1). Si non fourni, calculé automatiquement depuis le rapport de pression",
gt=0,
le=1,
examples=[0.70, 0.85]
)
mass_flow: float = Field(
0.1,
description="Débit massique (kg/s)",
gt=0,
examples=[0.1]
)
@field_validator('evap_temperature')
@classmethod
def validate_evap_input(cls, v, info):
"""Valide qu'on a soit evap_pressure, soit evap_temperature (mais pas les deux)."""
evap_pressure = info.data.get('evap_pressure')
if evap_pressure is None and v is None:
raise ValueError('Vous devez fournir soit evap_pressure, soit evap_temperature')
if evap_pressure is not None and v is not None:
raise ValueError('Fournissez soit evap_pressure, soit evap_temperature (pas les deux)')
return v
@field_validator('cond_temperature')
@classmethod
def validate_cond_input(cls, v, info):
"""Valide qu'on a soit cond_pressure, soit cond_temperature (mais pas les deux)."""
cond_pressure = info.data.get('cond_pressure')
if cond_pressure is None and v is None:
raise ValueError('Vous devez fournir soit cond_pressure, soit cond_temperature')
if cond_pressure is not None and v is not None:
raise ValueError('Fournissez soit cond_pressure, soit cond_temperature (pas les deux)')
return v
class CyclePerformance(BaseModel):
"""Performances calculées du cycle."""
cop: float = Field(..., description="Coefficient de performance")
cooling_capacity: float = Field(..., description="Puissance frigorifique (kW)")
heating_capacity: float = Field(..., description="Puissance calorifique (kW)")
compressor_power: float = Field(..., description="Puissance compresseur (kW)")
compressor_efficiency: float = Field(..., description="Rendement isentropique du compresseur")
mass_flow: float = Field(..., description="Débit massique (kg/s)")
volumetric_flow: Optional[float] = Field(None, description="Débit volumique (m³/h)")
compression_ratio: float = Field(..., description="Taux de compression")
discharge_temperature: float = Field(..., description="Température refoulement (°C)")
class SimpleCycleResponse(BaseModel):
"""Réponse complète pour un cycle frigorifique simple."""
success: bool = Field(True, description="Succès de l'opération")
refrigerant: str = Field(..., description="Réfrigérant utilisé")
points: List[CyclePoint] = Field(
...,
description="Points du cycle (1: sortie évap, 2: refoulement, 3: sortie cond, 4: aspiration)"
)
performance: CyclePerformance = Field(
...,
description="Performances du cycle"
)
diagram_data: Optional[Dict[str, Any]] = Field(
None,
description="Données pour tracer le cycle sur diagramme PH"
)
message: Optional[str] = Field(
None,
description="Message informatif"
)
class EconomizerCycleRequest(BaseModel):
"""Requête pour cycle avec économiseur."""
refrigerant: str = Field(..., description="Code du réfrigérant")
evap_pressure: float = Field(..., description="Pression évaporation (bar)", gt=0)
intermediate_pressure: float = Field(..., description="Pression intermédiaire (bar)", gt=0)
cond_pressure: float = Field(..., description="Pression condensation (bar)", gt=0)
superheat: float = Field(5.0, description="Surchauffe (°C)", ge=0)
subcool: float = Field(3.0, description="Sous-refroidissement (°C)", ge=0)
compressor_efficiency: float = Field(0.70, description="Rendement compresseur", gt=0, le=1)
mass_flow: float = Field(0.1, description="Débit massique total (kg/s)", gt=0)
@field_validator('intermediate_pressure')
@classmethod
def validate_intermediate_pressure(cls, v, info):
"""Valide P_evap < P_inter < P_cond."""
if 'evap_pressure' in info.data and v <= info.data['evap_pressure']:
raise ValueError('intermediate_pressure doit être > evap_pressure')
if 'cond_pressure' in info.data and v >= info.data['cond_pressure']:
raise ValueError('intermediate_pressure doit être < cond_pressure')
return v
class CycleError(BaseModel):
"""Erreur lors du calcul de cycle."""
success: bool = Field(False, description="Échec de l'opération")
error: str = Field(..., description="Message d'erreur")
details: Optional[str] = Field(None, description="Détails supplémentaires")

188
app/models/diagram.py Normal file
View File

@@ -0,0 +1,188 @@
"""
Modèles Pydantic pour les diagrammes PH.
"""
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field, field_validator
class DiagramPointRequest(BaseModel):
"""Point personnalisé à tracer sur le diagramme."""
pressure: float = Field(
...,
description="Pression (bar)",
gt=0
)
enthalpy: float = Field(
...,
description="Enthalpie (kJ/kg)"
)
temperature: Optional[float] = Field(
None,
description="Température (Celsius) - optionnel"
)
entropy: Optional[float] = Field(
None,
description="Entropie (kJ/kg.K) - optionnel"
)
quality: Optional[float] = Field(
None,
description="Titre vapeur (0-1) - optionnel",
ge=0,
le=1
)
label: Optional[str] = Field(
None,
description="Label du point - optionnel"
)
class PressureRange(BaseModel):
"""Plage de pression."""
min: float = Field(..., gt=0, description="Pression minimale (bar)")
max: float = Field(..., gt=0, description="Pression maximale (bar)")
class EnthalpyRange(BaseModel):
"""Plage d'enthalpie."""
min: float = Field(..., description="Enthalpie minimale (kJ/kg)")
max: float = Field(..., description="Enthalpie maximale (kJ/kg)")
class DiagramRequest(BaseModel):
"""Requête pour générer un diagramme PH."""
refrigerant: str = Field(
...,
description="Code du réfrigérant (ex: R134a, R410A)",
examples=["R134a", "R410A", "R744"]
)
pressure_range: PressureRange = Field(
...,
description="Plage de pression du diagramme"
)
enthalpy_range: Optional[EnthalpyRange] = Field(
None,
description="Plage d'enthalpie - auto si non fourni"
)
include_isotherms: bool = Field(
True,
description="Inclure les isothermes"
)
isotherm_values: Optional[List[float]] = Field(
None,
description="Températures isothermes spécifiques (Celsius)"
)
cycle_points: Optional[List[Dict[str, float]]] = Field(
None,
description="Points du cycle [(enthalpy, pressure), ...]"
)
title: Optional[str] = Field(
None,
description="Titre personnalisé du diagramme"
)
format: str = Field(
"both",
description="Format: 'png', 'json', ou 'both'",
pattern="^(png|json|both)$"
)
width: int = Field(1400, gt=0, description="Largeur image (pixels)")
height: int = Field(900, gt=0, description="Hauteur image (pixels)")
dpi: int = Field(100, gt=0, description="DPI de l'image")
class SaturationPoint(BaseModel):
"""Point sur la courbe de saturation."""
enthalpy: float = Field(..., description="Enthalpie (kJ/kg)")
pressure: float = Field(..., description="Pression (bar)")
class IsothermCurve(BaseModel):
"""Courbe isotherme."""
temperature: float = Field(..., description="Température (Celsius)")
unit: str = Field("°C", description="Unité de température")
points: List[SaturationPoint] = Field(
...,
description="Points de la courbe"
)
class DiagramPointResponse(BaseModel):
"""Point personnalisé dans la réponse."""
enthalpy: float = Field(..., description="Enthalpie (kJ/kg)")
pressure: float = Field(..., description="Pression (bar)")
temperature: Optional[float] = Field(None, description="Température (Celsius)")
entropy: Optional[float] = Field(None, description="Entropie (kJ/kg.K)")
quality: Optional[float] = Field(None, description="Titre vapeur (0-1)")
class DiagramDataResponse(BaseModel):
"""Données JSON du diagramme."""
refrigerant: str = Field(..., description="Code du réfrigérant")
ranges: Dict[str, Optional[float]] = Field(
...,
description="Plages de valeurs du diagramme"
)
saturation_curve: Dict[str, List[SaturationPoint]] = Field(
...,
description="Courbe de saturation (liquide et vapeur)"
)
isotherms: Optional[List[IsothermCurve]] = Field(
None,
description="Courbes isothermes"
)
custom_points: Optional[List[DiagramPointResponse]] = Field(
None,
description="Points personnalisés"
)
class DiagramResponse(BaseModel):
"""Réponse complète de génération de diagramme."""
success: bool = Field(True, description="Succès")
image: Optional[str] = Field(
None,
description="Image PNG base64"
)
data: Optional[Dict[str, Any]] = Field(
None,
description="Données JSON"
)
metadata: Dict[str, Any] = Field(
default_factory=dict,
description="Métadonnées"
)
message: Optional[str] = Field(
None,
description="Message"
)
class DiagramError(BaseModel):
"""Erreur lors de la génération de diagramme."""
success: bool = Field(False, description="Échec de l'opération")
error: str = Field(..., description="Message d'erreur")
details: Optional[str] = Field(None, description="Détails supplémentaires")

201
app/models/properties.py Normal file
View File

@@ -0,0 +1,201 @@
"""
Modeles pour les calculs de proprietes thermodynamiques
"""
from typing import Optional, Literal
from pydantic import BaseModel, Field, field_validator
class PropertyCalculationRequest(BaseModel):
"""Requete pour calculer des proprietes thermodynamiques"""
refrigerant: str = Field(
...,
description="Nom du refrigerant",
example="R134a"
)
calculation_type: Literal["px", "pT", "ph", "Tx"] = Field(
...,
description="Type de calcul: px (pression-qualite), pT (pression-temperature), ph (pression-enthalpie), Tx (temperature-qualite)",
example="px"
)
# Parametres d'entree selon le type
pressure: Optional[float] = Field(
None,
description="Pression en Pa",
gt=0,
example=500000
)
temperature: Optional[float] = Field(
None,
description="Temperature en K",
gt=0,
example=280.0
)
quality: Optional[float] = Field(
None,
description="Qualite (titre) entre 0 et 1",
ge=0,
le=1,
example=0.5
)
enthalpy: Optional[float] = Field(
None,
description="Enthalpie en J/kg",
example=250000
)
@field_validator('pressure')
@classmethod
def validate_pressure(cls, v, info):
calc_type = info.data.get('calculation_type')
if calc_type in ['px', 'pT', 'ph'] and v is None:
raise ValueError(f"Pression requise pour le calcul {calc_type}")
return v
@field_validator('temperature')
@classmethod
def validate_temperature(cls, v, info):
calc_type = info.data.get('calculation_type')
if calc_type in ['pT', 'Tx'] and v is None:
raise ValueError(f"Temperature requise pour le calcul {calc_type}")
return v
@field_validator('quality')
@classmethod
def validate_quality(cls, v, info):
calc_type = info.data.get('calculation_type')
if calc_type in ['px', 'Tx'] and v is None:
raise ValueError(f"Qualite requise pour le calcul {calc_type}")
return v
@field_validator('enthalpy')
@classmethod
def validate_enthalpy(cls, v, info):
calc_type = info.data.get('calculation_type')
if calc_type == 'ph' and v is None:
raise ValueError("Enthalpie requise pour le calcul ph")
return v
class Config:
json_schema_extra = {
"examples": [
{
"refrigerant": "R134a",
"calculation_type": "px",
"pressure": 500000,
"quality": 0.5
},
{
"refrigerant": "R410A",
"calculation_type": "pT",
"pressure": 800000,
"temperature": 280.0
},
{
"refrigerant": "R744",
"calculation_type": "ph",
"pressure": 3000000,
"enthalpy": 400000
}
]
}
class SaturationRequest(BaseModel):
"""Requete pour obtenir les proprietes de saturation"""
refrigerant: str = Field(
...,
description="Nom du refrigerant",
example="R134a"
)
pressure: float = Field(
...,
description="Pression en Pa",
gt=0,
example=500000
)
class Config:
json_schema_extra = {
"example": {
"refrigerant": "R134a",
"pressure": 500000
}
}
class PropertyInputs(BaseModel):
"""Parametres d'entree du calcul"""
pressure: Optional[float] = Field(None, description="Pression (Pa)")
pressure_bar: Optional[float] = Field(None, description="Pression (bar)")
temperature: Optional[float] = Field(None, description="Temperature (K)")
temperature_celsius: Optional[float] = Field(None, description="Temperature (°C)")
quality: Optional[float] = Field(None, description="Qualite (0-1)")
enthalpy: Optional[float] = Field(None, description="Enthalpie (J/kg)")
class ThermodynamicPropertiesDetailed(BaseModel):
"""Proprietes thermodynamiques detaillees"""
temperature: float = Field(..., description="Temperature (K)")
temperature_celsius: float = Field(..., description="Temperature (°C)")
enthalpy: float = Field(..., description="Enthalpie (J/kg)")
enthalpy_kj_kg: float = Field(..., description="Enthalpie (kJ/kg)")
entropy: float = Field(..., description="Entropie (J/kg.K)")
entropy_kj_kgK: float = Field(..., description="Entropie (kJ/kg.K)")
density: float = Field(..., description="Masse volumique (kg/m³)")
specific_volume: Optional[float] = Field(None, description="Volume specifique (m³/kg)")
quality: Optional[float] = Field(None, description="Qualite (0-1)")
class SaturationPropertiesDetailed(BaseModel):
"""Proprietes de saturation detaillees"""
temperature: float = Field(..., description="Temperature de saturation (K)")
temperature_celsius: float = Field(..., description="Temperature de saturation (°C)")
enthalpy_liquid: float = Field(..., description="Enthalpie liquide (J/kg)")
enthalpy_liquid_kj_kg: float = Field(..., description="Enthalpie liquide (kJ/kg)")
enthalpy_vapor: float = Field(..., description="Enthalpie vapeur (J/kg)")
enthalpy_vapor_kj_kg: float = Field(..., description="Enthalpie vapeur (kJ/kg)")
density_liquid: float = Field(..., description="Masse volumique liquide (kg/m³)")
density_vapor: float = Field(..., description="Masse volumique vapeur (kg/m³)")
latent_heat: float = Field(..., description="Chaleur latente (J/kg)")
latent_heat_kj_kg: float = Field(..., description="Chaleur latente (kJ/kg)")
class PropertyCalculationResponse(BaseModel):
"""Reponse complete d'un calcul de proprietes"""
refrigerant: str = Field(..., description="Nom du refrigerant")
inputs: PropertyInputs = Field(..., description="Parametres d'entree")
properties: ThermodynamicPropertiesDetailed = Field(..., description="Proprietes calculees")
saturation: SaturationPropertiesDetailed = Field(..., description="Proprietes de saturation")
note: Optional[str] = Field(None, description="Note informative")
class PhaseProperties(BaseModel):
"""Proprietes d'une phase (liquide ou vapeur)"""
enthalpy: float = Field(..., description="Enthalpie (J/kg)")
enthalpy_kj_kg: float = Field(..., description="Enthalpie (kJ/kg)")
density: float = Field(..., description="Masse volumique (kg/m³)")
specific_volume: Optional[float] = Field(None, description="Volume specifique (m³/kg)")
entropy: float = Field(..., description="Entropie (J/kg.K)")
entropy_kj_kgK: float = Field(..., description="Entropie (kJ/kg.K)")
class SaturationResponse(BaseModel):
"""Reponse pour les proprietes de saturation"""
refrigerant: str = Field(..., description="Nom du refrigerant")
pressure: float = Field(..., description="Pression (Pa)")
pressure_bar: float = Field(..., description="Pression (bar)")
temperature_saturation: float = Field(..., description="Temperature de saturation (K)")
temperature_saturation_celsius: float = Field(..., description="Temperature de saturation (°C)")
liquid: PhaseProperties = Field(..., description="Proprietes du liquide sature")
vapor: PhaseProperties = Field(..., description="Proprietes de la vapeur saturee")
latent_heat: float = Field(..., description="Chaleur latente (J/kg)")
latent_heat_kj_kg: float = Field(..., description="Chaleur latente (kJ/kg)")

59
app/models/refrigerant.py Normal file
View File

@@ -0,0 +1,59 @@
"""
Modeles Pydantic pour les refrigerants
"""
from typing import Optional, List
from pydantic import BaseModel, Field
class RefrigerantInfo(BaseModel):
"""Informations sur un refrigerant"""
name: str = Field(..., description="Nom du refrigerant (ex: R134a)")
available: bool = Field(..., description="Disponibilite de la bibliotheque")
loaded: bool = Field(default=False, description="Si charge en memoire")
error: Optional[str] = Field(None, description="Message d'erreur si indisponible")
class RefrigerantsListResponse(BaseModel):
"""Reponse pour la liste des refrigerants"""
refrigerants: List[RefrigerantInfo] = Field(..., description="Liste des refrigerants")
total: int = Field(..., description="Nombre total de refrigerants")
available_count: int = Field(..., description="Nombre de refrigerants disponibles")
class ThermodynamicProperties(BaseModel):
"""Proprietes thermodynamiques d'un point"""
temperature: float = Field(..., description="Temperature (K)")
pressure: float = Field(..., description="Pression (Pa)")
enthalpy: float = Field(..., description="Enthalpie (J/kg)")
entropy: float = Field(..., description="Entropie (J/kg.K)")
density: float = Field(..., description="Densite (kg/m3)")
quality: Optional[float] = Field(None, description="Qualite (0-1)", ge=0, le=1)
class SaturationProperties(BaseModel):
"""Proprietes de saturation"""
temperature_sat: float = Field(..., description="Temperature de saturation (K)")
pressure: float = Field(..., description="Pression (Pa)")
enthalpy_liquid: float = Field(..., description="Enthalpie liquide saturee (J/kg)")
enthalpy_vapor: float = Field(..., description="Enthalpie vapeur saturee (J/kg)")
density_liquid: float = Field(..., description="Densite liquide (kg/m3)")
density_vapor: float = Field(..., description="Densite vapeur (kg/m3)")
class PropertyRequest(BaseModel):
"""Requete pour calculer des proprietes"""
refrigerant: str = Field(..., description="Nom du refrigerant", example="R134a")
pressure: float = Field(..., description="Pression (Pa)", gt=0)
quality: Optional[float] = Field(None, description="Qualite (0-1)", ge=0, le=1)
temperature: Optional[float] = Field(None, description="Temperature (K)", gt=0)
enthalpy: Optional[float] = Field(None, description="Enthalpie (J/kg)")
class Config:
json_schema_extra = {
"example": {
"refrigerant": "R134a",
"pressure": 500000,
"quality": 0.5
}
}

11
app/requirements.txt Normal file
View File

@@ -0,0 +1,11 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
pydantic==2.5.0
pydantic-settings==2.1.0
numpy==1.26.3
pandas==2.2.0
matplotlib==3.8.2
plotly==5.18.0
python-multipart==0.0.6
cachetools==5.3.2
python-json-logger==2.0.7

1
app/services/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Business logic services"""

View File

@@ -0,0 +1,364 @@
"""
Service de calculs de cycles frigorifiques.
Ce module fournit les fonctionnalités pour calculer les performances
d'un cycle frigorifique:
- Cycle simple (compression simple)
- Cycle avec économiseur (double étage)
- Calculs de COP, puissance, rendement
"""
import math
from typing import Optional, Tuple, List, Dict, Any
from dataclasses import dataclass
from app.core.refrigerant_loader import RefrigerantLibrary
@dataclass
class ThermodynamicState:
"""État thermodynamique complet d'un point."""
pressure: float # Pa (SI)
temperature: float # °C
enthalpy: float # kJ/kg
entropy: float # kJ/kg.K
density: Optional[float] = None # kg/m³
quality: Optional[float] = None # 0-1
class CycleCalculator:
"""Calculateur de cycles frigorifiques."""
def _safe_val(self, value, default=0):
"""Retourne value ou default si None"""
return value if value is not None else default
def __init__(self, refrigerant: RefrigerantLibrary):
"""
Initialise le calculateur.
Args:
refrigerant: Instance de RefrigerantLibrary
"""
self.refrigerant = refrigerant
def get_pressure_from_saturation_temperature(self, temperature_celsius: float, quality: float = 0.5) -> float:
"""
Calcule la pression de saturation à partir d'une température.
Args:
temperature_celsius: Température de saturation (°C)
quality: Qualité pour le calcul (0.0 pour liquide, 1.0 pour vapeur, 0.5 par défaut)
Returns:
Pression de saturation (Pa)
"""
if temperature_celsius is None:
raise ValueError("temperature_celsius cannot be None")
temperature_kelvin = temperature_celsius + 273.15
pressure_pa = self.refrigerant.p_Tx(temperature_kelvin, quality)
# p_Tx retourne des Pa (Refifc utilise Pa). On travaille en Pa en interne.
return pressure_pa if pressure_pa else 1.0
def calculate_compressor_efficiency(self, pressure_ratio: float) -> float:
"""
Calcule le rendement isentropique du compresseur basé sur le rapport de pression.
Utilise une corrélation empirique typique pour compresseurs frigorifiques:
η_is = 0.90 - 0.04 × ln(PR)
Cette formule reflète la dégradation du rendement avec l'augmentation
du rapport de pression.
Args:
pressure_ratio: Rapport de pression P_cond / P_evap
Returns:
Rendement isentropique (0-1)
Note:
- PR = 2.0 → η ≈ 0.87 (87%)
- PR = 4.0 → η ≈ 0.84 (84%)
- PR = 6.0 → η ≈ 0.83 (83%)
- PR = 8.0 → η ≈ 0.82 (82%)
"""
if pressure_ratio < 1.0:
raise ValueError(f"Le rapport de pression doit être >= 1.0, reçu: {pressure_ratio}")
# Formule empirique typique
efficiency = 0.90 - 0.04 * math.log(pressure_ratio)
# Limiter entre des valeurs réalistes
efficiency = max(0.60, min(0.90, efficiency))
return efficiency
def calculate_point_px(
self,
pressure: float,
quality: float
) -> ThermodynamicState:
"""
Calcule l'état thermodynamique à partir de P et x.
Args:
pressure: Pression (Pa)
quality: Titre vapeur (0-1)
Returns:
État thermodynamique complet
"""
# RefrigerantLibrary prend des pressions en Pa
T_K = self.refrigerant.T_px(pressure, quality)
h_J = self.refrigerant.h_px(pressure, quality)
s_J = self.refrigerant.s_px(pressure, quality)
rho = self.refrigerant.rho_px(pressure, quality)
return ThermodynamicState(
pressure=pressure, # Pa
temperature=(T_K - 273.15) if T_K else 0, # °C
enthalpy=(h_J / 1000) if h_J else 0, # kJ/kg
entropy=(s_J / 1000) if s_J else 0, # kJ/kg.K (CORRECTION: était J/kg.K)
density=rho, # kg/m³
quality=quality
)
def calculate_point_ph(
self,
pressure: float,
enthalpy: float
) -> ThermodynamicState:
"""
Calcule l'état thermodynamique à partir de P et h.
Args:
pressure: Pression (Pa)
enthalpy: Enthalpie (kJ/kg)
Returns:
État thermodynamique complet
"""
# RefrigerantLibrary prend des pressions en Pa et enthalpie en J/kg
h_J = enthalpy * 1000
x = self.refrigerant.x_ph(pressure, h_J)
T_K = self.refrigerant.T_px(pressure, x)
s_J = self.refrigerant.s_px(pressure, x)
rho = self.refrigerant.rho_px(pressure, x)
return ThermodynamicState(
pressure=pressure, # Pa
temperature=(T_K - 273.15) if T_K else 0, # °C
enthalpy=enthalpy, # kJ/kg
entropy=(s_J / 1000) if s_J else 0, # kJ/kg.K (CORRECTION: était J/kg.K)
density=rho, # kg/m³
quality=x if 0 <= x <= 1 else None
)
def calculate_superheat_point(
self,
pressure: float,
superheat: float
) -> ThermodynamicState:
"""
Calcule un point avec surchauffe.
Args:
pressure: Pression (Pa)
superheat: Surchauffe (°C)
Returns:
État thermodynamique
"""
# RefrigerantLibrary prend des pressions en Pa
# Température de saturation
T_sat_K = self.refrigerant.T_px(pressure, 1.0)
T_K = (T_sat_K if T_sat_K else 273.15) + superheat
# Propriétés à P et T
h_J = self.refrigerant.h_pT(pressure, T_K)
return self.calculate_point_ph(pressure, (h_J / 1000) if h_J else 0)
def calculate_subcool_point(
self,
pressure: float,
subcool: float
) -> ThermodynamicState:
"""
Calcule un point avec sous-refroidissement.
Args:
pressure: Pression (Pa)
subcool: Sous-refroidissement (°C)
Returns:
État thermodynamique
"""
# RefrigerantLibrary prend des pressions en Pa
# Température de saturation
T_sat_K = self.refrigerant.T_px(pressure, 0.0)
T_K = (T_sat_K if T_sat_K else 273.15) - subcool
# Propriétés à P et T
h_J = self.refrigerant.h_pT(pressure, T_K)
return self.calculate_point_ph(pressure, (h_J / 1000) if h_J else 0)
def calculate_isentropic_compression(
self,
p_in: float,
h_in: float,
p_out: float
) -> ThermodynamicState:
"""
Calcule la compression isentropique (approximation).
Args:
p_in: Pression entrée (Pa)
h_in: Enthalpie entrée (kJ/kg)
p_out: Pression sortie (Pa)
Returns:
État en sortie (compression isentropique approximée)
"""
# État d'entrée
state_in = self.calculate_point_ph(p_in, h_in)
# Méthode simplifiée: utiliser relation polytropique
# Pour un gaz réel: T_out/T_in = (P_out/P_in)^((k-1)/k)
# Approximation pour réfrigérants: k ≈ 1.15
k = 1.15
T_in_K = (state_in.temperature + 273.15) if state_in.temperature is not None else 273.15
# p_out and p_in are in Pa; ratio is unitless
T_out_K = T_in_K * ((p_out / p_in) ** ((k - 1) / k))
# Calculer enthalpie à P_out et T_out
h_out_J = self.refrigerant.h_pT(p_out, T_out_K)
return self.calculate_point_ph(p_out, h_out_J / 1000)
def calculate_simple_cycle(
self,
evap_pressure: float,
cond_pressure: float,
superheat: float = 5.0,
subcool: float = 3.0,
compressor_efficiency: float = 0.70,
mass_flow: float = 0.1
) -> Dict[str, Any]:
"""
Calcule un cycle frigorifique simple (4 points).
Args:
evap_pressure: Pression évaporation (Pa)
cond_pressure: Pression condensation (Pa)
superheat: Surchauffe (°C)
subcool: Sous-refroidissement (°C)
compressor_efficiency: Rendement isentropique compresseur
mass_flow: Débit massique (kg/s)
Returns:
Dictionnaire avec points et performances
"""
# Point 1: Sortie évaporateur (aspiration compresseur)
point1 = self.calculate_superheat_point(evap_pressure, superheat)
# Point 2s: Refoulement isentropique
point2s = self.calculate_isentropic_compression(
evap_pressure,
point1.enthalpy,
cond_pressure
)
# Point 2: Refoulement réel
h2 = self._safe_val(point1.enthalpy) + (self._safe_val(point2s.enthalpy) - self._safe_val(point1.enthalpy)) / compressor_efficiency
point2 = self.calculate_point_ph(cond_pressure, h2)
# Point 3: Sortie condenseur
point3 = self.calculate_subcool_point(cond_pressure, subcool)
# Point 4: Sortie détendeur (détente isenthalpique)
point4 = self.calculate_point_ph(evap_pressure, point3.enthalpy)
# Calculs de performances
q_evap = self._safe_val(point1.enthalpy) - self._safe_val(point4.enthalpy) # kJ/kg
w_comp = self._safe_val(point2.enthalpy) - self._safe_val(point1.enthalpy) # kJ/kg
q_cond = self._safe_val(point2.enthalpy) - self._safe_val(point3.enthalpy) # kJ/kg
cooling_capacity = mass_flow * q_evap # kW
compressor_power = mass_flow * w_comp # kW
heating_capacity = mass_flow * q_cond # kW
cop = q_evap / w_comp if w_comp > 0 else 0
compression_ratio = cond_pressure / evap_pressure
# Débit volumique à l'aspiration
volumetric_flow = None
if point1.density and point1.density > 0:
volumetric_flow = (mass_flow / point1.density) * 3600 # m³/h
return {
"points": [
{
"point_id": "1",
"description": "Evaporator Outlet (Suction)",
"pressure": point1.pressure,
"temperature": point1.temperature,
"enthalpy": point1.enthalpy,
"entropy": point1.entropy,
"quality": point1.quality
},
{
"point_id": "2",
"description": "Compressor Discharge",
"pressure": point2.pressure,
"temperature": point2.temperature,
"enthalpy": point2.enthalpy,
"entropy": point2.entropy,
"quality": point2.quality
},
{
"point_id": "3",
"description": "Condenser Outlet",
"pressure": point3.pressure,
"temperature": point3.temperature,
"enthalpy": point3.enthalpy,
"entropy": point3.entropy,
"quality": point3.quality
},
{
"point_id": "4",
"description": "Expansion Valve Outlet",
"pressure": point4.pressure,
"temperature": point4.temperature,
"enthalpy": point4.enthalpy,
"entropy": point4.entropy,
"quality": point4.quality
}
],
"performance": {
"cop": cop,
"cooling_capacity": cooling_capacity,
"heating_capacity": heating_capacity,
"compressor_power": compressor_power,
"compressor_efficiency": compressor_efficiency,
"mass_flow": mass_flow,
"volumetric_flow": volumetric_flow,
"compression_ratio": compression_ratio,
"discharge_temperature": point2.temperature
},
"diagram_data": {
"cycle_points": [
{"enthalpy": point1.enthalpy, "pressure": point1.pressure},
{"enthalpy": point2.enthalpy, "pressure": point2.pressure},
{"enthalpy": point3.enthalpy, "pressure": point3.pressure},
{"enthalpy": point4.enthalpy, "pressure": point4.pressure},
{"enthalpy": point1.enthalpy, "pressure": point1.pressure} # Fermer le cycle
]
}
}
# Force reload 2025-10-18 23:04:14

View File

@@ -0,0 +1,354 @@
"""
Service de calculs de cycles frigorifiques.
Ce module fournit les fonctionnalités pour calculer les performances
d'un cycle frigorifique:
- Cycle simple (compression simple)
- Cycle avec économiseur (double étage)
- Calculs de COP, puissance, rendement
"""
import math
from typing import Optional, Tuple, List, Dict, Any
from dataclasses import dataclass
from app.core.refrigerant_loader import RefrigerantLibrary
@dataclass
class ThermodynamicState:
\"\"\"État thermodynamique complet d'un point.\"\"\"
pressure: float # bar
temperature: float # °C
enthalpy: float # kJ/kg
entropy: float # kJ/kg.K
density: Optional[float] = None # kg/m³
quality: Optional[float] = None # 0-1
class CycleCalculator:
\"\"\"Calculateur de cycles frigorifiques.\"\"\"
def __init__(self, refrigerant: RefrigerantLibrary):
\"\"\"
Initialise le calculateur.
Args:
refrigerant: Instance de RefrigerantLibrary
\"\"\"
self.refrigerant = refrigerant
def get_pressure_from_saturation_temperature(self, temperature_celsius: float, quality: float = 0.5) -> float:
\"\"\"
Calcule la pression de saturation à partir d'une température.
Args:
temperature_celsius: Température de saturation (°C)
quality: Qualité pour le calcul (0.0 pour liquide, 1.0 pour vapeur, 0.5 par défaut)
Returns:
Pression de saturation (bar)
\"\"\"
temperature_kelvin = temperature_celsius + 273.15
pressure_pa = self.refrigerant.p_Tx(temperature_kelvin, quality)
# p_Tx retourne des Pascals, convertir en bar
pressure_bar = pressure_pa / 1e5
return pressure_bar
def calculate_compressor_efficiency(self, pressure_ratio: float) -> float:
\"\"\"
Calcule le rendement isentropique du compresseur basé sur le rapport de pression.
Utilise une corrélation empirique typique pour compresseurs frigorifiques:
η_is = 0.90 - 0.04 × ln(PR)
Cette formule reflète la dégradation du rendement avec l'augmentation
du rapport de pression.
Args:
pressure_ratio: Rapport de pression P_cond / P_evap
Returns:
Rendement isentropique (0-1)
Note:
- PR = 2.0 → η ≈ 0.87 (87%)
- PR = 4.0 → η ≈ 0.84 (84%)
- PR = 6.0 → η ≈ 0.83 (83%)
- PR = 8.0 → η ≈ 0.82 (82%)
\"\"\"
if pressure_ratio < 1.0:
raise ValueError(f\"Le rapport de pression doit être >= 1.0, reçu: {pressure_ratio}\")
# Formule empirique typique
efficiency = 0.90 - 0.04 * math.log(pressure_ratio)
# Limiter entre des valeurs réalistes
efficiency = max(0.60, min(0.90, efficiency))
return efficiency
def calculate_point_px(
self,
pressure: float,
quality: float
) -> ThermodynamicState:
\"\"\"
Calcule l'état thermodynamique à partir de P et x.
Args:
pressure: Pression (bar)
quality: Titre vapeur (0-1)
Returns:
État thermodynamique complet
\"\"\"
# RefrigerantLibrary prend bar directement
T_K = self.refrigerant.T_px(pressure, quality)
h_J = self.refrigerant.h_px(pressure, quality)
s_J = self.refrigerant.s_px(pressure, quality)
rho = self.refrigerant.rho_px(pressure, quality)
return ThermodynamicState(
pressure=pressure, # bar
temperature=T_K - 273.15, # °C
enthalpy=h_J / 1000, # kJ/kg
entropy=s_J / 1000, # kJ/kg.K (CORRECTION: était J/kg.K)
density=rho, # kg/m³
quality=quality
)
def calculate_point_ph(
self,
pressure: float,
enthalpy: float
) -> ThermodynamicState:
\"\"\"
Calcule l'état thermodynamique à partir de P et h.
Args:
pressure: Pression (bar)
enthalpy: Enthalpie (kJ/kg)
Returns:
État thermodynamique complet
\"\"\"
# RefrigerantLibrary prend bar et J/kg
h_J = enthalpy * 1000
x = self.refrigerant.x_ph(pressure, h_J)
T_K = self.refrigerant.T_px(pressure, x)
s_J = self.refrigerant.s_px(pressure, x)
rho = self.refrigerant.rho_px(pressure, x)
return ThermodynamicState(
pressure=pressure, # bar
temperature=T_K - 273.15, # °C
enthalpy=enthalpy, # kJ/kg
entropy=s_J / 1000, # kJ/kg.K (CORRECTION: était J/kg.K)
density=rho, # kg/m³
quality=x if 0 <= x <= 1 else None
)
def calculate_superheat_point(
self,
pressure: float,
superheat: float
) -> ThermodynamicState:
\"\"\"
Calcule un point avec surchauffe.
Args:
pressure: Pression (bar)
superheat: Surchauffe (°C)
Returns:
État thermodynamique
\"\"\"
# RefrigerantLibrary prend bar directement
# Température de saturation
T_sat_K = self.refrigerant.T_px(pressure, 1.0)
T_K = T_sat_K + superheat
# Propriétés à P et T
h_J = self.refrigerant.h_pT(pressure, T_K)
return self.calculate_point_ph(pressure, h_J / 1000)
def calculate_subcool_point(
self,
pressure: float,
subcool: float
) -> ThermodynamicState:
\"\"\"
Calcule un point avec sous-refroidissement.
Args:
pressure: Pression (bar)
subcool: Sous-refroidissement (°C)
Returns:
État thermodynamique
\"\"\"
# RefrigerantLibrary prend bar directement
# Température de saturation
T_sat_K = self.refrigerant.T_px(pressure, 0.0)
T_K = T_sat_K - subcool
# Propriétés à P et T
h_J = self.refrigerant.h_pT(pressure, T_K)
return self.calculate_point_ph(pressure, h_J / 1000)
def calculate_isentropic_compression(
self,
p_in: float,
h_in: float,
p_out: float
) -> ThermodynamicState:
\"\"\"
Calcule la compression isentropique (approximation).
Args:
p_in: Pression entrée (bar)
h_in: Enthalpie entrée (kJ/kg)
p_out: Pression sortie (bar)
Returns:
État en sortie (compression isentropique approximée)
\"\"\"
# État d'entrée
state_in = self.calculate_point_ph(p_in, h_in)
# Méthode simplifiée: utiliser relation polytropique
# Pour un gaz réel: T_out/T_in = (P_out/P_in)^((k-1)/k)
# Approximation pour réfrigérants: k ≈ 1.15
k = 1.15
T_in_K = state_in.temperature + 273.15
T_out_K = T_in_K * ((p_out / p_in) ** ((k - 1) / k))
# Calculer enthalpie à P_out et T_out
h_out_J = self.refrigerant.h_pT(p_out, T_out_K)
return self.calculate_point_ph(p_out, h_out_J / 1000)
def calculate_simple_cycle(
self,
evap_pressure: float,
cond_pressure: float,
superheat: float = 5.0,
subcool: float = 3.0,
compressor_efficiency: float = 0.70,
mass_flow: float = 0.1
) -> Dict[str, Any]:
\"\"\"
Calcule un cycle frigorifique simple (4 points).
Args:
evap_pressure: Pression évaporation (bar)
cond_pressure: Pression condensation (bar)
superheat: Surchauffe (°C)
subcool: Sous-refroidissement (°C)
compressor_efficiency: Rendement isentropique compresseur
mass_flow: Débit massique (kg/s)
Returns:
Dictionnaire avec points et performances
\"\"\"
# Point 1: Sortie évaporateur (aspiration compresseur)
point1 = self.calculate_superheat_point(evap_pressure, superheat)
# Point 2s: Refoulement isentropique
point2s = self.calculate_isentropic_compression(
evap_pressure,
point1.enthalpy,
cond_pressure
)
# Point 2: Refoulement réel
h2 = point1.enthalpy + (point2s.enthalpy - point1.enthalpy) / compressor_efficiency
point2 = self.calculate_point_ph(cond_pressure, h2)
# Point 3: Sortie condenseur
point3 = self.calculate_subcool_point(cond_pressure, subcool)
# Point 4: Sortie détendeur (détente isenthalpique)
point4 = self.calculate_point_ph(evap_pressure, point3.enthalpy)
# Calculs de performances
q_evap = point1.enthalpy - point4.enthalpy # kJ/kg
w_comp = point2.enthalpy - point1.enthalpy # kJ/kg
q_cond = point2.enthalpy - point3.enthalpy # kJ/kg
cooling_capacity = mass_flow * q_evap # kW
compressor_power = mass_flow * w_comp # kW
heating_capacity = mass_flow * q_cond # kW
cop = q_evap / w_comp if w_comp > 0 else 0
compression_ratio = cond_pressure / evap_pressure
# Débit volumique à l'aspiration
volumetric_flow = None
if point1.density and point1.density > 0:
volumetric_flow = (mass_flow / point1.density) * 3600 # m³/h
return {
\"points\": [
{
\"point_id\": \"1\",
\"description\": \"Evaporator Outlet (Suction)\",
\"pressure\": point1.pressure,
\"temperature\": point1.temperature,
\"enthalpy\": point1.enthalpy,
\"entropy\": point1.entropy,
\"quality\": point1.quality
},
{
\"point_id\": \"2\",
\"description\": \"Compressor Discharge\",
\"pressure\": point2.pressure,
\"temperature\": point2.temperature,
\"enthalpy\": point2.enthalpy,
\"entropy\": point2.entropy,
\"quality\": point2.quality
},
{
\"point_id\": \"3\",
\"description\": \"Condenser Outlet\",
\"pressure\": point3.pressure,
\"temperature\": point3.temperature,
\"enthalpy\": point3.enthalpy,
\"entropy\": point3.entropy,
\"quality\": point3.quality
},
{
\"point_id\": \"4\",
\"description\": \"Expansion Valve Outlet\",
\"pressure\": point4.pressure,
\"temperature\": point4.temperature,
\"enthalpy\": point4.enthalpy,
\"entropy\": point4.entropy,
\"quality\": point4.quality
}
],
\"performance\": {
\"cop\": cop,
\"cooling_capacity\": cooling_capacity,
\"heating_capacity\": heating_capacity,
\"compressor_power\": compressor_power,
\"compressor_efficiency\": compressor_efficiency,
\"mass_flow\": mass_flow,
\"volumetric_flow\": volumetric_flow,
\"compression_ratio\": compression_ratio,
\"discharge_temperature\": point2.temperature
},
\"diagram_data\": {
\"cycle_points\": [
{\"enthalpy\": point1.enthalpy, \"pressure\": point1.pressure},
{\"enthalpy\": point2.enthalpy, \"pressure\": point2.pressure},
{\"enthalpy\": point3.enthalpy, \"pressure\": point3.pressure},
{\"enthalpy\": point4.enthalpy, \"pressure\": point4.pressure},
{\"enthalpy\": point1.enthalpy, \"pressure\": point1.pressure} # Fermer le cycle
]
}
}

View File

@@ -0,0 +1,354 @@
"""
Service de calculs de cycles frigorifiques.
Ce module fournit les fonctionnalités pour calculer les performances
d'un cycle frigorifique:
- Cycle simple (compression simple)
- Cycle avec économiseur (double étage)
- Calculs de COP, puissance, rendement
"""
import math
from typing import Optional, Tuple, List, Dict, Any
from dataclasses import dataclass
from app.core.refrigerant_loader import RefrigerantLibrary
@dataclass
class ThermodynamicState:
\"\"\"État thermodynamique complet d'un point.\"\"\"
pressure: float # bar
temperature: float # °C
enthalpy: float # kJ/kg
entropy: float # kJ/kg.K
density: Optional[float] = None # kg/m³
quality: Optional[float] = None # 0-1
class CycleCalculator:
\"\"\"Calculateur de cycles frigorifiques.\"\"\"
def __init__(self, refrigerant: RefrigerantLibrary):
\"\"\"
Initialise le calculateur.
Args:
refrigerant: Instance de RefrigerantLibrary
\"\"\"
self.refrigerant = refrigerant
def get_pressure_from_saturation_temperature(self, temperature_celsius: float, quality: float = 0.5) -> float:
\"\"\"
Calcule la pression de saturation à partir d'une température.
Args:
temperature_celsius: Température de saturation (°C)
quality: Qualité pour le calcul (0.0 pour liquide, 1.0 pour vapeur, 0.5 par défaut)
Returns:
Pression de saturation (bar)
\"\"\"
temperature_kelvin = temperature_celsius + 273.15
pressure_pa = self.refrigerant.p_Tx(temperature_kelvin, quality)
# p_Tx retourne des Pascals, convertir en bar
pressure_bar = pressure_pa / 1e5
return pressure_bar
def calculate_compressor_efficiency(self, pressure_ratio: float) -> float:
\"\"\"
Calcule le rendement isentropique du compresseur basé sur le rapport de pression.
Utilise une corrélation empirique typique pour compresseurs frigorifiques:
η_is = 0.90 - 0.04 × ln(PR)
Cette formule reflète la dégradation du rendement avec l'augmentation
du rapport de pression.
Args:
pressure_ratio: Rapport de pression P_cond / P_evap
Returns:
Rendement isentropique (0-1)
Note:
- PR = 2.0 → η ≈ 0.87 (87%)
- PR = 4.0 → η ≈ 0.84 (84%)
- PR = 6.0 → η ≈ 0.83 (83%)
- PR = 8.0 → η ≈ 0.82 (82%)
\"\"\"
if pressure_ratio < 1.0:
raise ValueError(f\"Le rapport de pression doit être >= 1.0, reçu: {pressure_ratio}\")
# Formule empirique typique
efficiency = 0.90 - 0.04 * math.log(pressure_ratio)
# Limiter entre des valeurs réalistes
efficiency = max(0.60, min(0.90, efficiency))
return efficiency
def calculate_point_px(
self,
pressure: float,
quality: float
) -> ThermodynamicState:
\"\"\"
Calcule l'état thermodynamique à partir de P et x.
Args:
pressure: Pression (bar)
quality: Titre vapeur (0-1)
Returns:
État thermodynamique complet
\"\"\"
# RefrigerantLibrary prend bar directement
T_K = self.refrigerant.T_px(pressure, quality)
h_J = self.refrigerant.h_px(pressure, quality)
s_J = self.refrigerant.s_px(pressure, quality)
rho = self.refrigerant.rho_px(pressure, quality)
return ThermodynamicState(
pressure=pressure, # bar
temperature=T_K - 273.15, # °C
enthalpy=h_J / 1000, # kJ/kg
entropy=s_J / 1000, # kJ/kg.K (CORRECTION: était J/kg.K)
density=rho, # kg/m³
quality=quality
)
def calculate_point_ph(
self,
pressure: float,
enthalpy: float
) -> ThermodynamicState:
\"\"\"
Calcule l'état thermodynamique à partir de P et h.
Args:
pressure: Pression (bar)
enthalpy: Enthalpie (kJ/kg)
Returns:
État thermodynamique complet
\"\"\"
# RefrigerantLibrary prend bar et J/kg
h_J = enthalpy * 1000
x = self.refrigerant.x_ph(pressure, h_J)
T_K = self.refrigerant.T_px(pressure, x)
s_J = self.refrigerant.s_px(pressure, x)
rho = self.refrigerant.rho_px(pressure, x)
return ThermodynamicState(
pressure=pressure, # bar
temperature=T_K - 273.15, # °C
enthalpy=enthalpy, # kJ/kg
entropy=s_J / 1000, # kJ/kg.K (CORRECTION: était J/kg.K)
density=rho, # kg/m³
quality=x if 0 <= x <= 1 else None
)
def calculate_superheat_point(
self,
pressure: float,
superheat: float
) -> ThermodynamicState:
\"\"\"
Calcule un point avec surchauffe.
Args:
pressure: Pression (bar)
superheat: Surchauffe (°C)
Returns:
État thermodynamique
\"\"\"
# RefrigerantLibrary prend bar directement
# Température de saturation
T_sat_K = self.refrigerant.T_px(pressure, 1.0)
T_K = T_sat_K + superheat
# Propriétés à P et T
h_J = self.refrigerant.h_pT(pressure, T_K)
return self.calculate_point_ph(pressure, h_J / 1000)
def calculate_subcool_point(
self,
pressure: float,
subcool: float
) -> ThermodynamicState:
\"\"\"
Calcule un point avec sous-refroidissement.
Args:
pressure: Pression (bar)
subcool: Sous-refroidissement (°C)
Returns:
État thermodynamique
\"\"\"
# RefrigerantLibrary prend bar directement
# Température de saturation
T_sat_K = self.refrigerant.T_px(pressure, 0.0)
T_K = T_sat_K - subcool
# Propriétés à P et T
h_J = self.refrigerant.h_pT(pressure, T_K)
return self.calculate_point_ph(pressure, h_J / 1000)
def calculate_isentropic_compression(
self,
p_in: float,
h_in: float,
p_out: float
) -> ThermodynamicState:
\"\"\"
Calcule la compression isentropique (approximation).
Args:
p_in: Pression entrée (bar)
h_in: Enthalpie entrée (kJ/kg)
p_out: Pression sortie (bar)
Returns:
État en sortie (compression isentropique approximée)
\"\"\"
# État d'entrée
state_in = self.calculate_point_ph(p_in, h_in)
# Méthode simplifiée: utiliser relation polytropique
# Pour un gaz réel: T_out/T_in = (P_out/P_in)^((k-1)/k)
# Approximation pour réfrigérants: k ≈ 1.15
k = 1.15
T_in_K = state_in.temperature + 273.15
T_out_K = T_in_K * ((p_out / p_in) ** ((k - 1) / k))
# Calculer enthalpie à P_out et T_out
h_out_J = self.refrigerant.h_pT(p_out, T_out_K)
return self.calculate_point_ph(p_out, h_out_J / 1000)
def calculate_simple_cycle(
self,
evap_pressure: float,
cond_pressure: float,
superheat: float = 5.0,
subcool: float = 3.0,
compressor_efficiency: float = 0.70,
mass_flow: float = 0.1
) -> Dict[str, Any]:
\"\"\"
Calcule un cycle frigorifique simple (4 points).
Args:
evap_pressure: Pression évaporation (bar)
cond_pressure: Pression condensation (bar)
superheat: Surchauffe (°C)
subcool: Sous-refroidissement (°C)
compressor_efficiency: Rendement isentropique compresseur
mass_flow: Débit massique (kg/s)
Returns:
Dictionnaire avec points et performances
\"\"\"
# Point 1: Sortie évaporateur (aspiration compresseur)
point1 = self.calculate_superheat_point(evap_pressure, superheat)
# Point 2s: Refoulement isentropique
point2s = self.calculate_isentropic_compression(
evap_pressure,
point1.enthalpy,
cond_pressure
)
# Point 2: Refoulement réel
h2 = point1.enthalpy + (point2s.enthalpy - point1.enthalpy) / compressor_efficiency
point2 = self.calculate_point_ph(cond_pressure, h2)
# Point 3: Sortie condenseur
point3 = self.calculate_subcool_point(cond_pressure, subcool)
# Point 4: Sortie détendeur (détente isenthalpique)
point4 = self.calculate_point_ph(evap_pressure, point3.enthalpy)
# Calculs de performances
q_evap = point1.enthalpy - point4.enthalpy # kJ/kg
w_comp = point2.enthalpy - point1.enthalpy # kJ/kg
q_cond = point2.enthalpy - point3.enthalpy # kJ/kg
cooling_capacity = mass_flow * q_evap # kW
compressor_power = mass_flow * w_comp # kW
heating_capacity = mass_flow * q_cond # kW
cop = q_evap / w_comp if w_comp > 0 else 0
compression_ratio = cond_pressure / evap_pressure
# Débit volumique à l'aspiration
volumetric_flow = None
if point1.density and point1.density > 0:
volumetric_flow = (mass_flow / point1.density) * 3600 # m³/h
return {
\"points\": [
{
\"point_id\": \"1\",
\"description\": \"Evaporator Outlet (Suction)\",
\"pressure\": point1.pressure,
\"temperature\": point1.temperature,
\"enthalpy\": point1.enthalpy,
\"entropy\": point1.entropy,
\"quality\": point1.quality
},
{
\"point_id\": \"2\",
\"description\": \"Compressor Discharge\",
\"pressure\": point2.pressure,
\"temperature\": point2.temperature,
\"enthalpy\": point2.enthalpy,
\"entropy\": point2.entropy,
\"quality\": point2.quality
},
{
\"point_id\": \"3\",
\"description\": \"Condenser Outlet\",
\"pressure\": point3.pressure,
\"temperature\": point3.temperature,
\"enthalpy\": point3.enthalpy,
\"entropy\": point3.entropy,
\"quality\": point3.quality
},
{
\"point_id\": \"4\",
\"description\": \"Expansion Valve Outlet\",
\"pressure\": point4.pressure,
\"temperature\": point4.temperature,
\"enthalpy\": point4.enthalpy,
\"entropy\": point4.entropy,
\"quality\": point4.quality
}
],
\"performance\": {
\"cop\": cop,
\"cooling_capacity\": cooling_capacity,
\"heating_capacity\": heating_capacity,
\"compressor_power\": compressor_power,
\"compressor_efficiency\": compressor_efficiency,
\"mass_flow\": mass_flow,
\"volumetric_flow\": volumetric_flow,
\"compression_ratio\": compression_ratio,
\"discharge_temperature\": point2.temperature
},
\"diagram_data\": {
\"cycle_points\": [
{\"enthalpy\": point1.enthalpy, \"pressure\": point1.pressure},
{\"enthalpy\": point2.enthalpy, \"pressure\": point2.pressure},
{\"enthalpy\": point3.enthalpy, \"pressure\": point3.pressure},
{\"enthalpy\": point4.enthalpy, \"pressure\": point4.pressure},
{\"enthalpy\": point1.enthalpy, \"pressure\": point1.pressure} # Fermer le cycle
]
}
}

View File

@@ -0,0 +1,354 @@
"""
Service de calculs de cycles frigorifiques.
Ce module fournit les fonctionnalités pour calculer les performances
d'un cycle frigorifique:
- Cycle simple (compression simple)
- Cycle avec économiseur (double étage)
- Calculs de COP, puissance, rendement
"""
import math
from typing import Optional, Tuple, List, Dict, Any
from dataclasses import dataclass
from app.core.refrigerant_loader import RefrigerantLibrary
@dataclass
class ThermodynamicState:
\"\"\"État thermodynamique complet d'un point.\"\"\"
pressure: float # bar
temperature: float # °C
enthalpy: float # kJ/kg
entropy: float # kJ/kg.K
density: Optional[float] = None # kg/m³
quality: Optional[float] = None # 0-1
class CycleCalculator:
\"\"\"Calculateur de cycles frigorifiques.\"\"\"
def __init__(self, refrigerant: RefrigerantLibrary):
\"\"\"
Initialise le calculateur.
Args:
refrigerant: Instance de RefrigerantLibrary
\"\"\"
self.refrigerant = refrigerant
def get_pressure_from_saturation_temperature(self, temperature_celsius: float, quality: float = 0.5) -> float:
\"\"\"
Calcule la pression de saturation à partir d'une température.
Args:
temperature_celsius: Température de saturation (°C)
quality: Qualité pour le calcul (0.0 pour liquide, 1.0 pour vapeur, 0.5 par défaut)
Returns:
Pression de saturation (bar)
\"\"\"
temperature_kelvin = temperature_celsius + 273.15
pressure_pa = self.refrigerant.p_Tx(temperature_kelvin, quality)
# p_Tx retourne des Pascals, convertir en bar
pressure_bar = pressure_pa / 1e5
return pressure_bar
def calculate_compressor_efficiency(self, pressure_ratio: float) -> float:
\"\"\"
Calcule le rendement isentropique du compresseur basé sur le rapport de pression.
Utilise une corrélation empirique typique pour compresseurs frigorifiques:
η_is = 0.90 - 0.04 × ln(PR)
Cette formule reflète la dégradation du rendement avec l'augmentation
du rapport de pression.
Args:
pressure_ratio: Rapport de pression P_cond / P_evap
Returns:
Rendement isentropique (0-1)
Note:
- PR = 2.0 η 0.87 (87%)
- PR = 4.0 η 0.84 (84%)
- PR = 6.0 η 0.83 (83%)
- PR = 8.0 η 0.82 (82%)
\"\"\"
if pressure_ratio < 1.0:
raise ValueError(f\"Le rapport de pression doit être >= 1.0, reçu: {pressure_ratio}\")
# Formule empirique typique
efficiency = 0.90 - 0.04 * math.log(pressure_ratio)
# Limiter entre des valeurs réalistes
efficiency = max(0.60, min(0.90, efficiency))
return efficiency
def calculate_point_px(
self,
pressure: float,
quality: float
) -> ThermodynamicState:
\"\"\"
Calcule l'état thermodynamique à partir de P et x.
Args:
pressure: Pression (bar)
quality: Titre vapeur (0-1)
Returns:
État thermodynamique complet
\"\"\"
# RefrigerantLibrary prend bar directement
T_K = self.refrigerant.T_px(pressure, quality)
h_J = self.refrigerant.h_px(pressure, quality)
s_J = self.refrigerant.s_px(pressure, quality)
rho = self.refrigerant.rho_px(pressure, quality)
return ThermodynamicState(
pressure=pressure, # bar
temperature=T_K - 273.15, # °C
enthalpy=h_J / 1000, # kJ/kg
entropy=s_J / 1000, # kJ/kg.K (CORRECTION: était J/kg.K)
density=rho, # kg/m³
quality=quality
)
def calculate_point_ph(
self,
pressure: float,
enthalpy: float
) -> ThermodynamicState:
\"\"\"
Calcule l'état thermodynamique à partir de P et h.
Args:
pressure: Pression (bar)
enthalpy: Enthalpie (kJ/kg)
Returns:
État thermodynamique complet
\"\"\"
# RefrigerantLibrary prend bar et J/kg
h_J = enthalpy * 1000
x = self.refrigerant.x_ph(pressure, h_J)
T_K = self.refrigerant.T_px(pressure, x)
s_J = self.refrigerant.s_px(pressure, x)
rho = self.refrigerant.rho_px(pressure, x)
return ThermodynamicState(
pressure=pressure, # bar
temperature=T_K - 273.15, # °C
enthalpy=enthalpy, # kJ/kg
entropy=s_J / 1000, # kJ/kg.K (CORRECTION: était J/kg.K)
density=rho, # kg/m³
quality=x if 0 <= x <= 1 else None
)
def calculate_superheat_point(
self,
pressure: float,
superheat: float
) -> ThermodynamicState:
\"\"\"
Calcule un point avec surchauffe.
Args:
pressure: Pression (bar)
superheat: Surchauffe (°C)
Returns:
État thermodynamique
\"\"\"
# RefrigerantLibrary prend bar directement
# Température de saturation
T_sat_K = self.refrigerant.T_px(pressure, 1.0)
T_K = T_sat_K + superheat
# Propriétés à P et T
h_J = self.refrigerant.h_pT(pressure, T_K)
return self.calculate_point_ph(pressure, h_J / 1000)
def calculate_subcool_point(
self,
pressure: float,
subcool: float
) -> ThermodynamicState:
\"\"\"
Calcule un point avec sous-refroidissement.
Args:
pressure: Pression (bar)
subcool: Sous-refroidissement (°C)
Returns:
État thermodynamique
\"\"\"
# RefrigerantLibrary prend bar directement
# Température de saturation
T_sat_K = self.refrigerant.T_px(pressure, 0.0)
T_K = T_sat_K - subcool
# Propriétés à P et T
h_J = self.refrigerant.h_pT(pressure, T_K)
return self.calculate_point_ph(pressure, h_J / 1000)
def calculate_isentropic_compression(
self,
p_in: float,
h_in: float,
p_out: float
) -> ThermodynamicState:
\"\"\"
Calcule la compression isentropique (approximation).
Args:
p_in: Pression entrée (bar)
h_in: Enthalpie entrée (kJ/kg)
p_out: Pression sortie (bar)
Returns:
État en sortie (compression isentropique approximée)
\"\"\"
# État d'entrée
state_in = self.calculate_point_ph(p_in, h_in)
# Méthode simplifiée: utiliser relation polytropique
# Pour un gaz réel: T_out/T_in = (P_out/P_in)^((k-1)/k)
# Approximation pour réfrigérants: k ≈ 1.15
k = 1.15
T_in_K = state_in.temperature + 273.15
T_out_K = T_in_K * ((p_out / p_in) ** ((k - 1) / k))
# Calculer enthalpie à P_out et T_out
h_out_J = self.refrigerant.h_pT(p_out, T_out_K)
return self.calculate_point_ph(p_out, h_out_J / 1000)
def calculate_simple_cycle(
self,
evap_pressure: float,
cond_pressure: float,
superheat: float = 5.0,
subcool: float = 3.0,
compressor_efficiency: float = 0.70,
mass_flow: float = 0.1
) -> Dict[str, Any]:
\"\"\"
Calcule un cycle frigorifique simple (4 points).
Args:
evap_pressure: Pression évaporation (bar)
cond_pressure: Pression condensation (bar)
superheat: Surchauffe (°C)
subcool: Sous-refroidissement (°C)
compressor_efficiency: Rendement isentropique compresseur
mass_flow: Débit massique (kg/s)
Returns:
Dictionnaire avec points et performances
\"\"\"
# Point 1: Sortie évaporateur (aspiration compresseur)
point1 = self.calculate_superheat_point(evap_pressure, superheat)
# Point 2s: Refoulement isentropique
point2s = self.calculate_isentropic_compression(
evap_pressure,
point1.enthalpy,
cond_pressure
)
# Point 2: Refoulement réel
h2 = point1.enthalpy + (point2s.enthalpy - point1.enthalpy) / compressor_efficiency
point2 = self.calculate_point_ph(cond_pressure, h2)
# Point 3: Sortie condenseur
point3 = self.calculate_subcool_point(cond_pressure, subcool)
# Point 4: Sortie détendeur (détente isenthalpique)
point4 = self.calculate_point_ph(evap_pressure, point3.enthalpy)
# Calculs de performances
q_evap = point1.enthalpy - point4.enthalpy # kJ/kg
w_comp = point2.enthalpy - point1.enthalpy # kJ/kg
q_cond = point2.enthalpy - point3.enthalpy # kJ/kg
cooling_capacity = mass_flow * q_evap # kW
compressor_power = mass_flow * w_comp # kW
heating_capacity = mass_flow * q_cond # kW
cop = q_evap / w_comp if w_comp > 0 else 0
compression_ratio = cond_pressure / evap_pressure
# Débit volumique à l'aspiration
volumetric_flow = None
if point1.density and point1.density > 0:
volumetric_flow = (mass_flow / point1.density) * 3600 # m³/h
return {
\"points\": [
{
\"point_id\": \"1\",
\"description\": \"Evaporator Outlet (Suction)\",
\"pressure\": point1.pressure,
\"temperature\": point1.temperature,
\"enthalpy\": point1.enthalpy,
\"entropy\": point1.entropy,
\"quality\": point1.quality
},
{
\"point_id\": \"2\",
\"description\": \"Compressor Discharge\",
\"pressure\": point2.pressure,
\"temperature\": point2.temperature,
\"enthalpy\": point2.enthalpy,
\"entropy\": point2.entropy,
\"quality\": point2.quality
},
{
\"point_id\": \"3\",
\"description\": \"Condenser Outlet\",
\"pressure\": point3.pressure,
\"temperature\": point3.temperature,
\"enthalpy\": point3.enthalpy,
\"entropy\": point3.entropy,
\"quality\": point3.quality
},
{
\"point_id\": \"4\",
\"description\": \"Expansion Valve Outlet\",
\"pressure\": point4.pressure,
\"temperature\": point4.temperature,
\"enthalpy\": point4.enthalpy,
\"entropy\": point4.entropy,
\"quality\": point4.quality
}
],
\"performance\": {
\"cop\": cop,
\"cooling_capacity\": cooling_capacity,
\"heating_capacity\": heating_capacity,
\"compressor_power\": compressor_power,
\"compressor_efficiency\": compressor_efficiency,
\"mass_flow\": mass_flow,
\"volumetric_flow\": volumetric_flow,
\"compression_ratio\": compression_ratio,
\"discharge_temperature\": point2.temperature
},
\"diagram_data\": {
\"cycle_points\": [
{\"enthalpy\": point1.enthalpy, \"pressure\": point1.pressure},
{\"enthalpy\": point2.enthalpy, \"pressure\": point2.pressure},
{\"enthalpy\": point3.enthalpy, \"pressure\": point3.pressure},
{\"enthalpy\": point4.enthalpy, \"pressure\": point4.pressure},
{\"enthalpy\": point1.enthalpy, \"pressure\": point1.pressure} # Fermer le cycle
]
}
}

View File

@@ -0,0 +1,319 @@
"""
Service de génération de diagrammes Pression-Enthalpie (PH).
Basé sur le code original diagram_PH.py qui fonctionne correctement.
"""
import io
import base64
import numpy as np
import matplotlib
# Configurer le backend Agg (non-interactif) pour éviter les problèmes sur Windows
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib.figure import Figure
from typing import Dict, List, Tuple, Optional, Any
from dataclasses import dataclass
from app.core.refrigerant_loader import RefrigerantLibrary
# Cache pour éviter de recalculer les courbes lourdes par réfrigérant
_diagram_cache: Dict[str, Any] = {}
@dataclass
class DiagramPoint:
"""Point dans le diagramme PH."""
pressure: float # bar
enthalpy: float # kJ/kg
temperature: Optional[float] = None # Celsius
entropy: Optional[float] = None # kJ/kg.K
quality: Optional[float] = None # 0-1
class DiagramGenerator:
"""Générateur de diagrammes Pression-Enthalpie."""
def __init__(self, refrigerant: RefrigerantLibrary):
"""
Initialise le générateur.
Args:
refrigerant: Bibliothèque du réfrigérant
"""
self.refrigerant = refrigerant
self.refrig_name = refrigerant.refrig_name
self.fig_width = 15
self.fig_height = 10
self.dpi = 100
# Utiliser le cache partagé pour éviter des recalculs coûteux
cached = _diagram_cache.get(self.refrig_name)
if cached and isinstance(cached, tuple) and len(cached) == 9:
(
self.Hsl,
self.Hsv,
self.Psat,
self.Tsat,
self.Tmax,
self.Tmin,
self.T_lst,
self.P,
self.IsoT_lst,
) = cached
else:
# Calculer et stocker dans le cache
Hsl, Hsv, Psat, Tsat = self.get_psat_values()
# Assign Tsat early because get_IsoT_values relies on self.Tsat
self.Hsl, self.Hsv, self.Psat, self.Tsat = Hsl, Hsv, Psat, Tsat
Tmax, Tmin, T_lst, P, IsoT_lst = self.get_IsoT_values()
self.Tmax, self.Tmin, self.T_lst, self.P, self.IsoT_lst = Tmax, Tmin, T_lst, P, IsoT_lst
_diagram_cache[self.refrig_name] = (
self.Hsl,
self.Hsv,
self.Psat,
self.Tsat,
self.Tmax,
self.Tmin,
self.T_lst,
self.P,
self.IsoT_lst,
)
def get_psat_values(self) -> Tuple[List[float], List[float], List[float], List[float]]:
"""
Calcule les valeurs de saturation (courbe en cloche).
COPIE EXACTE de diagram_PH.py lignes 39-63
Returns:
Tuple (Hsl, Hsv, Psat, Tsat)
"""
Hsl, Hsv, Psat, Tsat = [], [], [], []
# COPIE EXACTE ligne 49 du code original
for p in np.arange(self.refrigerant.p_begin(), self.refrigerant.p_end(), 0.5e5):
# Lignes 51-57 du code original
Hsl.append(self.refrigerant.hsl_px(p, 0) / 1e3)
Hsv.append(self.refrigerant.hsv_px(p, 1) / 1e3)
# Store Psat in Pa (internal SI), convert to bar only when displaying/exporting
Psat.append(p)
Tsat.append(self.refrigerant.T_px(p, 0.5))
# Lignes 60-61 du code original
if len(Hsl) > 2 and Hsl[-1] == Hsl[-2]:
break
return Hsl, Hsv, Psat, Tsat
def find_whole_10_numbers(self, Tmin: float, Tmax: float) -> np.ndarray:
"""
Trouve les températures rondes (multiples de 10) dans la plage.
COPIE EXACTE de refDLL.py lignes 131-133
Args:
Tmin: Température minimale (°C)
Tmax: Température maximale (°C)
Returns:
Array des températures
"""
# COPIE EXACTE lignes 131-133 de refDLL.py
start = int(Tmin // 10 + 1)
end = int(Tmax // 10 + 1)
return np.arange(start * 10, end * 10, 10)
def get_IsoT_values(self) -> Tuple[float, float, np.ndarray, np.ndarray, List[List[float]]]:
"""
Calcule les valeurs isothermes.
COPIE EXACTE de diagram_PH.py lignes 129-162
Returns:
Tuple (Tmax, Tmin, T_lst, P, IsoT_lst)
"""
# COPIE EXACTE ligne 138 du code original
# T = [self.callref.refrig.T_px(p, 0.5) - 273.15 for p in np.arange(self.callref.refrig.p_begin(), self.callref.refrig.p_end(), 50e5)]
# Lignes 141 du code original
Tmax = max(self.Tsat) - 273.15 - 1
Tmin = min(self.Tsat) - 273.15
# Ligne 144 du code original
T_lst = self.find_whole_10_numbers(Tmin, Tmax)
# Ligne 147 du code original
P = np.arange(self.refrigerant.p_begin(), self.refrigerant.p_end(), 0.05e5)
# Ligne 150 du code original
IsoT_lst = [[self.refrigerant.h_pT(p, temp + 273.15) / 1e3 for p in P] for temp in T_lst]
return Tmax, Tmin, T_lst, P, IsoT_lst
def plot_diagram(
self,
cycle_points: Optional[List[Tuple[float, float]]] = None,
title: Optional[str] = None
) -> Figure:
"""
Génère le diagramme PH complet.
COPIE EXACTE de diagram_PH.py lignes 183-224
Args:
cycle_points: Points du cycle [(h, p), ...]
title: Titre du diagramme
Returns:
Figure matplotlib
"""
# Configuration des tailles de police - COPIE EXACTE lignes 184-190
SMALL_SIZE = 10
MEDIUM_SIZE = 22
BIGGER_SIZE = 28
plt.rc('font', size=SMALL_SIZE)
plt.rc('axes', titlesize=SMALL_SIZE)
plt.rc('axes', labelsize=MEDIUM_SIZE)
plt.rc('xtick', labelsize=SMALL_SIZE)
plt.rc('ytick', labelsize=SMALL_SIZE)
plt.rc('legend', fontsize=SMALL_SIZE)
plt.rc('figure', titlesize=BIGGER_SIZE)
# Ligne 191 du code original - taille configurable
fig = Figure(figsize=[self.fig_width, self.fig_height])
ax = fig.add_subplot(111)
# Lignes 193-194 du code original: Plot saturation lines
# Psat is stored in Pa internally; plot in bar for readability
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')
# Lignes 196-202 du code original: Plot isotherms
for Th_lst, temp in zip(self.IsoT_lst, self.T_lst):
ax.plot(Th_lst, self.P / 1e5, 'g--', label=f'{temp}°C Isotherm', alpha=0.5)
ax.annotate('{:.0f}°C'.format(temp),
(self.refrigerant.h_px(self.refrigerant.p_Tx(temp + 273.15, 0.5), 0.1) / 1e3,
self.refrigerant.p_Tx(temp + 273.15, 0.5) / 1e5),
ha='center',
backgroundcolor="white")
# Ligne 204 du code original
ax.set_yscale('log')
# Tracer les points du cycle si fournis (adapté pour l'API)
if cycle_points and len(cycle_points) > 0:
h_cycle = [p[0] for p in cycle_points]
p_cycle = [p[1] for p in cycle_points]
ax.plot(h_cycle, p_cycle, 'r-o', linewidth=2, markersize=8,
label='Cycle', zorder=10)
# Lignes 218-221 du code original
ax.set_xlabel('Enthalpy [kJ/kg]')
ax.set_ylabel('Pressure [bar]')
if title:
ax.set_title(title)
else:
ax.set_title(f'PH Diagram for {self.refrig_name}')
ax.grid(True, which='both', linestyle='--')
# Ligne 223 du code original
ax.legend(loc='best', fontsize=SMALL_SIZE)
# Ligne 224 du code original
fig.tight_layout()
return fig
def export_to_base64(self, fig: Figure) -> str:
"""
Exporte la figure en PNG base64.
Args:
fig: Figure matplotlib
Returns:
String base64 de l'image PNG
"""
buf = io.BytesIO()
# NE PAS utiliser bbox_inches='tight' car ça peut tronquer le graphique
# Utiliser pad_inches pour ajouter une marge
fig.savefig(buf, format='png', dpi=self.dpi, bbox_inches='tight', pad_inches=0.2)
buf.seek(0)
img_base64 = base64.b64encode(buf.read()).decode('utf-8')
buf.close()
return img_base64
def generate_diagram_data(
self,
cycle_points: Optional[List[Tuple[float, float]]] = None
) -> Dict[str, Any]:
"""
Génère les données du diagramme en format JSON.
Utilise les données déjà calculées à l'initialisation.
Args:
cycle_points: Points du cycle
Returns:
Dictionnaire avec les données du diagramme
"""
data = {
"refrigerant": self.refrig_name,
"saturation_curve": [
{"enthalpy": float(h), "pressure": float(p / 1e5)}
for h, p in zip(self.Hsl + self.Hsv, self.Psat + self.Psat)
]
}
# Ajouter isothermes
isotherms_data = []
for Th_lst, temp in zip(self.IsoT_lst, self.T_lst):
points = []
for h, p in zip(Th_lst, self.P / 1e5):
if h is not None and not np.isnan(h):
points.append({"enthalpy": float(h), "pressure": float(p)})
if len(points) > 0:
isotherms_data.append({
"temperature": float(temp),
"points": points
})
data["isotherms"] = isotherms_data
# Ajouter points du cycle
if cycle_points:
data["cycle_points"] = [
{"enthalpy": float(h), "pressure": float(p)}
for h, p in cycle_points
]
return data
def generate_complete_diagram(
self,
cycle_points: Optional[List[Tuple[float, float]]] = None,
title: Optional[str] = None,
export_format: str = "both"
) -> Dict[str, Any]:
"""
Génère le diagramme complet.
Utilise les données déjà calculées à l'initialisation.
Args:
cycle_points: Points du cycle
title: Titre
export_format: "png", "json", "both"
Returns:
Dictionnaire avec image et/ou données
"""
result = {}
if export_format in ["png", "both"]:
fig = self.plot_diagram(cycle_points, title)
result["image_base64"] = self.export_to_base64(fig)
plt.close(fig)
if export_format in ["json", "both"]:
result["data"] = self.generate_diagram_data(cycle_points)
return result

View File

@@ -0,0 +1,251 @@
"""
Service pour les calculs thermodynamiques
"""
from typing import Dict, Optional, Any
from app.core.refrigerant_loader import get_refrigerant
class ThermodynamicsService:
"""Service pour effectuer les calculs thermodynamiques"""
def calculate_from_px(
self,
refrigerant: str,
pressure: float,
quality: float
) -> Dict[str, Any]:
"""
Calcule les proprietes thermodynamiques a partir de P et x
Args:
refrigerant: Nom du refrigerant (ex: "R134a")
pressure: Pression en Pa
quality: Qualite (0-1)
Returns:
Dictionnaire avec toutes les proprietes
"""
lib = get_refrigerant(refrigerant)
# Calculs des proprietes principales
temperature = lib.T_px(pressure, quality)
enthalpy = lib.h_px(pressure, quality)
entropy = lib.s_px(pressure, quality)
density = lib.rho_px(pressure, quality)
temp_sat = lib.Ts_px(pressure, quality)
# Proprietes de saturation
enthalpy_liquid = lib.hsl_px(pressure, 0)
enthalpy_vapor = lib.hsv_px(pressure, 1)
density_liquid = lib.rhosl_px(pressure, 0)
density_vapor = lib.rhosv_px(pressure, 1)
return {
"refrigerant": refrigerant,
"inputs": {
"pressure": pressure,
"pressure_bar": pressure / 1e5,
"quality": quality
},
"properties": {
"temperature": temperature,
"temperature_celsius": temperature - 273.15,
"enthalpy": enthalpy,
"enthalpy_kj_kg": enthalpy / 1000,
"entropy": entropy,
"entropy_kj_kgK": entropy / 1000,
"density": density,
"specific_volume": 1 / density if density > 0 else None,
"quality": quality
},
"saturation": {
"temperature": temp_sat,
"temperature_celsius": temp_sat - 273.15,
"enthalpy_liquid": enthalpy_liquid,
"enthalpy_liquid_kj_kg": enthalpy_liquid / 1000,
"enthalpy_vapor": enthalpy_vapor,
"enthalpy_vapor_kj_kg": enthalpy_vapor / 1000,
"density_liquid": density_liquid,
"density_vapor": density_vapor,
"latent_heat": enthalpy_vapor - enthalpy_liquid,
"latent_heat_kj_kg": (enthalpy_vapor - enthalpy_liquid) / 1000
}
}
def calculate_from_pT(
self,
refrigerant: str,
pressure: float,
temperature: float
) -> Dict[str, Any]:
"""
Calcule les proprietes a partir de P et T
Args:
refrigerant: Nom du refrigerant
pressure: Pression en Pa
temperature: Temperature en K
Returns:
Dictionnaire avec les proprietes
"""
lib = get_refrigerant(refrigerant)
enthalpy = lib.h_pT(pressure, temperature)
# Determiner la qualite approximativement
temp_sat = lib.Ts_px(pressure, 0.5)
# Si proche de la saturation, calculer la qualite
quality = None
if abs(temperature - temp_sat) < 0.1:
# En zone diphasique
h_liquid = lib.hsl_px(pressure, 0)
h_vapor = lib.hsv_px(pressure, 1)
if h_vapor > h_liquid:
quality = (enthalpy - h_liquid) / (h_vapor - h_liquid)
quality = max(0, min(1, quality))
# Si qualite determinee, utiliser px pour avoir toutes les proprietes
if quality is not None:
return self.calculate_from_px(refrigerant, pressure, quality)
# Sinon, calculer les proprietes de base (vapeur surchauffee ou liquide sous-refroidi)
# Approximation: utiliser x=1 pour vapeur ou x=0 pour liquide
if temperature > temp_sat:
# Vapeur surchauffee, utiliser x=1 comme approximation
quality_approx = 1.0
else:
# Liquide sous-refroidi, utiliser x=0 comme approximation
quality_approx = 0.0
result = self.calculate_from_px(refrigerant, pressure, quality_approx)
result["properties"]["temperature"] = temperature
result["properties"]["temperature_celsius"] = temperature - 273.15
result["properties"]["enthalpy"] = enthalpy
result["properties"]["enthalpy_kj_kg"] = enthalpy / 1000
result["properties"]["quality"] = quality
result["inputs"]["temperature"] = temperature
result["inputs"]["temperature_celsius"] = temperature - 273.15
result["note"] = "Calcul a partir de P et T - proprietes approximatives hors saturation"
return result
def calculate_from_ph(
self,
refrigerant: str,
pressure: float,
enthalpy: float
) -> Dict[str, Any]:
"""
Calcule les proprietes a partir de P et h
Args:
refrigerant: Nom du refrigerant
pressure: Pression en Pa
enthalpy: Enthalpie en J/kg
Returns:
Dictionnaire avec les proprietes
"""
lib = get_refrigerant(refrigerant)
# Calculer la qualite
quality = lib.x_ph(pressure, enthalpy)
# Utiliser la qualite pour calculer le reste
return self.calculate_from_px(refrigerant, pressure, quality)
def calculate_from_Tx(
self,
refrigerant: str,
temperature: float,
quality: float
) -> Dict[str, Any]:
"""
Calcule les proprietes a partir de T et x
Args:
refrigerant: Nom du refrigerant
temperature: Temperature en K
quality: Qualite (0-1)
Returns:
Dictionnaire avec les proprietes
"""
lib = get_refrigerant(refrigerant)
# Calculer la pression
pressure = lib.p_Tx(temperature, quality)
# Utiliser la pression pour calculer le reste
return self.calculate_from_px(refrigerant, pressure, quality)
def get_saturation_properties(
self,
refrigerant: str,
pressure: float
) -> Dict[str, Any]:
"""
Obtient les proprietes de saturation a une pression donnee
Args:
refrigerant: Nom du refrigerant
pressure: Pression en Pa
Returns:
Proprietes de saturation (liquide et vapeur)
"""
lib = get_refrigerant(refrigerant)
# Temperature de saturation
temp_sat = lib.Ts_px(pressure, 0.5)
# Proprietes liquide (x=0)
h_liquid = lib.hsl_px(pressure, 0)
rho_liquid = lib.rhosl_px(pressure, 0)
s_liquid = lib.s_px(pressure, 0)
# Proprietes vapeur (x=1)
h_vapor = lib.hsv_px(pressure, 1)
rho_vapor = lib.rhosv_px(pressure, 1)
s_vapor = lib.s_px(pressure, 1)
return {
"refrigerant": refrigerant,
"pressure": pressure,
"pressure_bar": pressure / 1e5,
"temperature_saturation": temp_sat,
"temperature_saturation_celsius": temp_sat - 273.15,
"liquid": {
"enthalpy": h_liquid,
"enthalpy_kj_kg": h_liquid / 1000,
"density": rho_liquid,
"specific_volume": 1 / rho_liquid if rho_liquid > 0 else None,
"entropy": s_liquid,
"entropy_kj_kgK": s_liquid / 1000
},
"vapor": {
"enthalpy": h_vapor,
"enthalpy_kj_kg": h_vapor / 1000,
"density": rho_vapor,
"specific_volume": 1 / rho_vapor if rho_vapor > 0 else None,
"entropy": s_vapor,
"entropy_kj_kgK": s_vapor / 1000
},
"latent_heat": h_vapor - h_liquid,
"latent_heat_kj_kg": (h_vapor - h_liquid) / 1000
}
# Instance globale du service
_service: Optional[ThermodynamicsService] = None
def get_thermodynamics_service() -> ThermodynamicsService:
"""Obtient l'instance du service thermodynamique"""
global _service
if _service is None:
_service = ThermodynamicsService()
return _service

1
app/utils/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Utility functions and helpers"""