Skip navigation
All Places > PI Developers Club > Blog > Author: rdavin
1 2 3 Previous Next

PI Developers Club

42 Posts authored by: rdavin Employee

I have been talking about AFSearch 2.10.5 for well over a year now. I spoke about it at PI World SF in April 2018 and again at Barcelona in Sept 2018. However, both of those talks used a beta build. The great news is that for the week of PI World SF in April 2019, this is no longer beta and is now production! Sadly, when working with beta code one should accept that the final production version may be a bit different, and that's certainly the case here. As I dusted off my code from Sept 2018, I could see it had several compiler errors since there was some fairly significant changes on the journey to production. Such is the life of a beta tester.

 

In case you haven't been following AFSearch or PI Server 2018 SP2 at all in the past year, the big changes for AFSearch with AF SDK 2.10.5 will be concisely summarized as:

 

  • Overlapped event frames searches got a performance boost.
  • Search queries now support OR conditions.
  • A new generic AFSearch<T> base class was added to implement many of the common properties and methods in derived search classes. As a result of this change, several methods have been marked as obsolete.

 

Don't let the short list fool you. While the first item requires no changes upon your part, the other items do require code changes. Supporting OR conditions is easy to say, but it did cause some breaking changes. In particular the old AFSearchToken structure is insufficient to represent new filters with OR. To that end, the AFSearchToken structure is being marked as Obsolete and replaced with a new AFSearchTokenBase class. Likewise, a generic AFSearch<T> base class introduces an IAFSearch<T>.FindObjects method, which means the many of the older Find methods (e.g. FindElements or FindEventFrames) are also marked as Obsolete. This is all mentioned in the help file under the heading What's new in PI AF 2018 SP2, but this blog expands upon short summary to give examples.

 

Event Frame Search Performance Boost

 

Let's start with the simplest change. If you are using captured event frames, then some searches should be noticeably faster, particular searches for Overlapped event frames.  For those of you rusty on the subject, Overlapped event frames are also considered "Active Between", meaning that any portion of the event frame falls within a requested time range.  I refer you to the image found in this link if you want more information.  Side note: when I was a customer writing AF applications, Overlapped was our most frequently used mode, and I know this is true for many of you.

 

An anecdotal example of this performance boost can be found in the PI World talks in 2018. In April, it took me about 1 minute to find 320K event frames from an AF Server of 6 million event frames. In September, I was using the same hardware but updated the AF SDK build every couple of weeks. About 3 weeks before Barcelona, the 1 minute search suddenly dropped to around 15 seconds. I asked the dev team and they confirmed they had implemented new logic for event frame searches.  I then gratefully said "thank you" and wiped the tears from my eyes.

 

This requires no change on your part other than the acceptance that your code may run twice as fast. What a nice problem to have!

 

OR - small word, BIG Changes

 

To implement support for OR, it quickly became apparent that the old AFSearchToken was not going to get the job done. There was a simplicity with the original AFSearchToken, but this breaks with the complexity of OR searches. There was just no way to represent it with the old structure. AFSearchToken is marked as obsolete, and any Tokens properties that depended upon AFSearchToken are also marked as obsolete. If your search does not use OR, then it will will get parsed by the Tokens property just fine (or with a little compiler warnings). But if you do use OR in your search, then accessing the Tokens property will produce an exception. Instead, you should begin using the TokenCollection property.

 

The TokenCollection property returns an AFSearchTokenCollection object, which implements IList<AFSearchTokenBase>. If you dig deeper into the help, you may discover that AFSearchTokenBase is an abstract class with 4 derived classes:

 

  • AFSearchExpressionToken, representing a logical grouping of search tokens. These tokens would be grouped together based on an OR or the default AND. I jokingly add that this is also akin to adding virtual parenthesis around your expression. All joking aside, if a query string contains parentheses, then the tokenized equivalent will likely need to use AFSearchExpressionToken.
  • AFSearchFilterToken, representing one criteria of a search class. Most of the obsolete AFSearchToken searches would use the new AFSearchFilterToken.
  • AFSearchQueryToken, representing a nested search query. Some obsolete AFSearchToken searches did use nested queries but should now use this AFSearchQueryToken.
  • AFSearchValueToken, representing an attribute value query.

 

Let's walk through an example to see these new tokens in action. I have chosen an example that uses all 4 of the new tokens. Let's first state how we plan to filter using simple phrases and business terms.

 

  • I want to perform an AFAttributeSearch, which means I will need a nested element query as well.
  • I want to search attributes on elements that have been derived from the "Storage Tank" element template.
  • I want to refine the element search to limit tank names (element names) to those starting with the letter "T".
  • I want a value filter on the attribute named "|Fill Percent". That filter should be on any tank level <= 10% or >= 90%. That is I want to filter on tanks that are too low or too high.
  • I want to return attributes named "Fill Percent", which is the tank level per tank (element).

 

Let's see how that would look as a query string. I personally like query strings, and many of the devs on the Asset Team prefer them as well.

 

Let's cheat a little and start out with what the fully resolved query string would look like:

 

"Element:{Template:'Storage Tank' Name:T* ('|Fill Percent':<=10 OR '|Fill Percent':>=90)} Name:'Fill Percent'"

 

Note that anything between the red { } braces is the nested element query. Within the confines of those red braces, the context for Name (as in Name:T*) refers to the nested element query, so Name in this context means element name. Outside the red braces, you will see another Name (as in Name:'Fill Percent'), whose context refers to the attribute search, and therefore is an attribute name. (This example was specifically chosen to throw a curveball at you, forcing you to reconcile which Name is which).

 

You will note that any names containing embedded blanks are wrapped in quotes. You are free to create a longer and uglier string using escaped double quotes, but my eyeballs are much happier with the single quotes.

 

Okay, now let's see how that would be written with the new AFSearchTokenBase tokens. There are a few different ways, none of which make your code shorter. First, let's define some variables and constants to hold many of the names and limit values.

 

const string templateName = "Storage Tank";
const string elemName = "T*";
const string attrName = "Fill Percent";  // Note the pipe or bar symbol "|" is omitted
const double lowLimit = 10;
const double highLimit = 90;

 

Let me point out that attrName is used when a name is required. However, many of the value filters requires an attribute path. Later below you will see me preface attrName with a "|" to transform it into a path. Wherever this is done, you will also see me wrap the final text in single quotes to account for embedded blanks in the name.

 

This will be done in several pieces of code. I try to work with the innermost filter first, so that would be the OR clause wrapped in parenthesis.

 

AFSearchExpressionToken orExprToken = new AFSearchExpressionToken(AFSearchLogicalOperator.Or, new List<AFSearchTokenBase> {
    new AFSearchValueToken($"|{attrName}", lowLimit.ToString(), AFSearchOperator.LessThanOrEqual, AFSearchValueType.Numeric),
    new AFSearchValueToken($"|{attrName}", highLimit.ToString(), AFSearchOperator.GreaterThanOrEqual, AFSearchValueType.Numeric) });

 

That is one really long assignment. If you like breaking it down into more lines, then you may use this instead:

 

// First let's compose an OR condition for our limit extremes.
IList<AFSearchTokenBase> orChildTokens = new List<AFSearchTokenBase>();
orChildTokens.Add(new AFSearchValueToken($"|{attrName}", lowLimit.ToString(), AFSearchOperator.LessThanOrEqual, AFSearchValueType.Numeric));
orChildTokens.Add(new AFSearchValueToken($"|{attrName}", highLimit.ToString(), AFSearchOperator.GreaterThanOrEqual, AFSearchValueType.Numeric));

// Next the OR must be grouped as a single expression, which is akin to enclosing it in parenthesis.
AFSearchExpressionToken orExprToken = new AFSearchExpressionToken(AFSearchLogicalOperator.Or, orChildTokens);

 

This demonstrates 2 different ways to populate the expression token. The first way created the tokens argument inside the AFSearchExpressionToken constructor. The second way created the expression tokens before the AFSearchExpressionToken was constructed. There are 2 other noteworthy items along this topic. First, and this is a big change, is that you cannot modify the input tokens once the instance has been constructed. Second, is that you may expand this thinking to include any search-related constructor expecting an argument of IList<AFSearchTokenBase> such as constructors for AFSearchQueryToken, AFElementSearch, AFAttributeSearch, etc.

 

Either of the above code snippets fill in the first major piece towards a fully tokenized call. The second major piece will be to complete the nested element query token. Note we use the orExprToken object we defined above.

 

// Gather tokens for the nested element query, which will include the OR expression group.
IList<AFSearchTokenBase> nestedQueryTokens = new List<AFSearchTokenBase>();
nestedQueryTokens.Add(new AFSearchFilterToken(AFSearchFilter.Template, templateName));
nestedQueryTokens.Add(new AFSearchFilterToken(AFSearchFilter.Name, elemName));
nestedQueryTokens.Add(orExprToken);

 

The final piece will be to complete the attribute search query by combining the nested element query with other attribute search filters.

 

// Finally gather tokens for the AFAttributeSearch, which includes the nested element query
// and the attribute name filter.
List<AFSearchTokenBase> tokens = new List<AFSearchTokenBase>();
tokens.Add(nestedQueryTokens);
tokens.Add(new AFSearchFilterToken(AFSearchFilter.Name, attrName));

 

Alternatively, you could have also performed the last assignment with:

 

List<AFSearchTokenBase> tokens = new List<AFSearchTokenBase>() { nestedQueryTokens, new AFSearchFilterToken(AFSearchFilter.Name, attrName) };

 

That wraps it up for the new tokens. Again, the complexity of using the new tokens was a necessary evil to accommodate the robust requirements of OR clauses.

 

If you straddle the border on whether to use tokens or strings, I would encourage you to learn more about query strings. Meanwhile, let's return that original query string. As I said, this has been fully resolved:

 

"Element:{Template:'Storage Tank' Name:T* ('|Fill Percent':<=10 OR '|Fill Percent':>=90)} Name:'Fill Percent'"

 

But in many cases this won't be fully resolved as many of your parameters are most likely stored in variables. What if you wanted to write the string to have variable substitution? Using String Interpolation, it would look like:

 

string query = $"Element:{{Template:'{templateName}' Name:'{elemName}' ('|{attrName}':<={lowLimit} OR '|{attrName}':>={highLimit})}} Name:'{attrName}'";

 

You will note that creating query is where we insert the pipe symbol. If you are more familiar with String.Format, you could use:

 

string query = String.Format("Element:{{Template:'{0}' Name:'{1}' ('|{2}':<={3} OR '|{2}':>={4})}} Name:'{2}'", templateName, elemName, attrName, lowLimit, highLimit);

 

I personally favor the Interpolated String because I know which variable is being used in-place, rather than we sequential numbers that require more eye movement.

 

New Methods and Constructors

 

It should be no surprise that the introduction of new TokenCollection and associated AFSearchTokenBase classes require new constructors and methods. This touches on virtually every related search class. Nothing much to add other than to make you aware of this. There is no need to enumerate them here since it's already been covered in Live Library's What's New link.

 

The Obsolete Find Methods

 

Under the hood, there is a significant change to the AFSearch abstract class. Prior to AF SDK 2.10.5, the class signature was:

 

public abstract class AFSearch : IDisposable

 

Effective with 2.10.5, the signature is now:

 

public abstract class AFSearch<T> : AFSearch, IAFSearch<T> where T : AFObject

 

This means the AFSearch class can now implement many of the common properties and methods in each of the derived search classes. One such common method is the FindObjects method, which is an implementation of the IAFSearch<T>.FindObjects interface.

 

Given how each derived search class has a FindObjects method, there is no longer a need to have each derived search class with its own type-specific Find method. Towards that end, the type-specific methods are marked as obsolete. This means you should start using FindObjects in place of:

 

  • AFAnalysisSearch.FindAnalyses
  • AFAnalysisTemplateSearch.FindAnalysisTemplates
  • AFAttributeSearch.FindAttributes
  • AFCaseSearch.FindCases
  • AFElementSearch.FindElements
  • AFEventFrameSearch.FindEventFrames
  • AFNotificationContactTemplateSearch.FindNotificationContactTemplates
  • AFNotificationRuleSearch.FindNotificationRules
  • AFNotificationRuleTemplateSearch.FindNotificationRuleTemplates
  • AFTransferSearch.FindTransfers

 

Again, don't be thrown off by the generic sounding FindObjects. Calling AFElementSearch.FindObjects still returns IEnumerable<AFElement>, so only the method name has changed.  FindObjects still returns type-specific items.

 

Team Query String versus Team Tokens

 

AF SDK is flexible in that you may choose to use either query strings or tokens, just as you may choose to use C#, VB.NET, or managed C++. As mentioned earlier, the Asset developers strongly prefer query strings.

 

I admit the new AFSearchTokenBase class is much more complicated to work with than the older AFSearchToken structure. I talked to several customer developers and many said they like tokens that spread out over many lines rather than a single-line query string because they can quickly comment out individual lines. With the new AFSearchTokenBase, you can still comment individual lines. However, there's nothing stopping you from having a multi-line query string or better yet, a StringBuilder object. With a StringBuilder, you would still be passing a string to the search constructor, but you would have the query span multiple lines where you may comment a given line.

 

Let's see an example of this in action. We return once again to the fully-resolved, single-line query string:

 

"Element:{Template:'Storage Tank' Name:T* ('|Fill Percent':<=10 OR '|Fill Percent':>=90)} Name:'Fill Percent'"

 

We can convert that a multiple line StringBuilder object. We really should have a separate StringBuilder object for the nested query and another for the full query.

 

StringBuilder nested = new StringBuilder();
nested.Append("Element:{");  // begin nested query
// Each inner filter will start with a blank
nested.Append($" Template:'{templateName}'");
nested.Append($" Name:'{elemName}'");
nested.Append($" (");  // begin OR expression
nested.Append($" '|{attrName}':<={lowLimit}");
nested.Append($" OR");
nested.Append($" '|{attrName}':>={highLimit}");
nested.Append(")");  // end OR expression
nested.Append("}");  // end nested query

StringBuilder query = new StringBuilder();
query.Append(nested.ToString());
query.Append($" Name:'{attrName}'");

using (AFAttributeSearch search = new AFAttributeSearch(root.Database, "", query.ToString()))
{
    // do something here
}

 

(A brief aside before we go any further, I want to say this to anyone who wants to have multi-line queries (string or tokens) in their code: I HOPE YOU ARE HAPPY. We have taken a single elegant line of a query string, and turned it into over a dozen lines of ugly code. Sorry. I needed to get that off my chest. Back to the lesson at hand ... )

 

Now let's consider what we would do if we want to temporarily change the search with these new rules:

 

  • We do not want to filter tank elements by name
  • We do not want to filter on low limit, which means we no longer need an OR clause. Another way to say this is we only need a value filter on high limit.

 

We would take the above and temporarily comment out lines accordingly:

 

StringBuilder nested = new StringBuilder();
nested.Append("Element:{");  // begin nested query
// Each inner filter will start with a blank
nested.Append($" Template:'{templateName}'");
//nested.Append($" Name:'{elemName}'");
//nested.Append($" (");  // begin OR expression
//nested.Append($" '|{attrName}':<={lowLimit}");
//nested.Append($" OR");
nested.Append($" '|{attrName}':>={highLimit}");
//nested.Append(")");  // end OR expression
nested.Append("}");  // end nested query

StringBuilder query = new StringBuilder();
query.Append(nested.ToString());
query.Append($" Name:'{attrName}'");

using (AFAttributeSearch search = new AFAttributeSearch(root.Database, "", query.ToString()))
{
    // do something here
}

 

There you go! We have demonstrated 3 examples for declaring a search query:

 

  • Using the new AFSearchTokenBase class
  • Using a single line query string
  • Using multi-lines of StringBuilder(s)

 

We leave it to your personal coding style and preference for what works best for you.

 

Caution using different Server and Client Versions

 

Let me issue this general caution regarding using different AF Server and AF Client version: while your app may compile correctly and run on mismatched versions, the features available would depend upon which system has the lowest version.  A client running 2.10.5 or later may be aware of the OR expression but a server running 2.10.0 or earlier would not.  Thus that app would be limited to 2.10.0 features.  If the client app tried to send an OR expression to the 2.10.0 server, an unsupported feature exception would be returned.

 

A very general rule of thumb: an older client app running against a newer server version should most likely run without issues, but a newer client app running against an older server version could throw unexpected errors.

 

 

Past AFSearch Blogs or Posts

 

Getting the most out of AFSearch - PI World Barcelona - Sept 2018. This used a beta of AF 2.10.5 to introduce OR support. Great video to help you understand the inner workings of AFSearch. This customer question hits on all the key points of the Barcelona Live Coding talk, and shows where lightweight searches can be great event frame searches with captured values. This Tech Talk was a refined repeat of May's Live Coding Talk at PI World 2018 San Francisco.

 

What's new in AFSearch 2.10 (PI AF 2018) (June 2018) - attribute searches are no longer are restricted to templates.

 

What's new in AFSearch 2.9.5 (PI AF 2017 R2) (March 2018) - AFAttributeSearch is introduced.

 

Coding Tips for using the AFSearch IN Operator - you may search for multiple values using IN(value1; value2; etc.). Some code is offered to make this easier to generate. This explains embedded blanks and using escaped double quotes or single quotes.

 

Using the AFEventFrameSearch Class (Nov 2016) - Giving some attention to event frames since many of earliest AFSearch examples were element based.

 

Aggregating Event Frame Data Part 1 of 9 - Introduction (May 2017) - From the UC 2017 TechCon/DevCon hands on lab for Advanced Developers.

 

Why you should use the new AF SDK Search (June 2016) - The granddaddy of them all. The earliest post explaining AFSearch, which was new at that time.

In this blog we will discuss AF SDK's PIPoint.FindPIPoints method when passing in an enumerable collection of tag names.  As far as immediate usage of the method, there is very little to add that isn't already in the help.  A general weakness of many help examples is that the example is intentionally short, simple, and typically has perfect data.  This blog is being written with the specific thought that some of the tag names passed into FindPIPoints intentionally will not find a tag.  We offer a couple of ideas on what you can do to discover which ones were not found, though any good developer should not feel limited to just the ideas presented here.

 

My inspiration for this blog comes from 2 different partner code reviews hosted months apart.  Both applications used the PIDataPipe where a sizeable list of tag names were being read from a text file.  Despite both developers being quite skillful, I was taken aback that both would try to find each tag one-at-a-time.  Overall, their logic was similar:

 

  • Open the text file and read a tag name for each line.
  • Issue a PIPoint.TryFindPIPoint to find the current tag by its name.
  • If the tag was not found, log it for later diagnostics.
  • If the tag was found, issue a AddSignups for that tag, even if it is the only tag in a list.

 

As I mentioned, these developers were quite skilled and knew about bulk calls.  This was apparent in each application where they would later issue a RemoveSignups in bulk.  When I asked one of them why they would read 5000 tags one-at-a-time instead of in bulk, the answer was all about the logging.  It was critical to their application that it logs whenever a tag is not found.

 

The thing about FindPIPoints is that it only returns what is found (doh!).  If you pass in 1000 tag names and 100 of them are not found, then what you get back is 900 PIPoints.  What we will explore here are ways to quickly, easily, and efficiently discover which 100 were not found.  That is the missing piece as to why these developers were not using a bulk call.  This is not a hard task to code up, and the possible solutions covered here rely more upon simple .NET Framework objects, and very little to do with AF SDK (that is, beyond the FindPIPoints call).

 

Case Insensitive Dictionary

The coding challenge is to reconcile the list of tag names you pass into FindPIPoints and quickly determine which ones didn't make the cut.  As an application developer, my first choice to solve this would be a dictionary keyed by the tag name (a string) with a lookup value of a PIPoint.  I would also want this dictionary to be case insensitive just in case your exact casing of the tag name in the text file does not exactly match the casing of the tag name on the PI Data Archive.

 

The logic would be that every tag name you pass in will be a key in the dictionary.  We then will make a one bulk call to FindPIPoints.  We enumerate over the returned list of PIPoint to populate the dictionary with the found PIPoints.  Afterwards, any remaining dictionary entry that does not have a valid PIPoint would indicate a tag name that was not found.  The biggest takeaway is not the mechanics of the dictionary, but rather that we issued only one efficient bulk call to the PI Data Archive.

 

Since my example method calls FindPIPoints and FindPIPoints has an optional 3rd parameter of attributeNamesthen I too want my custom method to mirror the signature of FindPIPoints. 

 

LEGAL DISCLAIMER

Since we will share some sample .NET code, we must provide the obligatory legal notice and disclaimers.  The sample code being shared herein is subject to the following disclaimer:

 

Copyright 2019 OSIsoft, LLC.

 

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at

 

http://www.apache.org/licenses/LICENSE-2.0

 

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

 

To put this another way, if you have problems with these sample methods, do NOT call Tech Support, nor create a case in my.OSIsoft.com. The only outlet for discussing issues with this code is here within the PI Developers Club forum.

 

The code being shared below:

  • Has not been put through any regression testing.
  • Has a very limited test profile (only me on my local PI Data Archive)
  • Has not been endorsed by any OSIsoft Product team
  • Is in no way an official offering by OSIsoft
  • Is offered only as a learning example

 

With that said, we strongly advise that you do not use this in your production code.  Keep in mind that we cannot stop you from doing so, but if you do, then it's no longer our code but rather is becomes YOUR code to support, maintain, validate, debug, and assume the full responsibilities as owner of said code.  Such is the nature of an "AS IS" license.

 

C# Sample Implementation Returning a Dictionary

public static IDictionary<string, PIPoint> GetExampleDictionary(PIServer piServer, IEnumerable<string> names, IEnumerable<string> attributeNames = null)
{
    if (piServer == null)
        throw new ArgumentNullException(nameof(piServer));
    if (names == null)
        throw new ArgumentNullException(nameof(names));

    // We will use a case-insensitive dictionary keyed by tag name with a Value of PIPoint.
    // A null PIPoint would indicate a tag name that was not found.
    var dict = new Dictionary<string, PIPoint>(StringComparer.OrdinalIgnoreCase);

    // We want every tag name to have an entry in our dictionary.
    // Initially the PIPoint will be null, which we try to populate shortly below.
    foreach (var tagname in names)
    {
        if (tagname != null)
            dict[tagname] = null;
    }

    if (dict.Count == 0)
        return dict;

    // Live Library: https://techsupport.osisoft.com/Documentation/PI-AF-SDK/html/M_OSIsoft_AF_PI_PIPoint_FindPIPoints_2.htm
    // If a tag name is not found, an exception is not thrown nor is a null PIPoint returned.
    // FindPIPoints will only return PIPoints that were actually found.
    var tags = PIPoint.FindPIPoints(piServer, names, attributeNames);
           
    // Finally we will assign the found PIPoints to its proper entry in the dictionary.
    // Note that some dictionary entries may still have null PIPoints for those tag names that were not found.
    foreach (var tag in tags)
    {
        dict[tag.Name] = tag;
    }

    return dict;
}

 

Great.  We issued a bulk call and thanks to a tiny bit of effort on our part, we now have a handy little dictionary at our fingertips.  Let's see an example on how we could consume this dictionary depending upon a given context of working with tags that have been found & validated, or working with tag names needing to log a "Not Found" message.  For setting up this example, we want to have a few good tag names along with some bad tag names.  For the bad ones, let's include some crazy edge cases of wild card patterns being used, duplicate names with different casing, or even a tag name containing illegal characters!

 

string wicked = "* ? ; { } [ ] | \\ ` ' \" ,"; // Every character not allowed in a tag name
string[] tagnames = new[] { "CDM158", "SINUSOID", "sinusoid", "sinus*", "This tag does not exist", "", "     ", wicked };

PIServer pida = new PIServers().DefaultPIServer;

var dict = LearningExample.GetExampleDictionary(pida, tagnames);
var points = dict.Values.Where(x => x != null).ToList();
var badNames = dict.Where(x => x.Value == null).Select(x => x.Key).ToList();

Console.WriteLine($"PIPoints Found: {points.Count}");
foreach (var point in points)
    Console.WriteLine($"   {point.Name}");

Console.WriteLine($"PIPoints NOT Found: {badNames.Count}");
foreach (var badName in badNames)
    Console.WriteLine($"   '{badName}'");

 

And that sample code might produce the following:

 

Sample Console Output
PIPoints Found: 2
   CDM158
   SINUSOID
PIPoints NOT Found: 5
   'sinus*'
   'This tag does not exist'
   ''
   '    '
   '* ? ; { } [ ] | \ ` ' " ,'

 

The help warns us that we cannot use wildcard patterns, so we understand why 'sinus*' is not found.  Observe that I even threw in some blank tag names, one with zero length and one with many blanks (which is why I quote the ones that are missing).  And the last line tells me that an exception will not be thrown even if you use illegal characters in the tag name being searched.

 

All my consuming application needed to do is take a little handling of whether a PIPoint was found or not.  Simple concept, simple code, but big performance difference when dealing with thousands of tags.

 

VB.NET Sample Implementation Returning a Dictionary

Public Function GetExampleDictionary(ByVal piServer As PIServer, ByVal names As IEnumerable(Of String), Optional ByVal attributeNames As IEnumerable(Of String) = Nothing) As IDictionary(Of String, PIPoint)

    If piServer Is Nothing Then Throw New ArgumentNullException(NameOf(piServer))
    If names Is Nothing Then Throw New ArgumentNullException(NameOf(names))

    ' We will use a case-insensitive dictionary keyed by tag name with a Value of PIPoint.
    ' A Nothing PIPoint would indicate a tag name that was not found.
    Dim dict = New Dictionary(Of String, PIPoint)(StringComparer.OrdinalIgnoreCase)

    ' We want every tag name to have an entry in our dictionary.
    ' Initially the PIPoint will be Nothing, which we try to populate shortly below.
    For Each tagname In names
        If tagname IsNot Nothing Then dict(tagname) = Nothing
    Next

    If dict.Count = 0 Then Return dict

    ' Live Library: https://techsupport.osisoft.com/Documentation/PI-AF-SDK/html/M_OSIsoft_AF_PI_PIPoint_FindPIPoints_2.htm
    ' If a tag name is not found, an exception is not thrown nor is a null PIPoint returned.
    ' FindPIPoints will only return PIPoints that were actually found.
    Dim tags = PIPoint.FindPIPoints(piServer, names, attributeNames)

    ' Finally we will assign the found PIPoints to its proper entry in the dictionary.
    ' Note that some dictionary entries may still have Nothing PIPoints for those tag names that were not found.
    For Each tag In tags
        dict(tag.Name) = tag
    Next

    Return dict
End Function

 

And here's an example of how VB.NET could consume that dictionary:

 

Dim wicked As String = "* ? ; { } [ ] | \ ` ' "" ,"  ' Every character not allowed in a tag name
Dim tagnames As String() = {"CDM158", "SINUSOID", "sinusoid", "sinus*", "This tag does not exist", "", "     ", wicked}

Dim pida As PIServer = New PIServers().DefaultPIServer

Dim dict = LearningExample.GetExampleDictionary(pida, tagnames, Nothing)
Dim points = dict.Values.Where(Function(x) x IsNot Nothing).ToList()
Dim badNames = dict.Where(Function(x) x.Value Is Nothing).Select(Function(x) x.Key).ToList()

Console.WriteLine($"PIPoints Found: {points.Count}")
For Each point In points
    Console.WriteLine($"   {point.Name}")
Next

Console.WriteLine($"PIPoints NOT Found: {badNames.Count}")
For Each badName In badNames
    Console.WriteLine($"   '{badName}'")
Next

 

Alternatively Return a ValueTuple

There are more than one way to do things and the above was my first train of thought.  I am the type of person that ponders other ways to try things to see which might be faster or easier to work with.  The problem I have with a dictionary being returned is that extra care and handling that must be done.  Other than the fact that you send in one list of tag names, what I really want coming back are 2 mostly unrelated things: I want a list of the PIPoints that were found and I want a separate list of the tag names where a PIPoint was not found.

 

Wanting to avoid the cumbersomeness of using out modifiers on the signature of a void method, I next considered tuples.  In particular, the ValueTuple class.

 

If you are using Visual Studio 2017 with .NET 4.6.2, you will need to install the NuGet package for ValueTuple.  If you are using Visual Studio 2019 with .NET 4.7.2, ValueTuple is now a part of mscorlib.  Tip: if you are converting from VS 2017 to VS 2019 and also upgraded the target framework being used, you will need to uninstall the NuGet ValueTuple package.

 

Internally, the methods will still use a case insensitive dictionary.  The only difference is what type of object we will return.

 

C# Sample Implementation Returning a ValueTuple

public static (IList<PIPoint> FoundList, IList<string> MissingList) GetExampleValueTuple(this PIServer piServer, IEnumerable<string> names, IEnumerable<string> attributeNames = null)
{
    if (piServer == null)
        throw new ArgumentNullException(nameof(piServer));
    if (names == null)
        throw new ArgumentNullException(nameof(names));

    // We will use a case-insensitive dictionary keyed by tag name with a Value of PIPoint.
    // A null PIPoint would indicate a tag name that was not found.
    var dict = new Dictionary<string, PIPoint>(StringComparer.OrdinalIgnoreCase);

    // We want every tag name to have an entry in our dictionary.
    // Initially the PIPoint will be null, which we try to populate shortly below.
    foreach (var tagname in names)
    {
        if (tagname != null)
            dict[tagname] = null;
    }

    if (dict.Count == 0)
        return (new PIPointList(), new List<string>());

    // Live Library: https://techsupport.osisoft.com/Documentation/PI-AF-SDK/html/M_OSIsoft_AF_PI_PIPoint_FindPIPoints_2.htm
    // If a tag name is not found, an exception is not thrown nor is a null PIPoint returned.
    // FindPIPoints will only return PIPoints that were actually found.
    var tags = PIPoint.FindPIPoints(piServer, names, attributeNames);

    // Finally we will assign the found PIPoints to its proper entry in the dictionary.
    // Note that some dictionary entries may still have null PIPoints for those tag names that were not found.
    foreach (var tag in tags)
    {
        dict[tag.Name] = tag;
    }

    // The variable 'tags' has the found PIPoints.
    // Let's create a list of tag names of any PIPoints that were not found, if any.
    var missing = dict.Where(x => x.Value == null)?.Select(x => x.Key);

    // Return a tuple of the found tags and the missing tag names.
    // The consumer can pass tags directly into a PIDataPipe.AddSignups,
    // and log the missing tag names.
    return (tags, missing.ToList());
}

 

C# allows different ways we can consume this object.  Both C# and VB.NET allow you to grab the returned ValueTuple as an singular object and then use its different fields:

 

var tagLookup = pida.GetExampleValueTuple(tagnames);

Console.WriteLine($"PIPoints Found: {tagLookup.FoundList.Count}");
foreach (var point in tagLookup.FoundList)
    Console.WriteLine($"   {point.Name}");

Console.WriteLine($"PIPoints NOT Found: {tagLookup.MissingList.Count}");
foreach (var badName in tagLookup.MissingList)
    Console.WriteLine($"   '{badName}'");

 

In the first line above, the tagLookup.FoundList will be a IList<PIPoint> and the tagLookup.MissingList will be an IList<string>.

 

The other nice way (which VB.NET lacks BTW) is to decompose the fields into individual variables as they are declared:

 

var (points, badNames) = pida.GetExampleValueTuple(tagnames, null);

Console.WriteLine($"PIPoints Found: {points.Count}");
foreach (var point in points)
    Console.WriteLine($"   {point.Name}");

Console.WriteLine($"PIPoints NOT Found: {badNames.Count}");
foreach (var badName in badNames)
    Console.WriteLine($"   '{badName}'");

 

In the first line above, the variable points will be a IList<PIPoint> and the variable badNames will be an IList<string>.

 

VB.NET Sample Implementation Returning a ValueTuple

<Extension()> Public Function GetExampleValueTuple(ByVal piServer As PIServer, ByVal names As IEnumerable(Of String), Optional ByVal attributeNames As IEnumerable(Of String) = Nothing) As (TagsFound As IList(Of PIPoint), MissingTagNames As IList(Of String))

    If piServer Is Nothing Then Throw New ArgumentNullException(NameOf(piServer))
    If names Is Nothing Then Throw New ArgumentNullException(NameOf(names))

    ' We will use a case-insensitive dictionary keyed by tag name with a Value of PIPoint.
    ' A Nothing PIPoint would indicate a tag name that was not found.
    Dim dict = New Dictionary(Of String, PIPoint)(StringComparer.OrdinalIgnoreCase)

    ' We want every tag name to have an entry in our dictionary.
    ' Initially the PIPoint will be Nothing, which we try to populate shortly below.
    For Each tagname In names
        If tagname IsNot Nothing Then dict(tagname) = Nothing
    Next

    If dict.Count = 0 Then Return (New List(Of PIPoint)(), New List(Of String)())

    ' Live Library: https://techsupport.osisoft.com/Documentation/PI-AF-SDK/html/M_OSIsoft_AF_PI_PIPoint_FindPIPoints_2.htm
    ' If a tag name is not found, an exception is not thrown nor is a null PIPoint returned.
    ' FindPIPoints will only return PIPoints that were actually found.
    Dim tags = PIPoint.FindPIPoints(piServer, names, attributeNames)

    ' Finally we will assign the found PIPoints to its proper entry in the dictionary.
    ' Note that some dictionary entries may still have Nothing PIPoints for those tag names that were not found.
    For Each tag In tags
        dict(tag.Name) = tag
    Next

    ' The variable 'tags' has the found PIPoints.
    ' Let's create a list of tag names of any PIPoints that were not found, if any.
    Dim missing = dict.Where(Function(x) x.Value Is Nothing).Select(Function(x) x.Key)

    ' Return a tuple of the found tags and the missing tag names.
    ' The consumer can pass tags directly into a PIDataPipe.AddSignups,
    ' and log the missing tag names.
    Return (tags, missing.ToList())

End Function

 

As mentioned above, VB.NET is unable to decompose the fields on one single line. Thus you must use the entire ValueTuple object and work with its fields.  Here is a simple example:

 

Dim tagLookup = pida.GetExampleValueTuple(tagnames)
Dim points = tagLookup.TagsFound
Dim badNames = tagLookup.MissingTagNames

Console.WriteLine($"PIPoints Found: {points.Count}")
For Each point In points
    Console.WriteLine($"   {point.Name}")
Next

Console.WriteLine($"PIPoints NOT Found: {badNames.Count}")
For Each badName In badNames
    Console.WriteLine($"   '{badName}'")
Next

 

Conclusion

The code above has not done anything special with the FindPIPoints method.  In fact, we are using it exactly for the purposes of which it was intended, and also exactly in the manner in which it was intended.  All we really did was considered various ways to take the results returned by FindPIPoints and transform them into another object more conducive to certain applications.  We then threw both good and bad cases at it to see if that transformation behaves as we expected.  These sample methods just ever so slightly touched upon AF SDK, and the subsequent transformation relies on very common, very routine .NET objects.

 

We showed 2 possible ways for you to transform the results.  And I am sure these are not the only ways.  I encourage you to contemplate these and other techniques to find what works best for you and your company based on your coding experience, preferences, style, and company policies.

Greetings to our PI DevClub community.  It's that time once again where we announce the 2019 class of PI DevClub All-Stars.  Not to spoil the surprise, but I could have dusted off the 2018 announcement, and only scratched off a couple of names.  Allow me to explain these particular awards a bit more.  You do not have to be a paid subscriber to PI Developers Club.  Anyone who is a subscriber is automatically eligible for the award.  For anyone is who is not a subscriber, we review many of their posts to be sure that a significant number of posts are code-related.  However, code is not limited to the PI Developer Technologies (AF SDK, PI Web API, SQL Family).  We include Asset Analytics and PowerShell as well.

 

What you will discover common in all the winners is not just a sharp technical knowledge of PI, but more so a willingness to help others.  Awards are given as a recognition of such spirit to help others.

 

PI Developers Club Community All-Stars

This is a repeat across the board as 2018.

 

  • Roger Palmen  checks in at the top of our list.  This should not be surprising to anyone who even casually follows the forums.  Since winning All-Star the previous year, Roger has also climbed to top of the All-Time Leaderboard.  Seriously, I have lost track of how many consecutive years Roger has won.  7 or 8 maybe.
  • John Messinger  wins for the 3rd year in a row.
  • Dan Fishman  wins for the 2nd year in a row.

 

It's interesting having a global community because when I wake up in the morning to read the forums, these All-Stars have most likely already replied to most new questions.  John is from Australia, Roger from the Netherlands, and Dan is US-based.

 

PI Developers Club Rising Star

Anyone who has not previously won an All-Star or Rising Star award is eligible for Rising Star as long as some portion of their posts have been code-related.  This years recipient is a very prolific poster, the majority of which are not within the PI DevClub forums.  Nonetheless since he posts with such a high volume, we were able to easily find many that were code-related.

 

 

 

Prizes

The Community All-Stars and Rising Star all win the same award:

 

  • Amazon Gift Card worth 400 USD.
  • 1 year free subscription to PI Developers Club.
  • 100% discount to one of the following PI World events: Gothenburg 2019 or any PI World 2020.

 

 

Employee All-Stars

While all OSIsoft employees are always eager to give a helping hand, we want to recognize those they go the extra mile.  Our 2019 Internal All-Stars are:

 

Sebastien Raposo  And Seb is already making a strong claim for 2020 with his excellent, must-read series on Asset Analytics.

Jinmo Yi  wins for the 2nd year in a row.

David Hearn  is a multiple recipient.  I joke that I know when the Asset team has completed their sprint because David's posting activity increases around that time.

 

The Internal All-Stars each win an Amazon Gift Card worth 200 USD.

 

 

Never too late to think of next year

It's not impossible to break into the top 3 and shake the grip that Roger, John, and Dan have on the All-Star awards.  I have seen Ernst Amort (prefers being called Holger) and Jim Gavigan each with good activity in their own blogs.  Will Rhys Kirk, Asle Frantzen, or Lonnie Bowling find time in their jobs to post more and find themselves returning to the winners circle?  Time will tell.  See you next year when we announce the 2020 winners.

The PI Geek Talks are generally presented by partners and customers.  The target audience is PI Admins and Developers.  All the talks will be on Wednesday, Day 2, at the Parc 55 Hotel in the Powell room located on Level 3.  You are invited to read the PI World Agenda for more information.  Along with Day 2 Tech Talks, these are great reasons to take the walk from the Hilton to the Parc 55.

 

 

Selecting the Right Analytics Tool

David Soll, Omicron

There are several analytics tools and approaches available for working with PI data: Performance Equations, AF analytics, custom data references, PI ACE, PI DataLInk and Business Intelligence (BI) tools. It can be a quandary in determining which tool should you use for what. Should you focus on only one tool or use a mix? As it turns out, the answer is not as simple as basing it on the specific analytic. Other considerations should be put into the decision including: scalability, reliability, maintainability, and future-proofing, to name a few.

This talk will discuss the various tools available for performing analytics on PI data and their strengths and weaknesses, their scalability, reliability, maintainability, and future-proofing. The tools will be separated into two major classes: server side (persistent) analytics and client side (query time) analytics and the general differences between the two classes. Attendees will learn practical guidelines to for selecting analytics tools

David Soll

 

 

Providing Enterprise Level Visibility for Snowflakes Using PI and AF

David Rodriguez, EDF Renewables, and Lonnie Bowling, Diemus

As part a larger project to monitor a large number of distributed wind farms throughout the US and Canada, the customer desired to have visibility into substation status information. This included showing substation one-line diagrams, voltage regulation status, breaker status, and events to notify them of any issues. Each wind project was design and installed by others which resulted in large differences between sites, include variability in networking, communications, and tag configuration. In other words, each project was like a snowflake. Using PI, AF Analytics, and Event frames, a solution was developed to normalized all wind projects. Once standardization was achieved, we then defined substation one-line circuits using an AF hierarchy. Data visualization was developed to provide on-demand, real-time rendering of circuits, voltage regulation trends, events, supporting information. This was implemented enterprise wide, and allowed for easy access and visibility for everyone in the organization.

David Rodriguez   Lonnie Bowling

 

Just Another Weather Application – Evaluating the OSIsoft Cloud System

Lonnie Bowling, Diemus

This session will showcase a weather application designed using the new OSIsoft Cloud System (OCS).

A backyard weather station was used as a data source for a live and historical data source. Forecasted data was then added to provide a complete picture of historical, current, and forecasted weather. Once all the data was streaming into an OCS sequencial data store, a full stack front-end solution was developed. This included an API layer in C#, Angular for the UI, and D3 for data visualization. A complete solution was developed to fully evaluate how OCS could be used in a real-life, purpose-built application. Key takeaways, including challenges, an architectural review, and source-code highlights will be shared.

Lonnie Bowling

 

Data Analytics to enhance Advanced Energy Communities planning and operation

John Rogers and Alberto Colombo, DERNetSoft

In today’s energy marketplace, poor energy awareness and a lack of data visibility coupled with the technical complexities of DER integration leads to a gap in local Advanced Energy Community development. DERNetSoft provides a scalable solution to this issue, making it possible to build advanced energy communities increasing energy awareness, enabling Distributed Energy Resources planning and supporting their operational optimization. We transform data into actionable insight and value-added advanced analytics and machine learning technique in the energy industry at the community level.

 

 

Data Quality & Shaping: Two Keys to Enabling Advanced Analytics & Data Science for the PI System

Camille Metzinger and Kleanthis Mazarakis, OSIsoft

Data quality is critical in the success of data-driven decisions. Issues with data will impact users across the organization- from operators, engineers, data scientists, to leaders. Answering business intelligence questions such as “which assets are performing well and which are under-performing” requires a birds-eye view of the data which may require (re)shaping of the data within the PI System. This talk and demo will explore the aspects of data quality and data shaping using PI System infrastructure by illustrating why they are so critical for success. We will also demonstrate the steps of how to improve Data Quality in the PI System and shape the PI System data to give it the right context for your advanced analytics.

Camille Metzinger   Kleanthis Mazarakis

In about a month, PI World 2019 will kick off in San Francisco.  Like the past many years, the events will be spread over 3 hotels.  Also like the past many years, the Parc 55 hotel is where you will find events catering specifically to developers and the data science community.  A year ago we introduced "Live Coding" sessions and "How To" walk-throughs to offer a more indepth talk (more steak, less sizzle).  This year we have collectively rebranded these new formats into a common track: Tech Talks.  These 90 minute talks hit the sweet spot for many.  If you leave a traditional 45 minute PowerPoint talk wishing for more details, then the longer 90 minute Tech Talk is for you.  If you feel a 3 hour lab is too slow, or you would rather be shown the material rather than typing it yourself, then the shorter 90 minute Tech Talk is for you too.

 

 

We have expanded the Tech Talks to begin on Day 2 rather than wait until Day 3.  Here's what you can find on the agenda.

 

Day 2 Tech Talks (Parc 55)

  • Using Stream Views for Real Time Analytics
  • Using PI Web API and PowerApps to Build Real World Apps With Your PI Data
  • Leveraging the Power of PI Vision Extensibility
  • Concurrent Programming for PI Developers
  • Generating API Clients with OpenAPI 2.0 (Swagger) specifications and interacting with REST endpoints
  • Effortlessly deploying a PI System in Azure or AWS

 

Day 3 Tech Talks (Parc 55)

  • OSIsoft Cloud Services for Developers
  • Writing High Performance Applications with AF SDK
  • Modernizing PI SQL For the Future
  • Create Dashboards to monitor PI Analysis Service

 

Check out the agenda for exact times and room locations.  While you are peeking at the agenda take a look at Day 2 PI Geek Talks and Day 3 Developer Talks too.  All are offered at the Parc 55.

 

And join us from 4:30-6:00 PM on Day 3 for the Developers Reception.

Dear Fellow PI Geeks,

 

It is with a heavy heart that I must announce that we have cancelled this year's PI World Innovation Hackathon.  It was not an easy decision.  We were given a cutoff date 8 weeks before PI World to make a decision to go forward or cancel.  It's hard to make firm predictions so far in advance.  While we were confident optimistic we could have a similar number of participants as last year, the tipping point in the decision was the bitter reality that the hackathon has been shrinking in attendance for many years.  When we started out on this brave new venture 7 years ago, we obviously were filled with hopes that it would grow rather than decline.

 

I have received emails asking if we will offer hackathons in the future.  YES, we will.  However, I do not see us offering hackathons during PI World.  Many of our partners and EA customers form the core of the developer community, but PI World has so many demands pulling those partners and customers in so many different directions.  Thus, we are currently considering a special hackathon-only event.

 

I will be hanging out at the Parc 55 hotel on Day 2 and Day 3, where the agenda has lots of developer related offerings from 90 minute Tech Talks, some cool PI Geek talks, and the traditional 45 minute Developer Talks.  Not to mention the Day 3 hands on labs!  So I invite all developers to come over to the Parc 55 to attend some in-depth talks. And if you happen to bump into me, I would love to talk to you about what you want to see in future hackathons.

 

There will be a Developer Reception from 04:30-06:30 PM at the Parc 55 with drinks and appetizers.  Come meet fellow developers or supporting members of the PI Developers Club.

 

If you have any concerns or comments, please email me and/or the entire team below.

 

Rick Davin    

rdavin@osisoft.com OR TechnologyEnablementTeam@osisoft.com

Great list, though the author admits the list is more exhausting than it is exhaustive.  A must-read for VB.NET developers.

 

An Exhausting List of Differences Between VB.NET & C# | Anthony's blog

Below is a link to the presentation given at Barcelona on September 27, 2018 for the LiveCoding session titled "Getting the Most Out of AFSearch".

 

Link to Recording:     LiveCoding--Getting-the-Most-Out-of-the-AFSearch

 

GitHub Repository:     DELETED

 

While some of it is a repeat of what was presented at San Francisco in April, the beginning section features new material on the upcoming AF SDK 2.10.5 release.  The new features discussed:

 

  • OR clauses in searches.
  • FindObjects replaces the now deprecated FindElements, FindEventFrames, FindAttributes, etc.
  • AFSearchToken structure being replaced by new AFSearchBaseToken abstract class with 4 concrete classes: AFSearchFilterToken, AFSearchQueryToken, AFSearchOrToken, and AFSearchExpressionToken.
  • AFSearchToken will be mapped to new AFSearchBaseToken, if possible.
  • If your query uses an OR, the AFSearch.Tokens property will throw an exception.
  • New AFSearch.TokensCollection property is meant to replace Tokens functionality and works with new AFSearchBaseToken.
  • AFSearchToken and AFSearch.Tokens property are both marked as deprecated.

ATTENTION Developers, Data Scientists, IT CyberSecurity, and Power Users.  For lack of a better word, I will refer to you collectively as "developers".

 

Developers coming to PI World Barcelona may notice more offerings than ever before.  We proudly proclaim that Barcelona will have the most robust Developer Agenda (see link) ever seen at UC or PI World EMEA.  This includes the Developer Innovation Hackathon on Day 0 (see link), thanks to our data sponsor DEME.

 

Besides the traditional hands-on labs, which require an additional fee and pre-registration, the Day 3 agenda is chock full of technical content aimed specifically at developers, thanks to 90-minute Live Coding or How-To sessions.  These in-depth technical talks do not require a fee nor pre-registration.  You are free to come and go as you please.  Make no mistake about it ... just because we call the LiveCoding "talks" does not mean they contain less technical information than labs. We expect to offer even more such talks at future PI World events because we think it is better event for you.  Our reasoning: you can attend 2 labs on Day 3 for $300, or you can sit in on 4 LiveCoding talks for free.  Who can argue against more technical training for less cost?  (Tip: if you are trying to convince your boss to send you to PI World, presenting it as a major training event (which it is) could be a strong justification to attend.)

 

I invite you once again to review the Day 3 agenda.  You will see an Analytics Track and Developer Track.  One late correction I would like to make is the PI Admin Track, which is not really for PI Admins but should be considered a 2nd Developer Track.

AFSearch Barcelona.png

 

Day 3, Thursday September 27, 11:30 - 13:00

LiveCoding: Getting the Most Out of the New AFSearch

CCIB: Room 117 134, P1 Level

 

To any members of the PI Developer Community who will be at PI World Barcelona, you are invited to join me in a presentation on new features of AFSearch.  If you ask "Hey Rick, didn't you give this already in SF?", my answer would be "Yes BUT new sections were added to specifically cover some important NEW stuff."  PI AF 2018 R2 (AF SDK 2.10.5) will finally support OR conditions with AFSearch.  That is a highly anticipated new feature that many are looking forward to.  But in order to support OR conditions, it required replacing the older AFSearchToken structure with a new AFSearchTokenBase class that now has 4 different token instances.  Trust me, you will want to see how these new tokens will be used in code.  Everyone who has ever attended this talk has said they definitely learned something!

 

UPDATE: The room has been moved to 134 on Level P1.

PI AF 2018 (AF SDK 2.10) offers a very significant change in filtering on attribute values: there is no longer a restriction that the attribute must belong to an element template.  The allows for a greater flexibility for filtering.  For example, you may now search for attributes that don't belong to any template.  Or better yet, you may search for attributes with the same name but belonging to different templates!

 

Other Features New to AF SDK 2.10

 

The bulk of this blog will cover the ValueType used with attribute value filters.  Before we dig too deep into that topic, let's take a quick look at the other new features.

 

There is a new AFSearchFilter.ID search filter to allow searching for objects by their unique identifier (GUID).  This unique ID is the fastest way to locate a given object on the AF Server.  A much-welcomed addition is that the ID supports the Equal or IN( ) operator.  If you are developing code, the best way to pass a GUID is by using ID.ToString("B").

 

New Search Fields are ConfigString, DisplayDigits, IsManualDataEntry, Trait, UOM, SecurityString, and SecurityToken.  Note that with a SecurityToken field, the FindObjectFields method would return an AFSecurityRightsToken object.

 

PI AF 2017 R2 (AF SDK 2.9.5) introduced the AFAttributeSearch and the PlugIn filter.  You could combine that filter plus the new ability to search on attributes without specifying a template.  For example, you now have the ability to perform a completely server-side search of all attributes referencing the PI Point data reference!  Stay tuned for a blog dedicated to this topic from one of my colleagues.

 

And now, the remainder of the blog will discuss the new ValueType.

 

If using an element or event frame Template

 

As mentioned earlier, previous versions required a Template for any attribute value filters.  An additional requirement was that the Template needed to be specified before the attribute value filters.  If the Template was specified after, then an exception was thrown.

 

Since AF SDK 2.10 removes the restriction on the Template, an interesting artifact is that you may specify the Template after the attribute value filter - and an exception will not be thrown.  However, we strongly recommend against this practice.  If you want to filter on a Template, we highly recommend you specify the Template first - just as you did with AF SDK prior to 2.10.  Nothing has changed with the new version here (nor should your existing code).  If you follow this advice, then you should skip the "AS valuetype" cast (more below).

 

Now let's consider if you don't follow the advice and you specify the Template after the attribute value filters.  You will need to include the "AS valuetype" cast, and its behavior will be as described in the remainder of this document.  The search will still be limited to the specified Template but as far as the attribute value filters are concerned, they will be treated as if the template was entirely omitted.  Precisely how they are treated depends on the attribute's data type and the "AS valuetype" cast you declare, which is presented in detail below.

 

Casting AS ValueType - When not using a Template

(or the Template is specified after the attribute value filters)

 

To support this new capability, there are several new things to discover with AFSearch to address the issue of a filter attribute's data type.  When based on a template, the data type is easily inferred.  What happens if a template isn't specified and the attribute does not belong to a template and/or may span different templates?  How does the search know which data type to use in the filter?  The answer is that it is left up to you (the developer) to pass the desired value type as you build the search, either by a query string or search tokens:

 

  • New AFSearchValueType enumeration
  • A new AFSearchToken.ValueType property (a string)
  • Two new AFSearchToken constructors to allow you to indicate the ValueType (also a string)

 

Golden Rule:

  • If you DO specify the template, do NOT specify the value type. 
  • If you do NOT specify a template, then you SHOULD specify a value type.

 

How carved in stone is the above "SHOULD"?  If there is any possibility whatsoever of an ambiguous interpretation between a String versus a Numeric, then you absolutely should specify the value type.  For example,  AFSearch has no way of knowing whether 1 or '1' or "1" should be a Numeric versus a String value.  Best practice: anything numeric should always specify "AS Numeric".

 

If you are using search tokens, you would use the new AFSearchToken constructors.  If you are using a query string, you would use the new AS <value type> syntax.  The available values for value type are:

  • Numeric, i.e. the literal text "Numeric"
  • String, i.e. the literal text "String"
  • EnumerationSet, the name of the applicable AFEnumerationSet.  Do NOT use the literal text "EnumerationSet".

 

Typical Scenarios with Numeric or String

 

The brief examples below look very similar with the exception of the value type designator (Numeric or String).  This bears repeating: you only need to use the AS valuetype if you do not specify a template.

 

Data Type Numeric: Integer (Byte, Int16, Int32, Int64) or Floating Point (Float, Single, or Double)

Consider if you have an attribute named RunStatus, and its data type is an Int32, where 0 means not running and 1 means running.  The query string could look like either of these:

  • "|RunStatus:1 AS Numeric"
  • "|RunStatus:'1' AS Numeric"

 

Data Type String

And if RunStatus was a String where "0" means not running and "1" means running, you would use these:

  • "|RunStatus:1 AS String"
  • "|RunStatus:'1' AS String"

 

Typical Scenarios with EnumerationSet

 

We continue covering scenarios when you do not specify a template.  We turn to another typical scenario of when the attribute's data type is an Enumeration Set.  Here it doesn't matter where the data comes from, be it a PI point, a table lookup, formula, or even a static value.  What matters to the value being filtered on is that the attribute has been declared to use an enumeration set.  The important thing is to use the AS specifier followed by the name of the enumeration set; do NOT use the literal value "EnumerationSet".

 

string attrPath = "|Classification";
string attrValue = "Commercial";
string enumSetName = "Building Type";
string query = $"'{attrPath}':='{attrValue}' AS '{enumSetName}'";

 

The above snippet safely wraps anything I created in quotes.  Note that because the enumeration set name contains a blank, I absolutely must wrap it in quotes.  In this specific case where neither attrPath or attrValue contain a blank, I could have omitted the quotes.  The value in the variable query will be:

 

"'|Classification':='Commercial' AS 'Building Type'"

 

Later when passed into an AFAttributeSearch constructor, the search instance will resolve to:

 

{|Classification:Commercial AS "Building Type"}

 

 

Cases Needing Special Consideration

 

There are special cases you may need to keep in mind beyond the typical Numeric, String, or EnumerationSet.  Obviously an attribute that is a string should use "AS String" and an attribute that has a number data type should use "AS Numeric".  What about data types that aren't so obvious?  Boolean or DateTime, for example?

 

Data Type Boolean: cast AS String

Before we even touch on enumeration sets, let's investigate another area of caution.  An attribute with a data type was Boolean is not exactly a number and not exactly a string.  As far as AFSearch is concerned, you should compare the literal values of a Boolean, namely "True" and "False", as strings.  Therefore the following filters would all be correct for a Boolean data type:

  • "|RunStatus:True AS String"
  • "|RunStatus:'True' AS String"

 

It's absolutely important with Booleans to specify "AS String".  You need to be aware that the following will quietly fail by returning 0 items:

  • "|RunStatus:True"

 

Data Type DateTime: cast AS Numeric

This can be a bit tricky.  The safest practice when dealing with DateTime attributes, whether you have a DateTime or an AFTime instance in code, is to use Round Trip Formatting.  That is to say, use ToString("O") when converting a DateTime or AFTime to string for the AFSearchToken constructor or within a query string.  And despite passing it as a string, it will actually be treated AS Numeric.  So these snippets work:

 

Variable "date" can either be DateTime or AFTime instance

  • $"|Timestamp:>'{date.ToString("o")}'"
  • $"|Timestamp:>'{date.ToString("o")}' AS Numeric"

 

The above uses an Interpolated String.  If you prefer string.Format, it would be:

  • string.Format("|Timestamp:>'{0}'", date.ToString("o"))
  • string.Format("|Timestamp:>'{0}' AS Numeric", date.ToString("o"))

 

TimeSpan with Data Type Anything: Not Supported

Some customers have attributes with data type "<Anything>" to hold a TimeSpan object.  Value filtering on such time span attributes is not supported in AF SDK 2.10.

 

If your time span attribute is defined to hold an integer or a floating point, then it would be treated as a Numeric data type (see above).

 

Digital PIPoint with Data Type Anything (not using an AFEnumerationSet): cast AS String or Omit AS specifier

For an attribute using the PI Point data reference that grabs from a Digital tag, we recommend that the attribute data type be "<Anything>".  You do not have to map the digital tag to an AFEnumerationSet.  You can if you wanted to, but that means (1) you have to copy the PIStateSet as an AFEnumerationSet, and (2) what to do with the attribute falls under "AS EnumerationSet" discussed elsewhere in this document.

 

Assuming you have a string variable named  "stateText", which contains the text of a given digital state, the following would be used to filter:

  • $"|Digital:'{stateText}'"
  • $"|Digital:'{stateText}' AS String"

 

For instance, if your digital tag used the "Modes" StateSet and you wanted to filter on those attributes with a mode of "Manual", either of these should suffice:

  • "|Digital:'Manual'"
  • "|Digital:'Manual' AS String"

 

Past AFSearch Blogs

 

What's new in AFSearch 2.9.5 (PI AF 2017 R2)  (March 2018) - AFAttributeSearch is introduced.

 

Coding Tips for using the AFSearch IN Operator - you may search for multiple values using IN(value1; value2; etc.).  Some code is offered to make this easier to generate.

 

Using the AFEventFrameSearch Class (Nov 2016) - Giving some attention to event frames since many of earliest AFSearch examples were element based.

 

Aggregating Event Frame Data Part 1 of 9 - Introduction  (May 2017) - From the UC 2017 TechCon/DevCon hands on lab for Advanced Developers.

 

PI World 2018 SF Developer Track - Getting the most out of AFSearch - (May 2018) From PI World 2018, DevCon presentation for Intermediate Developers.

 

Why you should use the new AF SDK Search  (June 2016) - The granddaddy of them all.  The earliest post explaining AFSearch, which was new at that time.

A customer recently asked about filtering on multiple values of an attribute in AFSearch.  This is easily addressed using the IN search operator in a query string, or the equivalent AFSearchToken constructor expecting an array of values.  There is one major caveat: the IN operator is not allowed for floating point types such as Single or Double, since binary floating point values are considered approximations instead of exact numbers.

 

There are a few tips to get developers pointed in the right direction.  First of all, values within the IN operator are delimited by semi-colons.  If you (like me) accidentally use a comma-delimited list, then you will receive an odd error about missing a closing parenthesis.  Then if you carefully inspect the query string, then you (like me) will start pulling your hair out because you don’t see a missing closing parenthesis!  Hopefully you may benefit from my pain by quickly switching your delimiter to be a semi-colon.

 

Another tip is that while blanks are allowed within the IN operator - or more specifically around the delimiters - any values that contain embedded blanks must be wrapped in quotes (single or double quotes).  Consider this list of cities:

 

  • London
  • Paris
  • San Francisco

 

Obviously, San Francisco needs to be wrapped in quotes due to the blank inside the name.  If the attribute path is “|City”, then you could have this C# filter in your query string:

 

string uglyQuery = "|City:IN(London; Paris; \"San Francisco\")";

 

Though ... that is a wee bit ugly.  Besides being ugly, it also might be confusing to someone using a different language, for instance, VB.NET.  A couple of weeks ago, I taught a class where a VB coder thought the unsightly \" were delimiters for AFSearch.   I explained to him that it's how C# escapes double quotes.  This can look much easier on the eyes if you just use single quotes:

 

string prettyQuery = "|City:IN(London; Paris; 'San Francisco')";

 

And it avoids any needless confusion that \" is an AFSearch delimiter.

 

This is all fine-and-dandy, but how often do you hard-code values?  It does make the example simple and straight-forward, but what if the list of items you wish to filter upon are in a list or array?  Let’s look at some code that helps us out.  After all, this is a PI Developers Club, which demands for code.  Strictly speaking, all we need for the collection of values is that they be in an enumerable collection.  Your source collection doesn’t have to be strings, but keep in mind that eventually we will need to pass a string to the AFSearch query, or if we use the AFSearchToken, then we must actually pass an array of strings.

 

For our cities example, it makes sense that the city names are strings.  Let’s not take the easy way out and make it an array of strings.  Instead we will use a list so that we can see what we must do differently to make it all work together:

 

List<string> cities = new List<string>() { "London", "Paris", "San Francisco" };

 

A tiny bit of  code is needed to put that inside a query string:

 

// A little LINQ to wrap single quotes around each city, and separate them with "; "
string delimitedCities = string.Join("; ", cities.Select(x => $"'{x}'"));

// An Interpolated String to substitute the delimitedCities.
string query = $"|City:IN({delimitedCities})";

 

This resulting value in query would be:

 

"|City:IN('London'; 'Paris'; 'San Francisco')"

 

Or if you prefer working with search tokens, this would be the equivalent:

 

// Tip: here you must pass a string array, so we must ToArray() on the cities list.
AFSearchToken token = new AFSearchToken(AFSearchFilter.Value, AFSearchOperator.In, cities.ToArray(), "|City");

 

If we had defined cities to be a string array, we would not need the ToArray(), but then this example would be boring and less educational.

 

What if our enumerable collection isn’t a bunch of strings?  Let’s say we have a bunch of GUID ’s.  (Exactly how you got these GUID's is an interesting question not addressed here ; suffice to say this example takes a collection of things that aren't strings and converts them to one that is.)  There would now be an extra step needed where we must convert to string.  Once we have a collection of strings we can then implement code similar to the previous examples.  Let’s imagine we have something like this:

 

IEnumerable<Guid> ids = GetListOfGuids();  // magically get a list of GUID

 

Or it could maybe have been:

 

IList<Guid> ids = GetListOfGuids();

 

Let's say we want to filter on an attribute path of “|ReferenceID”.  First let’s tackle the problem of converting a GUID into a string that is compatible with AFSearch.  This is easy enough thanks to LINQ:

 

// Nicest way to convert a GUID a string compatible with AFSearch is to use GUID.ToString("B").
IEnumerable<string> idStrings = ids.Select(x => x.ToString("B"));

 

Okay, so now we have an enumerable collection of strings.  Using what we learned in previous examples, we can knock this out:

 

// A little LINQ to wrap single quotes around each string item, and separate them with "; "
string delimitedIds = string.Join("; ", idStrings.Select(x => $"'{x}'"));

// An Interpolated String to substitute the items.
string query = $"|ReferenceID:IN({delimitedIds})";

 

Fantastic.  If you prefer AFSearchTokens, that’s easy enough as well, but we do require the idStrings to generate a string array.

 

// Tip: here you must pass a string array, so we ToArray() on the idStrings collection.
AFSearchToken token = new AFSearchToken(AFSearchFilter.Value, AFSearchOperator.In, idStrings.ToArray(), "|ReferenceID");

 

Granted our example would have been simplified if we defined idStrings to be an array in the first place, but what fun would there be in that?

 

 

VB.NET Examples

 

Some of us supporting the PI Developers Club think there should be more VB.NET examples.  Towards that end, here are code snippets for the VB coders out there:

 

    Dim uglyQuery As String = "|City:IN(London; Paris; ""San Francisco"")"

    Dim prettyQuery As String = "|City:IN(London; Paris; 'San Francisco')"

    Dim cities As List(Of String) = New List(Of String) From {"London", "Paris", "San Francisco"}

 

Cities Query Example:

 

Query String

        ' A little LINQ to wrap single quotes around each city, and separate them with "; "
        Dim delimitedCities As String = String.Join("; ", cities.Select(Function(x) $"'{x}'"))

        ' An Interpolated String to substitute the delimitedCities.
        Dim query As String = $"|City:IN({delimitedCities})"

 

AFSearchToken

' Tip: here you must pass a string array, so we ToArray() on the cities list.
Dim token As AFSearchToken = New AFSearchToken(AFSearchFilter.Value, AFSearchOperator.In, cities.ToArray(), "|City")

 

Guids Example (or something that is not a collection of strings)

 

Query String

        Dim ids As IEnumerable(Of Guid) = GetListOfGuids()   ' magically get a list of GUID

        ' Nicest way to convert a GUID a string compatible with AFSearch is to use GUID.ToString("B").
        Dim idStrings As IEnumerable(Of String) = ids.Select(Function(x) x.ToString("B"))

        ' A little LINQ to wrap single quotes around each string item, and separate them with "; "
        Dim delimitedIds As String = String.Join("; ", idStrings.Select(Function(x) $"'{x}'"))

        ' An Interpolated String to substitute the items.
        Dim query As String = $"|ReferenceID:IN({delimitedIds})"

 

AFSearchToken

        ' Tip: here you must pass a string array, so we ToArray() on the idStrings collection.
        Dim token As AFSearchToken = New AFSearchToken(AFSearchFilter.Value, AFSearchOperator.In, idStrings.ToArray(), "|ReferenceID")

 

Summary

 

If you want to filter on a variety of values for an attribute, this requires the IN search operator.

  • You may use IN in a query string or an AFSearchToken.
  • Values must be exact.  Therefore, IN does not work on Single or Double floating point data types.
  • In query strings
    • The semi-colon is the delimiter between IN values.  Example: "|City:IN(London; Paris; 'San Francisco')"
    • Values containing blanks must be enclosed in single or double quotes.  Example: "|City:IN(London; Paris; 'San Francisco')"
  • AFSearchToken
    • Values must be passed as strings in an array

I have been asked on many occasions “How can I find the first recorded event for a tag?”  The direct answer to this may be as brief as the question.  However, usually there is a lurking question-behind-the-question about what they really want to do with that first event, and if you dig even slightly deeper you will uncover the overall task they are trying to accomplish.  What you may ultimately discover is there is no need to find the first event.

 

Let’s start off with the direct, simple answer in the form of a C# extension method:

 

public static AFValue BeginningOfStream(this PIPoint tag) => tag.RecordedValue(AFTime.MinValue, AFRetrievalMode.After);

 

This works because the PI Point data reference implements the Rich Data Access (RDA) method of RecordedValue.  The earliest timestamp to query is AFTime.MinValue, that is midnight January 1, 1970 UTC.  Thanks to the AFRetrievalMode, you ask for the first value after January 1, 1970.  If it’s the earliest recorded timestamp you are only concerned with, you can use this extension method:

 

public static AFTime BeginningTimestamp(this PIPoint tag) => BeginningOfStream(tag).Timestamp;

 

For PI points, this would give you the BeginningOfStream method to go along with the built-in EndOfStream.  Before the advent of future data, the EndOfStream was simply the Snapshot.  But there are oddities related to future data, which required different handling of data compared to the traditional historical data.  Hence, Snapshot was replaced by CurrentValue, and EndOfStream was added.

 

An AFAttribute could have a BeginningOfStream method, but it doesn’t have the same nice guarantees of PIPoint.  It all depends upon the data reference being used and whether it supports the RDA Data.RecordedValue method, which is why you should properly check that it is supported before attempting to call it:

 

public static AFValue BeginningOfStream(this AFAttribute attribute)
{
    if (attribute.DataReferencePlugIn == null) // static attribute
    {
        return attribute.GetValue();
    }
    if (attribute.SupportedDataMethods.HasFlag(AFDataMethods.RecordedValue))
    {
        // Depends on how well the data reference PlugIn handles AFRetrievalMode.After.
        return attribute.Data.RecordedValue(AFTime.MinValue, AFRetrievalMode.After, desiredUOM: null);
    }
    // Fabricated answer.  Not exact that one is hoping for.  
    return AFValue.CreateSystemStateValue(AFSystemStateCode.NoData, AFTime.MinValue);
}

 

Since the value being returned may be fabricated, I would be hesitant to include a BeginningTimestamp method as it would mask the inaccuracies.  To compensate, I would think further inspection of the returned value is needed, i.e. check for “No Data”.  Such are the difficulties of trying to create a BeginningOfStream method within your code libraries.  This is why we begin to probe more and ask about your use-case, or simply “What are you really wanting to do?

 

Virtually 100% of the people asking me how to find the first value in a stream want to find it for historical PI points only.  This greatly simplifies part of the problem because there is no need to be concerned with attributes or tags with future data.  Which brings us right back to the direct answer at the top, where you may be inclined to stop.  But if you take time to dig just a little deeper into what they are really doing, the true mission is revealed: they want to copy all historical data from an old tag to a new tag.  And trust me, there are several legitimate use-cases for doing this.

 

When I hear anything about copying of historical data, the first thought I have is “How much data is in the old tag?”  There are two particular answers that require the same special handling: (a) I don’t know, or (b) a lot.

 

The real problem they need to solve isn’t finding the earliest recorded timestamp.  Rather, they may have so much data they will bump into the ArcMaxCollect limitation (typically 1.5 million data values in a single data request).  There are programming ways around ArcMaxCollect (more below) and they rely upon the PIPoint.RecordedValues method specifying a maxCount > 0 (for instance, 100K works well).  The perceived issue of knowing the earliest timestamp becomes a moot point.  The more important date is knowing the end time, that is the switch-over date from the old tag to the new tag.  Depending upon how the old tag was populated, this may very well be the EndOfStream.  But if there is a chance that the old tag could still be receiving “InterfaceShut” or “IOTimeout” messages, you will need to explicitly specify the end time.  Worrying about the earliest recorded date has been a distraction to solving the real problem.

 

What of your start time?  I would think an in-house developer should know of the earliest start of their company's archive files.  A contracted developer could use AFTime.MinValue or go with a later, but still much safer date, such as “1/1/1980”.  Which brings us back to what they really want to do: copy large or unknown amounts of data.    This has been blogged about many times before:

 

Extracting large event counts from the PI Data Archive

 

PI DataPipe Events Subscription and Data Access Utility using AF SDK - PIEventsNovo

 

GetLargeRecordedValues - working around ArcMaxCollect

The recording for this Live Coding presentation may be found at: Getting the most out of AFSearch

 

Associated code may be found on this GitHub repository:

GitHub - Rick-at-OSIsoft/AF-SDK-TechCon-2018-AFSearch-LiveCoding: Presentation also titled "Getting the most out of AFSe…

 

GitHub also includes a PDF file of the slide deck.  Topics include:

 

Demo 1: What's new in AF SDK 2.9.5 and 2.10

Demo 2: Breaking it down line-by-line to see what's happening on the client versus the server

Demo 3: Caching versus Not Caching

Demo 4: Accurate counts especially when client-side filtering is required

Demo 5: The perils of LINQ

 

Along the way we touch upon AFAttributeSearch, using the skinny, lightweight FindObjectFields with AFEventFrames, why caching is so important to performance, and why you need to properly dispose of the AFSearch objects.

On behalf of Engineering's Technology Enablement team, and in conjunction with Technical Support's Developer Technologies team, it is my privilege to announce this year's Community All-Stars.  We give special recognition to these individuals for their prolific and relentless contributions to the PI Community at large.  We thank them for sharing their experiences and knowledge for all things PI, and in particular for any posts that are developer related.  Let me add that each recipient holds near and dear to each of them the concept of "community", and even if we did not hand out such awards, or if we did not hand out any prize other than simple recognition, these individuals would still post and contribute to our PI Square Community with the same dedication for the sheer sake of expanding the knowledge base of the community.

 

I ask my fellow PI Geeks to help congratulate our deserving 2018 All-Stars!  It's interesting to note that are 3 Community All-Star and one Rising Star are from 4 different continents.

 

PI All Stars 2018.jpg

Rick Davin (far left), Paurav Joshi, John Messinger, and Dan Fishman.  Not shown: Roger Palmen.

 

 

PI Developers Community All-Stars 2018

  • Dan Fishman, Exele Information Systems.  Dan is a first time winner as Community All-Star but won twice before as OSIsoft All-Star.
  • John Messinger, Optimate Pty Ltd.  John wins for the 2nd year in a row.
  • Roger Palmen, CGI.  I don't remember a year when Roger wasn't a winner.

 

Last year as an experiment, I began following a lot of people.  Anyone who has ever been an All-Star, honorable mention, or just nominated.  I can tell you that my Inbox was slammed all year by answers from Dan, John, and Roger.  Thanks, guys.  I don't mean that sarcastically.  Each "spam" in my Inbox from you is you helping another community member, so absolutely, sincerely THANKS!

 

PI Developers Club Community Rising Star 2018

We introduced this category last year to recognize contributions and efforts on well deserving contributors that steadily add to our content here but just fall short of the elite upper strata of All-Star.  A big difference between Rising Star and All-Star is a Rising Star may only be awarded once, whereas All-Star may be won year after year (I'm looking at you, Roger).  Anyone who has ever been an All-Star or Rising Star in the past is ineligible for further consideration as a Rising Star.  Last year we handed out 3 of these as a means of playing catch-up to some folks who have been solid contributors over the year.  This year we have only one winner:

 

 

Paurav along with Dan, John, and Roger win:

  1. Recognition in the community
  2. One year free subscription or renewal to PI Developers Club
  3. One-time free registration to a 2019 PI World event (SF or EMEA)
  4. A legal waiver for them to sign
  5. An Amazon gift card worth $400 USD granted after submitting the signed legal waiver back to us

 

And let's not forget there are a lot of fine contributors within OSIsoft.  While it is our job to help customers, we do want to recognize a select few for their efforts the past year.

 

PI Developers Club OSIsoft All-Stars 2018

 

Our OSIsoft recipients win:

  1. Recognition in the community
  2. Amazon gift card worth $200 USD

 

The clock has been reset and everyone's slate is clean

It's never too early to start thinking about 2019 All-Stars.  We monitor the quantity and quality of posts from one PI World SF to the next in order to pick our winners.  The new countdown has begun.  One thing I can tell you that all of our 2018 winners have in common are the following:

  • A high volume of posts
  • Posts of high quality (lots flagged as helpful or marked as answers)
  • A strong desire to help others.

 

Absolutely, positively candidates must contribute to threads other than their own.  In short, you must show strong participation in the community as a whole.  Without a doubt this year's crop of winners have done just that.

 

CONGRATULATIONS TO ALL WINNERS!

Filter Blog

By date: By tag: