Skip to content
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,16 @@ The production server serves both frontend and backend from port **8200**
- Link to view all rolls using each batch
- Duplicate and retire batch actions

### Development Chart (B&W Timing)
- **Lookup table** for B&W film development times
- Database of film stock + developer + ISO + dilution + temperature → dev time
- Pre-seeded with **34 common combinations** (Ilford HP5+, FP4+, Delta; Kodak Tri-X, T-Max; Fomapan)
- Supports **push/pull processing** with different ISO ratings
- **Easy data entry** via CSV import or API
- **Autocomplete** for film stocks and developers
- **Filtering** by film stock, developer, or ISO rating
- **Lookup API** for quick dev time queries

### User Experience
- **Touch-friendly** mobile-responsive design
- **Drag-and-drop** with visual feedback
Expand Down Expand Up @@ -166,13 +176,24 @@ The production server serves both frontend and backend from port **8200**
- `PUT /api/chemistry/{id}` - Update batch
- `DELETE /api/chemistry/{id}` - Delete batch

### Development Chart (B&W Timing Lookup)
- `GET /api/dev-chart` - List all chart entries (filter: `?film_stock=HP5&developer=Ilfosol`)
- `POST /api/dev-chart` - Create new chart entry
- `GET /api/dev-chart/{id}` - Get chart entry
- `PUT /api/dev-chart/{id}` - Update chart entry
- `DELETE /api/dev-chart/{id}` - Delete chart entry
- `POST /api/dev-chart/lookup` - Lookup dev time (body: `{film_stock, developer, iso_rating, dilution_ratio?, temperature_celsius?}`)
- `GET /api/dev-chart/autocomplete/films` - Autocomplete film stock names
- `GET /api/dev-chart/autocomplete/developers` - Autocomplete developer names

## 🗄️ Database

**Location:** `backend/data/emulsion.db` (SQLite)

**Tables:**
- `film_rolls` - Film roll tracking with computed status
- `chemistry_batches` - Chemistry batch tracking with C41 dev time calculation
- `development_chart` - B&W development timing lookup table

**Changing Database Location:**

Expand All @@ -194,12 +215,32 @@ python import_chemistry.py --db-path ../../backend/data/emulsion.db
# Import film rolls
python import_rolls.py --db-path ../../backend/data/emulsion.db

# Import development chart data (B&W timing)
python import_dev_chart.py ../data/dev_chart_seed.csv --db-path ../../backend/data/emulsion.db

# Validate imported data
python validate.py --db-path ../../backend/data/emulsion.db
```

See `migration/README.md` for CSV format requirements.

### Development Chart CSV Format

The development chart CSV should have these columns:
```
film_stock,developer,iso_rating,dilution_ratio,temperature_celsius,development_time_seconds,agitation_notes,notes
```

Example:
```csv
Ilford HP5 Plus 400,Ilfosol 3,400,1+4,20.0,6:30,First 30s continuous then 10s every minute,From Ilford datasheet
Kodak Tri-X 400,D-76,800,1+1,20.0,14:00,Agitate 5s every 30s,Push +1 stop
```

**Note:** `development_time_seconds` can be in seconds (390) or MM:SS format (6:30).

A seed file with 34 common film/developer combinations is provided at `migration/data/dev_chart_seed.csv`.

## 🏗️ Architecture

### How It Works
Expand Down
3 changes: 2 additions & 1 deletion backend/app/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
api_router = APIRouter(prefix="/api")

# Import and include route modules
from app.api import rolls, chemistry
from app.api import rolls, chemistry, dev_chart

api_router.include_router(rolls.router, prefix="/rolls", tags=["Film Rolls"])
api_router.include_router(chemistry.router, prefix="/chemistry", tags=["Chemistry"])
api_router.include_router(dev_chart.router, prefix="/dev-chart", tags=["Development Chart"])

__all__ = ["api_router"]
256 changes: 256 additions & 0 deletions backend/app/api/dev_chart.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
"""Development chart API endpoints."""

from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_

from app.core.database import get_db
from app.models import DevelopmentChart
from app.api.schemas.development_chart import (
DevelopmentChartCreate,
DevelopmentChartUpdate,
DevelopmentChartResponse,
DevelopmentChartList,
DevelopmentChartLookupQuery,
DevelopmentChartLookupResponse,
)

router = APIRouter()


@router.get("", response_model=DevelopmentChartList)
def list_dev_chart_entries(
skip: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(100, ge=1, le=1000, description="Maximum number of records to return"),
film_stock: Optional[str] = Query(None, description="Filter by film stock (case-insensitive partial match)"),
developer: Optional[str] = Query(None, description="Filter by developer (case-insensitive partial match)"),
iso_rating: Optional[int] = Query(None, description="Filter by ISO rating (exact match)"),
db: Session = Depends(get_db),
):
"""
Get list of all development chart entries with optional filtering.

Supports filtering by film stock, developer, and ISO rating.
Film stock and developer filters are case-insensitive partial matches.
"""
query = db.query(DevelopmentChart)

# Apply filters
if film_stock:
query = query.filter(DevelopmentChart.film_stock.ilike(f"%{film_stock}%"))

if developer:
query = query.filter(DevelopmentChart.developer.ilike(f"%{developer}%"))

if iso_rating is not None:
query = query.filter(DevelopmentChart.iso_rating == iso_rating)

# Order by film stock, then developer, then ISO rating
query = query.order_by(
DevelopmentChart.film_stock,
DevelopmentChart.developer,
DevelopmentChart.iso_rating
)

total = query.count()
entries = query.offset(skip).limit(limit).all()

return DevelopmentChartList(entries=entries, total=total)


@router.post("", response_model=DevelopmentChartResponse, status_code=201)
def create_dev_chart_entry(
entry_data: DevelopmentChartCreate,
db: Session = Depends(get_db),
):
"""
Create a new development chart entry.

Adds a new timing datapoint for a specific combination of film stock,
developer, ISO rating, dilution ratio, and temperature.

Note: Database-level unique constraint prevents duplicate entries.
"""
from sqlalchemy.exc import IntegrityError

# Create new entry
new_entry = DevelopmentChart(**entry_data.model_dump())
db.add(new_entry)

try:
db.commit()
db.refresh(new_entry)
return new_entry
except IntegrityError:
db.rollback()
# Entry already exists - provide helpful error message
existing = db.query(DevelopmentChart).filter(
and_(
DevelopmentChart.film_stock == entry_data.film_stock,
DevelopmentChart.developer == entry_data.developer,
DevelopmentChart.iso_rating == entry_data.iso_rating,
DevelopmentChart.dilution_ratio == entry_data.dilution_ratio,
DevelopmentChart.temperature_celsius == entry_data.temperature_celsius,
)
).first()

entry_id = existing.id if existing else "unknown"
raise HTTPException(
status_code=409,
detail=(
f"Entry already exists for {entry_data.film_stock} + {entry_data.developer} "
f"at ISO {entry_data.iso_rating}, {entry_data.dilution_ratio}, {entry_data.temperature_celsius}°C. "
f"Use PUT to update existing entry (ID: {entry_id})"
)
)


@router.get("/{entry_id}", response_model=DevelopmentChartResponse)
def get_dev_chart_entry(
entry_id: str,
db: Session = Depends(get_db),
):
"""Get a specific development chart entry by ID."""
entry = db.query(DevelopmentChart).filter(DevelopmentChart.id == entry_id).first()

if not entry:
raise HTTPException(status_code=404, detail=f"Development chart entry {entry_id} not found")

return entry


@router.put("/{entry_id}", response_model=DevelopmentChartResponse)
def update_dev_chart_entry(
entry_id: str,
entry_data: DevelopmentChartUpdate,
db: Session = Depends(get_db),
):
"""
Update an existing development chart entry.

Only provided fields will be updated. Omitted fields remain unchanged.
"""
entry = db.query(DevelopmentChart).filter(DevelopmentChart.id == entry_id).first()

if not entry:
raise HTTPException(status_code=404, detail=f"Development chart entry {entry_id} not found")

# Update only provided fields
update_data = entry_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(entry, field, value)

db.commit()
db.refresh(entry)

return entry


@router.delete("/{entry_id}", status_code=204)
def delete_dev_chart_entry(
entry_id: str,
db: Session = Depends(get_db),
):
"""Delete a development chart entry."""
entry = db.query(DevelopmentChart).filter(DevelopmentChart.id == entry_id).first()

if not entry:
raise HTTPException(status_code=404, detail=f"Development chart entry {entry_id} not found")

db.delete(entry)
db.commit()

return None


@router.post("/lookup", response_model=DevelopmentChartLookupResponse)
def lookup_dev_time(
query: DevelopmentChartLookupQuery,
db: Session = Depends(get_db),
):
"""
Lookup development time for specific film/developer combination.

Searches for an exact match based on film stock, developer, ISO rating,
and optionally dilution ratio and temperature. If no exact match is found,
returns similar entries as suggestions.
"""
# Build query for exact match
filters = [
DevelopmentChart.film_stock == query.film_stock,
DevelopmentChart.developer == query.developer,
DevelopmentChart.iso_rating == query.iso_rating,
]

if query.dilution_ratio:
filters.append(DevelopmentChart.dilution_ratio == query.dilution_ratio)

if query.temperature_celsius:
filters.append(DevelopmentChart.temperature_celsius == query.temperature_celsius)

# Try exact match
entry = db.query(DevelopmentChart).filter(and_(*filters)).first()

if entry:
return DevelopmentChartLookupResponse(
found=True,
entry=entry,
suggestions=None
)

# No exact match - find suggestions (same film + developer, any ISO/dilution/temp)
suggestions_query = db.query(DevelopmentChart).filter(
and_(
DevelopmentChart.film_stock == query.film_stock,
DevelopmentChart.developer == query.developer,
)
).order_by(
DevelopmentChart.iso_rating,
DevelopmentChart.dilution_ratio,
DevelopmentChart.temperature_celsius
).limit(5)

suggestions = suggestions_query.all()

return DevelopmentChartLookupResponse(
found=False,
entry=None,
suggestions=suggestions if suggestions else None
)


@router.get("/autocomplete/films", response_model=list[str])
def autocomplete_film_stocks(
q: str = Query(..., min_length=1, description="Search query"),
limit: int = Query(10, ge=1, le=50, description="Maximum number of results"),
db: Session = Depends(get_db),
):
"""
Autocomplete film stock names.

Returns distinct film stock names that match the query.
"""
results = db.query(DevelopmentChart.film_stock).filter(
DevelopmentChart.film_stock.ilike(f"%{q}%")
).distinct().order_by(DevelopmentChart.film_stock).limit(limit).all()

return [r[0] for r in results]


@router.get("/autocomplete/developers", response_model=list[str])
def autocomplete_developers(
q: str = Query(..., min_length=1, description="Search query"),
limit: int = Query(10, ge=1, le=50, description="Maximum number of results"),
db: Session = Depends(get_db),
):
"""
Autocomplete developer names.

Returns distinct developer names that match the query.
"""
results = db.query(DevelopmentChart.developer).filter(
DevelopmentChart.developer.ilike(f"%{q}%")
).distinct().order_by(DevelopmentChart.developer).limit(limit).all()

return [r[0] for r in results]
16 changes: 16 additions & 0 deletions backend/app/api/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@
AssignChemistryRequest,
RateRollRequest,
)
from app.api.schemas.development_chart import (
DevelopmentChartBase,
DevelopmentChartCreate,
DevelopmentChartUpdate,
DevelopmentChartResponse,
DevelopmentChartList,
DevelopmentChartLookupQuery,
DevelopmentChartLookupResponse,
)

__all__ = [
"FilmRollBase",
Expand All @@ -36,4 +45,11 @@
"UnloadRollRequest",
"AssignChemistryRequest",
"RateRollRequest",
"DevelopmentChartBase",
"DevelopmentChartCreate",
"DevelopmentChartUpdate",
"DevelopmentChartResponse",
"DevelopmentChartList",
"DevelopmentChartLookupQuery",
"DevelopmentChartLookupResponse",
]
Loading