diff --git a/docs/docs/about/migration_guides/mechanical_efficiency.md b/docs/docs/about/migration_guides/mechanical_efficiency.md new file mode 100644 index 0000000000..3c929f5d9d --- /dev/null +++ b/docs/docs/about/migration_guides/mechanical_efficiency.md @@ -0,0 +1,90 @@ +--- +title: Mechanical Efficiency Migration +description: Migrating from POWER_ADJUSTMENT_FACTOR to SHAFT with MECHANICAL_EFFICIENCY +sidebar_position: -16 +--- + +# Migrating to SHAFT with MECHANICAL_EFFICIENCY + +This guide explains how to migrate from the deprecated `POWER_ADJUSTMENT_FACTOR` and `POWER_ADJUSTMENT_CONSTANT` parameters to the new `SHAFT` model with `MECHANICAL_EFFICIENCY`. + +## Why Migrate? + +The legacy power adjustment parameters were empirical corrections without clear physical meaning. The new approach provides: + +- **Physical clarity**: Mechanical efficiency represents real-world losses in bearings, gearboxes, and seals +- **Better documentation**: Efficiency values can be traced to equipment specifications +- **Future compatibility**: Legacy parameters will be removed in a future version + +## Conversion Formula + +$$ +\eta_{mechanical} = \frac{1}{\text{POWER\_ADJUSTMENT\_FACTOR}} +$$ + +For example: `POWER_ADJUSTMENT_FACTOR: 1.05` → `MECHANICAL_EFFICIENCY: 0.952` + +:::note +`POWER_ADJUSTMENT_CONSTANT` has no direct equivalent. If you used a constant offset, consult the eCalc team. +::: + +## Migration Examples + +### Common-Shaft Trains + +For `VARIABLE_SPEED_COMPRESSOR_TRAIN`, `SINGLE_SPEED_COMPRESSOR_TRAIN`, and `VARIABLE_SPEED_COMPRESSOR_TRAIN_MULTIPLE_STREAMS_AND_PRESSURES`: + +**Before:** +```yaml +- NAME: export_compressor + TYPE: VARIABLE_SPEED_COMPRESSOR_TRAIN + POWER_ADJUSTMENT_FACTOR: 1.05 + ... +``` + +**After:** +```yaml +- NAME: export_shaft + TYPE: SHAFT + MECHANICAL_EFFICIENCY: 0.952 + +- NAME: export_compressor + TYPE: VARIABLE_SPEED_COMPRESSOR_TRAIN + SHAFT: export_shaft + ... +``` + +:::note +Each `SHAFT` can only be used by one compressor train. Create separate shaft models if you have multiple trains. +::: + +### Simplified Trains + +For `SIMPLIFIED_VARIABLE_SPEED_COMPRESSOR_TRAIN`, set `MECHANICAL_EFFICIENCY` directly on the train: + +**Before:** +```yaml +- NAME: simple_train + TYPE: SIMPLIFIED_VARIABLE_SPEED_COMPRESSOR_TRAIN + POWER_ADJUSTMENT_FACTOR: 1.08 + ... +``` + +**After:** +```yaml +- NAME: simple_train + TYPE: SIMPLIFIED_VARIABLE_SPEED_COMPRESSOR_TRAIN + MECHANICAL_EFFICIENCY: 0.926 + ... +``` + +## Troubleshooting + +**Cannot specify both SHAFT and POWER_ADJUSTMENT_FACTOR**: Remove the legacy parameter when using `SHAFT` or `MECHANICAL_EFFICIENCY`. + +**SHAFT is referenced by multiple trains**: Each shaft can only be used by one train. Create separate shaft models. + +## Further Reading + +- [SHAFT Reference](/about/references/SHAFT.md) +- [MECHANICAL_EFFICIENCY Reference](/about/references/MECHANICAL_EFFICIENCY.md) diff --git a/docs/docs/about/modelling/setup/installations/compressor_models_in_calculations/variable_speed_compressor_train_model_with_multiple_streams_and_pressures.md b/docs/docs/about/modelling/setup/installations/compressor_models_in_calculations/variable_speed_compressor_train_model_with_multiple_streams_and_pressures.md index 0c17d07371..d44c4e0452 100644 --- a/docs/docs/about/modelling/setup/installations/compressor_models_in_calculations/variable_speed_compressor_train_model_with_multiple_streams_and_pressures.md +++ b/docs/docs/about/modelling/setup/installations/compressor_models_in_calculations/variable_speed_compressor_train_model_with_multiple_streams_and_pressures.md @@ -26,7 +26,8 @@ ENERGY_USAGE_MODEL: SUCTION_PRESSURE: DISCHARGE_PRESSURE: INTERSTAGE_CONTROL_PRESSURE: - POWER_ADJUSTMENT_CONSTANT: + SHAFT: + POWER_ADJUSTMENT_CONSTANT: ~~~~~~~~ The number of elements in [RATE_PER_STREAM](/about/references/RATE_PER_STREAM.md) must correspond to the number of streams defined for the model referenced in diff --git a/docs/docs/about/modelling/setup/models/compressor_modelling/compressor_models_types/simplified_variable_speed_compressor_train_model.md b/docs/docs/about/modelling/setup/models/compressor_modelling/compressor_models_types/simplified_variable_speed_compressor_train_model.md index c8b6360bf6..7fe80747bb 100644 --- a/docs/docs/about/modelling/setup/models/compressor_modelling/compressor_models_types/simplified_variable_speed_compressor_train_model.md +++ b/docs/docs/about/modelling/setup/models/compressor_modelling/compressor_models_types/simplified_variable_speed_compressor_train_model.md @@ -27,11 +27,12 @@ stages are calculated at run-time based on input data. MODELS: - NAME: TYPE: SIMPLIFIED_VARIABLE_SPEED_COMPRESSOR_TRAIN - FLUID_MODEL: COMPRESSOR_TRAIN: - POWER_ADJUSTMENT_CONSTANT: + MECHANICAL_EFFICIENCY: 0 and ≤ 1.> + POWER_ADJUSTMENT_CONSTANT: MAXIMUM_POWER: - CALCULATE_MAX_RATE: + CALCULATE_MAX_RATE: ~~~~~~~~ ### Simplified compressor train model with known compressor stages @@ -42,6 +43,7 @@ MODELS: - NAME: TYPE: SIMPLIFIED_VARIABLE_SPEED_COMPRESSOR_TRAIN FLUID_MODEL: + MECHANICAL_EFFICIENCY: COMPRESSOR_TRAIN: STAGES: - INLET_TEMPERATURE: @@ -49,7 +51,6 @@ MODELS: - INLET_TEMPERATURE: COMPRESSOR_CHART: - ... and so forth for each stage in the train - POWER_ADJUSTMENT_CONSTANT: MAXIMUM_POWER: ~~~~~~~~ @@ -68,11 +69,11 @@ MODELS: - NAME: TYPE: SIMPLIFIED_VARIABLE_SPEED_COMPRESSOR_TRAIN FLUID_MODEL: + MECHANICAL_EFFICIENCY: COMPRESSOR_TRAIN: MAXIMUM_PRESSURE_RATIO_PER_STAGE: COMPRESSOR_CHART: INLET_TEMPERATURE: - POWER_ADJUSTMENT_CONSTANT: ~~~~~~~~ ## Examples diff --git a/docs/docs/about/modelling/setup/models/compressor_modelling/compressor_models_types/single_speed_compressor_train_model.md b/docs/docs/about/modelling/setup/models/compressor_modelling/compressor_models_types/single_speed_compressor_train_model.md index 8750f13148..2e79453800 100644 --- a/docs/docs/about/modelling/setup/models/compressor_modelling/compressor_models_types/single_speed_compressor_train_model.md +++ b/docs/docs/about/modelling/setup/models/compressor_modelling/compressor_models_types/single_speed_compressor_train_model.md @@ -24,10 +24,11 @@ This means that a single speed compressor model needs the following to be define The following keywords are optional for a single speed compressor model: +- [SHAFT](/about/references/SHAFT.md) - Reference to a shaft model with mechanical efficiency - [MAXIMUM_DISCHARGE_PRESSURE](/about/references/MAXIMUM_DISCHARGE_PRESSURE.md) -- [POWER_ADJUSTMENT_CONSTANT](/about/references/POWER_ADJUSTMENT_CONSTANT.md) - [MAXIMUM_POWER](/about/references/MAXIMUM_POWER.md) - [CALCULATE_MAX_RATE](/about/references/CALCULATE_MAX_RATE.md) +- [POWER_ADJUSTMENT_CONSTANT](/about/references/POWER_ADJUSTMENT_CONSTANT.md) *(Deprecated - use SHAFT instead)* The model is defined under the main keyword [MODELS](/about/references/MODELS.md) in the format @@ -53,7 +54,8 @@ MODELS: CONTROL_MARGIN_UNIT: PRESSURE_DROP_AHEAD_OF_STAGE: - ... and so forth for each stage in the train - POWER_ADJUSTMENT_CONSTANT: + SHAFT: MAXIMUM_POWER: + POWER_ADJUSTMENT_CONSTANT: CALCULATE_MAX_RATE: ~~~~~~~~ diff --git a/docs/docs/about/modelling/setup/models/compressor_modelling/compressor_models_types/variable_speed_compressor_train_model.md b/docs/docs/about/modelling/setup/models/compressor_modelling/compressor_models_types/variable_speed_compressor_train_model.md index 8a3757d924..db53a85d7d 100644 --- a/docs/docs/about/modelling/setup/models/compressor_modelling/compressor_models_types/variable_speed_compressor_train_model.md +++ b/docs/docs/about/modelling/setup/models/compressor_modelling/compressor_models_types/variable_speed_compressor_train_model.md @@ -58,8 +58,9 @@ MODELS: PRESSURE_DROP_AHEAD_OF_STAGE: CONTROL_MARGIN_UNIT: - ... and so forth for each stage in the train - POWER_ADJUSTMENT_CONSTANT: + SHAFT: MAXIMUM_POWER: + POWER_ADJUSTMENT_CONSTANT: CALCULATE_MAX_RATE: PRESSURE_CONTROL: ~~~~~~~~ diff --git a/docs/docs/about/modelling/setup/models/compressor_modelling/compressor_models_types/variable_speed_compressor_train_model_with_multiple_streams_and_pressures.md b/docs/docs/about/modelling/setup/models/compressor_modelling/compressor_models_types/variable_speed_compressor_train_model_with_multiple_streams_and_pressures.md index a143e9f4c0..9b81f10de5 100644 --- a/docs/docs/about/modelling/setup/models/compressor_modelling/compressor_models_types/variable_speed_compressor_train_model_with_multiple_streams_and_pressures.md +++ b/docs/docs/about/modelling/setup/models/compressor_modelling/compressor_models_types/variable_speed_compressor_train_model_with_multiple_streams_and_pressures.md @@ -53,6 +53,7 @@ MODELS: CONTROL_MARGIN_UNIT: PRESSURE_DROP_AHEAD_OF_STAGE: - ... + SHAFT: MAXIMUM_POWER: ~~~~~~~~ diff --git a/docs/docs/about/references/MECHANICAL_EFFICIENCY.md b/docs/docs/about/references/MECHANICAL_EFFICIENCY.md new file mode 100644 index 0000000000..bdd6e57755 --- /dev/null +++ b/docs/docs/about/references/MECHANICAL_EFFICIENCY.md @@ -0,0 +1,77 @@ +# MECHANICAL_EFFICIENCY + +[MODELS](/about/references/MODELS.md) / +[SHAFT](/about/references/SHAFT.md) / +[MECHANICAL_EFFICIENCY](/about/references/MECHANICAL_EFFICIENCY.md) + +## Description + +`MECHANICAL_EFFICIENCY` defines the ratio of useful mechanical work output to the total mechanical work input for a shaft system. It accounts for power losses in bearings, gearboxes, seals, and couplings. + +The mechanical efficiency is used in a [SHAFT](/about/references/SHAFT.md) model to calculate the shaft power required from the driver: + +$$ +P_{shaft} = \frac{P_{gas}}{\eta_{mechanical}} +$$ + +## Format + +~~~~yaml +MODELS: + - NAME: + TYPE: SHAFT + MECHANICAL_EFFICIENCY: +~~~~ + +:::note Simplified trains +For `SIMPLIFIED_VARIABLE_SPEED_COMPRESSOR_TRAIN`, `MECHANICAL_EFFICIENCY` is set directly on the train model (not via a separate SHAFT). It applies uniformly to all stages and defaults to 1.0 if not specified. +::: + +## Example + +~~~~yaml +MODELS: + - NAME: compressor_shaft + TYPE: SHAFT + MECHANICAL_EFFICIENCY: 0.95 # 95% efficiency, 5% losses +~~~~ + +## Value Range + +| Constraint | Value | Description | +|------------|-------|-------------| +| Minimum | > 0 | Must be positive (exclusive) | +| Maximum | ≤ 1.0 | Cannot exceed 100% efficiency | + +### Typical Values + +| Drive Configuration | Typical Range | Typical Losses | Notes | +|---------------------|---------------|----------------|-------| +| Direct drive | 0.96 - 0.98 | 2-4% | Bearings (1-2%) + seals/couplings (1-2%) | +| With gearbox | 0.93 - 0.96 | 4-7% | Adds gearbox losses (2-3%) | + +:::note +These are approximate ranges for the **total** mechanical efficiency of the drive train. The actual value depends on equipment design, operating conditions, and manufacturer data. A typical default of ~0.95 (5% losses) covers a common configuration with gearbox. +::: + +## Physical Interpretation + +The mechanical efficiency accounts for power losses in the drivetrain between +the driver (motor/turbine) and the compressor, including: + +- **Bearing losses**: Friction in shaft support bearings +- **Gearbox losses**: Friction and churning in gear systems (if present) +- **Seal and coupling losses**: Minor friction in auxiliary components + +:::note Constant efficiency assumption +In reality, frictional losses scale approximately with the square of rotational speed. +However, eCalc assumes a **constant** mechanical efficiency across all operating points. +Choose a representative value for typical operating conditions, or consult vendor data +for the expected speed range. +::: + +## See Also + +- [SHAFT](/about/references/SHAFT.md) +- [EFFICIENCY](/about/references/EFFICIENCY.md) +- [POLYTROPIC_EFFICIENCY](/about/references/POLYTROPIC_EFFICIENCY.md) diff --git a/docs/docs/about/references/POWER_ADJUSTMENT_CONSTANT.md b/docs/docs/about/references/POWER_ADJUSTMENT_CONSTANT.md index 4f17b6a631..574148fa58 100644 --- a/docs/docs/about/references/POWER_ADJUSTMENT_CONSTANT.md +++ b/docs/docs/about/references/POWER_ADJUSTMENT_CONSTANT.md @@ -3,6 +3,10 @@ [MODELS](/about/references/MODELS.md) / [POWER_ADJUSTMENT_CONSTANT](/about/references/POWER_ADJUSTMENT_CONSTANT.md) +:::warning Deprecated +This parameter is deprecated and will be removed in a future version. Use [SHAFT](/about/references/SHAFT.md) with [MECHANICAL_EFFICIENCY](/about/references/MECHANICAL_EFFICIENCY.md) instead for a physically meaningful way to model mechanical losses. +::: + ## Description Optional constant MW adjustment added to the model. Only added if (electrical) POWER > 0. Can be used in combination with [POWER_ADJUSTMENT_FACTOR](/about/references/POWER_ADJUSTMENT_FACTOR.md). @@ -18,6 +22,7 @@ MODELS: ## Example +**Deprecated usage:** ~~~~~yaml MODELS: - NAME: simple_compressor @@ -26,11 +31,4 @@ MODELS: POWER_ADJUSTMENT_CONSTANT: 10 #MW ~~~~~ -~~~~~yaml -MODELS: - - NAME: simple_compressor - TYPE: SIMPLIFIED_VARIABLE_SPEED_COMPRESSOR_TRAIN - ... - POWER_ADJUSTMENT_CONSTANT: 10 #MW - POWER_ADJUSTMENT_FACTOR: 1.2 -~~~~~ \ No newline at end of file +**Recommended migration:** See the [Mechanical Efficiency Migration Guide](/about/migration_guides/mechanical_efficiency.md). \ No newline at end of file diff --git a/docs/docs/about/references/POWER_ADJUSTMENT_FACTOR.md b/docs/docs/about/references/POWER_ADJUSTMENT_FACTOR.md index dd40a017b7..f18818c40e 100644 --- a/docs/docs/about/references/POWER_ADJUSTMENT_FACTOR.md +++ b/docs/docs/about/references/POWER_ADJUSTMENT_FACTOR.md @@ -3,6 +3,10 @@ [MODELS](/about/references/MODELS.md) / [POWER_ADJUSTMENT_FACTOR](/about/references/POWER_ADJUSTMENT_FACTOR.md) +:::warning Deprecated +This parameter is deprecated and will be removed in a future version. Use [SHAFT](/about/references/SHAFT.md) with [MECHANICAL_EFFICIENCY](/about/references/MECHANICAL_EFFICIENCY.md) instead for a physically meaningful way to model mechanical losses. +::: + ## Description Optional factor adjusting the power in the model. The power is multiplied by this factor. Can be used in combination with [POWER_ADJUSTMENT_CONSTANT](/about/references/POWER_ADJUSTMENT_CONSTANT.md). @@ -18,6 +22,7 @@ MODELS: ## Example +**Deprecated usage:** ~~~~~yaml MODELS: - NAME: simple_compressor @@ -26,11 +31,4 @@ MODELS: POWER_ADJUSTMENT_FACTOR: 1.2 ~~~~~ -~~~~~yaml -MODELS: - - NAME: simple_compressor - TYPE: SIMPLIFIED_VARIABLE_SPEED_COMPRESSOR_TRAIN - ... - POWER_ADJUSTMENT_CONSTANT: 10 #MW - POWER_ADJUSTMENT_FACTOR: 1.2 -~~~~~ +**Recommended migration:** See the [Mechanical Efficiency Migration Guide](/about/migration_guides/mechanical_efficiency.md). diff --git a/docs/docs/about/references/SHAFT.md b/docs/docs/about/references/SHAFT.md new file mode 100644 index 0000000000..c8b3c461d1 --- /dev/null +++ b/docs/docs/about/references/SHAFT.md @@ -0,0 +1,121 @@ +# SHAFT + +[MODELS](/about/references/MODELS.md) / +[SHAFT](/about/references/SHAFT.md) + +## Description + +A `SHAFT` model represents the mechanical coupling between a compressor and its driver (turbine, electric motor, etc.). It captures mechanical losses that occur in bearings, gearboxes, seals, and couplings. + +The shaft model uses `MECHANICAL_EFFICIENCY` (η) to convert between gas power (thermodynamic power delivered to the gas) and shaft power (mechanical power required from the driver): + +$$ +P_{shaft} = \frac{P_{gas}}{\eta_{mechanical}} +$$ + +Where: +- $P_{shaft}$ is the shaft power (input from driver) in MW +- $P_{gas}$ is the gas power (thermodynamic work on gas) in MW +- $\eta_{mechanical}$ is the mechanical efficiency (0 < η ≤ 1) + +### Physical Interpretation + +- **η = 1.0**: No mechanical losses (ideal shaft) - all driver power goes to gas compression +- **η = 0.95**: 5% of shaft power delivered by the turbine/motor is lost to friction (bearings, gearbox, seals, etc.) +- **η = 0.93**: 7% mechanical losses + +:::tip +Use SHAFT with MECHANICAL_EFFICIENCY instead of the deprecated POWER_ADJUSTMENT_FACTOR and POWER_ADJUSTMENT_CONSTANT parameters. The SHAFT model provides a physically meaningful way to account for mechanical losses. +::: + +## Format + +~~~~yaml +MODELS: + - NAME: + TYPE: SHAFT + MECHANICAL_EFFICIENCY: +~~~~ + +## Example + +### Basic Shaft Definition + +~~~~yaml +MODELS: + - NAME: main_compressor_shaft + TYPE: SHAFT + MECHANICAL_EFFICIENCY: 0.95 # 5% mechanical losses +~~~~ + +### Using Shaft with Compressor Train + +~~~~yaml +MODELS: + - NAME: compressor_shaft + TYPE: SHAFT + MECHANICAL_EFFICIENCY: 0.97 + + - NAME: export_compressor + TYPE: VARIABLE_SPEED_COMPRESSOR_TRAIN + FLUID_MODEL: fluid_model_ref + SHAFT: compressor_shaft # Reference to shaft model + COMPRESSOR_TRAIN: + STAGES: + - INLET_TEMPERATURE: 30 + COMPRESSOR_CHART: compressor_chart_ref +~~~~ + +## Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| NAME | string | Yes | - | Unique name for the shaft model | +| TYPE | string | Yes | - | Must be `SHAFT` | +| MECHANICAL_EFFICIENCY | number | Yes | - | Mechanical efficiency (0 < η ≤ 1). See [MECHANICAL_EFFICIENCY](/about/references/MECHANICAL_EFFICIENCY.md) for typical values. | + +## Constraints + +- `MECHANICAL_EFFICIENCY` must be greater than 0 and less than or equal to 1 +- Each `SHAFT` model can only be referenced by one compressor train (physical constraint: one shaft cannot drive multiple independent trains) + +## Migration from Legacy Parameters + +If you're currently using `POWER_ADJUSTMENT_FACTOR`, you can migrate to the `SHAFT` model: + +### Before (deprecated) +~~~~yaml +MODELS: + - NAME: my_compressor + TYPE: VARIABLE_SPEED_COMPRESSOR_TRAIN + POWER_ADJUSTMENT_FACTOR: 1.05 # 5% power increase + ... +~~~~ + +### After (recommended) +~~~~yaml +MODELS: + - NAME: my_shaft + TYPE: SHAFT + MECHANICAL_EFFICIENCY: 0.952 # ~5% mechanical losses (1/1.05 ≈ 0.952) + + - NAME: my_compressor + TYPE: VARIABLE_SPEED_COMPRESSOR_TRAIN + SHAFT: my_shaft + ... +~~~~ + +:::note +The relationship between the old factor and new efficiency is: +$$\eta_{mechanical} = \frac{1}{\text{POWER\_ADJUSTMENT\_FACTOR}}$$ +::: + +### POWER_ADJUSTMENT_CONSTANT + +Migration from `POWER_ADJUSTMENT_CONSTANT` is not straightforward, as it was used for various purposes (calibration bias, fixed auxiliary loads, etc.) that don't map directly to mechanical efficiency. If you're using this parameter, please review your model to determine the original intent and contact eCalc support for migration guidance. + +## See Also + +- [COMPRESSOR_TRAIN_MODEL](/about/references/COMPRESSOR_TRAIN_MODEL.md) +- [MECHANICAL_EFFICIENCY](/about/references/MECHANICAL_EFFICIENCY.md) +- [POWER_ADJUSTMENT_FACTOR](/about/references/POWER_ADJUSTMENT_FACTOR.md) (deprecated) diff --git a/src/libecalc/domain/process/compressor/core/results.py b/src/libecalc/domain/process/compressor/core/results.py index fc66aef38c..564d932d23 100644 --- a/src/libecalc/domain/process/compressor/core/results.py +++ b/src/libecalc/domain/process/compressor/core/results.py @@ -24,6 +24,11 @@ class CompressorTrainStageResultSingleTimeStep: head [J/kg] Polytropic efficiency (0, 1] power [MW] + + Power terminology: + - gas_power_megawatt: Thermodynamic power transferred to the gas (from enthalpy change) + - shaft_power_megawatt: Mechanical power on the shaft = gas_power / mechanical_efficiency + - power_megawatt: Shaft power (for backward compatibility, equals shaft_power_megawatt) """ def __init__( @@ -43,6 +48,8 @@ def __init__( rate_exceeds_maximum: bool | None = None, pressure_is_choked: bool | None = None, head_exceeds_maximum: bool | None = None, + gas_power_megawatt: float | None = None, + shaft_power_megawatt: float | None = None, ): self.inlet_stream = inlet_stream self.outlet_stream = outlet_stream @@ -59,6 +66,10 @@ def __init__( self.pressure_is_choked = pressure_is_choked self.head_exceeds_maximum = head_exceeds_maximum self.point_is_valid = bool(point_is_valid) + # New power fields for gas/shaft power distinction + # Default to power_megawatt for backward compatibility if not specified + self.gas_power_megawatt = gas_power_megawatt if gas_power_megawatt is not None else power_megawatt + self.shaft_power_megawatt = shaft_power_megawatt if shaft_power_megawatt is not None else power_megawatt @classmethod def create_empty(cls) -> CompressorTrainStageResultSingleTimeStep: @@ -78,6 +89,8 @@ def create_empty(cls) -> CompressorTrainStageResultSingleTimeStep: pressure_is_choked=False, head_exceeds_maximum=False, point_is_valid=True, + gas_power_megawatt=0.0, + shaft_power_megawatt=0.0, ) @property diff --git a/src/libecalc/domain/process/compressor/core/train/stage.py b/src/libecalc/domain/process/compressor/core/train/stage.py index 73efc3c30d..73868d8d24 100644 --- a/src/libecalc/domain/process/compressor/core/train/stage.py +++ b/src/libecalc/domain/process/compressor/core/train/stage.py @@ -69,6 +69,16 @@ def __init__( def fluid_service(self) -> FluidService: return self._fluid_service + @property + def shaft(self): + """Get shaft from compressor (delegate to compressor).""" + return self.compressor.shaft + + @property + def mechanical_efficiency(self) -> float: + """Get mechanical efficiency from shaft, defaulting to 1.0 if no shaft.""" + return self.shaft.mechanical_efficiency if self.shaft else 1.0 + @property def remove_liquid_after_cooling(self) -> bool: return self.liquid_remover is not None @@ -187,10 +197,13 @@ def evaluate( outlet_stream_compressor = self.rate_modifier.remove_rate(outlet_stream_compressor_including_asv) enthalpy_change = operational_point.polytropic_head_joule_per_kg / operational_point.polytropic_efficiency - power_megawatt = calculate_power_in_megawatt( + # Gas power: thermodynamic power transferred to the gas + gas_power_megawatt = calculate_power_in_megawatt( enthalpy_change_joule_per_kg=enthalpy_change, mass_rate_kg_per_hour=inlet_stream_compressor_including_asv.mass_rate_kg_per_h, ) + # Shaft power: mechanical power on shaft = gas_power / mechanical_efficiency + shaft_power_megawatt = gas_power_megawatt / self.mechanical_efficiency return CompressorTrainStageResultSingleTimeStep( inlet_stream=inlet_stream_compressor, @@ -201,7 +214,9 @@ def evaluate( polytropic_efficiency=operational_point.polytropic_efficiency, chart_area_flag=chart_area_flag, polytropic_enthalpy_change_kJ_per_kg=enthalpy_change / 1000, - power_megawatt=power_megawatt, + power_megawatt=shaft_power_megawatt, # Backward compatible: power = shaft power + gas_power_megawatt=gas_power_megawatt, + shaft_power_megawatt=shaft_power_megawatt, point_is_valid=operational_point.is_valid, polytropic_enthalpy_change_before_choke_kJ_per_kg=enthalpy_change / 1000, ) diff --git a/src/libecalc/domain/process/entities/process_units/compressor/compressor.py b/src/libecalc/domain/process/entities/process_units/compressor/compressor.py index 86ab170a5b..a8b8a57afe 100644 --- a/src/libecalc/domain/process/entities/process_units/compressor/compressor.py +++ b/src/libecalc/domain/process/entities/process_units/compressor/compressor.py @@ -21,6 +21,12 @@ class OperationalPoint: class Compressor: def __init__(self, compressor_chart: ChartData, fluid_service: FluidService, shaft: Shaft): + """ + Args: + compressor_chart: Chart data for the compressor + fluid_service: Fluid service for thermodynamic calculations + shaft: Shaft with mechanical efficiency (required - mapper creates implicit shaft if needed) + """ self._compressor_chart = CompressorChart(compressor_chart) self._fluid_service = fluid_service self._shaft = shaft @@ -57,7 +63,8 @@ def chart_area_flag(self) -> ChartAreaFlag | None: return self._chart_area_flag @property - def shaft(self) -> Shaft | None: + def shaft(self) -> Shaft: + """The shaft this compressor is mounted on.""" return self._shaft @property diff --git a/src/libecalc/domain/process/entities/shaft/shaft.py b/src/libecalc/domain/process/entities/shaft/shaft.py index 2fe552a18a..d4c831a1a0 100644 --- a/src/libecalc/domain/process/entities/shaft/shaft.py +++ b/src/libecalc/domain/process/entities/shaft/shaft.py @@ -4,13 +4,27 @@ class Shaft(ABC): """Abstract base class for a shaft. - Can be expanded to include more properties and methods as needed. - Name, id, units connected to it, etc + A shaft is a physical rotating component that connects the driver (turbine/motor) + to a compressor. The shaft owns mechanical efficiency, which accounts for + losses in bearings, gearbox, and couplings. + + Attributes: + mechanical_efficiency: Fraction of shaft power that becomes gas power. + Constraint: 0 < η_mech ≤ 1. Default is 1.0 (no losses). + speed_rpm: The rotational speed of the shaft in RPM. """ - def __init__(self, speed_rpm: float | None = None): + def __init__(self, mechanical_efficiency: float = 1.0, speed_rpm: float | None = None): + if not (0 < mechanical_efficiency <= 1): + raise ValueError(f"Mechanical efficiency must be in the range (0, 1], got {mechanical_efficiency}") + self._mechanical_efficiency = mechanical_efficiency self._speed_rpm = speed_rpm + @property + def mechanical_efficiency(self) -> float: + """Fraction of shaft power that becomes gas power (0 < η ≤ 1).""" + return self._mechanical_efficiency + @abstractmethod def set_speed(self, value: float) -> None: pass @@ -29,6 +43,8 @@ def speed_is_defined(self) -> bool: class SingleSpeedShaft(Shaft): + """Shaft with fixed rotational speed. Once set, speed cannot be changed.""" + def set_speed(self, value: float): if self._speed_rpm is None: self._speed_rpm = value @@ -37,5 +53,7 @@ def set_speed(self, value: float): class VariableSpeedShaft(Shaft): + """Shaft with variable rotational speed. Speed can be changed at runtime.""" + def set_speed(self, value: float): self._speed_rpm = value diff --git a/src/libecalc/fixtures/cases/all_energy_usage_models/data/all_energy_usage_models.yaml b/src/libecalc/fixtures/cases/all_energy_usage_models/data/all_energy_usage_models.yaml index 66a3539b6a..fab34468a3 100644 --- a/src/libecalc/fixtures/cases/all_energy_usage_models/data/all_energy_usage_models.yaml +++ b/src/libecalc/fixtures/cases/all_energy_usage_models/data/all_energy_usage_models.yaml @@ -177,6 +177,7 @@ MODELS: TYPE: VARIABLE_SPEED_COMPRESSOR_TRAIN FLUID_MODEL: medium_gas PRESSURE_CONTROL: DOWNSTREAM_CHOKE + SHAFT: compressor_shaft # Uses mechanical efficiency instead of POWER_ADJUSTMENT_CONSTANT COMPRESSOR_TRAIN: STAGES: # stages for train with predefined number of compressors - INLET_TEMPERATURE: 30 @@ -187,7 +188,6 @@ MODELS: COMPRESSOR_CHART: predefined_compressor_chart CONTROL_MARGIN: 0 CONTROL_MARGIN_UNIT: PERCENTAGE # PERCENTAGE is default, FRACTION is other possible value - POWER_ADJUSTMENT_CONSTANT: 1 - NAME: variable_speed_compressor_train_predefined_variable_speed_chart_user_defined_fluid_composition TYPE: VARIABLE_SPEED_COMPRESSOR_TRAIN FLUID_MODEL: user_defined_fluid_composition @@ -208,6 +208,9 @@ MODELS: TURBINE_LOADS: [0, 2.352, 4.589, 6.853, 9.125, 11.399, 13.673, 15.947, 18.223, 20.496, 22.767] # MW TURBINE_EFFICIENCIES: [0, 0.138, 0.210, 0.255, 0.286, 0.310, 0.328, 0.342, 0.353, 0.360, 0.362] POWER_ADJUSTMENT_CONSTANT: 1 + - NAME: compressor_shaft + TYPE: SHAFT + MECHANICAL_EFFICIENCY: 0.95 # 5% mechanical losses (bearings, seals, couplings) - NAME: variable_speed_compressor_train_predefined_variable_speed_chart_user_defined_fluid_composition_with_turbine TYPE: COMPRESSOR_WITH_TURBINE COMPRESSOR_MODEL: variable_speed_compressor_train_predefined_variable_speed_chart_user_defined_fluid_composition diff --git a/src/libecalc/presentation/yaml/domain/reference_service.py b/src/libecalc/presentation/yaml/domain/reference_service.py index 2065f9fe69..065e352945 100644 --- a/src/libecalc/presentation/yaml/domain/reference_service.py +++ b/src/libecalc/presentation/yaml/domain/reference_service.py @@ -16,6 +16,7 @@ YamlCompressorChart, YamlCompressorWithTurbine, YamlFluidModel, + YamlShaft, YamlTurbine, ) from libecalc.presentation.yaml.yaml_types.models.yaml_compressor_trains import ( @@ -55,6 +56,9 @@ def get_fluid(self, reference: str) -> YamlFluidModel: ... @abc.abstractmethod def get_turbine(self, reference: str) -> YamlTurbine: ... + @abc.abstractmethod + def get_shaft(self, reference: str) -> YamlShaft: ... + @abc.abstractmethod def get_compressor_chart(self, reference: str) -> YamlCompressorChart: ... diff --git a/src/libecalc/presentation/yaml/mappers/consumer_function_mapper.py b/src/libecalc/presentation/yaml/mappers/consumer_function_mapper.py index 1b2d760e31..23c1bc1f08 100644 --- a/src/libecalc/presentation/yaml/mappers/consumer_function_mapper.py +++ b/src/libecalc/presentation/yaml/mappers/consumer_function_mapper.py @@ -352,6 +352,11 @@ def _create_compressor_train_stage( interstage_pressure_control: InterstagePressureControl | None = None, control_margin: float | None = None, ) -> CompressorTrainStage: + """Create a compressor train stage. + + Args: + shaft: Shaft with mechanical efficiency (required - mapper creates implicit shaft if needed). + """ chart_data = self._get_compressor_chart(compressor_chart_reference, control_margin) return CompressorTrainStage( @@ -378,6 +383,32 @@ def _create_compressor_train_stage( ), ) + def _create_shared_shaft( + self, + shaft_ref: str | None, + is_variable_speed: bool, + ): + """Create a Shaft domain object from YAML reference (shared by all compressors in train). + + Args: + shaft_ref: Name of the shaft model, or None for implicit shaft + is_variable_speed: Whether to create VariableSpeedShaft or SingleSpeedShaft + + Returns: + Shaft instance with mechanical efficiency + """ + if shaft_ref is None: + # Implicit shaft for backward compatibility + mechanical_efficiency = 1.0 + else: + shaft_model = self._reference_service.get_shaft(shaft_ref) + mechanical_efficiency = shaft_model.mechanical_efficiency + + if is_variable_speed: + return VariableSpeedShaft(mechanical_efficiency=mechanical_efficiency) + else: + return SingleSpeedShaft(mechanical_efficiency=mechanical_efficiency) + def _create_variable_speed_compressor_train( self, model: YamlVariableSpeedCompressorTrain ) -> tuple[CompressorTrainCommonShaft, FluidModel]: @@ -385,11 +416,16 @@ def _create_variable_speed_compressor_train( fluid_model = self._get_fluid_model(fluid_model_reference) train_spec = model.compressor_train - shaft = VariableSpeedShaft() # Get the fluid service singleton fluid_service = NeqSimFluidService.instance() + # Create shared shaft for all compressors in the train + shaft = self._create_shared_shaft( + shaft_ref=model.shaft, + is_variable_speed=True, + ) + # The stages are pre defined, known stages_data = train_spec.stages @@ -437,11 +473,16 @@ def _create_single_speed_compressor_train( fluid_model = self._get_fluid_model(fluid_model_reference) train_spec = model.compressor_train - shaft = SingleSpeedShaft() # Get the fluid service singleton fluid_service = NeqSimFluidService.instance() + # Create shared shaft for all compressors in the train + shaft = self._create_shared_shaft( + shaft_ref=model.shaft, + is_variable_speed=False, + ) + stages: list[CompressorTrainStage] = [ self._create_compressor_train_stage( compressor_chart_reference=stage.compressor_chart, @@ -537,7 +578,9 @@ def _create_simplified_model_with_prepared_stages( fluid_model = self._get_fluid_model(model.fluid_model) train_spec = model.compressor_train - shaft = SingleSpeedShaft() # Not used for simplified trains, but required by compressor + + # Get mechanical efficiency for per-stage shafts + mechanical_efficiency = model.mechanical_efficiency if isinstance(train_spec, YamlUnknownCompressorStages): assert operational_data is not None @@ -586,10 +629,13 @@ def _create_simplified_model_with_prepared_stages( [yaml_stage.inlet_temperature], input_unit=Unit.CELSIUS, )[0] + # Create per-stage shaft with train-level mechanical efficiency + # Simplified trains have independent stages, each with its own shaft + stage_shaft = VariableSpeedShaft(mechanical_efficiency=mechanical_efficiency) stages.append( CompressorTrainStage( - rate_modifier=RateModifier(chart, shaft=shaft), - compressor=Compressor(chart, fluid_service=fluid_service, shaft=shaft), + rate_modifier=RateModifier(chart, shaft=stage_shaft), + compressor=Compressor(chart, fluid_service=fluid_service, shaft=stage_shaft), temperature_setter=TemperatureSetter( required_temperature_kelvin=inlet_temperature_kelvin, fluid_service=fluid_service ), @@ -647,10 +693,13 @@ def _create_simplified_model_with_prepared_stages( stage_inlet_pressure = stage_outlet_pressure + # Create per-stage shaft with train-level mechanical efficiency + # Simplified trains have independent stages, each with its own shaft + stage_shaft = VariableSpeedShaft(mechanical_efficiency=mechanical_efficiency) stages.append( CompressorTrainStage( - rate_modifier=RateModifier(chart, shaft=shaft), - compressor=Compressor(chart, fluid_service=fluid_service, shaft=shaft), + rate_modifier=RateModifier(chart, shaft=stage_shaft), + compressor=Compressor(chart, fluid_service=fluid_service, shaft=stage_shaft), temperature_setter=TemperatureSetter( required_temperature_kelvin=inlet_temperature_kelvin, fluid_service=fluid_service ), @@ -674,11 +723,15 @@ def _create_variable_speed_compressor_train_multiple_streams_and_pressures( ) -> tuple[CompressorTrainCommonShaft, list[FluidModel | None]]: stream_references = {stream.name for stream in model.streams} - shaft = VariableSpeedShaft() - # Get the fluid service singleton fluid_service = NeqSimFluidService.instance() + # Create shared shaft for all compressors in the train + shaft = self._create_shared_shaft( + shaft_ref=model.shaft, + is_variable_speed=True, + ) + stream_to_stage_map: dict[str, int] = {} for stage_index, stage_config in enumerate(model.stages): for stream_reference in stage_config.stream or []: diff --git a/src/libecalc/presentation/yaml/yaml_keywords.py b/src/libecalc/presentation/yaml/yaml_keywords.py index 1981acb1b6..5a5061d60a 100644 --- a/src/libecalc/presentation/yaml/yaml_keywords.py +++ b/src/libecalc/presentation/yaml/yaml_keywords.py @@ -179,6 +179,11 @@ class EcalcYamlKeywords: models_power_adjustment_factor_mw = "POWER_ADJUSTMENT_FACTOR" models_maximum_power = "MAXIMUM_POWER" + # Shaft model + models_type_shaft = "SHAFT" + shaft = "SHAFT" + mechanical_efficiency = "MECHANICAL_EFFICIENCY" + facility_adjustment = "ADJUSTMENT" facility_adjustment_factor = "FACTOR" facility_adjustment_constant = "CONSTANT" diff --git a/src/libecalc/presentation/yaml/yaml_reference_service.py b/src/libecalc/presentation/yaml/yaml_reference_service.py index b9ad212108..72e395fd90 100644 --- a/src/libecalc/presentation/yaml/yaml_reference_service.py +++ b/src/libecalc/presentation/yaml/yaml_reference_service.py @@ -23,14 +23,27 @@ YamlCompressorChart, YamlConsumerModel, YamlFluidModel, + YamlShaft, YamlTurbine, ) +from libecalc.presentation.yaml.yaml_types.models.yaml_compressor_trains import ( + YamlSingleSpeedCompressorTrain, + YamlVariableSpeedCompressorTrain, + YamlVariableSpeedCompressorTrainMultipleStreamsAndPressures, +) from libecalc.presentation.yaml.yaml_types.models.yaml_enums import YamlModelType logger = logging.getLogger(__name__) YamlModel = YamlConsumerModel | YamlFacilityModel +# Types that support the shaft reference +YamlShaftSupportingTrain = ( + YamlSingleSpeedCompressorTrain + | YamlVariableSpeedCompressorTrain + | YamlVariableSpeedCompressorTrainMultipleStreamsAndPressures +) + ReferenceType = YamlModel | YamlFuelType # Some models are referenced by other models, for example a compressor model will reference compressor chart models @@ -41,6 +54,7 @@ YamlModelType.FLUID: 0, YamlModelType.COMPRESSOR_CHART: 0, YamlModelType.TURBINE: 0, + YamlModelType.SHAFT: 0, # Shaft is parsed before trains that reference it YamlModelType.SIMPLIFIED_VARIABLE_SPEED_COMPRESSOR_TRAIN: 1, YamlModelType.VARIABLE_SPEED_COMPRESSOR_TRAIN: 1, YamlModelType.SINGLE_SPEED_COMPRESSOR_TRAIN: 1, @@ -67,6 +81,37 @@ def _sort_models(models: Iterable[YamlModel]): return sorted(models, key=_model_parsing_order) +def _validate_shaft_single_usage(models: Iterable[YamlModel]) -> None: + """Validate that each SHAFT is referenced by at most one compressor train. + + A shaft physically connects a driver to compressors and cannot be shared between + independent trains. This validation enforces that constraint at the YAML level. + + Raises: + EcalcError: If a shaft is referenced by more than one compressor train. + """ + shaft_usage: dict[str, str] = {} # shaft_name -> train_name + + for model in models: + # Check if model is a train type that supports shaft references + if isinstance( + model, + YamlSingleSpeedCompressorTrain + | YamlVariableSpeedCompressorTrain + | YamlVariableSpeedCompressorTrainMultipleStreamsAndPressures, + ): + if hasattr(model, "shaft") and model.shaft is not None: + shaft_name = model.shaft + if shaft_name in shaft_usage: + raise EcalcError( + title="Invalid shaft reference", + message=f"SHAFT '{shaft_name}' is already used by train '{shaft_usage[shaft_name]}'. " + f"Each shaft can only be referenced by one compressor train. " + f"Train '{model.name}' cannot also use this shaft.", + ) + shaft_usage[shaft_name] = model.name + + Reference = str @@ -98,6 +143,9 @@ def __init__( self._references = references self._references_yaml_context = reference_yaml_context + # Validate shaft single-usage (each shaft can only be used by one train) + _validate_shaft_single_usage(configuration.models) + def get_yaml_path(self, reference: str) -> YamlPath: return self._references_yaml_context[reference] @@ -113,6 +161,12 @@ def get_turbine(self, reference: str) -> YamlTurbine: raise InvalidReferenceException("turbine model", reference) return model + def get_shaft(self, reference: str) -> YamlShaft: + model = self._resolve_yaml_reference(reference, "shaft model") + if not isinstance(model, YamlShaft): + raise InvalidReferenceException("shaft model", reference) + return model + def get_compressor_chart(self, reference: str) -> YamlCompressorChart: model = self._resolve_yaml_reference(reference, "compressor chart") if not isinstance(model, get_args(get_args(YamlCompressorChart)[0])): diff --git a/src/libecalc/presentation/yaml/yaml_types/models/__init__.py b/src/libecalc/presentation/yaml/yaml_types/models/__init__.py index 7959c46eb6..a8c545bd30 100644 --- a/src/libecalc/presentation/yaml/yaml_types/models/__init__.py +++ b/src/libecalc/presentation/yaml/yaml_types/models/__init__.py @@ -12,6 +12,7 @@ YamlCompressorWithTurbine, ) from libecalc.presentation.yaml.yaml_types.models.yaml_fluid import YamlFluidModel +from libecalc.presentation.yaml.yaml_types.models.yaml_shaft import YamlShaft from libecalc.presentation.yaml.yaml_types.models.yaml_turbine import YamlTurbine YamlConsumerModel = Annotated[ @@ -19,6 +20,7 @@ YamlCompressorChart, YamlCompressorWithTurbine, YamlFluidModel, + YamlShaft, YamlTurbine, YamlCompressorTrain, ], diff --git a/src/libecalc/presentation/yaml/yaml_types/models/model_reference_validation.py b/src/libecalc/presentation/yaml/yaml_types/models/model_reference_validation.py index 8a4d1d4a35..5f3f03c29f 100644 --- a/src/libecalc/presentation/yaml/yaml_types/models/model_reference_validation.py +++ b/src/libecalc/presentation/yaml/yaml_types/models/model_reference_validation.py @@ -207,3 +207,14 @@ def check_model_reference_wrapper(model_reference: Any, info: ValidationInfo): ) ), ] + +ShaftReference = Annotated[ + ModelName, + AfterValidator( + check_field_model_reference( + allowed_types=[ + YamlModelType.SHAFT, + ] + ) + ), +] diff --git a/src/libecalc/presentation/yaml/yaml_types/models/yaml_compressor_trains.py b/src/libecalc/presentation/yaml/yaml_types/models/yaml_compressor_trains.py index 8d54650336..e476f84d7d 100644 --- a/src/libecalc/presentation/yaml/yaml_types/models/yaml_compressor_trains.py +++ b/src/libecalc/presentation/yaml/yaml_types/models/yaml_compressor_trains.py @@ -1,3 +1,4 @@ +import logging from typing import Annotated, Literal, Union from pydantic import Field, model_validator @@ -8,6 +9,7 @@ from libecalc.presentation.yaml.yaml_types.models.model_reference import ModelName from libecalc.presentation.yaml.yaml_types.models.model_reference_validation import ( FluidModelReference, + ShaftReference, ) from libecalc.presentation.yaml.yaml_types.models.yaml_compressor_stages import ( YamlCompressorStage, @@ -22,6 +24,8 @@ YamlPressureControl, ) +logger = logging.getLogger(__name__) + class YamlCompressorTrainBase(YamlBase): name: ModelName = Field( @@ -65,15 +69,43 @@ class YamlSingleSpeedCompressorTrain(YamlCompressorTrainBase): ) power_adjustment_constant: float = Field( 0.0, - description="Constant to adjust power usage in MW", + description="[DEPRECATED] Constant to adjust power usage in MW. Use SHAFT with MECHANICAL_EFFICIENCY instead.", title="POWER_ADJUSTMENT_CONSTANT", ) power_adjustment_factor: float = Field( 1.0, - description="Factor to adjust power usage in MW", + description="[DEPRECATED] Factor to adjust power usage in MW. Use SHAFT with MECHANICAL_EFFICIENCY instead.", title="POWER_ADJUSTMENT_FACTOR", ) fluid_model: FluidModelReference = Field(..., description="Reference to a fluid model", title="FLUID_MODEL") + shaft: ShaftReference | None = Field( + None, + description="Reference to a SHAFT model that defines mechanical efficiency for this compressor train. " + "If not specified, an implicit shaft with mechanical efficiency of 1.0 is used.", + title="SHAFT", + ) + + @model_validator(mode="after") + def validate_shaft_and_legacy_params(self): + """Validate mutual exclusivity of SHAFT and legacy power adjustment parameters.""" + has_shaft = self.shaft is not None + has_legacy_factor = self.power_adjustment_factor != 1.0 + has_legacy_constant = self.power_adjustment_constant != 0.0 + + if has_shaft and (has_legacy_factor or has_legacy_constant): + raise ValueError( + f"Model '{self.name}': Cannot specify both SHAFT and POWER_ADJUSTMENT_FACTOR/POWER_ADJUSTMENT_CONSTANT. " + "Use SHAFT with MECHANICAL_EFFICIENCY instead of legacy power adjustment parameters." + ) + + if has_legacy_factor or has_legacy_constant: + logger.warning( + f"Model '{self.name}': POWER_ADJUSTMENT_FACTOR and POWER_ADJUSTMENT_CONSTANT are deprecated " + "and will be removed in a future version. Use SHAFT with MECHANICAL_EFFICIENCY instead. " + "See migration guide: https://equinor.github.io/ecalc/changelog/migration/mechanical-efficiency" + ) + + return self def to_dto(self): raise NotImplementedError @@ -103,15 +135,43 @@ class YamlVariableSpeedCompressorTrain(YamlCompressorTrainBase): ) power_adjustment_constant: float = Field( 0.0, - description="Constant to adjust power usage in MW", + description="[DEPRECATED] Constant to adjust power usage in MW. Use SHAFT with MECHANICAL_EFFICIENCY instead.", title="POWER_ADJUSTMENT_CONSTANT", ) power_adjustment_factor: float = Field( 1.0, - description="Factor to adjust power usage in MW", + description="[DEPRECATED] Factor to adjust power usage in MW. Use SHAFT with MECHANICAL_EFFICIENCY instead.", title="POWER_ADJUSTMENT_FACTOR", ) fluid_model: FluidModelReference = Field(..., description="Reference to a fluid model", title="FLUID_MODEL") + shaft: ShaftReference | None = Field( + None, + description="Reference to a SHAFT model that defines mechanical efficiency for this compressor train. " + "If not specified, an implicit shaft with mechanical efficiency of 1.0 is used.", + title="SHAFT", + ) + + @model_validator(mode="after") + def validate_shaft_and_legacy_params(self): + """Validate mutual exclusivity of SHAFT and legacy power adjustment parameters.""" + has_shaft = self.shaft is not None + has_legacy_factor = self.power_adjustment_factor != 1.0 + has_legacy_constant = self.power_adjustment_constant != 0.0 + + if has_shaft and (has_legacy_factor or has_legacy_constant): + raise ValueError( + f"Model '{self.name}': Cannot specify both SHAFT and POWER_ADJUSTMENT_FACTOR/POWER_ADJUSTMENT_CONSTANT. " + "Use SHAFT with MECHANICAL_EFFICIENCY instead of legacy power adjustment parameters." + ) + + if has_legacy_factor or has_legacy_constant: + logger.warning( + f"Model '{self.name}': POWER_ADJUSTMENT_FACTOR and POWER_ADJUSTMENT_CONSTANT are deprecated " + "and will be removed in a future version. Use SHAFT with MECHANICAL_EFFICIENCY instead. " + "See migration guide: https://equinor.github.io/ecalc/changelog/migration/mechanical-efficiency" + ) + + return self def to_dto(self): raise NotImplementedError @@ -135,6 +195,14 @@ class YamlSimplifiedVariableSpeedCompressorTrain(YamlCompressorTrainBase): title="CALCULATE_MAX_RATE", ) fluid_model: FluidModelReference = Field(..., description="Reference to a fluid model", title="FLUID_MODEL") + mechanical_efficiency: float = Field( + 1.0, + description="Mechanical efficiency of the compressors. Applied to each stage. " + "Value must be between 0 (exclusive) and 1 (inclusive). Typical values: 0.93-0.97.", + title="MECHANICAL_EFFICIENCY", + gt=0.0, + le=1.0, + ) power_adjustment_constant: float = Field( 0.0, description="Constant to adjust power usage in MW", @@ -149,6 +217,34 @@ class YamlSimplifiedVariableSpeedCompressorTrain(YamlCompressorTrainBase): def to_dto(self): raise NotImplementedError + @model_validator(mode="after") + def validate_mechanical_efficiency_and_legacy_params(self) -> "YamlSimplifiedVariableSpeedCompressorTrain": + """Validate mutual exclusivity between MECHANICAL_EFFICIENCY and legacy POWER_ADJUSTMENT_* params.""" + has_mechanical_efficiency = self.mechanical_efficiency != 1.0 + has_legacy_constant = self.power_adjustment_constant != 0.0 + has_legacy_factor = self.power_adjustment_factor != 1.0 + + # Mutual exclusivity check + if has_mechanical_efficiency and (has_legacy_constant or has_legacy_factor): + raise ValueError( + "MECHANICAL_EFFICIENCY cannot be used together with POWER_ADJUSTMENT_CONSTANT or " + "POWER_ADJUSTMENT_FACTOR. Use MECHANICAL_EFFICIENCY instead of the deprecated parameters." + ) + + # Deprecation warnings for legacy params + if has_legacy_constant: + logger.warning( + f"POWER_ADJUSTMENT_CONSTANT is deprecated for '{self.name}'. " + f"Use MECHANICAL_EFFICIENCY instead. See migration guide for conversion." + ) + if has_legacy_factor: + logger.warning( + f"POWER_ADJUSTMENT_FACTOR is deprecated for '{self.name}'. " + f"Use MECHANICAL_EFFICIENCY (= 1 / POWER_ADJUSTMENT_FACTOR) instead." + ) + + return self + @model_validator(mode="after") def check_compressor_chart(self, info: ValidationInfo): if info.context is not None: @@ -222,14 +318,20 @@ class YamlVariableSpeedCompressorTrainMultipleStreamsAndPressures(YamlCompressor ) power_adjustment_constant: float = Field( 0.0, - description="Constant to adjust power usage in MW", + description="[DEPRECATED] Constant to adjust power usage in MW. Use SHAFT with MECHANICAL_EFFICIENCY instead.", title="POWER_ADJUSTMENT_CONSTANT", ) power_adjustment_factor: float = Field( 1.0, - description="Factor to adjust power usage in MW", + description="[DEPRECATED] Factor to adjust power usage in MW. Use SHAFT with MECHANICAL_EFFICIENCY instead.", title="POWER_ADJUSTMENT_FACTOR", ) + shaft: ShaftReference | None = Field( + None, + description="Reference to a SHAFT model that defines mechanical efficiency for this compressor train. " + "If not specified, an implicit shaft with mechanical efficiency of 1.0 is used.", + title="SHAFT", + ) def to_dto(self): raise NotImplementedError @@ -247,6 +349,28 @@ def check_interstage_control_pressure(self): raise ValueError("Only one stage can have interstage control pressure defined.") return self + @model_validator(mode="after") + def validate_shaft_and_legacy_params(self): + """Validate mutual exclusivity of SHAFT and legacy power adjustment parameters.""" + has_shaft = self.shaft is not None + has_legacy_factor = self.power_adjustment_factor != 1.0 + has_legacy_constant = self.power_adjustment_constant != 0.0 + + if has_shaft and (has_legacy_factor or has_legacy_constant): + raise ValueError( + f"Model '{self.name}': Cannot specify both SHAFT and POWER_ADJUSTMENT_FACTOR/POWER_ADJUSTMENT_CONSTANT. " + "Use SHAFT with MECHANICAL_EFFICIENCY instead of legacy power adjustment parameters." + ) + + if has_legacy_factor or has_legacy_constant: + logger.warning( + f"Model '{self.name}': POWER_ADJUSTMENT_FACTOR and POWER_ADJUSTMENT_CONSTANT are deprecated " + "and will be removed in a future version. Use SHAFT with MECHANICAL_EFFICIENCY instead. " + "See migration guide: https://equinor.github.io/ecalc/changelog/migration/mechanical-efficiency" + ) + + return self + YamlCompressorTrain = Union[ YamlVariableSpeedCompressorTrain, diff --git a/src/libecalc/presentation/yaml/yaml_types/models/yaml_enums.py b/src/libecalc/presentation/yaml/yaml_types/models/yaml_enums.py index fe2c0f96d4..867b88f2ff 100644 --- a/src/libecalc/presentation/yaml/yaml_types/models/yaml_enums.py +++ b/src/libecalc/presentation/yaml/yaml_types/models/yaml_enums.py @@ -5,6 +5,7 @@ class YamlModelType(str, enum.Enum): FLUID = "FLUID" COMPRESSOR_CHART = "COMPRESSOR_CHART" TURBINE = "TURBINE" + SHAFT = "SHAFT" SIMPLIFIED_VARIABLE_SPEED_COMPRESSOR_TRAIN = "SIMPLIFIED_VARIABLE_SPEED_COMPRESSOR_TRAIN" SINGLE_SPEED_COMPRESSOR_TRAIN = "SINGLE_SPEED_COMPRESSOR_TRAIN" VARIABLE_SPEED_COMPRESSOR_TRAIN = "VARIABLE_SPEED_COMPRESSOR_TRAIN" diff --git a/src/libecalc/presentation/yaml/yaml_types/models/yaml_shaft.py b/src/libecalc/presentation/yaml/yaml_types/models/yaml_shaft.py new file mode 100644 index 0000000000..76ca17c315 --- /dev/null +++ b/src/libecalc/presentation/yaml/yaml_types/models/yaml_shaft.py @@ -0,0 +1,51 @@ +from typing import Literal + +from pydantic import Field + +from libecalc.presentation.yaml.yaml_types import YamlBase +from libecalc.presentation.yaml.yaml_types.models.model_reference import ModelName +from libecalc.presentation.yaml.yaml_types.models.yaml_enums import YamlModelType + + +class YamlShaft(YamlBase): + """YAML model for defining a shaft with mechanical efficiency. + + A shaft represents the physical rotating component that connects a driver + (turbine/motor) to compressor(s). Mechanical efficiency accounts for + losses in bearings, gearbox, and couplings. + + Example YAML: + MODELS: + - NAME: main_shaft + TYPE: SHAFT + MECHANICAL_EFFICIENCY: 0.95 # 5% mechanical loss + + Attributes: + name: Unique name to identify this shaft model. + type: Must be SHAFT. + mechanical_efficiency: Fraction of shaft power that becomes gas power. + Required, no default. Constraint: 0 < η ≤ 1. + Typical values: 0.93-0.97 depending on configuration. + """ + + name: ModelName = Field( + ..., + description="Name of the shaft model. See documentation for more information.", + title="NAME", + ) + type: Literal[YamlModelType.SHAFT] = Field( + ..., + description="Defines the type of model. Must be SHAFT.", + title="TYPE", + ) + mechanical_efficiency: float = Field( + ..., # Required, no default - user must specify + description="Mechanical efficiency of the shaft. Accounts for losses in bearings, gearbox, and couplings. " + "Must be in range (0, 1]. Typical values: ~0.97 for direct drive, ~0.95 with gearbox, ~0.93 for large gearbox/VSD.", + gt=0.0, + le=1.0, + title="MECHANICAL_EFFICIENCY", + ) + + def to_dto(self): + raise NotImplementedError diff --git a/tests/libecalc/domain/process/entities/test_shaft.py b/tests/libecalc/domain/process/entities/test_shaft.py new file mode 100644 index 0000000000..e1d8880232 --- /dev/null +++ b/tests/libecalc/domain/process/entities/test_shaft.py @@ -0,0 +1,71 @@ +"""Tests for Shaft domain entity.""" + +import pytest + +from libecalc.domain.process.entities.shaft import SingleSpeedShaft, VariableSpeedShaft + + +class TestShaftMechanicalEfficiency: + """Test mechanical efficiency behavior on shaft classes.""" + + def test_default_mechanical_efficiency(self): + """Shaft with no explicit efficiency should have η = 1.0.""" + shaft = VariableSpeedShaft() + assert shaft.mechanical_efficiency == 1.0 + + def test_custom_mechanical_efficiency(self): + """Shaft can be created with custom mechanical efficiency.""" + shaft = VariableSpeedShaft(mechanical_efficiency=0.95) + assert shaft.mechanical_efficiency == 0.95 + + def test_single_speed_shaft_mechanical_efficiency(self): + """SingleSpeedShaft supports mechanical efficiency.""" + shaft = SingleSpeedShaft(speed_rpm=3600.0, mechanical_efficiency=0.92) + assert shaft.mechanical_efficiency == 0.92 + assert shaft.get_speed() == 3600.0 + + def test_variable_speed_shaft_mechanical_efficiency(self): + """VariableSpeedShaft supports mechanical efficiency.""" + shaft = VariableSpeedShaft(mechanical_efficiency=0.88) + assert shaft.mechanical_efficiency == 0.88 + + def test_mechanical_efficiency_boundary_valid(self): + """Mechanical efficiency at boundary value 1.0 is valid.""" + shaft = VariableSpeedShaft(mechanical_efficiency=1.0) + assert shaft.mechanical_efficiency == 1.0 + + def test_mechanical_efficiency_near_zero_valid(self): + """Very low mechanical efficiency is valid (though unlikely in practice).""" + shaft = VariableSpeedShaft(mechanical_efficiency=0.01) + assert shaft.mechanical_efficiency == 0.01 + + @pytest.mark.parametrize( + "efficiency", + [0.0, -0.5, 1.1], + ids=["zero", "negative", "above_one"], + ) + def test_mechanical_efficiency_invalid_values(self, efficiency): + """Invalid mechanical efficiency values should raise error.""" + with pytest.raises(ValueError, match="Mechanical efficiency must be in the range"): + VariableSpeedShaft(mechanical_efficiency=efficiency) + + +class TestShaftSpeedBehavior: + """Test that speed-related behavior still works with mechanical efficiency.""" + + def test_variable_speed_shaft_set_speed(self): + """Variable speed shaft can set and get speed.""" + shaft = VariableSpeedShaft(mechanical_efficiency=0.95) + shaft.set_speed(5000.0) + assert shaft.get_speed() == 5000.0 + + def test_single_speed_shaft_fixed_speed(self): + """Single speed shaft has fixed speed.""" + shaft = SingleSpeedShaft(speed_rpm=3600.0, mechanical_efficiency=0.92) + assert shaft.get_speed() == 3600.0 + + def test_single_speed_shaft_set_speed_raises(self): + """Setting speed on single speed shaft should raise AttributeError.""" + shaft = SingleSpeedShaft(speed_rpm=3600.0, mechanical_efficiency=0.92) + with pytest.raises(AttributeError, match="Cannot modify speed"): + shaft.set_speed(5000.0) diff --git a/tests/libecalc/input/mappers/test_model_mapper.py b/tests/libecalc/input/mappers/test_model_mapper.py index 52e2f0806d..d69b097256 100644 --- a/tests/libecalc/input/mappers/test_model_mapper.py +++ b/tests/libecalc/input/mappers/test_model_mapper.py @@ -15,6 +15,7 @@ YamlTabularModel, ) from libecalc.presentation.yaml.yaml_types.models import YamlCompressorChart, YamlFluidModel, YamlTurbine +from libecalc.presentation.yaml.yaml_types.models.yaml_shaft import YamlShaft class DirectReferenceService(ReferenceService): @@ -51,6 +52,9 @@ def get_pump_model(self, reference: str) -> YamlPumpChartSingleSpeed | YamlPumpC def get_tabulated_model(self, reference: str) -> YamlTabularModel: raise NotImplementedError() + def get_shaft(self, reference: str) -> YamlShaft: + raise NotImplementedError() + class TestCompressorChartMapping: def test_compressor_chart_from_file_and_in_yaml_is_equal(self): diff --git a/tests/libecalc/integration/snapshots/test_all_energy_usage_models/test_all_results/all_energy_usage_models_v3.json b/tests/libecalc/integration/snapshots/test_all_energy_usage_models/test_all_results/all_energy_usage_models_v3.json index 2c94adadcb..67ff35bc8a 100644 --- a/tests/libecalc/integration/snapshots/test_all_energy_usage_models/test_all_results/all_energy_usage_models_v3.json +++ b/tests/libecalc/integration/snapshots/test_all_energy_usage_models/test_all_results/all_energy_usage_models_v3.json @@ -363,9 +363,9 @@ ], "unit": "MW", "values": [ - 299.95632, - 372.22471, - 348.94521, + 300.01029, + 372.27868, + 348.99919, 5.4146906 ] }, @@ -392,10 +392,10 @@ }, "unit": "GWh", "values": [ - 2627.6173, - 5888.3058, - 8945.0659, - 8945.0659 + 2628.0901, + 5889.2514, + 8946.4843, + 8946.4843 ] }, "power_electrical": { @@ -428,9 +428,9 @@ ], "unit": "MW", "values": [ - 267.7295, - 272.88952, - 274.05514, + 267.78347, + 272.94349, + 274.10912, 5.4146906 ] }, @@ -457,10 +457,10 @@ }, "unit": "GWh", "values": [ - 2345.3104, - 4735.8226, - 7136.5457, - 7136.5457 + 2345.7832, + 4736.7682, + 7137.964, + 7137.964 ] }, "power_mechanical": { @@ -54108,9 +54108,9 @@ ], "unit": "MW", "values": [ - 21.02546, - 21.02546, - 21.02546, + 21.079432, + 21.079432, + 21.079432, 0.0 ] }, @@ -54137,10 +54137,10 @@ }, "unit": "MWd", "values": [ - 7674.2929, - 15348.586, - 23022.879, - 23022.879 + 7693.9926, + 15387.985, + 23081.978, + 23081.978 ] }, "failure_status": [ @@ -54862,9 +54862,9 @@ ], "unit": "MW", "values": [ - 21.02546, - 21.02546, - 21.02546, + 21.079432, + 21.079432, + 21.079432, 0.0 ] }, @@ -54891,10 +54891,10 @@ }, "unit": "GWh", "values": [ - 184.18303, - 368.36606, - 552.54909, - 552.54909 + 184.65582, + 369.31164, + 553.96746, + 553.96746 ] }, "rate": { @@ -55280,9 +55280,9 @@ ], "unit": "MW", "values": [ - 6.3317606, - 6.3317606, - 6.3317606, + 6.6650111, + 6.6650111, + 6.6650111, 0.0 ] }, @@ -56167,9 +56167,9 @@ ], "unit": "MW", "values": [ - 6.3317606, - 6.3317606, - 6.3317606, + 6.6650111, + 6.6650111, + 6.6650111, 0.0 ] }, @@ -56543,9 +56543,9 @@ ], "unit": "MW", "values": [ - 13.693699, - 13.693699, - 13.693699, + 14.414421, + 14.414421, + 14.414421, 0.0 ] }, @@ -57430,9 +57430,9 @@ ], "unit": "MW", "values": [ - 13.693699, - 13.693699, - 13.693699, + 14.414421, + 14.414421, + 14.414421, 0.0 ] }, @@ -60199,9 +60199,9 @@ ], "unit": "MW", "values": [ - 299.95632, - 372.22471, - 348.94521, + 300.01029, + 372.27868, + 348.99919, 5.4146906 ] }, @@ -60228,10 +60228,10 @@ }, "unit": "GWh", "values": [ - 2627.6173, - 5888.3058, - 8945.0659, - 8945.0659 + 2628.0901, + 5889.2514, + 8946.4843, + 8946.4843 ] }, "power_electrical": { @@ -60264,9 +60264,9 @@ ], "unit": "MW", "values": [ - 267.7295, - 272.88952, - 274.05514, + 267.78347, + 272.94349, + 274.10912, 5.4146906 ] }, @@ -60293,10 +60293,10 @@ }, "unit": "GWh", "values": [ - 2345.3104, - 4735.8226, - 7136.5457, - 7136.5457 + 2345.7832, + 4736.7682, + 7137.964, + 7137.964 ] }, "power_mechanical": { @@ -60634,9 +60634,9 @@ ], "unit": "MW", "values": [ - 267.7295, - 272.88952, - 274.05514, + 267.78347, + 272.94349, + 274.10912, 5.4146906 ] }, @@ -60670,9 +60670,9 @@ ], "unit": "MW", "values": [ - 732.2705, - 727.11048, - 725.94486, + 732.21653, + 727.05651, + 725.89088, 994.58531 ] }, @@ -60699,10 +60699,10 @@ }, "unit": "GWh", "values": [ - 2345.3104, - 4735.8226, - 7136.5457, - 7136.5457 + 2345.7832, + 4736.7682, + 7137.964, + 7137.964 ] } }, @@ -64533,9 +64533,9 @@ ], "unit": "MW", "values": [ - 21.02546, - 21.02546, - 21.02546, + 21.079432, + 21.079432, + 21.079432, 0.0 ] }, @@ -64562,10 +64562,10 @@ }, "unit": "GWh", "values": [ - 184.18303, - 368.36606, - 552.54909, - 552.54909 + 184.65582, + 369.31164, + 553.96746, + 553.96746 ] }, "id": "variable_speed_compressor_train_predefined_charts", @@ -64650,9 +64650,9 @@ ], "unit": "MW", "values": [ - 21.02546, - 21.02546, - 21.02546, + 21.079432, + 21.079432, + 21.079432, 0.0 ] }, @@ -64679,10 +64679,10 @@ }, "unit": "GWh", "values": [ - 184.18303, - 368.36606, - 552.54909, - 552.54909 + 184.65582, + 369.31164, + 553.96746, + 553.96746 ] }, "rate_exceeds_maximum": { diff --git a/tests/libecalc/presentation/yaml/yaml_types/models/test_yaml_shaft.py b/tests/libecalc/presentation/yaml/yaml_types/models/test_yaml_shaft.py new file mode 100644 index 0000000000..1d80832b65 --- /dev/null +++ b/tests/libecalc/presentation/yaml/yaml_types/models/test_yaml_shaft.py @@ -0,0 +1,76 @@ +"""Tests for YamlShaft model type.""" + +import pytest +from pydantic import ValidationError + +from libecalc.presentation.yaml.yaml_types.models.yaml_enums import YamlModelType +from libecalc.presentation.yaml.yaml_types.models.yaml_shaft import YamlShaft + + +class TestYamlShaft: + """Test YamlShaft Pydantic model.""" + + def test_valid_shaft_model(self): + """Test creating a valid shaft model.""" + shaft = YamlShaft( + name="my_shaft", + type=YamlModelType.SHAFT, + mechanical_efficiency=0.95, + ) + assert shaft.name == "my_shaft" + assert shaft.type == YamlModelType.SHAFT + assert shaft.mechanical_efficiency == 0.95 + + def test_shaft_type_is_shaft(self): + """Test that shaft type is SHAFT.""" + shaft = YamlShaft( + name="test_shaft", + type="SHAFT", + mechanical_efficiency=0.9, + ) + assert shaft.type == YamlModelType.SHAFT + assert shaft.type.value == "SHAFT" + + def test_shaft_mechanical_efficiency_at_boundary(self): + """Test shaft with mechanical efficiency at 1.0 boundary.""" + shaft = YamlShaft( + name="perfect_shaft", + type="SHAFT", + mechanical_efficiency=1.0, + ) + assert shaft.mechanical_efficiency == 1.0 + + def test_shaft_low_mechanical_efficiency(self): + """Test shaft with low but valid mechanical efficiency.""" + shaft = YamlShaft( + name="lossy_shaft", + type="SHAFT", + mechanical_efficiency=0.5, + ) + assert shaft.mechanical_efficiency == 0.5 + + def test_shaft_requires_mechanical_efficiency(self): + """Test that mechanical_efficiency is required.""" + with pytest.raises(ValidationError, match="MECHANICAL_EFFICIENCY"): + YamlShaft( + name="missing_efficiency", + type="SHAFT", + # mechanical_efficiency not provided + ) + + @pytest.mark.parametrize( + "efficiency,reason", + [ + (0.0, "zero"), + (-0.1, "negative"), + (1.1, "above 1.0"), + ], + ) + def test_shaft_efficiency_invalid_values(self, efficiency, reason): + """Test that invalid mechanical efficiency values are rejected.""" + with pytest.raises(ValidationError): + YamlShaft( + name="invalid_shaft", + type="SHAFT", + mechanical_efficiency=efficiency, + ) diff --git a/tests/libecalc/presentation/yaml/yaml_types/models/test_yaml_shaft_validation.py b/tests/libecalc/presentation/yaml/yaml_types/models/test_yaml_shaft_validation.py new file mode 100644 index 0000000000..ad88607924 --- /dev/null +++ b/tests/libecalc/presentation/yaml/yaml_types/models/test_yaml_shaft_validation.py @@ -0,0 +1,143 @@ +"""Tests for shaft single-usage validation and compressor train shaft references.""" + +import pytest +from pydantic import ValidationError + +from libecalc.common.errors.exceptions import EcalcError +from libecalc.presentation.yaml.yaml_reference_service import _validate_shaft_single_usage +from libecalc.presentation.yaml.yaml_types.models.yaml_compressor_stages import ( + YamlCompressorStage, + YamlCompressorStageWithMarginAndPressureDrop, +) +from libecalc.presentation.yaml.yaml_types.models.yaml_compressor_trains import ( + YamlCompressorStages, + YamlSimplifiedVariableSpeedCompressorTrain, + YamlSingleSpeedCompressorTrain, + YamlVariableSpeedCompressorTrain, +) + + +@pytest.fixture +def common_shaft_stages(): + """Minimal stages for single/variable speed trains.""" + stage = YamlCompressorStageWithMarginAndPressureDrop( + compressor_chart="chart_ref", + inlet_temperature=30.0, + control_margin=0.0, + control_margin_unit="PERCENTAGE", + ) + return YamlCompressorStages(stages=[stage]) + + +@pytest.fixture +def simplified_stages(): + """Minimal stages for simplified trains.""" + return YamlCompressorStages[YamlCompressorStage]( + stages=[YamlCompressorStage(inlet_temperature=30, compressor_chart="chart_ref")], + ) + + +def create_single_speed_train(stages, **kwargs): + """Factory for single speed train with defaults.""" + return YamlSingleSpeedCompressorTrain( + name=kwargs.pop("name", "test_train"), + type="SINGLE_SPEED_COMPRESSOR_TRAIN", + compressor_train=stages, + fluid_model="fluid_ref", + **kwargs, + ) + + +def create_variable_speed_train(stages, **kwargs): + """Factory for variable speed train with defaults.""" + return YamlVariableSpeedCompressorTrain( + name=kwargs.pop("name", "test_train"), + type="VARIABLE_SPEED_COMPRESSOR_TRAIN", + compressor_train=stages, + fluid_model="fluid_ref", + **kwargs, + ) + + +def create_simplified_train(stages, **kwargs): + """Factory for simplified train with defaults.""" + return YamlSimplifiedVariableSpeedCompressorTrain( + name=kwargs.pop("name", "test_train"), + type="SIMPLIFIED_VARIABLE_SPEED_COMPRESSOR_TRAIN", + compressor_train=stages, + fluid_model="fluid_ref", + **kwargs, + ) + + +class TestShaftSingleUsageValidation: + """Test that shafts can only be used by one compressor train.""" + + def test_different_shafts_allowed(self, common_shaft_stages): + """Multiple trains with different shafts should pass validation.""" + train1 = create_single_speed_train(common_shaft_stages, name="train1", shaft="shaft1") + train2 = create_variable_speed_train(common_shaft_stages, name="train2", shaft="shaft2") + _validate_shaft_single_usage([train1, train2]) # Should not raise + + def test_same_shaft_disallowed(self, common_shaft_stages): + """Two trains referencing the same shaft should fail validation.""" + train1 = create_single_speed_train(common_shaft_stages, name="train1", shaft="shared_shaft") + train2 = create_variable_speed_train(common_shaft_stages, name="train2", shaft="shared_shaft") + + with pytest.raises(EcalcError, match="shared_shaft.*already used by train 'train1'"): + _validate_shaft_single_usage([train1, train2]) + + def test_trains_without_shaft_allowed(self, common_shaft_stages): + """Trains without shaft references should pass validation.""" + train1 = create_single_speed_train(common_shaft_stages, name="train1") + train2 = create_variable_speed_train(common_shaft_stages, name="train2") + _validate_shaft_single_usage([train1, train2]) # Should not raise + + def test_mixed_shaft_and_no_shaft(self, common_shaft_stages): + """Mix of trains with and without shaft references should pass.""" + train1 = create_single_speed_train(common_shaft_stages, name="train1", shaft="my_shaft") + train2 = create_variable_speed_train(common_shaft_stages, name="train2") + _validate_shaft_single_usage([train1, train2]) # Should not raise + + +class TestShaftAndLegacyParamsMutualExclusivity: + """Test that shaft and legacy power adjustment params are mutually exclusive.""" + + @pytest.mark.parametrize("legacy_param", ["power_adjustment_factor", "power_adjustment_constant"]) + def test_shaft_with_legacy_param_fails(self, common_shaft_stages, legacy_param): + """Cannot use both shaft and legacy power adjustment params.""" + kwargs = {"shaft": "my_shaft", legacy_param: 1.1 if "factor" in legacy_param else 0.5} + with pytest.raises(ValueError, match="Cannot specify both SHAFT and POWER_ADJUSTMENT"): + create_single_speed_train(common_shaft_stages, **kwargs) + + +class TestSimplifiedTrainMechanicalEfficiency: + """Tests for MECHANICAL_EFFICIENCY on simplified compressor trains.""" + + def test_mechanical_efficiency_valid(self, simplified_stages): + """Valid mechanical efficiency within range (0, 1].""" + train = create_simplified_train(simplified_stages, mechanical_efficiency=0.95) + assert train.mechanical_efficiency == 0.95 + + def test_mechanical_efficiency_default(self, simplified_stages): + """Default mechanical efficiency is 1.0 (no loss).""" + train = create_simplified_train(simplified_stages) + assert train.mechanical_efficiency == 1.0 + + @pytest.mark.parametrize("invalid_value", [0.0, -0.1, 1.1]) + def test_mechanical_efficiency_invalid(self, simplified_stages, invalid_value): + """Invalid mechanical efficiency values should fail.""" + with pytest.raises(ValidationError): + create_simplified_train(simplified_stages, mechanical_efficiency=invalid_value) + + +class TestSimplifiedTrainMutualExclusivity: + """Tests for mutual exclusivity on simplified trains.""" + + @pytest.mark.parametrize("legacy_param", ["power_adjustment_factor", "power_adjustment_constant"]) + def test_mechanical_efficiency_with_legacy_param_fails(self, simplified_stages, legacy_param): + """Cannot use both MECHANICAL_EFFICIENCY and legacy params.""" + kwargs = {"mechanical_efficiency": 0.95, legacy_param: 1.05 if "factor" in legacy_param else 1.0} + with pytest.raises(ValidationError) as exc_info: + create_simplified_train(simplified_stages, **kwargs) + assert "cannot be used together" in str(exc_info.value).lower()