# Story 10.4: AirSource et AirSink avec Propriétés Psychrométriques

**Epic:** 10 - Enhanced Boundary Conditions  
**Priorité:** P1-HIGH  
**Estimation:** 4h  
**Statut:** done  
**Dépendances:** Story 10-1 (Nouveaux types physiques)

---

## Story

> En tant que moteur de simulation thermodynamique,  
> Je veux que `AirSource` et `AirSink` supportent les propriétés psychrométriques,  
> Afin de pouvoir simuler les côtés air des échangeurs de chaleur (évaporateurs, condenseurs).

---

## Acceptance Criteria

- [x] `AirSource::from_dry_bulb_rh()` crée une source avec T sèche et HR
- [x] `AirSource::from_dry_and_wet_bulb()` calcule HR depuis T bulbe humide
- [x] `specific_enthalpy()` retourne l'enthalpie de l'air humide
- [x] `humidity_ratio()` retourne le rapport d'humidité
- [x] `AirSink::new()` crée un puits à pression atmosphérique
- [x] `energy_transfers()` retourne `(Power(0), Power(0))`
- [x] Validation de l'humidité relative (0-100%)
- [x] Tests unitaires avec valeurs de référence ASHRAE

---

## Tasks / Subtasks

- [x] Task 1: Implémenter AirSource (AC: #1, #2, #3, #4, #7)
  - [x] 1.1 Créer struct avec champs : `t_dry_k`, `rh`, `p_set_pa`, `w` (calculé), `h_set_jkg` (calculé), `outlet`
  - [x] 1.2 Implémenter `from_dry_bulb_rh()` avec calculs psychrométriques (W, h)
  - [x] 1.3 Implémenter `from_dry_and_wet_bulb()` via formule de Sprung
  - [x] 1.4 Implémenter `Component::compute_residuals()` (2 équations)
  - [x] 1.5 Implémenter `Component::jacobian_entries()` (diagonal 1.0)
  - [x] 1.6 Implémenter `Component::get_ports()`, `port_mass_flows()`, `port_enthalpies()`, `energy_transfers()`
  - [x] 1.7 Ajouter accesseurs : `t_dry()`, `rh()`, `p_set()`, `humidity_ratio()`, `h_set()`
  - [x] 1.8 Ajouter setters : `set_temperature()`, `set_rh()` (recalcul automatique)

- [x] Task 2: Implémenter AirSink (AC: #5, #6)
  - [x] 2.1 Créer struct avec champs : `p_back_pa`, `t_back_k` (optional), `rh_back` (optional), `h_back_jkg` (optional), `inlet`
  - [x] 2.2 Implémenter `new()` constructor (1-équation mode par défaut)
  - [x] 2.3 Implémenter count dynamique d'équations (1 ou 2)
  - [x] 2.4 Implémenter méthodes `Component` trait
  - [x] 2.5 Ajouter `set_return_temperature()`, `clear_return_temperature()` pour toggle dynamique

- [x] Task 3: Fonctions psychrométriques (AC: #3, #4, #8)
  - [x] 3.1 Implémenter `saturation_vapor_pressure()` (Magnus-Tetens)
  - [x] 3.2 Implémenter `humidity_ratio_from_rh()`
  - [x] 3.3 Implémenter `specific_enthalpy_from_w()`
  - [x] 3.4 Implémenter `rh_from_wet_bulb()` (formule de Sprung)

- [x] Task 4: Intégration du module (AC: #5, #6)
  - [x] 4.1 Ajouter `pub mod air_boundary` dans `crates/components/src/lib.rs`
  - [x] 4.2 Ajouter `pub use air_boundary::{AirSink, AirSource}`

- [x] Task 5: Tests (AC: #1-8)
  - [x] 5.1 Tests AirSource : `from_dry_bulb_rh`, `from_dry_and_wet_bulb`, wet > dry retourne erreur
  - [x] 5.2 Tests psychrométriques : `saturation_vapor_pressure` (ASHRAE ref), `humidity_ratio`, `specific_enthalpy`
  - [x] 5.3 Tests AirSink : création, pression invalide, toggle dynamique
  - [x] 5.4 Tests résiduels zéro au set-point (AirSource et AirSink 1-eq et 2-eq)
  - [x] 5.5 Tests trait object (`Box<dyn Component>`)
  - [x] 5.6 Tests `energy_transfers()` = (0, 0)
  - [x] 5.7 Tests signatures

- [x] Task 6: Validation
  - [x] 6.1 `cargo test --package entropyk-components --lib -- air_boundary` → 23 passed, 0 failed
  - [x] 6.2 `cargo test --package entropyk-components --lib` → 469 passed, 0 failed (aucune régression)
  - [x] 6.3 Aucun avertissement clippy dans `air_boundary.rs`

- [x] Task 7: Code Review Fixes (AI-Review)
  - [x] 7.1 Fixed `set_temperature()` and `set_rh()` to return `Result<(), ComponentError>`
  - [x] 7.2 Fixed `humidity_ratio_from_rh()` to return `Result<f64, ComponentError>` instead of silent 0.0
  - [x] 7.3 Added validation for P_v >= P_atm error case
  - [x] 7.4 Updated Sprung formula documentation for unventilated psychrometers
  - [x] 7.5 Tightened ASHRAE test tolerances (0.5% for P_sat, 1% for h and W)
  - [x] 7.6 Tightened specific_enthalpy test range (45-56 kJ/kg for 25°C/50%RH)
  - [x] 7.7 Updated File List with missing files from Epic 10

---

## Dev Notes

### Architecture Patterns (MUST follow)

1. **NewType Pattern**: Utiliser `RelativeHumidity` de `entropyk_core`, jamais `f64` nu pour l'humidité
2. **Zero-Panic Policy**: Toutes les méthodes retournent `Result<T, ComponentError>`
3. **Component Trait**: Implémenter toutes les méthodes du trait de façon identique aux composants existants
4. **Pas de dépendance backend**: Contrairement à BrineSource/RefrigerantSource, AirSource utilise des formules analytiques (Magnus-Tetens) — pas besoin de `FluidBackend`

### Pattern suivi

Ce composant suit le pattern exact de `brine_boundary.rs` et `refrigerant_boundary.rs`, avec les différences :

| Aspect | RefrigerantSource | BrineSource | AirSource |
|--------|-------------------|-------------|-----------|
| État spec | (P, VaporQuality) | (P, T, Concentration) | (T_dry, RH, P_atm) |
| Validation fluide | `!is_incompressible()` | `is_incompressible()` | aucune (air) |
| Backend requis | Oui | Oui | Non (analytique) |
| Calcul enthalpie | FluidBackend::PQ | FluidBackend::PT | Magnus-Tetens |

### Formules Psychrométriques

```rust
// Pression de saturation (Magnus-Tetens)
P_sat = 610.78 * exp(17.27 * T_c / (T_c + 237.3))   [Pa]

// Rapport d'humidité
W = 0.622 * P_v / (P_atm - P_v)   où P_v = RH * P_sat

// Enthalpie spécifique [J/kg_da]
h = 1006 * T_c + W * (2_501_000 + 1860 * T_c)

// Humidité relative depuis bulbe humide (Sprung)
e = e_sat(T_wet) - 6.6e-4 * (T_dry - T_wet) * P_atm
RH = e / e_sat(T_dry)
```

### Fichier créé

- `crates/components/src/air_boundary.rs` — AirSource, AirSink, helpers psychrométriques

### Fix préexistant

Corrigé `flooded_evaporator.rs:171-172` qui utilisait une méthode inexistante `enthalpy_px()`. Remplacé par l'appel correct via `FluidBackend::property()` avec `FluidState::from_px()`.

---

## Dev Agent Record

### Agent Model Used

openrouter/anthropic/claude-sonnet-4.6

### Debug Log References

Aucun blocage. Fix d'une erreur de compilation préexistante dans `flooded_evaporator.rs` (méthode `enthalpy_px` inexistante remplacée par `backend.property(...)` avec `FluidState::from_px(...)`).

### Completion Notes List

- Créé `crates/components/src/air_boundary.rs` avec `AirSource` et `AirSink`
- Implémenté 4 helpers psychrométriques : `saturation_vapor_pressure`, `humidity_ratio_from_rh`, `specific_enthalpy_from_w`, `rh_from_wet_bulb`
- Utilisé `RelativeHumidity` de `entropyk_core` pour la sécurité des types
- Aucune dépendance au `FluidBackend` — formules analytiques Magnus-Tetens
- `AirSink` dynamique : toggle entre 1-équation (pression seule) et 2-équations (P + h)
- 23 tests unitaires passent dont 3 validations ASHRAE de référence
- 469 tests au total dans le package, 0 régression
- Module exporté dans `lib.rs` avec `AirSource` et `AirSink`
- Fix secondaire : `flooded_evaporator.rs` erreur de compilation préexistante corrigée

### File List

- `crates/components/src/air_boundary.rs` (créé)
- `crates/components/src/lib.rs` (modifié — ajout module + re-exports)
- `crates/components/src/heat_exchanger/flooded_evaporator.rs` (modifié — fix erreur de compilation préexistante)

### Files Created in Epic 10 (Related Context)

- `crates/components/src/brine_boundary.rs` (créé — Story 10-3)
- `crates/components/src/refrigerant_boundary.rs` (créé — Story 10-2)
- `crates/components/src/drum.rs` (créé — Story 11-2)

### Change Log

- 2026-02-23: Implémentation AirSource et AirSink avec propriétés psychrométriques complètes (Story 10-4)
- 2026-02-23: **Code Review (AI)** — Fixed 8 issues:
  - Fixed `set_temperature()` and `set_rh()` to return `Result` with proper error handling
  - Fixed `humidity_ratio_from_rh()` to return `Result` instead of silent 0.0 on invalid P_v
  - Added validation for P_v >= P_atm (now returns descriptive error)
  - Updated Sprung formula documentation to clarify unventilated psychrometer assumption
  - Tightened ASHRAE test tolerances: P_sat (0.5%), enthalpy (1%), humidity ratio (1%)
  - Tightened specific_enthalpy test range from (40-80) to (45-56) kJ/kg
  - Updated File List with related files from Epic 10
  - 23 tests pass, 0 regressions, 0 air_boundary clippy warnings

---

## Senior Developer Review (AI)

**Reviewer:** Claude-4 (Sonnet)  
**Date:** 2026-02-23  
**Outcome:** ✅ **APPROVED with Fixes Applied**

### Issues Found and Fixed

#### 🔴 Critical (1)
1. **Missing Result type on setters** — `set_temperature()` and `set_rh()` did not return `Result` despite potential failure modes. **FIXED:** Both now return `Result<(), ComponentError>` with proper validation.

#### 🟡 High (2)
2. **Sprung formula assumptions undocumented** — The psychrometric constant A_psy = 6.6e-4 is specific to unventilated psychrometers. **FIXED:** Added explicit documentation about this assumption.

3. **ASHRAE test tolerances too loose** — Original tolerances (1.6% for P_sat, 2.6% for h) were too permissive. **FIXED:** Tightened to 0.5% for P_sat and 1% for h and W.

#### 🟡 Medium (2)
4. **File List incomplete** — Story documented only 3 files but Epic 10 created 6+ files. **FIXED:** Added "Files Created in Epic 10 (Related Context)" section.

5. **Silent error handling** — `humidity_ratio_from_rh()` returned 0.0 when P_v >= P_atm instead of error. **FIXED:** Now returns descriptive `ComponentError::InvalidState`.

#### 🟢 Low (3)
6. **RH clamping without warning** — Documented behavior, acceptable for production use.
7. **Test enthalpy range too wide** — Was 40-80 kJ/kg, now 45-56 kJ/kg (ASHRAE standard).
8. **Documentation mismatch** — Setter docs claimed Result return type but didn't implement it. **FIXED:** Implementation now matches documentation.

### Verification

- ✅ All 23 air_boundary tests pass
- ✅ All 469 component tests pass (0 regressions)
- ✅ 0 clippy warnings specific to air_boundary.rs
- ✅ All Acceptance Criteria validated
- ✅ All Tasks marked [x] verified complete

### Recommendation

Story is **READY FOR PRODUCTION**. All critical and high issues resolved. Test coverage excellent (23 tests, including 3 ASHRAE reference validations).
