rhylthyme-cli-runner 0.2.7a0__tar.gz → 0.2.8a0__tar.gz

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.
Files changed (89) hide show
  1. {rhylthyme_cli_runner-0.2.7a0/src/rhylthyme_cli_runner.egg-info → rhylthyme_cli_runner-0.2.8a0}/PKG-INFO +4 -2
  2. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/setup.py +4 -2
  3. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/__init__.py +1 -1
  4. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/cli.py +67 -4
  5. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/recorder.py +36 -0
  6. rhylthyme_cli_runner-0.2.8a0/src/rhylthyme_cli_runner/instruments.py +258 -0
  7. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/program_planner.py +30 -0
  8. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/program_runner.py +305 -6
  9. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/remote/cli.py +23 -1
  10. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/validate_program.py +19 -7
  11. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0/src/rhylthyme_cli_runner.egg-info}/PKG-INFO +4 -2
  12. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner.egg-info/SOURCES.txt +2 -0
  13. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner.egg-info/requires.txt +4 -1
  14. rhylthyme_cli_runner-0.2.8a0/tests/test_instruments.py +1129 -0
  15. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_remote.py +62 -0
  16. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_validate_program.py +8 -0
  17. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/LICENSE +0 -0
  18. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/README.md +0 -0
  19. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/pyproject.toml +0 -0
  20. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/setup.cfg +0 -0
  21. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/environment_icons.py +0 -0
  22. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/environment_loader.py +0 -0
  23. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/environment_schemas.py +0 -0
  24. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/__init__.py +0 -0
  25. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/cli.py +0 -0
  26. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/compare.py +0 -0
  27. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/gold.py +0 -0
  28. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/harness.py +0 -0
  29. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/llm.py +0 -0
  30. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/matcher.py +0 -0
  31. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/metrics.py +0 -0
  32. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/patterns/__init__.py +0 -0
  33. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/patterns/authoring_guide.md +0 -0
  34. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/patterns/baseline.md +0 -0
  35. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/patterns/baseline.py +0 -0
  36. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/patterns/extract.py +0 -0
  37. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/patterns/four_turn.py +0 -0
  38. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/eval/report.py +0 -0
  39. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/expand_replicates.py +0 -0
  40. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/__init__.py +0 -0
  41. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/calibrate.py +0 -0
  42. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/calibrate_cli.py +0 -0
  43. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/cli.py +0 -0
  44. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/evaluate.py +0 -0
  45. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/evaluate_cli.py +0 -0
  46. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/factors.py +0 -0
  47. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/hash.py +0 -0
  48. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/predict.py +0 -0
  49. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/render.py +0 -0
  50. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/replay.py +0 -0
  51. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/report.py +0 -0
  52. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/report_cli.py +0 -0
  53. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/store.py +0 -0
  54. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/synth.py +0 -0
  55. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/history/usable.py +0 -0
  56. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/import_cli.py +0 -0
  57. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/instance_checks.py +0 -0
  58. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/remote/__init__.py +0 -0
  59. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/remote/auth.py +0 -0
  60. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/remote/checks.py +0 -0
  61. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/remote/mcp_client.py +0 -0
  62. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner/render_cli.py +0 -0
  63. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner.egg-info/dependency_links.txt +0 -0
  64. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner.egg-info/entry_points.txt +0 -0
  65. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/src/rhylthyme_cli_runner.egg-info/top_level.txt +0 -0
  66. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_calibrate.py +0 -0
  67. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_cli.py +0 -0
  68. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_eval_baseline.py +0 -0
  69. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_eval_harness.py +0 -0
  70. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_eval_providers.py +0 -0
  71. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_eval_scorer.py +0 -0
  72. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_evaluate.py +0 -0
  73. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_example_validation.py +0 -0
  74. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_examples_integration.py +0 -0
  75. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_expand_replicates.py +0 -0
  76. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_hash_parity.py +0 -0
  77. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_history_report.py +0 -0
  78. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_import_cli.py +0 -0
  79. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_mcp_checks.py +0 -0
  80. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_planner_time_strings.py +0 -0
  81. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_predict.py +0 -0
  82. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_program_runner.py +0 -0
  83. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_render_cli.py +0 -0
  84. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_replay.py +0 -0
  85. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_run_recorder.py +0 -0
  86. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_skill.py +0 -0
  87. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_synth.py +0 -0
  88. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_usable.py +0 -0
  89. {rhylthyme_cli_runner-0.2.7a0 → rhylthyme_cli_runner-0.2.8a0}/tests/test_validate_examples_ci.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rhylthyme-cli-runner
3
- Version: 0.2.7a0
3
+ Version: 0.2.8a0
4
4
  Summary: CLI runner for Rhylthyme real-time program schedules
5
5
  Home-page: https://github.com/rhylthyme/rhylthyme-cli-runner
6
6
  Author: Rhylthyme Team
@@ -26,7 +26,9 @@ Requires-Dist: click>=8.0.0
26
26
  Requires-Dist: jsonschema>=4.0.0
27
27
  Requires-Dist: pyyaml>=6.0
28
28
  Requires-Dist: colorama>=0.4.0
29
- Requires-Dist: rhylthyme-spec>=0.2.0a0
29
+ Requires-Dist: rhylthyme-spec>=0.2.1a0
30
+ Provides-Extra: galago
31
+ Requires-Dist: rhylthyme-galago>=0.1.0a0; extra == "galago"
30
32
  Provides-Extra: dev
31
33
  Requires-Dist: pytest>=6.0.0; extra == "dev"
32
34
  Requires-Dist: pytest-cov>=2.10.0; extra == "dev"
@@ -19,7 +19,7 @@ def read_readme():
19
19
 
20
20
  setup(
21
21
  name="rhylthyme-cli-runner",
22
- version="0.2.7a0",
22
+ version="0.2.8a0",
23
23
  description="CLI runner for Rhylthyme real-time program schedules",
24
24
  long_description=read_readme(),
25
25
  long_description_content_type="text/markdown",
@@ -38,9 +38,11 @@ setup(
38
38
  "jsonschema>=4.0.0",
39
39
  "pyyaml>=6.0",
40
40
  "colorama>=0.4.0",
41
- "rhylthyme-spec>=0.2.0a0",
41
+ "rhylthyme-spec>=0.2.1a0",
42
42
  ],
43
43
  extras_require={
44
+ # Instrument steps on galago-tools lab instruments (--workcell)
45
+ "galago": ["rhylthyme-galago>=0.1.0a0"],
44
46
  "dev": [
45
47
  # Testing framework
46
48
  "pytest>=6.0.0",
@@ -6,7 +6,7 @@ This package provides the command-line interface for running and validating
6
6
  Rhylthyme real-time program schedules.
7
7
  """
8
8
 
9
- __version__ = "0.2.7a0"
9
+ __version__ = "0.2.8a0"
10
10
  __author__ = "Rhylthyme Team"
11
11
  __description__ = "CLI runner for Rhylthyme real-time program schedules"
12
12
 
@@ -105,7 +105,18 @@ def cli(ctx, environments_dir):
105
105
  is_flag=True,
106
106
  help="Enforce all tasks must be defined in resourceConstraints (strict mode)",
107
107
  )
108
- def validate(program_files, schema, environment, verbose, json_output, strict):
108
+ @click.option(
109
+ "--workcell",
110
+ type=click.Path(exists=True, dir_okay=False),
111
+ default=None,
112
+ help=(
113
+ "Workcell file: check instrument steps against its galago tools "
114
+ "(tool names, types, commands and params)"
115
+ ),
116
+ )
117
+ def validate(
118
+ program_files, schema, environment, verbose, json_output, strict, workcell
119
+ ):
109
120
  """
110
121
  Validate one or more program files against the schema.
111
122
 
@@ -115,6 +126,8 @@ def validate(program_files, schema, environment, verbose, json_output, strict):
115
126
  Use --json to get machine-readable output for CI or scripting.
116
127
  Use --strict to require all tasks used in steps/buffers to be defined in resourceConstraints.
117
128
  Use -e/--environment to validate against specific environment constraints.
129
+ Use --workcell to check instrument steps against a lab's galago tools;
130
+ without it, commands are checked against each step's toolType.
118
131
  """
119
132
  # Set up environment for validation if specified
120
133
  if environment:
@@ -165,7 +178,7 @@ def validate(program_files, schema, environment, verbose, json_output, strict):
165
178
  all_valid = True
166
179
  for program_file in program_files:
167
180
  success = validate_program_file(
168
- program_file, schema, verbose, json_output, strict
181
+ program_file, schema, verbose, json_output, strict, workcell
169
182
  )
170
183
  if not success:
171
184
  all_valid = False
@@ -256,6 +269,28 @@ def validate(program_files, schema, environment, verbose, json_output, strict):
256
269
  "answers, which are what the record stores in context.userTags"
257
270
  ),
258
271
  )
272
+ @click.option(
273
+ "--workcell",
274
+ type=click.Path(exists=True, dir_okay=False),
275
+ default=None,
276
+ help=(
277
+ "Workcell file mapping the program's instrument tools to galago-tools "
278
+ "servers; required for programs with instrument steps"
279
+ ),
280
+ )
281
+ @click.option(
282
+ "--live",
283
+ is_flag=True,
284
+ help=(
285
+ "Run instrument steps on real hardware instead of galago's simulated "
286
+ "mode; shows what will run and asks first"
287
+ ),
288
+ )
289
+ @click.option(
290
+ "--confirm-live",
291
+ is_flag=True,
292
+ help="Answer the --live question in advance (for scripts)",
293
+ )
259
294
  def run(
260
295
  program_file,
261
296
  schema,
@@ -270,6 +305,9 @@ def run(
270
305
  history_file,
271
306
  no_history,
272
307
  predict_context,
308
+ workcell,
309
+ live,
310
+ confirm_live,
273
311
  ):
274
312
  """
275
313
  Run a program file with the interactive UI.
@@ -296,6 +334,11 @@ def run(
296
334
  --history FILE to read a specific corpus, --no-history to turn it off,
297
335
  and --predict-context KEY=VALUE to predict for a context other than the
298
336
  factor answers.
337
+
338
+ Programs with instrument steps need --workcell FILE (galago-tools servers,
339
+ via rhylthyme-galago). Tools run in galago's simulated mode unless you pass
340
+ --live, which lists the tools and commands and asks you to type 'live'
341
+ before anything is configured; --confirm-live answers for scripts.
299
342
  """
300
343
  run_program(
301
344
  program_file,
@@ -311,6 +354,9 @@ def run(
311
354
  history_file=history_file,
312
355
  use_history=not no_history,
313
356
  predict_context=predict_context,
357
+ workcell=workcell,
358
+ live=live,
359
+ confirm_live=confirm_live,
314
360
  )
315
361
 
316
362
 
@@ -324,7 +370,16 @@ def run(
324
370
  @click.option(
325
371
  "--verbose", "-v", is_flag=True, help="Show detailed planning information"
326
372
  )
327
- def plan(input_file, output_file, environment, verbose):
373
+ @click.option(
374
+ "--workcell",
375
+ type=click.Path(exists=True, dir_okay=False),
376
+ default=None,
377
+ help=(
378
+ "Workcell file: estimate instrument steps that have no duration from "
379
+ "their tools' EstimateDuration"
380
+ ),
381
+ )
382
+ def plan(input_file, output_file, environment, verbose, workcell):
328
383
  """
329
384
  Optimize a program schedule to reduce resource contention.
330
385
 
@@ -333,9 +388,17 @@ def plan(input_file, output_file, environment, verbose):
333
388
  starts to reduce contention at critical junctures.
334
389
 
335
390
  The optimized program is saved to the specified output file.
391
+
392
+ Instrument steps without a duration get an estimated one (flagged in
393
+ metadata.durationEstimate): from the tool with --workcell, else from a
394
+ duration-like command param, else a default.
336
395
  """
337
396
  success = plan_program(
338
- input_file, output_file, verbose, environment_file=environment
397
+ input_file,
398
+ output_file,
399
+ verbose,
400
+ environment_file=environment,
401
+ workcell=workcell,
339
402
  )
340
403
  if not success:
341
404
  sys.exit(1)
@@ -13,6 +13,8 @@ the events it emits are:
13
13
  ``step_aborted`` {step_id, time, reason}
14
14
  ``program_paused`` {time, wall_time}
15
15
  ``program_resumed`` {time, wall_time, paused_seconds}
16
+ ``program_aborted`` {time, reason} (reason goes to ``context.abortReason``)
17
+ ``instrument_reply`` {step_id, time, ok, code, errorMessage, metadata}
16
18
 
17
19
  All ``time`` values are the runner's program clock (an epoch float that
18
20
  advances at ``time_scale``); the record stores them as seconds from the
@@ -217,6 +219,8 @@ class RunRecorder:
217
219
  self.context: Dict[str, Any] = self._build_context()
218
220
 
219
221
  self.actuals: Dict[str, Dict[str, Any]] = {}
222
+ # step id -> {tool, command, replies: [{attempt, at, code, ...}]}
223
+ self.instrument_log: Dict[str, Dict[str, Any]] = {}
220
224
  self.paused_seconds: Dict[str, float] = {sid: 0.0 for sid in self.step_order}
221
225
  self._pause_wall: Optional[float] = None
222
226
  self._paused_running: List[str] = []
@@ -271,6 +275,31 @@ class RunRecorder:
271
275
  entry.setdefault("start", data["time"])
272
276
  entry["end"] = data["time"]
273
277
  entry["endedBy"] = "abort"
278
+ elif event_type == "instrument_reply":
279
+ sid = data["step_id"]
280
+ step = getattr(self.runner, "steps", {}).get(sid)
281
+ instrument = (getattr(step, "instrument", None) or {}) if step else {}
282
+ log = self.instrument_log.setdefault(
283
+ sid,
284
+ {
285
+ "tool": str(instrument.get("tool", "")),
286
+ "command": str(instrument.get("command", "")),
287
+ "replies": [],
288
+ },
289
+ )
290
+ reply: Dict[str, Any] = {
291
+ "attempt": getattr(step, "instrument_attempts", 0)
292
+ or len(log["replies"]) + 1,
293
+ "at": data["time"],
294
+ "code": str(data.get("code", "")),
295
+ }
296
+ if data.get("errorMessage"):
297
+ reply["errorMessage"] = str(data["errorMessage"])
298
+ if data.get("metadata"):
299
+ reply["metadata"] = copy.deepcopy(data["metadata"])
300
+ log["replies"].append(reply)
301
+ elif event_type == "program_aborted":
302
+ self.context["abortReason"] = data.get("reason") or "aborted"
274
303
  elif event_type == "program_paused":
275
304
  self._pause_wall = data.get("wall_time", time.time())
276
305
  self._paused_running = list(getattr(self.runner, "running_steps", []))
@@ -344,6 +373,13 @@ class RunRecorder:
344
373
  entry["endedBy"] = actual.get("endedBy", "executor")
345
374
  if "triggerFiredAt" in actual:
346
375
  entry["triggerFiredAt"] = rel(actual["triggerFiredAt"])
376
+ log = self.instrument_log.get(sid)
377
+ if log:
378
+ entry["instrument"] = {
379
+ "tool": log["tool"],
380
+ "command": log["command"],
381
+ "replies": [dict(r, at=rel(r["at"])) for r in log["replies"]],
382
+ }
347
383
  steps.append(entry)
348
384
 
349
385
  started_iso = _iso_utc(start_epoch) if start_epoch is not None else None
@@ -0,0 +1,258 @@
1
+ """
2
+ Connect a ProgramRunner to galago-tools instruments (``rhylthyme run --workcell``).
3
+
4
+ Needs the optional ``rhylthyme-galago`` package (``pip install
5
+ rhylthyme[galago]``). When a step with an ``instrument`` starts, its command is
6
+ sent on a worker thread; the reply comes back through
7
+ ``ProgramRunner.post_instrument_reply`` and ends (or fails) the step.
8
+ """
9
+
10
+ from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
11
+
12
+ from .instance_checks import Finding
13
+
14
+ INSTALL_HINT = "Instrument steps need rhylthyme-galago: pip install 'rhylthyme[galago]'"
15
+
16
+
17
+ class InstrumentSetupError(RuntimeError):
18
+ """The workcell cannot run this program's instrument steps."""
19
+
20
+
21
+ def program_uses_instruments(program: Mapping[str, Any]) -> bool:
22
+ return any(
23
+ step.get("instrument")
24
+ for track in program.get("tracks", [])
25
+ for step in track.get("steps", [])
26
+ )
27
+
28
+
29
+ def instrument_findings(
30
+ program: Mapping[str, Any], workcell_source=None
31
+ ) -> List[Finding]:
32
+ """
33
+ Validation findings for a program's instrument steps: galago commands and
34
+ params checked against each step's toolType, or against the workcell's
35
+ tools when ``workcell_source`` (a path or dict) is given.
36
+ """
37
+ if workcell_source is None and not program_uses_instruments(program):
38
+ return []
39
+ try:
40
+ import rhylthyme_galago as galago
41
+ except ImportError:
42
+ return [
43
+ Finding(
44
+ code="instrument_unchecked",
45
+ message="Instrument steps were not checked",
46
+ where="program",
47
+ fix=INSTALL_HINT,
48
+ severity="error" if workcell_source is not None else "warning",
49
+ )
50
+ ]
51
+ workcell = None
52
+ if workcell_source is not None:
53
+ try:
54
+ workcell = galago.load_workcell(workcell_source)
55
+ except galago.WorkcellError as e:
56
+ return [Finding(code="workcell_invalid", message=str(e), where="workcell")]
57
+ return [
58
+ Finding(
59
+ code=issue.code,
60
+ message=issue.message,
61
+ where=f"step:{issue.step_id}",
62
+ severity=issue.severity,
63
+ )
64
+ for issue in galago.check_program(program, workcell)
65
+ ]
66
+
67
+
68
+ def fill_instrument_durations(
69
+ program: Dict[str, Any], workcell_source=None
70
+ ) -> Tuple[Dict[str, Any], List[str]]:
71
+ """
72
+ Give instrument steps without a duration an estimated one, for planning:
73
+ the tool's EstimateDuration (with a workcell), else a duration-like
74
+ command param, else a default. Returns the new program and report lines;
75
+ without rhylthyme-galago the program comes back unchanged with a note.
76
+ """
77
+ missing = [step for step in _instrument_steps(program) if "duration" not in step]
78
+ if not missing:
79
+ return program, []
80
+ try:
81
+ import rhylthyme_galago as galago
82
+ from rhylthyme_galago.estimates import describe
83
+ except ImportError:
84
+ return program, [
85
+ f"{len(missing)} instrument step(s) have no duration and were not "
86
+ f"estimated. {INSTALL_HINT}"
87
+ ]
88
+ workcell = None
89
+ if workcell_source is not None:
90
+ try:
91
+ workcell = galago.load_workcell(workcell_source)
92
+ except galago.WorkcellError as e:
93
+ raise InstrumentSetupError(str(e)) from None
94
+ filled, estimates = galago.fill_durations(program, workcell)
95
+ return filled, ["Estimated instrument durations:"] + describe(estimates)
96
+
97
+
98
+ def _reply_dict(reply) -> Dict[str, Any]:
99
+ return {
100
+ "ok": reply.ok,
101
+ "code": reply.code,
102
+ "errorMessage": reply.error_message,
103
+ "metadata": dict(reply.metadata),
104
+ }
105
+
106
+
107
+ def _instrument_steps(program: Mapping[str, Any]) -> List[Dict[str, Any]]:
108
+ return [
109
+ step
110
+ for track in program.get("tracks", [])
111
+ for step in track.get("steps", [])
112
+ if step.get("instrument")
113
+ ]
114
+
115
+
116
+ def _params_text(params: Optional[Mapping[str, Any]]) -> str:
117
+ return ", ".join(f"{k}={v!r}" for k, v in (params or {}).items())
118
+
119
+
120
+ class InstrumentSession:
121
+ """
122
+ The workcell tools one run uses. Opening it touches no instrument;
123
+ ``summary()`` only reads tool status; ``prepare()`` configures the tools
124
+ (simulated unless the session is live); ``attach()`` wires the runner so
125
+ each instrument step's command is sent when the step starts.
126
+ """
127
+
128
+ def __init__(self, executor: Any, tools: List[str]):
129
+ self.executor = executor
130
+ self.tools = tools
131
+ self.checks: List[Any] = []
132
+
133
+ @property
134
+ def live(self) -> bool:
135
+ return not self.executor.simulated
136
+
137
+ def summary(self, program: Mapping[str, Any], limit: int = 12) -> List[str]:
138
+ """What a run will do, for the --live confirmation. Status is read-only."""
139
+ workcell = self.executor.workcell
140
+ mode = "LIVE: real hardware will move" if self.live else "simulated"
141
+ lines = [f"Workcell {workcell.id!r} ({mode})", "Tools:"]
142
+ for name in self.tools:
143
+ binding = workcell.tool(name)
144
+ status = self.executor.client(name).status()
145
+ lines.append(
146
+ f" {name} ({binding.type}) @ {binding.address}: {status.status}"
147
+ )
148
+ steps = _instrument_steps(program)
149
+ lines.append(f"Instrument steps ({len(steps)}):")
150
+ for step in steps[:limit]:
151
+ inst = step["instrument"]
152
+ lines.append(
153
+ f" {step['stepId']}: {inst['tool']}.{inst['command']}"
154
+ f"({_params_text(inst.get('params'))})"
155
+ )
156
+ if len(steps) > limit:
157
+ lines.append(f" ... and {len(steps) - limit} more")
158
+ return lines
159
+
160
+ def prepare(self) -> None:
161
+ """Configure every tool; raise InstrumentSetupError unless all are ready."""
162
+ self.checks = self.executor.prepare(self.tools)
163
+ if not all(check.ready for check in self.checks):
164
+ raise InstrumentSetupError("\n".join(self.report()))
165
+
166
+ def report(self) -> List[str]:
167
+ mode = "simulated" if self.executor.simulated else "LIVE"
168
+ lines = [f"Workcell {self.executor.workcell.id!r} ({mode}):"]
169
+ for check in self.checks:
170
+ mark = "ok" if check.ready else "NOT READY"
171
+ detail = check.configure.error_message or check.status.error_message
172
+ line = f" {check.tool} @ {check.address}: {check.status.status} [{mark}]"
173
+ lines.append(line + (f" {detail}" if detail and not check.ready else ""))
174
+ return lines
175
+
176
+ def attach(self, runner) -> None:
177
+ executor = self.executor
178
+
179
+ def on_reply(step_id: str, reply) -> None:
180
+ runner.post_instrument_reply(step_id, _reply_dict(reply))
181
+
182
+ def on_event(event_type: str, data: Dict[str, Any]) -> None:
183
+ # A step's command goes out when it starts and again on each retry
184
+ if event_type not in ("step_started", "step_retry"):
185
+ return
186
+ step = runner.steps.get(data["step_id"])
187
+ if step is not None and step.instrument:
188
+ step.instrument_attempts += 1
189
+ executor.submit(step.step_id, step.instrument, on_reply)
190
+
191
+ runner.add_event_listener(on_event)
192
+
193
+ def shutdown(self) -> List[str]:
194
+ """Stop the executor; return steps whose commands were still in flight."""
195
+ return self.executor.shutdown()
196
+
197
+
198
+ def open_instruments(
199
+ program: Mapping[str, Any],
200
+ workcell_source,
201
+ *,
202
+ client_factory: Optional[Callable] = None,
203
+ live: bool = False,
204
+ ) -> InstrumentSession:
205
+ """
206
+ Load the workcell for ``program`` without contacting any tool.
207
+
208
+ Tools run simulated unless ``live`` is true. Raises InstrumentSetupError
209
+ when rhylthyme-galago is missing, the workcell is invalid, or a step
210
+ names a tool the workcell lacks.
211
+ """
212
+ try:
213
+ import rhylthyme_galago as galago
214
+ except ImportError:
215
+ raise InstrumentSetupError(INSTALL_HINT) from None
216
+
217
+ try:
218
+ workcell = galago.load_workcell(workcell_source)
219
+ except galago.WorkcellError as e:
220
+ raise InstrumentSetupError(str(e)) from None
221
+
222
+ tools = galago.instrument_tools(program)
223
+ unknown = [t for t in tools if t not in workcell.tools]
224
+ if unknown:
225
+ raise InstrumentSetupError(
226
+ f"Workcell {workcell.id!r} has no tool named "
227
+ + ", ".join(repr(t) for t in unknown)
228
+ + f" (tools: {', '.join(sorted(workcell.tools))})"
229
+ )
230
+
231
+ kwargs: Dict[str, Any] = {"simulated": not live}
232
+ if client_factory is not None:
233
+ kwargs["client_factory"] = client_factory
234
+ return InstrumentSession(galago.InstrumentExecutor(workcell, **kwargs), tools)
235
+
236
+
237
+ def attach_instruments(
238
+ runner,
239
+ workcell_source,
240
+ *,
241
+ client_factory: Optional[Callable] = None,
242
+ live: bool = False,
243
+ ) -> InstrumentSession:
244
+ """
245
+ Open, configure and attach in one go (no confirmation step): the tools
246
+ are configured and ``runner`` sends each instrument step's command when
247
+ the step starts.
248
+ """
249
+ session = open_instruments(
250
+ runner.program, workcell_source, client_factory=client_factory, live=live
251
+ )
252
+ try:
253
+ session.prepare()
254
+ except InstrumentSetupError:
255
+ session.shutdown()
256
+ raise
257
+ session.attach(runner)
258
+ return session
@@ -16,6 +16,8 @@ from typing import Any, Dict, List, Optional, Set, Tuple, Union
16
16
 
17
17
  import yaml
18
18
 
19
+ from .instruments import fill_instrument_durations
20
+
19
21
 
20
22
  def load_program_file(file_path: str) -> dict:
21
23
  """
@@ -965,11 +967,30 @@ def save_optimized_program(program: dict, output_file: str):
965
967
  json.dump(program, f, indent=2)
966
968
 
967
969
 
970
+ def planned_makespan(program: dict) -> float:
971
+ """
972
+ Seconds from program start to the end of the last step, following start
973
+ triggers and durations (resource waits not included). Uses the
974
+ validator's trigger arithmetic, which reads the current trackId/stepId
975
+ schema.
976
+ """
977
+ from .validate_program import calculate_step_start_time, parse_duration_to_seconds
978
+
979
+ end = 0.0
980
+ for track in program.get("tracks", []):
981
+ steps = track.get("steps", [])
982
+ for step in steps:
983
+ start = calculate_step_start_time(step, steps, program)
984
+ end = max(end, start + parse_duration_to_seconds(step.get("duration")))
985
+ return end
986
+
987
+
968
988
  def plan_program(
969
989
  input_file: str,
970
990
  output_file: str,
971
991
  verbose: bool = False,
972
992
  environment_file: Optional[str] = None,
993
+ workcell: Optional[str] = None,
973
994
  ) -> bool:
974
995
  """
975
996
  Plan and optimize a program schedule.
@@ -979,18 +1000,27 @@ def plan_program(
979
1000
  output_file: Path to the output program file
980
1001
  verbose: Whether to print verbose information
981
1002
  environment_file: Path to the environment file
1003
+ workcell: Workcell file; instrument steps without a duration are
1004
+ estimated from its tools (else from their params, else a default)
982
1005
 
983
1006
  Returns:
984
1007
  True if successful, False otherwise
985
1008
  """
986
1009
  try:
987
1010
  program = load_program_file(input_file)
1011
+ program, estimate_lines = fill_instrument_durations(program, workcell)
1012
+ for line in estimate_lines:
1013
+ print(line)
988
1014
  environment = None
989
1015
  if environment_file:
990
1016
  environment = load_program_file(environment_file)
991
1017
  planner = ProgramPlanner(program, verbose, environment)
992
1018
  optimized_program = planner.optimize_schedule()
993
1019
  save_optimized_program(optimized_program, output_file)
1020
+ print(
1021
+ "Makespan (by start triggers and durations): "
1022
+ f"{planned_makespan(optimized_program):g} s"
1023
+ )
994
1024
  if verbose:
995
1025
  print(f"Saved optimized program to {output_file}")
996
1026
  return True