Skip to content

SESIT: Industry carbon tax#94

Open
reetik-sahu wants to merge 9 commits into
INET-Complexity:mainfrom
reetik-sahu:sesit_carbon_tax
Open

SESIT: Industry carbon tax#94
reetik-sahu wants to merge 9 commits into
INET-Complexity:mainfrom
reetik-sahu:sesit_carbon_tax

Conversation

@reetik-sahu

Copy link
Copy Markdown
Contributor

feat: add Output-Based Pricing System (OBPS) (Example Canada Implementation)

Implements an industry-level carbon price signal based on each sector's
emissions relative to an output-based benchmark, as used in Canada's
federal OBPS regulation.

How it works

For each regulated sector i at time t (from 2019 onwards):

limit[i] = production[i] * reduction_factor[i] * reference_intensity[i]
obps_cost[i] = (input_emissions[i] + capital_emissions[i] - limit[i])
* carbon_price[t]

Note here that the cost can be negative: sectors emitting below their benchmark receive
a rebate that lowers their effective price, while sectors above the
benchmark face a positive marginal cost. Dividing by production gives a
per-unit marginal tax passed to price-setting and input-demand logic:

extra_marginal_taxes_firm[i] = obps_cost[i] / production[i]

The emission benchmark is output-based: the limit scales with production
so firms are not penalised for growing, only for exceeding the sector
intensity standard. Reference emission intensities are accumulated from
2017–2019 model periods. Post-2022 a tightening rate gradually lowers
the benchmark.

New components

  • macromodel/policy/output_based_price_system_can.py
    OutputBasedPriceSystemCAN — computes sectoral OBPS cost each
    period; accumulates 2017–2019 reference emissions to set
    industry-specific intensity benchmarks.

  • macro_data/readers/policy_data/obps_can_reader.py
    OBPSCANReader — reads industry reduction factors, tightening rates,
    and the carbon price schedule; produces an OBPSCANData container.

Wiring

  • CountryConfiguration gains use_obps_reg (default False).
  • Country.update_extra_taxes() computes extra_marginal_taxes_firm each
    period when use_obps_reg is True; called from the planning phase so
    the signal feeds into price-setting and input demand.
  • Firms' price setters and input-demand functions accept
    extra_marginal_taxes to shift the effective sector price.

Relevant tests have also been added.

reetik-sahu and others added 7 commits May 19, 2026 08:39
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Module and compute_obps docstrings claimed max(0, emissions - limit)
but the implementation computes the signed difference with no clamp —
sectors below their benchmark receive a rebate (negative cost).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Covers OutputBasedPriceSystemCAN (positive cost, negative rebate,
tightening rate, reference accumulation, disabled/pre-2019 guards)
and OBPSCANReader (CSV loading, missing files, optional elec file).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jose-moran

Copy link
Copy Markdown
Member

Before I read in detail -- are you sure the negative OBPS case flows smoothly? That there are no leaks, etc?

@jose-moran jose-moran left a comment

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.

minor comments for the moment, I'll check in more detail later

"""
self.extra_marginal_taxes_firm = np.zeros(self.firms.n_industries)

if self.use_obps_reg and self.obps is not None:

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.

should you not throw an error or a warning if this function is called but these things are not set?

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.

Added a logging.warning when use_obps_reg=True but self.obps is None, so misconfiguration is visible at runtime instead of silently doing nothing.

emission_limit: np.ndarray
price: np.ndarray
current_t: int = 0
current_year: int = 2014

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.

current or initial?

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.

Renamed reference_emission_intensity → baseline_emission_intensity to make clear it's the fixed 2017–2019 historical baseline, not a current value. Also added initial_year as an explicit field so reset() and the price-loading loop no longer hardcode 2014.

self.industries = industries

# Industries regulated under OBPS (federal schedule)
self.regulated_industries = [

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.

is there a check for the case where you are running this with other industries and you still are using this obps? An error should be thrown I think

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.

Added two warnings on init:

If some regulated industries are missing from the model's industry list → logs which ones were skipped
If none of the regulated industries match at all → logs that OBPS will have no effect

…tion

- Warn when use_obps_reg=True but no OBPS object is set (country.py)
- Floor extra_marginal_taxes_firm at -good_prices so a negative rebate
  cannot push the effective sector price below zero (country.py)
- Rename reference_emission_intensity -> baseline_emission_intensity to
  make clear the value is fixed from the 2017-2019 reference period
- Add initial_year field so reset() and price-loading no longer hardcode
  2014; current_year remains the mutable simulation state
- Warn on init when regulated industries are absent from the model's
  industry set, with details on which codes were skipped
- Update tests to reflect the baseline_emission_intensity rename
@reetik-sahu

Copy link
Copy Markdown
Contributor Author

Before I read in detail -- are you sure the negative OBPS case flows smoothly? That there are no leaks, etc?

Made this addition:
extra_marginal_taxes_firm is reset to zero at the top of every period, so negatives don't accumulate. Added unit test test_negative_cost_rebate_when_below_limit that explicitly verifies the rebate arithmetic. Shoul

@reetik-sahu reetik-sahu requested a review from jose-moran May 22, 2026 18:01
The post-2022 tightening formula (B - B * tightening_rate * (self.current_year - 2022) becomes negativewhere B = reduction_factor * self.baseline_emission_intensity[industry_idx]once tightening accumulates past the baseline, causing the allowable
emission limit to go below zero (~2042 onward for a 2% annual rate).
Clamp the returned limit to max(0, ...) so regulated sectors never
receive a pathologically negative allowance.

Add a test covering the far-future tightening case.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants