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.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from calculator import Results, calculator
from config import get_settings
from bread import BreadRecipe
from config import Settings
app = FastAPI()
settings = get_settings()
app = FastAPI(
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(
CORSMiddleware,
allow_origins=settings.cors_origins,
@ -17,25 +23,45 @@ app.add_middleware(
)
@app.get("/")
async def root():
return {"message": "Hello World!"}
@app.post("/calculate", summary="Calculate bread ingredients")
def calculate_bread_ingredients(recipe: BreadRecipe):
"""
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):
flour: float = 500.0
hydration: float = 60.0
sourdough_hydration: float = 50.0
sourdough_percent: float = 30.0
salt_percent: float = 1.6
@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
)
@app.get("/", summary="API information")
def root():
"""Root endpoint with API information."""
return {
"message": "Dough Calculator API",
"docs": "/docs",
"version": "1.0.0"
}

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)
def test_cors_headers():
"""Tests that CORS is set."""
response = client.options(
@ -16,43 +17,109 @@ def test_cors_headers():
)
assert "access-control-allow-origin" in response.headers
def test_settings_injection():
"""Tests that the settings are available."""
settings = get_settings()
assert settings is not None
assert hasattr(settings, "cors_origins")
def test_bread_proportions():
"""Tests the /calculate endpoint."""
"""Tests the /calculate endpoint returns correct structure."""
response = client.post(
"/calculate",
json = {
"flour": 500,
"hydration": 63,
"sourdough_hydration": 100,
"salt_percent": 1.6
json={
"total_flour": 500,
"hydration": 70,
"sourdough_percentage": 20,
"sourdough_ratio": {
"flour_parts": 1,
"water_parts": 1
},
"salt": 2
}
)
assert response.status_code == 200
data = response.json()
assert "flour" in data
assert "water" in data
assert "sourdough" in data
assert "salt" in data
# Check main structure
assert "ingredients_to_add" 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():
"""Tests the calculated values."""
"""Tests the calculated values are correct."""
response = client.post(
"/calculate",
json={
"flour": 1000,
"hydration": 62,
"sourdough_hydration": 50,
"sourdough_percent": 20
"total_flour": 1000,
"hydration": 70,
"sourdough_percentage": 20,
"sourdough_ratio": {
"flour_parts": 1,
"water_parts": 1
},
"salt": 2
}
)
assert response.status_code == 200
data = response.json()
assert data["flour"] == 900.0
assert data["water"] == 520.0
assert data["sourdough"] == 200.0
assert data["salt"] == 16.0
ingredients = data["ingredients_to_add"]
details = data["details"]
# 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