openretailscience 0.45.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.
- openretailscience/__init__.py +3 -0
- openretailscience/analysis/__init__.py +0 -0
- openretailscience/analysis/cohort.py +185 -0
- openretailscience/analysis/composite_rank.py +372 -0
- openretailscience/analysis/cross_shop.py +354 -0
- openretailscience/analysis/customer.py +459 -0
- openretailscience/analysis/customer_decision_hierarchy.py +359 -0
- openretailscience/analysis/gain_loss.py +340 -0
- openretailscience/analysis/haversine.py +50 -0
- openretailscience/analysis/product_association.py +418 -0
- openretailscience/analysis/revenue_tree.py +553 -0
- openretailscience/assets/fonts/Poppins-Bold.ttf +0 -0
- openretailscience/assets/fonts/Poppins-LightItalic.ttf +0 -0
- openretailscience/assets/fonts/Poppins-Medium.ttf +0 -0
- openretailscience/assets/fonts/Poppins-Regular.ttf +0 -0
- openretailscience/assets/fonts/Poppins-SemiBold.ttf +0 -0
- openretailscience/constants.py +295 -0
- openretailscience/metrics/__init__.py +0 -0
- openretailscience/metrics/base.py +29 -0
- openretailscience/metrics/distribution/__init__.py +0 -0
- openretailscience/metrics/distribution/acv.py +81 -0
- openretailscience/metrics/distribution/pct_of_stores.py +118 -0
- openretailscience/options.py +842 -0
- openretailscience/plots/__init__.py +0 -0
- openretailscience/plots/area.py +114 -0
- openretailscience/plots/bar.py +375 -0
- openretailscience/plots/broken_timeline.py +197 -0
- openretailscience/plots/cohort.py +94 -0
- openretailscience/plots/heatmap.py +134 -0
- openretailscience/plots/histogram.py +276 -0
- openretailscience/plots/index.py +505 -0
- openretailscience/plots/line.py +379 -0
- openretailscience/plots/period_on_period.py +144 -0
- openretailscience/plots/price.py +275 -0
- openretailscience/plots/scatter.py +382 -0
- openretailscience/plots/styles/__init__.py +1 -0
- openretailscience/plots/styles/colors.py +215 -0
- openretailscience/plots/styles/font_utils.py +62 -0
- openretailscience/plots/styles/graph_utils.py +834 -0
- openretailscience/plots/styles/styling_helpers.py +155 -0
- openretailscience/plots/time.py +160 -0
- openretailscience/plots/tree_diagram.py +813 -0
- openretailscience/plots/venn.py +127 -0
- openretailscience/plots/waterfall.py +217 -0
- openretailscience/plugin.py +238 -0
- openretailscience/segmentation/__init__.py +0 -0
- openretailscience/segmentation/hml.py +97 -0
- openretailscience/segmentation/nlr.py +218 -0
- openretailscience/segmentation/rfm.py +307 -0
- openretailscience/segmentation/segstats.py +1177 -0
- openretailscience/segmentation/threshold.py +165 -0
- openretailscience/utils/__init__.py +0 -0
- openretailscience/utils/date.py +226 -0
- openretailscience/utils/filter_and_label.py +52 -0
- openretailscience/utils/label.py +79 -0
- openretailscience/utils/validation.py +45 -0
- openretailscience-0.45.0.dist-info/METADATA +364 -0
- openretailscience-0.45.0.dist-info/RECORD +60 -0
- openretailscience-0.45.0.dist-info/WHEEL +4 -0
- openretailscience-0.45.0.dist-info/licenses/LICENSE +93 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Gain loss analysis (switching analysis) on a DataFrame.
|
|
2
|
+
|
|
3
|
+
Assesses customer movement between brands or products over time.
|
|
4
|
+
|
|
5
|
+
Gain loss analysis, also known as switching analysis, is a marketing analytics technique used to
|
|
6
|
+
assess customer movement between brands or products over time. It helps businesses understand the dynamics of customer
|
|
7
|
+
acquisition and churn. Here's a concise definition: Gain loss analysis examines the flow of customers to and from a
|
|
8
|
+
brand or product, quantifying:
|
|
9
|
+
|
|
10
|
+
1. Gains: New customers acquired from competitors
|
|
11
|
+
2. Losses: Existing customers lost to competitors
|
|
12
|
+
3. Net change: The overall impact on market share
|
|
13
|
+
|
|
14
|
+
This analysis helps marketers:
|
|
15
|
+
|
|
16
|
+
- Identify trends in customer behavior
|
|
17
|
+
- Evaluate the effectiveness of marketing strategies
|
|
18
|
+
- Understand competitive dynamics in the market
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import pandas as pd
|
|
22
|
+
from matplotlib.axes import Axes, SubplotBase
|
|
23
|
+
|
|
24
|
+
import openretailscience.plots.styles.graph_utils as gu
|
|
25
|
+
from openretailscience.options import ColumnHelper, get_option
|
|
26
|
+
from openretailscience.plots import bar
|
|
27
|
+
from openretailscience.plots.styles.colors import COLORS
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class GainLoss:
|
|
31
|
+
"""Perform gain loss analysis on a DataFrame.
|
|
32
|
+
|
|
33
|
+
Assesses customer movement between brands or products over time.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
df: pd.DataFrame,
|
|
39
|
+
p1_index: list[bool] | pd.Series,
|
|
40
|
+
p2_index: list[bool] | pd.Series,
|
|
41
|
+
focus_group_index: list[bool] | pd.Series,
|
|
42
|
+
focus_group_name: str,
|
|
43
|
+
comparison_group_index: list[bool] | pd.Series,
|
|
44
|
+
comparison_group_name: str,
|
|
45
|
+
group_col: str | None = None,
|
|
46
|
+
value_col: str = get_option("column.unit_spend"),
|
|
47
|
+
agg_func: str = "sum",
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Calculate the gain loss table for a given DataFrame at the customer level.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
df (pd.DataFrame): The DataFrame to calculate the gain loss table from.
|
|
53
|
+
p1_index (list[bool]): The index for the first time period.
|
|
54
|
+
p2_index (list[bool]): The index for the second time period.
|
|
55
|
+
focus_group_index (list[bool]): The index for the focus group.
|
|
56
|
+
focus_group_name (str): The name of the focus group.
|
|
57
|
+
comparison_group_index (list[bool]): The index for the comparison group.
|
|
58
|
+
comparison_group_name (str): The name of the comparison group.
|
|
59
|
+
group_col (str | None, optional): The column to group by. Defaults to None.
|
|
60
|
+
value_col (str, optional): The column to calculate the gain loss from. Defaults to option column.unit_spend.
|
|
61
|
+
agg_func (str, optional): The aggregation function to use. Defaults to "sum".
|
|
62
|
+
"""
|
|
63
|
+
# # Ensure no overlap between p1 and p2
|
|
64
|
+
if not df[p1_index].index.intersection(df[p2_index].index).empty:
|
|
65
|
+
raise ValueError("p1_index and p2_index should not overlap")
|
|
66
|
+
|
|
67
|
+
if not df[focus_group_index].index.intersection(df[comparison_group_index].index).empty:
|
|
68
|
+
raise ValueError("focus_group_index and comparison_group_index should not overlap")
|
|
69
|
+
|
|
70
|
+
if not len(p1_index) == len(p2_index) == len(focus_group_index) == len(comparison_group_index):
|
|
71
|
+
raise ValueError(
|
|
72
|
+
"p1_index, p2_index, focus_group_index, and comparison_group_index should have the same length",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
required_cols = [get_option("column.customer_id"), value_col] + ([group_col] if group_col is not None else [])
|
|
76
|
+
missing_cols = set(required_cols) - set(df.columns)
|
|
77
|
+
if len(missing_cols) > 0:
|
|
78
|
+
msg = f"The following columns are required but missing: {missing_cols}"
|
|
79
|
+
raise ValueError(msg)
|
|
80
|
+
|
|
81
|
+
self.focus_group_name = focus_group_name
|
|
82
|
+
self.comparison_group_name = comparison_group_name
|
|
83
|
+
self.group_col = group_col
|
|
84
|
+
self.value_col = value_col
|
|
85
|
+
|
|
86
|
+
self.gain_loss_df = self._calc_gain_loss(
|
|
87
|
+
df=df,
|
|
88
|
+
p1_index=p1_index,
|
|
89
|
+
p2_index=p2_index,
|
|
90
|
+
focus_group_index=focus_group_index,
|
|
91
|
+
comparison_group_index=comparison_group_index,
|
|
92
|
+
group_col=group_col,
|
|
93
|
+
value_col=value_col,
|
|
94
|
+
agg_func=agg_func,
|
|
95
|
+
)
|
|
96
|
+
self.gain_loss_table_df = self._calc_gains_loss_table(
|
|
97
|
+
gain_loss_df=self.gain_loss_df,
|
|
98
|
+
group_col=group_col,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def process_customer_group(
|
|
103
|
+
focus_p1: float,
|
|
104
|
+
comparison_p1: float,
|
|
105
|
+
focus_p2: float,
|
|
106
|
+
comparison_p2: float,
|
|
107
|
+
focus_diff: float,
|
|
108
|
+
comparison_diff: float,
|
|
109
|
+
) -> tuple[float, float, float, float, float, float]:
|
|
110
|
+
"""Process the gain loss for a customer group.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
focus_p1 (float | int): The focus group total in the first time period.
|
|
114
|
+
comparison_p1 (float | int): The comparison group total in the first time period.
|
|
115
|
+
focus_p2 (float | int): The focus group total in the second time period.
|
|
116
|
+
comparison_p2 (float | int): The comparison group total in the second time period.
|
|
117
|
+
focus_diff (float | int): The difference in the focus group totals.
|
|
118
|
+
comparison_diff (float | int): The difference in the comparison group totals.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
tuple[float, float, float, float, float, float]: The gain loss for the customer group.
|
|
122
|
+
"""
|
|
123
|
+
if focus_p1 == 0 and comparison_p1 == 0:
|
|
124
|
+
return focus_p2, 0, 0, 0, 0, 0
|
|
125
|
+
if focus_p2 == 0 and comparison_p2 == 0:
|
|
126
|
+
return 0, -1 * focus_p1, 0, 0, 0, 0
|
|
127
|
+
|
|
128
|
+
if focus_diff > 0:
|
|
129
|
+
focus_inc_dec = focus_diff if comparison_diff > 0 else max(0, comparison_diff + focus_diff)
|
|
130
|
+
elif comparison_diff < 0:
|
|
131
|
+
focus_inc_dec = focus_diff
|
|
132
|
+
else:
|
|
133
|
+
focus_inc_dec = min(0, comparison_diff + focus_diff)
|
|
134
|
+
|
|
135
|
+
increased_focus = max(0, focus_inc_dec)
|
|
136
|
+
decreased_focus = min(0, focus_inc_dec)
|
|
137
|
+
|
|
138
|
+
transfer = focus_diff - focus_inc_dec
|
|
139
|
+
switch_from_comparison = max(0, transfer)
|
|
140
|
+
switch_to_comparison = min(0, transfer)
|
|
141
|
+
|
|
142
|
+
return 0, 0, increased_focus, decreased_focus, switch_from_comparison, switch_to_comparison
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _calc_gain_loss(
|
|
146
|
+
df: pd.DataFrame,
|
|
147
|
+
p1_index: list[bool],
|
|
148
|
+
p2_index: list[bool],
|
|
149
|
+
focus_group_index: list[bool],
|
|
150
|
+
comparison_group_index: list,
|
|
151
|
+
group_col: str | None = None,
|
|
152
|
+
value_col: str = get_option("column.unit_spend"),
|
|
153
|
+
agg_func: str = "sum",
|
|
154
|
+
) -> pd.DataFrame:
|
|
155
|
+
"""Calculate the gain loss table for a given DataFrame at the customer level.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
df (pd.DataFrame): The DataFrame to calculate the gain loss table from.
|
|
159
|
+
p1_index (list[bool]): The index for the first time period.
|
|
160
|
+
p2_index (list[bool]): The index for the second time period.
|
|
161
|
+
focus_group_index (list[bool]): The index for the focus group.
|
|
162
|
+
comparison_group_index (list[bool]): The index for the comparison group.
|
|
163
|
+
group_col (str | None, optional): The column to group by. Defaults to None.
|
|
164
|
+
value_col (str, optional): The column to calculate the gain loss from. Defaults to option column.unit_spend.
|
|
165
|
+
agg_func (str, optional): The aggregation function to use. Defaults to "sum".
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
pd.DataFrame: The gain loss table.
|
|
169
|
+
"""
|
|
170
|
+
cols = ColumnHelper()
|
|
171
|
+
df = df[p1_index | p2_index].copy()
|
|
172
|
+
df[cols.customer_id] = df[cols.customer_id].astype("category")
|
|
173
|
+
|
|
174
|
+
grp_cols = [cols.customer_id] if group_col is None else [group_col, cols.customer_id]
|
|
175
|
+
|
|
176
|
+
p1_df = pd.concat(
|
|
177
|
+
[
|
|
178
|
+
df[focus_group_index & p1_index].groupby(grp_cols, observed=False)[value_col].agg(agg_func),
|
|
179
|
+
df[comparison_group_index & p1_index].groupby(grp_cols, observed=False)[value_col].agg(agg_func),
|
|
180
|
+
df[(focus_group_index | comparison_group_index) & p1_index]
|
|
181
|
+
.groupby(grp_cols, observed=False)[value_col]
|
|
182
|
+
.agg(agg_func),
|
|
183
|
+
],
|
|
184
|
+
axis=1,
|
|
185
|
+
)
|
|
186
|
+
p1_df.columns = ["focus", "comparison", "total"]
|
|
187
|
+
|
|
188
|
+
p2_df = pd.concat(
|
|
189
|
+
[
|
|
190
|
+
df[focus_group_index & p2_index].groupby(grp_cols, observed=False)[value_col].agg(agg_func),
|
|
191
|
+
df[comparison_group_index & p2_index].groupby(grp_cols, observed=False)[value_col].agg(agg_func),
|
|
192
|
+
df[(focus_group_index | comparison_group_index) & p2_index]
|
|
193
|
+
.groupby(grp_cols, observed=False)[value_col]
|
|
194
|
+
.agg(agg_func),
|
|
195
|
+
],
|
|
196
|
+
axis=1,
|
|
197
|
+
)
|
|
198
|
+
p2_df.columns = ["focus", "comparison", "total"]
|
|
199
|
+
|
|
200
|
+
gl_df = p1_df.merge(p2_df, on=grp_cols, how="outer", suffixes=("_p1", "_p2")).fillna(0)
|
|
201
|
+
|
|
202
|
+
# Remove rows that are all 0 due to grouping by customer_id as a categorical with observed=False
|
|
203
|
+
gl_df = gl_df[~(gl_df == 0).all(axis=1)]
|
|
204
|
+
|
|
205
|
+
gl_df["focus_diff"] = gl_df["focus_p2"] - gl_df["focus_p1"]
|
|
206
|
+
gl_df["comparison_diff"] = gl_df["comparison_p2"] - gl_df["comparison_p1"]
|
|
207
|
+
gl_df["total_diff"] = gl_df["total_p2"] - gl_df["total_p1"]
|
|
208
|
+
|
|
209
|
+
(
|
|
210
|
+
gl_df["new"],
|
|
211
|
+
gl_df["lost"],
|
|
212
|
+
gl_df["increased_focus"],
|
|
213
|
+
gl_df["decreased_focus"],
|
|
214
|
+
gl_df["switch_from_comparison"],
|
|
215
|
+
gl_df["switch_to_comparison"],
|
|
216
|
+
) = zip(
|
|
217
|
+
*gl_df.apply(
|
|
218
|
+
lambda x: GainLoss.process_customer_group(
|
|
219
|
+
focus_p1=x["focus_p1"],
|
|
220
|
+
comparison_p1=x["comparison_p1"],
|
|
221
|
+
focus_p2=x["focus_p2"],
|
|
222
|
+
comparison_p2=x["comparison_p2"],
|
|
223
|
+
focus_diff=x["focus_diff"],
|
|
224
|
+
comparison_diff=x["comparison_diff"],
|
|
225
|
+
),
|
|
226
|
+
axis=1,
|
|
227
|
+
),
|
|
228
|
+
strict=False,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
return gl_df
|
|
232
|
+
|
|
233
|
+
@staticmethod
|
|
234
|
+
def _calc_gains_loss_table(
|
|
235
|
+
gain_loss_df: pd.DataFrame,
|
|
236
|
+
group_col: str | None = None,
|
|
237
|
+
) -> pd.DataFrame:
|
|
238
|
+
"""Aggregates the gain loss table to show the total gains and losses across customers.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
gain_loss_df (pd.DataFrame): The gain loss table at customer level to aggregate.
|
|
242
|
+
group_col (str | None, optional): The column to group by. Defaults to None.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
pd.DataFrame: The aggregated gain loss table
|
|
246
|
+
"""
|
|
247
|
+
if group_col is None:
|
|
248
|
+
return gain_loss_df.sum().to_frame("").T
|
|
249
|
+
|
|
250
|
+
return gain_loss_df.groupby(level=0).sum()
|
|
251
|
+
|
|
252
|
+
def plot(
|
|
253
|
+
self,
|
|
254
|
+
title: str | None = None,
|
|
255
|
+
x_label: str | None = None,
|
|
256
|
+
y_label: str | None = None,
|
|
257
|
+
ax: Axes | None = None,
|
|
258
|
+
source_text: str | None = None,
|
|
259
|
+
move_legend_outside: bool = False,
|
|
260
|
+
**kwargs: dict[str, any],
|
|
261
|
+
) -> SubplotBase:
|
|
262
|
+
"""Plot the gain loss table using the bar.plot wrapper.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
title (str | None, optional): The title of the plot. Defaults to None.
|
|
266
|
+
x_label (str | None, optional): The x-axis label. Defaults to None.
|
|
267
|
+
y_label (str | None, optional): The y-axis label. Defaults to None.
|
|
268
|
+
ax (Axes | None, optional): The axes to plot on. Defaults to None.
|
|
269
|
+
source_text (str | None, optional): The source text to add to the plot. Defaults to None.
|
|
270
|
+
move_legend_outside (bool, optional): Whether to move the legend outside the plot. Defaults to False.
|
|
271
|
+
kwargs (dict[str, any]): Additional keyword arguments to pass to the plot.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
SubplotBase: The plot
|
|
275
|
+
"""
|
|
276
|
+
green_colors = [COLORS["green"][700], COLORS["green"][500], COLORS["green"][300]]
|
|
277
|
+
red_colors = [COLORS["red"][700], COLORS["red"][500], COLORS["red"][300]]
|
|
278
|
+
|
|
279
|
+
increase_cols = ["new", "increased_focus", "switch_from_comparison"]
|
|
280
|
+
decrease_cols = ["lost", "decreased_focus", "switch_to_comparison"]
|
|
281
|
+
all_cols = increase_cols + decrease_cols
|
|
282
|
+
|
|
283
|
+
plot_df = self.gain_loss_table_df.copy()
|
|
284
|
+
default_y_label = self.focus_group_name if self.group_col is None else self.group_col
|
|
285
|
+
plot_data = plot_df.copy()
|
|
286
|
+
|
|
287
|
+
color_dict = {col: green_colors[i] for i, col in enumerate(increase_cols)}
|
|
288
|
+
color_dict.update({col: red_colors[i] for i, col in enumerate(decrease_cols)})
|
|
289
|
+
|
|
290
|
+
kwargs.pop("stacked", None)
|
|
291
|
+
|
|
292
|
+
ax = bar.plot(
|
|
293
|
+
df=plot_data,
|
|
294
|
+
value_col=all_cols,
|
|
295
|
+
title=gu.not_none(title, f"Gain Loss from {self.focus_group_name} to {self.comparison_group_name}"),
|
|
296
|
+
y_label=gu.not_none(y_label, default_y_label),
|
|
297
|
+
x_label=gu.not_none(x_label, self.value_col),
|
|
298
|
+
orientation="horizontal",
|
|
299
|
+
ax=ax,
|
|
300
|
+
source_text=source_text,
|
|
301
|
+
move_legend_outside=move_legend_outside,
|
|
302
|
+
stacked=True,
|
|
303
|
+
**kwargs,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
for i, container in enumerate(ax.containers):
|
|
307
|
+
col_name = all_cols[i]
|
|
308
|
+
for patch in container:
|
|
309
|
+
patch.set_color(color_dict[col_name])
|
|
310
|
+
|
|
311
|
+
legend_labels = [
|
|
312
|
+
"New",
|
|
313
|
+
f"Increased {self.focus_group_name}",
|
|
314
|
+
f"Switch From {self.comparison_group_name}",
|
|
315
|
+
"Lost",
|
|
316
|
+
f"Decreased {self.focus_group_name}",
|
|
317
|
+
f"Switch To {self.comparison_group_name}",
|
|
318
|
+
]
|
|
319
|
+
|
|
320
|
+
if ax.get_legend():
|
|
321
|
+
ax.get_legend().remove()
|
|
322
|
+
|
|
323
|
+
legend = ax.legend(
|
|
324
|
+
legend_labels,
|
|
325
|
+
frameon=True,
|
|
326
|
+
bbox_to_anchor=(1.05, 1) if move_legend_outside else None,
|
|
327
|
+
loc="upper left" if move_legend_outside else "best",
|
|
328
|
+
)
|
|
329
|
+
legend.get_frame().set_facecolor("white")
|
|
330
|
+
legend.get_frame().set_edgecolor("white")
|
|
331
|
+
|
|
332
|
+
ax.axvline(0, color="black", linewidth=0.5)
|
|
333
|
+
|
|
334
|
+
decimals = gu.get_decimals(ax.get_xlim(), ax.get_xticks())
|
|
335
|
+
ax.xaxis.set_major_formatter(lambda x, pos: gu.human_format(x, pos, decimals=decimals))
|
|
336
|
+
ax.grid(axis="x", linestyle="--", alpha=0.7)
|
|
337
|
+
|
|
338
|
+
gu.standard_tick_styles(ax)
|
|
339
|
+
|
|
340
|
+
return ax
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Geospatial Distance Analysis for Retail Location Intelligence.
|
|
2
|
+
|
|
3
|
+
This module provides efficient geospatial distance calculations that power location-based retail analytics and
|
|
4
|
+
strategic decision-making.
|
|
5
|
+
|
|
6
|
+
## Technical Features
|
|
7
|
+
|
|
8
|
+
- **Ibis-Based Computation**: Scalable processing within existing data pipelines
|
|
9
|
+
- **Database Integration**: Calculations performed in SQL databases for efficiency
|
|
10
|
+
- **Haversine Formula**: Accurate great-circle distance computation
|
|
11
|
+
- **Backend Agnostic**: Works with multiple database and processing engines
|
|
12
|
+
|
|
13
|
+
## Limitations
|
|
14
|
+
|
|
15
|
+
- **Spherical Earth Assumption**: Minor inaccuracies due to Earth's actual oblate shape
|
|
16
|
+
- **Straight-Line Distance**: Measures "as the crow flies", not driving distances
|
|
17
|
+
- **Requires Trigonometric Functions**: Backend must support mathematical functions
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import ibis
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def haversine_distance(
|
|
24
|
+
lat_col: ibis.Column,
|
|
25
|
+
lon_col: ibis.Column,
|
|
26
|
+
target_lat_col: ibis.Column,
|
|
27
|
+
target_lon_col: ibis.Column,
|
|
28
|
+
radius: float = 6371.0,
|
|
29
|
+
) -> ibis.Column:
|
|
30
|
+
"""Computes the Haversine distance between two sets of latitude and longitude columns.
|
|
31
|
+
|
|
32
|
+
Parameters:
|
|
33
|
+
lat_col (ibis.Column): Column containing source latitudes.
|
|
34
|
+
lon_col (ibis.Column): Column containing source longitudes.
|
|
35
|
+
target_lat_col (ibis.Column): Column containing target latitudes.
|
|
36
|
+
target_lon_col (ibis.Column): Column containing target longitudes.
|
|
37
|
+
radius (float, optional): Earth's radius in kilometers (default: 6371 km).
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
ibis.Column: An Ibis expression representing the computed distances.
|
|
41
|
+
"""
|
|
42
|
+
lat1_rad = lat_col.radians()
|
|
43
|
+
lat2_rad = target_lat_col.radians()
|
|
44
|
+
delta_lat = (target_lat_col - lat_col).radians()
|
|
45
|
+
delta_lon = (target_lon_col - lon_col).radians()
|
|
46
|
+
|
|
47
|
+
a = (delta_lat / 2).sin().pow(2) + lat1_rad.cos() * lat2_rad.cos() * (delta_lon / 2).sin().pow(2)
|
|
48
|
+
c = 2 * a.sqrt().asin()
|
|
49
|
+
|
|
50
|
+
return radius * c
|