297 行
16 KiB
PHP
297 行
16 KiB
PHP
<?php
|
|
|
|
namespace app\common\library;
|
|
|
|
use think\Db;
|
|
|
|
/** Saved environmental conditions, immutable predictions and dated measurements. */
|
|
class GrainForecast
|
|
{
|
|
public static function metrics($type)
|
|
{
|
|
return $type === 'wheat' ? [
|
|
['key' => 'wheat_moisture', 'name' => '粮食水分', 'unit' => '%', 'max' => 100],
|
|
['key' => 'wheat_GWAC', 'name' => '面筋吸水量', 'unit' => '%', 'max' => 1000],
|
|
['key' => 'wheat_DON', 'name' => '脱氧雪腐镰刀菌烯醇(呕吐毒素)', 'unit' => 'μg/kg', 'max' => 100000000],
|
|
['key' => 'wheat_ZEN', 'name' => '玉米赤霉烯酮', 'unit' => 'μg/kg', 'max' => 100000000],
|
|
] : [
|
|
['key' => 'paddy_moisture', 'name' => '粮食水分', 'unit' => '%', 'max' => 100],
|
|
['key' => 'paddy_fatacid', 'name' => '脂肪酸值', 'unit' => 'mg/100g', 'max' => 100000000],
|
|
['key' => 'paddy_AFB1', 'name' => '黄曲霉毒素B1', 'unit' => 'μg/kg', 'max' => 100000000],
|
|
];
|
|
}
|
|
|
|
public static function number($value, $label, $min, $max)
|
|
{
|
|
if (!is_scalar($value) || is_bool($value) || !is_numeric($value)
|
|
|| !is_finite((float)$value) || $value < $min || $value > $max) {
|
|
throw new \InvalidArgumentException($label . '须为' . $min . '至' . $max . '之间的数值');
|
|
}
|
|
return round((float)$value, 4);
|
|
}
|
|
|
|
public static function date($value, $label)
|
|
{
|
|
$date = is_string($value) ? \DateTimeImmutable::createFromFormat('!Y-m-d', $value) : false;
|
|
if (!$date || $date->format('Y-m-d') !== $value || $value < '2000-01-01' || $value > date('Y-m-d')) {
|
|
throw new \InvalidArgumentException($label . '须为2000年至今天之间的有效日期');
|
|
}
|
|
return $date;
|
|
}
|
|
|
|
public function create(array $input, $adminId)
|
|
{
|
|
$key = isset($input['request_key']) ? $input['request_key'] : '';
|
|
if (!is_string($key) || !preg_match('/^[a-f0-9]{32,64}$/D', $key)) {
|
|
throw new \InvalidArgumentException('请求标识无效,请刷新后重试');
|
|
}
|
|
$existing = Db::name('grain_forecast')->where('request_key', $key)->find();
|
|
if ($existing) {
|
|
if ((int)$existing['admin_id'] !== (int)$adminId) throw new \InvalidArgumentException('请求标识已使用');
|
|
return (int)$existing['id'];
|
|
}
|
|
$gid = isset($input['gid']) && is_scalar($input['gid']) ? (int)$input['gid'] : 0;
|
|
$granary = Db::name('granary')->where('id', $gid)->find();
|
|
if (!$granary || !in_array($granary['varieties'], ['小麦', '稻谷'], true)) {
|
|
throw new \InvalidArgumentException('请选择小麦或稻谷仓库');
|
|
}
|
|
$date = self::date(isset($input['observed_date']) ? $input['observed_date'] : '', '温湿度采集日期');
|
|
$horizons = isset($input['horizons']) ? $input['horizons'] : [];
|
|
if (!is_array($horizons) || !$horizons || count($horizons) > 4) throw new \InvalidArgumentException('请选择预测周期');
|
|
foreach ($horizons as $day) {
|
|
if (!is_scalar($day) || !in_array((string)$day, ['7', '15', '30', '60'], true)) throw new \InvalidArgumentException('预测周期仅支持7、15、30、60天');
|
|
}
|
|
$horizons = array_unique(array_map('intval', $horizons));
|
|
sort($horizons);
|
|
$row = ['gid' => $gid, 'granary_name' => $granary['name'], 'storage' => $granary['storage'],
|
|
'grain_type' => $granary['varieties'] === '小麦' ? 'wheat' : 'rice',
|
|
'observed_date' => $date->format('Y-m-d'), 'request_key' => $key, 'admin_id' => (int)$adminId,
|
|
'status' => 'pending', 'create_time' => time(), 'update_time' => time()];
|
|
foreach (['tm_i' => '仓内温度', 'tm_o' => '仓外温度', 'hm_i' => '仓内湿度', 'hm_o' => '仓外湿度'] as $field => $label) {
|
|
$isTemp = strpos($field, 'tm_') === 0;
|
|
$row[$field] = self::number(isset($input[$field]) ? $input[$field] : null, $label, $isTemp ? -50 : 0, $isTemp ? 80 : 100);
|
|
}
|
|
return Db::transaction(function () use ($row, $horizons, $date) {
|
|
// The unique request key also prevents concurrent duplicate batches.
|
|
$id = Db::name('grain_forecast')->insertGetId($row);
|
|
foreach ($horizons as $days) {
|
|
Db::name('grain_forecast_point')->insert(['forecast_id' => $id, 'horizon' => $days,
|
|
'target_date' => $date->modify('+' . $days . ' days')->format('Y-m-d'),
|
|
'predicted' => '{}', 'actual' => '{}', 'version' => 0, 'update_time' => time()]);
|
|
}
|
|
return (int)$id;
|
|
});
|
|
}
|
|
|
|
/** The API document defines month/day as a calendar date, not a duration. */
|
|
protected function predict(array $record, array $point)
|
|
{
|
|
$date = new \DateTimeImmutable($point['target_date']);
|
|
$params = array_intersect_key($record, array_flip(['tm_i', 'tm_o', 'hm_i', 'hm_o']));
|
|
$params += ['month' => (int)$date->format('n'), 'day' => (int)$date->format('j'), 'type' => $record['grain_type'], 'commodity' => $record['grain_type']];
|
|
$url = 'http://www.probe-cdy-pests.cn:8116/alg/grainquality/forcast?' . http_build_query($params);
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_TIMEOUT => 6,
|
|
CURLOPT_PROXY => '', CURLOPT_FOLLOWLOCATION => false]);
|
|
$raw = curl_exec($ch);
|
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
$json = $raw !== false ? json_decode($raw, true) : null;
|
|
if ($status !== 200 || !is_array($json) || !isset($json['code']) || (string)$json['code'] !== '0'
|
|
|| empty($json['output']['targets']) || !is_array($json['output']['targets'])) {
|
|
throw new \RuntimeException('算法服务暂不可用,温湿度记录已保存,请稍后重新生成');
|
|
}
|
|
return $json['output']['targets'];
|
|
}
|
|
|
|
public function generate($id)
|
|
{
|
|
$record = Db::name('grain_forecast')->where('id', $id)->find();
|
|
if (!$record) throw new \InvalidArgumentException('预测记录不存在');
|
|
if ($record['status'] === 'ready') return;
|
|
// Recover an interrupted request after its bounded remote calls have expired.
|
|
$locked = Db::name('grain_forecast')->where('id', $id)->where(function ($q) {
|
|
$q->where('status', 'in', ['pending', 'failed'])->whereOr(function ($q) {
|
|
$q->where('status', 'processing')->where('attempt_time', '<', time() - 120);
|
|
});
|
|
})->update(['status' => 'processing', 'attempt_time' => time(), 'update_time' => time(), 'error' => '']);
|
|
if (!$locked) return;
|
|
try {
|
|
$points = Db::name('grain_forecast_point')->where('forecast_id', $id)->order('horizon')->select();
|
|
$predictions = [];
|
|
foreach ($points as $point) {
|
|
$targets = $this->predict($record, $point);
|
|
$values = [];
|
|
foreach (self::metrics($record['grain_type']) as $metric) {
|
|
$key = $metric['key'];
|
|
$values[$key] = self::number(isset($targets[$key]) ? $targets[$key] : null, $metric['name'] . '预测值', 0, $metric['max']);
|
|
}
|
|
$predictions[$point['id']] = json_encode($values, JSON_UNESCAPED_UNICODE);
|
|
}
|
|
Db::transaction(function () use ($id, $predictions) {
|
|
foreach ($predictions as $pointId => $values) {
|
|
Db::name('grain_forecast_point')->where('id', $pointId)->update(['predicted' => $values]);
|
|
}
|
|
Db::name('grain_forecast')->where('id', $id)->update(['status' => 'ready', 'error' => '', 'update_time' => time()]);
|
|
});
|
|
} catch (\Exception $e) {
|
|
Db::name('grain_forecast')->where('id', $id)->update(['status' => 'failed',
|
|
'error' => '算法服务暂不可用或返回数据不完整,请稍后重新生成', 'update_time' => time()]);
|
|
}
|
|
}
|
|
|
|
public function detail($id)
|
|
{
|
|
$record = Db::name('grain_forecast')->where('id', $id)->find();
|
|
if (!$record) throw new \InvalidArgumentException('预测记录不存在');
|
|
unset($record['request_key']);
|
|
$record['metrics'] = self::metrics($record['grain_type']);
|
|
$record['points'] = Db::name('grain_forecast_point')->where('forecast_id', $id)->order('horizon')->select();
|
|
foreach ($record['points'] as &$point) {
|
|
$point['predicted'] = json_decode($point['predicted'], true) ?: [];
|
|
$point['actual'] = json_decode($point['actual'], true) ?: [];
|
|
$point['rows'] = [];
|
|
foreach ($record['metrics'] as $metric) {
|
|
$key = $metric['key'];
|
|
$predicted = isset($point['predicted'][$key]) ? $point['predicted'][$key] : null;
|
|
$actual = isset($point['actual'][$key]) ? $point['actual'][$key] : null;
|
|
$delta = $predicted !== null && $actual !== null ? round($actual - $predicted, 4) : null;
|
|
$point['rows'][] = $metric + ['predicted' => $predicted, 'actual' => $actual, 'delta' => $delta,
|
|
'error_percent' => $delta !== null && $predicted != 0 ? round($delta / abs($predicted) * 100, 2) : null];
|
|
}
|
|
}
|
|
unset($point);
|
|
return $record;
|
|
}
|
|
|
|
public function saveActual(array $input, $adminId)
|
|
{
|
|
$point = Db::name('grain_forecast_point')->where('id', isset($input['point_id']) ? (int)$input['point_id'] : 0)->find();
|
|
if (!$point) throw new \InvalidArgumentException('预测周期不存在');
|
|
$record = Db::name('grain_forecast')->where('id', $point['forecast_id'])->find();
|
|
$actual = isset($input['actual']) ? $input['actual'] : [];
|
|
if (!is_array($actual)) throw new \InvalidArgumentException('实测数据格式错误');
|
|
$metrics = self::metrics($record['grain_type']);
|
|
if (array_diff(array_keys($actual), array_column($metrics, 'key'))) throw new \InvalidArgumentException('实测指标与粮食品种不匹配');
|
|
$values = [];
|
|
foreach ($metrics as $metric) {
|
|
$key = $metric['key'];
|
|
$value = isset($actual[$key]) ? $actual[$key] : null;
|
|
$values[$key] = $value === '' || $value === null ? null : self::number($value, $metric['name'], 0, $metric['max']);
|
|
}
|
|
$filled = count(array_filter($values, function ($v) { return $v !== null; }));
|
|
$actualDate = null;
|
|
if ($filled) {
|
|
$date = self::date(isset($input['actual_date']) ? $input['actual_date'] : '', '实际检测日期');
|
|
if ($date->format('Y-m-d') < $record['observed_date']) throw new \InvalidArgumentException('实际检测日期不能早于温湿度采集日期');
|
|
$actualDate = $date->format('Y-m-d');
|
|
}
|
|
$version = isset($input['version']) && is_scalar($input['version']) ? (int)$input['version'] : -1;
|
|
$saved = Db::name('grain_forecast_point')->where('id', $point['id'])->where('version', $version)->update([
|
|
'actual' => json_encode($values, JSON_UNESCAPED_UNICODE), 'actual_date' => $actualDate,
|
|
'actual_admin_id' => (int)$adminId, 'version' => $version + 1, 'update_time' => time(),
|
|
]);
|
|
if (!$saved) throw new \InvalidArgumentException('实测数据已被其他人更新,请关闭并重新打开后填写');
|
|
return (int)$point['forecast_id'];
|
|
}
|
|
|
|
public function delete($id)
|
|
{
|
|
$ids = is_array($id) ? $id : (strpos((string)$id, ',') !== false ? explode(',', (string)$id) : [(int)$id]);
|
|
$ids = array_values(array_filter(array_map('intval', $ids)));
|
|
if (empty($ids)) {
|
|
throw new \InvalidArgumentException('请选择要删除的记录');
|
|
}
|
|
return Db::transaction(function () use ($ids) {
|
|
Db::name('grain_forecast_point')->where('forecast_id', 'in', $ids)->delete();
|
|
return Db::name('grain_forecast')->where('id', 'in', $ids)->delete();
|
|
});
|
|
}
|
|
|
|
public function update($id, array $input, $adminId)
|
|
{
|
|
$id = (int)$id;
|
|
$existing = Db::name('grain_forecast')->where('id', $id)->find();
|
|
if (!$existing) {
|
|
throw new \InvalidArgumentException('预测记录不存在');
|
|
}
|
|
$gid = isset($input['gid']) && is_scalar($input['gid']) ? (int)$input['gid'] : 0;
|
|
$granary = Db::name('granary')->where('id', $gid)->find();
|
|
if (!$granary || !in_array($granary['varieties'], ['小麦', '稻谷'], true)) {
|
|
throw new \InvalidArgumentException('请选择小麦或稻谷仓库');
|
|
}
|
|
$date = self::date(isset($input['observed_date']) ? $input['observed_date'] : '', '温湿度采集日期');
|
|
$horizons = isset($input['horizons']) ? $input['horizons'] : [];
|
|
if (!is_array($horizons) || !$horizons || count($horizons) > 4) {
|
|
throw new \InvalidArgumentException('请选择预测周期');
|
|
}
|
|
foreach ($horizons as $day) {
|
|
if (!is_scalar($day) || !in_array((string)$day, ['7', '15', '30', '60'], true)) {
|
|
throw new \InvalidArgumentException('预测周期仅支持7、15、30、60天');
|
|
}
|
|
}
|
|
$horizons = array_unique(array_map('intval', $horizons));
|
|
sort($horizons);
|
|
|
|
$grainType = $granary['varieties'] === '小麦' ? 'wheat' : 'rice';
|
|
$row = [
|
|
'gid' => $gid,
|
|
'granary_name' => $granary['name'],
|
|
'storage' => $granary['storage'],
|
|
'grain_type' => $grainType,
|
|
'observed_date' => $date->format('Y-m-d'),
|
|
'status' => 'pending',
|
|
'error' => '',
|
|
'update_time' => time()
|
|
];
|
|
foreach (['tm_i' => '仓内温度', 'tm_o' => '仓外温度', 'hm_i' => '仓内湿度', 'hm_o' => '仓外湿度'] as $field => $label) {
|
|
$isTemp = strpos($field, 'tm_') === 0;
|
|
$row[$field] = self::number(isset($input[$field]) ? $input[$field] : null, $label, $isTemp ? -50 : 0, $isTemp ? 80 : 100);
|
|
}
|
|
|
|
Db::transaction(function () use ($id, $row, $horizons, $date, $grainType, $existing) {
|
|
Db::name('grain_forecast')->where('id', $id)->update($row);
|
|
|
|
$oldPoints = Db::name('grain_forecast_point')->where('forecast_id', $id)->select();
|
|
$oldPointsByHorizon = [];
|
|
foreach ($oldPoints as $op) {
|
|
$oldPointsByHorizon[(int)$op['horizon']] = $op;
|
|
}
|
|
|
|
$grainTypeChanged = ($existing['grain_type'] !== $grainType);
|
|
|
|
foreach ($horizons as $days) {
|
|
$targetDate = $date->modify('+' . $days . ' days')->format('Y-m-d');
|
|
if (isset($oldPointsByHorizon[$days]) && !$grainTypeChanged) {
|
|
Db::name('grain_forecast_point')->where('id', $oldPointsByHorizon[$days]['id'])->update([
|
|
'target_date' => $targetDate,
|
|
'predicted' => '{}',
|
|
'update_time' => time()
|
|
]);
|
|
} else {
|
|
if (isset($oldPointsByHorizon[$days])) {
|
|
Db::name('grain_forecast_point')->where('id', $oldPointsByHorizon[$days]['id'])->delete();
|
|
}
|
|
Db::name('grain_forecast_point')->insert([
|
|
'forecast_id' => $id,
|
|
'horizon' => $days,
|
|
'target_date' => $targetDate,
|
|
'predicted' => '{}',
|
|
'actual' => '{}',
|
|
'version' => 0,
|
|
'update_time' => time()
|
|
]);
|
|
}
|
|
}
|
|
|
|
foreach ($oldPointsByHorizon as $h => $op) {
|
|
if (!in_array($h, $horizons, true)) {
|
|
Db::name('grain_forecast_point')->where('id', $op['id'])->delete();
|
|
}
|
|
}
|
|
});
|
|
|
|
return (int)$id;
|
|
}
|
|
}
|