Skip to content

Commit 63f803d

Browse files
committed
Split bounds methods into new functions, class for cache, add tests
1 parent aa1ec50 commit 63f803d

2 files changed

Lines changed: 267 additions & 117 deletions

File tree

src/muse/carbon_budget.py

Lines changed: 158 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,7 @@ def bisection(
375375
price_too_high_threshold: float = 10,
376376
tolerance: float = 0.1,
377377
early_termination_count: int = 5,
378+
**kwargs,
378379
) -> float:
379380
"""Applies bisection algorithm to escalate carbon price and meet the budget.
380381
@@ -399,46 +400,41 @@ def bisection(
399400
tolerance: Maximum permitted deviation of emissions from the budget
400401
early_termination_count: Will terminate the loop early if the last n solutions
401402
are the same
403+
kwargs: Additional arguments (unused)
402404
403405
Returns:
404406
New value of global carbon price
405407
"""
406408
from logging import getLogger
407409

408-
# We calculate the carbon price and emissions threshold in the forecast year
410+
# Carbon price and emissions threshold in the forecast year
409411
future = market.year[-1]
410-
threshold = carbon_budget.sel(year=future).values.item()
412+
target = carbon_budget.sel(year=future).values.item()
411413
price = market.prices.sel(year=future, commodity=commodities).mean().values.item()
412414

413415
# Initial lower and upper bounds on carbon price for the bisection algorithm
414416
current = market.year[0]
415417
time_exp = int(future - current)
416-
low = 0.01
417-
up = max(price, 0.01) * 1.1**time_exp # i.e. 10% yearly increase on current price
418+
lb_price = 0.01
419+
ub_price = (
420+
max(price, 0.01) * 1.1**time_exp
421+
) # i.e. 10% yearly increase on current price
418422

419423
# Bisection loop
420-
emissions_cache: dict[float, float] = {} # caches emissions for different prices
424+
emissions_cache = EmissionsCache(market, equilibrium, commodities)
421425
for _ in range(sample_size): # maximum number of iterations before terminating
422426
# Cap prices between 0.01 and price_too_high_threshold
423427
if refine_price:
424-
up = min(up, price_too_high_threshold)
425-
low = max(low, 0.01)
428+
ub_price = min(ub_price, price_too_high_threshold)
429+
lb_price = max(lb_price, 0.01)
426430

427431
# Round prices to 2dp
428-
low = round(low, 2)
429-
up = round(up, 2)
430-
431-
# Calculate carbon emissions at new bounds
432-
if low not in emissions_cache:
433-
emissions_cache[low] = bisect_loop(
434-
market, sectors, equilibrium, commodities, low
435-
)
436-
ub = emissions_cache[low]
437-
if up not in emissions_cache:
438-
emissions_cache[up] = bisect_loop(
439-
market, sectors, equilibrium, commodities, up
440-
)
441-
lb = emissions_cache[up]
432+
lb_price = round(lb_price, 2)
433+
ub_price = round(ub_price, 2)
434+
435+
# Calculate/retrieve carbon emissions at new bounds
436+
lb_price_emissions = emissions_cache[lb_price]
437+
ub_price_emissions = emissions_cache[ub_price]
442438

443439
# Terminate early if many consecutive emissions are the same
444440
if len(emissions_cache) >= early_termination_count + 2:
@@ -447,23 +443,17 @@ def bisection(
447443
break
448444

449445
# Exit loop if lower or upper bound on emissions is close to threshold
450-
if abs(ub - threshold) <= abs(tolerance * threshold):
451-
return low
452-
if abs(lb - threshold) <= abs(tolerance * threshold):
453-
return up
446+
if abs(lb_price_emissions - target) <= abs(tolerance * target):
447+
return lb_price
448+
if abs(ub_price_emissions - target) <= abs(tolerance * target):
449+
return ub_price
454450

455451
# Convergence not yet reached -> calculate new bounds
456-
low, up = min_max_bisect(
457-
low,
458-
lb,
459-
up,
460-
ub,
461-
market,
462-
sectors,
463-
equilibrium,
464-
commodities,
465-
threshold, # type: ignore
466-
emissions_cache=emissions_cache,
452+
lb_price, ub_price = adjust_bounds(
453+
lb_price,
454+
ub_price,
455+
emissions_cache,
456+
target,
467457
)
468458

469459
# If convergence isn't reached, new price is that with emissions closest to
@@ -473,93 +463,145 @@ def bisection(
473463
"Try increasing the tolerance or sample size."
474464
)
475465
getLogger(__name__).warning(message)
476-
return min(emissions_cache, key=lambda k: (abs(emissions_cache[k] - threshold), k))
466+
return min(emissions_cache, key=lambda k: (abs(emissions_cache[k] - target), k))
477467

478468

479-
def min_max_bisect(
480-
low: float,
481-
lb: float,
482-
up: float,
483-
ub: float,
484-
market: xr.Dataset,
485-
sectors: list,
486-
equilibrium: Callable[
487-
[xr.Dataset, Sequence[AbstractSector], int], FindEquilibriumResults
488-
],
489-
commodities: list,
490-
threshold: float,
491-
emissions_cache: dict[float, float] = {},
492-
):
493-
"""Refines bisection algorithm to escalate carbon price and meet the budget.
469+
class EmissionsCache(dict):
470+
"""Cache of emissions at different price points for bisection algorithm.
471+
472+
If a price is queried that is not in the cache, it calculates the emissions at that
473+
price using bisect_solve_market and stores the result in the cache.
474+
"""
475+
476+
def __init__(self, market, equilibrium, commodities):
477+
super().__init__()
478+
self.market = market
479+
self.equilibrium = equilibrium
480+
self.commodities = commodities
481+
482+
def __missing__(self, price):
483+
value = bisect_solve_market(
484+
self.market, self.equilibrium, self.commodities, price
485+
)
486+
self[price] = value
487+
return value
488+
489+
490+
def adjust_bounds(
491+
lb_price: float,
492+
ub_price: float,
493+
emissions_cache: dict[float, float],
494+
target: float,
495+
) -> tuple[float, float]:
496+
"""Adjust the bounds of the carbon price for the bisection algorithm.
494497
495498
As emissions can be a discontinuous function of the carbon price, this method is
496499
used to improve the solution search when discontinuities are met, improving the
497500
bounds search.
498501
499502
Arguments:
500-
low: Value of carbon price at lower bound
501-
lb: Value of emissions at lower bound
502-
up: Value of carbon price at upper bound
503-
ub: Value of emissions at upper bound
504-
market: Market, with the prices, supply, consumption and demand
505-
sectors: List of sectors
506-
equilibrium: Method for searching market equilibrium
507-
commodities: List of carbon-related commodities
508-
threshold: Carbon budget
509-
emissions_cache: Cache of emissions for carbon prices
503+
lb_price: Value of carbon price at lower bound
504+
ub_price: Value of carbon price at upper bound
505+
emissions_cache: Dictionary of emissions at different price points
506+
target: Carbon budget
510507
511508
Returns:
512-
Value of lower and upper global carbon price
509+
New lower and upper bounds for the carbon price.
513510
"""
514-
denominator = max(threshold, 1e-3)
515-
if lb < threshold and ub < threshold:
516-
# Both prices are too high (emissions too low) -> decrease the lower bound
517-
exponent = (ub - threshold) / abs(denominator) # will be negative
518-
exponent = max(exponent, -1) # cap exponent at -1
519-
up = low if (ub >= lb) else up # new upper bound is price with higher emissions
520-
low = low * np.exp(exponent)
521-
522-
if ub > threshold and lb > threshold:
523-
# Both prices are too low (emissions too high) -> increase the upper bound
524-
exponent = (lb - threshold) / abs(denominator) # will be positive
525-
exponent = min(exponent, 1) # cap exponent at 1
526-
low = up if (ub >= lb) else low # new lower bound is price with lower emissions
527-
up = up * np.exp(exponent)
528-
529-
if ub > threshold and lb < threshold:
511+
lb_price_emissions = emissions_cache[lb_price]
512+
ub_price_emissions = emissions_cache[ub_price]
513+
514+
if lb_price_emissions < target and ub_price_emissions < target:
515+
# Emissions too low at both prices -> decrease prices
516+
method = decrease_bounds
517+
518+
elif lb_price_emissions > target and ub_price_emissions > target:
519+
# Emissions too high at both prices -> increase prices
520+
method = increase_bounds
521+
522+
elif lb_price_emissions > target and ub_price_emissions < target:
530523
# Threshold is between bounds -> perform bisection
531-
midpoint = round((low + up) / 2.0, 2)
532-
m = bisect_loop(market, sectors, equilibrium, commodities, midpoint)
533-
emissions_cache[midpoint] = m
534-
if m < threshold:
535-
# Midpoint price is too high -> becomes new upper bound
536-
up = midpoint
537-
else:
538-
# Midpoint price is too low -> becomes new lower bound
539-
low = midpoint
540-
541-
# Inverted bounds (i.e. increasing price leads to increasing emissions)
542-
# Unlikely case, but included for completeness
543-
if ub < threshold and lb > threshold:
544-
midpoint = round((low + up) / 2.0, 2)
545-
m = bisect_loop(market, sectors, equilibrium, commodities, midpoint)
546-
emissions_cache[midpoint] = m
547-
if m > threshold:
548-
# Midpoint price is too high -> becomes new upper bound
549-
up = midpoint
550-
else:
551-
# Midpoint price is too low -> becomes new lower bound
552-
low = midpoint
553-
554-
return low, up
555-
556-
557-
def bisect_loop(
524+
method = bisect_bounds
525+
526+
elif lb_price_emissions < target and ub_price_emissions > target:
527+
# Inverted bounds (i.e. increasing price leads to increasing emissions)
528+
# Unlikely case, but included for completeness
529+
method = bisect_bounds_inverted
530+
531+
else:
532+
# lb_price_emissions or ub_price_emissions is equal to the target, so we can
533+
# return the bounds unchanged
534+
return lb_price, ub_price
535+
536+
return method(
537+
lb_price,
538+
ub_price,
539+
emissions_cache,
540+
target,
541+
)
542+
543+
544+
def decrease_bounds(
545+
lb_price: float, ub_price: float, emissions_cache: dict[float, float], target: float
546+
) -> tuple[float, float]:
547+
"""Decreases the lb of the carbon price, and sets the ub to the previous lb."""
548+
denominator = max(target, 1e-3)
549+
lb_price_emissions = emissions_cache[lb_price]
550+
exponent = (lb_price_emissions - target) / abs(denominator) # will be negative
551+
exponent = max(exponent, -1) # cap exponent at -1
552+
ub_price = lb_price
553+
lb_price = lb_price * np.exp(exponent)
554+
return lb_price, ub_price
555+
556+
557+
def increase_bounds(
558+
lb_price: float, ub_price: float, emissions_cache: dict[float, float], target: float
559+
) -> tuple[float, float]:
560+
"""Increases the ub of the carbon price, and sets the lb to the previous ub."""
561+
denominator = max(target, 1e-3)
562+
ub_price_emissions = emissions_cache[ub_price]
563+
exponent = (ub_price_emissions - target) / abs(denominator) # will be positive
564+
exponent = min(exponent, 1) # cap exponent at 1
565+
lb_price = ub_price
566+
ub_price = ub_price * np.exp(exponent)
567+
return lb_price, ub_price
568+
569+
570+
def bisect_bounds(
571+
lb_price: float,
572+
ub_price: float,
573+
emissions_cache: dict[float, float],
574+
target: float,
575+
) -> tuple[float, float]:
576+
"""Bisects the bounds of the carbon price."""
577+
midpoint = round((lb_price + ub_price) / 2.0, 2)
578+
midpoint_emissions = emissions_cache[midpoint]
579+
if midpoint_emissions < target:
580+
ub_price = midpoint
581+
else:
582+
lb_price = midpoint
583+
return lb_price, ub_price
584+
585+
586+
def bisect_bounds_inverted(
587+
lb_price: float,
588+
ub_price: float,
589+
emissions_cache: dict[float, float],
590+
target: float,
591+
) -> tuple[float, float]:
592+
"""Bisects the bounds of the carbon price, in the case of inverted bounds."""
593+
midpoint = round((lb_price + ub_price) / 2.0, 2)
594+
midpoint_emissions = emissions_cache[midpoint]
595+
if midpoint_emissions > target:
596+
ub_price = midpoint
597+
else:
598+
lb_price = midpoint
599+
return lb_price, ub_price
600+
601+
602+
def bisect_solve_market(
558603
market: xr.Dataset,
559-
sectors: list,
560-
equilibrium: Callable[
561-
[xr.Dataset, Sequence[AbstractSector], int], FindEquilibriumResults
562-
],
604+
equilibrium: Callable[[xr.Dataset], FindEquilibriumResults],
563605
commodities: list,
564606
new_price: float,
565607
) -> float:
@@ -568,11 +610,10 @@ def bisect_loop(
568610
This updates emissions during iterations.
569611
570612
Arguments:
571-
market: Market, with the prices, supply, consumption and demand,
572-
sectors: List of sectors,
573-
equilibrium: Method for searching market equilibrium,
574-
commodities: List of carbon-related commodities,
575-
new_price: New carbon price from bisection,
613+
market: Market, with the prices, supply, consumption and demand
614+
equilibrium: Method for searching market equilibrium
615+
commodities: List of carbon-related commodities
616+
new_price: New carbon price from bisection
576617
577618
Returns:
578619
Emissions estimated at the new carbon price.
@@ -582,11 +623,11 @@ def bisect_loop(
582623

583624
# Assign new carbon price and solve market
584625
new_market.prices.loc[{"year": future, "commodity": commodities}] = new_price
585-
new_market = equilibrium(new_market, sectors).market
626+
new_market = equilibrium(new_market).market
586627
new_emissions = (
587628
new_market.supply.sel(year=future, commodity=commodities)
588629
.sum(["region", "timeslice", "commodity"])
589-
.round(decimals=3)
630+
.round(decimals=2)
590631
).values.item()
591632

592633
return new_emissions

0 commit comments

Comments
 (0)