Commit 71386365 authored by Nauta, Lisanne's avatar Nauta, Lisanne
Browse files

Merge branch 'hotfix/unit_conv'

parents c2682042 be31f4bf
{
"name": "api",
"version": "0.4.0",
"version": "0.4.1",
"description": "",
"author": "lisanne.nauta@wur.nl",
"private": true,
......
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
import { Point } from "geojson";
import { Forecast } from "src/forecast/forecast.entity";
import { Location } from "src/location/location.entity";
import { Observation } from "src/observation/observation.entity";
import { LocalSoilMoistureForecastSeries } from "src/soil-moisture/soil-moisture-forecast/local-soil-moisture-forecast/local-soil-moisture-forecast-series.entity";
import { LocalSoilMoistureForecast } from "src/soil-moisture/soil-moisture-forecast/local-soil-moisture-forecast/local-soil-moisture-forecast.entity";
import { LocalSoilMoistureForecastService } from "src/soil-moisture/soil-moisture-forecast/local-soil-moisture-forecast/local-soil-moisture-forecast.service";
import { Variable } from "src/variable/variable.entity";
import { EnsembleForecastTimeSeries, ForecastTimeSeries, TimeSeries } from "./time-series";
import { TimeSeriesItem } from "./time-series-item";
import { Point } from 'geojson';
@ApiExtraModels(EnsembleForecastTimeSeries,ForecastTimeSeries,TimeSeries,Variable)
export class DataResponse {
......@@ -89,17 +88,23 @@ export class DataResponse {
}
public static fromLocalSoilMoistureForecast(forecast:LocalSoilMoistureForecast){
return this.fromLocalSoilMoistureForecastSeries(forecast.series,forecast.cropfield.position,forecast.forecastdatetime)
}
public static fromLocalSoilMoistureForecastSeries(series:LocalSoilMoistureForecastSeries[], position:Point, forecastDateTime:Date){
const variable = new Variable('soil_moisture','soil moisture','cm3/cm3');
const location = new Location(forecast.cropfield.position);
const location = new Location(position);
let dataResponse = new DataResponse();
dataResponse.variables = [variable];
dataResponse.location = location;
let series = [];
forecast.series.forEach(item => {
let ts = [];
series.forEach(item => {
let timeSeriesItem = new TimeSeriesItem(item.value,item.datetime);
series.push(timeSeriesItem);
ts.push(timeSeriesItem);
});
const data = new ForecastTimeSeries(series,forecast.forecastdatetime);
const data = new ForecastTimeSeries(ts,forecastDateTime);
dataResponse.data[variable.code] = data;
return dataResponse;
}
......
import { Transform } from "class-transformer";
import { IsBoolean, IsDateString, IsNumber, IsOptional } from "class-validator";
export class ListLocalForecastQuery{
@IsOptional()
@IsDateString()
minforecastdate:Date;
@IsOptional()
@IsDateString()
maxforecastdate:Date;
@IsOptional()
@IsNumber()
@Transform(({ value }) => parseInt(value))
userid: number;
@IsOptional()
@IsBoolean()
latest:boolean = true;
@IsOptional()
cropfieldid:number;
}
\ No newline at end of file
......@@ -26,7 +26,7 @@ export class LocalSoilMoistureForecast {
transformer:{
to:(value:number)=>{
if(value!=null){
return parseFloat(value.toFixed(2))
return parseFloat(value.toFixed(3))
}
return value;
......@@ -51,7 +51,7 @@ export class LocalSoilMoistureForecast {
if(value != null){
let input = value.map((val)=>{
if(val!=null){
return parseFloat(val.toFixed(2))
return parseFloat(val.toFixed(3))
}
else{
return val;
......
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { InjectRepository } from '@nestjs/typeorm';
import { Point } from 'geojson';
import { DateTime } from 'luxon';
import { catchError, forkJoin, from, lastValueFrom, map, Observable, of, switchMap, tap } from 'rxjs';
import { CropField } from 'src/crop-field/crop-field.entity';
import { CropFieldService } from 'src/crop-field/crop-field.service';
import { Crop } from 'src/crop/crop.entity';
import { CropService } from 'src/crop/crop.service';
import { ForecastService } from 'src/forecast/forecast.service';
import { SoilMoistureService } from 'src/soil-moisture/soil-moisture.service';
import { SoilService } from 'src/soil/soil.service';
import { FindManyOptions, In, LessThan, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
import { User } from 'src/user/user.entity';
import { FindOneOptions, LessThan, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
import { ListLocalForecastQuery } from './dtos/list-local-forecast-query';
import { IrrigationTarget } from './irrigation-target';
import { LocalSoilMoistureForecastSeries } from './local-soil-moisture-forecast-series.entity';
import { LocalSoilMoistureForecast } from './local-soil-moisture-forecast.entity';
......@@ -77,7 +79,7 @@ export class LocalSoilMoistureForecastService {
const waitForRuns = forkJoin(runs);
return waitForRuns;
}),switchMap((forecasts)=>{
Logger.debug('Save crop field forecasts.',LocalSoilMoistureForecastService.name);
Logger.log('Save crop field forecasts.',LocalSoilMoistureForecastService.name);
// filter out failed crop field forecast runs
forecasts = forecasts.filter(f=> f!=null);
// save bulk in chunks of 1000.
......@@ -96,10 +98,6 @@ export class LocalSoilMoistureForecastService {
public runCropFieldForecast(cropField:CropField,forecastDateTime:Date,initMoist?:number, irrigation?:number[], irrigationTarget?:IrrigationTarget){
const lon = cropField.position.coordinates[0];
const lat = cropField.position.coordinates[1];
// get field capacity, wilting point for crop field coordinates
const getSoilData = this.soilService.getSoilData(lon, lat);
// get the meteo data for user region
const getMeteoData = this.forecastService.findLatestForRegion(cropField.user.regionid,['prec','temp'],forecastDateTime);
/* this part set the initial soil moisture value with a fallback procedure.
1) given user initial condition
......@@ -107,12 +105,16 @@ export class LocalSoilMoistureForecastService {
3) mid point of field capacity and wilting point. -> done in forecast days iteration */
let getInitMoist = of(initMoist);
if(!initMoist){
getInitMoist = from(this.findPreviousByCropFieldId(cropField.id,forecastDateTime)).pipe(map(forecast => {
let searchParams = {
cropfieldid:cropField.id,
latest: true
} as ListLocalForecastQuery
getInitMoist = from(this.findOne(searchParams)).pipe(map(forecast => {
let value = null;
if(forecast){
// searches for the forecast date minus 1 day in last soil moisture forecast
const i = forecast.series.findIndex(item => item.datetime.getTime() === forecastDateTime.getTime());
if(i!=-1){
if(i>0){
value = forecast.series[i-1].value;
}
}
......@@ -120,17 +122,33 @@ export class LocalSoilMoistureForecastService {
}));
}
const model = forkJoin([getSoilData,getMeteoData,getInitMoist]).pipe(map(res => {
let soilMoistVal = null;
const model = getInitMoist.pipe(switchMap((soilMoist)=>{
soilMoistVal = soilMoist
return this.runForecast(cropField.user,lon,lat,forecastDateTime,soilMoist,cropField.crop,cropField.plantdate,irrigation,irrigationTarget)
}),map((forecastSeries)=>{
return new LocalSoilMoistureForecast(forecastSeries,cropField.id,forecastSeries[0].datetime,soilMoistVal,irrigation,irrigationTarget);
}))
return lastValueFrom(model);
}
public runForecast(user:User, lon:number, lat:number,forecastDateTime:Date,initMoisture?:number, crop?:Crop, plantDate?:Date ,irrigation?:number[], irrigationTarget?: IrrigationTarget){
// get field capacity, wilting point for crop field coordinates
const getSoilData = this.soilService.getSoilData(lon, lat);
// get the meteo data for user region
const getMeteoData = this.forecastService.findLatestForRegion(user.regionid,["prec","temp"],forecastDateTime);
const model = forkJoin([getSoilData,getMeteoData]).pipe(map((res=>{
const soilData = res[0];
const maxInfilt = this.soilService.getMaxInfilt(soilData.tex_class);
// TODO: soil data can be missing values
const fc = soilData.fc;
const pwp = soilData.pwp;
const meteoData = res[1];
let getInitMoistVal = res[2]
if(!getInitMoistVal){
getInitMoistVal = (fc + pwp) / 2;
}
// throw error, since we cannot produce forecasts without meteo data
if(meteoData.length ==0){
throw new Error('Meteo forecast not available');
......@@ -139,8 +157,12 @@ export class LocalSoilMoistureForecastService {
const prec = meteoData.filter(item => item.variablecode == 'prec');
let forecastSeries = [] as LocalSoilMoistureForecastSeries[];
//forecastSeries.push(new LocalSoilMoistureForecastSeries(forecastDateTime,getInitMoistVal));
let iInitMoist = getInitMoistVal;
let iInitMoist = initMoisture;
if(!iInitMoist){
iInitMoist = (fc + pwp) / 2
}
// interate lead times of meteo forecast and run soil moisture balance model.
for(let i=0; i < temp.length; i++){
let iIrrigation = 0;
......@@ -165,21 +187,23 @@ export class LocalSoilMoistureForecastService {
const iDoy = +iDateTime.toFormat('o');
const Epot = this.soilMoistureService.getEpotHamon(temp[i].value,iDoy,lat);
// estimate crop factor
const kcValue = this.cropService.calcKcValue(cropField.crop,cropField.plantdate,temp[i].datetime)
let kcValue = 1;
if(crop && plantDate){
kcValue = this.cropService.calcKcValue(crop,plantDate,temp[i].datetime)
}
const ET = Epot * kcValue;
iSoilMoist = this.soilMoistureService.calcBalance(iInitMoist,prec[i].value,ET,maxInfilt,fc,pwp,iIrrigation);
}
const iForecastSeries = new LocalSoilMoistureForecastSeries(iDateTime.toJSDate(),iSoilMoist);
iInitMoist = iForecastSeries.value;
const iSoilMoistVal= parseFloat(iSoilMoist.toFixed(3))
const iForecastSeries = new LocalSoilMoistureForecastSeries(iDateTime.toJSDate(),iSoilMoistVal);
iInitMoist = iSoilMoist;
forecastSeries.push(iForecastSeries);
}
let soilMoistureForecast = new LocalSoilMoistureForecast(forecastSeries, cropField.id, forecastDateTime,initMoist,irrigation);
soilMoistureForecast.cropfieldid = cropField.id;
return soilMoistureForecast;
}));
return lastValueFrom(model);
return forecastSeries;
})))
return lastValueFrom(model)
}
public addForecast(forecast:LocalSoilMoistureForecast){
......@@ -215,35 +239,60 @@ export class LocalSoilMoistureForecastService {
return this.cropFieldService.getDto(cropField);
}
public findPreviousByCropFieldId(cropFieldId:number,maxForecastDateTime:Date){
const findPrevious = this.repo.findOne({
where:{
cropfieldid:cropFieldId,
forecastdatetime: LessThan(maxForecastDateTime)
},
order:{
forecastdatetime:'DESC'
}
});
return findPrevious
private getSelectQuery(params:ListLocalForecastQuery){
let query = this.repo.createQueryBuilder("forecast");
query.leftJoinAndSelect("forecast.cropfield","cropfield").leftJoinAndSelect("cropfield.crop","crop");
if(params.userid){
query.where("cropfield.userid = :id",{id:params.userid});
}
if(params.cropfieldid){
query.where("forecast.cropfieldid = :id",{id:params.cropfieldid});
}
if(params.minforecastdate){
query.andWhere("forecastdatetime >= :minDate",{minDate:params.minforecastdate});
}
if(params.maxforecastdate){
query.andWhere("forecastdatetime <= :maxDate",{maxDate:params.maxforecastdate});
}
if(params.latest){
query.distinctOn(["forecast.cropfieldid"]);
query.orderBy({
'forecast.cropfieldid':'ASC',
'forecast.forecastdatetime':'DESC'
})
}
return(query)
}
public find(userId?:number, minForecastDate?:Date,maxForecastDate?:Date){
let query = {} as FindManyOptions;
if(userId){
query.relations = ['cropfield']
query.where = {
cropfield: {
userid: userId
}
}
public findOne(params:ListLocalForecastQuery){
let q = {} as FindOneOptions;
q.relations = ['cropfield'];
q.where = {}
if(params.cropfieldid){
q.where['cropfieldid'] = params.cropfieldid;
}
if(minForecastDate){
query.where['forecastdatetime'] = MoreThanOrEqual(minForecastDate);
if(params.minforecastdate){
q.where['forecastdate'] = MoreThanOrEqual(params.minforecastdate);
}
if(maxForecastDate){
query.where['forecastdatetime'] = LessThanOrEqual(maxForecastDate);
if(params.maxforecastdate){
q.where['forecastdate'] = LessThanOrEqual(params.maxforecastdate);
}
return this.repo.find(query);
if(params.latest){
q.order = {
forecastdatetime:'DESC'
}
}
return this.repo.findOne(q);
}
public find(params: ListLocalForecastQuery){
const query = this.getSelectQuery(params)
return query.getMany();
}
}
......@@ -10,26 +10,19 @@ import { LocalSoilMoistureForecastService } from './soil-moisture-forecast/local
import { PutCropFieldSoilMoistureForecastDto } from './soil-moisture-forecast/local-soil-moisture-forecast/dtos/put-crop-field-soil-moisture-forecast-dto';
import { DataResponse } from 'src/data/models/data-response';
import { LocalSoilMoistureForecast } from './soil-moisture-forecast/local-soil-moisture-forecast/local-soil-moisture-forecast.entity';
import { IsDateString, IsNumber, IsOptional } from 'class-validator';
import { IsBoolean, IsDateString, IsNumber, IsOptional } from 'class-validator';
import { SoilService } from 'src/soil/soil.service';
import { Variable } from 'src/variable/variable.entity';
import { ForecastTimeSeries } from 'src/data/models/time-series';
import { TimeSeriesItem } from 'src/data/models/time-series-item';
import { Transform } from 'class-transformer';
import { CropFieldSoilMoistureForecastDto } from './soil-moisture-forecast/local-soil-moisture-forecast/dtos/crop-field-soil-moisture-forecast-dto';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {Point} from 'gdal-next'
import { ListLocalForecastQuery } from './soil-moisture-forecast/local-soil-moisture-forecast/dtos/list-local-forecast-query';
const PG_UNIQUE_CONSTRAINT_VIOLATION = "23505";
export class ListLocalForecastQuery{
@IsOptional()
@IsDateString()
minforecastdate:Date;
@IsOptional()
@IsNumber()
@Transform(({ value }) => parseInt(value))
userid: number;
}
@ApiTags('soil-moisture')
@Controller('soil-moisture')
......@@ -40,36 +33,6 @@ export class SoilMoistureController {
private soilService:SoilService){
}
/* @ApiResponse({
type:LocalSoilMoistureForecast
})
@UseInterceptors(ClassSerializerInterceptor)
@UseGuards(UserRoleGuard(UserRole.User))
@UsePipes(new ValidationPipe({ transform: true }))
@Post('local-forecast')
public async createLocalForecast(
@Req() request : Request, @Body() body: CropFieldSoilMoistureForecastDto){
const user = request.user as User;
if(body.cropField.userid != user.id){
throw new UnauthorizedException();
}
const now = DateTime.now();
const fdt = now.startOf('day').toJSDate();
const lon = body.cropField.position.coordinates[0];
const lat = body.cropField.position.coordinates[1];
const percentage = body.initMoistCondition? body.initMoistCondition : 50;
const soilData = await this.soilService.getSoilData(lon,lat);
const initMoist = await this.soilService.soilMoistCondition2Value(soilData[0].fc, soilData[0].pwp,percentage);
const response = from(this.soilMoistureForecastService.runCropFieldForecast(body.cropField,fdt,initMoist,body.irrigation)).pipe(switchMap((res)=>{
return this.soilMoistureForecastService.addForecast(res);
}),catchError((e:Error)=>{
throw new HttpException(e.message,500);
}))
return(response);
} */
@UseInterceptors(ClassSerializerInterceptor)
@UseGuards(UserRoleGuard(UserRole.User))
@ApiParam({
......@@ -153,9 +116,58 @@ export class SoilMoistureController {
if(!req.user.roles.includes(UserRole.Admin) && query.userid != req.user.id){
return new UnauthorizedException();
}
return this.soilMoistureForecastService.find(query.userid,query.minforecastdate)
return this.soilMoistureForecastService.find(query);
}
@ApiResponse({
type: DataResponse
})
@ApiQuery({
name:'init_condition',
type:Number,
required:true
})
@ApiQuery({
name:'lon',
type:Number,
required:true
})
@ApiQuery({
name:'lat',
type:Number,
required:true
})
@UseGuards(UserRoleGuard(UserRole.User))
@Get('forecast-local/data')
async getLocalForcastData(@Req() request : Request,@Query('init_condition') initCondition:number, @Query('lon') lon:number ,@Query('lat') lat:number ){
const user = request.user as User;
const fdt = DateTime.now().minus({days:1}).toJSDate();
const soilData = await this.soilService.getSoilData(lon,lat);
const initMoisture = this.soilService.soilMoistCondition2Value(soilData.fc, soilData.pwp,initCondition);
const forecastSeries = await this.soilMoistureForecastService.runForecast(user,lon,lat,fdt,initMoisture);
const position = new Point(lon,lat)
let dataResponse = DataResponse.fromLocalSoilMoistureForecastSeries(forecastSeries, position, forecastSeries[0].datetime);
const conditionVariable = new Variable("soil_moisture_condition","soil moisture condition","%")
const soilMoistureForecast = dataResponse.data["soil_moisture"] as ForecastTimeSeries;
var conditionSeries :TimeSeriesItem[] = [];
soilMoistureForecast.series.forEach(item=>{
const newValue = this.soilService.soilMoistValue2Condition(soilData.fc, soilData.pwp, item.value);
conditionSeries.push(new TimeSeriesItem(newValue,item.timestamp));
});
dataResponse.variables.push(conditionVariable);
dataResponse.data["soil_moisture_condition"] = new ForecastTimeSeries(conditionSeries,soilMoistureForecast.forecastDate);
return dataResponse;
}
@ApiResponse({
type: DataResponse
})
......@@ -178,11 +190,11 @@ export class SoilMoistureController {
const soilData = await this.soilService.getSoilData(lon,lat);
var dataReponse = DataResponse.fromLocalSoilMoistureForecast(forecast);
var dataResponse = DataResponse.fromLocalSoilMoistureForecast(forecast);
const conditionVariable = new Variable("soil_moisture_condition","soil moisture condition","%")
const soilMoistureForecast = dataReponse.data["soil_moisture"] as ForecastTimeSeries;
const soilMoistureForecast = dataResponse.data["soil_moisture"] as ForecastTimeSeries;
var conditionSeries :TimeSeriesItem[] = [];
soilMoistureForecast.series.forEach(item=>{
......@@ -190,11 +202,11 @@ export class SoilMoistureController {
conditionSeries.push(new TimeSeriesItem(newValue,item.timestamp));
});
dataReponse.variables.push(conditionVariable);
dataReponse.data["soil_moisture_condition"] = new ForecastTimeSeries(conditionSeries,soilMoistureForecast.forecastDate)
dataResponse.variables.push(conditionVariable);
dataResponse.data["soil_moisture_condition"] = new ForecastTimeSeries(conditionSeries,soilMoistureForecast.forecastDate)
return dataReponse;
return dataResponse;
}
}
......
......@@ -24,8 +24,8 @@ export class SoilMoistureService {
}
//calc soil moist for timestamp
let soilMoist = initMoist * 100 + precInfilt // distribute excess water over 1m top soil
soilMoist = soilMoist / 100 // back to [cm3/cm3]
let soilMoist = initMoist * 1000 + precInfilt // distribute excess water over 1m top soil
soilMoist = soilMoist / 1000 // back to fraction [cm3/cm3]
//check against fc and pwp
if(soilMoist < pwp){
soilMoist = pwp;
......@@ -33,7 +33,7 @@ export class SoilMoistureService {
if(soilMoist > fc){
soilMoist = fc
}
return(parseFloat(soilMoist.toFixed(2)))
return(soilMoist)
}
public getMaxInfilt(tex_class:number){
......
......@@ -55,12 +55,12 @@ export class SoilService {
public soilMoistCondition2Value(fc:number, pwp:number, percentage:number){
const value = pwp + (fc-pwp)*(percentage/100);
const value = pwp + (fc-pwp)*(percentage/100); // returns soil moisture fraction from a percentage
return value;
}
public soilMoistValue2Condition(fc:number, pwp:number, soilMoisture:number){
const condition = ((soilMoisture - pwp) / (fc - pwp)) * 100;
const condition = ((soilMoisture - pwp) / (fc - pwp)) * 100; // return a percentage soil moisture between field capacity and wilting point
return condition;
}
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment