UPDATE
This commit is contained in:
@@ -60,3 +60,5 @@ htmlcov/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
logs/*
|
||||
|
||||
+4
-1
@@ -1,3 +1,6 @@
|
||||
from .celery import app as celery_app
|
||||
try:
|
||||
from .celery import app as celery_app
|
||||
except ImportError: # pragma: no cover - fallback for test environments
|
||||
celery_app = None
|
||||
|
||||
__all__ = ("celery_app",)
|
||||
|
||||
+22
-9
@@ -1,7 +1,12 @@
|
||||
import os
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
except ImportError: # pragma: no cover - optional in stripped test envs
|
||||
def load_dotenv():
|
||||
return False
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -20,10 +25,6 @@ INSTALLED_APPS = [
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"rest_framework",
|
||||
"corsheaders",
|
||||
"drf_spectacular",
|
||||
"drf_spectacular_sidecar",
|
||||
"dashboard_data",
|
||||
"rag",
|
||||
"location_data",
|
||||
@@ -32,11 +33,20 @@ INSTALLED_APPS = [
|
||||
"plant",
|
||||
"irrigation",
|
||||
"fertilization",
|
||||
"crop_simulation.apps.CropSimulationConfig",
|
||||
]
|
||||
|
||||
for optional_app in [
|
||||
"rest_framework",
|
||||
"corsheaders",
|
||||
"drf_spectacular",
|
||||
"drf_spectacular_sidecar",
|
||||
]:
|
||||
if importlib.util.find_spec(optional_app):
|
||||
INSTALLED_APPS.insert(6, optional_app)
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
@@ -45,6 +55,9 @@ MIDDLEWARE = [
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
if importlib.util.find_spec("corsheaders"):
|
||||
MIDDLEWARE.insert(1, "corsheaders.middleware.CorsMiddleware")
|
||||
|
||||
ROOT_URLCONF = "config.urls"
|
||||
WSGI_APPLICATION = "config.wsgi.application"
|
||||
|
||||
@@ -72,12 +85,12 @@ DATABASES = {
|
||||
"PASSWORD": os.environ.get("DB_PASSWORD", ""),
|
||||
"HOST": os.environ.get("DB_HOST", "127.0.0.1"),
|
||||
"PORT": os.environ.get("DB_PORT", "3306"),
|
||||
"OPTIONS": {
|
||||
"charset": "utf8mb4",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if DATABASES["default"]["ENGINE"].endswith("mysql"):
|
||||
DATABASES["default"]["OPTIONS"] = {"charset": "utf8mb4"}
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
from .settings import * # noqa: F403,F401
|
||||
|
||||
ROOT_URLCONF = "config.test_urls"
|
||||
|
||||
LOGGING = { # noqa: F405
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"handlers": {"console": {"class": "logging.StreamHandler"}},
|
||||
"root": {"handlers": ["console"], "level": "WARNING"},
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
from django.http import HttpResponse
|
||||
from django.urls import path
|
||||
|
||||
|
||||
def test_view(_request):
|
||||
return HttpResponse("ok")
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("__test__/", test_view),
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CropSimulationConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "crop_simulation"
|
||||
verbose_name = "Crop Simulation"
|
||||
@@ -0,0 +1,125 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SimulationScenario",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(blank=True, default="", max_length=255)),
|
||||
(
|
||||
"scenario_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("single", "Single Simulation"),
|
||||
("crop_comparison", "Crop Comparison"),
|
||||
("fertilization_comparison", "Fertilization Comparison"),
|
||||
],
|
||||
db_index=True,
|
||||
default="single",
|
||||
max_length=64,
|
||||
),
|
||||
),
|
||||
(
|
||||
"model_name",
|
||||
models.CharField(default="Wofost72_WLP_CWB", max_length=128),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("running", "Running"),
|
||||
("success", "Success"),
|
||||
("failure", "Failure"),
|
||||
],
|
||||
db_index=True,
|
||||
default="pending",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
("input_payload", models.JSONField(blank=True, default=dict)),
|
||||
("result_payload", models.JSONField(blank=True, default=dict)),
|
||||
("error_message", models.TextField(blank=True, default="")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
"verbose_name": "Simulation Scenario",
|
||||
"verbose_name_plural": "Simulation Scenarios",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SimulationRun",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("run_key", models.CharField(max_length=64)),
|
||||
("label", models.CharField(max_length=255)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("running", "Running"),
|
||||
("success", "Success"),
|
||||
("failure", "Failure"),
|
||||
],
|
||||
db_index=True,
|
||||
default="pending",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
("weather_payload", models.JSONField(blank=True, default=list)),
|
||||
("soil_payload", models.JSONField(blank=True, default=dict)),
|
||||
("crop_payload", models.JSONField(blank=True, default=dict)),
|
||||
("site_payload", models.JSONField(blank=True, default=dict)),
|
||||
("agromanagement_payload", models.JSONField(blank=True, default=list)),
|
||||
("result_payload", models.JSONField(blank=True, default=dict)),
|
||||
("error_message", models.TextField(blank=True, default="")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"scenario",
|
||||
models.ForeignKey(
|
||||
on_delete=models.deletion.CASCADE,
|
||||
related_name="runs",
|
||||
to="crop_simulation.simulationscenario",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["scenario_id", "id"],
|
||||
"verbose_name": "Simulation Run",
|
||||
"verbose_name_plural": "Simulation Runs",
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="simulationrun",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("scenario", "run_key"),
|
||||
name="crop_simulation_unique_run_key_per_scenario",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class SimulationScenario(models.Model):
|
||||
class ScenarioType(models.TextChoices):
|
||||
SINGLE = "single", "Single Simulation"
|
||||
CROP_COMPARISON = "crop_comparison", "Crop Comparison"
|
||||
FERTILIZATION_COMPARISON = (
|
||||
"fertilization_comparison",
|
||||
"Fertilization Comparison",
|
||||
)
|
||||
|
||||
class Status(models.TextChoices):
|
||||
PENDING = "pending", "Pending"
|
||||
RUNNING = "running", "Running"
|
||||
SUCCESS = "success", "Success"
|
||||
FAILURE = "failure", "Failure"
|
||||
|
||||
name = models.CharField(max_length=255, blank=True, default="")
|
||||
scenario_type = models.CharField(
|
||||
max_length=64,
|
||||
choices=ScenarioType.choices,
|
||||
default=ScenarioType.SINGLE,
|
||||
db_index=True,
|
||||
)
|
||||
model_name = models.CharField(max_length=128, default="Wofost72_WLP_CWB")
|
||||
status = models.CharField(
|
||||
max_length=32,
|
||||
choices=Status.choices,
|
||||
default=Status.PENDING,
|
||||
db_index=True,
|
||||
)
|
||||
input_payload = models.JSONField(default=dict, blank=True)
|
||||
result_payload = models.JSONField(default=dict, blank=True)
|
||||
error_message = models.TextField(blank=True, default="")
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
verbose_name = "Simulation Scenario"
|
||||
verbose_name_plural = "Simulation Scenarios"
|
||||
|
||||
def __str__(self):
|
||||
return self.name or f"{self.scenario_type}:{self.pk}"
|
||||
|
||||
|
||||
class SimulationRun(models.Model):
|
||||
scenario = models.ForeignKey(
|
||||
SimulationScenario,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="runs",
|
||||
)
|
||||
run_key = models.CharField(max_length=64)
|
||||
label = models.CharField(max_length=255)
|
||||
status = models.CharField(
|
||||
max_length=32,
|
||||
choices=SimulationScenario.Status.choices,
|
||||
default=SimulationScenario.Status.PENDING,
|
||||
db_index=True,
|
||||
)
|
||||
weather_payload = models.JSONField(default=list, blank=True)
|
||||
soil_payload = models.JSONField(default=dict, blank=True)
|
||||
crop_payload = models.JSONField(default=dict, blank=True)
|
||||
site_payload = models.JSONField(default=dict, blank=True)
|
||||
agromanagement_payload = models.JSONField(default=list, blank=True)
|
||||
result_payload = models.JSONField(default=dict, blank=True)
|
||||
error_message = models.TextField(blank=True, default="")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["scenario_id", "id"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["scenario", "run_key"],
|
||||
name="crop_simulation_unique_run_key_per_scenario",
|
||||
)
|
||||
]
|
||||
verbose_name = "Simulation Run"
|
||||
verbose_name_plural = "Simulation Runs"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.scenario_id}:{self.run_key}"
|
||||
@@ -0,0 +1,603 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
from .models import SimulationRun, SimulationScenario
|
||||
|
||||
|
||||
DEFAULT_OUTPUT_VARS = ["DVS", "LAI", "TAGP", "TWSO", "SM"]
|
||||
DEFAULT_SUMMARY_VARS = ["TAGP", "TWSO", "CTRAT", "RD"]
|
||||
DEFAULT_TERMINAL_VARS = ["TAGP", "TWSO", "LAI", "DVS"]
|
||||
|
||||
|
||||
class CropSimulationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _json_ready(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _json_ready(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_json_ready(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return [_json_ready(item) for item in value]
|
||||
if isinstance(value, (date, datetime)):
|
||||
return value.isoformat()
|
||||
return value
|
||||
|
||||
|
||||
def _coerce_date(value: Any) -> date:
|
||||
if isinstance(value, date) and not isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
if isinstance(value, str):
|
||||
return date.fromisoformat(value)
|
||||
raise CropSimulationError(f"Unsupported date value: {value!r}")
|
||||
|
||||
|
||||
def _normalize_weather_records(weather: Any) -> list[dict[str, Any]]:
|
||||
if isinstance(weather, dict):
|
||||
if "records" in weather:
|
||||
records = weather["records"]
|
||||
else:
|
||||
records = [weather]
|
||||
else:
|
||||
records = weather
|
||||
|
||||
if not isinstance(records, list) or not records:
|
||||
raise CropSimulationError("Weather input must contain at least one record.")
|
||||
|
||||
normalized = []
|
||||
for raw in records:
|
||||
if not isinstance(raw, dict):
|
||||
raise CropSimulationError("Weather records must be dictionaries.")
|
||||
current_date = _coerce_date(raw.get("DAY") or raw.get("day"))
|
||||
normalized.append(
|
||||
{
|
||||
"DAY": current_date,
|
||||
"LAT": float(raw.get("LAT", raw.get("lat", 0.0))),
|
||||
"LON": float(raw.get("LON", raw.get("lon", 0.0))),
|
||||
"ELEV": float(raw.get("ELEV", raw.get("elev", 0.0))),
|
||||
"IRRAD": float(raw.get("IRRAD", raw.get("irrad", 15_000_000.0))),
|
||||
"TMIN": float(raw.get("TMIN", raw.get("tmin", 10.0))),
|
||||
"TMAX": float(raw.get("TMAX", raw.get("tmax", 20.0))),
|
||||
"VAP": float(raw.get("VAP", raw.get("vap", 12.0))),
|
||||
"WIND": float(raw.get("WIND", raw.get("wind", 2.0))),
|
||||
"RAIN": float(raw.get("RAIN", raw.get("rain", 0.0))),
|
||||
"E0": float(raw.get("E0", raw.get("e0", 0.35))),
|
||||
"ES0": float(raw.get("ES0", raw.get("es0", 0.3))),
|
||||
"ET0": float(raw.get("ET0", raw.get("et0", 0.32))),
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_agromanagement(agromanagement: Any) -> list[dict[str, Any]]:
|
||||
if isinstance(agromanagement, dict) and "AgroManagement" in agromanagement:
|
||||
campaigns = agromanagement["AgroManagement"]
|
||||
elif isinstance(agromanagement, list):
|
||||
campaigns = agromanagement
|
||||
elif isinstance(agromanagement, dict):
|
||||
campaigns = [agromanagement]
|
||||
else:
|
||||
raise CropSimulationError("Agromanagement input must be a dict or list.")
|
||||
|
||||
if not campaigns:
|
||||
raise CropSimulationError("Agromanagement input cannot be empty.")
|
||||
return campaigns
|
||||
|
||||
|
||||
def _normalize_pcse_output_records(records: Any) -> list[dict[str, Any]]:
|
||||
if records is None:
|
||||
return []
|
||||
if isinstance(records, dict):
|
||||
return [records]
|
||||
if isinstance(records, list):
|
||||
return records
|
||||
if isinstance(records, tuple):
|
||||
return list(records)
|
||||
return [records]
|
||||
|
||||
|
||||
def _pick_first_not_none(*values: Any) -> Any:
|
||||
for value in values:
|
||||
if value is not None:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _extract_total_n(agromanagement: list[dict[str, Any]]) -> float:
|
||||
total_n = 0.0
|
||||
for campaign in agromanagement:
|
||||
if not isinstance(campaign, dict):
|
||||
continue
|
||||
for payload in campaign.values():
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
for bucket_name in ("TimedEvents", "StateEvents"):
|
||||
for event_group in payload.get(bucket_name, []) or []:
|
||||
if not isinstance(event_group, dict):
|
||||
continue
|
||||
for event in event_group.get("events_table", []) or []:
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
for event_payload in event.values():
|
||||
if isinstance(event_payload, dict):
|
||||
total_n += float(event_payload.get("N_amount", 0.0))
|
||||
return total_n
|
||||
|
||||
|
||||
def _load_pcse_bindings() -> dict[str, Any] | None:
|
||||
try:
|
||||
base_module = importlib.import_module("pcse.base")
|
||||
models_module = importlib.import_module("pcse.models")
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
parameter_provider = getattr(base_module, "ParameterProvider", None)
|
||||
weather_provider = getattr(base_module, "WeatherDataProvider", object)
|
||||
weather_container = getattr(base_module, "WeatherDataContainer", None)
|
||||
if weather_container is None or parameter_provider is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"ParameterProvider": parameter_provider,
|
||||
"WeatherDataProvider": weather_provider,
|
||||
"WeatherDataContainer": weather_container,
|
||||
"models": models_module,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_model_class(bindings: dict[str, Any], model_name: str):
|
||||
models_source = bindings["models"]
|
||||
if isinstance(models_source, dict):
|
||||
return models_source[model_name]
|
||||
return getattr(models_source, model_name)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PreparedSimulationInput:
|
||||
weather: list[dict[str, Any]]
|
||||
soil: dict[str, Any]
|
||||
crop: dict[str, Any]
|
||||
site: dict[str, Any]
|
||||
agromanagement: list[dict[str, Any]]
|
||||
|
||||
|
||||
class PcseSimulationManager:
|
||||
def __init__(self, model_name: str = "Wofost72_WLP_CWB"):
|
||||
self.model_name = model_name
|
||||
|
||||
def run_simulation(
|
||||
self,
|
||||
*,
|
||||
weather: Any,
|
||||
soil: dict[str, Any],
|
||||
crop_parameters: dict[str, Any],
|
||||
agromanagement: Any,
|
||||
site_parameters: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
prepared = PreparedSimulationInput(
|
||||
weather=_normalize_weather_records(weather),
|
||||
soil=soil or {},
|
||||
crop=crop_parameters or {},
|
||||
site=site_parameters or {},
|
||||
agromanagement=_normalize_agromanagement(agromanagement),
|
||||
)
|
||||
bindings = _load_pcse_bindings()
|
||||
if bindings is None:
|
||||
raise CropSimulationError(
|
||||
"PCSE is not installed or required PCSE classes could not be loaded."
|
||||
)
|
||||
return self._run_with_pcse(prepared, bindings)
|
||||
|
||||
def _run_with_pcse(
|
||||
self,
|
||||
prepared: PreparedSimulationInput,
|
||||
bindings: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
weather_provider_base = bindings["WeatherDataProvider"]
|
||||
weather_container = bindings["WeatherDataContainer"]
|
||||
parameter_provider_cls = bindings["ParameterProvider"]
|
||||
model_cls = _resolve_model_class(bindings, self.model_name)
|
||||
|
||||
class DictWeatherProvider(weather_provider_base):
|
||||
def __init__(self, records: list[dict[str, Any]]):
|
||||
super().__init__()
|
||||
self._records = {
|
||||
item["DAY"]: weather_container(**item)
|
||||
for item in records
|
||||
}
|
||||
|
||||
def __call__(self, day):
|
||||
return self._records[_coerce_date(day)]
|
||||
|
||||
parameter_provider = parameter_provider_cls(
|
||||
cropdata=prepared.crop,
|
||||
soildata=prepared.soil,
|
||||
sitedata=prepared.site,
|
||||
)
|
||||
simulation = model_cls(
|
||||
parameterprovider=parameter_provider,
|
||||
weatherdataprovider=DictWeatherProvider(prepared.weather),
|
||||
agromanagement=prepared.agromanagement,
|
||||
output_vars=DEFAULT_OUTPUT_VARS,
|
||||
summary_vars=DEFAULT_SUMMARY_VARS,
|
||||
terminal_vars=DEFAULT_TERMINAL_VARS,
|
||||
)
|
||||
if hasattr(simulation, "run_till_terminate"):
|
||||
simulation.run_till_terminate()
|
||||
elif hasattr(simulation, "run"):
|
||||
simulation.run(days=len(prepared.weather))
|
||||
else:
|
||||
raise CropSimulationError("PCSE model does not expose a runnable interface.")
|
||||
|
||||
daily_output = _normalize_pcse_output_records(simulation.get_output())
|
||||
summary_output = _normalize_pcse_output_records(simulation.get_summary_output())
|
||||
terminal_output = _normalize_pcse_output_records(simulation.get_terminal_output())
|
||||
return self._build_result(
|
||||
engine="pcse",
|
||||
daily_output=daily_output,
|
||||
summary_output=summary_output,
|
||||
terminal_output=terminal_output,
|
||||
)
|
||||
|
||||
def _build_result(
|
||||
self,
|
||||
*,
|
||||
engine: str,
|
||||
daily_output: list[dict[str, Any]],
|
||||
summary_output: list[dict[str, Any]],
|
||||
terminal_output: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
terminal = terminal_output[-1] if terminal_output else {}
|
||||
summary = summary_output[-1] if summary_output else {}
|
||||
final_daily = daily_output[-1] if daily_output else {}
|
||||
metrics = {
|
||||
"yield_estimate": _pick_first_not_none(
|
||||
terminal.get("TWSO"),
|
||||
summary.get("TWSO"),
|
||||
final_daily.get("TWSO"),
|
||||
),
|
||||
"biomass": _pick_first_not_none(
|
||||
terminal.get("TAGP"),
|
||||
summary.get("TAGP"),
|
||||
final_daily.get("TAGP"),
|
||||
),
|
||||
"max_lai": _pick_first_not_none(
|
||||
terminal.get("LAI"),
|
||||
summary.get("LAIMAX"),
|
||||
final_daily.get("LAI"),
|
||||
),
|
||||
}
|
||||
return {
|
||||
"engine": engine,
|
||||
"model_name": self.model_name,
|
||||
"metrics": _json_ready(metrics),
|
||||
"daily_output": _json_ready(daily_output),
|
||||
"summary_output": _json_ready(summary_output),
|
||||
"terminal_output": _json_ready(terminal_output),
|
||||
}
|
||||
|
||||
|
||||
class CropSimulationService:
|
||||
def __init__(self, manager: PcseSimulationManager | None = None):
|
||||
self.manager = manager or PcseSimulationManager()
|
||||
|
||||
def run_single_simulation(
|
||||
self,
|
||||
*,
|
||||
weather: Any,
|
||||
soil: dict[str, Any],
|
||||
crop_parameters: dict[str, Any],
|
||||
agromanagement: Any,
|
||||
site_parameters: dict[str, Any] | None = None,
|
||||
name: str = "",
|
||||
) -> dict[str, Any]:
|
||||
scenario = SimulationScenario.objects.create(
|
||||
name=name,
|
||||
scenario_type=SimulationScenario.ScenarioType.SINGLE,
|
||||
model_name=self.manager.model_name,
|
||||
input_payload=_json_ready(
|
||||
{
|
||||
"weather": weather,
|
||||
"soil": soil,
|
||||
"crop_parameters": crop_parameters,
|
||||
"site_parameters": site_parameters or {},
|
||||
"agromanagement": agromanagement,
|
||||
}
|
||||
),
|
||||
)
|
||||
run = SimulationRun.objects.create(
|
||||
scenario=scenario,
|
||||
run_key="single",
|
||||
label=name or "single",
|
||||
weather_payload=_json_ready(weather),
|
||||
soil_payload=_json_ready(soil),
|
||||
crop_payload=_json_ready(crop_parameters),
|
||||
site_payload=_json_ready(site_parameters or {}),
|
||||
agromanagement_payload=_json_ready(agromanagement),
|
||||
)
|
||||
return self._execute_scenario(
|
||||
scenario=scenario,
|
||||
run_specs=[
|
||||
{
|
||||
"instance": run,
|
||||
"weather": weather,
|
||||
"soil": soil,
|
||||
"crop_parameters": crop_parameters,
|
||||
"site_parameters": site_parameters or {},
|
||||
"agromanagement": agromanagement,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
def compare_crops(
|
||||
self,
|
||||
*,
|
||||
weather: Any,
|
||||
soil: dict[str, Any],
|
||||
crop_a: dict[str, Any],
|
||||
crop_b: dict[str, Any],
|
||||
agromanagement: Any,
|
||||
site_parameters: dict[str, Any] | None = None,
|
||||
name: str = "",
|
||||
) -> dict[str, Any]:
|
||||
scenario = SimulationScenario.objects.create(
|
||||
name=name,
|
||||
scenario_type=SimulationScenario.ScenarioType.CROP_COMPARISON,
|
||||
model_name=self.manager.model_name,
|
||||
input_payload=_json_ready(
|
||||
{
|
||||
"weather": weather,
|
||||
"soil": soil,
|
||||
"crop_a": crop_a,
|
||||
"crop_b": crop_b,
|
||||
"site_parameters": site_parameters or {},
|
||||
"agromanagement": agromanagement,
|
||||
}
|
||||
),
|
||||
)
|
||||
runs = [
|
||||
SimulationRun.objects.create(
|
||||
scenario=scenario,
|
||||
run_key="crop_a",
|
||||
label=crop_a.get("crop_name", "crop_a"),
|
||||
weather_payload=_json_ready(weather),
|
||||
soil_payload=_json_ready(soil),
|
||||
crop_payload=_json_ready(crop_a),
|
||||
site_payload=_json_ready(site_parameters or {}),
|
||||
agromanagement_payload=_json_ready(agromanagement),
|
||||
),
|
||||
SimulationRun.objects.create(
|
||||
scenario=scenario,
|
||||
run_key="crop_b",
|
||||
label=crop_b.get("crop_name", "crop_b"),
|
||||
weather_payload=_json_ready(weather),
|
||||
soil_payload=_json_ready(soil),
|
||||
crop_payload=_json_ready(crop_b),
|
||||
site_payload=_json_ready(site_parameters or {}),
|
||||
agromanagement_payload=_json_ready(agromanagement),
|
||||
),
|
||||
]
|
||||
return self._execute_scenario(
|
||||
scenario=scenario,
|
||||
run_specs=[
|
||||
{
|
||||
"instance": runs[0],
|
||||
"weather": weather,
|
||||
"soil": soil,
|
||||
"crop_parameters": crop_a,
|
||||
"site_parameters": site_parameters or {},
|
||||
"agromanagement": agromanagement,
|
||||
},
|
||||
{
|
||||
"instance": runs[1],
|
||||
"weather": weather,
|
||||
"soil": soil,
|
||||
"crop_parameters": crop_b,
|
||||
"site_parameters": site_parameters or {},
|
||||
"agromanagement": agromanagement,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
def compare_fertilization_strategies(
|
||||
self,
|
||||
*,
|
||||
weather: Any,
|
||||
soil: dict[str, Any],
|
||||
crop_parameters: dict[str, Any],
|
||||
strategies: list[dict[str, Any]],
|
||||
site_parameters: dict[str, Any] | None = None,
|
||||
name: str = "",
|
||||
) -> dict[str, Any]:
|
||||
if len(strategies) < 2:
|
||||
raise CropSimulationError("At least two fertilization strategies are required.")
|
||||
|
||||
scenario = SimulationScenario.objects.create(
|
||||
name=name,
|
||||
scenario_type=SimulationScenario.ScenarioType.FERTILIZATION_COMPARISON,
|
||||
model_name=self.manager.model_name,
|
||||
input_payload=_json_ready(
|
||||
{
|
||||
"weather": weather,
|
||||
"soil": soil,
|
||||
"crop_parameters": crop_parameters,
|
||||
"site_parameters": site_parameters or {},
|
||||
"strategies": strategies,
|
||||
}
|
||||
),
|
||||
)
|
||||
run_specs = []
|
||||
for index, strategy in enumerate(strategies, start=1):
|
||||
run = SimulationRun.objects.create(
|
||||
scenario=scenario,
|
||||
run_key=f"strategy_{index}",
|
||||
label=strategy.get("label", f"strategy_{index}"),
|
||||
weather_payload=_json_ready(weather),
|
||||
soil_payload=_json_ready(soil),
|
||||
crop_payload=_json_ready(crop_parameters),
|
||||
site_payload=_json_ready(site_parameters or {}),
|
||||
agromanagement_payload=_json_ready(strategy["agromanagement"]),
|
||||
)
|
||||
run_specs.append(
|
||||
{
|
||||
"instance": run,
|
||||
"weather": weather,
|
||||
"soil": soil,
|
||||
"crop_parameters": crop_parameters,
|
||||
"site_parameters": site_parameters or {},
|
||||
"agromanagement": strategy["agromanagement"],
|
||||
}
|
||||
)
|
||||
return self._execute_scenario(scenario=scenario, run_specs=run_specs)
|
||||
|
||||
def get_scenario_result(self, scenario_id: int) -> dict[str, Any]:
|
||||
scenario = SimulationScenario.objects.prefetch_related("runs").get(pk=scenario_id)
|
||||
return {
|
||||
"id": scenario.id,
|
||||
"name": scenario.name,
|
||||
"scenario_type": scenario.scenario_type,
|
||||
"status": scenario.status,
|
||||
"model_name": scenario.model_name,
|
||||
"input_payload": scenario.input_payload,
|
||||
"result_payload": scenario.result_payload,
|
||||
"error_message": scenario.error_message,
|
||||
"runs": [
|
||||
{
|
||||
"id": run.id,
|
||||
"run_key": run.run_key,
|
||||
"label": run.label,
|
||||
"status": run.status,
|
||||
"result_payload": run.result_payload,
|
||||
"error_message": run.error_message,
|
||||
}
|
||||
for run in scenario.runs.all()
|
||||
],
|
||||
}
|
||||
|
||||
def _execute_scenario(
|
||||
self,
|
||||
*,
|
||||
scenario: SimulationScenario,
|
||||
run_specs: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
scenario.status = SimulationScenario.Status.RUNNING
|
||||
scenario.error_message = ""
|
||||
scenario.save(update_fields=["status", "error_message", "updated_at"])
|
||||
|
||||
results = []
|
||||
try:
|
||||
for spec in run_specs:
|
||||
run = spec["instance"]
|
||||
run.status = SimulationScenario.Status.RUNNING
|
||||
run.error_message = ""
|
||||
run.save(update_fields=["status", "error_message", "updated_at"])
|
||||
result = self.manager.run_simulation(
|
||||
weather=spec["weather"],
|
||||
soil=spec["soil"],
|
||||
crop_parameters=spec["crop_parameters"],
|
||||
agromanagement=spec["agromanagement"],
|
||||
site_parameters=spec["site_parameters"],
|
||||
)
|
||||
run.status = SimulationScenario.Status.SUCCESS
|
||||
run.result_payload = result
|
||||
run.save(update_fields=["status", "result_payload", "updated_at"])
|
||||
results.append(
|
||||
{
|
||||
"run_key": run.run_key,
|
||||
"label": run.label,
|
||||
"result": result,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
message = str(exc)
|
||||
run = spec["instance"]
|
||||
run.status = SimulationScenario.Status.FAILURE
|
||||
run.error_message = message
|
||||
run.save(update_fields=["status", "error_message", "updated_at"])
|
||||
scenario.status = SimulationScenario.Status.FAILURE
|
||||
scenario.error_message = message
|
||||
scenario.result_payload = {"runs": results}
|
||||
scenario.save(
|
||||
update_fields=["status", "error_message", "result_payload", "updated_at"]
|
||||
)
|
||||
raise
|
||||
|
||||
scenario_result = self._build_scenario_result(scenario, results)
|
||||
scenario.status = SimulationScenario.Status.SUCCESS
|
||||
scenario.result_payload = scenario_result
|
||||
scenario.error_message = ""
|
||||
scenario.save(
|
||||
update_fields=["status", "result_payload", "error_message", "updated_at"]
|
||||
)
|
||||
return scenario_result
|
||||
|
||||
def _build_scenario_result(
|
||||
self,
|
||||
scenario: SimulationScenario,
|
||||
results: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
"scenario_id": scenario.id,
|
||||
"scenario_type": scenario.scenario_type,
|
||||
"status": SimulationScenario.Status.SUCCESS,
|
||||
"runs": results,
|
||||
}
|
||||
if scenario.scenario_type == SimulationScenario.ScenarioType.SINGLE:
|
||||
payload["result"] = results[0]["result"]
|
||||
return payload
|
||||
|
||||
run_metrics = [
|
||||
{
|
||||
"run_key": item["run_key"],
|
||||
"label": item["label"],
|
||||
"yield_estimate": float(item["result"]["metrics"]["yield_estimate"] or 0.0),
|
||||
"biomass": float(item["result"]["metrics"]["biomass"] or 0.0),
|
||||
}
|
||||
for item in results
|
||||
]
|
||||
best = max(run_metrics, key=lambda item: item["yield_estimate"])
|
||||
payload["comparison"] = {
|
||||
"best_run_key": best["run_key"],
|
||||
"best_label": best["label"],
|
||||
"best_yield_estimate": best["yield_estimate"],
|
||||
"runs": run_metrics,
|
||||
}
|
||||
if scenario.scenario_type == SimulationScenario.ScenarioType.CROP_COMPARISON:
|
||||
payload["comparison"]["yield_gap"] = round(
|
||||
abs(run_metrics[0]["yield_estimate"] - run_metrics[1]["yield_estimate"]),
|
||||
3,
|
||||
)
|
||||
if (
|
||||
scenario.scenario_type
|
||||
== SimulationScenario.ScenarioType.FERTILIZATION_COMPARISON
|
||||
):
|
||||
payload["recommendation"] = {
|
||||
"recommended_run_key": best["run_key"],
|
||||
"recommended_label": best["label"],
|
||||
"expected_yield_estimate": best["yield_estimate"],
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def run_single_simulation(**kwargs) -> dict[str, Any]:
|
||||
return CropSimulationService().run_single_simulation(**kwargs)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def compare_crops(**kwargs) -> dict[str, Any]:
|
||||
return CropSimulationService().compare_crops(**kwargs)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def compare_fertilization_strategies(**kwargs) -> dict[str, Any]:
|
||||
return CropSimulationService().compare_fertilization_strategies(**kwargs)
|
||||
@@ -0,0 +1,63 @@
|
||||
import importlib.util
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from collections import namedtuple
|
||||
from datetime import date, timedelta
|
||||
from unittest import skipUnless
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from crop_simulation.services import CropSimulationService, PcseSimulationManager
|
||||
|
||||
|
||||
@skipUnless(
|
||||
importlib.util.find_spec("pcse") is not None,
|
||||
"pcse must be installed to run the real WOFOST test.",
|
||||
)
|
||||
class CropSimulationSingleRunTest(TestCase):
|
||||
def test_single_simulation_prints_response(self):
|
||||
os.environ["HOME"] = tempfile.mkdtemp(prefix="pcse-home-", dir="/tmp")
|
||||
from pcse import settings as pcse_settings
|
||||
from pcse.tests.db_input import (
|
||||
AgroManagementDataProvider,
|
||||
GridWeatherDataProvider,
|
||||
fetch_cropdata,
|
||||
fetch_sitedata,
|
||||
fetch_soildata,
|
||||
)
|
||||
|
||||
def namedtuple_factory(cursor, row):
|
||||
fields = [column[0] for column in cursor.description]
|
||||
cls = namedtuple("Row", fields)
|
||||
return cls._make(row)
|
||||
|
||||
db_path = os.path.join(pcse_settings.PCSE_USER_HOME, "pcse.db")
|
||||
connection = sqlite3.connect(db_path)
|
||||
connection.row_factory = namedtuple_factory
|
||||
|
||||
grid = int(os.environ.get("PCSE_TEST_GRID", "31031"))
|
||||
crop_no = int(os.environ.get("PCSE_TEST_CROP_NO", "1"))
|
||||
year = int(os.environ.get("PCSE_TEST_YEAR", "2000"))
|
||||
|
||||
weather = GridWeatherDataProvider(connection, grid_no=grid).export()
|
||||
soil = fetch_soildata(connection, grid)
|
||||
site = fetch_sitedata(connection, grid, year)
|
||||
crop_parameters = fetch_cropdata(connection, grid, year, crop_no)
|
||||
agromanagement = AgroManagementDataProvider(connection, grid, crop_no, year)
|
||||
|
||||
response = CropSimulationService(
|
||||
manager=PcseSimulationManager(model_name="Wofost72_WLP_CWB")
|
||||
).run_single_simulation(
|
||||
weather=weather,
|
||||
soil=soil,
|
||||
crop_parameters=crop_parameters,
|
||||
agromanagement=agromanagement,
|
||||
site_parameters=site,
|
||||
name="single real wofost run",
|
||||
)
|
||||
|
||||
connection.close()
|
||||
print("\nCrop Simulation Response:\n", response)
|
||||
self.assertEqual(response["result"]["engine"], "pcse")
|
||||
self.assertIn("yield_estimate", response["result"]["metrics"])
|
||||
@@ -0,0 +1,199 @@
|
||||
import importlib.util
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from collections import namedtuple
|
||||
from datetime import date, timedelta
|
||||
from unittest.mock import patch
|
||||
from unittest import skipUnless
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from .models import SimulationRun, SimulationScenario
|
||||
from .services import CropSimulationService, CropSimulationError, PcseSimulationManager
|
||||
|
||||
|
||||
def build_weather(days: int = 5) -> list[dict]:
|
||||
start = date(2026, 4, 1)
|
||||
return [
|
||||
{
|
||||
"DAY": start + timedelta(days=index),
|
||||
"LAT": 35.7,
|
||||
"LON": 51.4,
|
||||
"ELEV": 1200,
|
||||
"IRRAD": 16_000_000 + (index * 100_000),
|
||||
"TMIN": 11 + index,
|
||||
"TMAX": 22 + index,
|
||||
"VAP": 12,
|
||||
"WIND": 2.4,
|
||||
"RAIN": 0.8,
|
||||
"E0": 0.35,
|
||||
"ES0": 0.3,
|
||||
"ET0": 0.32,
|
||||
}
|
||||
for index in range(days)
|
||||
]
|
||||
|
||||
|
||||
def build_agromanagement(n_amount: float = 30.0) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
date(2026, 4, 1): {
|
||||
"CropCalendar": {
|
||||
"crop_name": "wheat",
|
||||
"variety_name": "winter-wheat",
|
||||
"crop_start_date": date(2026, 4, 5),
|
||||
"crop_start_type": "sowing",
|
||||
"crop_end_date": date(2026, 9, 1),
|
||||
"crop_end_type": "harvest",
|
||||
"max_duration": 180,
|
||||
},
|
||||
"TimedEvents": [
|
||||
{
|
||||
"event_signal": "apply_n",
|
||||
"name": "N strategy",
|
||||
"events_table": [
|
||||
{
|
||||
date(2026, 4, 20): {
|
||||
"N_amount": n_amount,
|
||||
"N_recovery": 0.7,
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"StateEvents": [],
|
||||
}
|
||||
},
|
||||
{},
|
||||
]
|
||||
|
||||
|
||||
class CropSimulationServiceTests(TestCase):
|
||||
def setUp(self):
|
||||
self.service = CropSimulationService()
|
||||
self.weather = build_weather()
|
||||
self.soil = {"SMFCF": 0.34, "SMW": 0.12, "RDMSOL": 120.0}
|
||||
self.site = {"WAV": 40.0}
|
||||
self.crop = {"crop_name": "wheat", "TSUM1": 800, "YIELD_SCALE": 1.0}
|
||||
|
||||
def test_failure_marks_scenario_and_run_failed(self):
|
||||
with patch.object(
|
||||
self.service.manager,
|
||||
"run_simulation",
|
||||
side_effect=CropSimulationError("pcse failed"),
|
||||
):
|
||||
with self.assertRaises(CropSimulationError):
|
||||
self.service.run_single_simulation(
|
||||
weather=self.weather,
|
||||
soil=self.soil,
|
||||
crop_parameters=self.crop,
|
||||
agromanagement=build_agromanagement(),
|
||||
site_parameters=self.site,
|
||||
name="broken run",
|
||||
)
|
||||
|
||||
scenario = SimulationScenario.objects.get()
|
||||
run = SimulationRun.objects.get()
|
||||
|
||||
self.assertEqual(scenario.status, SimulationScenario.Status.FAILURE)
|
||||
self.assertEqual(run.status, SimulationScenario.Status.FAILURE)
|
||||
self.assertEqual(scenario.error_message, "pcse failed")
|
||||
|
||||
def test_requires_at_least_two_fertilization_strategies(self):
|
||||
with self.assertRaises(CropSimulationError):
|
||||
self.service.compare_fertilization_strategies(
|
||||
weather=self.weather,
|
||||
soil=self.soil,
|
||||
crop_parameters=self.crop,
|
||||
strategies=[{"label": "only", "agromanagement": build_agromanagement()}],
|
||||
site_parameters=self.site,
|
||||
)
|
||||
|
||||
def test_raises_clear_error_when_pcse_is_unavailable(self):
|
||||
with patch("crop_simulation.services._load_pcse_bindings", return_value=None):
|
||||
with self.assertRaisesMessage(
|
||||
CropSimulationError,
|
||||
"PCSE is not installed or required PCSE classes could not be loaded.",
|
||||
):
|
||||
self.service.run_single_simulation(
|
||||
weather=self.weather,
|
||||
soil=self.soil,
|
||||
crop_parameters=self.crop,
|
||||
agromanagement=build_agromanagement(),
|
||||
site_parameters=self.site,
|
||||
name="missing pcse",
|
||||
)
|
||||
|
||||
|
||||
@skipUnless(
|
||||
importlib.util.find_spec("pcse") is not None,
|
||||
"pcse must be installed to run real WOFOST integration tests.",
|
||||
)
|
||||
class CropSimulationPcseIntegrationTests(TestCase):
|
||||
def setUp(self):
|
||||
os.environ["HOME"] = tempfile.mkdtemp(prefix="pcse-home-", dir="/tmp")
|
||||
from pcse import settings as pcse_settings
|
||||
from pcse.tests.db_input import (
|
||||
AgroManagementDataProvider,
|
||||
GridWeatherDataProvider,
|
||||
fetch_cropdata,
|
||||
fetch_sitedata,
|
||||
fetch_soildata,
|
||||
)
|
||||
|
||||
def namedtuple_factory(cursor, row):
|
||||
fields = [column[0] for column in cursor.description]
|
||||
cls = namedtuple("Row", fields)
|
||||
return cls._make(row)
|
||||
|
||||
self.grid = int(os.environ.get("PCSE_TEST_GRID", "31031"))
|
||||
self.crop_no = int(os.environ.get("PCSE_TEST_CROP_NO", "1"))
|
||||
self.year = int(os.environ.get("PCSE_TEST_YEAR", "2000"))
|
||||
|
||||
self.connection = sqlite3.connect(
|
||||
os.path.join(pcse_settings.PCSE_USER_HOME, "pcse.db")
|
||||
)
|
||||
self.connection.row_factory = namedtuple_factory
|
||||
|
||||
self.weather = GridWeatherDataProvider(
|
||||
self.connection,
|
||||
grid_no=self.grid,
|
||||
).export()
|
||||
self.soil = fetch_soildata(self.connection, self.grid)
|
||||
self.site = fetch_sitedata(self.connection, self.grid, self.year)
|
||||
self.crop = fetch_cropdata(
|
||||
self.connection,
|
||||
self.grid,
|
||||
self.year,
|
||||
self.crop_no,
|
||||
)
|
||||
self.agromanagement = AgroManagementDataProvider(
|
||||
self.connection,
|
||||
self.grid,
|
||||
self.crop_no,
|
||||
self.year,
|
||||
)
|
||||
self.service = CropSimulationService(
|
||||
manager=PcseSimulationManager(model_name="Wofost72_WLP_CWB")
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.connection.close()
|
||||
|
||||
def test_real_wofost_execute_full_service_path(self):
|
||||
result = self.service.run_single_simulation(
|
||||
weather=self.weather,
|
||||
soil=self.soil,
|
||||
crop_parameters=self.crop,
|
||||
agromanagement=self.agromanagement,
|
||||
site_parameters=self.site,
|
||||
name="pcse path",
|
||||
)
|
||||
|
||||
scenario = SimulationScenario.objects.get()
|
||||
|
||||
self.assertEqual(scenario.status, SimulationScenario.Status.SUCCESS)
|
||||
self.assertEqual(result["result"]["engine"], "pcse")
|
||||
self.assertIsNotNone(result["result"]["metrics"]["yield_estimate"])
|
||||
self.assertIsNotNone(result["result"]["metrics"]["biomass"])
|
||||
+8105
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -4,7 +4,12 @@ Adapter Pattern برای API providers — سوئیچ بین GapGPT، Avalai و
|
||||
import logging
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError: # pragma: no cover - optional for stripped test envs
|
||||
class OpenAI: # type: ignore[override]
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise ImportError("openai package is required for RAG clients.")
|
||||
|
||||
from .config import RAGConfig, load_rag_config
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ openai>=1.0,<1.40
|
||||
|
||||
# === NumPy (pinned for Python 3.10 compatibility) ===
|
||||
numpy>=1.23,<1.27
|
||||
pcse
|
||||
|
||||
# === Vector Databases ===
|
||||
chromadb>=0.4.24,<0.5
|
||||
|
||||
Reference in New Issue
Block a user