RunFeemsSim 0.6.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.6.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
  *,
@@ -362,11 +381,7 @@ class MachineryCalculation:
362
381
  propulsion_power_time_series=propulsion_power,
363
382
  auxiliary_load_kw=auxiliary_power_kw,
364
383
  )
365
- if self.system_feems.boiler is not None:
366
- self.system_feems.boiler.steam_out_kg_per_h = (
367
- steam_demand_kg_per_h if steam_demand_kg_per_h is not None
368
- else np.zeros(n_steps)
369
- )
384
+ self._set_boiler_steam_demand(steam_demand_kg_per_h)
370
385
  return self._run_simulation(
371
386
  fuel_specified_by=fuel_specified_by,
372
387
  ignore_power_balance=ignore_power_balance,
@@ -394,7 +409,10 @@ class MachineryCalculation:
394
409
  fuel_option(FuelOption): The fuel option to be used in the simulation. If None, the
395
410
  default fuel option in the system will be used.
396
411
  steam_demand_kg_per_h: Steam demand time series (kg/h). Must have the same length as
397
- the time series. If None and a boiler is attached, demand is treated as zero.
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.
398
416
 
399
417
  Returns:
400
418
  The result of the simulation. FEEMSResult or FEEMSResultForMachinery system.
@@ -423,11 +441,18 @@ class MachineryCalculation:
423
441
  f"The length of steam_demand_kg_per_h ({len(steam_demand_kg_per_h)}) must match "
424
442
  f"the time series ({n_steps})."
425
443
  )
426
- if self.system_feems.boiler is not None:
427
- self.system_feems.boiler.steam_out_kg_per_h = (
428
- steam_demand_kg_per_h if steam_demand_kg_per_h is not None
429
- else np.zeros(n_steps)
430
- )
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)
431
456
  return self._run_simulation(
432
457
  fuel_specified_by=fuel_specified_by,
433
458
  ignore_power_balance=ignore_power_balance,
@@ -480,11 +505,10 @@ class MachineryCalculation:
480
505
  auxiliary_load_kw=auxiliary_power_kw,
481
506
  time_is_given_as_interval=True,
482
507
  )
483
- if self.system_feems.boiler is not None:
484
- self.system_feems.boiler.steam_out_kg_per_h = (
485
- steam_demand_kg_per_h if steam_demand_kg_per_h is not None
486
- else np.zeros(n_steps)
487
- )
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)
488
512
  return self._run_simulation(
489
513
  fuel_specified_by=fuel_specified_by,
490
514
  ignore_power_balance=ignore_power_balance,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: RunFeemsSim
3
- Version: 0.6.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.6.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