rylieweber commited on
Commit
0c8aa0f
·
verified ·
1 Parent(s): 91624fb

Upload 4 files

Browse files
Files changed (4) hide show
  1. app.py +664 -0
  2. repair_table_data.py +236 -0
  3. requirements.txt +4 -0
  4. 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
+