# -*- coding: utf-8 -*-
# This code is part of Qiskit.
#
# (C) Copyright IBM 2017, 2021.
#
# This code is licensed under the Apache License, Version 2.0. You may
# obtain a copy of this license in the LICENSE.txt file in the root directory
# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
#
# Any modifications or derivative works of this code must retain this
# copyright notice, and modified files need to carry a notice indicating
# that they have been altered from the originals.
"""This is the main module that defines what an element is in Qiskit Metal.
See the docstring of `QGeometryTables`
"""
import inspect
import logging
import pandas as pd
import shapely
from typing import TYPE_CHECKING
from typing import Dict as Dict_
from typing import List, Tuple, Union, Any, Iterable
from geopandas import GeoDataFrame, GeoSeries
from .. import Dict
from ..draw import BaseGeometry
from qiskit_metal.draw.utility import round_coordinate_sequence
from shapely.geometry.multipolygon import MultiPolygon #to avoid MultiPolygons
from .. import config
if not config.is_building_docs():
from qiskit_metal.toolbox_python.utility_functions import get_range_of_vertex_to_not_fillet, data_frame_empty_typed
if TYPE_CHECKING:
from ..qlibrary.core import QComponent
from ..designs import QDesign
__all__ = ['is_qgeometry_table', 'QGeometryTables'] # , 'ElementTypes']
# from collections import OrderedDict
# dict are ordered in Python 3.6+ by default, this is for backward compatibility
# class ElementTypes:
# """
# Types of qgeometry
# positive : qgeometry that are positive mask
# negative : qgeometry that will be subtracted from the chip ground plane
# helper : qgeometry that are only used in a helper capacity,
# such as labels or mesh rectangles
# """
# positive = 0
# negative = 1
# helper = 2
def is_qgeometry_table(obj):
"""Check if an object is a Metal BaseElementTable, i.e., an instance of
`QGeometryTables`.
The problem is that the `isinstance` built-in method fails
when this module is reloaded.
Args:
obj (object): Test this object
Returns:
bool: True if is a Metal element
"""
if isinstance(obj, Dict):
return False
return hasattr(obj, '__i_am_qgeometry_table__')
#############################################################################
#
# Dictionary that specifies the column names of various element tables.
#
# TODO: implement data types in construction of tables?
# TODO: when a copy over the table and manipulate ehtmee
# i seem to loose the assignments to bool etc.
ELEMENT_COLUMNS = dict(
################################################
# DO NOT MODIFY THE base DICTIONARY.
# This is for Metal API use only.
# To add a new element type, add a new key below.
base=dict(
component=str, # Unique ID of the component to which the element belongs
name=str, # name of the element
geometry=object, # shapely object
layer=int, # gds type of layer
subtract=bool, # do we subtract from the ground place of the chip
helper=bool, # helper or not
chip=str, # chip name
# type=str, # metal, helper. poly=10 or path=11
__renderers__=dict(
# ADD specific renderers here, all renderes must register here.
# hfss = dict( ... ) # pick names as hfss_name
# hfss=dict(
# boundary_name=str,
# material=str,
# perfectE=bool
# ),
# gds=dict(
# type=str,
# color=str,
# )
)),
################################################
# Specifies a path, such as a CPW.
# Ideas: chamfer
path=dict(
width=float,
fillet=object, # TODO: not decided yet how to represent this
__renderers__=dict()),
################################################
# Specifies a polygon
# Ideas: chamfer
poly=dict(
fillet=object, # TODO: not decided yet how to represent this
__renderers__=dict()),
################################################
# Specifies a junction as a 2 point line and width
# This should provide enough information so as to
# - render a sheet with inductance (from renderer options) + a vector for EPR
# - generate ports (edge ports?) for Z analysis
# - provice bounding box dimensions for the p-cell of ebeam junction layout
# for GDS renderer
junction=dict(width=float, __renderers__=dict()),
################################################
# Specifies a curved object, such as a circle. Perhaps as a buffered point
# Not yet implemented
# curved = dict(
# __renderers__= dict(
# )
# )
)
"""Dictionary that specifies the column names of various element tables."""
#############################################################################
#
# Class to create, store, and handle element tables.
#
TRUE_BOOLS = [True, 'True', 'true', 'Yes', 'yes', '1', 1]
[docs]
class QGeometryTables(object):
"""Class to create, store, and handle element tables.
A regular user would not need to create tables themselves.
This is handled automatically by the design creation and plugins.
Structure:
A component, such as a qubit, is a collection of qgeometry.
For example, an element includes a rectangle, a cpw path, or a more general polygon.
An element is a row in a table.
All qgeometry of a type (Path or Polygon, or otherwise) are stored in a
single table of their element type.
All qgeometry of the same kind are stored in a table.
A renderer has to know how to handle all types of qgeometry in order to render them.
For plugin developers:
In the following, we provide an example that illustrates for plugin developers how
to add custom qgeometry and custom element properties. For example, we will add, for a renderer
called hfss, a string property called 'boundary', a bool property called 'perfectE', and a property called 'material'.
For plugin developers, example use:
.. code-block:: python
:linenos:
:emphasize-lines: 4,6
import qiskit_metal as metal
design = metal.designs.DesignPlanar()
design.qgeometry = metal.QGeometryTables(design)
# return the path table - give access to ..
design.qgeometry['path']
design.qgeometry.table['path']
# Define interfaces
design.qgeometry.get_component(
component_name,
element_name,
columns=all or geom or list) # get all elements for components
>>> component name geometry layer type chip subtract fillet color width
Now, if we want to add custom qgeometry through two fake renderers called hfss and gds:
.. code-block:: python
:linenos:
:emphasize-lines: 1-15
metal.QGeometryTables.add_renderer_extension('hfss', dict(
base=dict(
boundary=str,
perfectE=bool,
material=str,
)
))
metal.QGeometryTables.add_renderer_extension('gds', dict(
path=dict(
color=str,
pcell=bool,
)
))
design = metal.designs.DesignPlanar()
qgeometry = metal.QGeometryTables(design)
qgeometry.tables['path']
>>> component name geometry layer type chip subtract fillet color width hfss_boundary hfss_perfectE hfss_material gds_color gds_pcell
"""
# Dummy private attribute used to check if an instanciated object is
# indeed a element table class. The problem is that the `isinstance`
# built-in method fails when this module is reloaded.
# Used by `is_element` to check.
__i_am_qgeometry_table__ = True
ELEMENT_COLUMNS = ELEMENT_COLUMNS
"""
Table column names to use to create.
This dict should be updated by renderers.
"""
# For creating names of columns of renderer properties
name_delimiter = '_'
""" Delimiter to use when creating names of columns of renderer properties. """
def __init__(self, design: 'QDesign'):
"""The constructor for the `QGeometryTables` class.
Args:
design: Design in use
"""
self._design = design
self._tables = Dict()
# Need to call after columns are added by add_renderer_extension is run by all the renderers.
# self.create_tables()
@property
def design(self) -> 'QDesign':
"""Return a reference to the parent design object."""
return self._design
@property
def logger(self) -> logging.Logger:
"""Return the logger."""
return self._design.logger
@property
def tables(self) -> Dict_[str, GeoDataFrame]:
"""The dictionary of tables containing qgeometry.
Returns:
Dict_[str, GeoDataFrame]: The keys of this dictionary are
also obtained from `self.get_element_types()`
"""
return self._tables
[docs]
@classmethod
def add_renderer_extension(cls, renderer_name: str, qgeometry: dict):
"""Add renderer element extension to ELEMENT_COLUMNS. Called when the
load function of a renderer is called.
Args:
renderer_name (str): Name of renderer
qgeometry (dict): dict of dict. Keys give element type names,
such as base, poly, path, etc.
"""
# Make sure that the base and all other element kinds have this renderer registered
for element_key in cls.ELEMENT_COLUMNS:
if not renderer_name in cls.ELEMENT_COLUMNS[element_key][
'__renderers__']:
cls.ELEMENT_COLUMNS[element_key]['__renderers__'][
renderer_name] = dict()
# Now update the dictionaries with all qgeometry that the renderer may have
for element_key, element_column_ext_dict in qgeometry.items():
# The element the render is specifying is not in the specified qgeometry;
# then add it. This shouldn't really happen.
# The rest of the renderer dict keys in __renderers__ are missing for
# the created type. Avoid doing, else hope it works.
if not element_key in cls.ELEMENT_COLUMNS:
cls.ELEMENT_COLUMNS[element_key] = dict(__renderers__=dict())
# Now add qgeometry
cls.ELEMENT_COLUMNS[element_key]['__renderers__'][
renderer_name].update(element_column_ext_dict)
# could use weakref memorization
# https://stackoverflow.com/questions/33672412/python-functools-lru-cache-with-class-methods-release-object
[docs]
@classmethod
def get_element_types(cls) -> List[str]:
"""Return the names of the available qgeometry to create. This does not
include 'base', but is rather such as poly and path.
Returns:
list(str) : A list of name in self.ELEMENT_COLUMNS
"""
# TODO: I should probably make this a variable and memorize, only change when qgeometry are added and removed
# can be slow for performance to look up each time and recalculate, since may call this often
names = list(cls.ELEMENT_COLUMNS.keys())
names.remove('base')
return names
[docs]
def create_tables(self):
"""Creates the default tables once. Populates the dict 'tables' of
GeoDataFrame, each with columns corresponding to the types of qgeometry
defined in ELEMENT_COLUMNS.
Should only be done once when a new design is created.
"""
self.logger.debug('Creating Element Tables.')
for table_name in self.get_element_types():
# Create GeoDataFrame with correct columns and d types
assert isinstance(table_name, str)
assert table_name.isidentifier()
# Get column names
# Base names, add concrete names, then add renderer names
# Base names
columns_base = self.ELEMENT_COLUMNS['base'].copy()
columns_base_renderers = columns_base.pop('__renderers__')
# Concrete names
columns_concrete = self.ELEMENT_COLUMNS[table_name].copy()
columns_concrete_renderer = columns_concrete.pop('__renderers__')
assert isinstance(columns_base_renderers, dict) and\
isinstance(columns_concrete_renderer, dict),\
"Please make sure that all qgeometry types have __renderers__\
which is a dictionary."
# Combine all base names and renderer names
columns = columns_base
columns.update(columns_concrete)
# add renderer columns: base and then concrete
for renderer_key in columns_base_renderers:
columns.update(
self._prepend_renderer_names(table_name, renderer_key,
columns_base_renderers))
columns.update(
self._prepend_renderer_names(table_name, renderer_key,
columns_concrete_renderer))
# Validate -- Throws an error if not valid
self._validate_column_dictionary(table_name, columns)
# Create df with correct column names
table = GeoDataFrame(data_frame_empty_typed(columns))
# not used elsewhere, also the name becomes "name" for some reason
table.name = table_name
# Assign
self.tables[table_name] = table
def _validate_column_dictionary(self, table_name: str, column_dict: dict):
"""Validate A possible error here is if the user did not pass a valid
data type.
Throws an error if not valid.
Args:
table_name (str): Name of element table (e.g., 'poly')
column_dict (dict): Dictionary to check
Raises:
TypeError: Data type '' not understood
"""
__pre = 'ERROR CREATING ELEMENT TABLE FOR DESIGN: \
\n ELEMENT_TABLE_NAME = {table_name}\
\n KEY = {k} \
\n VALUE = {v}\n '
# Are these assertions still holding true?
for k, v in column_dict.items():
assert isinstance(k, str), __pre.format(**locals()) +\
' Key needs to be a string!'
assert k.isidentifier(), __pre.format(**locals()) +\
' Key needs to be a valid string identifier!'
assert inspect.isclass(v), __pre.format(**locals()) +\
' Value needs to be a class!'
[docs]
def get_rname(self, renderer_name: str, key: str) -> str:
"""Get name for renderer property.
Args:
renderer_name (str): Name of the renderer
key (str): Key to get the name for
Returns:
str: The unique named used as a column in the table
"""
return renderer_name + self.name_delimiter + key
def _prepend_renderer_names(self, table_name: str, renderer_key: str,
rdict: dict):
"""Prepare all the renderer names.
Args:
table_name (str): Unused
renderer_key (str): Key to check for
rdict (dict): Renderer dictionary
Returns:
dict: Prepared dictionary
TODO:
This function has arguments that are unused, fix the function or ditch the unused args
"""
return {
self.get_rname(renderer_key, k): v
for k, v in rdict.get(renderer_key, {}).items()
}
[docs]
def add_qgeometry(
self,
kind: str,
component_name: str,
geometry: dict,
subtract: bool = False,
helper: bool = False,
layer: Union[int, str] = 1, # chip will be here
chip: str = 'main',
**other_options):
"""Main interface to add qgeometries.
Args:
kind (str): Must be in get_element_types ('path', 'poly', etc.).
component_name (str): Component name.
geometry (dict): Dict of shapely geometry.
subtract (bool): Subtract - passed through. Defaults to False.
helper (bool): Helper - passed through. Defaults to False.
layer (Union[int, str]): Layer - passed through. Defaults to 1.
chip (str): Chip name - passed through. Defaults to 'main'.
**other_options (object): Other_options - passed through.
"""
# TODO: Add unit test
# ensure correct types
if not isinstance(subtract, bool):
subtract = subtract in TRUE_BOOLS
if not isinstance(helper, bool):
helper = helper in TRUE_BOOLS
if not (kind in self.get_element_types()):
self.logger.error(
f'Creator user error: Unknown element kind=`{kind}`'
f'Kind must be in {self.get_element_types()}. This failed for component'
f'name = `{component_name}`.\n'
f' The call was with subtract={subtract} and helper={helper}'
f' and layer={layer}, and options={other_options}')
#Checks if (any) of the geometry are MultiPolygons, and breaks them up into
#individual polygons. Rounds the coordinate sequences of those values to avoid
#numerical errors.
rounding_val = self.design.template_options['PRECISION']
new_dict = Dict()
for key, item in geometry.items():
if isinstance(geometry[key], MultiPolygon):
temp_multi = geometry[key]
shape_count = 0
for shape_temp in temp_multi.geoms:
new_dict[key + '_' +
str(shape_count)] = round_coordinate_sequence(
shape_temp, rounding_val)
shape_count += 1
else:
new_dict[key] = round_coordinate_sequence(item, rounding_val)
geometry = new_dict
# Create options TODO: Might want to modify this (component_name -> component_id)
# Give warning if length is to be fillet's and not long enough.
self.check_lengths(geometry, kind, component_name, **other_options)
# Create options
options = dict(component=component_name,
subtract=subtract,
helper=helper,
layer=int(layer),
chip=chip,
**other_options)
#replaces line above to generate the options.
#for keyC in design.qgeometry.tables[kind].columns:
# if keyC != 'geometry':
# options[keyC] = ???[keyC] -> alternative manner to pass options to the add_qgeometry function?
# instead have the add_qeometry in baseComponent generate the dict?
#Could we just append rather than make a new table each time? This seems slow
table = self.tables[kind]
# assert that all names in options are in table columns! TODO: New approach will not be wanting
#to do this (maybe check that all columns are in options?)
df = GeoDataFrame.from_dict(geometry,
orient='index',
columns=['geometry'])
df.index.name = 'name'
df = df.reset_index()
df = df.assign(**options)
# Set new table. Unfortunately, this creates a new instance. Can just direct append
self.tables[kind] = pd.concat([table, df],
axis=0,
join='outer',
ignore_index=True,
sort=False,
verify_integrity=False,
copy=False)
[docs]
def check_lengths(self, geometry: shapely.geometry.base.BaseGeometry,
kind: str, component_name: str, **other_options):
"""If user wants to fillet, check the line-segments to see if it is too
short for fillet.
Args:
geometry (shapely.geometry.base.BaseGeometry): The LineString to investigate.
kind (str): Name of table, i.e. 'path', 'poly', 'junction, etc
component_name (str): Is an integer id.
"""
if 'fillet' in other_options.keys():
fillet = other_options['fillet']
for key, geom in geometry.items():
if isinstance(geom, shapely.geometry.LineString):
coords = list(geom.coords)
qdesign_precision = self.design.template_options.PRECISION
range_vertex_of_short_segments = get_range_of_vertex_to_not_fillet(
coords, fillet, qdesign_precision, add_endpoints=False)
if len(range_vertex_of_short_segments) > 0:
range_string = ""
for item in range_vertex_of_short_segments:
range_string += f'({ item[0]}-{item[1]}) '
text_id = self.design._components[component_name]._name
self.logger.warning(
f'For {kind} table, component={text_id}, key={key}'
f' has short segments that could cause issues with fillet. Values in {range_string} '
f'are index(es) in shapely geometry.')
[docs]
def parse_value(self, value: Union[Any, List, Dict, Iterable]) -> Any:
"""Same as design.parse_value. See design for help.
Returns:
Parsed value of input.
"""
return self.design.parse_value(value)
[docs]
def clear_all_tables(self):
"""Clear all the internal tables and all else.
Use when clearing a design and starting from scratch.
"""
self.tables.clear()
self.create_tables() # remake all tables
[docs]
def delete_component(self, name: str):
"""Delete component by name.
Args:
name (str): Name of component (case sensitive)
"""
# TODO: Add unit test
# TODO: is this the best way to do this, or is there a faster way?
a_comp = self.design.components[name]
if a_comp is not None:
for table_name in self.tables:
df = self.tables[table_name]
self.tables[table_name] = df[df['component'] != a_comp.id]
[docs]
def delete_component_id(self, component_id: int):
"""Drop the components within the qgeometry.tables.
Args:
component_id (int): Unique number to describe the component.
"""
for table_name in self.tables:
df_table_name = self.tables[table_name]
# self.tables[table_name] = df_table_name.drop(df_table_name[df_table_name['component'] == component_id].index)
self.tables[table_name] = df_table_name[df_table_name['component']
!= component_id]
[docs]
def get_component(
self,
name: str,
table_name: str = 'all'
) -> Union[GeoDataFrame, Dict_[str, GeoDataFrame]]:
"""Return the table for just a given component. If all, returns a
dictionary with keys as table names and tables of components as values.
Args:
name (str): Name of component (case sensitive). Defaults to 'all'.
table_name (str): Element table name ('poly', 'path', etc.). Defaults to {'all'}.
Returns:
Union[GeoDataFrame, Dict_[str, GeoDataFrame]]: Either a GeoDataFrame or a dict or GeoDataFrame.
Example usage:
```table = pd.concat(qgeometry.get_component('Q1')) # , axis=0```
"""
if table_name == 'all':
tables = {}
for table_name in self.get_element_types():
tables[table_name] = self.get_component(name, table_name)
return tables
else:
df = self.tables[table_name]
a_comp = self.design.components[name]
if a_comp is None:
# Component not found.
return None
else:
return df[df.component == a_comp.id]
# comp_id = self.design.components[name].id
# return df[df.component == comp_id]
[docs]
def get_component_bounds(self,
name: str) -> Tuple[float, float, float, float]:
"""Returns a tuple containing minx, miny, maxx, maxy values for the
bounds of the component as a whole.
Args:
name (str): Component name
Returns:
Geometry: Bare element geometry
"""
gs = self.get_component_geometry(name) # Pandas GeoSeries
if len(gs) == 0:
return (0, 0, 0, 0)
else:
return gs.total_bounds
[docs]
def rename_component(self, component_id: int, new_name: str):
"""Rename component by ID (integer) cast to string format.
Args:
component_id (int) : ID of component (case sensitive)
new_name (str) : The new name of the component (case sensitive)
"""
# comp_id = self.design.components[name].id
component_int_id = int(component_id)
a_comp = self.design._components[component_int_id]
if a_comp is None:
return None
else:
# TODO: is this the best way to do this, or is there a faster way?
for table_name in self.tables:
table = self.tables[table_name]
table.component[table.component == a_comp.id] = new_name
[docs]
def get_component_geometry_list(self,
name: str,
table_name: str = 'all'
) -> List[BaseGeometry]:
"""Return just the bare element geometry (shapely geometry objects) as
a list, for the selected component.
Args:
name (str) : Name of component (case sensitive)
table_name (str) : Element type ('poly', 'path', etc.).
Can also be 'all' to return all. This is the default.
Returns:
list: List of shapley.geometry objects
"""
if table_name == 'all':
qgeometry = []
for table in self.get_element_types():
qgeometry += self.get_component_geometry_list(name, table)
else:
table = self.tables[table_name]
comp_id = self.design.components[name].id
qgeometry = table.geometry[table.component == comp_id].to_list()
return qgeometry
[docs]
def get_component_geometry(self, name: str) -> GeoSeries:
"""Returns geometry of a given component.
Args:
name (str) : Name of component (case sensitive)
Returns:
GeoSeries : Geometry of the component
"""
comp_id = self.design.components[name].id
qgeometry = {}
for table_name in self.get_element_types():
table = self.tables[table_name]
qgeometry[table_name] = table.geometry[table.component == comp_id]
qgeometry = pd.concat(qgeometry)
# when concatenating empty GeoSeries, returns Series (ugly fix)
if not isinstance(qgeometry, GeoSeries):
qgeometry = GeoSeries(qgeometry)
return qgeometry
[docs]
def get_component_geometry_dict(self,
name: str,
table_name: str = 'all'
) -> List[BaseGeometry]:
"""Return just the bare element geometry (shapely geometry objects) as
a dict, with key being the names of the qgeometry and the values as the
shapely geometry, for the selected component.
Args:
name (str) : Name of component (case sensitive)
table_name (str) : Element type ('poly', 'path', etc.). Defaults to 'all'.
Returns:
dict: Bare element geometry
"""
if table_name == 'all':
qgeometry = Dict()
for table in self.get_element_types():
qgeometry[table] = self.get_component_geometry_list(name, table)
return qgeometry # return pd.concat(qgeometry, axis=0)
else:
table = self.tables[table_name]
# mask the rows nad get only 2 columns
comp_id = self.design.components[name].id
df_comp_id = table.loc[table.component == comp_id,
['name', 'geometry']]
df_geometry = df_comp_id.geometry
df_geometry.index = df_comp_id.name
return df_geometry.to_dict()
[docs]
def check_element_type(self,
table_name: str,
log_issue: bool = True) -> bool:
"""Check if the name `table_name` is in the element tables.
Args:
table_name (str): Element type ('poly', 'path', etc.) or 'all'
log_issue (bool): Throw an error in the log if name missing. Defaults to True.
Returns:
bool: True if the name is valid, else False
"""
if not table_name in self.get_element_types() or table_name in 'all':
if log_issue:
self.logger.error(
f'Element Tables: Tried to access non-existing element table: `{table_name}`'
)
return False
else:
return True
[docs]
def get_all_unique_layers_for_all_tables(self,
qcomp_ids: Union[list, None] = None
) -> list:
"""Get a list of all unique layer number used in all of the geometry tables.
User can get for all components or a subset.
Args:
qcomp_ids (Union[list, None], optional): The list has integers
which denote component_id. Defaults to None.
Returns:
list: The unique layer numbers for the list of component ids passed in argument.
"""
if qcomp_ids is None:
qcomp_ids = []
frames = list()
unique_layers = None
if len(qcomp_ids) == 0:
# use all components
frames = [self.tables[a_df] for a_df in self.tables]
else:
# Use just the component ID's in qcomp_ids.
for table_key in self.tables:
temp_df = self.tables[table_key]
mask_temp = temp_df['component'].isin(qcomp_ids)
subset_temp_df = temp_df[mask_temp]
frames.append(subset_temp_df)
#Concat the frames and then determine the unique layer numbers.
unique_layers = list(
pd.concat(frames, ignore_index=True)['layer'].unique())
return unique_layers
[docs]
def get_all_unique_layers(self, chip_name: str) -> list:
"""Returns a lit of unique layers for the given chip names.
Args:
chip_name (str): Name of the chip
Returns:
list: List of unique layers
"""
unique_layers = list()
for table_name in self.design.qgeometry.get_element_types():
table = self.design.qgeometry.tables[table_name]
temp = table[table['chip'] == chip_name]
layers = temp['layer'].unique().tolist()
unique_layers += layers
unique_layers = list(set(unique_layers))
return unique_layers