Spaces:
Sleeping
Sleeping
Upload 4 files
Browse files- app.py +664 -0
- repair_table_data.py +236 -0
- requirements.txt +4 -0
- salvage_table_data.py +78 -0
app.py
ADDED
|
@@ -0,0 +1,664 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app.py — “Jerry” NLP Agent (per‑acre‑per‑year; uses salvage internally for Depr & OCC)
|
| 2 |
+
# ---------------------------------------------------------------------------------
|
| 3 |
+
# Students ALWAYS type: equipment_name, acres (per year), speed_acph (ac/hr), life_years, cost
|
| 4 |
+
# Optional (only when needed): pto_hp, diesel_price, wage, rate
|
| 5 |
+
#
|
| 6 |
+
# Jerry computes EXACTLY ONE cost per prompt (NLP → strict schema), with unit basis aligned:
|
| 7 |
+
# • repair_per_acre_year = (cost × repair_fraction@AccumHours) / (life_years × acres)
|
| 8 |
+
# • fuel_per_acre = (0.044 × pto_hp × diesel_price) / speed_acph
|
| 9 |
+
# • lube_per_acre = 0.15 × fuel_per_acre
|
| 10 |
+
# • labor_per_acre = wage / speed_acph
|
| 11 |
+
# • depreciation_per_acre_year = (C − S) / (life_years × acres) ← S from salvage table (hidden from students)
|
| 12 |
+
# • occ_per_acre_year = rate × ((C + S)/2) / (life_years × acres) ← S from salvage table
|
| 13 |
+
#
|
| 14 |
+
# Shared formula:
|
| 15 |
+
# AccumHours = life_years × (acres / speed_acph)
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
from typing import Optional, Dict, Any
|
| 19 |
+
import os, re, json
|
| 20 |
+
import gradio as gr
|
| 21 |
+
|
| 22 |
+
from repair_table_data import get_repair_fraction # repair fraction by accumulated hours
|
| 23 |
+
from salvage_lookup import classify_machine_for_salvage, get_salvage_fraction # salvage fraction (hidden)
|
| 24 |
+
|
| 25 |
+
from salvage_lookup import classify_machine_for_salvage, get_salvage_fraction # keep
|
| 26 |
+
# add this helper inside app.py:
|
| 27 |
+
def salvage_parts(equip: str, cost: float, hp, acres: float, speed: float, life: float):
|
| 28 |
+
"""Return (salv_frac, salvage_value, class_used). Fallback frac = 0.20."""
|
| 29 |
+
try:
|
| 30 |
+
cls = classify_machine_for_salvage(equip, hp)
|
| 31 |
+
except Exception:
|
| 32 |
+
cls = None
|
| 33 |
+
annual_hours = float(acres) / max(float(speed), 1e-9)
|
| 34 |
+
try:
|
| 35 |
+
frac = get_salvage_fraction(cls, annual_hours, float(life)) if cls else None
|
| 36 |
+
except Exception:
|
| 37 |
+
frac = None
|
| 38 |
+
if frac is None:
|
| 39 |
+
frac = 0.20
|
| 40 |
+
return float(frac), float(cost) * float(frac), cls
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
import copy
|
| 44 |
+
|
| 45 |
+
# Which fields we track in the student HUD
|
| 46 |
+
HUD_FIELDS = [
|
| 47 |
+
"equipment_name", "acres", "speed_acph", "life_years", "cost",
|
| 48 |
+
"which_cost", "pto_hp", "diesel_price", "wage", "rate"
|
| 49 |
+
]
|
| 50 |
+
|
| 51 |
+
# Requirements to be able to compute each cost
|
| 52 |
+
REQUIREMENTS = {
|
| 53 |
+
"repair_per_acre_year": ["equipment_name", "acres", "speed_acph", "life_years", "cost"],
|
| 54 |
+
"fuel_per_acre": ["acres", "speed_acph", "life_years", "cost", "pto_hp", "diesel_price"],
|
| 55 |
+
"lube_per_acre": ["acres", "speed_acph", "life_years", "cost", "pto_hp", "diesel_price"],
|
| 56 |
+
"labor_per_acre": ["acres", "speed_acph", "life_years", "cost", "wage"],
|
| 57 |
+
"depreciation_per_acre_year": ["equipment_name", "acres", "speed_acph", "life_years", "cost"],
|
| 58 |
+
"occ_per_acre_year": ["equipment_name", "acres", "speed_acph", "life_years", "cost", "rate"],
|
| 59 |
+
"tax": ["cost"],
|
| 60 |
+
"insurance": ["cost"],
|
| 61 |
+
"housing": ["cost"],
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
REQUIREMENTS.update({
|
| 65 |
+
"tax": ["cost", "acres"],
|
| 66 |
+
"insurance": ["cost", "acres"],
|
| 67 |
+
"housing": ["cost", "acres"],
|
| 68 |
+
})
|
| 69 |
+
|
| 70 |
+
def _pp(obj):
|
| 71 |
+
try:
|
| 72 |
+
return json.dumps(obj, indent=2, ensure_ascii=False, default=str)
|
| 73 |
+
except Exception:
|
| 74 |
+
return str(obj)
|
| 75 |
+
|
| 76 |
+
def merge_into_hud(hud: dict, new_data: dict) -> tuple[dict, list]:
|
| 77 |
+
"""Overwrite HUD with any non-null values from new_data. Return (updated_hud, changed_keys)."""
|
| 78 |
+
hud = copy.deepcopy(hud)
|
| 79 |
+
changed = []
|
| 80 |
+
for k in HUD_FIELDS:
|
| 81 |
+
if k in new_data and new_data[k] is not None:
|
| 82 |
+
if hud.get(k) != new_data[k]:
|
| 83 |
+
hud[k] = new_data[k]
|
| 84 |
+
changed.append(k)
|
| 85 |
+
return hud, changed
|
| 86 |
+
|
| 87 |
+
def missing_for(which_cost: str, hud: dict) -> list:
|
| 88 |
+
req = REQUIREMENTS.get(which_cost or "", [])
|
| 89 |
+
miss = [k for k in req if hud.get(k) in (None, "", float("nan"))]
|
| 90 |
+
return miss
|
| 91 |
+
|
| 92 |
+
#def _pp(obj):
|
| 93 |
+
# try:
|
| 94 |
+
# return json.dumps(obj, indent=2, ensure_ascii=False, default=str)
|
| 95 |
+
# except Exception:
|
| 96 |
+
# return str(obj)
|
| 97 |
+
|
| 98 |
+
# ---- Optional LLM ----
|
| 99 |
+
LLM_OK = False
|
| 100 |
+
client = None
|
| 101 |
+
try:
|
| 102 |
+
from openai import OpenAI
|
| 103 |
+
if os.getenv("OPENAI_API_KEY"):
|
| 104 |
+
client = OpenAI()
|
| 105 |
+
LLM_OK = True
|
| 106 |
+
except Exception:
|
| 107 |
+
LLM_OK = False
|
| 108 |
+
|
| 109 |
+
JERRY_SYSTEM_PROMPT = (
|
| 110 |
+
"You are JERRY, a farm machinery cost coach **in training**. The student is the boss; "
|
| 111 |
+
"you do exactly what they ask and nothing extra. Your job is to READ the student's sentence "
|
| 112 |
+
"and OUTPUT ONLY a **minified JSON** object that our app will compute with.\n"
|
| 113 |
+
|
| 114 |
+
"SCHEMA (always output these keys when present):\n"
|
| 115 |
+
"equipment_name (string), acres (number), speed_acph (number), life_years (number), cost (number), "
|
| 116 |
+
"which_cost (one of: 'repair_per_acre_year','fuel_per_acre','lube_per_acre','labor_per_acre',"
|
| 117 |
+
"'depreciation_per_acre_year','occ_per_acre_year'), "
|
| 118 |
+
"OPTIONAL: pto_hp (number), diesel_price (number), wage (number), rate (number), "
|
| 119 |
+
"OPTIONAL diagnostics: missing (array of strings listing required-but-missing fields).\n"
|
| 120 |
+
|
| 121 |
+
"TEACHING NOTES (formulas are for validation only—DO NOT COMPUTE VALUES):\n"
|
| 122 |
+
"- Fuel_per_acre = (0.044 * pto_hp * diesel_price) / speed_acph\n"
|
| 123 |
+
"- Lube_per_acre = 0.15 * Fuel_per_acre (needs same inputs as fuel)\n"
|
| 124 |
+
"- Labor_per_acre = wage / speed_acph\n"
|
| 125 |
+
"- Repair_per_acre_year uses a table fraction at AccumHours, where AccumHours = life_years * (acres / speed_acph)\n"
|
| 126 |
+
"- Depreciation_per_acre_year = (Cost - Salvage) / (life_years * acres) (salvage is internal; you never output it)\n"
|
| 127 |
+
"- OCC_per_acre_year = rate * ((Cost + Salvage)/2) / (life_years * acres) (salvage internal)\n"
|
| 128 |
+
|
| 129 |
+
"CHECKS & BALANCES (training Jerry):\n"
|
| 130 |
+
"1) The student ALWAYS supplies the five base items: equipment_name, acres, speed_acph, life_years, cost.\n"
|
| 131 |
+
" - If any base item is missing, still output JSON with that field = null and list it in 'missing'.\n"
|
| 132 |
+
"2) For the requested cost, ensure the extra inputs exist:\n"
|
| 133 |
+
" - fuel_per_acre: require pto_hp and diesel_price; if absent set nulls and add to 'missing'.\n"
|
| 134 |
+
" - lube_per_acre: require pto_hp and diesel_price; if absent set nulls and add to 'missing'.\n"
|
| 135 |
+
" - labor_per_acre: require wage; if absent set null and add to 'missing'.\n"
|
| 136 |
+
" - depreciation_per_acre_year, occ_per_acre_year: DO NOT ask for salvage; it is internal.\n"
|
| 137 |
+
"3) Parse numbers robustly—accept units and symbols and strip them: '$', commas, '%', 'ac/hr', 'acph', 'acres per hour', '/gal'. "
|
| 138 |
+
"Examples: '$350,000' -> 350000; '3.80/gal' -> 3.80; '12 ac/hr' -> 12; '8%' -> 0.08.\n"
|
| 139 |
+
"4) Never invent numbers. If unsure, use null and list the field in 'missing'.\n"
|
| 140 |
+
"5) Map the student’s words to exactly one which_cost (priority by explicit keyword in the text):\n"
|
| 141 |
+
" 'repair' -> 'repair_per_acre_year';\n"
|
| 142 |
+
" 'fuel' or 'fuel cost' -> 'fuel_per_acre';\n"
|
| 143 |
+
" 'lube' or 'lubrication' -> 'lube_per_acre';\n"
|
| 144 |
+
" 'labor' or 'wage' -> 'labor_per_acre';\n"
|
| 145 |
+
" 'depreciation' or 'depr' or 'straight line' -> 'depreciation_per_acre_year';\n"
|
| 146 |
+
" 'occ' or 'opportunity cost' or 'interest' -> 'occ_per_acre_year'.\n"
|
| 147 |
+
" If none appears, leave which_cost null.\n"
|
| 148 |
+
"6) Accept em dashes or punctuation as separators (e.g., '— fuel').\n"
|
| 149 |
+
|
| 150 |
+
"OUTPUT RULES:\n"
|
| 151 |
+
"- Output ONLY a single minified JSON object (no prose, no code block, no comments).\n"
|
| 152 |
+
"- Keys must match the schema exactly. Use nulls for unknown numerics. Include 'missing' only when nonempty.\n"
|
| 153 |
+
|
| 154 |
+
"EXAMPLES (do not echo back; just follow the pattern):\n"
|
| 155 |
+
"Input: '4WD tractor, acres=1200, speed=12 ac/hr, life=8, cost=$350000, hp=300, diesel=3.80 — fuel'\n"
|
| 156 |
+
"Output: {\"equipment_name\":\"4WD tractor\",\"acres\":1200,\"speed_acph\":12,\"life_years\":8,\"cost\":350000,"
|
| 157 |
+
"\"pto_hp\":300,\"diesel_price\":3.8,\"which_cost\":\"fuel_per_acre\"}\n"
|
| 158 |
+
|
| 159 |
+
"Input: 'Planter acres 900 speed 9 ac/hr life 12 cost 180000 lube'\n"
|
| 160 |
+
"Output: {\"equipment_name\":\"Planter\",\"acres\":900,\"speed_acph\":9,\"life_years\":12,\"cost\":180000,"
|
| 161 |
+
"\"which_cost\":\"lube_per_acre\"}\n"
|
| 162 |
+
|
| 163 |
+
"Input: 'Tractor acres=1000 speed=10 ac/hr life=10 cost=$200,000 wage $22 — labor'\n"
|
| 164 |
+
"Output: {\"equipment_name\":\"Tractor\",\"acres\":1000,\"speed_acph\":10,\"life_years\":10,\"cost\":200000,"
|
| 165 |
+
"\"wage\":22,\"which_cost\":\"labor_per_acre\"}\n"
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# ---- Computations ----
|
| 171 |
+
|
| 172 |
+
def accumulated_hours(life_years: float, acres: float, speed_acph: float) -> float:
|
| 173 |
+
return float(life_years) * (float(acres)/max(speed_acph,1e-9))
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _salvage_value_hidden(equipment_name: str, cost: float, acres: float, speed_acph: float,
|
| 177 |
+
life_years: float, pto_hp: Optional[float]) -> float:
|
| 178 |
+
"""Hidden salvage value S = cost × salv_frac (from table). Students never see S directly."""
|
| 179 |
+
machine_class = classify_machine_for_salvage(equipment_name, pto_hp)
|
| 180 |
+
annual_hours = float(acres)/max(speed_acph,1e-9)
|
| 181 |
+
salv_frac = get_salvage_fraction(machine_class, annual_hours, float(life_years)) if machine_class else None
|
| 182 |
+
if salv_frac is None:
|
| 183 |
+
salv_frac = 0.20 # safety fallback
|
| 184 |
+
return float(cost) * float(salv_frac)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
# --- Salvage helper (table-driven; fallback = 20%) ---
|
| 188 |
+
def salvage_value(equip: str, cost: float, hp, acres: float, speed: float, life: float) -> float:
|
| 189 |
+
"""
|
| 190 |
+
Return salvage $ using your salvage table.
|
| 191 |
+
- Classify machine with (equip, hp)
|
| 192 |
+
- annual_hours = acres / speed
|
| 193 |
+
- age_years = life
|
| 194 |
+
- salvage = cost * salvage_fraction
|
| 195 |
+
Fallback to 0.20 if the table can’t classify or returns None.
|
| 196 |
+
"""
|
| 197 |
+
try:
|
| 198 |
+
cls = classify_machine_for_salvage(equip, hp)
|
| 199 |
+
except Exception:
|
| 200 |
+
cls = None
|
| 201 |
+
|
| 202 |
+
try:
|
| 203 |
+
annual_hours = float(acres) / max(float(speed), 1e-9)
|
| 204 |
+
except Exception:
|
| 205 |
+
annual_hours = 0.0
|
| 206 |
+
|
| 207 |
+
try:
|
| 208 |
+
frac = get_salvage_fraction(cls, annual_hours, float(life)) if cls else None
|
| 209 |
+
except Exception:
|
| 210 |
+
frac = None
|
| 211 |
+
|
| 212 |
+
if frac is None:
|
| 213 |
+
frac = 0.20
|
| 214 |
+
return float(cost) * float(frac)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def compute_repair_per_acre_year(equip: str, cost: float, life: float, acres: float, speed: float) -> Dict[str, Any]:
|
| 218 |
+
hrs = accumulated_hours(life, acres, speed)
|
| 219 |
+
frac, canon, lo, hi = get_repair_fraction(equip, hrs)
|
| 220 |
+
total_repair = cost * frac
|
| 221 |
+
val = total_repair / max(life*acres,1e-9)
|
| 222 |
+
return {"canon":canon,"hours":hrs,"fraction":frac,"repair_per_acre_year":val}
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def compute_fuel_per_acre(hp: float, diesel: float, speed: float) -> float:
|
| 226 |
+
return (0.044*hp*diesel)/max(speed,1e-9)
|
| 227 |
+
|
| 228 |
+
def compute_lube_per_acre(fuel: float) -> float:
|
| 229 |
+
return 0.15*fuel
|
| 230 |
+
|
| 231 |
+
def compute_labor_per_acre(wage: float, speed: float) -> float:
|
| 232 |
+
return wage/max(speed,1e-9)
|
| 233 |
+
|
| 234 |
+
def compute_depr_per_acre_year(cost: float, salvage_value: float, life: float, acres: float) -> float:
|
| 235 |
+
return (float(cost) - float(salvage_value)) / max(life * acres, 1e-9)
|
| 236 |
+
|
| 237 |
+
def compute_occ_per_acre_year(cost: float, salvage: float, rate: float, life: float, acres: float) -> float:
|
| 238 |
+
avg_invest = 0.5 * (cost + salvage)
|
| 239 |
+
return float(rate) * avg_invest / max(acres, 1e-9)
|
| 240 |
+
|
| 241 |
+
def compute_tax_per_acre(cost: float, acres: float) -> float:
|
| 242 |
+
# Tax = 1% of cost, allocated per acre
|
| 243 |
+
return 0.01 * float(cost) / max(float(acres), 1e-9)
|
| 244 |
+
|
| 245 |
+
def compute_insurance_per_acre(cost: float, acres: float) -> float:
|
| 246 |
+
# Insurance = 0.5% of cost, allocated per acre
|
| 247 |
+
return 0.005 * float(cost) / max(float(acres), 1e-9)
|
| 248 |
+
|
| 249 |
+
def compute_housing_per_acre(cost: float, acres: float) -> float:
|
| 250 |
+
# Housing = 0.5% of cost, allocated per acre
|
| 251 |
+
return 0.005 * float(cost) / max(float(acres), 1e-9)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
# ---- Keyword intent + Regex fallback ----
|
| 255 |
+
NUM=r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?"
|
| 256 |
+
|
| 257 |
+
def _find(pat,s): m=re.search(pat,s,re.I); return float(m.group(1)) if m else None
|
| 258 |
+
|
| 259 |
+
def _name_guess(s: str) -> Optional[str]:
|
| 260 |
+
m=re.search(r"^\s*([^,\n]+?)\s*(?:,|acres|$)",s,flags=re.I)
|
| 261 |
+
return m.group(1).strip() if m else None
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def which_from_keywords(s: str) -> Optional[str]:
|
| 265 |
+
t=s.lower()
|
| 266 |
+
if "repair" in t or "maint" in t: return "repair_per_acre_year"
|
| 267 |
+
if "fuel" in t: return "fuel_per_acre"
|
| 268 |
+
if "lube" in t or "lubric" in t: return "lube_per_acre"
|
| 269 |
+
if "labor" in t or "wage" in t: return "labor_per_acre"
|
| 270 |
+
if "depr" in t or "straight line" in t: return "depreciation_per_acre_year"
|
| 271 |
+
if any(k in t for k in ["occ","interest","opportunity cost"]): return "occ_per_acre_year"
|
| 272 |
+
return None
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def regex_parse(user:str)->Dict[str,Any]:
|
| 276 |
+
s=user
|
| 277 |
+
d:Dict[str,Any]={}
|
| 278 |
+
d["equipment_name"]=_name_guess(s)
|
| 279 |
+
d["acres"]=_find(r"acres\s*(?:=|:)?\s*("+NUM+")",s) or _find(r"("+NUM+")\s*acres",s)
|
| 280 |
+
d["speed_acph"]=_find(r"(speed|acph)\s*(?:=|:)?\s*("+NUM+")",s) or _find(r"("+NUM+")\s*(?:acph|ac/hr|ac\/hr|acres per hour)",s)
|
| 281 |
+
d["life_years"]=_find(r"(life|years?)\s*(?:=|:)?\s*("+NUM+")",s)
|
| 282 |
+
d["cost"]=_find(r"(cost|price|purchase)\s*(?:=|:|\$)?\s*("+NUM+")",s) or _find(r"\$("+NUM+")",s)
|
| 283 |
+
d["pto_hp"]=_find(r"(pto\s*hp|hp|horsepower)\s*(?:=|:)?\s*("+NUM+")",s)
|
| 284 |
+
d["diesel_price"]=_find(r"(diesel|fuel)\s*(price)?\s*(?:=|:|\$)?\s*("+NUM+")",s)
|
| 285 |
+
d["wage"]=_find(r"(wage|labor\s*rate)\s*(?:=|:|\$)?\s*("+NUM+")",s)
|
| 286 |
+
d["rate"]=_find(r"(rate|interest|occ)\s*(?:=|:)?\s*("+NUM+")\s*%?",s)
|
| 287 |
+
w=which_from_keywords(s)
|
| 288 |
+
if w: d["which_cost"]=w
|
| 289 |
+
return {k:v for k,v in d.items() if v is not None}
|
| 290 |
+
|
| 291 |
+
# ---- LLM parse (same schema) ----
|
| 292 |
+
|
| 293 |
+
def llm_parse(user:str)->Dict[str,Any]:
|
| 294 |
+
if not LLM_OK: return {}
|
| 295 |
+
try:
|
| 296 |
+
resp=client.chat.completions.create(
|
| 297 |
+
model="gpt-4o-mini",temperature=0,
|
| 298 |
+
messages=[{"role":"system","content":JERRY_SYSTEM_PROMPT},{"role":"user","content":user}])
|
| 299 |
+
txt=(resp.choices[0].message.content or "").strip()
|
| 300 |
+
if txt.startswith("{"): return json.loads(txt)
|
| 301 |
+
except: pass
|
| 302 |
+
return {}
|
| 303 |
+
|
| 304 |
+
# ---- Orchestrator ----
|
| 305 |
+
DEFAULT_RATE=0.08
|
| 306 |
+
def _to_float(x):
|
| 307 |
+
if x is None: return None
|
| 308 |
+
if isinstance(x, (int, float)): return float(x)
|
| 309 |
+
s = str(x).strip().lower()
|
| 310 |
+
# strip common decorations
|
| 311 |
+
s = s.replace("$","").replace(",","")
|
| 312 |
+
s = s.replace("ac/hr","").replace("acph","").replace("acres per hour","")
|
| 313 |
+
s = s.replace("%","")
|
| 314 |
+
# keep only number-ish chars
|
| 315 |
+
s = re.sub(r"[^0-9eE\.\-\+]", "", s)
|
| 316 |
+
try:
|
| 317 |
+
return float(s)
|
| 318 |
+
except Exception:
|
| 319 |
+
return None
|
| 320 |
+
|
| 321 |
+
def which_from_keywords(s: str) -> Optional[str]:
|
| 322 |
+
t = s.lower()
|
| 323 |
+
if "fuel" in t: return "fuel_per_acre"
|
| 324 |
+
if "lube" in t or "lubric" in t: return "lube_per_acre"
|
| 325 |
+
if "labor" in t or "wage" in t: return "labor_per_acre"
|
| 326 |
+
if "depr" in t or "depreciation" in t or "straight line" in t: return "depreciation_per_acre_year"
|
| 327 |
+
if "occ" in t or "opportunity cost" in t or "interest" in t: return "occ_per_acre_year"
|
| 328 |
+
if "repair" in t or "maint" in t: return "repair_per_acre_year"
|
| 329 |
+
return None
|
| 330 |
+
|
| 331 |
+
def normalize_llm_data(data: Dict[str, Any], user_text: str) -> Dict[str, Any]:
|
| 332 |
+
d = dict(data or {})
|
| 333 |
+
|
| 334 |
+
# unify key names the LLM might choose
|
| 335 |
+
if "hp" in d and "pto_hp" not in d:
|
| 336 |
+
d["pto_hp"] = d["hp"]
|
| 337 |
+
if "diesel" in d and "diesel_price" not in d:
|
| 338 |
+
d["diesel_price"] = d["diesel"]
|
| 339 |
+
if "life" in d and "life_years" not in d:
|
| 340 |
+
d["life_years"] = d["life"]
|
| 341 |
+
if "speed" in d and "speed_acph" not in d:
|
| 342 |
+
d["speed_acph"] = d["speed"]
|
| 343 |
+
|
| 344 |
+
# normalize which_cost
|
| 345 |
+
wc = str(d.get("which_cost","")).strip().lower()
|
| 346 |
+
wc_map = {
|
| 347 |
+
"fuel":"fuel_per_acre",
|
| 348 |
+
"fuel per acre":"fuel_per_acre",
|
| 349 |
+
"fuel_per_acre":"fuel_per_acre",
|
| 350 |
+
"repair":"repair_per_acre_year",
|
| 351 |
+
"repair_per_acre_year":"repair_per_acre_year",
|
| 352 |
+
"lube":"lube_per_acre",
|
| 353 |
+
"lube_per_acre":"lube_per_acre",
|
| 354 |
+
"labor":"labor_per_acre",
|
| 355 |
+
"labor_per_acre":"labor_per_acre",
|
| 356 |
+
"depreciation":"depreciation_per_acre_year",
|
| 357 |
+
"depr":"depreciation_per_acre_year",
|
| 358 |
+
"depreciation_sl":"depreciation_per_acre_year",
|
| 359 |
+
"occ":"occ_per_acre_year",
|
| 360 |
+
"opportunity cost":"occ_per_acre_year",
|
| 361 |
+
"interest":"occ_per_acre_year",
|
| 362 |
+
"occ_per_acre_year":"occ_per_acre_year",
|
| 363 |
+
}
|
| 364 |
+
d["which_cost"] = wc_map.get(wc) or which_from_keywords(user_text) or d.get("which_cost")
|
| 365 |
+
|
| 366 |
+
# cast numerics (accept strings like "$350,000", "8%")
|
| 367 |
+
for k in ["acres","speed_acph","life_years","cost","pto_hp","diesel_price","wage","rate"]:
|
| 368 |
+
if k in d:
|
| 369 |
+
d[k] = _to_float(d[k])
|
| 370 |
+
|
| 371 |
+
# rate may be typed as 8 meaning 8% → 0.08
|
| 372 |
+
if d.get("rate") is not None and d["rate"] > 1.0:
|
| 373 |
+
d["rate"] = d["rate"] / 100.0
|
| 374 |
+
|
| 375 |
+
return d
|
| 376 |
+
|
| 377 |
+
def jerry_agent(user: str):
|
| 378 |
+
# ---------- Parse (LLM first, then regex) ----------
|
| 379 |
+
raw_llm = llm_parse(user) # dict or {}
|
| 380 |
+
if raw_llm:
|
| 381 |
+
mode = "LLM"
|
| 382 |
+
raw_regex = {}
|
| 383 |
+
raw = raw_llm
|
| 384 |
+
else:
|
| 385 |
+
mode = "regex"
|
| 386 |
+
raw_regex = regex_parse(user) # dict or {}
|
| 387 |
+
raw = raw_regex
|
| 388 |
+
|
| 389 |
+
# Fallback: infer which_cost from the text if parser left it empty
|
| 390 |
+
def _which_from_text(txt: str):
|
| 391 |
+
t = (txt or "").lower()
|
| 392 |
+
if "fuel" in t: return "fuel_per_acre"
|
| 393 |
+
if "lube" in t or "lubric" in t: return "lube_per_acre"
|
| 394 |
+
if "labor" in t or "wage" in t: return "labor_per_acre"
|
| 395 |
+
if "depr" in t or "depreciation" in t or "straight line" in t: return "depreciation_per_acre_year"
|
| 396 |
+
if "occ" in t or "opportunity cost" in t or "interest" in t: return "occ_per_acre_year"
|
| 397 |
+
if "tax" in t: return "tax"
|
| 398 |
+
if "insurance" in t or "ins " in t: return "insurance"
|
| 399 |
+
if "housing" in t or "house " in t: return "housing"
|
| 400 |
+
if "repair" in t or "maint" in t: return "repair_per_acre_year"
|
| 401 |
+
if "tax" in t or "taxes" in t: return "tax"
|
| 402 |
+
if "insurance" in t or "ins " in t or "insur" in t: return "insurance"
|
| 403 |
+
if "housing" in t or "house " in t or "storage" in t or "shed" in t: return "housing"
|
| 404 |
+
return None
|
| 405 |
+
|
| 406 |
+
data = dict(raw)
|
| 407 |
+
if not data.get("which_cost"):
|
| 408 |
+
guess = _which_from_text(user)
|
| 409 |
+
if guess: data["which_cost"] = guess
|
| 410 |
+
|
| 411 |
+
# ---------- Pull fields with defensive casting ----------
|
| 412 |
+
def _f(x, default=None):
|
| 413 |
+
if x is None: return default
|
| 414 |
+
try: return float(x)
|
| 415 |
+
except: return default
|
| 416 |
+
|
| 417 |
+
equip = str(data.get("equipment_name", "tractor"))
|
| 418 |
+
acres = _f(data.get("acres"), 0.0)
|
| 419 |
+
speed = _f(data.get("speed_acph"), 1.0)
|
| 420 |
+
life = _f(data.get("life_years"), 1.0)
|
| 421 |
+
cost = _f(data.get("cost"), 0.0)
|
| 422 |
+
which = str(data.get("which_cost") or "")
|
| 423 |
+
|
| 424 |
+
hp = _f(data.get("hp") if data.get("hp") is not None else data.get("pto_hp"))
|
| 425 |
+
diesel = _f(data.get("diesel_price"))
|
| 426 |
+
wage = _f(data.get("wage"))
|
| 427 |
+
rate = _f(data.get("rate"), 0.08)
|
| 428 |
+
if rate is not None and rate > 1.0: # accept 8 or 8% as 0.08
|
| 429 |
+
rate = rate / 100.0
|
| 430 |
+
|
| 431 |
+
# ---------- Derived diagnostics (for the teacher JSON box) ----------
|
| 432 |
+
annual_hours = acres / max(speed, 1e-9)
|
| 433 |
+
accum_hours = life * annual_hours
|
| 434 |
+
|
| 435 |
+
# Salvage diagnostics
|
| 436 |
+
machine_class = classify_machine_for_salvage(equip, hp)
|
| 437 |
+
salv_frac = None
|
| 438 |
+
if machine_class:
|
| 439 |
+
try:
|
| 440 |
+
salv_frac = get_salvage_fraction(machine_class, annual_hours, life)
|
| 441 |
+
except Exception:
|
| 442 |
+
salv_frac = None
|
| 443 |
+
salvage = cost * (salv_frac if salv_frac is not None else 0.20)
|
| 444 |
+
|
| 445 |
+
# Repair fraction diagnostics (safe try)
|
| 446 |
+
repair_frac = None
|
| 447 |
+
repair_bracket = None
|
| 448 |
+
canon_name = equip
|
| 449 |
+
try:
|
| 450 |
+
rf, canon, lo, hi = get_repair_fraction(equip, accum_hours)
|
| 451 |
+
repair_frac = rf
|
| 452 |
+
repair_bracket = {"low": {"hours": lo[0], "frac": lo[1]},
|
| 453 |
+
"high":{"hours": hi[0], "frac": hi[1]}}
|
| 454 |
+
canon_name = canon
|
| 455 |
+
except Exception:
|
| 456 |
+
pass
|
| 457 |
+
|
| 458 |
+
# ---------- Compute chosen cost ----------
|
| 459 |
+
ans = None
|
| 460 |
+
if which == "repair_per_acre_year":
|
| 461 |
+
# Use the same computation you already rely on
|
| 462 |
+
r = compute_repair_per_acre_year(equip, cost, life, acres, speed)
|
| 463 |
+
ans = f"Jerry → REPAIR per acre-year: fraction={r['fraction']:.3f}, value=${r['repair_per_acre_year']:.2f}/ac/yr (mode={mode})"
|
| 464 |
+
elif which == "fuel_per_acre":
|
| 465 |
+
if hp is None or diesel is None:
|
| 466 |
+
ans = f"Jerry → Need hp and diesel. (mode={mode})"
|
| 467 |
+
else:
|
| 468 |
+
f = compute_fuel_per_acre(hp, diesel, speed)
|
| 469 |
+
ans = f"Jerry → FUEL per acre=${f:.2f} (mode={mode})"
|
| 470 |
+
elif which == "lube_per_acre":
|
| 471 |
+
if hp is None or diesel is None:
|
| 472 |
+
ans = f"Jerry → Need hp and diesel. (mode={mode})"
|
| 473 |
+
else:
|
| 474 |
+
f = compute_fuel_per_acre(hp, diesel, speed)
|
| 475 |
+
l = compute_lube_per_acre(f)
|
| 476 |
+
ans = f"Jerry → LUBE per acre=${l:.2f} (mode={mode})"
|
| 477 |
+
elif which == "labor_per_acre":
|
| 478 |
+
if wage is None:
|
| 479 |
+
ans = f"Jerry → Need wage. (mode={mode})"
|
| 480 |
+
else:
|
| 481 |
+
l = compute_labor_per_acre(wage, speed)
|
| 482 |
+
ans = f"Jerry → LABOR per acre=${l:.2f} (mode={mode})"
|
| 483 |
+
elif which == "depreciation_per_acre_year":
|
| 484 |
+
salv_frac, salvage, salvage_class = salvage_parts(equip, cost, hp, acres, speed, life)
|
| 485 |
+
d = compute_depr_per_acre_year(cost, salvage, life, acres)
|
| 486 |
+
ans = (
|
| 487 |
+
f"Jerry → DEPRECIATION per acre-year=${d:.2f} "
|
| 488 |
+
f"(class={salvage_class}, salvage fraction={salv_frac:.2f}, salvage=${salvage:,.0f}; mode={mode})"
|
| 489 |
+
)
|
| 490 |
+
elif which == "occ_per_acre_year":
|
| 491 |
+
salv_frac, salvage, salvage_class = salvage_parts(equip, cost, hp, acres, speed, life)
|
| 492 |
+
o = compute_occ_per_acre_year(cost, salvage, rate, life, acres)
|
| 493 |
+
ans = (
|
| 494 |
+
f"Jerry → OCC per acre-year=${o:.2f} "
|
| 495 |
+
f"(class={salvage_class}, salvage fraction={salvage_frac:.2f}, salvage=${salvage:,.0f}; mode={mode})"
|
| 496 |
+
)
|
| 497 |
+
elif which == "tax":
|
| 498 |
+
tval = compute_tax_per_acre(cost, acres)
|
| 499 |
+
ans = f"Jerry → TAX per acre=${tval:.2f} (mode={mode})"
|
| 500 |
+
elif which == "insurance":
|
| 501 |
+
ival = compute_insurance_per_acre(cost, acres)
|
| 502 |
+
ans = f"Jerry → INSURANCE per acre=${ival:.2f} (mode={mode})"
|
| 503 |
+
elif which == "housing":
|
| 504 |
+
hval = compute_housing_per_acre(cost, acres)
|
| 505 |
+
ans = f"Jerry → HOUSING per acre=${hval:.2f} (mode={mode})"
|
| 506 |
+
else:
|
| 507 |
+
ans = f"Jerry → Unknown cost. (mode={mode})"
|
| 508 |
+
|
| 509 |
+
# ---------- Build teacher JSON (third box) ----------
|
| 510 |
+
teacher_json = _pp({
|
| 511 |
+
"mode": mode,
|
| 512 |
+
"raw_llm": raw_llm,
|
| 513 |
+
"raw_regex": raw_regex,
|
| 514 |
+
"final_data": data,
|
| 515 |
+
"derived": {
|
| 516 |
+
"machine_canonical": canon_name,
|
| 517 |
+
"machine_class_for_salvage": machine_class,
|
| 518 |
+
"annual_hours": annual_hours,
|
| 519 |
+
"accum_hours": accum_hours,
|
| 520 |
+
"salvage_fraction": salv_frac,
|
| 521 |
+
"salvage_value": salvage,
|
| 522 |
+
"repair_fraction": repair_frac,
|
| 523 |
+
"repair_bracket": repair_bracket,
|
| 524 |
+
}
|
| 525 |
+
})
|
| 526 |
+
|
| 527 |
+
# Return BOTH: (student answer, teacher JSON)
|
| 528 |
+
return ans, teacher_json
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
# ---- UI ----
|
| 532 |
+
with gr.Blocks(css="footer {visibility: hidden}") as demo:
|
| 533 |
+
gr.Markdown("## Jerry — NLP Farm Machinery Cost Coach (per-acre per-year, with salvage)")
|
| 534 |
+
|
| 535 |
+
# --- Session HUD state (persists across prompts within the browser session) ---
|
| 536 |
+
hud_state = gr.State({k: None for k in HUD_FIELDS})
|
| 537 |
+
|
| 538 |
+
with gr.Row():
|
| 539 |
+
with gr.Column():
|
| 540 |
+
q = gr.Textbox(label="Talk to Jerry", lines=4, placeholder="e.g., Tractor, acres=1000, speed=10 ac/hr, life=10, cost=$200000 — repair")
|
| 541 |
+
ask = gr.Button("Ask Jerry")
|
| 542 |
+
with gr.Column():
|
| 543 |
+
out = gr.Textbox(label="Jerry says", lines=8)
|
| 544 |
+
with gr.Column():
|
| 545 |
+
gr.Markdown("### Student HUD — what Jerry sees")
|
| 546 |
+
hud_box = gr.Textbox(label="Final Data (persists this session)", lines=18, value=_pp({k: None for k in HUD_FIELDS}))
|
| 547 |
+
changed_box = gr.Textbox(label="Fields updated by last prompt", lines=3, value="(none)")
|
| 548 |
+
|
| 549 |
+
# Optional: teacher debug accordion if you kept the teacher JSON earlier
|
| 550 |
+
# (If you added a teacher debug already, you can leave this out or keep your version.)
|
| 551 |
+
with gr.Accordion("Teacher debug — Parser & JSON (optional)", open=False):
|
| 552 |
+
dbg = gr.Textbox(label="LLM/Regex + Raw/Final JSON + Diagnostics", lines=22)
|
| 553 |
+
|
| 554 |
+
def _ask_with_hud(user_text, hud):
|
| 555 |
+
# ---- Parse (LLM first, then regex) ----
|
| 556 |
+
raw_llm = llm_parse(user_text)
|
| 557 |
+
if raw_llm:
|
| 558 |
+
mode = "LLM"; raw_regex = {}; raw = raw_llm
|
| 559 |
+
else:
|
| 560 |
+
mode = "regex"; raw_regex = regex_parse(user_text); raw = raw_regex
|
| 561 |
+
|
| 562 |
+
# Fallback which_cost if missing
|
| 563 |
+
def _which_from_text(txt: str):
|
| 564 |
+
t = (txt or "").lower()
|
| 565 |
+
if "fuel" in t: return "fuel_per_acre"
|
| 566 |
+
if "lube" in t or "lubric" in t: return "lube_per_acre"
|
| 567 |
+
if "labor" in t or "wage" in t: return "labor_per_acre"
|
| 568 |
+
if "depr" in t or "depreciation" in t or "straight line" in t: return "depreciation_per_acre_year"
|
| 569 |
+
if "occ" in t or "opportunity cost" in t or "interest" in t: return "occ_per_acre_year"
|
| 570 |
+
if "tax" in t: return "tax"
|
| 571 |
+
if "insurance" in t or "ins " in t: return "insurance"
|
| 572 |
+
if "housing" in t or "house " in t: return "housing"
|
| 573 |
+
if "repair" in t or "maint" in t: return "repair_per_acre_year"
|
| 574 |
+
return None
|
| 575 |
+
|
| 576 |
+
data = dict(raw)
|
| 577 |
+
if not data.get("which_cost"):
|
| 578 |
+
guessed = _which_from_text(user_text)
|
| 579 |
+
if guessed: data["which_cost"] = guessed
|
| 580 |
+
|
| 581 |
+
# ---- Merge into HUD memory ----
|
| 582 |
+
new_hud, changed = merge_into_hud(hud, data)
|
| 583 |
+
|
| 584 |
+
# Pull fields safely from HUD (not just this prompt)
|
| 585 |
+
equip = str(new_hud.get("equipment_name") or "tractor")
|
| 586 |
+
acres = float(new_hud.get("acres") or 0.0)
|
| 587 |
+
speed = float(new_hud.get("speed_acph") or 1.0)
|
| 588 |
+
life = float(new_hud.get("life_years") or 1.0)
|
| 589 |
+
cost = float(new_hud.get("cost") or 0.0)
|
| 590 |
+
which = str(new_hud.get("which_cost") or "")
|
| 591 |
+
|
| 592 |
+
hp = new_hud.get("hp") if new_hud.get("hp") is not None else new_hud.get("pto_hp")
|
| 593 |
+
hp = float(hp) if hp is not None else None
|
| 594 |
+
diesel = float(new_hud.get("diesel_price")) if new_hud.get("diesel_price") is not None else None
|
| 595 |
+
wage = float(new_hud.get("wage")) if new_hud.get("wage") is not None else None
|
| 596 |
+
rate = new_hud.get("rate")
|
| 597 |
+
if rate is not None:
|
| 598 |
+
rate = float(rate)
|
| 599 |
+
if rate > 1.0: rate = rate / 100.0 # accept 8 or 8% as 0.08
|
| 600 |
+
else:
|
| 601 |
+
rate = 0.08
|
| 602 |
+
|
| 603 |
+
# ---- Readiness check for chosen cost ----
|
| 604 |
+
if not which:
|
| 605 |
+
msg = "Jerry → Tell me which cost (e.g., fuel, repair, depreciation). Check the HUD to see what I have so far."
|
| 606 |
+
teacher_dbg = _pp({"mode": mode, "raw_llm": raw_llm, "raw_regex": raw_regex, "final_data": new_hud})
|
| 607 |
+
return msg, _pp(new_hud), ", ".join(changed) or "(none)", teacher_dbg, new_hud
|
| 608 |
+
|
| 609 |
+
missing = missing_for(which, new_hud)
|
| 610 |
+
if missing:
|
| 611 |
+
msg = f"Jerry → Not enough data for {which}. Missing: {', '.join(missing)}. Check the HUD and add what's missing."
|
| 612 |
+
teacher_dbg = _pp({"mode": mode, "raw_llm": raw_llm, "raw_regex": raw_regex, "final_data": new_hud, "missing": missing})
|
| 613 |
+
return msg, _pp(new_hud), ", ".join(changed) or "(none)", teacher_dbg, new_hud
|
| 614 |
+
|
| 615 |
+
# ---- Compute (now that HUD is complete for this cost) ----
|
| 616 |
+
salv_frac, salvage, salvage_class = salvage_parts(equip, cost, hp, acres, speed, life)
|
| 617 |
+
if which == "repair_per_acre_year":
|
| 618 |
+
r = compute_repair_per_acre_year(equip, cost, life, acres, speed)
|
| 619 |
+
ans = f"Jerry → REPAIR per acre-year: fraction={r['fraction']:.3f}, value=${r['repair_per_acre_year']:.2f}/ac/yr (mode={mode})"
|
| 620 |
+
elif which == "fuel_per_acre":
|
| 621 |
+
f = compute_fuel_per_acre(hp, diesel, speed)
|
| 622 |
+
ans = f"Jerry → FUEL per acre=${f:.2f} (mode={mode})"
|
| 623 |
+
elif which == "lube_per_acre":
|
| 624 |
+
f = compute_fuel_per_acre(hp, diesel, speed)
|
| 625 |
+
l = compute_lube_per_acre(f)
|
| 626 |
+
ans = f"Jerry → LUBE per acre=${l:.2f} (mode={mode})"
|
| 627 |
+
elif which == "labor_per_acre":
|
| 628 |
+
l = compute_labor_per_acre(wage, speed)
|
| 629 |
+
ans = f"Jerry → LABOR per acre=${l:.2f} (mode={mode})"
|
| 630 |
+
|
| 631 |
+
elif which == "depreciation_per_acre_year":
|
| 632 |
+
d = compute_depr_per_acre_year(cost, salvage, life, acres)
|
| 633 |
+
ans = (
|
| 634 |
+
f"Jerry → DEPRECIATION per acre-year=${d:.2f} "
|
| 635 |
+
f"(salvage fraction={salv_frac:.2f}, salvage=${salvage:,.0f}; mode={mode})"
|
| 636 |
+
)
|
| 637 |
+
elif which == "occ_per_acre_year":
|
| 638 |
+
o = compute_occ_per_acre_year(cost, salvage, rate, life, acres)
|
| 639 |
+
ans = (
|
| 640 |
+
f"Jerry → OCC per acre-year=${o:.2f} "
|
| 641 |
+
f"(salvage fraction={salv_frac:.2f}, salvage=${salvage:,.0f}; mode={mode})"
|
| 642 |
+
)
|
| 643 |
+
elif which == "tax":
|
| 644 |
+
tval = compute_tax_per_acre(cost, acres)
|
| 645 |
+
ans = f"Jerry → TAX per acre=${tval:.2f} (mode={mode})"
|
| 646 |
+
elif which == "insurance":
|
| 647 |
+
ival = compute_insurance_per_acre(cost, acres)
|
| 648 |
+
ans = f"Jerry → INSURANCE per acre=${ival:.2f} (mode={mode})"
|
| 649 |
+
elif which == "housing":
|
| 650 |
+
hval = compute_housing_per_acre(cost, acres)
|
| 651 |
+
ans = f"Jerry → HOUSING per acre=${hval:.2f} (mode={mode})"
|
| 652 |
+
teacher_dbg = _pp({
|
| 653 |
+
"mode": mode,
|
| 654 |
+
"raw_llm": raw_llm,
|
| 655 |
+
"raw_regex": raw_regex,
|
| 656 |
+
"final_data": new_hud
|
| 657 |
+
})
|
| 658 |
+
return ans, _pp(new_hud), ", ".join(changed) or "(none)", teacher_dbg, new_hud
|
| 659 |
+
|
| 660 |
+
# Wire it up; we update 5 outputs: student answer, HUD JSON, changed fields, teacher debug, and the state
|
| 661 |
+
ask.click(_ask_with_hud, [q, hud_state], [out, hud_box, changed_box, dbg, hud_state])
|
| 662 |
+
|
| 663 |
+
if __name__=="__main__":
|
| 664 |
+
demo.queue().launch(server_name="0.0.0.0",server_port=int(os.getenv("PORT","7860")))
|
repair_table_data.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# repair_table_data.py
|
| 2 |
+
# Full Iowa State "Accumulated Repair Costs as a Percentage of New List Price" (Table 3)
|
| 3 |
+
# Source figure: American Society of Agricultural Engineers (1996).
|
| 4 |
+
# Provides: REPAIR_TABLE, get_repair_fraction(machine, hours), list_machines()
|
| 5 |
+
|
| 6 |
+
from typing import Dict, Tuple, Optional, List
|
| 7 |
+
import re
|
| 8 |
+
|
| 9 |
+
# ----------------------------
|
| 10 |
+
# 1) Canonical table (fractions, not percents)
|
| 11 |
+
# ----------------------------
|
| 12 |
+
REPAIR_TABLE: Dict[str, Dict[int, float]] = {
|
| 13 |
+
# --- Tractors ---
|
| 14 |
+
"Two-wheel drive tractor": {
|
| 15 |
+
1000: 0.01, 2000: 0.03, 3000: 0.06, 4000: 0.11, 5000: 0.18,
|
| 16 |
+
6000: 0.25, 7000: 0.34, 8000: 0.45, 9000: 0.57, 10000: 0.70
|
| 17 |
+
},
|
| 18 |
+
"Four-wheel drive tractor": {
|
| 19 |
+
1000: 0.00, 2000: 0.01, 3000: 0.03, 4000: 0.05, 5000: 0.08,
|
| 20 |
+
6000: 0.11, 7000: 0.15, 8000: 0.19, 9000: 0.24, 10000: 0.30
|
| 21 |
+
},
|
| 22 |
+
|
| 23 |
+
# --- Tillage Implements ---
|
| 24 |
+
"Moldboard plow": {200:0.02, 400:0.06, 600:0.12, 800:0.19, 1000:0.29,
|
| 25 |
+
1200:0.40, 1400:0.53, 1600:0.68, 1800:0.84, 2000:1.01},
|
| 26 |
+
"Heavy-duty disk": {200:0.01, 400:0.04, 600:0.08, 800:0.12, 1000:0.18,
|
| 27 |
+
1200:0.25, 1400:0.32, 1600:0.40, 1800:0.49, 2000:0.58},
|
| 28 |
+
"Tandem disk": {200:0.01, 400:0.04, 600:0.08, 800:0.12, 1000:0.18,
|
| 29 |
+
1200:0.25, 1400:0.32, 1600:0.40, 1800:0.49, 2000:0.58},
|
| 30 |
+
"Chisel plow": {200:0.03, 400:0.08, 600:0.14, 800:0.20, 1000:0.28,
|
| 31 |
+
1200:0.36, 1400:0.45, 1600:0.54, 1800:0.64, 2000:0.74},
|
| 32 |
+
"Field cultivator": {200:0.03, 400:0.07, 600:0.13, 800:0.20, 1000:0.27,
|
| 33 |
+
1200:0.35, 1400:0.43, 1600:0.52, 1800:0.61, 2000:0.71},
|
| 34 |
+
"Harrow": {200:0.02, 400:0.05, 600:0.08, 800:0.12, 1000:0.16,
|
| 35 |
+
1200:0.22, 1400:0.29, 1600:0.37, 1800:0.46, 2000:0.56},
|
| 36 |
+
"Roller-packer, mulcher": {200:0.02, 400:0.05, 600:0.08, 800:0.12, 1000:0.16,
|
| 37 |
+
1200:0.22, 1400:0.29, 1600:0.37, 1800:0.46, 2000:0.56},
|
| 38 |
+
"Rotary hoe": {200:0.02, 400:0.06, 600:0.11, 800:0.17, 1000:0.23,
|
| 39 |
+
1200:0.29, 1400:0.37, 1600:0.44, 1800:0.52, 2000:0.60},
|
| 40 |
+
"Row crop cultivator": {200:0.00, 400:0.02, 600:0.06, 800:0.10, 1000:0.17,
|
| 41 |
+
1200:0.25, 1400:0.36, 1600:0.48, 1800:0.62, 2000:0.78},
|
| 42 |
+
|
| 43 |
+
# --- Harvesters & Hay ---
|
| 44 |
+
"Corn picker": {200:0.00, 400:0.02, 600:0.04, 800:0.08, 1000:0.14,
|
| 45 |
+
1200:0.21, 1400:0.30, 1600:0.41, 1800:0.54, 2000:0.69},
|
| 46 |
+
"Combine (pull)": {200:0.00, 400:0.02, 600:0.04, 800:0.08, 1000:0.14,
|
| 47 |
+
1200:0.21, 1400:0.30, 1600:0.41, 1800:0.54, 2000:0.69},
|
| 48 |
+
"Potato harvester": {200:0.02, 400:0.05, 600:0.09, 800:0.14, 1000:0.19,
|
| 49 |
+
1200:0.25, 1400:0.32, 1600:0.39, 1800:0.46, 2000:0.54},
|
| 50 |
+
"Mower-conditioner": {200:0.01, 400:0.04, 600:0.08, 800:0.13, 1000:0.18,
|
| 51 |
+
1200:0.24, 1400:0.31, 1600:0.38, 1800:0.46, 2000:0.55},
|
| 52 |
+
"Mower-conditioner (rotary)": {200:0.01, 400:0.04, 600:0.08, 800:0.13, 1000:0.18,
|
| 53 |
+
1200:0.24, 1400:0.31, 1600:0.38, 1800:0.46, 2000:0.55},
|
| 54 |
+
"Rake": {200:0.02, 400:0.05, 600:0.09, 800:0.12, 1000:0.16,
|
| 55 |
+
1200:0.22, 1400:0.27, 1600:0.33, 1800:0.39, 2000:0.45},
|
| 56 |
+
"Rectangular baler": {200:0.01, 400:0.03, 600:0.06, 800:0.10, 1000:0.15,
|
| 57 |
+
1200:0.20, 1400:0.26, 1600:0.32, 1800:0.38, 2000:0.45},
|
| 58 |
+
"Large square baler": {200:0.01, 400:0.03, 600:0.06, 800:0.10, 1000:0.15,
|
| 59 |
+
1200:0.20, 1400:0.26, 1600:0.32, 1800:0.38, 2000:0.45},
|
| 60 |
+
"Forage harvester (pull)": {200:0.01, 400:0.03, 600:0.07, 800:0.10, 1000:0.15,
|
| 61 |
+
1200:0.20, 1400:0.26, 1600:0.32, 1800:0.38, 2000:0.45},
|
| 62 |
+
|
| 63 |
+
"Forage harvester (SP)": {300:0.00, 600:0.01, 900:0.02, 1200:0.04, 1500:0.07,
|
| 64 |
+
1800:0.10, 2100:0.13, 2400:0.17, 2700:0.22, 3000:0.27},
|
| 65 |
+
"Combine (SP)": {300:0.00, 600:0.01, 900:0.02, 1200:0.04, 1500:0.07,
|
| 66 |
+
1800:0.10, 2100:0.13, 2400:0.17, 2700:0.22, 3000:0.27},
|
| 67 |
+
"Windrower (SP)": {300:0.01, 600:0.02, 900:0.04, 1200:0.09, 1500:0.15,
|
| 68 |
+
1800:0.23, 2100:0.32, 2400:0.42, 2700:0.53, 3000:0.66},
|
| 69 |
+
"Cotton picker (SP)": {300:0.01, 600:0.04, 900:0.09, 1200:0.15, 1500:0.23,
|
| 70 |
+
1800:0.32, 2100:0.42, 2400:0.53, 2700:0.66, 3000:0.79},
|
| 71 |
+
|
| 72 |
+
# --- Other Implements / Misc ---
|
| 73 |
+
"Mower (sickle)": {100:0.01, 200:0.03, 300:0.06, 400:0.10, 500:0.14,
|
| 74 |
+
600:0.19, 700:0.25, 800:0.31, 900:0.38, 1000:0.46},
|
| 75 |
+
"Mower (rotary)": {100:0.00, 200:0.02, 300:0.04, 400:0.07, 500:0.11,
|
| 76 |
+
600:0.16, 700:0.22, 800:0.28, 900:0.36, 1000:0.44},
|
| 77 |
+
"Large round baler": {100:0.01, 200:0.03, 300:0.06, 400:0.10, 500:0.15,
|
| 78 |
+
600:0.20, 700:0.26, 800:0.33, 900:0.40, 1000:0.48},
|
| 79 |
+
"Sugar beet harvester": {100:0.03, 200:0.07, 300:0.12, 400:0.18, 500:0.24,
|
| 80 |
+
600:0.30, 700:0.37, 800:0.44, 900:0.51, 1000:0.59},
|
| 81 |
+
"Rotary tiller": {100:0.01, 200:0.03, 300:0.06, 400:0.09, 500:0.13,
|
| 82 |
+
600:0.17, 700:0.22, 800:0.27, 900:0.33, 1000:0.40},
|
| 83 |
+
"Row crop planter": {100:0.00, 200:0.01, 300:0.03, 400:0.05, 500:0.07,
|
| 84 |
+
600:0.09, 700:0.11, 800:0.15, 900:0.20, 1000:0.26},
|
| 85 |
+
"Grain drill": {100:0.01, 200:0.03, 300:0.06, 400:0.09, 500:0.13,
|
| 86 |
+
600:0.19, 700:0.26, 800:0.32, 900:0.40, 1000:0.47},
|
| 87 |
+
"Fertilizer spreader": {100:0.03, 200:0.08, 300:0.13, 400:0.19, 500:0.26,
|
| 88 |
+
600:0.32, 700:0.40, 800:0.47, 900:0.55, 1000:0.63},
|
| 89 |
+
|
| 90 |
+
# --- Sprayers & Wagons ---
|
| 91 |
+
"Boom-type sprayer": {200:0.05, 400:0.12, 600:0.21, 800:0.31, 1000:0.41,
|
| 92 |
+
1200:0.52, 1400:0.63, 1600:0.76, 1800:0.88, 2000:1.01},
|
| 93 |
+
"Air-carrier sprayer": {200:0.02, 400:0.05, 600:0.09, 800:0.14, 1000:0.20,
|
| 94 |
+
1200:0.27, 1400:0.34, 1600:0.42, 1800:0.51, 2000:0.61},
|
| 95 |
+
"Bean puller-windrower": {200:0.02, 400:0.05, 600:0.09, 800:0.14, 1000:0.20,
|
| 96 |
+
1200:0.27, 1400:0.34, 1600:0.42, 1800:0.51, 2000:0.61},
|
| 97 |
+
"Stalk chopper": {200:0.03, 400:0.08, 600:0.14, 800:0.20, 1000:0.28,
|
| 98 |
+
1200:0.36, 1400:0.45, 1600:0.54, 1800:0.64, 2000:0.74},
|
| 99 |
+
"Forage blower": {200:0.01, 400:0.04, 600:0.09, 800:0.15, 1000:0.22,
|
| 100 |
+
1200:0.29, 1400:0.37, 1600:0.46, 1800:0.56, 2000:0.67},
|
| 101 |
+
"Wagon": {200:0.01, 400:0.04, 600:0.07, 800:0.11, 1000:0.16,
|
| 102 |
+
1200:0.21, 1400:0.27, 1600:0.34, 1800:0.41, 2000:0.49},
|
| 103 |
+
"Forage wagon": {200:0.02, 400:0.06, 600:0.10, 800:0.14, 1000:0.19,
|
| 104 |
+
1200:0.24, 1400:0.29, 1600:0.35, 1800:0.41, 2000:0.47},
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
# ----------------------------
|
| 108 |
+
# 2) Name normalization / synonyms
|
| 109 |
+
# ----------------------------
|
| 110 |
+
_CANON = {k.lower(): k for k in REPAIR_TABLE.keys()}
|
| 111 |
+
|
| 112 |
+
_SYNONYMS = {
|
| 113 |
+
# tractors
|
| 114 |
+
"2wd": "Two-wheel drive tractor",
|
| 115 |
+
"two wheel drive": "Two-wheel drive tractor",
|
| 116 |
+
"two-wheel drive": "Two-wheel drive tractor",
|
| 117 |
+
"two wheel drive tractor": "Two-wheel drive tractor",
|
| 118 |
+
|
| 119 |
+
"4wd": "Four-wheel drive tractor",
|
| 120 |
+
"four wheel drive": "Four-wheel drive tractor",
|
| 121 |
+
"four-wheel drive": "Four-wheel drive tractor",
|
| 122 |
+
"four wheel drive tractor": "Four-wheel drive tractor",
|
| 123 |
+
|
| 124 |
+
# common short names
|
| 125 |
+
"tractor": "Two-wheel drive tractor", # default if just "tractor"
|
| 126 |
+
"sp forage harvester": "Forage harvester (SP)",
|
| 127 |
+
"sp combine": "Combine (SP)",
|
| 128 |
+
"sp windrower": "Windrower (SP)",
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
# --- add these course-specific aliases (repair table name mapping) ---
|
| 132 |
+
_SYNONYMS.update({
|
| 133 |
+
# fertilizer equipment
|
| 134 |
+
"fertilizer sprayer": "Fertilizer spreader",
|
| 135 |
+
"fert sprayer": "Fertilizer spreader",
|
| 136 |
+
"fert spreader": "Fertilizer spreader",
|
| 137 |
+
"fertiliser sprayer": "Fertilizer spreader",
|
| 138 |
+
"fertiliser spreader": "Fertilizer spreader",
|
| 139 |
+
|
| 140 |
+
# disks
|
| 141 |
+
"offset disk": "Heavy-duty disk",
|
| 142 |
+
"offset disc": "Heavy-duty disk",
|
| 143 |
+
"hd disk": "Heavy-duty disk",
|
| 144 |
+
"heavy duty disk": "Heavy-duty disk",
|
| 145 |
+
"heavy-duty disc": "Heavy-duty disk",
|
| 146 |
+
|
| 147 |
+
# grain drill
|
| 148 |
+
"drill seeder": "Grain drill",
|
| 149 |
+
"drill": "Grain drill",
|
| 150 |
+
"seeder": "Grain drill",
|
| 151 |
+
})
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _normalize_name(s: str) -> Optional[str]:
|
| 155 |
+
key = re.sub(r"\s+", " ", s.strip().lower())
|
| 156 |
+
if key in _CANON:
|
| 157 |
+
return _CANON[key]
|
| 158 |
+
if key in _SYNONYMS:
|
| 159 |
+
return _SYNONYMS[key]
|
| 160 |
+
# try loose contains
|
| 161 |
+
for alias, canon in _SYNONYMS.items():
|
| 162 |
+
if alias in key:
|
| 163 |
+
return canon
|
| 164 |
+
# last resort: pick a canonical that fully appears
|
| 165 |
+
for canon_lower, canon in _CANON.items():
|
| 166 |
+
if canon_lower in key:
|
| 167 |
+
return canon
|
| 168 |
+
return None
|
| 169 |
+
|
| 170 |
+
# ----------------------------
|
| 171 |
+
# 3) Interpolation / extrapolation
|
| 172 |
+
# ----------------------------
|
| 173 |
+
def _sorted_points(machine: str) -> List[Tuple[int, float]]:
|
| 174 |
+
pts = list(REPAIR_TABLE[machine].items())
|
| 175 |
+
pts.sort(key=lambda x: x[0])
|
| 176 |
+
return pts
|
| 177 |
+
|
| 178 |
+
def _interp(x0: float, y0: float, x1: float, y1: float, x: float) -> float:
|
| 179 |
+
if x1 == x0:
|
| 180 |
+
return y0
|
| 181 |
+
return y0 + (y1 - y0) * ((x - x0) / (x1 - x0))
|
| 182 |
+
|
| 183 |
+
def get_repair_fraction(machine_name: str, hours: float) -> Tuple[float, str, Tuple[int, float], Tuple[int, float]]:
|
| 184 |
+
"""
|
| 185 |
+
Returns:
|
| 186 |
+
fraction (0..1+), canonical_machine_name, (x_lo,y_lo), (x_hi,y_hi)
|
| 187 |
+
Uses linear interpolation between nearest table points. Extrapolates gently
|
| 188 |
+
beyond edges (linear using last two points).
|
| 189 |
+
"""
|
| 190 |
+
if hours < 0:
|
| 191 |
+
hours = 0.0
|
| 192 |
+
|
| 193 |
+
canon = _normalize_name(machine_name) or "Two-wheel drive tractor"
|
| 194 |
+
pts = _sorted_points(canon)
|
| 195 |
+
xs = [p[0] for p in pts]
|
| 196 |
+
|
| 197 |
+
# below range
|
| 198 |
+
if hours <= xs[0]:
|
| 199 |
+
x0, y0 = pts[0]
|
| 200 |
+
x1, y1 = pts[1]
|
| 201 |
+
y = _interp(x0, y0, x1, y1, hours)
|
| 202 |
+
return max(0.0, y), canon, (x0, y0), (x1, y1)
|
| 203 |
+
|
| 204 |
+
# above range
|
| 205 |
+
if hours >= xs[-1]:
|
| 206 |
+
x0, y0 = pts[-2]
|
| 207 |
+
x1, y1 = pts[-1]
|
| 208 |
+
y = _interp(x0, y0, x1, y1, hours)
|
| 209 |
+
return max(0.0, y), canon, (x0, y0), (x1, y1)
|
| 210 |
+
|
| 211 |
+
# interior: find bracketing points
|
| 212 |
+
for i in range(1, len(pts)):
|
| 213 |
+
x0, y0 = pts[i-1]
|
| 214 |
+
x1, y1 = pts[i]
|
| 215 |
+
if x0 <= hours <= x1:
|
| 216 |
+
y = _interp(x0, y0, x1, y1, hours)
|
| 217 |
+
return max(0.0, y), canon, (x0, y0), (x1, y1)
|
| 218 |
+
|
| 219 |
+
# should not reach here
|
| 220 |
+
x0, y0 = pts[0]
|
| 221 |
+
x1, y1 = pts[1]
|
| 222 |
+
return y0, canon, (x0, y0), (x1, y1)
|
| 223 |
+
|
| 224 |
+
# ----------------------------
|
| 225 |
+
# 4) Utilities
|
| 226 |
+
# ----------------------------
|
| 227 |
+
def list_machines() -> List[str]:
|
| 228 |
+
"""Return the list of canonical machine names available in the table."""
|
| 229 |
+
return sorted(REPAIR_TABLE.keys())
|
| 230 |
+
|
| 231 |
+
def describe_machine_match(name: str) -> str:
|
| 232 |
+
"""Debug helper: shows which canonical name a user string maps to."""
|
| 233 |
+
c = _normalize_name(name)
|
| 234 |
+
if not c:
|
| 235 |
+
return f"No match for '{name}'. Known: {', '.join(list_machines()[:8])}…"
|
| 236 |
+
return f"'{name}' → '{c}'"
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=4.36.1
|
| 2 |
+
scikit-learn
|
| 3 |
+
pypdf
|
| 4 |
+
openai>=1.37.0 # optional; only used if OPENAI_API_KEY is set
|
salvage_table_data.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# salvage_table_data.py — exact values transcribed from Iowa State PM 710 (Tables 1a & 1b)
|
| 2 |
+
# Format:
|
| 3 |
+
# For tractors & combines: nested by annual-hours band → { age(years): fraction_of_new_price }
|
| 4 |
+
# For implements (Table 1b): single band 0 → { age: fraction } (age-only, no hours dependence)
|
| 5 |
+
|
| 6 |
+
SALVAGE_TABLE = {
|
| 7 |
+
# ---------- Table 1a: TRACTORS ----------
|
| 8 |
+
"30-79 hp tractor": {
|
| 9 |
+
200: {1:0.65, 2:0.59, 3:0.54, 4:0.51, 5:0.48, 6:0.45, 7:0.42, 8:0.40, 9:0.38,10:0.36,
|
| 10 |
+
11:0.35,12:0.33,13:0.32,14:0.30,15:0.29,16:0.28,17:0.26,18:0.25,19:0.24,20:0.23},
|
| 11 |
+
400: {1:0.60, 2:0.54, 3:0.49, 4:0.46, 5:0.43, 6:0.40, 7:0.38, 8:0.36, 9:0.34,10:0.32,
|
| 12 |
+
11:0.31,12:0.29,13:0.28,14:0.27,15:0.25,16:0.24,17:0.23,18:0.22,19:0.21,20:0.20},
|
| 13 |
+
600: {1:0.56, 2:0.50, 3:0.46, 4:0.43, 5:0.40, 6:0.37, 7:0.35, 8:0.33, 9:0.31,10:0.30,
|
| 14 |
+
11:0.28,12:0.27,13:0.25,14:0.24,15:0.23,16:0.22,17:0.21,18:0.20,19:0.19,20:0.18},
|
| 15 |
+
},
|
| 16 |
+
|
| 17 |
+
"80-149 hp tractor": {
|
| 18 |
+
200: {1:0.69, 2:0.62, 3:0.57, 4:0.53, 5:0.50, 6:0.47, 7:0.44, 8:0.42, 9:0.40,10:0.38,
|
| 19 |
+
11:0.36,12:0.34,13:0.33,14:0.31,15:0.30,16:0.28,17:0.27,18:0.26,19:0.25,20:0.24},
|
| 20 |
+
400: {1:0.68, 2:0.62, 3:0.57, 4:0.53, 5:0.49, 6:0.46, 7:0.44, 8:0.41, 9:0.39,10:0.37,
|
| 21 |
+
11:0.35,12:0.34,13:0.32,14:0.31,15:0.29,16:0.28,17:0.27,18:0.25,19:0.24,20:0.23},
|
| 22 |
+
600: {1:0.68, 2:0.61, 3:0.56, 4:0.52, 5:0.49, 6:0.46, 7:0.43, 8:0.41, 9:0.39,10:0.37,
|
| 23 |
+
11:0.35,12:0.33,13:0.32,14:0.30,15:0.29,16:0.27,17:0.26,18:0.25,19:0.24,20:0.23},
|
| 24 |
+
},
|
| 25 |
+
|
| 26 |
+
"150+ hp tractor": {
|
| 27 |
+
200: {1:0.69, 2:0.61, 3:0.55, 4:0.51, 5:0.47, 6:0.43, 7:0.40, 8:0.38, 9:0.35,10:0.33,
|
| 28 |
+
11:0.31,12:0.29,13:0.27,14:0.25,15:0.24,16:0.22,17:0.21,18:0.20,19:0.19,20:0.17},
|
| 29 |
+
400: {1:0.67, 2:0.59, 3:0.54, 4:0.49, 5:0.45, 6:0.42, 7:0.39, 8:0.36, 9:0.34,10:0.32,
|
| 30 |
+
11:0.30,12:0.28,13:0.26,14:0.24,15:0.23,16:0.21,17:0.20,18:0.19,19:0.18,20:0.17},
|
| 31 |
+
600: {1:0.66, 2:0.58, 3:0.52, 4:0.48, 5:0.44, 6:0.41, 7:0.38, 8:0.35, 9:0.33,10:0.31,
|
| 32 |
+
11:0.29,12:0.27,13:0.25,14:0.24,15:0.22,16:0.21,17:0.19,18:0.18,19:0.17,20:0.16},
|
| 33 |
+
},
|
| 34 |
+
|
| 35 |
+
# ---------- Table 1a: COMBINE / FORAGE HARVESTER ----------
|
| 36 |
+
"combine/forage harvester": {
|
| 37 |
+
100: {1:0.79, 2:0.67, 3:0.59, 4:0.52, 5:0.47, 6:0.42, 7:0.38, 8:0.35, 9:0.31,10:0.28,
|
| 38 |
+
11:0.26,12:0.23,13:0.21,14:0.19,15:0.17,16:0.16,17:0.14,18:0.13,19:0.11,20:0.10},
|
| 39 |
+
300: {1:0.69, 2:0.58, 3:0.50, 4:0.44, 5:0.39, 6:0.35, 7:0.31, 8:0.28, 9:0.25,10:0.23,
|
| 40 |
+
11:0.20,12:0.18,13:0.16,14:0.14,15:0.13,16:0.11,17:0.10,18:0.09,19:0.08,20:0.07},
|
| 41 |
+
500: {1:0.63, 2:0.52, 3:0.45, 4:0.39, 5:0.34, 6:0.30, 7:0.27, 8:0.24, 9:0.21,10:0.19,
|
| 42 |
+
11:0.17,12:0.15,13:0.13,14:0.12,15:0.10,16:0.09,17:0.08,18:0.07,19:0.06,20:0.05},
|
| 43 |
+
},
|
| 44 |
+
|
| 45 |
+
# ---------- Table 1b: IMPLEMENTS (age-only) ----------
|
| 46 |
+
"plows": { 0: {1:0.47, 2:0.44, 3:0.42, 4:0.40, 5:0.39, 6:0.38, 7:0.36, 8:0.35, 9:0.34,10:0.33,
|
| 47 |
+
11:0.32,12:0.32,13:0.31,14:0.30,15:0.29,16:0.29,17:0.28,18:0.27,19:0.27,20:0.26}},
|
| 48 |
+
"other tillage": { 0: {1:0.61, 2:0.54, 3:0.49, 4:0.45, 5:0.42, 6:0.39, 7:0.36, 8:0.34, 9:0.31,10:0.30,
|
| 49 |
+
11:0.28,12:0.26,13:0.24,14:0.23,15:0.22,16:0.20,17:0.19,18:0.18,19:0.17,20:0.16}},
|
| 50 |
+
"planter, drill, sprayer": { 0: {1:0.65, 2:0.60, 3:0.56, 4:0.53, 5:0.50, 6:0.48, 7:0.46, 8:0.44, 9:0.42,10:0.40,
|
| 51 |
+
11:0.39,12:0.38,13:0.36,14:0.35,15:0.34,16:0.33,17:0.32,18:0.30,19:0.29,20:0.29}},
|
| 52 |
+
"mower, chopper": { 0: {1:0.47, 2:0.44, 3:0.41, 4:0.39, 5:0.37, 6:0.35, 7:0.33, 8:0.32, 9:0.31,10:0.30,
|
| 53 |
+
11:0.28,12:0.27,13:0.26,14:0.26,15:0.25,16:0.24,17:0.23,18:0.22,19:0.22,20:0.21}},
|
| 54 |
+
"baler": { 0: {1:0.56, 2:0.50, 3:0.46, 4:0.42, 5:0.39, 6:0.37, 7:0.34, 8:0.32, 9:0.30,10:0.28,
|
| 55 |
+
11:0.27,12:0.25,13:0.24,14:0.22,15:0.21,16:0.20,17:0.19,18:0.18,19:0.17,20:0.16}},
|
| 56 |
+
"swather, rake": { 0: {1:0.49, 2:0.44, 3:0.40, 4:0.37, 5:0.35, 6:0.32, 7:0.30, 8:0.28, 9:0.27,10:0.25,
|
| 57 |
+
11:0.24,12:0.23,13:0.21,14:0.20,15:0.19,16:0.18,17:0.17,18:0.16,19:0.16,20:0.15}},
|
| 58 |
+
"vehicle": { 0: {1:0.42, 2:0.39, 3:0.36, 4:0.34, 5:0.33, 6:0.31, 7:0.30, 8:0.29, 9:0.27,10:0.26,
|
| 59 |
+
11:0.25,12:0.24,13:0.24,14:0.23,15:0.22,16:0.21,17:0.20,18:0.20,19:0.19,20:0.19}},
|
| 60 |
+
"others": { 0: {1:0.69, 2:0.62, 3:0.56, 4:0.52, 5:0.48, 6:0.45, 7:0.42, 8:0.40, 9:0.37,10:0.35,
|
| 61 |
+
11:0.33,12:0.31,13:0.29,14:0.28,15:0.26,16:0.25,17:0.24,18:0.22,19:0.21,20:0.20}},
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
# salvage_table_data.py (at the end)
|
| 65 |
+
# Make a case-insensitive view
|
| 66 |
+
# --- Normalize keys so lookups are case-insensitive and space-insensitive ---
|
| 67 |
+
def _norm_key(s: str) -> str:
|
| 68 |
+
return "".join(s.lower().split()) # lowercase + remove all whitespace
|
| 69 |
+
|
| 70 |
+
# Build a normalized alias map (in addition to the original keys)
|
| 71 |
+
_SALV_NORM = {}
|
| 72 |
+
for k, v in list(SALVAGE_TABLE.items()):
|
| 73 |
+
if isinstance(k, str):
|
| 74 |
+
_SALV_NORM[_norm_key(k)] = v
|
| 75 |
+
|
| 76 |
+
# Public accessor dict: original keys + normalized clones
|
| 77 |
+
SALVAGE_TABLE_NORM = dict(_SALV_NORM)
|
| 78 |
+
|