novo core da camera multiespectral versao produto, saude do atuador nao penaliza autonomia quando nao precisa, saude do movimentacao avalia travamento da roda, saude do sensoriamento avalia comando e leitura dos servos, reles e sinaleiros, com persistencia temporal, regras taticas permite parar o carro ao detectar travamento das rodas, travamento das rodas aciona parada de emergencia, ajustado contrato do weed worker para a versao produto, adicionado offset de referenciamento direcional, ajustado calculo de erro angular no direcional entre comando e leitura com persistencia temporal, ajustado limites dos sensores, incluido parada de emergencia mediante a liberacao humana apos travamento das rodas

This commit is contained in:
Diego Freitas 2026-08-31 09:30:08 -03:00
parent 21decdbbe9
commit c9ad8d2aff
24 changed files with 11007 additions and 1988 deletions

View File

@ -36,6 +36,7 @@ namespace AgroBase.Forms.Direcional
this.lblTipoMovimento = new System.Windows.Forms.Label(); this.lblTipoMovimento = new System.Windows.Forms.Label();
this.btnSalvarSentido = new System.Windows.Forms.Button(); this.btnSalvarSentido = new System.Windows.Forms.Button();
this.gpbDF = new System.Windows.Forms.GroupBox(); this.gpbDF = new System.Windows.Forms.GroupBox();
this.chbDFUsar = new System.Windows.Forms.CheckBox();
this.label5 = new System.Windows.Forms.Label(); this.label5 = new System.Windows.Forms.Label();
this.txtDFOffsetAngulo = new System.Windows.Forms.TextBox(); this.txtDFOffsetAngulo = new System.Windows.Forms.TextBox();
this.label2 = new System.Windows.Forms.Label(); this.label2 = new System.Windows.Forms.Label();
@ -47,6 +48,7 @@ namespace AgroBase.Forms.Direcional
this.lblDFEsquerda = new System.Windows.Forms.Label(); this.lblDFEsquerda = new System.Windows.Forms.Label();
this.lblDFDireita = new System.Windows.Forms.Label(); this.lblDFDireita = new System.Windows.Forms.Label();
this.gpbEF = new System.Windows.Forms.GroupBox(); this.gpbEF = new System.Windows.Forms.GroupBox();
this.chbEFUsar = new System.Windows.Forms.CheckBox();
this.label6 = new System.Windows.Forms.Label(); this.label6 = new System.Windows.Forms.Label();
this.txtEFOffsetAngulo = new System.Windows.Forms.TextBox(); this.txtEFOffsetAngulo = new System.Windows.Forms.TextBox();
this.label4 = new System.Windows.Forms.Label(); this.label4 = new System.Windows.Forms.Label();
@ -58,6 +60,7 @@ namespace AgroBase.Forms.Direcional
this.lblEFEsquerda = new System.Windows.Forms.Label(); this.lblEFEsquerda = new System.Windows.Forms.Label();
this.lblEFDireita = new System.Windows.Forms.Label(); this.lblEFDireita = new System.Windows.Forms.Label();
this.gpbDT = new System.Windows.Forms.GroupBox(); this.gpbDT = new System.Windows.Forms.GroupBox();
this.chbDTUsar = new System.Windows.Forms.CheckBox();
this.label8 = new System.Windows.Forms.Label(); this.label8 = new System.Windows.Forms.Label();
this.txtDTOffsetAngulo = new System.Windows.Forms.TextBox(); this.txtDTOffsetAngulo = new System.Windows.Forms.TextBox();
this.label3 = new System.Windows.Forms.Label(); this.label3 = new System.Windows.Forms.Label();
@ -69,6 +72,7 @@ namespace AgroBase.Forms.Direcional
this.lblDTEsquerda = new System.Windows.Forms.Label(); this.lblDTEsquerda = new System.Windows.Forms.Label();
this.lblDTDireita = new System.Windows.Forms.Label(); this.lblDTDireita = new System.Windows.Forms.Label();
this.gpbET = new System.Windows.Forms.GroupBox(); this.gpbET = new System.Windows.Forms.GroupBox();
this.chbETUsar = new System.Windows.Forms.CheckBox();
this.label7 = new System.Windows.Forms.Label(); this.label7 = new System.Windows.Forms.Label();
this.txtETOffsetAngulo = new System.Windows.Forms.TextBox(); this.txtETOffsetAngulo = new System.Windows.Forms.TextBox();
this.label1 = new System.Windows.Forms.Label(); this.label1 = new System.Windows.Forms.Label();
@ -79,10 +83,14 @@ namespace AgroBase.Forms.Direcional
this.cmbETEsquerda = new System.Windows.Forms.ComboBox(); this.cmbETEsquerda = new System.Windows.Forms.ComboBox();
this.lblETEsquerda = new System.Windows.Forms.Label(); this.lblETEsquerda = new System.Windows.Forms.Label();
this.lblETDireita = new System.Windows.Forms.Label(); this.lblETDireita = new System.Windows.Forms.Label();
this.chbDFUsar = new System.Windows.Forms.CheckBox(); this.txtDTOffsetRef = new System.Windows.Forms.TextBox();
this.chbDTUsar = new System.Windows.Forms.CheckBox(); this.label9 = new System.Windows.Forms.Label();
this.chbEFUsar = new System.Windows.Forms.CheckBox(); this.label10 = new System.Windows.Forms.Label();
this.chbETUsar = new System.Windows.Forms.CheckBox(); this.txtETOffsetRef = new System.Windows.Forms.TextBox();
this.label11 = new System.Windows.Forms.Label();
this.txtEFOffsetRef = new System.Windows.Forms.TextBox();
this.label12 = new System.Windows.Forms.Label();
this.txtDFOffsetRef = new System.Windows.Forms.TextBox();
this.gpbSentido.SuspendLayout(); this.gpbSentido.SuspendLayout();
this.gpbDF.SuspendLayout(); this.gpbDF.SuspendLayout();
((System.ComponentModel.ISupportInitialize)(this.nudDFOffset)).BeginInit(); ((System.ComponentModel.ISupportInitialize)(this.nudDFOffset)).BeginInit();
@ -109,7 +117,7 @@ namespace AgroBase.Forms.Direcional
this.gpbSentido.Margin = new System.Windows.Forms.Padding(2); this.gpbSentido.Margin = new System.Windows.Forms.Padding(2);
this.gpbSentido.Name = "gpbSentido"; this.gpbSentido.Name = "gpbSentido";
this.gpbSentido.Padding = new System.Windows.Forms.Padding(2); this.gpbSentido.Padding = new System.Windows.Forms.Padding(2);
this.gpbSentido.Size = new System.Drawing.Size(376, 427); this.gpbSentido.Size = new System.Drawing.Size(376, 463);
this.gpbSentido.TabIndex = 80; this.gpbSentido.TabIndex = 80;
this.gpbSentido.TabStop = false; this.gpbSentido.TabStop = false;
this.gpbSentido.Text = "Sentido de giro"; this.gpbSentido.Text = "Sentido de giro";
@ -117,7 +125,7 @@ namespace AgroBase.Forms.Direcional
// lblIP // lblIP
// //
this.lblIP.AutoSize = true; this.lblIP.AutoSize = true;
this.lblIP.Location = new System.Drawing.Point(9, 378); this.lblIP.Location = new System.Drawing.Point(9, 422);
this.lblIP.Margin = new System.Windows.Forms.Padding(2, 0, 2, 0); this.lblIP.Margin = new System.Windows.Forms.Padding(2, 0, 2, 0);
this.lblIP.Name = "lblIP"; this.lblIP.Name = "lblIP";
this.lblIP.Size = new System.Drawing.Size(17, 13); this.lblIP.Size = new System.Drawing.Size(17, 13);
@ -126,7 +134,7 @@ namespace AgroBase.Forms.Direcional
// //
// txtIP // txtIP
// //
this.txtIP.Location = new System.Drawing.Point(12, 395); this.txtIP.Location = new System.Drawing.Point(12, 439);
this.txtIP.Margin = new System.Windows.Forms.Padding(2); this.txtIP.Margin = new System.Windows.Forms.Padding(2);
this.txtIP.Name = "txtIP"; this.txtIP.Name = "txtIP";
this.txtIP.Size = new System.Drawing.Size(85, 20); this.txtIP.Size = new System.Drawing.Size(85, 20);
@ -154,7 +162,7 @@ namespace AgroBase.Forms.Direcional
// //
// btnSalvarSentido // btnSalvarSentido
// //
this.btnSalvarSentido.Location = new System.Drawing.Point(275, 391); this.btnSalvarSentido.Location = new System.Drawing.Point(275, 435);
this.btnSalvarSentido.Margin = new System.Windows.Forms.Padding(2); this.btnSalvarSentido.Margin = new System.Windows.Forms.Padding(2);
this.btnSalvarSentido.Name = "btnSalvarSentido"; this.btnSalvarSentido.Name = "btnSalvarSentido";
this.btnSalvarSentido.Size = new System.Drawing.Size(88, 24); this.btnSalvarSentido.Size = new System.Drawing.Size(88, 24);
@ -165,6 +173,8 @@ namespace AgroBase.Forms.Direcional
// //
// gpbDF // gpbDF
// //
this.gpbDF.Controls.Add(this.label12);
this.gpbDF.Controls.Add(this.txtDFOffsetRef);
this.gpbDF.Controls.Add(this.chbDFUsar); this.gpbDF.Controls.Add(this.chbDFUsar);
this.gpbDF.Controls.Add(this.label5); this.gpbDF.Controls.Add(this.label5);
this.gpbDF.Controls.Add(this.txtDFOffsetAngulo); this.gpbDF.Controls.Add(this.txtDFOffsetAngulo);
@ -180,11 +190,21 @@ namespace AgroBase.Forms.Direcional
this.gpbDF.Margin = new System.Windows.Forms.Padding(2); this.gpbDF.Margin = new System.Windows.Forms.Padding(2);
this.gpbDF.Name = "gpbDF"; this.gpbDF.Name = "gpbDF";
this.gpbDF.Padding = new System.Windows.Forms.Padding(2); this.gpbDF.Padding = new System.Windows.Forms.Padding(2);
this.gpbDF.Size = new System.Drawing.Size(168, 152); this.gpbDF.Size = new System.Drawing.Size(168, 175);
this.gpbDF.TabIndex = 9; this.gpbDF.TabIndex = 9;
this.gpbDF.TabStop = false; this.gpbDF.TabStop = false;
this.gpbDF.Text = "M4 - Direito Frente (DF)"; this.gpbDF.Text = "M4 - Direito Frente (DF)";
// //
// chbDFUsar
//
this.chbDFUsar.AutoSize = true;
this.chbDFUsar.Location = new System.Drawing.Point(127, 124);
this.chbDFUsar.Name = "chbDFUsar";
this.chbDFUsar.Size = new System.Drawing.Size(32, 17);
this.chbDFUsar.TabIndex = 19;
this.chbDFUsar.Text = "L";
this.chbDFUsar.UseVisualStyleBackColor = true;
//
// label5 // label5
// //
this.label5.AutoSize = true; this.label5.AutoSize = true;
@ -294,6 +314,8 @@ namespace AgroBase.Forms.Direcional
// //
// gpbEF // gpbEF
// //
this.gpbEF.Controls.Add(this.label11);
this.gpbEF.Controls.Add(this.txtEFOffsetRef);
this.gpbEF.Controls.Add(this.chbEFUsar); this.gpbEF.Controls.Add(this.chbEFUsar);
this.gpbEF.Controls.Add(this.label6); this.gpbEF.Controls.Add(this.label6);
this.gpbEF.Controls.Add(this.txtEFOffsetAngulo); this.gpbEF.Controls.Add(this.txtEFOffsetAngulo);
@ -309,11 +331,21 @@ namespace AgroBase.Forms.Direcional
this.gpbEF.Margin = new System.Windows.Forms.Padding(2); this.gpbEF.Margin = new System.Windows.Forms.Padding(2);
this.gpbEF.Name = "gpbEF"; this.gpbEF.Name = "gpbEF";
this.gpbEF.Padding = new System.Windows.Forms.Padding(2); this.gpbEF.Padding = new System.Windows.Forms.Padding(2);
this.gpbEF.Size = new System.Drawing.Size(168, 152); this.gpbEF.Size = new System.Drawing.Size(168, 175);
this.gpbEF.TabIndex = 9; this.gpbEF.TabIndex = 9;
this.gpbEF.TabStop = false; this.gpbEF.TabStop = false;
this.gpbEF.Text = "M2 - Esquerdo Frente (EF)"; this.gpbEF.Text = "M2 - Esquerdo Frente (EF)";
// //
// chbEFUsar
//
this.chbEFUsar.AutoSize = true;
this.chbEFUsar.Location = new System.Drawing.Point(127, 124);
this.chbEFUsar.Name = "chbEFUsar";
this.chbEFUsar.Size = new System.Drawing.Size(32, 17);
this.chbEFUsar.TabIndex = 21;
this.chbEFUsar.Text = "L";
this.chbEFUsar.UseVisualStyleBackColor = true;
//
// label6 // label6
// //
this.label6.AutoSize = true; this.label6.AutoSize = true;
@ -423,6 +455,8 @@ namespace AgroBase.Forms.Direcional
// //
// gpbDT // gpbDT
// //
this.gpbDT.Controls.Add(this.label9);
this.gpbDT.Controls.Add(this.txtDTOffsetRef);
this.gpbDT.Controls.Add(this.chbDTUsar); this.gpbDT.Controls.Add(this.chbDTUsar);
this.gpbDT.Controls.Add(this.label8); this.gpbDT.Controls.Add(this.label8);
this.gpbDT.Controls.Add(this.txtDTOffsetAngulo); this.gpbDT.Controls.Add(this.txtDTOffsetAngulo);
@ -434,15 +468,25 @@ namespace AgroBase.Forms.Direcional
this.gpbDT.Controls.Add(this.cmbDTEsquerda); this.gpbDT.Controls.Add(this.cmbDTEsquerda);
this.gpbDT.Controls.Add(this.lblDTEsquerda); this.gpbDT.Controls.Add(this.lblDTEsquerda);
this.gpbDT.Controls.Add(this.lblDTDireita); this.gpbDT.Controls.Add(this.lblDTDireita);
this.gpbDT.Location = new System.Drawing.Point(195, 216); this.gpbDT.Location = new System.Drawing.Point(195, 239);
this.gpbDT.Margin = new System.Windows.Forms.Padding(2); this.gpbDT.Margin = new System.Windows.Forms.Padding(2);
this.gpbDT.Name = "gpbDT"; this.gpbDT.Name = "gpbDT";
this.gpbDT.Padding = new System.Windows.Forms.Padding(2); this.gpbDT.Padding = new System.Windows.Forms.Padding(2);
this.gpbDT.Size = new System.Drawing.Size(168, 152); this.gpbDT.Size = new System.Drawing.Size(168, 175);
this.gpbDT.TabIndex = 9; this.gpbDT.TabIndex = 9;
this.gpbDT.TabStop = false; this.gpbDT.TabStop = false;
this.gpbDT.Text = "M3 - Direito Trás (DT)"; this.gpbDT.Text = "M3 - Direito Trás (DT)";
// //
// chbDTUsar
//
this.chbDTUsar.AutoSize = true;
this.chbDTUsar.Location = new System.Drawing.Point(125, 124);
this.chbDTUsar.Name = "chbDTUsar";
this.chbDTUsar.Size = new System.Drawing.Size(32, 17);
this.chbDTUsar.TabIndex = 23;
this.chbDTUsar.Text = "L";
this.chbDTUsar.UseVisualStyleBackColor = true;
//
// label8 // label8
// //
this.label8.AutoSize = true; this.label8.AutoSize = true;
@ -552,6 +596,8 @@ namespace AgroBase.Forms.Direcional
// //
// gpbET // gpbET
// //
this.gpbET.Controls.Add(this.label10);
this.gpbET.Controls.Add(this.txtETOffsetRef);
this.gpbET.Controls.Add(this.chbETUsar); this.gpbET.Controls.Add(this.chbETUsar);
this.gpbET.Controls.Add(this.label7); this.gpbET.Controls.Add(this.label7);
this.gpbET.Controls.Add(this.txtETOffsetAngulo); this.gpbET.Controls.Add(this.txtETOffsetAngulo);
@ -563,15 +609,25 @@ namespace AgroBase.Forms.Direcional
this.gpbET.Controls.Add(this.cmbETEsquerda); this.gpbET.Controls.Add(this.cmbETEsquerda);
this.gpbET.Controls.Add(this.lblETEsquerda); this.gpbET.Controls.Add(this.lblETEsquerda);
this.gpbET.Controls.Add(this.lblETDireita); this.gpbET.Controls.Add(this.lblETDireita);
this.gpbET.Location = new System.Drawing.Point(13, 216); this.gpbET.Location = new System.Drawing.Point(13, 239);
this.gpbET.Margin = new System.Windows.Forms.Padding(2); this.gpbET.Margin = new System.Windows.Forms.Padding(2);
this.gpbET.Name = "gpbET"; this.gpbET.Name = "gpbET";
this.gpbET.Padding = new System.Windows.Forms.Padding(2); this.gpbET.Padding = new System.Windows.Forms.Padding(2);
this.gpbET.Size = new System.Drawing.Size(168, 152); this.gpbET.Size = new System.Drawing.Size(168, 175);
this.gpbET.TabIndex = 0; this.gpbET.TabIndex = 0;
this.gpbET.TabStop = false; this.gpbET.TabStop = false;
this.gpbET.Text = "M1 - Esquerdo Trás (ET)"; this.gpbET.Text = "M1 - Esquerdo Trás (ET)";
// //
// chbETUsar
//
this.chbETUsar.AutoSize = true;
this.chbETUsar.Location = new System.Drawing.Point(127, 124);
this.chbETUsar.Name = "chbETUsar";
this.chbETUsar.Size = new System.Drawing.Size(32, 17);
this.chbETUsar.TabIndex = 23;
this.chbETUsar.Text = "L";
this.chbETUsar.UseVisualStyleBackColor = true;
//
// label7 // label7
// //
this.label7.AutoSize = true; this.label7.AutoSize = true;
@ -679,52 +735,88 @@ namespace AgroBase.Forms.Direcional
this.lblETDireita.TabIndex = 2; this.lblETDireita.TabIndex = 2;
this.lblETDireita.Text = "Direita"; this.lblETDireita.Text = "Direita";
// //
// chbDFUsar // txtDTOffsetRef
// //
this.chbDFUsar.AutoSize = true; this.txtDTOffsetRef.Location = new System.Drawing.Point(65, 146);
this.chbDFUsar.Location = new System.Drawing.Point(127, 124); this.txtDTOffsetRef.Margin = new System.Windows.Forms.Padding(2);
this.chbDFUsar.Name = "chbDFUsar"; this.txtDTOffsetRef.Name = "txtDTOffsetRef";
this.chbDFUsar.Size = new System.Drawing.Size(32, 17); this.txtDTOffsetRef.Size = new System.Drawing.Size(57, 20);
this.chbDFUsar.TabIndex = 19; this.txtDTOffsetRef.TabIndex = 24;
this.chbDFUsar.Text = "L"; this.txtDTOffsetRef.TextAlign = System.Windows.Forms.HorizontalAlignment.Center;
this.chbDFUsar.UseVisualStyleBackColor = true;
// //
// chbDTUsar // label9
// //
this.chbDTUsar.AutoSize = true; this.label9.AutoSize = true;
this.chbDTUsar.Location = new System.Drawing.Point(125, 124); this.label9.Location = new System.Drawing.Point(10, 149);
this.chbDTUsar.Name = "chbDTUsar"; this.label9.Margin = new System.Windows.Forms.Padding(2, 0, 2, 0);
this.chbDTUsar.Size = new System.Drawing.Size(32, 17); this.label9.Name = "label9";
this.chbDTUsar.TabIndex = 23; this.label9.Size = new System.Drawing.Size(55, 13);
this.chbDTUsar.Text = "L"; this.label9.TabIndex = 25;
this.chbDTUsar.UseVisualStyleBackColor = true; this.label9.Text = "Offset Ref";
// //
// chbEFUsar // label10
// //
this.chbEFUsar.AutoSize = true; this.label10.AutoSize = true;
this.chbEFUsar.Location = new System.Drawing.Point(127, 124); this.label10.Location = new System.Drawing.Point(10, 149);
this.chbEFUsar.Name = "chbEFUsar"; this.label10.Margin = new System.Windows.Forms.Padding(2, 0, 2, 0);
this.chbEFUsar.Size = new System.Drawing.Size(32, 17); this.label10.Name = "label10";
this.chbEFUsar.TabIndex = 21; this.label10.Size = new System.Drawing.Size(55, 13);
this.chbEFUsar.Text = "L"; this.label10.TabIndex = 27;
this.chbEFUsar.UseVisualStyleBackColor = true; this.label10.Text = "Offset Ref";
// //
// chbETUsar // txtETOffsetRef
// //
this.chbETUsar.AutoSize = true; this.txtETOffsetRef.Location = new System.Drawing.Point(65, 146);
this.chbETUsar.Location = new System.Drawing.Point(127, 124); this.txtETOffsetRef.Margin = new System.Windows.Forms.Padding(2);
this.chbETUsar.Name = "chbETUsar"; this.txtETOffsetRef.Name = "txtETOffsetRef";
this.chbETUsar.Size = new System.Drawing.Size(32, 17); this.txtETOffsetRef.Size = new System.Drawing.Size(57, 20);
this.chbETUsar.TabIndex = 23; this.txtETOffsetRef.TabIndex = 26;
this.chbETUsar.Text = "L"; this.txtETOffsetRef.TextAlign = System.Windows.Forms.HorizontalAlignment.Center;
this.chbETUsar.UseVisualStyleBackColor = true; //
// label11
//
this.label11.AutoSize = true;
this.label11.Location = new System.Drawing.Point(11, 149);
this.label11.Margin = new System.Windows.Forms.Padding(2, 0, 2, 0);
this.label11.Name = "label11";
this.label11.Size = new System.Drawing.Size(55, 13);
this.label11.TabIndex = 29;
this.label11.Text = "Offset Ref";
//
// txtEFOffsetRef
//
this.txtEFOffsetRef.Location = new System.Drawing.Point(66, 146);
this.txtEFOffsetRef.Margin = new System.Windows.Forms.Padding(2);
this.txtEFOffsetRef.Name = "txtEFOffsetRef";
this.txtEFOffsetRef.Size = new System.Drawing.Size(57, 20);
this.txtEFOffsetRef.TabIndex = 28;
this.txtEFOffsetRef.TextAlign = System.Windows.Forms.HorizontalAlignment.Center;
//
// label12
//
this.label12.AutoSize = true;
this.label12.Location = new System.Drawing.Point(10, 149);
this.label12.Margin = new System.Windows.Forms.Padding(2, 0, 2, 0);
this.label12.Name = "label12";
this.label12.Size = new System.Drawing.Size(55, 13);
this.label12.TabIndex = 31;
this.label12.Text = "Offset Ref";
//
// txtDFOffsetRef
//
this.txtDFOffsetRef.Location = new System.Drawing.Point(65, 146);
this.txtDFOffsetRef.Margin = new System.Windows.Forms.Padding(2);
this.txtDFOffsetRef.Name = "txtDFOffsetRef";
this.txtDFOffsetRef.Size = new System.Drawing.Size(57, 20);
this.txtDFOffsetRef.TabIndex = 30;
this.txtDFOffsetRef.TextAlign = System.Windows.Forms.HorizontalAlignment.Center;
// //
// frmDirConfig // frmDirConfig
// //
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.BackColor = System.Drawing.Color.White; this.BackColor = System.Drawing.Color.White;
this.ClientSize = new System.Drawing.Size(398, 447); this.ClientSize = new System.Drawing.Size(398, 485);
this.Controls.Add(this.gpbSentido); this.Controls.Add(this.gpbSentido);
this.Margin = new System.Windows.Forms.Padding(2); this.Margin = new System.Windows.Forms.Padding(2);
this.Name = "frmDirConfig"; this.Name = "frmDirConfig";
@ -805,5 +897,13 @@ namespace AgroBase.Forms.Direcional
private System.Windows.Forms.CheckBox chbEFUsar; private System.Windows.Forms.CheckBox chbEFUsar;
private System.Windows.Forms.CheckBox chbDTUsar; private System.Windows.Forms.CheckBox chbDTUsar;
private System.Windows.Forms.CheckBox chbETUsar; private System.Windows.Forms.CheckBox chbETUsar;
private System.Windows.Forms.Label label12;
private System.Windows.Forms.TextBox txtDFOffsetRef;
private System.Windows.Forms.Label label11;
private System.Windows.Forms.TextBox txtEFOffsetRef;
private System.Windows.Forms.Label label9;
private System.Windows.Forms.TextBox txtDTOffsetRef;
private System.Windows.Forms.Label label10;
private System.Windows.Forms.TextBox txtETOffsetRef;
} }
} }

View File

@ -72,6 +72,9 @@ namespace AgroBase.Forms.Direcional
TextBox txtOffsetAngulo = FuncoesGlobais.FindControlRecursive<TextBox>(gpbSentido, "txt" + Modulo.Modulo_ID + "OffsetAngulo"); TextBox txtOffsetAngulo = FuncoesGlobais.FindControlRecursive<TextBox>(gpbSentido, "txt" + Modulo.Modulo_ID + "OffsetAngulo");
txtOffsetAngulo.Text = Modulo.DirMotor.OffsetAnguloReal.ToString(); txtOffsetAngulo.Text = Modulo.DirMotor.OffsetAnguloReal.ToString();
TextBox txtOffsetRef = FuncoesGlobais.FindControlRecursive<TextBox>(gpbSentido, "txt" + Modulo.Modulo_ID + "OffsetRef");
txtOffsetRef.Text = Modulo.DirMotor.OffsetReferenciamento.ToString();
CheckBox chbUsar = FuncoesGlobais.FindControlRecursive<CheckBox>(gpbSentido, "chb" + Modulo.Modulo_ID + "Usar"); CheckBox chbUsar = FuncoesGlobais.FindControlRecursive<CheckBox>(gpbSentido, "chb" + Modulo.Modulo_ID + "Usar");
chbUsar.Checked = Modulo.DirMotor.UsarAnguloSensor; chbUsar.Checked = Modulo.DirMotor.UsarAnguloSensor;
} }
@ -119,6 +122,9 @@ namespace AgroBase.Forms.Direcional
TextBox txtOffsetAngulo = FuncoesGlobais.FindControlRecursive<TextBox>(gpbSentido, "txt" + Modulo.Modulo_ID + "OffsetAngulo"); TextBox txtOffsetAngulo = FuncoesGlobais.FindControlRecursive<TextBox>(gpbSentido, "txt" + Modulo.Modulo_ID + "OffsetAngulo");
Modulo.DirMotor.OffsetAnguloReal = Convert.ToDouble(txtOffsetAngulo.Text); Modulo.DirMotor.OffsetAnguloReal = Convert.ToDouble(txtOffsetAngulo.Text);
TextBox txtOffsetRef = FuncoesGlobais.FindControlRecursive<TextBox>(gpbSentido, "txt" + Modulo.Modulo_ID + "OffsetRef");
Modulo.DirMotor.OffsetReferenciamento = Convert.ToDouble(txtOffsetRef.Text);
CheckBox chbUsar = FuncoesGlobais.FindControlRecursive<CheckBox>(gpbSentido, "chb" + Modulo.Modulo_ID + "Usar"); CheckBox chbUsar = FuncoesGlobais.FindControlRecursive<CheckBox>(gpbSentido, "chb" + Modulo.Modulo_ID + "Usar");
Modulo.DirMotor.UsarAnguloSensor = chbUsar.Checked; Modulo.DirMotor.UsarAnguloSensor = chbUsar.Checked;
} }

View File

@ -339,13 +339,28 @@ namespace AgroBase.Models.Modules
{ {
var op = Variaveis.OperacaoEmAndamento; var op = Variaveis.OperacaoEmAndamento;
if (op.CalibrandoRuntime) if (op == null)
return; return;
long tickSolicitado = Stopwatch.GetTimestamp();
lock (_metricasLock) lock (_metricasLock)
{ {
long agoraTicks = Stopwatch.GetTimestamp(); long agoraTicks = Stopwatch.GetTimestamp();
DateTime agora = DateTime.Now; DateTime agora = DateTime.Now;
double esperaLockSeg = (agoraTicks - tickSolicitado) / (double)Stopwatch.Frequency;
double tempoMovimentoAtualSeg = op.Sensoriamento?.Movimentacao?.TempoMovimentoSegs ?? 0.0;
if (op.CalibrandoRuntime)
{
/*
* Durante a calibragem não integra, mas mantém
* o relógio ancorado no instante atual.
*/
ReancorarMetricas(agoraTicks, tempoMovimentoAtualSeg, encerrarTrechos: true);
return;
}
bool operacaoConcluida = op.Sensoriamento?.Operacao?.StatusOperacaoAtual == StatusOperacao.Concluido; bool operacaoConcluida = op.Sensoriamento?.Operacao?.StatusOperacaoAtual == StatusOperacao.Concluido;
bool operacaoAtiva = op.Sensoriamento?.Operacao?.OperacaoIniciada == true && !operacaoConcluida; bool operacaoAtiva = op.Sensoriamento?.Operacao?.OperacaoIniciada == true && !operacaoConcluida;
@ -411,10 +426,11 @@ namespace AgroBase.Models.Modules
: new HashSet<int>(); : new HashSet<int>();
double vazaoAtualMLs = operacaoAtiva ? ObterVazaoAtualConfiavelMLs(agora) : 0.0; double vazaoAtualMLs = operacaoAtiva ? ObterVazaoAtualConfiavelMLs(agora) : 0.0;
double tempoMovimentoAtualSeg = op.Sensoriamento?.Movimentacao?.TempoMovimentoSegs ?? 0.0;
if (_ultimoTickMetricas != 0) if (_ultimoTickMetricas != 0)
{ {
bool haviaAtuacaoNoIntervaloAnterior = _bicosAtivosIntervaloAnterior.Count > 0 || _bombasAtivasIntervaloAnterior.Count > 0 || _vazaoIntervaloAnteriorMLs > 0.01;
double dt = (agoraTicks - _ultimoTickMetricas) / (double)Stopwatch.Frequency; double dt = (agoraTicks - _ultimoTickMetricas) / (double)Stopwatch.Frequency;
if (dt > 0 && dt <= IntervaloMaximoIntegracaoSeg) if (dt > 0 && dt <= IntervaloMaximoIntegracaoSeg)
@ -443,8 +459,26 @@ namespace AgroBase.Models.Modules
if (dt > IntervaloMaximoIntegracaoSeg) if (dt > IntervaloMaximoIntegracaoSeg)
{ {
Variaveis.MostrarLog("[ATU/METRICAS] Integração ignorada por dt alto: " + dt.ToString("0.000") + "s"); string mensagem =
op.Sensoriamento.InserirLog(Dispositivo, StatusModulo.Alerta, 50, "[ATU/METRICAS] Integração ignorada por dt alto: " + dt.ToString("0.000") + "s"); "[ATU/METRICAS] Integração ignorada por dt alto: " +
$"{dt:0.000}s; " +
$"opAtiva={operacaoAtiva}; " +
$"atuacaoAnterior={haviaAtuacaoNoIntervaloAnterior}; " +
$"bicosAnterior={_bicosAtivosIntervaloAnterior.Count}; " +
$"bombasAnterior={_bombasAtivasIntervaloAnterior.Count}; " +
$"vazaoAnterior={_vazaoIntervaloAnteriorMLs:0.00}mL/s; " +
$"esperaLock={esperaLockSeg * 1000.0:0.0}ms";
Variaveis.MostrarLog(mensagem);
/*
* Só vira evento operacional se efetivamente
* perdemos um intervalo de pulverização.
*/
if (operacaoAtiva && haviaAtuacaoNoIntervaloAnterior)
{
op.Sensoriamento?.InserirLog(Dispositivo, StatusModulo.Alerta, 80, mensagem);
}
} }
} }
} }
@ -494,6 +528,24 @@ namespace AgroBase.Models.Modules
} }
} }
private void ReancorarMetricas(long agoraTicks, double tempoMovimentoAtualSeg, bool encerrarTrechos)
{
if (encerrarTrechos)
{
foreach (var bico in BicosPulverizadores ?? new List<AtuadorBicoModel>())
{
bico?.AtualizarEstadoTrecho(false);
}
}
_ultimoTickMetricas = agoraTicks;
_tempoMovimentoAnteriorSeg = tempoMovimentoAtualSeg;
_bicosAtivosIntervaloAnterior = new HashSet<int>();
_bombasAtivasIntervaloAnterior = new HashSet<int>();
_potenciaBombasIntervaloAnterior = new Dictionary<int, double>();
_vazaoIntervaloAnteriorMLs = 0.0;
ZerarVazoesInstantaneas();
}
private void IntegrarIntervaloAnterior(double dt, double tempoMovimentoIntervaloSeg) private void IntegrarIntervaloAnterior(double dt, double tempoMovimentoIntervaloSeg)
{ {
var bicosDoIntervalo = (BicosPulverizadores ?? var bicosDoIntervalo = (BicosPulverizadores ??

View File

@ -24,6 +24,7 @@ namespace AgroBase.Models.Modules
public Sentido Sentido_SP { get; set; } = Sentido.Parado; public Sentido Sentido_SP { get; set; } = Sentido.Parado;
public double Angulo_SP { get; set; } = 0; public double Angulo_SP { get; set; } = 0;
public double FolgaMecanica { get; set; } = 0; public double FolgaMecanica { get; set; } = 0;
public double OffsetReferenciamento { get; set; } = 0;
public double Velocidade_Max { get; set; } = 600; public double Velocidade_Max { get; set; } = 600;
public int Velocidade public int Velocidade
{ {
@ -149,11 +150,16 @@ namespace AgroBase.Models.Modules
return !Comandar || Sentido_SP == Sentido.Parado ? 0 : Angulo_SP; return !Comandar || Sentido_SP == Sentido.Parado ? 0 : Angulo_SP;
} }
} }
public double ErroAnguloSP public double ErroAngulo
{ {
get get
{ {
return Math.Abs(AnguloLeitura - AnguloSPAtual); double anguloSp =
!Comandar || Sentido_SP == Sentido.Parado
? 0
: Angulo_SP;
return anguloSp - AnguloLeitura;
} }
} }
public bool RetornandoAzero public bool RetornandoAzero
@ -165,13 +171,7 @@ namespace AgroBase.Models.Modules
return foraZero && parar; return foraZero && parar;
} }
} }
public bool EmMovimento public bool EmMovimento { get; set; }
{
get
{
return Math.Abs(RPM) > 1;
}
}
public bool AtingiuAnguloSP public bool AtingiuAnguloSP
{ {
get get
@ -207,6 +207,109 @@ namespace AgroBase.Models.Modules
} }
private DateTime _erroAngularDesde = DateTime.MinValue;
private DateTime _recuperacaoAngularDesde = DateTime.MinValue;
public DateTime UltimaMudancaAnguloSP { get; private set; }
public DateTime InicioMovimentoAngular { get; private set; }
public bool ErroSeguimentoAngularPersistente { get; private set; }
public void AtualizarSaudeSeguimentoAngular(DateTime agora)
{
const double toleranciaFalhaGraus = 3.0;
const double toleranciaRecuperacaoGraus = 2.0;
TimeSpan tempoGracaSP = TimeSpan.FromMilliseconds(750);
TimeSpan persistenciaFalha = TimeSpan.FromSeconds(2);
TimeSpan persistenciaRecuperacao = TimeSpan.FromMilliseconds(500);
TimeSpan timeoutMovimento = TimeSpan.FromSeconds(15);
if (!Comandar)
{
ResetarSaudeSeguimentoAngular();
return;
}
bool emGracaSP =
UltimaMudancaAnguloSP != DateTime.MinValue &&
agora - UltimaMudancaAnguloSP <= tempoGracaSP;
bool movimentoDentroDoPrazo =
EmMovimento &&
InicioMovimentoAngular != DateTime.MinValue &&
agora - InicioMovimentoAngular <= timeoutMovimento;
bool movimentoExcedeuTimeout =
EmMovimento &&
InicioMovimentoAngular != DateTime.MinValue &&
agora - InicioMovimentoAngular > timeoutMovimento;
double ErroAnguloAbs = Math.Abs(ErroAngulo);
bool candidatoFalha =
ErroAnguloAbs > toleranciaFalhaGraus &&
!emGracaSP &&
(!EmMovimento || movimentoExcedeuTimeout);
if (candidatoFalha)
{
_recuperacaoAngularDesde = DateTime.MinValue;
if (_erroAngularDesde == DateTime.MinValue)
_erroAngularDesde = agora;
if (agora - _erroAngularDesde >= persistenciaFalha)
ErroSeguimentoAngularPersistente = true;
return;
}
_erroAngularDesde = DateTime.MinValue;
bool recuperado =
ErroAnguloAbs <= toleranciaRecuperacaoGraus ||
movimentoDentroDoPrazo ||
emGracaSP;
if (!recuperado)
{
_recuperacaoAngularDesde = DateTime.MinValue;
return;
}
if (_recuperacaoAngularDesde == DateTime.MinValue)
_recuperacaoAngularDesde = agora;
if (agora - _recuperacaoAngularDesde >= persistenciaRecuperacao)
ErroSeguimentoAngularPersistente = false;
}
private void ResetarSaudeSeguimentoAngular()
{
_erroAngularDesde = DateTime.MinValue;
_recuperacaoAngularDesde = DateTime.MinValue;
ErroSeguimentoAngularPersistente = false;
}
public void AtualizarEmMovimento(bool emMovimentoAgora)
{
DateTime agora = DateTime.Now;
if (emMovimentoAgora && !EmMovimento)
{
InicioMovimentoAngular = agora;
}
if (!emMovimentoAgora)
{
InicioMovimentoAngular = DateTime.MinValue;
}
EmMovimento = emMovimentoAgora;
}
public Dictionary<TipoMovimentoDirecional, ConfiguracaoSentidoMotor> ConfigSentidos { get; set; } public Dictionary<TipoMovimentoDirecional, ConfiguracaoSentidoMotor> ConfigSentidos { get; set; }
public List<FuncoesPinout> Funcoes { get; set; } public List<FuncoesPinout> Funcoes { get; set; }
@ -313,38 +416,41 @@ namespace AgroBase.Models.Modules
var _Controle = op.Controle; var _Controle = op.Controle;
int AnguloTipoMovimento = VariaveisEquipamento.AnguloMovimento[_Controle.TipoMovimento]; int AnguloTipoMovimento = VariaveisEquipamento.AnguloMovimento[_Controle.TipoMovimento];
Sentido novoSentido = Sentido.Parado;
double novoAngulo = 0;
if (!Comandar) if (!Comandar)
{ {
Sentido_SP = Sentido.Parado; novoSentido = Sentido.Parado;
} }
else if (Direcao == Direcao.Direita) else if (Direcao == Direcao.Direita)
{ {
Sentido_SP = ConfigSentidos[_Controle.TipoMovimento].Direita; novoSentido = ConfigSentidos[_Controle.TipoMovimento].Direita;
} }
else if (Direcao == Direcao.Esquerda) else if (Direcao == Direcao.Esquerda)
{ {
Sentido_SP = ConfigSentidos[_Controle.TipoMovimento].Esquerda; novoSentido = ConfigSentidos[_Controle.TipoMovimento].Esquerda;
} }
else else
{ {
Sentido_SP = Sentido.Parado; novoSentido = Sentido.Parado;
} }
UltimaDirecao = Direcao; UltimaDirecao = Direcao;
int mx = Sentido_SP == Sentido.Parado ? 0 : Sentido_SP == Sentido.Antihorario ? -1 : 1; int mx = novoSentido == Sentido.Parado ? 0 : novoSentido == Sentido.Antihorario ? -1 : 1;
if (Sentido_SP == Sentido.Parado) if (novoSentido == Sentido.Parado)
{ {
Angulo_SP = 0; novoAngulo = 0;
UltimaDirecao = Direcao.Parado; UltimaDirecao = Direcao.Parado;
} }
else if (AnguloTipoMovimento != -1) else if (AnguloTipoMovimento != -1)
{ {
Angulo_SP = AnguloTipoMovimento; novoAngulo = AnguloTipoMovimento;
} }
else else
{ {
Angulo_SP = _Controle.Angulo; novoAngulo = _Controle.Angulo;
} }
if (Comandar) if (Comandar)
@ -353,8 +459,16 @@ namespace AgroBase.Models.Modules
//Console.WriteLine($"[{Mod_ID}] Angulo Atualizado para {Variaveis.OperacaoEmAndamento.SimulacaoAnguloControle}"); //Console.WriteLine($"[{Mod_ID}] Angulo Atualizado para {Variaveis.OperacaoEmAndamento.SimulacaoAnguloControle}");
} }
Angulo_SP = Math.Abs(Angulo_SP) * mx; novoAngulo = Math.Abs(novoAngulo) * mx;
Angulo_SP += AnguloFolgaCompensar; novoAngulo += AnguloFolgaCompensar;
if (Math.Abs(novoAngulo - Angulo_SP) >= 0.05)
{
UltimaMudancaAnguloSP = DateTime.Now;
}
Angulo_SP = novoAngulo;
Sentido_SP = novoSentido;
if (_Simulando) if (_Simulando)
{ {
@ -473,9 +587,7 @@ namespace AgroBase.Models.Modules
_sentido = _sentido == Sentido.Horario ? Sentido.Antihorario : Sentido.Horario; _sentido = _sentido == Sentido.Horario ? Sentido.Antihorario : Sentido.Horario;
Variaveis.MostrarLog($"[DirecionalModel] [ReferenciaMotorRelativo] [DIR {Mod_ID}] - Invertendo Sentido de Giro para {_sentido.ToString()}"); Variaveis.MostrarLog($"[DirecionalModel] [ReferenciaMotorRelativo] [DIR {Mod_ID}] - Invertendo Sentido de Giro para {_sentido.ToString()}");
double offset = Mod_ID == "DT" ? -2 : 0; _anguloSP = CalcularCentroCompensado(AnguloEntrada, AnguloSaida, _sentido, OffsetReferenciamento);
_anguloSP = CalcularCentroCompensado(AnguloEntrada, AnguloSaida, _sentido, offset);
} }
verificacoesAngulo++; verificacoesAngulo++;

View File

@ -494,6 +494,8 @@ namespace AgroBase.Models.Modules
modulo.DirMotor.EnviarComandoControle(controleDir.UltimaDirecao); modulo.DirMotor.EnviarComandoControle(controleDir.UltimaDirecao);
enviouControle = true; enviouControle = true;
} }
modulo.DirMotor.AtualizarSaudeSeguimentoAngular(agora);
} }
if (enviouControle) if (enviouControle)

View File

@ -625,9 +625,9 @@ namespace AgroBase.Models.Modules
posicao = CanMessagePosicaoDados.Dados1, posicao = CanMessagePosicaoDados.Dados1,
indice = 0, indice = 0,
analise_health = true, analise_health = true,
maximo = 5.2, maximo = 5.3,
minimo = 4.9, minimo = 4.75,
nominal = 5.0, nominal = 5.1,
unidade_medida = "V" unidade_medida = "V"
}, },
new SensorValoresLeituraModel() new SensorValoresLeituraModel()
@ -636,9 +636,9 @@ namespace AgroBase.Models.Modules
posicao = CanMessagePosicaoDados.Dados1, posicao = CanMessagePosicaoDados.Dados1,
indice = 1, indice = 1,
analise_health = true, analise_health = true,
maximo = 1500, maximo = 800,
minimo = 100, minimo = 100,
nominal = 280, nominal = 430,
unidade_medida = "mA" unidade_medida = "mA"
}, },
new SensorValoresLeituraModel() new SensorValoresLeituraModel()

View File

@ -4094,6 +4094,21 @@ namespace AgroBase.Models
op.TempoIniciarOperacao = Math.Max(0, Convert.ToInt32(Math.Round(tempoAguardarInicio))); op.TempoIniciarOperacao = Math.Max(0, Convert.ToInt32(Math.Round(tempoAguardarInicio)));
JObject emergenciaSistema = RedisService.GetField<JObject>(CtxKey.DadosOperacao, "emergencia_sistema");
bool emergenciaSistemicaSolicitada = emergenciaSistema?["solicitada"]?.Value<bool>() ?? RedisService.GetField<bool>(CtxKey.DadosOperacao, "emergencia_sistema_solicitada", false);
bool emergenciaAnterior = Operacao.Emergencia;
bool emergencia = emergenciaAnterior || emergenciaSistemicaSolicitada;
if (emergenciaSistemicaSolicitada && !emergenciaAnterior)
{
string codigo = emergenciaSistema?["codigo"]?.Value<string>() ?? "INTERTRAVAMENTO_SISTEMA";
string motivo = emergenciaSistema?["motivo"]?.Value<string>() ?? "Intertravamento sistêmico solicitado";
int severidade = emergenciaSistema?["severidade"]?.Value<int>() ?? 90;
InserirLog(T_Code.Mov, StatusModulo.Falha, Math.Max(0, 100 - severidade), $"Emergência sistêmica acionada [{codigo}]: {motivo}");
}
var operacaoAtualizada = new OperacaoSensoriamentoLogModel() var operacaoAtualizada = new OperacaoSensoriamentoLogModel()
{ {
Momento = agora, Momento = agora,
@ -4102,7 +4117,7 @@ namespace AgroBase.Models
Status = op.Status, Status = op.Status,
StatusOperacaoAnterior = Operacao.StatusOperacaoAnterior, StatusOperacaoAnterior = Operacao.StatusOperacaoAnterior,
OperacaoIniciada = Operacao.OperacaoIniciada, OperacaoIniciada = Operacao.OperacaoIniciada,
Emergencia = Operacao.Emergencia, Emergencia = emergencia,
Pausa = Operacao.Pausa, Pausa = Operacao.Pausa,
Calibrando = Operacao.Calibrando, Calibrando = Operacao.Calibrando,
Finalizando = Operacao.Finalizando, Finalizando = Operacao.Finalizando,
@ -4889,7 +4904,7 @@ namespace AgroBase.Models
? (velocidade / velocidadeMax) * 100.0 ? (velocidade / velocidadeMax) * 100.0
: 0; : 0;
double erroAngulo = dir != null ? CalcularErroAngulo(dir) : 0; double erroAngulo = dir?.ErroAngulo ?? 0;
return new DirecionalModuloLogModel return new DirecionalModuloLogModel
{ {
@ -4919,6 +4934,7 @@ namespace AgroBase.Models
ErroAngulo = Math.Round(erroAngulo, 2), ErroAngulo = Math.Round(erroAngulo, 2),
ErroAnguloAbs = Math.Round(Math.Abs(erroAngulo), 2), ErroAnguloAbs = Math.Round(Math.Abs(erroAngulo), 2),
ErroSeguimentoAngularPersistente = dir?.ErroSeguimentoAngularPersistente ?? false,
AtingiuAnguloSP = dir?.AtingiuAnguloSP ?? false, AtingiuAnguloSP = dir?.AtingiuAnguloSP ?? false,
RetornandoAzero = dir?.RetornandoAzero ?? false, RetornandoAzero = dir?.RetornandoAzero ?? false,
@ -4943,19 +4959,6 @@ namespace AgroBase.Models
}; };
} }
double CalcularErroAngulo(DirecionalModel dir)
{
if (dir == null)
return 0;
double anguloSp =
!dir.Comandar || dir.Sentido_SP == Sentido.Parado
? 0
: dir.Angulo_SP;
return anguloSp - dir.AnguloLeitura;
}
DirecionalStatusLogModel CriarStatus((bool, List<string>) status) DirecionalStatusLogModel CriarStatus((bool, List<string>) status)
{ {
return new DirecionalStatusLogModel return new DirecionalStatusLogModel
@ -5021,7 +5024,7 @@ namespace AgroBase.Models
model.AnguloMedioAbs = MediaOuZero(controlando, x => Math.Abs(x.DirMotor.AnguloLeitura)); model.AnguloMedioAbs = MediaOuZero(controlando, x => Math.Abs(x.DirMotor.AnguloLeitura));
model.AnguloSPMedioAbs = MediaOuZero(controlando, x => Math.Abs(x.DirMotor.Angulo_SP)); model.AnguloSPMedioAbs = MediaOuZero(controlando, x => Math.Abs(x.DirMotor.Angulo_SP));
model.ErroAnguloMedioAbs = MediaOuZero(controlando, x => Math.Abs(CalcularErroAngulo(x.DirMotor))); model.ErroAnguloMedioAbs = MediaOuZero(controlando, x => Math.Abs(x.DirMotor.ErroAngulo));
model.Status = model.Status =
dados != null && controle != null dados != null && controle != null
@ -5999,11 +6002,9 @@ namespace AgroBase.Models
public DirecionalStatusLogModel Status { get; set; } = new DirecionalStatusLogModel(); public DirecionalStatusLogModel Status { get; set; } = new DirecionalStatusLogModel();
public bool StatusGeralOk => public bool StatusEstruturalOk => Disponivel && Status.Ok && ModulosInicializados > 0;
Disponivel &&
Status.Ok && public bool StatusGeralOk => StatusEstruturalOk && !Modulos.Any(x => x.Controlar && x.ErroSeguimentoAngularPersistente);
ModulosInicializados > 0 &&
!Modulos.Any(x => x.ErroAnguloAbs > 3.0 && x.Controlar);
public List<DirecionalModuloLogModel> Modulos { get; set; } = new List<DirecionalModuloLogModel>(); public List<DirecionalModuloLogModel> Modulos { get; set; } = new List<DirecionalModuloLogModel>();
@ -6078,6 +6079,7 @@ namespace AgroBase.Models
// Diagnóstico geométrico // Diagnóstico geométrico
public double ErroAngulo { get; set; } public double ErroAngulo { get; set; }
public double ErroAnguloAbs { get; set; } public double ErroAnguloAbs { get; set; }
public bool ErroSeguimentoAngularPersistente { get; set; }
public bool AtingiuAnguloSP { get; set; } public bool AtingiuAnguloSP { get; set; }
public bool RetornandoAzero { get; set; } public bool RetornandoAzero { get; set; }

View File

@ -1228,6 +1228,8 @@ namespace AgroBase.Services
short rpm = DecodeInt16BE(data, 1); short rpm = DecodeInt16BE(data, 1);
item.RPM.valor = rpm; item.RPM.valor = rpm;
Variaveis.OperacaoEmAndamento?.DispMvd?.Dados?.Modulos?.FirstOrDefault(x => x.DirMotor._EnderecoCAN_Tx == item.EnderecoTx)?.DirMotor?.AtualizarEmMovimento(Math.Abs(rpm) > 1);
} }
private void ProcessarPulsosRecebidos(MKS057DModel item, byte[] data) private void ProcessarPulsosRecebidos(MKS057DModel item, byte[] data)

View File

@ -1,4 +1,5 @@
using AgroBase.Models; using AgroBase.Models;
using AgroBase.Models.Modules;
using AgroBase.Models.Operadores; using AgroBase.Models.Operadores;
using Newtonsoft.Json; using Newtonsoft.Json;
using System; using System;
@ -788,15 +789,30 @@ namespace AgroBase.Services.Operadores
}) })
.ToList(); .ToList();
bool possuiComandoServo = servo.UltimoComandoEnviado != DateTime.MinValue;
DateTime ultimaRespostaAnguloServo =
servo.ValoresLeituras?
.Where(x =>x.funcao == FuncoesPinout.ServoAnguloLeitura)
.OrderByDescending(x =>x.atual?.lidoEm ?? DateTime.MinValue)
.FirstOrDefault()?.atual?.lidoEm ?? DateTime.MinValue;
bool possuiRespostaServo = ultimaRespostaAnguloServo != DateTime.MinValue;
double idadeUltimoComandoServoMs = double idadeUltimoComandoServoMs =
servo.UltimoComandoEnviado == DateTime.MinValue !possuiComandoServo
? 999999 ? 999999
: (agora_dt - servo.UltimoComandoEnviado).TotalMilliseconds; : Math.Max(0, (agora_dt - servo.UltimoComandoEnviado).TotalMilliseconds);
double idadeUltimaRespostaServoMs = double idadeUltimaRespostaServoMs =
servo.UltimaLeitura == DateTime.MinValue !possuiRespostaServo
? 999999 ? 999999
: (agora_dt - servo.UltimaLeitura).TotalMilliseconds; : Math.Max(0, (agora_dt - ultimaRespostaAnguloServo).TotalMilliseconds);
bool telemetriaPosComandoServo =
possuiComandoServo &&
possuiRespostaServo &&
ultimaRespostaAnguloServo >= servo.UltimoComandoEnviado;
var servoObj = new var servoObj = new
{ {
@ -806,15 +822,21 @@ namespace AgroBase.Services.Operadores
tipo, tipo,
controlar = servo.Controlar, controlar = servo.Controlar,
mandatorio = servo.Mandatorio, mandatorio = servo.Mandatorio,
angulo_sp = servo._AnguloControle, angulo_sp = servo._AnguloControle,
angulo_desejado = servo.UltimoAnguloDesejado,
angulo_leitura = servo._AnguloLeiutra,
possui_comando = possuiComandoServo,
telemetria_pos_comando = telemetriaPosComandoServo,
ultimo_comando_ms = idadeUltimoComandoServoMs,
ultima_resposta_ms = idadeUltimaRespostaServoMs,
dados, dados,
timestamp = agora, timestamp = agora,
saude = saude ?? new { }, saude = saude ?? new { },
angulo_desejado = servo.UltimoAnguloDesejado,
angulo_leitura = servo._AnguloLeiutra,
ultimo_comando_ms = idadeUltimoComandoServoMs,
ultima_resposta_ms = idadeUltimaRespostaServoMs,
}; };
senServosAtivos.Add($"{tipo}.{id}"); senServosAtivos.Add($"{tipo}.{id}");
@ -843,15 +865,30 @@ namespace AgroBase.Services.Operadores
}) })
.ToList(); .ToList();
bool possuiComandoRele = rele.UltimoComandoEnviado != DateTime.MinValue; DateTime ultimaRespostaEstadoRele =
bool possuiRespostaRele = rele.UltimaLeitura != DateTime.MinValue; rele.ValoresLeituras?
double idadeUltimoComandoReleMs = !possuiComandoRele ? 999999 : Math.Max(0, (agora_dt - rele.UltimoComandoEnviado).TotalMilliseconds); .Where(x =>x.funcao == FuncoesPinout.EstadoLeitura)
double idadeUltimaRespostaReleMs = !possuiRespostaRele ? 999999 : Math.Max(0, (agora_dt - rele.UltimaLeitura).TotalMilliseconds); .OrderByDescending(x =>x.atual?.lidoEm ?? DateTime.MinValue)
.FirstOrDefault()?.atual?.lidoEm ?? DateTime.MinValue;
bool telemetriaPosComando = bool possuiComandoRele = rele.UltimoComandoEnviado != DateTime.MinValue;
bool possuiRespostaRele = ultimaRespostaEstadoRele != DateTime.MinValue;
double idadeUltimoComandoReleMs =
!possuiComandoRele
? 999999
: Math.Max(0, (agora_dt - rele.UltimoComandoEnviado).TotalMilliseconds);
double idadeUltimaRespostaReleMs =
!possuiRespostaRele
? 999999
: Math.Max(0, (agora_dt - ultimaRespostaEstadoRele).TotalMilliseconds);
bool telemetriaPosComandoRele =
possuiComandoRele && possuiComandoRele &&
possuiRespostaRele && possuiRespostaRele &&
rele.UltimaLeitura >= rele.UltimoComandoEnviado; ultimaRespostaEstadoRele >= rele.UltimoComandoEnviado;
var releObj = new var releObj = new
{ {
@ -870,7 +907,7 @@ namespace AgroBase.Services.Operadores
estado_leitura = rele._EstadoLeitura, estado_leitura = rele._EstadoLeitura,
possui_comando = possuiComandoRele, possui_comando = possuiComandoRele,
telemetria_pos_comando = telemetriaPosComando, telemetria_pos_comando = telemetriaPosComandoRele,
ultimo_comando_ms = idadeUltimoComandoReleMs, ultimo_comando_ms = idadeUltimoComandoReleMs,
ultima_resposta_ms = idadeUltimaRespostaReleMs, ultima_resposta_ms = idadeUltimaRespostaReleMs,
@ -902,15 +939,50 @@ namespace AgroBase.Services.Operadores
}) })
.ToList(); .ToList();
DateTime ultimaRespostaLedId =
led.ValoresLeituras?
.Where(x =>x.funcao == FuncoesPinout.StatusLedID)
.OrderByDescending(x => x.atual?.lidoEm ?? DateTime.MinValue)
.FirstOrDefault()?.atual?.lidoEm ?? DateTime.MinValue;
DateTime ultimaRespostaLedStatus =
led.ValoresLeituras?
.Where(x =>x.funcao == FuncoesPinout.StatusLedLeitura)
.OrderByDescending(x =>x.atual?.lidoEm ?? DateTime.MinValue)
.FirstOrDefault()?.atual?.lidoEm ?? DateTime.MinValue;
bool possuiComandoLed = led.UltimoComandoEnviado != DateTime.MinValue;
bool possuiRespostaLed =
ultimaRespostaLedId != DateTime.MinValue &&
ultimaRespostaLedStatus != DateTime.MinValue;
/*
* Usa a mais antiga das duas respostas.
* Assim ultima_resposta_ms representa a idade do conjunto completo.
*/
DateTime ultimaRespostaLed =
possuiRespostaLed
? (ultimaRespostaLedId <= ultimaRespostaLedStatus ? ultimaRespostaLedId : ultimaRespostaLedStatus)
: DateTime.MinValue;
double idadeUltimoComandoLedMs = double idadeUltimoComandoLedMs =
led.UltimoComandoEnviado == DateTime.MinValue !possuiComandoLed
? 999999 ? 999999
: (agora_dt - led.UltimoComandoEnviado).TotalMilliseconds; : Math.Max(0, (agora_dt - led.UltimoComandoEnviado).TotalMilliseconds);
double idadeUltimaRespostaLedMs = double idadeUltimaRespostaLedMs =
led.UltimaLeitura == DateTime.MinValue !possuiRespostaLed
? 999999 ? 999999
: (agora_dt - led.UltimaLeitura).TotalMilliseconds; : Math.Max(0, (agora_dt - ultimaRespostaLed).TotalMilliseconds);
bool telemetriaPosComandoLed =
possuiComandoLed &&
possuiRespostaLed &&
ultimaRespostaLedId >= led.UltimoComandoEnviado &&
ultimaRespostaLedStatus >= led.UltimoComandoEnviado;
int statusLedSp = led.UltimoComportamentoDesejado != null ? (int)led.UltimoComportamentoDesejado.statusLED : -1;
var ledObj = new var ledObj = new
{ {
@ -920,16 +992,24 @@ namespace AgroBase.Services.Operadores
tipo, tipo,
controlar = led.Controlar, controlar = led.Controlar,
mandatorio = led.Mandatorio, mandatorio = led.Mandatorio,
status_led_id_sp = led.UltimoComportamentoDesejado?.id ?? -1,
status_led_sp = statusLedSp,
status_led_id = (int)led._StatusLedId,
status_led_leitura = (int)led._StatusLed,
possui_comando = possuiComandoLed,
telemetria_pos_comando = telemetriaPosComandoLed,
ultimo_comando_ms = idadeUltimoComandoLedMs,
ultima_resposta_ms = idadeUltimaRespostaLedMs,
dados, dados,
timestamp = agora, timestamp = agora,
saude = saude ?? new { }, saude = saude ?? new { },
status_led_id_sp = led.UltimoComportamentoDesejado?.id ?? -1,
status_led_sp = led.UltimoComportamentoDesejado?.statusLED,
status_led_id = led._StatusLedId,
status_led_leitura = led._StatusLed,
ultimo_comando_ms = idadeUltimoComandoLedMs,
ultima_resposta_ms = idadeUltimaRespostaLedMs,
}; };
senLedsAtivos.Add($"{tipo}.{id}"); senLedsAtivos.Add($"{tipo}.{id}");

View File

@ -2,6 +2,7 @@ import json
import os import os
import time import time
import threading import threading
import hashlib
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
@ -15,6 +16,9 @@ from camera_worker.oak_fcc3_core.oak_fcc3_client import OakFcc3Client
from camera_worker.camera_imu import IMUCamera from camera_worker.camera_imu import IMUCamera
CAMERA_MULTISPECTRAL_VERSION = "production_v1_2026_08_24"
class CameraMultispectral: class CameraMultispectral:
""" """
Adaptador do módulo OAK-FFC-3 multiespectral para o ecossistema do robô. Adaptador do módulo OAK-FFC-3 multiespectral para o ecossistema do robô.
@ -28,6 +32,9 @@ class CameraMultispectral:
A classe NÃO expõe lógica radiométrica/fusão/flatfield para o worker. A classe NÃO expõe lógica radiométrica/fusão/flatfield para o worker.
Isso fica dentro do core OAK-FCC-3. Isso fica dentro do core OAK-FCC-3.
Em modo produto, hardware/resoluções/Bayer/target_size vêm exclusivamente
do module_params homologado e do hardware validado pelo Manager/Client.
""" """
def __init__( def __init__(
@ -41,6 +48,7 @@ class CameraMultispectral:
target_size=None, target_size=None,
cache_max_age_s=0.15, cache_max_age_s=0.15,
timeout_s=1.0, timeout_s=1.0,
require_product_contract=False,
): ):
self.mostrar_log = mostrar_log self.mostrar_log = mostrar_log
self.mx_id = str(mx_id) if mx_id else None self.mx_id = str(mx_id) if mx_id else None
@ -61,11 +69,42 @@ class CameraMultispectral:
self.rodando = False self.rodando = False
self.ultima_saude = {} self.ultima_saude = {}
self.width = int(width) # width/height recebidos do caller são somente compatibilidade legado.
self.height = int(height) # Em produto, resolução nativa vem do module_params/hardware real.
self.legacy_width = int(width)
self.legacy_height = int(height)
self.requested_target_size = (
None
if target_size is None
else [int(target_size[0]), int(target_size[1])]
)
# Aliases históricos. São sobrescritos pelo contrato do Client e passam
# a significar exclusivamente a resolução RGB nativa de referência.
self.width = self.legacy_width
self.height = self.legacy_height
self.fps = int(fps) self.fps = int(fps)
self.target_size = target_size self.target_size = self.requested_target_size
self.tensor_size = self.requested_target_size
self.timeout_s = float(timeout_s) self.timeout_s = float(timeout_s)
self.require_product_contract = bool(require_product_contract)
self.product_contract = False
self.client_contract = {}
self.sensor_size_by_role = {
"rgb": [self.legacy_width, self.legacy_height],
"re": [self.legacy_width, self.legacy_height],
"nir": [self.legacy_width, self.legacy_height],
}
self.camera_hardware = {}
self.rgb_native_size = [self.legacy_width, self.legacy_height]
self.spectral_native_size = {
"re": [self.legacy_width, self.legacy_height],
"nir": [self.legacy_width, self.legacy_height],
}
self.bayer_pattern = None
self.module_params_sha256 = None
self._cache_max_age_s = float(cache_max_age_s) self._cache_max_age_s = float(cache_max_age_s)
self._lock = threading.Lock() self._lock = threading.Lock()
@ -115,6 +154,9 @@ class CameraMultispectral:
module_calibration_json = self._resolver_module_params_default() module_calibration_json = self._resolver_module_params_default()
self.module_calibration_json = module_calibration_json self.module_calibration_json = module_calibration_json
self.module_params_sha256 = self._sha256_file_safe(
self.module_calibration_json
)
ContextoGlobalRedis.atualizar_ctx_dict( ContextoGlobalRedis.atualizar_ctx_dict(
ContextoGlobalRedis.CamKey(self.mx_id), ContextoGlobalRedis.CamKey(self.mx_id),
@ -137,8 +179,10 @@ class CameraMultispectral:
try: try:
self.client = OakFcc3Client( self.client = OakFcc3Client(
mx_id=self.mx_id, mx_id=self.mx_id,
width=self.width, # Fallbacks legado. O Client produto ignora estes valores como
height=self.height, # autoridade física e usa o module_params homologado.
width=self.legacy_width,
height=self.legacy_height,
bayer="BGGR", bayer="BGGR",
fps=self.fps, fps=self.fps,
frame_type="RAW_BRUTO", frame_type="RAW_BRUTO",
@ -146,15 +190,31 @@ class CameraMultispectral:
capture_mode="TRIPLE", capture_mode="TRIPLE",
raw_policy="require_triple", raw_policy="require_triple",
module_calibration_json=self.module_calibration_json, module_calibration_json=self.module_calibration_json,
sync_mode="best", sync_mode="strict",
sync_tolerance_ms=25.0, hardware_sync_enabled=False,
frame_sync_master="CAM_A",
sync_tolerance_ms=15.0,
imu_modo=self.imu_modo, imu_modo=self.imu_modo,
imu_freq_hz=self.imu_freq_hz, imu_freq_hz=self.imu_freq_hz,
# Runtime de campo: não executa a auditoria estatística
# evaluate_frame_quality() em todo frame.
# O tensor continua passando por todo o processamento científico
# necessário (decode, radiometria, flat-field, fusão e resize).
evaluate_quality=False,
require_product_contract=self.require_product_contract,
) )
# O contrato já está disponível antes do start porque o Client
# carrega/valida module_params no construtor.
self._adotar_contrato_client()
resp = self.client.start(print_debug=False) resp = self.client.start(print_debug=False)
# Reconfirma depois do Manager abrir e validar o hardware físico.
self._adotar_contrato_client()
# Se mx_id veio None, pega o ID real aberto pelo core antes de criar IMU. # Se mx_id veio None, pega o ID real aberto pelo core antes de criar IMU.
try: try:
self.mx_id = str(getattr(self.client, "mx_id", None) or self.mx_id) self.mx_id = str(getattr(self.client, "mx_id", None) or self.mx_id)
@ -235,6 +295,11 @@ class CameraMultispectral:
self.mostrar_log(f"[CameraMultispectral] Erro ao iniciar módulo: {e}") self.mostrar_log(f"[CameraMultispectral] Erro ao iniciar módulo: {e}")
# Produto é fail-closed: o CameraManager precisa receber o motivo
# real da falha, e não um objeto parcialmente inicializado.
if self.require_product_contract:
raise
ContextoGlobalRedis.atualizar_ctx_dict( ContextoGlobalRedis.atualizar_ctx_dict(
ContextoGlobalRedis.CamKey(self.mx_id), ContextoGlobalRedis.CamKey(self.mx_id),
mx_id=self.mx_id, mx_id=self.mx_id,
@ -267,40 +332,204 @@ class CameraMultispectral:
# Devolve o default mesmo se não existir, para o erro ficar claro no core. # Devolve o default mesmo se não existir, para o erro ficar claro no core.
return "calibration/module_params.json" return "calibration/module_params.json"
def _sha256_file_safe(self, path):
try:
if not path or not os.path.isfile(path):
return None
h = hashlib.sha256()
with open(path, "rb") as f:
while True:
chunk = f.read(1024 * 1024)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
except Exception:
return None
@staticmethod
def _normalize_size(value, field_name):
if not isinstance(value, (list, tuple)) or len(value) != 2:
raise RuntimeError(f"{field_name} inválido: {value!r}")
w = int(value[0])
h = int(value[1])
if w <= 0 or h <= 0:
raise RuntimeError(f"{field_name} inválido: {value!r}")
return [w, h]
def _adotar_contrato_client(self):
if self.client is None:
raise RuntimeError("OakFcc3Client não inicializado")
getter = getattr(self.client, "get_contract", None)
if not callable(getter):
if self.require_product_contract:
raise RuntimeError(
"OakFcc3Client sem get_contract(); versão produto obrigatória."
)
return
contract = getter() or {}
if not isinstance(contract, dict):
raise RuntimeError(
f"Contrato do OakFcc3Client inválido: {type(contract)}"
)
product = bool(contract.get("product_contract", False))
if self.require_product_contract and not product:
raise RuntimeError(
"CameraMultispectral exige module_params de produção homologado."
)
sizes_raw = contract.get("sensor_size_by_role") or {}
sizes = {}
for role in ("rgb", "re", "nir"):
if role not in sizes_raw:
if self.require_product_contract:
raise RuntimeError(
f"Contrato do Client sem sensor_size_by_role.{role}"
)
continue
sizes[role] = self._normalize_size(
sizes_raw[role],
f"sensor_size_by_role.{role}",
)
if "rgb" in sizes:
self.rgb_native_size = list(sizes["rgb"])
self.width = int(self.rgb_native_size[0])
self.height = int(self.rgb_native_size[1])
if "re" in sizes:
self.spectral_native_size["re"] = list(sizes["re"])
if "nir" in sizes:
self.spectral_native_size["nir"] = list(sizes["nir"])
if sizes:
self.sensor_size_by_role = {
role: list(size)
for role, size in sizes.items()
}
camera_hardware = contract.get("camera_hardware") or {}
if isinstance(camera_hardware, dict):
self.camera_hardware = json.loads(
json.dumps(camera_hardware, default=str)
)
bayer = contract.get("bayer_pattern")
if bayer:
bayer = str(bayer).upper()
if bayer not in ("RGGB", "BGGR", "GRBG", "GBRG"):
raise RuntimeError(f"Bayer inválido no contrato: {bayer!r}")
self.bayer_pattern = bayer
default_target = contract.get("default_target_size")
if default_target is not None:
default_target = self._normalize_size(
default_target,
"client.default_target_size",
)
if (
self.requested_target_size is not None
and default_target is not None
and list(self.requested_target_size) != list(default_target)
):
raise RuntimeError(
"Resolução IA divergente do module_params: "
f"caller={self.requested_target_size}, "
f"module_params={default_target}"
)
self.target_size = (
list(default_target)
if default_target is not None
else (
list(self.requested_target_size)
if self.requested_target_size is not None
else list(self.rgb_native_size)
)
)
self.tensor_size = list(self.target_size)
self.product_contract = product
self.client_contract = json.loads(
json.dumps(contract, default=str)
)
def _montar_parametros(self, status=None, start_resp=None): def _montar_parametros(self, status=None, start_resp=None):
status = status or {} status = status or {}
altura_camera = 111.0 altura_camera = 111.0
# Valores medidos por você anteriormente: # FOV prático histórico medido no conjunto mecânico.
# FOV prático: 96 x 157 cm a 111 cm de altura. # É mantido como referência operacional, mas não como calibração óptica.
largura_real = 157.0 largura_real = 157.0
altura_real = 96.0 altura_real = 96.0
cm_por_px_x = largura_real / float(self.width) tensor_w = int(self.tensor_size[0])
cm_por_px_y = altura_real / float(self.height) tensor_h = int(self.tensor_size[1])
# Para o worker, pixel operacional significa pixel do tensor entregue à IA,
# não pixel do sensor bruto. A homografia/crop pode reduzir discretamente
# a FOV real, então estes valores continuam sendo NOMINAIS.
cm_por_px_x = largura_real / float(tensor_w)
cm_por_px_y = altura_real / float(tensor_h)
parametros = { parametros = {
"tipo": "multispectral", "tipo": "multispectral",
"adapter_version": CAMERA_MULTISPECTRAL_VERSION,
"product_contract": bool(self.product_contract),
"module_calibration_json": self.module_calibration_json, "module_calibration_json": self.module_calibration_json,
"module_params_sha256": self.module_params_sha256,
"frame_type": "RAW_BRUTO", "frame_type": "RAW_BRUTO",
"channels": ["R", "G", "B", "RE", "NIR"], "channels": ["R", "G", "B", "RE", "NIR"],
"output_layout": "CHW", "output_layout": "CHW",
"dtype": "float32", "dtype": "float32",
"tensor_min": 0.0, "tensor_min": 0.0,
"tensor_max": 1.0, "tensor_max": 1.0,
"rgb_width": self.width,
"rgb_height": self.height, "reference_camera": "rgb",
"tensor_width": self.width if self.target_size is None else int(self.target_size[0]), "rgb_native_size": list(self.rgb_native_size),
"tensor_height": self.height if self.target_size is None else int(self.target_size[1]), "sensor_size_by_role": {
role: list(size)
for role, size in self.sensor_size_by_role.items()
},
"camera_hardware": json.loads(
json.dumps(self.camera_hardware, default=str)
),
"bayer_pattern": self.bayer_pattern,
# Aliases compatíveis, agora explicitamente RGB nativo.
"rgb_width": int(self.rgb_native_size[0]),
"rgb_height": int(self.rgb_native_size[1]),
"tensor_size": list(self.tensor_size),
"tensor_width": tensor_w,
"tensor_height": tensor_h,
"fps": self.fps, "fps": self.fps,
"altura_camera": altura_camera, "altura_camera": altura_camera,
"largura_real": largura_real, "largura_real": largura_real,
"altura_real": altura_real, "altura_real": altura_real,
"cm_por_px_x": cm_por_px_x, "cm_por_px_x": cm_por_px_x,
"cm_por_px_y": cm_por_px_y, "cm_por_px_y": cm_por_px_y,
"cm_por_px_nominal": True,
"status": status, "status": status,
"start_resp": start_resp, "start_resp": start_resp,
"client_contract": json.loads(
json.dumps(self.client_contract, default=str)
),
} }
return parametros return parametros
@ -557,6 +786,15 @@ class CameraMultispectral:
f"Tensor multiespectral deveria ter 5 canais, veio shape={tensor.shape}" f"Tensor multiespectral deveria ter 5 canais, veio shape={tensor.shape}"
) )
if self.target_size is not None:
expected_w = int(self.target_size[0])
expected_h = int(self.target_size[1])
if tuple(tensor.shape[1:]) != (expected_h, expected_w):
raise RuntimeError(
"Tensor final diverge do contrato de resolução: "
f"shape={tensor.shape}, esperado=(5,{expected_h},{expected_w})"
)
t_validate_ms = (time.perf_counter() - t0) * 1000.0 t_validate_ms = (time.perf_counter() - t0) * 1000.0
# ============================================================ # ============================================================
@ -595,6 +833,13 @@ class CameraMultispectral:
"shape": list(tensor.shape), "shape": list(tensor.shape),
"dtype": str(tensor.dtype), "dtype": str(tensor.dtype),
"channels": ["R", "G", "B", "RE", "NIR"], "channels": ["R", "G", "B", "RE", "NIR"],
"product_contract": bool(self.product_contract),
"tensor_size": list(self.tensor_size),
"sensor_size_by_role": {
role: list(size)
for role, size in self.sensor_size_by_role.items()
},
"bayer_pattern": self.bayer_pattern,
"frame_id": int(meta.get("frame_id", 0) or 0), "frame_id": int(meta.get("frame_id", 0) or 0),
"async_packet_seq": int( "async_packet_seq": int(
@ -615,6 +860,22 @@ class CameraMultispectral:
}, },
} }
#capture_perf = dict(meta.get("capture_perf", {}) or {})
#agora_log = time.monotonic()
#if not hasattr(self, "_ultimo_log_sync"):
# self._ultimo_log_sync = 0.0
#if agora_log - self._ultimo_log_sync >= 1.0:
# print(
# "[OAK_SYNC] "
# f"frame={meta.get('frame_id')} | "
# f"dt={float(meta.get('sync_dt_ms', 0.0) or 0.0):.3f}ms | "
# f"ok={bool(meta.get('sync_ok', False))} | "
# f"tol={float(meta.get('sync_tolerance_ms', 0.0) or 0.0):.3f}ms | "
# f"seq={capture_perf.get('selected_seq_by_cam')} | "
# f"ts={capture_perf.get('selected_ts_by_cam')}"
# )
# self._ultimo_log_sync = agora_log
with self._lock: with self._lock:
self.ultimo_tensor_multispec = tensor self.ultimo_tensor_multispec = tensor
self.ultimo_meta = meta self.ultimo_meta = meta
@ -674,7 +935,10 @@ class CameraMultispectral:
def requisitar_frame_rgb(self, force: bool = False, max_age_s: float = None): def requisitar_frame_rgb(self, force: bool = False, max_age_s: float = None):
""" """
Retorna preview BGR uint8 do tensor multiespectral. Retorna preview BGR uint8 gerado sob demanda a partir do Raw5 cacheado.
O hot path de inferência não paga custo de preview. O preview só é
construído quando stream/debug realmente pede um frame RGB.
""" """
try: try:
agora = time.time() agora = time.time()
@ -682,27 +946,58 @@ class CameraMultispectral:
max_age_s = self._cache_max_age_s max_age_s = self._cache_max_age_s
with self._lock: with self._lock:
cache_ok = ( preview_ok = (
self.ultimo_frame_rgb is not None self.ultimo_frame_rgb is not None
and self.timestamp_ultimo_frame_rgb is not None and self.timestamp_ultimo_frame_rgb is not None
and (agora - self.timestamp_ultimo_frame_rgb) < max_age_s and (agora - self.timestamp_ultimo_frame_rgb) < max_age_s
) )
if cache_ok and not force: if preview_ok and not force:
return self.ultimo_frame_rgb, dict(self._ultimo_resultado_rgb) return self.ultimo_frame_rgb, dict(self._ultimo_resultado_rgb)
# Atualiza tensor, que por consequência atualiza preview RGB. tensor_cache = self.ultimo_tensor_multispec
tensor, res = self.requisitar_tensor_multispec(force=force, max_age_s=max_age_s) ts_tensor = self.timestamp_ultimo_tensor
tensor_fresco = (
tensor_cache is not None
and ts_tensor is not None
and (agora - ts_tensor) < max_age_s
)
if not tensor_fresco or force:
tensor_cache, res = self.requisitar_tensor_multispec(
force=force,
max_age_s=max_age_s,
)
if tensor_cache is None:
return None, {
"erro": res.get("erro") or "tensor indisponível para preview",
"duracao": res.get("duracao", 0.0),
"frame_valido": False,
}
t0 = time.perf_counter()
preview = self._tensor_to_preview_bgr(tensor_cache)
dur = time.perf_counter() - t0
if preview is None or preview.size <= 0:
raise RuntimeError("Falha ao gerar preview RGB a partir do Raw5.")
resultado = {
"erro": None,
"duracao": dur,
"frame_valido": True,
"origem": "raw5_cache_on_demand",
"shape": list(preview.shape),
}
with self._lock: with self._lock:
if self.ultimo_frame_rgb is not None: self.ultimo_frame_rgb = preview
return self.ultimo_frame_rgb, dict(self._ultimo_resultado_rgb) self.timestamp_ultimo_frame_rgb = time.time()
self._ultimo_resultado_rgb = resultado
return None, { return preview, resultado
"erro": res.get("erro") or "preview RGB indisponível",
"duracao": res.get("duracao", 0.0),
"frame_valido": False,
}
except Exception as e: except Exception as e:
self.mostrar_log(f"[CameraMultispectral] Erro ao requisitar RGB: {e}") self.mostrar_log(f"[CameraMultispectral] Erro ao requisitar RGB: {e}")
@ -1341,8 +1636,12 @@ class CameraMultispectral:
core = getattr(self.client, "core", None) core = getattr(self.client, "core", None)
if core is not None and hasattr(core, "fuse_multispec_cameras"): if core is not None and hasattr(core, "fuse_multispec_cameras"):
t0 = time.perf_counter() t0 = time.perf_counter()
out = core.fuse_multispec_cameras(decoded_fixed, meta, 5) out = core.fuse_multispec_cameras(
out = core.resize_tensor_chw(out, target_size=self.target_size) decoded_fixed,
meta,
5,
target_size=self.target_size,
)
#t_core_build_ms = (time.perf_counter() - t0) * 1000.0 #t_core_build_ms = (time.perf_counter() - t0) * 1000.0
#t_total_ms = (time.perf_counter() - t_total0) * 1000.0 #t_total_ms = (time.perf_counter() - t_total0) * 1000.0
@ -1407,6 +1706,16 @@ class CameraMultispectral:
meta_item.setdefault("role", role) meta_item.setdefault("role", role)
meta_item.setdefault("socket", info.get("socket", cam_id)) meta_item.setdefault("socket", info.get("socket", cam_id))
meta_item.setdefault("sensor", info.get("sensor")) meta_item.setdefault("sensor", info.get("sensor"))
meta_item.setdefault("width", info.get("width"))
meta_item.setdefault("height", info.get("height"))
meta_item.setdefault("native_size", info.get("native_size"))
meta_item.setdefault("bit_depth", info.get("bit_depth"))
meta_item.setdefault("raw_format", info.get("raw_format"))
if role == "rgb":
meta_item.setdefault(
"bayer_pattern",
info.get("bayer_pattern") or self.bayer_pattern,
)
fixed["meta"] = meta_item fixed["meta"] = meta_item
out[cam_id] = fixed out[cam_id] = fixed
@ -1423,6 +1732,16 @@ class CameraMultispectral:
"role": role, "role": role,
"socket": info.get("socket", cam_id), "socket": info.get("socket", cam_id),
"sensor": info.get("sensor"), "sensor": info.get("sensor"),
"width": info.get("width"),
"height": info.get("height"),
"native_size": info.get("native_size"),
"bit_depth": info.get("bit_depth"),
"raw_format": info.get("raw_format"),
"bayer_pattern": (
info.get("bayer_pattern") or self.bayer_pattern
if role == "rgb"
else None
),
"timestamp": (meta.get("timestamps") or {}).get(cam_id) if isinstance(meta, dict) else None, "timestamp": (meta.get("timestamps") or {}).get(cam_id) if isinstance(meta, dict) else None,
"shape": list(item.shape), "shape": list(item.shape),
"dtype": str(item.dtype), "dtype": str(item.dtype),
@ -1441,6 +1760,28 @@ class CameraMultispectral:
# Salvamento científico / pós-processamento # Salvamento científico / pós-processamento
# ============================================================ # ============================================================
def _processing_diagnostics_json_safe(self):
core = getattr(self.client, "core", None) if self.client is not None else None
if core is None:
return None
payload = {
"fusion_result": getattr(core, "last_fusion_result", None),
"radiometric_normalization_result": getattr(
core,
"last_radiometric_normalization_result",
None,
),
"flatfield_result": getattr(core, "last_flatfield_result", None),
"frame_quality": getattr(core, "last_frame_quality_result", None),
}
try:
return json.loads(json.dumps(payload, default=str))
except Exception:
return None
def _ts_name(self) -> str: def _ts_name(self) -> str:
return datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3] return datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
@ -1466,6 +1807,20 @@ class CameraMultispectral:
with self._lock: with self._lock:
raw_meta = dict(self.ultimo_meta or {}) raw_meta = dict(self.ultimo_meta or {})
# Reforça o contrato científico no próprio stream_meta salvo.
raw_meta.setdefault("frame_type", "RAW_BRUTO")
raw_meta.setdefault("reference_camera", "rgb")
raw_meta.setdefault("bayer_pattern", self.bayer_pattern)
raw_meta.setdefault(
"sensor_size_by_role",
{role: list(size) for role, size in self.sensor_size_by_role.items()},
)
raw_meta.setdefault(
"camera_hardware",
json.loads(json.dumps(self.camera_hardware, default=str)),
)
raw_meta.setdefault("product_contract", bool(self.product_contract))
required = {"CAM_A", "CAM_B", "CAM_C"} required = {"CAM_A", "CAM_B", "CAM_C"}
presentes = set(raw_frame.keys()) presentes = set(raw_frame.keys())
faltando = sorted(required - presentes) faltando = sorted(required - presentes)
@ -1526,9 +1881,9 @@ class CameraMultispectral:
preview = self.client.build_save_preview_from_cam_a( preview = self.client.build_save_preview_from_cam_a(
packed_raw_by_camera=raw_frame, packed_raw_by_camera=raw_frame,
meta_stream=raw_meta, meta_stream=raw_meta,
sensor_width=self.width, sensor_width=int(self.rgb_native_size[0]),
sensor_height=self.height, sensor_height=int(self.rgb_native_size[1]),
bayer_pattern="BGGR", bayer_pattern=self.bayer_pattern,
) )
if preview is not None: if preview is not None:
@ -1641,9 +1996,22 @@ class CameraMultispectral:
"mx_id": self.mx_id, "mx_id": self.mx_id,
"module_calibration_json": self.module_calibration_json, "module_calibration_json": self.module_calibration_json,
"sensor_width": self.width, # Compatibilidade root: sensor_width/height representam CAM_A/RGB.
"sensor_height": self.height, "reference_camera": "rgb",
"bayer_pattern": "BGGR", "sensor_width": int(self.rgb_native_size[0]),
"sensor_height": int(self.rgb_native_size[1]),
"sensor_size_by_role": {
role: list(size)
for role, size in self.sensor_size_by_role.items()
},
"camera_hardware": json.loads(
json.dumps(self.camera_hardware, default=str)
),
"bayer_pattern": self.bayer_pattern,
"tensor_size": list(self.tensor_size),
"product_contract": bool(self.product_contract),
"camera_adapter_version": CAMERA_MULTISPECTRAL_VERSION,
"module_params_sha256": self.module_params_sha256,
"fps_target": self.fps, "fps_target": self.fps,
"frame_type": "RAW_BRUTO", "frame_type": "RAW_BRUTO",
@ -1662,6 +2030,10 @@ class CameraMultispectral:
"stream_meta": raw_meta, "stream_meta": raw_meta,
"actual_camera_controls": self.client.get_current_camera_controls() if self.client else None, "actual_camera_controls": self.client.get_current_camera_controls() if self.client else None,
"radiometric_last_result": self.client.get_radiometric_last_result() if self.client else None, "radiometric_last_result": self.client.get_radiometric_last_result() if self.client else None,
"client_contract": json.loads(
json.dumps(self.client_contract, default=str)
),
"processing_diagnostics": self._processing_diagnostics_json_safe(),
"resultado": resultado, "resultado": resultado,
} }

View File

@ -11,6 +11,9 @@ from .radiometric_controller import RadiometricController
PHYSICAL_CHANNEL_NAMES = ("R", "G", "B", "RE", "NIR") PHYSICAL_CHANNEL_NAMES = ("R", "G", "B", "RE", "NIR")
PHYSICAL_CHANNEL_COUNT = len(PHYSICAL_CHANNEL_NAMES) PHYSICAL_CHANNEL_COUNT = len(PHYSICAL_CHANNEL_NAMES)
OAK_FCC3_CLIENT_VERSION = "production_v1_2026_08_24"
PRODUCT_SCHEMA = "multispec_module_params_v3"
ASSEMBLY_SCHEMA = "multispec_module_params_assembly_v1"
class OakFcc3Client: class OakFcc3Client:
@ -46,58 +49,105 @@ class OakFcc3Client:
mx_id=None, mx_id=None,
imu_modo="rotation_vector", imu_modo="rotation_vector",
imu_freq_hz=200, imu_freq_hz=200,
evaluate_quality=True,
require_product_contract=False,
**kwargs, **kwargs,
): ):
self.width = width # width/height/bayer recebidos do caller ficam apenas como fallback legado.
self.height = height # Em module_params de produção, hardware real + MP são a autoridade.
self.bayer = bayer self.legacy_width = int(width)
self.fps = fps self.legacy_height = int(height)
self.frame_type = frame_type self.legacy_bayer = str(bayer or "BGGR").upper()
self.output_dtype = output_dtype
self.capture_mode = capture_mode self.fps = float(fps)
self.raw_policy = raw_policy self.frame_type = str(frame_type).upper()
self.output_dtype = str(output_dtype).lower()
self.capture_mode = str(capture_mode).upper()
self.raw_policy = str(raw_policy).lower()
self.module_calibration_json = module_calibration_json self.module_calibration_json = module_calibration_json
self.module_params = self._load_module_params(module_calibration_json) self.require_product_contract = bool(require_product_contract)
self.module_params = self._load_module_params(
module_calibration_json,
required=self.require_product_contract,
)
self.product_contract = self._is_product_module_params(self.module_params)
if self.require_product_contract and not self.product_contract:
raise RuntimeError(
"Contrato de produção obrigatório, mas module_params não foi "
"gerado pelo assembler oficial."
)
self.fusion_config = self.module_params.get("fusion_config", {}) or {} self.fusion_config = self.module_params.get("fusion_config", {}) or {}
self.camera_hardware = self._resolve_camera_hardware()
self.sensor_size_by_role = self._resolve_sensor_size_by_role()
rgb_size = self.sensor_size_by_role.get(
"rgb",
[self.legacy_width, self.legacy_height],
)
self.rgb_native_width = int(rgb_size[0])
self.rgb_native_height = int(rgb_size[1])
# Mantidos por compatibilidade, mas agora significam RGB de referência.
self.width = self.rgb_native_width
self.height = self.rgb_native_height
self.bayer = self._resolve_bayer_pattern()
self.default_target_size = self._resolve_default_target_size()
self.imu_modo = str(imu_modo).strip().lower() self.imu_modo = str(imu_modo).strip().lower()
self.imu_freq_hz = int(imu_freq_hz) self.imu_freq_hz = int(imu_freq_hz)
self.evaluate_quality = bool(evaluate_quality)
self.mx_id = str(mx_id) if mx_id else None self.mx_id = str(mx_id) if mx_id else None
# Validação antecipada do modo produto. O Manager também repete estes
# checks antes de abrir o hardware, de propósito.
self._validate_static_product_contract()
self.svc = OakFcc3Service( self.svc = OakFcc3Service(
timeout=10, timeout=10,
fps=fps, fps=self.fps,
width=width, width=self.width,
height=height, height=self.height,
frame_type=frame_type, frame_type=self.frame_type,
output_dtype=output_dtype, output_dtype=self.output_dtype,
capture_mode=capture_mode, capture_mode=self.capture_mode,
raw_policy=raw_policy, raw_policy=self.raw_policy,
sync_mode=sync_mode, sync_mode=sync_mode,
sync_tolerance_ms=sync_tolerance_ms, sync_tolerance_ms=sync_tolerance_ms,
mx_id=self.mx_id, mx_id=self.mx_id,
module_calibration_json=module_calibration_json, module_calibration_json=module_calibration_json,
module_params=self.module_params,
require_product_contract=self.require_product_contract,
imu_modo=self.imu_modo, imu_modo=self.imu_modo,
imu_freq_hz=self.imu_freq_hz, imu_freq_hz=self.imu_freq_hz,
**kwargs, **kwargs,
) )
self.applied_camera_controls = {} self.applied_camera_controls = {}
self.radiometric_controller = None self.radiometric_controller = None
self.radiometric_controller_enabled = False
self.core = RawProcessorCore( self.core = RawProcessorCore(
sensor_width=width, sensor_width=self.rgb_native_width,
sensor_height=height, sensor_height=self.rgb_native_height,
bayer_pattern=bayer, bayer_pattern=self.bayer,
calibration_json_path=module_calibration_json, calibration_json_path=module_calibration_json,
) )
# Preview é apenas visual, porém também precisa usar o Bayer/raster
# reais do RGB para não mentir sobre a AR0234.
self.preview = RawProcessorPreview( self.preview = RawProcessorPreview(
sensor_width=width, sensor_width=self.rgb_native_width,
sensor_height=height, sensor_height=self.rgb_native_height,
bayer_pattern=bayer, bayer_pattern=self.bayer,
) )
self._preview_cache = {
(self.rgb_native_width, self.rgb_native_height, self.bayer): self.preview,
}
def __enter__(self): def __enter__(self):
self.start() self.start()
@ -106,19 +156,247 @@ class OakFcc3Client:
def __exit__(self, exc_type, exc, tb): def __exit__(self, exc_type, exc, tb):
self.stop() self.stop()
def _load_module_params(self, path): def _load_module_params(self, path, required=False):
if not path or not os.path.isfile(path): if not path:
if required:
raise FileNotFoundError(
"module_params obrigatório no contrato de produção."
)
return {}
if not os.path.isfile(path):
if required:
raise FileNotFoundError(
f"module_params não encontrado: {path}"
)
return {} return {}
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
return json.load(f) data = json.load(f)
if not isinstance(data, dict):
raise RuntimeError(
f"module_params root deve ser dict/object: {path}"
)
return data
@staticmethod
def _is_product_module_params(module_params):
mp = module_params or {}
assembly = mp.get("assembly_metadata", {}) or {}
return bool(
mp.get("schema") == PRODUCT_SCHEMA
and assembly.get("schema") == ASSEMBLY_SCHEMA
and isinstance(mp.get("camera_hardware"), dict)
and isinstance(mp.get("sensor_size_by_role"), dict)
and isinstance(mp.get("calibration_provenance"), dict)
)
@staticmethod
def _normalize_size(value, label):
if not (isinstance(value, (list, tuple)) and len(value) == 2):
raise RuntimeError(f"{label} deve ser [W,H], recebido={value!r}")
w = int(value[0])
h = int(value[1])
if w <= 0 or h <= 0:
raise RuntimeError(f"{label} inválido: {value!r}")
return [w, h]
def _resolve_camera_hardware(self):
raw = self.module_params.get("camera_hardware", {}) or {}
if not self.product_contract:
return {}
out = {}
expected = {
"rgb": ("CAM_A", ("OV9782", "AR0234")),
"re": ("CAM_B", ("OV9282",)),
"nir": ("CAM_C", ("OV9282",)),
}
for role, (expected_socket, allowed_sensors) in expected.items():
item = raw.get(role)
if not isinstance(item, dict):
raise RuntimeError(f"camera_hardware sem role={role}")
socket = str(item.get("socket") or item.get("socket_name") or "").upper()
sensor = str(item.get("sensor") or item.get("sensor_name") or "").upper()
size = self._normalize_size(item.get("size"), f"camera_hardware.{role}.size")
if socket != expected_socket:
raise RuntimeError(
f"camera_hardware.{role}.socket={socket!r}, esperado={expected_socket!r}"
)
if sensor not in allowed_sensors:
raise RuntimeError(
f"camera_hardware.{role}.sensor={sensor!r}, permitidos={allowed_sensors}"
)
out[role] = {
"role": role,
"socket": socket,
"sensor": sensor,
"size": size,
}
return out
def _resolve_sensor_size_by_role(self):
if not self.product_contract:
return {
"rgb": [self.legacy_width, self.legacy_height],
"re": [self.legacy_width, self.legacy_height],
"nir": [self.legacy_width, self.legacy_height],
}
raw = self.module_params.get("sensor_size_by_role", {}) or {}
out = {}
for role in ("rgb", "re", "nir"):
size = self._normalize_size(
raw.get(role),
f"sensor_size_by_role.{role}",
)
expected = self.camera_hardware[role]["size"]
if size != expected:
raise RuntimeError(
f"sensor_size_by_role.{role}={size} != camera_hardware={expected}"
)
out[role] = size
return out
def _resolve_bayer_pattern(self):
value = self.module_params.get("bayer_pattern") if self.product_contract else None
bayer = str(value or self.legacy_bayer).upper()
if bayer not in ("RGGB", "BGGR", "GRBG", "GBRG"):
raise RuntimeError(f"bayer_pattern inválido: {bayer!r}")
return bayer
def _resolve_default_target_size(self):
value = (self.fusion_config or {}).get("target_size")
if value is None:
return None
return self._normalize_size(value, "fusion_config.target_size")
def _resolve_target_size(self, target_size):
value = target_size if target_size is not None else self.default_target_size
if value is None:
return None
size = self._normalize_size(value, "target_size")
if self.product_contract and self.default_target_size is not None:
if size != self.default_target_size:
raise RuntimeError(
"target_size solicitado diverge do module_params homologado: "
f"requested={size}, calibrated_runtime={self.default_target_size}"
)
return size
def _validate_static_product_contract(self):
if not self.product_contract:
return
if self.frame_type != "RAW_BRUTO":
raise RuntimeError(
"OakFcc3Client de produção aceita somente frame_type='RAW_BRUTO'."
)
if self.raw_policy != "require_triple":
raise RuntimeError(
"OakFcc3Client de produção exige raw_policy='require_triple'."
)
if self.capture_mode not in ("TRIPLE", "AUTO"):
raise RuntimeError(
"OakFcc3Client de produção exige capture_mode TRIPLE/AUTO."
)
expected_mx = (
(self.module_params.get("calibration_provenance", {}) or {})
.get("device_mx_id")
)
if expected_mx and self.mx_id and str(expected_mx) != self.mx_id:
raise RuntimeError(
"MX ID solicitado diverge da calibração homologada: "
f"requested={self.mx_id}, calibrated={expected_mx}"
)
def _sync_contract_from_manager_status(self, status):
if not isinstance(status, dict):
return
if self.product_contract and not bool(status.get("product_contract", False)):
raise RuntimeError(
"Client carregou module_params produto, mas Manager não reconheceu o contrato."
)
sizes = status.get("sensor_size_by_role")
if isinstance(sizes, dict):
normalized = {
role: self._normalize_size(sizes.get(role), f"manager.sensor_size_by_role.{role}")
for role in ("rgb", "re", "nir")
}
if self.product_contract and normalized != self.sensor_size_by_role:
raise RuntimeError(
"Manager e Client discordam sobre sensor_size_by_role: "
f"manager={normalized}, client={self.sensor_size_by_role}"
)
self.sensor_size_by_role = normalized
bayer = status.get("bayer_pattern")
if bayer:
bayer = str(bayer).upper()
if self.product_contract and bayer != self.bayer:
raise RuntimeError(
f"Manager Bayer={bayer} != Client/module_params={self.bayer}"
)
def get_contract(self):
return {
"client_version": OAK_FCC3_CLIENT_VERSION,
"product_contract": bool(self.product_contract),
"require_product_contract": bool(self.require_product_contract),
"sensor_size_by_role": {
role: list(size)
for role, size in self.sensor_size_by_role.items()
},
"camera_hardware": json.loads(json.dumps(self.camera_hardware)),
"bayer_pattern": self.bayer,
"default_target_size": (
None if self.default_target_size is None
else list(self.default_target_size)
),
"evaluate_quality": bool(self.evaluate_quality),
"radiometric_controller_enabled": bool(self.radiometric_controller_enabled),
}
def apply_module_camera_settings(self): def apply_module_camera_settings(self):
camera_settings = self.module_params.get("camera_settings", {}) or {} camera_settings = self.module_params.get("camera_settings", {}) or {}
applied = {} if self.product_contract:
missing = [
role
for role in ("rgb", "re", "nir")
if not isinstance(camera_settings.get(role), dict)
]
if missing:
raise RuntimeError(
f"module_params produto sem camera_settings para: {missing}"
)
for role, settings in camera_settings.items(): applied = {}
roles = ("rgb", "re", "nir") if self.product_contract else tuple(camera_settings.keys())
for role in roles:
settings = camera_settings.get(role)
if not isinstance(settings, dict): if not isinstance(settings, dict):
continue continue
@ -137,17 +415,43 @@ class OakFcc3Client:
} }
self.applied_camera_controls = applied self.applied_camera_controls = applied
if self.product_contract:
failed = {
role: value
for role, value in applied.items()
if not bool((value or {}).get("ok", False))
}
if failed:
raise RuntimeError(
f"Falha reaplicando camera_settings homologado: {failed}"
)
return applied return applied
def enable_radiometric_controller(self): def enable_radiometric_controller(self):
cfg = self.module_params.get("radiometric_config", {}) or {}
enabled = bool(cfg.get("enabled", False))
# No produto atual este controller de exposição em campo é OFF.
# Não instanciamos um segundo piloto para ficar parado dentro do loop.
if not enabled:
self.radiometric_controller = None
self.radiometric_controller_enabled = False
return None
self.radiometric_controller = RadiometricController( self.radiometric_controller = RadiometricController(
client=self, client=self,
config_json_path=self.module_calibration_json, config_json_path=self.module_calibration_json,
) )
self.radiometric_controller.sync_from_camera_controls(self.applied_camera_controls) self.radiometric_controller.sync_from_camera_controls(
self.applied_camera_controls
)
self.radiometric_controller.sync_from_actual_camera_controls() self.radiometric_controller.sync_from_actual_camera_controls()
self.radiometric_controller_enabled = bool(
getattr(self.radiometric_controller, "enabled", True)
)
return self.radiometric_controller return self.radiometric_controller
def update_radiometry(self, decoded, meta=None): def update_radiometry(self, decoded, meta=None):
@ -187,17 +491,45 @@ class OakFcc3Client:
) )
try: try:
self.mx_id = self.svc.manager.mx_id status = self.svc.get_status()
self.mx_id = str(status.get("mx_id") or self.mx_id or "") or None
self._sync_contract_from_manager_status(status)
except Exception: except Exception:
pass # Se a validação de contrato falhar, fecha hardware antes de propagar.
try:
self.svc.disconnect()
except Exception:
pass
raise
# O Manager de produção já aplicou estes controles via initialControl.
# Reaplicamos após start como confirmação operacional e para manter
# compatibilidade com Managers legados durante a migração.
applied = self.apply_module_camera_settings() applied = self.apply_module_camera_settings()
self.enable_radiometric_controller()
if print_debug: if print_debug:
print("[OAK CLIENT] START:", resp) print("[OAK CLIENT] START:", resp)
print("[OAK CLIENT] CONTRACT:", self.get_contract())
print("[OAK CLIENT] APPLIED CAMERA SETTINGS:", applied) print("[OAK CLIENT] APPLIED CAMERA SETTINGS:", applied)
self.enable_radiometric_controller() if isinstance(resp, dict):
resp = dict(resp)
resp["client_version"] = OAK_FCC3_CLIENT_VERSION
resp["product_contract"] = bool(self.product_contract)
resp["sensor_size_by_role"] = {
role: list(size)
for role, size in self.sensor_size_by_role.items()
}
resp["bayer_pattern"] = self.bayer
resp["default_target_size"] = (
None if self.default_target_size is None
else list(self.default_target_size)
)
resp["radiometric_controller_enabled"] = bool(
self.radiometric_controller_enabled
)
return resp return resp
@ -209,7 +541,29 @@ class OakFcc3Client:
return self.svc.get_device_metrics() return self.svc.get_device_metrics()
def get_status(self): def get_status(self):
return self.svc.get_status() status = self.svc.get_status()
if not isinstance(status, dict):
status = {}
else:
status = dict(status)
status.update({
"client_version": OAK_FCC3_CLIENT_VERSION,
"client_product_contract": bool(self.product_contract),
"client_require_product_contract": bool(self.require_product_contract),
"client_sensor_size_by_role": {
role: list(size)
for role, size in self.sensor_size_by_role.items()
},
"client_bayer_pattern": self.bayer,
"client_default_target_size": (
None if self.default_target_size is None
else list(self.default_target_size)
),
"evaluate_quality": bool(self.evaluate_quality),
"radiometric_controller_enabled": bool(self.radiometric_controller_enabled),
})
return status
def get_next_raw_frame(self, timeout=1.0): def get_next_raw_frame(self, timeout=1.0):
return self.svc.capture_frame(timeout=timeout) return self.svc.capture_frame(timeout=timeout)
@ -224,6 +578,12 @@ class OakFcc3Client:
frame_type = str(raw_meta.get("frame_type", self.frame_type)).upper() frame_type = str(raw_meta.get("frame_type", self.frame_type)).upper()
meta = dict(raw_meta) meta = dict(raw_meta)
if self.product_contract and frame_type != "RAW_BRUTO":
raise RuntimeError(
"OakFcc3Client produto recebeu frame_type não canônico: "
f"{frame_type!r}. Esperado='RAW_BRUTO'."
)
if frame_type == "RAW_BRUTO": if frame_type == "RAW_BRUTO":
decoded = self.decode_stream_cameras(raw_frame, raw_meta) decoded = self.decode_stream_cameras(raw_frame, raw_meta)
@ -320,6 +680,25 @@ class OakFcc3Client:
def build_infer_tensor(self, frame, meta, channels_expected, target_size=None): def build_infer_tensor(self, frame, meta, channels_expected, target_size=None):
channels_expected = self._validate_physical_channel_count(channels_expected) channels_expected = self._validate_physical_channel_count(channels_expected)
target_size = self._resolve_target_size(target_size)
frame_type = str(
(meta or {}).get("frame_type", self.frame_type)
if isinstance(meta, dict)
else self.frame_type
).upper()
# No runtime quente evitamos o quality audit pesado. Decodificamos e
# usamos exatamente o mesmo caminho do CameraMultispectral.
if not self.evaluate_quality and frame_type == "RAW_BRUTO":
decoded = self.core.decode_stream_cameras(frame, meta)
return self.build_infer_tensor_from_decoded(
decoded=decoded,
meta=meta,
channels_expected=channels_expected,
target_size=target_size,
)
return self.core.build_infer_tensor_from_stream( return self.core.build_infer_tensor_from_stream(
frame, frame,
meta, meta,
@ -327,19 +706,57 @@ class OakFcc3Client:
target_size=target_size, target_size=target_size,
) )
def build_infer_tensor_from_decoded(self, decoded, meta, channels_expected, target_size=None): def build_infer_tensor_from_decoded(
self,
decoded,
meta,
channels_expected,
target_size=None,
evaluate_quality=None,
):
channels_expected = self._validate_physical_channel_count(channels_expected) channels_expected = self._validate_physical_channel_count(channels_expected)
tensor = self.core.fuse_multispec_cameras(decoded, meta, channels_expected) target_size = self._resolve_target_size(target_size)
tensor = self.core.resize_tensor_chw(tensor, target_size=target_size)
if evaluate_quality is None:
evaluate_quality = self.evaluate_quality
evaluate_quality = bool(evaluate_quality)
# Core novo faz a geometria source->target em uma única etapa.
# NÃO redimensionar novamente depois da fusão.
tensor = self.core.fuse_multispec_cameras(
decoded,
meta,
channels_expected,
target_size=target_size,
)
patch_cfg = getattr(
self.core,
"patch_normalization_config",
{},
) or {}
# Mantém paridade com build_infer_tensor_from_stream: se a calibração
# habilitar patch normalization, ela também vale no caminho decoded.
patch_cfg = getattr(self.core, "patch_normalization_config", {}) or {}
if bool(patch_cfg.get("enabled", False)): if bool(patch_cfg.get("enabled", False)):
if self.product_contract:
raise RuntimeError(
"patch_normalization não é permitido no contrato produto."
)
tensor = self.core.apply_patch_normalization_to_tensor(tensor) tensor = self.core.apply_patch_normalization_to_tensor(tensor)
self.core.last_frame_quality_result = self.core.evaluate_frame_quality(tensor) if evaluate_quality:
return tensor self.core.last_frame_quality_result = self.core.evaluate_frame_quality(tensor)
else:
# Evita deixar resultado antigo no objeto e evita percentis no hot path.
self.core.last_frame_quality_result = {
"status": "skipped",
"usable_for_training": None,
"reason": "disabled_by_oak_fcc3_client",
"client_version": OAK_FCC3_CLIENT_VERSION,
}
return np.ascontiguousarray(
tensor.astype(np.float32, copy=False)
)
def decode_stream_cameras(self, frame, meta): def decode_stream_cameras(self, frame, meta):
if str(meta.get("frame_type", self.frame_type)).upper() == "PREVIEW": if str(meta.get("frame_type", self.frame_type)).upper() == "PREVIEW":
@ -382,12 +799,19 @@ class OakFcc3Client:
tensor = np.transpose(rgb01.astype(np.float32), (2, 0, 1)) tensor = np.transpose(rgb01.astype(np.float32), (2, 0, 1))
return np.ascontiguousarray(tensor.astype(np.float32, copy=False)) return np.ascontiguousarray(tensor.astype(np.float32, copy=False))
def build_multispec_tensor(self, decoded, meta=None, target_size=None): def build_multispec_tensor(
self,
decoded,
meta=None,
target_size=None,
evaluate_quality=None,
):
tensor = self.build_infer_tensor_from_decoded( tensor = self.build_infer_tensor_from_decoded(
decoded=decoded, decoded=decoded,
meta=meta, meta=meta,
channels_expected=5, channels_expected=5,
target_size=target_size, target_size=target_size,
evaluate_quality=evaluate_quality,
) )
return np.ascontiguousarray(tensor.astype(np.float32, copy=False)) return np.ascontiguousarray(tensor.astype(np.float32, copy=False))
@ -448,10 +872,14 @@ class OakFcc3Client:
arr = arr[:, :, 0] arr = arr[:, :, 0]
if bit_depth == 10 and arr.ndim == 2: if bit_depth == 10 and arr.ndim == 2:
role_size = self.sensor_size_by_role.get(
str(role).lower(),
[self.rgb_native_width, self.rgb_native_height],
)
raw16 = self.core.unpack_raw10_packed( raw16 = self.core.unpack_raw10_packed(
arr, arr,
sensor_width=int(info.get("width", self.width)), sensor_width=int(info.get("width", role_size[0])),
sensor_height=int(info.get("height", self.height)), sensor_height=int(info.get("height", role_size[1])),
) )
if role == "rgb": if role == "rgb":
@ -513,7 +941,7 @@ class OakFcc3Client:
or cam_meta.get("bayer") or cam_meta.get("bayer")
or stream_meta.get("bayer_pattern") or stream_meta.get("bayer_pattern")
or bayer_pattern or bayer_pattern
or "RGGB" or self.bayer
) )
bayer = str(bayer).upper() bayer = str(bayer).upper()
@ -545,19 +973,17 @@ class OakFcc3Client:
real_w = int(cam_meta.get("width", sensor_width)) real_w = int(cam_meta.get("width", sensor_width))
real_h = int(cam_meta.get("height", sensor_height)) real_h = int(cam_meta.get("height", sensor_height))
core = RawProcessorCore( cache_key = (real_w, real_h, bayer)
sensor_width=real_w, preview = self._preview_cache.get(cache_key)
sensor_height=real_h, if preview is None:
bayer_pattern=bayer, preview = RawProcessorPreview(
) sensor_width=real_w,
sensor_height=real_h,
bayer_pattern=bayer,
)
self._preview_cache[cache_key] = preview
preview = RawProcessorPreview( raw16 = self.core.unpack_raw10_packed(
sensor_width=real_w,
sensor_height=real_h,
bayer_pattern=bayer,
)
raw16 = core.unpack_raw10_packed(
packed, packed,
sensor_width=real_w, sensor_width=real_w,
sensor_height=real_h, sensor_height=real_h,
@ -624,6 +1050,11 @@ class OakFcc3Client:
def decode_oak_aligned_multispec(self, frame, meta): def decode_oak_aligned_multispec(self, frame, meta):
if self.product_contract:
raise RuntimeError(
"MULTISPEC alinhado pela OAK é legado e não faz parte do contrato produto."
)
""" """
Decodifica frames já alinhados pela OAK. Decodifica frames já alinhados pela OAK.
@ -672,6 +1103,11 @@ class OakFcc3Client:
return decoded return decoded
def build_multispec_tensor_from_oak_aligned(self, decoded, meta=None): def build_multispec_tensor_from_oak_aligned(self, decoded, meta=None):
if self.product_contract:
raise RuntimeError(
"Tensor MULTISPEC pré-alinhado pela OAK é legado no contrato produto."
)
""" """
Monta CHW [R,G,B,RE,NIR] sem reaplicar homografia. Monta CHW [R,G,B,RE,NIR] sem reaplicar homografia.
""" """

View File

@ -1,95 +1,634 @@
# camera_worker/oak_fcc3_core/raw_processor_preview.py
# -*- coding: utf-8 -*-
"""
RawProcessorPreview - Production
================================
Conversor EXCLUSIVAMENTE visual para RAW Bayer da câmera RGB.
Este módulo:
- NÃO participa do tensor científico;
- NÃO altera RAW salvo;
- NÃO aplica Flat-Field;
- NÃO aplica Radiometric Normalization;
- NÃO aplica Homography;
- NÃO deve ser usado para treino ou inferência.
Contrato oficial:
CAM_A = RGB
OV9782 -> 1280x800
AR0234 -> 1920x1200
A resolução e o Bayer devem vir do module_params/Client já validados.
Pipeline de preview:
RAW Bayer uint16
-> robust display levels
-> uint8
-> demosaic BGR
-> gray-world opcional
-> contraste opcional
-> JPEG/preview
Importante:
O resultado desta classe é BGR, compatível com OpenCV.
"""
from __future__ import annotations
from typing import Optional
import cv2 import cv2
import numpy as np import numpy as np
from typing import Optional
RAW_PROCESSOR_PREVIEW_VERSION = "production_v1_2026_08_24"
SUPPORTED_BAYER_PATTERNS = (
"RGGB",
"BGGR",
"GRBG",
"GBRG",
)
SUPPORTED_BIT_DEPTHS = (
8,
10,
12,
16,
)
class RawProcessorPreview: class RawProcessorPreview:
def __init__(self, sensor_width: int, sensor_height: int, bayer_pattern: str = "GBRG"): """
self.sensor_width = sensor_width Preview RAW Bayer para debug/stream/salvamento visual.
self.sensor_height = sensor_height
self.bayer_pattern = bayer_pattern.upper() Parâmetros
----------
sensor_width:
Largura NATIVA do RGB de referência.
sensor_height:
Altura NATIVA do RGB de referência.
bayer_pattern:
Bayer real da CAM_A, vindo do contrato homologado.
sensor_name:
Opcional, somente rastreabilidade.
strict_shape:
Se True, RAW recebido deve possuir exatamente HxW nativo.
Recomendado e default no produto.
"""
def __init__(
self,
sensor_width: int,
sensor_height: int,
bayer_pattern: str = "GBRG",
sensor_name: Optional[str] = None,
strict_shape: bool = True,
):
self.sensor_width = int(sensor_width)
self.sensor_height = int(sensor_height)
if self.sensor_width <= 0 or self.sensor_height <= 0:
raise ValueError(
"Resolução inválida para preview: "
f"{self.sensor_width}x{self.sensor_height}"
)
self.bayer_pattern = str(
bayer_pattern
).strip().upper()
if self.bayer_pattern not in SUPPORTED_BAYER_PATTERNS:
raise ValueError(
f"Padrão Bayer não suportado: {self.bayer_pattern}. "
f"Suportados={SUPPORTED_BAYER_PATTERNS}"
)
self.sensor_name = (
str(sensor_name).strip().upper()
if sensor_name
else None
)
self.strict_shape = bool(
strict_shape
)
# ============================================================
# Contrato
# ============================================================
def get_contract(self) -> dict:
return {
"preview_version": RAW_PROCESSOR_PREVIEW_VERSION,
"sensor_name": self.sensor_name,
"sensor_width": int(
self.sensor_width
),
"sensor_height": int(
self.sensor_height
),
"sensor_size": [
int(
self.sensor_width
),
int(
self.sensor_height
),
],
"bayer_pattern": (
self.bayer_pattern
),
"strict_shape": bool(
self.strict_shape
),
"output_color_order": "BGR",
"scientific_effect": "none",
}
# ============================================================
# Validação
# ============================================================
def _validate_raw(
self,
raw16: np.ndarray,
bit_depth: int,
) -> np.ndarray:
if raw16 is None:
raise ValueError(
"RAW de preview é None."
)
raw = np.asarray(
raw16
)
if raw.ndim != 2:
raise ValueError(
"RAW Bayer deve ser 2D HxW; "
f"shape={raw.shape}"
)
if self.strict_shape:
expected = (
self.sensor_height,
self.sensor_width,
)
if raw.shape != expected:
raise ValueError(
"RAW Bayer com shape incompatível: "
f"recebido={raw.shape}, esperado={expected}"
)
bit_depth = int(
bit_depth
)
if bit_depth not in SUPPORTED_BIT_DEPTHS:
raise ValueError(
f"bit_depth não suportado: {bit_depth}. "
f"Suportados={SUPPORTED_BIT_DEPTHS}"
)
if not np.issubdtype(
raw.dtype,
np.integer,
):
raise ValueError(
"RAW Bayer para preview deve ser inteiro; "
f"dtype={raw.dtype}"
)
return raw
@staticmethod
def _validate_levels(
black_level,
white_level,
max_val,
):
black = (
None
if black_level is None
else float(
black_level
)
)
white = (
None
if white_level is None
else float(
white_level
)
)
if (
black is not None
and not np.isfinite(
black
)
):
raise ValueError(
f"black_level inválido: {black_level}"
)
if (
white is not None
and not np.isfinite(
white
)
):
raise ValueError(
f"white_level inválido: {white_level}"
)
if black is not None:
black = float(
np.clip(
black,
0.0,
max_val,
)
)
if white is not None:
white = float(
np.clip(
white,
0.0,
max_val,
)
)
return black, white
# ============================================================
# RAW -> display uint8
# ============================================================
def raw16_to_vis8( def raw16_to_vis8(
self, raw16: np.ndarray, self,
black_level: Optional[int] = None, raw16: np.ndarray,
white_level: Optional[int] = None, black_level: Optional[float] = None,
gamma: float = 2.2, white_level: Optional[float] = None,
bit_depth: int = 10 gamma: float = 2.2,
bit_depth: int = 10,
auto_low_percentile: float = 0.5,
auto_high_percentile: float = 99.5,
) -> np.ndarray: ) -> np.ndarray:
""" """
Conversão para visualização: Converte RAW Bayer para uint8 de DISPLAY.
- auto-level
- gamma Quando níveis não são informados, usa percentis robustos em vez de
min/max para evitar que hot pixels ou pequenos pontos saturados lavem
todo o preview.
Isso é somente estética visual.
""" """
max_val = float((1 << bit_depth) - 1) raw = self._validate_raw(
raw16,
bit_depth,
)
raw = raw16.astype(np.float32) max_val = float(
(1 << int(bit_depth))
- 1
)
if black_level is None: black, white = (
black_level = float(raw.min()) self._validate_levels(
if white_level is None: black_level,
white_level = float(raw.max()) white_level,
max_val,
)
)
if white_level <= black_level: low_p = float(
norm = raw / max_val auto_low_percentile
)
high_p = float(
auto_high_percentile
)
if not (
0.0 <= low_p
< high_p
<= 100.0
):
raise ValueError(
"Percentis automáticos inválidos: "
f"{low_p}, {high_p}"
)
raw_f = raw.astype(
np.float32,
copy=False,
)
# Amostragem determinística para previews grandes.
# Evita percentil full-frame de 2.3M pixels em todo snapshot.
total = raw_f.size
max_samples = 250_000
if total > max_samples:
step = max(
1,
total // max_samples,
)
sample = raw_f.reshape(
-1
)[::step]
else: else:
norm = (raw - black_level) / (white_level - black_level) sample = raw_f.reshape(
-1
)
norm = np.clip(norm, 0.0, 1.0) if black is None:
black = float(
np.percentile(
sample,
low_p,
)
)
if gamma is not None and gamma > 0: if white is None:
norm = np.power(norm, 1.0 / gamma) white = float(
np.percentile(
sample,
high_p,
)
)
return (norm * 255.0).clip(0, 255).astype(np.uint8) if white <= black:
# Caso frame praticamente constante.
if max_val <= 0.0:
norm = np.zeros_like(
raw_f,
dtype=np.float32,
)
else:
norm = raw_f / max_val
else:
norm = (
raw_f - black
) / (
white - black
)
norm = np.clip(
norm,
0.0,
1.0,
)
if gamma is not None:
gamma = float(
gamma
)
if not np.isfinite(
gamma
) or gamma <= 0.0:
raise ValueError(
f"gamma inválido: {gamma}"
)
norm = np.power(
norm,
1.0 / gamma,
)
return np.clip(
norm * 255.0,
0.0,
255.0,
).astype(
np.uint8
)
# ============================================================
# Bayer
# ============================================================
def _debayer_code(self): def _debayer_code(self):
"""
Retorna código OpenCV que produz BGR.
A versão antiga usava COLOR_Bayer*2RGB_EA e em seguida tratava o
resultado como BGR, podendo trocar vermelho/azul no preview.
"""
# IMPORTANTE:
# Os aliases Bayer do OpenCV são contraintuitivos em relação ao
# nome físico 2x2 que usamos no produto. O mapeamento abaixo foi
# validado com mosaicos sintéticos de cor conhecida e produz BGR:
#
# físico RGGB -> OpenCV BayerBG2BGR
# físico BGGR -> OpenCV BayerRG2BGR
# físico GRBG -> OpenCV BayerGB2BGR
# físico GBRG -> OpenCV BayerGR2BGR
#
# Não "simplifique" este mapa pela semelhança dos nomes.
mapping = { mapping = {
"RGGB": cv2.COLOR_BayerRG2RGB_EA, "RGGB": (
"BGGR": cv2.COLOR_BayerBG2RGB_EA, cv2.COLOR_BayerBG2BGR_EA
"GRBG": cv2.COLOR_BayerGR2RGB_EA, ),
"GBRG": cv2.COLOR_BayerGB2RGB_EA, "BGGR": (
} cv2.COLOR_BayerRG2BGR_EA
),
"GRBG": (
cv2.COLOR_BayerGB2BGR_EA
),
"GBRG": (
cv2.COLOR_BayerGR2BGR_EA
),
}
if self.bayer_pattern not in mapping: return mapping[
raise ValueError(f"Padrão Bayer não suportado: {self.bayer_pattern}") self.bayer_pattern
]
return mapping[self.bayer_pattern] # ============================================================
# Ajustes VISUAIS
# ============================================================
def apply_preview_white_balance(self, bgr: np.ndarray, strength: float = 1.0) -> np.ndarray: @staticmethod
def apply_preview_white_balance(
bgr: np.ndarray,
strength: float = 1.0,
) -> np.ndarray:
""" """
Gray-world simples para deixar o preview mais agradável. Gray-world simples somente para deixar o preview legível.
Não usar no raw de treino.
Não usar no RAW científico.
""" """
img = bgr.astype(np.float32) img = np.asarray(
bgr
)
mean_b = float(img[:, :, 0].mean()) if (
mean_g = float(img[:, :, 1].mean()) img.ndim != 3
mean_r = float(img[:, :, 2].mean()) or img.shape[2] != 3
):
raise ValueError(
"Preview WB exige BGR HxWx3; "
f"shape={img.shape}"
)
mean_gray = (mean_b + mean_g + mean_r) / 3.0 strength = float(
strength
)
if not np.isfinite(
strength
):
raise ValueError(
f"strength inválido: {strength}"
)
strength = float(
np.clip(
strength,
0.0,
1.0,
)
)
work = img.astype(
np.float32,
copy=True,
)
# Amostragem reduz custo em 1920x1200.
h, w = work.shape[:2]
sample_step = max(
1,
int(
np.sqrt(
(h * w)
/ 200_000.0
)
),
)
sample = work[
::sample_step,
::sample_step,
]
mean_b = float(
sample[:, :, 0].mean()
)
mean_g = float(
sample[:, :, 1].mean()
)
mean_r = float(
sample[:, :, 2].mean()
)
mean_gray = (
mean_b
+ mean_g
+ mean_r
) / 3.0
eps = 1e-6 eps = 1e-6
gain_b = mean_gray / max(mean_b, eps)
gain_g = mean_gray / max(mean_g, eps)
gain_r = mean_gray / max(mean_r, eps)
# strength=1 aplica total, strength=0 não aplica gain_b = mean_gray / max(
gain_b = 1.0 + (gain_b - 1.0) * strength mean_b,
gain_g = 1.0 + (gain_g - 1.0) * strength eps,
gain_r = 1.0 + (gain_r - 1.0) * strength )
img[:, :, 0] *= gain_b gain_g = mean_gray / max(
img[:, :, 1] *= gain_g mean_g,
img[:, :, 2] *= gain_r eps,
)
return np.clip(img, 0, 255).astype(np.uint8) gain_r = mean_gray / max(
mean_r,
eps,
)
def apply_preview_contrast(self, bgr: np.ndarray, alpha: float = 1.08, beta: float = 0.0) -> np.ndarray: gain_b = 1.0 + (
gain_b - 1.0
) * strength
gain_g = 1.0 + (
gain_g - 1.0
) * strength
gain_r = 1.0 + (
gain_r - 1.0
) * strength
work[:, :, 0] *= gain_b
work[:, :, 1] *= gain_g
work[:, :, 2] *= gain_r
return np.clip(
work,
0.0,
255.0,
).astype(
np.uint8
)
@staticmethod
def apply_preview_contrast(
bgr: np.ndarray,
alpha: float = 1.08,
beta: float = 0.0,
) -> np.ndarray:
""" """
Ajuste leve de contraste/brilho para preview. Contraste/brilho de DISPLAY.
""" """
out = cv2.convertScaleAbs(bgr, alpha=alpha, beta=beta) alpha = float(
return out alpha
)
beta = float(
beta
)
if (
not np.isfinite(
alpha
)
or alpha <= 0.0
):
raise ValueError(
f"alpha inválido: {alpha}"
)
if not np.isfinite(
beta
):
raise ValueError(
f"beta inválido: {beta}"
)
return cv2.convertScaleAbs(
bgr,
alpha=alpha,
beta=beta,
)
# ============================================================
# Preview completo
# ============================================================
def raw16_to_preview_bgr( def raw16_to_preview_bgr(
self, self,
@ -99,28 +638,89 @@ class RawProcessorPreview:
apply_wb: bool = True, apply_wb: bool = True,
apply_contrast: bool = True, apply_contrast: bool = True,
bit_depth: int = 10, bit_depth: int = 10,
black_level: Optional[float] = None,
white_level: Optional[float] = None,
) -> np.ndarray: ) -> np.ndarray:
""" """
Pipeline de preview bonito: Pipeline visual:
1. auto-level + gamma no mosaico 1. robust levels + gamma no mosaico
2. demosaic 2. demosaic BGR
3. white balance simples 3. gray-world opcional
4. leve contraste final 4. contraste opcional
""" """
vis8 = self.raw16_to_vis8(raw16, gamma=gamma, bit_depth=bit_depth) vis8 = self.raw16_to_vis8(
bgr = cv2.cvtColor(vis8, self._debayer_code()) raw16,
black_level=black_level,
white_level=white_level,
gamma=gamma,
bit_depth=bit_depth,
)
bgr = cv2.cvtColor(
vis8,
self._debayer_code(),
)
if apply_wb: if apply_wb:
bgr = self.apply_preview_white_balance(bgr, strength=wb_strength) bgr = (
self.apply_preview_white_balance(
bgr,
strength=wb_strength,
)
)
if apply_contrast: if apply_contrast:
bgr = self.apply_preview_contrast(bgr, alpha=1.08, beta=0.0) bgr = (
self.apply_preview_contrast(
bgr,
alpha=1.08,
beta=0.0,
)
)
return bgr return np.ascontiguousarray(
bgr,
dtype=np.uint8,
)
def raw16_to_preview_jpg_bytes(
self,
raw16: np.ndarray,
jpeg_quality: int = 95,
**preview_kwargs,
) -> bytes:
quality = int(
jpeg_quality
)
if not (
1 <= quality <= 100
):
raise ValueError(
f"jpeg_quality inválido: {quality}"
)
bgr = (
self.raw16_to_preview_bgr(
raw16,
**preview_kwargs,
)
)
ok, enc = cv2.imencode(
".jpg",
bgr,
[
int(
cv2.IMWRITE_JPEG_QUALITY
),
quality,
],
)
def raw16_to_preview_jpg_bytes(self, raw16: np.ndarray, jpeg_quality: int = 95) -> bytes:
bgr = self.raw16_to_preview_bgr(raw16)
ok, enc = cv2.imencode(".jpg", bgr, [int(cv2.IMWRITE_JPEG_QUALITY), int(jpeg_quality)])
if not ok: if not ok:
raise RuntimeError("Falha ao codificar preview JPG") raise RuntimeError(
"Falha ao codificar preview JPG."
)
return enc.tobytes() return enc.tobytes()

View File

@ -149,7 +149,7 @@ class SegformerNavRunner:
"trt_fp16_enable": bool(self.config.get("trt_fp16_enable", True)), "trt_fp16_enable": bool(self.config.get("trt_fp16_enable", True)),
"trt_engine_cache_enable": bool(self.config.get("trt_engine_cache_enable", True)), "trt_engine_cache_enable": bool(self.config.get("trt_engine_cache_enable", True)),
"trt_engine_cache_path": str( "trt_engine_cache_path": str(
self.config.get("trt_engine_cache_path", "./trt_cache_visual_worker") self.config.get("trt_engine_cache_path", "./trt_cache_weed_worker")
), ),
} }

View File

@ -1402,13 +1402,17 @@ class ModuloAtuador(ModuloDiagnosticoBase):
calibrando = ctx["calibrando"] calibrando = ctx["calibrando"]
bomba_principal = ctx["bomba_principal"] bomba_principal = ctx["bomba_principal"]
autonomia_corredor_aplicavel = bool(
auto
and ctx["operacao_iniciada"]
and not calibrando
and not ctx["finalizando"]
)
herbicida_pode_bloquear = bool( herbicida_pode_bloquear = bool(
auto and autonomia_corredor_aplicavel
ctx["operacao_iniciada"] and and not ctx["pausa"]
not calibrando and and not ctx["emergencia"]
not ctx["finalizando"] and
not ctx["pausa"] and
not ctx["emergencia"]
) )
em_transicao = self._pulverizacao_em_transicao(ctx) em_transicao = self._pulverizacao_em_transicao(ctx)
@ -1491,7 +1495,7 @@ class ModuloAtuador(ModuloDiagnosticoBase):
}) })
score = min(score, 90) score = min(score, 90)
if not ctx["herbicida_ok_corredor"]: if (autonomia_corredor_aplicavel and not ctx["herbicida_ok_corredor"]):
motivo = self._texto_ou_padrao( motivo = self._texto_ou_padrao(
ctx.get("autonomia_motivo"), ctx.get("autonomia_motivo"),
ctx.get("motivo_herbicida_corredor"), ctx.get("motivo_herbicida_corredor"),

View File

@ -172,6 +172,7 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
motivos_gerais = [] motivos_gerais = []
condicoes_gerais = [] condicoes_gerais = []
drivers_com_travamento = []
saude_total_ativos = 0.0 saude_total_ativos = 0.0
total_drivers_em_uso = 0 total_drivers_em_uso = 0
algum_conectado = False algum_conectado = False
@ -209,6 +210,17 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
conectado = self._bool(saude_mod.get("conectado_efetivo", False)) conectado = self._bool(saude_mod.get("conectado_efetivo", False))
em_uso = self._bool(saude_mod.get("em_uso", True)) em_uso = self._bool(saude_mod.get("em_uso", True))
detalhes_valores = self._as_dict(saude_mod.get("detalhes_valores", {}))
if self._bool(detalhes_valores.get("travamento_confirmado", False)):
drivers_com_travamento.append({
"id": endereco_str,
"label": saude_mod.get("label"),
"rpm": detalhes_valores.get("rpm", 0),
"rpm_sp": detalhes_valores.get("rpm_sp", 0),
"corrente_motor": detalhes_valores.get("corrente_motor", 0),
"nivel": detalhes_valores.get("travamento_nivel"),
})
if conectado: if conectado:
algum_conectado = True algum_conectado = True
@ -253,6 +265,9 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
"drivers_ativos": ativos, "drivers_ativos": ativos,
"drivers_em_uso": total_drivers_em_uso, "drivers_em_uso": total_drivers_em_uso,
"modelo": "movimentacao_inteligente_por_resposta_comando_valores_e_telemetria", "modelo": "movimentacao_inteligente_por_resposta_comando_valores_e_telemetria",
"travamento_confirmado": len(drivers_com_travamento) > 0,
"drivers_com_travamento": drivers_com_travamento
}, },
) )
@ -924,6 +939,12 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
"temperatura_motor": temperatura_motor, "temperatura_motor": temperatura_motor,
"temperatura_driver": temperatura_driver, "temperatura_driver": temperatura_driver,
"score_valores": int(max(0, min(100, round(score)))), "score_valores": int(max(0, min(100, round(score)))),
# Novo contrato
"travamento_detectado": possivel_travamento,
"travamento_confirmado": estado_travamento["nivel"] == "confirmado",
"travamento_nivel": estado_travamento["nivel"],
"travamento_duracao_s": estado_travamento.get("duracao_s", 0.0),
} }
return { return {

View File

@ -25,6 +25,7 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
COMANDO_GRACE_MS = 2000.0 COMANDO_GRACE_MS = 2000.0
COMANDO_ALERTA_MS = 3000.0 COMANDO_ALERTA_MS = 3000.0
COMANDO_FALHA_MS = 7000.0 COMANDO_FALHA_MS = 7000.0
COMANDO_RECUPERACAO_MS = 1500.0
COMPONENTE_RECUPERACAO_GRACE_MS = 3000.0 COMPONENTE_RECUPERACAO_GRACE_MS = 3000.0
MODULO_RECUPERACAO_GRACE_MS = 10000.0 MODULO_RECUPERACAO_GRACE_MS = 10000.0
@ -82,7 +83,10 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
updates[f"sensores.{item['key']}.saude"] = s updates[f"sensores.{item['key']}.saude"] = s
for item in servos: for item in servos:
s = self._avaliar_servo(item) s = self._avaliar_servo(
item,
modulo_disponivel=sen_disponivel,
)
saude_individual.append(s) saude_individual.append(s)
updates[f"servos.{item['key']}.saude"] = s updates[f"servos.{item['key']}.saude"] = s
@ -92,7 +96,10 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
updates[f"reles.{item['key']}.saude"] = s updates[f"reles.{item['key']}.saude"] = s
for item in sinaleiros: for item in sinaleiros:
s = self._avaliar_sinaleiro(item) s = self._avaliar_sinaleiro(
item,
modulo_disponivel=sen_disponivel,
)
saude_individual.append(s) saude_individual.append(s)
updates[f"sinaleiros.{item['key']}.saude"] = s updates[f"sinaleiros.{item['key']}.saude"] = s
@ -239,12 +246,6 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
corrente_36v = self._valor_sensor(sensores, "AB36V", "Corrente") corrente_36v = self._valor_sensor(sensores, "AB36V", "Corrente")
temp_bateria = self._valor_sensor(sensores, "TBAT", "Temperatura") temp_bateria = self._valor_sensor(sensores, "TBAT", "Temperatura")
temps_motor = []
for s in sensores:
label = str(s.get("dados", {}).get("label", ""))
if label.startswith("TMV"):
temps_motor.append(self._float(self._campo_atual(s["dados"], "Temperatura", 0), 0))
return { return {
"bms_ligado": bms_ligado, "bms_ligado": bms_ligado,
"percent_bateria": percent_bateria, "percent_bateria": percent_bateria,
@ -257,7 +258,6 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
"tensao_36v": tensao_36v, "tensao_36v": tensao_36v,
"corrente_36v": corrente_36v, "corrente_36v": corrente_36v,
"temp_bateria": temp_bateria, "temp_bateria": temp_bateria,
"temperaturas_motor": temps_motor,
"debug": { "debug": {
"bms_ligado": bms_ligado, "bms_ligado": bms_ligado,
"percent_bateria": percent_bateria, "percent_bateria": percent_bateria,
@ -267,7 +267,6 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
"sensor_ab36v_em_uso": ab36v_em_uso, "sensor_ab36v_em_uso": ab36v_em_uso,
"tensao_36v": tensao_36v, "tensao_36v": tensao_36v,
"corrente_36v": corrente_36v, "corrente_36v": corrente_36v,
"temp_motor_max": max(temps_motor) if temps_motor else 0,
"temp_bateria": temp_bateria, "temp_bateria": temp_bateria,
}, },
} }
@ -281,7 +280,8 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
conectado = self._bool(dados.get("conectado", False)) conectado = self._bool(dados.get("conectado", False))
aferir = self._bool(dados.get("aferir", False)) aferir = self._bool(dados.get("aferir", False))
mandatorio = self._bool(dados.get("mandatorio", False)) mandatorio = self._bool(dados.get("mandatorio", False))
em_uso = aferir delegado_ao_mov = str(dados.get("label", "")).upper().startswith("TMV")
em_uso = aferir and not delegado_ao_mov
motivos = [] motivos = []
condicoes = [] condicoes = []
@ -298,7 +298,12 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
saude = int(max(0, min(100, round(saude)))) saude = int(max(0, min(100, round(saude))))
status = self._resolver_status_item(conectado, saude, condicoes) status = self._resolver_status_item(conectado, saude, condicoes)
debug = {"resposta": resposta_score, "valores": valor_debug, "telemetria": telemetria["parametros"]} debug = {
"resposta": resposta_score,
"valores": valor_debug,
"telemetria": telemetria["parametros"],
"avaliacao_delegada_ao_mov": delegado_ao_mov,
}
return self._item(item, status, saude, motivos, condicoes, em_uso, debug, "sensor") return self._item(item, status, saude, motivos, condicoes, em_uso, debug, "sensor")
def _avaliar_valores_sensor(self, item, ctx, motivos, condicoes): def _avaliar_valores_sensor(self, item, ctx, motivos, condicoes):
@ -403,8 +408,6 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
label = str(dados.get("label", "")) label = str(dados.get("label", ""))
if label == "AB36V" and ctx["sensor_ab36v_em_uso"]: if label == "AB36V" and ctx["sensor_ab36v_em_uso"]:
return ["Tensao", "Corrente", "Temperatura"] return ["Tensao", "Corrente", "Temperatura"]
if label.startswith("TMV"):
return ["Temperatura"]
if label == "TBAT" and not ctx["bms_ligado"]: if label == "TBAT" and not ctx["bms_ligado"]:
return ["Temperatura"] return ["Temperatura"]
if mandatorio: if mandatorio:
@ -415,73 +418,254 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
# Atuadores auxiliares # Atuadores auxiliares
# ============================================================ # ============================================================
def _avaliar_servo(self, item): def _avaliar_servo(self, item, modulo_disponivel=True):
dados = item["dados"] dados = item["dados"]
conectado = self._bool(dados.get("conectado", False))
controlar = self._bool(dados.get("controlar", False)) controlar = self._bool(dados.get("controlar", False))
if not conectado: runtime, suspenso = self._preparar_atuador_auxiliar(
return self._item(item, StatusModulo.DESCONECTADO, 0, ["desconectado"], [], controlar, {}, "servo") item=item,
grupo="servo",
nome="Servo SEN",
modulo_disponivel=modulo_disponivel,
)
if suspenso is not None:
return suspenso
motivos, condicoes = [], [] motivos, condicoes = [], []
resposta = self._score_resposta(dados, motivos, condicoes, "Servo SEN") resposta = self._score_resposta(dados, motivos, condicoes, "Servo SEN")
comando, dbg_cmd = self._avaliar_comando_servo(item, motivos, condicoes) comando, dbg_cmd = self._avaliar_comando_servo(
telem = self._avaliar_telemetria(dados, motivos, ["ServoAnguloLeitura"] if controlar else []) item,
motivos,
condicoes,
runtime,
)
telem = self._avaliar_telemetria(
dados,
motivos,
["ServoAnguloLeitura"] if controlar else [],
)
saude = resposta * 0.25 + comando * 0.60 + telem["score"] * 0.15 saude = resposta * 0.25 + comando * 0.60 + telem["score"] * 0.15
if any(self._is_falha_hardware(c) for c in condicoes): if any(self._is_falha_hardware(c) for c in condicoes):
saude = min(saude, 45) saude = min(saude, 45)
saude = int(max(0, min(100, round(saude)))) saude = int(max(0, min(100, round(saude))))
return self._item(item, self._resolver_status_item(conectado, saude, condicoes), saude, motivos, condicoes, controlar, {"resposta": resposta, "comando": dbg_cmd, "telemetria": telem["parametros"]}, "servo")
def _avaliar_comando_servo(self, item, motivos, condicoes): return self._item(
item,
self._resolver_status_item(True, saude, condicoes),
saude,
motivos,
condicoes,
controlar,
{
"resposta": resposta,
"comando": dbg_cmd,
"telemetria": telem["parametros"],
},
"servo",
)
def _avaliar_comando_servo(self, item, motivos, condicoes, runtime):
dados = item["dados"] dados = item["dados"]
label = dados.get("label") label = dados.get("label")
controlar = self._bool(dados.get("controlar", False)) controlar = self._bool(dados.get("controlar", False))
angulo_sp = self._float(dados.get("angulo_desejado", dados.get("angulo_sp", 0)), 0)
angulo = self._float(dados.get("angulo_leitura", self._campo_atual(dados, "ServoAnguloLeitura", -1)), -1)
ultimo_ms = self._float(dados.get("ultimo_comando_ms", 999999), 999999)
tem_idade = ultimo_ms < 900000
if angulo < 0: desejado_presente = (
motivos.append("Servo sem leitura de ângulo") "angulo_desejado" in dados or
condicoes.append({ "angulo_sp" in dados
"label": "Servo SEN", )
"valor": angulo, leitura_presente = (
"severidade": 70 if controlar else 35, "angulo_leitura" in dados or
"classe": "processo", self._campo_existe(dados, "ServoAnguloLeitura")
"descricao": f"Servo {label} sem leitura de ângulo válida", )
"acoes": ["Verificar retorno de ServoAnguloLeitura."],
}) angulo_sp = self._float(
return (60 if controlar else 85), {"angulo": angulo, "angulo_sp": angulo_sp, "sem_leitura": True} dados.get("angulo_desejado", dados.get("angulo_sp", 0.0)),
0.0,
)
angulo = self._float(
dados.get(
"angulo_leitura",
self._campo_atual(dados, "ServoAnguloLeitura", -1.0),
),
-1.0,
)
evidencia = self._evidencia_comando(dados)
debug = {
"controlar": controlar,
"angulo": angulo,
"angulo_sp": angulo_sp,
**evidencia,
}
if not controlar:
self._resetar_estado_comando(runtime, manter_desejado=False)
return 100, debug
if not desejado_presente or not leitura_presente:
self._resetar_estado_comando(runtime, manter_desejado=False)
debug["contrato_comando_completo"] = False
return 92, debug
token_desejado = round(angulo_sp, 3)
self._registrar_desejado(runtime, token_desejado)
erro = abs(angulo - angulo_sp) erro = abs(angulo - angulo_sp)
if not controlar or erro <= self.SERVO_TOL_GRAUS: debug["erro"] = erro
return 100, {"angulo": angulo, "angulo_sp": angulo_sp, "erro": erro, "controlar": controlar}
if tem_idade and ultimo_ms < self.COMANDO_GRACE_MS: if erro <= self.SERVO_TOL_GRAUS:
return 96, {"angulo": angulo, "angulo_sp": angulo_sp, "erro": erro, "ultimo_comando_ms": ultimo_ms} return self._confirmar_recuperacao_servo(
if erro >= self.SERVO_FALHA_GRAUS and tem_idade and ultimo_ms >= self.COMANDO_FALHA_MS: runtime,
motivos.append(f"Servo {label} não convergiu para o SP ({angulo:.1f}/{angulo_sp:.1f})") label,
condicoes.append({ angulo,
"label": "Servo SEN", angulo_sp,
"valor": angulo, erro,
"severidade": 92, motivos,
"classe": "falha_hardware", condicoes,
"descricao": f"Servo {label} não acompanhou o comando de ângulo", debug,
"acoes": ["Verificar travamento mecânico ou alimentação do servo."], )
})
return 25, {"angulo": angulo, "angulo_sp": angulo_sp, "erro": erro, "ultimo_comando_ms": ultimo_ms} runtime["coerencia_desde"] = None
if erro >= self.SERVO_ALERTA_GRAUS:
motivos.append(f"Servo {label} distante do SP ({angulo:.1f}/{angulo_sp:.1f})") if not evidencia["possui_comando"]:
condicoes.append({ runtime["divergencia_desde"] = None
"label": "Servo SEN", debug["avaliacao_suspensa"] = "aguardando primeiro comando"
"valor": angulo, return 96, debug
"severidade": min(89, max(45, int(erro * 4))),
"classe": "processo", if not evidencia["telemetria_pos_comando"]:
"descricao": f"Servo {label} distante do ângulo desejado", runtime["divergencia_desde"] = None
"acoes": ["Acompanhar se o erro persiste."], debug["avaliacao_suspensa"] = "aguardando telemetria posterior ao comando"
}) return 96, debug
return 70, {"angulo": angulo, "angulo_sp": angulo_sp, "erro": erro, "ultimo_comando_ms": ultimo_ms}
motivos.append(f"Servo {label} fora da tolerância momentaneamente ({erro:.1f}°)") if evidencia["ultimo_comando_ms"] < self.COMANDO_GRACE_MS:
return 88, {"angulo": angulo, "angulo_sp": angulo_sp, "erro": erro, "ultimo_comando_ms": ultimo_ms} runtime["divergencia_desde"] = None
debug["avaliacao_suspensa"] = "período de resposta ao comando"
return 96, debug
agora = time.monotonic()
tempo_retorno_ms = (
agora - runtime["componente_recuperado_em"]
) * 1000.0
if tempo_retorno_ms < self.COMPONENTE_RECUPERACAO_GRACE_MS:
runtime["divergencia_desde"] = None
debug["avaliacao_suspensa"] = "estabilização após reconexão"
debug["tempo_desde_retorno_componente_ms"] = tempo_retorno_ms
return 96, debug
if runtime["divergencia_desde"] is None:
runtime["divergencia_desde"] = agora
tempo_divergente_ms = (
agora - runtime["divergencia_desde"]
) * 1000.0
debug["tempo_divergente_ms"] = tempo_divergente_ms
if runtime["nivel_divergencia"] == "falha":
return self._falha_servo(
label, angulo, angulo_sp, erro,
motivos, condicoes, debug,
)
if tempo_divergente_ms < self.COMANDO_ALERTA_MS:
return 96, debug
if (
erro >= self.SERVO_FALHA_GRAUS and
tempo_divergente_ms >= self.COMANDO_FALHA_MS
):
runtime["nivel_divergencia"] = "falha"
return self._falha_servo(
label, angulo, angulo_sp, erro,
motivos, condicoes, debug,
)
runtime["nivel_divergencia"] = "alerta"
motivos.append(
f"Servo {label} distante do SP ({angulo:.1f}/{angulo_sp:.1f})"
)
condicoes.append({
"label": "Servo SEN",
"valor": angulo,
"severidade": min(89, max(55, int(erro * 4))),
"classe": "processo",
"descricao": f"Servo {label} não convergiu para o ângulo desejado",
"acoes": [
"Acompanhar a convergência do servo.",
"Verificar esforço mecânico se o erro permanecer.",
],
})
return (68 if erro >= self.SERVO_ALERTA_GRAUS else 82), debug
def _falha_servo(
self,
label,
angulo,
angulo_sp,
erro,
motivos,
condicoes,
debug,
):
motivos.append(
f"Servo {label} não convergiu para o SP "
f"({angulo:.1f}/{angulo_sp:.1f})"
)
condicoes.append({
"label": "Servo SEN",
"valor": angulo,
"severidade": 92,
"classe": "falha_hardware",
"descricao": f"Servo {label} não acompanhou o comando de ângulo",
"acoes": [
"Verificar travamento mecânico.",
"Verificar alimentação e retorno do servo.",
],
})
debug["erro"] = erro
return 25, debug
def _confirmar_recuperacao_servo(
self,
runtime,
label,
angulo,
angulo_sp,
erro,
motivos,
condicoes,
debug,
):
runtime["divergencia_desde"] = None
if runtime["nivel_divergencia"] is None:
runtime["coerencia_desde"] = None
return 100, debug
agora = time.monotonic()
if runtime["coerencia_desde"] is None:
runtime["coerencia_desde"] = agora
tempo_coerente_ms = (
agora - runtime["coerencia_desde"]
) * 1000.0
debug["recuperacao_em_confirmacao"] = True
debug["tempo_coerente_ms"] = tempo_coerente_ms
if tempo_coerente_ms >= self.COMANDO_RECUPERACAO_MS:
runtime["nivel_divergencia"] = None
runtime["coerencia_desde"] = None
return 100, debug
motivos.append(f"Servo {label} recuperou o SP; confirmando estabilidade")
condicoes.append({
"label": "Servo SEN",
"valor": angulo,
"severidade": 55,
"classe": "processo",
"descricao": f"Servo {label} voltou ao SP e está em confirmação",
"acoes": ["Aguardar confirmação da estabilidade do retorno."],
})
return 78, debug
def _avaliar_rele(self, item, modulo_disponivel=True): def _avaliar_rele(self, item, modulo_disponivel=True):
return self._avaliar_saida_binaria( return self._avaliar_saida_binaria(
@ -493,8 +677,11 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
modulo_disponivel=modulo_disponivel, modulo_disponivel=modulo_disponivel,
) )
def _avaliar_sinaleiro(self, item): def _avaliar_sinaleiro(self, item, modulo_disponivel=True):
return self._avaliar_sinaleiro_impl(item) return self._avaliar_sinaleiro_impl(
item,
modulo_disponivel=modulo_disponivel,
)
def _avaliar_saida_binaria( def _avaliar_saida_binaria(
self, self,
@ -672,8 +859,155 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
"componente_recuperado_em": 0.0, "componente_recuperado_em": 0.0,
"divergencia_desde": None, "divergencia_desde": None,
"coerencia_desde": None,
"nivel_divergencia": None,
"ultimo_desejado": None,
}) })
def _preparar_atuador_auxiliar(
self,
item,
grupo,
nome,
modulo_disponivel,
):
dados = item["dados"]
controlar = self._bool(dados.get("controlar", False))
conectado = self._bool(dados.get("conectado", False))
agora = time.monotonic()
runtime = self._runtime_saida_binaria(item)
if not modulo_disponivel:
runtime["modulo_disponivel_anterior"] = False
runtime["componente_disponivel_anterior"] = False
self._resetar_estado_comando(runtime, manter_desejado=False)
return runtime, self._item(
item,
StatusModulo.DESCONECTADO,
0,
[],
[],
False,
{
"avaliacao_suspensa": True,
"motivo_suspensao": "Módulo SEN indisponível",
},
grupo,
)
if not runtime["modulo_disponivel_anterior"]:
runtime["modulo_recuperado_em"] = agora
runtime["componente_disponivel_anterior"] = False
self._resetar_estado_comando(runtime, manter_desejado=False)
runtime["modulo_disponivel_anterior"] = True
tempo_retorno_modulo_ms = (
agora - runtime["modulo_recuperado_em"]
) * 1000.0
if not conectado:
runtime["componente_disponivel_anterior"] = False
self._resetar_estado_comando(runtime, manter_desejado=False)
if tempo_retorno_modulo_ms < self.MODULO_RECUPERACAO_GRACE_MS:
return runtime, self._item(
item,
StatusModulo.DESCONECTADO,
0,
[],
[],
False,
{
"avaliacao_suspensa": True,
"motivo_suspensao": (
"Aguardando reinicialização após retorno do SEN"
),
"tempo_desde_retorno_modulo_ms": tempo_retorno_modulo_ms,
},
grupo,
)
return runtime, self._item(
item,
StatusModulo.DESCONECTADO,
0,
["desconectado após período de recuperação"],
[{
"label": nome,
"valor": 0,
"severidade": 92,
"classe": "falha_hardware",
"descricao": (
f"{dados.get('label')} não reinicializou após o retorno do SEN"
),
"acoes": [
"Verificar configuração do componente.",
"Verificar retorno de telemetria.",
"Verificar firmware do SEN.",
],
}],
controlar,
{
"tempo_desde_retorno_modulo_ms": tempo_retorno_modulo_ms,
},
grupo,
)
if not runtime["componente_disponivel_anterior"]:
runtime["componente_recuperado_em"] = agora
self._resetar_estado_comando(runtime, manter_desejado=False)
runtime["componente_disponivel_anterior"] = True
return runtime, None
def _evidencia_comando(self, dados):
ultimo_comando_ms = self._float(
dados.get("ultimo_comando_ms", 999999),
999999,
)
ultima_resposta_ms = self._float(
dados.get("ultima_resposta_ms", 999999),
999999,
)
possui_comando = self._bool(
dados.get(
"possui_comando",
ultimo_comando_ms < 900000,
)
)
if "telemetria_pos_comando" in dados:
telemetria_pos_comando = self._bool(
dados.get("telemetria_pos_comando", False)
)
else:
telemetria_pos_comando = (
possui_comando and
ultima_resposta_ms <= ultimo_comando_ms + 100.0
)
return {
"possui_comando": possui_comando,
"telemetria_pos_comando": telemetria_pos_comando,
"ultimo_comando_ms": ultimo_comando_ms,
"ultima_resposta_ms": ultima_resposta_ms,
}
def _registrar_desejado(self, runtime, token_desejado):
if runtime.get("ultimo_desejado") == token_desejado:
return
self._resetar_estado_comando(runtime, manter_desejado=False)
runtime["ultimo_desejado"] = token_desejado
@staticmethod
def _resetar_estado_comando(runtime, manter_desejado=True):
runtime["divergencia_desde"] = None
runtime["coerencia_desde"] = None
runtime["nivel_divergencia"] = None
if not manter_desejado:
runtime["ultimo_desejado"] = None
def _avaliar_comando_binario( def _avaliar_comando_binario(
self, self,
item, item,
@ -874,57 +1208,213 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
return 30, debug return 30, debug
def _avaliar_sinaleiro_impl(self, item): def _avaliar_sinaleiro_impl(self, item, modulo_disponivel=True):
dados = item["dados"] dados = item["dados"]
conectado = self._bool(dados.get("conectado", False))
controlar = self._bool(dados.get("controlar", False)) controlar = self._bool(dados.get("controlar", False))
if not conectado: runtime, suspenso = self._preparar_atuador_auxiliar(
return self._item(item, StatusModulo.DESCONECTADO, 0, ["desconectado"], [], controlar, {}, "sinaleiro") item=item,
grupo="sinaleiro",
nome="Sinaleiro SEN",
modulo_disponivel=modulo_disponivel,
)
if suspenso is not None:
return suspenso
motivos, condicoes = [], [] motivos, condicoes = [], []
resposta = self._score_resposta(dados, motivos, condicoes, "Sinaleiro SEN") resposta = self._score_resposta(
comando, dbg_cmd = self._avaliar_comando_sinaleiro(item, motivos, condicoes) dados,
motivos,
condicoes,
"Sinaleiro SEN",
)
comando, dbg_cmd = self._avaliar_comando_sinaleiro(
item,
motivos,
condicoes,
runtime,
)
telem = self._avaliar_telemetria(dados, motivos, []) telem = self._avaliar_telemetria(dados, motivos, [])
# Sinaleiro é diagnóstico visual: não derruba operação sozinho.
# Sinaleiro é diagnóstico visual: uma divergência confirmada
# gera alerta, mas não transforma o SEN inteiro em falha.
saude = resposta * 0.45 + comando * 0.45 + telem["score"] * 0.10 saude = resposta * 0.45 + comando * 0.45 + telem["score"] * 0.10
saude = int(max(0, min(100, round(saude)))) saude = int(max(0, min(100, round(saude))))
return self._item(item, self._resolver_status_item(conectado, saude, condicoes), saude, motivos, condicoes, controlar, {"resposta": resposta, "comando": dbg_cmd, "telemetria": telem["parametros"]}, "sinaleiro")
def _avaliar_comando_sinaleiro(self, item, motivos, condicoes): return self._item(
item,
self._resolver_status_item(True, saude, condicoes),
saude,
motivos,
condicoes,
controlar,
{
"resposta": resposta,
"comando": dbg_cmd,
"telemetria": telem["parametros"],
},
"sinaleiro",
)
def _avaliar_comando_sinaleiro(
self,
item,
motivos,
condicoes,
runtime,
):
dados = item["dados"] dados = item["dados"]
label = dados.get("label") label = dados.get("label")
controlar = self._bool(dados.get("controlar", False)) controlar = self._bool(dados.get("controlar", False))
id_sp = self._safe_int(dados.get("status_led_id_sp", dados.get("led_id_sp", -1)), -1)
st_sp = self._safe_int(dados.get("status_led_sp", dados.get("led_status_sp", -1)), -1) id_sp = self._safe_int(
id_lido = self._safe_int(dados.get("status_led_id", self._campo_atual(dados, "StatusLedID", -1)), -1) dados.get("status_led_id_sp", dados.get("led_id_sp", -1)),
st_lido = self._safe_int(dados.get("status_led_leitura", self._campo_atual(dados, "StatusLedLeitura", -1)), -1) -1,
ultimo_ms = self._float(dados.get("ultimo_comando_ms", 999999), 999999) )
st_sp = self._safe_int(
dados.get("status_led_sp", dados.get("led_status_sp", -1)),
-1,
)
id_lido = self._safe_int(
dados.get(
"status_led_id",
self._campo_atual(dados, "StatusLedID", -1),
),
-1,
)
# O publicador C# envia as propriedades em todos os ciclos.
# Portanto, presença lógica é determinada pelo valor válido,
# não apenas pela existência da chave no dicionário.
id_sp_presente = id_sp >= 0
status_sp_presente = st_sp >= 0
st_lido = self._safe_int(
dados.get(
"status_led_leitura",
self._campo_atual(dados, "StatusLedLeitura", -1),
),
-1,
)
evidencia = self._evidencia_comando(dados)
debug = {
"controlar": controlar,
"status_lido": st_lido,
"status_sp": st_sp,
"id_lido": id_lido,
"id_sp": id_sp,
**evidencia,
}
if not controlar: if not controlar:
return 100, {"controlar": False, "status_lido": st_lido, "id_lido": id_lido} self._resetar_estado_comando(runtime, manter_desejado=False)
if id_sp < 0 and st_sp < 0: return 100, debug
return 94, {"contrato_comando_completo": False, "status_lido": st_lido, "id_lido": id_lido}
if not id_sp_presente and not status_sp_presente:
self._resetar_estado_comando(runtime, manter_desejado=False)
debug["contrato_comando_completo"] = False
return 94, debug
token_desejado = (
id_sp if id_sp_presente else None,
st_sp if status_sp_presente else None,
)
self._registrar_desejado(runtime, token_desejado)
# Se o SP existe, uma leitura ausente (-1) também é divergência.
coerente_id = (
not id_sp_presente or
(id_lido >= 0 and id_lido == id_sp)
)
coerente_status = (
not status_sp_presente or
(st_lido >= 0 and st_lido == st_sp)
)
coerente = coerente_id and coerente_status
debug["divergente"] = not coerente
coerente = True
if id_sp >= 0 and id_lido >= 0 and id_sp != id_lido:
coerente = False
if st_sp >= 0 and st_lido >= 0 and st_sp != st_lido:
coerente = False
if coerente: if coerente:
return 100, {"status_lido": st_lido, "status_sp": st_sp, "id_lido": id_lido, "id_sp": id_sp} runtime["divergencia_desde"] = None
if ultimo_ms < self.COMANDO_GRACE_MS:
return 96, {"status_lido": st_lido, "status_sp": st_sp, "id_lido": id_lido, "id_sp": id_sp, "ultimo_comando_ms": ultimo_ms}
motivos.append(f"Sinaleiro {label} diferente do comportamento desejado") if runtime["nivel_divergencia"] is None:
runtime["coerencia_desde"] = None
return 100, debug
agora = time.monotonic()
if runtime["coerencia_desde"] is None:
runtime["coerencia_desde"] = agora
tempo_coerente_ms = (
agora - runtime["coerencia_desde"]
) * 1000.0
debug["recuperacao_em_confirmacao"] = True
debug["tempo_coerente_ms"] = tempo_coerente_ms
if tempo_coerente_ms >= self.COMANDO_RECUPERACAO_MS:
runtime["nivel_divergencia"] = None
runtime["coerencia_desde"] = None
return 100, debug
motivos.append(
f"Sinaleiro {label} recuperou o estado; confirmando estabilidade"
)
return 88, debug
runtime["coerencia_desde"] = None
if not evidencia["possui_comando"]:
runtime["divergencia_desde"] = None
debug["avaliacao_suspensa"] = "aguardando primeiro comando"
return 96, debug
if not evidencia["telemetria_pos_comando"]:
runtime["divergencia_desde"] = None
debug["avaliacao_suspensa"] = "aguardando telemetria posterior ao comando"
return 96, debug
if evidencia["ultimo_comando_ms"] < self.COMANDO_GRACE_MS:
runtime["divergencia_desde"] = None
debug["avaliacao_suspensa"] = "período de resposta ao comando"
return 96, debug
agora = time.monotonic()
tempo_retorno_ms = (
agora - runtime["componente_recuperado_em"]
) * 1000.0
if tempo_retorno_ms < self.COMPONENTE_RECUPERACAO_GRACE_MS:
runtime["divergencia_desde"] = None
debug["avaliacao_suspensa"] = "estabilização após reconexão"
debug["tempo_desde_retorno_componente_ms"] = tempo_retorno_ms
return 96, debug
if runtime["divergencia_desde"] is None:
runtime["divergencia_desde"] = agora
tempo_divergente_ms = (
agora - runtime["divergencia_desde"]
) * 1000.0
debug["tempo_divergente_ms"] = tempo_divergente_ms
if tempo_divergente_ms < self.COMANDO_ALERTA_MS:
return 96, debug
runtime["nivel_divergencia"] = "alerta"
motivos.append(
f"Sinaleiro {label} diferente do comportamento desejado"
)
condicoes.append({ condicoes.append({
"label": "Sinaleiro SEN", "label": "Sinaleiro SEN",
"valor": st_lido, "valor": st_lido,
"severidade": 45, "severidade": 55,
"classe": "processo", "classe": "processo",
"descricao": f"Sinaleiro {label} não está refletindo o comportamento desejado", "descricao": (
"acoes": ["Conferir LED/sinaleiro físico e comando pelo SEN."], f"Sinaleiro {label} não está refletindo o comportamento desejado"
),
"acoes": [
"Conferir LED/sinaleiro físico.",
"Conferir comando e retorno publicados pelo SEN.",
],
}) })
return 82, {"status_lido": st_lido, "status_sp": st_sp, "id_lido": id_lido, "id_sp": id_sp, "ultimo_comando_ms": ultimo_ms} return 78, debug
# ============================================================ # ============================================================
# Processo geral # Processo geral
@ -1013,30 +1503,6 @@ class ModuloSensoriamento(ModuloDiagnosticoBase):
score = min(score, 60) score = min(score, 60)
energia_critica = True energia_critica = True
temp_motor_max = max(ctx["temperaturas_motor"]) if ctx["temperaturas_motor"] else 0
if temp_motor_max >= 90:
motivos.append(f"Temperatura máxima dos motores crítica ({temp_motor_max:.1f} °C)")
condicoes.append({
"label": "Temperatura motores",
"valor": temp_motor_max,
"severidade": 95,
"classe": "falha_hardware",
"descricao": f"Temperatura de motor crítica ({temp_motor_max:.1f} °C)",
"acoes": ["Reduzir carga/parar movimentação e verificar refrigeração."],
})
score = min(score, 45)
elif temp_motor_max >= 80:
motivos.append(f"Temperatura máxima dos motores elevada ({temp_motor_max:.1f} °C)")
condicoes.append({
"label": "Temperatura motores",
"valor": temp_motor_max,
"severidade": 75,
"classe": "risco_operacional",
"descricao": f"Temperatura de motor elevada ({temp_motor_max:.1f} °C)",
"acoes": ["Reduzir esforço se persistir."],
})
score = min(score, 75)
if ctx["temp_bateria"] >= 60: if ctx["temp_bateria"] >= 60:
motivos.append(f"Temperatura da bateria crítica ({ctx['temp_bateria']:.1f} °C)") motivos.append(f"Temperatura da bateria crítica ({ctx['temp_bateria']:.1f} °C)")
condicoes.append({ condicoes.append({

View File

@ -29,6 +29,9 @@ class RegrasTaticas:
# Pulverizador automático. # Pulverizador automático.
"atu_indisponivel": self._nova_persistencia(), "atu_indisponivel": self._nova_persistencia(),
"bomba_cavitada": self._nova_persistencia(), "bomba_cavitada": self._nova_persistencia(),
# MOV / possível atolamento ou bloqueio mecânico.
"mov_travamento": self._nova_persistencia(),
} }
# ============================================================ # ============================================================
@ -51,6 +54,13 @@ class RegrasTaticas:
motivos.extend(novos_motivos) motivos.extend(novos_motivos)
freio_necessario = freio_necessario or freio freio_necessario = freio_necessario or freio
novos_motivos, freio = self._avaliar_travamento_mov(
controle=controle,
operacao=operacao
)
motivos.extend(novos_motivos)
freio_necessario = freio_necessario or freio
novos_motivos, freio = self._avaliar_oak_parada_por_bloqueio( novos_motivos, freio = self._avaliar_oak_parada_por_bloqueio(
controle=controle, controle=controle,
operacao=operacao operacao=operacao
@ -126,6 +136,92 @@ class RegrasTaticas:
return motivos, freio_necessario return motivos, freio_necessario
def _avaliar_travamento_mov(self, controle: dict, operacao: dict):
movimento_automatico = self._bool(
controle.get("movimento_automatico", False)
)
controle_manual = self._bool(
controle.get("controle_manual_acionado", False)
)
status_operacao = self._enum_or(
StatusOperacao,
operacao.get(
"status",
StatusOperacao.NaoIniciado.value
),
StatusOperacao.NaoIniciado
)
pausa = self._bool(operacao.get("pausa", False))
emergencia = self._bool(operacao.get("emergencia", False))
calibrando = self._bool(operacao.get("calibrando", False))
finalizando = self._bool(operacao.get("finalizando", False))
deve_avaliar = (
status_operacao == StatusOperacao.EmAndamento
and movimento_automatico
and not controle_manual
and not pausa
and not emergencia
and not calibrando
and not finalizando
)
if not deve_avaliar:
self._resetar_persistencia("mov_travamento")
return [], False
mov, _, operante = self._modulo_operante(
T_Code.Mov,
aceitar_alerta=True
)
if not operante:
# Indisponibilidade do módulo não deve ser confundida com atolamento.
self._resetar_persistencia("mov_travamento")
return [], False
saude = self._dict(mov.get("saude", {}))
detalhes = self._dict(saude.get("detalhes", {}))
travamento_health = self._bool(
detalhes.get("travamento_confirmado", False)
)
# O health já exige aproximadamente 2 segundos e 3 amostras.
# Esta persistência curta apenas protege a regra contra troca de
# snapshot ou inconsistência momentânea no Redis.
travamento_tatico = self._avaliar_persistencia(
nome="mov_travamento",
estado_bruto=travamento_health,
tempo_acionar=0.30,
tempo_liberar=2.00
)
if not travamento_tatico:
return [], False
drivers = detalhes.get("drivers_com_travamento", []) or []
ids = [
str(driver.get("label") or driver.get("id"))
for driver in drivers
if isinstance(driver, dict)
]
complemento = (
f" | motores: {', '.join(ids)}"
if ids
else ""
)
return [
"Possível atolamento ou bloqueio mecânico detectado"
f"{complemento}. Operação pausada para intervenção do operador."
], True
def _avaliar_oak_parada_por_bloqueio(self, controle: dict, operacao: dict): def _avaliar_oak_parada_por_bloqueio(self, controle: dict, operacao: dict):
""" """
Bloqueia somente se a função de parada por bloqueio estiver ativa. Bloqueia somente se a função de parada por bloqueio estiver ativa.

View File

@ -630,6 +630,90 @@ class ContextoGlobalRedis:
"motivos": motivos_controle_runtime, "motivos": motivos_controle_runtime,
} }
# ------------------------------------------------------------
# 5.1) Solicitação de intertravamento sistêmico
# ------------------------------------------------------------
parada_seguranca = cls._dict(
controle.get("parada_seguranca", {})
)
parada_seguranca_ativa = cls._bool(
parada_seguranca.get("ativa", False)
)
codigo_parada_seguranca = str(
parada_seguranca.get("codigo", "") or ""
).strip().upper()
codigos_que_solicitam_emergencia = {
"MOV_TRAVAMENTO",
}
codigo_valido_emergencia = (
codigo_parada_seguranca
in codigos_que_solicitam_emergencia
)
solicitacao_da_regra = cls._bool(
parada_seguranca.get(
"solicitar_emergencia",
False
)
)
emergencia_sistema_solicitada = bool(
parada_seguranca_ativa
and codigo_valido_emergencia
and solicitacao_da_regra
)
motivo_emergencia_sistema = (
str(
parada_seguranca.get(
"motivo",
"Intertravamento sistêmico solicitado"
)
)
if emergencia_sistema_solicitada
else None
)
origem_emergencia_sistema = (
parada_seguranca.get("origem")
if emergencia_sistema_solicitada
else None
)
severidade_emergencia_sistema = (
cls._int(
parada_seguranca.get("severidade", 90),
90
)
if emergencia_sistema_solicitada
else 0
)
ts_evento_emergencia_sistema = (
parada_seguranca.get("ts_evento")
if emergencia_sistema_solicitada
else None
)
detalhes_regras["intertravamento_sistema"] = {
"ativo": emergencia_sistema_solicitada,
"codigo": (
codigo_parada_seguranca
if emergencia_sistema_solicitada
else None
),
"origem": origem_emergencia_sistema,
"severidade": severidade_emergencia_sistema,
"motivo": motivo_emergencia_sistema,
"ts_evento": ts_evento_emergencia_sistema,
"drivers": parada_seguranca.get("drivers", []),
}
# ------------------------------------------------------------ # ------------------------------------------------------------
# 6) Decisão final # 6) Decisão final
# ------------------------------------------------------------ # ------------------------------------------------------------
@ -667,6 +751,22 @@ class ContextoGlobalRedis:
liberado=liberou, liberado=liberou,
motivo_nao_liberado="\n\n".join(motivos) if not liberou else None, motivo_nao_liberado="\n\n".join(motivos) if not liberou else None,
motivos_nao_liberado_lista=motivos, motivos_nao_liberado_lista=motivos,
emergencia_sistema_solicitada=emergencia_sistema_solicitada,
emergencia_sistema={
"solicitada": emergencia_sistema_solicitada,
"codigo": (
codigo_parada_seguranca
if emergencia_sistema_solicitada
else None
),
"origem": origem_emergencia_sistema,
"severidade": severidade_emergencia_sistema,
"motivo": motivo_emergencia_sistema,
"ts_evento": ts_evento_emergencia_sistema,
"drivers": parada_seguranca.get("drivers", []),
},
operacao_liberacao_debug={ operacao_liberacao_debug={
"ts": time.time(), "ts": time.time(),
"liberado": liberou, "liberado": liberou,

View File

@ -1,7 +1,9 @@
import datetime import datetime
import json
import os import os
import threading import threading
import time import time
from pathlib import Path
import cv2 import cv2
import numpy as np import numpy as np
@ -24,6 +26,13 @@ from visual_worker.utils import converter_valores_numpy
from shared.perf_monitor import VisualPerfMonitor from shared.perf_monitor import VisualPerfMonitor
CAMERA_MANAGER_VERSION = "production_v1_2026_08_24"
PRODUCT_ASSEMBLY_SCHEMA = "multispec_module_params_assembly_v1"
PRODUCT_MODULE_SCHEMA = "multispec_module_params_v3"
PHYSICAL_RAW5_CHANNELS = 5
class CameraManager: class CameraManager:
""" """
CameraManager v1 do Weed Worker. CameraManager v1 do Weed Worker.
@ -61,6 +70,12 @@ class CameraManager:
self.weed_detector = None self.weed_detector = None
self.seg_config = None self.seg_config = None
# Contrato imutável da sessão produto.
# Não faz parte do reset operacional porque descreve modelo + MP + hardware.
self._pipeline_contract = {}
self._module_params_snapshot = {}
self._module_params_path = None
self.operante = False self.operante = False
self.iniciando = False self.iniciando = False
self.debug_visual = False self.debug_visual = False
@ -226,6 +241,488 @@ class CameraManager:
self._proxima_inicializacao_monotonic = time.monotonic() + atraso self._proxima_inicializacao_monotonic = time.monotonic() + atraso
return atraso return atraso
@staticmethod
def _normalizar_size_wh(value, field_name):
if not isinstance(value, (list, tuple)) or len(value) != 2:
raise RuntimeError(
f"{field_name} deve ser [W,H]; recebido={value!r}"
)
w = int(value[0])
h = int(value[1])
if w <= 0 or h <= 0:
raise RuntimeError(
f"{field_name} inválido: {value!r}"
)
return [w, h]
@staticmethod
def _path_normalizado(value):
if not value:
return None
try:
p = Path(str(value))
if p.is_file():
return str(p.resolve())
# Mesmo quando ainda não existe, normaliza sem exigir existência.
return str(p.expanduser().absolute())
except Exception:
return str(value)
@staticmethod
def _carregar_json_obrigatorio(path_value, field_name):
if not path_value:
raise RuntimeError(f"{field_name} não definido.")
p = Path(str(path_value))
if not p.is_file():
raise FileNotFoundError(
f"{field_name} não encontrado: {p}"
)
with p.open("r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise RuntimeError(
f"{field_name} deve conter JSON object/dict."
)
return p.resolve(), data
def _obter_tamanho_entrada_modelo(self):
if self.model_svc is None:
raise RuntimeError("Modelo ONNX ainda não inicializado.")
shape = list(
getattr(self.model_svc, "onnx_input_shape", None)
or []
)
if len(shape) != 4:
raise RuntimeError(
f"ONNX input deve ser BCHW 4D; shape={shape}"
)
h = shape[2]
w = shape[3]
if not isinstance(h, (int, np.integer)) or not isinstance(w, (int, np.integer)):
raise RuntimeError(
"Produto exige resolução ONNX estática em H/W; "
f"shape={shape}"
)
if int(w) <= 0 or int(h) <= 0:
raise RuntimeError(
f"Resolução ONNX inválida: {shape}"
)
return [int(w), int(h)]
def _validar_module_params_produto(self, seg_config, mx_id):
module_path, mp = self._carregar_json_obrigatorio(
seg_config.get("module_calibration_json"),
"module_calibration_json",
)
require_product = bool(
seg_config.get("require_product_contract", True)
)
if mp.get("schema") != PRODUCT_MODULE_SCHEMA:
raise RuntimeError(
f"module_params schema inválido: {mp.get('schema')!r}. "
f"Esperado={PRODUCT_MODULE_SCHEMA!r}"
)
assembly = mp.get("assembly_metadata", {}) or {}
assembly_schema = assembly.get("schema")
product_contract = assembly_schema == PRODUCT_ASSEMBLY_SCHEMA
if require_product and not product_contract:
raise RuntimeError(
"CameraManager produto exige module_params gerado pelo "
"module_params_assembler_production.py. "
f"assembly_metadata.schema={assembly_schema!r}"
)
fusion = mp.get("fusion_config", {}) or {}
mp_target = self._normalizar_size_wh(
fusion.get("target_size"),
"module_params.fusion_config.target_size",
)
sizes_raw = mp.get("sensor_size_by_role", {}) or {}
hardware = mp.get("camera_hardware", {}) or {}
sizes = {}
for role in ("rgb", "re", "nir"):
size = sizes_raw.get(role)
if size is None:
hw_role = hardware.get(role, {}) or {}
size = hw_role.get("size")
if require_product and size is None:
raise RuntimeError(
f"module_params sem sensor_size_by_role.{role}"
)
if size is not None:
sizes[role] = self._normalizar_size_wh(
size,
f"module_params.sensor_size_by_role.{role}",
)
if require_product and set(sizes) != {"rgb", "re", "nir"}:
raise RuntimeError(
f"module_params incompleto em sensor_size_by_role: {sizes}"
)
provenance = mp.get("calibration_provenance", {}) or {}
calibrated_mx = provenance.get("device_mx_id")
if calibrated_mx and str(calibrated_mx) != str(mx_id):
raise RuntimeError(
"MX ID solicitado diverge do módulo calibrado: "
f"solicitado={mx_id} calibrado={calibrated_mx}"
)
return {
"path": str(module_path),
"module_params": mp,
"product_contract": product_contract,
"require_product_contract": require_product,
"target_size": mp_target,
"sensor_size_by_role": sizes,
"camera_hardware": hardware,
"calibrated_mx_id": calibrated_mx,
}
def _validar_contrato_pipeline(self, seg_config, mx_id):
"""
Fecha a autoridade da resolução antes de abrir a OAK:
ONNX input [W,H]
==
module_params.fusion_config.target_size
==
seg_config.ia_resolution
camera_width/camera_height deixam de participar da decisão física.
"""
model_target = self._obter_tamanho_entrada_modelo()
mp_info = self._validar_module_params_produto(
seg_config,
mx_id,
)
mp_target = list(mp_info["target_size"])
if model_target != mp_target:
raise RuntimeError(
"Contrato de resolução incompatível entre ONNX e module_params: "
f"ONNX={model_target} module_params={mp_target}"
)
cfg_target_raw = seg_config.get("ia_resolution")
if cfg_target_raw is not None:
cfg_target = self._normalizar_size_wh(
cfg_target_raw,
"seg_config.ia_resolution",
)
if cfg_target != model_target:
raise RuntimeError(
"Contrato de resolução incompatível entre config, ONNX e MP: "
f"config={cfg_target} ONNX={model_target} MP={mp_target}"
)
else:
cfg_target = list(model_target)
# Canonicaliza o config em memória. A partir daqui quem consulta
# ia_resolution recebe o contrato já validado, não um default solto.
seg_config["ia_resolution"] = list(model_target)
seg_config["module_calibration_json"] = mp_info["path"]
model_channels = int(
getattr(self.model_svc, "channels", 0)
or 0
)
input_names = list(
getattr(
self.model_svc,
"input_channel_names",
[],
)
or []
)
if model_channels <= 0 or len(input_names) != model_channels:
raise RuntimeError(
"Contrato de canais do modelo inválido: "
f"C={model_channels} names={input_names}"
)
physical_allowed = {
"R", "G", "B", "RE", "NIR", "NDVI", "NDRE",
}
invalid = [
str(x)
for x in input_names
if str(x).upper() not in physical_allowed
]
if invalid:
raise RuntimeError(
f"Canais ONNX não suportados pelo pipeline: {invalid}"
)
rgb_native = (
list(mp_info["sensor_size_by_role"].get("rgb"))
if mp_info["sensor_size_by_role"].get("rgb") is not None
else None
)
if mp_info["require_product_contract"] and rgb_native is None:
raise RuntimeError(
"Produto exige resolução RGB nativa no module_params."
)
model_path = getattr(
self.model_svc,
"onnx_path",
None,
)
contract = {
"validated": True,
"camera_manager_version": CAMERA_MANAGER_VERSION,
"product_contract": bool(mp_info["product_contract"]),
"require_product_contract": bool(
mp_info["require_product_contract"]
),
"tensor_size": list(model_target),
"onnx_input_shape": list(
getattr(
self.model_svc,
"onnx_input_shape",
[],
)
or []
),
"onnx_input_channels": input_names,
"onnx_model_path": (
str(model_path)
if model_path is not None
else None
),
"module_params_path": mp_info["path"],
"sensor_size_by_role": dict(
mp_info["sensor_size_by_role"]
),
"camera_hardware": dict(
mp_info["camera_hardware"]
),
"rgb_native_size": rgb_native,
"calibrated_mx_id": mp_info[
"calibrated_mx_id"
],
}
self._pipeline_contract = contract
self._module_params_snapshot = mp_info[
"module_params"
]
self._module_params_path = mp_info[
"path"
]
self.mostrar_log(
"[weed][CONTRACT] "
f"tensor={contract['tensor_size']} "
f"channels={contract['onnx_input_channels']} "
f"product={contract['product_contract']} "
f"mp={contract['module_params_path']}"
)
return contract
def _validar_tensor_camera(self, tensor5, contexto="runtime"):
if not isinstance(tensor5, np.ndarray):
raise RuntimeError(
f"[{contexto}] tensor da câmera deve ser np.ndarray; "
f"recebido={type(tensor5)}"
)
if tensor5.ndim != 3:
raise RuntimeError(
f"[{contexto}] Raw5 deve ser CHW 3D; shape={tensor5.shape}"
)
if int(tensor5.shape[0]) != PHYSICAL_RAW5_CHANNELS:
raise RuntimeError(
f"[{contexto}] câmera deve entregar Raw5 com C=5; "
f"shape={tensor5.shape}"
)
expected = (
self._pipeline_contract.get(
"tensor_size"
)
or []
)
if len(expected) == 2:
expected_w = int(expected[0])
expected_h = int(expected[1])
if (
int(tensor5.shape[2]) != expected_w
or int(tensor5.shape[1]) != expected_h
):
raise RuntimeError(
f"[{contexto}] tensor H/W incompatível: "
f"recebido={tensor5.shape} "
f"esperado=(5,{expected_h},{expected_w})"
)
if tensor5.dtype != np.float32:
raise RuntimeError(
f"[{contexto}] Raw5 produto deve ser float32; "
f"dtype={tensor5.dtype}"
)
return True
def _validar_config_dinamica_contrato(self, cfg):
"""
Somente parâmetros operacionais podem mudar durante a sessão.
Modelo, MP, canais e resolução exigem reinicialização completa.
"""
if not self._pipeline_contract.get("validated", False):
return cfg
expected_size = list(
self._pipeline_contract[
"tensor_size"
]
)
requested_size = cfg.get(
"ia_resolution"
)
if requested_size is not None:
requested_size = self._normalizar_size_wh(
requested_size,
"config dinâmica ia_resolution",
)
if requested_size != expected_size:
raise RuntimeError(
"ia_resolution não pode mudar com a sessão ativa: "
f"atual={expected_size} solicitado={requested_size}. "
"Reinicie CameraManager com o novo modelo/MP."
)
cfg["ia_resolution"] = list(
expected_size
)
expected_mp = self._pipeline_contract.get(
"module_params_path"
)
requested_mp = self._path_normalizado(
cfg.get(
"module_calibration_json"
)
)
if (
expected_mp
and requested_mp
and requested_mp != self._path_normalizado(
expected_mp
)
):
raise RuntimeError(
"module_calibration_json não pode mudar com a sessão ativa. "
"Feche/reinicialize o CameraManager."
)
current_model = self._path_normalizado(
self._pipeline_contract.get(
"onnx_model_path"
)
)
requested_model = self._path_normalizado(
cfg.get(
"onnx_model_path"
)
or cfg.get(
"ia_model_path"
)
)
if (
current_model
and requested_model
and current_model != requested_model
):
raise RuntimeError(
"Modelo ONNX não pode ser trocado dinamicamente na sessão ativa. "
"Feche/reinicialize o CameraManager."
)
requested_channels = cfg.get(
"input_channels"
)
if isinstance(
requested_channels,
str,
):
requested_channels = [
x.strip().upper()
for x in requested_channels.split(",")
if x.strip()
]
elif requested_channels is not None:
requested_channels = [
str(x).upper()
for x in requested_channels
]
current_channels = [
str(x).upper()
for x in self._pipeline_contract.get(
"onnx_input_channels",
[]
)
]
if (
requested_channels is not None
and requested_channels != current_channels
):
raise RuntimeError(
"input_channels não pode mudar com ONNX ativo: "
f"atual={current_channels} solicitado={requested_channels}"
)
return cfg
def inicializar(self, mx_id): def inicializar(self, mx_id):
if mx_id is None: if mx_id is None:
return False return False
@ -271,7 +768,20 @@ class CameraManager:
self.seg_config.get("posproc_intervalo_min_s", 5.0) self.seg_config.get("posproc_intervalo_min_s", 5.0)
) )
camera_nova = self._inicializar_camera(mx_id, self.seg_config) # Produto: valida o modelo e o MP ANTES de abrir a OAK.
# Assim não ocupamos USB/pipeline para só depois descobrir
# que ONNX, module_params e ia_resolution são incompatíveis.
self._inicializar_modelo()
self._validar_contrato_pipeline(
self.seg_config,
mx_id,
)
camera_nova = self._inicializar_camera(
mx_id,
self.seg_config,
)
if camera_nova is None: if camera_nova is None:
atraso = self._registrar_falha_inicializacao() atraso = self._registrar_falha_inicializacao()
self.mostrar_log( self.mostrar_log(
@ -294,7 +804,6 @@ class CameraManager:
self.debug_visual = bool(self.seg_config.get("debug_visual", False)) self.debug_visual = bool(self.seg_config.get("debug_visual", False))
self.debug_perf = bool(self.seg_config.get("debug_perf", False)) self.debug_perf = bool(self.seg_config.get("debug_perf", False))
self._inicializar_modelo()
self._inicializar_detector() self._inicializar_detector()
self.operante = False self.operante = False
@ -374,15 +883,56 @@ class CameraManager:
nova = None nova = None
try: try:
contract = self._pipeline_contract or {}
if not contract.get("validated", False):
raise RuntimeError(
"Contrato pipeline não validado antes da câmera."
)
target_size = list(
contract["tensor_size"]
)
rgb_native = (
contract.get(
"rgb_native_size"
)
or [1280, 800]
)
# camera_width/camera_height do config deixam de ser autoridade.
# Estes valores são apenas fallback de compatibilidade do construtor
# e vêm do MP homologado.
fallback_w = int(
rgb_native[0]
)
fallback_h = int(
rgb_native[1]
)
nova = CameraMultispectral( nova = CameraMultispectral(
self.mostrar_log, self.mostrar_log,
mx_id=mx_id, mx_id=mx_id,
module_calibration_json=seg_config.get("module_calibration_json"), module_calibration_json=contract[
width=int(seg_config.get("camera_width", 1280)), "module_params_path"
height=int(seg_config.get("camera_height", 800)), ],
# Default conservador para campo. Se o JSON definir 40, ele será respeitado. width=fallback_w,
fps=int(seg_config.get("camera_fps", 20)), height=fallback_h,
target_size=seg_config.get("ia_resolution", [1024, 640]), # FPS continua sendo política operacional.
fps=int(
seg_config.get(
"camera_fps",
20,
)
),
target_size=target_size,
require_product_contract=bool(
contract.get(
"require_product_contract",
True,
)
),
) )
if not nova.iniciado: if not nova.iniciado:
@ -392,6 +942,62 @@ class CameraManager:
pass pass
return None return None
if bool(
contract.get(
"require_product_contract",
True,
)
):
if not bool(
getattr(
nova,
"product_contract",
False,
)
):
raise RuntimeError(
"CameraMultispectral iniciou sem contrato produto."
)
camera_tensor_size = list(
getattr(
nova,
"tensor_size",
[],
)
or []
)
if camera_tensor_size != target_size:
raise RuntimeError(
"CameraMultispectral divergiu do tamanho final: "
f"camera={camera_tensor_size} "
f"contrato={target_size}"
)
camera_sizes = dict(
getattr(
nova,
"sensor_size_by_role",
{},
)
or {}
)
expected_sizes = dict(
contract.get(
"sensor_size_by_role",
{},
)
or {}
)
if camera_sizes != expected_sizes:
raise RuntimeError(
"CameraMultispectral divergiu do hardware homologado: "
f"camera={camera_sizes} esperado={expected_sizes}"
)
return nova return nova
except Exception as e: except Exception as e:
@ -400,12 +1006,51 @@ class CameraManager:
nova.parar() nova.parar()
except Exception: except Exception:
pass pass
self.mostrar_log(f"⚠️ Camera com ID {mx_id} não conectada: {e}")
self.mostrar_log(
f"⚠️ Camera com ID {mx_id} não conectada: {e}"
)
return None return None
def _inicializar_modelo(self): def _inicializar_modelo(self):
requested = (
self.seg_config.get("onnx_model_path")
or self.seg_config.get("ia_model_path")
or self.seg_config.get("model_path")
or self.seg_config.get("onnx_path")
)
if self.model_svc is not None: if self.model_svc is not None:
return current = getattr(
self.model_svc,
"onnx_path",
None,
)
current_norm = self._path_normalizado(
current
)
requested_norm = self._path_normalizado(
requested
)
if (
current_norm
and requested_norm
and current_norm != requested_norm
):
self.mostrar_log(
"[weed][MODEL] modelo mudou entre sessões; "
f"recriando runtime | antigo={current_norm} "
f"novo={requested_norm}"
)
# Libera a referência. O ORT/TRT anterior será coletado
# quando não houver mais referências ao serviço.
self.model_svc = None
else:
return
self.model_svc = MultiSpecSegformerService( self.model_svc = MultiSpecSegformerService(
model_config=self.seg_config, model_config=self.seg_config,
@ -414,7 +1059,8 @@ class CameraManager:
self.mostrar_log( self.mostrar_log(
f"[weed][MODEL] backend={getattr(self.model_svc, 'runtime_backend', 'onnx')} " f"[weed][MODEL] backend={getattr(self.model_svc, 'runtime_backend', 'onnx')} "
f"runtime_mode={getattr(self.model_svc, 'runtime_mode', None)}" f"runtime_mode={getattr(self.model_svc, 'runtime_mode', None)} "
f"input_shape={getattr(self.model_svc, 'onnx_input_shape', None)}"
) )
def _inicializar_detector(self): def _inicializar_detector(self):
@ -689,6 +1335,11 @@ class CameraManager:
time.sleep(0.05) time.sleep(0.05)
continue continue
self._validar_tensor_camera(
tensor5,
contexto="warmup",
)
frame_id = int( frame_id = int(
res.get("frame_id") res.get("frame_id")
or res.get("async_packet_seq") or res.get("async_packet_seq")
@ -910,6 +1561,7 @@ class CameraManager:
from weed_worker.config import load_seg_config from weed_worker.config import load_seg_config
cfg = load_seg_config() cfg = load_seg_config()
cfg = self._validar_config_dinamica_contrato(cfg)
self.seg_config = cfg self.seg_config = cfg
self.qtd_bicos = int(cfg.get("qtd_bicos", self.qtd_bicos or 7) or 7) self.qtd_bicos = int(cfg.get("qtd_bicos", self.qtd_bicos or 7) or 7)
self.posproc_intervalo_min_s = float(cfg.get("posproc_intervalo_min_s", self.posproc_intervalo_min_s)) self.posproc_intervalo_min_s = float(cfg.get("posproc_intervalo_min_s", self.posproc_intervalo_min_s))
@ -974,7 +1626,9 @@ class CameraManager:
motivos = [] motivos = []
if self.camera is None: if not self._pipeline_contract.get("validated", False):
motivos.append("contrato ONNX/module_params não validado")
elif self.camera is None:
motivos.append("câmera indisponível") motivos.append("câmera indisponível")
elif self.iniciando: elif self.iniciando:
motivos.append("câmera inicializando") motivos.append("câmera inicializando")
@ -1010,6 +1664,23 @@ class CameraManager:
"timeout_tensor_s": timeout_tensor, "timeout_tensor_s": timeout_tensor,
"timeout_inferencia_s": timeout_inferencia, "timeout_inferencia_s": timeout_inferencia,
"timeout_deteccao_s": timeout_deteccao, "timeout_deteccao_s": timeout_deteccao,
"contract": {
"validated": bool(
self._pipeline_contract.get("validated", False)
),
"product_contract": bool(
self._pipeline_contract.get("product_contract", False)
),
"tensor_size": list(
self._pipeline_contract.get("tensor_size", []) or []
),
"onnx_input_channels": list(
self._pipeline_contract.get("onnx_input_channels", []) or []
),
"module_params_path": self._pipeline_contract.get(
"module_params_path"
),
},
} }
self.pipeline_ia_ok = ok self.pipeline_ia_ok = ok
@ -1017,6 +1688,20 @@ class CameraManager:
return resultado return resultado
def get_pipeline_contract(self):
"""Snapshot somente-leitura do contrato ONNX + MP da sessão atual."""
try:
return json.loads(
json.dumps(
self._pipeline_contract,
default=str,
)
)
except Exception:
return dict(
self._pipeline_contract
)
# ============================================================ # ============================================================
# Caches # Caches
# ============================================================ # ============================================================
@ -1415,6 +2100,11 @@ class CameraManager:
time.sleep(0.50 if reiniciou else 0.05) time.sleep(0.50 if reiniciou else 0.05)
continue continue
self._validar_tensor_camera(
tensor5,
contexto="capture",
)
if not self._sessao_camera_valida(camera_atual, generation): if not self._sessao_camera_valida(camera_atual, generation):
self.perf.inc("tensor_descartado_geracao") self.perf.inc("tensor_descartado_geracao")
continue continue