Keysight EMPro Scripting Cookbook to Serve Python
User’s Guide
Notices © Keysight Technologies, Inc. 2014
Warranty
Restricted Rights Legend
No part of this manual may be reproduced in any form or by any means (including electronic storage and retrieval or translation into a foreign language) without prior agreement and written consent from Keysight Technologies, Inc. as governed by United States and international copyright laws.
The material contained in this document is provided “as is”, and is subject to being changed, without notice, in future editions. Further, to the maximum extent permitted by applicable law, Keysight disclaims all warranties, either express or implied, with regard to this documentation and any information contained herein, including but not limited to the implied warranties of merchantability and fitness for a particular purpose. Keysight shall not be liable for errors or for incidental or consequential damages in connection with the furnishing, use, or performance of this document or of any information contained herein. Should Keysight and the user have a separate written agreement with warranty terms covering the material in this document that conflict with these terms, the warranty terms in the separate agreement shall control.
If software is for use in the performance of a U.S. Government prime contract or subcontract, Software is delivered and licensed as “Commercial computer software” as defined in DFAR 252.2277014 (June 1995), or as a “commercial item” as defined in FAR 2.101(a) or as “Restricted computer software” as defined in FAR 52.227-19 (June 1987) or any equivalent agency regulation or contract clause. Use, duplication or disclosure of Software is subject to Keysight Technologies’ standard commercial license terms, and nonDOD Departments and Agencies of the U.S. Government will receive no greater than Restricted Rights as defined in FAR 52.227-19(c)(1-2) (June 1987). U.S. Government users will receive no greater than Limited Rights as defined in FAR 52.227-14 (June 1987) or DFAR 252.227-7015 (b)(2) (November 1995), as applicable in any technical data.
Edition Second edition, December 2014 Keysight Technologies, Inc. 1400 Fountaingrove Pkwy. Santa Rosa, CA 95403 USA
Technology Licenses The hardware and/or software described in this document are furnished under a license and may be used or copied only in accordance with the terms of such license.
Printed copies may not be current. Check the Knowledge Center for the latest version. www.keysight.com/find/eesof-empro-python-cookbook 2
Keysight EMPro Scripting Cookbook
Contents 1 Introduction What this Cookbook is About Learning Python
5
5
Using Python in EMPro User Interface 6 Using the Commandline Python Interpreter 7 Downloading the Recipes as a Library 9 A Guided Tour 9
2 Creating and Manipulating Geometry Expressions, Vectors and Parameters 16 Creating Wire Bodies 18 Creating Sheet Bodies 20 Recursively Traversing Geometry: Covering all Wire Bodies 21 Creating Extrusions 22 Creating Traces: Meanders
23
Creating Equation Objects: Sheet Spiral 25 Creating Bondwires with Profile Definitions
27
Flat Lists and Filtering: Getting all Bondwires 30 Using Lofting: Tapered Waveguides 32 Sweeping Paths: Thick Wire Coils 35
3 Defining Ports and Sensors Creating an Internal Port
43
Creating Waveguide Ports (FEM only) 45 Creating Rectangular Sheet Ports (FEM only) 47 User Defined Waveforms (FDTD only) 48 Importing User Defined Waveforms from CSV Files (FDTD only) 51 Adding a Far Zone Sensor 52 Adding a Planar Near Field Sensor 52 Keysight EMPro Scripting Cookbook
3
4 Creating and Running Simulations Setting Up the FDTD Grid 55 Creating an FDTD Simulation 58 Creating an FEM Simulation 59 Waiting Until a Simulation Is Completed 60
5 Post-processing and Exporting Simulation Results An Incomplete Introduction To Datasets 63 Something About Units
68
Getting Simulation Result with getResult 71 Creating XY Graphs 74 Working with CITI Files
75
Exporting to Touchstone Files 76 Exporting Surface Sensor Results
76
Directly Sampling Near Fields (FEM only) 78 Reducing Dataset Dimensions 79 Plotting Far Zone Fields 81 Multi-port Weighting 83 Maximum Gain, Maximum Field Strength 84 Integration over Time 86 Exporting Arbitrary Datasets to CSV Files
87
Exporting Surface Sensor Topology to OBJ file 91
6 Extending EMPro with Add-ons Hello World!
95
Adding Dialog Boxes: Simple Parameter Sweep 97 Extending Context Menu of Project Tree: Cover Wire Body 100
References
4
Keysight EMPro Scripting Cookbook
Keysight EMPro Scripting Cookbook
1 Introduction What this Cookbook is About Learning Python
5
5
Using Python in EMPro User Interface
6
Using the Commandline Python Interpreter 7 Downloading the Recipes as a Library 9 A Guided Tour 9
What this Cookbook is About One of the popular features of EMPro is its extensive scripting interface using the Python programming language. Users can automate many tasks, such as creating and manipulating geometries, setting up and launching simulations, processing results, and even extending the user interface with new controls. The possibilities that the Python interface provides are endless. This cookbook includes some of the code snippets written over the years in response to questions from users on how to apply Python scripting in EMPro. Consider them recipes of how to perform certain tasks. Some of them can be used verbatim, while others will serve as a starting point for your own scripts. The recipes are organized by topic in different chapters. Within each chapter, they are roughly ordered from basic to advanced. This cookbook is continuously being updated and more recipes are added. Updated versions will be posted on the Keysight Knowledge Center. www.keysight.com/find/eesof-empro-python-cookbook
Learning Python This cookbook is not an introduction into the Python programming language. There are many resources already available for learning Python programming 5
1
Introduction
basics, and we list a few below. We’ll assume you have at least basic knowledge of the language, such as its basic data structures (tuples, lists, strings, dictionaries, ...), control flow (if statements, for loops, exception handling, list comprehensions, ...), how to define functions and classes, how to use modules, and what the most common modules of the Python Standard Library are (math, os, time, ...). Whenever we use more exotic constructs, we’ll briefly mention them, usually referring to online documentation. If you’re just starting out with Python, we welcome you to the club. You’ll love it [38]! You’ll find out it’s a widely used programming language gaining much popularity, even as a replacement for MATLAB [9]. Especially for you, we’ve compiled a list of our favorite online introductions and resources: The Python Tutorial The place to start. This tutorial is part of the official Python documentation and it covers all the basics you need to know. If you’ve been through this one, you should be well prepared to understand the recipes in this book, and start writing your own scripts in EMPro. It assumes you follow the tutorial using an interactive Python interpreter, see “Using the Commandline Python Interpreter” on page 7 on how to start one. http://docs.python.org/2.7/tutorial/ Python 2.7 quick reference Written by John W. Shipman of the New Mexico Tech Computer Center, this document covers the same grounds as the tutorial, but in a reference style. It’s a good place to look up basic features, without submerging yourself in the legalese of the official language reference. http://infohost.nmt.edu/tcc/help/pubs/python/web/ Python Module of the Week A good overview of the Python Standard Library by Doug Hellmann. It complements the official documentation by being much more example oriented. If you want to use a module that you’ve never used before, this is a good place to start. http://www.doughellmann.com/PyMOTW/ Python Books A compilation of free online resources, this bundles links to e-books or their HTML counterpart. Zed Shaw’s Learn Python The Hard Way and Miller & Ranum’s How to Think Like a Computer Scientist are quite popular ones that should get you started. http://pythonbooks.revolunet.com/
Using Python in EMPro User Interface Python code can be executed in the EMPro user interface from the script editor, Figure 1.1. Click on View > Scripting to open the editor. Create a new script, type in your code and hit the run button. The scripts are embedded in the project files so they are available wherever you open the same project. They can also be exported and imported. See [7] for more information on how to use the script editor. Additionally, add-ons can be written that are loaded in the EMPro application per user. This means you can use them to extend the user interface independent 6
Keysight EMPro Scripting Cookbook
Introduction
1
Figure 1.1 Python Editor in EMPro User Interface
of the loaded project. More information on how to do this can be found in Chapter 6, “Exporting Surface Sensor Topology to OBJ file”.
Using the Commandline Python Interpreter EMPro also ships a commandline version of the Python interpreter. To use it, it is however required that the correct environment variables are set to support the EMPro runtime libraries. The easiest way to do that is to start it up is through the emproenv or emproenv.bat shell scripts, as shown in Figure 1.2 and Figure 1.3. c:\keysight\EMPro2012_09\win32_64\bin\emproenv.bat python /usr/local/EMPro2012_09/linux_x86_64/bin/emproenv python
Keysight EMPro Scripting Cookbook
7
1
Introduction
Figure 1.2 Commandline Python Interpreter on Windows
Figure 1.3 Commandline Python Interpreter on Linux
8
Keysight EMPro Scripting Cookbook
Introduction
1
Downloading the Recipes as a Library The recipes included in this cookbook are available as an EMPro Library. You can drag-and-drop recipes from the library onto the Project Tree to add them to your project. Then, you can open them in the Scripting editor to edit and run them. Take the following actions to load the library in EMPro: 1 Download ScriptingCookbookLibrary.zip from the Keysight Knowledge Center. www.keysight.com/find/eesof-empro-python-cookbook 2 Unarchive the downloaded file. 3 Open the Libraries window by clicking on View > Libraries. 4 In the Libraries window, click on File > Add Library..., select the unarchived library and click Add.
A Guided Tour Let’s see how you can create a new project from scratch by just using Python scripting. Start by ... creating a new project: empro.activeProject.clear()
Creating Geometry Substrate The project is going to need some geometry. Read the introduction of Chapter 2 of the cookbook, page 9 and 10. Now, create box of 20 mm by 15 mm and 2 mm high and add it to the project1 : model = empro.geometry.Model() model.recipe.append(empro.geometry.Box("20 mm", "2 mm", "15 mm")) empro.activeProject.geometry().append(model)
It will need some material to be used in simulations. Materials can be added by name from the default library. Add FR-4 to the project like so: empro.activeProject.materials().append( empro.toolkit.defaultMaterial("FR-4"))
Now set the subtrate material to FR-4. The substrate is the first part in the list, so you can access it on index 0 (zero) of geometry. Materials can be retrieved by name. You can type the following on one line, the backslash at the end of the first line indicates that the following code could not be printed on one line and that it’s continued on the next (though you can type it verbatim as well, Python does recognize the backslash too): 1 The order of the arguments for Box are width (X), height (Z) and depth (Y). So not in the XYZ order as you would expect. Ooops, an unfortunate historical mistake that cannot be corrected without breaking existing scripts.
Keysight EMPro Scripting Cookbook
9
1
Introduction empro.activeProject.geometry()[0].material = \ empro.activeProject.materials()["FR-4"]
And give the substrate also name. But before you get too tired of typing empro.activeProject.geometry() all of the time, assign the geometry object to a local variable parts that you can use as alias. parts = empro.activeProject.geometry() parts[0].name = "Substrate"
Groundplane You’ll need a groundplane as well. Do that by using a Sheet Body. In Chapter 2 of the cookbook, study the sections “Creating Wire Bodies” and “Creating Sheet Bodies”. Afterwards, copy the functions makePolyLine, makePolygon and sheetFromWireBody into a new script. Add the following code, and execute: verts = [ ("-10 mm", "-7.5 mm"), ("10 mm", "-7.5 mm"), ("10 mm", "7.5 mm"), ("-10 mm", "7.5 mm"), ] wirebody = makePolygon(verts) sheet = sheetFromWireBody(wirebody) sheet.name = "Ground" parts.append(sheet)
This time, you gave it a name before you’ve added it to the project, which works just as well. Give it a material too. Create an alias for empro.activeProject.materials() too by assigning it to a local variable mats. The groundplane is the second part added to the parts list, so it should have index 1 (one). But here, we’ll be a bit more cunning. Use the index -1 (minus one). Just like you can use negative indices with Python list, -1 will give you the last item from the parts list. Since you’ve just added the groundplane, you know it’s the last one, so use -1 to refer to it. That saves you from the trouble of keeping tab of the actual indices: mats = empro.activeProject.materials() mats.append( empro.toolkit.defaultMaterial("Cu") ) parts[-1].material = mats["Cu"]
Microstrip Now add a parameterized microstrip on top. Use a trace for that. But first you’ll have to add a parameter to the project: empro.activeProject.parameters().append("myWidth", "2 mm")
Create a wirebody for the centerline: verts = [ ("-10 mm", "0 mm"), ("10 mm", "0 mm"), ] wirebody = makePolyLine(verts)
Now you can create a model with the Trace feature: 10
Keysight EMPro Scripting Cookbook
1
Introduction model = empro.geometry.Model() trace = empro.geometry.Trace(wirebody) trace.width = "myWidth" model.recipe.append(trace) model.name = "Microstrip" parts.append(model)
Mmmh, where is it? Oops, it’s on the bottom side, as it’s created in the z = 0 plane. You need to reposition it. There are various ways of doing so—just like in the UI—but one easy way is setting its anchor point (assuming its translation is (0, 0, 0)). Since it’s the last object in the parts list, you can again use -1 as index: parts[-1].coordinateSystem.anchorPoint = (0, 0, "2 mm")
NOTE
To alter a part’s position, you manipulate its coordinateSystem. You cannot set its origin directly, but you can specify anchorPoint and translation. And since origin = anchor point + translation, you set both like so: part.coordinateSystem.translation = (0, 0, 0) part.coordinateSystem.anchorPoint = (x, y, z)
And give it a material too: parts[-1].material = mats["Cu"]
Adding Ports Let’s add ports! Browse to Chapter 3 of the Cookbook. First, you need to create a new Circuit Component Definition. Create a 75 ohm 1 V voltage source. Examine page 34 of the cookbook. It says you don’t need to add them to the project before you can use them, so don’t do that. And skip the waveform things (we’re going to do FEM later on) feedDef = empro.components.Feed("Yet Another Voltage Source") feedDef.feedType = "Voltage" feedDef.amplitudeMultiplier = "1 V" feedDef.impedance.resistance = "75 ohm"
Use this definition to create two feeds, one on each side of the microstrip. This time, we’ll write a function that will help add a port def addPort(name, tail, head, definition): port = empro.components.CircuitComponent() port.name = name port.definition = definition port.tail = tail port.head = head empro.activeProject.circuitComponents().append(port) addPort("P1", ("-10 mm", 0, 0), ("-10 mm", 0, "2 mm"), feedDef) addPort("P2", ("10 mm", 0, 0), ("10 mm", 0, "2 mm"), feedDef)
Keysight EMPro Scripting Cookbook
11
1
Introduction
Simulating Now that you have built the entire design, it’s time to simulate it. You do that by manipulating the empro.activeProject.createSimulationData() object. To save some typing, create an alias, by assigning that object to a local variable: simSetup = empro.activeProject.createSimulationData()
If the default engine in the user interface is the FDTD engine, you’ll notice some errors next to the ports because you haven’t defined proper waveforms. That’s OK, since you’ll do an FEM simulation. So the first thing you should do is to configure the project to use the FEM simulator: simSetup.engine = "FemEngine"
The errors should disappear. The default setup is pretty much ready to go, but for the sake of this guided tour, we’ll configure a frequency plan. Examine plage 50 of the cookbook. Since the setup already has plan, you need to clear the list first: freqPlans = simSetup.femFrequencyPlanList() freqPlans.clear()
Now you can add a new one: plan = empro.simulation.FrequencyPlan() plan.type = "Adaptive" plan.startFrequency = "minFreq" plan.stopFrequency = "maxFreq" plan.samplePointsLimit = 10 freqPlans.append(plan)
We used the parameters “minFreq” and “maxFreq”, so you should now set the parameters to the desired values. See pages 11–12 of the cookbook. params = empro.activeProject.parameters() params.setFormula("minFreq", "1 GHz") params.setFormula("maxFreq", "5 GHz")
Maybe make some more changes to the simulation settings, like these: simSetup.femMatrixSolver.solverType = "MatrixSolverDirect" simSetup.femMeshSettings.autoConductorMeshing = True
Before you can actually simulate, you must save the project. For this guided tour, we’ll avoid dealing with OpenAccess libraries, and save the project in legacy format: empro.activeProject.saveActiveProjectTo(r"C:\tmp\MyProject.ep")
You can now create and run a simulation. The function createSimulation takes a boolean parameter. If it’s True, it creates and queues the simulation. If it’s False it only creates the simulation. You almost always want it to be True: sim = empro.activeProject.createSimulation(True)
The return value is the actual simulation object, and it’s assigned to a variable sim for further manipulations. 12
Keysight EMPro Scripting Cookbook
1
Introduction
OK, now your simulation is running and you have to wait for it to end. But how do you do that programmatorically? Simple, you use the wait function! You pass the simulation object you’ve just created and it will wait for its completion, or failure. from empro.toolkit.simulation import wait empro.gui.activeProjectView().showScriptingWindow() print "waiting ..." wait(sim) print "Done!"
How do you know if the simulation has succeeded? Simple, you check its status: print sim.status
Post-processing OK, you have now a completed simulation. How do you inspect the results? Start with importing a few of the modules from the toolkit that you will need: from empro.toolkit import portparam, dataset, graphing, citifile
If you have a simulation object like you’ve created before, you can grab the entire S matrix, and plot it like this: S = portparam.getSMatrix(sim) graphing.showXYGraph(S)
If you don’t have the simulation object, but you know the simulation ID, you can use that as well. For example: portparam.getSMatrix(sim='000001'), or simply portparam.getSMatrix(sim='1') S is a matrix which uses the 1-based port numbers as indices. So you can also
plot individual S parameters: graphing.showXYGraph(S[1, 2])
You can also export the S parameters as a CITI file: citifile.write("C:\\tmp\\MyProject.cti", S)
You can also get individual results using the getResult function from the empro.toolkit.dataset module. It takes quite a few parameters, but there’s an easy way to get the desired syntax: look if you can find the result in the Result Browser, right click on it, and select Copy Python Expression, as shown in Figure 1.4. Then paste it in your script. Use this technique to copy the expression for the input impedance of port one (the simulation number 14 will be different in your case): z = empro.toolkit.dataset.getResult(sim=14, run=1, object='P1', result='Impedance') graphing.showXYGraph(z)
Keysight EMPro Scripting Cookbook
13
1
Introduction
Figure 1.4 Copying the getResult expression for a result available in the Result Browser
14
Keysight EMPro Scripting Cookbook
Keysight EMPro Scripting Cookbook
2 Creating and Manipulating Geometry Expressions, Vectors and Parameters 16 Creating Wire Bodies 18 Creating Sheet Bodies 20 Recursively Traversing Geometry: Covering all Wire Bodies 21 Creating Extrusions
22
Creating Traces: Meanders
23
Creating Equation Objects: Sheet Spiral 25 Creating Bondwires with Profile Definitions
27
Flat Lists and Filtering: Getting all Bondwires 30 Using Lofting: Tapered Waveguides 32 Sweeping Paths: Thick Wire Coils 35
Creating new geometry is one of the more popular uses of EMPro’s scripting facilities. Going from importing third party layouts or CAD, to creating complex parametrized parts, this chapter is all about manipulating empro.activeProject.geometry(). Any distinct object in the project’s geometry is called a Part. As shown in Figure 2.1, different kinds of parts exist such as Model, Sketch, and Assembly. The last one, Assembly, is a container of other parts and as such the project’s geometry is a tree of parts. In fact, empro.activeProject.geometry() is just an Assembly too, and will also be called the root part hereafter. Model is the workhorse of the geometry modeling. It will be used for about any part you’ll create, the notable exception being wire bodies for which Sketch is used. A Model basically is a recipe of features: a flat list of operations that describe how the model must be build. Extrude, Cover, Box, Loft are all
examples of such features. This way of constructing geometry is called feature-based modeling (FBM). In the recipes that follow in this chapter, many Model parts will be created and it usually follows the following pattern: 1 A new model is created. 15
2
Creating and Manipulating Geometry
Extrude
Model
+recipe 1
Recipe
Cover
Box 0..*
Part
Sketch
0..*
Assembly
Feature
Loft Pattern
Transform … Figure 2.1 Geometry Class Diagram
2 Features are added to its recipe. 3 The model is appended to an assembly. Here’s an example that creates a box and adds it to the project’s root assembly: model = empro.geometry.Model() model.recipe.append(empro.geometry.Box("1 cm", "2 cm", "3 cm")) empro.activeProject.geometry().append(model)
Expressions, Vectors and Parameters Expressions In the user interface of EMPro, almost anywhere where you can enter a quantity (a length, a frequency, a resistance, ...), it is allowed to enter a full expression with parameters, operators, functions and units [4]. The same expressions are found on the scripting side as Expression objects. That’s why you see a lot of functions accepting or returning expressions instead of floats as you might have expected. You can construct them from a string (formulas), float, int or another Expression object: a b c d
= = = =
empro.core.Expression("2 * 1 cm") empro.core.Expression(3.14) empro.core.Expression(42) b * a / 36
In practice however, you won’t be using the Expression constructor much in your scripts, as strings, floats and integers are usually implicitly converted. But bear in mind that float and int arguments are always interpreted in reference units. For example, Box takes three Expression parameters for the width, height and depth. But in the following code fragment, only the width argument 16
Keysight EMPro Scripting Cookbook
2
Creating and Manipulating Geometry
is an explicit expression argument. The height argument is a string automatically interpreted as an expression formula, and the depth argument is a float which automatically interpreted in meters1 . And yet, this still creates a cube of 1 × 1 × 1 cm: from empro import core, geometry box = geometry.Box(core.Expression("1 cm"), "1 cm", .01)
NOTE
NOTE
Quantities specified without units—as float, int, or as str without unit—are always interpreted in reference units, see Table 5.1.
It is important to realize that the mini-language of Expression is totally separate of your Python scripts: expression parameters are no Python variables or vice-versa, Python functions cannot be used in expressions. See below how to work with expression parameters in Python.
Vectors 3D position and directions are often represented by empro.geometry.Vector3d, which is an (x, y, z) triple of Expression objects. Again, you can often omit the explicit Vector3d constructor and just pass a tuple of three values or expressions. As an illustration of this flexibility, the following snippet constructs a line segment from (1, 2, 3) cm to (4, 5, 6) cm, passing the former using an explicit Vector3d instance and the latter as a simple tuple: from empro import core, geometry segment = geometry.Line( geometry.Vector3d(core.Expression("1 cm"), "2 cm", .03), (core.Expression("4 cm"), "5 cm", .06))
Vectors also support basic operations: a = empro.geometry.Vector3d(1, 2, 3) b = empro.geometry.Vector3d(4, 5, 6) c = a + 2 * b
2D directions and positions are likewise served by Vector2d.
Parameters To create parameters usable in Expression objects, you must append their name and formula to the parameters list of the active project, optionally adding a comment. 1 There’s nothing particular about using a string for the height and a float for the depth. Any of the three parameters can be specified as expressions, strings or floats. This is just an example
Keysight EMPro Scripting Cookbook
17
2
Creating and Manipulating Geometry from empro import activeProject activeProject.parameters().append("foo", "1 cm + 4 dm") activeProject.parameters().append("baz", "2 GHz", "This is Baz")
To update a parameter, you set a new formula: activeProject.parameters().setFormula("foo", "2 cm")
Getting the current formula goes as follows: print activeProject.parameters().formula("baz")
Whenever you want to evaluate a parameter to a Python float, you simply feed it into an Expression and convert it to a float: dt = float(empro.core.Expression("timestep"))
The following code snippet will print all parameters available in the project, together with their current formula and floating-point value: for name in empro.activeProject.parameters().names(): formula = empro.activeProject.parameters().formula(name) value = float(empro.core.Expression(name)) print "%(name)10s = %(formula)-20s = %(value)s" % vars()
NOTE
To illustrate Python variables and expression parameters really have no relationship, try to add a variable as a parameter and then try to change the variable: buzz = 1 empro.activeProject.parameters().append("buzz", buzz) buzz = 2 print buzz, "!=", float(empro.core.Expression("buzz"))
When doing so, the parameter won’t change accordingly, so this will output 2 != 1.0.
Creating Wire Bodies Most geometrical modeling starts with a wire body. Sometimes they’ll exist on their own as thin wire models of dipole antennas (FDTD only), but usually they’re needed as the profile of a sheet body, or as the cross section of an extrusion. What is known as a Wire Body in the user interface, is known as a Sketch in the Python API. It consists of a number of edges that must be added to the sketch. Different kind of Edge elements exist such as Line, Arc, LawEdge, ...
Creating a Single Line Segment Here’s a very simple example that creates a single line segment. Start by importing the geometry module so you don’t need to type the empro prefix all of the time. Then create a new Sketch and give it a name. A single Line edge is 18
Keysight EMPro Scripting Cookbook
2
Creating and Manipulating Geometry
added between two (x, y, z) triples, commonly known as the tail and head. Finally, the sketch is added to activeProject. from empro import geometry sketch = geometry.Sketch() sketch.name = "my sketch" sketch.add(geometry.Line((0, 0, 0), ("1 mm", "2 mm", "3 mm"))) empro.activeProject.geometry().append(sketch)
Creating Polylines and Polygons Adding adjacent line segments to the sketch will result in a polyline or, when closed, a polygon. Recipe 2.1 shows two functions that can help with this. Both take a series of vertices that need to be connected, saving you from needing to duplicate the shared vertices of adjacent line segments. As an example, a rectangle is created in the XY plane, centered around the origin. makePolyline is a straight forward extension of the example above, connecting all vertices. It takes two optional arguments sketch and name: by passing in an existing Sketch, you can append to it instead of creating a new one.
Each line segment connects two vertices. An interesting way to extract the tails and heads of individual line segments is demonstrated on line 12, using a bit of slicing [10]. Suppose there are n vertices, then there are n − 1 line segments. vertices[:-1] yields all but the last of the vertices, and thus the n − 1 required segment tails. vertices[1:] yields all but the first of the vertices, and thus the n−1 required segment heads. zipping both gives us the n−1 required (tail, head) pairs. makePolygon cunningly reuses makePolyline by observing that a polygon can be created as a closed polyline, the first vertex being repeated as the last. Because a single vertex cannot be concatenated to a list, the single-element slice vertices[:1] is used instead.
NOTE
None is great to use as default value for function parameters: if no argument for the parameter is specified, it will have the value None. Because None evaluates to False in Boolean tests, you can easily check if the argument has a valid non-default value: if name: sketch.name = name
None is also often used as a substitute for the real default value. For example, if you want the default name to be “Polyline”, you can still use None as default value and use the following recurring idiom using an or clause: sketch.name = name or "Polyline"
Unlike you might expect, this does not result in sketch.name to be True or False. Instead x or y yields either x or y [16]: # z = x or y z = x if x else y
If name is an actual string, it will be assigned to sketch.name. But if name is Name, "Polyline" will be assigned instead. Keysight EMPro Scripting Cookbook
19
2
Creating and Manipulating Geometry
This idiom can also be used to replace a argument by an actual default value, if it was unassigned: sketch = sketch or geometry.Sketch()
There’s one caveat: using the fact that None evaluates to False means that 0, empty strings, empty lists, or anything that evaluates to False will be considered as an invalid argument and be replaced by the default value. This is usually acceptable, but if you want to avoid that, you should explicitly test if the argument is not None [31]: # "" evaluates to False will also be replaced by "Polyline" sketch.name = name or "Polyline" # "" will be accepted and not replaced sketch.name = name if name is not None else "Polyline"
Recipe 2.1 PolylineAndPolygon.py def makePolyLine(vertices, sketch=None, name=None): """ - vertices: sequence of (x,y,z) coordinates to be connected. - sketch [optional]: if given, append polyline to it. Otherwise a new Sketch will be created. - name [optional]: name of sketch """ from empro import geometry sketch = sketch or geometry.Sketch() if name: sketch.name = name for tail, head in zip(vertices[:-1], vertices[1:]): sketch.add(geometry.Line(tail, head)) return sketch def makePolygon(vertices, sketch=None, name=None): return makePolyLine(vertices + vertices[:1], sketch=sketch, name=name) # --- example --if __name__ == "__main__": width = 0.20 height = 0.10 vertices = [ (-width/2, -height/2, 0), (+width/2, -height/2, 0), (+width/2, +height/2, 0), (-width/2, +height/2, 0) ] empro.activeProject.geometry().append( makePolygon(vertices, name="my rectangle"))
Creating Sheet Bodies Sheet bodies are very simple to construct as they are basically covered wire bodies. Recipe 2.2 demonstrates the straight forward function sheetFromWireBody that accomplishes this task. It takes one argument wirebody which is the Sketch to be covered. An optional name argument allows to specify a name. As a prime example of the FBM techniques used in EMPro, it 20
Keysight EMPro Scripting Cookbook
2
Creating and Manipulating Geometry
creates a new Model with exactly one feature: the Cover. The wirebody is cloned as ownership will be taken, and you want the original unharmed. The example reuses makePolygon of Recipe 2.1 to create one wire body with two rectangles, the second enclosed in the first. This way, you can create sheet bodies with holes. Recipe 2.2 SheetFromWireBody.py def sheetFromWireBody(wirebody, name=None): ''' Creates a Sheet Body by covering a Wire Body. A new Model is returned. wirebody is cloned so the original is unharmed. ''' from empro import geometry model = geometry.Model() model.recipe.append(geometry.Cover(wirebody.clone())) model.name = name or wirebody.name return model # --- example --if __name__ == "__main__": from PolylineAndPolygon import makePolygon width1 = 0.20 height1 = 0.10 vertices1 = [ (-width1 / 2, -height1 / 2, 0), (+width1 / 2, -height1 / 2, 0), (+width1 / 2, +height1 / 2, 0), (-width1 / 2, +height1 / 2, 0) ] wirebody = makePolygon(vertices1) width2 = 0.15 height2 = 0.05 vertices2 = [ (-width2 / 2, -height2 / 2, 0), (+width2 / 2, -height2 / 2, 0), (+width2 / 2, +height2 / 2, 0), (-width2 / 2, +height2 / 2, 0) ] wirebody = makePolygon(vertices2, sketch=wirebody) # append sheet = sheetFromWireBody(wirebody, name="my sheet") empro.activeProject.geometry().append(sheet)
Recursively Traversing Geometry: Covering all Wire Bodies Suppose you’ve imported a PCB and all the traces are imported as wire bodies so you only have their outlines. That’s a problem because you cannot simulate them as such. First you must cover the outlines as sheet bodies to get the full traces. Recipe 2.3 demonstrates how to replace all wire bodies in the project by equivalent sheet bodies. coverAllWireBodies is a simple recursive function that iterates over all parts in an assembly. If it finds another Assembly, it simply descends into it by calling coverAllWireBodies again with the new assembly Keysight EMPro Scripting Cookbook
21
2
Creating and Manipulating Geometry
as argument. If it encounters a Sketch, it’ll use sheetFromWireBody of Recipe 2.2 to create an equivalent Sheet Body. Using the index from enumerate [23], the existing Wire Body is simply replaced. Recipe 2.3 CoverWireBodies.py def coverAllWireBodies(assembly=None): ''' Covers all Wire Bodies found in assembly and it's sub-asssemblies. if assembly is None, empro.activeProject.geometry() will be used. ''' from SheetFromWireBody import sheetFromWireBody assembly = assembly or empro.activeProject.geometry() for (index, part) in enumerate(assembly): if isinstance(part, empro.geometry.Assembly): coverAllWireBodies(part) elif isinstance(part, empro.geometry.Sketch): assembly[index] = sheetFromWireBody(part) # --- example --if __name__ == "__main__": coverAllWireBodies()
Creating Extrusions Creating extrusions is much like creating sheet bodies, except that Extrude needs an additional direction and distance. Recipe 2.4 shows extrudeFromWireBody, in similar fashion as in Recipe 2.2.
NOTE
When extruding a sketch, it’s best to construct it entirely in the XY-plane. So use z = 0 for all of its vertices. Otherwise you may get some unexpected results. Compare this to extrude operations in the UI where the cross section is really a 2D sketch.
Recipe 2.4 ExtrudeFromWireBody.py def extrudeFromWireBody(wirebody, distance, direction=(0,0,1), name=None): ''' Creates an extrusion. A new Model is returned. wirebody is cloned so the original is unharmed. ''' from empro import geometry model = geometry.Model() model.recipe.append(geometry.Extrude(wirebody.clone(), distance, direction)) model.name = name or wirebody.name return model # --- example --if __name__ == "__main__": from PolylineAndPolygon import makePolygon width1 = 0.20
22
Keysight EMPro Scripting Cookbook
2
Creating and Manipulating Geometry height1 = 0.10 vertices1 = [ (-width1 / 2, -height1 / 2, 0), (+width1 / 2, -height1 / 2, 0), (+width1 / 2, +height1 / 2, 0), (-width1 / 2, +height1 / 2, 0) ] wirebody = makePolygon(vertices1) extrude = extrudeFromWireBody(wirebody, name="my extrude") empro.activeProject.geometry().append(extrude)
Creating Traces: Meanders Creating traces is also similar to creating sheet bodies. Both start from a Sketch, and both generate a single flat surface. But in case of a trace, the Sketch represents the centerline rather than the outline. And if the centerline is polygonal, you can reuse makePolyline of “Creating Polylines and Polygons” on page 19 to create the Sketch. Recipe 2.5 shows makeMeander that generates a meandering trace in the XYplane, as shown in Figure 2.2, so that the endpoints of the meander are (0, 0, 0) and (length, 0, 0). makeMeander starts by sanitizing the arguments by evaluating everything to floating point values. Whatever the caller specifies as argument—a float, an int, an Expression object or an expression as str, the function will continue to work with the floating point representation. Before casting them to a float, each argument is first converted to an Expression so that strings are correctly
interpreted as expressions. Attempting to directly cast a string to a float will fail for expressions like "a * b" or "1 cm". For more information, see “Parameters” on page 17. The default value for pitch is twice the traceWidth, but you can’t specify that as such in the function definition. Instead, the default value for pitch is None, and it is later replaced by twice the traceWidth. See note on page 19 for more details on this technique. Next up is calculating how many meanders can fit between start and end—minus the minimal leads—and how long each meander should be to achieve the desired total length. To create the actual centerline, a helper function x(k) will assist calculating the x-coordinates of the vertices. In Python you can nest function inside other functions as many times you want, and they see the variables of the surrounding scope. Each meander consists of two vertices with the same y-coordinate. Depending on whether this is an even or odd meander—the modulo operation k % 2 returns the remainder of dividing k by 2—the meander extends in the positive or negative y -direction. The x-coordinate of the second vertex is equal to the first vertex of the next meander. Keysight EMPro Scripting Cookbook
23
2
Creating and Manipulating Geometry
Figure 2.2 Meandering trace parameters
Finally, makePolyline of Recipe 2.1 is reused to create the centerline, which is used to create the Trace object. Recipe 2.5 Meander.py def makeMeander(length, totalLength, traceWidth, pitch=None, minimalLeadLength=0, name=None): ''' Creates a Meandering trace in XY plane, along the X-axis. - length: distance between end-points of meander along x-axis. So end-points will be (0,0,0) and (length,0,0) - totalLength: total length of the trace's centerline (or electrical length) - traceWidth: width of the trace to be generated - pitch: center-to-center distance between meanders. By default this is the double of traceWidth - minimalLeadLength: - name: name of model to be created, by default this is "Meander" ''' from empro import core, geometry import math # pitch can be None, in which case the double trace width is used. pitch = pitch or (2 * core.Expression(traceWidth)) # clean-up parameters length = float(core.Expression(length)) totalLength = float(core.Expression(totalLength)) pitch = float(core.Expression(pitch)) minimalLeadLength = float(core.Expression(minimalLeadLength)) # how much space do we actually have for the meander? availableLength = length - 2 * minimalLeadLength # how many meanders fit? if availableLength < pitch: # not even enough space for one meander # but do one anyway. n = 1 else: n = int(math.floor(availableLength / pitch)) # actual lead length leadLength = (length - n * pitch) / 2 # how much do we extend from the center line?
24
Keysight EMPro Scripting Cookbook
Creating and Manipulating Geometry
2
meanderExtent = (totalLength - length) / (2 * n) # make the actual centerline. def x(k): return leadLength + k * pitch verts = [(0, 0), (x(0), 0)] # starting lead # n meanders for k in range(n): isEven = k % 2 == 0 y = meanderExtent if isEven else -meanderExtent verts += [(x(k), y), (x(k+1), y)] verts += [(x(n), 0), (length, 0)] # ending lead centerLine = makePolyLine(verts) # finally, make the trace trace = geometry.Trace(centerLine) trace.width = traceWidth model = geometry.Model() model.name = name or "Meander" model.recipe.append(trace) return model if __name__ == "__main__": meander = makeMeander(length="1 cm", totalLength="10 cm", traceWidth=".5 mm", minimalLeadLength=".1 cm") empro.activeProject.geometry().append(meander)
Creating Equation Objects: Sheet Spiral
Another way to create single surface parts is to use a model with an Equation feature. This creates surfaces parameterized in u and v . Recipe 2.6 shows a function that uses this to create a sheet spiral of Figure 2.3 with the following
Figure 2.3 Sheet as spiral antenna, using an Equation
Keysight EMPro Scripting Cookbook
25
2
Creating and Manipulating Geometry
equation: x (u, v) = ku cos (2πu) y (u, v) = ku sin (2πu) z (u, v) = v
In these equations, v is in the direction of the width of the strip, so it goes from width − width 2 to + 2 . u = 1 is a full turn, so that k must be the pitch. makeSheetSpiral starts on line 5 by sanitizing the arguments that can be
EMPro expressions, and evaluates them as floating point values, just like before. What follows is the usual tandem of first creating a Model object on line 11, and adding to it a Equation feature on line line 38. You just need to supply three strings for the x, y and z functions, and 4 values for minimum and maximum u and v —which can be integers, floating point values, Expression options or just expression strings. The tricky bit about Equation is that the equations are evaluated in modeling units. What does that mean? Well, say that the modeling unit in millimeter. If minimum and maximum u is 0 mm and 10 mm, then the u will go from 0 to 10 and sin (2πu) will go through 10 revolvements. Pretty much as you expect. But if minimum and maximum u is in meters from 0 m to 10 m, then u will actually go from 0 to 10000! This is especially surprising if you enter a unitless value for uMin and uMax like floating point values 0 and 10, because these are interpreted in the reference unit meter. The solution to that problem, is to introduce a multiplier c that can scale u and v back to reference units. And you get that multiplier by asking the size of the modeling unit in reference units. The x, y and z equations can only have u and v parameters—EMPro parameters are not supported in these equations—so you need to substitute k and c in the equations using string formatting. Here, the trick with vars() is used to use the local variable names in the format string. Recipe 2.6 SheetSpiral.py def makeSheetSpiral(numTurns, width, pitch, name=None): from empro import core, geometry import math # Clean up arguments numTurns = int(core.Expression(numTurns)) width = float(core.Expression(width)) pitch = float(core.Expression(pitch)) # Create a new model model = geometry.Model() model.name = name or "Sheet Spiral" # Ranges of u and v, using sequence unpacking to assign # two variables at once. # Using floating point numbers means that values will # be interpreted in reference units (mete). uMin, uMax = 0, numTurns vMin, vMax = -width / 2, width / 2 # In x, y and z equations, u and v will be in modeling units
26
Keysight EMPro Scripting Cookbook
2
Creating and Manipulating Geometry # # # # # # # # c
This means that if uMin and uMax is 0 and 1 (meter), and if modeling unit is millimeter, then u will actually range from 0 to 1000. This is fine for linear equations (x, y and z are interpreted in millimeters too), but when using that for a number of revolvements, we need to compensate by scaling it down. So c is a multiplier (.001 in case of millimeter) to convert u and v back to reference units. = model.unit.toReferenceUnits(1)
k x y z
= = = =
pitch "%(k)s * u * cos(2 * pi * u * %(c)s)" % vars() "%(k)s * u * sin(2 * pi * u * %(c)s)" % vars() "v"
# now you know everything, just add the equation and return. model.recipe.append(geometry.Equation(x, y, z, uMin, uMax, vMin, vMax)) return model width = "5 cm" pitch = "5 cm" numTurns = 3 spiral = makeSheetSpiral(numTurns, width, pitch, name="My Spiral") empro.activeProject.geometry().append(spiral)
Creating Bondwires with Profile Definitions EMPro supports a Bondwire feature that easily allows placing multiple bondwires sharing a single profile definition. While the user interface has a nice dialog to create the profile definitions, here it is demonstrated how you can do the same using scripting. An n-segment profile has n + 1 vertices. The first and the last vertex are the begin- and endpoint of the individual bondwire, so you’re left to define the n − 1 vertices in the vertical plane in between [5]. Each vertex needs a pair of offsets (∆t, ∆z). Each offset can have a different reference and type (Table 2.1 and Table 2.2). addBondVertex has the task of adding individual vertices to the profile. Next to the BondwireDefinition to be modified, it takes the six arguments required to
define a single vertex. The added bonus of the function is that it will check the offset type and reference arguments for validity. checkValue compares their value against the TYPES and REFERENCES tuples and raises a ValueError if they Table 2.1 Bondwire Definition Offset References Value
UI name
Description
"Begin" "Previous"
Begin Previous
"End"
End
Referenced to the begin point of the bondwire. Referenced to the previous vertex of the profile, or to the begin point of the bondwire if this is the first vertex of the profile. Referenced to the end point of the bondwire and in the opposite direction (from end to beginning).
Keysight EMPro Scripting Cookbook
27
2
Creating and Manipulating Geometry Table 2.2 Bondwire Definition Offset Types Value
UI name
Description
empro.units.LENGTH empro.units.SCALAR
Length Proportional
empro.units.ANGLE
Angular
Absolute length or height. Proportional to horizontal length of bondwire instance. An angular relationship so that ∆z/∆t = tan θ. Can be used for either the horizontal or vertical offset, but not for both.
don’t match. Because checkValue also returns the value, it’s easy to insert the check in the assignments. makeJEDECProfile constructs a standard four-segment JEDEC profile [3]. Only the α, β and h1 parameters are used, as h2 is specific to each bondwire instance and d is implicitly available as the scale for the proportional offsets.
The first vertex to be inserted is the most tricky one. You know its height is ∆z0 = h1 , but it may be an absolute height or proportional to d. Therefore, the following convention is used: if h1 is an expression with a length unit, assume it’s an absolute height. If it has any other unit class, or if it lacks a unit, assume it is proportional. The unit class can simply be queried on an Expression, but since h1 can also be an int, float or string, convert it to an Expression object first (line 44). More information about unit classes can be found in “Unit Class” on page 68. It is also known that the first segment makes an angle α with the horizontal plane. Since you’ve already set the vertical offset, use the angular specification for the horizontal one: ∆t0 = α. The second segment is a horizontal line with length d/8, so insert a vertex referenced to the previous one, and with a proportional ∆t1 = 12.5% and ∆z1 = 0. The final segment has a horizontal length of d/2, so reference the third vertex from the end with ∆t2 = 50%. Its height should have the angular offset ∆z2 = β .
NOTE
When adding a BondwireDefinition to activeProject, a copy is made. When using the definition of making bondwires, it’s best to make sure you’re using that copy, and not the original definition you’ve created. So that’s why on line 68, the definition that was just added is retrieve back.
Recipe 2.7 BondwireDefinition.py def addBondVertex(bondDef, horOffset, horType, horReference, vertOffset, vertType, vertReference): ''' add one vertex to bondwire Definition: - bondDef: instance of BondwireDefinition to be modified in place - horOffset, vertOffset: horizontal and vertical offset as an expression - horType, verType: how the horizontal or vertical offset must be
28
Keysight EMPro Scripting Cookbook
2
Creating and Manipulating Geometry interpreted as an absolute offset in the instantiated bondwire. One of empro.units.SCALAR (=Proportional), empro.units.LENGTH (=Length) or empro.units.ANGLE (=Angular) - horReference, vertReference: the basepoint for the horizontal or vertical offset. One of "Begin" (=begin point of bondwire), "Previous" (=previous vertex) or "End" (=end point of bondwire and offset is in the opposite direction) ''' from empro import units, geometry def checkValue(value, name, allowedValues): if not value in allowedValues: raise ValueError("%(name)s must be one of " "%(allowedValues)r. Got %(value)r " "instead" % vars()) return value TYPES = (units.SCALAR, units.LENGTH, units.ANGLE) REFERENCES = ("Begin", "Previous", "End") vert = geometry.BondwireVertex(horOffset, vertOffset) vert.tUnitClass = checkValue(horType, "horType", TYPES) vert.zUnitClass = checkValue(vertType, "vertType", TYPES) vert.tReference = checkValue(horReference, "horReference", REFERENCES) vert.zReference = checkValue(vertReference, "vertReference", REFERENCES) bondDef.append(vert) def makeJEDECProfile(alpha, beta, h1, name="JEDEC", radius="0.5 mil", numberOfSides=6): from empro import core, units, geometry # figure out if h1 should be Proportional or a Length. h1Type = core.Expression(h1).unitClass() if h1Type != units.LENGTH: h1Type = units.SCALAR bondDef = geometry.BondwireDefinition(name, radius, numberOfSides) addBondVertex(bondDef, alpha, units.ANGLE, "Begin", h1, h1Type, "Begin") addBondVertex(bondDef, "12.5 pct", units.SCALAR, "Previous", "0", units.SCALAR, "Previous") addBondVertex(bondDef, "50 pct", units.SCALAR, "End", beta, units.ANGLE, "End") return bondDef # --- example --if __name__ == "__main__": bondDef = makeJEDECProfile("60 deg", "15 deg", "30 pct", name="My JEDEC") # hack: add bonddef to activeProject and get it back empro.activeProject.bondwireDefinitions().append(bondDef) bondDef = empro.activeProject.bondwireDefinitions()[-1] # ok, let's add an instance now bond = empro.geometry.Model() bond.recipe.append(empro.geometry.Bondwire((0, 0, ".2 mm"), ("2 mm", 0, 0), bondDef)) empro.activeProject.geometry().append(bond)
Keysight EMPro Scripting Cookbook
29
2
Creating and Manipulating Geometry
Flat Lists and Filtering: Getting all Bondwires Flat Lists In Recipe 2.2, recursion was used to traverse the part hierarchy. There is an alternative approach if you’re not interested in the exact part node within the tree: flat part lists. Any Assembly—and thus also empro.activeProject.geometry()—has the flatList method to request a single list of all parts in the assembly, including the parts of its sub-assemblies (and their sub-assemblies, all the way down2 ). It takes exactly one Boolean argument: whether or not to include the sub-assemblies themselves in the list. You usually want to set it to False. Load the “QFN Package” example. It consists of a number of assemblies and one extrude. First, you simple iterate over the root assembly, and you print the type and name of each part encountered: for part in empro.activeProject.geometry(): print type(part), part.name
You’ll get the following output:
'empro.libpyempro.geometry.Assembly'> cond 'empro.libpyempro.geometry.Assembly'> cond2 'empro.libpyempro.geometry.Assembly'> diel 'empro.libpyempro.geometry.Assembly'> diel2 'empro.libpyempro.geometry.Assembly'> pcvia1 'empro.libpyempro.geometry.Assembly'> pcvia2 'empro.libpyempro.geometry.Assembly'> pcvia3 'empro.libpyempro.geometry.Assembly'> Bondwire 'empro.libpyempro.geometry.Model'> Board
Repeat the exercise on the flat list, passing True as argument: for part in empro.activeProject.geometry().flatList(True): print type(part), part.name
The output you’ll will look as following. This is only a snippet and the order in which the parts are printed may vary—flatList does not return the parts in the same order as they appear in the tree—but you can already notice parts like bw1 which exist in the Bondwire assembly.
'empro.libpyempro.geometry.Assembly'> pcvia3 'empro.libpyempro.geometry.Assembly'> cond2 'empro.libpyempro.geometry.Assembly'> Bondwire 'empro.libpyempro.geometry.Model'> 'empro.libpyempro.geometry.Model'> 'empro.libpyempro.geometry.Model'> Board 'empro.libpyempro.geometry.Model'> 'empro.libpyempro.geometry.Model'> bw1
Try that again, but now pass False as argument: for part in empro.activeProject.geometry().flatList(False): print type(part), part.name 2 Or up, depending how you look at it. We’re used to draw part trees with the root node at the top and leaf nodes at the bottom, but the nomenclature suggests otherwise. We’ll stick to the top-down representation.
30
Keysight EMPro Scripting Cookbook
2
Creating and Manipulating Geometry
You’ll get similar output, but notice that the assemblies are no longer listed. This is the mode in which you’ll usually want to use it.
'empro.libpyempro.geometry.Model'> 'empro.libpyempro.geometry.Model'> 'empro.libpyempro.geometry.Model'> Board 'empro.libpyempro.geometry.Model'> 'empro.libpyempro.geometry.Model'> bw1 'empro.libpyempro.geometry.Model'> 'empro.libpyempro.geometry.Model'> 'empro.libpyempro.geometry.Model'>
Filtering Once you have a flat list of all parts, it’s easy to filter them as well using a list comprehension [15]. So let’s put that knowledge to some practical use. Recipe 2.8 shows a function that returns all bondwire parts that exist in the project, no matter how deeply they are buried in the part hierarchy. When simply executing the script, it will print a list of parts in similar fashion as above, and you can see it only shows the bondwire parts indeed:
'empro.libpyempro.geometry.Model'> 'empro.libpyempro.geometry.Model'> 'empro.libpyempro.geometry.Model'> 'empro.libpyempro.geometry.Model'>
bw3 bw4 bw2 bw1
The hart—and return value—of allBondwires is the list comprehension on line 9. It filters the flat list based on a predicate isBondwire, which simply accepts one part and returns True if it is indeed a bondwire. The way this works is pretty simple. All bondwires are Model parts that have a recipe with a Bondwire as first feature. isBondwire first grabs the recipe but guards this in a try/except AttributeError clause because not all parts have recipes (assemblies, sketches, ...) in which case it simply returns False. Once it has the recipe, it simply checks if the first feature is a Bondwire.3 With this function, you can for example easily replace the bondwire definition of all bondwires in the project. Assuming you’ve created a bondwire definition like in “Creating Bondwires with Profile Definitions” on page 27, you can do: for part in allBondwires(): part.recipe[0].definition = bondDef
There is of course an endless variation of possible predicates that can be used. One very easy thing to do is filtering on part names. The following gives you a list of all parts with names starting with bw, which in this case is the same list of bondwires: [part for part in empro.activeProject.geometry().flatList(False) if part.name.startswith("bw")]
Recipe 2.8 AllBondwires.py def allBondwires(assembly=None): def isBondwire(part): 3 Instead of the try/except clause to grab the recipe, you can also use isinstance to first check if the part is a Model. In Python it is however more idiomatic to ask forgiveness rather than permission, or EAFP [13].
Keysight EMPro Scripting Cookbook
31
2
Creating and Manipulating Geometry try: recipe = part.recipe except AttributeError: return False return isinstance(recipe[0], empro.geometry.Bondwire) assembly = assembly or empro.activeProject.geometry() return [part for part in assembly.flatList(False) if isBondwire(part)] # --- example --if __name__ == "__main__": for part in allBondwires(): print type(part), part.name
Using Lofting: Tapered Waveguides Simple rectangular waveguides are easily created by subtracting two boxes from each other, one for the outside and one for the inside. But what if you want to create something more complex, like an exponentially tapered waveguide as in Figure 2.4? In Recipe 2.9, it is shown how you can use lofting to create a piecewise linear approximation of such a waveguide. makeExponentialWaveguide creates a waveguide along the X-axis, from x = 0 to x =length. Therefore, it will create a number of rectangular cross sections
in the YZ-plane and connect them using lofting. This way, you get a piecewise linear approximation. The width and height of the inside of each cross section follows an exponential relationship, where s is either the y - or z -coordinate, and sbegin and send are the begin and end values of width or height:
s (x) = send exp (−α (length − x))
with
Figure 2.4 Exponentially Tapered Waveguide
32
Keysight EMPro Scripting Cookbook
2
Creating and Manipulating Geometry
α=
−1 send ln length sbegin
So each cross section is a rectangular sheet with a rectangular hole. However, the Loft feature cannot properly handle holes. To work around that, create the outer and inner rectangles of the cross sections as separate sheet bodies, and loft them separately. This is taken care of by a helper function frustum. Call that function twice to get the outer and inner volume of one waveguide section, and then use a Boolean operation to subtract the former from the latter. frustum calls makeLoft which helps creating a lofting between two faces in
general. Faces are identified using ID strings, but the ID itself is not enough. You also need to know which part the face belongs to. So, makeLoft takes as input two part references and two face ID strings. If a part has only one face, like sheet bodies, then it is not required to provide the face ID since there’s only one possibility anyway. That’s where fixFace comes in to play. It corrects the incoming face ID if necessary: • If a face ID is omitted, it will assert the part indeed had only one face and pick up its ID. • The Loft feature is very particular about face IDs to be used. It are not the ones from the original faces, but from some internal processed face. Use the form "face:LP()" where is either 1 or 2, and is the original face ID. fixFace will take care of this too. The Loft feature also stores references to the parts being connected. If you want to store copies of the original parts, use clone=True. To condense the two cases in one line, a conditional expression is used [17]. Once all sections are made, they are united in one Boolean operation on line line 67. The strange way to call uniteMulti is because it demands two arguments: the blank part and a list of tool parts. In case of a subtraction, this is logical as you subtract the tools from the blank, but for a union this seems somewhat odd. Yet, to accommodate this function call, supply the first section as the blank and all the others as the tools. Recipe 2.9 LoftingWaveguide.py def makeExponentialWaveguide(startWidth, startHeight, endWidth, endHeight, length, thickness, steps=5, name=None): ''' Creates and returns exponentially tapered waveguide along X-axis. - startWidth and startHeight: Y and Z size of inner rectangle at x=0 - endWidth and endHeight: Y and Z size of inner rectangle at x=length - length: length of waveguide along X-axis. - thickness: - steps: number of sections for approximation [default=5] - name: name of waveguide [default="waveguide"] ''' from empro.core import Expression from empro.geometry import Vector3d, Assembly, Boolean from empro.toolkit.geometry import plateYZ import math # sanitize the arguments
Keysight EMPro Scripting Cookbook
33
2
Creating and Manipulating Geometry startWidth = float(Expression(startWidth)) startHeight = float(Expression(startHeight)) endWidth = float(Expression(endWidth)) endHeight = float(Expression(endHeight)) length = float(Expression(length)) thickness = float(Expression(thickness)) steps = int(Expression(steps)) name = name or "waveguide" def frustum(x0, y0, z0, x1, y1, z1, name): ''' create two rectangular sheets and loft in between ''' sheet0 = plateYZ(Vector3d(x0, 0, 0), Expression(y0), Expression(z0), "%s-sheet0" % name) sheet1 = plateYZ(Vector3d(x1, 0, 0), Expression(y1), Expression(z1), "%s-sheet1" % name) return makeLoft(sheet0, sheet1, name=name) alphaWidth = -math.log(startWidth / endWidth) / length alphaHeight = -math.log(startHeight / endHeight) / length dx = length / steps tt = 2 * thickness # create all pieces of the waveguide and gather than in the # sections list x0, y0, z0 = 0, startWidth, startHeight sections = [] for k in range(steps): x1 = (k + 1) * dx y1 = endWidth * math.exp(-alphaWidth * (length - x1)) z1 = endHeight * math.exp(-alphaHeight * (length - x1)) # create both volumes ... inner = frustum(x0, y0, z0, x1, y1, z1, "%(name)s-inner-%(k)d" % vars()) outer = frustum(x0, y0 + tt, z0 + tt, x1, y1 + tt, z1 + tt, "%(name)s-outer-%(k)d" % vars()) # ... and subtract to get one section section = Boolean.subtract(outer, inner) section.name = "%(name)s-section-%(k)d" % vars() sections.append(section) x0, y0, z0 = x1, y1, z1 # for next round # unite all sections waveguide = Boolean.uniteMulti(sections[0], sections[1:]) waveguide.name = name or "waveguide" return waveguide def makeLoft(part1, part2, face1=None, face2=None, smoothFactor1=0, smoothFactor2=0, clone=False, name=None): def fixFace(part, face, index): if not face and len(part.faces()) != 1: raise ValueError("part%(index)d has more than one face, " "you must provide a face ID in " "face%(index)d" % vars()) face = face or part.faces()[0] prefix = "face:LP%d" % index if not face.startswith(prefix): face = "%(prefix)s(%(face)s)" % vars() return face face1 = fixFace(part1, face1, 1) face2 = fixFace(part2, face2, 2) loft = empro.geometry.Loft(face1, smoothFactor1, face2, smoothFactor2)
34
Keysight EMPro Scripting Cookbook
2
Creating and Manipulating Geometry loft.part1 = part1.clone() if clone else part1 loft.part2 = part2.clone() if clone else part2 model = empro.geometry.Model() model.name = name or "Loft" model.recipe.append(loft) return model # --- example --if __name__ == "__main__": waveguide = makeExponentialWaveguide(startWidth="20 mm", startHeight="10 mm", endWidth="60 mm", endHeight="20 mm", length="40 mm", thickness="1 mm", stepds=20) empro.activeProject.geometry().append(waveguide)
Sweeping Paths: Thick Wire Coils Suppose you want to create a model of an RFID antenna like in Figure 2.5. If all you want is a thin wire model, you can construct one using a wire body. But to get a thick wire model, you’ll need to sweep a cross section profile along a path. Recipe 2.10 will show you exactly how.
Rectangular Coils It starts with a rather lengthy function pathRectangular to create a wirebody like in Figure 2.5. This will be the path along which the cross section will be swept. Starting in the origin, it creates a sequence of adjacent edges: the head of the last becomes the tail of the next. Most of the coil is just a series of straight edges with round corners. Since there are two feed lines, at least one of them needs to cross the coil on another level. So one will need to be raised by bridgeLz. Depending on whether feedLength is positive or negative, the feeds will be on the out- or inside of the coil. This will determine whether it’ll be the first or the last feed: • The left (and first) feed always connects to the outside of the coil. So if feedLength is positive, then the right feed needs to be raised. • If negative, it should be the first feed, but since that one always starts in the origin (x = y = z = 0), the rest of the coil is lowered instead. Once the first feed is created, move to the right over a distance of feedSeparation + feedOffset. Compute a new head as if there are no rounded corners, and then use _side_with_corner to insert the straight edge + the corner. If cornerRadius is zero, then that function will simply insert a straight line between tail and head, and move on. Otherwise, it will compute an adjusted endpoint for the straight edge and then insert the rounded corner. In both cases, it will return the position to be used as the tail for the next side. Keysight EMPro Scripting Cookbook
35
2
Creating and Manipulating Geometry
Figure 2.5 Rectangular coil parameters
So it goes on for a number of turns, keeping the pitch distance between the lines. Finally it creates the second feed. crossSectionCircular creates a cross section in the XZ plane, nicely centered around the origin. It uses an sketch with a single Arc edge which is defined by three points. Its last argument is True to make a full circle.
Notice that the path starts in the origin and profile is centered around it, and that the path starts orthogonal (Y axis) to the profile (XZ plane).
NOTE
Path sweeping expects the path to start in a point within a profile, for example its center. Doing otherwise may yield unexpected results. For best results, start the sweep path in the origin, and center the profile around the same origin, in a plane orthogonal to the starting direction of the sweep path.
Once both the path and profile are created, it’s only a matter of constructing a new model that uses the SweepPath feature to combine them into a thick model of the coil. The sweep function defined in the recipe helps with that. At the end of the script, there’s an example of how all this fits together. Recipe 2.10 RectangularCoil.py def pathRectangular(numTurns=5, Lx=75e-3, Ly=44e-3, pitch=0.3e-3, bridgeLz=.5e-3, cornerRadius=0.5e-3, feedLength=-2e-3, feedOffset=3e-3, feedSeparation=4e-3, name="Coil"): ''' - crossSection: the cross section of the wire, an object returned by crossSectionRectangular or crossSectionCircular - numTurns: number of turns, integer (full turns only) - Lx: outer spiral length along x-axis (not accounting for wire
36
Keysight EMPro Scripting Cookbook
2
Creating and Manipulating Geometry thickness) - Ly: outer spiral length along y-axis (not accounting for wire thickness) - pitch: spacing between turns (not accounting for wire thickness) - bridgeLz: height of airbridge above coil - feedLength: feedline length: If positive, feeds sticks out of coil and length is measured from outside (not accounting for wire thickness) If negative, feeds is on inside of coile and length is measured from inside (not accounting for wire thickness) - feedOffset: distance from rightmost feed to right most side of spiral (not accounting for wire thickness) - feedSeparation: distance between leftmost and rightmost feed (not accounting for wire thickness) - name: name of coil, can be None ''' from empro import core, geometry # clean up arguments numTurns = int(core.Expression(numTurns)) Lx = float(core.Expression(Lx)) Ly = float(core.Expression(Ly)) pitch = float(core.Expression(pitch)) bridgeLz = float(core.Expression(bridgeLz)) cornerRadius = float(core.Expression(cornerRadius)) feedLength = float(core.Expression(feedLength)) feedOffset = float(core.Expression(feedOffset)) feedSeparation = float(core.Expression(feedSeparation)) assert int(numTurns) == numTurns, "numTurns must be integer" assert numTurns > 0 assert Lx > 0 assert Ly > 0 assert pitch > 0 if feedLength < 0: feedLength -= (numTurns - 1) * pitch halfLx, halfLy = Lx / 2, Ly / 2 numSectors = 4 path = geometry.Sketch() # first feed tail = start = geometry.Vector3d(0, 0, 0) head = geometry.Vector3d(0, feedLength, 0) path.add(geometry.Line(tail, head)) tail = head # first feed is bridge? if feedLength < 0: z = -bridgeLz head = tail + geometry.Vector3d(0, 0, z) path.add(geometry.Line(tail, head)) tail = head else: z = 0
# to first corner head = geometry.Vector3d(feedOffset + feedSeparation, feedLength, z) tail = _side_with_corner(path, tail, head, cornerRadius, numSectors - 1) # the coil itself. for k in range(numTurns): for sector in range(numSectors): dx = pitch * (k + (1 if sector >= 3 else 0)) dy = pitch * (k + (1 if sector >= 2 else 0)) sx, sy = _SECTORS[sector] y = sy * (halfLy - dy) + halfLy + feedLength isLastSide = (k == numTurns - 1) and \ (sector == numSectors - 1)
Keysight EMPro Scripting Cookbook
37
2
Creating and Manipulating Geometry if isLastSide: head = geometry.Vector3d(feedSeparation, y, z) path.add(geometry.Line(tail, head)) tail = head else: x = sx * (halfLx - dx) - halfLx + \ feedOffset + feedSeparation head = geometry.Vector3d(x, y, z) tail = _side_with_corner(path, tail, head, cornerRadius, sector) # last feed is bridge? if feedLength > 0: z = bridgeLz head = tail + geometry.Vector3d(0, 0, z) path.add(geometry.Line(tail, head)) tail = head # last feed head = geometry.Vector3d(feedSeparation, 0, z) path.add(geometry.Line(tail, head)) return path def crossSectionCircular(radius=0.05e-3): from empro import core, geometry radius = float(core.Expression(radius)) assert radius > 0 sketch = geometry.Sketch() fullCircle = True sketch.add(geometry.Arc( (radius,0,0), (0,0,radius), (-radius,0,0), fullCircle )) return sketch def sweep(crossSection, path, name=None): from empro import geometry model = geometry.Model() if name: model.name = name model.recipe.append(geometry.SweepPath(crossSection, path)) return model # --- implementation --_SECTORS = (1, 1), (-1, 1), (-1, -1), (1, -1) def _side_with_corner(path, tail, head, cornerRadius, sector): from empro.geometry import Vector3d, Line, Arc import math if not cornerRadius: path.add(Line(tail, head)) return head sx, sy = _SECTORS[sector] f = 1 - 1 / math.sqrt(2) cornerTail = head - Vector3d(sx * cornerRadius, 0, 0) cornerHead = head - Vector3d(0, sy * cornerRadius, 0) if sx == sy: cornerTail, cornerHead = cornerHead, cornerTail cornerMid = head - Vector3d(f * sx * cornerRadius, f * sy * cornerRadius, 0)
38
Keysight EMPro Scripting Cookbook
2
Creating and Manipulating Geometry Table 2.3 Approximation Policies for Polygonal Cross Sections Constant
Description
INSCRIBED
Use an inscribed polygon as approximation of the circle, the radius remains unmodified. The resulting 3D wire will have a smaller volume and smaller surface area. Use a polygon with the same area as the original circle. The resulting 3D wire will have the correct volume, but a greater surface area. Use a polygon with the same perimeter as the original circle. The resulting 3D wire will have the correct surface area, but a smaller volume.
EQUAL_AREA EQUAL_PERIMETER
fullCircle = False path.add(Line(tail, cornerTail)) path.add(Arc(cornerTail, cornerMid, cornerHead, fullCircle)) return cornerHead # --- example --if __name__ == "__main__": cross = crossSectionCircular("50 um") path = pathRectangular(numTurns=5, Lx="60 mm", Ly="40 mm", pitch="0.3 mm", bridgeLz="0.3 mm", cornerRadius="5 mm", feedLength="5 mm", feedOffset="9 mm", feedSeparation="3 mm") coil = sweep(cross, path, name="RFID") empro.activeProject.geometry().append(coil)
More Cross Sections Recipe 2.11 shows two more examples of cross sections. Both are defined in the XZ-plane, just like before. One is a simple rectangle, the other is a polygonal approximation of a circle. Apart from the number of sides and the radius of the circle to be approximated, the polygonal cross section also takes an argument that controls how the circle must be approximated: preserving the radius, area or perimeter. Therefore a number of constants is defined on line 16, their exact meaning is explained in Table 2.3. Recipe 2.11 MoreCrossSections.py def crossSectionRectangular(width=0.05e-3, thickness=0.01e-3): from empro import core, geometry width = float(core.Expression(width)) thickness = float(core.Expression(thickness)) assert width > 0 assert thickness > 0 x, z = width / 2, thickness/ 2 points = [( x, 0, -z), ( x, 0, z), (-x, 0, z), (-x, 0, -z)] # reuse makePolygon from PolylineAndPolygon.py return makePolygon(points) APPROXIMATION_POLICIES = (INSCRIBED, EQUAL_AREA,
Keysight EMPro Scripting Cookbook
39
2
Creating and Manipulating Geometry EQUAL_PERIMETER) = range(3) def crossSectionPolygonal(radius=0.05e-3, numSides=6, approximationPolicy=INSCRIBED): from empro import core, geometry import math radius = float(core.Expression(radius)) numSides = int(core.Expression(numSides)) assert radius > 0 assert numSides >= 3 assert approximationPolicy in APPROXIMATION_POLICIES pie_angle = 2 * math.pi / numSides if approximationPolicy == EQUAL_AREA: area = math.pi * (radius * radius) poly_radius = math.sqrt(2 * area / (numSides * math.sin(pie_angle))) elif approximationPolicy == EQUAL_PERIMETER: perimeter = 2 * math.pi * radius poly_radius = perimeter / (2 * numSides * math.sin(pie_angle / 2)) else: poly_radius = radius if numSides % 4 == 0: theta_offset = pie_angle / 2 else: theta_offset = 0 def vertex(k): theta = k * pie_angle + theta_offset return (poly_radius * math.cos(theta), 0, poly_radius * math.sin(theta)) sketch = geometry.Sketch() for k in range(numSides): tail, head = vertex(k), vertex((k + 1) % numSides) sketch.add(geometry.Line(tail, head)) return sketch
Spiral Coil Finally, Recipe 2.12 also presents you a function that generates a path for a circular RFID coil. The function parameters are similar to pathRectangular, but Lx and Ly are replaced by a single diameter parameter, and feedOffset is no longer used. A new parameter is discretisationAngle: unless zero, it is used to approximate the spiral by linear segments, which gives a more predictable sweeping behavior. Recipe 2.12 SpiralCoil.py def pathSpiral(numTurns, diameter, pitch, bridgeLz, feedLength, feedSeparation, discretisationAngle): from empro import geometry import math # clean up arguments numTurns = int(core.Expression(numTurns)) diameter = float(core.Expression(diameter)) pitch = float(core.Expression(pitch)) bridgeLz = float(core.Expression(bridgeLz)) feedLength = float(core.Expression(feedLength)) feedSeparation = float(core.Expression(feedSeparation)) discretisationAngle = float(core.Expression(discretisationAngle)) assert int(numTurns) == numTurns, "numTurns must be integer" assert diameter > 0 assert pitch > 0
40
Keysight EMPro Scripting Cookbook
Creating and Manipulating Geometry
2
assert feedSeparation >= 0 assert feedSeparation < (diameter - 2 * numTurns * pitch) if feedLength < 0: feedLength -= numTurns * pitch radius = diameter / 2. center = (-radius, 0, 0) sketch = Sketch() # translate seperation to angles, this is an approximation! rho = lambda theta: (radius - pitch * theta / (2 * math.pi)) halfSeperation = feedSeparation / 2 radiusStart = radius thetaStart = math.asin(halfSeperation / radiusStart) radiusEnd = radius - numTurns * pitch thetaEnd = 2 * math.pi * numTurns - math.asin(halfSeperation / radiusEnd) xOffset = -math.sin(thetaStart) * rho(thetaStart) yOffset = radius + feedLength # first feed yStart = -math.cos(thetaStart) * rho(thetaStart) + yOffset zStart = (0, bridgeLz)[feedLength < 0] tail = start = (0, 0, 0) head = (0, yStart, 0) sketch.add(geometry.Line(tail, head)) tail = head # first feed is bridge? if feedLength < 0: z = -bridgeLz head = (0, yStart, z) sketch.add(Line(tail, head)) tail = head else: z = 0 # spiral xy = lambda theta: (math.sin(theta) * rho(theta) + xOffset, -math.cos(theta) * rho(theta) + yOffset) xEnd, yEnd = xy(thetaEnd) if not discretisationAngle: # use a true spiral wire. # doesn't really work wel for the sweep though. center = (xOffset, yOffset, z) sketch.add(Spiral(center, tail, -pitch, thetaEnd - thetaStart, Spiral.Right)) tail = (xEnd, yEnd, z) else: n = int(math.ceil((thetaEnd - thetaStart) / discretisationAngle)) for k in range(1, n): theta = thetaStart + k * discretisationAngle x, y = xy(theta) head = (x, y, z) sketch.add(geometry.Line(tail, head)) tail = head head = (xEnd, yEnd, z) sketch.add(geometry.Line(tail, head)) tail = head # last feed is bridge? if feedLength > 0: z = bridgeLz head = (xEnd, yEnd, z) sketch.add(geometry.Line(tail, head)) tail = head # last feed head = (xEnd, 0, z)
Keysight EMPro Scripting Cookbook
41
2
Creating and Manipulating Geometry sketch.add(geometry.Line(tail, head)) tail = head return sketch
42
Keysight EMPro Scripting Cookbook
Keysight EMPro Scripting Cookbook
3 Defining Ports and Sensors Creating an Internal Port
43
Creating Waveguide Ports (FEM only) 45 Creating Rectangular Sheet Ports (FEM only) 47 User Defined Waveforms (FDTD only) 48 Importing User Defined Waveforms from CSV Files (FDTD only) 51 Adding a Far Zone Sensor 52 Adding a Planar Near Field Sensor 52
With only geometry, one cannot define a simulation. Excitations need to be defined to drive the simulation. Sensors need to be added to record simulation results. In this chapter, it is explored how the design geometry can be complemented with ports and sensors.
Creating an Internal Port Recipe 3.1 shows a full example of how to add an internal port. The following sections will explain it in more detail.
Circuit Components Internal ports are defined by impedance lines between two endpoints, together with some properties. To create one, a CircuitComponent with a proper CircuitComponentDefinition must be inserted in the project. Here’s a minimal example of what to do: feed = empro.components.CircuitComponent() feed.name = "My Feed" feed.definition = empro.activeProject.defaultFeed() feed.tail = (0, 0, "-1 mm") feed.head = (0, 0, "+1 mm") empro.activeProject.circuitComponents().append(feed)
43
3
Defining Ports and Sensors
This creates a component called “My Feed” between two points and adds it to the project.
Circuit Component Definitions The definition used above is a default one created by EMPro, a 50 Ω voltage source definition with a proper waveform for FDTD simulations. In real situations, you’ll create your own definition though. All circuit component definitions can be found in the empro.components module. The most commonly used one is Feed which represents a voltage or current source. Other types are PassiveLoad, ModalFeed which represents a power source to compute modal waveguide S parameters, Diode, NonlinearCapacitor and Switch. The last three are only supported for FDTD, and ModalFeed is FEM only. Here’s how to define your own voltage source: feedDef = empro.components.Feed("My Voltage Source") feedDef.feedType = "Voltage" feedDef.amplitudeMultiplier = "1 V" feedDef.impedance.resistance = "50 ohm" feedDef.waveform = empro.activeProject.defaultWaveform() # for FDTD only feed.definition = feedDef
The amplitude attribute is called amplitudeMultiplier because for FDTD it’s really used to scale a time-domain waveform (which is supposed to be normalized). The FEM engine doesn’t use waveforms, but the attribute is still called amplitudeMultiplier.
NOTE
In contrary to all other sorts of definitions, it’s not required to first add the circuit component definition to empro.activeProject.circuitComponentDefinitions() before you
can use it (see “Creating Bondwires with Profile Definitions” on page 27). You can directly use the created definition for one or more circuit components, and only add the circuit components to the project. EMPro will detect what definitions are used, and will automatically add them to the list of circuit component definitions. The same is valid for waveforms.
Waveforms (FDTD only) For FDTD simulations, Feed ports need to be driven using a time-domain waveform. In the previous example, defaultWaveform was used, which basically generates an Automatic waveform. Creating waveforms manually consists of two parts: first you create a waveform shape, then you add it to a waveform object. Here’s how you create a Modulated Gaussian waveform shape, which has two 44
Keysight EMPro Scripting Cookbook
Defining Ports and Sensors
3
parameters. All shape constructors require a string as name, but they’re not really used, so you can pass an empty string. shape = empro.waveform.ModulatedGaussianWaveformShape("") shape.pulseWidth = "10 ns" shape.frequency = "1 GHz"
Next, you create a Waveform object. This one does make proper use of the name string, so call it “My Waveform”. Then simply set the shape and assign the complete waveform the the circuit component definition. waveform = empro.waveform.Waveform("My Waveform") waveform.shape = shape feedDef.waveform = waveform
NOTE
Oddly enough, the Automatic and Step waveform shapes are not exported to Python. The former is exactly what’s returned by defaultWaveform, so you can use that instead. For the latter you’ll need to fall back to a User Defined waveform (see “User Defined Waveforms (FDTD only)” on page 48).
Recipe 3.1 InternalPort.py import empro shape = empro.waveform.ModulatedGaussianWaveformShape("") shape.pulseWidth = "10 ns" shape.frequency = "1 GHz" waveform = empro.waveform.Waveform("My Waveform") waveform.shape = shape feedDef = empro.components.Feed("My Voltage Source") feedDef.feedType = "Voltage" feedDef.amplitudeMultiplier = "1 V" feedDef.impedance.resistance = "50 ohm" feedDef.waveform = waveform feed = empro.components.CircuitComponent() feed.name = "Feed" feed.definition = feedDef feed.port = True feed.direction = "Automatic" # for FDTD feed.polarity = "Positive" feed.tail = (0, 0, "-1 mm") feed.head = (0, 0, "+1 mm") empro.activeProject.circuitComponents().append(feed)
Creating Waveguide Ports (FEM only) Creating waveguide ports from scripting seems a bit tricky at first. You need to attach the waveguide to a face of a geometrical part, and you need a face ID for that. Picking tools exist to give this information if you allow for user interaction. But if you want to do it fully automatically, knowing the right face ID is difficult. The trick around it is to create auxiliary geometry that has only one face which ID is known. The prime candidate for this task is a sheet object (or Cover), and Keysight EMPro Scripting Cookbook
45
3
Defining Ports and Sensors
that’s exactly what’s done in Recipe 3.2. On line 36, a simple rectangular sheet is created. Its size is that of the waveguide to be created. To make it transparent, set it invisible and exclude it from the mesh. Mae sure to add it to the project first. Otherwise, you won’t have mesh parameters to be modified. Different than for definitions, adding a geometry part to the project does not create a copy, so you can keep using the original one. To get the much needed face ID, simply get the first and only face of the sheet (line 47). Ports need definitions, and on line 51, a ModalFeed is created to get modal Sparameters. If you use a Voltage or Current source instead, you’ll get nodal Sparameters. Adding circuit component definitions to the project create copies, so to make sure to used the one owned by the project, retrieve the one you’ve just appended (line 55) Finally, prepare a list of (tail, head) pairs for the impedance lines and call makeWaveguide defined at the top of the script. It’s a very straight forward function. The only weird thing about it is that it sets the grid generator. It doesn’t do anything specific for FEM, but failing to set it may lead to unexpected results. Recipe 3.2 WaveguidePort.py def makeWaveguide(part, faceId, definition, modes=None, name=None): ''' creates a basic waveguide port - part: part to attach waveguide to - faceId: string identifying a face on that part - definition: a CircuitComponentDefintion from the activeProject. - modes: optional, a sequence of (tail,head) pairs, defining the impedance lines for each mode. Instead of a (tail,head) pair, a sequence item may also be None for a mode without an impedance line (only for 2D port simulation). When modes is ommitted, a single mode without impedance line will be created. - name: optional, name of the waveguide port. ''' from empro import components, activeProject wg = components.WaveGuide(name or "Waveguide Port") wg.setGridGenerator(activeProject.gridGenerator()) wg.faces = [(part, faceId)] wg.definition = definition for m in modes: mode = components.WaveGuideMode() if m is not None: mode.tail, mode.head = m wg.appendMode(mode) return wg # --- example ---if __name__ == "__main__": Vector3d = empro.geometry.Vector3d Expression = empro.core.Expression activeProject = empro.activeProject # getting a good face Id is the trickiest part. # Here we'll create a simple sheet, since it only has one face. sheet = empro.toolkit.geometry.plateXY(Vector3d(0, 0, 0), Expression("10 mm"), Expression("20 mm"), "waveguide geometry") # Excluding the sheet from the mesh to make it transparent.
46
Keysight EMPro Scripting Cookbook
3
Defining Ports and Sensors # first add it to project, otherwise we'll have no meshParameters activeProject.geometry().append(sheet) sheet.visible = False sheet.meshParameters.includeInMesh = False assert len(sheet.faces()) == 1, \ "assuming that a sheet only has one face!" faceId = sheet.faces()[0] # Use a power feed to get modal S-parameters. # Add copy to project, and retrieve it again. definition = empro.components.ModalFeed("My Power Feed") definition.sourcePower = "1 W" ccDefinitions = activeProject.circuitComponentDefinitions() ccDefinitions.append(definition) definition = ccDefinitions[len(ccDefinitions)-1] impedanceLines = [ (Vector3d("-5 mm", 0, 0), Vector3d("5 mm", 0, 0)), (Vector3d(0, "-10 mm", 0), Vector3d(0, "10 mm", 0)), ]
# create it and of course, add it to project waveguide = makeWaveguide(sheet, faceId, definition, impedanceLines, "waveguide from script") activeProject.waveGuides().append(waveguide)
Creating Rectangular Sheet Ports (FEM only) You have imported a design with a lot of internal ports, but they are all defined as simple impedance lines. You would like to change them into rectangular sheet ports of a certain width. setRectangularSheetPort of Recipe 3.3 helps to modify a circuit component to use the rectangular sheet extent. You pass it the component to be modified and the desired width of the sheet. To define a sheet port automatically, there’s one tricky problem to solve: in what geometrical plane does the sheet need to be defined? There are an infinite number of planes passing through the impedance line, and you need to pick the “best” one. The best orientation is not uniquely defined however, and therefore setRectangularSheetPort accepts a third optional argument: the zenith vector. The sheet extension will be oriented as orthogonal as possible to the zenith, while still going through the impedance line. To define a sheet extent, you need to set two corner points of the quadrilateral: one for the tail and one for the head. The other two corner points are implicitly defined by mirroring the first two across tail and head. You need an offset vector orthogonal to both the impedance line and zenith vector, so you take their cross product. The offset’s size should be half the sheet’s width, so you scale accordingly. If the impedance line is parallel to the zenith vector, then their cross product is the null vector and no proper offset can be determined. When using the default zenith vector (0, 0, 1), this will happen when applying this recipe to vertical ports. Using a vertical zenith vector causes the sheet extents to be as horizontal as possible, and this is impossible to do in case of vertical ports. As a result, setRectangularSheetPort will fail with an error message, and you should override the zenith vector with the normal vector of the plane in which you want Keysight EMPro Scripting Cookbook
47
3
Defining Ports and Sensors
to define the sheet extent: use (1, 0, 0) for the YZ plane and (0, 1, 0) for the XZ plane. Once you have all the information, you assign a new SheetExtent to the component’s extent attribute, set its both corner points, and finally you enable the useExtent flag. Recipe 3.3 RectangularSheetPort.py def setRectangularSheetPort(component, width, zenith=(0,0,1)): ''' Modify a circuit component to use a rectangular sheet port of given width (half the width on both sides of the impedance line). The algorithm will attempt to orientate the sheet as orthogonal to the zenith vector as possible. By default, zenith will be the z-axis, and the sheet will be orientated as horizontal as possible. If you want to create vertical sheet ports, you'll have to define a proper zenith vector yourself. If you want it to be YZ aligned, use zenith=(1,0,0). If you want it to be XZ aligned, use zenith=(0,1,0). ''' from empro.core import Expression from empro.geometry import Vector3d def cross(a, b): "cross product of vectors return Vector3d(a.y * b.z a.z * b.x a.x * b.y
a -
and a.z a.x a.y
b" * b.y, * b.z, * b.x)
width = float(Expression(width)) if not isinstance(zenith, Vector3d): zenith = Vector3d(*zenith) tail = component.tail.asVector3d() head = component.head.asVector3d() direction = head - tail offset = cross(direction, zenith) if offset.magnitude() < 1e-20: raise ValueError("zenith vector %(zenith)s is parallel to port " "impedance line, pick one that is orthogonal " "to it" % vars()) offset *= .5 * width / offset.magnitude() # scale to half width component.extent = empro.components.SheetExtent() component.extent.endPoint1Position = tail + offset component.extent.endPoint2Position = head + offset component.useExtent = True # --- example --if __name__ == "__main__": port = empro.activeProject.circuitComponents()["Port1"] setRectangularSheetPort(port, "0.46mm", zenith=(1,0,0))
User Defined Waveforms (FDTD only) So what if none of the available waveform shapes matches what you want? Then you make your own user defined waveform using TimestepSampledWaveformShape. The name already discloses what it is: a waveform shape made from sampled data, and its sampling rate is 1/∆t where 48
Keysight EMPro Scripting Cookbook
3
Defining Ports and Sensors
∆t is the “timestep” parameter of the FDTD simulation.
Suppose you want to create the following waveform a (t) which looks suspiciously a lot like the Step Waveform, where Tr is the 10%–90% rise time and t0 is the offset time: ( ( )2 ) t−t0 1 − exp −1.423 Tr ⇔ t > t0 a (t) = 0 ⇔ t ≤ t0 So you need to evaluate your waveform function in equidistant time samples 0, dt, 2*dt, ..., (n-1)*dt. The sampling rate dt must be the timestep of the simulation. As described in “Parameters” on page 17, you can get it by evaluating the “timestep” parameter using an Expression object (line 36). The number of samples n is determined by the minimum of two limits: max_time or max_samples. If the waveform quickly falls off after an initial pulse, you can set max_time to generate no more samples than necessary. It is implicitly padded with zeroes. To avoid generating an extraordinary large amount of samples, you can also set max_samples. By default, the Maximum Simulation Time of the Termination Criteria is used to initialized both limits. The TimestepSampledWaveformShape also requires the derivative of the waveform. Here, it’s simply estimated using central differences. A similar approach as in “Creating Polylines and Polygons” on page 19 is used to zip As into a sequence of pairs with the previous and next values. Only the first and the last value needs to be computed differently. izip of itertools [24] is used instead of zip to avoid the memory overhead. zip would create a list of all the pairs, but it only needs to be iterated over once and then it’s discarded. This is wastefull. Instead, izip will generate the pairs
on the fly while iterating over them, whithout ever storing the full list in memory. For the same reason, xrange is used instead of range. Once you have both the waveform samples and derivatives, the only thing left to be done is to create the new waveform and give it a timestep sampled shape. For the example, a step function is created that also takes the rise time and offset as parameters. To reduce it to a function that only takes a time parameter, you need to bind the desired rise time to it. There are various ways to do so, but the most elegant one is using functools.partial available in the Python Standard Library [37, 25].
NOTE
When using user defined waveforms like this, one must keep the following in mind: • The TimestepSampledWaveformShape is not automatically resampled when the “timestep” parameter changes. So for example, when “timestep” is doubled, the waveform will be ”played” with half the speed. So you must recreate the waveform, each time “timestep” is changed! • When a waveform is added to the project, a copy is made. When using the waveform, it’s best to use the copy owned by activeProject. So you add the waveform to the project, and then you retrieve it back form the project. Quirky, but necessary.
Keysight EMPro Scripting Cookbook
49
3
Defining Ports and Sensors
To help with that, Recipe 3.4 also provides a function replace_waveform. The waveforms list doesn’t really act like a Python list or dictionary, and so it needs a bit of special treatment: you need to look up the index by name, and it will return -1 if it can’t be found instead of raising ValueError. If you want to replace an existing waveform, you must use the replace method instead a simple assignment. And waveforms[-1] won’t return the last one, so you need to use its length.
Recipe 3.4 UserDefinedWaveform.py def make_user_defined_waveform(func, max_time=None, max_samples=None, name=None): ''' Creates a User Defined Waveform by sampling a function along the time dimension. arguments: - func(t): the function to be sampled. It's supposed to take one argument: time. - max_time: limits the time interval in which the function is sampled to [0, max_time). By default this equals to terminationCriteria.maximumSimulationTime of empro.activeProject.createSimulationData() - max_samples: the maximum number of samples to generate. By default, this is equals to terminationCriteria.maximumTimesteps of empro.activeProject.createSimulationData() - name: the name of the waveform. The actual number of samples taken will the the minumum of max_samples and max_time / dt where dt is the current value of the expression parameter 'timestep'. NOTE: if the timestep created, the waveform you must execute this ''' import empro from itertools import
parameter is changed *after* the waveform is will not automatically be resampled! function again to recreate the waveform. izip
# evaluate arguments simData = empro.activeProject.createSimulationData() termCrit = simData.terminationCriteria max_time = max_time or float(termCrit.maximumSimulationTime) max_samples = max_samples or termCrit.maximumTimesteps dt = float(empro.core.Expression("timestep")) n = min(max_samples, int(max_time / dt)) # sample function As = [func(k * dt) for k in xrange(n)] # estimate derivative dAs = [(As[1] - As[0]) / dt] dAs.extend((a_p - a_m) / (2 * dt) for a_m, a_p in izip(As[:-2], As[2:])) dAs.append((As[-1] - As[-2]) / dt) assert len(dAs) == n # create waveform waveform = empro.waveform.Waveform(name or "") waveform.shape = empro.waveform.TimestepSampledWaveformShape(As, dAs) return waveform def replace_waveform(waveform):
50
Keysight EMPro Scripting Cookbook
3
Defining Ports and Sensors ''' Searches the project for a waveform with the same name. If it can be found, it's replaced by by a copy of the new one. Otherwise, the copy of the new waveform is simply appended. Finally, it returns a reference to the new copy as owned by the project ''' import empro waveforms = empro.activeProject.waveforms() index = waveforms.index(waveform.name) if index < 0: waveforms.append(waveform) return waveforms[len(waveforms)-1] else: waveforms.replace(index, waveform) return waveforms[index] def step(t, risetime, offset=0): import math if t <= offset: return 0 return 1 - math.exp(-1.423 * ((t - offset) / risetime) ** 2) # --- example --if __name__ == "__main__": import empro from functools import partial risetime = float(empro.core.Expression("20 * timestep"))), waveform = make_user_defined_waveform(partial(step, risetime=risetime), name="My Step") # add to or replace in project, and get waveform _from_ project waveform = replace_waveform(waveform) # now, we pick up a circuit component definition from the project # tree, and we set the waveform + its amplitude definitions = empro.activeProject.circuitComponentDefinitions() definitions["50 ohm Voltage Source"].waveform = waveform
Importing User Defined Waveforms from CSV Files (FDTD only) Reading a waveform from a comma-separated values (CSV) file has got a whole lot easier in EMPro 2013.07 with the introduction of the UserDefinedWaveformShapeFromCSVFile waveform shape. You simply create such a shape, and load the data from the file. Simple. But again, before using this waveform, you must first add it to the project and get it back from the project, as explained in “User Defined Waveforms (FDTD only)” on page 48: # load waveform waveform = empro.waveform.Waveform("My Stimulus") waveform.shape = empro.waveform.UserDefinedWaveformShapeFromCSVFile() waveform.shape.loadFromFile(r"C:\tmp\stimulus.csv") # add to project, and get waveform _from_ project waveforms = empro.activeProject.waveforms() waveforms.append(waveform) waveform = waveforms[len(waveforms)-1]
Keysight EMPro Scripting Cookbook
51
3
Defining Ports and Sensors # now, we pick up a circuit component definition from the project tree, # and we set the waveform + its amplitude definitions = empro.activeProject.circuitComponentDefinitions() definitions["50 ohm Voltage Source"].waveform = waveform
Adding a Far Zone Sensor When designing antennas, you’ll want to setup a far zone sensor in your product to calculate the antenna gain. This simply requires adding a FarZoneSensor instance to activeProject.farZoneSensors(). Recipe 3.5 shows how to add a spherical far zone sensor that collects steady state data. The properties of the sensor object map directly onto the properties you can find in the user interface, so this should be straight forward to use. The possible values for coordinateSystemType are “ThetaPhi”, “AlphaEpsilon” and “ElevationAzimuth”, which dictates the meaning of the first and second angle properties. When using a constant angle, only the start value should be set. Recipe 3.5 FarZoneSensor.py def makeSphericalFarZoneSensor(resolution="5 deg", name=None): import empro sensor = empro.sensor.FarZoneSensor() sensor.name = name or "Far Zone Sensor" sensor.coordinateSystemType = "ThetaPhi" sensor.collectSteadyState = True sensor.collectTransient = False sensor.useConstantAngle1 = False sensor.angle1Start = 0 sensor.angle1Stop = "180 deg" sensor.angle1Increment = resolution sensor.useConstantAngle2 = False sensor.angle2Start = 0 sensor.angle2Stop = "360 deg" sensor.angle2Increment = resolution return sensor # --- example --if __name__ == "__main__": import empro fz = makeSphericalFarZoneSensor("10 deg") empro.activeProject.farZoneSensors().append(fz)
Adding a Planar Near Field Sensor Adding near field sensors is slightly more complicated than far zone sensors, as you need to create a separate geometry and data definition—which you can reuse. The following example illustrates how you can add a planar near field sensor collecting steady state electric field data. PlaneSurfaceGeometry defines a plane using a position of a point on the plane, and a normal vector. The properties of SurfaceSensorDataDefinition map directly onto the properties available in the user interface. You finally create a SurfaceSensor, set both geometry and definition, and you add it to 52
Keysight EMPro Scripting Cookbook
Defining Ports and Sensors
3
activeProject.nearFieldSensors(). The definition will automatically be added to activeProject.sensorDataDefinitions(), it’s unnecessary to add it separately, and in fact you’re encouraged not to. plane = empro.sensor.PlaneSurfaceGeometry() plane.position = ("1 mm", "2 mm", "3 mm") plane.normal = (0, 0, 1) definition = empro.sensor.SurfaceSensorDataDefinition() definition.name = "My Definition" definition.collectSteadyEFieldVsFOI = True sensor = empro.sensor.SurfaceSensor() sensor.name = "My Sensor" sensor.geometry = plane sensor.definition = definition empro.activeProject.nearFieldSensors().append(sensor)
Keysight EMPro Scripting Cookbook
53
3
54
Defining Ports and Sensors
Keysight EMPro Scripting Cookbook
Keysight EMPro Scripting Cookbook
4 Creating and Running Simulations Setting Up the FDTD Grid
55
Creating an FDTD Simulation Creating an FEM Simulation
58 59
Waiting Until a Simulation Is Completed 60
Now you know how to set up geometry, ports and sensors. But how do you actually simulate your circuit? This chapter is mostly about specifying various settings, so there are not so many recipes to be found here.
Setting Up the FDTD Grid All things gridwise start with empro.activeProject.gridGenerator(). There are many settings to this, but they all correspond to elements in the user interface. So they won’t be explained in detail, but rather their relation to the settings in the FDTD Grid editor will be shown, tab by tab.
Setting the Grid Size In the user interface, there’s a Basic and Advanced mode for setting the grid size. In scripting, there’s only the advanced mode. Most of the cellSizes settings take a tuple of three values: for the X, Y and Z directions. There’s the target and minimum cell sizes to be set, and both accept expressions. So you can use a tuple of strings, floats, Expression objects, or a mixture of these as shown in the example below. You can also use Vector3d objects if you want—see “Vectors” on page 17. minimumType corresponds to the Ratio check box: "RatioType" corresponds with the selected state, "AbsoluteType" makes the minimum absolute and thus corresponds with the cleared state of the check box. grid = empro.activeProject.gridGenerator() grid.cellSizes.target = ("1.0 mm", 1e-3, "1.0 mm") grid.cellSizes.minimum = ("0.1 mm", 1e-4, 0.1) grid.cellSizes.minimumType = ("AbsoluteType", "AbsoluteType",
55
4
Creating and Running Simulations "RatioType")
Just to show that using Vector3d also works: grid.cellSizes.target = Vector3d("1.0 mm", 1e-3, "1.0 mm")
For the padding, you can either set the number of padding cells using the padding attribute, or you can directly set the bounding box using the boundingBox attribute. Both have a lower and upper attribute accepting triples of expressions—as demonstrated in various ways below. You must however be careful to set the gridSpecificationType to the method you’ve chosen, similar to the radio buttons in the user interface: grid.gridSpecificationType = "PaddingSizeType" grid.padding.lower = (15, "15", "10 + 5") grid.padding.upper = (15, 15, 0) # no padding in lower Z direction
or: grid.gridSpecificationType = "BoundingBoxSizeType" grid.boundingBox.lower = (-0.015, "-15 mm", "-10 mm - 5 mm") grid.boundingBox.upper = (0.015, "15 mm", 0)
Adding Fixed Points Adding a fixed point involves creating a FixedPoint, setting its location and specifying for which axes it will fix the grid. The axes attribute is a string that represents a bit field corresponding to the state of the Fixed check boxes. It’s a combination of three flags "X", "Y" and "Z" that can be combined using the pipe as OR operator. Finally, you add the fixed point to the grid generator. def addFixedPoint(location, axes="X|Y|Z"): point = empro.mesh.FixedPoint() point.location = location point.axes = axes empro.activeProject.gridGenerator().addManualFixedPoint(point) addFixedPoint(("1 mm", "2 mm", 0 ), "X|Y") # for X and Y only.
You can also inspect the currently set fixed points: for fixed in grid.getManualFixedPoints(): print fixed.location.asVector3d(), fixed.axes
Clearing all of them can be done with grid.clearManualFixedPoints().
Adding Grid Regions Additional grid regions can be added in similar fashion. You create a ManualGridRegionParameters and set the desired parameters. cellSizes works like the global Size settings; gridRegionDirections is a bit field like axes of a fixed point; and setting regionBounds is a bounding box. Finally, you add the region to the grid: region = empro.mesh.ManualGridRegionParameters() region.cellSizes.target = (".5 mm", ".5 mm", ".5 mm") region.cellSizes.target = (".5 mm", ".5 mm", ".1 mm")
56
Keysight EMPro Scripting Cookbook
4
Creating and Running Simulations region.cellSizes.minimumType = ("AbsoluteType", "AbsoluteType", "AbsoluteType") region.gridRegionDirections = "X|Y|Z" region.regionBounds.lower = ("-5 mm", "-5 mm", "-5 mm") region.regionBounds.upper = ("5 mm", "5 mm", 0) grid.addManualGridRegion(region)
Inspecting and clearing grid regions can be done with getManualGridRegions and clearManualGridRegions, in similar fashion as for fixed points.
Setting General Limits In the Limits tab, you can set the Maximum Cell Step Factor and the Maximum Cells, which correspond to the following properties. The state of the check box is represented by useMaximumNumberOfCells. grid = empro.activeProject.gridGenerator() grid.maximumStepFactor = 2 grid.useMaximumNumberOfCells = True grid.maximumNumberOfCells = "10 million"
Adding Grid Settings to Individual Objects Assuming you know what you’re doing, you can further refine the grid settings by having individual objects adding fixed points and grid regions automatically—besides the one you’ve added manually like shown above. Any part of the project’s geometry has a gridParameters attribute that can be used to further refine the FDTD grid. To have the object to automatically add fixed points, you first need to set the useFixedPoints attribute. Then specify fixedPointsLocations and fixedPointsOptions which are again a combination of string constants that can be OR’ed. Check the reference documentation [6] to know what the valid constants are. Here’s a function that will set these parameters for all objects at once. Not all parts have grid parameters though, in which case part.gridParameters will evaluate to None and attempting to set any of its attributes will result in an AttributeError being raised. To prevent the function from failing in such case, the try / except clause is added to catch and ignore the exception: def SetAutomaticFixedPointsForAllObjects(locations="All", options="C1VertexDiscontinuities|GridAxisAlignedLineEndPoints"): for part in empro.activeProject.geometry().flatList(True): try: part.gridParameters.useFixedPoints = True part.gridParameters.fixedPointsLocations = locations part.gridParameters.fixedPointsOptions = options except AttributeError: pass
By setting useGridRegions you can also have the part to include a grid region automatically. Specify cellSizes as above. The boundaryExtensions can be used to expand the region beyond the part’s bounding box. Set it in normal bounding box fashion. Keysight EMPro Scripting Cookbook
57
4
Creating and Running Simulations
Creating an FDTD Simulation The short story to create an FDTD simulation is that you grab the empro.activeProject.createSimulationData() object to specify the simulation settings of the simulations to be created, you set its engine to FDTD, and you create a new simulation with createSimulation. simSetup = empro.activeProject.createSimulationData() simSetup.engine = "FdtdEngine" # ... sim = empro.activeProject.createSimulation(True)
createSimulation automatically uses the settings specified in createSimulationData, so there’s no parameter to pass it. It does take one
Boolean argument though: whether or not to immediately queue the simulation. True is like pressing the Create & Queue Simulation button, False is like the Create Simulation button. The return value of createSimulation is a Simulation object that you can use to wait for (see “Waiting Until a Simulation Is Completed” on page 60), or to retrieve results from (see Chapter 5, “Waiting Until a Simulation Is Completed”).
Computing S-parameters To compute S-parameters, you first need to enable the according option in the simulation setup: simSetup.sParametersEnabled = True
You also need to tell what the active feeds are (the rows of your matrix). Below is a function that accepts a list of port names or numbers and sets the according ports as active (setting the others as inactive). It’s a bit convoluted as circuitComponents doesn’t yet support the iterator protocol, and getting the port number requires you to call getPortNumber with the index of that port within circuitComponents. Components that are not a port report a negative port number. Recipe 4.1 ActivePorts.py def setActivePorts(ports): simSetup = empro.activeProject.createSimulationData() components = empro.activeProject.circuitComponents() for k in range(len(components)): component = components[k] portNumber = components.getPortNumber(k) isActive = component.name in ports or \ (portNumber > 0 and portNumber in ports) simSetup.setPortAsActive(component, isActive) # --- example --if __name__ == "__main__": setActivePorts(["Port1", 2])
58
Keysight EMPro Scripting Cookbook
4
Creating and Running Simulations
Setting Frequencies of Interest for Steady-State Data The list of steady-state frequencies is managed in the foiParameters attribute. To collect steady-state data you must first set its collectSteadyStateData flag. To specify your own frequencies, set foiSource to "Specified". Setting it to "Waveform" will use the waveform frequency, which only works for sinusoidal waveforms. Clearing all frequencies can be done with clearSpecifiedFrequencies and adding new ones with addSpecifiedFrequency. Here’s a function setFrequenciesOfIntereset that helps specifying a list of frequencies: def setSteadyStateFrequencies(frequencies): setup = empro.activeProject.createSimulationData() foi = setup.foiParameters foi.collectSteadyStateData = True foi.foiSource = "Specified" foi.clearSpecifiedFrequencies() for f in frequencies: foi.addSpecifiedFrequency(f)
You can use it with a list of Expression objects, strings or floats: setSteadyStateFrequencies(["1 GHz", 2e9, empro.core.Expression("3 GHz")])
You can also do some fancier things with generator expressions [15] to add a range of frequencies. Here’s how to add the series 1 GHz, 2 GHz, ..., 10 GHz. Just remember that the upper bound of range is excluded from the series1 : setSteadyStateFrequencies("%s GHz" % k for k in range(1, 11))
Creating an FEM Simulation Just like for FDTD simulations, the short story is to grab the createSimulationData object, set the engine to FEM, specify the other settings and call createSimulation: simSetup = empro.activeProject.createSimulationData() simSetup.engine = "FemEngine" # ... sim = empro.activeProject.createSimulation(True)
Specifying Frequency Plans The FEM frequency plans are managed by the femFrequencyPlanList subobject: 1 Whether or not this makes sense to you, is probably correlated to your preference to either zero- or onebased indexing. There are two major conventions for index ranges: zero-based half-open intervals, and onebased closed intervals. As most general purpose programming languages, Python uses the former as it turns out to be the practical choice for most applications—regrettably matrix calculation is not one of them. As a consequence, the upper bound of range is excluded so that range(len(some_list)) covers all valid indices for that list, and that len(range(n)) == n. [2, 35]
Keysight EMPro Scripting Cookbook
59
4
Creating and Running Simulations freqPlans = simSetup.femFrequencyPlanList()
Individual plans can be added by appending FrequencyPlan objects. Here’s how to add an adaptive frequency plan: plan = empro.simulation.FrequencyPlan() plan.type = "Adaptive" plan.startFrequency = "1 GHz" plan.stopFrequency = "10 GHz" plan.samplePointsLimit = 50 freqPlans.append(plan)
Frequency plans of type "Linear" and "Logarithmic" use numberOfFrequencyPoints instead of samplePointsLimit. A single frequency can be added as following: plan = empro.simulation.FrequencyPlan() plan.type = "Single" plan.startFrequency = "5 GHz" freqPlans.append(plan)
Waiting Until a Simulation Is Completed Now you know how to create and run simulations, but maybe you also want to add some code to process the results (see Chapter 5, “Waiting Until a Simulation Is Completed”). Then you’ll need to be able to wait until a simulation is completed. You can write a simple loop that checks the status of the Simulation object (which is simply the return value of createSimulation) until it’s completed. As with any good polling loop, you put in a short sleep. And in EMPro you also want to put in a processEvents call so that the user interface stays responsive: import empro, time sim = empro.activeProject.createSimulation(True) while sim.status in ('Queued', 'Running', 'PostProcessing', 'Interrupting', 'Killing'): time.sleep(.1) empro.gui.processEvents()
To make things easier, the simulation module in the toolkit has a function wait that nicely wraps it up for you. You pass it a Simulation object and it will wait until that simulation is completed. import empro, empro.toolkit.simulation sim = empro.activeProject.createSimulation(True) empro.toolkit.simulation.wait(sim) print "Done!"
You can also wait for more than one simulation in a single call by passing the simulation objects in a list: import empro, empro.toolkit.simulation sim1 = empro.activeProject.createSimulation(True) # ... sim2 = empro.activeProject.createSimulation(True) empro.toolkit.simulation.wait([sim1, sim2]) print "Both sim1 and sim2 are done!"
60
Keysight EMPro Scripting Cookbook
Creating and Running Simulations
4
If you call it without any arguments, it will simply wait until all simulations in the queue are done. The benefit of that is that you don’t need to have the simulation objects, like when creating a sweep: from empro.toolkit.import simulation simulation.simulateParameterSweep(width=["4 mm", "5 mm", "6 mm"], height=["1 mm", "2 mm"]) simulation.wait() print "All done!"
Keysight EMPro Scripting Cookbook
61
4
62
Creating and Running Simulations
Keysight EMPro Scripting Cookbook
Keysight EMPro Scripting Cookbook
5 Post-processing and Exporting Simulation Results An Incomplete Introduction To Datasets Something About Units
63
68
Getting Simulation Result with getResult
71
Creating XY Graphs 74 Working with CITI Files
75
Exporting to Touchstone Files
76
Exporting Surface Sensor Results
76
Directly Sampling Near Fields (FEM only) 78 Reducing Dataset Dimensions Plotting Far Zone Fields
79
81
Multi-port Weighting 83 Maximum Gain, Maximum Field Strength 84 Integration over Time
86
Exporting Arbitrary Datasets to CSV Files
87
Exporting Surface Sensor Topology to OBJ file
91
When you have run a simulation, you want to process its results. In this chapter, it is explored how you can operate on results to calculate new quantities, how you can export results, and how you can create graphs within EMPro. But first, some basics about datasets and units need to covered.
An Incomplete Introduction To Datasets A DataSet is a scripting type that represents a multidimensional scalar function. 63
5
Post-processing and Exporting Simulation Results
That sort of wraps it up. It’s the alpha—but not the omega—of multidimensional data representation in EMPro. It can be one-dimensional like the time series of the current through a circuit component. It can also be multidimensional like far zone data, depending on two angles and the frequency. Most datasets are discrete; they are sampled for certain values of their dimensions. There are also continuous datasets, but these are rare.
NOTE
It’s important to remember that EMPro’s DataSet type is totally unrelated to ADS DataSet files. In this cookbook, whenever we talk about a dataset, we mean the scripting type.
The following code snippets will assume you have loaded the “Microstrip Dipole Antenna” example in EMPro. Don’t worry about the getResult calls, we’ll explain that in “Getting Simulation Result with getResult” on page 71.
Dataset as a Python Sequence Discrete datasets are just arrays of floating point numbers. So it’s only natural to give them a similar interface like Python’s tuple or list [26]. That means you can get their size with the len operator, and index values with the [] operators1 : s11 = empro.toolkit.dataset.getResult(sim=1, run=1, object='Feed', result='SParameters', complexPart='ComplexMagnitude') f = file("s11.txt", "w") for k in range(len(s11)): f.write("%s\n" % s11[k]) f.close()
Most datasets are read-only, just like a tuple: s11[0] = 0.123 # TypeError: 'DataSet' object does not support item assignment
Negative indices work as expected: if s11[-1] == s11[len(s11) - 1]: print "OK"
And instead of indexing, you can also iterate over datasets: out = file("c:/tmp/s11.txt", "w") for s in s11: out.write("%s\n" % s) out.close()
Or even: out = file("c:/tmp/s11.txt", "w") out.write("\n".join(map(str, s11))) out.close() 1 In Python, the len and [] operators are actually called __len__ and __getitem__ [18]. Pronounce them as “dunder len” and “dunder getitem”.
64
Keysight EMPro Scripting Cookbook
5
Post-processing and Exporting Simulation Results
Since datasets support the iterator protocol, you can use many of the functions that accept sequence arguments: print min(s11) print max(s11) print sum(s11)
You can also use zip to iterate over more than one dataset of the same size, at the same time: a11 = empro.toolkit.dataset.getResult(sim=1, run=1, object='Feed', result='SParameters', complexPart='Phase') out = file("c:/tmp/s11.txt", "w") for s, a in zip(s11, a11): out.write("%s\t%s\n" % (s, a)) out.close()
Or better, use izip of itertools [24] to avoid the memory overhead of zip: from itertools import izip for s, a in izip(s11, a11): out.write("%s\t%s\n" % (s, a))
The in operator is also supported, but it’s an O(n) operation, just like for tuple or list: if float("nan") in s11: print "s11 has invalid numbers"
Aspects of the sequence protocol that are not supported are slicing, concatenation—the + and * operators have other meanings, see “Dataset as a Python Number” on page 66—and the index and count methods.
Dimensions All result datasets have one or more dimensions associated with. For example, an S-parameter dataset will have a dimension that tells the frequency of each of dataset values. They can be retrieved by index using the dimension method, and numberOfDimensions will give you their count: for k in range(s11.numberOfDimensions()): print s11.dimension(k).name
There’s also the dimensions method that will return a tuple of all dimensions. So the above could be written more elegantly as: for dim in s11.dimensions(): print dim.name
Dimensions are DataSets themselves; they support the same methods and protocols: freq = s11.dimension(0) out = file("c:/tmp/s11.txt", "w") for f, s in zip(freq, s11): out.write("%s\t%s\n" % (f, s)) out.close()
Keysight EMPro Scripting Cookbook
65
5
Post-processing and Exporting Simulation Results
Multidimensional Datasets Dimensions are also used to chunk the single array of values along multiple axes. For example, the result of a far zone sensor has a frequency and two angular dimensions, three in total. fz = empro.toolkit.dataset.getResult(sim=1, object='3D Far Field', timeDependence='SteadyState', result='E', complexPart='ComplexMagnitude') for dim in fz.dimensions(): print dim.name
The total dataset size is of course the product of the dimension sizes: n = 1 for dim in fz.dimensions(): n *= len(dim) if n == len(fz): print "OK"
To access datasets with an multidimensional index (i, j, k), one cannot use the [] operator as that indexes the flat array. Instead, you must use the at method that takes an variable number of arguments: freq, theta, phi = fz.dimensions() out = file("c:/tmp/fz.txt", "w") for i in range(len(freq)): out.write("# Frequency = %s\n" % freq[i]) for j in range(len(theta)): for k in range(len(phi)): out.write("%s\t%s\t%s\n" % (theta[j], phi[k], fz.at(i, j, k))) out.close()
The relationship between the flat and multidimensional indices is the following, where (ni , nj , nk ) are the dimension sizes: index = ((i ∗ nj + j) ∗ nk + k) ∗ . . . To verify this is true: ni, nj, nk = [len(dim) for dim in fz.dimensions()] ok = True for i in range(len(freq)): for j in range(len(theta)): for k in range(len(phi)): index = (i * nj + j) * nk + k if fz[index] != fz.at(i, j, k): print "oops" ok = False if ok: print "OK"
Dataset as a Python Number Datasets also behave a lot like normal Python numbers. That means you can add, subtract, multiply or divide datasets just like they are simple numbers. The operations are then performed on the individual elements. The requirement for this to work is that both operands have the same dimensions and are of the same size. 66
Keysight EMPro Scripting Cookbook
5
Post-processing and Exporting Simulation Results
Given the port voltage and current time signals, you can compute an instantaneous impedance suitable for TDR analysis: v = empro.toolkit.dataset.getResult(sim=1, object='Feed', result='V', timeDependence='Transient') i = empro.toolkit.dataset.getResult(sim=1, object='Feed', result='I', timeDependence='Transient') z_tdr = v / i
abs will take the absolute value of each dataset value. Combining this with the
sequence protocol, you can compute the root mean square as following: import math v_rms = math.sqrt(sum(abs(v) ** 2) / len(v)) print v_rms
Complex Datasets Since DataSet instances always return scalar values, you need two datasets to represent complex quantities: one for the real part and one for the imaginary part. The dataset module in the toolkit contains a class ComplexDataSet that wraps both and acts like they are one dataset returning complex values. ComplexDataSet does it very best to walk, swim and quack like a real valued DataSet [30]. Most of the time, you won’t need to bother about this, since getResult will return a ComplexDataSet automatically when appropriate, and
most of the toolkit functions will understand what to do with it (see “Getting Simulation Result with getResult” on page 71). But it’s good to known that ComplexDataSet is a wrapper around real valued datasets, rather than a subclass of the DataSet base class.
Dataset Matrices The dataset module also contains a DataSetMatrix class, which is useful when working with many datasets that have the same dimensions, like S-parameters. It behaves a lot like a regular dict with keys() and values(). Each key is a tuple of two port numbers. To get S12 , you can write: from empro.toolkit import portparam s = portparam.getSMatrix(sim=1) s12 = s[(1, 2)]
And since in Python it is not required to write the tuple’s parentheses unless things are ambiguous, you can also write: s12 = s[1, 2]
Nice. It also supports a lot of the normal mathematical operators, and a method inversed() to return the inverse matrix: y = gRef.inversed() * (s * zRef + e * zRefConj).inversed() * (e - s) * \ gRef
A few more examples are shown further down in this chapter. Keysight EMPro Scripting Cookbook
67
5
Post-processing and Exporting Simulation Results
Something About Units In this chapter, you’ll see a lot of unit handling code. So it’s best to have an introductory section on units.
Unit Class The term unit class is used to indicate physical quantities like time, length, electric field strength, ... All datasets have an attribute unitClass that will tell you want kind of quantity the dataset contains. The value of this attribute is a string like "TIME", "LENGTH" or "ELECTRIC_FIELD_STRENGTH". Unitless data is specified using the "SCALAR" unit class. The full list of known unit classes is available in the documentation, or can be printed as follows: for unitClass in sorted(empro.units.displayUnits()): print unitClass
For your convenience, all these strings are also defined as constants in the units module: empro.units.TIME, empro.units.LENGTH, empro.units.ELECTRIC_FIELD_STRENGTH, empro.units.SCALAR, ...
Reference Units For each unit class, there’s an assumed reference unit that is used as an absolute standard when converting physical quantities from one unit to another. These reference units simply are the SI units, expanded with directly derived 2 kg ones like Ω = ms2 A . The reference unit for plane and solid angles are radians and steradians. See Table 5.1 for the full list of reference units.
Unit Objects A unit object is an instance of empro.units.Unit and offers the required information and functionality for unit to be used in EMPro. It has methods to query its name, unitClass, preferred abbreviation, or to get a list of allAbbreviations that are recognized in expressions. To convert from reference units, it also has a conversionMultiplier, conversionOffset and a method to know if it’s a logScale. To help with the conversion from and to reference units, there’s also fromReferenceUnits and toReferenceUnits that accepts a float argument and return the converted value as a float. All known unit objects can be retrieved by abbreviation or by name with unitByAbbreviation and unitByName. Here’s an example that shows the 68
Keysight EMPro Scripting Cookbook
Post-processing and Exporting Simulation Results
5
Table 5.1 Reference Units Unit Class
Reference Unit
empro.units.ACCELERATION empro.units.AMOUNT_OF_SUBSTANCE empro.units.ANGLE empro.units.ANGULAR_MOMENTUM empro.units.ANGULAR_VELOCITY empro.units.AREA empro.units.AREA_POWER_DENSITY empro.units.DATA_AMOUNT empro.units.DENSITY empro.units.ELECTRIC_CAPACITANCE empro.units.ELECTRIC_CHARGE empro.units.ELECTRIC_CHARGE_DENSITY empro.units.ELECTRIC_CONDUCTANCE empro.units.ELECTRIC_CONDUCTIVITY empro.units.ELECTRIC_CURRENT empro.units.ELECTRIC_CURRENT_DENSITY empro.units.ELECTRIC_FIELD_STRENGTH empro.units.ELECTRIC_POTENTIAL empro.units.ELECTRIC_RESISTANCE empro.units.ENERGY empro.units.FORCE empro.units.FREQUENCY empro.units.HEAT_CAPACITY empro.units.INDUCTANCE empro.units.LENGTH empro.units.LUMINOUS_FLUX empro.units.LUMINOUS_INTENSITY empro.units.MAGNETIC_FIELD_STRENGTH empro.units.MAGNETIC_FLUX empro.units.MAGNETIC_FLUX_DENSITY empro.units.MASS empro.units.MASS_POWER_DENSITY empro.units.MOMENTUM empro.units.PERFUSION_OF_BLOOD empro.units.PERMEABILITY empro.units.PERMITTIVITY empro.units.POWER empro.units.PRESSURE empro.units.SCALAR empro.units.SOLID_ANGLE empro.units.THERMAL_CONDUCTIVITY empro.units.THERMODYNAMIC_TEMPERATURE empro.units.TIME empro.units.VELOCITY empro.units.VOLUME empro.units.VOLUMETRIC_ENERGY_DENSITY empro.units.VOLUMETRIC_POWER_DENSITY
m/s**2 mol rad N*m*s rad/s m**2 W/m**2 bytes kg/m**3 F C C/m**3 S S/m A A/m**2 V/m V ohm J N Hz J/kg/K H m lm cd A/m Wb T kg W/kg N*s L/g/s H/m F/m W Pa
Keysight EMPro Scripting Cookbook
sr W/m/K K s m/s m**3 J/m**3 W/m**3
69
5
Post-processing and Exporting Simulation Results
properties of the micrometers unit. The comments are the expected output of each print statement: unit = empro.units.unitByAbbreviation("um") print unit.name() # micrometers print unit.abbreviation() # um print unit.allAbbreviations() # (u'um', u'micron') print unit.conversionMultiplier() # 1000000.0 print unit.conversionOffset() # 0.0 print unit.logScale() # No_Log_Scale print unit.fromReferenceUnits(1.2345) # 1234500.0 print unit.toReferenceUnits(1.2345) # 1.2345e-06
Display Units Each project has a list of units that’s normally used to display values. That’s the list of units that’s normally found under Edit > Project Properties... On the scripting side, that list is represented by the empro.units.displayUnits() dictionary. You simply index it with a unit class to get the appropriate display unit: freqUnit = empro.units.displayUnits()[empro.units.FREQUENCY] print freqUnit.abbreviation() # GHz print freqUnit.fromReferenceUnits(1e9) # 1.0
In this chapter, you’ll see the display units used a lot to export data to files.
Backend Units Internally, physical values are stored and processed in backend units. You’ll encounter these units in two situations: 1 Values retrieved from a DataSet are internal values returned as float, in backend units. 2 When converting an expression to a float, the result is in backend units: print float(empro.core.Expression("1 mil")) # will print 2.54e-05
In all normal circumstances, the backend units are exactly the same as the reference units, see ??. You can verify this by iterating of all of them and checking that the conversion multiplier is one, the offset is zero, and that this is not a logarithmic scale: print "%25s %15s %10s" % ("unit class", "abbreviation", "reference") for unitClass, unit in sorted(empro.units.backendUnits().items()): isReference = (unit.conversionMultiplier() == 1 and unit.conversionOffset() == 0 and unit.logScale() == "No_Log_Scale") print "%25s %15s %10s" % (unitClass, unit.abbreviation(), isReference)
NOTE
When creating your own DataSet objects, it’s best to populate them with data in backend units as well. This will avoid weird behavior when exporting or displaying that data. The same is true when operating on existing datasets. Do not multiply by 180 to π convert angular data from radians to degrees, but rather use the appropriate
70
Keysight EMPro Scripting Cookbook
5
Post-processing and Exporting Simulation Results
unit when exporting or displaying. The conversion will be done for you.
Helper Functions Here are a few helper functions you’ll see a lot in the following scripts. They’re not rocket science, but they help making the scripts a bit more readable. When writing data to a file, you want to appropriately label that column using the data name, but also with the abbreviation of the unit in which the data is displayed. columnHeader helps with this simple task: def columnHeader(name, displayUnit): """ Build a suitable column header using data name and unit. """ if unit.abbreviation(): return "%s[%s]" % (name, unit.abbreviation()) else: return name
Data usually needs to written to files a strings, and you also want to convert that data from reference to display units. strUnit is a small helper function that does both at once: def strUnit(value, displayUnit): """ Converts a float in reference units to a string in display units. """ return str(displayUnit.fromReferenceUnits(value))
Getting Simulation Result with getResult To postprocess results, you first need to get them. getResult is your first tool of the trade here. You probably want to read this section a few times to make sure you fully understand this. Fundamentally, retrieving data from simulations requires painstakingly filling in 11 attributes of a ResultQuery, in order! Only then you can retrieve the dataset. You also should not forget to add the project to the result browser first, otherwise no data will be available. For example, to get the current through Port1 of the first run of the FDTD simulation of the “Microstrip Low Pass Filter” example, you would need to do all of this: empro.output.resultBrowser().addProject( "C:/keysight/EMPro2012_09/examples/ExampleProjects/"\ "%Microstrip#20%Low#20%Pass#20%Filter") query = empro.output.ResultQuery() query.projectId = "C:/keysight/EMPro2012_09/examples/ExampleProjects/" \ "%Microstrip#20%Low#20%Pass#20%Filter" query.simulationId = '000001' query.runId = 'Run0001'
Keysight EMPro Scripting Cookbook
71
5
Post-processing and Exporting Simulation Results query.outputObjectId = ('CircuitComponent', 'Port1') query.timeDependence = 'Transient' query.resultType = 'I' query.fieldScatter = 'NoFieldScatter' query.resultComponent = 'Scalar' query.dataTransform = 'NoTransform' query.complexPart = 'NotComplex' query.surfaceInterpolationResolution = 'NoInterpolation' data = empro.output.ResultDataSet("Current", query)
Scary, isn’t it? Luckily, the dataset module in the toolkit contains a function called getResult that greatly simplifies this: from empro.toolkit import dataset current = dataset.getResult( "C:/keysight/EMPro2012_09/examples/ExampleProjects/" \ "%Microstrip#20%Low#20%Pass#20%Filter", sim=1, run=1, object='Port1', result='I')
NOTE
In the user interface, for any result listed in the Results Browser, you can retrieve the corresponding getResult call by clicking Copy Python Expression in the context menu. Then you simply paste the expression in your script.
getResult works by having a lot of optional parameters and trying to guess
sensible defaults for any parameter not specified. Here’s its signature: def getResult(context=None, sim=None, run=None, object=None, result=None, timeDependence=None, fieldScatter=None, component=None, transform=None, complexPart=None, interpolation=None):
You see almost a one-on-one relationship with the query fields. That’s because getResult will exactly build such a query for you! Only projectId is missing, but that’s handled by context. getResult starts with a context in which it needs to interpret the arguments
that follow. It can be many things: • The project path as a string, as in the example above. This tells getResult to add the project to the result browser if necessary, and to use that string as the projectId. • A Simulation object retrieved from createSimulation. This not only tells the projectId, but also already fills in the simulationId. So, when using this as a context, it’s no longer necessary to specify the sim parameter2 . from empro.toolkit import simulation, dataset sim = empro.activeProject.createSimulation(True) simulation.wait(sim) current = dataset.getResult(sim, run=1, object='Port1', result='I')
• None. when omitted, the active project is assumed as the context, and the projectId is set to its rootDir. Once you have to context, you still need to specify exactly what result you want. The various parameters you can specify are: 2 Using a Simulation object as context does not work in EMPro 2012.09 for OpenAccess projects. This is fixed in EMPro 2013.07.
72
Keysight EMPro Scripting Cookbook
5
Post-processing and Exporting Simulation Results
• sim of course lets you set the simulation you want to retrieve data from. It should either be a full string ID like '000001' or simply its integer equivalent. If there’s only one simulation in the project, you can omit this parameter. • Similarly, run should either be a full run ID like 'Run0001' or an integer—which is often the active port number. Again, if there’s only one run in the simulation, you can omit it. • The object parameter needs to be set to the name of the sensor or port you want to retrieve data from. This is one of the arguments that always needs to be specified. • result is also one of the parameters that always should be set, and common values are 'V', 'I', 'E' or 'SParameter'. • timeDependence lets you choose between time and frequency domain, and it defaults to 'Transient' or 'Broadband', depending on the simulator. You most likely only need to specify it as 'SteadyState' if you want the discrete frequencies instead. 'Transient' is FDTD only and 'Broadband' is FEM only. If you want broadband S-parameters from an FDTD simulation, you need to get the 'Transient' data and request an FFT transform (see below). • fieldScatter is only interesting for FDTD near fields and defaults to 'TotalField'. • Depending on the result type, component defaults to either 'Scalar' for scalar data (real or complex) like port parameters, 'VectorMagnitude' for vector data like near and far fields. Set it to 'X', 'Y' or 'Z' to get individual 3D vector components. Some of the many possible components for far zones are 'Theta' and 'Phi'. • dataTransform usually defaults to 'NoTransform' and can be set to 'Fft' to transform the transient FDTD data to frequency domain. That’s also the default for result types that you normally expect in frequency domain like Sparameters. It’s very rare that you need to worry about this option. • When requesting complex-valued data, complexPart allows you to specify if you want 'RealPart', 'ImaginaryPart', 'ComplexMagnitude' or 'Phase'. These options will all return a real-valued DataSet. In addition, getResult also supports the 'Complex' option to get both the real and imaginary parts wrapped as one ComplexDataSet. And that’s also the default. So in most cases, you don’t need to worry about this parameter: real-valued result types will return a real-valued DataSet, and complex-valued result types will return a ComplexDataSet. Too easy. • interpolation is also one you can mostly ignore and only matters for near field data.
NOTE
That’s a lot of parameters and a lot of possible arguments, but what you need to remember is: • The defaults are often what you want anyway. • If you give a wrong option, it will complain with suggestions of what you should use instead.
Keysight EMPro Scripting Cookbook
73
5
Post-processing and Exporting Simulation Results
• You can get good templates of getResult calls by using the Copy Python Expression.
Creating XY Graphs OK, so you’ve computed some data, but how do you plot it on the screen? The toolkit contains a module called graphing that can assist with that. Showing an XY graph is very easy with showXYGraph: from empro.toolkit import graphing, dataset v = dataset.getResult(sim=1, run=1, object='Port1', result='V'), graphing.showXYGraph(v)
The full signature has a number of keyword parameters, which are explained below: def showXYGraph(data, ..., title=None, names=None, xUnit=None, yUnit=None, xBounds=None, yBounds=None, xLabel=None, yLabel=None):
Specifying Axis Units By default, the graph axes use display units according to the unit class of the dataset (for the Y axis) and its dimension (for the X axis). If you want to override that, you can specify the unit’s abbreviation as following: graphing.showXYGraph(v, xUnit="ps")
Alternatively, you can also pass a unit object: dBV = empro.units.unitByAbbreviation("dBV") print dBV graphing.showXYGraph(v, yUnit=dBV)
S-parameters are a bit special because as ratios, their unit class is "SCALAR" and scalar values are shown linearly by default. However, if showXYGraph can guess you’re actually showing S-parameters, it’ll automatically use a logarithmic scale. from empro.toolkit import portparam, graphing S = portparam.getSMatrix(sim=1) graphing.showXYGraph(S[1, 1])
If it guesses wrong, or you want to be sure, you can force it by specifying yUnit="dB" graphing.showXYGraph(S[1, 1], yUnit="dB")
74
Keysight EMPro Scripting Cookbook
5
Post-processing and Exporting Simulation Results
Plotting Multiple Datasets You can plot more than one datasets at once: graphing.showXYGraph(S[1, 1], S[1, 2], yUnit="dB")
Or just plot the whole matrix: graphing.showXYGraph(S)
Setting Labels and Titles The graph title can be set using the title parameter, the axis labels with xLabel and yLabel. If you want to use custom labels in the legend, you can supply a list of strings to the names parameter, one string per dataset. Here’s an example using the “Microstrip Low Pass Filter” example: s11_fdtd = dataset.getResult(sim=1, run=1, object='Port1', ¬ result='SParameters') s11_fem = dataset.getResult(sim=2, run=1, object='Port1', ¬ result='SParameters') graphing.showXYGraph(s11_fdtd, s11_fem, title="FDTD vs FEM", names=["S11 (FDTD)", "S11 (FEM)"], xLabel="f", yLabel="S")
Setting Axis Bounds You can zoom in on the graph by setting xBounds and yBounds to the area of interest. These parameters expect pairs of floats in reference units. That means expressions must be evaluated first. For S-parameters, that means you need to provide limits in linear scale. So say 1 instead of 0 dB: graphing.showXYGraph(S, xBounds=(0, 10e9), yBounds=(float(empro.core.Expression("-50 dBa")), 1))
Working with CITI Files The toolkit contains a citifile module that can be used to read and write CITI files. It has a class CitiFile that behaves a lot like a ordinary dict, and a couple of helper functions.
Exporting S-parameters Exporting just the S-matrix of a simulation can simply be done as follows: from empro.toolkit import citifile, portparam citifile.write("C:/tmp/s-matrix.cti", portparam.getSMatrix(sim=1))
Keysight EMPro Scripting Cookbook
75
5
Post-processing and Exporting Simulation Results
Basically, you can store any combination of dataset or dataset matrices in a CITI file, as long as they share the same dimensions. It’s a matter of populating a CitiFile object and then saving it. Here’s how you can add the port reference impedance: citi = citifile.CitiFile(portparam.getSMatrix(sim=1)) for (i,j), zport in portparam.getRefImpedances(sim=1).items(): assert i == j citi['ZPORT[%s]' % i] = zport citi.write('c:/tmp/s-matrix.cti')
Importing S-parameters Importing is almost as easy. from empro.toolkit import citifile, portparam citi = citifile.read("C:/tmp/s-matrix.cti") print citi.keys() s11 = citi["S[1,1]"]
The asMatrix method recognizes patterns in key names like "S[1,1]" and groups them into one matrix: S = citi.asMatrix('S')
Or directly from citifile to XY-graph: from empro.toolkit import citifile, portparam, graphing graphing.showXYGraph(citifile.read("C:/tmp/s-matrix.cti").asMatrix('S'), yUnit="dBa")
Exporting to Touchstone Files S-parameters can also be exported to Touchstone files using the touchstone toolkit module. Importing Touchstone files is not supported yet. from empro.toolkit import touchstone, portparam touchstone.write("C:/tmp/s-matrix.snp", portparam.getSMatrix(sim=1))
Exporting Surface Sensor Results You have setup a surface sensor in EMPro, and you want to export the data to a text file. These sensors already have sampled the data in discrete vertices (x, y, z) and their DataSets usually have two dimensions: a “Frequency” or “Time” dimension, and “VertexIndex”. The latter is a dimension of indices over a list of vertices, which can be retrieved from the topology attribute. Recipe 5.1 shows a function exportSurfaceSensorData that can export steady-state data of surface sensors to a tabulated text file. It writes one line per vertex. The first three columns contain the x, y and z coordinate values. They 76
Keysight EMPro Scripting Cookbook
5
Post-processing and Exporting Simulation Results
are followed by two columns per frequency containing the field data: real and imaginary parts. So exportSurfaceSensorData assumes the dataset is complex-valued, which is often the case for steady-state data, but it actually has no problem dealing with real-valued data: float also has real and imag attributes since Python 2.6. It’ll just write a lot of zeroes in the imag columns. In case you want to export transient data, you’ll want to write a version that assumes real-valued data, and only write one column per timestep. But that’s left as an exercise for the reader. The list of vertices is retrieved from the topology attribute. ComplexDataSets don’t have that attribute themselves, but their real and imag parts may have. In case you want to override that, or if the dataset doesn’t has a topology at all, you can provide your own list of vertices as an optional function argument. On the first line of the file goes the name and unit of the whole dataset. On the second line go the column titles. For some, the columnHeader function is used, described earlier in “Helper Functions” on page 71. The idiomatic way to write tabular data to a file or output stream, is to build up a list line of the different string fields first, and then to join them with tabs into a single string on line 50. It looks a bit odd to call a method on a string literal, but most Pythonians will recognize this idiom. Finally, there’s loop over all vertex indices. They are used to retrieve the actual vertex coordinate from the vertices list. The dimension vertexIndices actually stores the indices as floats which are not accepted as list indices. So it is required to cast them to an int explicitly (line 55). Again, a list is build with all fields for a single line, and then joined to be written to the file. Recipe 5.1 ExportSurfaceSensor.py # some helper functions def columnHeader(name, unit): return "%s[%s]" % (name, unit.abbreviation()) def strUnit(value, unit): return str(unit.fromReferenceUnits(value)) def exportSurfaceSensorData(dataset, path, vertices=None): from empro import core, units, toolkit # unpack dimensions if [d.name for d in dataset.dimensions()] != ["Frequency", "VertexIndex"]: raise ValueError("dataset must have two dimensions Frequency " "and VertexIndex, in that order. Please, use " "datasets from Surface Sensors") (frequencies, vertexIndices) = dataset.dimensions() # get all 3D vertices if not vertices: try: topology = dataset.topology except AttributeError: try: topology = dataset.real.topology except AttributeError: raise ValueError("dataset must have a topology " "attribute. Please, use a dataset " "returned by "
Keysight EMPro Scripting Cookbook
77
5
Post-processing and Exporting Simulation Results "empro.toolkit.dataset.getResult") vertices = topology.vertices # units for formatting. freqUnit = empro.units.displayUnits()[units.FREQUENCY] lengthUnit = empro.units.displayUnits()[units.LENGTH] dataUnit = empro.units.displayUnits()[dataset.getUnitClass()] # open file, and write info about datatype. out = open(path, 'w') out.write('# %s\n' % columnHeader(dataset.name, dataUnit)) # write column info line = [columnHeader(x, lengthUnit) for x in ('X', 'Y', 'Z')] for freq in frequencies: line += [ "Re,%s %s" % (strUnit(freq, freqUnit), freqUnit.abbreviation()), "Im,%s %s" % (strUnit(freq, freqUnit), freqUnit.abbreviation()), ] out.write('# %s\n' % '\t'.join(line)) # for each point, write xyz triple + # real/imag pairs for each frequency. for (k, vertexIndex) in enumerate(vertexIndices): vertex = vertices[int(vertexIndex)] line = [strUnit(x, lengthUnit) for x in vertex] for i in range(len(frequencies)): x = dataset.at(i, k) line += [strUnit(x.real, dataUnit), strUnit(x.imag, dataUnit)] out.write('\t'.join(line) + '\n') # --- example --if __name__ == "__main__": E = empro.toolkit.dataset.getResult('C:/tmp/Microstrip_50_Ohm.ep', sim=4, run=1, object='Planar Sensor', timeDependence='SteadyState') exportSurfaceSensorData(E, 'C:/tmp/test.txt') print 'done'
Directly Sampling Near Fields (FEM only) In FEM, near field sensors don’t sample on a regular grid like in FDTD, and it’s not limited to discrete grid locations either. So, depending on your needs, it may be more convenient to bring-your-own sample points instead, to directly evaluate the FEM near fields. The fem module of the toolkit provides a class NearField that provides the necessary interface for doing so. In Recipe 5.2 it is shown how to use that to evaluate the electric field in a single point for all available frequencies. evaluateElectricFieldInPoint accepts an initialized NearField object and
a 3D coordinate. It iterates over all frequencies and evaluates the electric field. This results in a triple of complex values—one for each of the X-, Y- and Z-components—and since a real-valued dataset will be returned, the vector magnitude needs to be computed. When doing so, make sure not to sum the 78
Keysight EMPro Scripting Cookbook
5
Post-processing and Exporting Simulation Results
vector components as complex numbers, sum the complex magnitudes instead: √ √
2 2 2
∥e∥ = ∥ex ∥ + ey + ∥ez ∥ = |ex |2 + |ey |2 + |ez |2 Once all values are retrieved, makeDataSet is used to create a frequency dimension and to return the data as a single dataset. Recipe 5.2 DirectSamplingNearFieldFEM.py def evaluateElectricFieldInPoint(nearfield, x, y, z): ''' function to evaluate a single near field point for all available frequencies, returning a dataset. - nearfield: initialized empro.toolkit.fem.NearField instance - x, y, z: position in meters precondition: it is required that nearfield is properly initialized: - it is a NearField object with a simulation result loaded - nearfield.excitation is properly set to a port number ''' import math from empro.toolkit import dataset x, y, z = float(x), float(y), float(z) # evaluate expressions. Es = [] Fs = [] for f in nearfield.frequencies: Fs.append(f) nearfield.frequency = f ex, ey, ez = nearfield.E(x, y, z) Es.append(math.sqrt(abs(ex) ** 2 + abs(ey) ** 2 + abs(ez) ** 2)) frequency = dataset.makeDataSet(Fs, "Frequency", unitClass=empro.units.FREQUENCY) return dataset.makeDataSet(Es, "E(%(x)s,%(y)s,%(z)s" % vars(), dimensions=[frequency], unitClass=empro.units.ELECTRIC_FIELD_STRENGTH) # --- example --if __name__ == "__main__": from empro.toolkit import fem, graphing nearfield = fem.NearField(empro.activeProject, "000005") nearfield.excitation = 1 # = port number x, y, z = "12 mm", "15 mm", "2 mm" E = evaluateElectricFieldInPoint(nearfield, x, y, z) graphing.showXYGraph(E)
Reducing Dataset Dimensions When dealing with multidimensional datasets like far zone data, you sometimes want to reduce the number of dimensions by fixating some of them to a certain value. For example, most far zone field results have three dimensions: “Frequency”, “Theta” and “Phi”. But XY-plots must be one dimensional: you can plot the field strength in function of frequency but only for a fixed theta and phi, you can plot a cross section for all theta if you fix phi and frequency to one value. Load the “EMI Analysis” example to get the “Far Zone Sensor 3D” dataset. To plot it using showXYGraph you need to reduce the three-dimensional dataset to a one-dimensional one. You can do that with reduceDimensionsToIndex of Keysight EMPro Scripting Cookbook
79
5
Post-processing and Exporting Simulation Results
the dataset module in the toolkit. As first argument, you pass the dataset to be reduced. After that you pass a number of keyword arguments: the keywords are the names of the dimensions you want to reduce, and you assign them the index of the value you want to fix the dimension to. In the following example, both “Theta” and “Phi” are fixed to index 18, which in this case happens to be 90 ◦ . from empro.toolkit import dataset, graphing fz = dataset.getResult(sim=2, object='Far Zone Sensor 3D', timeDependence='SteadyState', result='E') fz2 = abs(dataset.reduceDimensionsToIndex(fz, Theta=18, Phi=18)) graphing.showXYGraph(fz2)
How do you figure out what index values to use for the reduced dimensions? Here’s an interesting Python idiom to find the nearest match in a sequence: use min to find the minimum error abs(target - x), paired with the value of interest x. When two pairs are compared, it is done so by comparing them lexicographically [32]. This means that the pair with the smallest first value—the smallest error in this case—will be considered to be the minimum of the list. And so we get the closest match: xs = [-1.35, 3.78, -0.44, 1.8, 0.69, 1.33, -3.55, 2.68, -4.78] target = 1.5 error, best = min( (abs(target-x), x) for x in xs ) print "%s (error=%s)" % (best, error)
This will print: 1.33 (error=0.17)
Using that idiom, findNearestIndex in Recipe 5.3 returns the index of the nearest value within a dataset. Instead of the value x, its index k within the dataset is used as the second value of the pair—which is obviously retrieved using enumerate [23]. Building upon that, reduceDimensionsToNearestValue is a variation of reduceDimensionsToIndex. It has a var-keyword parameter **values [14, 28] to accept arbitrary name=value arguments, where name must be a dimension name and value an expression to which the dimension must be reduced. Using a dict comprehension [27] and findNearestNeighbour, it builds a new dictionary indices where the values are converted to an index within the according dimension. Finally, it forwards the call to reduceDimensionsToIndex passing **indices as individual keyword arguments. As an example, both “Theta” and “Phi” are fixed to "90 deg". Recipe 5.3 ReduceDimensions.py def findNearestIndex(ds, value): ''' Searches within the dataset ds for the element nearest to value, and returns its index within the dataset. ''' value = float(empro.core.Expression(value)) err, index = min((abs(x - value), k) for (k, x) in enumerate(ds)) return index def reduceDimensionsToNearestValue(ds, **values): ''' Similar to dataset.reduceDimensionsToIndex but accepts name=value keyword arguments where name is the name of a dimension of ds,
80
Keysight EMPro Scripting Cookbook
5
Post-processing and Exporting Simulation Results and this dimension will be reduced to the element nearest to value. ''' from empro.toolkit import dataset dimensions = { dim.name: dim for dim in ds.dimensions() } indices = { name: findNearestIndex( dimensions[name], value ) for name, value in values.items() } return dataset.reduceDimensionsToIndex(ds, **indices) # --- example --if __name__ == "__main__": from empro.toolkit import dataset, graphing fz = dataset.getResult(sim=1, object='3D Far Field', timeDependence='SteadyState', result='E') fz2 = reduceDimensionsToNearestValue(fz, Theta="90 deg", Phi="90 deg") graphing.showXYGraph(abs(fz2))
Plotting Far Zone Fields When creating XY or polar graphs, you’ll find that the graph functions only accept one-dimensional datasets. Multi-dimensional datasets like far zone fields are not accepted. In order to plot such data, some work needs to be done to reduce them to one-dimensional datasets. Recipe 5.4 shows you how you can accomplish that. The first part of showFarZoneGraph basically checks if a valid dataset is being supplied. All steady-state far field datasets have three dimensions: the first is “Frequency” and the other two are angular (for example “Theta” and “Phi”). The function is however a bit more liberal than that and also accepts datasets with one or two dimensions3 . This can occur when you already have reduced a threedimensional far zone dataset to fix one or both of the angles to a specific value. If only the frequency dimension is present, showFarZoneGraph falls back to a regular data versus frequency XY plot, see line 28. Next, on line 34, it searches which of the angleDims dimensions are single valued. It builds a dictionary indices that maps their names to zero, the only index possible within a single valued dimension. Later on, this dictionary will be used to reduce the dataset to get rid of these dimensions. Again, a dict comprehension is used with a condition: only dimensions with one value will get an entry. If indices and angleDims are of the same size, then all angular dimensions are single valued, and you again have a data versus frequency plot. Use indices to reduce the dataset to one dimension and show a regular XY graph. The **operator is used to unpack indices as separate keyword arguments [29], since reduceDimensionsToIndex expects them so. If the size of indices and angleDims differs more than one, it means that at least two angular dimensions have more than one value4 . This kind of datasets cannot be plotted using polar graphs and require a 3D plot. An error is raised. 3 If the dataset has only one dimensions, it must be “Frequency”. If it has two dimensions, the first must be “Frequency” and the second must be an angular dimensions. 4 Or both angular dimensions, since there cannot be more than two.
Keysight EMPro Scripting Cookbook
81
5
Post-processing and Exporting Simulation Results
By now, it’s established that there’s exactly one meaningful angular dimension, and you can start making a polar plot over that angle. There’s however still the frequency dimension to deal with. If there’s more than one frequency, a polar plot should be made per frequency, and they should all be superimposed on one graph. The solution to that is of course more reduction. Loop over all frequencies using enumerate so you get the index as well, add it to indices, reduce the dataset and add it to the list perFrequency. At the end, that list should consist of one or more one-dimensional datasets, and you can simply pass that to showPolarGraph by unpacking it with the *-operator. Recipe 5.4 ShowFarZoneGraph.py def strUnit(value, displayUnit): return "%.2f %s" % (displayUnit.fromReferenceUnits(value), displayUnit.abbreviation()) def showFarZoneGraph(dataset, **kwargs): from empro import units from empro.toolkit.dataset import reduceDimensionsToIndex from empro.toolkit.graphing import showXYGraph, showPolarGraph # check if we have proper dimensions. # the first one should be a frequency, the others angular. dimensions = dataset.dimensions() if not dimensions: raise ValueError("dataset must have at least one dimension") freqDim, angleDims = dimensions[0], dimensions[1:] if freqDim.unitClass != units.FREQUENCY: raise ValueError("first dimensions must be frequency") if len(angleDims) > 2: raise ValueError("you can't have more than two angular " "dimensions") if any(dim.unitClass != units.ANGLE for dim in angleDims): raise ValueError("all dimensions except the first must be " "angular") # if no angular dimensions, this is just a data vs. frequency plot if not angleDims: return showXYGraph(dataset, **kwargs) # only one angular dimension should have a length greater than 1 # this will be the angle for the polar plots. # the others should be one angle only, and will be reduced. # so start by making table of dimensions to be reduced. indices = { dim.name: 0 for dim in angleDims if len(dim) == 1 } if len(indices) == len(angleDims): # all angular dimensions are constant. # after reduction, this is a regular data vs. frequency plot reduced = reduceDimensionsToIndex(dataset, **indices) reduced.unitClass = dataset.unitClass return showXYGraph(reduced, **kwargs) elif len(indices) < len(angleDims) - 1: raise ValueError("only one angular dimension should have a " "length greater than 1, the other should be " "constant (one value). You need a 3D plot to " "visualize this dataset, or you need to " "reduce it first.") # now let's make polar plots. # we need one-dimensional datasets, but we still have two # dimensions: frequency and angle. Make datasets per frequency and # add them to one graph. freqUnit = units.displayUnits()[units.FREQUENCY] perFrequency = []
82
Keysight EMPro Scripting Cookbook
5
Post-processing and Exporting Simulation Results for (index, freq) in enumerate(freqDim): indices[freqDim.name] = index reduced = reduceDimensionsToIndex(dataset, **indices) reduced.name = "%s @ %s" % (dataset.name, strUnit(freq, freqUnit)) reduced.unitClass = dataset.unitClass perFrequency.append(reduced) return showPolarGraph(*perFrequency, **kwargs) # --- example --if __name__ == "__main__": from empro.toolkit import dataset fz = dataset.getResult(sim=2, object='2D Far Zone Field Phi=0deg ', timeDependence='SteadyState', result='E') showFarZoneGraph(abs(fz))
Multi-port Weighting The results available in EMPro’s Result Browser and from getResult are mostly single-port excitation results5 . But what do you do if you’re interested in the combined results where more than one port is excited simulatanously? You take advantage of dataset arithmetic. As explained in “Dataset as a Python Number” on page 66, DataSet supports many of the arithmetic operations that can be applied to regular Python numbers. You can add, multiply or scale datasets. You can also sum a list of datasets6 . So you have everything at your disposal to do linear combinations. getWeightedResult of Recipe 5.5 demonstrates this, and acts as a replacement for getResult where the run parameter is replaced by runWeights: a
dictionary of run:weight pairs. It then loops over these pairs, gets the single-port result and scales it, and sums everything at the end. It’s simple enough to fit in a single statement, apart from an import and argument validation. **kwargs again acts like a passthrough dictionary, so that getWeightedResults accepts additional keyword arguments like result='E' which are simply passed to getResult.
NOTE
Whenever you weight vector field data, make sure you weight the separate complex vector components7 , not the magnitudes.
Combining it with Recipe 5.3 and Recipe 5.4, it’s also easy to plot multi-port far field data. Here, the “Theta” and “Phi” vector components are combined separately, because otherwise the phase would not correctly be taken into account. Once you have the weighted components, you can compute the vector 5 This is true for FEM and most of FDTD simulations. The exception are FDTD simulations where you don’t compute S parameter results, so that more than one port can be active in one run. 6 You can also apply sum to a dataset directly, but that will compute the sum of all its values. The sum of a list of datasets will yield a new dataset with the element-by-element sums. 7 “Theta” and “Phi” components for far fields; “X”, “Y” and “Z” for near fields.
Keysight EMPro Scripting Cookbook
83
5
Post-processing and Exporting Simulation Results
magnitude easily as explained in “Directly Sampling Near Fields (FEM only)” on page 78. eFields = [getWeightedResult(sim=1, runWeights=runWeights, object='Far Zone Sensor', timeDependence='SteadyState', result='E', component=component) for component in ('Theta', 'Phi')] eMagnitude = dataset.sqrt(sum(abs(e) ** 2 for e in eFields)) eMagnitude.unitClass = empro.units.ELECTRIC_FIELD_STRENGTH showFarZoneGraph(reduceDimensionsToNearestValue(eMagnitude, Theta="90 deg"))
Recipe 5.5 GetWeightedResult.py def getWeightedResult(context=None, runWeights=None, **kwargs): from empro.toolkit import dataset if not runWeights: raise ValueError("You must supply a dictionary of run:weight " "pairs") return sum(weight * dataset.getResult(context, run=run, **kwargs) for (run, weight) in runWeights.iteritems()) # --- example (using Keysight Phone) --if __name__ == "__main__": runWeights = { 1: 0.75, # GSM Antenna 2: 0.25, # Bluetooth Antenna } eff = getWeightedResult(sim=1, runWeights=runWeights, object='System', timeDependence='SteadyState', result='RadiationEfficiency') for e, f in zip(eff, eff.dimension(0)): print f, ":", e
Maximum Gain, Maximum Field Strength You’ve computed far zone fields all around your antenna, but you’re only interested in the maximum gain per frequency. You can find this number in the 3D plots of the far field, but how do you get in Python? maxPerFrequency in Recipe 5.6 will help with that task. It takes advantage of the fact that you can call the max operator on any dataset to retrieve its maximum value: gain = dataset.getResult(sim=2, object='3D Far Field', timeDependence='SteadyState', result='Gain') print max(gain)
But since you want to know the maximum value per frequency, you’ll first have to reduce the dataset to each frequency. Doing that with a list comprehension results in: freq = gain.dimension(0) print [max(dataset.reduceDimensionsToIndex(gain, Frequency=index)) for index,f in enumerate(freq)]
84
Keysight EMPro Scripting Cookbook
5
Post-processing and Exporting Simulation Results maxPerFrequency slightly generalizes that by first looking which of the
dimensions is the frequency axis. It’s not necessarely the first, and it’s not necessarely called “Frequency”, but it should have the FREQUENCY unit class. There should be exactly one of course, and an exception is raised when this is not the case. Because you cannot use a variable as a keyword argument’s name, a literal dictionary {freq.name: index} is unpacked as keyword argument instead [29]. Once a list of the maximum values has been computed, it is transformed into a dataset using makeDataSet. The original frequency dimension is attached to it, but it is cloned to prevent it being destroyed when the original dataset ds goes out of scope.
NOTE
Whenever you reuse an existing dimension to attach it to a newly created DataSet, you should clone it first. Normal Python’s object lifetime does not count here, and the original dimension is destroyed when its original parent is.
maxPerFrequency only deals with real numbers, so if you want to compute the maximum electrical field, you should add complexPart='ComplexMagnitude'
to the query: eField = dataset.getResult(sim=2, object='3D Far Field', timeDependence='SteadyState', result='E', complexPart='ComplexMagnitude') maxEField = maxPerFrequency(eField) graphing.showXYGraph(maxEField)
Or take advantage of the abs operator: eField = dataset.getResult(sim=2, object='3D Far Field', timeDependence='SteadyState', result='E') maxEField = maxPerFrequency(abs(eField)) graphing.showXYGraph(maxEField)
Recipe 5.6 MaxPerFrequency.py def maxPerFrequency(ds): from empro import units from empro.toolkit.dataset import makeDataSet, \ reduceDimensionsToIndex freqDims = [dim for dim in ds.dimensions() if dim.unitClass == units.FREQUENCY] if len(freqDims) != 1: raise ValueError("dataset should have exactly one frequency " "dimension") freq = freqDims[0] maxValues = [max(reduceDimensionsToIndex(ds, **{freq.name: index})) for index,f in enumerate(freq)] return makeDataSet(maxValues, dimensions=[freq.clone()], name="Max(%s)" % ds.name, unitClass=ds.unitClass) # --- example --if __name__ == "__main__":
Keysight EMPro Scripting Cookbook
85
5
Post-processing and Exporting Simulation Results from empro.toolkit import dataset, graphing gain = dataset.getResult(sim=2, object='3D Far Field', timeDependence='SteadyState', result='Gain') maxGain = maxPerFrequency(gain) graphing.showXYGraph(maxGain)
Integration over Time Suppose you have a time dependent DataSet, and you want to integrate it. It could be a one-dimensional signal like instantaneous power, or maybe a two-dimensional signal like the transient Poynting vectors on a surface sensor. Recipe 5.7 shows you timeIntegrate that helps with this task. Applying it to a one-dimensional transient signal, simply returns a number: p = empro.toolkit.dataset.getResult(sim=1, run=1, object='Port1', result='InstantaneousPower') print timeIntegrate(p)
Integrating Poynting vectors will reduce the dataset dimensionality from two to one, eliminating “Time” and leaving “VertexIndex”. However, if you’re integrating vector data, you must be carefull to integrate the components seperately. Failing to do so will result in integrating the vector magnitude instead, which will yield the wrong result: sx, sy, sz = [dataset.empro.toolkit.getResult(sim=1, run=1, object='Surface Sensor', result='S', component=comp) for comp in ('X', 'Y', 'Z')] Sx, Sy, Sz = [timeIntegrate(s) for s in (sx, sy, sz)]
At the center of timeIntegrate, you can find helper function integrate. It accepts a one-dimensional dataset ds—which of course should be time dependent—and a sequence dts of timestep deltas. The function simply uses the rectangle method to integrate the signal—more advanced methods are left as an excercise to the reader—which is fully implemented on line 16 as a single sum statement fed by a generator expression [15]. Using a generator expression instead of a list comprehension avoids have to build a list of d * dt products in memory, only to iterate over it again to sum all its values. Instead, sum will now add the products while they are computed. For the same reason, izip of itertools [24] is being used instead of the regular zip, because the latter too builds a new list in memory while izip does everything on the fly. dts itself is generated as a list—so we can iterate over it several times—and is simply the difference between the current and the next timestep. xrange is the iterator version of range, and again used to avoid creating the long list of indices
in memory. Depending of the number of the other dimensions, dataset will either be directly integrated to a single number, of a new dataset will be generated. In the latter case, you simply enumerate over the other dimension, each time reducing dataset and integrate it, storing the result in a list. A nice list comprehension will do of course. Using makeDataSet, you turn that list into a DataSet, 86
Keysight EMPro Scripting Cookbook
5
Post-processing and Exporting Simulation Results
attaching the other dimension and setting the right unit class. other is cloned to avoid scoping issues, as explained in “Maximum Gain, Maximum Field Strength” on page 85. Recipe 5.7 TimeIntegration.py def timeIntegrate(dataset): ''' Takes some Time dependent dataset and integrate over time. Returns a dataset without Time dimension, only with VertexIndex dimension ''' from empro import units from empro.toolkit.dataset import makeDataSet, \ reduceDimensionsToIndex from itertools import izip def integrate(ds, dts): assert len(ds) == len(dts) assert len(ds.dimensions()) == 1 assert ds.dimension(0).unitClass == units.TIME return sum(d * dt for (d, dt) in izip(ds, dts)) # make sure there's exactly one time dimension, and get it. timeDims = [dim for dim in dataset.dimensions() if dim.unitClass == units.TIME] if len(timeDims) != 1: raise ValueError("expects a dataset with exactly one time " "dimension.") time = timeDims[0] # compute time deltas. dts = [time[k+1] - time[k] for k in xrange(len(time) - 1)] dts.append(dts[-1]) otherDims = [dim for dim in dataset.dimensions() if dim.unitClass != units.TIME] if not otherDims: return integrate(dataset, dts) elif len(otherDims) == 1: other = otherDims[0] result = [integrate(reduceDimensionsToIndex(dataset, **{other.name: i}), dts) for i,_ in enumerate(other)] return makeDataSet(result, id="Int(%s)" % dataset.name, dimensions=[other.clone()], unitClass=dataset.unitClass) else: raise ValueError("datasets with more than two dimensions are " " not supported.")
Exporting Arbitrary Datasets to CSV Files What about a general function that can export arbitrary datasets of arbitrary dimensions to a general format? The Python Standard Library contains a module csv to work with comma-separated values (CSV) files [21, 19]. Recipe 5.8 shows an function that uses that module to export any number of datasets to a CSV file. So you can export just one dataset, or ten, or a whole S-matrix. They can be real, complex, or a mixture of both. They can be of different unit classes, so you can mix voltage and current data. Keysight EMPro Scripting Cookbook
87
5
Post-processing and Exporting Simulation Results
All datasets must share the same dimensions though: they must have the same number of dimensions; their dimensions should have the same names and unit classes, and should be listed in the same order; and the dimensions should be sampled identically. Although the implementation of the function uses rather advanced Python concepts, using it is very simple. Here’s how you can export both a voltage and current dataset to one file: # exporting two datasets of different result types v = empro.toolkit.dataset.getResult(sim=2, object='Feed', result='V') i = empro.toolkit.dataset.getResult(sim=2, object='Feed', result='I') exportDatasetsToCSV("C:\\tmp\\test1.csv", v, i)
Exporting matrices is equally simple. In the following example, it’s also demonstrated you can use the dialect argument to specify a format that uses tab delimiters instead of comma’s. See [21] for more dialect parameters. # exporting matrices, using a CSV dialect with tabs as delimiter. from empro.toolkit import portparam s = portparam.getSMatrix(sim=1) zref = portparam.getRefImpedances(sim=1) exportDatasetsToCSV("C:\\tmp\\test2.csv", s, zref, dialect="excel-tab")
Function Definition with Arbitrary (Keyword) Parameters Recipe 5.8 shows an advanced function exportDatasetsToCSV that accepts a file path and an arbitrary number of datasets as arguments. It also accepts an optional keyword argument names with a list of column names to be used for each of the datasets. The parameter definition with the * and ** notations may look unfamiliar to you. Here’s what’s going on using the terminology of [14]: exportDatasetsToCSV first defines a positional-or-keyword parameter path and a var-positional parameter *datasets. This will result in the first positional argument to be assigned to path, and all positional arguments following to be gathered as a tuple in datasets. Because no positional-or-keyword parameter for names can follow on a var-positional one, a var-keyword parameter **kwargs is used that gathers undefined keyword arguments. When passing a names argument, it will register as a dictionary entry in kwargs, which can be picked up later. The above description is a very technical one. Let’s try to illustrate this with a simple example: def func(first, *args, **kwargs): print "first:", first print "args:", args print "kwargs:", kwargs func("spam", "bacon", "eggs", "spam", extra="spam")
The first positional argument "spam" will be assigned to first, the others are gathered as a tuple args. The keyword argument extra="spam" will be stored in the dictionary kwargs, and the output will be: first: spam args: ('bacon', 'eggs', 'spam')
88
Keysight EMPro Scripting Cookbook
5
Post-processing and Exporting Simulation Results kwargs: {'extra': 'spam'}
In this manner, exportDatasetsToCSV extracts the names argument from kwargs on line 7. pop works similar to get, except it also removes the entry from the dictionary [20]. This makes sure that the excess can be passed as **kwargs to cvs.writer so that CSV dialect parameters [22] can also be passed as optional arguments to exportDatasetsToCSV. If names wouldn’t be removed from the dictionary, cvs.writer would complain about an unknown keyword argument. pop also takes a default value so that if no names argument is given, the dataset names are used instead.
Boiling Down Inputs to a Real-valued DataSet List exportDatasetsToCSV can receive real or complex datasets—or even whole
matrices—but the CSV columns should only store floating point values8 . This means that the each ComplexDataSet needs to be be replaced by its real and imaginary subparts, and matrices should be replaced by their individual elements. That’s the task of unwrapDatasets. It will generate a list of real-valued DataSet/name pairs that can be directly exported to the CSV file. unwrapDatasets takes a list of datasets and names, and returns a single list unwrapped of real-valued dataset/name pairs. It loops over each dataset/name
pair and checks if the dataset is something more complicated than a real-valued DataSet. If it’s a matrix, it will build a new list of datasets and names, subsets and subnames for all of the elements of the matrix, and it will recursively call exportDatasetsToCSV–so that complex elements can be unwrapped again–and concatenates the result to unwrapped. If not a matrix, it tests for the existence of the real and imag attributes. If an AttributeError is raised, they don’t exist and it is concluded that dataset is a regular real-valued DataSet. It’s simply appended to unwrapped, together with its name. If real and imag do exist, it must have been a ComplexDataSet, and both parts are appended to unwrapped separately. The zip(*...) construction on line 11 is a Python idiom known as unzip [34]. unwrapDatasets returns a single list of dataset/name pairs, but you really want a list of datasets and a list of names. So you unzip by using the * syntax to feed the pairs as individual arguments to zip.9
Writing to the CSV File What’s still left is creating the actual CSV file. On line 17, a writer is initialized with the excess keyword arguments **kwargs. 8 Well, that’s technically not true, CSV data can be anything you want, but limiting yourself to floating point value will make it a lot easier to handle the data in external programs. 9 If you think of matrices as lists of lists, then this is basically transposing the matrix, and this idiom is often used for that too.
Keysight EMPro Scripting Cookbook
89
5
Post-processing and Exporting Simulation Results
On the first line of the file goes a row with column headers which we create from the field names and units. The way columnHeader is used for this is explained before. Just keep in mind you not only need to store the datasets, but also the dimensions. The interesting bit is how enumerateFlat is used to iterate over all dimensions at once. The product function of the itertools module [24]. It takes a number of distinct sequences (like dimensions), and iterates over every possible combination of values, like in a nested loop. Here’s an example with two simple Python lists. from itertools import product print list(product([10, 20, 30], [1, 2]))
This will result in the following list of pairs: [(10, 1), (10, 2), (20, 1), (20, 2), (30, 1), (30, 2)]
If you compare this to the way flat indices iterate over datasets in “Multidimensional Datasets” on page 66, you’ll see this happens in the exact same order. So, that means you can use the product of the dataset dimensions, feed it through enumerate [23], and you’ll be iterating over the flat index of the dataset. So you use enumerateFlat to iterate over all dimensions, and each time you get the flat index k which you can use to retrieve the actual data values from the datasets, and dimVal which is a tuple of the actual values of the dimensions for that record. The dimVal tuple is converted to a list so that the list comprehension can be added to it, and the record is written to the CSV file. Recipe 5.8 ExportToCSV.py def exportDatasetsToCSV(csvfile, *datasets, **kwargs): from empro import units import csv import codecs dimensions = datasets[0].dimensions() names = kwargs.pop("names", [ds.name for ds in datasets]) assert len(datasets) == len(names), \ "You should supply exactly the same number of names as datasets" datasets, names = zip(*unwrapDatasets(datasets, names)) fieldnames = [dim.name for dim in dimensions] + list(names) fieldunits = [units.displayUnits()[x.unitClass] for x in (dimensions + datasets)] writer = csv.writer(open(csvfile, 'wb'), **kwargs) writer.writerow([encodeUtf8(columnHeader(name, unit)) for name, unit in zip(fieldnames, fieldunits)]) for (k, dimVal) in enumerateFlat(dimensions): fields = list(dimVal) + [ds[k] for ds in datasets] writer.writerow([unit.fromReferenceUnits(x) for x, unit in zip(fields, fieldunits)]) def unwrapDatasets(datasets, names=None): from empro.toolkit.dataset import DataSetMatrix unwrapped = [] for ds, name in zip(datasets, names): if isinstance(ds, DataSetMatrix):
90
Keysight EMPro Scripting Cookbook
5
Post-processing and Exporting Simulation Results keys = sorted(ds.keys()) subsets = [ds[key] for key in keys] if ds.isDiagonal(): subnames = ["%s[%s]" % (name, r) for (r, c) in keys] else: subnames = ["%s[%s,%s]" % (name, r, c) for (r, c) in keys] unwrapped += unwrapDatasets(subsets, subnames) else: try: re, im = ds.real, ds.imag except AttributeError: unwrapped.append((ds, name)) else: unwrapped += [ (re, "Re(%s)" % name), (im, "Im(%s)" % name), ] return unwrapped def enumerateFlat(dimensions): from itertools import product return enumerate(product(*dimensions)) def columnHeader(name, unit): if unit.abbreviation(): return "%s[%s]" % (name, unit.abbreviation()) else: return name def strUnit(value, unit): return str(unit.fromReferenceUnits(value)) def encodeUtf8(x): if isinstance(x, unicode): return x.encode("utf8") return x
Exporting Surface Sensor Topology to OBJ file You’ve exported the near field data of a surface sensor so that you can visualize it in another tool, but you also need to accompanying surface geometry. Wavefront OBJ [36] is a simple 3D geometry definition file format and widely supported, which makes it ideal for this purpose. As mentioned in “Exporting Surface Sensor Results” on page 76, surface sensor results have a topology property, so you can export that. It has lists of vertices, vertex normals and facets: Jc = dataset.getResult(sim=1, object='Surface Sensor', result='Jc') topology = Jc.topology print "verts:", len(topology.vertices), topology.vertices[:10], '...' print "normals:", len(topology.vertexNormals), \ topology.vertexNormals[:10], '...' print "facets:", len(topology.facets), topology.facets[:10], '...'
There are as many normals as vertices, and both lists contain (x, y, z) triplets. Keysight EMPro Scripting Cookbook
91
5
Post-processing and Exporting Simulation Results
Each facet is a tuple of indices which refer into the vertex and normal lists10 . The OBJ file format is a plain text format, so it’s just a matter of writing all vertices, normals and facets to the file. Vertices are just three numbers per line, separated by whitespace and prepended by v. Here’s the eight vertices of a cube: v v v v v v v v
-1 -1 -1 -1 +1 +1 +1 +1
-1 -1 +1 +1 -1 -1 +1 +1
-1 +1 -1 +1 -1 +1 -1 +1
Each vertex gets an implicit one-based index: the first vertex of the file gets index 1, the second 2, and so on. Vertex normals are likewise written to the file, but each line starts with vn instead of v: vn vn vn vn vn vn
-1 0 0 +1 0 0 0 -1 0 0 +1 0 0 0 -1 0 0 +1
For a facet, the line starts with f and is then followed by the indices of each of its vertices. Here’s how the six faces of the cube are encoded: f f f f f f
1 3 7 5 1 2
2 4 8 6 3 6
4 8 6 2 7 8
3 7 5 1 5 4
If each facet vertex also has a normal, you write it next to the vertex index, separated with a double slash: f f f f f f
1//1 3//4 7//2 5//3 1//5 2//6
2//5 4//4 8//2 6//3 3//5 6//6
4//1 8//4 6//2 2//3 7//5 8//6
3//1 7//4 5//2 1//3 5//5 4//6
Putting all this together results in exportToOBJ of Recipe 5.9. The vertex coordinates are stored in display units. The normals are stored in reference units as they are normalized. The vertex indices of the facets need to be incremented by one, to translate from zero-based to one-based indexing. Recipe 5.9 ExportToOBJ.py def exportToOBJ(path, topology): ''' export topology in Wavefront OBJ format (because it's a simple format) NOTE: indices are one-based! faces index in the vertex and normal arrays, but they start counting from one. ''' 10 Vertices
92
and normals are ordered in the same way, so the same index is used for both lists.
Keysight EMPro Scripting Cookbook
Post-processing and Exporting Simulation Results
5
lengthUnit = empro.units.displayUnits()[empro.units.LENGTH] strLength = lambda x: str(lengthUnit.fromReferenceUnits(x)) with file(path, "w") as out: out.write("# vertices [%s]\n" % lengthUnit.abbreviation()) for v in topology.vertices: out.write("v %s\n" % " ".join(map(strLength, v))) out.write("# normals\n") for vn in topology.vertexNormals: out.write("vn %s\n" % " ".join(map(str, vn))) out.write("# faces\n") for facet in topology.facets: out.write("f %s\n" % " ".join("%d//%d" % (i+1, i+1) for i in facet)) def columnHeader(name, unit): return "%s [%s]" % (name, unit.abbreviation()) def strUnit(value, unit): return str(unit.fromReferenceUnits(value)) # --- example --if __name__ == "__main__": Jc = empro.toolkit.dataset.getResult(sim=1, object='Surface Sensor', timeDependence='SteadyState', result='Jc') exportToOBJ("C:/tmp/plane.obj", Jc.real.topology)
Keysight EMPro Scripting Cookbook
93
5
94
Post-processing and Exporting Simulation Results
Keysight EMPro Scripting Cookbook
Keysight EMPro Scripting Cookbook
6 Extending EMPro with Add-ons Hello World!
95
Adding Dialog Boxes: Simple Parameter Sweep 97 Extending Context Menu of Project Tree: Cover Wire Body 100
Since 2012, EMPro provides an add-on mechanism that allows you to easily extend the GUI with new functionality that can be written or customized by yourself. Before, one had to copy/paste or import Python scripts in every project you wanted to use it, select the right script in the Scripting editor, and press play. With the new add-on mechanism, it becomes possible to insert new persistent commands in the Tools menu or in the Project Tree’s context menu. Given the nature of this cookbook, it should come as no surprise that these add-ons must be written as Python modules. In this chapter, it is shown how to create one. The Keysight Knowledge Center has a download section where you can find additional add-ons. Take a look at their source code to see how they work, it may help to build your own add-on. Or take an existing one, and modify it for your own purposes. When you’ve created an add-on that you think may be useful for others, you can submit it on the knowledge center so that we may make it available as a user contributed add-on. www.keysight.com/find/eesof-empro-addons
Hello World! To get your feet wet, this chapter starts with the Hello World of the add-ons, to demonstrate the basic elements every add-on should have. Recipe 6.1 shows a minimal implementation that will add a new command Tools > Hello World showing a simple message. The meat and mead of this example add-on is the function helloWorld defined on lines 8 to 10. Calling this function will cause a message box to appear saying ’Hi There ...’ In a real world case, you would of course have something more usefull instead. 95
6
Extending EMPro with Add-ons
The helloWorld function by itself would already make a nice Python module, but it’s not an add-on yet. The missing elements are shown one by one.
Documentation To document add-ons, you simply use docstrings [11] which are Python’s natural mechanism to document modules, classes, or functions: ''' Documenting our Hello World Add-on '''
To add one, simply write a string literal as the first statement in your module. Here, the documentation is a triple quoted blockstring on lines 1 to 3. Although normal string literals will do just fine, most docstrings will be blockstrings because they naturally allow multilined strings. No need to insert /n between lines.
Author and version For the author and version metadata of the add-on, the practice of assigning the __author__ and __version__ variables is adopted: __author__ = "John Doe" __version__ = "1.0"
Although neither are standard and are entirely optional, they are commonly used conventions. If you’re interested, they’re both referred to in the documentation of Epydoc [8], the usage of the __version__ convention is also documented in PEP 396 [12].
Add-on definition Each add-on is required to have an entry-point function _defineAddon. It should not have any parameters, and it must return an instance of empro.toolkit.addon.AddonDefinition. While the documentation, author and version are entirely optional, without the _defineAddon function your python script will not be recognized as a proper add-on. def _defineAddon(): from empro.toolkit import addon return addon.AddonDefinition( menuItem=addon.makeAction('Hello World', helloWorld)
As you can see, almost the whole of _defineAddon exists of a single return statement that returns the add-on definition (lines 14 to 16). The only other statement is to import the addon module (line 13). This will be typical for most add-ons. 96
Keysight EMPro Scripting Cookbook
6
Extending EMPro with Add-ons
For this example, an AddonDefinition with a single menuItem is returned, for which the python function helloWorld defined on lines 8 to 10 is wrapped as an action using empro.toolkit.addon.makeAction. The first argument of makeAction is the caption so that the menu item will show up as Tools > Hello World. When clicked, it will call the function helloWorld, showing a simple message box. Recipe 6.1 HelloWorld.py ''' Documenting our Hello World Add-on ''' __author__ = "John Doe" __version__ = "1.0" def helloWorld(): from empro import gui gui.MessageBox.information("Hello World!", "Hi There ...", gui.Ok, gui.Ok) def _defineAddon(): from empro.toolkit import addon return addon.AddonDefinition( menuItem=addon.makeAction('Hello World', helloWorld) )
Adding Dialog Boxes: Simple Parameter Sweep Most add-ons will probably require some sort of a dialog box for the user to enter some parameters. This is demonstrated in Recipe 6.2. showParameterSweepDialog starts by instantiating a new SimpleDialog with
two command buttons, of which the OK button is renamed. A label is added on line 28 to display some help instructions for the user. Instead of repeating yourself and retyping the add-on’s documentation, you can simply reuse the module’s docstring. That’s easy enough, because the special variable name __doc__ contains it. Just strip the whitespace and you’re done. Next, a drop-down list is added filled with the names of all the editable parameters. Finally, three ExpressionEdit fields are added which are similar to a normal text edit field but optimized for editing expressions. Each time we select another parameter, you’d like to update the unitClass of the expression editors and preserve the display value while doing so. If the current unit is mm and you enter the value 10, you’ll see 10 mm. It’s display value is 10, but it’s real value in references units (meters) is 0.01. If you simply change the unit class to frequency with GHz as display unit, you’ll get 1e-11 GHz instead of the 10 GHz as you would have expected. To fix that, in onParameterSelected you first check if the expression’s formula is a literal number by feeding it to the built-in float operator (line 71). Any more complex formula with operators and units will raise a ValueError and is simply ignored. If it succeeds however, you now have the value in reference units. Convert it to display units with fromReferenceUnits. Then, convert it back to the reference units of the new unit class with toReferenceUnits and set it as the new Keysight EMPro Scripting Cookbook
97
6
Extending EMPro with Add-ons
formula. Call onParameterSelected once on line 81 to initialize the unit classes at the start. When the dialog is dismissed, onFinished is called to perform the actual sweep. simulateParameterSweep of the simulation toolkit module expects the parameters to sweep over as keyword arguments [28]. For example, if you would want to sweep over parameters “foo” and “bar”, you’d type something like simulateParameterSweep(foo=[1, 2], bar=[3, 4]). However, that only works if you know the parameter names when writing the script. In this case, the parameter name is variable, so you can’t do that. The solution on line 96 is to build a dictionary of (parameter name, value sequence) pairs and unpack it as keyword arguments using the **-operator [29]. sequence is somewhat similar to Python’s own range() function but not quite. It only supports the three-argument version, and in contrary to range(), stop
will always be part of the generated sequence. Instead of returning a simple list, it returns a generator over which can be iterated [33]. For a more detailed discussion of floating-point range replacements, see [1]. Recipe 6.2 SimpleParameterSweep.py ''' An example add-on performing a sweep over a single parameter, demonstrating how to add new dialogs to EMPro. To use it, you select the Parameter over which you want to sweep, and set the Start, Stop and Step values (which may be expressions). Finally, you click on Create & Queue Simulations. A new simulation will be created for each of the values in the range Start + k * Step for k = 0,1,2,... until Stop is reached. ''' __author__ = "Keysight Technologies, Inc." __version__ = "1.0" def showParameterSweepDialog(): import empro from empro import core, gui, units dialog = gui.SimpleDialog(gui.Ok | gui.Cancel) dialog.windowFlags &= ~gui.WF_WindowStaysOnTopHint dialog.title = "Simple Parameter Sweep" # rename the OK button to something more meaningfull. dialog.setButtonText(gui.Ok, "Create && Queue Simulations") layout = dialog.layout layout.add( gui.Label( __doc__.strip() )) # for the parameter selector, add a label and a combobox. parameterWidget = gui.Widget() parameterLayout = parameterWidget.layout = gui.HBoxLayout() parameterLayout.spacing = 0 parameterLayout.addWidget( gui.Label("Parameter")) parameterCombo = gui.ComboBox() parameterLayout.addWidget( parameterCombo ) layout.add(parameterWidget) # fill the combo box with all editable parameters. parameters = empro.activeProject.parameters() for name in parameters.names(): if not parameters.isEditable(name): continue parameterCombo.addItem(name)
98
Keysight EMPro Scripting Cookbook
6
Extending EMPro with Add-ons
# add three expression editors. startEdit = gui.ExpressionEdit(0, units.SCALAR, "Start ") layout.add(startEdit) stopEdit = gui.ExpressionEdit(10, units.SCALAR, "Stop ") layout.add(stopEdit) stepEdit = gui.ExpressionEdit(1, units.SCALAR, "Step ") layout.add(stepEdit) editors = (startEdit, stopEdit, stepEdit) def onParameterSelected(index): ''' If possible, change unit of editors to that of new parameter, but preserve the display value so that 1 mm becomes 1 GHz instead of 1e-12 GHz ''' # figure out unit class by running it through an Expression. parameter = parameterCombo.itemText(index) formula = empro.activeProject.parameters().formula( parameter ) unitClass = core.Expression( formula ).unitClass() unit = units.displayUnits()[unitClass] for edit in editors: if edit.unitClass() == unitClass: continue try: refValue = float(edit.expression().formula()) except ValueError: pass # formula is more than just a literal, ignore else: oldUnit = units.displayUnits()[edit.unitClass()] displayValue = oldUnit.fromReferenceUnits(refValue) edit.setExpression(unit.toReferenceUnits(displayValue)) edit.setUnitClass(unitClass) parameterCombo.onCurrentIndexChanged = onParameterSelected onParameterSelected(0) # run it once, to init the editors. def onFinished(code): ''' When Create & Queue Simulations is clicked, perform sweep. ''' if code != dialog.Accepted: return from empro.toolkit.simulation import simulateParameterSweep # translate expressions to floating point values (start, stop, step) = [float(edit.expression()) for edit in editors] # pass dict of parameter:sequence pair as keyword arguments parameter = parameterCombo.currentText() kwargs = {parameter : list(sequence(start, stop, step))} simulateParameterSweep(**kwargs) dialog.onFinished = onFinished dialog.show(True) def sequence(start, stop, step): ''' Somewhat similar to Python's range(), but for floating point. In contrary to range(), stop will be included in the sequence. ''' from itertools import count for k in count(): value = start + k * step if value >= (stop - .05 * step): # exit loop when value reaches stop within 5% of step yield stop return yield value
Keysight EMPro Scripting Cookbook
99
6
Extending EMPro with Add-ons
def _defineAddon(): from empro.toolkit import addon return addon.AddonDefinition( menuItem=addon.makeAction('Simple Parameter Sweep', showParameterSweepDialog, icon=":/application/ParameterSweep.ico") )
Extending Context Menu of Project Tree: Cover Wire Body Add-ons can also be used to add new menu items to the context menu of the items in the Project Tree. To demonstrate how, the function sheetFromWireBody from Recipe 2.3 is taken and turned it into an add-on so it can be applied to any Wire Body from the user interface. coverWireBody is the heart of the add-on. It’s role is similar to coverAllWireBodies, but it only replaces a single Wire Body. It searches the project’s geometry for the Assembly containing wirebody on line 34. Then, it figures out the index within assembly so it can replace the original part by the covered copy. If it fails to find the index, a ValueError exception is raised, and covered is simply appended. _doCoverWireBody wraps coverWireBody so that it can be called as an menu action. It takes one parameter selection that will hold the list of selected items
when called from the context menu. It is made optional so that the same function can double as an action for the Tools menu. If no arguments are passed, selection will be None, in which case it defaults to the list of selected items from the globalSelectionList (line 49). The function goes on to verify that exactly one Wire Body is selected and finally calls coverWireBody. By splitting the functionality over two functions coverWireBody and _doCoverWireBody, the former function can easily be reused. When the add-on is enabled, it can be imported as module empro.addons.CoverWireBody so that the functions sheetFromWireBody and coverWireBody can easily be called from other scripts. The leading underscore of _doCoverWireBody is a convention used to indicate that a function is considered a private implementation detail, and not usefull for others. Context menus need to be populated based on the context, so instead of simply defining menu items, you need to supply some logic to analyze the context. This needs to come in the form of a function that will be called each time the context menu is to be shown, and it needs to compute which menu items to add. In this recipe, that function is _onContextMenu. It takes two parameters: the list of the selected items, and the types of these items as a set. The former is the same as for _doCoverWireBody, but the latter might require some more explanation. Say you have four Wire Bodies and two Assemblies selected in the user interface. So selection will be a list of six items. To know if any of them is a Wire Body, you’d need to iterate over each item and test its type: hasSketch = any(isinstance(x, geometry.Sketch) for x in selection)
100
Keysight EMPro Scripting Cookbook
6
Extending EMPro with Add-ons
This is however a linear operation, and predicates like this must be efficient: they’re called every time the context menu is shown. You don’t want heavy operations there. To avoid that, the set of the select types is provided as an extra parameter. In the example above, selectedTypes will contain two elements: geometry.Sketch and geometry.Assembly. Checking if any of the selected items is a Wire Body simply becomes a containment test: hasSketch = geometry.Sketch in selectedTypes
Once it is determined the context is right, a menu item is created using addon.makeContextAction and returned to be inserted in the menu. It’s a different maker than for regular menu items because of the selection parameter. If nothing (or None) is returned, no menu items will be inserted. The last thing you need to do to get the context menu working, is to define it properly in _defineAddon, by setting onContextMenu. menuItem is also defined so that you get a regular menu item in Tools too. Recipe 6.3 CoverWireBody.py ''' A simple add-on converting a Wire Body into a Sheet Body, demonstrating how to add new items to the Project Tree's context menu. ''' __author__ = "Keysight Technologies, Inc." __version__ = "1.0" def sheetFromWireBody(wirebody, name=None): ''' Creates a Sheet Body by covering a Wire Body. A new Model is returned. wirebody is cloned so the original is unharmed. ''' from empro import geometry model = geometry.Model() model.recipe.append(geometry.Cover(wirebody.clone())) model.name = name or wirebody.name return model def coverWireBody(wirebody, assembly=None): ''' Replaces a wirebody by a sheet. - wirebody: a geometry.Sketch. Should be a part of the project's geometry. If it isn't, the sheet will be appended to the project. ''' from empro import activeProject covered = sheetFromWireBody(wirebody) parts = activeProject.geometry() assembly = (parts.pathToPart(wirebody) or (parts,))[-1] try: index = assembly.index(wirebody) except ValueError: assembly.append(covered) else: assembly[index] = covered def _doCoverWireBody(selection=None): ''' Function called when the menu item is clicked. '''
Keysight EMPro Scripting Cookbook
101
6
Extending EMPro with Add-ons from empro import geometry, gui if not selection: selection = gui.SelectionList.globalSelectionList().selection() if not (len(selection) == 1 and isinstance(selection[0], geometry.Sketch)): gui.MessageBox.critical("Cover Wire Body", "The Cover Wire Body add-on expects " "exactly one Wire Body to be " "selected.\n" "Select one Wire Body and try again.", gui.Ok, gui.Ok) return coverWireBody(selection[0]) def _onContextMenu(selection, selectedTypes): ''' Filter for context menu to only allow sketches. ''' from empro import geometry from empro.toolkit import addon if len(selection) == 1 and geometry.Sketch in selectedTypes: return addon.makeContextAction("Cover Wire Body", _doCoverWireBody, icon=":/geometry/CoverWirebody.ico") def _defineAddon(): from empro.toolkit import addon return addon.AddonDefinition( menuItem=addon.makeAction("&Cover Wire Body", _doCoverWireBody, icon=":/geometry/CoverWirebody.ico"), onContextMenu=_onContextMenu )
102
Keysight EMPro Scripting Cookbook
References [1] ActiveState Python Recipes. frange(), a range function with float increments. http://code.activestate.com/recipes/66472-frange-a-range-function-with-float-increments/. [2] E. W. Dijkstra. Why numbering should start at zero. 1982. http://www.cs.utexas.edu/~EWD/ewd08xx/EWD831.PDF. [3] EIA/JESD59 - Bond Wire Modeling Standard. 1997. http://www.jedec.org/sites/default/files/docs/jesd59.pdf. [4] EMPro Documentation. Defining Parameters. http://www.keysight.com/find/eesof-knowledgecenter. [5] EMPro Documentation. Editing Bondwire Definition. http://www.keysight.com/find/eesof-knowledgecenter. [6] EMPro Documentation. Python Reference: empro.mesh.PartGridParameters. http://www.keysight.com/find/eesof-knowledgecenter. [7] EMPro Documentation. Using Python Scripts. http://www.keysight.com/find/eesof-knowledgecenter. [8] Epydoc. Module metadata variables. http://epydoc.sourceforge.net/epydoc.html#module-metadata-variables. [9] Hoyt Koepke. 10 Reasons Python Rocks for Research (And a Few Reasons it Doesn’t). http://www.stat.washington.edu/~hoytak/blog/whypython.html. [10] B. Miller and D. Ranum. How to Think Like a Computer Scientist - List Slices. http://interactivepython.org/courselib/static/thinkcspy/Lists/lists.html#list-slices. [11] PEP 257 - Docstring Conventions. http://www.python.org/dev/peps/pep-0257/. [12] PEP 396 - Module Version Numbers. http://www.python.org/dev/peps/pep-0396/. [13] The Python Glossary. EAFP. http://docs.python.org/2/glossary.html#term-eafp. [14] The Python Glossary. Parameter. http://docs.python.org/2/glossary.html#term-parameter. [15] Python HOWTOs. Generator expressions and list comprehensions. http : / / docs . python . org / 2 / howto / functional . html # generator - expressions - and - list comprehensions. [16] The Python Language Reference. Boolean Operations – and, or, pynot. http://docs.python.org/release/2/library/stdtypes.html#boolean-operations-and-or-not. [17] The Python Language Reference. Conditional Expressions. http://docs.python.org/2/reference/expressions.html#conditional-expressions. [18] The Python Language Reference. Special method names. http://docs.python.org/2/reference/datamodel.html#special-method-names. [19] Python Module of the Week. csv – Comma-seperated value files. http://pymotw.com/2/csv/index.html. [20] The Python Standard Library. Mapping Types – dict. http://docs.python.org/2/library/stdtypes.html#mapping-types-dict. [21] The Python Standard Library. csv - CSV File Reading and Writing. http://docs.python.org/2/library/csv.html. Keysight EMPro Scripting Cookbook
103
[22] The Python Standard Library. csv.writer. http://docs.python.org/2/library/csv.html#csv.writer. [23] The Python Standard Library. enumerate. http://docs.python.org/2/library/functions.html#enumerate. [24] The Python Standard Library. itertools – Functions creating iterators for efficient looping. http://docs.python.org/2/library/itertools.html. [25] The Python Standard Library. partial. http://docs.python.org/2/library/functools.html#functools.partial. [26] The Python Standard Library. Sequence Types – str, unicode, list, tuple, bytearray, buffer, xrange. http://docs.python.org/2/library/stdtypes.html#sequence- types- str- unicode- list- tuplebytearray-buffer-xrange. [27] The Python Tutorial. Dictionaries. http://docs.python.org/2/tutorial/datastructures.html#dictionaries. [28] The Python Tutorial. Keyword Arguments. http://docs.python.org/2/tutorial/controlflow.html#keyword-arguments. [29] The Python Tutorial. Unpacking Argument Lists. http://docs.python.org/2/tutorial/controlflow.html#tut-unpacking-arguments. [30] John W. Shipman. Duck typing, or: what is an interface? http://infohost.nmt.edu/tcc/help/pubs/python/web/interface.html. [31] stackoverflow. not None test in Python. http://stackoverflow.com/questions/3965104/not-none-test-in-python/3965129#3965129. [32] stackoverflow. Python tuple comparison. http://stackoverflow.com/questions/5292303/python-tuple-comparison. [33] stackoverflow. The Python yield keyword explained. http://stackoverflow.com/questions/231767/the-python-yield-keyword-explained/231855# 231855. [34] stackoverflow. Unzipping and the * operator. http://stackoverflow.com/questions/5917522/unzipping-and-the-operator. [35] Guido van Rossum. Why Python uses 0-based indexing. https://plus.google.com/115212051037621986145/posts/YTUxbXYZyfi. [36] Wavefront OBJ file format. http://paulbourke.net/dataformats/obj/. [37] wxPython Wiki. Passing Arguments to Callbacks. http://wiki.wxpython.org/Passing%20Arguments%20to%20Callbacks. [38] XKCD. Python. http://www.xkcd.com/353/.
104
Keysight EMPro Scripting Cookbook
Index Symbols __author__, 96 __version__, 96 _defineAddon, 96
results, 71 topology, 76
dataset toolkit, 72
DataSetMatrix, 67 dialog box, 97
A abbreviation, 68 abs, 67 active ports, 58 add-ons, 95
AddonDefinition, 97 allAbbreviations, 68 allBondwires, 31 amplitudeMultiplier, 44 Arc, 35, 36 Assembly, 15
B backend units, 70 Bondwire, 27
BondwireDefinition, 27 Boolean, 33 Box, 16
C
dimension, 65 dimensions, 65
78
exportDatasetsToCSV, 88 exportSurfaceSensorData, 76 exportToOBJ, 92 Expression, 16 ExpressionEdit, 97 Extrude, 22 extrudeFromWireBody, 22
F
toolkit, 74 as number, 66, 83 as sequence, 64 class, 63 complex numbers, 67 dimensions, 65 integrals, 86 matrices, 67, 75, 76
Keysight EMPro Scripting Cookbook
makeAction, 97 makeExponentialWaveguide, 32
makeLoft, 33 makePolygon, 19 makePolyline, 19 E makeWaveguide, 46 maxPerFrequency, 84 enumerate, 22, 80, 90 menuItem, 97 enumerateFlat, 90 , 15 evaluateElectricFieldInPointModel ,
graphing
DataSet
M
display units, 70 docstrings, 96
far zone graphs, 81, 83 CircuitComponent, 43 sensors, 52 CircuitComponentDefinition, FBM, 15 44 Feature, 15 CITI files, 75 femFrequencyPlanList, 59 columnHeader, 71 findNearestIndex, 80 ComplexDataSet, 67 flatList, 30 context, 72 foiParameters, 59 context menu, 100 frequencies of interest, 59 conversionMultiplier, 68 FrequencyPlan, 59 conversionOffset, 68 fromReferenceUnits, 68, 97 Cover, 20, 21 coverAllWireBodies, 21, 100 coverWireBody, 100 G crossSectionCircular, 36 getPortNumber, 58 csv, 87 getSMatrix, 67 CSV files, 51, 87 getWeightedResult, 83
D
line segment, 18 Loft, 32 logScale, 68
J JEDEC, 28
multidimensional datasets, 66, 79
N near field results, 76, 78 sensors, 52 topology, 91
numberOfDimensions, 65
P parameters definition, 17 sweeping, 97 Part, 15
pathRectangular, 35 plotting, 74 polar graphs, 81 polygon, 19 polyline, 19 ports internal, 43 Poynting vectors, 86 projectId, 72
R recipe, 15 reduceDimensionsToIndex, 79 reduceDimensionsToNearestValue, 80 reference units, 68
replace_waveform, 50 ResultQuery, 71 RFID antenna, 35
L Line, 18, 35
105
S S-parameters, 58, 67, 75, 76
setFrequenciesOfIntereset, 59 Sheet Body, 20
sheetFromWireBody, 20, 100 showFarZoneGraph, 81 showPolarGraph, 81 showXYGraph, 74 SimpleDialog, 97 simulationId, 72 Sketch, 15, 18, 35 steady-state frequencies, 59 strUnit, 71 SurfaceSensor, 52 SweepPath, 35
T timeIntegrate, 86 TimestepSampledWaveformShape, 49
topology, 76 toReferenceUnits, 68, 97 Touchstone files, 76 traversing geometry, 21
U unitByAbbreviation, 68 unitByName, 68 unitClass, 68 User Defined Waveform, 48
V Vector2d, 17 Vector3d, 17 VertexIndex, 76
W waveforms, 50 waveguide ports, 45 Wire Body, 18, 100
X XY graph, 74
106
Keysight EMPro Scripting Cookbook
Printed copies may not be current. Check the Knowledge Center for the latest version. www.keysight.com/find/eesof-empro-python-cookbook Keysight EMPro Scripting Cookbook
107