diagram_ph/IMPLEMENTATION_PLAN.md

17 KiB

Plan d'implémentation - API Diagramme PH

Vue d'ensemble

Ce document décrit le plan détaillé pour implémenter l'API REST de génération de diagrammes PH et calculs frigorifiques, avec déploiement sur AWS Elastic Beanstalk.


Phase 1: Configuration du projet (Durée: 1-2 jours)

Tâche 1.1: Structure initiale

# Créer la structure du projet
mkdir -p diagram-ph-api/{app,libs,tests,docker,deployment,docs}
cd diagram-ph-api

# Initialiser Git
git init
git add .
git commit -m "Initial project structure"

# Créer environnement virtuel
python -m venv .venv
source .venv/bin/activate  # Windows: .venv\Scripts\activate

# Installer dépendances de base
pip install fastapi uvicorn pydantic pytest

Tâche 1.2: Fichiers de configuration

requirements.txt

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
altair==5.2.0
python-multipart==0.0.6
aiofiles==23.2.1
cachetools==5.3.2
slowapi==0.1.9
python-json-logger==2.0.7
boto3==1.34.0
pytest==7.4.3
httpx==0.26.0

pyproject.toml

[project]
name = "diagram-ph-api"
version = "1.0.0"
description = "API REST pour diagrammes PH et calculs frigorifiques"
requires-python = ">=3.12"
dependencies = [
    "fastapi>=0.109.0",
    "uvicorn[standard]>=0.27.0",
    # ... autres dépendances
]

[project.optional-dependencies]
dev = [
    "pytest>=7.4.3",
    "pytest-cov>=4.1.0",
    "black>=23.12.0",
    "ruff>=0.1.8",
]

[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]

.env.example

# Application
ENV=development
LOG_LEVEL=DEBUG
API_VERSION=v1

# Server
HOST=0.0.0.0
PORT=8000
WORKERS=4

# AWS (pour production)
AWS_REGION=eu-west-1
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=

# Rate limiting
RATE_LIMIT_PER_MINUTE=100

# Cache
CACHE_TTL_SECONDS=3600
CACHE_MAX_SIZE=10000

Phase 2: Modules Core (Durée: 3-4 jours)

Tâche 2.1: RefrigerantEngine

app/core/refrigerant_engine.py

from pathlib import Path
import ctypes
import platform
from functools import lru_cache
from typing import Dict, Any
import sys

class RefrigerantEngine:
    """
    Moteur de calcul des propriétés thermodynamiques
    Gère le chargement des DLL/SO et le cache
    """
    
    def __init__(self, refrigerant: str):
        self.refrigerant = refrigerant
        self.lib = self._load_library()
        self._validate_library()
    
    def _load_library(self):
        """Charge la bibliothèque selon l'OS"""
        base_path = Path(__file__).parent.parent.parent / "libs"
        
        if platform.system() == "Windows":
            lib_path = base_path / "dll" / f"{self.refrigerant}.dll"
        elif platform.system() == "Linux":
            lib_path = base_path / "so" / f"lib{self.refrigerant}.so"
        else:
            raise OSError(f"Unsupported OS: {platform.system()}")
        
        if not lib_path.exists():
            raise FileNotFoundError(f"Library not found: {lib_path}")
        
        try:
            return ctypes.CDLL(str(lib_path))
        except OSError as e:
            raise RuntimeError(f"Failed to load {lib_path}: {e}")
    
    def _validate_library(self):
        """Vérifie que la bibliothèque est fonctionnelle"""
        try:
            # Test basique: récupérer les limites de pression
            p_min = self.p_begin()
            p_max = self.p_end()
            if p_min >= p_max:
                raise ValueError("Invalid pressure range")
        except Exception as e:
            raise RuntimeError(f"Library validation failed: {e}")
    
    @lru_cache(maxsize=1000)
    def get_properties_PT(self, pressure: float, temperature: float) -> Dict[str, Any]:
        """Calcul propriétés à partir de P et T (avec cache)"""
        # TODO: Implémenter appels DLL
        pass
    
    @lru_cache(maxsize=1000)
    def get_properties_PX(self, pressure: float, quality: float) -> Dict[str, Any]:
        """Calcul propriétés à partir de P et X (avec cache)"""
        # TODO: Implémenter appels DLL
        pass
    
    def get_saturation_curve(self) -> Dict[str, list]:
        """Génère la courbe de saturation"""
        # TODO: Implémenter
        pass
    
    def p_begin(self) -> float:
        """Pression minimale du fluide"""
        # TODO: Appel DLL
        pass
    
    def p_end(self) -> float:
        """Pression maximale du fluide"""
        # TODO: Appel DLL
        pass

Tâche 2.2: DiagramGenerator

app/core/diagram_generator.py

import matplotlib.pyplot as plt
import plotly.graph_objects as go
import base64
from io import BytesIO
from typing import List, Dict
from app.models.requests import Point, DiagramOptions
from app.models.enums import OutputFormat

class DiagramGenerator:
    """Générateur de diagrammes PH multi-format"""
    
    def __init__(self, refrigerant_engine):
        self.engine = refrigerant_engine
    
    def generate(
        self,
        points: List[Point],
        output_format: OutputFormat,
        options: DiagramOptions
    ):
        """Point d'entrée principal pour génération"""
        
        # Récupérer données de base
        saturation = self.engine.get_saturation_curve()
        isotherms = self._calculate_isotherms(options)
        
        # Calculer propriétés des points
        calculated_points = self._calculate_points(points)
        
        # Générer selon format
        if output_format == OutputFormat.MATPLOTLIB_PNG:
            return self._generate_matplotlib(
                saturation, isotherms, calculated_points, options
            )
        elif output_format == OutputFormat.PLOTLY_JSON:
            return self._generate_plotly_json(
                saturation, isotherms, calculated_points, options
            )
        elif output_format == OutputFormat.PLOTLY_HTML:
            return self._generate_plotly_html(
                saturation, isotherms, calculated_points, options
            )
    
    def _generate_matplotlib(self, saturation, isotherms, points, options):
        """Génère image PNG avec Matplotlib"""
        fig, ax = plt.subplots(figsize=(options.width/100, options.height/100))
        
        # Tracer saturation
        ax.plot(saturation['liquid']['enthalpy'], 
                saturation['liquid']['pressure'], 
                'k-', label='Saturation liquide')
        ax.plot(saturation['vapor']['enthalpy'], 
                saturation['vapor']['pressure'], 
                'k-', label='Saturation vapeur')
        
        # Échelle log pour pression
        ax.set_yscale('log')
        
        # Labels
        ax.set_xlabel('Enthalpie [kJ/kg]')
        ax.set_ylabel('Pression [bar]')
        ax.set_title(options.title or f'Diagramme PH - {self.engine.refrigerant}')
        ax.grid(True)
        
        # Convertir en base64
        buffer = BytesIO()
        plt.savefig(buffer, format='png', dpi=100, bbox_inches='tight')
        buffer.seek(0)
        image_base64 = base64.b64encode(buffer.read()).decode()
        plt.close()
        
        return {
            'image_base64': image_base64,
            'mime_type': 'image/png'
        }
    
    def _generate_plotly_json(self, saturation, isotherms, points, options):
        """Génère figure Plotly en JSON"""
        fig = go.Figure()
        
        # Ajouter traces...
        # TODO: Implémenter
        
        return fig.to_dict()

Tâche 2.3: CycleCalculator

app/core/cycle_calculator.py

from typing import Dict, Any
from dataclasses import dataclass

@dataclass
class CycleResults:
    cop_cooling: float
    cop_heating: float
    cooling_capacity: float
    heating_capacity: float
    compressor_power: float
    efficiencies: Dict[str, float]
    cycle_points: list

class CycleCalculator:
    """Calculs de cycles frigorifiques"""
    
    def __init__(self, refrigerant_engine):
        self.engine = refrigerant_engine
    
    def calculate_standard_cycle(
        self,
        evap_outlet,
        comp_outlet,
        cond_outlet,
        exp_outlet,
        mass_flow: float,
        efficiencies: Dict
    ) -> CycleResults:
        """Calcul cycle standard 4 points"""
        
        # Récupérer propriétés complètes
        p1 = self.engine.get_properties_PT(
            evap_outlet['pressure'], 
            evap_outlet['temperature']
        )
        p2 = self.engine.get_properties_PT(
            comp_outlet['pressure'], 
            comp_outlet['temperature']
        )
        p3 = self.engine.get_properties_PT(
            cond_outlet['pressure'], 
            cond_outlet['temperature']
        )
        p4 = self.engine.get_properties_PX(
            exp_outlet['pressure'], 
            exp_outlet['quality']
        )
        
        # Calculs énergétiques
        h1, h2, h3, h4 = p1['enthalpy'], p2['enthalpy'], p3['enthalpy'], p4['enthalpy']
        
        q_evap = mass_flow * (h1 - h4)  # Puissance frigorifique [W]
        q_cond = mass_flow * (h2 - h3)  # Puissance calorifique [W]
        w_comp = mass_flow * (h2 - h1)  # Puissance compresseur [W]
        
        # COP
        cop_cooling = q_evap / w_comp
        cop_heating = q_cond / w_comp
        
        # Rendement isentropique
        h2s = self._calc_isentropic_compression(p1, p2['pressure'])
        eta_isentropic = (h2s - h1) / (h2 - h1)
        
        return CycleResults(
            cop_cooling=cop_cooling,
            cop_heating=cop_heating,
            cooling_capacity=q_evap,
            heating_capacity=q_cond,
            compressor_power=w_comp,
            efficiencies={
                'isentropic': eta_isentropic,
                'volumetric': efficiencies.get('volumetric', 0.85),
                'mechanical': efficiencies.get('mechanical', 0.95)
            },
            cycle_points=[p1, p2, p3, p4]
        )
    
    def _calc_isentropic_compression(self, point1, p2):
        """Calcule enthalpie après compression isentropique"""
        # Compression à entropie constante
        s1 = point1['entropy']
        # TODO: Appel DLL h_ps(p2, s1)
        pass

Phase 3: API REST (Durée: 3-4 jours)

Tâche 3.1: Structure FastAPI

app/main.py

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from app.api.v1.router import api_router
from app.core.config import settings
from app.utils.logger import setup_logging

# Configuration logging
setup_logging()

# Création application
app = FastAPI(
    title="Diagram PH API",
    description="API pour diagrammes PH et calculs frigorifiques",
    version="1.0.0",
    docs_url="/docs",
    redoc_url="/redoc"
)

# Middlewares
app.add_middleware(
    CORSMiddleware,
    allow_origins=settings.CORS_ORIGINS,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)
app.add_middleware(GZipMiddleware, minimum_size=1000)

# Routes
app.include_router(api_router, prefix="/api/v1")

@app.on_event("startup")
async def startup_event():
    """Actions au démarrage"""
    logger.info("Starting Diagram PH API...")
    # Vérifier DLL/SO
    # Pré-charger données communes
    pass

@app.on_event("shutdown")
async def shutdown_event():
    """Actions à l'arrêt"""
    logger.info("Shutting down Diagram PH API...")
    pass

Tâche 3.2: Endpoints

app/api/v1/endpoints/diagram.py

from fastapi import APIRouter, HTTPException
from app.models.requests import DiagramRequest
from app.models.responses import DiagramResponse
from app.services.diagram_service import DiagramService

router = APIRouter()
diagram_service = DiagramService()

@router.post("/generate", response_model=DiagramResponse)
async def generate_diagram(request: DiagramRequest):
    """Génère un diagramme PH"""
    try:
        result = await diagram_service.generate_diagram(
            refrigerant=request.refrigerant,
            points=request.points,
            output_format=request.output_format,
            options=request.diagram_options
        )
        return result
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    except Exception as e:
        raise HTTPException(status_code=500, detail="Internal server error")

Tâche 3.3: Modèles Pydantic

app/models/requests.py

from pydantic import BaseModel, Field, validator
from typing import List, Optional
from app.models.enums import PointType, OutputFormat

class Point(BaseModel):
    type: PointType
    pressure: Optional[float] = Field(None, gt=0, description="Pression [Pa]")
    temperature: Optional[float] = Field(None, description="Température [°C]")
    quality: Optional[float] = Field(None, ge=0, le=1, description="Titre [0-1]")
    enthalpy: Optional[float] = Field(None, description="Enthalpie [J/kg]")
    label: Optional[str] = None
    order: Optional[int] = None
    
    @validator('pressure', 'temperature', 'quality', 'enthalpy')
    def check_required_fields(cls, v, values):
        point_type = values.get('type')
        # Validation selon le type de point
        # TODO: Implémenter validation complète
        return v

class DiagramOptions(BaseModel):
    show_isotherms: bool = True
    isotherm_step: int = Field(10, gt=0, le=50)
    show_saturation_lines: bool = True
    title: Optional[str] = None
    width: int = Field(1000, ge=400, le=2000)
    height: int = Field(800, ge=300, le=1500)

class DiagramRequest(BaseModel):
    refrigerant: str = Field(..., description="Nom du réfrigérant")
    output_format: OutputFormat
    points: List[Point] = Field(default_factory=list)
    diagram_options: DiagramOptions = Field(default_factory=DiagramOptions)

Phase 4: Tests (Durée: 2-3 jours)

Tâche 4.1: Tests unitaires

tests/test_core/test_refrigerant_engine.py

import pytest
from app.core.refrigerant_engine import RefrigerantEngine

def test_load_r134a():
    engine = RefrigerantEngine("R134a")
    assert engine.refrigerant == "R134a"
    assert engine.lib is not None

def test_properties_pt():
    engine = RefrigerantEngine("R134a")
    props = engine.get_properties_PT(500000, 278.15)
    assert 'enthalpy' in props
    assert 'entropy' in props
    assert props['pressure'] == 500000

def test_invalid_refrigerant():
    with pytest.raises(FileNotFoundError):
        RefrigerantEngine("R999")

Tâche 4.2: Tests d'intégration

tests/test_api/test_diagram.py

from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_generate_diagram():
    response = client.post(
        "/api/v1/diagram/generate",
        json={
            "refrigerant": "R134a",
            "output_format": "plotly_json",
            "points": [
                {
                    "type": "PT",
                    "pressure": 500000,
                    "temperature": 5,
                    "order": 1
                }
            ]
        }
    )
    assert response.status_code == 200
    data = response.json()
    assert data['success'] is True
    assert 'data' in data

Phase 5: Docker & AWS (Durée: 2-3 jours)

Tâche 5.1: Containerisation

# Build image
docker build -f docker/Dockerfile -t diagram-ph-api:latest .

# Test local
docker run -p 8000:8000 diagram-ph-api:latest

# Test avec docker-compose
docker-compose -f docker/docker-compose.yml up

Tâche 5.2: Déploiement AWS

# Créer repository ECR
aws ecr create-repository --repository-name diagram-ph-api

# Push image
docker push <ecr-repo-url>

# Initialiser EB
eb init -p docker diagram-ph-api

# Créer environnement
eb create diagram-ph-api-prod

# Déployer
./deployment/scripts/deploy.sh

Phase 6: Documentation & Finalisation (Durée: 1-2 jours)

Tâche 6.1: Documentation utilisateur

docs/EXAMPLES.md - Créer exemples pour chaque endpoint

Tâche 6.2: Tests finaux

  • Tests de charge (load testing)
  • Tests de sécurité
  • Validation sur environnement staging

Métriques de succès

Métrique Cible Status
Code coverage > 80%
Latence P95 < 500ms
Disponibilité > 99%
Taux d'erreur < 0.1%

Risques & Mitigation

Risque Impact Probabilité Mitigation
DLL/SO incompatibles Linux Élevé Moyen Tester .so tôt, avoir plan B
Performance insuffisante Moyen Faible Cache agressif, optimisation
Coûts AWS élevés Moyen Moyen Monitoring, auto-scaling
Problèmes calculs thermodynamiques Élevé Faible Tests exhaustifs, validation

Timeline estimé

Phase Durée Dates suggérées
Phase 1: Configuration 1-2 jours Jour 1-2
Phase 2: Modules Core 3-4 jours Jour 3-6
Phase 3: API REST 3-4 jours Jour 7-10
Phase 4: Tests 2-3 jours Jour 11-13
Phase 5: Docker & AWS 2-3 jours Jour 14-16
Phase 6: Documentation 1-2 jours Jour 17-18
TOTAL 12-18 jours ~3 semaines

Prochaines étapes immédiates

  1. Valider le plan avec l'équipe
  2. 🔄 Préparer fichiers .so Linux
  3. Initialiser repository Git
  4. Créer structure projet
  5. Commencer Phase 1