from __future__ import annotations
import asyncio
from collections.abc import (
AsyncIterable,
Callable,
Coroutine,
Generator,
Iterable,
Mapping,
Sequence,
)
from contextlib import AbstractContextManager, contextmanager
from logging import getLogger
from pathlib import Path
import threading
from types import UnionType
from typing import Any, Protocol, cast, overload
import warnings
from jitx._structural import Proxy, _warn_skips
from jitx._translate.lookup import ComputedNet
from jitx._utils.unionfind import UnionFind
from jitx.copper import Copper
from jitx.feature import SurfaceFeature
from jitx.layerindex import Side
from jitx.net import Net, Port, PortAttachment, TopologyNet
from jitx.circuit import Circuit
from jitx.component import Component
from jitx.landpattern import PadMapping
from jitx.placement import Placement
from jitx.stackup import Stackup
from jitx.via import Via
from google.protobuf.json_format import ParseDict
from jitxcore._proto import (
configuration_pb2,
design_issues_pb2,
design_pb2,
idmap_pb2,
messages_pb2,
physical_design_pb2,
stable_id_pb2,
)
from .._translate.idmap import ReverseMapper, fqnameob
from .._translate.reverse_flow.placement import placement_from_pose_side_proto
from ._layout_events import (
apply_controls,
apply_pours,
apply_regions,
apply_routes,
apply_vias,
)
from .configuration import AsyncConfiguration, SyncConfiguration
from .layout import AsyncLayout, RequestRejected, SyncLayout
from .monitor import Changes, LayoutMonitor, _Monitors
from .._websocket import (
Envelope,
Message,
PersistentWebSocketClient,
Subscription,
design_route,
runtime_uri,
)
from ..design import Design
from ..inspect import Trace, extract, visit
from ..issue import IssueCategory, IssueReport
from ..query import query
from ..transform import IDENTITY, Transform
logger = getLogger(__name__)
def _warn_if_issues(rmap: ReverseMapper) -> None:
if not rmap.unattached_copper_warning:
return
W = 38
def row(text: str = "") -> str:
return "|" + (" " + text).center(W) + "|"
lines = [
"",
"+" + "-" * W + "+",
row("DESIGN COMPLETED WITH WARNINGS"),
row(),
row("The design may not export correctly."),
row("Please review the warning(s) above."),
"+" + "-" * W + "+",
"",
]
logger.warning("\n".join(lines))
[docs]
class Runtime:
class __Active(threading.local):
runtime: list[Runtime | SyncRuntime | None]
instantiation: list[AbstractContextManager[None]]
def __init__(self):
super().__init__()
self.runtime = []
self.instantiation = []
active = __Active()
@overload
def __init__(self): ...
@overload
def __init__(self, *, uri: str): ...
@overload
def __init__(self, *, project: str | Path): ...
@overload
def __init__(self, *, client: PersistentWebSocketClient): ...
def __init__(
self,
*,
uri: str | None = None,
project: str | Path | None = None,
client: PersistentWebSocketClient | None = None,
):
import jitx.run
# if a Runtime object is constructed, it's fairly safe to assume that
# we're not trying to autodetect a design run as '__main__'.
jitx.run.autodetect_design = False
if client:
self.__uri = client.uri
self.__client = client
else:
self.__uri = uri or (
runtime_uri(project=project) if project else runtime_uri()
)
self.__client = PersistentWebSocketClient(uri=self.__uri)
self.__reentered = 0
# Event subscriptions of submitted designs, and the tasks applying
# them; ended on close.
self.__watches: list[tuple[Subscription, asyncio.Task[None]]] = []
@property
def _client(self):
return self.__client
[docs]
def decouple(self):
return self.__class__(uri=self.__uri)
def __enter(self, r: Runtime | SyncRuntime):
from jitx._instantiation import instantiation
# design objects may construct structural objects lazily on access, so
# keep instantiation active while interacting with a runtime.
active = instantiation.require()
active.__enter__()
Runtime.active.instantiation.append(active)
Runtime.active.runtime.append(r)
if not self.__reentered:
self.__client.keepalive()
self.__reentered += 1
return self.__reentered == 1
def __exit(self):
self.__reentered -= 1
Runtime.active.runtime.pop()
Runtime.active.instantiation.pop().__exit__(None, None, None)
return self.__reentered == 0
async def __aenter__(self) -> Runtime:
if self.__enter(self):
await self.open()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
if self.__exit():
await self.close()
[docs]
async def open(self):
await self.__client.establish()
[docs]
async def close(self):
watches, self.__watches = self.__watches, []
for subscription, task in watches:
task.cancel()
if not self.__client.is_closed():
try:
await subscription.unbind()
except Exception:
logger.debug("Failed to unbind design events", exc_info=True)
await self.__client.disconnect()
def __enter__(self) -> SyncRuntime:
sr = SyncRuntime(self)
if self.__enter(sr):
sr.open()
return sr
def __exit__(self, exc_type, exc_val, exc_tb):
sr = Runtime.active.runtime[-1]
if self.__exit():
# should always be true.
if isinstance(sr, SyncRuntime):
sr.close()
[docs]
@staticmethod
def require():
if Runtime.active.runtime:
r = Runtime.active.runtime[-1]
if not isinstance(r, Runtime | SyncRuntime):
raise TypeError("Active runtime is not a Runtime")
return r
else:
return Runtime()
def _construct[T: Design](self, design: T | type[T]) -> T:
return _instantiate_design(design)
def _package_design[T: Design](
self, constructed: T, name: str | None = None
) -> tuple[design_pb2.Design, ReverseMapper[T]]:
return _package_design(constructed, name)
[docs]
async def submit[T: Design](
self, design: T | type[T], *, name: str | None = None
) -> AsyncRuntimeDesign[T]:
constructed = self._construct(design)
packaged, rmap = self._package_design(constructed, name)
design_name = name or fqnameob(constructed)
rd = AsyncRuntimeDesign(_DesignState(constructed, rmap), design_name, self)
conversation = await self.__client.root().request(
Message(
"des",
"request",
messages_pb2.Request(
design_name=design_name,
design_manager=messages_pb2.DesignManagerRequest(
load=messages_pb2.LoadDesignTrigger(design=packaged)
),
),
),
)
from jitx.run._prompts import ConsolePrompts
async def answer(question_id: str, confirmed: bool) -> None:
await conversation.send(
Message("des", "answer", {"id": question_id, "confirmed": confirmed})
)
prompts = ConsolePrompts(answer)
async for envelope in conversation:
async with envelope.process(namespace="des") as des:
@des
def response(response: messages_pb2.Response):
if response.HasField("design_manager"):
pass
# trigger = response.design_manager.trigger
# if trigger.message:
# result_message = trigger.message
elif response.HasField("error"):
raise RuntimeError(f"Load failed: {response.error.message}")
@des
def ids(idmap: idmap_pb2.IDMap):
from jitx._instantiation import instantiation
# flattening might generate IDs that generate access to implicit
# elements, which then needs to trigger instantiation.
with instantiation.require(), instantiation.frame():
rd._rmap.populate_stable_id_chains(idmap)
# Asked of any open UI as well, so either may settle it.
@des
def prompt(p: messages_pb2.UserPrompt):
prompts.prompt(p)
@des
def settled(s: messages_pb2.Settled):
prompts.settled(s)
@des
async def stdin(data: Envelope):
# should no longer be used, prompt is used instead.
await conversation.send(data.message.response({"message": "N"}))
@des
def stdout(data: Mapping):
logger.info(data["message"])
@des
def unhandled():
raise RuntimeError(
f"Unhandled response message: {envelope.type}" # noqa: B023
)
# Keeps the design in step with the runtime, for changes from requests,
# the UI, and other clients alike. Before any capture, so changes made
# while capturing are held and applied to the captured tree.
events = await self.__client.route(design_route(design_name)).subscribe(
"phd", "event"
)
self.__watches.append(
(events, asyncio.create_task(_apply_events(events, rd._state)))
)
if constructed._event_context:
from jitx._instantiation import instantiation
with (
instantiation.require(),
instantiation.frame(),
constructed._event_context,
):
event = Design.Submitted(constructed)
event.fire()
if event._captures:
rd = await rd.capture()
for captured in event._captures:
captured(rd)
if event._resubmit:
return await self.submit(constructed, name=name)
return rd
[docs]
async def capture[T: RuntimeDesign](
self, design: T, *, name: str | None = None, wait: float = 5.0
) -> T:
"""Capture the runtime's physical layout back into the design.
Args:
design: The submitted design.
name: The runtime's name for the design; defaults to the design's
fully qualified name.
wait: Seconds to wait for background tasks to finish before the
layout is taken. 0 captures immediately.
"""
from jitx._translate.reverse_flow.linker import Capture
if name is None:
name = design.name
state = design._state
with state.capturing():
conversation = await self.__client.route(design_route(name)).request(
Message(
"des",
"request",
messages_pb2.Request(
design_name=name,
timeout=max(0, round(wait * 1000)),
physical_design=messages_pb2.PhysicalDesignRequest(
get_layout_output=messages_pb2.GetLayoutOutputQuery()
),
),
)
)
async for envelope in conversation:
with envelope.process(namespace="des") as des:
@des
def response(response: messages_pb2.Response):
layout = response.physical_design.layout_output.output
# A fresh tree each capture: the previous one stays as
# it was for anyone holding on to it.
root = Proxy.create(state.design)
rmap = state.rmap.rebase(root)
Capture(layout, rmap, design=root).run()
_warn_if_issues(rmap)
state.replace(root, rmap)
@des
def stdout(data: Mapping):
logger.info(data["message"])
@des
def failure(data: Mapping):
raise RuntimeError(
f"Failed to capture output ({data['kind']}): {data['message']}"
)
@des
def unhandled():
raise RuntimeError(
f"Unhandled response message: {envelope.type}" # noqa: B023
)
return design
async def _configuration(
self,
design_name: str,
operations: configuration_pb2.ConfigurationOperations,
) -> configuration_pb2.ConfigurationOperationsResponse:
"""Send configuration operations to change a submitted design.
Raises:
RequestRejected: if the runtime refused the operations.
"""
conversation = await self.__client.route(design_route(design_name)).request(
Message("des", "configuration", operations)
)
async for envelope in conversation:
if envelope.ns != "des":
continue
if envelope.type == "configuration":
return ParseDict(
dict(envelope.body),
configuration_pb2.ConfigurationOperationsResponse(),
ignore_unknown_fields=True,
)
if envelope.type == "failure":
body = envelope.body
operation = body.get("operation")
raise RequestRejected(
str(body.get("kind", "")),
str(body.get("message", "")),
int(operation) if operation is not None else None,
)
if envelope.type == "stdout":
logger.info(envelope.body["message"])
raise RuntimeError("the runtime sent no reply to the configuration operations")
async def _operations(
self, design_name: str, operations: physical_design_pb2.PhysicalDesignOperations
) -> physical_design_pb2.PhysicalDesignOperationsResponse:
"""Send operations to change a submitted design.
Raises:
RequestRejected: if the runtime refused the operations.
"""
conversation = await self.__client.route(design_route(design_name)).request(
Message("phd", "operations", operations)
)
async for envelope in conversation:
if envelope.ns != "phd":
continue
if envelope.type == "operations":
return ParseDict(
dict(envelope.body),
physical_design_pb2.PhysicalDesignOperationsResponse(),
ignore_unknown_fields=True,
)
if envelope.type == "failure":
body = envelope.body
operation = body.get("operation")
raise RequestRejected(
str(body.get("kind", "")),
str(body.get("message", "")),
int(operation) if operation is not None else None,
)
# The request is the runtime's current UI message, so the UI output
# it causes (events, progress, geometry) arrives here too; events
# are applied through the design's subscription.
raise RuntimeError("the runtime sent no reply to the operations")
async def _publish(
self, design_name: str, event: physical_design_pb2.PhysicalDesignEvent
) -> None:
"""Send an event to the subscribers of a submitted design's
events."""
# Blind: the runtime does not handle events, and no subscriber is not
# an error.
await self.__client.route(design_route(design_name)).send(
Message("phd", "event", event), blind=True
)
[docs]
async def issues(
self,
design: RuntimeDesign,
*,
categories: Iterable[IssueCategory | int] | None = None,
satisfied: bool = False,
wait: float = 5.0,
) -> IssueReport:
"""Query the physical-design issues of a submitted design.
Args:
design: The submitted design.
categories: Categories to report; all if None.
satisfied: Also report groups whose checks passed.
wait: Seconds to wait for background tasks to finish before the
snapshot is taken, like capture does. Categories still in
progress when the wait ends are reported as pending. 0 answers
immediately.
"""
from jitx._translate.reverse_flow.issue import (
parse_response,
report_from_proto,
)
name = design.name
# The stubs type enum fields as the proto enum wrapper, but any int is
# a valid (open) enum value on the wire, including ones this build of
# jitxcore does not name.
query = messages_pb2.GetDesignIssuesQuery(
categories=[
cast("design_issues_pb2.DesignIssueCategory", int(c))
for c in categories or ()
],
include_satisfied=satisfied,
)
conversation = await self.__client.route(f"design/{name}").request(
Message(
"des",
"request",
messages_pb2.Request(
design_name=name,
timeout=max(0, round(wait * 1000)),
physical_design=messages_pb2.PhysicalDesignRequest(
get_design_issues=query
),
),
)
)
report: IssueReport | None = None
async for envelope in conversation:
with envelope.process(namespace="des") as des:
@des
def response(data: Mapping):
nonlocal report
update = parse_response(data).physical_design.design_issues.update
rmap = design._issue_mapper(update.design_generation)
report = report_from_proto(update, rmap)
@des
def failure(data: Mapping):
raise RuntimeError(
f"Failed to query design issues ({data['kind']}): {data['message']}"
)
@des
def stdout(data: Mapping):
logger.info(data["message"])
@des
def unhandled():
raise RuntimeError(
f"Unhandled response message: {envelope.type}" # noqa: B023
)
if report is None:
raise RuntimeError("The runtime sent no design issue report")
return report
class _RunAsync(Protocol):
def __call__[T](self, coro: Coroutine[None, None, T], /) -> T: ...
[docs]
class SyncRuntime:
class _AsyncBridgeThread:
def __init__(self):
self._loop: asyncio.AbstractEventLoop | None = None
self._thread: threading.Thread | None = None
self._ready = threading.Event()
def start(self) -> None:
if self._thread is not None:
return
def _run():
loop = asyncio.new_event_loop()
self._loop = loop
asyncio.set_event_loop(loop)
# link calling and running thread's runtime stack
# it's a blocking call, so it's safe to do so.
self._ready.set()
try:
loop.run_forever()
finally:
pending = asyncio.all_tasks(loop)
for t in pending:
t.cancel()
loop.run_until_complete(
asyncio.gather(*pending, return_exceptions=True)
)
loop.close()
self._thread = threading.Thread(
target=_run, name="jitx-sync-async-bridge", daemon=True
)
self._thread.start()
self._ready.wait()
def run(self, coro):
assert self._loop is not None, "loop thread not started"
return asyncio.run_coroutine_threadsafe(coro, self._loop).result()
def close(self) -> None:
if self._loop is None or self._thread is None:
return
self._loop.call_soon_threadsafe(self._loop.stop)
self._thread.join()
self._loop = None
self._thread = None
self._ready.clear()
def __init__(self, runtime: Runtime):
self.__runtime = runtime
self.__loop_thread = SyncRuntime._AsyncBridgeThread()
[docs]
def runasync[T](self, coro: Coroutine[None, None, T]) -> T:
from jitx._instantiation import instantiation
self.__loop_thread.start()
outer_instantiation = instantiation.data.stack
async def decoupler():
instantiation.data.stack = outer_instantiation
async with self.__runtime.decouple():
return await coro
return self.__loop_thread.run(decoupler())
# Technically we'd be free to run an event loop here if there isn't one,
# however, if we, for example, run websockets open like this, and then
# websockets close the same way, those will be in different event loops,
# and it has callbacks from the first event loop running in the second
# which it does not like.
# return asyncio.run(coro)
# So it turns out it doesn't matter whether there's an event loop on
# this thread or not, we always have to start a new one.
def __enter__(self):
return self.__runtime.__enter__()
def __exit__(self, exc_type, exc_ob, exc_tb):
return self.__runtime.__exit__(exc_type, exc_ob, exc_tb)
# these should logically not be possible to construct from code,
# and thus could just be exceptions, but it seems fine to keep the parity.
# having them defined allows the Runtime|SyncRuntime return type to not
def __aenter__(self):
return self.__runtime.__aenter__()
def __aexit__(self, exc_type, exc_ob, exc_tb):
return self.__runtime.__aexit__(exc_type, exc_ob, exc_tb)
[docs]
def close(self):
self.runasync(self.__runtime.close())
self.__loop_thread.close()
[docs]
def open(self):
self.runasync(self.__runtime.open())
[docs]
def submit[T: Design](self, design: T | type[T], *, name: str | None = None):
ard = self.runasync(self.__runtime.submit(design, name=name))
return SyncRuntimeDesign(ard._state, ard.name, self.__runtime, self.runasync)
[docs]
def capture[T: RuntimeDesign](
self, design: T, *, name: str | None = None, wait: float = 5.0
) -> T:
return self.runasync(self.__runtime.capture(design, name=name, wait=wait))
[docs]
def issues(
self,
design: RuntimeDesign,
*,
categories: Iterable[IssueCategory | int] | None = None,
satisfied: bool = False,
wait: float = 5.0,
) -> IssueReport:
"""Query the physical-design issues of a submitted design. See
:py:meth:`Runtime.issues`."""
return self.runasync(
self.__runtime.issues(
design, categories=categories, satisfied=satisfied, wait=wait
)
)
class _RuntimeDispatch:
@overload
def __call__(self) -> Runtime | SyncRuntime: ...
@overload
def __call__(self, *, uri: str) -> Runtime: ...
@overload
def __call__(self, *, project: str | Path) -> Runtime: ...
def __call__(self, *, uri: str | None = None, project: str | Path | None = None):
if uri:
r = Runtime(uri=uri)
elif project:
r = Runtime(project=project)
else:
r = Runtime.require()
return r
def __enter__(self) -> SyncRuntime:
return self().__enter__()
def __exit__(self, exc_type, exc_ob, exc_tb):
r = Runtime.active.runtime[-1]
assert r is not None
return r.__exit__(exc_type, exc_ob, exc_tb)
async def __aenter__(self) -> Runtime:
return await self().__aenter__()
async def __aexit__(self, exc_type, exc_ob, exc_tb):
r = Runtime.active.runtime[-1]
assert r is not None
return await r.__aexit__(exc_type, exc_ob, exc_tb)
async def submit[T: Design](self, design: T | type[T], *, name: str | None = None):
async with Runtime.require() as r:
return await r.submit(design, name=name)
async def capture[T: RuntimeDesign](
self, design: T, *, name: str | None = None, wait: float = 5.0
) -> T:
async with Runtime.require() as r:
return await r.capture(design, name=name, wait=wait)
async def issues(
self,
design: RuntimeDesign,
*,
categories: Iterable[IssueCategory | int] | None = None,
satisfied: bool = False,
wait: float = 5.0,
) -> IssueReport:
"""Query the physical-design issues of a submitted design. See
:py:meth:`Runtime.issues`."""
async with Runtime.require() as r:
return await r.issues(
design, categories=categories, satisfied=satisfied, wait=wait
)
@staticmethod
def require():
return Runtime.require()
dispatch = _RuntimeDispatch()
[docs]
class RuntimeDesign[T: Design]:
"""A design that has been submitted to the runtime.
``root`` is the instantiated design object. The rest of this class gives
access to what the runtime knows about it: use :py:meth:`~RuntimeDesign.query`
to walk the design tree, :py:attr:`nets` for connectivity, and :py:attr:`layers`
to resolve layer indices and board sides through placements.
"""
def __init__(self, state: _DesignState[T], name: str):
self._state = state
self.__name = name
@property
def root(self) -> T:
"""The design as last captured. A capture replaces it with a new
object; one held on to keeps its captured state."""
return self._state.root
@property
def _rmap(self) -> ReverseMapper[T]:
return self._state.rmap
def _invalidate(self):
self._state.nets = None
def _issue_mapper(self, generation: str) -> ReverseMapper | None:
# The load reply carries no generation, so the first issue report
# records it; a later mismatch means the runtime reloaded the design
# and ids no longer match this process's mapper. Kept in the shared
# state, so every view of the design agrees.
state = self._state
if state.issue_generation is None:
state.issue_generation = generation
if generation == state.issue_generation:
return self._rmap
if not state.warned_generation:
state.warned_generation = True
logger.warning(
"The runtime's design %s has changed since it was first "
"queried; design issue objects are left unresolved.",
self.__name,
)
return None
@property
def name(self):
"""The name the runtime knows this design by, either the ``name``
passed to :py:meth:`Runtime.submit` or the fully qualified name of the
design object."""
return self.__name
[docs]
def query[Q](
self,
target: type[Q] | tuple[type[Q], ...],
/,
*,
through: tuple[type, ...] | UnionType | None = None,
transform: Transform | None = IDENTITY,
opaque: tuple[type, ...] | UnionType | None = None,
refs: bool = False,
filter: Callable[[Q], bool] | None = None,
) -> Generator[tuple[Trace, Q], None, None]:
"""Convenience method to query this design. See
:py:meth:`~jitx.query.TransformQuery.query` for details."""
return query(
self.root,
target,
through=through,
transform=transform,
opaque=opaque,
refs=refs,
filter=filter,
)
@property
def nets(self):
"""The connectivity of this design computed by the runtime, as a :class:`Nets` instance."""
if self._state.nets is not None:
return self._state.nets
uf: UnionFind[Net | TopologyNet] = UnionFind()
for net in extract(self.root, Net | TopologyNet):
uf.add(net)
if cn := ComputedNet.get(net):
if cn.net:
uf.union(net, cn.net)
non_computed_count = 0
for net in extract(self.root, Net | TopologyNet):
if isinstance(net, Net):
def recurse(net):
count = 0
for n in net._connected:
if isinstance(n, Net):
if not uf.connected(net, n):
count += 1
uf.union(net, n)
count += recurse(n)
return count
non_computed_count += recurse(net)
if non_computed_count:
logger.debug(
"Unioned nets without computed net connection: %d", non_computed_count
)
for netgroup in uf.groups().values():
for net in netgroup:
under = net
while Proxy.is_proxy(under):
under = Proxy.of(under)
# casts here because types in python can't be narrowed to Net & Proxied.
uf.union(cast(Net, net), cast(Net, under))
missing_nets: list[tuple[Trace, Component, set[Port]]] = []
for trace, comp in visit(self.root, (Component, PortAttachment)):
if isinstance(comp, Component):
missing_net_on_comp: list[Port] = []
for pm in extract(comp, PadMapping):
for port, pads in pm.items():
cn = ComputedNet.get(port)
if not cn:
missing_net_on_comp.append(port)
continue
if not isinstance(pads, Sequence):
pads = (pads,)
for pad in pads:
cn.assign(pad)
if missing_net_on_comp:
missing_nets.append((trace, comp, set(missing_net_on_comp)))
elif isinstance(comp, PortAttachment):
if isinstance(comp.port, Sequence):
for ap, cp in zip(
extract(comp.attachment, Port, filter=Port.is_single_pin),
comp.port,
strict=False,
):
cn = ComputedNet.get(cp)
if cn:
cn.assign(ap)
else:
cn = ComputedNet.get(comp.port)
if cn:
cn.assign(comp.attachment)
if missing_nets:
for trace, comp, ports in missing_nets:
portnames = []
for t, _pt in visit(
comp, Port, filter=lambda p, ports=ports: p in ports
):
portnames.append(str(t.path))
if len(portnames) > 1:
portnames.insert(0, "") # don't leave one on the same line
logger.warning(
"no computed net for port in component %s: %s",
trace.path,
"\n ".join(portnames),
)
self._state.nets = self.Nets(uf)
return self._state.nets
@property
def layers(self):
"""Layer index and board side arithmetic for this design's stackup, as
a :class:`Layers` object."""
return self.Layers(self.root.substrate.stackup)
[docs]
class Net[N: Port]:
"""A single net of a captured design.
``nets`` holds the :class:`~jitx.net.Net` and
:class:`~jitx.net.TopologyNet` objects that are connected,
and ``name`` is their connected name.
"""
def __init__(self, nets: Sequence[Net[N] | TopologyNet[N]]):
cname = None
for net in nets:
nname = net._connected_name
if nname:
if cname and cname != nname:
logger.warning(
"Duplicate connected names encountered for nets '%s' and '%s'",
cname,
net._connected_name,
)
else:
cname = nname
self.nets = nets
self.name = cname
@overload
def __iter__(
self: RuntimeDesign.Net[Port],
) -> Generator[Port | Copper | Via, None, None]: ...
@overload
def __iter__(self) -> Generator[N, None, None]: ...
def __iter__(self):
"""Yield every port, copper shape, and via on this net."""
# cast to erase the port type, or pyright thinks the overloads
# might be incompatible - they can't really be at this point,
# but hard to prove.
nets = cast(Sequence[Net], self.nets)
for net in nets:
yield from net
[docs]
class Nets:
"""A manager for looking up the net that an element belongs to."""
def __init__(self, uf: UnionFind[Net | TopologyNet]):
self.__uf = uf
self.__nets = {
root: RuntimeDesign.Net(group) for root, group in uf.groups().items()
}
[docs]
def find(self, element) -> RuntimeDesign.Net | None:
"""Look up the net an element belongs to.
Args:
element: A :class:`~jitx.net.Net` or
:class:`~jitx.net.TopologyNet`, or an element the runtime
assigned to a net, such as a :class:`~jitx.copper.Copper`
or a :class:`~jitx.via.Via`.
Returns:
The :class:`Net` the element is on, or None if it is not on any
net of this design.
"""
if isinstance(element, Net | TopologyNet):
net = element
else:
cnet = ComputedNet.get(element)
if cnet and cnet.net:
net = cnet.net
else:
return None
if net not in self.__uf:
return None
return self.__nets.get(self.__uf.find(net))
def __call__(self):
"""Return self.
.. version-deprecated:: 4.6
Use the ``nets`` property directly.
"""
warnings.warn(
"design.nets() is deprecated; nets is a property, use design.nets",
RuntimeWarning,
skip_file_prefixes=_warn_skips,
stacklevel=0,
)
return self
[docs]
class Layers:
"""A manager for layer index and board side arithmetic."""
def __init__(self, stackup: Stackup):
self.__conductor_count = len(stackup.conductors)
[docs]
def normalize(self, layer: int):
"""Return a 0-indexed ``layer`` counted from the top conductor.
Args:
layer: The layer index to normalize (supports negative indexing).
Returns:
A normalized 0-indexed integer.
"""
if layer < 0:
layer += self.__conductor_count
return layer
[docs]
def apply(self, transform: Transform | Placement, layer: int) -> int:
"""Return the conductor index, counted from the top, that an element
on ``layer`` ends up on once ``transform`` is applied.
Args:
transform: The Transform or Placement to apply.
layer: The conductor layer index to transform.
Returns:
The resulting normalized conductor layer index.
"""
if isinstance(transform, Placement) and transform.side:
layer = -layer - 1
return self.normalize(layer)
[docs]
def side(
self, transform: Transform | Placement, feature: SurfaceFeature
) -> Side:
"""Return the board side a :class:`SurfaceFeature` ends up on
with a transform applied.
Args:
transform: The Transform or Placement to apply.
feature: The SurfaceFeature to transform.
Returns:
The resulting :class:`Side`.
"""
placed = transform.side if isinstance(transform, Placement) else Side.Top
return placed * feature.side
def __call__(self):
"""Return self.
.. version-deprecated:: 4.6
Use the ``layers`` property directly.
"""
warnings.warn(
"design.layers() is deprecated; layers is a property, "
"use design.layers",
RuntimeWarning,
skip_file_prefixes=_warn_skips,
stacklevel=0,
)
return self
[docs]
class AsyncRuntimeDesign[T: Design](RuntimeDesign[T]):
def __init__(self, state: _DesignState[T], name: str, runtime: Runtime):
super().__init__(state, name)
self.__runtime = runtime
self.__layout = AsyncLayout(
lambda: state.rmap,
_operations_sender(runtime, state, name),
monitor=LayoutMonitor(state.monitors),
publish=lambda event: runtime._publish(name, event),
)
self.__configuration = AsyncConfiguration(
lambda: state.rmap,
lambda operations: runtime._configuration(name, operations),
)
@property
def layout(self) -> AsyncLayout:
"""The physical design (layout) of this design in the runtime:
requests that change it, e.g. ``await design.layout.place(...)``, and
:py:attr:`~AsyncLayout.monitor` for callbacks on its changes. Changes
are applied to :py:attr:`root` as the runtime reports them."""
return self.__layout
[docs]
async def capture(self, *, wait: float = 5.0):
"""Capture the runtime's physical layout back into this design. See
:py:meth:`Runtime.capture`."""
return await self.__runtime.capture(self, wait=wait)
[docs]
async def issues(
self,
*,
categories: Iterable[IssueCategory | int] | None = None,
satisfied: bool = False,
wait: float = 5.0,
) -> IssueReport:
"""Query the physical-design issues of this design. See
:py:meth:`Runtime.issues`."""
return await self.__runtime.issues(
self, categories=categories, satisfied=satisfied, wait=wait
)
@property
def configuration(self) -> AsyncConfiguration:
"""The configuration of this design in the runtime: requests that
change settings applied on its next load, e.g.
``await design.configuration.reassign_reference_designators(...)``."""
return self.__configuration
def _sync(self, runasync: _RunAsync) -> SyncRuntimeDesign[T]:
# A sync view sharing this design's state, for code running off the
# event loop; runasync runs a coroutine on the loop and waits for it.
return SyncRuntimeDesign(self._state, self.name, self.__runtime, runasync)
[docs]
class SyncRuntimeDesign[T: Design](RuntimeDesign[T]):
def __init__(
self,
state: _DesignState[T],
name: str,
runtime: Runtime,
runasync: _RunAsync,
):
super().__init__(state, name)
self.__runtime = runtime
self.__runasync = runasync = _off_loop(runasync)
send = _operations_sender(runtime, state, name)
self.__layout = SyncLayout(
lambda: state.rmap,
lambda operations: runasync(send(operations)),
monitor=LayoutMonitor(state.monitors),
publish=lambda event: runasync(runtime._publish(name, event)),
)
self.__configuration = SyncConfiguration(
lambda: state.rmap,
lambda operations: runasync(runtime._configuration(name, operations)),
)
@property
def layout(self) -> SyncLayout:
"""The physical design (layout) of this design in the runtime:
requests that change it, e.g. ``design.layout.place(...)``, and
:py:attr:`~SyncLayout.monitor` for callbacks on its changes. Changes
are applied to :py:attr:`root` as the runtime reports them."""
return self.__layout
[docs]
def capture(self, *, wait: float = 5.0):
"""Capture the runtime's physical layout back into this design. See
:py:meth:`Runtime.capture`."""
return self.__runasync(self.__runtime.capture(self, wait=wait))
[docs]
def issues(
self,
*,
categories: Iterable[IssueCategory | int] | None = None,
satisfied: bool = False,
wait: float = 5.0,
) -> IssueReport:
"""Query the physical-design issues of this design. See
:py:meth:`Runtime.issues`."""
return self.__runasync(
self.__runtime.issues(
self, categories=categories, satisfied=satisfied, wait=wait
)
)
@property
def configuration(self) -> SyncConfiguration:
"""The configuration of this design in the runtime: requests that
change settings applied on its next load, e.g.
``design.configuration.reassign_reference_designators(...)``."""
return self.__configuration
def _off_loop(runasync: _RunAsync) -> _RunAsync:
# Waiting on the loop from the loop's own thread (a monitor callback)
# would deadlock; refuse instead.
def run[T](coro: Coroutine[None, None, T], /) -> T:
try:
asyncio.get_running_loop()
except RuntimeError:
return runasync(coro)
coro.close()
raise RuntimeError(
"a sync runtime design cannot wait on the runtime from an event "
"loop, such as in a layout monitor callback"
)
return run
class _DesignState[T: Design]:
"""The mutable state of a submitted design, shared by every view of it."""
# Views (async, sync, request objects) hold this rather than copies, so a
# capture or a placement update is seen by all of them.
def __init__(self, design: T, rmap: ReverseMapper[T]):
self.design = design
self.root: T = rmap.root
self.rmap = rmap
self.nets: RuntimeDesign.Nets | None = None
# The design generation of the first issue report, and whether a
# later mismatch was warned about.
self.issue_generation: str | None = None
self.warned_generation = False
self.monitors = _Monitors()
self.__queued: list[Callable[[], None]] | None = None
def replace(self, root: T, rmap: ReverseMapper[T]) -> None:
"""Make a newly captured tree the current one."""
self.rmap.dispose_links()
self.root = root
self.rmap = rmap
self.nets = None
@contextmanager
def capturing(self) -> Generator[None, None, None]:
"""Hold updates while a capture is running, and apply them to the
captured tree afterwards."""
# Updates arriving between the runtime producing the layout and the
# new tree replacing the old one would otherwise be lost with the old
# tree. They are absolute (placements) or keyed by runtime id (routes,
# vias), so applying them again is harmless.
self.__queued = []
try:
yield
finally:
queued, self.__queued = self.__queued, None
for update in queued:
update()
def __apply(self, update: Callable[[], None]) -> None:
if self.__queued is not None:
self.__queued.append(update)
else:
update()
def apply_placements(
self, placements: Sequence[tuple[stable_id_pb2.CompositeID, Placement]]
) -> None:
"""Apply board-absolute instance placements reported by the runtime."""
placements = list(placements)
def update() -> None:
self.monitors.notify_placements(
_apply_instance_placements(self.root, self.rmap, placements)
)
self.__apply(update)
def apply_routes(self, changed: physical_design_pb2.RoutesChanged) -> None:
"""Apply routes the runtime reports as routed, blocked, or removed."""
def update() -> None:
changes = apply_routes(self.root, self.rmap, changed)
self.nets = None
self.monitors.notify("routes", changes)
self.__apply(update)
def apply_vias(self, changed: physical_design_pb2.ViasChanged) -> None:
"""Apply vias the runtime reports as added, changed, or removed."""
def update() -> None:
changes = apply_vias(self.root, self.rmap, changed)
self.nets = None
self.monitors.notify("vias", changes)
self.__apply(update)
def apply_controls(self, changed: physical_design_pb2.ControlPointsChanged) -> None:
"""Apply control points the runtime reports as added, changed, or
removed."""
def update() -> None:
changes = apply_controls(self.root, self.rmap, changed)
self.nets = None
self.monitors.notify("controls", changes)
self.__apply(update)
def apply_regions(self, changed: physical_design_pb2.AreaRegionsChanged) -> None:
"""Apply area regions the runtime reports as added, changed, or
removed."""
def update() -> None:
changes = apply_regions(self.root, self.rmap, changed)
self.nets = None
self.monitors.notify("regions", changes)
self.__apply(update)
def apply_pours(self, changed: physical_design_pb2.PoursChanged) -> None:
"""Apply pours the runtime reports as computed or removed."""
def update() -> None:
changes = apply_pours(self.root, self.rmap, changed)
self.nets = None
self.monitors.notify("pours", changes)
self.__apply(update)
def apply_event(self, event: physical_design_pb2.PhysicalDesignEvent) -> None:
"""Apply a ``phd``/``event`` from the runtime, then notify the
monitors."""
def update() -> None:
# Placements first: the other kinds are placed in the frames they
# set. Monitors are notified once the whole transition is applied,
# user events last.
moved: list[Component | Circuit] = []
changes: list[tuple[str, Changes[Any]]] = []
if event.HasField("placements"):
moved = _apply_instance_placements(
self.root, self.rmap, _placements(event.placements.placements)
)
if event.HasField("controls"):
c = apply_controls(self.root, self.rmap, event.controls)
changes.append(("controls", c))
if event.HasField("regions"):
changes.append(
("regions", apply_regions(self.root, self.rmap, event.regions))
)
if event.HasField("pours"):
changes.append(
("pours", apply_pours(self.root, self.rmap, event.pours))
)
if event.HasField("routes"):
changes.append(
("routes", apply_routes(self.root, self.rmap, event.routes))
)
if event.HasField("vias"):
changes.append(("vias", apply_vias(self.root, self.rmap, event.vias)))
if changes:
self.nets = None
self.monitors.notify_placements(moved)
for kind, c in changes:
self.monitors.notify(kind, c)
if event.HasField("user"):
self.monitors.notify_user(event.user, self.rmap)
self.__apply(update)
def apply_results(
self, response: physical_design_pb2.PhysicalDesignOperationsResponse
) -> None:
"""Apply the placements an operations reply reports."""
self.apply_placements(
_placements(
ip
for result in response.results
for ip in result.place_components.placements
)
)
async def _apply_events(events: AsyncIterable[Envelope], state: _DesignState) -> None:
# Applies a design's ``phd``/``event`` messages until the subscription
# ends.
async for envelope in events:
try:
event = ParseDict(
dict(envelope.body),
physical_design_pb2.PhysicalDesignEvent(),
ignore_unknown_fields=True,
)
state.apply_event(event)
except Exception:
logger.exception("Failed to apply a physical design event")
def _placements(
placements: Iterable[physical_design_pb2.InstancePlacement],
) -> list[tuple[stable_id_pb2.CompositeID, Placement]]:
# The model's form of reported board-absolute placements.
return [
(ip.instance, placement_from_pose_side_proto(ip.pose, ip.side))
for ip in placements
]
def _operations_sender(
runtime: Runtime, state: _DesignState, name: str
) -> Callable[
[physical_design_pb2.PhysicalDesignOperations],
Coroutine[None, None, physical_design_pb2.PhysicalDesignOperationsResponse],
]:
async def send(
operations: physical_design_pb2.PhysicalDesignOperations,
) -> physical_design_pb2.PhysicalDesignOperationsResponse:
response = await runtime._operations(name, operations)
state.apply_results(response)
return response
return send
def _apply_instance_placements(
root: Design,
rmap: ReverseMapper,
placements: Sequence[tuple[stable_id_pb2.CompositeID, Placement]],
) -> list[Component | Circuit]:
# Returns the instances whose transform changed.
from jitx._translate.reverse_flow.resolve import resolve
targets: dict[int, Placement] = {}
for instance, placement in placements:
obj = resolve(
instance,
rmap,
False,
context="InstancePlacement",
expect=(Component, Circuit),
)
if obj is None:
logger.debug("No instance for placement update %s", instance)
continue
targets[id(obj)] = placement
moved: list[Component | Circuit] = []
if not targets:
return moved
# Top-down, as capture does: a moved circuit gets its transform before its
# contents are made relative to it.
def loop(parent: Circuit, transform: Transform) -> None:
for t, component in visit(parent, Component, through=(), transform=transform):
placement = targets.get(id(component))
if placement is not None and t.transform is not None:
ctx = ~t.transform * placement
if component.transform != ctx:
component.transform = ctx
moved.append(component)
for t, circuit in visit(parent, Circuit, through=(), transform=transform):
if t.transform is None:
continue
placement = targets.get(id(circuit))
if placement is not None:
ctx = ~t.transform * placement
if circuit.transform != ctx:
circuit.transform = ctx
moved.append(circuit)
loop(
circuit,
t.transform * circuit.transform if circuit.transform else t.transform,
)
top = root if isinstance(root, Circuit) else root.circuit
loop(top, IDENTITY)
return moved
def _instantiate_design[T: Design](design: T | type[T]) -> T:
from jitx._instantiation import instantiation
from jitx._structural import Instantiable, Proxy
import gc
if isinstance(design, Instantiable):
cls = design._instantiable_()
if not (isinstance(cls, type) and issubclass(cls, Design)):
raise ValueError(f"Not an instantiable design: {cls}")
with instantiation.require():
with instantiation.frame():
instantiated = design._instantiate_({})
elif isinstance(design, type):
if not issubclass(design, Design):
raise ValueError(f"Not a Design subclass: {cls}")
try:
if hasattr(design, "__signature__"):
design.__signature__.bind()
except TypeError:
raise ValueError(
"Design is parameterized but no parameters were provided"
) from None
with instantiation.require():
with instantiation.frame():
instantiated = design()
else:
instantiated = design
assert isinstance(instantiated, Design), (
f"Instantiation resulted in non-Design object {Proxy.type(instantiated)}"
)
gc.collect()
return cast(T, instantiated)
def _package_design[T: Design](
design: T, name: str | None = None
) -> tuple[design_pb2.Design, ReverseMapper[T]]:
from jitx._structural import Proxy
from jitx._instantiation import instantiation
from jitx._translate.design import package_design
from .._translate.idmap import idmap
if not isinstance(design, Design):
raise TypeError(f"{design} is not a Design class")
idmap.clear()
with instantiation.require():
packaged = package_design(design, name)
rmap = idmap.export(Proxy.create(design))
return packaged, rmap