在面板/半导体行业领域,设备状态的管理一般包含以下两个层面:
1,设备状态管理的层级:一般分为3个层级。主设备(EQP)、一级子单元(Unit),二级子单元(SubUnit)。
2,设备状态的定义:RUN(运行)、IDLE(空闲)、DOWN(宕机)、PM(保养)、ENG(调机)、TEST(测试)、JC(换线)等状态。
这两大方面是设备稼动率、OEE等指标计算的基础。行业内的各公司在这两大基础面的定义相差无几。然而,基于设备状态切换历史,进而计算设备的各项稼动指标的具体逻辑,则不尽然相同。
考虑多腔室类型的设备,借用编程的术语,这些设备具备“并发”能力。举例来说,某设备EQP_MAIN具有两个制程能力相同的UNIT,分别为UNIT1和UNIT2。若UNIT1为DOWN且UNIT2为RUN,此状态持续1小时,此时,无论EQP_MAIN挂RUN还是DOWN,均需要考虑:这1小时并非完整的DOWN或者RUN,而应该折算成RUN 0.5小时和DOWN 0.5小时。
现实世界中的设备层级要远复杂于此例子。但借鉴此思想,对各设备一级单元(Unit)或者二级子单元(SubUnit)进行合理划分,然后考虑不同UNIT状态的组合,并按其对设备状态影响程度,赋予不同的权重因子,则可实现对设备状态精细化的计算。
本文接下来主要描述如何设计算法和程序,来实现这种计算。
首先考虑以下几个要素:
计算时间点的选择:有两种选择,一是预先算好,例如采用ETL的方式。二是实时查询并计算。由于用户要求的应用场景等多种因素限制(例如PM等状态是人为标注的,且存在多次修改备注信息的场景,所以预先算好的方式不适用),本文采用了实时计算的方式。
查询性能的预估分析:由于设备的状态切换历史非常频繁,所以表中的数据量非常庞大。假如用户的查询范围是2个月内全部设备,则查询原始数据,然后再进行各种复杂计算,可以想象,要想快速计算出结果,必然是一个相当大的挑战。
系统架构的限制:不幸的是,面板行业/半导体行业的报表系统架构,基本上是采用集中式架构。此需求根本无法应用分布式计算框架。只能通过精心设计程序和算法,充分压榨oracle服务器和应用服务器,实现大数据量的快速计算。
计算资源的分配:报表采用的WEB架构,所以不可能在本机计算。只能是在DB服务器或者应用服务器上进行计算。以此需求为例,从DB试select 1台设备1个月的原始数据,一共4万笔,大约需要10多秒钟。select 1天,一共1千多笔,则需要300ms。考虑到底层数据是设备状态的原始数据,而计算过程相当复杂,如果把所有的逻辑计算放在DB服务器,对用户而言,查询的响应时间必然很慢。所以要合理平衡DB服务器和应用服务器,分配合适的计算任务。
进行以上要素分析和试验之后,采用以下设计:
1,采用分治法。在oracle端,将原始数据按设备按天切割(切割点为每天的08:00),然后发给应用服务器。应用服务器采用java/C#等编程语言,对按天切割后的数据,进行复杂逻辑运算。然后应用服务器对子计算任务的结果进行合并,作为最终结果。
2,虽然有多个子计算任务,但由于DB服务器和应用器同时并发,所以整体的查询响应时间,优于仅在oracle 上运算的方案。
3,状态组合的逻辑、各单元的权重等信息,设计配置表,由用户来维护。维护表需要包含以下信息:
- 设备单元信息。定义了各个设备、各级子单元等信息。
- 设备的状态关键单元。定义每个设备要查看哪些关键单元。
- 设备的状态组合与状态权重的对应关系。定义关键单元的每种状态组合下,其状态权重。
接下来进行按天查询指定设备算法的设计。
1,应用服务器同时请求查询原始数据表和配置信息表。
private DataTable QueryRawData(DateTime periodDate, string machineName)
{
var sql = $@"
select a.machinename as {RawDataTable.MACHINE_NAME}, a.timekey as {RawDataTable.EVENT_TIME},
a.oldmachinestatename as {RawDataTable.PREV_STATUS}, a.machinestatename as {RawDataTable.CUR_STATUS}
from CT_MACHINEHISTORY a
where 1=1
and a.machinename like '{machineName}%'
and timekey>='{periodDate.ToString("yyyyMMddHH")}'
and timekey<'{periodDate.AddDays(1).ToString("yyyyMMddHH")}'
order by a.machinename, a.timekey
";
var dtRaw = ConnectionKey.MESDBDG.Query(sql);
return dtRaw;
}
private DataTable QueryEqpSpec(string machineName)
{
var sql = $@"
select a.machinename as {SpecDataTable.MACHINE_NAME},
a.detailmachinetype as detail_machine_type
from MACHINESPEC a
where 1=1
and a.machinename like '{machineName}%'
order by a.machinename";
var dt = ConnectionKey.MESDBDG.Query(sql);
return dt;
}
private DataTable QueryEqpStatusFactorSpec(string machineName)
{
var sql = $@"
select a.MACHINE_NAME as {EqpStatusFactorSpecTable.MACHINE_NAME},
a.KEY_UNIT_LIST as {EqpStatusFactorSpecTable.KEY_UNIT_LIST},
a.RATIO_LIST as {EqpStatusFactorSpecTable.RATIO_LIST},
REGEXP_REPLACE(REGEXP_REPLACE(a.UNIT_STATUS_PATTERN,'\*','.'),',','') as {EqpStatusFactorSpecTable.UNIT_STATUS_PATTERN},
a.DEFINED_MACHINE_STATUS as {EqpStatusFactorSpecTable.DEFINED_MACHINE_STATUS}
from Eqp_Status_Factor_Spec a
where 1=1
and a.MACHINE_NAME = '{machineName}'";
var dt = ConnectionKey.RPTDB.Query(sql);
return dt;
}
注:配置表有2个。分别在不同的DB。原因是其中一个DB为readonly,不能建表。而已有的配置信息不够全,需要完善。
2,找出该天中“缺少”的单元,并补出它在当天的状态。由于每个设备包含多个单元,而有些单元可能在该天无任何状态变化,则这样原始数据表中无此单元的记录。但是状态计算时,需要考虑到每个单元,所以需要“补偿”。基本思想就是,通过配置表,找到完整的单元集合。然后减去有实际抓取出来的单元集合,得到“缺少”的单元集合。然后将“缺少”的每个单元,找出其离指定最近的前一笔记录,该笔记录的设备单元状态,作为该单元在指定天起点和终点的状态(全天维持状态不变)。
要说明的是,如果存在多个“缺少”的单元,假如采用每笔问询一次oracle,则会大大降低整体查询效能。所以,这里优化设计,仅查询1次。
private DataTable CompensateMissedHistory(DateTime periodDate, DataTable dtEqpSpec, DataTable dtRaw)
{
var dt1 = dtEqpSpec.SelectDistinct(SpecDataTable.MACHINE_NAME);
var dt2 = dtRaw.SelectDistinct(RawDataTable.MACHINE_NAME);
var dt3 = dt1.Except(dt2);
if (dt3.Rows.Count > 0)
{
var dt4 = QueryPrevRawData(periodDate, dt3.GetColumnData<string>(0));
var dt5 = dt4.Clone();
foreach (DataRow dr in dt4.Rows)
{
dt5.Rows.Add(dr[0], periodDate.ToString("yyyyMMddHHmmssffffff"), dr[3], dr[3]);
dt5.Rows.Add(dr[0], periodDate.AddDays(1).ToString("yyyyMMddHHmmssffffff"), dr[3], dr[3]);
}
return dt5;
}
return dt3;
}
private DataTable QueryPrevRawData(DateTime periodDate, IEnumerable<string> machineNames)
{
var sqlFormat = @"
select * from
(
select a.machinename as {0}, a.timekey as {1},
a.oldmachinestatename as prev_status, a.machinestatename as cur_status
from CT_MACHINEHISTORY a
where 1=1
and a.machinename = '{2}'
and timekey<'{3}'
and rownum<=1
order by a.machinename, a.timekey desc
)
";
var sql = new StringBuilder();
var uninVerb = " union ";
foreach (var machineName in machineNames)
{
sql.AppendFormat(sqlFormat, RawDataTable.MACHINE_NAME, RawDataTable.EVENT_TIME, machineName, periodDate.ToString("yyyyMMddHH"));
sql.Append(uninVerb);
}
sql = sql.Remove(sql.Length - uninVerb.Length, uninVerb.Length);
var dt = ConnectionKey.MESDBDG.Query(sql);
return dt;
}
3,将各单元按每天的时间切割点进行“对齐”。各单元在该天的首笔状态切换,不会刚好发生在切割点时间。所以需要补偿出从时间切割点到首笔状态变化之间的状态。同理,各单元在该天的末笔状态切换,也不会刚好发生在第二天的切割点时间。所以需要补偿出末笔到第二天切割点之间的状态。
private DataTable CompensateDateTimeBoundary(DateTime periodDate, DataTable dtRaw)
{
var ds = dtRaw.GroupBy(RawDataTable.MACHINE_NAME);
var startTime = periodDate.ToString("yyyyMMddHHmmssffffff");
var endTime = periodDate.AddDays(1).ToString("yyyyMMddHHmmssffffff");
for (var i = 1; i < ds.Tables.Count; i++)
{
var dt = ds.Tables[i];
if (!object.Equals(dt.Rows[0][1], startTime))
{
var dr1 = dt.NewRow();
dr1[0] = dt.Rows[0][0];
dr1[1] = startTime;
dr1[3] = dr1[2] = dt.Rows[0][2];
dt.Rows.InsertAt(dr1, 0);
}
if (!object.Equals(dt.Rows[dt.Rows.Count - 1][0], endTime))
{
var dr2 = dt.NewRow();
dr2[0] = dt.Rows[dt.Rows.Count - 1][0];
dr2[1] = endTime;
dr2[3] = dr2[2] = dt.Rows[dt.Rows.Count - 1][3];
dt.Rows.Add(dr2);
}
}
var dtResult = ds.Tables[1].Clone();
for (var i = 1; i < ds.Tables.Count; i++)
{
dtResult = dtResult.UnionAll(ds.Tables[i]);
}
return dtResult;
}
4,至此,该天该设备的全部单元的切换历史,就准备完成了。不过进一步分析原始数据,发现有些单元会存在少量的这种情况:下一笔的状态和上一笔的状态相同。所以,为降低该天时间切割的区间数量,减少后续的计算量,可以对这种情况进行优化。也就说,对状态维持不变的多笔记录,"压缩“成1笔记录。
private DataTable CompressRawData(DataTable dtRaw)
{
var dt = dtRaw.Clone();
dt.ImportRow(dtRaw.Rows[0]);
for (var i = 1; i < dtRaw.Rows.Count - 1; i++)
{
if (dtRaw.Rows[i][3].ToString() == dtRaw.Rows[i - 1][3].ToString() &&
//dtRaw.Rows[i][2].ToString() == dtRaw.Rows[i - 1][2].ToString() &&
dtRaw.Rows[i][0].ToString() == dtRaw.Rows[i - 1][0].ToString())
continue;
dt.ImportRow(dtRaw.Rows[i]);
}
return dt;
}
5,得到这些数据后,就可以在应用服务器进行开始进行逻辑运算。为根据指定的状态组合组合和权重进行计算,有考虑设计关联查询的方案和类似掩码的方案。关联查询方案的方案理论可行,但是当设备的单元够多时(例如有N个单元),则各单元的可能的状态组合数是7的N次方。计算量非常庞大,实际不可行。采用类似掩码的方案,一番分析下来发现很难实现。首先是每个单元状态有7种,0和1的方案本身就无法代表。如果要扩展,则要判断状态时,会变得非常复杂,基本不可行。综合先来,唯一可行的解法,就是设计正则表达式。这样实现了以尽可能少的行覆盖了全部的状态组合。既方便用户建配置表,又能使程序快速得到权重规则,进而基于规则再进行计算。
考虑到为提高运算效率,分析7种状态,发现首字母各不相同,故分别用首字母代表各个状态。算法简述如下:
- 首先,将设备单元的实际状态,提取首字母,“浓缩”成一个字符串。这个字符串代表当前设备各个单元的状态组合。
- 接下来,以上述字符串作为正则表达式的输入,扫描配置表中用户定义的全部状态组合模式,进行正则表达式模式匹配。一旦匹配成功,取出该状态对应的权重计算规则。为方便用户建立和维护配置表,状态组合模式并非全部采用正则表达式语法,故需要在取配置表时做一定的替换。例如,用户建表时,让其输入‘*’代表任意字符,并将各状态以','分隔方便查看。取出来时要转成‘.’,且为了提高匹配效率,去掉了','。
- 然后,分析权重计算规则,将设备实际的单元状态,回填到规则,生成各个状态的权重系数。权重规则中,是要捕获实际的状态值。这里有两种设计法。一种是按正则表达式的组捕获方法,来设计规则输入模式;另一种,采用字符串占位符,然后编写解析和回填方法。为提高程序运行效率且便于用户理解,这里采用了第二种设计。
- 再下来,对已生成的各状态权重系数进行同类求和,得到最终结果。
public DataTable BuildResult(DateTime periodDate, string machineName)
{
var dtRaw = QueryRawData(periodDate, machineName);
var dtSpec = QueryEqpSpec(machineName);
var dtFactorSpec = QueryEqpStatusFactorSpec(machineName);
var dtMissed = CompensateMissedHistory(periodDate, dtSpec, dtRaw);
var dtRawCompensated = CompensateDateTimeBoundary(periodDate, dtRaw);
var dtRawComplete = dtRawCompensated.UnionAll(dtMissed);
var dtRawCompressed = CompressRawData(dtRawComplete);
var dtDateTimePoints = dtRawComplete.SelectDistinct(RawDataTable.EVENT_TIME).OrderByAsc(RawDataTable.EVENT_TIME);
var dtResult = dtDateTimePoints.Copy();
var ds = dtRawCompressed.GroupBy(RawDataTable.MACHINE_NAME);
for (var i = 1; i < ds.Tables.Count; i++)
{
var dt = ds.Tables[i].SelectFrom(RawDataTable.EVENT_TIME, RawDataTable.CUR_STATUS).
RenameColumns(RawDataTable.CUR_STATUS, ds.Tables[0].Rows[i - 1][0].ToString());
dtResult = dtResult.LeftJoin(dt, RawDataTable.EVENT_TIME);
}
for (var i = 1; i < dtResult.Rows.Count; i++)
{
for (var j = 1; j < dtResult.Columns.Count; j++)
{
if (dtResult.Rows[i][j] == DBNull.Value)
{
dtResult.Rows[i][j] = dtResult.Rows[i - 1][j];
dtResult.Rows[i][j] = dtResult.Rows[i - 1][j];
}
}
}
dtResult = Compute(dtResult, dtFactorSpec);
return dtResult;
}
上述代码主要是完成原始数据的准备。包含为了方便计算,进行的空值补偿。
核心的计算逻辑如下:
public DataTable Compute(DataTable dtResult, DataTable dtFactorSpec)
{
var dc = dtResult.AddColumn<string>("CombinedUnitStatus");
var allStatus = new string[] { "E", "P", "T", "J", "D", "I", "R" };
dtResult.AddColumns<decimal>(allStatus);
var keyUnitList = dtFactorSpec.Rows[0][EqpStatusFactorSpecTable.KEY_UNIT_LIST].ToString().Split(',').Select(x => x.Trim()).ToArray();
dc.Expression = keyUnitList.Select(x => $"SUBSTRING([{x}],1,1)").ToText('+');
var patternList = dtFactorSpec.SelectFrom(EqpStatusFactorSpecTable.UNIT_STATUS_PATTERN).GetColumnData<string>(0).ToArray();
var definedMachineStatusList = dtFactorSpec.SelectFrom(EqpStatusFactorSpecTable.DEFINED_MACHINE_STATUS).GetColumnData<string>(0).Select(x => x.Trim()).ToArray();
for (var i = 0; i < dtResult.Rows.Count; i++)
{
var dr = dtResult.Rows[i];
var combinedStatus = dr["CombinedUnitStatus"].ToString();
for (var j = 0; j < patternList.Length; j++)
{
if (!Regex.IsMatch(combinedStatus, patternList[j]))
continue;
var definedStatus = definedMachineStatusList[j];
var actualStatus = ReplaceWithActual(combinedStatus, definedStatus);
var dictWeight = SumStatusWeight(actualStatus);
foreach (var key in dictWeight.Keys)
{
dr[key] = dictWeight[key];
}
break;
}
}
return dtResult;
}
其中,根据实际值回填规则,生成各个状态的权重系数的方法如下:
private string ReplaceWithActual(string combinedStatus, string definedMachineStatus)
{
if (!definedMachineStatus.Contains("{"))
return definedMachineStatus;
var patterns = definedMachineStatus.Split(',');
var actualList = new string[patterns.Length];
for (var i = 0; i < patterns.Length; i++)
{
if (!patterns[i].StartsWith("{"))
continue;
var index = int.Parse(patterns[i].Substring(1, patterns[i].IndexOf('}') - 1));
actualList[i] = $@"{combinedStatus[index]}:{patterns[i].Substring(patterns[i].IndexOf(':') + 1)}";
}
return actualList.ToText(',');
}
对同类权重系数求和的方法如下:
private Dictionary<string, decimal> SumStatusWeight(string acutalStatus)
{
var dict = new Dictionary<string, decimal>();
var actualStatusArray = acutalStatus.Split(',');
foreach (var status in actualStatusArray)
{
var kv = status.Split(':');
if (!dict.ContainsKey(kv[0]))
dict.Add(kv[0], Fraction.FromString(kv[1]).ToDecimal());
else
dict[kv[0]] += Fraction.FromString(kv[1]).ToDecimal();
}
return dict;
}
至此,计算出某个设备在指定天的精细化的状态。后续的展示及更高一层的统计,便可以此为基础数据。
如果要计算多个设备在多天的精细化状态,便可采用多并行编程的方式,进行并行计算。这里不再赘述。
另外要说明的一点,为方便高效开发,程序设计中并未采用linq to dataset的方式进行数据查询和处理,而是自己开发出一套完整的DataTable Extension 流式API,对DataTable进行类似ORACLE式的操作,使得开发更高效,代码更简洁。例如代码中所示的LeftJoin, GroupBy等方法。
完整的程序截图如下。