diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 79387eb..d8b7deb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,7 +41,12 @@ jobs: name: test with OCB services: postgres: - image: postgres:13 + # postgis/postgis carries the same Postgres major version as the + # upstream OCA template (13) but bundles PostGIS so base_geoengine + # (and any farm.field geometry module) can `CREATE EXTENSION postgis`. + # Drop back to `postgres:13` when farm-pack no longer ships geo + # modules that need PostGIS. + image: postgis/postgis:13-3.4-alpine env: POSTGRES_USER: odoo POSTGRES_PASSWORD: odoo diff --git a/farm_field_geo/README.rst b/farm_field_geo/README.rst new file mode 100644 index 0000000..f206374 --- /dev/null +++ b/farm_field_geo/README.rst @@ -0,0 +1,125 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +======================= +Farm Field — Geospatial +======================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:156d6ff91db3f7522e9501d6a413cbc2a2e29881632c0f19543ed5de002262fc + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-ledoent%2Ffarm--pack-lightgray.png?logo=github + :target: https://github.com/ledoent/farm-pack/tree/19.0/farm_field_geo + :alt: ledoent/farm-pack + +|badge1| |badge2| |badge3| + +Adds PostGIS polygon boundaries to ``farm.field`` and auto-computes +acreage from the geometry. + +Without this module, ``farm.field.acres`` is a manual decimal entry. +With it, set a WGS84 polygon as the field's boundary and the acreage +updates from the polygon area, reprojected to EPSG:5070 (Conus Albers +Equal Area) — the projection NRCS and NASS use for lower-48 area +calculations, so the number matches what soil-survey and yield tooling +would report for the same parcel. + +``acres`` remains editable as a fallback: fields without a digitized +boundary keep working with a manual estimate, and the compute only fires +once a polygon is set. + +**Scope.** This module ships a single ``GeoPolygon`` per field — +non-contiguous plots (two disconnected polygons that are +administratively one "field") should be recorded as separate +``farm.field`` records for now. A future ``farm_field_multi_polygon`` +extension can promote the column to ``GeoMultiPolygon`` if the demand +materializes. + +**Install requirement.** The Postgres instance must have PostGIS +enabled. ``base_geoengine`` declares the extension; install will fail +cleanly if the extension is missing. The base ``farm_field`` module +stays usable without PostGIS — install this extension only when geometry +support is wanted. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +1. Open Farm → Fields → Fields and pick a field. +2. The **Boundary** notebook tab renders a Leaflet map of the field's + polygon. +3. Set the polygon via the geoengine map view (vertex drag works there) + or by importing a WKT string. In-form vertex editing through the + boundary tab arrives once ``web_leaflet_draw_lib`` is migrated to + Odoo 19. +4. Save. The **Acres** field auto-recomputes from the polygon area, + reprojected to EPSG:5070 Albers Equal Area. +5. Switch the action's view to **Map** (geoengine) to see every field at + once. + +The ``acres`` field stays editable as a fallback: fields without a +digitized boundary can carry a manual estimate. Once a polygon is set, +the compute overrides the manual value. + +Multi-plot fields (a "north 40" that is two non-contiguous polygons) +need ``GeoMultiPolygon`` rather than ``GeoPolygon`` — out of scope for +the MVP; record each plot as its own ``farm.field`` for now. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Ledo Enterprises + +Contributors +------------ + +- Daniel Kendall + +Maintainers +----------- + +.. |maintainer-dnplkndll| image:: https://github.com/dnplkndll.png?size=40px + :target: https://github.com/dnplkndll + :alt: dnplkndll + +Current maintainer: + +|maintainer-dnplkndll| + +This module is part of the `ledoent/farm-pack `_ project on GitHub. + +You are welcome to contribute. diff --git a/farm_field_geo/__init__.py b/farm_field_geo/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/farm_field_geo/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/farm_field_geo/__manifest__.py b/farm_field_geo/__manifest__.py new file mode 100644 index 0000000..ed144e2 --- /dev/null +++ b/farm_field_geo/__manifest__.py @@ -0,0 +1,28 @@ +{ + "name": "Farm Field — Geospatial", + "version": "19.0.1.0.0", + "summary": "PostGIS polygons + auto-computed acreage for farm.field", + "author": "Ledo Enterprises, Odoo Community Association (OCA)", + "maintainers": ["dnplkndll"], + "website": "https://github.com/ledoent/farm-pack", + "license": "AGPL-3", + "category": "Vertical/Agriculture", + # Alpha matches farm_field; OCA's check-dev-status job rejects higher + # dev-status modules depending on lower-status ones. + "development_status": "Alpha", + "depends": [ + "farm_field", + "base_geoengine", + ], + "external_dependencies": { + # pyproj: reproject WGS84 polygon → EPSG:5070 Albers for acreage. + # shapely: base_geoengine already requires it; listed here to make + # the dep graph explicit (CI's oca_install_addons uses this list). + "python": ["pyproj", "shapely"], + }, + "data": [ + "views/farm_field_views.xml", + ], + "installable": True, + "application": False, +} diff --git a/farm_field_geo/models/__init__.py b/farm_field_geo/models/__init__.py new file mode 100644 index 0000000..ec95450 --- /dev/null +++ b/farm_field_geo/models/__init__.py @@ -0,0 +1 @@ +from . import farm_field diff --git a/farm_field_geo/models/farm_field.py b/farm_field_geo/models/farm_field.py new file mode 100644 index 0000000..1f920aa --- /dev/null +++ b/farm_field_geo/models/farm_field.py @@ -0,0 +1,52 @@ +from functools import lru_cache + +from pyproj import Transformer +from shapely.ops import transform as shapely_transform + +from odoo import api, fields, models + +# Square meters in one US survey acre. +M2_PER_ACRE = 4046.8564224 + + +@lru_cache(maxsize=1) +def _wgs84_to_albers_conus(): + """Build the EPSG:4326 → EPSG:5070 transformer once and reuse it. + + EPSG:5070 (Conus Albers Equal Area) is what NRCS and NASS use for + lower-48 acreage. Reprojecting the WGS84 polygon there before measuring + area gives a number that matches soil-survey + yield tooling for the + same parcel. + """ + return Transformer.from_crs("EPSG:4326", "EPSG:5070", always_xy=True).transform + + +class FarmField(models.Model): + _inherit = "farm.field" + + geom = fields.GeoPolygon( + string="Boundary", + srid=4326, + help="Field boundary as a WGS84 polygon. Drives the computed acreage; " + "stored as PostGIS geometry.", + ) + acres = fields.Float( + compute="_compute_acres_from_geom", + store=True, + readonly=False, + digits=(8, 2), + help="Auto-computed from the polygon area when a boundary is drawn. " + "Editable as a fallback for fields without a digitized boundary yet.", + ) + + @api.depends("geom") + def _compute_acres_from_geom(self): + # base_geoengine returns the field as a shapely geometry on read. + # shapely has no `.transform()` method — reproject explicitly via + # pyproj + shapely.ops.transform before measuring area. + transformer = _wgs84_to_albers_conus() + for rec in self: + if not rec.geom: + continue + projected = shapely_transform(transformer, rec.geom) + rec.acres = projected.area / M2_PER_ACRE diff --git a/farm_field_geo/pyproject.toml b/farm_field_geo/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/farm_field_geo/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/farm_field_geo/readme/CONTRIBUTORS.md b/farm_field_geo/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..c71b705 --- /dev/null +++ b/farm_field_geo/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Daniel Kendall <dkendall@ledoweb.com> diff --git a/farm_field_geo/readme/DESCRIPTION.md b/farm_field_geo/readme/DESCRIPTION.md new file mode 100644 index 0000000..2f68ede --- /dev/null +++ b/farm_field_geo/readme/DESCRIPTION.md @@ -0,0 +1,23 @@ +Adds PostGIS polygon boundaries to `farm.field` and auto-computes acreage +from the geometry. + +Without this module, `farm.field.acres` is a manual decimal entry. With it, +set a WGS84 polygon as the field's boundary and the acreage updates from the +polygon area, reprojected to EPSG:5070 (Conus Albers Equal Area) — the +projection NRCS and NASS use for lower-48 area calculations, so the number +matches what soil-survey and yield tooling would report for the same parcel. + +`acres` remains editable as a fallback: fields without a digitized boundary +keep working with a manual estimate, and the compute only fires once a +polygon is set. + +**Scope.** This module ships a single `GeoPolygon` per field — non-contiguous +plots (two disconnected polygons that are administratively one "field") +should be recorded as separate `farm.field` records for now. A future +`farm_field_multi_polygon` extension can promote the column to +`GeoMultiPolygon` if the demand materializes. + +**Install requirement.** The Postgres instance must have PostGIS enabled. +`base_geoengine` declares the extension; install will fail cleanly if the +extension is missing. The base `farm_field` module stays usable without +PostGIS — install this extension only when geometry support is wanted. diff --git a/farm_field_geo/readme/USAGE.md b/farm_field_geo/readme/USAGE.md new file mode 100644 index 0000000..61bd247 --- /dev/null +++ b/farm_field_geo/readme/USAGE.md @@ -0,0 +1,16 @@ +1. Open Farm → Fields → Fields and pick a field. +2. The **Boundary** notebook tab renders a Leaflet map of the field's polygon. +3. Set the polygon via the geoengine map view (vertex drag works there) or by + importing a WKT string. In-form vertex editing through the boundary tab + arrives once `web_leaflet_draw_lib` is migrated to Odoo 19. +4. Save. The **Acres** field auto-recomputes from the polygon area, reprojected + to EPSG:5070 Albers Equal Area. +5. Switch the action's view to **Map** (geoengine) to see every field at once. + +The `acres` field stays editable as a fallback: fields without a digitized +boundary can carry a manual estimate. Once a polygon is set, the compute +overrides the manual value. + +Multi-plot fields (a "north 40" that is two non-contiguous polygons) need +`GeoMultiPolygon` rather than `GeoPolygon` — out of scope for the MVP; +record each plot as its own `farm.field` for now. diff --git a/farm_field_geo/readme/newsfragments/.gitkeep b/farm_field_geo/readme/newsfragments/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/farm_field_geo/static/description/index.html b/farm_field_geo/static/description/index.html new file mode 100644 index 0000000..d80c7ec --- /dev/null +++ b/farm_field_geo/static/description/index.html @@ -0,0 +1,474 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Farm Field — Geospatial

+ +

Alpha License: AGPL-3 ledoent/farm-pack

+

Adds PostGIS polygon boundaries to farm.field and auto-computes +acreage from the geometry.

+

Without this module, farm.field.acres is a manual decimal entry. +With it, set a WGS84 polygon as the field’s boundary and the acreage +updates from the polygon area, reprojected to EPSG:5070 (Conus Albers +Equal Area) — the projection NRCS and NASS use for lower-48 area +calculations, so the number matches what soil-survey and yield tooling +would report for the same parcel.

+

acres remains editable as a fallback: fields without a digitized +boundary keep working with a manual estimate, and the compute only fires +once a polygon is set.

+

Scope. This module ships a single GeoPolygon per field — +non-contiguous plots (two disconnected polygons that are +administratively one “field”) should be recorded as separate +farm.field records for now. A future farm_field_multi_polygon +extension can promote the column to GeoMultiPolygon if the demand +materializes.

+

Install requirement. The Postgres instance must have PostGIS +enabled. base_geoengine declares the extension; install will fail +cleanly if the extension is missing. The base farm_field module +stays usable without PostGIS — install this extension only when geometry +support is wanted.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Usage

+
    +
  1. Open Farm → Fields → Fields and pick a field.
  2. +
  3. The Boundary notebook tab renders a Leaflet map of the field’s +polygon.
  4. +
  5. Set the polygon via the geoengine map view (vertex drag works there) +or by importing a WKT string. In-form vertex editing through the +boundary tab arrives once web_leaflet_draw_lib is migrated to +Odoo 19.
  6. +
  7. Save. The Acres field auto-recomputes from the polygon area, +reprojected to EPSG:5070 Albers Equal Area.
  8. +
  9. Switch the action’s view to Map (geoengine) to see every field at +once.
  10. +
+

The acres field stays editable as a fallback: fields without a +digitized boundary can carry a manual estimate. Once a polygon is set, +the compute overrides the manual value.

+

Multi-plot fields (a “north 40” that is two non-contiguous polygons) +need GeoMultiPolygon rather than GeoPolygon — out of scope for +the MVP; record each plot as its own farm.field for now.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Ledo Enterprises
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

Current maintainer:

+

dnplkndll

+

This module is part of the ledoent/farm-pack project on GitHub.

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/farm_field_geo/tests/__init__.py b/farm_field_geo/tests/__init__.py new file mode 100644 index 0000000..93da03c --- /dev/null +++ b/farm_field_geo/tests/__init__.py @@ -0,0 +1 @@ +from . import test_farm_field_geo diff --git a/farm_field_geo/tests/test_farm_field_geo.py b/farm_field_geo/tests/test_farm_field_geo.py new file mode 100644 index 0000000..672e71b --- /dev/null +++ b/farm_field_geo/tests/test_farm_field_geo.py @@ -0,0 +1,96 @@ +from shapely.geometry import Polygon + +from odoo.tests.common import TransactionCase + + +def _polygon(min_lon, min_lat, max_lon, max_lat): + """Return a Shapely polygon — base_geoengine's GeoPolygon field accepts + shapely geometries via to_column_format(); EWKT strings raise hex-parse + errors because the field's serializer expects EWKB-hex on write paths. + """ + return Polygon( + [ + (min_lon, min_lat), + (max_lon, min_lat), + (max_lon, max_lat), + (min_lon, max_lat), + (min_lon, min_lat), + ] + ) + + +# Reference rectangle: ~0.01° square around Ligonier PA (40.242N, 79.245W). +# Albers reprojection of this size is ~233 acres — assertion ranges below +# leave generous slack for projection edge cases. +LIGONIER_BIG = _polygon(-79.245, 40.242, -79.235, 40.252) +LIGONIER_SMALL = _polygon(-79.245, 40.242, -79.244, 40.243) + + +class TestFarmFieldGeo(TransactionCase): + """Verify the polygon → acreage compute and its interaction with manual entry.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.farm = cls.env["res.partner"].create( + {"name": "Test Farm", "is_company": True} + ) + cls.crop = cls.env["farm.crop"].create({"name": "Corn"}) + + def _make_field(self, geom=None, acres=None): + vals = { + "name": "North 40", + "farm_partner_id": self.farm.id, + "crop_id": self.crop.id, + } + if geom is not None: + vals["geom"] = geom + if acres is not None: + vals["acres"] = acres + return self.env["farm.field"].create(vals) + + def test_acres_manual_entry_persists_without_geom(self): + # No polygon drawn yet — the user can set acres manually and the + # compute must leave the manual value untouched. (The parent + # `farm.field.acres` already defaults to 0.0, so to actually probe + # the compute behaviour we set a distinctive value here.) + field = self._make_field(acres=12.5) + field.invalidate_recordset() + self.assertEqual(field.acres, 12.5) + + def test_acres_compute_from_polygon(self): + # ~0.01° square at 40N: one deg longitude is ~85 km here and one + # deg latitude is ~111 km, so the rectangle is ~850 m × 1110 m ≈ + # 943,500 m² ≈ 233 acres. Allow generous slack for projection. + field = self._make_field(geom=LIGONIER_BIG) + self.assertGreater(field.acres, 200.0) + self.assertLess(field.acres, 260.0) + + def test_acres_compute_overrides_manual_entry_on_geom_set(self): + # User started with a manual estimate, then drew the boundary. + # The compute should win. + field = self._make_field(acres=999.0) + self.assertEqual(field.acres, 999.0) + field.geom = LIGONIER_BIG + field.flush_recordset() + field.invalidate_recordset() + self.assertNotEqual(field.acres, 999.0) + self.assertGreater(field.acres, 200.0) + + def test_acres_recomputes_on_geom_change(self): + field = self._make_field(geom=LIGONIER_SMALL) + small_acres = field.acres + self.assertGreater(small_acres, 0.0) + + field.geom = LIGONIER_BIG + field.flush_recordset() + field.invalidate_recordset() + self.assertGreater( + field.acres, small_acres * 50, "Bigger polygon → bigger acreage" + ) + + def test_acres_deterministic_for_identical_polygon(self): + # Two fields with the same boundary must report the same acreage. + a = self._make_field(geom=LIGONIER_BIG) + b = self._make_field(geom=LIGONIER_BIG) + self.assertEqual(a.acres, b.acres) diff --git a/farm_field_geo/views/farm_field_views.xml b/farm_field_geo/views/farm_field_views.xml new file mode 100644 index 0000000..46fc4c3 --- /dev/null +++ b/farm_field_geo/views/farm_field_views.xml @@ -0,0 +1,46 @@ + + + + + farm.field.form.geo + farm.field + + + + + + + + + + + + + farm.field.geoengine + farm.field + + + + + + + + + + + + + + geoengine + + + + diff --git a/requirements.txt b/requirements.txt index 56190be..b863f07 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ # generated from manifests external_dependencies intuit-oauth +pyproj python-quickbooks +shapely diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..db16359 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,11 @@ +# [DON'T MERGE STRAIGHT TO MAIN] Pin OCA/geospatial 19.0 MIG branches that +# the farm_field_geo / farm_field_overlays modules depend on. Both modules +# fail the OCA `Detect unreleased dependencies` check while these upstream +# PRs are still open — accepted trade-off per the documented convention. +# +# Remove the pins once the upstream MIGs land: +# - base_geoengine: OCA/geospatial PR #446 (leNeo) +# - web_leaflet_lib + web_leaflet_draw_lib: ledoent/geospatial PR #1 +odoo-addon-base_geoengine @ git+https://github.com/leNeo/geospatial.git@19.0#subdirectory=base_geoengine +odoo-addon-web_leaflet_lib @ git+https://github.com/ledoent/geospatial.git@19.0-mig-web_leaflet_lib#subdirectory=web_leaflet_lib +odoo-addon-web_leaflet_draw_lib @ git+https://github.com/ledoent/geospatial.git@19.0-mig-web_leaflet_lib#subdirectory=web_leaflet_draw_lib