Files

278 lines
9.3 KiB
PHP

<?php
declare(strict_types=1);
namespace Belevo\EnelixUtils\CC100;
use InvalidArgumentException;
use RuntimeException;
final class CC100ChannelModel
{
/** @var array<string, int> */
private const CHANNEL_COUNTS = [
'DI' => 8,
'DO' => 4,
'AI' => 2,
'AO' => 2,
'PT' => 2,
];
/** @var array<string, int> */
private const DEFAULT_INTERVALS = [
'DI' => 500,
'DO' => 500,
'AI' => 5000,
'AO' => 5000,
'PT' => 5000,
];
/**
* @return list<array{Aktiv: bool, Typ: string, Kanal: int, Anzeigename: string, Abfrageintervall: int}>
*/
public static function defaultSelection(): array
{
$selection = [];
foreach (self::CHANNEL_COUNTS as $type => $count) {
for ($channel = 1; $channel <= $count; $channel++) {
$selection[] = [
'Aktiv' => false,
'Typ' => $type,
'Kanal' => $channel,
'Anzeigename' => self::defaultName($type, $channel),
'Abfrageintervall' => self::DEFAULT_INTERVALS[$type],
];
}
}
return $selection;
}
/**
* @return list<array{
* key: string,
* type: string,
* channel: int,
* name: string,
* active: bool,
* pollInterval: int
* }>
*/
public static function normalizeSelection(string $json): array
{
try {
$rows = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $exception) {
throw new InvalidArgumentException('Kanalauswahl ist kein gueltiges JSON.', 0, $exception);
}
if (!is_array($rows)) {
throw new InvalidArgumentException('Kanalauswahl muss eine Liste sein.');
}
$result = [];
$keys = [];
foreach ($rows as $index => $row) {
if (!is_array($row)) {
throw new InvalidArgumentException('Kanalzeile ' . ($index + 1) . ' ist ungueltig.');
}
$type = strtoupper(trim((string) ($row['Typ'] ?? '')));
$channel = filter_var($row['Kanal'] ?? null, FILTER_VALIDATE_INT);
if ($channel === false || !isset(self::CHANNEL_COUNTS[$type])) {
throw new InvalidArgumentException('Kanalzeile ' . ($index + 1) . ' hat einen ungueltigen Typ oder Kanal.');
}
if ($channel < 1 || $channel > self::CHANNEL_COUNTS[$type]) {
throw new InvalidArgumentException(
sprintf('%s erlaubt nur die Kanaele 1 bis %d.', $type, self::CHANNEL_COUNTS[$type])
);
}
$key = $type . $channel;
if (isset($keys[$key])) {
throw new InvalidArgumentException('Kanal ' . $key . ' ist doppelt konfiguriert.');
}
$keys[$key] = true;
$name = trim((string) ($row['Anzeigename'] ?? ''));
if ($name === '') {
$name = self::defaultName($type, $channel);
}
if (strlen($name) > 80) {
throw new InvalidArgumentException('Der Anzeigename von ' . $key . ' ist zu lang.');
}
$pollInterval = filter_var($row['Abfrageintervall'] ?? null, FILTER_VALIDATE_INT);
if ($pollInterval === false || $pollInterval < 100 || $pollInterval > 3600000) {
throw new InvalidArgumentException(
'Das Abfrageintervall von ' . $key . ' muss zwischen 100 und 3600000 ms liegen.'
);
}
$result[] = [
'key' => $key,
'type' => $type,
'channel' => $channel,
'name' => $name,
'active' => (bool) ($row['Aktiv'] ?? false),
'pollInterval' => $pollInterval,
];
}
return $result;
}
/**
* @return array{variableType: int, profile: string, writable: bool, position: int}
*/
public static function definition(string $type, int $channel): array
{
$type = strtoupper($type);
if (!isset(self::CHANNEL_COUNTS[$type]) || $channel < 1 || $channel > self::CHANNEL_COUNTS[$type]) {
throw new InvalidArgumentException('Unbekannter CC100-Kanal.');
}
$offsets = ['DI' => 100, 'DO' => 200, 'AI' => 300, 'AO' => 400, 'PT' => 500];
return [
'variableType' => in_array($type, ['DI', 'DO'], true) ? 0 : 2,
'profile' => match ($type) {
'DO' => '~Switch',
'AI', 'AO' => '~Volt',
'PT' => '~Temperature',
default => '',
},
'writable' => in_array($type, ['DO', 'AO'], true),
'position' => $offsets[$type] + $channel,
];
}
public static function defaultName(string $type, int $channel): string
{
return match ($type) {
'DI' => 'Digitaler Eingang ' . $channel,
'DO' => 'Digitaler Ausgang ' . $channel,
'AI' => 'Analoger Eingang ' . $channel,
'AO' => 'Analoger Ausgang ' . $channel,
'PT' => 'PT1000 Temperatur ' . $channel,
default => $type . $channel,
};
}
/**
* @return array<string, array{x1: float, y1: float, x2: float, y2: float}>
*/
public static function parseCalibration(string $content): array
{
$lines = preg_split('/\R/', trim($content)) ?: [];
$lines = array_values(array_filter(array_map('trim', $lines), static fn (string $line): bool => $line !== ''));
if (count($lines) < 2) {
throw new InvalidArgumentException('Kalibrierdatei ist leer oder unvollstaendig.');
}
$headers = preg_split('/\s+/', array_shift($lines)) ?: [];
$calibration = [];
foreach ($headers as $index => $header) {
if (!isset($lines[$index])) {
throw new InvalidArgumentException('Kalibrierwert fuer ' . $header . ' fehlt.');
}
$key = strtoupper($header);
if ($key === 'A01') {
$key = 'AO1';
} elseif ($key === 'A02') {
$key = 'AO2';
}
$values = preg_split('/\s+/', $lines[$index]) ?: [];
if (count($values) !== 4 || count(array_filter($values, 'is_numeric')) !== 4) {
throw new InvalidArgumentException('Kalibrierwert fuer ' . $key . ' ist ungueltig.');
}
[$x1, $y1, $x2, $y2] = array_map('floatval', $values);
if ($x1 === $x2 || $y1 === $y2) {
throw new InvalidArgumentException('Kalibrierpunkte fuer ' . $key . ' sind nicht eindeutig.');
}
$calibration[$key] = compact('x1', 'y1', 'x2', 'y2');
}
return $calibration;
}
/** @param array{x1: float, y1: float, x2: float, y2: float} $calibration */
public static function rawToEngineering(float $raw, array $calibration): float
{
return (($calibration['y2'] - $calibration['y1']) / ($calibration['x2'] - $calibration['x1']))
* ($raw - $calibration['x1'])
+ $calibration['y1'];
}
/** @param array{x1: float, y1: float, x2: float, y2: float} $calibration */
public static function engineeringToRaw(float $value, array $calibration): int
{
$raw = (($calibration['x2'] - $calibration['x1']) / ($calibration['y2'] - $calibration['y1']))
* ($value - $calibration['y1'])
+ $calibration['x1'];
return (int) round($raw);
}
public static function pt1000Temperature(float $resistance): float
{
if (!is_finite($resistance) || $resistance < 760.0 || $resistance > 2300.0) {
throw new InvalidArgumentException('PT1000-Widerstand liegt ausserhalb des unterstuetzten Bereichs.');
}
$low = -60.0;
$high = 350.0;
for ($iteration = 0; $iteration < 50; $iteration++) {
$middle = ($low + $high) / 2.0;
if (self::pt1000Resistance($middle) < $resistance) {
$low = $middle;
} else {
$high = $middle;
}
}
return round(($low + $high) / 2.0, 1);
}
public static function parseInteger(string $content): int
{
$value = trim($content);
if (!preg_match('/^-?\d+$/', $value)) {
throw new RuntimeException('Hardwarewert ist keine ganze Zahl.');
}
return (int) $value;
}
public static function bitState(int $aggregate, int $channel): bool
{
if ($channel < 1 || $channel > 8) {
throw new InvalidArgumentException('Bitkanal muss zwischen 1 und 8 liegen.');
}
return ($aggregate & (1 << ($channel - 1))) !== 0;
}
public static function withBitState(int $aggregate, int $channel, bool $state): int
{
$mask = 1 << ($channel - 1);
return $state ? ($aggregate | $mask) : ($aggregate & ~$mask);
}
private static function pt1000Resistance(float $temperature): float
{
$a = 3.9083e-3;
$b = -5.775e-7;
$c = -4.183e-12;
$factor = 1.0 + ($a * $temperature) + ($b * $temperature * $temperature);
if ($temperature < 0.0) {
$factor += $c * ($temperature - 100.0) * $temperature * $temperature * $temperature;
}
return 1000.0 * $factor;
}
}