diff --git a/jafgen/cli.py b/jafgen/cli.py index 091b508..44b9a25 100644 --- a/jafgen/cli.py +++ b/jafgen/cli.py @@ -1,4 +1,5 @@ from typing import Annotated + import typer from jafgen.simulation import Simulation @@ -15,7 +16,7 @@ def run( str, typer.Option(help="Optional prefix for the output file names."), ] = "raw", -): +) -> None: sim = Simulation(years, pre) sim.run_simulation() sim.save_results() diff --git a/jafgen/curves.py b/jafgen/curves.py index cc5eb0e..f4e24e0 100644 --- a/jafgen/curves.py +++ b/jafgen/curves.py @@ -1,24 +1,30 @@ import datetime from abc import ABC, abstractmethod + import numpy as np +import numpy.typing as npt +from typing_extensions import override + +NumberArr = npt.NDArray[np.float64] | npt.NDArray[np.int32] + class Curve(ABC): @property @abstractmethod - def Domain(self): + def Domain(self) -> NumberArr: raise NotImplementedError @abstractmethod - def TranslateDomain(self, date): + def TranslateDomain(self, date: datetime.date) -> int: raise NotImplementedError @abstractmethod - def Expr(self, x): + def Expr(self, x: float) -> float: raise NotImplementedError @classmethod - def eval(cls, date): + def eval(cls, date: datetime.date) -> float: instance = cls() domain_value = instance.TranslateDomain(date) domain_index = domain_value % len(instance.Domain) @@ -28,25 +34,28 @@ def eval(cls, date): class AnnualCurve(Curve): @property - def Domain(self): - return np.linspace(0, 2 * np.pi, 365) + @override + def Domain(self) -> NumberArr: + return np.linspace(0, 2 * np.pi, 365, dtype=np.float64) - def TranslateDomain(self, date): + @override + def TranslateDomain(self, date: datetime.date) -> int: return date.timetuple().tm_yday - def Expr(self, x): + @override + def Expr(self, x: float) -> float: return (np.cos(x) + 1) / 10 + 0.8 class WeekendCurve(Curve): @property - def Domain(self): - return tuple(range(6)) + def Domain(self) -> NumberArr: + return np.array(range(6), dtype=np.float64) - def TranslateDomain(self, date): + def TranslateDomain(self, date: datetime.date) -> int: return date.weekday() - 1 - def Expr(self, x): + def Expr(self, x: float): if x >= 6: return 0.6 else: @@ -55,65 +64,13 @@ def Expr(self, x): class GrowthCurve(Curve): @property - def Domain(self): - return tuple(range(500)) + def Domain(self) -> NumberArr: + return np.arange(500, dtype=np.int32) - def TranslateDomain(self, date): + def TranslateDomain(self, date: datetime.date) -> int: return (date.year - 2016) * 12 + date.month - def Expr(self, x): + def Expr(self, x: float) -> float: # ~ aim for ~20% growth/year return 1 + (x / 12) * 0.2 - -class Day(object): - EPOCH = datetime.datetime(year=2018, month=9, day=1) - SEASONAL_MONTHLY_CURVE = AnnualCurve() - WEEKEND_CURVE = WeekendCurve() - GROWTH_CURVE = GrowthCurve() - - def __init__(self, date_index, minutes=0): - self.date_index = date_index - self.date = self.EPOCH + datetime.timedelta(days=date_index, minutes=minutes) - self.effects = [ - self.SEASONAL_MONTHLY_CURVE.eval(self.date), - self.WEEKEND_CURVE.eval(self.date), - self.GROWTH_CURVE.eval(self.date), - ] - - def at_minute(self, minutes): - return Day(self.date_index, minutes=minutes) - - def get_effect(self): - total = 1 - for effect in self.effects: - total = total * effect - return total - - # weekend_effect = 0.8 if date.is_weekend else 1 - # summer_effect = 0.7 if date.season == 'summer' else 1 - - @property - def day_of_week(self): - return self.date.weekday() - - @property - def is_weekend(self): - # 5 + 6 are weekends - return self.date.weekday() >= 5 - - @property - def season(self): - month_no = self.date.month - day_no = self.date.day - - if month_no in (1, 2) or (month_no == 3 and day_no < 21): - return "winter" - elif month_no in (3, 4, 5) or (month_no == 6 and day_no < 21): - return "spring" - elif month_no in (6, 7, 8) or (month_no == 9 and day_no < 21): - return "summer" - elif month_no in (9, 10, 11) or (month_no == 12 and day_no < 21): - return "fall" - else: - return "winter" diff --git a/jafgen/customers/customers.py b/jafgen/customers/customers.py index 9d79c04..9328756 100644 --- a/jafgen/customers/customers.py +++ b/jafgen/customers/customers.py @@ -1,8 +1,7 @@ -import random -import datetime import uuid from abc import ABC, abstractmethod -from typing import Any +from dataclasses import dataclass, field +from typing import Any, NewType import numpy as np from faker import Faker @@ -10,64 +9,76 @@ from jafgen.customers.order import Order from jafgen.customers.tweet import Tweet from jafgen.stores.inventory import Inventory +from jafgen.stores.item import Item, ItemType +from jafgen.stores.store import Store +from jafgen.time import Day, Season fake = Faker() -Faker.seed(123456789) +CustomerId = NewType("CustomerId", uuid.UUID) +@dataclass(frozen=True) class Customer(ABC): - def __init__(self, store): - self.customer_id = str(uuid.uuid4()) - self.store = store - self.name = fake.name() - self.favorite_number = int(np.random.rand() * 100) - self.fan_level = random.randint(1, 5) - - def p_buy_season(self, day): + store: Store + id: CustomerId = field(default_factory=lambda: CustomerId(fake.uuid4())) + name: str = field(default_factory=fake.name) + favorite_number: int = field(default_factory=lambda: fake.random.randint(1, 100)) + fan_level: int = field(default_factory=lambda: fake.random.randint(1, 5)) + + def p_buy_season(self, day: Day): return self.store.p_buy(day) - def p_buy(self, day): + def p_buy(self, day: Day) -> float: p_buy_season = self.p_buy_season(day) p_buy_persona = self.p_buy_persona(day) p_buy_on_day = (p_buy_season * p_buy_persona) ** 0.5 return p_buy_on_day - def p_tweet(self, day): + @abstractmethod + def p_buy_persona(self, day: Day) -> float: + raise NotImplementedError() + + @abstractmethod + def p_tweet_persona(self, day: Day) -> float: + raise NotImplementedError() + + def p_tweet(self, day: Day) -> float: return self.p_tweet_persona(day) - def get_order(self, day): + def get_order(self, day: Day) -> Order | None: items = self.get_order_items(day) - order_time_delta = self.get_order_time(day) - order_time = day.at_minute(order_time_delta + self.store.opens_at(day)) - if not self.store.is_open_at(order_time): - return None + order_minute = self.get_order_minute(day) + order_day = day.at_minute(order_minute) - return Order(self, items, self.store, order_time) + if not self.store.is_open_at(order_day): + return None - def get_tweet(self, order): - tweet_time = order.order_time.date + datetime.timedelta( - minutes=(np.random.rand() * 20) + return Order( + customer=self, + items=items, + store=self.store, + day=order_day ) - return Tweet(self, tweet_time, order) - @abstractmethod - def get_order_items(self, day): - raise NotImplementedError() - - @abstractmethod - def get_order_time(self, day): - raise NotImplementedError() + def get_tweet(self, order: Order) -> Tweet: + minutes_delta = int(fake.random.random() * 20) + tweet_day = order.day.at_minute(order.day.total_minutes + minutes_delta) + return Tweet( + customer=self, + order=order, + day=tweet_day + ) @abstractmethod - def p_buy_persona(self, day): + def get_order_items(self, day: Day) -> list[Item]: raise NotImplementedError() @abstractmethod - def p_tweet_persona(self, day): + def get_order_minute(self, day: Day) -> int: raise NotImplementedError() - def sim_day(self, day): + def sim_day(self, day: Day): p_buy = self.p_buy(day) p_buy_threshold = np.random.random() p_tweet = self.p_tweet(day) @@ -86,147 +97,146 @@ def sim_day(self, day): def to_dict(self) -> dict[str, Any]: return { - "id": str(self.customer_id), + "id": str(self.id), "name": str(self.name), } class RemoteWorker(Customer): - "This person works from a coffee shop" + """This person works from a coffee shop""" - def p_buy_persona(self, day): + def p_buy_persona(self, day: Day): buy_propensity = (self.favorite_number / 100) * 0.4 return 0.001 if day.is_weekend else buy_propensity - def p_tweet_persona(self, day): + def p_tweet_persona(self, day: Day): return 0.01 - def get_order_time(self, day): + def get_order_minute(self, day: Day) -> int: # most likely to order in the morning # exponentially less likely to order in the afternoon avg_time = 60 * 7 order_time = np.random.normal(loc=avg_time, scale=180) return max(0, int(order_time)) - def get_order_items(self, day): + def get_order_items(self, day: Day): num_drinks = 1 food = [] - if random.random() > 0.7: + if fake.random.random() > 0.7: num_drinks = 2 - if random.random() > 0.7: - food = Inventory.get_food(1) + if fake.random.random() > 0.7: + food = Inventory.get_item_type(ItemType.JAFFLE, 1) - return Inventory.get_drink(num_drinks) + food + return Inventory.get_item_type(ItemType.BEVERAGE, num_drinks) + food class BrunchCrowd(Customer): - "Do you sell mimosas?" + """Do you sell mimosas?""" - def p_buy_persona(self, day): + def p_buy_persona(self, day: Day): buy_propensity = 0.2 + (self.favorite_number / 100) * 0.2 return buy_propensity if day.is_weekend else 0 - def p_tweet_persona(self, day): + def p_tweet_persona(self, day: Day): return 0.8 - def get_order_time(self, day): + def get_order_minute(self, day: Day) -> int: # most likely to order in the early afternoon avg_time = 300 + ((self.favorite_number - 50) / 50) * 120 order_time = np.random.normal(loc=avg_time, scale=120) return max(0, int(order_time)) - def get_order_items(self, day): + def get_order_items(self, day: Day): num_customers = 1 + int(self.favorite_number / 20) - return Inventory.get_drink(num_customers) + Inventory.get_food(num_customers) + return Inventory.get_item_type(ItemType.JAFFLE, num_customers) + Inventory.get_item_type(ItemType.BEVERAGE, num_customers) class Commuter(Customer): - "the regular, thanks" + """the regular, thanks""" - def p_buy_persona(self, day): + def p_buy_persona(self, day: Day): buy_propensity = 0.5 + (self.favorite_number / 100) * 0.3 return 0.001 if day.is_weekend else buy_propensity - def p_tweet_persona(self, day): + def p_tweet_persona(self, day: Day): return 0.2 - def get_order_time(self, day): + def get_order_minute(self, day: Day) -> int: # most likely to order in the morning # exponentially less likely to order in the afternoon avg_time = 60 order_time = np.random.normal(loc=avg_time, scale=30) return max(0, int(order_time)) - def get_order_items(self, day): - return Inventory.get_drink(1) + def get_order_items(self, day: Day): + return Inventory.get_item_type(ItemType.BEVERAGE, 1) class Student(Customer): - "coffee might help" + """coffee might help""" - def p_buy_persona(self, day): - if day.season == "summer": + def p_buy_persona(self, day: Day): + if day.season == Season.SUMMER: return 0 - else: - buy_propensity = 0.1 + (self.favorite_number / 100) * 0.4 - return buy_propensity - def p_tweet_persona(self, day): + buy_propensity = 0.1 + (self.favorite_number / 100) * 0.4 + return buy_propensity + + def p_tweet_persona(self, day: Day): return 0.8 - def get_order_time(self, day): + def get_order_minute(self, day: Day) -> int: # later is better avg_time = 9 * 60 order_time = np.random.normal(loc=avg_time, scale=120) return max(0, int(order_time)) - def get_order_items(self, day): + def get_order_items(self, day: Day): food = [] - if random.random() > 0.5: - food = Inventory.get_food(1) + if fake.random.random() > 0.5: + food = Inventory.get_item_type(ItemType.JAFFLE, 1) - return Inventory.get_drink(1) + food + return Inventory.get_item_type(ItemType.BEVERAGE, 1) + food class Casuals(Customer): - "just popping in" + """just popping in""" - def p_buy_persona(self, day): + def p_buy_persona(self, day: Day): return 0.1 - def p_tweet_persona(self, day): + def p_tweet_persona(self, day: Day): return 0.1 - def get_order_time(self, day): + def get_order_minute(self, day: Day) -> int: avg_time = 5 * 60 order_time = np.random.normal(loc=avg_time, scale=120) return max(0, int(order_time)) - def get_order_items(self, day): - num_drinks = int(random.random() * 10 / 3) - num_food = int(random.random() * 10 / 3) - return Inventory.get_drink(num_drinks) + Inventory.get_food(num_food) + def get_order_items(self, day: Day): + num_drinks = int(fake.random.random() * 10 / 3) + num_food = int(fake.random.random() * 10 / 3) + return Inventory.get_item_type(ItemType.BEVERAGE, num_drinks) + Inventory.get_item_type(ItemType.JAFFLE, num_food) class HealthNut(Customer): - "A light beverage in the sunshine as a treat" + """A light beverage in the sunshine as a treat""" - def p_buy_persona(self, day): - if day.season == "summer": + def p_buy_persona(self, day: Day): + if day.season == Season.SUMMER: buy_propensity = 0.1 + (self.favorite_number / 100) * 0.4 return buy_propensity - else: - return 0.2 + return 0.2 - def p_tweet_persona(self, day): + def p_tweet_persona(self, day: Day): return 0.6 - def get_order_time(self, day): + def get_order_minute(self, day: Day) -> int: avg_time = 5 * 60 order_time = np.random.normal(loc=avg_time, scale=120) return max(0, int(order_time)) - def get_order_items(self, day): - return Inventory.get_drink(1) + def get_order_items(self, day: Day): + return Inventory.get_item_type(ItemType.BEVERAGE, 1) diff --git a/jafgen/customers/order.py b/jafgen/customers/order.py index 2adef14..5d59e87 100644 --- a/jafgen/customers/order.py +++ b/jafgen/customers/order.py @@ -1,29 +1,44 @@ import uuid -from typing import Any +from dataclasses import dataclass, field +from typing import Any, NewType -from jafgen.customers.order_item import OrderItem +from faker import Faker +import jafgen.customers.customers as customer +from jafgen.stores.item import Item +from jafgen.stores.store import Store +from jafgen.time import Day -class Order(object): - def __init__(self, customer, items, store, order_time): - self.order_id = str(uuid.uuid4()) - self.customer = customer - self.items = [OrderItem(self.order_id, item) for item in items] - self.store = store - self.order_time = order_time - self.subtotal = sum(i.item.price for i in self.items) - self.tax_paid = store.tax_rate * self.subtotal - self.order_total = self.subtotal + self.tax_paid +fake = Faker() + +OrderId = NewType("OrderId", uuid.UUID) + +@dataclass +class Order: + customer: "customer.Customer" + day: Day + store: Store + items: list[Item] + id: OrderId = field(default_factory=lambda: OrderId(fake.uuid4())) + + subtotal: float = field(init=False) + tax_paid: float = field(init=False) + total: float = field(init=False) + + def __post_init__(self) -> None: + self.subtotal = sum(i.price for i in self.items) + self.tax_paid = self.store.tax_rate * self.subtotal + self.total = self.subtotal + self.tax_paid def __str__(self): - return f"{self.customer.name} bought {str(self.items)} at {self.order_time}" + return f"{self.customer.name} bought {str(self.items)} at {self.day}" def to_dict(self) -> dict[str, Any]: return { - "id": str(self.order_id), - "customer": str(self.customer.customer_id), - "ordered_at": str(self.order_time.date.isoformat()), - "store_id": str(self.store.store_id), + "id": str(self.id), + "customer": str(self.customer.id), + "ordered_at": str(self.day.date.isoformat()), + "store_id": str(self.store.id), "subtotal": int(self.subtotal * 100), "tax_paid": int(self.tax_paid * 100), # TODO: figure out why this is doesn't cause a test failure diff --git a/jafgen/customers/order_item.py b/jafgen/customers/order_item.py deleted file mode 100644 index 6b7f1eb..0000000 --- a/jafgen/customers/order_item.py +++ /dev/null @@ -1,16 +0,0 @@ -import uuid -from typing import Any - - -class OrderItem(object): - def __init__(self, order_id, item): - self.item_id = str(uuid.uuid4()) - self.order_id = order_id - self.item = item - - def to_dict(self) -> dict[str, Any]: - return { - "id": self.item_id, - "order_id": self.order_id, - "sku": self.item.sku, - } diff --git a/jafgen/customers/tweet.py b/jafgen/customers/tweet.py index 6b7c483..d4565c0 100644 --- a/jafgen/customers/tweet.py +++ b/jafgen/customers/tweet.py @@ -1,32 +1,46 @@ -import random import uuid +from dataclasses import dataclass, field +from typing import NewType +from faker import Faker +import jafgen.customers.customers as customer +from jafgen.customers.order import Order +from jafgen.time import Day + +fake = Faker() + +TweetId = NewType("TweetId", uuid.UUID) + + +@dataclass class Tweet: - def __init__(self, customer, tweet_time, order) -> None: - self.uuid = str(uuid.uuid4()) - self.tweeted_at = tweet_time - self.customer = customer - self.order = order - self.content = self.construct_tweet() + day: Day + customer: "customer.Customer" + order: Order + id: TweetId = field(default_factory=lambda: TweetId(fake.uuid4())) + content: str = field(init=False) + + def __post_init__(self) -> None: + self.content = self._construct_tweet() def to_dict(self) -> dict[str, str]: return { - "id": self.uuid, - "user_id": self.customer.customer_id, - "tweeted_at": str(self.tweeted_at.isoformat()), + "id": str(self.id), + "user_id": str(self.customer.id), + "tweeted_at": str(self.day.date.isoformat()), "content": self.content, } - def construct_tweet(self) -> str: + def _construct_tweet(self) -> str: if len(self.order.items) == 1: - items_sentence = f"Ordered a {self.order.items[0].item.name}" + items_sentence = f"Ordered a {self.order.items[0].name}" elif len(self.order.items) == 2: - items_sentence = f"Ordered a {self.order.items[0].item.name} and a {self.order.items[1].item.name}" + items_sentence = f"Ordered a {self.order.items[0].name} and a {self.order.items[1].name}" else: - items_sentence = f"Ordered a {', a '.join(item.item.name for item in self.order.items[:-1])}, and a {self.order.items[-1].item.name}" + items_sentence = f"Ordered a {', a '.join(item.name for item in self.order.items[:-1])}, and a {self.order.items[-1].name}" if self.customer.fan_level > 3: - adjective = random.choice( + adjective = fake.random.choice( [ "the best", "awesome", @@ -39,7 +53,7 @@ def construct_tweet(self) -> str: ) return f"Jaffles from the Jaffle Shop are {adjective}! {items_sentence}." elif self.customer.fan_level < 3: - adjective = random.choice( + adjective = fake.random.choice( [ "terrible", "the worst", @@ -52,7 +66,7 @@ def construct_tweet(self) -> str: ) return f"Jaffle Shop again. {items_sentence}. This place is {adjective}." else: - adjective = random.choice( + adjective = fake.random.choice( [ "okay", "fine", diff --git a/jafgen/simulation.py b/jafgen/simulation.py index 4a5a94c..873f9b4 100644 --- a/jafgen/simulation.py +++ b/jafgen/simulation.py @@ -1,104 +1,63 @@ import csv import os -import uuid +from typing import Any from rich.progress import track -from jafgen.curves import Day +from jafgen.customers.customers import Customer, CustomerId +from jafgen.customers.order import Order +from jafgen.customers.tweet import Tweet from jafgen.stores.inventory import Inventory from jafgen.stores.market import Market from jafgen.stores.stock import Stock from jafgen.stores.store import Store - -T_7AM = 60 * 7 -T_8AM = 60 * 8 -T_3PM = 60 * 15 -T_8PM = 60 * 20 - - -class HoursOfOperation(object): - def __init__(self, weekday_range, weekend_range): - self.weekday_range = weekday_range - self.weekend_range = weekend_range - - def minutes_open(self, date): - if date.is_weekend: - return self.weekend_range[1] - self.weekend_range[0] - else: - return self.weekday_range[1] - self.weekday_range[0] - - def opens_at(self, date): - if date.is_weekend: - return self.weekend_range[0] - else: - return self.weekday_range[0] - - def closes_at(self, date): - if date.is_weekend: - return self.weekend_range[1] - else: - return self.weekday_range[1] - - def is_open(self, date): - opens_at = self.opens_at(date) - closes_at = self.closes_at(date) - - dt = date.date.hour * 60 + date.date.minute - return dt >= opens_at and dt < closes_at - - def iter_minutes(self, date): - if date.is_weekend: - start, end = self.weekend_range - else: - start, end = self.weekday_range - - for i in range(end - start): - yield start + i - - -class Simulation(object): - def __init__(self, years, prefix): +from jafgen.time import ( + Day, + DayHoursOfOperation, + WeekHoursOfOperation, + time_from_total_minutes, +) + +T_7AM = time_from_total_minutes(60 * 7) +T_8AM = time_from_total_minutes(60 * 8) +T_3PM = time_from_total_minutes(60 * 15) +T_8PM = time_from_total_minutes(60 * 20) + +class Simulation: + def __init__(self, years: int, prefix: str): self.years = years self.scale = 100 self.prefix = prefix self.stores = [ - # id | name | popularity | opened | TAM | tax - (str(uuid.uuid4()), "Philadelphia", 0.85, 0, 9 * self.scale, 0.06), - (str(uuid.uuid4()), "Brooklyn", 0.95, 192, 14 * self.scale, 0.04), - (str(uuid.uuid4()), "Chicago", 0.92, 605, 12 * self.scale, 0.0625), - (str(uuid.uuid4()), "San Francisco", 0.87, 615, 11 * self.scale, 0.075), - (str(uuid.uuid4()), "New Orleans", 0.92, 920, 8 * self.scale, 0.04), - (str(uuid.uuid4()), "Los Angeles", 0.87, 1107, 8 * self.scale, 0.08), + # name | popularity | opened | TAM | tax + ("Philadelphia", 0.85, 0, 9 * self.scale, 0.06), + ("Brooklyn", 0.95, 192, 14 * self.scale, 0.04), + ("Chicago", 0.92, 605, 12 * self.scale, 0.0625), + ("San Francisco", 0.87, 615, 11 * self.scale, 0.075), + ("New Orleans", 0.92, 920, 8 * self.scale, 0.04), + ("Los Angeles", 0.87, 1107, 8 * self.scale, 0.08), ] - self.markets = [] - for ( - store_id, - store_name, - popularity, - opened_date, - market_size, - tax, - ) in self.stores: - market = Market( + self.markets: list[Market] = [ + Market( Store( - store_id=store_id, name=store_name, base_popularity=popularity, - hours_of_operation=HoursOfOperation( - weekday_range=(T_7AM, T_8PM), - weekend_range=(T_8AM, T_3PM), + hours_of_operation=WeekHoursOfOperation( + week_days=DayHoursOfOperation(opens_at=T_7AM, closes_at=T_8PM), + weekends=DayHoursOfOperation(opens_at=T_8AM, closes_at=T_3PM), ), - opened_date=Day(opened_date), + opened_day=Day(opened_date), tax_rate=tax, ), num_customers=market_size, ) - self.markets.append(market) + for store_name, popularity, opened_date, market_size, tax in self.stores + ] - self.customers = {} - self.orders = [] - self.tweets = [] + self.customers: dict[CustomerId, Customer] = {} + self.orders: list[Order] = [] + self.tweets: list[Tweet] = [] self.sim_days = 365 * self.years def run_simulation(self): @@ -110,15 +69,15 @@ def run_simulation(self): for order, tweet in market.sim_day(day): if order: self.orders.append(order) - if order.customer.customer_id not in self.customers: - self.customers[order.customer.customer_id] = order.customer + if order.customer.id not in self.customers: + self.customers[order.customer.id] = order.customer if tweet: self.tweets.append(tweet) def save_results(self) -> None: stock: Stock = Stock() inventory: Inventory = Inventory() - entities: dict[str, list[dict]] = { + entities: dict[str, list[dict[str, Any]]] = { "customers": [customer.to_dict() for customer in self.customers.values()], "orders": [order.to_dict() for order in self.orders], "items": [item.to_dict() for order in self.orders for item in order.items], diff --git a/jafgen/stores/inventory.py b/jafgen/stores/inventory.py index 958fb06..009054f 100644 --- a/jafgen/stores/inventory.py +++ b/jafgen/stores/inventory.py @@ -1,31 +1,30 @@ -import random from typing import Any -from jafgen.stores.item import Item +from faker import Faker +from jafgen.stores.item import Item, ItemType +from jafgen.stores.supply import StorageKeepingUnit as SKU -class Inventory(object): - inventory: dict[Any, Any] = {} +fake = Faker() - @classmethod - def update(cls, inventory_list): - cls.inventory["jaffle"] = [] - cls.inventory["beverage"] = [] - for item in inventory_list: - ttype = item.type - cls.inventory[ttype].append(item) + +class Inventory: + inventory: dict[ItemType, list[Item]] = {} @classmethod - def get_food(cls, count=1): - return [random.choice(cls.inventory["jaffle"]) for i in range(count)] + def update(cls, inventory_list: list[Item]): + cls.inventory[ItemType.JAFFLE]= [] + cls.inventory[ItemType.BEVERAGE] = [] + for item in inventory_list: + cls.inventory[item.type].append(item) @classmethod - def get_drink(cls, count=1): - return [random.choice(cls.inventory["beverage"]) for i in range(count)] + def get_item_type(cls, type: ItemType, count: int = 1): + return [fake.random.choice(cls.inventory[type])for _ in range(count)] @classmethod def to_dict(cls) -> list[dict[str, Any]]: - all_items = [] + all_items: list[dict[str, Any]] = [] for key in cls.inventory: all_items += [item.to_dict() for item in cls.inventory[key]] return all_items @@ -34,73 +33,71 @@ def to_dict(cls) -> list[dict[str, Any]]: Inventory.update( [ Item( - sku="JAF-001", + sku=SKU("JAF-001"), name="nutellaphone who dis?", description="nutella and banana jaffle", - type="jaffle", + type=ItemType.JAFFLE, price=11, ), Item( - sku="JAF-002", + sku=SKU("JAF-002"), name="doctor stew", description="house-made beef stew jaffle", - type="jaffle", + type=ItemType.JAFFLE, price=11, ), Item( - sku="JAF-003", + sku=SKU("JAF-003"), name="the krautback", description="lamb and pork bratwurst with house-pickled cabbage sauerkraut and mustard", - type="jaffle", + type=ItemType.JAFFLE, price=12, ), Item( - sku="JAF-004", + sku=SKU("JAF-004"), name="flame impala", description="pulled pork and pineapple al pastor marinated in ghost pepper sauce, kevin parker's favorite! ", - type="jaffle", + type=ItemType.JAFFLE, price=14, ), Item( - sku="JAF-005", - name="mel-bun", - description="melon and minced beef bao, in a jaffle, savory and sweet", - type="jaffle", + sku=SKU("JAF-005"), name="mel-bun", description="melon and minced beef bao, in a jaffle, savory and sweet", + type=ItemType.JAFFLE, price=12, ), Item( - sku="BEV-001", + sku=SKU("BEV-001"), name="tangaroo", description="mango and tangerine smoothie", - type="beverage", + type=ItemType.BEVERAGE, price=6, ), Item( - sku="BEV-002", + sku=SKU("BEV-002"), name="chai and mighty", description="oatmilk chai latte with protein boost", - type="beverage", + type=ItemType.BEVERAGE, price=5, ), Item( - sku="BEV-003", + sku=SKU("BEV-003"), name="vanilla ice", description="iced coffee with house-made french vanilla syrup", - type="beverage", + type=ItemType.BEVERAGE, price=6, ), Item( - sku="BEV-004", + sku=SKU("BEV-004"), name="for richer or pourover ", description="daily selection of single estate beans for a delicious hot pourover", - type="beverage", + type=ItemType.BEVERAGE, price=7, ), Item( - sku="BEV-005", + sku=SKU("BEV-005"), name="adele-ade", description="a kiwi and lime agua fresca, hello from the other side of thirst", - type="beverage", + type=ItemType.BEVERAGE, price=4, ), ] diff --git a/jafgen/stores/item.py b/jafgen/stores/item.py index bd57bb4..11e0b6a 100644 --- a/jafgen/stores/item.py +++ b/jafgen/stores/item.py @@ -1,13 +1,22 @@ +from dataclasses import dataclass +from enum import Enum from typing import Any +from jafgen.stores.supply import StorageKeepingUnit -class Item(object): - def __init__(self, sku, name, description, type, price): - self.sku = sku - self.name = name - self.description = description - self.type = type - self.price = price + +class ItemType(str, Enum): + JAFFLE = "JAFFLE" + BEVERAGE = "BEVERAGE" + + +@dataclass(frozen=True) +class Item: + sku: StorageKeepingUnit + name: str + description: str + type: ItemType + price: float def __str__(self): return f"<{self.name} @ ${self.price}>" @@ -17,7 +26,7 @@ def __repr__(self): def to_dict(self) -> dict[str, Any]: return { - "sku": str(self.sku), + "sku": self.sku, "name": str(self.name), "type": str(self.type), "price": int(self.price * 100), diff --git a/jafgen/stores/market.py b/jafgen/stores/market.py index 792c5cc..44629bc 100644 --- a/jafgen/stores/market.py +++ b/jafgen/stores/market.py @@ -1,21 +1,25 @@ -import random from typing import Iterator import numpy as np +from faker import Faker from jafgen.customers.customers import ( BrunchCrowd, Casuals, Commuter, + Customer, HealthNut, RemoteWorker, Student, ) from jafgen.customers.order import Order from jafgen.customers.tweet import Tweet +from jafgen.stores.store import Store +from jafgen.time import Day +fake = Faker() -class Market(object): +class Market: PersonaMix = [ (Commuter, 0.25), (RemoteWorker, 0.25), @@ -25,23 +29,22 @@ class Market(object): (HealthNut, 0.1), ] - def __init__(self, store, num_customers, days_to_penetration=365): + def __init__(self, store: Store, num_customers: int, days_to_penetration: int = 365): self.store = store self.num_customers = num_customers self.days_to_penetration = days_to_penetration - self.addressable_customers = [] + self.addressable_customers: list[Customer] = [] + self.active_customers: list[Customer] = [] for Persona, weight in self.PersonaMix: num_customers = int(weight * self.num_customers) - for i in range(num_customers): + for _ in range(num_customers): self.addressable_customers.append(Persona(store)) - random.shuffle(self.addressable_customers) + fake.random.shuffle(self.addressable_customers) - self.active_customers = [] - - def sim_day(self, day) -> Iterator[tuple[Order | None, Tweet | None]]: + def sim_day(self, day: Day) -> Iterator[tuple[Order | None, Tweet | None]]: days_since_open = self.store.days_since_open(day) if days_since_open < 0: yield None, None diff --git a/jafgen/stores/stock.py b/jafgen/stores/stock.py index e96eb14..32193ae 100644 --- a/jafgen/stores/stock.py +++ b/jafgen/stores/stock.py @@ -1,13 +1,14 @@ from typing import Any -from jafgen.stores.supply import Supply +from jafgen.stores.supply import StorageKeepingUnit as SKU +from jafgen.stores.supply import Supply, SupplyId -class Stock(object): - stock: dict[Any, Any] = {} +class Stock: + stock: dict[SKU, list[Supply]] = {} @classmethod - def update(cls, stock_list): + def update(cls, stock_list: list[Supply]): for supply in stock_list: skus = supply.skus for sku in skus: @@ -17,164 +18,163 @@ def update(cls, stock_list): @classmethod def to_dict(cls) -> list[dict[str, Any]]: - all_items = [] + all_items: list[dict[str, Any]] = [] for key in cls.stock: all_items += [item.to_dict(key) for item in cls.stock[key]] return all_items - Stock.update( [ Supply( - id="SUP-001", + id=SupplyId("SUP-001"), name="compostable cutlery - knife", cost=0.07, perishable=False, - skus=["JAF-001", "JAF-002", "JAF-003", "JAF-004", "JAF-005"], + skus=[SKU("JAF-001"), SKU("JAF-002"), SKU("JAF-003"), SKU("JAF-004"), SKU("JAF-005")], ), Supply( - id="SUP-002", + id=SupplyId("SUP-002"), name="cutlery - fork", cost=0.07, perishable=False, - skus=["JAF-001", "JAF-002", "JAF-003", "JAF-004", "JAF-005"], + skus=[SKU("JAF-001"), SKU("JAF-002"), SKU("JAF-003"), SKU("JAF-004"), SKU("JAF-005")], ), Supply( - id="SUP-003", + id=SupplyId("SUP-003"), name="serving boat", cost=0.11, perishable=False, - skus=["JAF-001", "JAF-002", "JAF-003", "JAF-004", "JAF-005"], + skus=[SKU("JAF-001"), SKU("JAF-002"), SKU("JAF-003"), SKU("JAF-004"), SKU("JAF-005")], ), Supply( - id="SUP-004", + id=SupplyId("SUP-004"), name="napkin", cost=0.04, perishable=False, - skus=["JAF-001", "JAF-002", "JAF-003", "JAF-004", "JAF-005"], + skus=[SKU("JAF-001"), SKU("JAF-002"), SKU("JAF-003"), SKU("JAF-004"), SKU("JAF-005")], ), Supply( - id="SUP-005", + id=SupplyId("SUP-005"), name="16oz compostable clear cup", cost=0.13, perishable=False, - skus=["BEV-001", "BEV-002", "BEV-003", "BEV-004", "BEV-005"], + skus=[SKU("BEV-001"), SKU("BEV-002"), SKU("BEV-003"), SKU("BEV-004"), SKU("BEV-005")], ), Supply( - id="SUP-006", + id=SupplyId("SUP-006"), name="16oz compostable clear lid", cost=0.04, perishable=False, - skus=["BEV-001", "BEV-002", "BEV-003", "BEV-004", "BEV-005"], + skus=[SKU("BEV-001"), SKU("BEV-002"), SKU("BEV-003"), SKU("BEV-004"), SKU("BEV-005")], ), Supply( - id="SUP-007", + id=SupplyId("SUP-007"), name="biodegradable straw", cost=0.13, perishable=False, - skus=["BEV-001", "BEV-002", "BEV-003", "BEV-004", "BEV-005"], + skus=[SKU("BEV-001"), SKU("BEV-002"), SKU("BEV-003"), SKU("BEV-004"), SKU("BEV-005")], ), Supply( - id="SUP-008", name="chai mix", cost=0.98, perishable=True, skus=["BEV-002"] + id=SupplyId("SUP-008"), name="chai mix", cost=0.98, perishable=True, skus=[SKU("BEV-002")] ), Supply( - id="SUP-009", + id=SupplyId("SUP-009"), name="bread", cost=0.33, perishable=True, - skus=["JAF-001", "JAF-002", "JAF-003", "JAF-004", "JAF-005"], + skus=[SKU("JAF-001"), SKU("JAF-002"), SKU("JAF-003"), SKU("JAF-004"), SKU("JAF-005")], ), Supply( - id="SUP-010", + id=SupplyId("SUP-010"), name="cheese", cost=0.2, perishable=True, - skus=["JAF-002", "JAF-003", "JAF-004", "JAF-005"], + skus=[SKU("JAF-002"), SKU("JAF-003"), SKU("JAF-004"), SKU("JAF-005")], ), Supply( - id="SUP-011", name="nutella", cost=0.46, perishable=True, skus=["JAF-001"] + id=SupplyId("SUP-011"), name="nutella", cost=0.46, perishable=True, skus=[SKU("JAF-001")] ), Supply( - id="SUP-012", name="banana", cost=0.13, perishable=True, skus=["JAF-001"] + id=SupplyId("SUP-012"), name="banana", cost=0.13, perishable=True, skus=[SKU("JAF-001")] ), Supply( - id="SUP-013", name="beef stew", cost=1.69, perishable=True, skus=["JAF-002"] + id=SupplyId("SUP-013"), name="beef stew", cost=1.69, perishable=True, skus=[SKU("JAF-002")] ), Supply( - id="SUP-014", + id=SupplyId("SUP-014"), name="lamb and pork bratwurst", cost=2.34, perishable=True, - skus=["JAF-003"], + skus=[SKU("JAF-003")], ), Supply( - id="SUP-015", + id=SupplyId("SUP-015"), name="house-pickled cabbage sauerkraut", cost=0.43, perishable=True, - skus=["JAF-003"], + skus=[SKU("JAF-003")], ), Supply( - id="SUP-016", name="mustard", cost=0.07, perishable=True, skus=["JAF-003"] + id=SupplyId("SUP-016"), name="mustard", cost=0.07, perishable=True, skus=[SKU("JAF-003")] ), Supply( - id="SUP-017", + id=SupplyId("SUP-017"), name="pulled pork", cost=2.15, perishable=True, - skus=["JAF-004"], + skus=[SKU("JAF-004")], ), Supply( - id="SUP-018", name="pineapple", cost=0.26, perishable=True, skus=["JAF-004"] + id=SupplyId("SUP-018"), name="pineapple", cost=0.26, perishable=True, skus=[SKU("JAF-004")] ), Supply( - id="SUP-019", name="melon", cost=0.33, perishable=True, skus=["JAF-005"] + id=SupplyId("SUP-019"), name="melon", cost=0.33, perishable=True, skus=[SKU("JAF-005")] ), Supply( - id="SUP-020", + id=SupplyId("SUP-020"), name="minced beef", cost=1.24, perishable=True, - skus=["JAF-005"], + skus=[SKU("JAF-005")], ), Supply( - id="SUP-021", + id=SupplyId("SUP-021"), name="ghost pepper sauce", cost=0.2, perishable=True, - skus=["JAF-004"], + skus=[SKU("JAF-004")], ), Supply( - id="SUP-022", name="mango", cost=0.32, perishable=True, skus=["BEV-001"] + id=SupplyId("SUP-022"), name="mango", cost=0.32, perishable=True, skus=[SKU("BEV-001")] ), Supply( - id="SUP-023", name="tangerine", cost=0.2, perishable=True, skus=["BEV-001"] + id=SupplyId("SUP-023"), name="tangerine", cost=0.2, perishable=True, skus=[SKU("BEV-001")] ), Supply( - id="SUP-024", name="oatmilk", cost=0.11, perishable=True, skus=["BEV-002"] + id=SupplyId("SUP-024"), name="oatmilk", cost=0.11, perishable=True, skus=[SKU("BEV-002")] ), Supply( - id="SUP-025", + id=SupplyId("SUP-025"), name="whey protein", cost=0.36, perishable=True, - skus=["BEV-002"], + skus=[SKU("BEV-002")], ), Supply( - id="SUP-026", + id=SupplyId("SUP-026"), name="coffee", cost=0.52, perishable=True, - skus=["BEV-003", "BEV-004"], + skus=[SKU("BEV-003"), SKU("BEV-004")], ), Supply( - id="SUP-027", + id=SupplyId("SUP-027"), name="french vanilla syrup", cost=0.72, perishable=True, - skus=["BEV-003"], + skus=[SKU("BEV-003")], ), - Supply(id="SUP-028", name="kiwi", cost=0.2, perishable=True, skus=["BEV-005"]), - Supply(id="SUP-029", name="lime", cost=0.13, perishable=True, skus=["BEV-005"]), + Supply(id=SupplyId("SUP-028"), name="kiwi", cost=0.2, perishable=True, skus=[SKU("BEV-005")]), + Supply(id=SupplyId("SUP-029"), name="lime", cost=0.13, perishable=True, skus=[SKU("BEV-005")]), ] ) diff --git a/jafgen/stores/store.py b/jafgen/stores/store.py index e4ae81b..1b52dc3 100644 --- a/jafgen/stores/store.py +++ b/jafgen/stores/store.py @@ -1,46 +1,53 @@ -from typing import Any +import datetime as dt +import uuid +from dataclasses import dataclass, field +from typing import Iterator, NewType +from faker import Faker -class Store(object): - def __init__( - self, store_id, name, base_popularity, hours_of_operation, opened_date, tax_rate - ): - self.store_id = store_id - self.name = name - self.base_popularity = base_popularity - self.hours_of_operation = hours_of_operation - self.opened_date = opened_date - self.tax_rate = tax_rate +from jafgen.time import Day, WeekHoursOfOperation - def p_buy(self, date): - date_effect = date.get_effect() - return self.base_popularity * date_effect +fake = Faker() - def minutes_open(self, date): - return self.hours_of_operation.minutes_open(date) +StoreId = NewType("StoreId", uuid.UUID) - def iter_minutes_open(self, date): - yield from self.hours_of_operation.iter_minutes(date) +@dataclass(frozen=True) +class Store: + name: str + base_popularity: float + hours_of_operation: WeekHoursOfOperation + opened_day: Day + tax_rate: float + id: StoreId = field(default_factory=lambda: StoreId(fake.uuid4())) - def is_open(self, date): - return date.date >= self.opened_date.date + def p_buy(self, day: Day) -> float: + return self.base_popularity * day.get_effect() - def is_open_at(self, date): - return self.hours_of_operation.is_open(date) + def minutes_open(self, day: Day) -> int: + return self.hours_of_operation.total_minutes_open(day) - def days_since_open(self, date): - return date.date_index - self.opened_date.date_index + def iter_minutes_open(self, day: Day) -> Iterator[int]: + yield from self.hours_of_operation.iter_minutes(day) - def opens_at(self, date): - return self.hours_of_operation.opens_at(date) + def is_open(self, day: Day) -> bool: + return day.date >= self.opened_day.date - def closes_at(self, date): - return self.hours_of_operation.closes_at(date) + def is_open_at(self, day: Day) -> bool: + return self.hours_of_operation.is_open(day) - def to_dict(self) -> dict[str, Any]: + def days_since_open(self, day: Day) -> int: + return day.date_index - self.opened_day.date_index + + def opens_at(self, day: Day) -> dt.time: + return self.hours_of_operation.opens_at(day) + + def closes_at(self, day: Day) -> dt.time: + return self.hours_of_operation.closes_at(day) + + def to_dict(self) -> dict[str, str]: return { - "id": str(self.store_id), + "id": str(self.id), "name": str(self.name), - "opened_at": str(self.opened_date.date.isoformat()), + "opened_at": str(self.opened_day.date.isoformat()), "tax_rate": str(self.tax_rate), } diff --git a/jafgen/stores/supply.py b/jafgen/stores/supply.py index 543705b..bfb60b6 100644 --- a/jafgen/stores/supply.py +++ b/jafgen/stores/supply.py @@ -1,13 +1,16 @@ -from typing import Any +from dataclasses import dataclass +from typing import NewType +SupplyId = NewType("SupplyId", str) +StorageKeepingUnit = NewType("StorageKeepingUnit", str) -class Supply(object): - def __init__(self, id, name, cost, perishable, skus): - self.id = id - self.name = name - self.cost = cost - self.perishable = perishable - self.skus = skus +@dataclass(frozen=True) +class Supply: + id: SupplyId + name: str + cost: float + perishable: bool + skus: list[StorageKeepingUnit] def __str__(self): return f"<{self.name} @ ${self.cost}>" @@ -15,7 +18,7 @@ def __str__(self): def __repr__(self): return self.__str__() - def to_dict(self, sku) -> dict[str, Any]: + def to_dict(self, sku: StorageKeepingUnit) -> dict[str, str | int]: return { "id": str(self.id), "name": str(self.name), diff --git a/jafgen/time.py b/jafgen/time.py new file mode 100644 index 0000000..bf3692c --- /dev/null +++ b/jafgen/time.py @@ -0,0 +1,142 @@ +import datetime as dt +from dataclasses import dataclass +from enum import Enum +from typing import Iterator + +from jafgen.curves import AnnualCurve, GrowthCurve, WeekendCurve + + +def time_to_delta(t: dt.time) -> dt.timedelta: + """Reinterpret a time (fixed time at a day) as a timedelta (duration).""" + return dt.datetime.combine(dt.datetime.min, t) - dt.datetime.min + + +def time_delta_add(t: dt.time, d: dt.timedelta) -> dt.time: + """Add time and a timedelta without worrying about date overflow.""" + t_dt = dt.datetime.combine(dt.date.min, t) + return (t_dt + d).time() + + +def time_delta_sub(t: dt.time, d: dt.timedelta) -> dt.time: + """Subctract time and timedelta without worrying about date underflow.""" + return time_delta_add(t, -d) + + +def time_from_total_minutes(mins: int) -> dt.time: + """Return a datetime.time from total minutes since midnight.""" + return dt.time(hour=mins // 60, minute=mins % 60) + + +def total_minutes_elapsed(t: dt.time | dt.timedelta) -> int: + """Get the total minutes that passed since midnight for a time or timedelta.""" + if isinstance(t, dt.time): + return t.second * 60 + t.minute + + return int(t.total_seconds() // 60) + + +class Season(str, Enum): + WINTER = "WINTER" + SPRING = "SPRING" + SUMMER = "SUMMER" + FALL = "FALL" + + @classmethod + def from_date(cls, date: dt.date) -> "Season": + month_no = date.month + day_no = date.day + + if month_no in (1, 2) or (month_no == 3 and day_no < 21): + return cls.WINTER + elif month_no in (3, 4, 5) or (month_no == 6 and day_no < 21): + return cls.SPRING + elif month_no in (6, 7, 8) or (month_no == 9 and day_no < 21): + return cls.SUMMER + elif month_no in (9, 10, 11) or (month_no == 12 and day_no < 21): + return cls.FALL + + return cls.WINTER + +@dataclass(init=False) +class Day: + EPOCH = dt.datetime(year=2018, month=9, day=1) + SEASONAL_MONTHLY_CURVE = AnnualCurve() + WEEKEND_CURVE = WeekendCurve() + GROWTH_CURVE = GrowthCurve() + + def __init__(self, date_index: int, minutes: int = 0): + self.date_index = date_index + self.date = self.EPOCH + dt.timedelta(days=date_index, minutes=minutes) + self.effects = [ + self.SEASONAL_MONTHLY_CURVE.eval(self.date), + self.WEEKEND_CURVE.eval(self.date), + self.GROWTH_CURVE.eval(self.date), + ] + + def at_minute(self, minutes: int) -> "Day": + return Day(self.date_index, minutes=minutes) + + def get_effect(self) -> float: + total = 1 + for effect in self.effects: + total = total * effect + return total + + @property + def day_of_week(self) -> int: + return self.date.weekday() + + @property + def is_weekend(self) -> bool: + # 5 + 6 are weekends + return self.date.weekday() >= 5 + + @property + def season(self) -> Season: + return Season.from_date(self.date) + + @property + def total_minutes(self) -> int: + return self.date.hour * 60 + self.date.minute + +@dataclass(frozen=True) +class DayHoursOfOperation: + opens_at: dt.time + closes_at: dt.time + + @property + def total_minutes_open(self) -> int: + time_open = time_delta_sub(self.closes_at, time_to_delta(self.opens_at)) + return total_minutes_elapsed(time_open) + + def is_open(self, time: dt.time) -> bool: + return time >= self.opens_at and time < self.closes_at + + def iter_minutes(self) -> Iterator[int]: + for minute in range(self.total_minutes_open): + yield minute + +@dataclass(frozen=True) +class WeekHoursOfOperation: + week_days: DayHoursOfOperation + weekends: DayHoursOfOperation + + def _get_todays_schedule(self, day: Day) -> DayHoursOfOperation: + return self.weekends if day.is_weekend else self.week_days + + def opens_at(self, day: Day) -> dt.time: + return self._get_todays_schedule(day).opens_at + + def closes_at(self, day: Day) -> dt.time: + return self._get_todays_schedule(day).closes_at + + def total_minutes_open(self, day: Day) -> int: + return self._get_todays_schedule(day).total_minutes_open + + def is_open(self, day: Day) -> bool: + time = time_from_total_minutes(day.total_minutes) + return self._get_todays_schedule(day).is_open(time) + + def iter_minutes(self, day: Day) -> Iterator[int]: + yield from self._get_todays_schedule(day).iter_minutes() + diff --git a/lefthook.yaml b/lefthook.yaml index 6909f84..f1ba17d 100644 --- a/lefthook.yaml +++ b/lefthook.yaml @@ -2,8 +2,8 @@ pre-commit: commands: # ruff: # run: .venv/bin/ruff check --fix - # pyright: - # run: .venv/bin/pyright + pyright: + run: .venv/bin/pyright commit-msg: template-checker: diff --git a/pyrightconfig.json b/pyrightconfig.json index f10296e..0cd9310 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -1,3 +1,4 @@ { - "typeCheckingMode": "basic" + "typeCheckingMode": "strict", + "venv": ".venv/" } diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2c0e595 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,28 @@ +import pytest + +from jafgen.stores.store import Store +from jafgen.time import ( + Day, + DayHoursOfOperation, + WeekHoursOfOperation, + time_from_total_minutes, +) + +T_7AM = time_from_total_minutes(60 * 7) +T_8AM = time_from_total_minutes(60 * 8) +T_3PM = time_from_total_minutes(60 * 15) +T_8PM = time_from_total_minutes(60 * 20) + +@pytest.fixture +def default_store() -> Store: + """Return a pre-initialized store that can be used for tests.""" + return Store( + name="Testylvania", + base_popularity=0.85, + hours_of_operation=WeekHoursOfOperation( + week_days=DayHoursOfOperation(opens_at=T_7AM, closes_at=T_8PM), + weekends=DayHoursOfOperation(opens_at=T_8AM, closes_at=T_3PM), + ), + opened_day=Day(date_index=0, minutes=0), + tax_rate=0.0659123, + ) diff --git a/tests/test_inventory.py b/tests/test_inventory.py index cd487ac..6b23311 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -1,25 +1,26 @@ from jafgen.stores.inventory import Inventory +from jafgen.stores.item import ItemType from jafgen.stores.stock import Stock from jafgen.stores.supply import Supply def test_stock_and_inventory_equal(): """Ensure Stock and Inventory have an equal number of items""" - assert len(Stock.stock) == len(Inventory.inventory["jaffle"]) + len( - Inventory.inventory["beverage"] + assert len(Stock.stock) == len(Inventory.inventory[ItemType.JAFFLE]) + len( + Inventory.inventory[ItemType.BEVERAGE] ) def test_all_inventory_beverages_are_beverages(): """Ensure all the items in inventory.beverage are of type beverage""" - for item in Inventory.inventory["beverage"]: - assert item.type == "beverage" + for item in Inventory.inventory[ItemType.BEVERAGE]: + assert item.type == ItemType.BEVERAGE def test_all_inventory_jaffles_are_jaffles(): """Ensure all the items in inventory.jaffle are of type jaffle""" - for item in Inventory.inventory["jaffle"]: - assert item.type == "jaffle" + for item in Inventory.inventory[ItemType.JAFFLE]: + assert item.type == ItemType.JAFFLE def test_inventory_stock_all_has_supplies(): diff --git a/tests/test_order_totals.py b/tests/test_order_totals.py index a86d024..90c0d32 100644 --- a/tests/test_order_totals.py +++ b/tests/test_order_totals.py @@ -1,63 +1,39 @@ +from jafgen.time import Day +from jafgen.customers.customers import Customer, BrunchCrowd, RemoteWorker, Student from jafgen.customers.order import Order -from jafgen.curves import Day -from jafgen.stores.store import Store -from jafgen.customers.customers import RemoteWorker, BrunchCrowd, Student +from jafgen.stores.item import ItemType from jafgen.stores.inventory import Inventory +from jafgen.stores.store import Store -def test_order_totals(): - """Ensure order totals are equivalent to the sum of the item prices and tax paid""" - - store = Store(str(1), "Testylvania", 0.85, 0, 9 * 100, 0.0659123) +def test_order_totals(default_store: Store): + """Ensure order totals are equivalent to the sum of the item prices and tax paid.""" inventory = Inventory() - orders = [] + orders: list[Order] = [] + customer_types: list[type[Customer]] = [RemoteWorker, BrunchCrowd, Student] for i in range(1000): - orders.append( - Order( - customer=RemoteWorker(store=store), - items=[ - inventory.get_food()[0], - inventory.get_drink()[0], - inventory.get_food()[0], - ], - store=store, - order_time=Day(date_index=i), - ) - ) - orders.append( - Order( - customer=BrunchCrowd(store=store), - items=[ - inventory.get_food()[0], - inventory.get_drink()[0], - inventory.get_food()[0], - ], - store=store, - order_time=Day(date_index=i), + for CustType in customer_types: + orders.append( + Order( + customer=CustType(store=default_store), + items= + inventory.get_item_type(ItemType.JAFFLE, 2) + + inventory.get_item_type(ItemType.BEVERAGE, 1), + store=default_store, + day=Day(date_index=i), + ) ) - ) - orders.append( - Order( - customer=Student(store=store), - items=[ - inventory.get_food()[0], - inventory.get_drink()[0], - inventory.get_food()[0], - ], - store=store, - order_time=Day(date_index=i), - ) - ) + for order in orders: assert ( order.subtotal - == order.items[0].item.price - + order.items[1].item.price - + order.items[2].item.price + == order.items[0].price + + order.items[1].price + + order.items[2].price ) assert order.tax_paid == order.subtotal * order.store.tax_rate - assert order.order_total == order.subtotal + order.tax_paid - assert round(float(order.order_total), 2) == round( + assert order.total == order.subtotal + order.tax_paid + assert round(float(order.total), 2) == round( float(order.subtotal), 2 ) + round(float(order.tax_paid), 2) order_dict = order.to_dict() diff --git a/tests/test_tweets.py b/tests/test_tweets.py index 02f077d..dfee52c 100644 --- a/tests/test_tweets.py +++ b/tests/test_tweets.py @@ -1,40 +1,22 @@ -import datetime -from jafgen.stores.store import Store +from jafgen.time import Day from jafgen.customers.customers import ( - RemoteWorker, BrunchCrowd, - HealthNut, - Commuter, Casuals, + Commuter, + Customer, + HealthNut, + RemoteWorker, Student, ) -from jafgen.curves import Day -from jafgen.simulation import HoursOfOperation - -T_7AM = 60 * 7 -T_8AM = 60 * 8 -T_3PM = 60 * 15 -T_8PM = 60 * 20 +from jafgen.stores.store import Store -def test_tweets(): +def test_tweets(default_store: Store): """Test that tweets only come after orders and in the range of 20 minutes after.""" - - store = Store( - str(1), - "Testylvania", - 0.85, - HoursOfOperation( - weekday_range=(T_7AM, T_8PM), - weekend_range=(T_8AM, T_3PM), - ), - 0, - 0.0659123, - ) - customers = [] + customers: list[Customer] = [] personas = [RemoteWorker, BrunchCrowd, HealthNut, Commuter, Casuals, Student] for i in range(100): - customers.append(personas[i % len(personas)](store)) + customers.append(personas[i % len(personas)](default_store)) for i in range(100): day = Day(i) @@ -44,8 +26,8 @@ def test_tweets(): assert tweet.customer == customer assert tweet.order == order assert ( - tweet.tweeted_at - <= tweet.order.order_time.date + datetime.timedelta(minutes=20) + tweet.day.date + <= tweet.order.day.at_minute(tweet.order.day.total_minutes + 20).date ) if not order: assert not tweet