mergeron 2024.738963.0__py3-none-any.whl → 2024.738973.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mergeron might be problematic. Click here for more details.

@@ -0,0 +1,826 @@
1
+ import decimal
2
+ from importlib.metadata import version
3
+ from typing import Any, Literal
4
+
5
+ import numpy as np
6
+ from mpmath import mp, mpf # type: ignore
7
+ from numpy.typing import NDArray
8
+
9
+ from .. import _PKG_NAME # noqa: TID252
10
+ from . import GuidelinesBoundary
11
+
12
+ __version__ = version(_PKG_NAME)
13
+
14
+
15
+ mp.prec = 80
16
+ mp.trap_complex = True
17
+
18
+
19
+ def dh_area(_dh_val: float = 0.01, /, *, prec: int = 9) -> float:
20
+ R"""
21
+ Area under the ΔHHI boundary.
22
+
23
+ When the given ΔHHI bound matches a Guidelines standard,
24
+ the area under the boundary is half the intrinsic clearance rate
25
+ for the ΔHHI safeharbor.
26
+
27
+ Notes
28
+ -----
29
+ To derive the knots, :math:`(s^0_1, s^1_1), (s^1_1, s^0_1)`
30
+ of the ΔHHI boundary, i.e., the points where it intersects
31
+ the merger-to-monopoly boundary, solve
32
+
33
+ .. math::
34
+
35
+ 2 s1 s_2 &= ΔHHI\\
36
+ s_1 + s_2 &= 1
37
+
38
+ Parameters
39
+ ----------
40
+ _dh_val
41
+ Change in concentration.
42
+ prec
43
+ Specified precision in decimal places.
44
+
45
+ Returns
46
+ -------
47
+ Area under ΔHHI boundary.
48
+
49
+ """
50
+
51
+ _dh_val = mpf(f"{_dh_val}")
52
+ _s_naught = (1 - mp.sqrt(1 - 2 * _dh_val)) / 2
53
+
54
+ return round(
55
+ float(_s_naught + (_dh_val / 2) * (mp.ln(1 - _s_naught) - mp.ln(_s_naught))),
56
+ prec,
57
+ )
58
+
59
+
60
+ def shrratio_boundary_wtd_avg(
61
+ _delta_star: float = 0.075,
62
+ _r_val: float = 0.855,
63
+ /,
64
+ *,
65
+ agg_method: Literal["arithmetic", "geometric", "distance"] = "arithmetic",
66
+ weighting: Literal["own-share", "cross-product-share"] | None = "own-share",
67
+ recapture_form: Literal["inside-out", "proportional"] = "inside-out",
68
+ prec: int = 5,
69
+ ) -> GuidelinesBoundary:
70
+ """
71
+ Share combinations on the share-weighted average diversion ratio boundary.
72
+
73
+ Parameters
74
+ ----------
75
+ _delta_star
76
+ Share ratio (:math:`\\overline{d} / \\overline{r}`)
77
+ _r_val
78
+ recapture ratio
79
+ agg_method
80
+ Whether "arithmetic", "geometric", or "distance".
81
+ weighting
82
+ Whether "own-share" or "cross-product-share" (or None for simple, unweighted average).
83
+ recapture_form
84
+ Whether recapture-ratio is MNL-consistent ("inside-out") or has fixed
85
+ value for both merging firms ("proportional").
86
+ prec
87
+ Number of decimal places for rounding returned shares and area.
88
+
89
+ Returns
90
+ -------
91
+ Array of share-pairs, area under boundary.
92
+
93
+ Notes
94
+ -----
95
+ An analytical expression for the share-weighted arithmetic mean boundary
96
+ is derived and plotted from y-intercept to the ray of symmetry as follows::
97
+
98
+ from sympy import plot as symplot, solve, symbols
99
+ s_1, s_2 = symbols("s_1 s_2", positive=True)
100
+
101
+ g_val, r_val, m_val = 0.06, 0.80, 0.30
102
+ delta_star = g_val / (r_val * m_val)
103
+
104
+ # recapture_form == "inside-out"
105
+ oswag = solve(
106
+ s_1 * s_2 / (1 - s_1)
107
+ + s_2 * s_1 / (1 - (r_val * s_2 + (1 - r_val) * s_1))
108
+ - (s_1 + s_2) * delta_star,
109
+ s_2
110
+ )[0]
111
+ symplot(
112
+ oswag,
113
+ (s_1, 0., d_hat / (1 + d_hat)),
114
+ ylabel=s_2
115
+ )
116
+
117
+ cpswag = solve(
118
+ s_2 * s_2 / (1 - s_1)
119
+ + s_1 * s_1 / (1 - (r_val * s_2 + (1 - r_val) * s_1))
120
+ - (s_1 + s_2) * delta_star,
121
+ s_2
122
+ )[1]
123
+ symplot(
124
+ cpwag,
125
+ (s_1, 0., d_hat / (1 + d_hat)),
126
+ ylabel=s_2
127
+ )
128
+
129
+ # recapture_form == "proportional"
130
+ oswag = solve(
131
+ s_1 * s_2 / (1 - s_1)
132
+ + s_2 * s_1 / (1 - s_2)
133
+ - (s_1 + s_2) * delta_star,
134
+ s_2
135
+ )[0]
136
+ symplot(
137
+ oswag,
138
+ (s_1, 0., d_hat / (1 + d_hat)),
139
+ ylabel=s_2
140
+ )
141
+
142
+ cpswag = solve(
143
+ s_2 * s_2 / (1 - s_1)
144
+ + s_1 * s_1 / (1 - s_2)
145
+ - (s_1 + s_2) * delta_star,
146
+ s_2
147
+ )[1]
148
+ symplot(
149
+ cpswag,
150
+ (s_1, 0.0, d_hat / (1 + d_hat)),
151
+ ylabel=s_2
152
+ )
153
+
154
+
155
+ """
156
+
157
+ _delta_star = mpf(f"{_delta_star}")
158
+ _s_mid = _delta_star / (1 + _delta_star)
159
+
160
+ # initial conditions
161
+ _gbdry_points = [(_s_mid, _s_mid)]
162
+ _s_1_pre, _s_2_pre = _s_mid, _s_mid
163
+ _s_2_oddval, _s_2_oddsum, _s_2_evnsum = True, 0, 0
164
+
165
+ # parameters for iteration
166
+ _gbd_step_sz = mp.power(10, -prec)
167
+ _theta = _gbd_step_sz * (10 if weighting == "cross-product-share" else 1)
168
+ for _s_1 in mp.arange(_s_mid - _gbd_step_sz, 0, -_gbd_step_sz):
169
+ # The wtd. avg. GUPPI is not always convex to the origin, so we
170
+ # increment _s_2 after each iteration in which our algorithm
171
+ # finds (s1, s2) on the boundary
172
+ _s_2 = _s_2_pre * (1 + _theta)
173
+
174
+ if (_s_1 + _s_2) > mpf("0.99875"):
175
+ # Loss of accuracy at 3-9s and up
176
+ break
177
+
178
+ while True:
179
+ _de_1 = _s_2 / (1 - _s_1)
180
+ _de_2 = (
181
+ _s_1 / (1 - lerp(_s_1, _s_2, _r_val))
182
+ if recapture_form == "inside-out"
183
+ else _s_1 / (1 - _s_2)
184
+ )
185
+
186
+ _r = (
187
+ mp.fdiv(
188
+ _s_1 if weighting == "cross-product-share" else _s_2, _s_1 + _s_2
189
+ )
190
+ if weighting
191
+ else 0.5
192
+ )
193
+
194
+ match agg_method:
195
+ case "geometric":
196
+ _delta_test = mp.expm1(lerp(mp.log1p(_de_1), mp.log1p(_de_2), _r))
197
+ case "distance":
198
+ _delta_test = mp.sqrt(lerp(_de_1**2, _de_2**2, _r))
199
+ case _:
200
+ _delta_test = lerp(_de_1, _de_2, _r)
201
+
202
+ _test_flag, _incr_decr = (
203
+ (_delta_test > _delta_star, -1)
204
+ if weighting == "cross-product-share"
205
+ else (_delta_test < _delta_star, 1)
206
+ )
207
+
208
+ if _test_flag:
209
+ _s_2 += _incr_decr * _gbd_step_sz
210
+ else:
211
+ break
212
+
213
+ # Build-up boundary points
214
+ _gbdry_points.append((_s_1, _s_2))
215
+
216
+ # Build up area terms
217
+ _s_2_oddsum += _s_2 if _s_2_oddval else 0
218
+ _s_2_evnsum += _s_2 if not _s_2_oddval else 0
219
+ _s_2_oddval = not _s_2_oddval
220
+
221
+ # Hold share points
222
+ _s_2_pre = _s_2
223
+ _s_1_pre = _s_1
224
+
225
+ if _s_2_oddval:
226
+ _s_2_evnsum -= _s_2_pre
227
+ else:
228
+ _s_2_oddsum -= _s_1_pre
229
+
230
+ _s_intcpt = _shrratio_boundary_intcpt(
231
+ _s_1_pre,
232
+ _delta_star,
233
+ _r_val,
234
+ recapture_form=recapture_form,
235
+ agg_method=agg_method,
236
+ weighting=weighting,
237
+ )
238
+
239
+ if weighting == "own-share":
240
+ _gbd_prtlarea = (
241
+ _gbd_step_sz * (4 * _s_2_oddsum + 2 * _s_2_evnsum + _s_mid + _s_2_pre) / 3
242
+ )
243
+ # Area under boundary
244
+ _gbdry_area_total = float(
245
+ 2 * (_s_1_pre + _gbd_prtlarea)
246
+ - (mp.power(_s_mid, "2") + mp.power(_s_1_pre, "2"))
247
+ )
248
+
249
+ else:
250
+ _gbd_prtlarea = (
251
+ _gbd_step_sz * (4 * _s_2_oddsum + 2 * _s_2_evnsum + _s_mid + _s_intcpt) / 3
252
+ )
253
+ # Area under boundary
254
+ _gbdry_area_total = float(2 * _gbd_prtlarea - mp.power(_s_mid, "2"))
255
+
256
+ _gbdry_points = np.row_stack((_gbdry_points, (mpf("0.0"), _s_intcpt))).astype(
257
+ np.float64
258
+ )
259
+
260
+ # Points defining boundary to point-of-symmetry
261
+ return GuidelinesBoundary(
262
+ np.row_stack((np.flip(_gbdry_points, 0), np.flip(_gbdry_points[1:], 1))),
263
+ round(float(_gbdry_area_total), prec),
264
+ )
265
+
266
+
267
+ def shrratio_boundary_xact_avg(
268
+ _delta_star: float = 0.075,
269
+ _r_val: float = 0.855,
270
+ /,
271
+ *,
272
+ recapture_form: Literal["inside-out", "proportional"] = "inside-out",
273
+ prec: int = 5,
274
+ ) -> GuidelinesBoundary:
275
+ """
276
+ Share combinations for the simple average GUPPI boundary with symmetric
277
+ merging-firm margins.
278
+
279
+ Notes
280
+ -----
281
+ An analytical expression for the exact average boundary is derived
282
+ and plotted from the y-intercept to the ray of symmetry as follows::
283
+
284
+ from sympy import latex, plot as symplot, solve, symbols
285
+
286
+ s_1, s_2 = symbols("s_1 s_2")
287
+
288
+ g_val, r_val, m_val = 0.06, 0.80, 0.30
289
+ d_hat = g_val / (r_val * m_val)
290
+
291
+ # recapture_form = "inside-out"
292
+ sag = solve(
293
+ (s_2 / (1 - s_1))
294
+ + (s_1 / (1 - (r_val * s_2 + (1 - r_val) * s_1)))
295
+ - 2 * d_hat,
296
+ s_2
297
+ )[0]
298
+ symplot(
299
+ sag,
300
+ (s_1, 0., d_hat / (1 + d_hat)),
301
+ ylabel=s_2
302
+ )
303
+
304
+ # recapture_form = "proportional"
305
+ sag = solve((s_2/(1 - s_1)) + (s_1/(1 - s_2)) - 2 * d_hat, s_2)[0]
306
+ symplot(
307
+ sag,
308
+ (s_1, 0., d_hat / (1 + d_hat)),
309
+ ylabel=s_2
310
+ )
311
+
312
+ Parameters
313
+ ----------
314
+ _delta_star
315
+ Share ratio (:math:`\\overline{d} / \\overline{r}`).
316
+ _r_val
317
+ Recapture ratio
318
+ recapture_form
319
+ Whether recapture-ratio is MNL-consistent ("inside-out") or has fixed
320
+ value for both merging firms ("proportional").
321
+ prec
322
+ Number of decimal places for rounding returned shares.
323
+
324
+ Returns
325
+ -------
326
+ Array of share-pairs, area under boundary, area under boundary.
327
+
328
+ """
329
+
330
+ _delta_star = mpf(f"{_delta_star}")
331
+ _s_mid = _delta_star / (1 + _delta_star)
332
+ _gbd_step_sz = mp.power(10, -prec)
333
+
334
+ _gbdry_points_start = np.array([(_s_mid, _s_mid)])
335
+ _s_1 = np.array(mp.arange(_s_mid - _gbd_step_sz, 0, -_gbd_step_sz), np.float64)
336
+ if recapture_form == "inside-out":
337
+ _s_intcpt = mp.fdiv(
338
+ mp.fsub(
339
+ 2 * _delta_star * _r_val + 1, mp.fabs(2 * _delta_star * _r_val - 1)
340
+ ),
341
+ 2 * mpf(f"{_r_val}"),
342
+ )
343
+ _nr_t1 = 1 + 2 * _delta_star * _r_val * (1 - _s_1) - _s_1 * (1 - _r_val)
344
+
345
+ _nr_sqrt_mdr = 4 * _delta_star * _r_val
346
+ _nr_sqrt_mdr2 = _nr_sqrt_mdr * _r_val
347
+ _nr_sqrt_md2r2 = _nr_sqrt_mdr2 * _delta_star
348
+
349
+ _nr_sqrt_t1 = _nr_sqrt_md2r2 * (_s_1**2 - 2 * _s_1 + 1)
350
+ _nr_sqrt_t2 = _nr_sqrt_mdr2 * _s_1 * (_s_1 - 1)
351
+ _nr_sqrt_t3 = _nr_sqrt_mdr * (2 * _s_1 - _s_1**2 - 1)
352
+ _nr_sqrt_t4 = (_s_1**2) * (_r_val**2 - 6 * _r_val + 1)
353
+ _nr_sqrt_t5 = _s_1 * (6 * _r_val - 2) + 1
354
+
355
+ _nr_t2_mdr = _nr_sqrt_t1 + _nr_sqrt_t2 + _nr_sqrt_t3 + _nr_sqrt_t4 + _nr_sqrt_t5
356
+
357
+ # Alternative grouping of terms in np.sqrt
358
+ _nr_sqrt_s1sq = (_s_1**2) * (
359
+ _nr_sqrt_md2r2 + _nr_sqrt_mdr2 - _nr_sqrt_mdr + _r_val**2 - 6 * _r_val + 1
360
+ )
361
+ _nr_sqrt_s1 = _s_1 * (
362
+ -2 * _nr_sqrt_md2r2 - _nr_sqrt_mdr2 + 2 * _nr_sqrt_mdr + 6 * _r_val - 2
363
+ )
364
+ _nr_sqrt_nos1 = _nr_sqrt_md2r2 - _nr_sqrt_mdr + 1
365
+
366
+ _nr_t2_s1 = _nr_sqrt_s1sq + _nr_sqrt_s1 + _nr_sqrt_nos1
367
+
368
+ if not np.isclose(
369
+ np.einsum("i->", _nr_t2_mdr.astype(np.float64)),
370
+ np.einsum("i->", _nr_t2_s1.astype(np.float64)),
371
+ rtol=0,
372
+ atol=0.5 * prec,
373
+ ):
374
+ raise RuntimeError(
375
+ "Calculation of sq. root term in exact average GUPPI"
376
+ f"with recapture spec, {f'"{recapture_form}"'} is incorrect."
377
+ )
378
+
379
+ _s_2 = (_nr_t1 - np.sqrt(_nr_t2_s1)) / (2 * _r_val)
380
+
381
+ else:
382
+ _s_intcpt = mp.fsub(_delta_star + 1 / 2, mp.fabs(_delta_star - 1 / 2))
383
+ _s_2 = (
384
+ (1 / 2)
385
+ + _delta_star
386
+ - _delta_star * _s_1
387
+ - np.sqrt(
388
+ ((_delta_star**2) - 1) * (_s_1**2)
389
+ + (-2 * (_delta_star**2) + _delta_star + 1) * _s_1
390
+ + (_delta_star**2)
391
+ - _delta_star
392
+ + (1 / 4)
393
+ )
394
+ )
395
+
396
+ _gbdry_points_inner = np.column_stack((_s_1, _s_2))
397
+ _gbdry_points_end = np.array([(mpf("0.0"), _s_intcpt)], np.float64)
398
+
399
+ _gbdry_points = np.row_stack((
400
+ _gbdry_points_end,
401
+ np.flip(_gbdry_points_inner, 0),
402
+ _gbdry_points_start,
403
+ np.flip(_gbdry_points_inner, 1),
404
+ np.flip(_gbdry_points_end, 1),
405
+ )).astype(np.float64)
406
+ _s_2 = np.concatenate((np.array([_s_mid], np.float64), _s_2))
407
+
408
+ _gbdry_ends = [0, -1]
409
+ _gbdry_odds = np.array(range(1, len(_s_2), 2), np.int64)
410
+ _gbdry_evns = np.array(range(2, len(_s_2), 2), np.int64)
411
+
412
+ # Double the are under the curve, and subtract the double counted bit.
413
+ _gbdry_area_simpson = 2 * _gbd_step_sz * (
414
+ (4 / 3) * np.sum(_s_2.take(_gbdry_odds))
415
+ + (2 / 3) * np.sum(_s_2.take(_gbdry_evns))
416
+ + (1 / 3) * np.sum(_s_2.take(_gbdry_ends))
417
+ ) - np.power(_s_mid, 2)
418
+
419
+ _s_1_pts, _s_2_pts = np.split(_gbdry_points, 2, axis=1)
420
+ return GuidelinesBoundary(
421
+ np.column_stack((np.array(_s_1_pts), np.array(_s_2_pts))),
422
+ round(float(_gbdry_area_simpson), prec),
423
+ )
424
+
425
+
426
+ def shrratio_boundary_min(
427
+ _delta_star: float = 0.075,
428
+ _r_val: float = 0.855,
429
+ /,
430
+ *,
431
+ recapture_form: str = "inside-out",
432
+ prec: int = 10,
433
+ ) -> GuidelinesBoundary:
434
+ """
435
+ Share combinations on the minimum GUPPI boundary, with symmetric
436
+ merging-firm margins.
437
+
438
+ Notes
439
+ -----
440
+ With symmetric merging-firm margins, the maximum GUPPI boundary is
441
+ defined by the diversion ratio from the smaller merging-firm to the
442
+ larger one, and is hence unaffected by the method of estimating the
443
+ diversion ratio for the larger firm.
444
+
445
+ Parameters
446
+ ----------
447
+ _delta_star
448
+ Share ratio (:math:`\\overline{d} / \\overline{r}`).
449
+ _r_val
450
+ Recapture ratio.
451
+ recapture_form
452
+ Whether recapture-ratio is MNL-consistent ("inside-out") or has fixed
453
+ value for both merging firms ("proportional").
454
+ prec
455
+ Number of decimal places for rounding returned shares.
456
+
457
+ Returns
458
+ -------
459
+ Array of share-pairs, area under boundary.
460
+
461
+ """
462
+
463
+ _delta_star = mpf(f"{_delta_star}")
464
+ _s_intcpt = mpf("1.00")
465
+ _s_mid = _delta_star / (1 + _delta_star)
466
+
467
+ if recapture_form == "inside-out":
468
+ # ## Plot envelope of GUPPI boundaries with r_k = r_bar if s_k = min(_s_1, _s_2)
469
+ # ## See (s_i, s_j) in equation~(44), or thereabouts, in paper
470
+ _smin_nr = _delta_star * (1 - _r_val)
471
+ _smax_nr = 1 - _delta_star * _r_val
472
+ _guppi_bdry_env_dr = _smin_nr + _smax_nr
473
+ _s1_pts = np.array(
474
+ (
475
+ 0,
476
+ _smin_nr / _guppi_bdry_env_dr,
477
+ _s_mid,
478
+ _smax_nr / _guppi_bdry_env_dr,
479
+ _s_intcpt,
480
+ ),
481
+ np.float64,
482
+ )
483
+
484
+ _gbd_area = _s_mid + _s1_pts[1] * (1 - 2 * _s_mid)
485
+ else:
486
+ _s1_pts, _gbd_area = np.array((0, _s_mid, _s_intcpt), np.float64), _s_mid
487
+
488
+ return GuidelinesBoundary(
489
+ np.column_stack((_s1_pts, _s1_pts[::-1])), round(float(_gbd_area), prec)
490
+ )
491
+
492
+
493
+ def shrratio_boundary_max(
494
+ _delta_star: float = 0.075, _r_val: float = 0.855, /, *, prec: int = 10
495
+ ) -> GuidelinesBoundary:
496
+ """
497
+ Share combinations on the minimum GUPPI boundary with symmetric
498
+ merging-firm margins.
499
+
500
+ Parameters
501
+ ----------
502
+ _delta_star
503
+ Share ratio (:math:`\\overline{d} / \\overline{r}`).
504
+ _r_val
505
+ Recapture ratio.
506
+ prec
507
+ Number of decimal places for rounding returned shares.
508
+
509
+ Returns
510
+ -------
511
+ Array of share-pairs, area under boundary.
512
+
513
+ """
514
+
515
+ # _r_val is not needed for max boundary, but is specified for consistency
516
+ # of function call with other shrratio_mgnsym_boundary functions
517
+ del _r_val
518
+ _delta_star = mpf(f"{_delta_star}")
519
+ _s_intcpt = _delta_star
520
+ _s_mid = _delta_star / (1 + _delta_star)
521
+
522
+ _s1_pts = (0, _s_mid, _s_intcpt)
523
+
524
+ return GuidelinesBoundary(
525
+ np.column_stack((
526
+ np.array(_s1_pts, np.float64),
527
+ np.array(_s1_pts[::-1], np.float64),
528
+ )),
529
+ round(float(_s_intcpt * _s_mid), prec), # simplified calculation
530
+ )
531
+
532
+
533
+ def _shrratio_boundary_intcpt(
534
+ _s_2_pre: float,
535
+ _delta_star: mpf,
536
+ _r_val: mpf,
537
+ /,
538
+ *,
539
+ recapture_form: Literal["inside-out", "proportional"],
540
+ agg_method: Literal["arithmetic", "geometric", "distance"],
541
+ weighting: Literal["cross-product-share", "own-share"] | None,
542
+ ) -> float:
543
+ match weighting:
544
+ case "cross-product-share":
545
+ _s_intcpt: float = _delta_star
546
+ case "own-share":
547
+ _s_intcpt = mpf("1.0")
548
+ case None if agg_method == "distance":
549
+ _s_intcpt = _delta_star * mp.sqrt("2")
550
+ case None if agg_method == "arithmetic" and recapture_form == "inside-out":
551
+ _s_intcpt = mp.fdiv(
552
+ mp.fsub(
553
+ 2 * _delta_star * _r_val + 1, mp.fabs(2 * _delta_star * _r_val - 1)
554
+ ),
555
+ 2 * mpf(f"{_r_val}"),
556
+ )
557
+ case None if agg_method == "arithmetic":
558
+ _s_intcpt = mp.fsub(_delta_star + 1 / 2, mp.fabs(_delta_star - 1 / 2))
559
+ case _:
560
+ _s_intcpt = _s_2_pre
561
+
562
+ return _s_intcpt
563
+
564
+
565
+ def lerp(
566
+ _x1: int | float | mpf | NDArray[np.float64 | np.int64] = 3,
567
+ _x2: int | float | mpf | NDArray[np.float64 | np.int64] = 1,
568
+ _r: float | mpf = 0.25,
569
+ /,
570
+ ) -> float | mpf | NDArray[np.float64]:
571
+ """
572
+ From the function of the same name in the C++ standard [2]_
573
+
574
+ Constructs the weighted average, :math:`w_1 x_1 + w_2 x_2`, where
575
+ :math:`w_1 = 1 - r` and :math:`w_2 = r`.
576
+
577
+ Parameters
578
+ ----------
579
+ _x1, _x2
580
+ bounds :math:`x_1, x_2` to interpolate between.
581
+ _r
582
+ interpolation weight :math:`r` assigned to :math:`x_2`
583
+
584
+ Returns
585
+ -------
586
+ The linear interpolation, or weighted average,
587
+ :math:`x_1 + r \\cdot (x_1 - x_2) \\equiv (1 - r) \\cdot x_1 + r \\cdot x_2`.
588
+
589
+ Raises
590
+ ------
591
+ ValueError
592
+ If the interpolation weight is not in the interval, :math:`[0, 1]`.
593
+
594
+ References
595
+ ----------
596
+
597
+ .. [2] C++ Reference, https://en.cppreference.com/w/cpp/numeric/lerp
598
+
599
+ """
600
+
601
+ if not 0 <= _r <= 1:
602
+ raise ValueError("Specified interpolation weight must lie in [0, 1].")
603
+ elif _r == 0:
604
+ return _x1
605
+ elif _r == 1:
606
+ return _x2
607
+ elif _r == 0.5:
608
+ return 1 / 2 * (_x1 + _x2)
609
+ else:
610
+ return _r * _x2 + (1 - _r) * _x1
611
+
612
+
613
+ def round_cust(
614
+ _num: float = 0.060215,
615
+ /,
616
+ *,
617
+ frac: float = 0.005,
618
+ rounding_mode: str = "ROUND_HALF_UP",
619
+ ) -> float:
620
+ """
621
+ Custom rounding, to the nearest 0.5% by default.
622
+
623
+ Parameters
624
+ ----------
625
+ _num
626
+ Number to be rounded.
627
+ frac
628
+ Fraction to be rounded to.
629
+ rounding_mode
630
+ Rounding mode, as defined in the :code:`decimal` package.
631
+
632
+ Returns
633
+ -------
634
+ The given number, rounded as specified.
635
+
636
+ Raises
637
+ ------
638
+ ValueError
639
+ If rounding mode is not defined in the :code:`decimal` package.
640
+
641
+ Notes
642
+ -----
643
+ Integer-round the quotient, :code:`(_num / frac)` using the specified
644
+ rounding mode. Return the product of the rounded quotient times
645
+ the specified precision, :code:`frac`.
646
+
647
+ """
648
+
649
+ if rounding_mode not in (
650
+ decimal.ROUND_05UP,
651
+ decimal.ROUND_CEILING,
652
+ decimal.ROUND_DOWN,
653
+ decimal.ROUND_FLOOR,
654
+ decimal.ROUND_HALF_DOWN,
655
+ decimal.ROUND_HALF_EVEN,
656
+ decimal.ROUND_HALF_UP,
657
+ decimal.ROUND_UP,
658
+ ):
659
+ raise ValueError(
660
+ f"Value, {f'"{rounding_mode}"'} is invalid for rounding_mode."
661
+ "Documentation for the, \"decimal\" built-in lists valid rounding modes."
662
+ )
663
+
664
+ _n, _f, _e = (decimal.Decimal(f"{_g}") for _g in [_num, frac, 1])
665
+
666
+ return float(_f * (_n / _f).quantize(_e, rounding=rounding_mode))
667
+
668
+
669
+ def boundary_plot(*, mktshares_plot_flag: bool = True) -> tuple[Any, ...]:
670
+ """Setup basic figure and axes for plots of safe harbor boundaries.
671
+
672
+ See, https://matplotlib.org/stable/tutorials/text/pgf.html
673
+ """
674
+
675
+ import matplotlib as mpl
676
+ import matplotlib.axes as mpa
677
+ import matplotlib.patches as mpp
678
+ import matplotlib.ticker as mpt
679
+
680
+ mpl.use("pgf")
681
+ import matplotlib.pyplot as plt
682
+
683
+ plt.rcParams.update({
684
+ "pgf.rcfonts": False,
685
+ "pgf.texsystem": "lualatex",
686
+ "pgf.preamble": "\n".join([
687
+ R"\pdfvariable minorversion=7",
688
+ R"\usepackage{fontspec}",
689
+ R"\usepackage{luacode}",
690
+ R"\begin{luacode}",
691
+ R"local function embedfull(tfmdata)",
692
+ R' tfmdata.embedding = "full"',
693
+ R"end",
694
+ R"",
695
+ R"luatexbase.add_to_callback("
696
+ R' "luaotfload.patch_font", embedfull, "embedfull"'
697
+ R")",
698
+ R"\end{luacode}",
699
+ R"\usepackage{mathtools}",
700
+ R"\usepackage{unicode-math}",
701
+ R"\setmathfont[math-style=ISO]{STIX Two Math}",
702
+ R"\setmainfont{STIX Two Text}",
703
+ r"\setsansfont{Fira Sans Light}",
704
+ R"\setmonofont[Scale=MatchLowercase,]{Fira Mono}",
705
+ R"\defaultfontfeatures[\rmfamily]{",
706
+ R" Ligatures={TeX, Common},",
707
+ R" Numbers={Proportional, Lining},",
708
+ R" }",
709
+ R"\defaultfontfeatures[\sffamily]{",
710
+ R" Ligatures={TeX, Common},",
711
+ R" Numbers={Monospaced, Lining},",
712
+ R" LetterSpace=0.50,",
713
+ R" }",
714
+ R"\usepackage[",
715
+ R" activate={true, nocompatibility},",
716
+ R" tracking=true,",
717
+ R" ]{microtype}",
718
+ ]),
719
+ })
720
+
721
+ # Initialize a canvas with a single figure (set of axes)
722
+ _fig = plt.figure(figsize=(5, 5), dpi=600)
723
+ _ax_out = _fig.add_subplot()
724
+
725
+ def _set_axis_def(
726
+ _ax1: mpa.Axes,
727
+ /,
728
+ *,
729
+ mktshares_plot_flag: bool = False,
730
+ mktshares_axlbls_flag: bool = False,
731
+ ) -> mpa.Axes:
732
+ # Set the width of axis gridlines, and tick marks:
733
+ # both axes, both major and minor ticks
734
+ # Frame, grid, and facecolor
735
+ for _spos0 in "left", "bottom":
736
+ _ax1.spines[_spos0].set_linewidth(0.5)
737
+ _ax1.spines[_spos0].set_zorder(5)
738
+ for _spos1 in "top", "right":
739
+ _ax1.spines[_spos1].set_linewidth(0.0)
740
+ _ax1.spines[_spos1].set_zorder(0)
741
+ _ax1.spines[_spos1].set_visible(False)
742
+ _ax1.set_facecolor("#E6E6E6")
743
+
744
+ _ax1.grid(linewidth=0.5, linestyle=":", color="grey", zorder=1)
745
+ _ax1.tick_params(axis="x", which="both", width=0.5)
746
+ _ax1.tick_params(axis="y", which="both", width=0.5)
747
+
748
+ # Tick marks skip, size, and rotation
749
+ # x-axis
750
+ plt.setp(
751
+ _ax1.xaxis.get_majorticklabels(),
752
+ horizontalalignment="right",
753
+ fontsize=6,
754
+ rotation=45,
755
+ )
756
+ # y-axis
757
+ plt.setp(
758
+ _ax1.yaxis.get_majorticklabels(), horizontalalignment="right", fontsize=6
759
+ )
760
+
761
+ if mktshares_plot_flag:
762
+ # Axis labels
763
+ if mktshares_axlbls_flag:
764
+ # x-axis
765
+ _ax1.set_xlabel("Firm 1 Market Share, $s_1$", fontsize=10)
766
+ _ax1.xaxis.set_label_coords(0.75, -0.1)
767
+ # y-axis
768
+ _ax1.set_ylabel("Firm 2 Market Share, $s_2$", fontsize=10)
769
+ _ax1.yaxis.set_label_coords(-0.1, 0.75)
770
+
771
+ # Plot the ray of symmetry
772
+ _ax1.plot(
773
+ [0, 1], [0, 1], linewidth=0.5, linestyle=":", color="grey", zorder=1
774
+ )
775
+
776
+ # Axis scale
777
+ _ax1.set_xlim(0, 1)
778
+ _ax1.set_ylim(0, 1)
779
+ _ax1.set_aspect(1.0)
780
+
781
+ # Truncate the axis frame to a triangle:
782
+ _ax1.add_patch(
783
+ mpp.Rectangle(
784
+ xy=(1.0025, 0.00),
785
+ width=1.1 * mp.sqrt(2),
786
+ height=1.1 * mp.sqrt(2),
787
+ angle=45,
788
+ color="white",
789
+ edgecolor=None,
790
+ fill=True,
791
+ clip_on=True,
792
+ zorder=5,
793
+ )
794
+ )
795
+ # Feasible space is bounded by the other diagonal:
796
+ _ax1.plot(
797
+ [0, 1], [1, 0], linestyle="-", linewidth=0.5, color="black", zorder=1
798
+ )
799
+
800
+ # Axis Tick-mark locations
801
+ # One can supply an argument to mpt.AutoMinorLocator to
802
+ # specify a fixed number of minor intervals per major interval, e.g.:
803
+ # minorLocator = mpt.AutoMinorLocator(2)
804
+ # would lead to a single minor tick between major ticks.
805
+ _minorLocator = mpt.AutoMinorLocator(5)
806
+ _majorLocator = mpt.MultipleLocator(0.05)
807
+ for _axs in _ax1.xaxis, _ax1.yaxis:
808
+ if _axs == _ax1.xaxis:
809
+ _majorticklabels_rot = 45
810
+ elif _axs == _ax1.yaxis:
811
+ _majorticklabels_rot = 0
812
+ # x-axis
813
+ _axs.set_major_locator(_majorLocator)
814
+ _axs.set_minor_locator(_minorLocator)
815
+ # It"s always x when specifying the format
816
+ _axs.set_major_formatter(mpt.StrMethodFormatter("{x:>3.0%}"))
817
+
818
+ # Hide every other tick-label
819
+ for _axl in _ax1.get_xticklabels(), _ax1.get_yticklabels():
820
+ plt.setp(_axl[::2], visible=False)
821
+
822
+ return _ax1
823
+
824
+ _ax_out = _set_axis_def(_ax_out, mktshares_plot_flag=mktshares_plot_flag)
825
+
826
+ return plt, _fig, _ax_out, _set_axis_def