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

18 KiB
Raw Permalink Blame History

Story 11.13: SWEP Parser

Status: done

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

  • Task 1: Create sample SWEP JSON data files (AC: 2)
    • Subtask 1.1: Create data/swep/bphx/B5THx20.json with realistic BPHX geometry and UA curve
    • Subtask 1.2: Create data/swep/bphx/B8THx30.json as second model (without UA curve)
    • Subtask 1.3: Create data/swep/bphx/index.json with ["B5THx20", "B8THx30"]
  • Task 2: Implement SwepBackend (AC: 1, 3, 4, 5, 6, 7, 8)
    • Subtask 2.1: Create src/heat_exchangers/swep.rs with SwepBackend struct
    • Subtask 2.2: Implement SwepBackend::new() — resolve data path via env!("CARGO_MANIFEST_DIR")
    • Subtask 2.3: Implement load_index() — read index.json, parse to Vec<String>
    • Subtask 2.4: Implement load_model() — read individual JSON file, deserialize to BphxParameters
    • Subtask 2.5: Implement pre-caching loop in new() — load all models, skip with warning on failure
    • Subtask 2.6: Implement VendorBackend trait for SwepBackend
    • Subtask 2.7: Override compute_ua() — use UaCurve::interpolate() when curve is available
  • Task 3: Wire up module exports (AC: 1)
    • Subtask 3.1: Uncomment and activate pub mod swep; in src/heat_exchangers/mod.rs
    • Subtask 3.2: Add pub use heat_exchangers::swep::SwepBackend; to src/lib.rs
  • Task 4: Write unit tests (AC: 9)
    • Subtask 4.1: Test SwepBackend::new() successfully constructs
    • Subtask 4.2: Test list_bphx_models() returns expected model names in sorted order
    • Subtask 4.3: Test get_bphx_parameters() returns valid parameters
    • Subtask 4.4: Test parameter values match JSON data (geometry + UA)
    • Subtask 4.5: Test ModelNotFound error for unknown model
    • Subtask 4.6: Test compute_ua() returns interpolated value when UA curve present
    • Subtask 4.7: Test compute_ua() returns ua_nominal when no UA curve
    • Subtask 4.8: Test list_compressor_models() returns empty vec
    • Subtask 4.9: Test get_compressor_coefficients() returns InvalidFormat
    • Subtask 4.10: Test vendor_name() returns "SWEP"
    • Subtask 4.11: Test object safety via Box<dyn VendorBackend>
  • Task 5: Verify all tests pass (AC: 9)
    • Subtask 5.1: Run cargo test -p entropyk-vendors
    • 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 dependenciesserde, 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)

// 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)

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]):

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):

{
  "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):

{
  "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 DebugSwepBackend must derive Debug
  • #![warn(missing_docs)] is active in lib.rs — all public items need doc comments
  • Trait is object-safeBox<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 listslist_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:

#[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

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)