"""Build "Dishwasher warranty reliability.ana" from the generated data. Inputs (in this folder): service_calls.csv from make_service_calls.py installs_by_month.csv from make_service_calls.py saved_jev_answers.csv optional; Jev's answers from one live run (see export_jev_answers.txt). Without it, the model's saved-answer tables are empty. Output: ../Dishwasher warranty reliability.ana (next to Jev library.ana, which it links to by relative path). Usage: python -I build_model.py """ import csv import datetime as dt import os HERE = os.path.dirname(os.path.abspath(__file__)) OUT = os.path.join(os.path.dirname(HERE), 'Dishwasher warranty reliability.ana') COMPONENTS = [ ('Drain pump', 'drain_pump', 'DP-220', 'The drain pump failed (motor, impeller or bearing), so water is not pumped out of the tub.', 185), ('Circulation pump', 'circulation_pump', 'CP-410', 'The circulation (wash) pump or its motor failed, so water is not sprayed with enough force.', 290), ('Heating element', 'heating_element', 'HE-150', 'The heating element failed, so the water does not heat or the dishes do not dry.', 210), ('Water inlet valve', 'inlet_valve', 'IV-75', 'The water inlet (fill) valve failed: it does not open (no fill) or does not close (overfill).', 150), ('Door latch', 'door_latch', 'DL-30', 'The door latch or its switch failed, so the unit will not start or will not stay latched.', 135), ('Control board', 'control_board', 'CB-900', 'The main electronic control board failed.', 265), ('Door seal', 'door_seal', 'DS-24', 'The door or tub gasket is torn, worn or out of place, causing a leak.', 140), ('Spray arm', 'spray_arm', 'SA-12', 'A spray arm is cracked, broken or will not rotate.', 105), ('Installation error', 'installation', '', 'The problem was caused by how the unit was installed (hoses, fittings, wiring, leveling, mounting, ' 'the disposal knockout plug), not by a defective part.', 125), ('No fault found', 'no_fault', '', 'No defect was found. The problem came from use, maintenance (such as a dirty filter), settings or ' 'detergent, or could not be reproduced.', 95), ('Other part', 'other', 'Various', 'A part not listed above was defective, such as a rack, basket, dispenser, panel or light.', 110), ] CODE_TO_LABEL = {code: label for label, code, *_ in COMPONENTS} IMPACT_LEVELS = [ 'Minor: a nuisance, but the dishwasher still works normally', 'Degraded: it works, but poorly (poor cleaning or drying, noise, intermittent faults)', 'Unusable: the dishwasher cannot be used until it is repaired', ] NUM_DATE = '2,D,4,2,0,0,4,0,$,0,"d-MMM-yyyy",0,,,0,0' NUM_MONTH = '2,D,4,2,0,0,4,0,$,0,"MMM yyyy",0,,,0,0' NUM_DOLLARS = '2,F,4,0,0,0,4,0,$,0,"ABBREV",0,,,0,0' NUM_PCT = '1,%,4,0,0,0' def ana_date(iso): """Analytica date number: 1-Jan-1904 is day 1.""" return (dt.date.fromisoformat(iso) - dt.date(1904, 1, 1)).days + 1 def q(text): """Analytica text literal.""" assert '"' not in text, text return '"' + text + '"' def lines(text): """Continue a multi-line attribute value: every line but the last ends in ~.""" return '~\n'.join(text.strip('\n').split('\n')) class Ana: def __init__(self): self.out = [] def obj(self, cls, ident, title, definition=None, description=None, loc=None, size=(80, 24), extra=()): o = [f'{cls} {ident}', f'Title: {title}'] if description: o.append('Description: ' + lines(description)) if definition is not None: o.append('Definition: ' + lines(definition)) if loc: o.append(f'NodeLocation: {loc[0]},{loc[1]},1') o.append(f'NodeSize: {size[0]},{size[1]}') o.extend(extra) self.out.append('\n'.join(o) + '\n') def text_node(self, ident, text, loc, size, color='65535,65535,52428'): self.out.append('\n'.join([ f'Text {ident}', f'Title: {ident}', 'Description: ' + lines(text), f'NodeLocation: {loc[0]},{loc[1]},1', f'NodeSize: {size[0]},{size[1]}', 'NodeInfo: 1,,,,1,1', f'NodeColor: {color}']) + '\n') def module(self, ident, title, description, loc): self.out.append('\n'.join([ f'Module {ident}', f'Title: {title}', 'Description: ' + lines(description), f'NodeLocation: {loc[0]},{loc[1]},1', 'NodeSize: 88,28', 'DiagState: 2,40,40,1300,640,17,10']) + '\n') def close(self, ident): self.out.append(f'Close {ident}\n') def text(self): return '\n'.join(self.out) def table1(index, values): return f'Table({index})(\n' + ',\n'.join(values) + '\n)' def main(): with open(os.path.join(HERE, 'service_calls.csv'), encoding='utf-8') as f: calls = list(csv.DictReader(f)) with open(os.path.join(HERE, 'installs_by_month.csv'), encoding='utf-8') as f: installs = list(csv.DictReader(f)) saved = None saved_path = os.path.join(HERE, 'saved_jev_answers.csv') if os.path.exists(saved_path): with open(saved_path, encoding='utf-8') as f: saved = {r['call_id']: r for r in csv.DictReader(f)} assert len(saved) == len(calls), 'saved answers do not match the calls' labels = [c for c, *_ in COMPONENTS] a = Ana() n_calls = len(calls) first_month = installs[0]['install_month'] # ------------------------------------------------------------------ top a.text_node('Te_story', '''Larkspur Appliance Company launched its QuietClean 24 built-in dishwasher in January 2024, with a two-year parts-and-labor warranty. By 1 October 2026 there are about 3,100 in kitchens, and authorized technicians have made {n} warranty service calls. Each call left a short free-text note. The reliability team has three questions: 1. How much should Larkspur reserve for warranty costs over the next 12 months? 2. How many of each spare part should the parts depot stock for the next quarter? 3. Should new production switch to an upgraded drain pump that costs $8.50 more per unit? The notes hold the answers, but they're free text: "not draining" might be a failed pump, a clogged filter or a kinked hose. This model uses Jev to read every note, estimates failure rates for each part by age, then forecasts costs and parts demand and evaluates the pump upgrade. Start with Jev answers. "Saved answers" uses answers stored in the model, so no API key is needed; "Live Jev calls" asks Jev again (about $0.01 and a few minutes).'''.format(n=n_calls), (300, 150), (270, 140)) a.obj('Index', 'Jev_source_options', 'Jev source options', "['Saved answers', 'Live Jev calls']", description='The two ways the model can get Jev\'s reading of the notes.') a.obj('Decision', 'Jev_source', 'Jev answers', 'Choice(Jev_source_options, 1, False)', description='''Saved answers: use Jev's answers from a run made when this model was built. Needs no API key and costs nothing. Live Jev calls: send all {n} notes to Jev now, one call per note. Needs the JEV_API_KEY environment variable (see the Jev library documentation), takes a few minutes, and costs about a cent. Answers can differ slightly from the saved ones.'''.format(n=n_calls), loc=(712, 64), size=(80, 24)) # ------------------------------------------------------------- data module a.module('Service_call_data', 'Service call data', f'''The data Larkspur has: units installed each month (from warranty registrations), and the {n_calls} warranty service calls through {calls[-1]['service_date']}, each with the technician's note. This data is fictional. It was generated by make_service_calls.py.''', (712, 152)) a.obj('Constant', 'Analysis_date', 'Analysis date', 'MakeDate(2026, 10, 1)', description='The date of the analysis. Service calls and installations are known up to this date.', loc=(120, 64), extra=[f'NumberFormat: {NUM_DATE}']) a.obj('Constant', 'Warranty_years', 'Warranty length', '2', description='Length of the parts-and-labor warranty, in years. Calls after this age are not covered, so the data has none.', loc=(120, 136)) a.obj('Index', 'Install_month', 'Installation month', f"DateAdd(MakeDate(2024, 1, 1), 0..{len(installs) - 1}, 'M')", description='Months in which units were installed, from launch to the month before the analysis date.', loc=(336, 64), extra=[f'NumberFormat: {NUM_MONTH}']) a.obj('Variable', 'Units_installed', 'Units installed', table1('Install_month', [r['units_installed'] for r in installs]), description='Number of QuietClean 24 units installed in each month, from warranty registrations.', loc=(336, 136)) a.obj('Index', 'Service_call', 'Service call', '[' + ',\n'.join(f"'{c['call_id']}'" for c in calls) + ']', description='One element for each warranty service call, in date order.', loc=(560, 64)) a.obj('Variable', 'Unit_serial', 'Unit serial number', table1('Service_call', [q(c['serial']) for c in calls]), description='Serial number of the dishwasher that was serviced.', loc=(560, 136)) a.obj('Variable', 'Install_date', 'Installation date', table1('Service_call', [str(ana_date(c['install_date'])) for c in calls]), description='When the serviced unit was installed.', loc=(560, 208), extra=[f'NumberFormat: {NUM_DATE}']) a.obj('Variable', 'Service_date', 'Service date', table1('Service_call', [str(ana_date(c['service_date'])) for c in calls]), description='Date of the service call.', loc=(560, 280), extra=[f'NumberFormat: {NUM_DATE}']) a.obj('Variable', 'Tech_note', 'Technician note', table1('Service_call', [q(c['tech_note']) for c in calls]), description='The note the technician wrote for the call: free text, with the abbreviations and shorthand technicians use.', loc=(784, 136)) a.obj('Variable', 'Unit_age_at_call', 'Unit age at call', '(Service_date - Install_date) / 365.25', description='Age of the unit when it was serviced, in years.', loc=(784, 280)) a.close('Service_call_data') # -------------------------------------------------------------- Jev module a.module('Reading_the_notes', 'Reading the notes with Jev', '''Asks Jev four questions about each note: what caused the call, whether a replacement part was needed, how severe the problem was for the household, and whether water damaged the home. Jev answers with probabilities, not just a label. A note that clearly describes a seized drain pump gets nearly all its probability on Drain pump. An ambiguous one spreads its probability, and the model carries that uncertainty forward rather than forcing a guess.''', (712, 216)) a.obj('Index', 'Component', 'Cause', '[' + ', '.join(f"'{c}'" for c in labels) + ']', description='The possible root causes of a service call: the parts that can fail, plus installation errors, calls where no fault was found, and other parts.', loc=(120, 64)) a.obj('Variable', 'Cause_description', 'Cause description', table1('Component', [q(c[3]) for c in COMPONENTS]), description="What each cause means. Jev uses these descriptions to classify the notes, so editing them changes Jev's answers when you use live calls.", loc=(120, 136)) a.obj('Index', 'Impact_level', 'Impact level', '0..2', description='Severity levels for the household, numbered from 0 as Jev numbers Score levels.', loc=(120, 280)) a.obj('Variable', 'Impact_label', 'Impact label', table1('Impact_level', [q(t) for t in IMPACT_LEVELS]), description='What each impact level means.', loc=(120, 352)) a.obj('Variable', 'Note_questions', 'Questions for each note', '''JevQuestions( cause: JevChoice( "What was the root cause of this warranty service call? Classify by what the technician found, not by the symptom the customer reported.", ParseJSON(MakeJSON(Cause_description), JevChoiceCriteria) ), part_needed: JevNoul( "Did fixing the problem require a replacement part, whether it was installed during this visit or ordered for a return visit?", JevNoulCriteria( "A replacement part was installed, or was ordered for a later visit.", "The problem was fixed by cleaning, adjusting, reconnecting, resetting or explaining, or nothing was done; no part was used or ordered." ) ), impact: JevScore( "Before the repair, how severe was the problem for the household?", ''' + '[' + ', '.join(q(t) for t in IMPACT_LEVELS) + '''] ), water_damage: JevNoul( "Does the note report that water damaged the home, such as floors, cabinets, walls or ceilings?", JevNoulCriteria( "The note says floors, cabinets, walls, ceilings or other parts of the home were damaged by water, or that the customer is making a damage claim.", "No damage to the home is mentioned. A leak, a puddle or water inside the dishwasher alone does not count." ) ) )''', description='The four questions Jev answers for every note. The cause options and their descriptions come from Cause description, and the impact levels match Impact label.', loc=(336, 136), size=(80, 28)) a.obj('Variable', 'Jev_state', 'Evidence for each call', '''For c := Service_call Do JevState( product: "Larkspur QuietClean 24 built-in dishwasher", days_since_installation: Service_date[Service_call = c] - Install_date[Service_call = c], technician_note: Tech_note[Service_call = c] )''', description="What Jev sees for each call: the product, how long the unit had been installed, and the technician's note. The For loop builds one state per call; JevState given whole arrays would build a single state containing all of them.", loc=(336, 208)) a.obj('Variable', 'Jev_requests', 'Jev requests', 'JevRequest(Jev_state, Note_questions)', description='One Jev request per service call. Building requests makes no calls.', loc=(336, 280)) a.obj('Variable', 'Jev_live_responses', 'Live Jev responses', 'Jev_requests -> JevCall(allowIncomplete: True)', description=f'''LIVE: calls Jev once for each of the {n_calls} notes, one after another. This is evaluated only when Jev answers is set to Live Jev calls. It needs the JEV_API_KEY environment variable, takes a few minutes, and costs about a cent in total. allowIncomplete: True means a question Jev declines to answer doesn't stop the whole batch; that call's answer is just left empty.''', loc=(560, 280)) def saved_table(name, title, index2, getter, description, loc): if saved is None: definition = 'Null' elif index2 is None: definition = table1('Service_call', [getter(saved[c['call_id']]) for c in calls]) else: cells = [getter(saved[c['call_id']], k) for c in calls for k in index2[1]] definition = f'Table(Service_call, {index2[0]})(\n' + ',\n'.join(cells) + '\n)' a.obj('Variable', name, title, definition, description=description, loc=loc) saved_table('Saved_cause_probs', 'Saved cause probabilities', ('Component', labels), lambda r, k: r['cause:' + k], "Jev's cause probabilities for each call from the run made when this model was built.", (784, 136)) saved_table('Saved_part_needed', 'Saved part-needed probabilities', None, lambda r: r['part_needed'], "Jev's probability that each call needed a replacement part, from the build-time run.", (784, 208)) saved_table('Saved_water_damage', 'Saved water-damage probabilities', None, lambda r: r['water_damage'], "Jev's probability that each call involved water damage to the home, from the build-time run.", (784, 424)) saved_table('Saved_impact_probs', 'Saved impact probabilities', ('Impact_level', range(len(IMPACT_LEVELS))), lambda r, k: r[f'impact:{k}'], "Jev's impact-level probabilities for each call, from the build-time run.", (784, 280)) total_cost = sum(float(r['cost'] or 0) for r in saved.values()) if saved else 0 a.obj('Constant', 'Saved_jev_cost', 'Cost of the saved run', f'{total_cost:.6g}', description='What the build-time run of Jev cost, in US dollars, as reported by the gateway.', loc=(784, 352), extra=['NumberFormat: 2,F,4,4,0,0,4,0,$,0,"ABBREV",0,,,0,0']) live = "Jev_source = 'Live Jev calls'" a.obj('Variable', 'Cause_probs', 'Cause probabilities', f'''Local p := If {live} Then ( For k := Component Do `->`(Jev_live_responses -> answers -> cause -> probabilities, k) ) Else Saved_cause_probs; If Sum(p, Component) > 0 Then p Else (Component = 'Other part')''', description="Jev's probability for each possible cause of each call. Each call's probabilities sum to 1. In the rare case that Jev declines to answer, the call is counted as Other part.", loc=(1008, 136), size=(80, 24)) a.obj('Variable', 'P_part_needed', 'Probability a part was needed', f'''Local p := If {live} Then Jev_live_responses -> answers -> part_needed -> noul Else Saved_part_needed; If IsNull(p) Then 0.5 Else p''', description="Jev's probability that the repair needed a replacement part.", loc=(1008, 208)) a.obj('Variable', 'Impact_probs', 'Impact probabilities', f'''Local p := If {live} Then ( For k := Impact_level Do `->`(Jev_live_responses -> answers -> impact -> probabilities, k & '') ) Else Saved_impact_probs; If Sum(p, Impact_level) > 0 Then p Else (Impact_level = 1)''', description="Jev's probability for each impact level, for each call.", loc=(1008, 280)) a.obj('Variable', 'P_water_damage', 'Probability of water damage', f'''Local p := If {live} Then Jev_live_responses -> answers -> water_damage -> noul Else Saved_water_damage; If IsNull(p) Then 0 Else p''', description="Jev's probability that each call involved water damage to the home.", loc=(1008, 496)) a.obj('Variable', 'Jev_cost', 'Cost of reading the notes', f'If {live} Then Sum(Jev_live_responses -> cost, Service_call) Else Saved_jev_cost', description='What reading all the notes with Jev cost, in US dollars.', loc=(1008, 352), extra=['NumberFormat: 2,F,4,4,0,0,4,0,$,0,"ABBREV",0,,,0,0']) a.obj('Variable', 'Most_likely_cause', 'Most likely cause', 'ArgMax(Cause_probs, Component)', description="Jev's single most probable cause for each call. The failure model uses the full probabilities, not just this label.", loc=(1008, 424)) a.close('Reading_the_notes') # ---------------------------------------------------------- failure rates a.module('Failure_rates', 'Failure rates', '''Estimates how often each cause leads to a service call, per unit-year, in three age bands. The bands matter: some failures happen early (installation errors, control boards), others as parts wear (the drain pump). Each call counts toward each cause in proportion to Jev's probabilities, so an ambiguous note counts partly toward several causes. The rate is that count divided by the unit-years the fleet has spent in the age band. Its uncertainty is a Gamma distribution, the standard result for a rate estimated from a count of events.''', (712, 280)) a.obj('Index', 'Age_band', 'Age band', "['0-3 months', '3-12 months', '1-2 years']", description='Age bands, by time since installation.', loc=(120, 64)) a.obj('Variable', 'Band_start', 'Band start', table1('Age_band', ['0', '0.25', '1']), description='Start of each age band, in years.', loc=(120, 136)) a.obj('Variable', 'Band_end', 'Band end', table1('Age_band', ['0.25', '1', '2']), description='End of each age band, in years. The last band ends when the warranty does.', loc=(120, 208)) a.obj('Function', 'Years_overlap(lo, hi, b0, b1)', 'Years overlap', '''Local top := If hi < b1 Then hi Else b1; Local bot := If lo > b0 Then lo Else b0; If top > bot Then top - bot Else 0''', description='Length of the overlap between the age interval [lo, hi] and the band [b0, b1], in years.', loc=(120, 280)) a.obj('Variable', 'Fleet_age_now', 'Fleet age now', '(Analysis_date - (Install_month + 15)) / 365.25', description='Age at the analysis date of the units installed each month, in years, taking installation as mid-month.', loc=(336, 64)) a.obj('Variable', 'Unit_years_observed', 'Unit-years observed', 'Sum(Units_installed * Years_overlap(0, Fleet_age_now, Band_start, Band_end), Install_month)', description='Total time the fleet has spent in each age band so far, in unit-years. This is the exposure against which calls are counted. The oldest units are only now leaving the 1-2 year band, so that band has the least exposure and the most uncertain rates.', loc=(336, 136)) a.obj('Variable', 'In_band', 'Call in age band', 'Unit_age_at_call >= Band_start And Unit_age_at_call < Band_end', description='Whether each call falls in each age band.', loc=(336, 208)) a.obj('Variable', 'Calls_by_cause', 'Calls by cause', 'Sum(Cause_probs * In_band, Service_call)', description='Expected number of calls caused by each cause in each age band: the sum of Jev\'s probabilities. These are fractional, because an ambiguous note counts partly toward each possible cause.', loc=(560, 136)) a.obj('Variable', 'Rate_estimate', 'Failure rate estimate', 'Calls_by_cause / Unit_years_observed', description='Point estimate of calls per unit-year, by cause and age band.', loc=(560, 208)) a.obj('Chance', 'Failure_rate', 'Failure rate', 'Gamma(Calls_by_cause + 0.5, 1 / Unit_years_observed)', description='Uncertain rate of calls per unit-year, by cause and age band. This is the Bayesian posterior for a Poisson rate given the observed count and exposure (with a Jeffreys prior). Bands with fewer calls or less exposure are more uncertain.', loc=(560, 280)) a.obj('Variable', 'Calls_per_1000_units', 'Calls per 1000 units per year', '1000 * Rate_estimate', description='The rate estimate expressed per 1000 units per year, which is easier to read.', loc=(784, 208)) a.close('Failure_rates') # ---------------------------------------------------------- forecasts a.module('Forecasts_and_decisions', 'Forecasts and decisions', '''Uses the failure rates to answer the three questions: the warranty reserve for the next 12 months, spare parts for the next quarter, and whether to upgrade the drain pump.''', (712, 344)) a.obj('Variable', 'Planned_installs', 'Planned installs per month', '115', description='Expected installations per month from now on.', loc=(120, 64)) a.obj('Function', 'Future_unit_years(h)', 'Future unit-years', '''Local existing := Sum(Units_installed * Years_overlap(Fleet_age_now, Fleet_age_now + h, Band_start, Band_end), Install_month); Index Future_month := 1..Ceil(h * 12); Local added := Sum(Planned_installs * Years_overlap(0, h - (Future_month - 0.5) / 12, Band_start, Band_end), Future_month); existing + added''', description='Unit-years the fleet will spend in each age band during the next «h» years, counting units already installed and planned installations. Units past the warranty no longer count.', loc=(120, 136)) a.obj('Variable', 'Unit_years_next_year', 'Unit-years, next 12 months', 'Future_unit_years(1)', description='Warranty exposure in each age band over the next 12 months.', loc=(336, 64)) a.obj('Variable', 'Unit_years_next_quarter', 'Unit-years, next quarter', 'Future_unit_years(0.25)', description='Warranty exposure in each age band over the next 3 months.', loc=(336, 136)) a.obj('Chance', 'Calls_next_year', 'Calls next 12 months', 'Poisson(Sum(Failure_rate * Unit_years_next_year, Age_band))', description='Uncertain number of warranty calls for each cause over the next 12 months.', loc=(560, 64)) a.obj('Variable', 'Repair_cost', 'Cost per call', table1('Component', [str(c[4]) for c in COMPONENTS]), description='Average warranty cost of one call by cause, in dollars: parts, labor and the trip.', loc=(560, 136), extra=[f'NumberFormat: {NUM_DOLLARS}']) a.obj('Index', 'Damage_count_options', 'Damage counting options', "['Calls with probability > 0.5', 'Sum of probabilities']", description='The two ways to count water-damage calls.') a.obj('Decision', 'Damage_counting', 'Count water damage by', 'Choice(Damage_count_options, 1, False)', description='''How to count calls that involved water damage. Sum of probabilities is the right way to count when Jev's probabilities are calibrated, and the model counts causes that way. But water damage is rare, and Jev gives almost every call a small chance of it (2-3%). Over hundreds of calls, those small chances add up to more claims than the notes describe. Compare the two counts in "Water-damage calls: two ways to count" in the Checking module.''', loc=(784, 64)) a.obj('Variable', 'Damage_share', 'Water damage share', '''Local d := If Damage_counting = 'Sum of probabilities' Then P_water_damage Else (P_water_damage > 0.5); Sum(Cause_probs * d, Service_call) / Sum(Cause_probs, Service_call)''', description='Fraction of calls with each cause that involved water damage to the home, counted as chosen in Count water damage by.', loc=(784, 136), extra=[f'NumberFormat: {NUM_PCT}']) a.obj('Constant', 'Damage_claim', 'Average damage claim', '2400', description='Average cost to Larkspur of a water-damage claim, in dollars.', loc=(784, 208), extra=[f'NumberFormat: {NUM_DOLLARS}']) a.obj('Variable', 'Warranty_cost_by_cause', 'Warranty cost by cause', 'Calls_next_year * (Repair_cost + Damage_share * Damage_claim)', description='Warranty cost over the next 12 months, by cause, including water-damage claims.', loc=(560, 208), extra=[f'NumberFormat: {NUM_DOLLARS}']) a.obj('Variable', 'Part_share', 'Share needing a part', 'Sum(Cause_probs * P_part_needed, Service_call) / Sum(Cause_probs, Service_call)', description='Fraction of calls with each cause that needed a replacement part.', loc=(784, 280), extra=[f'NumberFormat: {NUM_PCT}']) a.obj('Chance', 'Parts_demand', 'Parts needed next quarter', 'Poisson(Sum(Failure_rate * Unit_years_next_quarter, Age_band) * Part_share)', description='Uncertain number of each replacement part needed over the next 3 months.', loc=(560, 352)) a.obj('Constant', 'Service_level', 'Parts service level', '0.95', description='Desired probability that the depot has enough of a part to cover the quarter.', loc=(336, 352), extra=[f'NumberFormat: {NUM_PCT}']) a.obj('Variable', 'Part_number', 'Part number', table1('Component', [q(c[2]) for c in COMPONENTS]), description='Larkspur part number for each replaceable part.', loc=(784, 352)) a.obj('Decision', 'Pump_option', 'Drain pump for new production', "['Current pump', 'Upgraded pump']", description='Whether new production keeps the current drain pump or switches to the upgraded one. The model evaluates both.', loc=(120, 424)) a.obj('Constant', 'Upgrade_cost', 'Extra cost of upgraded pump', '8.5', description='Additional cost per unit of the upgraded drain pump, in dollars.', loc=(120, 496), extra=['NumberFormat: 2,F,4,2,0,0,4,0,$,0,"ABBREV",0,,,0,0']) a.obj('Chance', 'Upgraded_relative_rate', 'Upgraded pump relative failure rate', 'Triangular(0.3, 0.5, 0.8)', description="Failure rate of the upgraded pump as a fraction of the current pump's. The supplier's accelerated life tests suggest about half, but field performance is uncertain.", loc=(336, 496)) a.obj('Variable', 'Drain_failures_per_unit', 'Drain pump failures per unit', "Sum(Failure_rate[Component = 'Drain pump'] * (Band_end - Band_start), Age_band)", description='Expected drain pump failures per new unit over the two-year warranty, with the current pump.', loc=(336, 424)) a.obj('Variable', 'Drain_cost_per_unit', 'Drain pump warranty cost per unit', '''Local factor := If Pump_option = 'Upgraded pump' Then Upgraded_relative_rate Else 1; Local extra := If Pump_option = 'Upgraded pump' Then Upgrade_cost Else 0; Drain_failures_per_unit * factor * (Repair_cost[Component = 'Drain pump'] + Damage_share[Component = 'Drain pump'] * Damage_claim) + extra''', description="Expected cost per new unit of drain pump failures under warranty, plus the upgrade's extra cost, for each pump option.", loc=(560, 424), extra=['NumberFormat: 2,F,4,2,0,0,4,0,$,0,"ABBREV",0,,,0,0']) a.close('Forecasts_and_decisions') # ----------------------------------------------------------- validation review = [c['engineer_finding'] for c in calls] a.module('Checking_Jev', "Checking Jev's answers", '''Two checks on how much to trust Jev's reading of the notes. 1. An engineer reviewed a random sample of 60 calls and recorded the true cause. How often does Jev agree? 2. What would a simple keyword search have concluded? Counting notes that mention "drain" lumps failed pumps together with clogged filters, kinked hoses and notes that merely say the unit "drained normally".''', (712, 408)) a.obj('Variable', 'Engineer_review', 'Engineer review', table1('Service_call', [q(r) for r in review]), description='The cause recorded by an engineer who reviewed a random sample of 60 calls in detail. Blank for calls not reviewed.', loc=(120, 64)) a.obj('Variable', 'Is_reviewed', 'Reviewed', "Engineer_review <> ''", description='Whether the engineer reviewed the call.', loc=(120, 136)) a.obj('Variable', 'Review_agreement', 'Agreement with engineer', 'Sum((Most_likely_cause = Engineer_review) And Is_reviewed, Service_call) / Sum(Is_reviewed, Service_call)', description="Fraction of reviewed calls where Jev's most likely cause matches the engineer's.", loc=(336, 64), extra=[f'NumberFormat: {NUM_PCT}']) a.obj('Variable', 'P_true_cause', 'Probability on the true cause', 'Sum(Cause_probs[Component = Engineer_review] * Is_reviewed, Service_call) / Sum(Is_reviewed, Service_call)', description="Average probability Jev gave to the cause the engineer found. This is a stricter test than agreement: it penalizes confident wrong answers and rewards appropriately hedged ones.", loc=(336, 136), extra=[f'NumberFormat: {NUM_PCT}']) a.obj('Index', 'Jev_says', 'Jev says', 'CopyIndex(Component)', description='A copy of Cause, for the columns of the comparison table.', loc=(120, 208)) a.obj('Variable', 'Review_comparison', 'Engineer vs. Jev', 'Sum((Engineer_review = Component) * (Most_likely_cause = Jev_says), Service_call)', description="Number of reviewed calls by the engineer's cause (rows) and Jev's most likely cause (columns). Counts off the diagonal are disagreements.", loc=(336, 208)) a.obj('Variable', 'Mentions_drain', 'Note mentions drain', "FindInText('drain', Tech_note, caseInsensitive: True) > 0", description='Whether the note contains the word "drain" in any form.', loc=(560, 64)) a.obj('Variable', 'Drain_count_comparison', 'Drain pump calls: keywords vs. Jev', '''Index Method := ['Keyword search', 'Jev']; Array(Method, [Sum(Mentions_drain * In_band, Service_call), Calls_by_cause[Component = 'Drain pump']])''', description='Drain pump failures in each age band, counted two ways: notes that mention "drain", and the sum of Jev\'s probabilities that the drain pump was the cause.', loc=(560, 136)) a.obj('Variable', 'Damage_count_comparison', 'Water-damage calls: two ways to count', '''Index Count_method := ['Sum of probabilities', 'Calls with probability > 0.5']; Array(Count_method, [Sum(P_water_damage, Service_call), Sum(P_water_damage > 0.5, Service_call)])''', description='''Number of calls with water damage, counted two ways. Summing probabilities is the right way to count when the probabilities are calibrated. But Jev gives nearly every call a small chance of water damage (2-3%), and over hundreds of calls those small chances add up to many claims that may never have happened. For rare events, check this against a sample of reviewed calls before trusting the sum.''', loc=(784, 64)) a.obj('Variable', 'Keyword_upgrade_savings', 'Upgrade savings if keywords were right', '''Local kwRate := (Sum(Mentions_drain * In_band, Service_call) + 0.5) / Unit_years_observed; Local failures := Sum(kwRate * (Band_end - Band_start), Age_band); 1000 * (failures * (1 - Upgraded_relative_rate) * (Repair_cost[Component = 'Drain pump'] + Damage_share[Component = 'Drain pump'] * Damage_claim) - Upgrade_cost)''', description='Net savings per 1000 new units from the pump upgrade if every note mentioning "drain" were a drain pump failure. Compare with Upgrade savings per 1000 units.', loc=(560, 208), extra=[f'NumberFormat: {NUM_DOLLARS}']) a.close('Checking_Jev') # ------------------------------------------------------- key results a.obj('Objective', 'Warranty_cost_next_year', 'Warranty cost, next 12 months', 'Sum(Warranty_cost_by_cause, Component)', description='Uncertain total warranty cost over the next 12 months, including water-damage claims. View its probability distribution, or its Statistics for the mean and percentiles.', loc=(936, 152), size=(88, 28), extra=[f'NumberFormat: {NUM_DOLLARS}']) a.obj('Variable', 'Warranty_reserve', 'Warranty reserve (90th percentile)', 'GetFract(Warranty_cost_next_year, 0.9)', description='A reserve large enough to cover the next 12 months of warranty costs with 90% probability.', loc=(1160, 152), size=(88, 28), extra=[f'NumberFormat: {NUM_DOLLARS}']) a.obj('Variable', 'Parts_to_stock', 'Spare parts to stock', "If Part_number = '' Then Null Else GetFract(Parts_demand, Service_level)", description='Units of each spare part to stock for the next quarter, enough to meet demand with the chosen service level (95% by default).', loc=(936, 232), size=(88, 28)) a.obj('Objective', 'Upgrade_savings', 'Upgrade savings per 1000 units', "1000 * (Drain_cost_per_unit[Pump_option = 'Current pump'] - Drain_cost_per_unit[Pump_option = 'Upgraded pump'])", description="Net savings from the upgraded drain pump per 1000 new units over the warranty: warranty costs avoided minus the extra cost of the pump. Uncertain, because both the current pump's failure rate and the upgrade's improvement are uncertain.", loc=(936, 312), size=(88, 28), extra=[f'NumberFormat: {NUM_DOLLARS}']) a.obj('Variable', 'P_upgrade_pays', 'Probability the upgrade pays off', 'Probability(Upgrade_savings > 0)', description='Probability that the upgraded pump saves more in warranty costs than it costs.', loc=(1160, 312), size=(88, 28), extra=[f'NumberFormat: {NUM_PCT}']) a.obj('Variable', 'Break_even_upgrade_cost', 'Break-even price of the upgrade', "Mean(Upgrade_savings) / 1000 + Upgrade_cost", description='The extra cost per unit at which the upgraded pump would break even on average: the expected warranty savings per unit.', loc=(1160, 392), size=(88, 28), extra=['NumberFormat: 2,F,4,2,0,0,4,0,$,0,"ABBREV",0,,,0,0']) body = a.text() header = f'''{{ Analytica Model Dishwasher_warranty_reliability, encoding="UTF-8" }} SoftwareVersion 7.2.3 {{!-70199|Error("This model requires Analytica release 7.2 or later")}} {{ System Variables with non-default values: }} SampleSize := 1000 TypeChecking := 1 Checking := 1 SaveOptions := 2 SaveValues := 0 Model Dishwasher_warranty_reliability Title: Dishwasher warranty reliability (Jev example) Description: An example of using the Jev library to turn free-text technician notes into a failure model. A fictional appliance maker uses its warranty service notes to forecast warranty costs, plan spare parts, and decide whether to upgrade a part.~ ~ Requires the Jev library (Jev library.ana), in the same folder as this model. Runs without an API key using saved Jev answers. Author: Lonnie Chrisman, Ph.D.~ Lumina Decision Systems Date: Mon, Oct 26, 2026 12:00 PM DiagState: 2,0,0,1320,560,17,10 FontStyle: Arial,15 Include 0,LinkLibrary Jev_library,2,2,0,0,Jev library.ana NodeLocation Jev_library: 1160,64,1 NodeSize Jev_library: 64,24 ''' text = header + body + '\nClose Dishwasher_warranty_reliability\n' with open(OUT, 'w', encoding='utf-8', newline='\r\n') as f: f.write(text) print(f'wrote {OUT} ({n_calls} calls, saved answers: {"yes" if saved else "no"})') if __name__ == '__main__': main()