Paper mill and synthetic aviation fuels - #818
Conversation
johnjasa
left a comment
There was a problem hiding this comment.
Thanks for this PR, @mariya-koleva! I'm really glad to see you contributing your useful models to H2I, both so others can use them but also so you can get more acclimated with the code and development process. I've pushed up some changes directly and left some non-exhaustive comments and questions.
My changes I pushed up directly:
- removed both the paper mill and SAF baseclasses in favor of just having directly performance and cost classes for them. The baseclass model makes more sense if we know we're going to have multiple technologies that inherit that, but we don't have that for paper mills and SAF, so to make the code easier to track, I removed the baseclasses.
- reworked the example's postprocessing methods so it doesn't use an ad hoc excel spreadsheet output method; instead we use the existing
sql_to_csvmethods to produce.csvfiles. This makes the example a bit cleaner and leans on existing H2I methods for postprocessing, Let me know if that was too big of a change and you want that back in the example and we can get it back in! - refactored some of the inner computations for the
compute()methods to make them hopefully a bit easier to read - added
plant_capacity_mtpyas an OpenMDAO inputs so we can more easily do parameter sweeps using it - removed a lot of the inputs that weren't used in the performance models
- added
ligninas an available commodity in thepipetransporter and updated the example to use that pipe
Additional changes I'm requesting for this PR:
- please add a test to
test_all_examples.pyfor this example case - add to the documentation for the new paper mill and SAF models; see other technology models for examples of this
- the SAF performance model does not actually use the lignin in any way. The cost model has lignin consumed, but the performance model doesn't use the
inputs['lignin_in']commodity stream. This should be changed so that the actual lignin produced by the paper mill is used by the SAF plant, or such that the lignin used in the SAF plant cannot exceed the inputted lignin profile. Right now, these two systems are not actually coupled because the lignin profile is not used.
Other thoughts on my mind as I went through this. I don't think we necessarily need to change these for this PR, but here's what I'm thinking:
- because the paper mill and SAF plant operate in steady state and assume constant production, they're relatively simplistic models in an H2I sense. If they varied hourly, we'd want to expose more variables to the OpenMDAO framework instead of just using the config file inputs directly. A specific example would be the electricity input profile being an input.
- beyond that, both the paper mill and SAF models don't have variables exposed to the OpenMDAO framework. This means that you couldn't do parameter sweeps or optimizations with these models very easily. I've made two variables into inputs to show how this would be done; if you think more variables should be exposed in this way to get more parameter sweep capabilities, we should add them as part of this PR
Overall, fantastic first big contribution to H2I! All my comments and suggestions are just in support of getting some of the code more in line with other H2I technology models so others can use and enjoy them. I'm happy to chat or work through any of this 1-on-1 with you, just let me know!
| file_path = "Breakdown_costs_per_scenario.xlsx" | ||
| df = pd.read_excel(file_path, sheet_name="Sheet1", header=None) |
There was a problem hiding this comment.
It looks like this file depends on an Excel sheet, but this isn't included as part of this PR. Do you mean to add the sheet, or have a different script that produces it? Or maybe just remove this file entirely?
There was a problem hiding this comment.
Just removing it entirely would be fine. The excel was added to help me do the plots for the E2C NE MN project.
| # # -*- coding: utf-8 -*- | ||
| # """ | ||
| # Created on Fri May 15 07:38:06 2026 | ||
|
|
||
| # @author: mkoleva | ||
| # """ | ||
|
|
||
| # import pandas as pd | ||
| # import matplotlib.pyplot as plt | ||
|
|
||
| # # Load Excel file |
There was a problem hiding this comment.
If keeping this file, could you please add a top-level docstring explanation about what it does (i.e. what's plotted, what needs to be run beforehand)?
There was a problem hiding this comment.
As we are removing the excel file, I would suggest we remove this one, too.
There was a problem hiding this comment.
What is the purpose of this file? Is it to test the ProFAST calculations for the paper mill, or something else? I'd suggest removing this or moving the file to a test folder and renaming the file to make it clear it's a test
There was a problem hiding this comment.
The file doesn't play a role in the analysis. I will remove it.
There was a problem hiding this comment.
I've refactored this to use existing postprocessing utilities in H2I; the SQL to .csv timeseries and summary info. I'd prefer to not have one-off postprocessing methods in the example scripts, but if you need this specific capability we can add it in a general way. Take a look at the outputted files using the built-in methods and see if it accomplishes your goal! I've also added a top-level docstring to this run script.
| outputs["lignin_out"] = 0.06 * 1000 * paper_mill_production_mtpy / 8760 # tons per hour | ||
| outputs["rated_lignin_production"] = 0.06 * 1000 * self.config.plant_capacity_mtpy / 8760 | ||
| outputs["total_lignin_produced"] = outputs["lignin_out"].sum() | ||
| outputs["annual_lignin_produced"] = outputs["total_lignin_produced"] * ( | ||
| 1 / self.fraction_of_year_simulated | ||
| ) | ||
|
|
||
| outputs["pulp_out"] = 1.1 * paper_mill_production_mtpy / 8760 |
There was a problem hiding this comment.
Would these multipliers, e.g. 0.06 and 1.1 ever change? As they're hardcoded currently, a user couldn't modify them without modifying the source code. I'd suggest exposing them as a config level input.
There was a problem hiding this comment.
I agree with this! If you do put these in the configuration class, it could be nice to link to the reference for these numbers in the doc string!
There was a problem hiding this comment.
These hard-coded numbers shouldn't change with this process. Nevertheless, I agree - it is a good idea to have them on a config level
| # TODO: Need to update labor cost | ||
| labor_cost_annual_operation = ( | ||
| 69375996.9 | ||
| * ((self.config.plant_capacity_mtpy / 365 * 1000) ** 0.25242) | ||
| / ((1162077 / 365 * 1000) ** 0.25242) | ||
| ) | ||
| labor_cost_maintenance = 0.00863 * total_plant_capex | ||
| 0.25 * (labor_cost_annual_operation + labor_cost_maintenance) |
There was a problem hiding this comment.
This labor cost calculation appears to be incomplete; L185 is a calculation that isn't assigned to any variable. Perhaps this is part of your TODO note, but could you revisit this and update it as needed?
| # Sum total consumables costs and consumption | ||
| c = self.config | ||
| consumable_costs_per_mt = { | ||
| "raw_water": c.raw_water_consumption * c.raw_water_unitcost, | ||
| "wood": c.wood_consumption * (c.wood_unitcost + c.wood_transport_cost), | ||
| "calcium_carbonate": c.calcium_carbonate_consumption | ||
| * (c.calcium_carbonate_unitcost + c.calcium_carbonate_transport_cost), | ||
| "sodium_sulfide": c.sodium_sulfide_consumption | ||
| * (c.sodium_sulfide_unitcost + c.sodium_sulfide_transport_cost), | ||
| "sodium_hydroxide": c.sodium_hydroxide_consumption | ||
| * (c.sodium_hydroxide_unitcost + c.sodium_hydroxide_transport_cost), | ||
| "chlorine_dioxide": c.chlorine_dioxide_consumption | ||
| * (c.chlorine_dioxide_unitcost + c.chlorine_dioxide_transport_cost), | ||
| "hydrogen_peroxide": c.hydrogen_peroxide_consumption | ||
| * (c.hydrogen_peroxide_unitcost + c.hydrogen_peroxide_transport_cost), | ||
| "magnesium_sulfate": c.magnesium_sulfate_consumption | ||
| * (c.magnesium_sulfate_unitcost + c.magnesium_sulfate_transport_cost), | ||
| "oxygen": c.oxygen_consumption * (c.oxygen_unitcost + c.oxygen_transport_cost), | ||
| } | ||
| variable_consumables_cost = c.plant_capacity_mtpy * sum(consumable_costs_per_mt.values()) |
There was a problem hiding this comment.
I've refactored this so hopefully it's a bit more readable and extensible
| hydrogen_chloride_consumption: float = field(default=1.5) # kg/MT product | ||
| electricity_consumption: float = field(default=19750) # kWh/tonne product | ||
| water_disposal_unitcost: float = field(default=0.002013) # $/kg | ||
| water_disposal_rate: float = field(default=0) # TODO: Change assumption |
There was a problem hiding this comment.
Address TODO as part of this PR?
There was a problem hiding this comment.
This would require some information digging so, I would suggest it is a future TODO, not a part of this PR.
elenya-grant
left a comment
There was a problem hiding this comment.
Hi Masha! So excited to see this PR and your contribution! I know it's one of your first PRs into the new H2I framework - so I was a bit overly verbose with my comments! Also - some of my comments may be in weird places since John pushed changes while I was reviewing it! I don't know much about these technologies - so some of my questions are just me trying to understand these techs. I mostly just reviewed the SAF and paper mill models so far (I haven't reviewed the example yet) - but I will do another review of the whole thing!
I think I'm curious on the separation between the performance model and the cost model. It seems like the cost models account for the feedstock usage but that wouldn't impact performance? That is OK - just curious about it!
I also think that some very basic unit or regression tests of the paper mill and SAF models would be nice to have. These tests would live in a file like h2integrate/converters/paper_mill/test/test_paper_mill.py and h2integrate/converters/saf/test/test_saf.py. I think it'd be good to test the performance and cost models separately, and perhaps also test them together! This would just help to ensure that any future changes to these models would be trackable through the tests and subtests.
Please let me know if you have any questions or want to discuss any of my comments! I agree with a lot of the suggestions from @johnjasa (and some of my comments may be repetitive of his - sorry!)
| self.add_input("magnesium_sulfate_in", val=0, shape=n_timesteps, units="kg/h") | ||
| self.add_input("oxygen_in", val=0, shape=n_timesteps, units="kg/h") | ||
| self.add_output("lignin_out", val=0.0, shape=n_timesteps, units="kg/h") | ||
| self.add_output("rated_lignin_production", shape=n_timesteps, val=0.0, units="kg/h") |
There was a problem hiding this comment.
it seems like the feedstocks for the paper mill are wood, water, calcium carbonate, sodium sulfide, chlorine dioxide, hydrogen peroxide, magnesium sulfate, and oxygen. Could you also add outputs for the amount of these consumed? Like water_consumed, calcium_carbonate_consumed, etc? This would make the performance model compatible with the feedstock models and could perhaps be used with other finance models!
Also! Is lignin the same as paper?
There was a problem hiding this comment.
Absolutely, will add the outputs and the amounts.
Lignin and paper are different in the sense that lignin could be considered by-product that is cellulose rich and is typically removed so that paper doesn't turn yellow and break down.
| self.add_output("rated_pulp_out_production", val=0.0, units="t/h") | ||
| self.add_output("total_pulp_out_produced", val=0.0, units="t") | ||
| self.add_output("annual_pulp_out_produced", val=0.0, units="t/year") |
There was a problem hiding this comment.
To align with the naming convention in the PerformanceModelBaseClass - I think these outputs should be rated_pulp_production, total_pulp_produced and annual_pulp_produced
| additional_cls_name=self.__class__.__name__, | ||
| ) | ||
|
|
||
| self.add_output("pulp_out", val=0.0, shape=n_timesteps, units="t/h") |
There was a problem hiding this comment.
I would suggest adding an input for the rated capacity, this would allow for the paper mill capacity to be an optimization variable:
self.add_input("paper_mill_capacity", val=self.config.plant_capacity_mtpy, shape=1, units="t/y")There was a problem hiding this comment.
Great suggestion!
| self.add_output("annual_pulp_out_produced", val=0.0, units="t/year") | ||
|
|
||
| def compute(self, inputs, outputs): | ||
| paper_mill_production_mtpy = self.config.plant_capacity_mtpy * self.config.capacity_factor |
There was a problem hiding this comment.
would the paper mill production be limited by the feedstocks? I'm curious why the performance model baseclass has inputs for things like oxygen, hydrogen peroxide, etc, but those aren't used in these calculations?
There was a problem hiding this comment.
The baseclass has been removed.
| 1 / self.fraction_of_year_simulated | ||
| ) | ||
|
|
||
| outputs["lignin_out"] = 0.06 * 1000 * paper_mill_production_mtpy / 8760 # tons per hour |
There was a problem hiding this comment.
I don't know much about paper mills - do they produce pulp, paper, and lignin?
There was a problem hiding this comment.
That's correct.
| wood_consumption: float = field(default=0.225) # MT/MT product | ||
| raw_water_consumption: float = field(default=40693) # kg/tonne product | ||
|
|
||
| calcium_carbonate_consumption: float = field(default=240) # kg/MT product | ||
| sodium_sulfide_consumption: float = field(default=16.5) # kg/MT product | ||
| sodium_hydroxide_consumption: float = field(default=3.5) # kg/MT product | ||
| chlorine_dioxide_consumption: float = field(default=2) # kg/MT product | ||
| hydrogen_peroxide_consumption: float = field(default=0.5) # kg/MT product | ||
| magnesium_sulfate_consumption: float = field(default=0.2) # kg/MT product | ||
| oxygen_consumption: float = field(default=2.25) # kg/MT product | ||
|
|
||
| electricity_consumption: float = field(default=68.7) # kWh/tonne product |
There was a problem hiding this comment.
Should these consumption ratios be parameters in the PaperMillPerformanceModelConfig?
There was a problem hiding this comment.
Will add them. Thank you!
| outputs["lignin_out"] = 0.06 * 1000 * paper_mill_production_mtpy / 8760 # tons per hour | ||
| outputs["rated_lignin_production"] = 0.06 * 1000 * self.config.plant_capacity_mtpy / 8760 | ||
| outputs["total_lignin_produced"] = outputs["lignin_out"].sum() | ||
| outputs["annual_lignin_produced"] = outputs["total_lignin_produced"] * ( | ||
| 1 / self.fraction_of_year_simulated | ||
| ) | ||
|
|
||
| outputs["pulp_out"] = 1.1 * paper_mill_production_mtpy / 8760 |
There was a problem hiding this comment.
I agree with this! If you do put these in the configuration class, it could be nice to link to the reference for these numbers in the doc string!
|
|
||
| def initialize(self): | ||
| super().initialize() | ||
| self.commodity = "saf" |
There was a problem hiding this comment.
could you add in the _control_classifier attribute to the SAFPerformanceModel - in the same place that _time_step_bounds is set? I'm not sure what this falls under (it's not dispatchable because demand is not an input, maybe it's fixed)? Any thoughts on what type of _control_classifier this should be @johnjasa
| Computes annual paper production based on plant capacity and capacity factor. | ||
| """ | ||
|
|
||
| _time_step_bounds = ( |
There was a problem hiding this comment.
could you add in the _control_classifier attribute to the PaperMillPerformanceModel - in the same place that _time_step_bounds is set? I'm not sure what this falls under (it's not dispatchable because demand is not an input, maybe it's fixed)? Any thoughts on what type of _control_classifier this should be @johnjasa
|
|
||
| self.add_input("plant_capacity_mtpy", val=self.config.plant_capacity_mtpy, units="t/year") | ||
|
|
||
| n_timesteps = self.options["plant_config"]["plant"]["simulation"]["n_timesteps"] |
There was a problem hiding this comment.
nitpick: after super().setup() is ran, the PerformanceModelBaseClass creates an attribute named n_timesteps (so you could replace n_timesteps with `self.n_timesteps)
There was a problem hiding this comment.
That's on me, I reworked these classes. I'll modify it!
Paper mill and synthetic aviation fuels
This feature add paper mill and synthetic aviation fuels (SAF) as converters and an example containing (1) paper mill only, (2) SAF only, (3) paper mill + SAF, depending on the need of the user. The paper mill process assumed is Kraft process. SAF is currently assuming all the lignin from the paper mill will be available for feedstock and hydrogen is delivered to SAF at a specified by the user price.
Section 1: Type of Contribution
Section 2: Draft PR Checklist
TODO:
Type of Reviewer Feedback Requested (on Draft PR)
Structural feedback: Please, provide comments how the structure of the added features could be improved.
Implementation feedback: Please, provide comments how the implementation of the added features could be improved.
Other feedback: None
Section 3: General PR Checklist
docs/files are up-to-date, or added when necessaryCHANGELOG.md"A complete thought. [PR XYZ]((https://github.com/NatLabRockies/H2Integrate/pull/XYZ)", where
XYZshould be replaced with the actual number.Section 4: Related Issues
Section 5: Impacted Areas of the Software
Section 5.1: New Files
h2integrate/converters/saf/saf.pyh2integrate/converters/saf/saf_baseclass.pymethod1: SAF converterh2integrate/converters/paper_mill/paper_mill.pyh2integrate/converters/paper_mill/paper_mill_baseclass.pymethod1: paper mill convertermethod1: a folder that contains the plant, tech and driver config yaml files as well as a run_paper_mill_mn.py fileSection 5.2: Modified Files
h2integrate/core/supported_models.pymethod1: Added the performance models entries of the new features.Section 6: Additional Supporting Information
Section 7: Test Results, if applicable
Section 8 (Optional): New Model Checklist
docs/developer_guide/coding_guidelines.mdattrsclass to define theConfigto load in attributes for the modelBaseConfigorCostModelBaseConfiginitialize()method,setup()method,compute()methodCostModelBaseClasssupported_models.pycreate_financial_modelinh2integrate_model.pytest_all_examples.pydocs/user_guide/model_overview.mddocs/section<model_name>.mdis added to the_toc.ymlgenerate_class_hierarchy.pyto update the class hierarchy diagram indocs/developer_guide/class_structure.md