|
| 1 | +"""Benchmark comparisons for ammonia commodity competitiveness. |
| 2 | +
|
| 3 | +The corridor model calculates delivered green ammonia. This module adds the |
| 4 | +missing market-context layer: how that delivered cost compares with conventional |
| 5 | +grey ammonia, blue ammonia, and carbon-adjusted grey ammonia in the destination |
| 6 | +market. The result is not a price forecast; it is an auditable parity screen. |
| 7 | +""" |
| 8 | + |
| 9 | +from __future__ import annotations |
| 10 | + |
| 11 | +import pandas as pd |
| 12 | + |
| 13 | +from src.config import EngineeringAssumptions |
| 14 | +from src.utils import clip_0_100, inverse_linear_score |
| 15 | + |
| 16 | + |
| 17 | +def _merge_by_importer( |
| 18 | + corridors: pd.DataFrame, |
| 19 | + benchmarks: pd.DataFrame, |
| 20 | + prefix: str, |
| 21 | +) -> pd.DataFrame: |
| 22 | + """Merge benchmark rows by `corridors.importer` -> `benchmarks.market_key`.""" |
| 23 | + |
| 24 | + if benchmarks.empty: |
| 25 | + return corridors.copy() |
| 26 | + renamed = benchmarks.add_prefix(prefix) |
| 27 | + return corridors.merge( |
| 28 | + renamed, |
| 29 | + left_on="importer", |
| 30 | + right_on=f"{prefix}market_key", |
| 31 | + how="left", |
| 32 | + ) |
| 33 | + |
| 34 | + |
| 35 | +def apply_conventional_ammonia_benchmarks( |
| 36 | + corridors: pd.DataFrame, |
| 37 | + conventional_benchmarks: pd.DataFrame, |
| 38 | + assumptions: EngineeringAssumptions, |
| 39 | +) -> pd.DataFrame: |
| 40 | + """Attach conventional ammonia benchmarks and calculate green premiums.""" |
| 41 | + |
| 42 | + df = _merge_by_importer(corridors, conventional_benchmarks, "conv_") |
| 43 | + |
| 44 | + # If a future corridor lacks a benchmark row, fall back to conservative |
| 45 | + # generic values so the dashboard still renders and the missing-data issue |
| 46 | + # remains visible through blank source fields. |
| 47 | + fallback_series = pd.Series(index=df.index, dtype=float) |
| 48 | + df["conv_grey_nh3_delivered_usd_per_tonne"] = pd.to_numeric( |
| 49 | + df.get("conv_grey_nh3_delivered_usd_per_tonne", fallback_series), |
| 50 | + errors="coerce", |
| 51 | + ).fillna(430.0) |
| 52 | + df["conv_blue_nh3_delivered_usd_per_tonne"] = pd.to_numeric( |
| 53 | + df.get("conv_blue_nh3_delivered_usd_per_tonne", fallback_series), |
| 54 | + errors="coerce", |
| 55 | + ).fillna(610.0) |
| 56 | + df["conv_grey_nh3_carbon_intensity_tco2_per_tonne"] = pd.to_numeric( |
| 57 | + df.get("conv_grey_nh3_carbon_intensity_tco2_per_tonne", fallback_series), |
| 58 | + errors="coerce", |
| 59 | + ).fillna(assumptions.conventional_nh3_tco2_per_tonne_nh3) |
| 60 | + |
| 61 | + # Prefer a market-specific carbon-price proxy from the power benchmark once |
| 62 | + # that table has been joined; otherwise use the global default assumption. |
| 63 | + carbon_price = pd.to_numeric( |
| 64 | + df.get("power_carbon_price_usd_per_tco2", fallback_series), |
| 65 | + errors="coerce", |
| 66 | + ).fillna(assumptions.default_carbon_price_usd_per_tco2) |
| 67 | + df["carbon_price_usd_per_tco2"] = carbon_price |
| 68 | + |
| 69 | + df["carbon_adjusted_grey_nh3_cost_usd_per_tonne"] = ( |
| 70 | + df["conv_grey_nh3_delivered_usd_per_tonne"] |
| 71 | + + carbon_price * df["conv_grey_nh3_carbon_intensity_tco2_per_tonne"] |
| 72 | + ) |
| 73 | + df["green_premium_vs_grey_usd_per_tonne"] = ( |
| 74 | + df["delivered_cost_usd_per_tonne_nh3"] |
| 75 | + - df["conv_grey_nh3_delivered_usd_per_tonne"] |
| 76 | + ) |
| 77 | + df["green_premium_vs_carbon_adjusted_grey_usd_per_tonne"] = ( |
| 78 | + df["delivered_cost_usd_per_tonne_nh3"] |
| 79 | + - df["carbon_adjusted_grey_nh3_cost_usd_per_tonne"] |
| 80 | + ) |
| 81 | + df["green_premium_vs_blue_usd_per_tonne"] = ( |
| 82 | + df["delivered_cost_usd_per_tonne_nh3"] |
| 83 | + - df["conv_blue_nh3_delivered_usd_per_tonne"] |
| 84 | + ) |
| 85 | + avoided = df["conv_grey_nh3_carbon_intensity_tco2_per_tonne"].clip(lower=0.01) |
| 86 | + df["carbon_price_required_for_grey_parity_usd_per_tco2"] = ( |
| 87 | + df["green_premium_vs_grey_usd_per_tonne"] / avoided |
| 88 | + ).clip(lower=0.0) |
| 89 | + |
| 90 | + df["commodity_competitiveness_score"] = df[ |
| 91 | + "green_premium_vs_carbon_adjusted_grey_usd_per_tonne" |
| 92 | + ].apply( |
| 93 | + lambda value: inverse_linear_score( |
| 94 | + value, |
| 95 | + assumptions.green_premium_score_best_usd_per_tonne_nh3, |
| 96 | + assumptions.green_premium_score_worst_usd_per_tonne_nh3, |
| 97 | + ) |
| 98 | + ) |
| 99 | + df["commodity_competitiveness_score"] = df["commodity_competitiveness_score"].apply(clip_0_100) |
| 100 | + return df |
| 101 | + |
| 102 | + |
| 103 | +def benchmark_source_notes( |
| 104 | + conventional_benchmarks: pd.DataFrame, |
| 105 | + power_benchmarks: pd.DataFrame, |
| 106 | +) -> list[str]: |
| 107 | + """Summarize unique benchmark sources for dashboard source disclosure.""" |
| 108 | + |
| 109 | + notes: list[str] = [] |
| 110 | + for label, frame in ( |
| 111 | + ("Conventional ammonia benchmark", conventional_benchmarks), |
| 112 | + ("Import-market power benchmark", power_benchmarks), |
| 113 | + ): |
| 114 | + if frame.empty: |
| 115 | + notes.append(f"{label}: unavailable; generic fallback values were used.") |
| 116 | + continue |
| 117 | + source_rows = ( |
| 118 | + frame[["source_name", "source_url", "source_note", "data_quality"]] |
| 119 | + .drop_duplicates() |
| 120 | + .to_dict("records") |
| 121 | + ) |
| 122 | + for row in source_rows: |
| 123 | + notes.append( |
| 124 | + f"{label}: {row['source_name']} ({row['data_quality']}) - " |
| 125 | + f"{row['source_note']} Source: {row['source_url']}" |
| 126 | + ) |
| 127 | + return notes |
0 commit comments