Skip to content

Migrate costing metrics from valorization_costing_block - #1764

Open
luohezhiming wants to merge 26 commits into
watertap-org:mainfrom
luohezhiming:migrate_valorization_costing_block
Open

Migrate costing metrics from valorization_costing_block#1764
luohezhiming wants to merge 26 commits into
watertap-org:mainfrom
luohezhiming:migrate_valorization_costing_block

Conversation

@luohezhiming

Copy link
Copy Markdown
Contributor

Fixes/Resolves:

(replace this with the issue # fixed or resolved, if no issue exists then a brief statement of what this PR does)

Summary/Motivation:

Migrate costing metrics from valorization_costing_block from brackish_valorization_reaktoro

Changes proposed in this PR:

Legal Acknowledgement

By contributing to this software project, I agree to the following terms and conditions for my contribution:

  1. I agree my contributions are submitted under the license terms described in the LICENSE.txt file at the top level of this directory.
  2. I represent I am authorized to make the contributions and grant the license. If my employer has rights to intellectual property that includes these contributions, I represent that I have received permission to make contributions and grant the required license on behalf of that employer.

@luohezhiming luohezhiming self-assigned this Apr 21, 2026
@ksbeattie ksbeattie added the Priority:Normal Normal Priority Issue or PR label Apr 30, 2026
Comment thread watertap/costing/watertap_costing_package.py Outdated
Comment thread watertap/costing/watertap_costing_package.py Outdated
Comment thread watertap/costing/watertap_costing_package.py Outdated
Comment thread watertap/costing/watertap_costing_package.py Outdated
@tristantc

Copy link
Copy Markdown
Contributor

I suggest consolidating mass-based and volumetric methods into shared basis-aware implementation for each metric, so the same methods can handle either flow basis through flow_basis.

def add_LCOW(self, flow_rate, flow_basis="volumetric", name="LCOW"):
    """
    Add Levelized Cost of Water (LCOW) or Product (LCOP) to costing block.

    Args:
        flow_rate: flow rate to be used in calculating the levelized cost
        flow_basis: basis for the flow rate, either "volumetric" or "mass"
        name: name for the levelized cost expression
    """
    if flow_basis not in ("volumetric", "mass"):
        raise ValueError(
            f"Unrecognized flow_basis {flow_basis}. Valid options are "
            "'volumetric' and 'mass'."
        )

    flow_units = (
        pyo.units.m**3 / self.base_period
        if flow_basis == "volumetric"
        else pyo.units.kg / self.base_period
    )
    denominator = (
        pyo.units.convert(flow_rate, to_units=flow_units)
        * self.utilization_factor
    )

    self.add_component(
        name,
        pyo.Expression(
            expr=(
                self.total_capital_cost * self.capital_recovery_factor
                + self.total_operating_cost
            )
            / denominator,
            doc=f"Levelized cost based on flow {flow_rate.name}",
        ),
    )


def add_specific_energy_consumption(
    self, flow_rate, flow_basis="volumetric", name="specific_energy_consumption"
):
    """
    Add specific energy consumption (kWh/m^3 or kWh/kg) to costing block.

    Args:
        flow_rate: flow rate to be used in calculating specific energy consumption
        flow_basis: basis for the flow rate, either "volumetric" or "mass"
        name: name for the specific energy consumption expression
    """
    if flow_basis not in ("volumetric", "mass"):
        raise ValueError(
            f"Unrecognized flow_basis {flow_basis}. Valid options are "
            "'volumetric' and 'mass'."
        )

    flow_units = (
        pyo.units.m**3 / pyo.units.hr
        if flow_basis == "volumetric"
        else pyo.units.kg / pyo.units.hr
    )

    self.add_component(
        name,
        pyo.Expression(
            expr=self.aggregate_flow_electricity
            / pyo.units.convert(flow_rate, to_units=flow_units),
            doc=f"Specific energy consumption based on flow {flow_rate.name}",
        ),
    )


def add_annual_water_production(
    self, flow_rate, flow_basis="volumetric", name="annual_water_production"
):
    """
    Add annual water production or product generation to costing block.

    Args:
        flow_rate: flow rate to be used in calculating annual production
        flow_basis: basis for the flow rate, either "volumetric" or "mass"
        name: name for the annual production expression
    """
    if flow_basis not in ("volumetric", "mass"):
        raise ValueError(
            f"Unrecognized flow_basis {flow_basis}. Valid options are "
            "'volumetric' and 'mass'."
        )

    flow_units = (
        pyo.units.m**3 / self.base_period
        if flow_basis == "volumetric"
        else pyo.units.kg / self.base_period
    )

    self.add_component(
        name,
        pyo.Expression(
            expr=pyo.units.convert(flow_rate, to_units=flow_units)
            * self.utilization_factor,
            doc=f"Annual production based on flow {flow_rate.name}",
        ),
    )

Possible culprits:

  1. Some of the supporting component-by-component and aggregate breakdown expressions still assume volumetric units, so those would also need to be generalized for flow_basis.
  2. Some docstrings and names are still water-specific, which could be confusing when the same methods are used for mass-based product calculations.
  3. If the mass-specific methods are removed immediately, existing downstream code may break.

Follow-up actions:

  • Add tests for volumetric path, mass path, and invalid flow_basis.
  • Update docs/examples to show basis-aware calls.

luohezhiming and others added 4 commits May 5, 2026 10:20
Co-authored-by: Carolina Tristan <ctristan@purdue.edu>
Co-authored-by: Carolina Tristan <ctristan@purdue.edu>
Co-authored-by: Carolina Tristan <ctristan@purdue.edu>
Co-authored-by: Carolina Tristan <ctristan@purdue.edu>
@luohezhiming

Copy link
Copy Markdown
Contributor Author

I suggest consolidating mass-based and volumetric methods into shared basis-aware implementation for each metric, so the same methods can handle either flow basis through flow_basis.

def add_LCOW(self, flow_rate, flow_basis="volumetric", name="LCOW"):
    """
    Add Levelized Cost of Water (LCOW) or Product (LCOP) to costing block.

    Args:
        flow_rate: flow rate to be used in calculating the levelized cost
        flow_basis: basis for the flow rate, either "volumetric" or "mass"
        name: name for the levelized cost expression
    """
    if flow_basis not in ("volumetric", "mass"):
        raise ValueError(
            f"Unrecognized flow_basis {flow_basis}. Valid options are "
            "'volumetric' and 'mass'."
        )

    flow_units = (
        pyo.units.m**3 / self.base_period
        if flow_basis == "volumetric"
        else pyo.units.kg / self.base_period
    )
    denominator = (
        pyo.units.convert(flow_rate, to_units=flow_units)
        * self.utilization_factor
    )

    self.add_component(
        name,
        pyo.Expression(
            expr=(
                self.total_capital_cost * self.capital_recovery_factor
                + self.total_operating_cost
            )
            / denominator,
            doc=f"Levelized cost based on flow {flow_rate.name}",
        ),
    )


def add_specific_energy_consumption(
    self, flow_rate, flow_basis="volumetric", name="specific_energy_consumption"
):
    """
    Add specific energy consumption (kWh/m^3 or kWh/kg) to costing block.

    Args:
        flow_rate: flow rate to be used in calculating specific energy consumption
        flow_basis: basis for the flow rate, either "volumetric" or "mass"
        name: name for the specific energy consumption expression
    """
    if flow_basis not in ("volumetric", "mass"):
        raise ValueError(
            f"Unrecognized flow_basis {flow_basis}. Valid options are "
            "'volumetric' and 'mass'."
        )

    flow_units = (
        pyo.units.m**3 / pyo.units.hr
        if flow_basis == "volumetric"
        else pyo.units.kg / pyo.units.hr
    )

    self.add_component(
        name,
        pyo.Expression(
            expr=self.aggregate_flow_electricity
            / pyo.units.convert(flow_rate, to_units=flow_units),
            doc=f"Specific energy consumption based on flow {flow_rate.name}",
        ),
    )


def add_annual_water_production(
    self, flow_rate, flow_basis="volumetric", name="annual_water_production"
):
    """
    Add annual water production or product generation to costing block.

    Args:
        flow_rate: flow rate to be used in calculating annual production
        flow_basis: basis for the flow rate, either "volumetric" or "mass"
        name: name for the annual production expression
    """
    if flow_basis not in ("volumetric", "mass"):
        raise ValueError(
            f"Unrecognized flow_basis {flow_basis}. Valid options are "
            "'volumetric' and 'mass'."
        )

    flow_units = (
        pyo.units.m**3 / self.base_period
        if flow_basis == "volumetric"
        else pyo.units.kg / self.base_period
    )

    self.add_component(
        name,
        pyo.Expression(
            expr=pyo.units.convert(flow_rate, to_units=flow_units)
            * self.utilization_factor,
            doc=f"Annual production based on flow {flow_rate.name}",
        ),
    )

Possible culprits:

  1. Some of the supporting component-by-component and aggregate breakdown expressions still assume volumetric units, so those would also need to be generalized for flow_basis.
  2. Some docstrings and names are still water-specific, which could be confusing when the same methods are used for mass-based product calculations.
  3. If the mass-specific methods are removed immediately, existing downstream code may break.

Follow-up actions:

  • Add tests for volumetric path, mass path, and invalid flow_basis.
  • Update docs/examples to show basis-aware calls.

I think keep add_LCOP and SEC with respect to product will be more reasonable, it's not only we want to account for volumetric and mass flow, the major difference is to base on product or water, and usually the product is mass based, the product can be any byproducts like if we can recovering nutrients. Then we devide by the mass flow of nutrients, the performance index like levelized cost of product should not be called LCOW anymore, it is supposed to be called LCOP

@luohezhiming

Copy link
Copy Markdown
Contributor Author

I suggest consolidating mass-based and volumetric methods into shared basis-aware implementation for each metric, so the same methods can handle either flow basis through flow_basis.

def add_LCOW(self, flow_rate, flow_basis="volumetric", name="LCOW"):
    """
    Add Levelized Cost of Water (LCOW) or Product (LCOP) to costing block.

    Args:
        flow_rate: flow rate to be used in calculating the levelized cost
        flow_basis: basis for the flow rate, either "volumetric" or "mass"
        name: name for the levelized cost expression
    """
    if flow_basis not in ("volumetric", "mass"):
        raise ValueError(
            f"Unrecognized flow_basis {flow_basis}. Valid options are "
            "'volumetric' and 'mass'."
        )

    flow_units = (
        pyo.units.m**3 / self.base_period
        if flow_basis == "volumetric"
        else pyo.units.kg / self.base_period
    )
    denominator = (
        pyo.units.convert(flow_rate, to_units=flow_units)
        * self.utilization_factor
    )

    self.add_component(
        name,
        pyo.Expression(
            expr=(
                self.total_capital_cost * self.capital_recovery_factor
                + self.total_operating_cost
            )
            / denominator,
            doc=f"Levelized cost based on flow {flow_rate.name}",
        ),
    )


def add_specific_energy_consumption(
    self, flow_rate, flow_basis="volumetric", name="specific_energy_consumption"
):
    """
    Add specific energy consumption (kWh/m^3 or kWh/kg) to costing block.

    Args:
        flow_rate: flow rate to be used in calculating specific energy consumption
        flow_basis: basis for the flow rate, either "volumetric" or "mass"
        name: name for the specific energy consumption expression
    """
    if flow_basis not in ("volumetric", "mass"):
        raise ValueError(
            f"Unrecognized flow_basis {flow_basis}. Valid options are "
            "'volumetric' and 'mass'."
        )

    flow_units = (
        pyo.units.m**3 / pyo.units.hr
        if flow_basis == "volumetric"
        else pyo.units.kg / pyo.units.hr
    )

    self.add_component(
        name,
        pyo.Expression(
            expr=self.aggregate_flow_electricity
            / pyo.units.convert(flow_rate, to_units=flow_units),
            doc=f"Specific energy consumption based on flow {flow_rate.name}",
        ),
    )


def add_annual_water_production(
    self, flow_rate, flow_basis="volumetric", name="annual_water_production"
):
    """
    Add annual water production or product generation to costing block.

    Args:
        flow_rate: flow rate to be used in calculating annual production
        flow_basis: basis for the flow rate, either "volumetric" or "mass"
        name: name for the annual production expression
    """
    if flow_basis not in ("volumetric", "mass"):
        raise ValueError(
            f"Unrecognized flow_basis {flow_basis}. Valid options are "
            "'volumetric' and 'mass'."
        )

    flow_units = (
        pyo.units.m**3 / self.base_period
        if flow_basis == "volumetric"
        else pyo.units.kg / self.base_period
    )

    self.add_component(
        name,
        pyo.Expression(
            expr=pyo.units.convert(flow_rate, to_units=flow_units)
            * self.utilization_factor,
            doc=f"Annual production based on flow {flow_rate.name}",
        ),
    )

Possible culprits:

  1. Some of the supporting component-by-component and aggregate breakdown expressions still assume volumetric units, so those would also need to be generalized for flow_basis.
  2. Some docstrings and names are still water-specific, which could be confusing when the same methods are used for mass-based product calculations.
  3. If the mass-specific methods are removed immediately, existing downstream code may break.

Follow-up actions:

  • Add tests for volumetric path, mass path, and invalid flow_basis.
  • Update docs/examples to show basis-aware calls.

I suggest consolidating mass-based and volumetric methods into shared basis-aware implementation for each metric, so the same methods can handle either flow basis through flow_basis.

def add_LCOW(self, flow_rate, flow_basis="volumetric", name="LCOW"):
    """
    Add Levelized Cost of Water (LCOW) or Product (LCOP) to costing block.

    Args:
        flow_rate: flow rate to be used in calculating the levelized cost
        flow_basis: basis for the flow rate, either "volumetric" or "mass"
        name: name for the levelized cost expression
    """
    if flow_basis not in ("volumetric", "mass"):
        raise ValueError(
            f"Unrecognized flow_basis {flow_basis}. Valid options are "
            "'volumetric' and 'mass'."
        )

    flow_units = (
        pyo.units.m**3 / self.base_period
        if flow_basis == "volumetric"
        else pyo.units.kg / self.base_period
    )
    denominator = (
        pyo.units.convert(flow_rate, to_units=flow_units)
        * self.utilization_factor
    )

    self.add_component(
        name,
        pyo.Expression(
            expr=(
                self.total_capital_cost * self.capital_recovery_factor
                + self.total_operating_cost
            )
            / denominator,
            doc=f"Levelized cost based on flow {flow_rate.name}",
        ),
    )


def add_specific_energy_consumption(
    self, flow_rate, flow_basis="volumetric", name="specific_energy_consumption"
):
    """
    Add specific energy consumption (kWh/m^3 or kWh/kg) to costing block.

    Args:
        flow_rate: flow rate to be used in calculating specific energy consumption
        flow_basis: basis for the flow rate, either "volumetric" or "mass"
        name: name for the specific energy consumption expression
    """
    if flow_basis not in ("volumetric", "mass"):
        raise ValueError(
            f"Unrecognized flow_basis {flow_basis}. Valid options are "
            "'volumetric' and 'mass'."
        )

    flow_units = (
        pyo.units.m**3 / pyo.units.hr
        if flow_basis == "volumetric"
        else pyo.units.kg / pyo.units.hr
    )

    self.add_component(
        name,
        pyo.Expression(
            expr=self.aggregate_flow_electricity
            / pyo.units.convert(flow_rate, to_units=flow_units),
            doc=f"Specific energy consumption based on flow {flow_rate.name}",
        ),
    )


def add_annual_water_production(
    self, flow_rate, flow_basis="volumetric", name="annual_water_production"
):
    """
    Add annual water production or product generation to costing block.

    Args:
        flow_rate: flow rate to be used in calculating annual production
        flow_basis: basis for the flow rate, either "volumetric" or "mass"
        name: name for the annual production expression
    """
    if flow_basis not in ("volumetric", "mass"):
        raise ValueError(
            f"Unrecognized flow_basis {flow_basis}. Valid options are "
            "'volumetric' and 'mass'."
        )

    flow_units = (
        pyo.units.m**3 / self.base_period
        if flow_basis == "volumetric"
        else pyo.units.kg / self.base_period
    )

    self.add_component(
        name,
        pyo.Expression(
            expr=pyo.units.convert(flow_rate, to_units=flow_units)
            * self.utilization_factor,
            doc=f"Annual production based on flow {flow_rate.name}",
        ),
    )

Possible culprits:

  1. Some of the supporting component-by-component and aggregate breakdown expressions still assume volumetric units, so those would also need to be generalized for flow_basis.
  2. Some docstrings and names are still water-specific, which could be confusing when the same methods are used for mass-based product calculations.
  3. If the mass-specific methods are removed immediately, existing downstream code may break.

Follow-up actions:

  • Add tests for volumetric path, mass path, and invalid flow_basis.
  • Update docs/examples to show basis-aware calls.

@tristantc for LCOP and annual_product_generation, please double check their performance indices name, I don't think user should expect to use add_LCOW function to add LCOP and add_annual_water_production function for adding product generation. The major difference is not only the volumetric-base and mass-base, the product does not need to be water, user will not use add_annual_water_production to add generation for product. maybe the specific_energy_consumption we should change the name, but I think we still need to keep different functions

@ksbeattie

Copy link
Copy Markdown
Contributor

@luohezhiming, movnig this to Sept, speak up if you disagree.

"'volumetric', 'mass', and 'energy'."
)

flow_units = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not add these as a property of the class like we have base_currency and base_period? A user might want to use a different basis for flow.

"""
return self.add_annual_total(flow_rate, flow_basis="volumetric", name=name)

def add_annual_total(self, flow_rate, flow_basis="volumetric", name="annual_total"):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add_annual_total_product is a better name imo; water is still a product.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not restricted to product; could be any annual total

@kurbansitterley kurbansitterley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Is the purpose of these changes only to add levelized metrics based on mass?
  2. I would prefer if any changes in this PR goes along with proper documentation changes (in this PR).

Comment on lines +161 to +162
m.fs.product.properties[0].flow_vol,
flow_basis="volumetric",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add test to check what happens when flow basis is different from actual variable supplied (e.g., flow_vol, flow_basis="mass")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed

Copilot AI added a commit that referenced this pull request Jul 17, 2026
… call sites

The PR #1764 (migrate_valorization_costing_block) changed the signature of
add_flow_component_breakdown() to require 'name' as 2nd positional arg and
'flow_rate' as 3rd, but forgot to update docs/how_to_guides/how_to_use_watertap_costing.rst.

This commit applies all PR changes including:
- New _FLOW_BASIS_KEYWORDS and _check_flow_basis_consistency for flow basis validation
- New add_levelized_cost method supporting volumetric/mass/energy flow bases
- New add_annual_total method
- Updated add_specific_energy_consumption with flow_basis parameter
- Updated add_flow_component_breakdown with new required 'name' arg as 2nd positional
- Updated add_specific_electrical_carbon_intensity internal call
- Updated ion_exchange_demo.py call
- Updated test_costing_base.py (new test_flow_basis_mismatch replaces test_breakdowns_with_no_unit)
- Fixed all 4 calls in how_to_use_watertap_costing.rst (the missing fix causing CI failure)
- Updated code-block example in costing_base.rst
@tristantc
tristantc requested a review from MarcusHolly as a code owner July 17, 2026 14:16

@tristantc tristantc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, but we should make sure to include documentation for the updated cost metrics in a later PR

name,
pyo.Expression(
expr=(
self.total_capital_cost * self.capital_recovery_factor

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, we actually have total_annualized_cost Expression that we can simply use here in stread-> will make it alot more clear.

@avdudchenko avdudchenko Jul 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So then here we would simply


 self.add_component(
            name,
            pyo.Expression(
                expr=pyunits.convert((self.total_annualzied_cost/(flow_rate*utiliation_factor), to_unit=normalized_units[flow_type]))

Where normalzied units is either pre-registerd dict unit or user supplied unit in cost/amount format

so we would need
normalized_units={'mass':self.base_currency/pyunits.kg... etc}

Then if user provides a desired unit, we use it instead of the one registed.

)
self._check_flow_basis_consistency(flow_rate, flow_basis)

flow_units = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should really let user define final desired units USD/kg as well as an option. I also think, it makes more sense to instead of covnerting the flow to instead enforce final conversion (e.g we should define things on final cost basis (USD/kg ,USD/m3 etc. Ideally we should simply pull out actual mass/flow basis unit from the Var being passed but could not finda simple way to do it.

)
self._check_flow_basis_consistency(flow_rate, flow_basis)

flow_units = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above, user should supply desired final unit (kW/kg etc. we should also do conversion and defin self.base_energy_units -> actually, it will be way better if we create bese units for costing and use them through out instead of manually defining them - we then should create a function that will return the right base untis for conversion. e.g. def get_normalzied_cost(self, type='volume'): -> returns self.base_currancy/self.base_volume

etc.

@avdudchenko avdudchenko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is overall misses a number of improvements and simplifications in implementation.

(1) We should commit to defining base units in costing for flow, mass, power etc. same as we do for cost and base_period, we should be consistent.
(2) We should have a standard function to get normalized units that is used by levelized/total methods. This way we do not constantly defining unit dicts everywhere.

This also means a user can change base untis as they see fit - .e.g we should have a way for user to specify base units for costing after creation (m.fs.costing() - or via config - pick one...) m.fs.costing.set_base_cost_untis('USD_2024), set_base_power_units('kW') etc.

This levelized functions should be applying a conversion to levelized value in final expression, rather just converting the flow rate. - the user should also be able to specify the units (USD/kg as they fit. We should also run check_units_consistent on expression build, to ensrue its all good.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Priority:Normal Normal Priority Issue or PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants