13 KiB
Story 10.2: RefrigerantSource and RefrigerantSink
Status: done
Story
As a thermodynamic engineer,
I want dedicated RefrigerantSource and RefrigerantSink components that natively support vapor quality,
So that I can model refrigerant cycles with precise two-phase state specification without confusion.
Acceptance Criteria
-
Given the new
VaporQualitytype from Story 10-1 When I create aRefrigerantSourceThen I can specify the refrigerant state via (Pressure, VaporQuality) instead of (Pressure, Enthalpy) -
RefrigerantSource imposes fixed thermodynamic state on outlet edge:
- Constructor:
RefrigerantSource::new(fluid, p_set, quality, backend, outlet) - Uses
VaporQualitytype for type-safe quality specification - Internal conversion: quality → enthalpy via FluidBackend
- 2 equations:
P_edge - P_set = 0,h_edge - h_set = 0
- Constructor:
-
RefrigerantSink imposes back-pressure (optional quality):
- Constructor:
RefrigerantSink::new(fluid, p_back, quality_opt, backend, inlet) - Optional quality:
None= free enthalpy (1 equation),Some(q)= fixed quality (2 equations) - Methods:
set_quality(),clear_quality()for dynamic toggle
- Constructor:
-
Given a refrigerant at saturated liquid (quality = 0) When creating RefrigerantSource Then the source outputs subcooled/saturated liquid state
-
Given a refrigerant at saturated vapor (quality = 1) When creating RefrigerantSource Then the source outputs saturated/superheated vapor state
-
Fluid validation: only accept refrigerants (R410A, R134a, R32, CO2, etc.), reject incompressible fluids
-
Implements
Componenttrait (object-safe,Box<dyn Component>) -
All methods return
Result<T, ComponentError>(Zero-Panic Policy) -
Unit tests cover: quality conversions, boundary cases (0, 1), invalid fluids, optional quality toggle
-
Documentation with examples and LaTeX equations
Tasks / Subtasks
-
Task 1: Implement RefrigerantSource (AC: #1, #2, #4, #5, #6)
- 1.1 Create struct with fields:
fluid_id,p_set,quality,h_set(computed),backend,outlet - 1.2 Implement
new()constructor with quality → enthalpy conversion via backend - 1.3 Add fluid validation (reject incompressible via
is_incompressible()) - 1.4 Implement
Component::compute_residuals()(2 equations) - 1.5 Implement
Component::jacobian_entries()(diagonal 1.0) - 1.6 Implement
Component::get_ports(),port_mass_flows(),port_enthalpies(),energy_transfers() - 1.7 Add accessor methods:
fluid_id(),p_set_pa(),quality(),h_set_jkg() - 1.8 Add setters:
set_pressure(),set_quality()(recompute enthalpy)
- 1.1 Create struct with fields:
-
Task 2: Implement RefrigerantSink (AC: #3, #6)
- 2.1 Create struct with fields:
fluid_id,p_back,quality_opt,h_back_opt(computed),backend,inlet - 2.2 Implement
new()constructor with optional quality - 2.3 Implement dynamic equation count (1 or 2 based on quality_opt)
- 2.4 Implement
Componenttrait methods - 2.5 Add
set_quality(),clear_quality()methods
- 2.1 Create struct with fields:
-
Task 3: Module integration (AC: #7, #8)
- 3.1 Add to
crates/components/src/lib.rsexports - 3.2 Add type aliases if needed (optional)
- 3.3 Ensure
Box<dyn Component>compatibility
- 3.1 Add to
-
Task 4: Testing (AC: #9)
- 4.1 Unit tests for RefrigerantSource: quality 0, 0.5, 1; invalid fluids
- 4.2 Unit tests for RefrigerantSink: with/without quality, dynamic toggle
- 4.3 Residual validation tests (zero at set-point)
- 4.4 Trait object tests (
Box<dyn Component>) - 4.5 Energy methods tests (Q=0, W=0 for boundaries)
-
Task 5: Validation
- 5.1 Run
cargo test --package entropyk-components - 5.2 Run
cargo clippy -- -D warnings - 5.3 Run
cargo test --workspace(no regressions)
- 5.1 Run
Dev Notes
Architecture Patterns (MUST follow)
From architecture.md:
- NewType Pattern: Use
VaporQualityfrom Story 10-1, NEVER baref64for quality - Zero-Panic Policy: All methods return
Result<T, ComponentError> - Component Trait: Must implement all trait methods identically to existing components
- Tracing: Use
tracingfor logging, NEVERprintln!
Existing RefrigerantSource/RefrigerantSink Pattern
This is a REFACTORING to add type-specific variants, NOT a rewrite. Study the existing implementation at:
File: crates/components/src/refrigerant_boundary.rs
Key patterns to follow:
- Struct layout with
FluidKind,fluid_id, pressure, enthalpy, port - Constructor validation (positive pressure, fluid type check)
Componenttrait implementation with 2 equations (or 1 for sink without enthalpy)- Jacobian entries are diagonal 1.0 for boundary conditions
port_mass_flows()returnsMassFlow::from_kg_per_s(0.0)placeholderenergy_transfers()returnsSome((Power::from_watts(0.0), Power::from_watts(0.0)))
Fluid Quality → Enthalpy Conversion
use entropyk_fluids::FluidBackend;
use entropyk_core::VaporQuality;
// Convert quality to enthalpy at saturation
fn quality_to_enthalpy(
backend: &dyn FluidBackend,
fluid: &str,
p: Pressure,
quality: VaporQuality,
) -> Result<Enthalpy, FluidError> {
// Get saturation properties at pressure P
let h_liquid = backend.sat_liquid_enthalpy(fluid, p)?;
let h_vapor = backend.sat_vapor_enthalpy(fluid, p)?;
// Linear interpolation in two-phase region
// h = h_l + x * (h_v - h_l)
let h = h_liquid.to_joules_per_kg()
+ quality.to_fraction() * (h_vapor.to_joules_per_kg() - h_liquid.to_joules_per_kg());
Ok(Enthalpy::from_joules_per_kg(h))
}
Note: This assumes FluidBackend has saturation methods. Check crates/fluids/src/lib.rs for available methods.
Fluid Validation
Reuse existing is_incompressible() from flow_junction.rs:
fn is_incompressible(fluid: &str) -> bool {
matches!(
fluid.to_lowercase().as_str(),
"water" | "glycol" | "brine" | "meg" | "peg"
)
}
For refrigerants, accept anything NOT incompressible (CoolProp handles validation).
Component Trait Implementation
impl Component for RefrigerantSource {
fn n_equations(&self) -> usize {
2 // P and h constraints
}
fn compute_residuals(
&self,
_state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
if residuals.len() < 2 {
return Err(ComponentError::InvalidResidualDimensions {
expected: 2,
actual: residuals.len(),
});
}
residuals[0] = self.outlet.pressure().to_pascals() - self.p_set_pa;
residuals[1] = self.outlet.enthalpy().to_joules_per_kg() - self.h_set_jkg;
Ok(())
}
fn jacobian_entries(
&self,
_state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
jacobian.add_entry(0, 0, 1.0);
jacobian.add_entry(1, 1, 1.0);
Ok(())
}
fn get_ports(&self) -> &[ConnectedPort] {
&[]
}
fn port_mass_flows(&self, _state: &StateSlice) -> Result<Vec<MassFlow>, ComponentError> {
Ok(vec![MassFlow::from_kg_per_s(0.0)])
}
fn port_enthalpies(&self, _state: &StateSlice) -> Result<Vec<Enthalpy>, ComponentError> {
Ok(vec![self.outlet.enthalpy()])
}
fn energy_transfers(&self, _state: &StateSlice) -> Option<(Power, Power)> {
Some((Power::from_watts(0.0), Power::from_watts(0.0)))
}
}
Equations Summary
RefrigerantSource (2 equations):
r_0 = P_{edge} - P_{set} = 0
r_1 = h_{edge} - h(P_{set}, x) = 0
RefrigerantSink (1 or 2 equations):
r_0 = P_{edge} - P_{back} = 0 \quad \text{(always)}
r_1 = h_{edge} - h(P_{back}, x) = 0 \quad \text{(if quality specified)}
Project Structure Notes
- File to create:
crates/components/src/refrigerant_boundary.rs - Export file:
crates/components/src/lib.rs(add module and re-export) - Test location: Inline in
refrigerant_boundary.rsunder#[cfg(test)] mod tests - Alignment: Follows existing pattern of
refrigerant_boundary.rs,flow_junction.rs
Dependencies
Requires Story 10-1 to be complete:
VaporQualitytype fromcrates/core/src/types.rsConcentration,VolumeFlow,RelativeHumiditynot needed for this story
Fluid Backend:
FluidBackendtrait fromentropyk_fluidscrate- May need to add
sat_liquid_enthalpy()andsat_vapor_enthalpy()methods if not present
Common LLM Mistakes to Avoid
- Don't use bare f64 for quality - Always use
VaporQualitytype - Don't copy-paste RefrigerantSource entirely - Refactor to share code if possible, or at least maintain consistency
- Don't forget backend dependency - Need
FluidBackendfor quality→enthalpy conversion - Don't skip fluid validation - Must reject incompressible fluids
- Don't forget energy_transfers - Must return
Some((0, 0))for boundary conditions - Don't forget port_mass_flows/enthalpies - Required for energy balance validation
- Don't panic on invalid input - Return
Result::Errinstead
Test Patterns
use approx::assert_relative_eq;
#[test]
fn test_refrigerant_source_quality_zero() {
let backend = CoolPropBackend::new();
let port = make_port("R410A", 8.5e5, 200_000.0);
let source = RefrigerantSource::new(
"R410A",
Pressure::from_pascals(8.5e5),
VaporQuality::SATURATED_LIQUID,
&backend,
port,
).unwrap();
// h_set should equal saturated liquid enthalpy at 8.5 bar
let h_sat_liq = backend.sat_liquid_enthalpy("R410A", Pressure::from_pascals(8.5e5)).unwrap();
assert_relative_eq!(source.h_set_jkg(), h_sat_liq.to_joules_per_kg(), epsilon = 1e-6);
}
#[test]
fn test_refrigerant_source_rejects_water() {
let backend = CoolPropBackend::new();
let port = make_port("Water", 1.0e5, 100_000.0);
let result = RefrigerantSource::new(
"Water",
Pressure::from_pascals(1.0e5),
VaporQuality::from_fraction(0.5),
&backend,
port,
);
assert!(result.is_err());
}
References
- [Source: crates/components/src/refrigerant_boundary.rs] - Existing RefrigerantSource/RefrigerantSink pattern to follow
- [Source: crates/components/src/flow_junction.rs:20-30] -
is_incompressible()function - [Source: architecture.md#L476-L506] - NewType pattern rationale
- [Source: architecture.md#L357-L404] - Error handling with ThermoError
- [Source: crates/core/src/types.rs] - VaporQuality type (Story 10-1)
- [Source: epic-10-enhanced-boundary-conditions.md] - Epic context and objectives
Downstream Dependencies
- Story 10-3 (BrineSource/Sink) follows similar pattern
- Story 10-4 (AirSource/Sink) follows similar pattern
- Story 10-5 (Migration) will deprecate old
RefrigerantSource::new()in favor ofRefrigerantSource
Dev Agent Record
Agent Model Used
zai-anthropic/glm-5
Debug Log References
None
Completion Notes List
- Created
crates/components/src/refrigerant_boundary.rswithRefrigerantSourceandRefrigerantSinkstructs - Used
VaporQualitytype fromentropyk_corefor type-safe quality specification - Implemented
FluidBackendintegration usingFluidState::PressureQuality(P, Quality)for enthalpy conversion - Fluid validation rejects incompressible fluids (Water, Glycol, Brine, MEG, PEG)
- Created
MockRefrigerantBackendfor unit testing (supportsPressureQualitystate) - All 24 unit tests pass
- Module exported in
lib.rs
File List
crates/components/src/refrigerant_boundary.rs(created)crates/components/src/lib.rs(modified)
Senior Developer Review (AI)
Review Date: 2026-02-23
Issues Found: 3 HIGH, 4 MEDIUM, 3 LOW
Issues Fixed:
-
[HIGH] Missing doc comments - Added comprehensive documentation with LaTeX equations for:
RefrigerantSourceandRefrigerantSinkstructs- All public methods with
# Arguments,# Errors,# Examplesections - Module-level documentation with design philosophy
-
[MEDIUM] Unused imports in test module - Removed unused
TestBackendandQualityimports -
[MEDIUM] Tracing not available - Removed
debug!()macro calls sincetracingcrate is not in Cargo.toml -
[LOW] Removed Debug/Clone derives - Removed
#[derive(Debug, Clone)]sinceArc<dyn FluidBackend>doesn't implementDebug
Remaining Issues (Deferred):
- [MEDIUM] get_ports() returns empty slice - Same pattern as existing
RefrigerantSource/RefrigerantSink. Should be addressed consistently across all boundary components. - [MEDIUM] No integration test with real CoolPropBackend - MockRefrigerantBackend is sufficient for unit tests. Integration tests would require CoolProp linking fix.
Verification:
- All 24 unit tests pass
cargo test --package entropyk-componentspasses- Pre-existing CoolProp linking issues prevent full workspace test (not related to this story)