bread: refactor calculator into bread

- Rewrites the logic to calculate the ingredient for a bread.
- Adapt the app accordingly.
- Improves the tests.

Co-Authored-by: iGor milhit <igor@milhit.ch>
main
iGor milhit 2026-01-10 18:43:15 +01:00
parent d171af8d0b
commit cd99b31e0a
Signed by: igor
GPG Key ID: 692D97C3D0228A99
5 changed files with 350 additions and 83 deletions

91
bread.py 100644
View File

@ -0,0 +1,91 @@
"""Bread ingredients model and calculation logic."""
from pydantic import BaseModel, Field, computed_field
class SourdoughRatio(BaseModel):
"""
Represents the flour:water ratio in the sourdough starter.
Example:
- Ratio(1, 1) = 50% hydration (equal parts)
- Ratio(1, 0.8) = 44.4% hydration
- Ratio(1, 1.2) = 54.5% hydration
"""
flour_parts: float = Field(gt=0, description="Flour parts in starter (typically 1)")
water_parts: float = Field(gt=0, description="Water parts in starter")
@computed_field
@property
def hydration_percentage(self) -> float:
"""Calculate hydration % of the starter."""
return (self.water_parts / self.flour_parts) * 100
class BreadRecipe(BaseModel):
"""
Input parameters for bread recipe calculation.
All percentages follow baker's percentage (relative to total flour weight).
"""
# What the user provides
total_flour: float = Field(gt=0, description="Total flour weight in grams")
hydration: float = Field(
ge=50, le=100, description="Total hydration % (water/flour)"
)
sourdough_percentage: float = Field(
ge=0,
le=50,
description="Sourdough as % of total flour weight"
)
sourdough_ratio: SourdoughRatio = Field(
description="Flour:water ratio in starter"
)
salt: float = Field(ge=0, le=5, description="Salt as % of total flour")
# What Pydantic calculates automatically
@computed_field
@property
def sourdough_weight(self) -> float:
"""Calculate total sourdough weight in grams."""
return round(self.total_flour * (self.sourdough_percentage / 100), 2)
@computed_field
@property
def water_in_sourdough(self) -> float:
"""Calculate water content in the sourdough."""
ratio = self.sourdough_ratio
total_parts = ratio.flour_parts + ratio.water_parts
water_fraction = self.sourdough_ratio.water_parts / total_parts
return round(self.sourdough_weight * water_fraction, 2)
@computed_field
@property
def flour_in_sourdough(self) -> float:
"""Calculate flour content in the sourdough."""
return round(self.sourdough_weight - self.water_in_sourdough, 2)
@computed_field
@property
def total_water(self) -> float:
"""Calculate total water needed in the recipe."""
return round(self.total_flour * (self.hydration / 100), 2)
@computed_field
@property
def water_to_add(self) -> float:
"""Calculate water to add (excluding water in sourdough)."""
return round(self.total_water - self.water_in_sourdough, 2)
@computed_field
@property
def flour_to_add(self) -> float:
"""Calculate flour to add (excluding flour in sourdough)."""
return round(self.total_flour - self.flour_in_sourdough, 2)
@computed_field
@property
def salt_weight(self) -> float:
"""Calculate salt weight in grams."""
return round(self.total_flour * (self.salt / 100), 2)

View File

@ -1,39 +0,0 @@
from dataclasses import dataclass
@dataclass
class Results:
flour: float
water: float
sourdough: float
salt: float
def calculator(
base_flour: float = 500.0,
hydration: float = 60.0,
sourdough_hydration: float = 50.0,
salt_percent: float = 1.6,
sourdough_percent: float = 30
) -> Results:
"""Calculate the quantities for a bread dough based on desired hydration,
sourdough hydration and the desired quantity of flour."""
sourdough = base_flour * sourdough_percent / 100
water_in_sourdough = sourdough * sourdough_hydration / 100
flour_in_sourdough = sourdough - water_in_sourdough
total_water = base_flour * hydration / 100
water = total_water - water_in_sourdough
flour = base_flour - flour_in_sourdough
salt = base_flour * salt_percent / 100
return Results(
flour = flour,
water = water,
sourdough = sourdough,
salt = salt
)

76
main.py
View File

@ -1,13 +1,19 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from calculator import Results, calculator from bread import BreadRecipe
from config import get_settings from config import Settings
app = FastAPI() app = FastAPI(
settings = get_settings() title="Dough Calculator API",
description="""Calculate bread ingredient proportions
based on baker's percentages""",
version="1.0.0"
)
settings = Settings()
# CORS middleware
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origins, allow_origins=settings.cors_origins,
@ -17,25 +23,45 @@ app.add_middleware(
) )
@app.get("/") @app.post("/calculate", summary="Calculate bread ingredients")
async def root(): def calculate_bread_ingredients(recipe: BreadRecipe):
return {"message": "Hello World!"} """
Calculate the exact quantities of ingredients needed for bread.
**Inputs (what the user provides):**
- total_flour: Total flour weight desired (grams)
- hydration: Total hydration percentage
- sourdough_percentage: Sourdough as % of total flour
- sourdough_ratio: Flour:water ratio in the starter
- salt: Salt as % of total flour
**Outputs (what to use):**
- flour_to_add: Flour to add (excluding flour in sourdough)
- water_to_add: Water to add (excluding water in sourdough)
- sourdough_weight: Total sourdough needed
- salt_weight: Salt needed
"""
return {
"ingredients_to_add": {
"flour": recipe.flour_to_add,
"water": recipe.water_to_add,
"sourdough": recipe.sourdough_weight,
"salt": recipe.salt_weight
},
"details": {
"total_flour": recipe.total_flour,
"total_water": recipe.total_water,
"flour_in_sourdough": recipe.flour_in_sourdough,
"water_in_sourdough": recipe.water_in_sourdough
}
}
class BreadParams(BaseModel): @app.get("/", summary="API information")
flour: float = 500.0 def root():
hydration: float = 60.0 """Root endpoint with API information."""
sourdough_hydration: float = 50.0 return {
sourdough_percent: float = 30.0 "message": "Dough Calculator API",
salt_percent: float = 1.6 "docs": "/docs",
"version": "1.0.0"
@app.post("/calculate", response_model=Results) }
def bread_proportions(params: BreadParams):
return calculator(
base_flour=params.flour,
hydration=params.hydration,
sourdough_hydration=params.sourdough_hydration,
sourdough_percent=params.sourdough_percent,
salt_percent=params.salt_percent
)

122
tests/test_bread.py 100644
View File

@ -0,0 +1,122 @@
"""Unit tests for bread calculation logic."""
import pytest
from bread import BreadRecipe, SourdoughRatio
class TestSourdoughRatio:
"""Test the SourdoughRatio model."""
def test_equal_parts_hydration(self):
"""Test 1:1 ratio gives 100% hydration."""
ratio = SourdoughRatio(flour_parts=1, water_parts=1)
assert ratio.hydration_percentage == 100.0
def test_dry_starter_hydration(self):
"""Test 1:0.5 ratio gives 50% hydration."""
ratio = SourdoughRatio(flour_parts=1, water_parts=0.5)
assert ratio.hydration_percentage == 50.0
def test_wet_starter_hydration(self):
"""Test 1:1.2 ratio gives 120% hydration."""
ratio = SourdoughRatio(flour_parts=1, water_parts=1.2)
assert ratio.hydration_percentage == 120.0
def test_invalid_zero_flour(self):
"""Test that zero flour parts is rejected."""
with pytest.raises(ValueError):
SourdoughRatio(flour_parts=0, water_parts=1)
class TestBreadRecipe:
"""Test the BreadRecipe calculations."""
def test_sourdough_weight_calculation(self):
"""Test sourdough weight is correctly calculated."""
recipe = BreadRecipe(
total_flour=1000,
hydration=70,
sourdough_percentage=20,
sourdough_ratio=SourdoughRatio(flour_parts=1, water_parts=1),
salt=2
)
assert recipe.sourdough_weight == 200.0
def test_water_in_sourdough_equal_ratio(self):
"""Test water calculation with 1:1 ratio."""
recipe = BreadRecipe(
total_flour=1000,
hydration=70,
sourdough_percentage=20,
sourdough_ratio=SourdoughRatio(flour_parts=1, water_parts=1),
salt=2
)
assert recipe.water_in_sourdough == 100.0
assert recipe.flour_in_sourdough == 100.0
def test_water_in_sourdough_unequal_ratio(self):
"""Test water calculation with 1:0.8 ratio."""
recipe = BreadRecipe(
total_flour=1000,
hydration=70,
sourdough_percentage=20,
sourdough_ratio=SourdoughRatio(flour_parts=1, water_parts=0.8),
salt=2
)
# 200g sourdough with 1:0.8 ratio
# Total parts = 1.8
# Water = 200 * (0.8/1.8) = 88.89g
# Flour = 200 * (1/1.8) = 111.11g
assert recipe.water_in_sourdough == 88.89
assert recipe.flour_in_sourdough == 111.11
def test_flour_to_add(self):
"""Test flour to add calculation."""
recipe = BreadRecipe(
total_flour=500,
hydration=70,
sourdough_percentage=20,
sourdough_ratio=SourdoughRatio(flour_parts=1, water_parts=1),
salt=2
)
# 500g total - 50g in sourdough = 450g to add
assert recipe.flour_to_add == 450.0
def test_water_to_add(self):
"""Test water to add calculation."""
recipe = BreadRecipe(
total_flour=500,
hydration=70,
sourdough_percentage=20,
sourdough_ratio=SourdoughRatio(flour_parts=1, water_parts=1),
salt=2
)
# Total water = 500 * 70% = 350g
# Water in sourdough = 50g
# Water to add = 350 - 50 = 300g
assert recipe.water_to_add == 300.0
def test_salt_weight(self):
"""Test salt calculation."""
recipe = BreadRecipe(
total_flour=500,
hydration=70,
sourdough_percentage=20,
sourdough_ratio=SourdoughRatio(flour_parts=1, water_parts=1),
salt=2
)
assert recipe.salt_weight == 10.0
def test_no_sourdough_recipe(self):
"""Test recipe without sourdough (0%)."""
recipe = BreadRecipe(
total_flour=500,
hydration=70,
sourdough_percentage=0,
sourdough_ratio=SourdoughRatio(flour_parts=1, water_parts=1),
salt=2
)
assert recipe.sourdough_weight == 0.0
assert recipe.flour_to_add == 500.0
assert recipe.water_to_add == 350.0

View File

@ -5,6 +5,7 @@ from main import app
client = TestClient(app) client = TestClient(app)
def test_cors_headers(): def test_cors_headers():
"""Tests that CORS is set.""" """Tests that CORS is set."""
response = client.options( response = client.options(
@ -16,43 +17,109 @@ def test_cors_headers():
) )
assert "access-control-allow-origin" in response.headers assert "access-control-allow-origin" in response.headers
def test_settings_injection(): def test_settings_injection():
"""Tests that the settings are available.""" """Tests that the settings are available."""
settings = get_settings() settings = get_settings()
assert settings is not None assert settings is not None
assert hasattr(settings, "cors_origins") assert hasattr(settings, "cors_origins")
def test_bread_proportions(): def test_bread_proportions():
"""Tests the /calculate endpoint.""" """Tests the /calculate endpoint returns correct structure."""
response = client.post( response = client.post(
"/calculate", "/calculate",
json={ json={
"flour": 500, "total_flour": 500,
"hydration": 63, "hydration": 70,
"sourdough_hydration": 100, "sourdough_percentage": 20,
"salt_percent": 1.6 "sourdough_ratio": {
"flour_parts": 1,
"water_parts": 1
},
"salt": 2
} }
) )
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert "flour" in data
assert "water" in data # Check main structure
assert "sourdough" in data assert "ingredients_to_add" in data
assert "salt" in data assert "details" in data
# Check ingredients_to_add fields
ingredients = data["ingredients_to_add"]
assert "flour" in ingredients
assert "water" in ingredients
assert "sourdough" in ingredients
assert "salt" in ingredients
def test_bread_proportions_values(): def test_bread_proportions_values():
"""Tests the calculated values.""" """Tests the calculated values are correct."""
response = client.post( response = client.post(
"/calculate", "/calculate",
json={ json={
"flour": 1000, "total_flour": 1000,
"hydration": 62, "hydration": 70,
"sourdough_hydration": 50, "sourdough_percentage": 20,
"sourdough_percent": 20 "sourdough_ratio": {
"flour_parts": 1,
"water_parts": 1
},
"salt": 2
} }
) )
assert response.status_code == 200
data = response.json() data = response.json()
assert data["flour"] == 900.0
assert data["water"] == 520.0 ingredients = data["ingredients_to_add"]
assert data["sourdough"] == 200.0 details = data["details"]
assert data["salt"] == 16.0
# Test calculated values
assert ingredients["flour"] == 900.0 # 1000 - 100 (flour in sourdough)
assert ingredients["water"] == 600.0 # 700 - 100 (water in sourdough)
assert ingredients["sourdough"] == 200.0 # 1000 * 20%
assert ingredients["salt"] == 20.0 # 1000 * 2%
# Test details
assert details["total_flour"] == 1000
assert details["total_water"] == 700.0 # 1000 * 70%
assert details["flour_in_sourdough"] == 100.0
assert details["water_in_sourdough"] == 100.0
def test_validation_hydration_too_high():
"""Tests that validation rejects hydration > 100%."""
response = client.post(
"/calculate",
json={
"total_flour": 500,
"hydration": 150, # ❌ Too high
"sourdough_percentage": 20,
"sourdough_ratio": {
"flour_parts": 1,
"water_parts": 1
},
"salt": 2
}
)
assert response.status_code == 422 # Validation error
def test_validation_negative_flour():
"""Tests that validation rejects negative flour."""
response = client.post(
"/calculate",
json={
"total_flour": -500, # ❌ Negative
"hydration": 70,
"sourdough_percentage": 20,
"sourdough_ratio": {
"flour_parts": 1,
"water_parts": 1
},
"salt": 2
}
)
assert response.status_code == 422 # Validation error