hudini 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hudini/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Reads da Vinci Xi system state from the heads-up display in surgical video."""
hudini/archive.py ADDED
@@ -0,0 +1,414 @@
1
+ """Reads one directory tree of logs for the web viewer.
2
+
3
+ :class:`Archive` keeps an inventory of the ``.hudini.jsonl.gz`` logs under
4
+ a directory. It answers what the index page asks: which videos exist,
5
+ their summaries, and which entries match a query. A scan reads only the
6
+ first and last lines of each file. This module does not import torch or
7
+ paddleocr.
8
+ """
9
+
10
+ import gzip
11
+ import os
12
+ import threading
13
+ import zlib
14
+ from concurrent.futures import ThreadPoolExecutor
15
+ from dataclasses import dataclass
16
+ from enum import StrEnum
17
+ from pathlib import Path
18
+
19
+ import msgspec
20
+ from rapidfuzz import fuzz
21
+
22
+ from hudini.catalog import Catalog
23
+ from hudini.corrections import correct, rules_from_settings
24
+ from hudini.schema import (
25
+ APPENDIX_MARK,
26
+ FOOTER_MARK,
27
+ Engine,
28
+ Footer,
29
+ Header,
30
+ decode_appendix,
31
+ decode_footer,
32
+ decode_header,
33
+ )
34
+ from hudini.storage import load, tail_lengths
35
+ from hudini.views import Interval, Summary, interval_view, intervals_from_appendix
36
+
37
+ LOG_SUFFIX = ".hudini.jsonl.gz"
38
+ PARTIAL_LOG_SUFFIX = ".hudini.jsonl.partial"
39
+ VIDEO_SUFFIXES = (".mp4", ".mkv", ".mov", ".avi")
40
+ FUZZY_MATCH_THRESHOLD = 80.0
41
+ TAIL_WINDOW_BYTES = 2**20
42
+ HEAD_PROBE_BYTES = 2**16
43
+ SCAN_WORKERS = 8
44
+
45
+
46
+ class EntryState(StrEnum):
47
+ """What the archive knows about one log."""
48
+
49
+ COMPLETE = "complete"
50
+ RUNNING = "running"
51
+ OUTDATED = "outdated"
52
+
53
+
54
+ class SearchField(StrEnum):
55
+ """The corpus fields a search token can be narrowed to.
56
+
57
+ ``TEXT`` is the union of the popup and banner fields, for queries
58
+ that do not care which kind of message carried a string.
59
+ """
60
+
61
+ NAME = "name"
62
+ INSTRUMENT = "instrument"
63
+ ACTION = "action"
64
+ POPUP = "popup"
65
+ BANNER = "banner"
66
+ TEXT = "text"
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class ArchiveEntry:
71
+ """One log in the archive, as the index page shows it.
72
+
73
+ Attributes:
74
+ name: the log's path inside the archive without the log suffix,
75
+ in POSIX form. This is the archive-wide identity.
76
+ state: COMPLETE for a sealed log with a current summary,
77
+ RUNNING for a partial one, OUTDATED for a sealed log whose
78
+ summary version is not current.
79
+ summary: the typed summary for a COMPLETE entry, else None.
80
+ progress: parsed fraction 0-1 for a RUNNING entry with a known
81
+ video duration, else None.
82
+ """
83
+
84
+ name: str
85
+ path: Path
86
+ state: EntryState
87
+ header: Header
88
+ footer: Footer | None = None
89
+ summary: Summary | None = None
90
+ progress: float | None = None
91
+
92
+
93
+ def log_locale(header: Header) -> str:
94
+ """The catalog locale a log was parsed with. ``en`` when none is
95
+ recorded."""
96
+ catalog_engine = header.engines.get(Engine.CATALOG)
97
+ if catalog_engine is None or catalog_engine.locale is None:
98
+ return "en"
99
+ return catalog_engine.locale
100
+
101
+
102
+ def _head_line(head: bytes) -> bytes | None:
103
+ """The first decompressed line inside a gzip head probe, or None
104
+ when the probe did not reach a newline."""
105
+ decompressor = zlib.decompressobj(wbits=zlib.MAX_WBITS | 16)
106
+ text = decompressor.decompress(head)
107
+ newline = text.find(b"\n")
108
+ if newline < 0:
109
+ return None
110
+ return text[:newline]
111
+
112
+
113
+ def _edge_lines(path: Path) -> tuple[bytes, bytes, bytes]:
114
+ """Line 1 and the last two non-empty lines, no body parsing.
115
+
116
+ A ``.gz`` file with a tail pointer costs three small reads: the
117
+ head probe, then one seek each for the footer and appendix
118
+ members. A ``.gz`` file without one is one streaming decompression
119
+ pass. A plain path, the ``.partial`` form, reads only the head and
120
+ a tail window. A missing line is empty bytes."""
121
+ if path.suffix == ".gz":
122
+ with path.open("rb") as file:
123
+ head = file.read(HEAD_PROBE_BYTES)
124
+ lengths = tail_lengths(head)
125
+ first = _head_line(head) if lengths is not None else None
126
+ if lengths is not None and first is not None:
127
+ appendix_length, footer_length = lengths
128
+ size = file.seek(0, os.SEEK_END)
129
+ file.seek(size - footer_length)
130
+ last = gzip.decompress(file.read(footer_length)).strip()
131
+ second_last = b""
132
+ if appendix_length:
133
+ file.seek(size - footer_length - appendix_length)
134
+ second_last = gzip.decompress(file.read(appendix_length)).strip()
135
+ return first, second_last, last
136
+ first = b""
137
+ second_last = b""
138
+ last = b""
139
+ with gzip.open(path, "rb") as file:
140
+ for line in file:
141
+ if not line.strip():
142
+ continue
143
+ if not first:
144
+ first = line
145
+ second_last = last
146
+ last = line
147
+ return first, second_last, last
148
+ with path.open("rb") as file:
149
+ first = file.readline()
150
+ file.seek(0, os.SEEK_END)
151
+ size = file.tell()
152
+ window = min(size, TAIL_WINDOW_BYTES)
153
+ file.seek(size - window)
154
+ chunk = file.read(window)
155
+ lines = [line for line in chunk.splitlines() if line.strip()]
156
+ if window < size and lines:
157
+ lines = lines[1:]
158
+ last = lines[-1] if lines else b""
159
+ second_last = lines[-2] if len(lines) >= 2 else b""
160
+ if not first.strip():
161
+ first = b""
162
+ return first, second_last, last
163
+
164
+
165
+ def _maybe_footer(line: bytes) -> Footer | None:
166
+ entry = msgspec.json.decode(line)
167
+ if isinstance(entry, dict) and entry.get("hudini") == FOOTER_MARK:
168
+ return decode_footer(line)
169
+ return None
170
+
171
+
172
+ def _cached_intervals(second_last: bytes) -> list[Interval] | None:
173
+ """The appendix intervals from the second-to-last line, or None
174
+ when the line is not an appendix or its version is stale."""
175
+ if not second_last:
176
+ return None
177
+ entry = msgspec.json.decode(second_last)
178
+ if not (isinstance(entry, dict) and entry.get("hudini") == APPENDIX_MARK):
179
+ return None
180
+ return intervals_from_appendix(decode_appendix(second_last))
181
+
182
+
183
+ def _maybe_time_s(line: bytes) -> float | None:
184
+ """The ``t`` of an observation line, or None when the line is not
185
+ one. The live tail of a partial file can end mid-write."""
186
+ try:
187
+ entry = msgspec.json.decode(line)
188
+ except msgspec.DecodeError:
189
+ return None
190
+ if isinstance(entry, dict) and isinstance(entry.get("t"), (int, float)):
191
+ return float(entry["t"])
192
+ return None
193
+
194
+
195
+ def tokenize(query: str) -> list[tuple[SearchField | None, str]]:
196
+ """Query tokens as (field, text). A quoted phrase is one token, and
197
+ a ``field:`` prefix narrows a token to one corpus field."""
198
+ tokens: list[tuple[SearchField | None, str]] = []
199
+ fields = {member.value: member for member in SearchField}
200
+ rest = query.strip()
201
+ while rest:
202
+ field = None
203
+ prefix, colon, after = rest.partition(":")
204
+ if colon and prefix.casefold() in fields and " " not in prefix:
205
+ field = fields[prefix.casefold()]
206
+ rest = after.lstrip()
207
+ if rest.startswith('"'):
208
+ text, _quote, rest = rest[1:].partition('"')
209
+ else:
210
+ text, _space, rest = rest.partition(" ")
211
+ rest = rest.lstrip()
212
+ if text:
213
+ tokens.append((field, text))
214
+ return tokens
215
+
216
+
217
+ class Archive:
218
+ """A reader over one directory tree of logs, with an inventory.
219
+
220
+ Discovery is explicit: :meth:`scan` walks the tree and rebuilds
221
+ the inventory, and :meth:`entries` and :meth:`search` read the
222
+ inventory without touching the filesystem. Per-file reads are
223
+ memoized across scans, keyed by name, mtime, and size. A changed
224
+ file is read again, an unchanged one is not.
225
+ """
226
+
227
+ def __init__(self, directory: Path | str) -> None:
228
+ self.directory = Path(directory).expanduser().resolve()
229
+ self._memo: dict[str, tuple[tuple[float, int], ArchiveEntry]] = {}
230
+ self._inventory: list[ArchiveEntry] = []
231
+ self._scan_lock = threading.Lock()
232
+ self._intervals: dict[str, tuple[tuple[float, int], list[Interval]]] = {}
233
+ self._catalogs: dict[str, Catalog] = {}
234
+
235
+ def scan(self) -> list[ArchiveEntry]:
236
+ """Walk the tree and rebuild the inventory.
237
+
238
+ Finds every log under the directory, nested included, and reads
239
+ the edges of new and changed files on a thread pool. One scan
240
+ runs at a time: a call that arrives while another scan runs
241
+ waits for that scan and returns its inventory.
242
+ """
243
+ if not self._scan_lock.acquire(blocking=False):
244
+ with self._scan_lock:
245
+ return self._inventory
246
+ try:
247
+ self._inventory = self._walk()
248
+ finally:
249
+ self._scan_lock.release()
250
+ return self._inventory
251
+
252
+ def _walk(self) -> list[ArchiveEntry]:
253
+ found: list[ArchiveEntry] = []
254
+ unread: list[tuple[Path, str, tuple[float, int]]] = []
255
+ for parent, _subdirectories, files in os.walk(self.directory):
256
+ for file_name in files:
257
+ if not file_name.endswith((LOG_SUFFIX, PARTIAL_LOG_SUFFIX)):
258
+ continue
259
+ path = Path(parent) / file_name
260
+ relative = path.relative_to(self.directory).as_posix()
261
+ stat = path.stat()
262
+ key = (stat.st_mtime, stat.st_size)
263
+ memoized = self._memo.get(relative)
264
+ if memoized is not None and memoized[0] == key:
265
+ found.append(memoized[1])
266
+ continue
267
+ unread.append((path, relative, key))
268
+ if unread:
269
+ with ThreadPoolExecutor(max_workers=SCAN_WORKERS) as pool:
270
+ read = pool.map(lambda task: self._read_entry(task[0], task[1]), unread)
271
+ for (_path, relative, key), entry in zip(unread, read, strict=True):
272
+ self._memo[relative] = (key, entry)
273
+ found.append(entry)
274
+ found.sort(key=lambda entry: entry.name)
275
+ return found
276
+
277
+ def entries(self) -> list[ArchiveEntry]:
278
+ """The inventory of the last scan, sorted by name.
279
+
280
+ Empty before the first :meth:`scan`. Reads no file.
281
+ """
282
+ return self._inventory
283
+
284
+ def search(self, query: str) -> list[ArchiveEntry]:
285
+ """The inventory entries whose corpus matches every token of
286
+ ``query``.
287
+
288
+ A token matches by substring first, then by fuzzy ratio. Entries
289
+ without a summary match on the file name only. An empty query
290
+ matches everything. Reads no file.
291
+ """
292
+ tokens = tokenize(query)
293
+ return [
294
+ entry
295
+ for entry in self._inventory
296
+ if all(self._matches(entry, field, text) for field, text in tokens)
297
+ ]
298
+
299
+ def intervals(self, name: str) -> list[Interval]:
300
+ """One video's intervals, with the log's own corrections applied.
301
+
302
+ A log with a current appendix answers from its cached
303
+ intervals, one tail read, no derivation. A log without one, or
304
+ with a stale ``intervals_version``, derives live from the
305
+ body. Results are memoized by name, mtime, and size.
306
+
307
+ Raises:
308
+ FileNotFoundError: no finished log named ``name``.
309
+ """
310
+ path = self.directory / f"{name}{LOG_SUFFIX}"
311
+ stat = path.stat()
312
+ key = (stat.st_mtime, stat.st_size)
313
+ memoized = self._intervals.get(name)
314
+ if memoized is not None and memoized[0] == key:
315
+ return memoized[1]
316
+ _first, second_last, _last = _edge_lines(path)
317
+ intervals = _cached_intervals(second_last)
318
+ if intervals is None:
319
+ log = load(path)
320
+ catalog = self._catalog(log_locale(log.header))
321
+ patches = correct(log, rules_from_settings(log.header.corrections), catalog)
322
+ intervals = interval_view(log, patches, catalog)
323
+ self._intervals[name] = (key, intervals)
324
+ return intervals
325
+
326
+ def video_path(self, name: str) -> Path | None:
327
+ """The video file for ``name``, next to its log, or None.
328
+
329
+ Looks for the filename the log's header records, then for the
330
+ log's own basename with a common video suffix.
331
+ """
332
+ memoized = self._memo.get(f"{name}{LOG_SUFFIX}") or self._memo.get(
333
+ f"{name}{PARTIAL_LOG_SUFFIX}"
334
+ )
335
+ candidates = []
336
+ if memoized is not None:
337
+ candidates.append((self.directory / name).parent / memoized[1].header.video.filename)
338
+ candidates.extend(self.directory / f"{name}{suffix}" for suffix in VIDEO_SUFFIXES)
339
+ for candidate in candidates:
340
+ if candidate.is_file():
341
+ return candidate
342
+ return None
343
+
344
+ def _catalog(self, locale: str) -> Catalog:
345
+ if locale not in self._catalogs:
346
+ self._catalogs[locale] = Catalog.load(locale=locale)
347
+ return self._catalogs[locale]
348
+
349
+ def _read_entry(self, path: Path, relative: str) -> ArchiveEntry:
350
+ first, _second_last, last = _edge_lines(path)
351
+ header = decode_header(first)
352
+ name = relative.removesuffix(LOG_SUFFIX).removesuffix(PARTIAL_LOG_SUFFIX)
353
+ footer = None if path.name.endswith(PARTIAL_LOG_SUFFIX) else _maybe_footer(last)
354
+ if footer is None:
355
+ return ArchiveEntry(
356
+ name=name,
357
+ path=path,
358
+ state=EntryState.RUNNING,
359
+ header=header,
360
+ progress=self._progress(header, last),
361
+ )
362
+ summary = Summary.from_footer(footer)
363
+ if summary is None:
364
+ return ArchiveEntry(
365
+ name=name, path=path, state=EntryState.OUTDATED, header=header, footer=footer
366
+ )
367
+ return ArchiveEntry(
368
+ name=name,
369
+ path=path,
370
+ state=EntryState.COMPLETE,
371
+ header=header,
372
+ footer=footer,
373
+ summary=summary,
374
+ )
375
+
376
+ def _progress(self, header: Header, last: bytes) -> float | None:
377
+ duration = header.video.duration_s
378
+ time_s = _maybe_time_s(last)
379
+ if duration is None or duration <= 0 or time_s is None:
380
+ return None
381
+ return min(1.0, round(time_s / duration, 4))
382
+
383
+ def _matches(self, entry: ArchiveEntry, field: SearchField | None, text: str) -> bool:
384
+ corpus = self._corpus(entry)
385
+ if field is None:
386
+ values = [value for values in corpus.values() for value in values]
387
+ elif field is SearchField.TEXT:
388
+ values = [*corpus[SearchField.POPUP], *corpus[SearchField.BANNER]]
389
+ else:
390
+ values = corpus[field]
391
+ needle = text.casefold()
392
+ if any(needle in value.casefold() for value in values):
393
+ return True
394
+ return any(
395
+ fuzz.partial_ratio(needle, value.casefold()) >= FUZZY_MATCH_THRESHOLD
396
+ for value in values
397
+ )
398
+
399
+ def _corpus(self, entry: ArchiveEntry) -> dict[SearchField, list[str]]:
400
+ corpus: dict[SearchField, list[str]] = {
401
+ SearchField.NAME: [entry.name],
402
+ SearchField.INSTRUMENT: [],
403
+ SearchField.ACTION: [],
404
+ SearchField.POPUP: [],
405
+ SearchField.BANNER: [],
406
+ }
407
+ if entry.summary is None:
408
+ return corpus
409
+ for use in entry.summary.instruments:
410
+ corpus[SearchField.INSTRUMENT].extend((use.name, use.type))
411
+ corpus[SearchField.ACTION].extend(entry.summary.presses_by_action)
412
+ corpus[SearchField.POPUP].extend(entry.summary.popup_texts)
413
+ corpus[SearchField.BANNER].extend(entry.summary.banner_texts)
414
+ return corpus
@@ -0,0 +1,266 @@
1
+ {
2
+ "_description": "Da Vinci Xi pod strings — instrument names and system messages as they appear in the surgical UI. Stored lowercase; UI rendering should apply .title() (or similar) at display boundaries. Loading expands each entry into its aliases — the string forms it can be found under (canonical name, localized name, reload-color variants). Aliases are derived at load time and are not stored here.",
3
+ "_schema": {
4
+ "entries[i].name": "Canonical English UI name (required). Lowercase; how the instrument appears in the da Vinci surgeon-console UI.",
5
+ "entries[i].type": "Required. Taxonomy of the pod string (snake_case). For instruments it is the clinical/functional class — e.g. bipolar_grasper, needle_driver, stapler ('what does this tool do?'); non-instrument pod strings use system_message.",
6
+ "entries[i].localized": "Optional. {locale: name} for entries whose UI display is locale-specific (e.g. system messages rendered in German).",
7
+ "entries[i].reload_colors": "Optional. List of English color codes; expanded to 'name [color_locales[locale][c]]' at load time. Only present on stapler entries that have reload variants.",
8
+ "entries[i].pedals": "Optional. Per-pedal labels seen in the UI for this instrument, stored lowercase like other catalog strings. Shape: {yellow: [labels...], blue: [labels...]}. Each list is the labels that can appear on that pedal across instrument states, ORDERED WITH THE IDLE LABEL FIRST — what the pod shows while the pedal is not pressed. Multiple labels indicate state-dependent behavior (e.g. force bipolar yellow reads 'grip' idle and 'strong' held), and the reader only OCRs the label while a pedal is pressed, taking the idle one from here. An empty list means that pedal has no action for this instrument (e.g. maryland bipolar has no yellow pedal). Omitting the whole `pedals` field means unknown — do not infer from type, since some mechanical instruments may still have pedal actions."
9
+ },
10
+ "color_locales": {
11
+ "de": {
12
+ "gray": "grau",
13
+ "white": "weiss",
14
+ "blue": "blau",
15
+ "green": "grün",
16
+ "black": "schwarz"
17
+ },
18
+ "en": {
19
+ "gray": "gray",
20
+ "white": "white",
21
+ "blue": "blue",
22
+ "green": "green",
23
+ "black": "black"
24
+ }
25
+ },
26
+ "entries": [
27
+ {
28
+ "name": "maryland bipolar forceps",
29
+ "type": "bipolar_grasper",
30
+ "pedals": { "yellow": [], "blue": ["bipolar"] }
31
+ },
32
+ {
33
+ "name": "curved bipolar dissector",
34
+ "type": "bipolar_grasper",
35
+ "pedals": { "yellow": [], "blue": ["coag"] }
36
+ },
37
+ {
38
+ "name": "long bipolar grasper",
39
+ "type": "bipolar_grasper",
40
+ "pedals": { "yellow": [], "blue": ["coag"] }
41
+ },
42
+ {
43
+ "name": "force bipolar",
44
+ "type": "bipolar_grasper",
45
+ "pedals": { "yellow": ["grip", "strong"], "blue": ["coag"] }
46
+ },
47
+ {
48
+ "name": "micro bipolar forceps",
49
+ "type": "bipolar_grasper",
50
+ "pedals": { "yellow": [], "blue": ["coag"] }
51
+ },
52
+ {
53
+ "name": "fenestrated bipolar forceps",
54
+ "type": "bipolar_grasper",
55
+ "pedals": { "yellow": [], "blue": ["coag"] }
56
+ },
57
+
58
+ {
59
+ "name": "medium-large clip applier",
60
+ "type": "clip_applier",
61
+ "pedals": { "yellow": [], "blue": [] }
62
+ },
63
+ {
64
+ "name": "large clip applier",
65
+ "type": "clip_applier",
66
+ "pedals": { "yellow": [], "blue": [] }
67
+ },
68
+ {
69
+ "name": "small clip applier",
70
+ "type": "clip_applier",
71
+ "pedals": { "yellow": [], "blue": [] }
72
+ },
73
+
74
+ {
75
+ "name": "potts scissors",
76
+ "type": "cold_scissors",
77
+ "pedals": { "yellow": [], "blue": [] }
78
+ },
79
+ { "name": "round tip scissors", "type": "cold_scissors" },
80
+ { "name": "curved scissors", "type": "cold_scissors" },
81
+
82
+ {
83
+ "name": "prograsp forceps",
84
+ "type": "grasper",
85
+ "pedals": { "yellow": [], "blue": [] }
86
+ },
87
+ { "name": "long tip forceps", "type": "grasper" },
88
+ {
89
+ "name": "small grasping retractor",
90
+ "type": "grasper",
91
+ "pedals": { "yellow": [], "blue": [] }
92
+ },
93
+ { "name": "cobra grasper", "type": "grasper" },
94
+ {
95
+ "name": "crocodile grasper",
96
+ "type": "grasper",
97
+ "pedals": { "yellow": [], "blue": [] }
98
+ },
99
+ {
100
+ "name": "tip-up fenestrated grasper",
101
+ "type": "grasper",
102
+ "pedals": { "yellow": [], "blue": [] }
103
+ },
104
+ {
105
+ "name": "cadiere forceps",
106
+ "type": "grasper",
107
+ "pedals": { "yellow": [], "blue": [] }
108
+ },
109
+ {
110
+ "name": "tenaculum forceps",
111
+ "type": "grasper",
112
+ "pedals": { "yellow": [], "blue": [] }
113
+ },
114
+ { "name": "fundus grasper", "type": "grasper" },
115
+ { "name": "maryland dissector", "type": "grasper" },
116
+ {
117
+ "name": "resano forceps",
118
+ "type": "grasper",
119
+ "pedals": { "yellow": [], "blue": [] }
120
+ },
121
+ { "name": "dual blade retractor", "type": "grasper" },
122
+ { "name": "cardiac probe grasper", "type": "grasper" },
123
+ { "name": "atrial retractor short right", "type": "grasper" },
124
+ {
125
+ "name": "black diamond micro forceps",
126
+ "type": "grasper",
127
+ "pedals": { "yellow": [], "blue": [] }
128
+ },
129
+ {
130
+ "name": "debakey forceps",
131
+ "type": "grasper",
132
+ "pedals": { "yellow": [], "blue": [] }
133
+ },
134
+
135
+ {
136
+ "name": "monopolar curved scissors",
137
+ "type": "monopolar_cautery",
138
+ "pedals": { "yellow": ["cut"], "blue": ["coag"] }
139
+ },
140
+ {
141
+ "name": "permanent cautery spatula",
142
+ "type": "monopolar_cautery",
143
+ "pedals": { "yellow": ["cut"], "blue": ["coag"] }
144
+ },
145
+ {
146
+ "name": "permanent cautery hook",
147
+ "type": "monopolar_cautery",
148
+ "pedals": { "yellow": ["cut"], "blue": ["coag"] }
149
+ },
150
+
151
+ {
152
+ "name": "large needle driver",
153
+ "type": "needle_driver",
154
+ "pedals": { "yellow": [], "blue": [] }
155
+ },
156
+ {
157
+ "name": "mega needle driver",
158
+ "type": "needle_driver",
159
+ "pedals": { "yellow": [], "blue": [] }
160
+ },
161
+ {
162
+ "name": "large suturecut needle driver",
163
+ "type": "needle_driver",
164
+ "pedals": { "yellow": [], "blue": [] }
165
+ },
166
+ {
167
+ "name": "mega suturecut needle driver",
168
+ "type": "needle_driver",
169
+ "pedals": { "yellow": [], "blue": [] }
170
+ },
171
+ { "name": "wristed needle driver", "type": "needle_driver" },
172
+ {
173
+ "name": "curved needle driver",
174
+ "type": "needle_driver",
175
+ "pedals": { "yellow": [], "blue": [] }
176
+ },
177
+
178
+ {
179
+ "name": "sureform 60",
180
+ "type": "stapler",
181
+ "reload_colors": ["white", "blue", "green", "black"],
182
+ "pedals": { "yellow": ["fire"], "blue": ["clamp"] }
183
+ },
184
+ {
185
+ "name": "sureform 45",
186
+ "type": "stapler",
187
+ "reload_colors": ["gray", "white", "blue", "green", "black"],
188
+ "pedals": { "yellow": ["fire"], "blue": ["clamp"] }
189
+ },
190
+ {
191
+ "name": "sureform 45 curved-tip",
192
+ "type": "stapler",
193
+ "reload_colors": ["gray", "white", "blue", "green", "black"],
194
+ "pedals": { "yellow": ["fire"], "blue": ["clamp"] }
195
+ },
196
+ {
197
+ "name": "sureform 30 curved-tip",
198
+ "type": "stapler",
199
+ "reload_colors": ["gray", "white", "blue"],
200
+ "pedals": { "yellow": ["fire"], "blue": ["clamp"] }
201
+ },
202
+ {
203
+ "name": "stapler 30",
204
+ "type": "stapler",
205
+ "reload_colors": ["gray", "white", "blue", "green"],
206
+ "pedals": { "yellow": ["fire"], "blue": ["clamp"] }
207
+ },
208
+ {
209
+ "name": "stapler 30 curved-tip",
210
+ "type": "stapler",
211
+ "reload_colors": ["gray", "white", "blue", "green"],
212
+ "pedals": { "yellow": ["fire"], "blue": ["clamp"] }
213
+ },
214
+ {
215
+ "name": "stapler 45",
216
+ "type": "stapler",
217
+ "reload_colors": ["white", "blue", "green"],
218
+ "pedals": { "yellow": ["fire"], "blue": ["clamp"] }
219
+ },
220
+ {
221
+ "name": "stapler 45 curved-tip",
222
+ "type": "stapler",
223
+ "reload_colors": ["white", "blue", "green"],
224
+ "pedals": { "yellow": ["fire"], "blue": ["clamp"] }
225
+ },
226
+
227
+ {
228
+ "name": "suction irrigator",
229
+ "type": "suction_irrigator",
230
+ "pedals": { "yellow": ["irrigate"], "blue": ["suction"] }
231
+ },
232
+
233
+ {
234
+ "name": "remove instrument",
235
+ "type": "system_message",
236
+ "localized": { "de": "instrument entfernen" }
237
+ },
238
+ {
239
+ "name": "arm stowed",
240
+ "type": "system_message",
241
+ "localized": { "de": "arm in stau-position" }
242
+ },
243
+
244
+ {
245
+ "name": "harmonic ace",
246
+ "type": "ultrasonic_shears",
247
+ "pedals": { "yellow": ["min"], "blue": ["max"] }
248
+ },
249
+
250
+ {
251
+ "name": "vessel sealer",
252
+ "type": "vessel_sealer",
253
+ "pedals": { "yellow": ["cut"], "blue": ["bipolar", "seal"] }
254
+ },
255
+ {
256
+ "name": "vessel sealer extend",
257
+ "type": "vessel_sealer",
258
+ "pedals": { "yellow": ["cut"], "blue": ["seal"] }
259
+ },
260
+ {
261
+ "name": "synchroseal",
262
+ "type": "vessel_sealer",
263
+ "pedals": { "yellow": ["sync"], "blue": ["seal", "coag"] }
264
+ }
265
+ ]
266
+ }
@@ -0,0 +1,9 @@
1
+ [
2
+ {
3
+ "id": "energy_preset_applied",
4
+ "templates": {
5
+ "de": "{surgeon}s 'preset {x}' energievoreinstellung angewendet",
6
+ "en": "{surgeon}'s '{x}' energy preset applied"
7
+ }
8
+ }
9
+ ]