phase1: ingestion to parquet, unit parser (+tests), retailers hierarchy, daily/weekly panel reconstruction (duckdb), StatCan benchmarks, panel invariant tests
12 changed files +549 −1
modified
reports/phase0_feasibility.md
+1 −1
@@ -34,7 +34,7 @@ Public cross-check (2026-11-01 ~08:00 UTC): homepage shows 50 760 products, 54 b | ||
| 34 | 34 | |
| 35 | 35 | ### 3.2 Data-generating process (from the connector code, read-only copy in `data/raw/foodka-src/`) |
| 36 | 36 | |
| 37 | −- 57 active source feeds = **54 with active products**, belonging to far fewer **retail companies**: Loblaw (maxi, provigo, club_entrepot + 3 flyers + inter_marche, axep? no: Axep/Richelieu/Marché Ami are Metro affiliates), Metro Inc. (metro, superc + 2 flyers + richelieu, marche_ami, axep), Sobeys (iga + flyer, tradition, bonichoix, rachelle_bery), Walmart, Costco, Adonis (Metro Inc.), Avril, independents, 4 pharmacies, SAQ. A `retailers` table (company → banner → feed → channel) will be built in Phase 1. | |
| 37 | +- 57 active source feeds = **54 with active products**, belonging to far fewer **retail companies** (Loblaw, Metro Inc., Sobeys/Empire, Walmart, Costco, independents, four pharmacy banners, the SAQ monopoly); several banners appear twice (online storefront feed + Flipp circular feed). A `retailers` table (company → banner → feed → channel) is built in Phase 1 and ownership is verified against public corporate information in Phase 2. | |
| 38 | 38 | - **Two channels**: (a) *online storefront* feeds (Shopify/WooCommerce JSON, Loblaw/Metro/Walmart/Costco/IGA sites scraped through residential proxy pools without store selection); (b) *weekly circulars* via Flipp for postal code **H2X 1Y4 (Montréal)** with `valid_from`/`valid_to`, `original_price`, `dollars_off`, fine print. |
| 39 | 39 | - **Geography**: no store identifier anywhere. Circulars are regional (Montréal zone); online prices are whatever store the retailer's site assigned to the proxy session. → **Store-level geography is unavailable** (H4 and travel-cost components of Model E cannot be estimated from Food-Ka). |
| 40 | 40 | - **Identifiers**: Metro and Super C `external_id` are valid GS1 UPC-A codes (100 % check-digit valid, n = 1 954 active). Shopify/Walmart/Loblaw ids are internal SKUs (check-digit validity ≈ 10 %, i.e. chance). Exact GTIN matching is therefore only possible within Metro Inc.; everything else requires validated probabilistic matching. |
added
src/__init__.py
+0 −0
added
src/cleaning/__init__.py
+0 −0
added
src/cleaning/build_panel.py
+159 −0
@@ -0,0 +1,159 @@ | ||
| 1 | +"""Phase 1.3 — reconstruct the product × day (and × week) price panel from the event log. | |
| 2 | + | |
| 3 | +Data-generating process (Food-Ka db.py, read-only copy): price_log holds one row at a product's first observation and | |
| 4 | +one row at every change of the displayed price (NULL = no displayed price). Between two rows the product kept the | |
| 5 | +recorded price and was re-observed at every connector pass (median 7.2 h). A product's observation span is | |
| 6 | +[first_seen, last_seen]; after two consecutive misses it is deactivated (active=0) — gaps inside the span are not | |
| 7 | +logged (limitation documented in reports/phase1_data_audit.md). | |
| 8 | + | |
| 9 | +Outputs (data/processed): | |
| 10 | + offers_daily.parquet — uid × local date: duration-weighted price statistics | |
| 11 | + offers_weekly.parquet — uid × week (Thu→Wed 'flyer week'): same statistics | |
| 12 | + products.parquet — canonical product table with re-parsed sizes, unit prices, flags | |
| 13 | + snapshots.parquet — connector passes (from sync_log) | |
| 14 | +""" | |
| 15 | +from __future__ import annotations | |
| 16 | +import json | |
| 17 | +import duckdb, pandas as pd, numpy as np | |
| 18 | +from src.common import path, get_logger, ledger, write_manifest, TZ, SETTINGS | |
| 19 | +from src.cleaning.units import parse_size, parse_price_label, unit_price | |
| 20 | + | |
| 21 | +log = get_logger("panel") | |
| 22 | + | |
| 23 | +def canonical_products(prod: pd.DataFrame) -> pd.DataFrame: | |
| 24 | + out = prod.copy() | |
| 25 | + sizes = [parse_size(sl, nm) for sl, nm in zip(out["size_label"], out["name"])] | |
| 26 | + labels = [parse_price_label(pl) for pl in out["price_label"]] | |
| 27 | + out["size_qty"] = [s.qty if s else np.nan for s in sizes] | |
| 28 | + out["size_base"] = [s.base if s else None for s in sizes] | |
| 29 | + out["size_approx"] = [bool(s.approx) if s else False for s in sizes] | |
| 30 | + out["size_source"] = [s.source if s else None for s in sizes] | |
| 31 | + out["per_weight_label"] = [l.per_weight_unit for l in labels] | |
| 32 | + out["multibuy_n"] = [l.multibuy_n for l in labels] | |
| 33 | + up = [unit_price(p, s, l) for p, s, l in zip(out["price"], sizes, labels)] | |
| 34 | + out["unit_price_rp"] = [u[0] if u else np.nan for u in up] | |
| 35 | + out["unit_base_rp"] = [u[1] if u else None for u in up] | |
| 36 | + det = out["details"].apply(lambda s: json.loads(s) if isinstance(s, str) and s else {}) | |
| 37 | + out["flyer_id"] = det.apply(lambda d: d.get("flyer_id")) | |
| 38 | + out["valid_from"] = det.apply(lambda d: d.get("valid_from")) | |
| 39 | + out["valid_to"] = det.apply(lambda d: d.get("valid_to")) | |
| 40 | + out["fine_print"] = det.apply(lambda d: d.get("fine_print")) | |
| 41 | + out["off_barcode"] = det.apply(lambda d: (d.get("off") or {}).get("code") if isinstance(d.get("off"), dict) else None) | |
| 42 | + fp = out["fine_print"].fillna("").str.lower() | |
| 43 | + out["flag_weighted"] = out["per_weight_label"].notna() | out["size_approx"] | |
| 44 | + out["flag_multibuy"] = out["multibuy_n"].notna() | |
| 45 | + out["flag_loyalty"] = fp.str.contains("pc optimum|moi|scène|scene|points|membre|member", regex=True) | |
| 46 | + out["flag_min_qty"] = fp.str.contains("limite|limit|minimum|achat de|à l'achat", regex=True) | |
| 47 | + out["flag_deposit_tax"] = fp.str.contains(r"\+\s*tx|taxes|consigne|dépôt|deposit", regex=True) | |
| 48 | + out["flag_online_only"] = out["source"].isin(["epipresto", "bocoboco", "aliments_merci", "maturin", "costco"]) | |
| 49 | + out["flag_price_missing"] = out["price"].isna() | |
| 50 | + out["flag_unit_missing"] = out["unit_price_rp"].isna() | |
| 51 | + out["flag_reg_inconsistent"] = out["regular_price"].notna() & (out["regular_price"] <= out["price"]) | |
| 52 | + out["flag_unrealistic"] = out["price"].notna() & ((out["price"] < 0.05) | (out["price"] > 1500) | | |
| 53 | + (out["unit_price_rp"].notna() & (out["unit_base_rp"] == "100 g") & (out["unit_price_rp"] > 50))) | |
| 54 | + out["gtin_valid"] = out["external_id"].apply(_gs1_ok) | |
| 55 | + out["first_seen_local"] = pd.to_datetime(out["first_seen"], unit="s", utc=True).dt.tz_convert(TZ) | |
| 56 | + out["last_seen_local"] = pd.to_datetime(out["last_seen"], unit="s", utc=True).dt.tz_convert(TZ) | |
| 57 | + out["updated_local"] = pd.to_datetime(out["updated_at"], unit="s", utc=True).dt.tz_convert(TZ) | |
| 58 | + out["span_days"] = (out["last_seen"] - out["first_seen"]) / 86400.0 | |
| 59 | + return out.drop(columns=["details", "images", "description"]) | |
| 60 | + | |
| 61 | +def _gs1_ok(code: str) -> bool: | |
| 62 | + if not isinstance(code, str) or not code.isdigit() or len(code) not in (8, 12, 13, 14): | |
| 63 | + return False | |
| 64 | + d = [int(c) for c in code]; body, chk = d[:-1], d[-1] | |
| 65 | + s = sum(x * (3 if (len(body) - i) % 2 == 1 else 1) for i, x in enumerate(body)) | |
| 66 | + return (10 - s % 10) % 10 == chk | |
| 67 | + | |
| 68 | +def build(con: duckdb.DuckDBPyConnection) -> None: | |
| 69 | + con.execute(f"SET TimeZone='{TZ}'") | |
| 70 | + con.execute(""" | |
| 71 | + CREATE OR REPLACE TABLE ev AS | |
| 72 | + SELECT pl.uid, p.source, to_timestamp(pl.ts) AS ts, pl.price, | |
| 73 | + to_timestamp(p.first_seen) AS first_seen, to_timestamp(p.last_seen) AS last_seen | |
| 74 | + FROM raw_price_log pl JOIN raw_products p USING (uid)""") | |
| 75 | + # segments: a price holds from its event until the next event, or until last_seen | |
| 76 | + con.execute(""" | |
| 77 | + CREATE OR REPLACE TABLE seg AS | |
| 78 | + SELECT uid, source, price, ts AS seg_start, | |
| 79 | + COALESCE(LEAD(ts) OVER (PARTITION BY uid ORDER BY ts), last_seen) AS seg_end | |
| 80 | + FROM ev""") | |
| 81 | + con.execute("DELETE FROM seg WHERE seg_end <= seg_start") | |
| 82 | + # expand to local days | |
| 83 | + con.execute(""" | |
| 84 | + CREATE OR REPLACE TABLE segday AS | |
| 85 | + WITH s AS ( | |
| 86 | + SELECT *, CAST(seg_start AT TIME ZONE 'UTC' AS TIMESTAMP) AS a, CAST(seg_end AT TIME ZONE 'UTC' AS TIMESTAMP) AS b FROM seg | |
| 87 | + ), s2 AS ( | |
| 88 | + SELECT uid, source, price, seg_start, seg_end, | |
| 89 | + (seg_start AT TIME ZONE '%(tz)s')::DATE AS d0, (seg_end AT TIME ZONE '%(tz)s')::DATE AS d1 FROM seg | |
| 90 | + ), g AS ( | |
| 91 | + SELECT uid, source, price, seg_start, seg_end, UNNEST(generate_series(d0, d1, INTERVAL 1 DAY))::DATE AS day FROM s2 | |
| 92 | + ) | |
| 93 | + SELECT uid, source, price, day, | |
| 94 | + GREATEST(seg_start, (day::TIMESTAMP) AT TIME ZONE '%(tz)s') AS a, | |
| 95 | + LEAST(seg_end, ((day + INTERVAL 1 DAY)::TIMESTAMP) AT TIME ZONE '%(tz)s') AS b | |
| 96 | + FROM g""" % {"tz": TZ}) | |
| 97 | + con.execute("DELETE FROM segday WHERE b <= a") | |
| 98 | + con.execute("ALTER TABLE segday ADD COLUMN hours DOUBLE") | |
| 99 | + con.execute("UPDATE segday SET hours = epoch(b - a) / 3600.0") | |
| 100 | + _aggregate(con, "segday", "day", "offers_daily") | |
| 101 | + # flyer week: Thursday -> Wednesday, keyed by the Thursday | |
| 102 | + con.execute("""CREATE OR REPLACE TABLE segweek AS | |
| 103 | + SELECT *, day - CAST((dayofweek(day) + 3) % 7 AS INTEGER) AS week FROM segday""") # dayofweek: Sun=0 ... Thu=4 -> offset 0 | |
| 104 | + _aggregate(con, "segweek", "week", "offers_weekly") | |
| 105 | + | |
| 106 | +def _aggregate(con, table: str, key: str, out: str) -> None: | |
| 107 | + con.execute(f""" | |
| 108 | + CREATE OR REPLACE TABLE {out} AS | |
| 109 | + WITH byprice AS ( | |
| 110 | + SELECT uid, source, {key}, price, SUM(hours) AS h, MIN(a) AS first_a | |
| 111 | + FROM {table} GROUP BY uid, source, {key}, price | |
| 112 | + ), tot AS ( | |
| 113 | + SELECT uid, {key}, SUM(h) AS hours_obs, SUM(CASE WHEN price IS NULL THEN h ELSE 0 END) AS hours_null, | |
| 114 | + COUNT(*) FILTER (WHERE price IS NOT NULL) AS n_distinct | |
| 115 | + FROM byprice GROUP BY uid, {key} | |
| 116 | + ), ranked AS ( | |
| 117 | + SELECT *, ROW_NUMBER() OVER (PARTITION BY uid, {key} ORDER BY h DESC, price ASC) AS rk | |
| 118 | + FROM byprice WHERE price IS NOT NULL | |
| 119 | + ), modep AS (SELECT uid, {key}, price AS p_mode, h AS mode_hours FROM ranked WHERE rk = 1), | |
| 120 | + stats AS ( | |
| 121 | + SELECT uid, source, {key}, MIN(price) AS p_min, MAX(price) AS p_max, | |
| 122 | + SUM(price * hours) / NULLIF(SUM(hours), 0) AS p_twmean, | |
| 123 | + ARG_MAX(price, b) AS p_close, ARG_MIN(price, a) AS p_open, | |
| 124 | + COUNT(*) - 1 AS n_changes | |
| 125 | + FROM {table} WHERE price IS NOT NULL GROUP BY uid, source, {key} | |
| 126 | + ), med AS ( | |
| 127 | + SELECT uid, {key}, QUANTILE_CONT(price, 0.5) AS p_median_events FROM {table} WHERE price IS NOT NULL GROUP BY uid, {key} | |
| 128 | + ) | |
| 129 | + SELECT s.uid, s.source, s.{key}, s.p_open, s.p_close, s.p_min, s.p_max, s.p_twmean, m.p_mode, m.mode_hours, | |
| 130 | + md.p_median_events, t.n_distinct, s.n_changes, t.hours_obs, t.hours_null, | |
| 131 | + m.mode_hours / NULLIF(t.hours_obs - t.hours_null, 0) AS mode_share, | |
| 132 | + (s.p_max - s.p_min) / NULLIF(m.p_mode, 0) AS rel_range | |
| 133 | + FROM stats s JOIN modep m USING (uid, {key}) JOIN tot t USING (uid, {key}) JOIN med md USING (uid, {key})""") | |
| 134 | + con.execute(f"COPY {out} TO '{path('processed') / (out + '.parquet')}' (FORMAT PARQUET)") | |
| 135 | + n, = con.execute(f"SELECT COUNT(*) FROM {out}").fetchone() | |
| 136 | + log.info("%s: %d rows", out, n) | |
| 137 | + | |
| 138 | +def main() -> None: | |
| 139 | + interim = path("interim") | |
| 140 | + con = duckdb.connect() | |
| 141 | + for t in ["products", "price_log", "sync_log"]: | |
| 142 | + con.execute(f"CREATE VIEW raw_{t} AS SELECT * FROM read_parquet('{interim / ('raw_' + t + '.parquet')}')") | |
| 143 | + build(con) | |
| 144 | + prod = canonical_products(pd.read_parquet(interim / "raw_products.parquet")) | |
| 145 | + prod.to_parquet(path("processed") / "products.parquet", index=False) | |
| 146 | + log.info("products canonical: %d rows; unit price available for %.1f%% (Food-Ka: %.1f%%)", len(prod), | |
| 147 | + 100 * prod["unit_price_rp"].notna().mean(), 100 * prod["unit_price"].notna().mean()) | |
| 148 | + sl = pd.read_parquet(interim / "raw_sync_log.parquet") | |
| 149 | + sl["ts_utc"] = pd.to_datetime(sl["ts"], unit="s", utc=True) | |
| 150 | + st = sl["stats"].apply(lambda s: json.loads(s) if isinstance(s, str) and s else {}) | |
| 151 | + sl["null_price_rate"] = st.apply(lambda d: d.get("null_price_rate")); sl["missed"] = st.apply(lambda d: d.get("missed")) | |
| 152 | + sl["pipeline_commit"] = SETTINGS["snapshot"]["foodka_commit"][:7] | |
| 153 | + sl.drop(columns=["stats"]).to_parquet(path("processed") / "snapshots.parquet", index=False) | |
| 154 | + files = [path("processed") / f for f in ["offers_daily.parquet", "offers_weekly.parquet", "products.parquet", "snapshots.parquet"]] | |
| 155 | + write_manifest("panel", files) | |
| 156 | + ledger("1", "build_panel", "offers_daily/weekly, products, snapshots written") | |
| 157 | + | |
| 158 | +if __name__ == "__main__": | |
| 159 | + main() | |
added
src/cleaning/retailers.py
+96 −0
@@ -0,0 +1,96 @@ | ||
| 1 | +"""Phase 1.2 — canonical `retailers` table: company → banner → source feed → channel. | |
| 2 | + | |
| 3 | +Ownership/affiliation is taken from the Food-Ka registry text (`region`, `tech`) and from public corporate | |
| 4 | +information; the column `ownership_basis` records which. Items marked 'public-corporate' are re-verified in | |
| 5 | +Phase 2 (institutional context) and any correction is logged. | |
| 6 | +""" | |
| 7 | +from __future__ import annotations | |
| 8 | +import pandas as pd | |
| 9 | +from src.common import path, get_logger, ledger | |
| 10 | + | |
| 11 | +log = get_logger("retailers") | |
| 12 | + | |
| 13 | +# feed id -> (company, banner, channel, format, ownership_basis) | |
| 14 | +MAP = { | |
| 15 | + "metro": ("Metro Inc.", "Metro", "online_storefront", "supermarket", "public-corporate"), | |
| 16 | + "metro_flyer": ("Metro Inc.", "Metro", "circular", "supermarket", "public-corporate"), | |
| 17 | + "superc": ("Metro Inc.", "Super C", "online_storefront", "discount", "public-corporate"), | |
| 18 | + "superc_flyer": ("Metro Inc.", "Super C", "circular", "discount", "public-corporate"), | |
| 19 | + "adonis": ("Metro Inc.", "Marché Adonis", "online_storefront", "ethnic_supermarket", "public-corporate"), | |
| 20 | + "adonis_flyer": ("Metro Inc.", "Marché Adonis", "circular", "ethnic_supermarket", "public-corporate"), | |
| 21 | + "richelieu": ("Metro Inc. (affiliate)", "Marché Richelieu", "circular", "proximity", "registry"), | |
| 22 | + "marche_ami": ("Metro Inc. (affiliate)", "Marché Ami", "circular", "proximity", "registry"), | |
| 23 | + "axep": ("Metro Inc. (affiliate)", "Axep", "circular", "proximity", "registry"), | |
| 24 | + "jean_coutu": ("Metro Inc.", "Jean Coutu", "circular", "pharmacy", "public-corporate"), | |
| 25 | + "brunet": ("Metro Inc.", "Brunet", "circular", "pharmacy", "public-corporate"), | |
| 26 | + "maxi": ("Loblaw Companies", "Maxi", "online_storefront", "discount", "public-corporate"), | |
| 27 | + "maxi_flyer": ("Loblaw Companies", "Maxi", "circular", "discount", "public-corporate"), | |
| 28 | + "provigo": ("Loblaw Companies", "Provigo", "online_storefront", "supermarket", "public-corporate"), | |
| 29 | + "provigo_flyer": ("Loblaw Companies", "Provigo", "circular", "supermarket", "public-corporate"), | |
| 30 | + "club_entrepot": ("Loblaw Companies", "Club Entrepôt", "online_storefront", "wholesale_club", "registry"), | |
| 31 | + "pharmaprix": ("Loblaw Companies", "Pharmaprix", "circular", "pharmacy", "public-corporate"), | |
| 32 | + "tt": ("Loblaw Companies", "T&T Supermarket", "online_storefront", "ethnic_supermarket", "public-corporate"), | |
| 33 | + "inter_marche": ("Loblaw Companies (affiliate)", "L'Inter-Marché", "circular", "proximity", "registry"), | |
| 34 | + "inter_marche_intl": ("Loblaw Companies (affiliate)", "L'Inter-Marché International", "circular", "ethnic_supermarket", "registry"), | |
| 35 | + "iga": ("Sobeys / Empire", "IGA", "online_storefront", "supermarket", "public-corporate"), | |
| 36 | + "iga_flyer": ("Sobeys / Empire", "IGA", "circular", "supermarket", "public-corporate"), | |
| 37 | + "tradition": ("Sobeys / Empire", "Les Marchés Tradition", "circular", "proximity", "registry"), | |
| 38 | + "bonichoix": ("Sobeys / Empire", "Marché Bonichoix", "circular", "proximity", "registry"), | |
| 39 | + "rachelle_bery": ("Sobeys / Empire", "Rachelle-Béry", "circular", "health_specialty", "public-corporate"), | |
| 40 | + "walmart": ("Walmart Canada", "Walmart", "online_storefront", "mass_merchant", "public-corporate"), | |
| 41 | + "walmart_flyer": ("Walmart Canada", "Walmart", "circular", "mass_merchant", "public-corporate"), | |
| 42 | + "costco": ("Costco Wholesale Canada", "Costco", "online_storefront", "warehouse_club", "public-corporate"), | |
| 43 | + "costco_flyer": ("Costco Wholesale Canada", "Costco", "circular", "warehouse_club", "public-corporate"), | |
| 44 | + "giant_tiger": ("Giant Tiger Stores", "Giant Tiger", "online_storefront", "discount_general", "public-corporate"), | |
| 45 | + "avril": ("Avril Supermarché Santé", "Avril", "online_storefront", "health_specialty", "registry"), | |
| 46 | + "avril_flyer": ("Avril Supermarché Santé", "Avril", "circular", "health_specialty", "registry"), | |
| 47 | + "pa": ("Supermarché PA", "PA Supermarché", "online_storefront", "independent_supermarket", "registry"), | |
| 48 | + "pa_nature": ("Supermarché PA", "PA Nature", "circular", "health_specialty", "registry"), | |
| 49 | + "uniprix": ("McKesson Canada", "Uniprix", "circular", "pharmacy", "public-corporate"), | |
| 50 | + "saq": ("Société des alcools du Québec", "SAQ", "online_storefront", "alcohol_monopoly", "registry"), | |
| 51 | + "mayrand": ("Groupe Mayrand", "Mayrand", "online_storefront", "cash_and_carry", "registry"), | |
| 52 | + "epipresto": ("Epipresto", "Epipresto", "online_storefront", "online_only", "registry"), | |
| 53 | + "nuvo": ("Marché Nuvo", "Marché Nuvo", "online_storefront", "independent_supermarket", "registry"), | |
| 54 | + "boite_a_grains": ("La Boîte à Grains", "La Boîte à Grains", "online_storefront", "health_specialty", "registry"), | |
| 55 | + "bocoboco": ("BocoBoco", "BocoBoco", "online_storefront", "online_only", "registry"), | |
| 56 | + "aliments_merci": ("Aliments Merci", "Aliments Merci", "online_storefront", "online_only", "registry"), | |
| 57 | + "akhavan": ("Akhavan", "Akhavan", "online_storefront", "ethnic_supermarket", "registry"), | |
| 58 | + "aubut": ("Aubut", "Aubut", "online_storefront", "cash_and_carry", "registry"), | |
| 59 | + "maturin": ("Maturin", "Maturin", "online_storefront", "online_only", "registry"), | |
| 60 | + "tau": ("Marché Tau", "Marché Tau", "online_storefront", "health_specialty", "registry"), | |
| 61 | + "loco": ("Épiceries LOCO", "LOCO", "online_storefront", "zero_waste", "registry"), | |
| 62 | + "kim_phat": ("Kim Phat", "Kim Phat", "circular", "ethnic_supermarket", "registry"), | |
| 63 | + "fu_tai": ("Marché Fu Tai", "Fu Tai", "circular", "ethnic_supermarket", "registry"), | |
| 64 | + "marche_ct": ("Marché C&T", "C&T", "circular", "ethnic_supermarket", "registry"), | |
| 65 | + "aures": ("Supermarché Aurès", "Aurès", "circular", "ethnic_supermarket", "registry"), | |
| 66 | + "euro_marche": ("Euro Marché", "Euro Marché", "circular_hybrid", "ethnic_supermarket", "registry"), | |
| 67 | + "val_mont": ("Marché Val-Mont", "Val-Mont", "circular", "independent_supermarket", "registry"), | |
| 68 | + "bonanza": ("Marché Bonanza", "Bonanza", "circular", "independent_supermarket", "registry"), | |
| 69 | + "pasquier": ("Pasquier", "Pasquier", "circular", "independent_supermarket", "registry"), | |
| 70 | + "marche_vegetarien": ("Le Marché Végétarien", "Marché Végétarien", "circular", "health_specialty", "registry"), | |
| 71 | + "aliments_mm": ("Les Aliments M&M", "M&M", "circular", "frozen_specialty", "registry"), | |
| 72 | + "bulk_barn": ("Bulk Barn Foods", "Bulk Barn", "none", "bulk_specialty", "public-corporate"), | |
| 73 | + "dollarama": ("Dollarama", "Dollarama", "none", "discount_general", "public-corporate"), | |
| 74 | + "lufa": ("Fermes Lufa", "Lufa", "none", "online_only", "registry"), | |
| 75 | + "frenco": ("Frenco", "Frenco", "none", "bulk_specialty", "registry"), | |
| 76 | +} | |
| 77 | + | |
| 78 | +def main() -> None: | |
| 79 | + src = pd.read_parquet(path("interim") / "sources.parquet") | |
| 80 | + rows = [] | |
| 81 | + for _, r in src.iterrows(): | |
| 82 | + comp, banner, channel, fmt, basis = MAP[r["id"]] | |
| 83 | + rows.append(dict(feed_id=r["id"], feed_name=r["name"], company=comp, banner=banner, channel=channel, | |
| 84 | + store_format=fmt, ownership_basis=basis, registry_status=r["status"], registry_region=r["region"], | |
| 85 | + tech=r["tech"], url=r["url"], | |
| 86 | + circular_postal_zone=("H2X 1Y4 (Montréal)" if channel.startswith("circular") else None), | |
| 87 | + store_identified=False, is_grocery=(fmt not in ("pharmacy", "alcohol_monopoly")))) | |
| 88 | + ret = pd.DataFrame(rows) | |
| 89 | + ret.to_parquet(path("processed") / "retailers.parquet", index=False) | |
| 90 | + ret.to_csv(path("processed") / "retailers.csv", index=False) | |
| 91 | + log.info("retailers: %d feeds, %d banners, %d companies; channels=%s", len(ret), ret.banner.nunique(), ret.company.nunique(), | |
| 92 | + ret.channel.value_counts().to_dict()) | |
| 93 | + ledger("1", "retailers", f"{len(ret)} feeds -> {ret.banner.nunique()} banners -> {ret.company.nunique()} companies") | |
| 94 | + | |
| 95 | +if __name__ == "__main__": | |
| 96 | + main() | |
added
src/cleaning/units.py
+90 −0
@@ -0,0 +1,90 @@ | ||
| 1 | +"""Unit normalisation for grocery listings (CAD; g / ml / unit). | |
| 2 | + | |
| 3 | +Re-implements and extends Food-Ka's parser (data/raw/foodka-src/foodka/normalize.py) so that the research | |
| 4 | +pipeline does not depend on production code. Differences are documented in reports/phase1_data_audit.md. | |
| 5 | +""" | |
| 6 | +from __future__ import annotations | |
| 7 | +import re, unicodedata | |
| 8 | +from dataclasses import dataclass | |
| 9 | + | |
| 10 | +_UNITS = {"kg": (1000.0, "g"), "g": (1.0, "g"), "mg": (0.001, "g"), "lb": (453.592, "g"), "lbs": (453.592, "g"), | |
| 11 | + "oz": (28.3495, "g"), "l": (1000.0, "ml"), "ml": (1.0, "ml"), "cl": (10.0, "ml"), | |
| 12 | + "un": (1.0, "un"), "unite": (1.0, "un"), "unites": (1.0, "un"), "ea": (1.0, "un"), "each": (1.0, "un"), | |
| 13 | + "pk": (1.0, "un"), "ch": (1.0, "un"), "pc": (1.0, "un"), "pcs": (1.0, "un"), "units": (1.0, "un"), "unit": (1.0, "un")} | |
| 14 | +_SIZE_RE = re.compile(r"(?:(\d+)\s*[x×*]\s*)?(\d+(?:[.,]\d+)?)\s*(kg|g|mg|lb|lbs|oz|ml|cl|l|un|unites?|ea|each|pk|ch|pcs?|units?)\b", re.I) | |
| 15 | +_RANGE_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*(?:-|–|a|à)\s*(\d+(?:[.,]\d+)?)\s*(kg|g|lb|lbs|oz|ml|l)\b", re.I) | |
| 16 | +_PERKG_RE = re.compile(r"/\s*(kg|lb|100\s*g|100\s*ml|l|ch|un)\b", re.I) | |
| 17 | +_MULTI_RE = re.compile(r"^\s*(\d+)\s*(?:/|pour|for)\s*(?:(\d{1,4}(?:[.,]\d{1,2})?)\s*\$)?") | |
| 18 | +_APPROX_RE = re.compile(r"environ|approx|moyenne|avg|about|~", re.I) | |
| 19 | +_SOLD_INDIV_RE = re.compile(r"vendu individuellement|sold individually|a l'unite|à l'unité|l'unite|unite$", re.I) | |
| 20 | + | |
| 21 | +def strip_accents(s: str) -> str: | |
| 22 | + return "".join(c for c in unicodedata.normalize("NFKD", s or "") if not unicodedata.combining(c)) | |
| 23 | + | |
| 24 | +@dataclass | |
| 25 | +class Size: | |
| 26 | + qty: float # in base unit (g, ml, un) | |
| 27 | + base: str # 'g' | 'ml' | 'un' | |
| 28 | + approx: bool = False | |
| 29 | + source: str = "size_label" # where it was parsed from | |
| 30 | + | |
| 31 | +def parse_size(label: str | None, name: str | None = None) -> Size | None: | |
| 32 | + """Parse '450 g', '2 L', '12 x 355 ml', '6 un', 'Vendu individuellement', '1,36 kg'; falls back to the name.""" | |
| 33 | + for text, src in ((label, "size_label"), (name, "name")): | |
| 34 | + if not text: | |
| 35 | + continue | |
| 36 | + t = strip_accents(text).lower().replace("\u00a0", " ") | |
| 37 | + m = _RANGE_RE.search(t) | |
| 38 | + if m: # "1,2 - 1,5 kg" -> midpoint, approximate (checked before the simple pattern) | |
| 39 | + a, b = float(m.group(1).replace(",", ".")), float(m.group(2).replace(",", ".")) | |
| 40 | + factor, base = _UNITS[m.group(3).lower()] | |
| 41 | + return Size((a + b) / 2 * factor, base, True, src) | |
| 42 | + m = _SIZE_RE.search(t) | |
| 43 | + if m: | |
| 44 | + mult = int(m.group(1)) if m.group(1) else 1 | |
| 45 | + qty = float(m.group(2).replace(",", ".")) | |
| 46 | + factor, base = _UNITS[m.group(3).lower()] | |
| 47 | + total = mult * qty * factor | |
| 48 | + if total > 0: | |
| 49 | + return Size(total, base, bool(_APPROX_RE.search(t)), src) | |
| 50 | + if src == "size_label" and _SOLD_INDIV_RE.search(t): | |
| 51 | + return Size(1.0, "un", False, src) | |
| 52 | + return None | |
| 53 | + | |
| 54 | +@dataclass | |
| 55 | +class PriceLabel: | |
| 56 | + per_weight_unit: str | None = None # 'kg','lb','100 g','100 ml','l' when the displayed price is per weight/volume | |
| 57 | + multibuy_n: int | None = None # 'n / x $' | |
| 58 | + multibuy_total: float | None = None | |
| 59 | + | |
| 60 | +def parse_price_label(label: str | None) -> PriceLabel: | |
| 61 | + pl = PriceLabel() | |
| 62 | + if not label: | |
| 63 | + return pl | |
| 64 | + t = strip_accents(label).lower().replace(" ", " ") | |
| 65 | + m = _PERKG_RE.search(t) | |
| 66 | + if m: | |
| 67 | + pl.per_weight_unit = re.sub(r"\s+", " ", m.group(1)) | |
| 68 | + m = _MULTI_RE.match(t) | |
| 69 | + if m: | |
| 70 | + pl.multibuy_n = int(m.group(1)) | |
| 71 | + if m.group(2): | |
| 72 | + pl.multibuy_total = float(m.group(2).replace(",", ".")) | |
| 73 | + return pl | |
| 74 | + | |
| 75 | +def unit_price(price: float | None, size: Size | None, label: PriceLabel | None = None) -> tuple[float, str] | None: | |
| 76 | + """Comparable price: $/100 g, $/100 ml or $/unit. Returns None when not computable.""" | |
| 77 | + if price is None or price <= 0: | |
| 78 | + return None | |
| 79 | + if label and label.per_weight_unit: | |
| 80 | + u = label.per_weight_unit | |
| 81 | + if u == "kg": return (round(price / 10.0, 6), "100 g") | |
| 82 | + if u == "lb": return (round(price / 4.53592, 6), "100 g") | |
| 83 | + if u == "100 g": return (price, "100 g") | |
| 84 | + if u == "100 ml": return (price, "100 ml") | |
| 85 | + if u == "l": return (round(price / 10.0, 6), "100 ml") | |
| 86 | + if size is None: | |
| 87 | + return None | |
| 88 | + if size.base == "un": | |
| 89 | + return (round(price / size.qty, 6), "un") | |
| 90 | + return (round(price / size.qty * 100.0, 6), f"100 {size.base}") | |
added
src/common.py
+59 −0
@@ -0,0 +1,59 @@ | ||
| 1 | +"""Shared helpers: settings, paths, logging, deterministic seeds.""" | |
| 2 | +from __future__ import annotations | |
| 3 | +import hashlib, json, logging, os, random, sys, time | |
| 4 | +from pathlib import Path | |
| 5 | +import numpy as np | |
| 6 | +import yaml | |
| 7 | + | |
| 8 | +ROOT = Path(__file__).resolve().parents[1] | |
| 9 | +SETTINGS = yaml.safe_load((ROOT / "config" / "settings.yaml").read_text()) | |
| 10 | +SEED = int(SETTINGS["seed"]) | |
| 11 | +TZ = SETTINGS.get("timezone_display", "America/Toronto") | |
| 12 | + | |
| 13 | +def path(key: str) -> Path: | |
| 14 | + p = ROOT / SETTINGS["paths"][key] | |
| 15 | + p.mkdir(parents=True, exist_ok=True) | |
| 16 | + return p | |
| 17 | + | |
| 18 | +def snapshot_db() -> Path: | |
| 19 | + return ROOT / SETTINGS["snapshot"]["path"] | |
| 20 | + | |
| 21 | +def verify_snapshot() -> str: | |
| 22 | + h = hashlib.sha256() | |
| 23 | + with open(snapshot_db(), "rb") as f: | |
| 24 | + for chunk in iter(lambda: f.read(1 << 20), b""): | |
| 25 | + h.update(chunk) | |
| 26 | + digest = h.hexdigest() | |
| 27 | + if digest != SETTINGS["snapshot"]["sha256"]: | |
| 28 | + raise SystemExit(f"snapshot sha256 mismatch: {digest}") | |
| 29 | + return digest | |
| 30 | + | |
| 31 | +def seed_everything(seed: int = SEED) -> None: | |
| 32 | + random.seed(seed); np.random.seed(seed) | |
| 33 | + os.environ["PYTHONHASHSEED"] = str(seed) | |
| 34 | + | |
| 35 | +def get_logger(name: str) -> logging.Logger: | |
| 36 | + log = logging.getLogger(name) | |
| 37 | + if not log.handlers: | |
| 38 | + fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s", "%Y-%m-%dT%H:%M:%S") | |
| 39 | + sh = logging.StreamHandler(sys.stdout); sh.setFormatter(fmt); log.addHandler(sh) | |
| 40 | + fh = logging.FileHandler(path("logs") / "pipeline.log"); fh.setFormatter(fmt); log.addHandler(fh) | |
| 41 | + log.setLevel(logging.INFO) | |
| 42 | + return log | |
| 43 | + | |
| 44 | +def ledger(phase: str, action: str, result: str) -> None: | |
| 45 | + """Append a machine-readable line to the structured run ledger.""" | |
| 46 | + rec = {"utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "phase": phase, "action": action, "result": result} | |
| 47 | + with open(path("logs") / "run_ledger.jsonl", "a") as f: | |
| 48 | + f.write(json.dumps(rec, ensure_ascii=False) + "\n") | |
| 49 | + | |
| 50 | +def write_manifest(name: str, files: list[Path], extra: dict | None = None) -> None: | |
| 51 | + man = {"name": name, "utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "seed": SEED, | |
| 52 | + "snapshot": SETTINGS["snapshot"]["id"], "files": {}} | |
| 53 | + for f in files: | |
| 54 | + f = Path(f) | |
| 55 | + if f.exists(): | |
| 56 | + man["files"][str(f.relative_to(ROOT))] = {"bytes": f.stat().st_size, | |
| 57 | + "sha256": hashlib.sha256(f.read_bytes()).hexdigest() if f.stat().st_size < 400_000_000 else None} | |
| 58 | + if extra: man.update(extra) | |
| 59 | + (path("logs") / f"manifest_{name}.json").write_text(json.dumps(man, indent=2, ensure_ascii=False)) | |
added
src/ingestion/__init__.py
+0 −0
added
src/ingestion/load_snapshot.py
+29 −0
@@ -0,0 +1,29 @@ | ||
| 1 | +"""Phase 1.1 — Load the authorised Food-Ka SQLite snapshot (read-only) into Parquet tables. | |
| 2 | + | |
| 3 | +Nothing is modified in the snapshot. Output: data/interim/raw_{products,price_log,sync_log,product_links,off_cache}.parquet | |
| 4 | +plus sources.parquet from the JSON registry. | |
| 5 | +""" | |
| 6 | +from __future__ import annotations | |
| 7 | +import json, sqlite3 | |
| 8 | +import pandas as pd | |
| 9 | +from src.common import ROOT, SETTINGS, path, verify_snapshot, snapshot_db, get_logger, ledger, write_manifest | |
| 10 | + | |
| 11 | +log = get_logger("ingest") | |
| 12 | + | |
| 13 | +def main() -> None: | |
| 14 | + digest = verify_snapshot() | |
| 15 | + log.info("snapshot verified sha256=%s", digest[:16]) | |
| 16 | + con = sqlite3.connect(f"file:{snapshot_db()}?mode=ro", uri=True) | |
| 17 | + out = path("interim"); files = [] | |
| 18 | + for t in ["products", "price_log", "sync_log", "product_links", "off_cache"]: | |
| 19 | + df = pd.read_sql_query(f"SELECT * FROM {t}", con) | |
| 20 | + f = out / f"raw_{t}.parquet"; df.to_parquet(f, index=False); files.append(f) | |
| 21 | + log.info("%s: %d rows x %d cols", t, *df.shape) | |
| 22 | + reg = json.loads((ROOT / SETTINGS["snapshot"]["sources_registry"]).read_text())["sources"] | |
| 23 | + src = pd.DataFrame(reg); f = out / "sources.parquet"; src.to_parquet(f, index=False); files.append(f) | |
| 24 | + log.info("sources registry: %d entries (%s)", len(src), src["status"].value_counts().to_dict()) | |
| 25 | + write_manifest("ingest", files, {"snapshot_sha256": digest}) | |
| 26 | + ledger("1", "load_snapshot", f"{len(files)} parquet files written to data/interim") | |
| 27 | + | |
| 28 | +if __name__ == "__main__": | |
| 29 | + main() | |
added
src/ingestion/statcan.py
+54 −0
@@ -0,0 +1,54 @@ | ||
| 1 | +"""Download Statistics Canada benchmarks via the WDS API (public, no key). | |
| 2 | + | |
| 3 | +- 18-10-0004-01 CPI monthly NSA: Quebec (geo 11), Montréal (13), Canada (2) × food aggregates (members 3,4,5,21,22,28,29,30,34,39,49,58) | |
| 4 | +- 18-10-0245-01 Monthly average retail prices for selected products: Quebec, all 110 products | |
| 5 | +Raw JSON kept in data/raw/statcan/ with download timestamp; tidy CSVs in data/processed/. | |
| 6 | +""" | |
| 7 | +from __future__ import annotations | |
| 8 | +import json, time, requests, pandas as pd | |
| 9 | +from src.common import ROOT, path, get_logger, ledger | |
| 10 | + | |
| 11 | +log = get_logger("statcan") | |
| 12 | +WDS = "https://www150.statcan.gc.ca/t1/wds/rest/" | |
| 13 | +RAW = ROOT / "data" / "raw" / "statcan"; RAW.mkdir(parents=True, exist_ok=True) | |
| 14 | + | |
| 15 | +def coords(product_id: int, pairs: list[tuple[int, int]], periods: int) -> pd.DataFrame: | |
| 16 | + body = [{"productId": product_id, "coordinate": f"{g}.{p}.0.0.0.0.0.0.0.0", "latestN": periods} for g, p in pairs] | |
| 17 | + r = requests.post(WDS + "getDataFromCubePidCoordAndLatestNPeriods", json=body, timeout=120); r.raise_for_status() | |
| 18 | + (RAW / f"{product_id}_{int(time.time())}.json").write_text(r.text) | |
| 19 | + rows = [] | |
| 20 | + for item in r.json(): | |
| 21 | + if item["status"] != "SUCCESS": | |
| 22 | + log.warning("WDS item failed: %s", item); continue | |
| 23 | + o = item["object"] | |
| 24 | + for dp in o["vectorDataPoint"]: | |
| 25 | + rows.append(dict(productId=product_id, coordinate=o["coordinate"], vectorId=o["vectorId"], | |
| 26 | + ref_date=dp["refPer"], value=dp["value"], scalar=dp["scalarFactorCode"], status=dp.get("statusCode"))) | |
| 27 | + return pd.DataFrame(rows) | |
| 28 | + | |
| 29 | +def main() -> None: | |
| 30 | + meta = {} | |
| 31 | + for pid in (18100004, 18100245): | |
| 32 | + r = requests.post(WDS + "getCubeMetadata", json=[{"productId": pid}], timeout=120); r.raise_for_status() | |
| 33 | + meta[pid] = r.json()[0]["object"]; (RAW / f"meta_{pid}.json").write_text(r.text) | |
| 34 | + # CPI | |
| 35 | + m = meta[18100004]; geo = {x["memberId"]: x["memberNameEn"] for x in m["dimension"][0]["member"]} | |
| 36 | + prodn = {x["memberId"]: x["memberNameEn"] for x in m["dimension"][1]["member"]} | |
| 37 | + food_members = [2, 3, 4, 5, 16, 21, 22, 28, 29, 30, 34, 39, 49, 58] | |
| 38 | + cpi = coords(18100004, [(g, p) for g in (2, 11, 13) for p in food_members], periods=60) | |
| 39 | + cpi["geo"] = cpi.coordinate.str.split(".").str[0].astype(int).map(geo) | |
| 40 | + cpi["product"] = cpi.coordinate.str.split(".").str[1].astype(int).map(prodn) | |
| 41 | + cpi.to_csv(path("processed") / "statcan_cpi_food.csv", index=False) | |
| 42 | + log.info("CPI: %d points, %s → %s, latest release %s", len(cpi), cpi.ref_date.min(), cpi.ref_date.max(), m.get("releaseTime")) | |
| 43 | + # Average retail prices | |
| 44 | + m2 = meta[18100245]; geo2 = {x["memberId"]: x["memberNameEn"] for x in m2["dimension"][0]["member"]} | |
| 45 | + prod2 = {x["memberId"]: x["memberNameEn"] for x in m2["dimension"][1]["member"]} | |
| 46 | + qc = [k for k, v in geo2.items() if v == "Quebec"][0] | |
| 47 | + arp = coords(18100245, [(qc, p) for p in prod2], periods=36) | |
| 48 | + arp["geo"] = "Quebec"; arp["product"] = arp.coordinate.str.split(".").str[1].astype(int).map(prod2) | |
| 49 | + arp.to_csv(path("processed") / "statcan_avg_retail_prices_qc.csv", index=False) | |
| 50 | + log.info("Average retail prices QC: %d points, %d products, %s → %s", len(arp), arp["product"].nunique(), arp.ref_date.min(), arp.ref_date.max()) | |
| 51 | + ledger("1", "statcan", f"CPI {len(cpi)} pts to {cpi.ref_date.max()}; ARP {len(arp)} pts to {arp.ref_date.max()}") | |
| 52 | + | |
| 53 | +if __name__ == "__main__": | |
| 54 | + main() | |
added
tests/test_panel.py
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +"""Invariants of the reconstructed panel against the raw event log.""" | |
| 2 | +import duckdb, pandas as pd, numpy as np, pytest | |
| 3 | +from src.common import ROOT | |
| 4 | + | |
| 5 | +P = ROOT / "data" / "processed"; I = ROOT / "data" / "interim" | |
| 6 | + | |
| 7 | +@pytest.fixture(scope="module") | |
| 8 | +def d(): | |
| 9 | + return pd.read_parquet(P / "offers_daily.parquet") | |
| 10 | + | |
| 11 | +def test_daily_bounds(d): | |
| 12 | + assert (d.hours_obs <= 24.0 + 1e-6).all() | |
| 13 | + assert (d.p_min <= d.p_mode + 1e-9).all() and (d.p_mode <= d.p_max + 1e-9).all() | |
| 14 | + assert (d.p_min <= d.p_twmean + 1e-9).all() and (d.p_twmean <= d.p_max + 1e-9).all() | |
| 15 | + assert (d.n_distinct >= 1).all() and (d.mode_share <= 1.0 + 1e-9).all() | |
| 16 | + | |
| 17 | +def test_total_hours_equal_span(d): | |
| 18 | + prod = pd.read_parquet(I / "raw_products.parquet", columns=["uid", "first_seen", "last_seen"]) | |
| 19 | + pl = pd.read_parquet(I / "raw_price_log.parquet") | |
| 20 | + # products with >=1 priced event: covered hours == span from first event to last_seen (NULL segments excluded) | |
| 21 | + tot = d.groupby("uid").hours_obs.sum() | |
| 22 | + first_ev = pl.groupby("uid").ts.min() | |
| 23 | + span = ((prod.set_index("uid").last_seen - first_ev) / 3600.0).dropna() | |
| 24 | + common = tot.index.intersection(span.index) | |
| 25 | + diff = (tot.loc[common] - span.loc[common]).abs() | |
| 26 | + assert diff.quantile(0.99) < 1e-3, diff.describe() | |
| 27 | + | |
| 28 | +def test_known_sequence(d): | |
| 29 | + # metro:068400662600 (mayonnaise): raw events on 2026-08-13 local = 7.49 carried, then 5.77 at 07h UTC... bounds must hold | |
| 30 | + x = d[d.uid == "metro:068400662600"].sort_values("day") | |
| 31 | + assert x.day.min() == pd.Timestamp("2026-08-12").date() | |
| 32 | + assert set(x.loc[x.day == pd.Timestamp("2026-08-13").date(), "p_min"]) == {5.77} | |
| 33 | + assert (x.hours_obs.iloc[1:-1] == 24.0).all() | |
| 34 | + | |
| 35 | +def test_single_price_products_have_one_distinct(d): | |
| 36 | + pl = pd.read_parquet(I / "raw_price_log.parquet") | |
| 37 | + one = pl.groupby("uid").size(); one = one[one == 1].index | |
| 38 | + sub = d[d.uid.isin(one)] | |
| 39 | + assert (sub.n_distinct == 1).all() and (sub.n_changes == 0).all() | |
added
tests/test_units.py
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +from src.cleaning.units import parse_size, parse_price_label, unit_price | |
| 2 | + | |
| 3 | +def test_sizes(): | |
| 4 | + assert parse_size("450 g").qty == 450 and parse_size("450 g").base == "g" | |
| 5 | + assert parse_size("2 L").qty == 2000 and parse_size("2 L").base == "ml" | |
| 6 | + assert parse_size("12 x 355 ml").qty == 12 * 355 | |
| 7 | + assert parse_size("1,36 kg").qty == 1360 | |
| 8 | + assert parse_size("6 un").base == "un" and parse_size("6 un").qty == 6 | |
| 9 | + assert parse_size("Vendu individuellement").base == "un" | |
| 10 | + s = parse_size("Une barquette contient en moyenne 1250 g"); assert s.qty == 1250 and s.approx | |
| 11 | + s = parse_size("", "Lait 2% 2 L"); assert s.qty == 2000 and s.source == "name" | |
| 12 | + s = parse_size("1,2 - 1,5 kg"); assert abs(s.qty - 1350) < 1e-6 and s.approx | |
| 13 | + assert parse_size("") is None and parse_size(None, None) is None | |
| 14 | + | |
| 15 | +def test_labels_and_unit_price(): | |
| 16 | + pl = parse_price_label("6,57 $ / kg"); assert pl.per_weight_unit == "kg" | |
| 17 | + assert unit_price(6.57, None, pl) == (0.657, "100 g") | |
| 18 | + pl = parse_price_label("2 / 5,00 $"); assert pl.multibuy_n == 2 and pl.multibuy_total == 5.0 | |
| 19 | + assert unit_price(4.99, parse_size("500 g")) == (0.998, "100 g") | |
| 20 | + assert unit_price(3.0, parse_size("6 un")) == (0.5, "un") | |
| 21 | + assert unit_price(None, parse_size("500 g")) is None | |
| 22 | + assert unit_price(4.99, None) is None | |
| 23 | ||