Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / multimedia / GDI+

Create SPC CP and CPK Chart using C#

4.90/5 (25 votes)
14 Jul 2015CPOL7 min read 66.3K   4.7K  
Use C# to create a simple SP, CP and CPK chart
In this article, we can see in detail how to create a simple SPC (Statistical Process Control) CP, CPK Chart using C#.

Image 1

Introduction

In this article, we can see in detail how to create a simple SPC (Statistical Process Control) CP, CPK Chart.

I have been working on several automation projects. Nowadays, automobile industry is interested in automated measuring machines to ensure quality and to compete in the global industry. The main part of any automation software is to get the accurate result with Quality check, for this purpose, we use the SPC (Statistical Process Control) to find the quality result.

You can also view my previous articles related to Factory Automation like:

Capability is to get the continuous data from any part and compare the result with Cp, Cpk, Pp and Ppk. Where Cp, Cpk are Process Capability and Pp, Ppk are process performance. Let’s consider one of my real time projects, which is using in a Manufacturing Automation Measuring machines. Let’s consider now Camshaft, Crankshaft or any part of Car Engine which need to be checked with Quality control. The check part will be measured using some kind for Sensors, for example, in our project, we use a digital sensor for measuring Camshaft and Crankshaft. Using the sensor, we get continuous data and the real time data will be checked with SPC Cp, Cpk, Pp and Ppk charts. Display the final output to the operator and to the Quality control Engineer. Here is my real time project screen. All the Measurement data has been received from the Digital Sensor.

Image 2

I have hidden the SPC Cp, Cpk, Pp and Ppk chart as we used some third party chart control at that time. For the SPC chart in the market, there are very few third party chart controls available and there is no any free chart for SPC Cp, Cpk, Pp and Ppk. I thought of creating a simple SPC Cp, Cpk, Pp and Ppk chart. As a result, after a long study about SPC and all its functionality, I have created a simple SPC Cp, Cpk, Pp and Ppk chart. Hope you all will like it.

I created the SPC chart as a User Control. You can just add my User Control DLL to the project and use it.

Shanu CpCpkChart User Control Features

  • Display chart with Sum, Mean and Range values
  • Cp, Cpk, Pp, Ppk with alert result text warning with Red for NG and green for OK result
  • Mean (XBAR) and Range (RBAR) chart with Alert image with Red for NG and green for OK result
  • Automatic Calculate and display UCL(Upper Control Limit), LCL(Lower Control Limit) Value with both XBAR and RBAR value
  • Save Chart as Image (Note to save the Chart image double click the chart or right click and save as Image).
  • Real Time data Gathering and display in the Chart
  • User can add Chart Watermark text

Image 3

First, let’s see what is Cp and Cpk.

Cp and Cpk -> Process Capability

  • Cp - measures how well the data fits within the spec limits (USL, LSL)
  • Cpk - measures how centered the data is between the spec limits

Cp, Cpk Formula

Image 4

Cp=(USL-LSL/6SIGMA) -> USL-LSL/6*RBAR/d2
Cpk=min(USL-XBAR/3Sigma,LSL-XBAR/3Sigma)

Pp and Ppk -> Process Performance

  • Pp - measures how well the data fits within the spec limits (USL, LSL)
  • Ppk - measures how centered the data is between the spec limits

Pp, Ppk Formula

Image 5

Pp=(USL-LSL/6SIGMA) -> USL-LSL/6 STDEV

Ppk=min(USL-XBAR/3STDEV,LSL-XBAR/3STDEV)

Reference Websites

Now let’s see how I have created a SPC Cp, Cpk chart. My main aim is to make a very simple SPC Chart which can be used by the end users.

I have created a SPC Cp, Cpk Chart as a User Control so that it can be used easily in all projects.

I have attached a zip file named as ShanuSPCCpCpkChart.zip which you can download from the link at the top of this article. It contains my SPC chart user control source and a demo program.

  1. "ShanuCPCPKChart.dll": You can use this user controls in your project and pass the data as DataTable to userControl.
    In the user control, I will get the DataTable value and calculate the result to display as:
  • Sum
  • Mean
  • Range
  • Cp
  • Cpk
  • Pp
  • Ppk

Bind all the results to the chart with smiley alert image. If the data is good, then display the green smiley and if the data is NG(Not Good). Using the mean and Range values, I will plot the result as Mean and Range chart with Alert Image.

User can pass the USL (upper Specification Limit), LSL (lower Specification Limit) Cpk Limit values to the ShanuCPCPKChart user control. Using the USL, LSL and Cpk value, the result will be calculated and display the appropriate alert with red if NG for Cp, Cpk ,Pp, Ppk values. If the result is good, then green text will be displayed to Cp, Cpk, Pp, Ppk in the chart.

"ShanuSPCCpCPK_Demo" folder - This folder contains the demo program which includes the ShanuCPCPKChart user control with Random Data sample.

Note: I have used DataTable as the data input for the User Control. From the Windows Form, we need to pass the DataTable to User control to plot the Cp, Cpk, Pp and Ppk result with SPC Range Chart.

Save Chart User can save the Chart by double clicking on the chart control or by right click the chart and click Save.

Image 6

Using the Code

I have used Visual Studio 2010.

1) SPC User Control Program

First, we will start with the User Control. To create a user control:

  1. Create a new Windows Control Library project.
  2. Set the Name of Project and click OK (here, my user control name is ShanuCPCPKChart).
  3. Add all the controls which are needed.
  4. In code behind, declare all the public variables and Public method. In User control, I have added one panel and one Picture Box Control.
C#
public DataTable dt = new DataTable();
        Font f12 = new Font("arial", 12, FontStyle.Bold, GraphicsUnit.Pixel);
        Pen B1pen = new Pen(Color.Black, 1);
        Pen B2pen = new Pen(Color.Black, 2);
        Double XDoublkeBAR = 0;
        Double RBAR = 0;
        Double XBARUCL = 0;
        Double XBARLCL = 0;
        Double RANGEUCL = 0;
        Double RANGELCL = 0;
        Double[] intMeanArrayVals;
        Double[] intRangeArrayVals;
        Double[] intSTDEVArrayVals;
        Double[] intXBARArrayVals;

        int First_chartDatarectHeight = 80;
        Font f10 = new Font("arial", 10, FontStyle.Bold, GraphicsUnit.Pixel);
        LinearGradientBrush a2 = new LinearGradientBrush(new RectangleF(0, 0, 100, 19), 
        Color.DarkGreen, Color.Green, LinearGradientMode.Horizontal);
        LinearGradientBrush a3 = new LinearGradientBrush(new RectangleF(0, 0, 100, 19), 
        Color.DarkRed, Color.Red, LinearGradientMode.Horizontal);
        LinearGradientBrush a1 = new LinearGradientBrush(new RectangleF(0, 0, 100, 19), 
        Color.Blue, Color.DarkBlue, LinearGradientMode.Horizontal);

Once the variable is declared, I create a public function as Bindgrid. This function will be used from Windows Form to pass the DataTable. In this function, I will check for the DataTable and if the DataTable is not null, I refresh the PictureBox which will call the PictureBox paint method to Plot all new values for SPC Chart.

C#
public void Bindgrid(DataTable dtnew)
        {
            if (dtnew != null)
            {
                dt = dtnew;
                PicBox.Refresh();
            }
        }

In PictureBox paint event, I will check for the DataTable data. Using the data, I calculate and draw the result of all Sum, Mean, Range, Cp, Cpk, Pp and Ppk to the SPC Chart. Using this information, I have created UCL and LCL by Standard formula. For details about UCL and LCL calculation, kindly check the above links. After all the values are calculated, I will draw the SPC chart in PictureBox using GDI+ and display the result to the end user.

C#
public void PicBox_Paint(object sender, PaintEventArgs e)
        {
            if (dt.Rows.Count <= 0)
            {
                return;
            }

            int opacity = 68; // 50% opaque (0 = invisible, 255 = fully opaque)

            e.Graphics.DrawString(ChartWaterMarkText,
                new Font("Arial", 72),
                new SolidBrush(Color.FromArgb(opacity, Color.OrangeRed)),
                80,
                  PicBox.Height / 2 - 15);

            int NoofTrials = dt.Rows.Count;
            int NoofParts = dt.Columns.Count - 1;

            intMeanArrayVals = new Double[NoofParts];
            intRangeArrayVals = new Double[NoofParts];
            intSTDEVArrayVals = new Double[NoofParts];
            intXBARArrayVals = new Double[NoofParts];

            if (dt.Rows.Count <= 0)
            {
                return;
            }

            PicBox.Width = dt.Columns.Count * 50 + 40;

            // 1) For the Chart Data Display ---------

     e.Graphics.DrawRectangle(Pens.Black, 10, 10, PicBox.Width - 20, 
                              First_chartDatarectHeight + 78);

            // for the chart data Horizontal Line Display
            e.Graphics.DrawLine(B1pen, 10, 25, PicBox.Width - 10, 25);
            e.Graphics.DrawLine(B1pen, 10, 45, PicBox.Width - 10, 45);
            e.Graphics.DrawLine(B1pen, 10, 65, PicBox.Width - 10, 65);
            e.Graphics.DrawLine(B1pen, 10, 85, PicBox.Width - 10, 85);
            e.Graphics.DrawLine(B1pen, 10, 105, PicBox.Width - 10, 105);
            e.Graphics.DrawLine(B1pen, 10, 125, PicBox.Width - 10, 125);
            e.Graphics.DrawLine(B1pen, 10, 145, PicBox.Width - 10, 145);
            // e.Graphics.DrawLine(B1pen, 10, 165, PicBox.Width - 10, 165);

            // for the chart data Vertical Line Display
            e.Graphics.DrawLine(B1pen, 60, 10, 60, First_chartDatarectHeight + 87);
            e.Graphics.DrawLine(B1pen, 110, 10, 110, First_chartDatarectHeight + 87);

            //-------------

            // DrawItemEventArgs String

            e.Graphics.DrawString("SUM", f12, a1, 14, 10);
            e.Graphics.DrawString("MEAN", f12, a1, 14, 30);
            e.Graphics.DrawString("Range", f12, a1, 14, 50);
            e.Graphics.DrawString("Cp", f12, a1, 14, 70);
            e.Graphics.DrawString("Cpk", f12, a1, 14, 90);
            e.Graphics.DrawString("Pp", f12, a1, 14, 110);
            e.Graphics.DrawString("Ppk", f12, a1, 14, 130);

            // load data
            //Outer Loop for Columns count
            int xLineposition = 110;
            int xStringDrawposition = 14;
            Double[] locStdevarr;
            for (int iCol = 1; iCol <= dt.Columns.Count - 1; iCol++)
            {
                //inner Loop for Rows count
                Double Sumresult = 0;
                Double Meanresult = 0;
                Double Rangeresult = 0;
                Double minRangeValue = int.MaxValue;
                Double maxRangeValue = int.MinValue;

                locStdevarr = new Double[NoofTrials];

                for (int iRow = 0; iRow < dt.Rows.Count; iRow++)
                {

              Sumresult = Sumresult + 
                          System.Convert.ToDouble(dt.Rows[iRow][iCol].ToString());
              Double accountLevel = System.Convert.ToDouble(dt.Rows[iRow][iCol].ToString());
              minRangeValue = Math.Min(minRangeValue, accountLevel);
              maxRangeValue = Math.Max(maxRangeValue, accountLevel);
              locStdevarr[iRow] = System.Convert.ToDouble(dt.Rows[iRow][iCol].ToString());
                }

                xLineposition = xLineposition + 50;
                xStringDrawposition = xStringDrawposition + 50;

       e.Graphics.DrawLine(B1pen, xLineposition, 10, xLineposition, 
                           First_chartDatarectHeight + 87);
                //Sum Data Display
       e.Graphics.DrawString(Math.Round(Sumresult, 3).ToString(), f10, 
                             a2, xStringDrawposition, 12);

                //MEAN Data Display
                Meanresult = Sumresult / NoofTrials;
       e.Graphics.DrawString(Math.Round(Meanresult, 3).ToString(), 
                             f10, a2, xStringDrawposition, 30);

                //RANGE Data Display
                Rangeresult = maxRangeValue - minRangeValue;
       e.Graphics.DrawString(Math.Round(Rangeresult, 3).ToString(), 
                             f10, a2, xStringDrawposition, 50);

                //XDoubleBar used to display in chart
                XDoublkeBAR = XDoublkeBAR + Meanresult;

                //RBAR used to display in chart
                RBAR = RBAR + Rangeresult;

                intMeanArrayVals[iCol - 1] = Meanresult;
                intRangeArrayVals[iCol - 1] = Rangeresult;
                intSTDEVArrayVals[iCol - 1] = StandardDeviation(locStdevarr);
            }

            //End 1 ) -------------------

            // 2) --------------------------

            // XdoubleBAr/RBAR/UCL and LCL Calculation.

            //XDoubleBar used to display in chart

            XDoublkeBAR = XDoublkeBAR / NoofParts;

            //RBAR used to display in chart
            RBAR = RBAR / NoofParts;

            //XBARUCL to display in chart
            XBARUCL = XDoublkeBAR + UCLLCLTYPE("A2", RBAR, NoofTrials);

            //XBARLCL to display in chart
            XBARLCL = XDoublkeBAR - UCLLCLTYPE("A2", RBAR, NoofTrials);

            //XBARUCL to display in chart
            RANGEUCL = UCLLCLTYPE("D4", RBAR, NoofTrials);

            //XBARLCL to display in chart
            RANGELCL = UCLLCLTYPE("D3", RBAR, NoofTrials);

            //2.1) Status Display inside pic grid +++++++++++++++++++++++++++

            int XCirclegDrawposition = 24;
            int YCirclegDrawposition = 147;

            xStringDrawposition = 14;
            for (int i = 0; i < intMeanArrayVals.Length; i++)
            {
                Color pointColor = new Color();
                pointColor = Color.YellowGreen;
                XCirclegDrawposition = XCirclegDrawposition + 50;
                Point p1 = new Point();
                p1.X = XCirclegDrawposition;
                p1.Y = YCirclegDrawposition;

                if (intMeanArrayVals[i] < XBARLCL)
                {
                    pointColor = Color.Red;
                }
                else if (intMeanArrayVals[i] > XBARUCL)
                {
                    pointColor = Color.Red;
                }

                Pen pen = new Pen(Color.SeaGreen);
                e.Graphics.DrawPie(pen, p1.X, p1.Y, 18, 18, 0, 360);
                e.Graphics.FillPie(new SolidBrush(pointColor), p1.X, p1.Y, 18, 18, 10, 360);

                pen = new Pen(Color.Black);
                e.Graphics.DrawPie(pen, p1.X + 3, p1.Y + 4, 2, 2, 10, 360);
                e.Graphics.DrawPie(pen, p1.X + 11, p1.Y + 4, 2, 2, 10, 360);
                e.Graphics.DrawPie(pen, p1.X + 5, p1.Y + 12, 8, 4, 10, 180);

                // 1)
                //Cp Calculation (((((((((((((((((((((((((((
                //Cp=(USL-LSL/6SIGMA) -> USL-LSL/6*RBAR/d2

                Double d2 = d2Return(NoofTrials);
                Double USLResult = USLs - LSLs;
                Double RBARS = intRangeArrayVals[i] / NoofTrials;
                Double Sigma = RBARS / d2;

                Double CpResult = USLResult / 6 * Sigma;

                xStringDrawposition = xStringDrawposition + 50;
    e.Graphics.DrawString(Math.Round(CpResult, 3).ToString(), 
                          f10, a2, xStringDrawposition, 70);

                //End Cp Calculation  ))))))))))))))

                // 2)

                //Cpk Calculation \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\

                //Cpk=min(USL-XBAR/3Sigma,LSL-XBAR/3Sigma)

                Double CpU = USLs - intMeanArrayVals[i] / 3 * Sigma;

                Double CpL = intMeanArrayVals[i] - LSLs / 3 * Sigma;

                Double CpkResult = Math.Min(CpU, CpL);

                if (CpkResult < CpkPpKAcceptanceValue)
                {
    e.Graphics.DrawString(Math.Round(CpkResult, 3).ToString(), 
                          f10, a3, xStringDrawposition, 90);
                }
                else
                {
    e.Graphics.DrawString(Math.Round(CpkResult, 3).ToString(), 
                          f10, a2, xStringDrawposition, 90);
                }

                //End Cpk Calculation  \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\

                // 3)
                //Pp Calculation {{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{

                //Pp=(USL-LSL/6SIGMA) -> USL-LSL/6 STDEV

                Double PpResult = USLResult / 6 * intSTDEVArrayVals[i];

    e.Graphics.DrawString(Math.Round(PpResult, 3).ToString(), 
                          f10, a2, xStringDrawposition, 110);

                //End Pp Calculation  }}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}

                // 4)

                //PpK Calculation ``````````````````````````````````````````````````````

                //PpK=min(USL-XBAR/3STDEV,LSL-XBAR/3STDEVa)
                Double PpU = USLs - intMeanArrayVals[i] / 3 * intSTDEVArrayVals[i];
                Double PpL = intMeanArrayVals[i] - LSLs / 3 * intSTDEVArrayVals[i];
                Double PpkResult = Math.Min(PpU, PpL);
                if (PpkResult < CpkPpKAcceptanceValue)
                {
     e.Graphics.DrawString(Math.Round(PpkResult, 3).ToString(), 
                           f10, a3, xStringDrawposition, 130);
                }
                else
                {
    e.Graphics.DrawString(Math.Round(PpkResult, 3).ToString(), 
                          f10, a2, xStringDrawposition, 130);
                }
                // end of Ppk `````````````````````````````````````````````````````````````
            }

            //end of 2.1) ++++++++++++++++

            //---------------------------------
            //3) Average chart Display ---------------
    //  e.Graphics.DrawRectangle(Pens.Black, 10, 10, 
                   picSpcChart.Width - 20, First_chartDatarectHeight);
            int chartAvarageDatarectHeight = 18;

    e.Graphics.DrawRectangle(Pens.Black, 10, First_chartDatarectHeight + 96, 
                             PicBox.Width - 20, chartAvarageDatarectHeight);

            e.Graphics.DrawLine(B2pen, 476, 194, 480, 176);
            e.Graphics.DrawString
            ("MEAN CHART", f12, a1, 14, First_chartDatarectHeight + 98);
            e.Graphics.DrawString
            ("XBarS:", f12, a1, 160, First_chartDatarectHeight + 98);
    e.Graphics.DrawString(Math.Round(XDoublkeBAR, 3).ToString(), 
                          f12, a2, 202, First_chartDatarectHeight + 98);
            e.Graphics.DrawString("UCL:", f12, a1, 300, First_chartDatarectHeight + 98);
    e.Graphics.DrawString(Math.Round(XBARUCL, 3).ToString(), 
                          f12, a2, 330, First_chartDatarectHeight + 98);

            e.Graphics.DrawString("LCL:", f12, a1, 400, First_chartDatarectHeight + 98);
    e.Graphics.DrawString(Math.Round(XBARLCL, 3).ToString(), 
                          f12, a2, 430, First_chartDatarectHeight + 98);

            e.Graphics.DrawString("RANGE CHART", f12, a1, 490, First_chartDatarectHeight + 98);

            e.Graphics.DrawString
            ("RBar : ", f12, a1, 600, First_chartDatarectHeight + 98);
    e.Graphics.DrawString(Math.Round(RBAR, 3).ToString(), f12, 
               a2, 638, First_chartDatarectHeight + 98);
            e.Graphics.DrawString("UCL : ", f12, a1, 700, First_chartDatarectHeight + 98);
    e.Graphics.DrawString(Math.Round(RANGEUCL, 3).ToString(), 
                          f12, a2, 734, First_chartDatarectHeight + 98);
            e.Graphics.DrawString("LCL : ", f12, a1, 800, First_chartDatarectHeight + 98);
    e.Graphics.DrawString(Math.Round(RANGELCL, 3).ToString(), 
                          f12, a2, 834, First_chartDatarectHeight + 98);

            // vertical Line
            e.Graphics.DrawLine(B2pen, 860, 194, 866, 176);
            e.Graphics.DrawString("USL : ", f12, a1, 880, First_chartDatarectHeight + 98);
    e.Graphics.DrawString(Math.Round(USLs, 3).ToString(), 
               f12, a2, 910, First_chartDatarectHeight + 98);
        e.Graphics.DrawString("LSL : ", f12, a1, 960, First_chartDatarectHeight + 98);
    e.Graphics.DrawString(Math.Round(LSLs, 3).ToString(), 
                          f12, a2, 990, First_chartDatarectHeight + 98);

            //Mean Line Chart
    DrawLineChart(e.Graphics, intMeanArrayVals, XBARUCL, XBARLCL, 
    PicBox.Width - 70, 154, 60, 170, "MEAN", XDoublkeBAR);

    DrawLineChart(e.Graphics, intRangeArrayVals, RANGEUCL, 
    RANGELCL, PicBox.Width - 70, 154, 60, 340, "RANGE", RBAR);
            //End 3)---------------------
        }

2) Demo Program

Now we create a Windows application and add and test our "ShanuCPCPKChart" User Control.

  1. Create a new Windows project.
  2. Open your form and then from Toolbox > right click > choose items > browse select your user control DLL and add.
  3. Drag the User Control to your Windows Form.
  4. Call the "Bindgrid" method of user control and pass the Datatable to Usercontrol and check the result.

Global Variable Declaration

C#
#region Local Vairables
        DataTable dt = new DataTable();
        private static readonly Random random = new Random();
        Double gridMinvalue = 1.2;
        Double gridMaxvalue = 2.4;
        int totalColumntoDisplay = 20;
        Double USLs = 2.27;
        Double LSLs = 1.26;
        Double CpkPpkAcceptanceValue = 1.33;
        #endregion

Form Load Event

In form load event, I call the loadGridColumn() method. In this function, I add the columns for the datatable. I have used 20 columns which will be used to add 20 sample data with 5 trials.

C#
//Create Datatable  Colums.
        public void loadGridColums()
    {
      dt.Columns.Add("No");
for (int jval = 1; jval <= totalColumntoDisplay; jval++)
             {
                dt.Columns.Add(jval.ToString());
             }
        }

Next in form load event, I call the loadgrid() method. In this method, I generate sample random numbers for 20 columns to plot our chart.

C#
public void loadgrid()
        {
            dt.Clear();
            dt.Rows.Clear();
            for (int i = 1; i <= 5; i++)
            {
               DataRow row = dt.NewRow();
                row["NO"] = i.ToString();
                for (int jval = 1; jval <= totalColumntoDisplay; jval++)
                {
                    row[jval.ToString()] = RandomNumberBetween(gridMinvalue, gridMaxvalue);
                }            

                dt.Rows.Add(row);
            }
            dataGridView1.AutoResizeColumns();
            dataGridView1.DataSource = dt;
            dataGridView1.AutoResizeColumns();
        }

Next in form load, I have passed the USL, LSL, Cpk Value and Datatable to “shanuCPCPKChart” User control to generate XBAR and Range Chart with Cp, Cpk, Pp and Ppk result.

C#
private void Form1_Load(object sender, EventArgs e)
        {
            loadGridColums();
            loadgrid();
            USLs = Convert.ToDouble(txtusl.Text);
            LSLs = Convert.ToDouble(txtLSL.Text);
            CpkPpkAcceptanceValue = Convert.ToDouble(txtData.Text);
            shanuCPCPKChart.USL = USLs;
            shanuCPCPKChart.LSL = LSLs;
            shanuCPCPKChart.CpkPpKAcceptanceValue = CpkPpkAcceptanceValue;
            shanuCPCPKChart.Bindgrid(dt);  
        }

To display the real time data chart result, here I have used the timer and can bind the data using random data and pass all the result to the user control to refresh and display the result.

C#
private void timer1_Tick(object sender, EventArgs e)
        {
            loadgrid();
            USLs = Convert.ToDouble(txtusl.Text);
            LSLs = Convert.ToDouble(txtLSL.Text);
            CpkPpkAcceptanceValue = Convert.ToDouble(txtData.Text);
            shanuCPCPKChart.USL = USLs;
            shanuCPCPKChart.LSL = LSLs;
            shanuCPCPKChart.CpkPpKAcceptanceValue = CpkPpkAcceptanceValue;
            shanuCPCPKChart.Bindgrid(dt);  
        }

Points of Interest

I will be happy to say that this will be the first SPC Cp, Cpk, Pp and Ppk Chart control which is available for free with the source code. If you like these controls, kindly do vote for the article.

History

  • 2nd July, 2015: Initial version

License

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