1+ #
2+ # ABOUT
3+ # Kaleido CSV roast profile importer for Artisan
4+
5+ import csv
6+ import re
7+ import logging
8+ from collections .abc import Callable
9+ from typing import Final
10+
11+ from artisanlib .util import encodeLocal , weight_units , weight_units_lower
12+ from artisanlib .atypes import ProfileData
13+
14+ _log : Final [logging .Logger ] = logging .getLogger (__name__ )
15+
16+
17+ # returns a dict containing all profile information contained in the given Kaleido CSV file
18+ def extractProfileKaleidoCSV (file : str ,
19+ _etypesdefault : list [str ],
20+ alt_etypesdefault : list [str ],
21+ _artisanflavordefaultlabels : list [str ],
22+ eventsExternal2InternalValue : Callable [[int ], float ]) -> ProfileData :
23+ res : ProfileData = ProfileData () # the interpreted data set
24+
25+ # Initialize data list
26+ timex = [] # Timeline
27+ temp1 = [] # ET
28+ temp2 = [] # BT
29+ temp3 = [] # RoR
30+
31+ specialevents = [] # Special event time points
32+ specialeventstype = [] # Event Type (0=Fan, 1=Drum, 2=Damper, 3=Burner)
33+ specialeventsvalue = [] # Event value
34+ specialeventsStrings = [] # Event Description
35+
36+ timeindex = [- 1 , 0 , 0 , 0 , 0 , 0 , 0 , 0 ] # Time Index: [CHARGE, DRY END, FC START, FC END, SC START, SC END, DROP, COOL]
37+ # CHARGE index init set to -1 as 0 could be an actual index used
38+
39+ # Analysis Kaleido CSV File(Kaleido The file is in text format and contains multiple sections.)
40+ with open (file , encoding = 'utf-8' ) as f :
41+ content = f .read ()
42+
43+ # Split file content into different sections
44+ sections = {}
45+ current_section = None
46+ lines = content .split ('\n ' )
47+
48+ i = 0
49+ while i < len (lines ):
50+ line = lines [i ].strip ()
51+
52+ # Check if it is a section tag, such as [{DATA}], [{EVENT}], [{CookDate}] ...
53+ if line .startswith ('[{' ) and line .endswith ('}]' ):
54+ current_section = line [2 :- 2 ] # Remove [{ and }]
55+ sections [current_section ] = []
56+ i += 1
57+ continue
58+
59+ # If currently in a certain section Inside, collecting content
60+ if current_section and line :
61+ sections [current_section ].append (line )
62+
63+ i += 1
64+
65+ # Analyze basic information
66+ # Analyzing the baking date and time
67+ if 'CookDate' in sections and sections ['CookDate' ]:
68+ cook_date = sections ['CookDate' ][0 ].strip () if sections ['CookDate' ] else ''
69+ if cook_date :
70+ # Format: 25-05-18 19:32:48
71+ try :
72+ date_part , time_part = cook_date .split (' ' )
73+ year = f"20{ date_part [:2 ]} "
74+ month = date_part [3 :5 ]
75+ day = date_part [6 :8 ]
76+ res ['roastdate' ] = f"{ year } -{ month } -{ day } "
77+ res ['roastisodate' ] = f"{ year } -{ month } -{ day } "
78+ res ['roasttime' ] = time_part
79+ except :
80+ _log .warning ('Could not parse CookDate: %s' , cook_date )
81+
82+ # Analyze comments/titles
83+ if 'Comment' in sections and sections ['Comment' ]:
84+ comment = sections ['Comment' ][0 ].strip () if sections ['Comment' ] else ''
85+ if comment :
86+ res ['title' ] = comment
87+
88+ # Analyzing preheating temperature
89+ if 'PreTemp' in sections and sections ['PreTemp' ]:
90+ pre_temp = sections ['PreTemp' ][0 ].strip () if sections ['PreTemp' ] else ''
91+ if pre_temp :
92+ try :
93+ res ['drumtemp' ] = float (pre_temp ) # Use the preheating temperature as the initial drum temperature
94+ except ValueError :
95+ pass
96+
97+ # Parse DATA section
98+ if 'DATA' in sections :
99+ data_lines = sections ['DATA' ]
100+
101+ # First line is header
102+ if data_lines :
103+ headers = [h .strip () for h in data_lines [0 ].split (',' )]
104+
105+ # Store previous parameter values to detect changes
106+ last_fan = None # Corresponds to SM (Fan/Air) -> Fan (index 0)
107+ last_drum = None # Corresponds to RL (Rotation) -> Drum (index 1)
108+ last_burner = None # Corresponds to HP (Heat Power) -> Burner (index 3)
109+
110+ # Prepare lists for extra device data
111+ sm_list = [] # SM (Fan/Air) -> Fan %
112+ rl_list = [] # RL (Rotation) -> Drum %
113+ hp_list = [] # HP (Heat Power) -> Burner %
114+ sv_list = [] # SV (Set Value) -> Set Value
115+ # hpm_list = [] # HPM (Manual/Auto mode) -> Mode (not displayed in Roast Properties Data)
116+ # ps_list = [] # PS (Status) -> Status (not displayed in Roast Properties Data)
117+
118+ for idx , line in enumerate (data_lines [1 :]): # Skip the header row
119+ line = line .strip ()
120+ if line :
121+ parts = line .split (',' )
122+ if len (parts ) >= 11 : # Ensure there are enough columns
123+ try :
124+ # 解析数据列: Index,Time,BT,ET,RoR,SV,HPM,HP,SM,RL,PS
125+ # Index = parts[0] (跳过)
126+ time_ms = int (parts [1 ]) # Time (milliseconds)
127+ bt = float (parts [2 ]) # BT
128+ et = float (parts [3 ]) # ET
129+ ror = float (parts [4 ]) # RoR
130+ sv = float (parts [5 ]) # Set value
131+
132+ hpm_str = parts [6 ].strip () # HPM (Manual/Auto Mode - M=Manual Heating, A=PID Heating Control based on SV value)
133+ hp_str = parts [7 ].strip () # HP (Burner)
134+ sm_str = parts [8 ].strip () # SM (Air damper setting)
135+ rl_str = parts [9 ].strip () # RL (RPM)
136+ ps_str = parts [10 ].strip () # PS (Burner Status - O OR C)
137+
138+ # Conversion time (milliseconds to seconds)
139+ time_sec = time_ms / 1000.0
140+
141+ # Convert each parameter value
142+ hpm = hpm_str if hpm_str else 'M' # Default to manual mode
143+ hp = float (hp_str ) if hp_str and hp_str not in ['0' , '' ] else 0.0
144+ sm = float (sm_str ) if sm_str and sm_str not in ['0' , '' ] else 0.0
145+ rl = float (rl_str ) if rl_str and rl_str not in ['0' , '' ] else 0.0
146+ ps = ps_str if ps_str else 'O' # Default to firepower on.
147+
148+ # Add to the data list
149+ timex .append (time_sec )
150+ temp1 .append (et )
151+ temp2 .append (bt )
152+ temp3 .append (ror )
153+
154+ # Add to extra device data lists
155+ sm_list .append (sm )
156+ rl_list .append (rl )
157+ hp_list .append (hp )
158+ sv_list .append (sv )
159+ # HPM: M=Manual Heat (100), A=PID Heat Control based on SV value (0) - Kaleido machine mode, not displayed in Roast Properties Data
160+ # hpm_numeric = 100 if hpm == 'M' else 0
161+ # hpm_list.append(hpm_numeric) - HPM data not added to extra device list
162+ # PS: O=Heat On (100), C=Heat Off (0) - Kaleido machine specific, not displayed in Roast Properties Data
163+ # ps_numeric = 100 if ps == 'O' else 0
164+ # ps_list.append(ps_numeric) - PS data not added to extra device list
165+
166+ # Detect Fan (SM - Fan/Air) changes - Map to Artisan Fan (index 0)
167+ if sm != last_fan :
168+ last_fan = sm
169+ specialeventsvalue .append (eventsExternal2InternalValue (int (sm )))
170+ specialevents .append (idx )
171+ specialeventstype .append (0 ) # Fan
172+ specialeventsStrings .append (f'SM={ int (sm )} %' )
173+
174+ # Detect Drum (RL - Rotation) changes - Map to Artisan Drum (index 1)
175+ if rl != last_drum :
176+ last_drum = rl
177+ specialeventsvalue .append (eventsExternal2InternalValue (int (rl )))
178+ specialevents .append (idx )
179+ specialeventstype .append (1 ) # Drum
180+ specialeventsStrings .append (f'RL={ int (rl )} %' )
181+
182+ # Detect Burner (HP - Heat Power) changes - Map to Artisan Burner (index 3)
183+ if hp != last_burner :
184+ last_burner = hp
185+ specialeventsvalue .append (eventsExternal2InternalValue (int (hp )))
186+ specialevents .append (idx )
187+ specialeventstype .append (3 ) # Burner
188+ specialeventsStrings .append (f'HP={ int (hp )} %' )
189+
190+ except (ValueError , IndexError ) as e :
191+ # Skip unparsable lines
192+ _log .warning ('Could not parse data line: %s, error: %s' , line , str (e ))
193+ continue
194+
195+ # Add extra device data - Include all Kaleido parameters except HPM and PS
196+ if timex : # Ensure there is data
197+ res ['extradevices' ] = [32 , 33 , 44 , 45 ] # Device IDs, removed HPM and PS device IDs
198+ res ['extraname1' ] = ['SM' , 'RL' , 'HP' , 'SV' ] # Removed HPM and PS
199+ res ['extraname2' ] = ['Fan' , 'Drum' , 'Burner' , 'SV' ] # Removed HPM and PS
200+ res ['extratimex' ] = [timex , timex , timex , timex ] # Removed HPM and PS time axis
201+ res ['extratemp1' ] = [sm_list , rl_list , hp_list , sv_list ] # Removed HPM and PS data
202+ res ['extratemp2' ] = [[0 ]* len (timex ), [0 ]* len (timex ), [0 ]* len (timex ), [0 ]* len (timex )] # Removed HPM and PS data
203+
204+ # Parse event timestamps and map to Artisan timeindex
205+ # Mapping relationship:
206+ # StartBeansIn -> CHARGE (timeindex[0])
207+ # TurntoYellow -> DRY END (timeindex[1])
208+ # 1stBoomStart -> FC START (timeindex[2])
209+ # 1stBoomEnd -> FC END (timeindex[3])
210+ # 2ndBoomStart -> SC START (timeindex[4])
211+ # 2ndBoomEnd -> SC END (timeindex[5])
212+ # BeansColdDown -> DROP (timeindex[6])
213+
214+ event2timeindex = {
215+ 'StartBeansIn' : 0 , # CHARGE
216+ 'TurntoYellow' : 1 , # DRY END
217+ '1stBoomStart' : 2 , # FC START
218+ '1stBoomEnd' : 3 , # FC END
219+ '2ndBoomStart' : 4 , # SC START
220+ '2ndBoomEnd' : 5 , # SC END
221+ 'BeansColdDown' : 6 # DROP
222+ }
223+
224+ # Process events
225+ for event_name , time_idx in event2timeindex .items ():
226+ if event_name in sections :
227+ event_data = sections [event_name ][0 ] if sections [event_name ] else ''
228+ if event_data :
229+ # Parse format like "170@00:00" -> temperature@time
230+ if '@' in event_data :
231+ try :
232+ time_str = event_data .split ('@' )[1 ]
233+ time_parts = time_str .split (':' )
234+ if len (time_parts ) == 2 :
235+ minutes = int (time_parts [0 ])
236+ seconds = int (time_parts [1 ])
237+ time_seconds = minutes * 60 + seconds
238+ # Find closest time index
239+ if timex : # Ensure timex is not empty
240+ closest_idx = min (range (len (timex )), key = lambda i : abs (timex [i ] - time_seconds ))
241+ timeindex [time_idx ] = closest_idx
242+ except (ValueError , IndexError ) as e :
243+ _log .warning ('Could not parse event time for %s: %s, error: %s' , event_name , event_data , str (e ))
244+
245+ # Set basic roast information
246+ res ['samplinginterval' ] = 1.5 # Kaleido CSV sampling interval is 1.5 seconds
247+ res ['mode' ] = 'C' # Default Celsius
248+
249+ # Set roast data
250+ res ['timex' ] = timex
251+ res ['temp1' ] = temp1
252+ res ['temp2' ] = temp2
253+ if temp3 and all (x != 0 for x in temp3 ): # If RoR data exists and not all zeros
254+ res ['temp3' ] = temp3
255+
256+ res ['timeindex' ] = timeindex
257+
258+ # Set special events (if exist)
259+ if len (specialevents ) > 0 :
260+ res ['specialevents' ] = specialevents
261+ res ['specialeventstype' ] = specialeventstype
262+ res ['specialeventsvalue' ] = specialeventsvalue
263+ res ['specialeventsStrings' ] = specialeventsStrings
264+
265+ # Set event type names
266+ # Artisan event type system:
267+ # Index 0: Fan
268+ # Index 1: Drum
269+ # Index 2: Damper
270+ # Index 3: Burner
271+ # Index 4: '--' (Special events/annotations for marking notes during roasting)
272+ #
273+ # Kaleido has only 3 control events, using 3 from Artisan:
274+ # SM (Fan/Air) -> Fan (0)
275+ # RL (Rotation) -> Drum (1)
276+ # HP (Heat Power) -> Burner (3)
277+ # HPM (M=Manual Heat, A=PID Heat Control based on SV value) - Kaleido machine mode, not used as Artisan control event
278+ # PS (Status) - Kaleido machine specific, not mapped to Artisan control event
279+ alt_etypesdefault [0 ] = 'Fan' # Corresponds to SM
280+ alt_etypesdefault [1 ] = 'Drum' # Corresponds to RL
281+ alt_etypesdefault [2 ] = 'Damper' # Reserved Damper label, but Kaleido doesn't use
282+ alt_etypesdefault [3 ] = 'Burner' # Corresponds to HP
283+ alt_etypesdefault [4 ] = '--' # Special events/annotations
284+ res ['etypes' ] = alt_etypesdefault
285+
286+ # Set roaster information
287+ res ['roastertype' ] = 'Kaleido Legacy'
288+ res ['roastersize' ] = 0.1 # Default roaster size
289+
290+ # Parse total time
291+ if 'TotalTime' in sections and sections ['TotalTime' ]:
292+ total_time = sections ['TotalTime' ][0 ].strip () if sections ['TotalTime' ] else ''
293+ if total_time :
294+ try :
295+ time_parts = total_time .split (':' )
296+ if len (time_parts ) == 2 :
297+ minutes = int (time_parts [0 ])
298+ seconds = int (time_parts [1 ])
299+ total_seconds = minutes * 60 + seconds
300+ res ['roasttotaltime' ] = total_seconds
301+ except ValueError :
302+ pass
303+
304+ return res
0 commit comments