Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
- Tous les labels proviennent du jeu de données [Données des entreprises utilisées dans l'Annuaire des Entreprises](https://www.data.gouv.fr/datasets/donnees-des-entreprises-utilisees-dans-lannuaire-des-entreprises/), qui agrège les sources primaires.
- Retry sur les téléchargements SIRENE du prétraitement mensuel (unités légales, établissements)
- Correction du type déclaré de `acheteur_id`, `titulaire_id` et `lieuExecution_code` dans le schéma publié (`schema.json`) : `string` au lieu de `integer`.
- Relecture des `dateNotification` issues d'une conversion ratée, départagée entre les deux formats source en cause (`JJ-MM-AA` lu comme `AAAA-MM-JJ`, et ordre ISO avec une année sur deux chiffres) par la date de publication puis par le millésime de l'identifiant du marché ([#191](https://github.com/ColinMaudry/decp-processing/issues/191))

#### 2.14.0 2026-08-17

Expand Down
20 changes: 20 additions & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,26 @@ def make_sirene_data_dir(sirene_data_parent_dir) -> Path:
ALL_CONFIG["ANOMALY_PAIRS_ABERRANT_THRESHOLD"] = ANOMALY_PAIRS_ABERRANT_THRESHOLD
ALL_CONFIG["ANOMALY_GROUPE_MIN_SIZE"] = ANOMALY_GROUPE_MIN_SIZE

# === Détection des dates invraisemblables ===
# https://github.com/ColinMaudry/decp-processing/issues/191

# Année plancher en deçà de laquelle une date est jugée invraisemblable
DATE_ANNEE_MIN = int(os.getenv("DATE_ANNEE_MIN", "2000"))
ALL_CONFIG["DATE_ANNEE_MIN"] = DATE_ANNEE_MIN

# Siècle ajouté à l'année sur deux chiffres d'une date relue
DATE_SIECLE_ANNEE_COURTE = 2000

# Écart notification/publication au-delà duquel une relecture est écartée (5 ans)
DATE_ECART_PUBLICATION_MAX_JOURS = int(
os.getenv("DATE_ECART_PUBLICATION_MAX_JOURS", "1825")
)

# Écart maximal entre l'année d'une relecture et le millésime lu dans l'identifiant
# du marché, second arbitre utilisé quand la date de publication manque
DATE_ECART_MILLESIME_MAX_ANNEES = int(os.getenv("DATE_ECART_MILLESIME_MAX_ANNEES", "2"))
ALL_CONFIG["DATE_ECART_PUBLICATION_MAX_JOURS"] = DATE_ECART_PUBLICATION_MAX_JOURS

COLUMNS_TO_DROP = [
# Pas encore incluses
"actesSousTraitance",
Expand Down
92 changes: 91 additions & 1 deletion src/tasks/clean.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,21 @@
import polars as pl
from polars import selectors as cs

from src.config import DecpFormat
from src.config import (
DATE_ANNEE_MIN,
DATE_ECART_MILLESIME_MAX_ANNEES,
DecpFormat,
)
from src.tasks.transform import (
apply_modifications,
)
from src.tasks.utils import (
date_coherente_avec_expr,
date_non_future_expr,
millesime_identifiant_expr,
relire_date_aammjj_expr,
relire_date_jjmmaa_expr,
)


def clean_decp(lf: pl.LazyFrame, decp_format: DecpFormat) -> pl.LazyFrame:
Expand Down Expand Up @@ -123,6 +134,8 @@ def clean_decp(lf: pl.LazyFrame, decp_format: DecpFormat) -> pl.LazyFrame:
.name.keep()
)

lf = relire_dates_mal_converties(lf)

# Nature
lf = lf.with_columns(
pl.col("nature")
Expand Down Expand Up @@ -275,6 +288,83 @@ def clean_titulaires(lf: pl.LazyFrame, decp_format: DecpFormat, column) -> pl.La
return lf


def relire_dates_mal_converties(lf: pl.LazyFrame) -> pl.LazyFrame:
"""Relit les dateNotification dont la conversion source a échoué (#191).

Deux formats source distincts produisent une année antérieure à DATE_ANNEE_MIN :
JJ-MM-AA lu comme AAAA-MM-JJ, et l'ordre ISO avec une année sur deux chiffres.
Les deux relectures sont mises en concurrence et départagées par deux arbitres
successifs : la datePublicationDonnees, que la notification doit précéder, puis
à défaut le millésime lu dans l'identifiant du marché, dont la relecture retenue
doit être la plus proche.

Sans départage net, la date est laissée telle quelle. La colonne reste une
chaîne : le typage a lieu plus tard, dans fix_data_types.
"""
notification = pl.col("dateNotification").str.strptime(
pl.Date, format="%Y-%m-%d", strict=False
)
publication = pl.col("datePublicationDonnees").str.strptime(
pl.Date, format="%Y-%m-%d", strict=False
)

douteuse = notification.is_not_null() & (notification.dt.year() < DATE_ANNEE_MIN)

# Une publication elle-même douteuse ne peut pas servir d'arbitre.
reference = (
pl.when(publication.dt.year() >= DATE_ANNEE_MIN)
.then(publication)
.otherwise(None)
)

jjmmaa = relire_date_jjmmaa_expr(notification)
aammjj = relire_date_aammjj_expr(notification)
jjmmaa_suit_publication = date_coherente_avec_expr(jjmmaa, reference)
aammjj_suit_publication = date_coherente_avec_expr(aammjj, reference)

pub_dit_jjmmaa = jjmmaa_suit_publication & ~aammjj_suit_publication
pub_dit_aammjj = aammjj_suit_publication & ~jjmmaa_suit_publication
pub_a_tranche = pub_dit_jjmmaa | pub_dit_aammjj

millesime = millesime_identifiant_expr(pl.col("id"))
ecart_jjmmaa = (jjmmaa.dt.year() - millesime).abs()
ecart_aammjj = (aammjj.dt.year() - millesime).abs()

# Le millésime ne retient pas une relecture que la publication dément.
id_dit_jjmmaa = (
(ecart_jjmmaa < ecart_aammjj)
& (ecart_jjmmaa <= DATE_ECART_MILLESIME_MAX_ANNEES)
& (reference.is_null() | jjmmaa_suit_publication)
)
id_dit_aammjj = (
(ecart_aammjj < ecart_jjmmaa)
& (ecart_aammjj <= DATE_ECART_MILLESIME_MAX_ANNEES)
& (reference.is_null() | aammjj_suit_publication)
)

relue = (
pl.when(
douteuse
& date_non_future_expr(jjmmaa)
& (pub_dit_jjmmaa | (~pub_a_tranche & id_dit_jjmmaa))
)
.then(jjmmaa)
.when(
douteuse
& date_non_future_expr(aammjj)
& (pub_dit_aammjj | (~pub_a_tranche & id_dit_aammjj))
)
.then(aammjj)
.otherwise(None)
)

return lf.with_columns(
dateNotification=pl.when(relue.is_not_null())
.then(relue.dt.strftime("%Y-%m-%d"))
.otherwise(pl.col("dateNotification"))
)


def fix_data_types(lf: pl.LazyFrame) -> pl.LazyFrame:
"""
To enable easier data ingestion, everything is initially cast as strings... until here. This
Expand Down
58 changes: 58 additions & 0 deletions src/tasks/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
ALL_CONFIG,
BASE_DF_COLUMNS,
CACHE_EXPIRATION_TIME_HOURS,
DATE_ECART_PUBLICATION_MAX_JOURS,
DATE_NOW,
DATE_SIECLE_ANNEE_COURTE,
DIST_DIR,
RESOURCE_CACHE_DIR,
SIRENE_DATA_DIR,
Expand Down Expand Up @@ -146,6 +148,62 @@ def log_column_stats(lf: pl.LazyFrame, nb_lignes: int) -> None:
)


def _recompose_date_expr(annee: pl.Expr, mois: pl.Expr, jour: pl.Expr) -> pl.Expr:
"""Assemble trois composants en pl.Date, null si la combinaison n'existe pas."""
return pl.format(
"{}-{}-{}",
annee.cast(pl.String).str.zfill(4),
mois.cast(pl.String).str.zfill(2),
jour.cast(pl.String).str.zfill(2),
).str.strptime(pl.Date, format="%Y-%m-%d", strict=False)


def relire_date_jjmmaa_expr(date_expr: pl.Expr) -> pl.Expr:
"""Relit une date typée comme si sa source était au format JJ-MM-AA (#191).

`31-05-22` lu comme AAAA-MM-JJ donne l'an 31 : le composant lu comme jour
porte l'année.
"""
return _recompose_date_expr(
DATE_SIECLE_ANNEE_COURTE + date_expr.dt.day(),
date_expr.dt.month(),
date_expr.dt.year(),
)


def relire_date_aammjj_expr(date_expr: pl.Expr) -> pl.Expr:
"""Relit une date typée comme si sa source était au format AA-MM-JJ (#191).

Ordre ISO, mais année sur deux chiffres : `22-05-13` donne l'an 22.
"""
return _recompose_date_expr(
DATE_SIECLE_ANNEE_COURTE + date_expr.dt.year(),
date_expr.dt.month(),
date_expr.dt.day(),
)


def date_non_future_expr(date_expr: pl.Expr) -> pl.Expr:
"""Vrai quand la date existe et n'est pas postérieure à aujourd'hui."""
return date_expr.is_not_null() & (date_expr <= pl.lit(datetime.now().date()))


def millesime_identifiant_expr(id_expr: pl.Expr) -> pl.Expr:
"""Extrait le premier millésime 20xx présent dans un identifiant de marché."""
return id_expr.str.extract(r"(20[0-2][0-9])", 1).cast(pl.Int32)


def date_coherente_avec_expr(candidate: pl.Expr, reference: pl.Expr) -> pl.Expr:
"""Vrai quand candidate précède reference d'au plus DATE_ECART_PUBLICATION_MAX_JOURS."""
ecart = (reference - candidate).dt.total_days()
return (
candidate.is_not_null()
& reference.is_not_null()
& (ecart >= 0)
& (ecart <= DATE_ECART_PUBLICATION_MAX_JOURS)
)


# Statistiques pour toutes les données collectées ce jour
def generate_stats(lf: pl.LazyFrame, output_dir=DIST_DIR):
now = datetime.now()
Expand Down
65 changes: 65 additions & 0 deletions tests/test_clean.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
clean_titulaires,
extract_innermost_struct,
fix_data_types,
relire_dates_mal_converties,
)


Expand Down Expand Up @@ -297,3 +298,67 @@ def test_clean_decp():

# Check codeCPV
assert df_result["codeCPV"].to_list() == ["12345678", "87654100"]


class TestRelireDatesMalConverties:
"""Deux formats source produisent le même symptôme, cf. issue #191."""

@staticmethod
def _lf(notification, publication, identifiants):
return pl.LazyFrame(
{
"id": identifiants,
"dateNotification": notification,
"datePublicationDonnees": publication,
},
schema={
"id": pl.String,
"dateNotification": pl.String,
"datePublicationDonnees": pl.String,
},
)

def _relire(self, notification, publication, identifiant=None):
lf = self._lf([notification], [publication], [identifiant])
return relire_dates_mal_converties(lf).collect()["dateNotification"][0]

def test_jjmmaa_tranche_par_la_publication(self):
# 0031-05-22 : JJ-MM-AA donne 2022-05-31, AA-MM-JJ donnerait 2031-05-22,
# postérieur à la publication.
assert self._relire("0031-05-22", "2022-06-15") == "2022-05-31"

def test_aammjj_tranche_par_la_publication(self):
# 0022-05-13 : AA-MM-JJ donne 2022-05-13, JJ-MM-AA donnerait 2013-05-22,
# neuf ans avant la publication.
assert self._relire("0022-05-13", "2022-06-15") == "2022-05-13"

def test_millesime_tranche_sans_publication(self):
assert self._relire("0031-05-22", None, "2022V2207201") == "2022-05-31"

def test_deux_lectures_plausibles_ne_sont_pas_tranchees(self):
# JJ-MM-AA donne 2025-06-24, AA-MM-JJ donne 2024-06-25 : les deux précèdent
# la publication et aucun millésime ne départage.
assert self._relire("0024-06-25", "2026-01-10") == "0024-06-25"

def test_millesime_trop_eloigne_ne_tranche_pas(self):
assert self._relire("0031-05-22", None, "2010ABC01") == "0031-05-22"

def test_millesime_equidistant_ne_tranche_pas(self):
# JJ-MM-AA donne 2024, AA-MM-JJ donne 2026, tous deux à un an de 2025.
assert self._relire("0026-06-24", None, "2025ABC01") == "0026-06-24"

def test_le_millesime_ne_retient_pas_ce_que_la_publication_dement(self):
# Le millésime 2024 désignerait 2024-06-26, postérieur à la publication.
assert self._relire("0026-06-24", "2023-01-01", "2024ABC01") == "0026-06-24"

def test_relecture_dans_le_futur_refusee(self):
futur = datetime.date.today().year + 1
annee_courte = str(futur - 2000).zfill(2)
assert self._relire(f"0031-05-{annee_courte}", None, f"{futur}ABC01") == (
f"0031-05-{annee_courte}"
)

def test_dates_valides_et_nulles_intactes(self):
assert self._relire("2022-05-31", "2022-06-15") == "2022-05-31"
assert self._relire(None, "2022-06-15") is None
assert self._relire("pas-une-date", "2022-06-15") == "pas-une-date"