Perspective Property Bindings Returning "null" until clicked on

Hello,

I have a view canvas with a bunch of instances on it. These instances have tag bindings on an "xy" parameter. As you can see in the screenshot below, they're returning null.

When I click on the binding and edit it, however, the binding evaluates. It then continues to evaluate when it's supposed to (in this case it refreshes when the currentDateTime tag changes value).

I'm creating the bindings on this component by first editing the view.json via a button press that reads the view json and dynamically creates ViewCanvas instances based on a number of UDT instances. After the view.json has the new instances written to it, my script writes the bindings to the properties on my ViewCanvas instances.

What I find strange is that the "xy" viewParam property of the first several instances, which point to a different viewPath and have different bindings, has a binding that begins evaluating immediately after writing to the view.json file.

I'd appreciate any help - thanks!

While this definitely doesn't sound like it's working correctly... The way you're creating these bindings is weird, and very much not optimal for performance.

A regular tag binding or two on the tag you're reading in the transform script is going to be significantly faster, automatically update whenever the tag changes, and not be subject to whatever weird behavior you're seeing here, guaranteed.

1 Like

Thanks for the quick response!

If the weirdness you're referring to is on account of me writing to the view.json... I'm creating the bindings this way only once when deploying this. I've basically made a template which works with a variable number of AGVs / Stations. I have a script that browses the tags on my PLC and creates x number of AGV and Station UDT instances and ViewCanvas instances, where x is defined by numAGV and numStation tags. I want to avoid creating each AGV and station ViewCanvas instance (and their bindings) manually for all 100+ AGVs and stations. The onClick scripts which build these instances and their bindings is enabled / disabled based on the device.type session property, so this can only be done from within the designer.

I should clarify that the xy property of the AGV is bound to the position tag of the AGV.

I'm binding the XY property of the station to the gateway time only for testing purposes. This xy value will not change after reading, since the POS_Start and POS_End tag values for the station UDT instance won't change. I could honestly change this so that the station XY property is set only when the view loads, but I'd first like to understand why this is working for my AGVs and not for my Stations.

  • Why have you created an indirect tag binding to a direct tag?
  • Why are you reading Gateway/CurrentDateTime and then not using it in your script transform? If you want to poll periodically then use an Expression Binding now(5000) for every 5 s.

Post the whole script and format it using the </> button.

2 Likes

I was using direct tag bindings before and had the same issue. I can't recall exactly why I decided to test it with indirect tag bindings (something I saw on the forum led me to try this). Thanks for suggesting using expression bindings - I'll use now() from now on.

Here's the binding on ViewCanvas.props.instances[12].viewParams.xy (station instance):

	station_number = 5
	plc = 'Engine'
	line_number = 1
	POS_End = system.tag.readBlocking(["{}/Line/Line {}/Station {}/POS_End".format(plc, line_number, station_number)])[0].value
	POS_Start = system.tag.readBlocking(["{}/Line/Line {}/Station {}/POS_Start".format(plc, line_number, station_number)])[0].value
	POS_Center = (POS_End - POS_Start)/2 + POS_Start

	agvMap = system.tag.readBlocking(["[default]{}/MCP/TrackSection/path".format(plc)])[0].value
	
	# Convert keys to integers and sort them
	sorted_keys = sorted(agvMap.keys(), key=lambda x: int(x))
	
	# Linear search to find the two closest keys
	def linear_search(sorted_keys, value):
		previous_key = sorted_keys[0]
		for key in sorted_keys[1:]:
			if int(key) >= int(value):
				# Interpolate between the two keys
				lower_key = str(previous_key)
				upper_key = str(key)
				lower_x, lower_y = agvMap[lower_key]["x"], agvMap[lower_key]["y"]
				upper_x, upper_y = agvMap[upper_key]["x"], agvMap[upper_key]["y"]
				ratio = (value - int(lower_key)) / float((int(upper_key) - int(lower_key)))
				interpolated_x = round(float(str(lower_x)) + ratio * (float(str(upper_x)) - float(str(lower_x))), 4)
				interpolated_y = round(float(str(lower_y)) + ratio * (float(str(upper_y)) - float(str(lower_y))), 4)
				return [interpolated_x, interpolated_y]
			previous_key = key
	
		# If value is greater than all keys, use the last key
		if value > int(sorted_keys[-1]):
			return [agvMap[sorted_keys[-1]]["x"], agvMap[sorted_keys[-1]]["y"]]
	
	interpolated_coordinates = linear_search(sorted_keys, POS_Center)
	return interpolated_coordinates

And here's the script on an AGV instance (ViewCanvas.props.instances[2].viewParams.xy):

	line_number = str(system.tag.readBlocking(["[default]Engine/AGV/AGV 3/POS_Sta"])[0].value)
	if len(line_number) == 1:
		line_number = "00{}".format(line_number)
	line_number = line_number[0]
	agvMap = system.tag.readBlocking(["[default]Engine/MCP/TrackSection/path"])[0].value
	
	
	# Convert keys to integers and sort them
	sorted_keys = sorted(agvMap.keys(), key=lambda x: int(x))
	
	# Linear search to find the two closest keys
	def linear_search(sorted_keys, value):
		previous_key = sorted_keys[0]
		for key in sorted_keys[1:]:
			if int(key) >= value:
				# Interpolate between the two keys
				lower_key = str(previous_key)
				upper_key = str(key)
				lower_x, lower_y = agvMap[lower_key]["x"], agvMap[lower_key]["y"]
				upper_x, upper_y = agvMap[upper_key]["x"], agvMap[upper_key]["y"]
				ratio = (value - int(lower_key)) / float((int(upper_key) - int(lower_key)))
				interpolated_x = round(float(str(lower_x)) + ratio * (float(str(upper_x)) - float(str(lower_x))), 4)
				interpolated_y = round(float(str(lower_y)) + ratio * (float(str(upper_y)) - float(str(lower_y))), 4)
				return [interpolated_x, interpolated_y]
			previous_key = key
	
		# If value is greater than all keys, use the last key
		if value > int(sorted_keys[-1]):
			return [agvMap[sorted_keys[-1]]["x"], agvMap[sorted_keys[-1]]["y"]]
	
	interpolated_coordinates = linear_search(sorted_keys, value)
	return interpolated_coordinates

EDIT: I updated my binding creation script so that the xy value of the Station instances update based on a boolean memory tag "Update All Displays". The problem still exists however, since it's an issue of the binding not even evaluating the tag value until I click on the binding to update it. I'll also post my binding creation scripts below.

def build_canvas_instances(self):
	if self.session.props.device.type == "designer":
		height = 800
		width = 1200
	else:
		height = self.page.props.dimensions.viewport.height
		width = self.page.props.dimensions.viewport.width
	canvas_instances = []
	plcs = []
	folders = system.tag.browse("[default]", filter={"tagType" : "Folder"})
	for folder in folders:
		if folder["name"] != "_types_":
			plcs.append({
				"name" : folder["name"],
				"fullPath" : folder["fullPath"]
			})
	for plc in plcs:
		agv_list = system.tag.browse('''{}/AGV'''.format(plc["fullPath"]), filter={})
		for agv in agv_list:
			agv_instance = {
			  "position": "absolute",
			  "top": "0px",
			  "left": "0px",
			  "bottom": "auto",
			  "right": "auto",
			  "width": "24px",
			  "height": "24px",
			  "zIndex": "auto",
			  "viewPath": "Overview/Subviews/AGV Instance",
			  "viewParams": {
			    "agv_number": agv["name"][agv["name"].find(" ")+1:],
			    "height": "24px",
			    "plc": plc["name"],
			    "width": "24px",
			    "position" : {
			      "y_min": 297.28,
			      "x_max": 555.72,
			      "y_max": 377.28,
			      "x_min": 579.72
			    }
			  },
			  "style": {
			    "classes": ""
			  }
			}
			canvas_instances.append(agv_instance)
		line_tags = system.tag.browse('''{}/Line'''.format(plc["fullPath"]), filter={})
		line_list = []
		for line in line_tags:
			line_list.append(line["fullPath"])
		stations = []
		for line_path in line_list:
			for item in system.tag.browse(line_path, filter={}):
				if item["name"].find("Station") != -1:
					stations.append({
					"station" : item["name"][item["name"].find(' '):].strip(' '),
					"line" : str(line_path)[str(line_path).find(" "):].strip(" ")
					})
		for item in stations:
			station_instance = {
				"position": "absolute",
				"top": "0px",
				"left": "0px",
				"bottom": "auto",
				"right": "auto",
				"zIndex": "auto",
				"width": width,
				"height": height,
				"viewPath": "Overview/Subviews/Station Instance",
				"viewParams": {
					"height": height,
					"line_number": item["line"],
					"plc": plc["name"],
					"station_number": item["station"],
					"width": width,
				    "position" : {
				      "y_min": 297.28,
				      "x_max": 555.72,
				      "y_max": 377.28,
				      "x_min": 579.72
				    }
				},
				"style": {
				"classes": ""
				}
			}
			canvas_instances.append(station_instance)
	return canvas_instances
	
	
def buildStationBinding(i, station_number, plc, line_number):
	xyCode = "\t# Read ending and starting positions\n\tstation_number = "+station_number+"\n\tplc = "+"""'{}'""".format(plc)+"\n\tline_number = "+line_number+"\n\tPOS_End = system.tag.readBlocking([\"{}/Line/Line {}/Station {}/POS_End\".format(plc, line_number, station_number)])[0].value\n\tPOS_Start = system.tag.readBlocking([\"{}/Line/Line {}/Station {}/POS_Start\".format(plc, line_number, station_number)])[0].value\n\tPOS_Center = (POS_End - POS_Start)/2 + POS_Start\n\n\tagvMap = system.tag.readBlocking([\"[default]{}/MCP/TrackSection/path\".format(plc)])[0].value\n\t\n\t# Convert keys to integers and sort them\n\tsorted_keys = sorted(agvMap.keys(), key=lambda x: int(x))\n\t\n\t# Linear search to find the two closest keys\n\tdef linear_search(sorted_keys, value):\n\t\tprevious_key = sorted_keys[0]\n\t\tfor key in sorted_keys[1:]:\n\t\t\tif int(key) >= int(value):\n\t\t\t\t# Interpolate between the two keys\n\t\t\t\tlower_key = str(previous_key)\n\t\t\t\tupper_key = str(key)\n\t\t\t\tlower_x, lower_y = agvMap[lower_key][\"x\"], agvMap[lower_key][\"y\"]\n\t\t\t\tupper_x, upper_y = agvMap[upper_key][\"x\"], agvMap[upper_key][\"y\"]\n\t\t\t\tratio = (value - int(lower_key)) / float((int(upper_key) - int(lower_key)))\n\t\t\t\tinterpolated_x = round(float(str(lower_x)) + ratio * (float(str(upper_x)) - float(str(lower_x))), 4)\n\t\t\t\tinterpolated_y = round(float(str(lower_y)) + ratio * (float(str(upper_y)) - float(str(lower_y))), 4)\n\t\t\t\treturn [interpolated_x, interpolated_y]\n\t\t\tprevious_key = key\n\t\n\t\t# If value is greater than all keys, use the last key\n\t\tif value > int(sorted_keys[-1]):\n\t\t\treturn [agvMap[sorted_keys[-1]][\"x\"], agvMap[sorted_keys[-1]][\"y\"]]\n\t\n\t# Perform linear search to find the closest \u0027mm_pos\u0027 that does not exceed the \u0027value\u0027\n\tinterpolated_coordinates = linear_search(sorted_keys, POS_Center)\n\treturn interpolated_coordinates",
	binding_data = {
	   "props.instances[{}].height".format(i): {
	      "binding": {
	        "config": {
	          "path": "view.custom.station_height"
	        },
	        "type": "property"
	      }
	    },
	    "props.instances[{}].left".format(i): {
	      "binding": {
	        "config": {
	          "path": "this.props.instances[{}].viewParams.xy[0]".format(i)
	        },
	        "overlayOptOut": True,
	        "transforms": [
	          {
	            "code": "\tx = float(str(value))*100 - ((self.props.instances["+str(i)+"].width/self.view.props.defaultSize.width)/2)*100\n\treturn \"{}%\".format(x)",
	            "type": "script"
	          }
	        ],
	        "type": "property"
	      }
	    },
	    "props.instances[{}].top".format(i): {
	      "binding": {
	        "config": {
	          "path": "this.props.instances[{}].viewParams.xy[1]".format(i)
	        },
	        "transforms": [
	          {
	            "code": "\ty = float(str(value))*100 - ((self.props.instances["+str(i)+"].height/self.view.props.defaultSize.height)/2)*100\n\treturn \"{}%\".format(y)",
	            "type": "script"
	          }
	        ],
	        "type": "property"
	      }
	    },
	    "props.instances[{}].viewParams.xy".format(i): {
			"binding": {
			  "type": "tag",
			  "config": {
  			    "mode": "direct",
			    "tagPath": "[default]Update All Displays",
			    "fallbackDelay": 2.5
			  },
			  "transforms": [
			    {
				  "code": xyCode,
			      "type": "script"
			    }
			  ]
			}
	    },
	    "props.instances[{}].width".format(i): {
	      "binding": {
	        "config": {
	          "path": "view.custom.station_width"
	        },
	        "overlayOptOut": True,
	        "type": "property"
	      }
	    }
	}
	return binding_data
	
def buildAGVBinding(i, agv_num, plc):
	code = "\tline_number = str(system.tag.readBlocking([\"[default]"+str(plc)+"/AGV/AGV "+str(agv_num)+"/POS_Sta\"])[0].value)\n\tif len(line_number) == 1:\n\t\tline_number = \"00{}\".format(line_number)\n\tline_number = line_number[0]\n\tagvMap = system.tag.readBlocking([\"[default]"+str(plc)+"/MCP/TrackSection/path\"])[0].value\n\t\n\t\n\t# Convert keys to integers and sort them\n\tsorted_keys = sorted(agvMap.keys(), key=lambda x: int(x))\n\t\n\t# Linear search to find the two closest keys\n\tdef linear_search(sorted_keys, value):\n\t\tprevious_key = sorted_keys[0]\n\t\tfor key in sorted_keys[1:]:\n\t\t\tif int(key) >= value:\n\t\t\t\t# Interpolate between the two keys\n\t\t\t\tlower_key = str(previous_key)\n\t\t\t\tupper_key = str(key)\n\t\t\t\tlower_x, lower_y = agvMap[lower_key][\"x\"], agvMap[lower_key][\"y\"]\n\t\t\t\tupper_x, upper_y = agvMap[upper_key][\"x\"], agvMap[upper_key][\"y\"]\n\t\t\t\tratio = (value - int(lower_key)) / float((int(upper_key) - int(lower_key)))\n\t\t\t\tinterpolated_x = round(float(str(lower_x)) + ratio * (float(str(upper_x)) - float(str(lower_x))), 4)\n\t\t\t\tinterpolated_y = round(float(str(lower_y)) + ratio * (float(str(upper_y)) - float(str(lower_y))), 4)\n\t\t\t\treturn [interpolated_x, interpolated_y]\n\t\t\tprevious_key = key\n\t\n\t\t# If value is greater than all keys, use the last key\n\t\tif value > int(sorted_keys[-1]):\n\t\t\treturn [agvMap[sorted_keys[-1]][\"x\"], agvMap[sorted_keys[-1]][\"y\"]]\n\t\n\t# Perform linear search to find the closest \u0027mm_pos\u0027 that does not exceed the \u0027value\u0027\n\tinterpolated_coordinates = linear_search(sorted_keys, value)\n\treturn interpolated_coordinates"
	binding_data = {
		"props.instances[{}].left".format(i): {
			"binding": {
			  "config": {
			    "path": "this.props.instances[{}].viewParams.xy[0]".format(i)
			  },
			  "transforms": [
			    {
			      "code": "\treturn value * self.view.props.defaultSize.width",
			      "type": "script"
			    }
			  ],
			  "type": "property"
			}
		},
		"props.instances[{}].top".format(i): {
			"binding": {
			  "config": {
			    "path": "this.props.instances[{}].viewParams.xy[1]".format(i)
			  },
			  "transforms": [
			    {
			      "code": "\treturn value * self.view.props.defaultSize.height",
			      "type": "script"
			    }
			  ],
			  "type": "property"
			}
		},
		"props.instances[{}].viewParams.xy".format(i): {
			"binding": {
			  "config": {
			    "fallbackDelay": 2.5,
			    "mode": "indirect",
			    "references": {},
			    "tagPath": "[default]{}/AGV/AGV {}/POS_mm".format(plc, agv_num)
			  },
			  "transforms": [
			    {
			      "code": code,
			      "type": "script"
			    }
			  ],
			  "type": "tag"
			},
			
		}
	}
	return binding_data
	
	
def readFile(filePath="/var/lib/ignition/data/projects/Display/com.inductiveautomation.perspective/views/Overview/Canvas/view.json"):
	import os
	import system
	
	# Check if the file exists
	if os.path.exists(filePath):
	    # If the file exists, read its contents
	    with open(filePath, 'r') as file:
	        fileContents = file.read()
	    logger = system.util.getLogger("FileContentLogger")
	    # Log the contents using the Ignition logger

	    logger.info("Contents of {}: \n{}".format(filePath, fileContents))
	    return {"filePath":filePath, "fileContents":fileContents}
	else:
	    # Log a message if the file does not exist
	    logger = system.util.getLogger("FileContentLogger")
	    logger.warn("File not found: {}".format(filePath))
	    return -1
	    
def build_canvas_bindings(i, file_path, file_contents, binding):
	logger = system.util.getLogger("build_canvas_bindings")
	file_contents = system.util.jsonDecode(file_contents)
	for k,v in binding.items():
		logger.info("Key in pyContents.items(): {}".format(k))
		logger.info("Value in pyContents.items(): {}".format(v))
		file_contents["root"]["children"][1]["propConfig"][k] = v
	return system.util.jsonEncode(file_contents)

	
def write_view_json(file_path, view_json):
	system.file.writeFile(file_path, view_json)
	system.project.requestScan()

Here's the script on the onClick event of my "Create Bindings" button:

	LOGGER = system.util.getLogger("binding_string")
	view_json_path = "/var/lib/ignition/data/projects/Display/com.inductiveautomation.perspective/views/Overview/Canvas/view.json"
	view_json = build_canvas.readFile(view_json_path)
	new_view_json = view_json["fileContents"]
	for i, instance in enumerate(self.getSibling("ViewCanvas").props.instances):
		try:
			line_number = instance.viewParams.line_number
			instance_type = "station"
		except:
			instance_type = "agv"
		if instance_type == "agv":
			system.perspective.print("THE INSTANCE TYPE IS AGV")
			agv_num = instance.viewParams.agv_number
			plc = instance.viewParams.plc
			binding_data = build_canvas.buildAGVBinding(i, agv_num, plc)
		elif instance_type == "station":
			station_number = instance.viewParams.station_number
			plc = instance.viewParams.plc
			line_number = instance.viewParams.line_number 
			binding_data = build_canvas.buildStationBinding(i, station_number, plc, line_number)
		system.perspective.print("instance_type: {} ||| i: {}".format(instance_type, i))
		self.getSibling("Label").props.text = new_view_json
		system.perspective.print("||||||||||||| {} |||||||||||||".format(type(new_view_json)))
		new_view_json = build_canvas.build_canvas_bindings(i, file_path=view_json_path, file_contents=new_view_json, binding=binding_data)
	build_canvas.write_view_json(file_path=view_json["filePath"], view_json=new_view_json)

I don't understand this, at all.

I'll be totally frank: This seems like you took your first thought and ran with it, creating a great deal of very neat code that's now, more or less, impossible to debug.

If you wanted to go this route, it should absolutely not live in the same project as the output. It should be an entirely separate project, so that you can guarantee your output generated resources are "clean" of any weirdness, and individually look at them and test them for correctness.

But, the more important question is: Why go down this route at all? You can just...parameterize your views.
Basically what I'm saying is: If it's possible to generate all these views using relatively simple scripting...then it's equally possible to parameterize them, and not have to generate a bunch of views.
That's less maintenance burden for you (what if you want to change the structure of the views later, or add a new one, or change the number of stations, or whatever other requirement change) and for the next guy, who might know Ignition or Perspective or might not, but is guaranteed to not know your custom view generation script.

Again: This is all somewhat orthogonal to your issue at hand. I would expect your bindings and transforms to work, as expected; that they don't is unusual, and possibly a question for support... but you're doing so much "weird" stuff here that it's hard to untangle what's legitimate issue and what's due to all this extra engineering you've done.

As a general rule, leaning into the tools at hand in Ignition is going to be a happier path than the "just script everything" pattern - while we do make this possible (or more accurately, we don't prevent you from doing this), it's by no means encouraged.

2 Likes

Let me try to explain why I'm going down this route so that it's a bit clearer. I have a need to interpolate the x-y coordinates on screen for a handful of AGVs and stations and have them update as the position tags on the PLC change. Here's a gif of a much earlier version of this project (note that agv positions are being simulated with a timer script and that none of this is actually live):

This design relied on a single binding on the "instances" property of the view canvas. Each view canvas instance embedded another canvas. If the instance on the top-level canvas was an AGV, then that instance's view path would be "AGV Canvas". If the instance was a station, then the view path would be "Instance Canvas". What I ended up with was basically dozens of canvases layered on top of each other. This worked because I could pass AGV/Station number, PLC, and line_number into a canvas and evaluate the position of that single agv or station based on the parameters that were passed in.

I then needed to add a feature such that if a user clicked on an AGV or a station then a popup would appear for that station / agv. I was able to do this by storing the x-y coordinates of each agv instance or station instance in outbound parameters that were accessible from the top-level view canvas, but this was less than ideal. Later, I needed to wrap everything in a pan-zoom container, so identifying where each instance / agv was at from coordinates became more difficult.

I decided that getting rid of the "AGV Canvas" and "Station Canvas" middle layers would be the best way to move forward, as I wouldn't need to calculate the position of each agv / station in an onClick event on the top-level canvas and identify which item I've clicked on.

My issue then became that when relying on a single binding on the "instances" property of the ViewCanvas I would no longer be able to calculate the x-y position of each instance based on that instance's position tag value. I couldn't bind an instance's x-y position to a particular tag if the instance itself was being created from a binding, and I didn't want for the "props.instances" binding to refresh all instances every time that a single AGV moved. I need these bindings to be on my top-level view canvas, as they directly control the position of the AGVs and Stations on the view canvas.

Sure, I could copy/paste these bindings by hand every time I deploy this line overview, but it's very likely that I'll need to deploy this more than once. Having everything built from a few button presses or startup events would be nice, and I'm very close to this.

If there's a simpler way to dynamically build ViewCanvas instances and the bindings on the properties of each instance with a single binding, and refresh each of those instance bindings based on different tag changes, then I'm amenable to doing that instead.

EDIT:
The issue of someone else deploying this later on and needing to change the number of stations or AGVs is taken care of. The instance and binding creation scripts create exactly as many AGVs / Stations as are there are UDT instances of AGVs and Stations. These UDT instances are created (before creating the viewcanvas instances) when an authorized user connects a PLC to this gateway via a Perspective view.

I don't understand why you couldn't pass in a tag path as a parameter and have an internal binding that continually evaluates X & Y.

Admittedly, I'm seeing a small fraction of your problem, from way outside; it's possible that what you've come up with is the best/cleanest way to accomplish this. And, again, I would encourage you to contact support if you can demonstrate this issue happening without all this extra stuff going on (or contact them anyways; depending on the rep you get they may not be scared off by all this extra stuff and can do a much better job in a live troubleshooting session than I can, asynchronously).

But to me "dynamically generate a bunch of very repetitive views" is never the answer.
If I'm going to repeat a bunch of views, I'd parameterize the views.
If I'm hitting a limitation with a particular Perspective component, and I've got engineering budget to burn, I'd make a new Perspective component - so I can still parameterize my views, but have my own control over the rendering situation.

2 Likes

I've been considering making a new component for this, but would have to wrap my head around Java and the SDK, lol.

The internal binding via parameter tag path makes sense, and I can see if that fixes my issue, but I'd need to send that back out to the view canvas and would still need to bind the instance.top and instance.left properties (for all 100+ instances in full production) to that calculated xy position , hence why I want to make my life easier with a function I can call to edit all of these bindings for me. The good news is that my instance.top and instance.left bindings evaluate as soon as the xy binding evaluates.

I appreciate you not getting scared off by all the extra stuff - thanks again for your help.