ggh4x-python 0.3.1.9000__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.
- ggh4x/__init__.py +140 -0
- ggh4x/_aimed_text_grob.py +432 -0
- ggh4x/_borrowed_ggplot2.py +273 -0
- ggh4x/_cli.py +84 -0
- ggh4x/_datasets.py +106 -0
- ggh4x/_download.py +111 -0
- ggh4x/_facet_helpers.py +313 -0
- ggh4x/_facet_utils.py +649 -0
- ggh4x/_gap_grobs.py +606 -0
- ggh4x/_registry.py +10 -0
- ggh4x/_rlang.py +93 -0
- ggh4x/_utils.py +150 -0
- ggh4x/_vctrs.py +233 -0
- ggh4x/conveniences.py +601 -0
- ggh4x/coord_axes_inside.py +380 -0
- ggh4x/element_part_rect.py +545 -0
- ggh4x/facet_grid2.py +1018 -0
- ggh4x/facet_manual.py +901 -0
- ggh4x/facet_nested.py +776 -0
- ggh4x/facet_nested_wrap.py +193 -0
- ggh4x/facet_wrap2.py +896 -0
- ggh4x/geom_box.py +536 -0
- ggh4x/geom_outline_point.py +444 -0
- ggh4x/geom_pointpath.py +259 -0
- ggh4x/geom_polygonraster.py +252 -0
- ggh4x/geom_rectrug.py +489 -0
- ggh4x/geom_text_aimed.py +279 -0
- ggh4x/guide_stringlegend.py +354 -0
- ggh4x/help_secondary.py +549 -0
- ggh4x/multiscale/__init__.py +51 -0
- ggh4x/multiscale/_multiscale_add.py +207 -0
- ggh4x/multiscale/scale_listed.py +167 -0
- ggh4x/multiscale/scale_manual.py +478 -0
- ggh4x/multiscale/scale_multi.py +393 -0
- ggh4x/panel_scales/__init__.py +58 -0
- ggh4x/panel_scales/at_panel.py +115 -0
- ggh4x/panel_scales/facetted_pos_scales.py +647 -0
- ggh4x/panel_scales/force_panelsize.py +411 -0
- ggh4x/panel_scales/scale_facet.py +222 -0
- ggh4x/position_disjoint_ranges.py +229 -0
- ggh4x/position_lineartrans.py +242 -0
- ggh4x/py.typed +0 -0
- ggh4x/resources/faithful.csv +273 -0
- ggh4x/resources/iris.csv +151 -0
- ggh4x/resources/mtcars.csv +33 -0
- ggh4x/resources/pressure.csv +20 -0
- ggh4x/resources/volcano.csv +87 -0
- ggh4x/save.py +255 -0
- ggh4x/stat_difference.py +388 -0
- ggh4x/stat_funxy.py +436 -0
- ggh4x/stat_rle.py +290 -0
- ggh4x/stat_rollingkernel.py +369 -0
- ggh4x/stat_theodensity.py +681 -0
- ggh4x/strip_nested.py +448 -0
- ggh4x/strip_split.py +687 -0
- ggh4x/strip_tag.py +636 -0
- ggh4x/strip_themed.py +232 -0
- ggh4x/strip_vanilla.py +1464 -0
- ggh4x/themes.py +31 -0
- ggh4x/themes_ggh4x.py +67 -0
- ggh4x_python-0.3.1.9000.dist-info/METADATA +40 -0
- ggh4x_python-0.3.1.9000.dist-info/RECORD +64 -0
- ggh4x_python-0.3.1.9000.dist-info/WHEEL +4 -0
- ggh4x_python-0.3.1.9000.dist-info/licenses/LICENSE +3 -0
ggh4x/help_secondary.py
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
"""Secondary axis helper.
|
|
2
|
+
|
|
3
|
+
Python port of the ggh4x R source file ``help_secondary.R``.
|
|
4
|
+
|
|
5
|
+
The public entry point :func:`help_secondary` constructs a
|
|
6
|
+
:class:`ggplot2_py.scale.AxisSecondary` whose reverse transformation
|
|
7
|
+
(``trans``) is fitted from data, and which carries an extra ``proj``
|
|
8
|
+
attribute (a forward-projection callable) used to map secondary data onto
|
|
9
|
+
the primary axis when building a plot.
|
|
10
|
+
|
|
11
|
+
R uses non-standard evaluation (``rlang::enquo``/``eval_tidy``/``as_label``)
|
|
12
|
+
so that users can write ``help_secondary(df, y1, y2)`` with bare column
|
|
13
|
+
symbols. Python has no NSE, so this port accepts either array-likes
|
|
14
|
+
(``numpy``/``pandas``/sequences) **or** column-name strings that are
|
|
15
|
+
resolved against the ``data`` argument (a :class:`pandas.DataFrame`).
|
|
16
|
+
|
|
17
|
+
Five fitting ``method`` choices are implemented, each mirroring the
|
|
18
|
+
corresponding ``help_sec_*`` helper in the R source:
|
|
19
|
+
|
|
20
|
+
``"range"``
|
|
21
|
+
Overlap the full ranges of primary and secondary data
|
|
22
|
+
(``scales::rescale``).
|
|
23
|
+
``"max"``
|
|
24
|
+
Make the maxima coincide (``scales::rescale_max``).
|
|
25
|
+
``"fit"``
|
|
26
|
+
Use the coefficients of ``lm(primary ~ secondary)``
|
|
27
|
+
(ported via :func:`numpy.polyfit`).
|
|
28
|
+
``"ccf"``
|
|
29
|
+
Align series by the lag of maximum cross-correlation
|
|
30
|
+
(a faithful re-implementation of ``stats::ccf``) then apply ``"fit"``.
|
|
31
|
+
``"sortfit"``
|
|
32
|
+
Independently sort both inputs then apply ``"fit"``.
|
|
33
|
+
|
|
34
|
+
R source reference: ``help_secondary.R`` (functions ``help_secondary``,
|
|
35
|
+
``new_sec_axis``, ``help_sec_range``, ``help_sec_max``, ``help_sec_fit``,
|
|
36
|
+
``help_sec_ccf``, ``help_sec_sortfit``).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
from typing import Any, Callable, Dict, Optional, Sequence, Tuple, Union
|
|
42
|
+
|
|
43
|
+
import numpy as np
|
|
44
|
+
|
|
45
|
+
try: # pragma: no cover - pandas is part of the runtime env
|
|
46
|
+
import pandas as pd
|
|
47
|
+
except ImportError: # pragma: no cover
|
|
48
|
+
pd = None # type: ignore
|
|
49
|
+
|
|
50
|
+
from ggplot2_py.scale import sec_axis, AxisSecondary
|
|
51
|
+
from ggplot2_py._compat import is_waiver
|
|
52
|
+
|
|
53
|
+
import scales
|
|
54
|
+
|
|
55
|
+
from ._cli import cli_abort
|
|
56
|
+
from ._rlang import arg_match0
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"help_secondary",
|
|
60
|
+
"_new_sec_axis",
|
|
61
|
+
"_help_sec_range",
|
|
62
|
+
"_help_sec_max",
|
|
63
|
+
"_help_sec_fit",
|
|
64
|
+
"_help_sec_ccf",
|
|
65
|
+
"_help_sec_sortfit",
|
|
66
|
+
"_help_sec_ccf_acf",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
# Allowed ``method`` choices, in R's declaration order.
|
|
70
|
+
_METHODS: Tuple[str, ...] = ("range", "max", "fit", "ccf", "sortfit")
|
|
71
|
+
|
|
72
|
+
ArrayLike = Union[Sequence[float], np.ndarray, "pd.Series"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Input resolution (replaces R's NSE eval_tidy / as_label)
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def _resolve(value: Any, data: Any) -> Tuple[np.ndarray, Optional[str]]:
|
|
80
|
+
"""Resolve a ``primary``/``secondary`` argument to a numeric array.
|
|
81
|
+
|
|
82
|
+
Mirrors R's ``eval_tidy(enquo(x), data)`` plus ``as_label(x)`` for the
|
|
83
|
+
default axis title. Because Python has no bare-symbol capture, a string
|
|
84
|
+
is treated as a column name looked up in ``data``; anything else is
|
|
85
|
+
coerced to a numeric :class:`numpy.ndarray`.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
value : Any
|
|
90
|
+
A column-name string, a :class:`pandas.Series`, or any array-like /
|
|
91
|
+
scalar sequence of numbers.
|
|
92
|
+
data : Any
|
|
93
|
+
A :class:`pandas.DataFrame` (or mapping) used to resolve string
|
|
94
|
+
column names. May be ``None``.
|
|
95
|
+
|
|
96
|
+
Returns
|
|
97
|
+
-------
|
|
98
|
+
arr : numpy.ndarray
|
|
99
|
+
The resolved values as a 1-D float array.
|
|
100
|
+
name : str or None
|
|
101
|
+
A label for the values (column name or ``Series.name``), used as the
|
|
102
|
+
default secondary-axis title. ``None`` when no label is available.
|
|
103
|
+
"""
|
|
104
|
+
name: Optional[str] = None
|
|
105
|
+
|
|
106
|
+
if isinstance(value, str):
|
|
107
|
+
# Column-name string: resolve against ``data`` (R eval_tidy).
|
|
108
|
+
name = value
|
|
109
|
+
if data is None:
|
|
110
|
+
cli_abort(
|
|
111
|
+
"Cannot resolve column {.val %s}: `data` is `None`." % value
|
|
112
|
+
)
|
|
113
|
+
try:
|
|
114
|
+
col = data[value]
|
|
115
|
+
except Exception:
|
|
116
|
+
cli_abort("Column {.val %s} not found in `data`." % value)
|
|
117
|
+
arr = np.asarray(getattr(col, "to_numpy", lambda: col)(), dtype=float)
|
|
118
|
+
return np.atleast_1d(arr), name
|
|
119
|
+
|
|
120
|
+
if pd is not None and isinstance(value, pd.Series):
|
|
121
|
+
nm = value.name
|
|
122
|
+
name = None if nm is None else str(nm)
|
|
123
|
+
return np.atleast_1d(np.asarray(value.to_numpy(), dtype=float)), name
|
|
124
|
+
|
|
125
|
+
# Plain array-like / sequence / scalar.
|
|
126
|
+
arr = np.atleast_1d(np.asarray(value, dtype=float))
|
|
127
|
+
return arr, name
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# Public constructor
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def help_secondary(
|
|
135
|
+
data: Any = None,
|
|
136
|
+
primary: ArrayLike = (0, 1),
|
|
137
|
+
secondary: ArrayLike = (0, 1),
|
|
138
|
+
method: str = "range",
|
|
139
|
+
na_rm: bool = True,
|
|
140
|
+
**kwargs: Any,
|
|
141
|
+
) -> AxisSecondary:
|
|
142
|
+
"""Construct a secondary axis with a fitted projection.
|
|
143
|
+
|
|
144
|
+
Python port of R ``help_secondary`` (``help_secondary.R`` L70-106).
|
|
145
|
+
|
|
146
|
+
The intent is to call this **before** building a plot. The returned
|
|
147
|
+
:class:`~ggplot2_py.scale.AxisSecondary` has its ``trans`` populated by a
|
|
148
|
+
fitted reverse transformation and carries an extra ``proj`` attribute --
|
|
149
|
+
a forward-projection callable that maps secondary data onto the primary
|
|
150
|
+
axis (used as an aesthetic value, e.g. ``aes(y = sec.proj(psavert))``).
|
|
151
|
+
|
|
152
|
+
Parameters
|
|
153
|
+
----------
|
|
154
|
+
data : pandas.DataFrame or mapping or None, optional
|
|
155
|
+
Data used to resolve ``primary``/``secondary`` when they are given as
|
|
156
|
+
column-name strings. Mirrors the ``data`` argument of the R
|
|
157
|
+
function. Default ``None``.
|
|
158
|
+
primary, secondary : str or array_like, optional
|
|
159
|
+
The primary and secondary values. Either array-likes (numpy /
|
|
160
|
+
pandas / sequences) or column-name strings resolved against ``data``.
|
|
161
|
+
Replaces R's bare-symbol NSE expressions. Default ``(0, 1)``.
|
|
162
|
+
method : {'range', 'max', 'fit', 'ccf', 'sortfit'}, optional
|
|
163
|
+
Fitting strategy (see module docstring). Default ``'range'``.
|
|
164
|
+
na_rm : bool, optional
|
|
165
|
+
Whether to remove missing values (``True``) or propagate them
|
|
166
|
+
(``False``). Applies to ``method='range'`` and ``method='max'``.
|
|
167
|
+
Default ``True``.
|
|
168
|
+
**kwargs
|
|
169
|
+
Forwarded to :func:`ggplot2_py.scale.sec_axis` (``name``, ``breaks``,
|
|
170
|
+
``labels``, ``guide``). Mirrors ``@inheritDotParams
|
|
171
|
+
ggplot2::sec_axis -trans``.
|
|
172
|
+
|
|
173
|
+
Returns
|
|
174
|
+
-------
|
|
175
|
+
ggplot2_py.scale.AxisSecondary
|
|
176
|
+
The secondary axis, with an added ``proj`` callable attribute and,
|
|
177
|
+
when no ``name`` was supplied, ``name`` defaulted to the secondary
|
|
178
|
+
label.
|
|
179
|
+
|
|
180
|
+
See Also
|
|
181
|
+
--------
|
|
182
|
+
ggplot2_py.scale.sec_axis
|
|
183
|
+
"""
|
|
184
|
+
method = arg_match0(method, _METHODS, arg_name="method")
|
|
185
|
+
|
|
186
|
+
primary_vals, _ = _resolve(primary, data)
|
|
187
|
+
secondary_vals, sec_name = _resolve(secondary, data)
|
|
188
|
+
|
|
189
|
+
# ``name = as_label(secondary)`` (R L82). The label is derived during
|
|
190
|
+
# resolution; fall back to the original string repr if unavailable.
|
|
191
|
+
name = sec_name
|
|
192
|
+
if name is None and isinstance(secondary, str):
|
|
193
|
+
name = secondary
|
|
194
|
+
|
|
195
|
+
if method == "range":
|
|
196
|
+
help_ = _help_sec_range(primary_vals, secondary_vals, na_rm=na_rm)
|
|
197
|
+
elif method == "max":
|
|
198
|
+
help_ = _help_sec_max(primary_vals, secondary_vals, na_rm=na_rm)
|
|
199
|
+
elif method == "fit":
|
|
200
|
+
help_ = _help_sec_fit(primary_vals, secondary_vals)
|
|
201
|
+
elif method == "ccf":
|
|
202
|
+
help_ = _help_sec_ccf(primary_vals, secondary_vals)
|
|
203
|
+
else: # "sortfit"
|
|
204
|
+
help_ = _help_sec_sortfit(primary_vals, secondary_vals)
|
|
205
|
+
|
|
206
|
+
# R: ggproto(NULL, new_sec_axis(trans = help$reverse, ...), proj = help$forward)
|
|
207
|
+
out = _new_sec_axis(trans=help_["reverse"], **kwargs)
|
|
208
|
+
# Attach the forward projection. AxisSecondary is a plain class, so this
|
|
209
|
+
# extra attribute is safe (R stores it as the ggproto `proj` member).
|
|
210
|
+
out.proj = help_["forward"]
|
|
211
|
+
|
|
212
|
+
# R: if (inherits(out$name, "waiver")) out$name <- name
|
|
213
|
+
if is_waiver(out.name) and name is not None:
|
|
214
|
+
out.name = name
|
|
215
|
+
|
|
216
|
+
return out
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _new_sec_axis(trans: Optional[Callable] = None, **kwargs: Any) -> AxisSecondary:
|
|
220
|
+
"""Bridge ``trans``/``transform`` to :func:`ggplot2_py.scale.sec_axis`.
|
|
221
|
+
|
|
222
|
+
Python port of R ``new_sec_axis`` (``help_secondary.R`` L109-115), which
|
|
223
|
+
renames ``trans`` to ``transform`` for ggplot2 >= 3.5.0. ``sec_axis`` in
|
|
224
|
+
ggplot2_py already accepts both ``transform`` and ``trans``; passing
|
|
225
|
+
``transform`` avoids the deprecation warning emitted for ``trans``.
|
|
226
|
+
|
|
227
|
+
Parameters
|
|
228
|
+
----------
|
|
229
|
+
trans : callable, optional
|
|
230
|
+
The reverse transformation function.
|
|
231
|
+
**kwargs
|
|
232
|
+
Additional ``sec_axis`` arguments (``name``, ``breaks``, ``labels``,
|
|
233
|
+
``guide``).
|
|
234
|
+
|
|
235
|
+
Returns
|
|
236
|
+
-------
|
|
237
|
+
ggplot2_py.scale.AxisSecondary
|
|
238
|
+
"""
|
|
239
|
+
return sec_axis(transform=trans, **kwargs)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# Range helpers
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
def _range(x: np.ndarray, na_rm: bool) -> Tuple[float, float]:
|
|
247
|
+
"""Compute ``base::range(x, na.rm=na_rm)``.
|
|
248
|
+
|
|
249
|
+
Parameters
|
|
250
|
+
----------
|
|
251
|
+
x : numpy.ndarray
|
|
252
|
+
Numeric values.
|
|
253
|
+
na_rm : bool
|
|
254
|
+
If ``True``, ignore NaN (``nanmin``/``nanmax``); if ``False``,
|
|
255
|
+
propagate NaN so the result is ``(nan, nan)`` when any value is NaN
|
|
256
|
+
(matching R's ``range(x, na.rm = FALSE)``).
|
|
257
|
+
|
|
258
|
+
Returns
|
|
259
|
+
-------
|
|
260
|
+
tuple of float
|
|
261
|
+
``(min, max)``.
|
|
262
|
+
"""
|
|
263
|
+
x = np.asarray(x, dtype=float)
|
|
264
|
+
if na_rm:
|
|
265
|
+
return float(np.nanmin(x)), float(np.nanmax(x))
|
|
266
|
+
return float(np.min(x)), float(np.max(x))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _help_sec_range(
|
|
270
|
+
from_: np.ndarray, to: np.ndarray, na_rm: bool = True
|
|
271
|
+
) -> Dict[str, Callable]:
|
|
272
|
+
"""Range-overlap projection.
|
|
273
|
+
|
|
274
|
+
Python port of R ``help_sec_range`` (``help_secondary.R`` L119-130).
|
|
275
|
+
|
|
276
|
+
Parameters
|
|
277
|
+
----------
|
|
278
|
+
from_ : numpy.ndarray
|
|
279
|
+
Primary values.
|
|
280
|
+
to : numpy.ndarray
|
|
281
|
+
Secondary values.
|
|
282
|
+
na_rm : bool, optional
|
|
283
|
+
Passed to :func:`_range`. Default ``True``.
|
|
284
|
+
|
|
285
|
+
Returns
|
|
286
|
+
-------
|
|
287
|
+
dict
|
|
288
|
+
``{'forward': callable, 'reverse': callable}`` where ``forward`` maps
|
|
289
|
+
secondary -> primary and ``reverse`` maps primary -> secondary, both
|
|
290
|
+
via :func:`scales.rescale`.
|
|
291
|
+
"""
|
|
292
|
+
from_rng = _range(from_, na_rm=na_rm)
|
|
293
|
+
to_rng = _range(to, na_rm=na_rm)
|
|
294
|
+
|
|
295
|
+
def forward(x: ArrayLike) -> np.ndarray:
|
|
296
|
+
return scales.rescale(_num(x), to=from_rng, from_range=to_rng)
|
|
297
|
+
|
|
298
|
+
def reverse(x: ArrayLike) -> np.ndarray:
|
|
299
|
+
return scales.rescale(_num(x), to=to_rng, from_range=from_rng)
|
|
300
|
+
|
|
301
|
+
return {"forward": forward, "reverse": reverse}
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _help_sec_max(
|
|
305
|
+
from_: np.ndarray, to: np.ndarray, na_rm: bool = True
|
|
306
|
+
) -> Dict[str, Callable]:
|
|
307
|
+
"""Maxima-coincidence projection.
|
|
308
|
+
|
|
309
|
+
Python port of R ``help_sec_max`` (``help_secondary.R`` L132-143).
|
|
310
|
+
|
|
311
|
+
Parameters
|
|
312
|
+
----------
|
|
313
|
+
from_ : numpy.ndarray
|
|
314
|
+
Primary values.
|
|
315
|
+
to : numpy.ndarray
|
|
316
|
+
Secondary values.
|
|
317
|
+
na_rm : bool, optional
|
|
318
|
+
Passed to :func:`_range`. Default ``True``.
|
|
319
|
+
|
|
320
|
+
Returns
|
|
321
|
+
-------
|
|
322
|
+
dict
|
|
323
|
+
``{'forward': callable, 'reverse': callable}`` via
|
|
324
|
+
:func:`scales.rescale_max`.
|
|
325
|
+
"""
|
|
326
|
+
from_rng = _range(from_, na_rm=na_rm)
|
|
327
|
+
to_rng = _range(to, na_rm=na_rm)
|
|
328
|
+
|
|
329
|
+
def forward(x: ArrayLike) -> np.ndarray:
|
|
330
|
+
return scales.rescale_max(_num(x), to=from_rng, from_range=to_rng)
|
|
331
|
+
|
|
332
|
+
def reverse(x: ArrayLike) -> np.ndarray:
|
|
333
|
+
return scales.rescale_max(_num(x), to=to_rng, from_range=from_rng)
|
|
334
|
+
|
|
335
|
+
return {"forward": forward, "reverse": reverse}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ---------------------------------------------------------------------------
|
|
339
|
+
# Linear-fit helpers
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
def _help_sec_fit(from_: np.ndarray, to: np.ndarray) -> Dict[str, Callable]:
|
|
343
|
+
"""Linear-model projection.
|
|
344
|
+
|
|
345
|
+
Python port of R ``help_sec_fit`` (``help_secondary.R`` L145-159).
|
|
346
|
+
Uses ``coef(lm(from ~ to))``; the port computes the same coefficients
|
|
347
|
+
with :func:`numpy.polyfit` on ``(to, from_)``. Like R's ``lm`` (whose
|
|
348
|
+
default ``na.action`` is ``na.omit``), pairs containing NaN in either
|
|
349
|
+
series are dropped before fitting.
|
|
350
|
+
|
|
351
|
+
Parameters
|
|
352
|
+
----------
|
|
353
|
+
from_ : numpy.ndarray
|
|
354
|
+
Primary values (the response in ``lm``).
|
|
355
|
+
to : numpy.ndarray
|
|
356
|
+
Secondary values (the predictor in ``lm``).
|
|
357
|
+
|
|
358
|
+
Returns
|
|
359
|
+
-------
|
|
360
|
+
dict
|
|
361
|
+
``{'forward': callable, 'reverse': callable}``. ``forward(x) =
|
|
362
|
+
intercept + x * slope`` (secondary -> primary); ``reverse(x) =
|
|
363
|
+
(x - intercept) / slope`` (primary -> secondary).
|
|
364
|
+
|
|
365
|
+
Raises
|
|
366
|
+
------
|
|
367
|
+
ValueError
|
|
368
|
+
If ``from_`` and ``to`` have unequal length (via :func:`cli_abort`).
|
|
369
|
+
"""
|
|
370
|
+
from_ = np.asarray(from_, dtype=float)
|
|
371
|
+
to = np.asarray(to, dtype=float)
|
|
372
|
+
if from_.shape[0] != to.shape[0]:
|
|
373
|
+
cli_abort("The primary and secondary values must have the same length.")
|
|
374
|
+
|
|
375
|
+
# R lm() drops rows where either variable is NA (na.action = na.omit).
|
|
376
|
+
mask = ~(np.isnan(from_) | np.isnan(to))
|
|
377
|
+
f_fit = from_[mask]
|
|
378
|
+
t_fit = to[mask]
|
|
379
|
+
|
|
380
|
+
# coef(lm(from ~ to)) = c(intercept, slope).
|
|
381
|
+
# numpy.polyfit(to, from, 1) returns [slope, intercept]; reverse the order.
|
|
382
|
+
poly = np.polyfit(t_fit, f_fit, 1)
|
|
383
|
+
slope = float(poly[0])
|
|
384
|
+
intercept = float(poly[1])
|
|
385
|
+
|
|
386
|
+
def forward(x: ArrayLike) -> np.ndarray:
|
|
387
|
+
return intercept + _num(x) * slope
|
|
388
|
+
|
|
389
|
+
def reverse(x: ArrayLike) -> np.ndarray:
|
|
390
|
+
return (_num(x) - intercept) / slope
|
|
391
|
+
|
|
392
|
+
return {"forward": forward, "reverse": reverse}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _help_sec_ccf_acf(from_: np.ndarray, to: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
|
396
|
+
"""Cross-correlation function, faithful to ``stats::ccf``.
|
|
397
|
+
|
|
398
|
+
Reproduces R's ``ccf(from, to, lag.max = n - 1, plot = FALSE)`` exactly.
|
|
399
|
+
R de-means both series, then for lag ``k`` correlates ``from[t + k]`` with
|
|
400
|
+
``to[t]``, dividing the cross-covariance by ``n`` and by the product of
|
|
401
|
+
the two series' sample standard deviations (each computed with a ``1/n``
|
|
402
|
+
divisor). Lags run from ``-(n - 1)`` to ``(n - 1)``.
|
|
403
|
+
|
|
404
|
+
Parameters
|
|
405
|
+
----------
|
|
406
|
+
from_ : numpy.ndarray
|
|
407
|
+
First series (``x``).
|
|
408
|
+
to : numpy.ndarray
|
|
409
|
+
Second series (``y``). Must have the same length as ``from_``.
|
|
410
|
+
|
|
411
|
+
Returns
|
|
412
|
+
-------
|
|
413
|
+
lags : numpy.ndarray
|
|
414
|
+
Integer lags from ``-(n - 1)`` to ``(n - 1)``.
|
|
415
|
+
acf : numpy.ndarray
|
|
416
|
+
The cross-correlation at each lag, matching ``ccf$acf``.
|
|
417
|
+
"""
|
|
418
|
+
from_ = np.asarray(from_, dtype=float)
|
|
419
|
+
to = np.asarray(to, dtype=float)
|
|
420
|
+
n = from_.shape[0]
|
|
421
|
+
|
|
422
|
+
xc = from_ - np.mean(from_)
|
|
423
|
+
yc = to - np.mean(to)
|
|
424
|
+
|
|
425
|
+
# Denominator: sqrt( (sum(xc^2)/n) * (sum(yc^2)/n) ), i.e. product of the
|
|
426
|
+
# 1/n-normalised sample standard deviations of each (de-meaned) series.
|
|
427
|
+
denom = np.sqrt((np.sum(xc ** 2) / n) * (np.sum(yc ** 2) / n))
|
|
428
|
+
|
|
429
|
+
# np.correlate(xc, yc, 'full')[i] == sum_t xc[t + (i - (n-1))] * yc[t],
|
|
430
|
+
# which is exactly R's lag convention. Length is 2n - 1.
|
|
431
|
+
cross = np.correlate(xc, yc, mode="full") / n
|
|
432
|
+
acf = cross / denom
|
|
433
|
+
lags = np.arange(-(n - 1), n)
|
|
434
|
+
return lags, acf
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _help_sec_ccf(from_: np.ndarray, to: np.ndarray) -> Dict[str, Callable]:
|
|
438
|
+
"""Cross-correlation-aligned linear projection.
|
|
439
|
+
|
|
440
|
+
Python port of R ``help_sec_ccf`` (``help_secondary.R`` L161-178).
|
|
441
|
+
Finds the lag of maximum cross-correlation (:func:`_help_sec_ccf_acf`),
|
|
442
|
+
truncates the two series to align them at that lag, then delegates to
|
|
443
|
+
:func:`_help_sec_fit`.
|
|
444
|
+
|
|
445
|
+
Parameters
|
|
446
|
+
----------
|
|
447
|
+
from_ : numpy.ndarray
|
|
448
|
+
Primary values.
|
|
449
|
+
to : numpy.ndarray
|
|
450
|
+
Secondary values. Must have the same length as ``from_``.
|
|
451
|
+
|
|
452
|
+
Returns
|
|
453
|
+
-------
|
|
454
|
+
dict
|
|
455
|
+
``{'forward': callable, 'reverse': callable}`` from the fit on the
|
|
456
|
+
aligned data.
|
|
457
|
+
|
|
458
|
+
Raises
|
|
459
|
+
------
|
|
460
|
+
ValueError
|
|
461
|
+
If ``from_`` and ``to`` have unequal length (via :func:`cli_abort`).
|
|
462
|
+
"""
|
|
463
|
+
from_ = np.asarray(from_, dtype=float)
|
|
464
|
+
to = np.asarray(to, dtype=float)
|
|
465
|
+
n = from_.shape[0]
|
|
466
|
+
if n != to.shape[0]:
|
|
467
|
+
cli_abort("The primary and secondary values must have the same length.")
|
|
468
|
+
|
|
469
|
+
lags, acf = _help_sec_ccf_acf(from_, to)
|
|
470
|
+
# which.max returns the FIRST maximum; np.argmax matches that tie rule.
|
|
471
|
+
lag = int(lags[int(np.argmax(acf))])
|
|
472
|
+
|
|
473
|
+
# No block for 0-lag because the data is already optimally aligned.
|
|
474
|
+
if np.sign(lag) == 1:
|
|
475
|
+
# R: from <- tail(from, -lag); to <- head(to, -lag)
|
|
476
|
+
from_ = from_[lag:]
|
|
477
|
+
to = to[:-lag]
|
|
478
|
+
elif np.sign(lag) == -1:
|
|
479
|
+
# R: from <- head(from, lag); to <- tail(to, lag), with lag < 0.
|
|
480
|
+
# head(x, lag<0) drops the LAST |lag| (keeps first n-|lag|); tail(x,
|
|
481
|
+
# lag<0) drops the FIRST |lag| (keeps last n-|lag|) — so both keep
|
|
482
|
+
# n-|lag| elements and stay aligned. (Previously `to[-(-lag):]` kept
|
|
483
|
+
# the last |lag|, a length mismatch that raised in _help_sec_fit.)
|
|
484
|
+
from_ = from_[:lag]
|
|
485
|
+
to = to[-lag:]
|
|
486
|
+
|
|
487
|
+
return _help_sec_fit(from_=from_, to=to)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _help_sec_sortfit(from_: np.ndarray, to: np.ndarray) -> Dict[str, Callable]:
|
|
491
|
+
"""Sorted linear projection.
|
|
492
|
+
|
|
493
|
+
Python port of R ``help_sec_sortfit`` (``help_secondary.R`` L180-182).
|
|
494
|
+
Independently sorts both series (dropping NaN, as R's ``sort`` does by
|
|
495
|
+
default) then delegates to :func:`_help_sec_fit`.
|
|
496
|
+
|
|
497
|
+
Parameters
|
|
498
|
+
----------
|
|
499
|
+
from_ : numpy.ndarray
|
|
500
|
+
Primary values.
|
|
501
|
+
to : numpy.ndarray
|
|
502
|
+
Secondary values.
|
|
503
|
+
|
|
504
|
+
Returns
|
|
505
|
+
-------
|
|
506
|
+
dict
|
|
507
|
+
``{'forward': callable, 'reverse': callable}``.
|
|
508
|
+
"""
|
|
509
|
+
return _help_sec_fit(from_=_sort(from_), to=_sort(to))
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
# ---------------------------------------------------------------------------
|
|
513
|
+
# Small numeric utilities
|
|
514
|
+
# ---------------------------------------------------------------------------
|
|
515
|
+
|
|
516
|
+
def _num(x: ArrayLike) -> np.ndarray:
|
|
517
|
+
"""Coerce an aesthetic/array value to a float :class:`numpy.ndarray`.
|
|
518
|
+
|
|
519
|
+
Parameters
|
|
520
|
+
----------
|
|
521
|
+
x : array_like
|
|
522
|
+
Value to coerce (handles pandas Series via ``to_numpy``).
|
|
523
|
+
|
|
524
|
+
Returns
|
|
525
|
+
-------
|
|
526
|
+
numpy.ndarray
|
|
527
|
+
Float array (scalars become 0-D arrays, preserved by NumPy ops).
|
|
528
|
+
"""
|
|
529
|
+
if pd is not None and isinstance(x, pd.Series):
|
|
530
|
+
x = x.to_numpy()
|
|
531
|
+
return np.asarray(x, dtype=float)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _sort(x: np.ndarray) -> np.ndarray:
|
|
535
|
+
"""Sort ascending, dropping NaN (mirrors R ``sort`` defaults).
|
|
536
|
+
|
|
537
|
+
Parameters
|
|
538
|
+
----------
|
|
539
|
+
x : numpy.ndarray
|
|
540
|
+
Values to sort.
|
|
541
|
+
|
|
542
|
+
Returns
|
|
543
|
+
-------
|
|
544
|
+
numpy.ndarray
|
|
545
|
+
Sorted finite values (NaN removed).
|
|
546
|
+
"""
|
|
547
|
+
x = np.asarray(x, dtype=float)
|
|
548
|
+
x = x[~np.isnan(x)]
|
|
549
|
+
return np.sort(x)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Multiple / listed / manual scales for ggh4x.
|
|
2
|
+
|
|
3
|
+
This package ports three R ggh4x files that implement two independent
|
|
4
|
+
capabilities sharing the :class:`MultiScale` deferred-mutation container plus a
|
|
5
|
+
standalone manual position scale:
|
|
6
|
+
|
|
7
|
+
* :mod:`scale_multi` (``scale_multi.R``) -- :func:`scale_colour_multi` /
|
|
8
|
+
:func:`scale_fill_multi`: map several non-standard colour/fill aesthetics each
|
|
9
|
+
to its own gradient :func:`ggplot2_py.continuous_scale`.
|
|
10
|
+
* :mod:`scale_listed` (``scale_listed.R``) -- :func:`scale_listed`: distribute a
|
|
11
|
+
user-supplied list of discrete scales bound to non-standard aesthetics, grouped
|
|
12
|
+
by the standard aesthetic each replaces. Also home of the shared
|
|
13
|
+
:class:`MultiScale` container and its ``ggplot_add`` handler.
|
|
14
|
+
* :mod:`scale_manual` (``scale_manual.R``) -- :func:`scale_x_manual` /
|
|
15
|
+
:func:`scale_y_manual`: a hybrid discrete/continuous position scale
|
|
16
|
+
(:class:`ScaleManualPosition`) that places discrete levels at arbitrary
|
|
17
|
+
continuous coordinates.
|
|
18
|
+
|
|
19
|
+
Importing this package registers the ``MultiScale`` handler on
|
|
20
|
+
:func:`ggplot2_py.plot.update_ggplot` (via the :mod:`_multiscale_add` import),
|
|
21
|
+
exactly as ``ggnewscale.__init__`` imports ``_ggplot_add`` for its side effect.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
# Importing _multiscale_add registers @update_ggplot.register(MultiScale).
|
|
27
|
+
from ._multiscale_add import MultiScale
|
|
28
|
+
from .scale_listed import scale_listed
|
|
29
|
+
from .scale_manual import (
|
|
30
|
+
ScaleManualPosition,
|
|
31
|
+
scale_x_manual,
|
|
32
|
+
scale_y_manual,
|
|
33
|
+
sep_discrete,
|
|
34
|
+
)
|
|
35
|
+
from .scale_multi import (
|
|
36
|
+
scale_color_multi,
|
|
37
|
+
scale_colour_multi,
|
|
38
|
+
scale_fill_multi,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"scale_fill_multi",
|
|
43
|
+
"scale_colour_multi",
|
|
44
|
+
"scale_color_multi",
|
|
45
|
+
"scale_listed",
|
|
46
|
+
"scale_x_manual",
|
|
47
|
+
"scale_y_manual",
|
|
48
|
+
"sep_discrete",
|
|
49
|
+
"ScaleManualPosition",
|
|
50
|
+
"MultiScale",
|
|
51
|
+
]
|