labcode 0.3.0__py3-none-any.whl → 0.4.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.
labcode/backend.py CHANGED
@@ -57,6 +57,7 @@ from labcode.extension import (
57
57
  script_endpoints,
58
58
  script_flavor,
59
59
  spot_device,
60
+ transport_label,
60
61
  transporter_connections,
61
62
  )
62
63
  from labcode.idgen import DEFAULT_ID_GENERATOR, IdGenerator
@@ -245,8 +246,7 @@ def make_transport_resolver(environment: dict) -> Callable:
245
246
  code = _flavored(
246
247
  code, script,
247
248
  _transport_machines(transport, transporters, devices, script),
248
- f"transport {transporter!r} "
249
- f"{transport.get('from')} -> {transport.get('to')}",
249
+ transport_label(transport),
250
250
  )
251
251
  routes[key] = code
252
252
 
labcode/dialect.py CHANGED
@@ -59,8 +59,11 @@ from labcode.extension import (
59
59
  parse_connection,
60
60
  parse_op_timeout,
61
61
  parse_probe,
62
+ route_transporter,
63
+ script_endpoints,
62
64
  script_flavor,
63
65
  spot_device,
66
+ transport_label,
64
67
  unknown_key_messages,
65
68
  )
66
69
  from labcode.objectid import RESERVED_ID, reserved_collisions
@@ -464,11 +467,8 @@ def _validate_replenishments(environment: dict, errors: list, warnings: list) ->
464
467
  )
465
468
 
466
469
 
467
- def _transport_label(transport: dict) -> str:
468
- return (
469
- f"transport {transport.get('transporter')!r} "
470
- f"{transport.get('from')} -> {transport.get('to')}"
471
- )
470
+ # `transport_label` (extension.py) is shared with the backend, so the same route is named
471
+ # the same way in a front-door diagnostic and in a failing operation.
472
472
 
473
473
 
474
474
  def _validate_transports(
@@ -485,7 +485,7 @@ def _validate_transports(
485
485
  for transport in environment.get("transports") or []:
486
486
  if not isinstance(transport, dict):
487
487
  continue
488
- label = _transport_label(transport)
488
+ label = transport_label(transport)
489
489
  extension = transport.get(EXTENSION_KEY)
490
490
  if extension is None:
491
491
  if transport.get("from") != transport.get("to"): # a real move
@@ -505,7 +505,9 @@ def _validate_transports(
505
505
  continue
506
506
  _check_transport_endpoints(transport, script, label, devices, errors, warnings)
507
507
  if script_flavor(script) == FLAVOR_SILA2:
508
- _check_transport_connection(transport, label, transporters, errors)
508
+ _check_transport_connection(
509
+ transport, script, label, transporters, devices, errors
510
+ )
509
511
 
510
512
 
511
513
  def _check_transport_endpoints(
@@ -519,7 +521,12 @@ def _check_transport_endpoints(
519
521
  clients at all (§1.6), so the request cannot be honoured and the author expects
520
522
  something that will not happen. Asking when neither end has an address is a **warning**
521
523
  -- the route still works through its transporter, and an environment written before its
522
- instruments have addresses is a legitimate intermediate state (as with `probe`)."""
524
+ instruments have addresses is a legitimate intermediate state (as with `probe`).
525
+
526
+ That last one holds only for a route that *has* a transporter to fall back on. A route
527
+ with no transporter (§4.6/§5.4) has nothing else to drive, so the same situation is an
528
+ error there -- reported by `_check_transport_connection`, which is where the rest of
529
+ that route's connection requirement lives, so that one problem yields one message."""
523
530
  endpoints = script.get("endpoints")
524
531
  prefix = f"{label}: x-labcode.script.endpoints"
525
532
  if endpoints is None:
@@ -535,8 +542,9 @@ def _check_transport_endpoints(
535
542
  f"{script_flavor(script)!r}: only a 'sila2' script is handed clients (§1.6)"
536
543
  )
537
544
  return
538
- ends = [spot_device(transport.get(end)) for end in ("from", "to")]
539
- named = [device for device in ends if device is not None]
545
+ if route_transporter(transport) is None:
546
+ return # nothing to fall back on; _check_transport_connection reports it as an error
547
+ named = _route_ends(transport)
540
548
  if not any(device in devices.with_connection for device in named):
541
549
  warnings.append(
542
550
  f"{prefix} is true, but neither end of the route ({', '.join(named)}) declares "
@@ -544,16 +552,54 @@ def _check_transport_endpoints(
544
552
  )
545
553
 
546
554
 
555
+ def _route_ends(transport: dict) -> list[str]:
556
+ """The devices at either end of a route, named and deduplicated in route order.
557
+
558
+ A move within one device names it once: it is one machine, and one client."""
559
+ ends = [spot_device(transport.get(end)) for end in ("from", "to")]
560
+ return list(dict.fromkeys(device for device in ends if device is not None))
561
+
562
+
547
563
  def _check_transport_connection(
548
- transport: dict, label: str, transporters: _NodeIndex, errors: list
564
+ transport: dict,
565
+ script: dict,
566
+ label: str,
567
+ transporters: _NodeIndex,
568
+ devices: _NodeIndex,
569
+ errors: list,
549
570
  ) -> None:
550
- """A `sila2` transport script drives one machine -- the transporter that carries the
551
- move -- so that is where its connection has to be."""
552
- identifier = transport.get("transporter")
571
+ """Where a `sila2` transport script's connection has to be.
572
+
573
+ Normally the transporter: it is the machine that carries the move, and the one
574
+ `sila2_client` names.
575
+
576
+ A route with **no transporter** (`transporter: null`, ofplang-schedule §4.6/§5.4) is
577
+ performed by the devices at its ends -- a cycler loading its own block -- so those are
578
+ what the script must be able to reach. It receives them only if it asks (`endpoints`,
579
+ §1.6), and it is made to ask rather than being read as asking: `endpoints` is the
580
+ author's statement of which machines the script drives, and inferring it here would take
581
+ that statement away from them for exactly the routes where it matters most. The cost of
582
+ requiring it is one line in the environment; the cost of assuming it is a script handed
583
+ clients its author never asked for."""
553
584
  prefix = f"{label}: x-labcode.script.flavor 'sila2' but"
554
- if not isinstance(identifier, str) or not identifier:
555
- errors.append(f"{prefix} the route names no transporter")
556
- elif identifier not in transporters.declared:
557
- errors.append(f"{prefix} transporter {identifier!r} is not declared in transporters[]")
558
- elif identifier not in transporters.with_connection:
559
- errors.append(f"{prefix} transporter {identifier!r} declares no x-labcode.connection")
585
+ identifier = route_transporter(transport)
586
+ if identifier is not None:
587
+ if identifier not in transporters.declared:
588
+ errors.append(f"{prefix} transporter {identifier!r} is not declared in transporters[]")
589
+ elif identifier not in transporters.with_connection:
590
+ errors.append(f"{prefix} transporter {identifier!r} declares no x-labcode.connection")
591
+ return
592
+ if not script_endpoints(script):
593
+ errors.append(
594
+ f"{prefix} the route names no transporter and the script does not ask for its "
595
+ f"endpoint clients; add `endpoints: true` so it is handed the devices at either "
596
+ f"end of the route, which are what performs a move nothing carries"
597
+ )
598
+ return
599
+ named = _route_ends(transport)
600
+ if not any(device in devices.with_connection for device in named):
601
+ errors.append(
602
+ f"{prefix} the route names no transporter and neither end "
603
+ f"({', '.join(named)}) declares an x-labcode.connection; there is no machine "
604
+ f"left for the script to drive"
605
+ )
labcode/extension.py CHANGED
@@ -315,6 +315,38 @@ def declared_probe(extension: Any) -> dict[str, Any]:
315
315
  return declared
316
316
 
317
317
 
318
+ def route_transporter(transport: Any) -> str | None:
319
+ """The transporter carrying a transport route, or None when the route names none.
320
+
321
+ A route may declare ``transporter: null`` (ofplang-schedule SPECIFICATIONS §4.6/§5.4):
322
+ the move needs no transporter at all -- a device shifting material between its own
323
+ spots, a chute -- and is performed by the devices at its ends instead.
324
+
325
+ Anything that is not a non-empty string reads as "none" here. A malformed value is the
326
+ schedule environment validator's to reject, and this must not hold a second opinion
327
+ about it; what matters at this layer is only whether there is a transporter to drive.
328
+ """
329
+ if not isinstance(transport, dict):
330
+ return None
331
+ identifier = transport.get("transporter")
332
+ return identifier if isinstance(identifier, str) and identifier else None
333
+
334
+
335
+ def transport_label(transport: Any) -> str:
336
+ """How a transport route is named in a message: its carrier, then its ends.
337
+
338
+ A route with no transporter says so in words. Rendering the absent id would print
339
+ ``transport None a -> b``, which reads as a defect in the message rather than as a fact
340
+ about the route -- and this label reaches the user in both a front-door diagnostic and a
341
+ failing operation, so it is written once and shared.
342
+ """
343
+ if not isinstance(transport, dict):
344
+ transport = {}
345
+ identifier = route_transporter(transport)
346
+ who = repr(identifier) if identifier is not None else "(no transporter)"
347
+ return f"transport {who} {transport.get('from')} -> {transport.get('to')}"
348
+
349
+
318
350
  def spot_device(spot: Any) -> str | None:
319
351
  """The device a qualified spot (``<device>.<spot>``, schedule SPECIFICATIONS §8.2)
320
352
  belongs to, or None when there is no name to take.
labcode/run_cli.py CHANGED
@@ -65,6 +65,13 @@ def _build_parser() -> argparse.ArgumentParser:
65
65
  "--margin", type=int, default=None, metavar="M",
66
66
  help="running-task margin for replans (default: the poll interval)",
67
67
  )
68
+ p.add_argument(
69
+ "--max-transport-legs", type=int, default=1, metavar="N",
70
+ help="how many transport activities one Object-bearing arc may be carried in "
71
+ "(default 1: the single hop), joined by relays -- raise it for an "
72
+ "instrument the transporter reaches at one position only, or a plate "
73
+ "that has to cross a hand-off station",
74
+ )
68
75
  p.add_argument(
69
76
  "--poll-interval", type=int, default=1, metavar="D",
70
77
  help="poll every D time units (default 1)",
@@ -329,6 +336,11 @@ def main(argv: Sequence[str] | None = None) -> int:
329
336
  running_task_margin=args.margin,
330
337
  random_seed=args.seed,
331
338
  poll_interval=args.poll_interval,
339
+ # How many moves one arc may be carried in (schedule SPEC §6.4.1). Passed
340
+ # through rather than decided here: it is the scheduler's offer, and a real
341
+ # laboratory is exactly where an instrument reachable from one position
342
+ # only makes the single hop insufficient.
343
+ max_transport_legs=args.max_transport_legs,
332
344
  # 0 is "no limit" here as it is upstream; the runner spells that None.
333
345
  max_ticks=args.max_ticks or None,
334
346
  seconds_per_tick=args.seconds_per_tick,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labcode
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: labcode -- a dialect wrapper over the Object-Flow Programming Language toolchain
5
5
  Author-email: Kazunari Kaizu <kwaizu@gmail.com>
6
6
  License-Expression: MIT
@@ -21,8 +21,8 @@ Requires-Python: >=3.10
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: ofplang-validate<0.3,>=0.2
24
- Requires-Dist: ofplang-schedule<0.4,>=0.3
25
- Requires-Dist: ofplang-run<0.5,>=0.4
24
+ Requires-Dist: ofplang-schedule<0.9,>=0.8
25
+ Requires-Dist: ofplang-run<0.8,>=0.7
26
26
  Provides-Extra: test
27
27
  Requires-Dist: pytest>=7.0; extra == "test"
28
28
  Requires-Dist: opentelemetry-sdk>=1.20; extra == "test"
@@ -143,7 +143,8 @@ described below.
143
143
  lc run <workflow> --env <env>
144
144
  [--boundary DOC] [-o OUT] [--boundary-out FILE] [--observation-out FILE]
145
145
  [--seconds-per-tick S] [--op-timeout S | --no-op-timeout] [--no-probe]
146
- [--ignore-resources] [--trace] [--mission-id ID] [--object-ids seeded|real]
146
+ [--max-transport-legs N] [--ignore-resources] [--trace] [--mission-id ID]
147
+ [--object-ids seeded|real]
147
148
  ```
148
149
 
149
150
  - `<workflow>` — the portable v0 workflow: *what* happens.
@@ -176,6 +177,15 @@ lc run <workflow> --env <env>
176
177
  else 7200 real seconds. The two forms exclude each other.
177
178
  - `--no-probe` — ignore the environment's `x-labcode.probe` policies and treat every
178
179
  machine as reachable (§1.6). The documents are still validated.
180
+ - `--max-transport-legs N` — how many transport activities one Object-bearing arc may
181
+ be carried in (schedule spec §6.4.1), joined by **relay** activities. It is 1 by
182
+ default: the single hop. Raise it where the transporter cannot reach an instrument
183
+ from where the plate is — a bench it serves from one position only, or a hand-off
184
+ station a plate has to cross — and the move is planned as the shortest chain between
185
+ the two spots instead of being reported unreachable. Only the fewest possible moves
186
+ are offered, so a plate one move away is never sent round by way of somewhere else.
187
+ This describes the laboratory's reach rather than tuning the loop, which is why it is
188
+ here and not among the replan knobs below.
179
189
  - `--ignore-resources` — switch the consumable model off. The environment's resource
180
190
  declarations are still checked for shape but none is applied, so a bench whose devices
181
191
  declare stocks nobody is tracking runs without the boundary saying what they held.
@@ -1,10 +1,10 @@
1
1
  labcode/__init__.py,sha256=jS_lhcLqdu2BfOAq6j5jPTvGNY5M3_1RMJtNRr3ABp4,79
2
2
  labcode/__main__.py,sha256=T5DM7E3tdcuKP4oQ7JacBP4Mi-VmujWueJvB9yCIQTc,252
3
3
  labcode/_child.py,sha256=dXsOyvX9J39-G9Y5XuXsT0Cp-gzZ6v0t4iKIHruIbRU,3695
4
- labcode/backend.py,sha256=rBaicVGPsKnIOCN_MjT4sZM7mFQodL75LtvLva-tLzU,33680
4
+ labcode/backend.py,sha256=kkJ1DcJQciM-86fTQWyy0N-wAEVyo5JJ3HuVRzDiEuY,33630
5
5
  labcode/cli.py,sha256=paeYARy1BvACCf0lmlZLdgWyCgq3VyywSpvUKsGI0Mc,3538
6
- labcode/dialect.py,sha256=2K3F_m8UJlOd2JPCppQjkQ7TqAnYsLfvjrQx6P1Gu6o,25211
7
- labcode/extension.py,sha256=vR-hPf16qSXRErMVZvVaFj0cpFp83leX9NyRNyvTbPs,19790
6
+ labcode/dialect.py,sha256=x6defFkzP-fv4fi2SG-Qt8XxJRuF1NXhiOXxcKFyx3k,27468
7
+ labcode/extension.py,sha256=4qrw4jEMDNrj-l66HWNlBsy-bWHStSIe7CMcrj41-LQ,21377
8
8
  labcode/idgen.py,sha256=MYCke8ytQU1GnwNVq2sPwlvzqrs0JS-FZzgs5MoosFw,2850
9
9
  labcode/objectid.py,sha256=NQHZ81WCRwCXF7SwdXPUg2Q8ln4UJ936kepxHQAxUS0,10620
10
10
  labcode/otel.py,sha256=FvMIb07nn6OSSuAjixA898AxXp8OcSD-iNJYboa_pyI,14713
@@ -12,14 +12,14 @@ labcode/otel_sila2.py,sha256=t2H8x_jH2cysWRQ5joX6b8cqFDArALYcObEPTXHzepw,8612
12
12
  labcode/probe.py,sha256=niUm3qrHdKBq0DCbVTh3ThmBlIn0GIkx_zDSqKrPbfs,8305
13
13
  labcode/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
14
  labcode/record.py,sha256=TuqalknpDeOkOCjfoLuW_8cSMGou9NZVJK7x1N-Z6kA,12094
15
- labcode/run_cli.py,sha256=_xMfQBDYTZnjyJcNC6Uh4lHpYYrANYa_N8CUr_gxPsc,18131
15
+ labcode/run_cli.py,sha256=7VUdOWoxdofbw5UI32UeWCH-sEPGHRHAjjau-bgLiZA,18897
16
16
  labcode/runner.py,sha256=Ux_gkgAxOepuKO9zRicoeCe-YG5ycKjqRd8BY14ovxk,11277
17
17
  labcode/sila2.py,sha256=jd_f2ARVvH96As5HCa9qAG6cDERPpsvjg_ZYioDyI7U,13477
18
18
  labcode/sila2_commands.py,sha256=oYuqUDUKcN5sxo2QvxtkNqRfvietrHkLg7Yqvcj1eZ4,5349
19
19
  labcode/sila2_instrument.py,sha256=Lne4AO257UfAHprh4Pmlqg8jVLng8haeJhMVfSfMfdE,24564
20
- labcode-0.3.0.dist-info/licenses/LICENSE,sha256=weUnWoHlmaiawSjaLy_ECNpyTe7F5vDBfLGJKocpUhI,1071
21
- labcode-0.3.0.dist-info/METADATA,sha256=U2AjqpMA7ZLJYvEthsphZmJoiFsgVoHRitr_biFsOPc,16186
22
- labcode-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
23
- labcode-0.3.0.dist-info/entry_points.txt,sha256=eP2fNwGx6ZYNJ_AWzGZhiDJaPqeqaA0iZAc-Tae1PGQ,40
24
- labcode-0.3.0.dist-info/top_level.txt,sha256=7WhrsXy6psZ8TDzksIEEuj2q93GeXWkHL9h1RbdBrJg,8
25
- labcode-0.3.0.dist-info/RECORD,,
20
+ labcode-0.4.0.dist-info/licenses/LICENSE,sha256=weUnWoHlmaiawSjaLy_ECNpyTe7F5vDBfLGJKocpUhI,1071
21
+ labcode-0.4.0.dist-info/METADATA,sha256=yuxDVZ9Wg58EwEl2YV4mGFr7Qo7k624uaTpVKQMOKTc,16952
22
+ labcode-0.4.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
23
+ labcode-0.4.0.dist-info/entry_points.txt,sha256=eP2fNwGx6ZYNJ_AWzGZhiDJaPqeqaA0iZAc-Tae1PGQ,40
24
+ labcode-0.4.0.dist-info/top_level.txt,sha256=7WhrsXy6psZ8TDzksIEEuj2q93GeXWkHL9h1RbdBrJg,8
25
+ labcode-0.4.0.dist-info/RECORD,,