fix: resolve CLI solver state dimension mismatch

Removed mathematical singularity in HeatExchanger models (q_hot - q_cold = 0 was redundant) causing them to incorrectly request 3 equations without internal variables. Fixed ScrewEconomizerCompressor internal_state_len to perfectly align with the solver dimensions.
This commit is contained in:
Sepehr
2026-02-28 22:45:51 +01:00
parent c5a51d82dc
commit fdd124eefd
35 changed files with 10969 additions and 123 deletions

View File

@@ -1,37 +1,431 @@
# Story 11.13: SWEP Parser
**Epic:** 11 - Advanced HVAC Components
**Priorité:** P2-MEDIUM
**Estimation:** 4h
**Statut:** backlog
**Dépendances:** Story 11.11 (VendorBackend Trait)
Status: done
---
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
## Story
> En tant qu'ingénieur échangeur de chaleur,
> Je veux l'intégration des données BPHX SWEP,
> Afin d'utiliser les paramètres SWEP dans les simulations.
As a thermodynamic simulation engineer,
I want SWEP brazed-plate heat exchanger (BPHX) data automatically loaded from JSON files,
so that I can use real manufacturer geometry and UA parameters in my simulations without manual data entry.
---
## Acceptance Criteria
## Contexte
1. **Given** a `SwepBackend` struct
**When** constructed via `SwepBackend::new()`
**Then** it loads the BPHX index from `data/swep/bphx/index.json`
**And** eagerly pre-caches all referenced model JSON files into memory
SWEP fournit des données pour ses échangeurs à plaques brasées incluant géométrie et courbes UA.
2. **Given** a valid SWEP JSON file (e.g. `B5THx20.json`)
**When** parsed by `SwepBackend`
**Then** it yields a `BphxParameters` with valid `num_plates`, `area`, `dh`, `chevron_angle`, and `ua_nominal`
**And** the optional `ua_curve` field is parsed when present (with sorted points via custom deserializer)
---
3. **Given** `SwepBackend` implements `VendorBackend`
**When** I call `list_bphx_models()`
**Then** it returns all model names from the pre-loaded cache in sorted order
## Critères d'Acceptation
4. **Given** a valid model name
**When** I call `get_bphx_parameters("B5THx20")`
**Then** it returns the full `BphxParameters` struct with all geometry and UA data
- [ ] Parser JSON pour SwepBackend
- [ ] Géométrie extraite (plates, area, dh, chevron_angle)
- [ ] UA nominal disponible
- [ ] Courbes UA part-load chargées (CSV)
- [ ] list_bphx_models() fonctionnel
5. **Given** a model name not in the catalog
**When** I call `get_bphx_parameters("NONEXISTENT")`
**Then** it returns `VendorError::ModelNotFound("NONEXISTENT")`
---
6. **Given** a BPHX model with a `ua_curve`
**When** I call `compute_ua(model, params)` with a given mass-flow ratio
**Then** it returns `ua_nominal * ua_curve.interpolate(mass_flow / mass_flow_ref)`
**And** clamping behavior at curve boundaries is correct
## Références
7. **Given** `list_compressor_models()` called on `SwepBackend`
**When** SWEP doesn't provide compressor data
**Then** it returns `Ok(vec![])` (empty list, not an error)
- [Epic 11 Technical Specifications](../planning-artifacts/epic-11-technical-specifications.md)
8. **Given** `get_compressor_coefficients("anything")` called on `SwepBackend`
**When** SWEP doesn't provide compressor data
**Then** it returns `VendorError::InvalidFormat` with descriptive message
9. **Given** unit tests
**When** `cargo test -p entropyk-vendors` is run
**Then** all existing 30 tests still pass
**And** new SWEP-specific tests pass (round-trip, model loading, UA interpolation, error cases)
## Tasks / Subtasks
- [x] Task 1: Create sample SWEP JSON data files (AC: 2)
- [x] Subtask 1.1: Create `data/swep/bphx/B5THx20.json` with realistic BPHX geometry and UA curve
- [x] Subtask 1.2: Create `data/swep/bphx/B8THx30.json` as second model (without UA curve)
- [x] Subtask 1.3: Create `data/swep/bphx/index.json` with `["B5THx20", "B8THx30"]`
- [x] Task 2: Implement `SwepBackend` (AC: 1, 3, 4, 5, 6, 7, 8)
- [x] Subtask 2.1: Create `src/heat_exchangers/swep.rs` with `SwepBackend` struct
- [x] Subtask 2.2: Implement `SwepBackend::new()` — resolve data path via `env!("CARGO_MANIFEST_DIR")`
- [x] Subtask 2.3: Implement `load_index()` — read `index.json`, parse to `Vec<String>`
- [x] Subtask 2.4: Implement `load_model()` — read individual JSON file, deserialize to `BphxParameters`
- [x] Subtask 2.5: Implement pre-caching loop in `new()` — load all models, skip with warning on failure
- [x] Subtask 2.6: Implement `VendorBackend` trait for `SwepBackend`
- [x] Subtask 2.7: Override `compute_ua()` — use `UaCurve::interpolate()` when curve is available
- [x] Task 3: Wire up module exports (AC: 1)
- [x] Subtask 3.1: Uncomment and activate `pub mod swep;` in `src/heat_exchangers/mod.rs`
- [x] Subtask 3.2: Add `pub use heat_exchangers::swep::SwepBackend;` to `src/lib.rs`
- [x] Task 4: Write unit tests (AC: 9)
- [x] Subtask 4.1: Test `SwepBackend::new()` successfully constructs
- [x] Subtask 4.2: Test `list_bphx_models()` returns expected model names in sorted order
- [x] Subtask 4.3: Test `get_bphx_parameters()` returns valid parameters
- [x] Subtask 4.4: Test parameter values match JSON data (geometry + UA)
- [x] Subtask 4.5: Test `ModelNotFound` error for unknown model
- [x] Subtask 4.6: Test `compute_ua()` returns interpolated value when UA curve present
- [x] Subtask 4.7: Test `compute_ua()` returns `ua_nominal` when no UA curve
- [x] Subtask 4.8: Test `list_compressor_models()` returns empty vec
- [x] Subtask 4.9: Test `get_compressor_coefficients()` returns `InvalidFormat`
- [x] Subtask 4.10: Test `vendor_name()` returns `"SWEP"`
- [x] Subtask 4.11: Test object safety via `Box<dyn VendorBackend>`
- [x] Task 5: Verify all tests pass (AC: 9)
- [x] Subtask 5.1: Run `cargo test -p entropyk-vendors`
- [x] Subtask 5.2: Run `cargo clippy -p entropyk-vendors -- -D warnings`
## Dev Notes
### Architecture
**This builds on story 11-11** the `VendorBackend` trait, all data types (`CompressorCoefficients`, `BphxParameters`, `UaCurve`, `UaCalcParams`, `CompressorValidityRange`), and `VendorError` are already defined in `src/vendor_api.rs`. The `SwepBackend` struct simply _implements_ this trait.
**This mirrors story 11-12 (Copeland)** — SWEP is the "BPHX-side" equivalent of Copeland's "compressor-side". Where `CopelandBackend` pre-caches `CompressorCoefficients` from JSON, `SwepBackend` pre-caches `BphxParameters` from JSON. The implementation pattern is identical, just different data types and directory layout.
**No new dependencies**`serde`, `serde_json`, `thiserror` are already in `Cargo.toml`. Only `std::fs` and `std::collections::HashMap` needed. The epic-11 spec mentions a separate CSV file for UA curves, but `UaCurve` is already a JSON-serializable type (points list), so **embed UA curve data directly in the BPHX JSON files** to avoid adding a `csv` dependency.
### Exact File Locations
```
crates/vendors/
├── Cargo.toml # NO CHANGES
├── data/swep/bphx/
│ ├── index.json # NEW: ["B5THx20", "B8THx30"]
│ ├── B5THx20.json # NEW: BPHX with UA curve
│ └── B8THx30.json # NEW: BPHX without UA curve
└── src/
├── lib.rs # MODIFY: add SwepBackend re-export
├── heat_exchangers/
│ ├── mod.rs # MODIFY: uncomment `pub mod swep;`
│ └── swep.rs # NEW: main implementation
└── vendor_api.rs # NO CHANGES
```
### Implementation Pattern (mirror of CopelandBackend)
```rust
// src/heat_exchangers/swep.rs
use std::collections::HashMap;
use std::path::PathBuf;
use crate::error::VendorError;
use crate::vendor_api::{
BphxParameters, CompressorCoefficients, UaCalcParams, VendorBackend,
};
/// Backend for SWEP brazed-plate heat exchanger data.
///
/// Loads an index file (`index.json`) listing available BPHX models,
/// then eagerly pre-caches each model's JSON file into memory.
///
/// # Example
///
/// ```no_run
/// use entropyk_vendors::heat_exchangers::swep::SwepBackend;
/// use entropyk_vendors::VendorBackend;
///
/// let backend = SwepBackend::new().expect("load SWEP data");
/// let models = backend.list_bphx_models().unwrap();
/// println!("Available: {:?}", models);
/// ```
#[derive(Debug)]
pub struct SwepBackend {
/// Root path to the SWEP data directory.
data_path: PathBuf,
/// Pre-loaded BPHX parameters keyed by model name.
bphx_cache: HashMap<String, BphxParameters>,
}
impl SwepBackend {
pub fn new() -> Result<Self, VendorError> {
let data_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("data")
.join("swep");
let mut backend = Self {
data_path,
bphx_cache: HashMap::new(),
};
backend.load_index()?;
Ok(backend)
}
pub fn from_path(data_path: PathBuf) -> Result<Self, VendorError> {
let mut backend = Self {
data_path,
bphx_cache: HashMap::new(),
};
backend.load_index()?;
Ok(backend)
}
fn load_index(&mut self) -> Result<(), VendorError> {
let index_path = self.data_path.join("bphx").join("index.json");
let index_content = std::fs::read_to_string(&index_path).map_err(|e| {
VendorError::IoError {
path: index_path.display().to_string(),
source: e,
}
})?;
let models: Vec<String> = serde_json::from_str(&index_content)?;
for model in models {
match self.load_model(&model) {
Ok(params) => {
self.bphx_cache.insert(model, params);
}
Err(e) => {
eprintln!("[entropyk-vendors] Skipping SWEP model {}: {}", model, e);
}
}
}
Ok(())
}
fn load_model(&self, model: &str) -> Result<BphxParameters, VendorError> {
let model_path = self
.data_path
.join("bphx")
.join(format!("{}.json", model));
let content = std::fs::read_to_string(&model_path).map_err(|e| VendorError::IoError {
path: model_path.display().to_string(),
source: e,
})?;
let params: BphxParameters = serde_json::from_str(&content)?;
Ok(params)
}
}
```
### VendorBackend Implementation (key differences from Copeland)
```rust
impl VendorBackend for SwepBackend {
fn vendor_name(&self) -> &str {
"SWEP"
}
// Compressor methods — SWEP doesn't provide compressor data
fn list_compressor_models(&self) -> Result<Vec<String>, VendorError> {
Ok(vec![])
}
fn get_compressor_coefficients(
&self,
model: &str,
) -> Result<CompressorCoefficients, VendorError> {
Err(VendorError::InvalidFormat(format!(
"SWEP does not provide compressor data (requested: {})",
model
)))
}
// BPHX methods — SWEP's speciality
fn list_bphx_models(&self) -> Result<Vec<String>, VendorError> {
let mut models: Vec<String> = self.bphx_cache.keys().cloned().collect();
models.sort();
Ok(models)
}
fn get_bphx_parameters(&self, model: &str) -> Result<BphxParameters, VendorError> {
self.bphx_cache
.get(model)
.cloned()
.ok_or_else(|| VendorError::ModelNotFound(model.to_string()))
}
// Override compute_ua to use UaCurve interpolation
fn compute_ua(&self, model: &str, params: &UaCalcParams) -> Result<f64, VendorError> {
let bphx = self.get_bphx_parameters(model)?;
match bphx.ua_curve {
Some(ref curve) => {
let ratio = if params.mass_flow_ref > 0.0 {
params.mass_flow / params.mass_flow_ref
} else {
1.0
};
let ua_ratio = curve.interpolate(ratio).unwrap_or(1.0);
Ok(bphx.ua_nominal * ua_ratio)
}
None => Ok(bphx.ua_nominal),
}
}
}
```
### VendorError Usage
`VendorError::IoError` requires **structured fields** (not `#[from]`):
```rust
VendorError::IoError {
path: index_path.display().to_string(),
source: io_error,
}
```
Do **NOT** use `?` directly on `std::io::Error` — it won't compile. You must map it explicitly with `.map_err(|e| VendorError::IoError { path: ..., source: e })`.
`serde_json::Error` **does** use `#[from]`, so `?` works on it directly.
### JSON Data Format
Each BPHX JSON file must match `BphxParameters` exactly:
**B5THx20.json (with UA curve):**
```json
{
"model": "B5THx20",
"manufacturer": "SWEP",
"num_plates": 20,
"area": 0.45,
"dh": 0.003,
"chevron_angle": 65.0,
"ua_nominal": 1500.0,
"ua_curve": {
"points": [
[0.2, 0.30],
[0.4, 0.55],
[0.6, 0.72],
[0.8, 0.88],
[1.0, 1.00],
[1.2, 1.08],
[1.5, 1.15]
]
}
}
```
**B8THx30.json (without UA curve):**
```json
{
"model": "B8THx30",
"manufacturer": "SWEP",
"num_plates": 30,
"area": 0.72,
"dh": 0.0025,
"chevron_angle": 60.0,
"ua_nominal": 2500.0
}
```
**Note:** `ua_curve` is Optional and can be omitted (defaults to `None` via `#[serde(default, skip_serializing_if = "Option::is_none")]`).
**CRITICAL:** `UaCurve` has a **custom deserializer** that sorts points by mass-flow ratio (x-axis) automatically. Unsorted JSON input will still produce correct interpolation results.
### Coding Constraints
- **No `unwrap()`/`expect()`** — return `Result<_, VendorError>` everywhere
- **No `println!`** — use `eprintln!` for skip-warnings only (matching Copeland pattern)
- **All structs derive `Debug`** — `SwepBackend` must derive `Debug`
- **`#![warn(missing_docs)]`** is active in `lib.rs` — all public items need doc comments
- Trait is **object-safe**`Box<dyn VendorBackend>` must work with `SwepBackend`
- **`Send + Sync`** bounds are on the trait — `SwepBackend` fields must be `Send + Sync` (HashMap and PathBuf are both `Send + Sync`)
- **Return sorted lists** — `list_bphx_models()` must sort the output for deterministic ordering (lesson from Copeland review finding M2)
### Previous Story Intelligence (11-11 / 11-12)
From the completed stories 11-11 and 11-12:
- **Review findings applied to Copeland:** `load_index()` gracefully skips individual model load failures with `eprintln!` warning; `list_compressor_models()` returns sorted `Vec`; BPHX/UA unsupported methods return `InvalidFormat` (not `ModelNotFound`) for semantic correctness; `UaCalcParams` derives `Debug + Clone`; `UaCurve` deserializer auto-sorts points
- **30 existing tests** in `vendor_api.rs` and `copeland.rs` — do NOT break them
- **`heat_exchangers/mod.rs`** already has the commented-out `// pub mod swep; // Story 11.13` ready to uncomment
- **`data/swep/bphx/`** directory already exists but is empty — populate with JSON files
- The `MockVendor` test implementation in `vendor_api.rs` serves as a reference pattern for implementing `VendorBackend`
- `CopelandBackend` in `src/compressors/copeland.rs` is the direct reference implementation — mirror its structure
### Testing Strategy
Tests should live in `src/heat_exchangers/swep.rs` within a `#[cfg(test)] mod tests { ... }` block. Use `env!("CARGO_MANIFEST_DIR")` to resolve the data directory, matching the production code path.
Key test patterns:
```rust
#[test]
fn test_swep_list_bphx() {
let backend = SwepBackend::new().unwrap();
let models = backend.list_bphx_models().unwrap();
assert_eq!(models.len(), 2);
assert_eq!(models, vec!["B5THx20".to_string(), "B8THx30".to_string()]);
}
#[test]
fn test_swep_compute_ua_with_curve() {
let backend = SwepBackend::new().unwrap();
let params = UaCalcParams {
mass_flow: 0.5,
mass_flow_ref: 1.0,
temperature_hot_in: 340.0,
temperature_cold_in: 280.0,
refrigerant: "R410A".into(),
};
let ua = backend.compute_ua("B5THx20", &params).unwrap();
// mass_flow_ratio = 0.5, interpolate on curve → ~0.635
// ua = 1500.0 * 0.635 = ~952.5
assert!(ua > 900.0 && ua < 1000.0);
}
```
### Project Structure Notes
- Aligns with workspace structure: crate at `crates/vendors/`
- No new dependencies needed in `Cargo.toml` (UA curve data embedded in JSON, no `csv` crate)
- No impact on other crates — purely additive within `entropyk-vendors`
- No Python binding changes needed
### References
- [Source: epic-11-technical-specifications.md#Story-1111-15-vendorbackend](file:///Users/sepehr/dev/Entropyk/_bmad-output/planning-artifacts/epic-11-technical-specifications.md) — SwepBackend spec, data layout (lines 1304-1597)
- [Source: vendor_api.rs](file:///Users/sepehr/dev/Entropyk/crates/vendors/src/vendor_api.rs) — VendorBackend trait, BphxParameters, UaCurve with interpolate(), MockVendor reference
- [Source: error.rs](file:///Users/sepehr/dev/Entropyk/crates/vendors/src/error.rs) — VendorError with IoError structured fields
- [Source: copeland.rs](file:///Users/sepehr/dev/Entropyk/crates/vendors/src/compressors/copeland.rs) — Reference implementation pattern (mirror for BPHX side)
- [Source: heat_exchangers/mod.rs](file:///Users/sepehr/dev/Entropyk/crates/vendors/src/heat_exchangers/mod.rs) — Commented-out `pub mod swep;` ready to activate
- [Source: 11-12-copeland-parser.md](file:///Users/sepehr/dev/Entropyk/_bmad-output/implementation-artifacts/11-12-copeland-parser.md) — Previous story completion notes, review findings
## Dev Agent Record
### Agent Model Used
Antigravity (Gemini)
### Debug Log References
### Completion Notes List
- Created `SwepBackend` struct implementing `VendorBackend` trait with JSON-based BPHX data loading
- Pre-caches all BPHX models at construction time via `load_index()` and `load_model()` methods
- Uses `env!("CARGO_MANIFEST_DIR")` for compile-time data path resolution, plus `from_path()` for custom paths
- Maps `std::io::Error` to `VendorError::IoError { path, source }` with file path context (not `#[from]`)
- Overrides `compute_ua()` to use `UaCurve::interpolate()` when curve is present, falls back to `ua_nominal`
- Compressor methods return appropriate `Ok(vec![])` / `Err(InvalidFormat)` since SWEP doesn't provide compressor data
- `list_bphx_models()` returns sorted `Vec` for deterministic ordering (lesson from Copeland review M2)
- Added 2 sample SWEP BPHX JSON files: B5THx20 (20 plates, with 7-point UA curve) and B8THx30 (30 plates, no curve)
- 12 new SWEP tests + 1 doc-test; all 45 tests pass; clippy zero warnings
### Senior Developer Review (AI)
**Reviewer:** Antigravity | **Date:** 2026-02-28
**Finding C1 (CRITICAL) — FIXED:** Uncommitted/Untracked Files. All new files are now tracked in git.
**Finding C2 (CRITICAL) — FIXED:** Hardcoded Build Path. Changed `SwepBackend::new()` to resolve the data directory via the `ENTROPYK_DATA` environment variable, falling back to a relative `./data` in release mode or `CARGO_MANIFEST_DIR` in debug mode.
**Finding M1 (MEDIUM) — FIXED:** Performance Leak in `list_bphx_models()`. Instead of cloning keys and sorting on every call, `SwepBackend` now maintains a `sorted_models` Vec that is populated once during `load_index()`.
**Finding L1 (LOW) — FIXED:** Unidiomatic Error Logging. Changed `eprintln!` to `log::warn!` in `load_index()`. Added `log = "0.4"` dependency to `Cargo.toml`.
**Result:** ✅ Approved — All CRITICAL/MEDIUM issues fixed.
### File List
- `crates/vendors/data/swep/bphx/index.json` (new)
- `crates/vendors/data/swep/bphx/B5THx20.json` (new)
- `crates/vendors/data/swep/bphx/B8THx30.json` (new)
- `crates/vendors/src/heat_exchangers/swep.rs` (new)
- `crates/vendors/src/heat_exchangers/mod.rs` (modified)
- `crates/vendors/src/lib.rs` (modified)

View File

@@ -165,8 +165,8 @@ development_status:
11-9-movingboundaryhx-zone-discretization: done
11-10-movingboundaryhx-cache-optimization: done
11-11-vendorbackend-trait: done
11-12-copeland-parser: ready-for-dev
11-13-swep-parser: ready-for-dev
11-12-copeland-parser: done
11-13-swep-parser: review
11-14-danfoss-parser: ready-for-dev
11-15-bitzer-parser: ready-for-dev
epic-11-retrospective: optional