Skip to content
4 changes: 4 additions & 0 deletions macro_data/processing/synthetic_country.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
from macro_data.readers.emissions.emissions_reader import CH4EmissionsDataCAN, EmissionsData
from macro_data.readers.exo_prices.exo_prices_reader import SectorExoPrices
from macro_data.readers.exogenous_data import ExogenousCountryData
from macro_data.readers.policy_data.obps_can_reader import OBPSCANData


@dataclass
Expand Down Expand Up @@ -173,6 +174,7 @@ class SyntheticCountry:
emission_factors: EmissionsData
emission_fractions: Optional[EmissionFractions] = None
firm_exo_prices: Optional[SectorExoPrices] = None
obps_data: Optional[OBPSCANData] = None
emission_factors_ch4: Optional[CH4EmissionsDataCAN] = None
historical_emissions_df: Optional[pd.DataFrame] = None

Expand Down Expand Up @@ -378,6 +380,7 @@ def eu_synthetic_country(
firm_exo_prices=(
SectorExoPrices.from_reader(readers.exo_prices) if readers.exo_prices is not None else None
),
obps_data=readers.obps_can.obps_data if readers.obps_can is not None else None,
)

@classmethod
Expand Down Expand Up @@ -594,6 +597,7 @@ def proxied_synthetic_country(
firm_exo_prices=(
SectorExoPrices.from_reader(readers.exo_prices) if readers.exo_prices is not None else None
),
obps_data=readers.obps_can.obps_data if readers.obps_can is not None else None,
)

@classmethod
Expand Down
32 changes: 32 additions & 0 deletions macro_data/readers/default_readers.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
)
from macro_data.readers.io_tables.icio_reader import ICIOReader, split_gfcf_column
from macro_data.readers.io_tables.industries import AGGREGATED_INDUSTRIES
from macro_data.readers.policy_data.obps_can_reader import OBPSCANReader
from macro_data.readers.population_data.compustat_banks_reader import (
CompustatBanksReader,
)
Expand All @@ -61,6 +62,21 @@
from macro_data.readers.util.prune_util import DataFilterWarning


@dataclass
class OBPSPaths:
"""File paths for the Canada Output-Based Pricing System data.

Attributes:
rates_path: CSV of carbon price rates by year and jurisdiction.
policy_path: CSV of per-industry reduction factors and tightening rates.
policy_elec_path: Optional CSV for electricity-specific tightening rates.
"""

rates_path: Path
policy_path: Path
policy_elec_path: Optional[Path] = None


@dataclass
class DataPaths:
"""Manages file paths for all data sources used in the model.
Expand Down Expand Up @@ -113,6 +129,7 @@ class DataPaths:
emissions_fraction_path: Optional[Path] = None
firm_prices_path: Optional[Path] = None
ch4_emissions_path: Optional[Path] = None
obps_path: Optional[OBPSPaths] = None

@classmethod
def default_paths(cls, raw_data_path: Path, icio_years: Iterable[int]):
Expand Down Expand Up @@ -150,6 +167,11 @@ def default_paths(cls, raw_data_path: Path, icio_years: Iterable[int]):
ch4_emissions_path=raw_data_path
/ "emission_factors"
/ "EN-GHG_EconSectByGas-CA_Emissions_2014_2023_v4.csv",
obps_path=OBPSPaths(
rates_path=raw_data_path / "policy" / "output_based_price_system_rates.csv",
policy_path=raw_data_path / "policy" / "output_based_price_system_policy_values_disagg.csv",
policy_elec_path=raw_data_path / "policy" / "output_based_price_system_policy_values_elec.csv",
),
)

# @classmethod
Expand Down Expand Up @@ -206,6 +228,7 @@ class DataReaders:
emission_fractions: Optional[EmissionsFractionReader] = None
exo_prices: Optional[SectorExoPricesReader] = None
ch4_emissions: Optional[CH4EmissionsReaderCAN] = None
obps_can: Optional[OBPSCANReader] = None
regions_dict: Optional[dict[Country, list[Region]]] = None

@classmethod
Expand Down Expand Up @@ -481,6 +504,14 @@ def get_investment_year(year: int, country_names_: Optional[list[Country | Regio
if datapaths.ch4_emissions_path is not None and datapaths.ch4_emissions_path.exists():
ch4_emissions = CH4EmissionsReaderCAN.read_data(datapaths.ch4_emissions_path)

obps_can = None
if datapaths.obps_path is not None:
obps_can = OBPSCANReader.read_from_raw_data(
rates_path=datapaths.obps_path.rates_path,
policy_path=datapaths.obps_path.policy_path,
policy_elec_path=datapaths.obps_path.policy_elec_path,
)

return cls(
icio=icio,
wiod_sea=wiod_sea,
Expand All @@ -500,6 +531,7 @@ def get_investment_year(year: int, country_names_: Optional[list[Country | Regio
emission_fractions=emission_fractions,
exo_prices=exo_prices,
ch4_emissions=ch4_emissions,
obps_can=obps_can,
regions_dict=regions_dict,
)

Expand Down
3 changes: 3 additions & 0 deletions macro_data/readers/policy_data/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from macro_data.readers.policy_data.obps_can_reader import OBPSCANData, OBPSCANReader

__all__ = ["OBPSCANData", "OBPSCANReader"]
85 changes: 85 additions & 0 deletions macro_data/readers/policy_data/obps_can_reader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Reader and container for Canada Output-Based Pricing System (OBPS) policy data."""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Optional

import pandas as pd


@dataclass
class OBPSCANData:
"""Container for Canada OBPS policy tables loaded from CSV files.

Attributes:
df_rates: Carbon price trajectory. Rows are years; columns are
'Date' plus one column per province/jurisdiction code.
df_policy: Per-industry emission standards. Must have columns
'Industry', 'reduction_factor', and 'tightening_rate'.
df_policy_elec: Optional electricity-specific tightening rates.
Same structure as df_policy but for electricity sectors only.
"""

df_rates: pd.DataFrame
df_policy: pd.DataFrame
df_policy_elec: Optional[pd.DataFrame] = None


@dataclass
class OBPSCANReader:
"""Reader for Canada OBPS CSV data files.

CSV formats
-----------
rates_path:
Date, CAN, CAN_AB, CAN_BC, ...
2014, 15.0, 10.0, 12.0, ...
2015, 20.0, 15.0, 15.0, ...

policy_path:
Industry, reduction_factor, tightening_rate
B05a, 0.80, 0.02
C19, 0.75, 0.02

policy_elec_path (optional):
same structure as policy_path but for electricity sectors.
"""

obps_data: Optional[OBPSCANData] = None

@classmethod
def read_from_raw_data(
cls,
rates_path: Path | str,
policy_path: Path | str,
policy_elec_path: Optional[Path | str] = None,
) -> OBPSCANReader:
"""Load OBPS data from CSV files.

Args:
rates_path: Path to carbon price rates CSV.
policy_path: Path to per-industry policy values CSV.
policy_elec_path: Optional path to electricity-specific CSV.

Returns:
OBPSCANReader with loaded OBPSCANData, or obps_data=None if any
required file is absent.
"""
rates_path = Path(rates_path)
policy_path = Path(policy_path)

if not rates_path.exists() or not policy_path.exists():
return cls(obps_data=None)

df_rates = pd.read_csv(rates_path)
df_policy = pd.read_csv(policy_path)

df_policy_elec = None
if policy_elec_path is not None:
policy_elec_path = Path(policy_elec_path)
if policy_elec_path.exists():
df_policy_elec = pd.read_csv(policy_elec_path)

return cls(obps_data=OBPSCANData(df_rates=df_rates, df_policy=df_policy, df_policy_elec=df_policy_elec))
4 changes: 4 additions & 0 deletions macromodel/agents/firms/firms.py
Original file line number Diff line number Diff line change
Expand Up @@ -1011,6 +1011,7 @@ def compute_price(
current_estimated_ppi_inflation: np.ndarray,
previous_average_good_prices: np.ndarray,
ppi_during: np.ndarray,
extra_marginal_taxes: Optional[np.ndarray] = None,
) -> np.ndarray:
"""Set prices for each firm's output.

Expand All @@ -1026,6 +1027,8 @@ def compute_price(
current_estimated_ppi_inflation (np.ndarray): Expected PPI inflation
previous_average_good_prices (np.ndarray): Previous period prices
ppi_during (np.ndarray): Producer price indices
extra_marginal_taxes (np.ndarray, optional): Per-sector marginal
tax added to sector average prices. Defaults to None.

Returns:
np.ndarray: New prices for each firm
Expand All @@ -1051,6 +1054,7 @@ def compute_price(
),
ppi_during=ppi_during,
current_time=len(self.ts.historic("price")),
extra_marginal_taxes=extra_marginal_taxes,
)

def compute_unconstrained_demand_for_intermediate_inputs(
Expand Down
32 changes: 26 additions & 6 deletions macromodel/agents/firms/func/prices.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from abc import ABC, abstractmethod
from typing import Optional

import numpy as np
from scipy.interpolate import interp1d
Expand Down Expand Up @@ -74,6 +75,7 @@ def compute_price(
prev_unit_costs: np.ndarray,
ppi_during: np.ndarray,
current_time: int,
extra_marginal_taxes: Optional[np.ndarray] = None,
) -> np.ndarray:
"""Calculate prices for each firm based on market conditions.

Expand Down Expand Up @@ -139,11 +141,12 @@ def compute_price(
current_time: int,
min_inflation: float = -0.1,
max_inflation: float = 0.1,
extra_marginal_taxes: Optional[np.ndarray] = None,
) -> np.ndarray:
"""Calculate prices using the default multi-factor strategy.

The method:
1. Maps sector average prices to firms
1. Maps sector average prices to firms (plus any OBPS marginal tax)
2. Calculates demand-pull inflation based on market position
3. Calculates cost-push inflation from unit costs
4. Combines all factors with random noise
Expand All @@ -158,11 +161,18 @@ def compute_price(
Defaults to -0.1 (-10%).
max_inflation (float, optional): Upper bound on inflation rates.
Defaults to 0.1 (10%).
extra_marginal_taxes (np.ndarray, optional): Per-sector marginal
tax (e.g. OBPS) added to sector average prices seen by firms.
Shape (n_industries,). Defaults to None.

Returns:
np.ndarray: Updated prices by firm, guaranteed to be positive
"""
average_price_by_firm = prev_average_good_prices[current_firm_sectors]
tax_by_sector = (
extra_marginal_taxes if extra_marginal_taxes is not None else np.zeros_like(prev_average_good_prices)
)
average_price_by_firm = (prev_average_good_prices + tax_by_sector)[current_firm_sectors]
tax_by_firm = tax_by_sector[current_firm_sectors]

# Demand-pull inflation
demand_pull_inflation = np.zeros_like(prev_firm_prices)
Expand All @@ -187,12 +197,13 @@ def compute_price(
)
demand_pull_inflation = np.maximum(min_inflation, np.minimum(max_inflation, demand_pull_inflation))

# Cost-push inflation
# Cost-push inflation: include the tax in unit costs so positive tax raises prices
total_unit_costs = curr_unit_costs + tax_by_firm
cost_push_inflation = (
np.divide(
curr_unit_costs,
total_unit_costs,
average_price_by_firm,
out=np.ones_like(curr_unit_costs),
out=np.ones_like(total_unit_costs),
where=average_price_by_firm != 0.0,
)
- 1.0
Expand Down Expand Up @@ -276,6 +287,7 @@ def compute_price(
current_time: int,
min_inflation: float = -0.1,
max_inflation: float = 0.1,
extra_marginal_taxes: Optional[np.ndarray] = None,
) -> np.ndarray:
"""Compute prices, overriding listed sectors with exogenous sector paths.

Expand Down Expand Up @@ -317,6 +329,7 @@ def compute_price(
current_time=current_time,
min_inflation=min_inflation,
max_inflation=max_inflation,
extra_marginal_taxes=extra_marginal_taxes,
)

if self.firm_exo_prices is None or len(self.overriden_industries) == 0:
Expand All @@ -328,12 +341,16 @@ def compute_price(
else prev_average_good_prices
)

tax_by_firm = (
extra_marginal_taxes[current_firm_sectors] if extra_marginal_taxes is not None else np.zeros_like(price)
)

for industry_name in self.firm_exo_prices.prices.columns:
if industry_name not in self.overriden_industries:
continue
ratio = self._normalised_price(industry_name, current_quarter=current_time)
for idx in self._indices_for(industry_name):
price[idx] = base_prices[idx] * ratio
price[idx] = base_prices[idx] * ratio + tax_by_firm[idx]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two notes on how the tax enters price-setting:

  • The additive application here (base_prices*ratio + tax) is the SectorExogenousPriceSetter. In DefaultPriceSetter the tax only shifts average_price_by_firm, which feeds the cost-push ratio -- so for default sectors a positive carbon tax can actually nudge the price down via the cost-push channel, and the -good_prices floor is largely inert there. Worth confirming the intended sign of the price response for non-exogenous sectors.
  • ExogenousPriceSetter.compute_price didn't get the extra_marginal_taxes kwarg that the siblings did; if OBPS is ever combined with that setter, firms.compute_price forwarding the kwarg will TypeError. Add it (ignored) for signature consistency.


return price

Expand Down Expand Up @@ -371,6 +388,7 @@ def compute_price(
current_time: int,
min_inflation: float = -0.1,
max_inflation: float = 0.1,
extra_marginal_taxes: Optional[np.ndarray] = None,
) -> np.ndarray:
"""Set prices according to exogenous PPI path.

Expand All @@ -383,6 +401,8 @@ def compute_price(
current_time (int): Current period index
min_inflation (float, optional): Unused. Defaults to -0.1.
max_inflation (float, optional): Unused. Defaults to 0.1.
extra_marginal_taxes (np.ndarray, optional): Unused. Accepts for
interface consistency. Defaults to None.

Returns:
np.ndarray: Price level from exogenous PPI path
Expand Down
1 change: 1 addition & 0 deletions macromodel/configurations/country_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class CountryConfiguration(BaseModel):
assume_zero_noise: bool = False
use_emission_multiplier: bool = False
CH4_production_emissions_only: bool = False
use_obps_reg: bool = False

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Generalization -- the main thing to address before this is reusable. Canada specifics are nicely sealed in the _can class/reader (good), but the generic machinery still names the concrete scheme in three shared files: this use_obps_reg boolean, country.update_extra_taxes() (hardwired to self.obps), and simulation.iterate(). Adding a second country today means editing all three.

A thin PolicyInstrument base (marginal_tax_by_sector(), advance_to(year), reset()) plus a country.policy_instruments: list[...] would make a new scheme = one new class + reader + config entry, with zero shared-file edits. Given the PR is framed as an "Example Canada Implementation", this interface is what makes that framing true -- happy to take it as a fast-follow if you'd rather merge the Canada case first.


@classmethod
def n_industry_default(
Expand Down
Loading