Click here to Skip to main content
13,588,575 members
Click here to Skip to main content
Add your own
alternative version

Tagged as

Stats

4.7K views
4 bookmarked
Posted 15 Apr 2018
Licenced CPOL

The DARL language and its online Fuzzy Logic Expert System engine

, 15 Apr 2018
Rate this:
Please Sign up or sign in to vote.
A free service makes it possible to use a fuzzy logic expert system online. We'll go through an example of coding in .Net core to access this, and look at some of the features of the engine and the DARL language.

Introduction

When the very first work on AI was done in the '50s there were two threads: logic and language, and copying nature. The earliest work was in simulated neurons, using strange electrical mechanical devices with motors and variable resistors, but pretty soon expert systems began to take over. Neural nets resurged in the '80s, along with other nature-inspired ideas like GA and GP,  and once again they are the flavor of the month with deep learning, but expert systems faded in the intervening years because of the complexity of their maintenance and the requirement for "knowledge engineers" to create them.

There are still expert systems in use. If you use one of the general equation solving and mathematical manipulation products, you are using an expert system. This application is perfect for the old-fashioned kind of expert systems, because the laws of mathematics will never change - so no maintenance!

DARL is an attempt to drag experts systems into the 21st century.  DARL was initially created as a solution to a problem that still exists today in Machine Learning: how do you audit a trained Neural network? I.e. if you use Machine Learning to create a model that you use in a real world example, how do you ensure it doesn't accidentally do something bad, like identify the wrong person as a potential terrorist, or deny a loan to a minority group? Neural networks and other similar techniques produce models that are "black boxes". The answer the designer of DARL found was to use Fuzzy Logic Rules as the  model representation mechanism. Algorithms exist to perform Supervised, Unsupervised and Reinforcement learning to these rules.

DARL grew out of that. Initially the models were coded in XML, but later a fully fledged language was created so that all the usual tools like editors, interpreters etc. could be used with the models. The rules are very easy to understand, so auditing them for unexpected effects is simple. It would be possible, for instance, for a finance company to machine  learn a model to, say, determine loan qualification and then publish the DARL code, or at least provide it in court as a defence to charges of bias.

So DARL became something you could use both to data mine to, and something you could create models with by hand. It is unique in serving both purposes.

In this article we are going to look at the free online resources for DARL, and use a pre-written piece of DARL that calculates taxes for UK residents to demonstrate some of the features of DARL and the engine.

Uncertainty

All machine learning has inherent innacuracies. If a particular process can be modelled by some kind of analytical process no one in their right mind would use ML. It is only used where the model is very uncertain. Usually the data is noisy, and the model itself carries so called "model errors" as part of imperfect machine learning.

So a modelling language for ML must have implicit support for uncertainty too. DARL has many ways to handle uncertainty. It supports Fuzzy logic, so supports the idea of partial truth, and the associated mathematical discipline of Possibility Theory. It supports Fuzzy Arithmetic, so it supports interval maths, tolerances, fuzzy sets, etc. In using DARL and its engine you can input 'crisp' values, i.e. numeric or categorical values you are certain of, or you can annotate them with uncertainty: so supply an interval or a fuzzy number if a value is uncertain.

There are more possibilities too. DARL will shortly have temporal inputs and outputs, supporting "fuzzy time" and DARL can handle text inputs as well in various ways, But our example here will concentrate on just categorical and numeric values.

 

A DARL program

In its simplest form, a DARL program consists of a single Ruleset. This is a collection of rules, input and output definitions that form a functional block. 

ruleset UKTAXNI
{

}

Inside the ruleset you define the local inputs and outputs. They look like this for the tax example:

    //Input definitions
    input numeric EARNED_INCOME  {{Low , 0.000, 0.000}, {High , 1000000.000, 1000000.000}};
    input numeric DIVIDEND_INCOME  {{Low , 0.000, 0.000}, {High , 1000000.000, 1000000.000}};
    input numeric AGE_YEARS  {{Low , 0.000, 0.000}, {High , 100.000, 100.000}};
    input categorical MARRIED  {True, False};
    input categorical BLIND  {True, False};
    //Output definitions
    output numeric TOTAL_ALLOWANCES ;
    output numeric TOTAL_INCOME ;
    output numeric EARNED_TAX ;
    output numeric DIVIDEND_TAX ;
    output numeric TOTAL_TAX ;
    output numeric TOTAL_TAX_MONTHLY ;
    output numeric NI ;
    output numeric NI_MONTHLY ;
    output numeric EARNED_INCOME_AFTER_ALLOWANCES ;
    output numeric TOTAL_INCOME_AFTER_ALLOWANCES ;
    output numeric TOTAL_YEARLY_TAKE_HOME ;
    output numeric TOTAL_MONTHLY_TAKE_HOME ;
    output numeric YEARLY_EMPLOYERS_NI ;
    output numeric TAX_TAKE_PERCENT ;

In DARL, within a ruleset there is no neccessary order for any element. You can declare an input or output, or write a rule anywhere. There is considerable runtime processing of rules to establish dependencies, look for circular references etc. Because of this you do not need to consider sequence of rules or definitions. However things are much easier to read if you keep to the format of inputs, outputs, constants and then rules.

In the above example we've got numeric and categorical inputs and numeric outputs. 

input numeric EARNED_INCOME  {{Low , 0.000, 0.000}, {High , 1000000.000, 1000000.000}};

defines a numeric input called EARNED_INCOME with two fuzzy sets defined, called low and high. The sets aren't used in the example, but they help post processing and the questionnaire engine by defining the expected range of the input.

Variable names in darl follow C# conventions, as do comments.

input categorical MARRIED  {True, False};

This is a categorical input. two categories are defined.  The names of categories or sets don't have to be C# type names, but if they are not they must be enclosed in quotations.

There are constants defined in this ruleset. You can use constants to represent numbers or strings so as to need to change only one location if a change is required, but number or string literals can be used directly

    //numeric constant definitions
    constant Basic_Allowance 8105;
    constant Allowance_Income_Limit 100000;
    constant Blind_Persons_allowance 2100;
    constant SixtyFive_74_allowance 9940;
    constant AGE_65 65;
    constant AGE_75 75;
    constant SeventyFive_allowance 10090;
    constant Age_related_income_limit 24000;
    constant Married_couple_max 7295;
    constant Married_couple_min 2800;
    constant Higher_rate_threshold 34371;
    constant Basic_rate 0.2;
    constant Higher_rate 0.4;
    constant Additional_rate_threshold 150000;
    constant Additional_rate 0.5;
    constant Savings_starting_rate_threshold 2560;
    constant Savings_rate 0.1;
    constant Dividend_upper_rate_threshold 34371;
    constant Dividend_additional_rate_threshold 150000;
    constant Dividend_ordinary_rate 0.1;
    constant Dividend_upper_rate 0.325;
    constant Dividend_additional_rate 0.425;
    constant Dividend_grossing_up 1.1111111;
    constant NI_lower_threshold 7592;
    constant NI_upper_threshold 42484;
    constant NI_lower_rate 0.12;
    constant NI_upper_rate 0.02;
    constant NI_lower_total 4187;
    constant ZERO 0;
    constant Allowance_derating 0.5;
    constant Employers_NI_Threshold 7488;
    constant Employers_NI_Rate 0.138;
    constant One_Hundred 100;
    constant Twelve 12;
    constant One 1;
    constant Ten 10;
    //string constant definitions
    string MonthlyEmpNIConst "Employers' NI, per month";

The rules in this tax example look like this:

    if AGE_YEARS is < AGE_65 and BLIND is False then TOTAL_ALLOWANCES will be maximum( Basic_Allowance - maximum( TOTAL_INCOME - Allowance_Income_Limit , ZERO ) * Allowance_derating , ZERO ) ;
    if AGE_YEARS is < AGE_65 and BLIND is True then TOTAL_ALLOWANCES will be maximum( Basic_Allowance + Blind_Persons_allowance - maximum( TOTAL_INCOME - Allowance_Income_Limit , ZERO ) * Allowance_derating , ZERO ) ;
    if AGE_YEARS is >= AGE_65 and BLIND is False and TOTAL_INCOME is < Allowance_Income_Limit and AGE_YEARS is < AGE_75 then TOTAL_ALLOWANCES will be SixtyFive_74_allowance ;
    if AGE_YEARS is >= AGE_75 and BLIND is False and TOTAL_INCOME is < Allowance_Income_Limit and MARRIED is False then TOTAL_ALLOWANCES will be SeventyFive_allowance ;
    if EARNED_INCOME is <= TOTAL_ALLOWANCES then EARNED_TAX will be ZERO ;
    if EARNED_INCOME_AFTER_ALLOWANCES is <= Higher_rate_threshold then EARNED_TAX will be EARNED_INCOME_AFTER_ALLOWANCES * Basic_rate ;
    if EARNED_INCOME_AFTER_ALLOWANCES is > Higher_rate_threshold and EARNED_INCOME_AFTER_ALLOWANCES is <= Additional_rate_threshold then EARNED_TAX will be Higher_rate_threshold * Basic_rate + ( EARNED_INCOME_AFTER_ALLOWANCES - Higher_rate_threshold ) * Higher_rate ;
    if EARNED_INCOME_AFTER_ALLOWANCES is > Additional_rate_threshold then EARNED_TAX will be Higher_rate_threshold * Basic_rate + ( Additional_rate_threshold - Higher_rate_threshold ) * Higher_rate + ( EARNED_INCOME_AFTER_ALLOWANCES - Additional_rate_threshold ) * Additional_rate ;
    if TOTAL_INCOME_AFTER_ALLOWANCES is <= Dividend_upper_rate_threshold then DIVIDEND_TAX will be ZERO ;
    if TOTAL_INCOME_AFTER_ALLOWANCES is > Dividend_upper_rate_threshold and TOTAL_INCOME_AFTER_ALLOWANCES is <= Dividend_additional_rate_threshold then DIVIDEND_TAX will be minimum( TOTAL_INCOME_AFTER_ALLOWANCES - Dividend_upper_rate_threshold , DIVIDEND_INCOME * Dividend_grossing_up ) * ( Dividend_upper_rate - Dividend_ordinary_rate ) ;
    if TOTAL_INCOME_AFTER_ALLOWANCES is > Dividend_additional_rate_threshold then DIVIDEND_TAX will be minimum( TOTAL_INCOME_AFTER_ALLOWANCES - Dividend_upper_rate_threshold , DIVIDEND_INCOME * Dividend_grossing_up ) * ( Dividend_upper_rate - Dividend_ordinary_rate ) ;
    if EARNED_INCOME is <= NI_lower_threshold then NI will be ZERO ;
    if EARNED_INCOME is > NI_lower_threshold and EARNED_INCOME is <= NI_upper_threshold then NI will be ( EARNED_INCOME - NI_lower_threshold ) * NI_lower_rate ;
    if EARNED_INCOME is > NI_upper_threshold then NI will be ( EARNED_INCOME - NI_upper_threshold ) * NI_upper_rate + NI_lower_total ;
    if anything then TOTAL_INCOME will be sum( EARNED_INCOME , DIVIDEND_INCOME * Dividend_grossing_up ) ;
    if anything then EARNED_INCOME_AFTER_ALLOWANCES will be maximum( EARNED_INCOME - TOTAL_ALLOWANCES , ZERO ) ;
    if anything then TOTAL_INCOME_AFTER_ALLOWANCES will be maximum( EARNED_INCOME + DIVIDEND_INCOME * Dividend_grossing_up - TOTAL_ALLOWANCES , ZERO ) ;
    if anything then TOTAL_TAX will be sum( EARNED_TAX , DIVIDEND_TAX ) ;
    if EARNED_INCOME is <= Employers_NI_Threshold then YEARLY_EMPLOYERS_NI will be ZERO ;
    if EARNED_INCOME is > Employers_NI_Threshold then YEARLY_EMPLOYERS_NI will be ( EARNED_INCOME - Employers_NI_Threshold ) * Employers_NI_Rate ;
    if anything then TAX_TAKE_PERCENT will be ( TOTAL_TAX + YEARLY_EMPLOYERS_NI ) / maximum( TOTAL_INCOME + YEARLY_EMPLOYERS_NI , One ) * One_Hundred ;
    if anything then TOTAL_YEARLY_TAKE_HOME will be EARNED_INCOME + DIVIDEND_INCOME - TOTAL_TAX + NI ;
    if anything then TOTAL_MONTHLY_TAKE_HOME will be ( EARNED_INCOME + DIVIDEND_INCOME - TOTAL_TAX + NI ) / Twelve ;
    if anything then NI_MONTHLY will be NI / Twelve ;
    if anything then TOTAL_TAX_MONTHLY will be TOTAL_TAX / Twelve ;

That's all that is required to handle UK personsal TAX and NI!

Rules follow a basic form:

if <logical tests> then <output> will be <output rhs> [confidence [0-1]];

To pick some examples

if anything then NI_MONTHLY will be NI / Twelve ;

anything always returns a degree of truth of 1. NI_MONTHLY is a numeric output representing the national insurance paid per month, and here it's value is assigned to an arithmetic expression of the value of the output NI divided by the constant twelve.

if EARNED_INCOME is <= TOTAL_ALLOWANCES then EARNED_TAX will be ZERO ;

In this case "is" is used as a comparison operator, and what appears on the right hand side depends on the input or output to the left. Since EARNED_INCOME is numeric, it can be compared to a constant, an arithmetic expression, a fuzzy set definition, or a fuzzy number. 

if AGE_YEARS is < AGE_65 and BLIND is False then TOTAL_ALLOWANCES will be maximum( Basic_Allowance - maximum( TOTAL_INCOME - Allowance_Income_Limit , ZERO ) * Allowance_derating , ZERO ) ;

In this long rule "BLIND is False" compares "BLIND" to the category "False". Note that booleans in DARL are just examples of categorical variables. 

Rules can optionally have a degree of confidence of their own, which controls the power of that individual rule.

There is also another construction not used in this example for default handling which looks like this:

otherwise if <logical tests> then <output> will be <output rhs> [confidence [0-1]];

There can only be one of these per output and they are triggered only if no other rules for that output are triggered.

 

Editing your own rulesets

An online editor is provided at Darl.ai/darldevelop that can be used to edit DARL. It has a built in syntax checker and auto-suggest.

Using the ruleset in anger

The code used in this example can be obtained at https://github.com/drandysip/DarlRestExample.

It makes use of two classes DarlVar and DarlInfData which are helpers to use the API.

DarlVar is a general purpose class intended to represent a wide range of input and output types and their associated uncertainties.  you can see a definition here, but in this example we only use four fields. These are "name", the name of the I/O, dataType, which can be numeric, categorical or textual, Value, which is the central value as a string, values, a list of doubles for numeric uncertainty, and a list of categories for catagorical uncertainty, which consist of pairs of categories and certainty values. 

DarlInfData just holds the source DARL code and a list of DarlVars corresponding to the data to run through the ruleset.

The REST interface that we're going to use is defined here.

        static async Task<List<DarlVar>> PerformInference(string source, List<DarlVar> values)
        {
            var data = new DarlInfData { source = source, values = values };
            var valueString = JsonConvert.SerializeObject(data);
            var client = new HttpClient();
            var response = await client.PostAsync("https://darl.ai/api/Linter/DarlInf", new StringContent(valueString, Encoding.UTF8, "application/json"));
            var resp = await response.Content.ReadAsStringAsync();
            return JsonConvert.DeserializeObject<List<DarlVar>>(resp);
        }

This code accesses the inference engine passing a DarlInfData class encoded in Json, and receiving a list of DarlVars

The rest of the demonstration code looks like this:

        static async Task DarlInference()

        {

            var reader = new StreamReader(Assembly.GetExecutingAssembly().GetManifestResourceStream("DarlRestExample.UKTaxNI.darl"));

            var source = reader.ReadToEnd();

            var values = new List<DarlVar>();

            values.Add(new DarlVar { name = "EARNED_INCOME", Value = "15600", dataType = DarlVar.DataType.numeric });

            values.Add(new DarlVar { name = "DIVIDEND_INCOME", Value = "15600", dataType = DarlVar.DataType.numeric });

            values.Add(new DarlVar { name = "AGE_YEARS", Value = "52", dataType = DarlVar.DataType.numeric });

            values.Add(new DarlVar { name = "MARRIED", Value = "False", dataType = DarlVar.DataType.categorical });

            values.Add(new DarlVar { name = "BLIND", Value = "False", dataType = DarlVar.DataType.categorical });

            var response = await PerformInference(source, values);

            Console.WriteLine("Simple crisp example");

            foreach (var r in response)

                Console.WriteLine(r.ToString());

            values.Clear();

            values.Add(new DarlVar { name = "EARNED_INCOME",  values = new List<double> { 12000.0, 18000.0 }, dataType = DarlVar.DataType.numeric });

            values.Add(new DarlVar { name = "DIVIDEND_INCOME", values = new List<double> { 18000.0, 19000.0 }, dataType = DarlVar.DataType.numeric });

            values.Add(new DarlVar { name = "AGE_YEARS", values = new List<double> { 37.0, 42.0 }, dataType = DarlVar.DataType.numeric });

            values.Add(new DarlVar { name = "MARRIED", Value = "False", dataType = DarlVar.DataType.categorical });

            values.Add(new DarlVar { name = "BLIND", categories = new Dictionary<string, double> { { "True", 0.7 }, { "False", 0.3 } }, dataType = DarlVar.DataType.categorical });

            response = await PerformInference(source, values);

            Console.WriteLine("Fuzzy Interval example");

            foreach (var r in response)

                Console.WriteLine(r.ToString());

            values.Clear();

            values.Add(new DarlVar { name = "EARNED_INCOME", dataType = DarlVar.DataType.numeric });

            values.Add(new DarlVar { name = "DIVIDEND_INCOME", dataType = DarlVar.DataType.numeric, Value = "33000", unknown = true });

            values.Add(new DarlVar { name = "AGE_YEARS", values = new List<double> { 37.0, 42.0 }, dataType = DarlVar.DataType.numeric });

            values.Add(new DarlVar { name = "MARRIED", Value = "False", dataType = DarlVar.DataType.categorical });

            values.Add(new DarlVar { name = "BLIND", Value = "False", dataType = DarlVar.DataType.categorical });

            response = await PerformInference(source, values);

            Console.WriteLine("Unknown handling example");

            foreach (var r in response)

                Console.WriteLine(r.ToString());

            values.Clear();
}

The above code has three sections after the DARL source is loaded from an embedded fresource file.

In the first a set of DarlVars are created matching the inputs of the ruleset and with values set to 'crisp' values. 

In the second, the Value fields are not used, except for "MARRIED", and instead intervals are passed for the numeric inputs and fuzzy categories for the Blind input, so demonstrating  inference engine's ability to infer in the presence of uncertainty.

And finally, in the third section a couple of the DarlVars are set to the unknown state, in one case explicitly, in another by giving no Value, and the inference engine is shows how it handles missing data.

The results generated:

Simple crisp example
name = TOTAL_ALLOWANCES, datatype = numeric Central value: 8105, isUnknown = False, confidence = 1
name = TOTAL_INCOME, datatype = numeric Central value: 32933.33316, isUnknown = False, confidence = 1
name = EARNED_TAX, datatype = numeric Central value: 1499, isUnknown = False, confidence = 1
name = DIVIDEND_TAX, datatype = numeric Central value: 0, isUnknown = False, confidence = 1
name = TOTAL_TAX, datatype = numeric Central value: 1499, isUnknown = False, confidence = 1
name = TOTAL_TAX_MONTHLY, datatype = numeric Central value: 124.916666666667, isUnknown = False, confidence = 1
name = NI, datatype = numeric Central value: 960.96, isUnknown = False, confidence = 1
name = NI_MONTHLY, datatype = numeric Central value: 80.08, isUnknown = False, confidence = 1
name = EARNED_INCOME_AFTER_ALLOWANCES, datatype = numeric Central value: 7495, isUnknown = False, confidence = 1
name = TOTAL_INCOME_AFTER_ALLOWANCES, datatype = numeric Central value: 24828.33316, isUnknown = False, confidence = 1
name = TOTAL_YEARLY_TAKE_HOME, datatype = numeric Central value: 30661.96, isUnknown = False, confidence = 1
name = TOTAL_MONTHLY_TAKE_HOME, datatype = numeric Central value: 2555.16333333333, isUnknown = False, confidence = 1
name = YEARLY_EMPLOYERS_NI, datatype = numeric Central value: 1119.456, isUnknown = False, confidence = 1
name = TAX_TAKE_PERCENT, datatype = numeric Central value: 7.68940243836403, isUnknown = False, confidence = 1
Fuzzy Interval example
name = TOTAL_ALLOWANCES, datatype = numeric Central value: 9155, isUnknown = False, confidence = 1 Fuzzy numeric values = 8105,10205
name = TOTAL_INCOME, datatype = numeric Central value: 35555.55535, isUnknown = False, confidence = 1 Fuzzy numeric values = 31999.9998,39111.1109
name = EARNED_TAX, datatype = numeric Central value: 1169, isUnknown = False, confidence = 1 Fuzzy numeric values = 359,1979
name = DIVIDEND_TAX, datatype = numeric Central value: 0, isUnknown = False, confidence = 1
name = TOTAL_TAX, datatype = numeric Central value: 1169, isUnknown = False, confidence = 1 Fuzzy numeric values = 359,1979
name = TOTAL_TAX_MONTHLY, datatype = numeric Central value: 97.4166666666666, isUnknown = False, confidence = 1 Fuzzy numeric values = 29.9166666666667,164.916666666667
name = NI, datatype = numeric Central value: 888.96, isUnknown = False, confidence = 1 Fuzzy numeric values = 528.96,1248.96
name = NI_MONTHLY, datatype = numeric Central value: 74.08, isUnknown = False, confidence = 1 Fuzzy numeric values = 44.08,104.08
name = EARNED_INCOME_AFTER_ALLOWANCES, datatype = numeric Central value: 5845, isUnknown = False, confidence = 1 Fuzzy numeric values = 1795,9895
name = TOTAL_INCOME_AFTER_ALLOWANCES, datatype = numeric Central value: 26400.55535, isUnknown = False, confidence = 1 Fuzzy numeric values = 21794.9998,31006.1109
name = TOTAL_YEARLY_TAKE_HOME, datatype = numeric Central value: 33219.96, isUnknown = False, confidence = 1 Fuzzy numeric values = 28549.96,37889.96
name = TOTAL_MONTHLY_TAKE_HOME, datatype = numeric Central value: 2768.33, isUnknown = False, confidence = 1 Fuzzy numeric values = 2379.16333333333,3157.49666666667
name = YEARLY_EMPLOYERS_NI, datatype = numeric Central value: 1036.656, isUnknown = False, confidence = 1 Fuzzy numeric values = 622.656,1450.656
name = TAX_TAKE_PERCENT, datatype = numeric Central value: 6.46663096745925, isUnknown = False, confidence = 1 Fuzzy numeric values = 2.42015098213091,10.5131109527876
Unknown handling example
name = TOTAL_ALLOWANCES, datatype = numeric Central value: , isUnknown = True, confidence = 0
name = TOTAL_INCOME, datatype = numeric Central value: , isUnknown = True, confidence = 0
name = EARNED_TAX, datatype = numeric Central value: , isUnknown = True, confidence = 0
name = DIVIDEND_TAX, datatype = numeric Central value: , isUnknown = True, confidence = 0
name = TOTAL_TAX, datatype = numeric Central value: , isUnknown = True, confidence = 0
name = TOTAL_TAX_MONTHLY, datatype = numeric Central value: , isUnknown = True, confidence = 0
name = NI, datatype = numeric Central value: , isUnknown = True, confidence = 0
name = NI_MONTHLY, datatype = numeric Central value: , isUnknown = True, confidence = 0
name = EARNED_INCOME_AFTER_ALLOWANCES, datatype = numeric Central value: , isUnknown = True, confidence = 0
name = TOTAL_INCOME_AFTER_ALLOWANCES, datatype = numeric Central value: , isUnknown = True, confidence = 0
name = TOTAL_YEARLY_TAKE_HOME, datatype = numeric Central value: , isUnknown = True, confidence = 0
name = TOTAL_MONTHLY_TAKE_HOME, datatype = numeric Central value: , isUnknown = True, confidence = 0
name = YEARLY_EMPLOYERS_NI, datatype = numeric Central value: , isUnknown = True, confidence = 0
name = TAX_TAKE_PERCENT, datatype = numeric Central value: , isUnknown = True, confidence = 0

Summing up

In our new rush to all things AI rule based systems have been forgotten, but they have huge potential. 

For more information look at the darl.ai site. The free trial gives you acces to a Bot system making use of DARL, but there are also multiple example rulesets covering personality tests to Lawtech examples. You can also see machine learning to DARL in action.

History

First version 04/15/2018

 

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

AndyEdmonds
United Kingdom United Kingdom
No Biography provided

You may also be interested in...

Pro
Pro

Comments and Discussions

 
Answerthanks Pin
Member 138168737-May-18 23:44
memberMember 138168737-May-18 23:44 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

Permalink | Advertise | Privacy | Cookies | Terms of Use | Mobile
Web01 | 2.8.180615.1 | Last Updated 16 Apr 2018
Article Copyright 2018 by AndyEdmonds
Everything else Copyright © CodeProject, 1999-2018
Layout: fixed | fluid