ci: commit workspace changes from notebook and backend fixes (excludes test_env, Frontend)
This commit is contained in:
80
app/README_API.md
Normal file
80
app/README_API.md
Normal 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
6
app/__init__.py
Normal 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
1
app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API routes"""
|
||||
1
app/api/v1/__init__.py
Normal file
1
app/api/v1/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API v1 routes"""
|
||||
1
app/api/v1/endpoints/__init__.py
Normal file
1
app/api/v1/endpoints/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API v1 endpoints"""
|
||||
335
app/api/v1/endpoints/cycles.py
Normal file
335
app/api/v1/endpoints/cycles.py
Normal 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]
|
||||
}
|
||||
]
|
||||
}
|
||||
187
app/api/v1/endpoints/diagrams.py
Normal file
187
app/api/v1/endpoints/diagrams.py
Normal 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"
|
||||
]
|
||||
}
|
||||
225
app/api/v1/endpoints/properties.py
Normal file
225
app/api/v1/endpoints/properties.py
Normal 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
|
||||
}
|
||||
}
|
||||
150
app/api/v1/endpoints/refrigerants.py
Normal file
150
app/api/v1/endpoints/refrigerants.py
Normal 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
42
app/config.py
Normal 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
1
app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Core business logic modules"""
|
||||
223
app/core/refrigerant_loader.py
Normal file
223
app/core/refrigerant_loader.py
Normal 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
112
app/main.py
Normal 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
1
app/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Pydantic models for requests and responses"""
|
||||
223
app/models/cycle.py
Normal file
223
app/models/cycle.py
Normal 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
188
app/models/diagram.py
Normal 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
201
app/models/properties.py
Normal 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
59
app/models/refrigerant.py
Normal 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
11
app/requirements.txt
Normal 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
1
app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Business logic services"""
|
||||
364
app/services/cycle_calculator.py
Normal file
364
app/services/cycle_calculator.py
Normal 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
|
||||
354
app/services/cycle_calculator.py.backup
Normal file
354
app/services/cycle_calculator.py.backup
Normal 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
|
||||
]
|
||||
}
|
||||
}
|
||||
354
app/services/cycle_calculator.py.temp
Normal file
354
app/services/cycle_calculator.py.temp
Normal 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
|
||||
]
|
||||
}
|
||||
}
|
||||
354
app/services/cycle_calculator_clean.py
Normal file
354
app/services/cycle_calculator_clean.py
Normal 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
|
||||
]
|
||||
}
|
||||
}
|
||||
319
app/services/diagram_generator.py
Normal file
319
app/services/diagram_generator.py
Normal 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
|
||||
251
app/services/thermodynamics.py
Normal file
251
app/services/thermodynamics.py
Normal 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
1
app/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Utility functions and helpers"""
|
||||
Reference in New Issue
Block a user