"""Helper functions for defining and analyzing boundaries for Guidelines standards."""
import decimal
from collections.abc import Callable
from math import fma
from types import ModuleType
from typing import Literal, TypedDict
import matplotlib as mpl
import matplotlib.axes as mpa
import matplotlib.offsetbox as mof
import matplotlib.patches as mpp
import matplotlib.pyplot as plt
import matplotlib.ticker as mpt
import numpy as np
from mpmath import mp, mpf # type: ignore
from .. import _PKG_NAME, DEFAULT_REC, VERSION, ArrayDouble # noqa: TID252
from . import GuidelinesBoundary, MPFloat
__version__ = VERSION
mp.dps = 32
mp.trap_complex = True
[docs]
class DiversionShareBoundaryKeywords(TypedDict, total=False):
"""Keyword arguments for functions generating share ratio boundaries."""
[docs]
agg_method: Literal["arithmetic mean", "geometric mean", "distance"]
[docs]
weighting: Literal["own-share", "cross-product-share", None]
[docs]
def dh_area(_delta_bound: float | MPFloat = 0.01, /, *, dps: int = 9) -> float:
R"""
Area under the ΔHHI boundary.
When the given ΔHHI bound matches a Guidelines standard,
the area under the boundary is half the intrinsic clearance rate
for the ΔHHI safeharbor.
Notes
-----
To derive the knots, :math:`(s^0_1, s^1_1), (s^1_1, s^0_1)`
of the ΔHHI boundary, i.e., the points where it intersects
the merger-to-monopoly boundary, solve
.. math::
2 s1 s_2 &= ΔHHI \\
s_1 + s_2 &= 1
Parameters
----------
_delta_bound
Change in concentration.
dps
Specified precision in decimal places.
Returns
-------
Area under ΔHHI boundary.
"""
_delta_bound = mpf(f"{_delta_bound}")
_s_naught = (1 - mp.sqrt(1 - 2 * _delta_bound)) / 2
return round(
float(
_s_naught + (_delta_bound / 2) * (mp.ln(1 - _s_naught) - mp.ln(_s_naught))
),
dps,
)
[docs]
def hhi_delta_boundary(
_delta_bound: float | decimal.Decimal | MPFloat = 0.01, /, *, dps: int = 5
) -> GuidelinesBoundary:
"""
Generate the list of share combination on the ΔHHI boundary.
Parameters
----------
_delta_bound:
Merging-firms' ΔHHI bound.
dps
Number of decimal places for rounding reported shares.
Returns
-------
Array of share-pairs, area under boundary.
"""
_delta_bound = mpf(f"{_delta_bound}")
_s_naught = 1 / 2 * (1 - mp.sqrt(1 - 2 * _delta_bound))
_s_mid = mp.sqrt(_delta_bound / 2)
_step_size = mp.power(10, -6)
_s_1 = np.array(mp.arange(_s_mid, _s_naught - mp.eps, -_step_size))
# Boundary points
half_bdry = np.vstack((
np.stack((_s_1, _delta_bound / (2 * _s_1)), axis=1).astype(float),
np.array([(mpf("0.0"), mpf("1.0"))], float),
))
bdry = np.vstack((half_bdry[::-1], half_bdry[1:, ::-1]), dtype=float)
return GuidelinesBoundary(bdry, dh_area(_delta_bound, dps=dps))
[docs]
def hhi_pre_contrib_boundary(
_hhi_bound: float | decimal.Decimal | MPFloat = 0.03125, /, *, dps: int = 5
) -> GuidelinesBoundary:
"""
Share combinations on the premerger HHI contribution boundary.
Parameters
----------
_hhi_bound:
Merging-firms' pre-merger HHI contribution bound.
dps
Number of decimal places for rounding reported shares.
Returns
-------
Array of share-pairs, area under boundary.
"""
_hhi_bound = mpf(f"{_hhi_bound}")
_s_mid = mp.sqrt(_hhi_bound / 2)
step_size = mp.power(10, -dps)
# Range-limit is 0 less a step, which is -1 * step-size
s_1 = np.array(mp.arange(_s_mid, -step_size, -step_size))
s_2 = np.sqrt(_hhi_bound - s_1**2)
half_bdry = np.stack((s_1, s_2), axis=1).astype(float)
return GuidelinesBoundary(
np.vstack((half_bdry[::-1], half_bdry[1:, ::-1]), dtype=float),
round(float(mp.pi * _hhi_bound / 4), dps),
)
[docs]
def combined_share_boundary(
_s_intcpt: float | decimal.Decimal | MPFloat = 0.0625, /, *, dps: int = 10
) -> GuidelinesBoundary:
"""
Share combinations on the merging-firms' combined share boundary.
Assumes symmetric merging-firm margins. The combined-share is
congruent to the post-merger HHI contribution boundary, as the
post-merger HHI bound is the square of the combined-share bound.
Parameters
----------
_s_intcpt:
Merging-firms' combined share.
dps
Number of decimal places for rounding reported shares.
Returns
-------
Array of share-pairs, area under boundary.
"""
_s_intcpt = mpf(f"{_s_intcpt}")
_s_mid = _s_intcpt / 2
_s1 = np.array([0, _s_mid, _s_intcpt], float)
return GuidelinesBoundary(
np.array(list(zip(_s1, _s1[::-1])), float),
round(float(_s_intcpt * _s_mid), dps),
)
[docs]
def hhi_post_contrib_boundary(
_hhi_bound: float | decimal.Decimal | MPFloat = 0.800, /, *, dps: int = 10
) -> GuidelinesBoundary:
"""
Share combinations on the postmerger HHI contribution boundary.
The post-merger HHI contribution boundary is identical to the
combined-share boundary.
Parameters
----------
_hhi_bound:
Merging-firms' pre-merger HHI contribution bound.
dps
Number of decimal places for rounding reported shares.
Returns
-------
Array of share-pairs, area under boundary.
"""
return combined_share_boundary(
_hhi_bound.sqrt()
if isinstance(_hhi_bound, decimal.Decimal | mpf)
else np.sqrt(_hhi_bound),
dps=dps,
)
# hand-rolled root finding
[docs]
def diversion_share_boundary_wtd_avg(
_delta_star: float = 0.075,
_r_val: float = DEFAULT_REC,
/,
*,
agg_method: Literal[
"arithmetic mean", "geometric mean", "distance"
] = "arithmetic mean",
weighting: Literal["own-share", "cross-product-share", None] = "own-share",
recapture_form: Literal["inside-out", "fixed"] = "inside-out",
dps: int = 5,
) -> GuidelinesBoundary:
R"""
Share combinations on the share-weighted average diversion share boundary.
Parameters
----------
_delta_star
Diversion share, :math:`\overline{d} / \overline{r}` or :math:`\overline{g} / (m^* \cdot \overline{r})`.
_r_val
Recapture rate.
agg_method
Whether "arithmetic mean", "geometric mean", or "distance".
weighting
Whether "own-share" or "cross-product-share" (or None for simple, unweighted average).
recapture_form
Whether recapture rate is share-proportional ("inside-out") or has fixed
value for both merging firms ("fixed").
dps
Number of decimal places for rounding returned shares and area.
Returns
-------
Array of share-pairs, area under boundary.
Notes
-----
An analytical expression for the share-weighted arithmetic mean boundary
is derived and plotted from y-intercept to the ray of symmetry as follows::
from sympy import plot as symplot, solve, symbols
s_1, s_2 = symbols("s_1 s_2", positive=True)
g_val, r_val, m_val = 0.06, 0.80, 0.30
delta_star = g_val / (r_val * m_val)
# recapture_form == "inside-out"
oswag = solve(
s_1 * s_2 / (1 - s_1)
+ s_2 * s_1 / (1 - (r_val * s_2 + (1 - r_val) * s_1))
- (s_1 + s_2) * delta_star,
s_2
)[0]
symplot(
oswag,
(s_1, 0., d_hat / (1 + d_hat)),
ylabel=s_2
)
cpswag = solve(
s_2 * s_2 / (1 - s_1)
+ s_1 * s_1 / (1 - (r_val * s_2 + (1 - r_val) * s_1))
- (s_1 + s_2) * delta_star,
s_2
)[1]
symplot(
cpwag,
(s_1, 0.0, d_hat / (1 + d_hat)), ylabel=s_2
)
# recapture_form == "fixed"
oswag = solve(
s_1 * s_2 / (1 - s_1)
+ s_2 * s_1 / (1 - s_2)
- (s_1 + s_2) * delta_star,
s_2
)[0]
symplot(
oswag,
(s_1, 0., d_hat / (1 + d_hat)),
ylabel=s_2
)
cpswag = solve(
s_2 * s_2 / (1 - s_1)
+ s_1 * s_1 / (1 - s_2)
- (s_1 + s_2) * delta_star,
s_2
)[1]
symplot(
cpswag,
(s_1, 0.0, d_hat / (1 + d_hat)),
ylabel=s_2
)
"""
_delta_star, _r_val = (mpf(f"{_v}") for _v in (_delta_star, _r_val))
_s_mid = mp.fdiv(_delta_star, 1 + _delta_star)
# initial conditions
bdry = [(_s_mid, _s_mid)]
s_1_pre, s_2_pre = _s_mid, _s_mid
s_2_oddval, s_2_oddsum, s_2_evnsum = True, 0.0, 0.0
# parameters for iteration
_step_size = mp.power(10, -dps)
theta_ = _step_size * (10 if weighting == "cross-product-share" else 1)
for s_1 in mp.arange(_s_mid - _step_size, 0, -_step_size):
# The wtd. avg. GUPPI is not always convex to the origin, so we
# increment s_2 after each iteration in which our algorithm
# finds (s1, s2) on the boundary
s_2 = s_2_pre * (1 + theta_)
while True:
de_1 = s_2 / (1 - s_1)
de_2 = (
s_1 / (1 - lerp(s_1, s_2, _r_val))
if recapture_form == "inside-out"
else s_1 / (1 - s_2)
)
r_ = (
mp.fdiv(s_1 if weighting == "cross-product-share" else s_2, s_1 + s_2)
if weighting
else 0.5
)
match agg_method:
case "geometric mean":
delta_test = mp.expm1(lerp(mp.log1p(de_1), mp.log1p(de_2), r_))
case "distance":
delta_test = mp.sqrt(lerp(de_1**2, de_2**2, r_))
case _:
delta_test = lerp(de_1, de_2, r_)
test_flag, incr_decr = (
(delta_test > _delta_star, -1)
if weighting == "cross-product-share"
else (delta_test < _delta_star, 1)
)
if test_flag:
s_2 += incr_decr * _step_size
else:
break
# Build-up boundary points
bdry.append((s_1, s_2))
# Build up area terms
s_2_oddsum += s_2 if s_2_oddval else 0
s_2_evnsum += s_2 if not s_2_oddval else 0
s_2_oddval = not s_2_oddval
# Hold share points
s_2_pre = s_2
s_1_pre = s_1
if (s_1 + s_2) > mpf("0.99875"):
# Loss of accuracy at 3-9s and up
break
if s_2_oddval:
s_2_evnsum -= s_2_pre
else:
s_2_oddsum -= s_1_pre
_s_intcpt = _diversion_share_boundary_intcpt(
s_2_pre,
_delta_star,
_r_val,
recapture_form=recapture_form,
agg_method=agg_method,
weighting=weighting,
)
if weighting == "own-share":
gbd_prtlarea = (
_step_size * (4 * s_2_oddsum + 2 * s_2_evnsum + _s_mid + s_2_pre) / 3
)
# Area under boundary
bdry_area_total = float(
2 * (s_1_pre + gbd_prtlarea)
- (mp.power(_s_mid, "2") + mp.power(s_1_pre, "2"))
)
else:
gbd_prtlarea = (
_step_size * (4 * s_2_oddsum + 2 * s_2_evnsum + _s_mid + _s_intcpt) / 3
)
# Area under boundary
bdry_area_total = float(2 * gbd_prtlarea - mp.power(_s_mid, "2"))
bdry.append((mpf("0.0"), _s_intcpt))
bdry_array = np.array(bdry, float)
# Points defining boundary to point-of-symmetry
return GuidelinesBoundary(
np.vstack((bdry_array[::-1], bdry_array[1:, ::-1]), dtype=float),
round(float(bdry_area_total), dps),
)
[docs]
def diversion_share_boundary_xact_avg(
_delta_star: float = 0.075,
_r_val: float = DEFAULT_REC,
/,
*,
recapture_form: Literal["inside-out", "fixed"] = "inside-out",
dps: int = 5,
) -> GuidelinesBoundary:
R"""
Share combinations for the exact average diversion share boundary.
Notes
-----
An analytical expression for the exact average boundary is derived
and plotted from the y-intercept to the ray of symmetry as follows::
from sympy import latex, plot as symplot, solve, symbols
s_1, s_2 = symbols("s_1 s_2")
g_val, r_val, m_val = 0.06, 0.80, 0.30
d_hat = g_val / (r_val * m_val)
# recapture_form = "inside-out"
sag = solve(
(s_2 / (1 - s_1))
+ (s_1 / (1 - (r_val * s_2 + (1 - r_val) * s_1)))
- 2 * d_hat,
s_2
)[0]
symplot(
sag,
(s_1, 0., d_hat / (1 + d_hat)),
ylabel=s_2
)
# recapture_form = "fixed"
sag = solve((s_2/(1 - s_1)) + (s_1/(1 - s_2)) - 2 * d_hat, s_2)[0]
symplot(
sag,
(s_1, 0., d_hat / (1 + d_hat)),
ylabel=s_2
)
Parameters
----------
_delta_star
Diversion share, :math:`\overline{d} / \overline{r}` or :math:`\overline{g} / (m^* \cdot \overline{r})`.
_r_val
Recapture rate.
recapture_form
Whether recapture rate is share-proportional ("inside-out") or has fixed
value for both merging firms ("fixed").
dps
Number of decimal places for rounding returned shares.
Returns
-------
Array of share-pairs, area under boundary, area under boundary.
"""
_s_mid = _delta_star / (1 + _delta_star)
_step_size = 10**-dps
_bdry_start = np.array([(_s_mid, _s_mid)])
_s_1 = np.arange(_s_mid - _step_size, 0, -_step_size)
if recapture_form == "inside-out":
_s_intcpt = (
2 * _delta_star * _r_val + 1 - np.abs(2 * _delta_star * _r_val - 1)
) / (2 * _r_val)
nr_t1: ArrayDouble = (
1 + 2 * _delta_star * _r_val * (1 - _s_1) - _s_1 * (1 - _r_val)
)
nr_sqrt_mdr: float = 4 * _delta_star * _r_val
nr_sqrt_mdr2: float = nr_sqrt_mdr * _r_val
nr_sqrt_md2r2: float = nr_sqrt_mdr2 * _delta_star
nr_sqrt_t1: ArrayDouble = nr_sqrt_md2r2 * (_s_1**2 - 2 * _s_1 + 1)
nr_sqrt_t2: ArrayDouble = nr_sqrt_mdr2 * _s_1 * (_s_1 - 1)
nr_sqrt_t3: ArrayDouble = nr_sqrt_mdr * (2 * _s_1 - _s_1**2 - 1)
nr_sqrt_t4: ArrayDouble = (_s_1**2) * (_r_val**2 - 6 * _r_val + 1)
nr_sqrt_t5: ArrayDouble = _s_1 * (6 * _r_val - 2) + 1
nr_t2_mdr: ArrayDouble = (
nr_sqrt_t1 + nr_sqrt_t2 + nr_sqrt_t3 + nr_sqrt_t4 + nr_sqrt_t5
)
# Alternative grouping of terms in np.sqrt
nr_sqrt_nos1: float = nr_sqrt_md2r2 - nr_sqrt_mdr + 1
nr_sqrt_s1: ArrayDouble = _s_1 * (
-2 * nr_sqrt_md2r2 - nr_sqrt_mdr2 + 2 * nr_sqrt_mdr + 6 * _r_val - 2
)
nr_sqrt_s1sq: ArrayDouble = (_s_1**2) * (
nr_sqrt_md2r2 + nr_sqrt_mdr2 - nr_sqrt_mdr + _r_val**2 - 6 * _r_val + 1
)
nr_t2_s1: ArrayDouble = nr_sqrt_s1sq + nr_sqrt_s1 + nr_sqrt_nos1
if not np.isclose(
np.einsum("i->", nr_t2_mdr),
np.einsum("i->", nr_t2_s1),
rtol=0,
atol=0.5 * dps,
):
raise RuntimeError(
"Calculation of sq. root term in exact average GUPPI"
f"with recapture spec, {f'"{recapture_form}"'} is incorrect."
)
s_2 = (nr_t1 - nr_t2_s1**0.5) / (2 * _r_val)
else:
_s_intcpt = _delta_star + 1 / 2 - np.abs(_delta_star - 1 / 2)
s_2 = (
0.5
+ _delta_star
- _delta_star * _s_1
- (
(
((_delta_star**2) - 1) * (_s_1**2)
+ (-2 * (_delta_star**2) + _delta_star + 1) * _s_1
+ (_delta_star**2)
- _delta_star
+ (1 / 4)
)
** 0.5
)
)
bdry_inner = np.stack((_s_1, s_2), axis=1)
bdry_end = np.array([(0.0, _s_intcpt)], float)
bdry = np.vstack((
bdry_end,
bdry_inner[::-1],
_bdry_start,
bdry_inner[:, ::-1],
bdry_end[:, ::-1],
))
s_2 = np.concatenate((np.array([_s_mid], float), s_2))
bdry_ends = [0, -1]
bdry_odds = np.array(range(1, len(s_2), 2), int)
bdry_evns = np.array(range(2, len(s_2), 2), int)
# Double the area under the curve, and subtract the double counted bit.
bdry_area_simpson = 2 * _step_size * (
(4 / 3) * np.sum(s_2.take(bdry_odds))
+ (2 / 3) * np.sum(s_2.take(bdry_evns))
+ (1 / 3) * np.sum(s_2.take(bdry_ends))
) - np.power(_s_mid, 2)
return GuidelinesBoundary(bdry, round(float(bdry_area_simpson), dps))
[docs]
def diversion_share_boundary_min(
_delta_star: float = 0.075,
_r_val: float = DEFAULT_REC,
/,
*,
recapture_form: Literal["inside-out", "fixed"] = "inside-out",
dps: int = 10,
) -> GuidelinesBoundary:
R"""
Share combinations on the minimum diversion-ratio/share-ratio boundary.
Notes
-----
With symmetric merging-firm margins, the maximum GUPPI boundary is
defined by the diversion ratio from the smaller merging-firm to the
larger one, and is hence unaffected by the method of estimating the
diversion ratio for the larger firm.
Parameters
----------
_delta_star
Diversion share, :math:`\overline{d} / \overline{r}` or :math:`\overline{g} / (m^* \cdot \overline{r})`.
_r_val
Recapture rate.
recapture_form
Whether recapture rate is share-proportional ("inside-out") or has fixed
value for both merging firms ("fixed").
dps
Number of decimal places for rounding returned shares.
Returns
-------
Array of share-pairs, area under boundary.
"""
_delta_star, _r_val = (mpf(f"{_v}") for _v in (_delta_star, _r_val))
_s_intcpt = mpf("1.00")
_s_mid = _delta_star / (1 + _delta_star)
if recapture_form == "inside-out":
# ## Plot envelope of GUPPI boundaries with rk_ = r_bar if sk_ = min(s_1, s_2)
# ## See (si_, sj_) in equation~(44), or thereabouts, in paper
smin_nr = _delta_star * (1 - _r_val)
smax_nr = 1 - _delta_star * _r_val
dr = smin_nr + smax_nr
s_1 = np.array((0, smin_nr / dr, _s_mid, smax_nr / dr, _s_intcpt), float)
bdry_area = _s_mid + s_1[1] * (1 - 2 * _s_mid)
else:
s_1, bdry_area = np.array((0, _s_mid, _s_intcpt), float), _s_mid
return GuidelinesBoundary(
np.array(list(zip(s_1, s_1[::-1])), float), round(float(bdry_area), dps)
)
[docs]
def diversion_share_boundary_max(
_delta_star: float = 0.075, _: float = DEFAULT_REC, /, *, dps: int = 10
) -> GuidelinesBoundary:
R"""
Share combinations on the minimum diversion-ratio/share-ratio boundary.
Parameters
----------
_delta_star
Diversion share, :math:`\overline{d} / \overline{r}` or :math:`\overline{g} / (m^* \cdot \overline{r})`.
_
Placeholder for recapture rate included for consistency with other
share-ratio boundary functions.
dps
Number of decimal places for rounding returned shares.
Returns
-------
Array of share-pairs, area under boundary.
"""
_delta_star = mpf(f"{_delta_star}")
_s_intcpt = _delta_star
_s_mid = _delta_star / (1 + _delta_star)
_s1_pts = (0, _s_mid, _s_intcpt)
return GuidelinesBoundary(
np.array(list(zip(_s1_pts, _s1_pts[::-1])), float),
round(float(_s_intcpt * _s_mid), dps), # simplified calculation
)
def _diversion_share_boundary_intcpt(
s_2_pre: float,
_delta_star: MPFloat,
_r_val: MPFloat,
/,
*,
recapture_form: Literal["inside-out", "fixed"],
agg_method: Literal["arithmetic mean", "geometric mean", "distance"],
weighting: Literal["cross-product-share", "own-share", None],
) -> float:
match weighting:
case "cross-product-share":
_s_intcpt: float = _delta_star
case "own-share":
_s_intcpt = mpf("1.0")
case None if agg_method == "distance":
_s_intcpt = _delta_star * mp.sqrt("2")
case None if agg_method == "arithmetic mean" and recapture_form == "inside-out":
_s_intcpt = mp.fdiv(
mp.fsub(
2 * _delta_star * _r_val + 1, mp.fabs(2 * _delta_star * _r_val - 1)
),
2 * mpf(f"{_r_val}"),
)
case None if agg_method == "arithmetic mean" and recapture_form == "fixed":
_s_intcpt = mp.fsub(_delta_star + 1 / 2, mp.fabs(_delta_star - 1 / 2))
case _:
_s_intcpt = s_2_pre
return _s_intcpt
[docs]
def lerp[LerpT: (float, MPFloat, ArrayDouble)](
_x1: LerpT, _x2: LerpT, _r: float | MPFloat = 0.25, /
) -> LerpT:
R"""
From the function of the same name in the C++ standard [#]_. Also, [#]_.
Constructs the weighted average, :math:`w_1 x_1 + w_2 x_2`, where
:math:`w_1 = 1 - r` and :math:`w_2 = r`.
Parameters
----------
_x1, _x2
Interpolation bounds :math:`x_1, x_2`.
_r
Interpolation weight :math:`r` assigned to :math:`x_2`
Returns
-------
The linear interpolation, or weighted average,
:math:`x_1 + r \cdot (x_2 - x_1) \equiv (1 - r) \cdot x_1 + r \cdot x_2`.
Raises
------
ValueError
If the interpolation weight is not in the interval, :math:`[0, 1]`.
References
----------
.. [#] C++ Reference, https://en.cppreference.com/w/cpp/numeric/lerp
.. [#] Harris, Mark (2015). "GPU Pro Tip: Lerp faster in C++". nvidia.com (Jun 10, 2015). Online at:
https://developer.nvidia.com/blog/lerp-faster-cuda/
"""
match _r:
case 0:
return _x1
case 1:
return _x2
case _:
if not 0 <= _r <= 1:
raise ValueError("Specified interpolation weight must lie in [0, 1].")
if isinstance(_x1, np.ndarray) or isinstance(_x2, np.ndarray):
return (1 - _r) * _x1 + _r * _x2
else:
return fma(_x2, _r, fma(_x1, -_r, _x1))
[docs]
def round_cust(
_num: float | decimal.Decimal | MPFloat = 0.060215,
/,
*,
frac: float = 0.005,
rounding_mode: str = "ROUND_HALF_UP",
) -> float:
"""
Round to given fraction; the nearest 0.5% by default.
Parameters
----------
_num
Number to be rounded.
frac
Fraction to be rounded to.
rounding_mode
Rounding mode, as defined in the :code:`decimal` package.
Returns
-------
The given number, rounded as specified.
Raises
------
ValueError
If rounding mode is not defined in the :code:`decimal` package.
Notes
-----
Integer-round the quotient, :code:`(_num / frac)` using the specified
rounding mode. Return the product of the rounded quotient times
the specified precision, :code:`frac`.
"""
if rounding_mode not in {
decimal.ROUND_05UP,
decimal.ROUND_CEILING,
decimal.ROUND_DOWN,
decimal.ROUND_FLOOR,
decimal.ROUND_HALF_DOWN,
decimal.ROUND_HALF_EVEN,
decimal.ROUND_HALF_UP,
decimal.ROUND_UP,
}:
raise ValueError(
f"Value, {f'"{rounding_mode}"'} is invalid for rounding_mode."
'Documentation for the, "decimal" built-in lists valid rounding modes.'
)
n_, f_, e_ = (decimal.Decimal(f"{g_}") for g_ in [_num, frac, 1])
return float(f_ * (n_ / f_).quantize(e_, rounding=rounding_mode))
[docs]
def boundary_plot(
_plt: ModuleType,
*,
mktshare_plot_flag: bool = True,
mktshare_axes_flag: bool = True,
backend: Literal["pgf"] | str | None = "pgf",
) -> tuple[mpl.figure.Figure, Callable[..., None]]:
"""Set up basic figure and axes for plots of safe harbor boundaries.
See, https://matplotlib.org/stable/tutorials/text/pgf.html
"""
if backend == "pgf":
mpl.use("pgf")
mpl.rcParams.update({
"text.usetex": True,
"pgf.rcfonts": False,
"pgf.texsystem": "lualatex",
"pgf.preamble": "\n".join([
R"\usepackage{fontspec}",
R"\usepackage{luacode}",
R"\begin{luacode}",
R"local function embedfull(tfmdata)",
R' tfmdata.embedding = "full"',
R"end",
R"",
R"luatexbase.add_to_callback("
R' "luaotfload.patch_font", embedfull, "embedfull"'
R")",
R"\end{luacode}",
R"\defaultfontfeatures[\rmfamily]{",
R" Ligatures={TeX, Common},",
R" Numbers={Proportional, Lining},",
R" }",
R"\defaultfontfeatures[\sffamily, \dvsfamily]{",
R" Ligatures={TeX, Common},",
R" Numbers={Monospaced, Lining},",
R" LetterSpace=0.50,",
R" }",
R"\setmainfont{STIX Two Text}",
R"\setsansfont{Fira Sans Light}",
R"\setmonofont[Scale=MatchLowercase,]{Fira Mono}",
R"\newfontfamily\dvsfamily{DejaVu Sans}",
R"\usepackage{mathtools}",
R"\usepackage{unicode-math}",
R"\setmathfont{STIX Two Math}[math-style=ISO,bold-style=ISO]",
R"\setmathfont{STIX Two Math}[math-style=ISO,range={scr,bfscr},StylisticSet=01]",
R"\usepackage[",
R" activate={true, nocompatibility},",
R" tracking=true,",
R" ]{microtype}",
]),
})
else:
if backend:
mpl.use(backend)
mpl.rcParams.update({
"text.usetex": False,
"pgf.rcfonts": True,
"font.family": "sans-serif",
"font.sans-serif": ["DejaVu Sans", "sans-serif"],
"font.monospace": ["DejaVu Mono", "monospace"],
"font.serif": ["stix", "serif"],
"mathtext.fontset": "stix",
})
# Initialize a canvas with a single figure (set of axes)
fig_ = plt.figure(figsize=(5, 5), dpi=600)
ax_ = fig_.add_subplot()
# Set the width of axis grid lines, and tick marks:
# both axes, both major and minor ticks
# Frame, grid, and face color
for _spos0 in "left", "bottom":
ax_.spines[_spos0].set_linewidth(0.5)
ax_.spines[_spos0].set_zorder(5)
for _spos1 in "top", "right":
ax_.spines[_spos1].set_linewidth(0.0)
ax_.spines[_spos1].set_zorder(0)
ax_.spines[_spos1].set_visible(False)
ax_.set_facecolor("#E6E6E6")
ax_.grid(linewidth=0.5, linestyle=":", color="grey", zorder=1)
ax_.tick_params(axis="both", which="both", width=0.5)
# Tick marks skip, size, and rotation
# x-axis
for _t in ax_.get_xticklabels():
_t.update({"fontsize": 6, "rotation": 45, "ha": "right"})
# y-axis
for _t in ax_.get_yticklabels():
_t.update({"fontsize": 6, "rotation": 0, "ha": "right"})
def _set_axis_def(
ax0_: mpa.Axes,
/,
*,
mktshare_plot_flag: bool = False,
mktshare_axes_flag: bool = False,
) -> None:
if mktshare_plot_flag:
# Axis scale
ax0_.set_xlim(0, 1)
ax0_.set_ylim(0, 1)
ax0_.set_aspect(1.0)
# Plot the ray of symmetry
ax0_.plot(
[0, 1], [0, 1], linewidth=0.5, linestyle=":", color="grey", zorder=1
)
# Truncate the axis frame to a triangle bounded by the other diagonal:
ax0_.plot(
[0, 1], [1, 0], linestyle="-", linewidth=0.5, color="black", zorder=1
)
ax0_.add_patch(
mpp.Rectangle(
xy=(1.0025, 0.00),
width=1.1 * mp.sqrt(2),
height=1.1 * mp.sqrt(2),
angle=45,
color="white",
edgecolor=None,
fill=True,
clip_on=True,
zorder=5,
)
)
# Axis Tick-mark locations
# One can supply an argument to mpt.AutoMinorLocator to
# specify a fixed number of minor intervals per major interval, e.g.:
# minorLocator = mpt.AutoMinorLocator(2)
# would lead to a single minor tick between major ticks.
for axs_ in ax0_.xaxis, ax0_.yaxis:
axs_.set_major_locator(mpt.MultipleLocator(0.05))
axs_.set_minor_locator(mpt.AutoMinorLocator(5))
# It"s always x when specifying the format
axs_.set_major_formatter(mpt.StrMethodFormatter("{x:>3.0%}"))
# Hide every other tick-label
for axl_ in ax0_.get_xticklabels(), ax0_.get_yticklabels():
for _t in axl_[::2]:
_t.set_visible(False)
# package version badge
# https://futurile.net/2016/03/14/partial-colouring-text-in-matplotlib-with-latex/
badge_fmt_str = R"{{\dvsfamily {}}}" if backend == "pgf" else "{}"
badge_txt_list = [
badge_fmt_str.format(_s) for _s in [f"{_PKG_NAME}", f"v{VERSION}"]
]
badge_fmt_list = [
_btp := {"color": "#fff", "backgroundcolor": "#555", "size": 2},
_btp | {"backgroundcolor": "#007ec6"},
]
badge_box = mof.HPacker(
sep=2.6,
children=[
mof.TextArea(_t, textprops=_f)
for _t, _f in zip(badge_txt_list, badge_fmt_list)
],
)
ax0_.add_artist(
mof.AnnotationBbox(
badge_box,
xy=(0.5, 0.5),
xybox=(-0.05, -0.12),
xycoords="data",
boxcoords="data",
frameon=False,
pad=0,
)
)
# Axis labels
if mktshare_axes_flag:
# x-axis
ax0_.set_xlabel("Firm 1 Market Share, $s_1$", fontsize=10)
ax0_.xaxis.set_label_coords(0.75, -0.1)
# y-axis
ax0_.set_ylabel("Firm 2 Market Share, $s_2$", fontsize=10)
ax0_.yaxis.set_label_coords(-0.1, 0.75)
_set_axis_def(
ax_,
mktshare_plot_flag=mktshare_plot_flag,
mktshare_axes_flag=mktshare_axes_flag,
)
return fig_, _set_axis_def