Cost and climate impact of a Rankine cycle¶
The Rankine cycle of the first example, solved again and then assessed: what its equipment costs, what it costs to run, and its climate-change impact. An assessment is a function of the verified state. Attaching one changes neither the solution, nor its certificate, nor the run's replay identity.
The solve uses only the public application contract. The assessment uses the assessment layer,
openflowsheet.assessment, which reads the solved state; the CLI, HTTP and MCP interfaces do not
offer it yet. The user guide explains each part of the document below.
Solving the cycle¶
The revision is the Rankine example's, read from its change set
(examples/rankine-cycle.changeset.json in a source checkout). The run's replay bundle holds the
solved state and the revision document exactly as it was solved.
import json
from pathlib import Path
from openflowsheet.application.local import LocalApplication
from openflowsheet.application.types import Change
NAME = Path("examples", "rankine-cycle.changeset.json")
CHANGE_SET = next(
base / NAME for base in (Path.cwd(), *Path.cwd().parents) if (base / NAME).is_file()
)
change, expected_revision, key = Change.from_change_set(json.loads(CHANGE_SET.read_text()))
app = LocalApplication.in_memory()
committed = app.commit_change(change, expected_revision, key)
run = app.solve(committed.revision_id, "default")
print(run.outcome, run.verification_status)
bundle = next(o.artifact_id for o in run.outputs if o.kind == "replay_bundle")
state = json.loads(app.artifact_bytes(f"{bundle}/solution-state.json"))["variables"]
revision = json.loads(app.artifact_bytes(f"{bundle}/revision.json"))
app.close()
CONVERGED VERIFIED
The assessment document¶
An assessment is a JSON document (schemas/assessment.schema.json). This one states:
- the cost basis (US dollars of 2023), the operating hours and the annualisation;
- the correlation set,
sslw-idaes-v1, and a bare-module factor for each costed item, named by unit id (P.driveis the pump's electric motor); - the utilities and their prices, and the feed water as a raw material;
- the capital chain from bare-module costs to total capital, and maintenance as a fixed operating cost;
- for the LCA, each utility's and material's exchange with a background activity, and the
functional unit: one kilogram of condensate leaving the condenser (stream
S5).
The factors and prices are this example's inputs, not recommendations.
The LCA uses test-background-v1, a synthetic three-activity background that ships for
tests. Its scores are not those of any real process, so the impact below shows how the
calculation works and says nothing about a real power plant. A real study uses a unit-score
table built from an LCA database with the lca extra.
def exchange(activity, per):
"""One unit of a test-background activity per `per` of the boundary quantity."""
return [{"table": "test-background-v1", "activity": activity, "amount": 1.0, "per": per}]
document = {
"schema_version": "assessment-v1",
"assessment_id": "rankine-cost-and-gwp",
"basis": {"currency": "USD", "year": 2023},
"operation": {"hours_per_year": 8000.0, "lifetime_years": 20, "discount_rate": 0.08},
"correlation_set": "sslw-idaes-v1",
"defaults": {"U_by_service": {"heating": 800.0, "cooling": 500.0}},
"items": {
"P": {"F_BM": 3.3},
"P.drive": {"F_BM": 1.5},
"B": {"F_BM": 3.17},
"T": {"F_BM": 3.5},
"C": {"F_BM": 3.17},
},
"utilities": [
{
"utility_id": "HPS",
"kind": "heating",
"T_supply": 750.0,
"dT_min": 10.0,
"price": {"value": 12.0, "per": "GJ"},
"lca": exchange("tb.heat", "MJ"),
},
{
"utility_id": "CW",
"kind": "cooling",
"T_supply": 293.15,
"T_return": 303.15,
"dT_min": 5.0,
"price": {"value": 0.5, "per": "GJ"},
},
{
"utility_id": "EL",
"kind": "electricity",
"price": {"value": 0.1, "per": "kWh"},
"lca": exchange("tb.electricity", "kWh"),
},
],
"materials": [
{
"connection": "S1",
"role": "raw_material",
"basis": "mass_total",
"price": {"value": 0.002, "per": "kg"},
"lca": exchange("tb.chemical_x", "kg"),
}
],
"capital": {"chain": "ebtf-prisma-v1"},
"fixed_opex": [{"line": "maintenance", "fraction_of": {"of": "fci", "fraction": 0.03}}],
"lca": {
"tables": ["test-background-v1"],
"categories": ["climate_change_test"],
"functional_unit": {"connection": "S5", "basis": "mass_total"},
},
"uncertainty": {"asm:utility.EL.price": {"relative_range": [-0.2, 0.2]}},
}
Building the assessment¶
bind_revision_flowsheet binds the solved revision; view_of gives the assessment its view of
the flowsheet. AssessmentProgram.build plans each contributor (cost, LCA) at the solved state
and selects the regime: the discrete choices, such as the utility that serves each exchanger
or the size range of each correlation, that the cost depends on. It returns a program, or a typed
Refusal when the request is not supported; it raises nothing for an unsupported request.
from openflowsheet.application.revision_binding import bind_revision_flowsheet
from openflowsheet.assessment.program import AssessmentProgram
from openflowsheet.assessment.spec import load_assessment
from openflowsheet.assessment.views import view_of
from openflowsheet.canonical import state_sha256
binding = bind_revision_flowsheet(revision)
program = AssessmentProgram.build(
view_of(binding.flowsheet), binding.spec, load_assessment(document), state
)
report = program.report(state, state_sha256=state_sha256(state, binding.spec.variable_ids))
print(report["status"])
for entry in report["regime"]["entries"]:
print(f" {entry['entry_id']:18s} {entry['choice']}")
COMPLETE P.S1.dormant active B.service heating B.utility HPS C.service cooling C.utility CW P.size floor P.drive.size floor B.size floor B.pressure.piece 1 T.size units:1 C.size floor C.pressure.piece floor
Capital cost¶
Each costed item reports its size, the correlation and its source, the escalation from the
correlation's cost basis to 2023, and its purchased and bare-module cost. The capital chain
ebtf-prisma-v1 turns the sum of bare-module costs into the total capital.
for item in report["items"]:
print(
f"{item['item_id']:8s} {item['family']:18s} {item['status']:7s}"
f" purchased {item['purchased_value']:9.0f} bare module {item['bare_module_value']:9.0f}"
f" {item['source']['entry']}"
)
print(f"total capital {report['capex']['capex.total']:12.0f} USD")
P machine.pump costed purchased 16039 bare module 52928 pump P.drive machine.drive costed purchased 468 bare module 702 motor.electric B exchanger.utility costed purchased 64970 bare module 205956 exchanger.shell_tube T machine.turbine costed purchased 8726 bare module 30542 turbine C exchanger.utility costed purchased 59824 bare module 189642 exchanger.shell_tube total capital 836869 USD
At one mole per second every item is smaller than its correlation's smallest size, so each
.size entry of the regime is floor: the smallest unit is bought whatever the size below it,
at a constant cost (the derivative of that cost with respect to the state is exactly zero).
The page does not restate the capital figures in its text; the printed values are what the
software computes when the page is built.
Operating cost and annual figures¶
The steam bill follows from the boiler duty and the steam price; electricity is netted, and the turbine generates more than the pump's motor draws, so the electricity line is a credit. The annual figures add the capital charge (the capital recovered over the lifetime at the discount rate) to the operating cost.
for group in ("opex", "annual"):
for name, value in report[group].items():
print(f"{name:26s} {value:14.2f} {program.units[name]}")
opex.fixed.maintenance 25106.07 USD/yr opex.material.S1 1037.68 USD/yr opex.total 33618.93 USD/yr opex.utility.CW 559.57 USD/yr opex.utility.EL -12006.85 USD/yr opex.utility.HPS 18922.46 USD/yr annual.capital_charge 85236.95 USD/yr lcox 0.23 USD/kg product.annual 518839.72 kg/yr profit -118855.88 USD/yr revenue.total 0.00 USD/yr tac 118855.88 USD/yr
Climate-change impact¶
The impact is the inventory at the boundary — steam heat, net electricity and feed water, each per second — multiplied by the background table's unit scores. Because the scores are inputs of the compiled functional, the derivative of the impact with respect to each score is that activity's inventory amount, which gives the contribution analysis below directly.
impacts = {row["id"]: row["value"] for row in report["lca"]["impacts"]}
gwp_rate = impacts["impact.climate_change_test.rate"]
gwp_per_kg = impacts["impact.climate_change_test.per_fu"]
for row in report["lca"]["inventory"]:
print(f"{row['id']:44s} {row['value']:12.6f} {row['unit']}")
print(f"impact {gwp_rate:.6f} kg CO2-eq/s, {gwp_per_kg:.4f} kg CO2-eq per kg of condensate")
scores = [name for name in program.theta if name.startswith("asm:lca.q.")]
lin = program.linearize(state, {}, ["impact.climate_change_test.rate"], scores)
for name, amount in zip(lin.d_dp.col_ids, lin.d_dp.data, strict=True):
share = program.theta[name].value * amount
print(f" {name.rsplit('.', 1)[1]:15s} {share:10.6f} kg CO2-eq/s")
inventory.material.S1.tb.chemical_x.rate 0.018015 kg/s inventory.utility.EL.tb.electricity.rate -0.004169 kWh/s inventory.utility.HPS.tb.heat.rate 0.054752 MJ/s impact 0.024320 kg CO2-eq/s, 1.3500 kg CO2-eq per kg of condensate heat 0.010354 kg CO2-eq/s electricity -0.002282 kg CO2-eq/s chemical_x 0.016247 kg CO2-eq/s
Accuracy class, uncertainty and limitations¶
The estimate's accuracy class is the largest AACE class over the costed items, with its range. Inputs with a declared range (here the electricity price, ±20 %, and each correlation's own accuracy) are propagated to first order: a worst-case interval and a standard deviation, each with its stated assumption. Every report lists its limitations and closes with the same statement.
uncertainty = report["uncertainty"]
print("AACE class", uncertainty["aace_class"], uncertainty["aace_range"])
low, high = uncertainty["linear_interval"]["tac"]
print(f"tac interval {low:.0f} to {high:.0f} USD/yr, sigma {uncertainty['rss_sigma']['tac']:.0f}")
for name, assumption in uncertainty["assumptions"].items():
print(f" {name}: {assumption}")
for limitation in report["limitations"]:
print("-", limitation["code"], limitation["target"])
print(report["statement"])
AACE class 4 {'low': [-0.15, -0.3], 'high': [0.2, 0.5]}
tac interval 83352 to 176429 USD/yr, sigma 15283
linear_interval: linear, first order; each input at its own adverse bound (box worst case; conservative for any correlation)
rss_sigma: linear, first order; inputs independent and uniform on their ranges
- LMTD_ACROSS_PHASE_CHANGE B
- GENERATOR_NOT_COSTED T
- LMTD_ACROSS_PHASE_CHANGE C
- INDEX_BASIS_MAPPED sslw-idaes-v1
- ELECTRICITY_NETTED EL
- ELECTRICITY_NETTED lca.EL
- LCA_BACKGROUND_UNCERTAINTY_NOT_PROPAGATED lca
- LCA_CAPITAL_GOODS_EXCLUDED lca
Assessment evidence: an estimate under the stated basis, correlations, data and regimes. It is not numerical verification of the flowsheet and not empirical validation.
How the impact moves with the boiler temperature¶
An assessment output is a function of the state, so its sensitivity to a specification is the
chain rule through the flowsheet: dy/dp = ∇ₓy · dx/dp + ∂y/∂p. assessment_sensitivity builds
it on the certified solution, with the flowsheet's own sensitivity (dx/dp) from the studies
layer. Here the outputs are the impact and the steam bill, and the parameter is the boiler outlet
temperature.
import numpy as np
from openflowsheet.studies.assessment import (
assessment_sensitivity,
evaluation_context,
revision_sensitivity_host,
)
from openflowsheet.studies.sensitivity import OutputFunctional, SensitivityRequest, StudyParameter
host = revision_sensitivity_host(binding.flowsheet, state)
request = SensitivityRequest(
context=evaluation_context(host.compiled),
parameters=(StudyParameter("B.T_spec", 673.15, 573.15, 773.15),),
outputs=(OutputFunctional.of_variable("B.Q", 5.0e4),),
mode="forward",
)
x = np.array([state[name] for name in host.spec.variable_ids])
outputs = ("impact.climate_change_test.rate", "opex.utility.HPS")
sensitivity = assessment_sensitivity(program, host, request, x, outputs)
(dgwp_dt,), (dsteam_dt,) = sensitivity.forward.unscaled
print(sensitivity.base.status)
print(f"d(impact)/dT {dgwp_dt:.4e} kg CO2-eq/(s K)")
print(f"d(steam bill)/dT {dsteam_dt:.4f} USD/(yr K)")
QUALIFIED d(impact)/dT 5.1150e-06 kg CO2-eq/(s K) d(steam bill)/dT 14.1964 USD/(yr K)
What this example shows¶
On the synthetic test background the cycle's impact is 0.024320 kg CO2-eq/s, or 1.3500 kg
CO2-eq per kilogram of condensate, and it rises by 5.115 mg CO2-eq/s for each kelvin of boiler
outlet temperature. The steam bill is 18922 USD/yr and rises by 14.20 USD/yr per kelvin.
These numbers are checked by the test suite every time this page is built; the impact also
matches, digit for digit, the hand superposition of the same inventory that E01's tests carry
(benchmarks/e01/lca_hand/lca_hand.yaml). The capital figures are printed, not stated. This is a
demonstration of the software, not validation evidence.