Files
clickstream-data-platform/generator/src/clickstream_generator/day.py
T
ddadminandClaude Opus 5 b2fcc7e593 feat(generator): заказы дня — проекция покупок с деньгами магазина
- Зачем:
  - тикет #90: второй источник должен смотреть на торговую половину дня
    глазами бэкенда — заказ есть проекция покупки, а не второе порождение;
    учебный результат — два источника согласованы по построению, а не
    сверкой.
- Что:
  - `commerce` отдаёт вторым выходом покупки дня: номер заказа, корзину,
    выручку клиента, промокод и человека за кукой; новых бросков в
    подпоток `COMMERCE` не добавилось, события дня не сдвинулись.
  - заведена заказная сторона: подпоток `ORDERS` на позиции 2 дерева зерна
    (прежнее мёртвое имя `DISCREPANCIES`), ветвление — по дню рождения
    заказа; `LATECOMERS` не тронут, его наполнит этап 6.
  - новый модуль `orders.py`: деньги заказа целыми копейками —
    `items_total` из корзины, `discount` по таблице «код → скидка»
    (округление вниз), `delivery` броском по таблице весов на полную длину
    дня, `total` = `items_total` − `discount` + `delivery`.
  - план состава завёл `person_id` — последним броском когорты, после
    паспортов: вторая кука пары повторяет ID своего человека, заказ
    показывает его как `user_id`, в событие Метрики он не попадает.
  - `world`: окно изменяемости `ORDER_WINDOW_DAYS` = 7 рядом с D0 и поясом,
    черновая таблица стоимости доставки.
  - слепок дня в тестах сторожит обе половины: заказы сравниваются наравне
    с потоком событий.
  - доки: имя подпотока заказной стороны и состав денег заказа записаны в
    `docs/architecture/orders`, README генератора знает про новый модуль.
- Проверка:
  - из `generator/`: make lint, make typecheck, make test (417 тестов);
    в корне — make lint.
  - опись мира не покраснела: байты восьми дней те же, заказная сторона
    события не сдвинула.

Closes #90

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:30:13 +03:00

572 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""День-функция: (зерно, D) → упорядоченный поток событий модельных суток.
Здесь трафиковая половина мира: визиты, страницы, атрибуция, устройство и
гео. Торговые события садятся на этот же поток и добавляют к нему свои
строки — их собирает `commerce` и им же поток заканчивается. У просмотра
страницы торговые колонки присутствуют, но пусты по смыслу: «пусто» — всегда
пустой массив, пустая строка или 0, а не отсутствие ключа.
День — чистая функция зерна и номера дня: одна и та же пара даёт те же
события, а день N+1 не трогает дни 1…N. Держится это на подпотоке
`Component.TRAFFIC` (спека генератора, раздел 2) и на плане состава, который
про горизонт ничего не знает. Случайность целочисленная и векторная: считать
посточно приходится ровно одно — цепочку страниц визита, где следующий шаг
зависит от предыдущего. Посточные проходы есть и кроме неё (адреса,
заголовки, IP), но там ничего не решается: numpy не умеет собирать строки, а
броски к тому времени уже сделаны — векторно и все разом.
Модельные сутки считаются в поясе счётчика, как в выгрузке Метрики:
`EventDate` — дата в поясе счётчика, `UTCEventTime` — абсолютная метка. У
ночных событий гостей из других поясов `toDate(UTCEventTime)` ≠ `EventDate`;
сторона хранилища должна знать это заранее, иначе выведет дату сама и
разойдётся на несколько часов данных.
**Правила резки визитов** — те же, по которым лаба сессий собирает визиты
сама и сверяет сборку с `VisitID`:
1. Визит принадлежит одной куке: склейка `ClientID` визитом не считается.
2. Пауза дольше 30 минут рвёт визит надвое, поэтому паузы внутри визита
всегда короче таймаута, а соседние визиты куки разведены дальше него —
считая от последнего события визита, которым бывает покупка, а не от
последней его страницы.
3. Граница модельных суток режет визит: события за полночь в дне не живут.
Исключение одно — визит с заказом, обещанным планом двухкуковых пар: его
старт сдвигается назад, чтобы воронка уместилась в сутки вместе с
торговым хвостом. Это принятое ограничение модели: обещание плана —
гарантия, ради неё мы сужаем свободу старта. Цена названа — около 24
визитов в день из ~9,5 тыс. не начинаются в последние минуты суток.
**Шов с торговыми событиями.** Поток несёт, кроме колонок, три выровненных
по строкам ряда: `page` — какая это страница магазина, `product` — какой
товар показывала карточка (−1 у прочих страниц), и `person` — человек за
кукой этой строки. По первым двум `commerce` знает и то, куда сажать событие
(карточка, подтверждение), и то, что посетитель на самом деле смотрел: товар
в корзине, которого никто не открывал, — видимая глупость в воронке. Третий
в события не попадает вовсе: личность нужна заказу, а кликстрим анонимен.
Визит с назначенным заказом всегда доходит до `/confirmation`, а перед
корзиной у него всегда есть карточка товара. Случайность у половин разная:
трафик берёт подпоток `TRAFFIC`, торговля — `COMMERCE`, заказы — свой, и
правка одной не сдвигает другие.
**Заказы бэкенда** день отдаёт второй половиной: покупки, которые собрала
торговая половина, заказная превращает в заказы дня — с личностью
покупателя и деньгами магазина (`orders`).
"""
from dataclasses import dataclass
from typing import Any
import numpy as np
from numpy.typing import NDArray
from clickstream_generator import catalog, commerce, ids, orders, plan, reference, world
from clickstream_generator.reference import Page
from clickstream_generator.seeds import Component, day_stream
from clickstream_generator.weights import pick, pick_row
DAY_SECONDS = 24 * 60 * 60
# Карточка перед корзиной обязательна: положить в корзину то, чего не
# открывал, посетитель не может.
MIN_PAGES_BEFORE_CART = 2
# На столько секунд визит длиннее своих страниц: торговое событие встаёт
# позже страницы, на которую село, и последним событием визита бывает
# покупка, а не просмотр подтверждения. Величина нужна здесь дважды — визит
# с обещанным заказом обязан уместиться в сутки вместе с хвостом, а соседние
# визиты куки разводятся дальше таймаута тоже от хвоста, а не от страницы.
TRADE_TAIL_SECONDS = world.TRADE_DELAY_SECONDS[1]
_VISIT_COUNT_CUMULATIVE = np.cumsum(world.VISITS_PER_ACTIVE_DAY_WEIGHTS)
_VISIT_PAGES_CUMULATIVE = np.cumsum(world.VISIT_PAGES_WEIGHTS)
_SOURCE_CUMULATIVE = np.cumsum([source.weight for source in reference.TRAFFIC_SOURCES])
@dataclass(frozen=True, slots=True)
class Day:
"""Прожитый день: поток событий трекера и заказы бэкенда.
Строки упорядочены по времени — так их и проиграет проигрыватель.
`columns` — колонки контракта схемы по его порядку, все до одной;
`page` и `product` выровнены по тем же строкам (см. шов в докстринге
модуля). `orders` — вторая половина дня: заказы, рождённые в нём.
Половины уезжают разными командами и в разные топики, но считаются
вместе: заказ — проекция покупки, и порознь им разойтись негде.
"""
day: int
columns: dict[str, NDArray[Any]]
page: NDArray[np.uint8]
product: NDArray[np.int64]
orders: orders.Orders
def __len__(self) -> int:
return self.page.size
@dataclass(frozen=True, slots=True)
class _Visits:
"""Визиты дня рядами: строка — визит, а его страницы лежат подряд.
Где именно лежат, говорят `first` и `pages`: с какой строки начинается
визит и сколько их у него. Всё остальное решено до того, как страницы
сложились в цепочку.
"""
cookie: NDArray[np.int64] # номер куки в дневной аудитории
ordinal: NDArray[np.int64] # какой это визит куки за день
ordering: NDArray[np.bool_] # визит с заказом, обещанным планом
source: NDArray[np.int64]
category: NDArray[np.int64]
stage: NDArray[np.int64] # докуда дошла воронка
browse: NDArray[np.int64] # страниц до воронки
pages: NDArray[np.int64]
first: NDArray[np.int64]
def __len__(self) -> int:
return self.cookie.size
def stream(seed: int, day: int) -> Day:
"""События дня `day` мира `seed`, упорядоченные по времени."""
audience = plan.audience(seed, day)
rng = day_stream(seed, day, Component.TRAFFIC)
visits = _visits(rng, audience)
page, product = _walk(rng, visits)
elapsed, duration = _elapsed(rng, visits)
start = _starts(rng, day, audience, visits, duration)
second = np.repeat(start, visits.pages) + elapsed
# Граница суток режет визит: хвост за полночью в этот день не попадает.
# Странице подтверждения нужно место и под её покупку: подтверждение без
# покупки было бы потерей на клиентской стороне, а она здесь честная —
# потери и дубли стенд заводит намеренно и позже (этапы 4 и 6). Поэтому
# полночь забирает подтверждение вместе с торговым хвостом или не
# забирает ни того, ни другого.
room = np.where(page == Page.CONFIRMATION, TRADE_TAIL_SECONDS, 0)
alive = second + room < DAY_SECONDS
visit_id = np.repeat(ids.unique(rng, len(visits)), visits.pages)
watch_id = ids.unique(rng, int(alive.sum()))
rest = _columns(rng, day, audience, visits, page, product, second)
columns = {
"WatchID": watch_id,
"VisitID": visit_id[alive],
**{name: value[alive] for name, value in rest.items()},
}
order = np.lexsort((columns["WatchID"], columns["UTCEventTime"]))
# Торговые события садятся на готовый трафиковый поток и отдают его
# целиком: в нём же они и упорядочиваются. Вторым выходом приходят
# покупки дня — из них заказная сторона собирает заказы.
person = np.repeat(audience.person_id[visits.cookie], visits.pages)
columns, page, product, purchases = commerce.weave(
seed,
day,
{name: value[order] for name, value in columns.items()},
page[alive][order],
product[alive][order],
person[alive][order],
)
return Day(
day=day,
columns=columns,
page=page,
product=product,
orders=orders.of_day(seed, day, purchases),
)
def _visits(rng: np.random.Generator, audience: plan.DayAudience) -> _Visits:
"""Визиты дня: сколько их у каждой куки и что в каждом.
Всё, кроме цепочки страниц: откуда пришли, за какой категорией, докуда
дойдут по воронке и сколько страниц на это уйдёт.
"""
counts = 1 + pick(rng, _VISIT_COUNT_CUMULATIVE, audience.client_id.size)
cookie = np.repeat(np.arange(counts.size, dtype=np.int64), counts)
ordinal = np.arange(cookie.size) - np.repeat(np.cumsum(counts) - counts, counts)
visits = cookie.size
source = pick(rng, _SOURCE_CUMULATIVE, visits)
category = rng.integers(0, len(catalog.CATEGORIES), visits)
length = 1 + pick(rng, _VISIT_PAGES_CUMULATIVE, visits)
# Обещанный планом заказ достаётся первому визиту дня: слева от него
# соседей нет, поэтому двигать его внутри суток можно свободно.
ordering = audience.assigned_order[cookie] & (ordinal == 0)
stage = _funnel(rng, audience.buyer[cookie], ordering)
# Воронка удлиняет визит, а не съедает его: до корзины надо ещё дойти.
browse = np.where(
stage > 0, np.maximum(length - stage, MIN_PAGES_BEFORE_CART), length
)
pages = browse + stage
return _Visits(
cookie=cookie,
ordinal=ordinal,
ordering=ordering,
source=source,
category=category,
stage=stage,
browse=browse,
pages=pages,
first=np.cumsum(pages) - pages,
)
def _funnel(
rng: np.random.Generator,
buyer: NDArray[np.bool_],
ordering: NDArray[np.bool_],
) -> NDArray[np.int64]:
"""Докуда дошёл визит: 0 — до корзины не дошёл, 3 — до подтверждения.
Помеченный планом покупатель отличается на обоих шагах: и до корзины
доходит чаще, и бросает её реже. Склонность покупать — свойство
человека, а не визита, поэтому одинаковый для всех бросок оставил бы
метку плана словом без следа в данных (спека генератора, раздел 9).
"""
draw = rng.integers(0, 100, (3, buyer.size))
cart = draw[0] < np.where(buyer, world.BUYER_CART_PERCENT, world.CART_PERCENT)
checkout = cart & (
draw[1]
< np.where(
buyer,
world.BUYER_CHECKOUT_OF_CART_PERCENT,
world.CHECKOUT_OF_CART_PERCENT,
)
)
confirmation = checkout & (draw[2] < world.CONFIRMATION_OF_CHECKOUT_PERCENT)
stage = cart.astype(np.int64) + checkout + confirmation
# Заказ, обещанный планом, воронку проходит целиком: гарантия пар стоит
# на том, что событие покупки в этот день случится: его повесит `commerce`.
stage[ordering] = len(reference.FUNNEL_PAGES)
return stage
def _walk(
rng: np.random.Generator, visits: _Visits
) -> tuple[NDArray[np.uint8], NDArray[np.int64]]:
"""Страницы каждого визита и товар их карточек.
Единственное место дня, где посточен сам расчёт: следующая страница
зависит от предыдущей, векторно такую цепочку не сложить. Броски
заготовлены заранее и целиком — в цикле остаётся ходить по таблицам.
"""
total = int(visits.pages.sum())
page = np.empty(total, dtype=np.uint8)
page[visits.first] = _ENTRY_PAGE[visits.source, rng.integers(0, 100, len(visits))]
step = rng.integers(0, 100, total)
funnel = np.array(reference.FUNNEL_PAGES, dtype=np.uint8)
for visit in range(len(visits)):
begin, browsed = int(visits.first[visit]), int(visits.browse[visit])
for row in range(begin + 1, begin + browsed):
previous = page[row - 1]
table = _NEXT_FROM_PRODUCT if previous == Page.PRODUCT else _NEXT_FROM_LIST
page[row] = table[step[row]]
stage = int(visits.stage[visit])
if stage:
# В корзину — только с карточки: иначе в ней окажется товар,
# которого посетитель не открывал.
page[begin + browsed - 1] = Page.PRODUCT
page[begin + browsed : begin + browsed + stage] = funnel[:stage]
goods = catalog.catalog()
shown = page == Page.PRODUCT
row_category = np.repeat(visits.category, visits.pages)[shown]
# Товар — равномерно внутри категории визита: карточки всех товаров
# открывают одинаково часто, а различает их уровень спроса — уже в
# корзине, а не в показе (см. `catalog`).
inside = rng.integers(0, goods.count[row_category])
product = np.full(total, -1, dtype=np.int64)
product[shown] = goods.grouped[goods.first[row_category] + inside]
return page, product
def _elapsed(
rng: np.random.Generator, visits: _Visits
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
"""Секунды каждой страницы от начала своего визита и длина визитов."""
total = int(visits.pages.sum())
short = rng.integers(*world.PAGE_PAUSE_SECONDS, total)
long = rng.integers(*world.LONG_PAUSE_SECONDS, total)
thinking = rng.integers(0, 100, total) < world.LONG_PAUSE_PERCENT
pause = np.where(thinking, long, short)
pause[visits.first] = 0
elapsed = np.cumsum(pause)
elapsed -= np.repeat(elapsed[visits.first], visits.pages)
return elapsed, elapsed[visits.first + visits.pages - 1]
def _starts(
rng: np.random.Generator,
day: int,
audience: plan.DayAudience,
visits: _Visits,
duration: NDArray[np.int64],
) -> NDArray[np.int64]:
"""Секунда начала каждого визита внутри модельных суток.
Волна задана в местном времени посетителя, а сутки считаются в поясе
счётчика: гостю из другого пояса профиль поворачивается на разницу.
Пик от этого слегка размазывается — как в жизни.
"""
hour = pick_row(rng, _hour_cumulative(day), audience.city[visits.cookie])
start = hour * 3600 + rng.integers(0, 3600, hour.size)
# Визит с обещанным заказом обязан уместиться в сутки целиком — вместе с
# торговым хвостом: событие покупки встаёт на секунды позже страницы
# подтверждения, и зажимать его к последней секунде значило бы ломать
# правило ровно там, ради чего оно написано.
fits = np.minimum(start, DAY_SECONDS - duration - TRADE_TAIL_SECONDS - 1)
start = np.where(visits.ordering, fits, start)
# Визиты куки идут по возрастанию времени и разведены дальше таймаута —
# иначе лаба склеила бы два визита в один и разошлась бы с `VisitID`.
# Разводятся они от последнего события визита, а им бывает покупка:
# считать от последней страницы значило бы отдать таймауту торговый хвост.
start = start[np.lexsort((start, visits.cookie))]
for repeat in range(1, len(world.VISITS_PER_ACTIVE_DAY_WEIGHTS)):
later = np.flatnonzero(visits.ordinal == repeat)
earlier = later - 1
start[later] = np.maximum(
start[later],
start[earlier]
+ duration[earlier]
+ TRADE_TAIL_SECONDS
+ world.VISIT_TIMEOUT_SECONDS
+ 1,
)
return start
def _columns(
rng: np.random.Generator,
day: int,
audience: plan.DayAudience,
visits: _Visits,
page: NDArray[np.uint8],
product: NDArray[np.int64],
second: NDArray[np.int64],
) -> dict[str, NDArray[Any]]:
"""Колонки выгрузки по строкам-страницам — все, что решил контракт схемы.
Торговые колонки заполнены пустотой своего типа: пустым массивом и
пустой строкой. Ключ есть всегда — потребитель не должен гадать, была
колонка или её забыли.
"""
total = page.size
sources = reference.TRAFFIC_SOURCES
source, pages, first = visits.source, visits.pages, visits.first
device = audience.device[visits.cookie]
city = audience.city[visits.cookie]
url, title = _addresses(rng, visits, page, product)
# Метки перехода живут и в адресе входа, как в жизни: разбор такого
# адреса — материал лабы, а колонки UTM рядом дают ей эталон.
url[first] = [
f"{address}{'&' if '?' in address else '?'}{query}" if query else address
for address, query in zip(url[first], _UTM_QUERY[source], strict=True)
]
referer = np.empty(total, dtype=object)
referer[1:], referer[0] = url[:-1], ""
referer[first] = _of(sources, "referer")[source]
def by_visit(values: NDArray[Any]) -> NDArray[Any]:
return np.repeat(values, pages)
def by_source(field: str, dtype: Any = object) -> NDArray[Any]:
return by_visit(_of(sources, field, dtype)[source])
def by_device(field: str, dtype: Any = object) -> NDArray[Any]:
return by_visit(_of(reference.DEVICE_PROFILES, field, dtype)[device])
def by_city(field: str, dtype: Any = object) -> NDArray[Any]:
return by_visit(_of(reference.CITIES, field, dtype)[city])
# Дата дня — в поясе счётчика, а полночь того же дня — метка UTC, от
# которой отсчитываются секунды: между ними ровно смещение пояса.
date = np.datetime64(world.ORIGIN, "D") + np.timedelta64(day, "D")
midnight = np.datetime64(world.ORIGIN, "s") + np.timedelta64(day, "D")
away = np.timedelta64(world.COUNTER_TIMEZONE_MINUTES, "m")
yclid = np.where(
_of(sources, "has_yclid", bool)[source],
rng.integers(1, YCLID_LIMIT, source.size, dtype=np.uint64),
0,
).astype(np.uint64)
return {
"ClientID": by_visit(audience.client_id[visits.cookie]),
"CounterID": np.full(total, world.COUNTER_ID, dtype=np.uint32),
"EventDate": np.full(total, date, dtype="datetime64[D]"),
"UTCEventTime": midnight - away + second.astype("timedelta64[s]"),
"ClientTimeZone": by_city("timezone_minutes", np.int16),
"EventType": np.full(total, "pageview", dtype=object),
"Sign": np.ones(total, dtype=np.int8),
"URL": url,
"Referer": referer,
"Title": title,
"UTMSource": by_source("utm_source"),
"UTMMedium": by_source("utm_medium"),
"UTMCampaign": by_source("utm_campaign"),
"UTMContent": by_source("utm_content"),
"UTMTerm": by_source("utm_term"),
"LastTrafficSource": by_source("last_traffic_source"),
"HasGCLID": by_source("has_gclid", np.uint8),
"YCLID": by_visit(yclid),
"Browser": by_device("browser"),
"BrowserMajorVersion": by_device("browser_major_version", np.uint16),
"BrowserLanguage": by_device("language"),
"OperatingSystem": by_device("operating_system"),
"OperatingSystemRoot": by_device("operating_system_root"),
"DeviceCategory": by_device("category", np.uint8),
"MobilePhoneModel": by_device("phone_model"),
"ScreenWidth": by_device("screen_width", np.uint16),
"ScreenHeight": by_device("screen_height", np.uint16),
"IPAddress": by_visit(_ip_addresses(rng, city, device)),
"RegionCountry": np.full(total, reference.COUNTRY_NAME, dtype=object),
"RegionCity": by_city("name"),
"RegionCountryID": np.full(total, reference.COUNTRY_REGION_ID, dtype=np.uint32),
"RegionCityID": by_city("region_id", np.uint32),
# Цели дублируют торговые события, поэтому их ставит `commerce`:
# у просмотра страницы достигнутых целей нет.
"GoalsReached": _blank(total, "uint32"),
# Своих параметров сайт стенда пока не шлёт: вариант A/B-теста был бы
# постоянной куки, а не поведением дня. Решение отложено, не забыто:
# колонку заполнит #47.
"ParsedParamsKey1": _blank(total, object),
"purchaseID": _blank(total, object),
"purchaseRevenue": _blank(total, "float64"),
"purchaseCurrency": _blank(total, object),
"purchaseCoupon": _blank(total, object),
"productID": _blank(total, object),
"productName": _blank(total, object),
"productCategory": _blank(total, object),
"productPrice": _blank(total, "int64"),
"productQuantity": _blank(total, "uint64"),
"productEventType": _blank(total, object),
"ecommerce": np.full(total, "", dtype=object),
}
def _addresses(
rng: np.random.Generator,
visits: _Visits,
page: NDArray[np.uint8],
product: NDArray[np.int64],
) -> tuple[NDArray[np.object_], NDArray[np.object_]]:
"""Адрес и заголовок каждой страницы; у входа в адресе живут метки UTM."""
total = page.size
goods = catalog.catalog()
row_category = np.repeat(visits.category, visits.pages)
query = rng.integers(0, len(reference.SEARCH_QUERIES), total)
static = rng.integers(0, len(reference.STATIC_PAGES), total)
url: list[str] = []
title: list[str] = []
for row in range(total):
kind = page[row]
if kind == Page.PRODUCT:
number = product[row]
path, heading = f"/product/{goods.sku[number]}", goods.name[number]
elif kind == Page.CATALOG:
group = catalog.CATEGORIES[row_category[row]]
path, heading = f"/catalog/{group.slug}", group.name
elif kind == Page.SEARCH:
text = reference.SEARCH_QUERIES[query[row]]
path, heading = f"/search?text={text}", "Поиск по магазину"
elif kind == Page.STATIC:
path, heading = reference.STATIC_PAGES[static[row]]
else:
path, heading = _FIXED_PAGES[kind]
url.append(reference.SITE_URL + path)
title.append(f"{heading} — {reference.SITE_NAME}")
return np.array(url, dtype=object), np.array(title, dtype=object)
def _ip_addresses(
rng: np.random.Generator, city: NDArray[np.int64], device: NDArray[np.int64]
) -> NDArray[np.object_]:
"""Адрес визита: городской ломоть, а у телефонов — CGNAT оператора."""
count = city.size
prefix = _of(reference.CITIES, "ip_prefix")[city]
category = _of(reference.DEVICE_PROFILES, "category", np.int64)[device]
phone = category == reference.PHONE_CATEGORY
operator = rng.integers(*reference.MOBILE_IP_SECOND_BYTE, count)
block = rng.integers(0, 256, count)
host = rng.integers(1, 255, count)
first_byte = reference.MOBILE_IP_FIRST_BYTE
return np.array(
[
f"{first_byte}.{operator[visit]}.{block[visit]}.{host[visit]}"
if phone[visit]
else f"{prefix[visit]}{host[visit]}"
for visit in range(count)
],
dtype=object,
)
def _hour_cumulative(day: int) -> NDArray[np.int64]:
"""Веса часов суток по городам, накопленные: строка города — его волна."""
# Суббота и воскресенье — последние два дня недели, а D0 — понедельник.
weekend = day % 7 >= 5
profile = np.array(
world.WEEKEND_HOURS_PERCENT if weekend else world.WEEKDAY_HOURS_PERCENT
)
shifts = [
(city.timezone_minutes - world.COUNTER_TIMEZONE_MINUTES) // 60
for city in reference.CITIES
]
return np.cumsum([np.roll(profile, -shift) for shift in shifts], axis=1)
def _of(table: tuple[Any, ...], field: str, dtype: Any = object) -> NDArray[Any]:
"""Колонка справочника массивом: строка выбирается своим номером."""
return np.array([getattr(row, field) for row in table], dtype=dtype)
def _blank(size: int, dtype: Any) -> NDArray[np.object_]:
"""Колонка-массив, пустая по смыслу: у каждой строки пустой массив."""
empty = np.array([], dtype=dtype)
empty.flags.writeable = False
column = np.empty(size, dtype=object)
column.fill(empty)
return column
def _hundred(percent: tuple[int, ...]) -> NDArray[np.uint8]:
"""Сотня ячеек «бросок 0…99 → страница»: выбор одним обращением."""
return np.repeat(np.array(reference.BROWSE_PAGES, dtype=np.uint8), percent)
def _utm_query(source: reference.TrafficSource) -> str:
"""Метки перехода строкой запроса; у источника без меток — пустая."""
marks = ((field, getattr(source, f"utm_{field}")) for field in _UTM_FIELDS)
return "&".join(f"utm_{field}={value}" for field, value in marks if value)
_UTM_FIELDS = ("source", "medium", "campaign", "content", "term")
_UTM_QUERY = np.array(
[_utm_query(source) for source in reference.TRAFFIC_SOURCES], dtype=object
)
_FIXED_PAGES = {
Page.HOME: ("/", "Интернет-магазин товаров для дома"),
Page.CART: ("/cart", "Корзина"),
Page.CHECKOUT: ("/checkout", "Оформление заказа"),
Page.CONFIRMATION: ("/confirmation", "Заказ оформлен"),
}
_ENTRY_PAGE = np.array(
[_hundred(source.entry_percent) for source in reference.TRAFFIC_SOURCES]
)
_NEXT_FROM_LIST = _hundred(reference.FROM_LISTING_PERCENT)
_NEXT_FROM_PRODUCT = _hundred(reference.FROM_PRODUCT_PERCENT)
# Потолок id клика Директа: тринадцать цифр, как у настоящих меток.
YCLID_LIMIT = 10**13