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.
432 lines
18 KiB
Markdown
432 lines
18 KiB
Markdown
# 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", ¶ms).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)
|