Sepehr fdd124eefd 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.
2026-02-28 22:45:51 +01:00

432 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Story 11.13: SWEP Parser
Status: done
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
## Story
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
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
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
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
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
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)
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)