RunFeemsSim 0.5.0__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: RunFeemsSim
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: A library for running feems simulation
5
5
  Author-email: Kevin Koosup Yum <kevinkoosup.yum@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -290,6 +290,25 @@ class MachineryCalculation:
290
290
  fuel_option=fuel_option,
291
291
  )
292
292
 
293
+ def _set_boiler_steam_demand(self, steam_demand_kg_per_h: Optional[np.ndarray]) -> None:
294
+ """Set the boiler steam demand aligned to the simulation's integration intervals.
295
+
296
+ The number of integration points equals ``len(time_interval_s)``, which is one less
297
+ than the number of timestamps when the load is given as a timestamp series (the last
298
+ point is dropped, mirroring how the propulsion power is handled). The steam demand is
299
+ therefore truncated to the same number of points so the boiler fuel integration matches
300
+ the engine integration. No-op when the system has no boiler.
301
+ """
302
+ if self.system_feems.boiler is None:
303
+ return
304
+ n_points = len(np.atleast_1d(self.electric_system.time_interval_s))
305
+ if steam_demand_kg_per_h is None:
306
+ self.system_feems.boiler.steam_out_kg_per_h = np.zeros(n_points)
307
+ else:
308
+ self.system_feems.boiler.steam_out_kg_per_h = (
309
+ np.asarray(steam_demand_kg_per_h, dtype=float)[:n_points]
310
+ )
311
+
293
312
  def calculate_machinery_system_output_from_propulsion_power_time_series(
294
313
  self,
295
314
  *,
@@ -298,6 +317,7 @@ class MachineryCalculation:
298
317
  fuel_specified_by: FuelSpecifiedBy = FuelSpecifiedBy.IMO,
299
318
  ignore_power_balance: bool = False,
300
319
  fuel_option: Optional[FuelOption] = None,
320
+ steam_demand_kg_per_h: Optional[np.ndarray] = None,
301
321
  ) -> Union[FEEMSResult, FEEMSResultForMachinerySystem]:
302
322
  """
303
323
  Calculate the machinery system output from a time series of the propulsion power and
@@ -313,6 +333,8 @@ class MachineryCalculation:
313
333
  ignore_power_balance(bool): If True, the power balance calculation will be ignored.
314
334
  fuel_option(FuelOption): The fuel option to be used in the simulation. If None, the
315
335
  default fuel option in the system will be used.
336
+ steam_demand_kg_per_h: Steam demand time series (kg/h). Must have the same length as
337
+ propulsion_power. If None and a boiler is attached, demand is treated as zero.
316
338
 
317
339
  Returns:
318
340
  The result of the calculation. FEEMSResult or FEEMSResultForMachinerySystem.
@@ -347,11 +369,19 @@ class MachineryCalculation:
347
369
  f"The length of the auxiliary power({len(auxiliary_power_kw)}) must be 1"
348
370
  f" or the same as the propulsion power ({len(propulsion_power)})"
349
371
  )
372
+ n_steps = len(propulsion_power)
373
+ if steam_demand_kg_per_h is not None:
374
+ if len(steam_demand_kg_per_h) != n_steps:
375
+ raise ValueError(
376
+ f"The length of steam_demand_kg_per_h ({len(steam_demand_kg_per_h)}) must match "
377
+ f"propulsion_power ({n_steps})."
378
+ )
350
379
 
351
380
  self._set_input_load_time_interval_from_propulsion_power_time_series(
352
381
  propulsion_power_time_series=propulsion_power,
353
382
  auxiliary_load_kw=auxiliary_power_kw,
354
383
  )
384
+ self._set_boiler_steam_demand(steam_demand_kg_per_h)
355
385
  return self._run_simulation(
356
386
  fuel_specified_by=fuel_specified_by,
357
387
  ignore_power_balance=ignore_power_balance,
@@ -368,6 +398,7 @@ class MachineryCalculation:
368
398
  fuel_specified_by: FuelSpecifiedBy = FuelSpecifiedBy.IMO,
369
399
  ignore_power_balance: bool = False,
370
400
  fuel_option: Optional[FuelOption] = None,
401
+ steam_demand_kg_per_h: Optional[np.ndarray] = None,
371
402
  ) -> Union[FEEMSResult, FEEMSResultForMachinerySystem]:
372
403
  """
373
404
  Calculate the machinery system output from statistics of the propulsion power.
@@ -377,6 +408,11 @@ class MachineryCalculation:
377
408
  ignore_power_balance(bool): If True, the power balance calculation will be ignored.
378
409
  fuel_option(FuelOption): The fuel option to be used in the simulation. If None, the
379
410
  default fuel option in the system will be used.
411
+ steam_demand_kg_per_h: Steam demand time series (kg/h). Must have the same length as
412
+ the time series. Takes precedence over the boiler_steam_demand_kg_per_h carried by
413
+ the time_series proto; if None, that proto value is used instead (per-instance, or
414
+ the top-level constant when the per-instance values are all zero). If both are
415
+ absent and a boiler is attached, demand is treated as zero.
380
416
 
381
417
  Returns:
382
418
  The result of the simulation. FEEMSResult or FEEMSResultForMachinery system.
@@ -398,7 +434,25 @@ class MachineryCalculation:
398
434
  raise TypeError(
399
435
  "time_series must be a TimeSeriesResult or TimeSeriesResultForMultiplePropulsors."
400
436
  )
401
-
437
+ n_steps = len(df)
438
+ if steam_demand_kg_per_h is not None:
439
+ if len(steam_demand_kg_per_h) != n_steps:
440
+ raise ValueError(
441
+ f"The length of steam_demand_kg_per_h ({len(steam_demand_kg_per_h)}) must match "
442
+ f"the time series ({n_steps})."
443
+ )
444
+ # The explicit steam_demand_kg_per_h argument takes precedence; otherwise fall back to the
445
+ # boiler_steam_demand_kg_per_h carried by the time-series proto (per-instance values, with a
446
+ # top-level constant fallback resolved by the converter).
447
+ steam_demand_from_proto = (
448
+ df["boiler_steam_demand_kg_per_h"].values
449
+ if "boiler_steam_demand_kg_per_h" in df.columns
450
+ else None
451
+ )
452
+ effective_steam_demand = (
453
+ steam_demand_kg_per_h if steam_demand_kg_per_h is not None else steam_demand_from_proto
454
+ )
455
+ self._set_boiler_steam_demand(effective_steam_demand)
402
456
  return self._run_simulation(
403
457
  fuel_specified_by=fuel_specified_by,
404
458
  ignore_power_balance=ignore_power_balance,
@@ -414,6 +468,7 @@ class MachineryCalculation:
414
468
  fuel_specified_by: FuelSpecifiedBy = FuelSpecifiedBy.IMO,
415
469
  ignore_power_balance: bool = False,
416
470
  fuel_option: Optional[FuelOption] = None,
471
+ steam_demand_kg_per_h: Optional[np.ndarray] = None,
417
472
  ) -> Union[FEEMSResult, FEEMSResultForMachinerySystem]:
418
473
  """
419
474
  Calculate the machinery system output from statistics of the propulsion power.
@@ -428,19 +483,32 @@ class MachineryCalculation:
428
483
  ignore_power_balance(bool): If True, the power balance calculation will be ignored.
429
484
  fuel_option(FuelOption): The fuel option to be used in the simulation. If None, the
430
485
  default fuel option in the system will be used.
486
+ steam_demand_kg_per_h: Steam demand for each mode in kg/h. Must have the same length
487
+ as propulsion_power. If None and a boiler is attached, demand is treated as zero.
431
488
 
432
489
  Returns:
433
490
  The result of the simulation. FEEMSResult or FEEMSResultForMachinery system.
434
491
  """
492
+ n_steps = len(propulsion_power)
435
493
  if not np.isscalar(auxiliary_power_kw):
436
494
  assert (
437
- len(propulsion_power) == len(auxiliary_power_kw) or len(auxiliary_power_kw) == 1
495
+ n_steps == len(auxiliary_power_kw) or len(auxiliary_power_kw) == 1
438
496
  ), "The length of the auxiliary power must be 1 or the same as the propulsion power"
497
+ if steam_demand_kg_per_h is not None:
498
+ if len(steam_demand_kg_per_h) != n_steps:
499
+ raise ValueError(
500
+ f"The length of steam_demand_kg_per_h ({len(steam_demand_kg_per_h)}) must match "
501
+ f"propulsion_power ({n_steps})."
502
+ )
439
503
  self._set_input_load_time_interval_from_propulsion_power_time_series(
440
504
  propulsion_power_time_series=pd.Series(data=propulsion_power, index=frequency),
441
505
  auxiliary_load_kw=auxiliary_power_kw,
442
506
  time_is_given_as_interval=True,
443
507
  )
508
+ # Time is given as intervals here, so there is no last-point truncation: the helper's
509
+ # alignment is a no-op (n_points == n_steps). Routed through the helper anyway to keep a
510
+ # single boiler-steam code path across all calculation methods.
511
+ self._set_boiler_steam_demand(steam_demand_kg_per_h)
444
512
  return self._run_simulation(
445
513
  fuel_specified_by=fuel_specified_by,
446
514
  ignore_power_balance=ignore_power_balance,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: RunFeemsSim
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: A library for running feems simulation
5
5
  Author-email: Kevin Koosup Yum <kevinkoosup.yum@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "RunFeemsSim"
7
- version = "0.5.0"
7
+ version = "0.7.0"
8
8
  description = "A library for running feems simulation"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -13,6 +13,7 @@ from feems.components_model.component_mechanical import (
13
13
  COGAS,
14
14
  EngineMultiFuel,
15
15
  FuelCharacteristics,
16
+ SteamBoiler,
16
17
  )
17
18
  from feems.fuel import FuelOrigin, FuelSpecifiedBy, TypeFuel
18
19
  from feems.system_model import ElectricPowerSystem, FuelOption
@@ -20,6 +21,7 @@ from feems.types_for_feems import EngineCycleType, TypeComponent, TypePower
20
21
  from MachSysS.convert_to_feems import convert_proto_propulsion_system_to_feems
21
22
  from MachSysS.gymir_result_pb2 import (
22
23
  GymirResult,
24
+ PropulsionPowerInstance,
23
25
  PropulsionPowerInstanceForMultiplePropulsors,
24
26
  SimulationInstance,
25
27
  TimeSeriesResult,
@@ -461,3 +463,209 @@ def test_machinery_calculation_multifuel_coges():
461
463
  rtol=1e-6,
462
464
  ), "Explicit LNG option should match default"
463
465
 
466
+
467
+
468
+ # --- Boiler steam demand carried through TimeSeriesResult ---------------------------------
469
+ #
470
+ # These tests cover boiler_steam_demand_kg_per_h on the TimeSeriesResult proto: the value is
471
+ # read back by the converter and applied to the system boiler, producing boiler fuel/CO2 that
472
+ # matches the equivalent table-mode path (calculate_..._from_propulsion_power_time_series with
473
+ # the same steam_demand_kg_per_h array). Both paths use a timestamp series, so the last point is
474
+ # dropped during integration; the helper that builds the proto and the table array shares the
475
+ # same epochs/steam values, so they stay aligned.
476
+
477
+ _BOILER_EPOCHS = [0, 60, 120, 180]
478
+ _BOILER_PROPULSION_KW = [1500.0, 1600.0, 1700.0, 1800.0]
479
+ _BOILER_AUX_KW = 200.0
480
+
481
+
482
+ def _make_steam_boiler() -> SteamBoiler:
483
+ flat_eff = np.array([[0.25, 0.85], [0.50, 0.85], [0.75, 0.85], [1.00, 0.85]])
484
+ return SteamBoiler(
485
+ name="test boiler",
486
+ rated_steam_production_kg_per_h=10_000.0,
487
+ working_pressure_barg=6.0,
488
+ thermal_efficiency_curve=flat_eff,
489
+ fuel_type=TypeFuel.HFO,
490
+ fuel_origin=FuelOrigin.FOSSIL,
491
+ feed_water_temperature_c=80.0,
492
+ uid="boiler-uid-ts",
493
+ )
494
+
495
+
496
+ def _build_boiler_machinery_calculation() -> MachineryCalculation:
497
+ """Fresh MachineryCalculation (electric system) with a standalone boiler attached.
498
+
499
+ A fresh instance is required per run because boiler.steam_out_kg_per_h is mutated in place.
500
+ """
501
+ package_dir = os.path.dirname(os.path.abspath(__file__))
502
+ path = os.path.join(package_dir, "system_proto.mss")
503
+ system_feems = convert_proto_propulsion_system_to_feems(retrieve_machinery_system_from_file(path))
504
+ system_feems.boiler = _make_steam_boiler()
505
+ return MachineryCalculation(feems_system=system_feems)
506
+
507
+
508
+ def _table_mode_boiler_result(steam_demand_kg_per_h: np.ndarray):
509
+ series = pd.Series(index=_BOILER_EPOCHS, data=_BOILER_PROPULSION_KW)
510
+ return _build_boiler_machinery_calculation().calculate_machinery_system_output_from_propulsion_power_time_series(
511
+ propulsion_power=series,
512
+ auxiliary_power_kw=_BOILER_AUX_KW,
513
+ steam_demand_kg_per_h=steam_demand_kg_per_h,
514
+ )
515
+
516
+
517
+ def test_time_series_result_per_instance_boiler_steam_matches_table_mode():
518
+ """AC1: per-timestep boiler_steam_demand_kg_per_h matches the table-mode path."""
519
+ steam = np.array([2000.0, 3000.0, 4000.0, 5000.0])
520
+ time_series = TimeSeriesResult(
521
+ propulsion_power_timeseries=[
522
+ PropulsionPowerInstance(
523
+ epoch_s=epoch,
524
+ propulsion_power_kw=prop,
525
+ auxiliary_power_kw=_BOILER_AUX_KW,
526
+ boiler_steam_demand_kg_per_h=s,
527
+ )
528
+ for epoch, prop, s in zip(_BOILER_EPOCHS, _BOILER_PROPULSION_KW, steam)
529
+ ],
530
+ )
531
+ res_proto = _build_boiler_machinery_calculation().calculate_machinery_system_output_from_time_series_result(
532
+ time_series=time_series
533
+ )
534
+ res_table = _table_mode_boiler_result(steam)
535
+
536
+ # Boiler actually ran (guards against a silently-zero comparison).
537
+ assert res_proto.fuel_consumption_boiler_total.total_fuel_consumption > 0
538
+ assert res_proto.steam_production_boiler_total_kg > 0
539
+ # Independent absolute check so a bug in the shared steam-alignment helper can't be hidden by
540
+ # the proto==table comparison (both route through it). The last timestamp is dropped, so the
541
+ # integrated steam is [2000, 3000, 4000] kg/h over three 60 s intervals = 9000/3600*60 = 150 kg.
542
+ assert np.isclose(res_proto.steam_production_boiler_total_kg, 150.0)
543
+ assert np.isclose(
544
+ res_proto.fuel_consumption_boiler_total.total_fuel_consumption,
545
+ res_table.fuel_consumption_boiler_total.total_fuel_consumption,
546
+ )
547
+ assert np.isclose(
548
+ res_proto.fuel_consumption_boiler_total.get_total_co2_emissions().tank_to_wake_kg_or_gco2eq_per_gfuel,
549
+ res_table.fuel_consumption_boiler_total.get_total_co2_emissions().tank_to_wake_kg_or_gco2eq_per_gfuel,
550
+ )
551
+ assert np.isclose(
552
+ res_proto.steam_production_boiler_total_kg,
553
+ res_table.steam_production_boiler_total_kg,
554
+ )
555
+
556
+
557
+ def test_time_series_result_constant_boiler_steam_fallback():
558
+ """AC2: all-zero per-instance values + non-zero top-level constant behaves as a constant."""
559
+ constant = 3500.0
560
+ time_series = TimeSeriesResult(
561
+ propulsion_power_timeseries=[
562
+ PropulsionPowerInstance(
563
+ epoch_s=epoch,
564
+ propulsion_power_kw=prop,
565
+ auxiliary_power_kw=_BOILER_AUX_KW,
566
+ # boiler_steam_demand_kg_per_h left at 0 on every instance
567
+ )
568
+ for epoch, prop in zip(_BOILER_EPOCHS, _BOILER_PROPULSION_KW)
569
+ ],
570
+ boiler_steam_demand_kg_per_h=constant,
571
+ )
572
+ res_proto = _build_boiler_machinery_calculation().calculate_machinery_system_output_from_time_series_result(
573
+ time_series=time_series
574
+ )
575
+ res_table = _table_mode_boiler_result(np.full(len(_BOILER_EPOCHS), constant))
576
+
577
+ assert res_proto.fuel_consumption_boiler_total.total_fuel_consumption > 0
578
+ assert np.isclose(
579
+ res_proto.fuel_consumption_boiler_total.total_fuel_consumption,
580
+ res_table.fuel_consumption_boiler_total.total_fuel_consumption,
581
+ )
582
+ assert np.isclose(
583
+ res_proto.steam_production_boiler_total_kg,
584
+ res_table.steam_production_boiler_total_kg,
585
+ )
586
+
587
+
588
+ def test_time_series_result_zero_boiler_steam_no_contribution():
589
+ """AC3: all fields zero produces zero boiler contribution (no regression)."""
590
+ time_series = TimeSeriesResult(
591
+ propulsion_power_timeseries=[
592
+ PropulsionPowerInstance(
593
+ epoch_s=epoch,
594
+ propulsion_power_kw=prop,
595
+ auxiliary_power_kw=_BOILER_AUX_KW,
596
+ )
597
+ for epoch, prop in zip(_BOILER_EPOCHS, _BOILER_PROPULSION_KW)
598
+ ],
599
+ )
600
+ res_proto = _build_boiler_machinery_calculation().calculate_machinery_system_output_from_time_series_result(
601
+ time_series=time_series
602
+ )
603
+ assert res_proto.fuel_consumption_boiler_total.total_fuel_consumption == 0.0
604
+ assert res_proto.steam_production_boiler_total_kg == 0.0
605
+
606
+
607
+ def test_time_series_result_explicit_steam_demand_overrides_proto():
608
+ """The explicit steam_demand_kg_per_h argument takes precedence over the proto value."""
609
+ explicit = np.array([2000.0, 3000.0, 4000.0, 5000.0])
610
+ # Proto carries a different (constant) value that must be ignored when the arg is given.
611
+ time_series = TimeSeriesResult(
612
+ propulsion_power_timeseries=[
613
+ PropulsionPowerInstance(
614
+ epoch_s=epoch,
615
+ propulsion_power_kw=prop,
616
+ auxiliary_power_kw=_BOILER_AUX_KW,
617
+ )
618
+ for epoch, prop in zip(_BOILER_EPOCHS, _BOILER_PROPULSION_KW)
619
+ ],
620
+ boiler_steam_demand_kg_per_h=9999.0,
621
+ )
622
+ res_override = _build_boiler_machinery_calculation().calculate_machinery_system_output_from_time_series_result(
623
+ time_series=time_series,
624
+ steam_demand_kg_per_h=explicit,
625
+ )
626
+ res_table = _table_mode_boiler_result(explicit)
627
+ assert np.isclose(
628
+ res_override.fuel_consumption_boiler_total.total_fuel_consumption,
629
+ res_table.fuel_consumption_boiler_total.total_fuel_consumption,
630
+ )
631
+
632
+
633
+ def test_time_series_result_multiple_propulsors_boiler_steam():
634
+ """Per-instance boiler steam is carried through the multi-propulsor time-series path.
635
+
636
+ The boiler depends only on steam demand and the integration intervals, not on how propulsion
637
+ power is split across drives, so the boiler fuel/steam must match the single-propulsor
638
+ table-mode result for the same epochs and steam profile.
639
+ """
640
+ steam = np.array([2000.0, 3000.0, 4000.0, 5000.0])
641
+ machinery_calculation = _build_boiler_machinery_calculation()
642
+ propulsor_names = [drive.name for drive in machinery_calculation.electric_system.propulsion_drives]
643
+ # Split the same total propulsion power equally across the drives.
644
+ per_drive_power = [[prop / len(propulsor_names)] * len(propulsor_names) for prop in _BOILER_PROPULSION_KW]
645
+ time_series = TimeSeriesResultForMultiplePropulsors(
646
+ propulsion_power_timeseries=[
647
+ PropulsionPowerInstanceForMultiplePropulsors(
648
+ epoch_s=epoch,
649
+ propulsion_power_kw=power_row,
650
+ auxiliary_power_kw=_BOILER_AUX_KW,
651
+ boiler_steam_demand_kg_per_h=s,
652
+ )
653
+ for epoch, power_row, s in zip(_BOILER_EPOCHS, per_drive_power, steam)
654
+ ],
655
+ propulsor_names=propulsor_names,
656
+ )
657
+ res_proto = machinery_calculation.calculate_machinery_system_output_from_time_series_result(
658
+ time_series=time_series
659
+ )
660
+ res_table = _table_mode_boiler_result(steam)
661
+
662
+ assert res_proto.fuel_consumption_boiler_total.total_fuel_consumption > 0
663
+ assert np.isclose(res_proto.steam_production_boiler_total_kg, 150.0)
664
+ assert np.isclose(
665
+ res_proto.fuel_consumption_boiler_total.total_fuel_consumption,
666
+ res_table.fuel_consumption_boiler_total.total_fuel_consumption,
667
+ )
668
+ assert np.isclose(
669
+ res_proto.steam_production_boiler_total_kg,
670
+ res_table.steam_production_boiler_total_kg,
671
+ )
File without changes
File without changes
File without changes