Skip to content

Commit 972739a

Browse files
committed
Add Goal Seek feature with time series, alerts, and historical data
- Goal Seek solver (bisection method) to find metal price / cost thresholds for NSR viability - Save/manage Goal Seek scenarios with configurable alert frequency (hourly/daily/weekly) - Email alerts via Resend when NSR crosses target threshold (hysteresis-based) - NSR time series chart (Recharts AreaChart) with hover tooltips showing full price/cost breakdown - APScheduler background job for periodic NSR computation and snapshot recording - Historical seed data: 366 daily snapshots (Feb 2025 - Feb 2026) with real market prices - Silver: indexmundi, statmuse, bullion-rates (monthly averages) - Gold: ycharts end-of-month prices - Copper: LME monthly via ycharts ($/MT -> $/lb conversion) - Alembic migration for goal_seek_scenarios and nsr_snapshots tables - Frontend: Goal Seek page with i18n (en, pt-BR, es), save dialog, scenario list - .dockerignore files for faster Docker builds
1 parent 084fc7c commit 972739a

26 files changed

Lines changed: 3195 additions & 4 deletions

backend/.dockerignore

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
__pycache__
2+
*.pyc
3+
*.pyo
4+
.venv
5+
venv
6+
.env
7+
.env.*
8+
.git
9+
.pytest_cache
10+
.mypy_cache
11+
*.egg-info
12+
dist
13+
build

backend/Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,5 @@ USER appuser
2929
# Expose port (Railway uses dynamic PORT)
3030
EXPOSE 8000
3131

32-
# Run uvicorn directly - no entrypoint
33-
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
32+
# Run migrations, seed demo data, and start server
33+
CMD ["sh", "-c", "alembic upgrade head && python scripts/seed_snapshots.py && uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8080}"]

backend/alembic/env.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
# Import models to register them with Base
1111
from app.db.session import Base
12-
from app.models import User, Region, Mine, UserMine # noqa: F401
12+
from app.models import User, Region, Mine, UserMine, GoalSeekScenario, NsrSnapshot # noqa: F401
1313
from app.config import get_settings
1414

1515
# this is the Alembic Config object
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Add goal seek scenarios and NSR snapshots tables
2+
3+
Revision ID: 003_goal_seek
4+
Revises: 002_geo_fields
5+
Create Date: 2026-02-12
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
from sqlalchemy.dialects.postgresql import UUID
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = '003_goal_seek'
16+
down_revision: Union[str, None] = '002_geo_fields'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
# Goal Seek Scenarios table
23+
op.create_table(
24+
'goal_seek_scenarios',
25+
sa.Column('id', UUID(as_uuid=True), primary_key=True),
26+
sa.Column('user_id', UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
27+
sa.Column('mine_id', UUID(as_uuid=True), sa.ForeignKey('mines.id', ondelete='SET NULL'), nullable=True),
28+
sa.Column('name', sa.String(255), nullable=False),
29+
sa.Column('base_inputs', sa.JSON(), nullable=False),
30+
sa.Column('target_variable', sa.String(50), nullable=False),
31+
sa.Column('target_nsr', sa.Float(), nullable=False, server_default='0'),
32+
sa.Column('threshold_value', sa.Float(), nullable=False),
33+
sa.Column('alert_enabled', sa.Boolean(), server_default='false'),
34+
sa.Column('alert_email', sa.String(255), nullable=True),
35+
sa.Column('alert_frequency', sa.String(20), nullable=False, server_default='daily'),
36+
sa.Column('alert_last_checked_at', sa.DateTime(timezone=True), nullable=True),
37+
sa.Column('alert_triggered_at', sa.DateTime(timezone=True), nullable=True),
38+
sa.Column('last_nsr_value', sa.Float(), nullable=True),
39+
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
40+
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
41+
)
42+
op.create_index('ix_goal_seek_scenarios_user_id', 'goal_seek_scenarios', ['user_id'])
43+
44+
# NSR Snapshots table
45+
op.create_table(
46+
'nsr_snapshots',
47+
sa.Column('id', UUID(as_uuid=True), primary_key=True),
48+
sa.Column('scenario_id', UUID(as_uuid=True), sa.ForeignKey('goal_seek_scenarios.id', ondelete='CASCADE'), nullable=False),
49+
sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
50+
sa.Column('nsr_per_tonne', sa.Float(), nullable=False),
51+
sa.Column('nsr_cu', sa.Float(), nullable=False),
52+
sa.Column('nsr_au', sa.Float(), nullable=False),
53+
sa.Column('nsr_ag', sa.Float(), nullable=False),
54+
sa.Column('cu_price', sa.Float(), nullable=False),
55+
sa.Column('au_price', sa.Float(), nullable=False),
56+
sa.Column('ag_price', sa.Float(), nullable=False),
57+
sa.Column('cu_tc', sa.Float(), nullable=False),
58+
sa.Column('cu_rc', sa.Float(), nullable=False),
59+
sa.Column('cu_freight', sa.Float(), nullable=False),
60+
sa.Column('is_viable', sa.Boolean(), nullable=False),
61+
sa.Column('metadata_extra', sa.JSON(), nullable=True),
62+
)
63+
op.create_index(
64+
'ix_nsr_snapshots_scenario_timestamp',
65+
'nsr_snapshots',
66+
['scenario_id', 'timestamp'],
67+
)
68+
69+
70+
def downgrade() -> None:
71+
op.drop_table('nsr_snapshots')
72+
op.drop_table('goal_seek_scenarios')

backend/app/api/compute.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77

88
from app.nsr_engine.calculations import compute_nsr_complete
99
from app.nsr_engine.models import NSRInput, NSRResult
10+
from app.nsr_engine.goal_seek import (
11+
goal_seek,
12+
GoalSeekResult,
13+
GoalSeekError,
14+
GOAL_SEEK_VARIABLES,
15+
)
1016
from app.services.metal_prices import get_metal_prices
1117

1218
router = APIRouter()
@@ -248,3 +254,163 @@ async def compute_scenarios(
248254
raise HTTPException(status_code=422, detail=str(e))
249255
except Exception as e:
250256
raise HTTPException(status_code=500, detail=f"Computation error: {str(e)}")
257+
258+
259+
# ──────────────────────────────────────────────────────────
260+
# Goal Seek
261+
# ──────────────────────────────────────────────────────────
262+
263+
264+
class GoalSeekRequest(BaseModel):
265+
"""Request body for Goal Seek computation."""
266+
267+
# Base NSR parameters (same as ComputeNSRRequest)
268+
mine: str = Field(..., description="Mine name")
269+
area: str = Field(..., description="Area within mine")
270+
cu_grade: float = Field(..., ge=0, le=100, description="Copper grade (%)")
271+
au_grade: float = Field(..., ge=0, description="Gold grade (g/t)")
272+
ag_grade: float = Field(..., ge=0, description="Silver grade (g/t)")
273+
ore_tonnage: float = Field(default=1000, gt=0, description="Ore tonnage (tonnes)")
274+
mine_dilution: float = Field(default=0.14, ge=0, le=1, description="Mine dilution")
275+
ore_recovery: float = Field(default=0.98, ge=0, le=1, description="Ore recovery")
276+
cu_price: Optional[float] = Field(default=None, description="Cu price ($/lb)")
277+
au_price: Optional[float] = Field(default=None, description="Au price ($/oz)")
278+
ag_price: Optional[float] = Field(default=None, description="Ag price ($/oz)")
279+
cu_payability: Optional[float] = Field(default=None, description="Cu payability")
280+
cu_tc: Optional[float] = Field(default=None, description="Treatment charge ($/dmt)")
281+
cu_rc: Optional[float] = Field(default=None, description="Refining charge Cu ($/lb)")
282+
cu_freight: Optional[float] = Field(default=None, description="Freight ($/dmt)")
283+
284+
# Goal Seek specific
285+
target_variable: str = Field(
286+
..., description="Variable to solve for (e.g., 'cu_price', 'cu_tc')"
287+
)
288+
target_nsr: float = Field(
289+
default=0.0, description="Target NSR value in $/t (default 0 = break-even)"
290+
)
291+
292+
model_config = {
293+
"json_schema_extra": {
294+
"example": {
295+
"mine": "Vermelhos UG",
296+
"area": "Vermelhos Sul",
297+
"cu_grade": 0.8,
298+
"au_grade": 0.15,
299+
"ag_grade": 1.5,
300+
"target_variable": "cu_price",
301+
"target_nsr": 50.0,
302+
}
303+
}
304+
}
305+
306+
307+
class GoalSeekResponse(BaseModel):
308+
"""Response for Goal Seek computation."""
309+
310+
target_variable: str
311+
target_variable_unit: str
312+
target_nsr: float
313+
threshold_value: float
314+
current_value: float
315+
current_nsr: float
316+
delta_percent: float
317+
is_currently_viable: bool
318+
converged: bool
319+
iterations: int
320+
tolerance_achieved: float
321+
bound_hit: str = "" # "lower", "upper", or "" — when no exact solution in range
322+
323+
324+
class GoalSeekVariablesResponse(BaseModel):
325+
"""Available variables for Goal Seek."""
326+
327+
variables: List[dict]
328+
329+
330+
@router.get("/compute/goal-seek/variables", response_model=GoalSeekVariablesResponse)
331+
async def list_goal_seek_variables() -> GoalSeekVariablesResponse:
332+
"""List all variables available for Goal Seek."""
333+
variables = []
334+
for name, (direction, lower, upper, unit) in GOAL_SEEK_VARIABLES.items():
335+
variables.append(
336+
{
337+
"name": name,
338+
"direction": direction,
339+
"unit": unit,
340+
"lower_bound": lower,
341+
"upper_bound": upper,
342+
}
343+
)
344+
return GoalSeekVariablesResponse(variables=variables)
345+
346+
347+
@router.post("/compute/goal-seek", response_model=GoalSeekResponse)
348+
async def compute_goal_seek(request: GoalSeekRequest) -> GoalSeekResponse:
349+
"""
350+
Goal Seek: find the value of a variable that yields a target NSR.
351+
352+
Like Excel's Goal Seek, but for NSR calculations. Finds the minimum
353+
metal price (or maximum cost) needed for viability.
354+
355+
Example: "What Cu price do I need for NSR = $50/t?"
356+
"""
357+
try:
358+
# Fetch live prices if not provided
359+
cu_price = request.cu_price
360+
au_price = request.au_price
361+
ag_price = request.ag_price
362+
363+
if cu_price is None or au_price is None or ag_price is None:
364+
live_prices = await get_metal_prices()
365+
if cu_price is None:
366+
cu_price = live_prices.cu_price_per_lb
367+
if au_price is None:
368+
au_price = live_prices.au_price_per_oz
369+
if ag_price is None:
370+
ag_price = live_prices.ag_price_per_oz
371+
372+
nsr_input = NSRInput(
373+
mine=request.mine,
374+
area=request.area,
375+
cu_grade=request.cu_grade,
376+
au_grade=request.au_grade,
377+
ag_grade=request.ag_grade,
378+
ore_tonnage=request.ore_tonnage,
379+
mine_dilution=request.mine_dilution,
380+
ore_recovery=request.ore_recovery,
381+
cu_price=cu_price,
382+
au_price=au_price,
383+
ag_price=ag_price,
384+
cu_payability=request.cu_payability,
385+
cu_tc=request.cu_tc,
386+
cu_rc=request.cu_rc,
387+
cu_freight=request.cu_freight,
388+
)
389+
390+
result = goal_seek(
391+
base_input=nsr_input,
392+
target_variable=request.target_variable,
393+
target_nsr=request.target_nsr,
394+
)
395+
396+
return GoalSeekResponse(
397+
target_variable=result.target_variable,
398+
target_variable_unit=result.target_variable_unit,
399+
target_nsr=result.target_nsr,
400+
threshold_value=result.threshold_value,
401+
current_value=result.current_value,
402+
current_nsr=result.current_nsr,
403+
delta_percent=result.delta_percent,
404+
is_currently_viable=result.is_currently_viable,
405+
converged=result.converged,
406+
iterations=result.iterations,
407+
tolerance_achieved=result.tolerance_achieved,
408+
bound_hit=result.bound_hit,
409+
)
410+
411+
except GoalSeekError as e:
412+
raise HTTPException(status_code=422, detail=str(e))
413+
except ValueError as e:
414+
raise HTTPException(status_code=422, detail=str(e))
415+
except Exception as e:
416+
raise HTTPException(status_code=500, detail=f"Goal Seek error: {str(e)}")

0 commit comments

Comments
 (0)